From 80a663be31e300f2b075da71fa74b2cac554d9b7 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 06:04:44 +0000 Subject: [PATCH 01/95] =?UTF-8?q?Docs:=20cross-refactoring=20=E5=86=8D?= =?UTF-8?q?=E3=80=85=E6=A4=9C=E8=A8=BC=E3=81=AE=E5=AF=BE=E8=B1=A1=20PR=20?= =?UTF-8?q?=E3=82=92=E4=BD=9C=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 未到達の 2 経路(指摘の修正と再レビュー / 上限到達時の項目単位の見送り)を 実機で通すための種コミット。実行条件は本文に記載した。 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01WxxF27RXyg3QMjayii5dp1 --- .../issue-113-cross-refactoring-re-retrial.md | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 issues/issue-113-cross-refactoring-re-retrial.md diff --git a/issues/issue-113-cross-refactoring-re-retrial.md b/issues/issue-113-cross-refactoring-re-retrial.md new file mode 100644 index 00000000..1979ff87 --- /dev/null +++ b/issues/issue-113-cross-refactoring-re-retrial.md @@ -0,0 +1,47 @@ +# cross-refactoring 再々検証(3 回目) + +`/ndf:cross-refactoring` の**未到達 2 経路**を実機で通すための Pull Request。 +経緯は [引継ぎメモ](issue-113-cross-refactoring-re-retrial-handoff.md) にある。 + +## 通したい経路 + +| 経路 | 何が観測できれば通ったと言えるか | +| --- | --- | +| 指摘の修正と再レビュー | `merge-fix` が修正コミットを取り込み、再レビューで判定が変わる | +| 上限到達時の項目単位の見送り | `should-abandon` が上限到達を返し、`abandon-items` が未解決の項目だけ取り消す | + +あわせて、PR #121 で入れた次の 3 経路も実機で確認する。 + +| 対象 | 確認内容 | +| --- | --- | +| 公開の責務 | 実装担当が push せず、`merge-apply` の後に進行側が push する | +| 生成物の同期 | 同期コミットが push の直前に積まれる | +| 対象外への記録 | 適用で失敗した項目が次ラウンドの提案で再採用されない | + +## 実行条件 + +2 回目はラウンド 1 で両レビュー担当とも指摘 0 件だったため、修正と見送りの経路へ +到達できなかった。3 回目は指摘が出る確率を上げる 2 つの調整を併用する。 + +| 引数 | 値 | 狙い | +| --- | --- | --- | +| `--max-fix-rounds` | `1` | 指摘が 1 回の修正で解決しなければ、即 `should-abandon` へ到達する | +| `--max-items-per-round` | `8` | 採用件数を増やし、指摘が出る確率を上げる | +| `--max-outer-rounds` | `3` | 既定どおり | + +```bash +export PLUGIN_ROOT=/work/ai-plugins/plugins/ndf-claude + +/ndf:cross-refactoring \ + --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 --max-fix-rounds 1 --max-items-per-round 8 +``` + +着手前のテストは 444 件が通ることを確認済み。 From ffc933abd63047416ec7d0104dd4c779fd286cef Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 06:31:11 +0000 Subject: [PATCH 02/95] =?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_ownership に抽出。 Item-Id: R1-001 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../cross-refactoring/scripts/refactor.py | 106 ++++++++++++------ 1 file changed, 69 insertions(+), 37 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 1a224c66..1c7efacf 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1088,6 +1088,68 @@ def cmd_merge_proposals(args: argparse.Namespace) -> None: sys.exit(2) +def _verify_ownership( + entry: dict[str, Any], + reported: dict[str, dict[str, Any]], + ordered_range: list[str], + work: str, + state: dict[str, Any], +) -> Optional[str]: + """適用範囲の各コミットが、ちょうど 1 項目に所有されているか検証する。""" + in_range = set(ordered_range or []) + round_items = set(entry["items"]) + unknown_ids: list[str] = [] + raw_items = ((state.get("_merge_apply_payload") or {}).get("items")) + if not isinstance(raw_items, list): + raw_items = [] + for r in raw_items: + if not isinstance(r, dict): + continue + item_id = r.get("item_id") + if item_id is not None and item_id not in round_items: + unknown_ids.append(str(item_id)) + + 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)) + entry["_ownership_unassigned"] = unassigned + entry["_ownership_unknown_ids"] = unknown_ids + entry["_ownership_duplicated"] = duplicated + if not (unassigned or unknown_ids or duplicated): + return None + + 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])})" + ) + return ( + "、".join(causes) + + "。検証を回避した変更や、状態と実差分の食い違いを Pull Request に" + "残さないため、ラウンドごと取り消します" + ) + + def cmd_merge_apply(args: argparse.Namespace) -> None: """Step 4 — 適用結果を検証して取り込む。 @@ -1163,7 +1225,6 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: # そのまま Pull Request に残せてしまう。 round_items = set(entry["items"]) reported: dict[str, dict[str, Any]] = {} - unknown_ids: list[str] = [] raw_items = payload.get("items") if not isinstance(raw_items, list): info(f"⚠ 適用結果の items が配列ではありません({type(raw_items).__name__})") @@ -1174,8 +1235,6 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: item_id = r.get("item_id") if item_id in round_items: reported[item_id] = r - elif item_id is not None: - unknown_ids.append(str(item_id)) # **範囲のコミットは全て、いずれかの改善項目に割り当てられていること。** # 申告から漏れたコミットはテストもトレーラーも差分予算も検査されず、そのまま @@ -1186,40 +1245,13 @@ 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)) - 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 に" - "残さないため、ラウンドごと取り消します" - ) + state["_merge_apply_payload"] = payload + reason = _verify_ownership(entry, reported, ordered_range, work, state) + state.pop("_merge_apply_payload", None) + unassigned = entry.pop("_ownership_unassigned", []) + unknown_ids = entry.pop("_ownership_unknown_ids", []) + duplicated = entry.pop("_ownership_duplicated", []) + if reason: info(f"❌ {reason}") for item_id in entry["items"]: it = _find_item(state, item_id) From 49c075ef3cfe64515e94aeafed23d60e2363259f Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 06:34:07 +0000 Subject: [PATCH 03/95] =?UTF-8?q?Refactor:=20split=5Finto=5Fpipeline=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 項目ごとの適用検証と分類を _verify_and_classify_items に抽出。 Item-Id: R1-001 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../cross-refactoring/scripts/refactor.py | 106 +++++++++++------- 1 file changed, 66 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 1c7efacf..5ac4ad5e 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1150,6 +1150,64 @@ def _verify_ownership( ) +def _verify_and_classify_items( + entry: dict[str, Any], + state: dict[str, Any], + reported: dict[str, dict[str, Any]], + in_range: set[str], + test_command: str, + head_branch: str, + timeout: int, + scope: list[str], +) -> tuple[list[str], list[str]]: + """各改善項目のコミット facts を検証し、採用候補と失敗へ分類する。""" + applied: list[str] = [] + failed: list[str] = [] + work = state["worktrees"]["work"] + 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, 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 state.get("_merge_apply_dry_run"): + state_path = state.pop("_merge_apply_path") + dry_run = state.pop("_merge_apply_dry_run", None) + statefile.save(state_path, state) + state["_merge_apply_path"] = state_path + state["_merge_apply_dry_run"] = dry_run + return applied, failed + + def cmd_merge_apply(args: argparse.Namespace) -> None: """Step 4 — 適用結果を検証して取り込む。 @@ -1295,51 +1353,19 @@ 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) + state["_merge_apply_path"] = path + state["_merge_apply_dry_run"] = args.dry_run + applied, failed = _verify_and_classify_items( + entry, state, reported, in_range, test_command, head_branch, + _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), scope, + ) + state.pop("_merge_apply_path", None) + state.pop("_merge_apply_dry_run", None) entry["apply"] = { "applied": applied, From 9b7f7c06ff38df695b18791bddec600a176c7f92 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 06:35:27 +0000 Subject: [PATCH 04/95] =?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 pidfile 起動待ちと初期 AgentStatus 作成を read_started_agent に抽出。 Item-Id: R1-002 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../cross-review/scripts/lib/monitor.py | 46 ++++++++++++++----- 1 file changed, 35 insertions(+), 11 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..997b28b1 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,31 @@ def _tail_last_nonempty_line(path: pathlib.Path, limit: int = 4096) -> str: return "" +def read_started_agent( + agent: str, + paths: AgentPaths, + started: float, + log_prefix: str, +) -> Optional[AgentStatus]: + """pidfile を待ち、監視開始に必要な AgentStatus を返す。""" + status = AgentStatus(agent=agent) + grace_end = started + 30 + while time.monotonic() < grace_end: + if paths.pidfile.exists(): + break + time.sleep(2) + pid = _read_pidfile(paths.pidfile) + if pid is None: + status.status = "PIDFILE_BAD" + status.exit_code = 6 + status.detail = f"pidfile not found: {paths.pidfile}" + _emit_log(log_prefix, agent, status) + return None + + status.pid = pid + return status + + def monitor_agent( agent: str, pr: int, @@ -630,21 +655,20 @@ def monitor_agent( hard timeout / stall / sentinel / result.json のみで判定する。 """ paths = AgentPaths.for_(agent, pr, stem_template) - status = AgentStatus(agent=agent) started = time.monotonic() # 起動チェック: 30 秒待っても pidfile が無ければ起動失敗 - grace_end = started + 30 - while time.monotonic() < grace_end: - if paths.pidfile.exists(): - break - time.sleep(2) - pid = _read_pidfile(paths.pidfile) + status = read_started_agent(agent, paths, started, log_prefix) + if status is None: + return AgentStatus( + agent=agent, + status="PIDFILE_BAD", + exit_code=6, + detail=f"pidfile not found: {paths.pidfile}", + ) + + pid = status.pid if pid is None: - status.status = "PIDFILE_BAD" - status.exit_code = 6 - status.detail = f"pidfile not found: {paths.pidfile}" - _emit_log(log_prefix, agent, status) return status status.pid = pid From cfb9d48facdd3effe1478f86068c623c5104bf41 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 06:36:37 +0000 Subject: [PATCH 05/95] =?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 sentinel と result age fallback による lingering process 完了判定を抽出。 Item-Id: R1-002 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../cross-review/scripts/lib/monitor.py | 100 +++++++++++------- 1 file changed, 60 insertions(+), 40 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 997b28b1..f89eda77 100755 --- a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py @@ -638,6 +638,61 @@ def read_started_agent( return status +def try_complete_lingering_process( + agent: str, + paths: AgentPaths, + status: AgentStatus, + pid: int, + alive: bool, + cmdline_validated: bool, + started_wall: float, + log_prefix: str, +) -> Optional[AgentStatus]: + """result.json を出したまま残ったプロセスを完了扱いにできるか判定する。""" + if agent == "codex": + status.sentinel_seen = _scan_codex_sentinel(paths.err_log) + + 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, @@ -693,29 +748,16 @@ def monitor_agent( # 1. プロセス生存確認 → 死んでいたら最終判定へ (result.json 存在をチェック) alive = _pid_alive(pid) - 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 判定で返す。 - 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 + completed = try_complete_lingering_process( + agent, paths, status, pid, alive, cmdline_validated, started_wall, log_prefix, + ) + if completed is not None: + return completed # result.json が書かれた後もプロセスがハング��るケース (gemini で観測: # MCP サーバー切断待ち等��� exit しない)。sentinel 機構を持たない agent 向け @@ -724,28 +766,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 dd7f9a8dfbd0f4a0cfec13fada0598676835d54d Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 06:37:42 +0000 Subject: [PATCH 06/95] =?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 early error の fatal/warn 判定を check_early_error に抽出。 Item-Id: R1-002 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../cross-review/scripts/lib/monitor.py | 74 ++++++++++++------- 1 file changed, 46 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 f89eda77..93c8e117 100755 --- a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py @@ -693,6 +693,47 @@ def try_complete_lingering_process( return None +def check_early_error( + agent: str, + paths: AgentPaths, + status: AgentStatus, + pid: int, + alive: bool, + warned_early_error: bool, + log_prefix: str, +) -> tuple[Optional[AgentStatus], bool]: + """early error の fatal/warn を判定し、fatal 時は終了 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, 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, @@ -795,34 +836,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, paths, status, pid, alive, warned_early_error, log_prefix, + ) + if early_status is not None: + return early_status if not alive: # プロセス終了 — result.json を確認 From f2109197d795dc20550f1c2726969525239ed301 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 06:38:34 +0000 Subject: [PATCH 07/95] =?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 判定を finish_exited_process に抽出。 Item-Id: R1-002 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../cross-review/scripts/lib/monitor.py | 39 ++++++++++++------- 1 file changed, 25 insertions(+), 14 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 93c8e117..a246968d 100755 --- a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py @@ -734,6 +734,30 @@ def check_early_error( return None, warned_early_error +def finish_exited_process( + agent: str, + paths: AgentPaths, + status: AgentStatus, + require_result: bool, + log_prefix: str, +) -> AgentStatus: + """終了済みプロセスの result.json 有無から最終 status を作る。""" + status.result_exists = paths.result.exists() and paths.result.stat().st_size > 0 + if status.result_exists or not require_result: + status.status = "OK" + status.exit_code = 0 + status.detail = ( + f"process exited; sentinel={status.sentinel_seen}; " + f"result_exists={status.result_exists}" + ) + else: + status.status = "NO_RESULT" + status.exit_code = 3 + status.detail = f"process exited but result.json missing: {paths.result}" + _emit_log(log_prefix, agent, status) + return status + + def monitor_agent( agent: str, pr: int, @@ -844,20 +868,7 @@ def monitor_agent( if not alive: # プロセス終了 — result.json を確認 - status.result_exists = paths.result.exists() and paths.result.stat().st_size > 0 - if status.result_exists or not require_result: - status.status = "OK" - status.exit_code = 0 - status.detail = ( - f"process exited; sentinel={status.sentinel_seen}; " - f"result_exists={status.result_exists}" - ) - else: - status.status = "NO_RESULT" - status.exit_code = 3 - status.detail = f"process exited but result.json missing: {paths.result}" - _emit_log(log_prefix, agent, status) - return status + return finish_exited_process(agent, paths, status, require_result, log_prefix) # 4. stall detection (err.log / stdout.log / progress.log をモニタ。 # gemini は stdout 側だけ進捗が出るケースがあり、progress.log には From 1ba3f771b6fbfd9c9593f2611eaab3ade2bfd5c4 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 06:39:29 +0000 Subject: [PATCH 08/95] =?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 ログ進捗更新と STALLED 判定を update_progress_and_check_stall に抽出。 Item-Id: R1-002 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../cross-review/scripts/lib/monitor.py | 63 ++++++++++++------- 1 file changed, 42 insertions(+), 21 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 a246968d..cd6b3ecb 100755 --- a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py @@ -758,6 +758,43 @@ def finish_exited_process( return status +def update_progress_and_check_stall( + agent: str, + paths: AgentPaths, + status: AgentStatus, + pid: int, + alive: bool, + stall_timeout: int, + last_progress_size: int, + last_progress: float, + log_prefix: str, +) -> tuple[Optional[AgentStatus], int, float]: + """ログ進捗を更新し、stall_timeout 超過なら終了 status を返す。""" + status.err_log_size = _safe_size(paths.err_log) + status.stdout_log_size = _safe_size(paths.stdout_log) + status.progress_log_size = _safe_size(paths.progress_log) + status.progress_tail = _tail_last_nonempty_line(paths.progress_log) + progress_size = ( + status.err_log_size + status.stdout_log_size + status.progress_log_size + ) + if progress_size != last_progress_size: + last_progress_size = progress_size + last_progress = time.monotonic() + status.idle_seconds = time.monotonic() - last_progress + if status.idle_seconds >= stall_timeout: + if alive: + _kill_pid(pid) + status.status = "STALLED" + status.exit_code = 5 + status.detail = ( + f"no log progress for {stall_timeout}s " + f"(pid {pid}, last size {last_progress_size}B)" + ) + _emit_log(log_prefix, agent, status) + return status, last_progress_size, last_progress + return None, last_progress_size, last_progress + + def monitor_agent( agent: str, pr: int, @@ -874,28 +911,12 @@ def monitor_agent( # gemini は stdout 側だけ進捗が出るケースがあり、progress.log には # launcher が要求した短いフェーズマーカーが出るため、いずれかが # 更新されれば progress として扱う) - status.err_log_size = _safe_size(paths.err_log) - status.stdout_log_size = _safe_size(paths.stdout_log) - status.progress_log_size = _safe_size(paths.progress_log) - status.progress_tail = _tail_last_nonempty_line(paths.progress_log) - progress_size = ( - status.err_log_size + status.stdout_log_size + status.progress_log_size + stalled, last_progress_size, last_progress = update_progress_and_check_stall( + agent, paths, status, pid, alive, stall_timeout, + last_progress_size, last_progress, log_prefix, ) - if progress_size != last_progress_size: - last_progress_size = progress_size - last_progress = time.monotonic() - status.idle_seconds = time.monotonic() - last_progress - if status.idle_seconds >= stall_timeout: - if alive: - _kill_pid(pid) - status.status = "STALLED" - status.exit_code = 5 - status.detail = ( - f"no log progress for {stall_timeout}s " - f"(pid {pid}, last size {last_progress_size}B)" - ) - _emit_log(log_prefix, agent, status) - return status + if stalled is not None: + return stalled # poll 中の進捗ログ _emit_progress(log_prefix, agent, status) From b884ae86804a09daafa380628f8ca395af6cd8f1 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 06:41:06 +0000 Subject: [PATCH 09/95] =?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=5Ffix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 未検証範囲の pending_push 保存、revert、base sha 更新を _revert_unverified_range に共通化。 Item-Id: R1-003 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../cross-refactoring/scripts/refactor.py | 66 +++++++++++-------- 1 file changed, 40 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 5ac4ad5e..f1927d96 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1208,6 +1208,36 @@ def _verify_and_classify_items( return applied, failed +def _revert_unverified_range( + state: dict[str, Any], + entry: dict[str, Any], + ordered_range: list[str], + reason: str, + path: pathlib.Path, + *, + dry_run: bool = False, + base_key: str = "fix_base_sha", + item_id: Optional[str] = None, +) -> Optional[str]: + """検証できない範囲を取り消し、再実行用の起点 SHA を更新する。""" + if reason: + info(reason) + whole_range = { + "item_id": item_id or 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_range, dry_run) + new_base = None + if not dry_run: + new_base = _git_out(state["worktrees"]["work"], ["rev-parse", "HEAD"]) + entry[base_key] = new_base + statefile.save(path, state) + return new_base + + def cmd_merge_apply(args: argparse.Namespace) -> None: """Step 4 — 適用結果を検証して取り込む。 @@ -1318,20 +1348,11 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: # 範囲全体を取り消す。どのコミットが安全かを決められない以上、 # 起点まで戻すのが最も確実である。順序は `_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"]) + _revert_unverified_range( + state, entry, ordered_range, "", + path, dry_run=args.dry_run, base_key="apply_base_sha", + item_id=f"R{entry['round']}-range", + ) entry["apply"] = { "applied": [], "failed": list(entry["items"]), "base_sha": entry.get("apply_base_sha"), "head_sha": head_sha, @@ -1846,22 +1867,15 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: if unassigned or problems: # **状態へ記録する前に取り消す。** 先に記録すると、取り消し済みのコミットが # 状態ファイルに残り、後の見送り処理が同じコミットをもう一度取り消そうとする。 - info("検証を通らない変更を残さないため、この修正ラウンドの範囲を取り消します") - # **取り消しへ着手する前に印を立てる。** 取り消しは済んだのに push できずに - # 終わると、未検証の変更が Pull Request に残ったままになる。 - entry["pending_push"] = True - statefile.save(path, state) - _revert_item_commits( - state, - {"item_id": f"R{entry['round']}-fix{entry['fix_rounds'] + 1}", - "commits": list(ordered_range)}, - dry_run=False, + _revert_unverified_range( + state, entry, ordered_range, + "検証を通らない変更を残さないため、この修正ラウンドの範囲を取り消します", + path, + item_id=f"R{entry['round']}-fix{entry['fix_rounds'] + 1}", ) # 取り消し後の状態を新しい起点にし、**その場で保存する**。ここで保存せずに # 落ちると、次の実行は古い起点から範囲を取り直して取り消しコミット自体を # 「未申告」と判定し、**取り消しを取り消して**しまう。 - entry["fix_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) - statefile.save(path, state) # **push は保存のあと。** ここで push して失敗すると、取り消しコミットは # ローカルに残るのに起点の更新が保存されず、叩き直しで二重に取り消してしまう。 info("⚠ 修正を取り消したため、解決の申告は採用しません") From 685774bf4136800a740e849ee841822bdcdc4555 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 06:42:30 +0000 Subject: [PATCH 10/95] =?UTF-8?q?Refactor:=20split=5Finto=5Fpipeline=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 修正結果の冪等キー生成と既処理判定を build_fix_merge_key / already_merged_fix に抽出。 Item-Id: R1-004 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../cross-refactoring/scripts/refactor.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index f1927d96..37ff664f 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1750,6 +1750,16 @@ def cmd_abandon_items(args: argparse.Namespace) -> None: statefile.save(path, state) +def build_fix_merge_key(result: pathlib.Path, attempt: int) -> str: + """修正結果の冪等キーを試行番号と結果ファイル内容から作る。""" + return f"{attempt}:" + hashlib.sha256(result.read_bytes()).hexdigest() + + +def already_merged_fix(entry: dict[str, Any], merge_key: str) -> bool: + """同じ修正結果がすでに取り込み済みかを返す。""" + return merge_key in entry.setdefault("fix_merged_keys", []) + + def cmd_merge_fix(args: argparse.Namespace) -> None: """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" path, state = _load(args.id) @@ -1777,12 +1787,9 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: work = state["worktrees"]["work"] head_now = _git_out(work, ["rev-parse", "HEAD"]) or "" attempt = entry.get("fix_attempts", 0) - merge_key = ( - f"{attempt}:" - + hashlib.sha256(result.read_bytes()).hexdigest() - ) + merge_key = build_fix_merge_key(result, attempt) merged_keys = entry.setdefault("fix_merged_keys", []) - if merge_key in merged_keys: + if already_merged_fix(entry, merge_key): info( f"↻ この修正結果は取り込み済みです" f"(修正ラウンド {entry['fix_rounds']})" From b535305aa54e39da6801be2e3c72b22275055991 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 06:43:26 +0000 Subject: [PATCH 11/95] =?UTF-8?q?Refactor:=20split=5Finto=5Fpipeline=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 resolved_thread_ids の型検査と GitHub 実解決との照合を resolve_claimed_threads に抽出。 Item-Id: R1-004 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../cross-refactoring/scripts/refactor.py | 40 +++++++++++-------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 37ff664f..6fe12b58 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1760,6 +1760,28 @@ def already_merged_fix(entry: dict[str, Any], merge_key: str) -> bool: return merge_key in entry.setdefault("fix_merged_keys", []) +def resolve_claimed_threads(payload: dict[str, Any], state: dict[str, Any]) -> set[str]: + """自己申告された解決 thread を 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(state["repo"], state["current_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_fix(args: argparse.Namespace) -> None: """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" path, state = _load(args.id) @@ -1799,25 +1821,9 @@ 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 = resolve_claimed_threads(payload, state) # 修正コミットも適用と同じ基準で、**git と実際のテスト実行から**検証する。 # 結果ファイルの申告で済ませると、手順を満たさない変更が収束済みになれてしまう。 From 3685c22658f484800f5fa6134092204d5be3d9d6 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 06:44:26 +0000 Subject: [PATCH 12/95] =?UTF-8?q?Refactor:=20split=5Finto=5Fpipeline=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 修正範囲の確定、申告外コミット検出、commit facts 収集を collect_fix_range_report に抽出。 Item-Id: R1-004 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../cross-refactoring/scripts/refactor.py | 58 +++++++++++-------- 1 file changed, 35 insertions(+), 23 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 6fe12b58..00915d63 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1782,6 +1782,39 @@ def resolve_claimed_threads(payload: dict[str, Any], state: dict[str, Any]) -> s return resolved +def collect_fix_range_report( + entry: dict[str, Any], + state: dict[str, Any], + payload: dict[str, Any], + work: str, + head_now: str, +) -> tuple[list[str], list[str], list[dict[str, Any]]]: + """修正範囲を確定し、申告外コミットと commit facts を返す。""" + baseline = state.get("baseline_test") or {} + ordered_range = commits_in_range(work, entry.get("fix_base_sha"), head_now) + if ordered_range is None: + die( + "修正の範囲を確定できませんでした" + f"(起点 {entry.get('fix_base_sha')} / HEAD {head_now})。" + "検証できない修正は採りません", + code=2, + ) + reported_shas = _reported_shas(payload) + reported_full = { + full for full in ( + _git_out(work, ["rev-parse", "--verify", f"{s}^{{commit}}"]) + for s in reported_shas + ) if full + } + unassigned = sorted(set(ordered_range) - reported_full) + facts = collect_commit_facts( + work, reported_shas, set(ordered_range), + baseline.get("command") or "true", state["head_branch"], + _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), + ) + return ordered_range, unassigned, facts + + def cmd_merge_fix(args: argparse.Namespace) -> None: """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" path, state = _load(args.id) @@ -1827,33 +1860,12 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: # 修正コミットも適用と同じ基準で、**git と実際のテスト実行から**検証する。 # 結果ファイルの申告で済ませると、手順を満たさない変更が収束済みになれてしまう。 - baseline = state.get("baseline_test") or {} # 修正の範囲も**オーケストレータが記録した起点**から取る。起点は # `judge-review` が変更要求を返したときの HEAD である。 - ordered_range = commits_in_range(work, entry.get("fix_base_sha"), head_now) - if ordered_range is None: - die( - "修正の範囲を確定できませんでした" - f"(起点 {entry.get('fix_base_sha')} / HEAD {head_now})。" - "検証できない修正は採りません", - code=2, - ) - reported_shas = _reported_shas(payload) - # 適用と同じく、**範囲のコミットは全て申告されていること**を求める。 # 申告から漏れた修正コミットは検証を受けないまま Pull Request に残る。 - reported_full = { - full for full in ( - _git_out(work, ["rev-parse", "--verify", f"{s}^{{commit}}"]) - for s in reported_shas - ) if full - } - unassigned = sorted(set(ordered_range) - reported_full) - - facts = collect_commit_facts( - work, reported_shas, set(ordered_range), - baseline.get("command") or "true", state["head_branch"], - _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), + ordered_range, unassigned, facts = collect_fix_range_report( + entry, state, payload, work, head_now, ) # **不正なコミットが 1 件でもあれば、修正ラウンドの範囲ごと取り消す。** From 53706cfd2092b0c9a7af4b6220214afa9dbce3dd Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 06:45:11 +0000 Subject: [PATCH 13/95] =?UTF-8?q?Refactor:=20split=5Finto=5Fpipeline=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 commit facts から problems と accepted を作る処理を evaluate_fix_commits に抽出。 Item-Id: R1-004 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 00915d63..0a2334cd 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1815,6 +1815,24 @@ def collect_fix_range_report( return ordered_range, unassigned, facts +def evaluate_fix_commits( + facts: list[dict[str, Any]], + scope: list[str], +) -> tuple[list[str], list[tuple[str, str]]]: + """修正コミット facts を検証し、問題と採用コミットを返す。""" + 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 problems, accepted + + def cmd_merge_fix(args: argparse.Namespace) -> None: """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" path, state = _load(args.id) @@ -1872,16 +1890,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"])) + problems, accepted = evaluate_fix_commits(facts, state.get("target_scope") or []) if unassigned: info( From bd3f0b70f258d7b875b5c66c6b98015e542ef13d Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 06:46:01 +0000 Subject: [PATCH 14/95] =?UTF-8?q?Refactor:=20split=5Finto=5Fpipeline=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 不正な修正範囲の取り消しと起点保存を reject_fix_range に抽出。 Item-Id: R1-004 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../cross-refactoring/scripts/refactor.py | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 0a2334cd..fdfd6eae 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1833,6 +1833,21 @@ def evaluate_fix_commits( return problems, accepted +def reject_fix_range( + path: pathlib.Path, + state: dict[str, Any], + entry: dict[str, Any], + ordered_range: list[str], +) -> None: + """検証を通らない修正範囲を取り消し、次回用の起点を保存する。""" + _revert_unverified_range( + state, entry, ordered_range, + "検証を通らない変更を残さないため、この修正ラウンドの範囲を取り消します", + path, + item_id=f"R{entry['round']}-fix{entry['fix_rounds'] + 1}", + ) + + def cmd_merge_fix(args: argparse.Namespace) -> None: """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" path, state = _load(args.id) @@ -1901,12 +1916,7 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: if unassigned or problems: # **状態へ記録する前に取り消す。** 先に記録すると、取り消し済みのコミットが # 状態ファイルに残り、後の見送り処理が同じコミットをもう一度取り消そうとする。 - _revert_unverified_range( - state, entry, ordered_range, - "検証を通らない変更を残さないため、この修正ラウンドの範囲を取り消します", - path, - item_id=f"R{entry['round']}-fix{entry['fix_rounds'] + 1}", - ) + reject_fix_range(path, state, entry, ordered_range) # 取り消し後の状態を新しい起点にし、**その場で保存する**。ここで保存せずに # 落ちると、次の実行は古い起点から範囲を取り直して取り消しコミット自体を # 「未申告」と判定し、**取り消しを取り消して**しまう。 From e3000487303020d5c2e602e4532fdf5aa4f5af1f Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 06:47:23 +0000 Subject: [PATCH 15/95] =?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 レビュー結果から state に保存する record 構築を _build_review_record に抽出。 Item-Id: R1-005 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 fdfd6eae..b9be964c 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1543,6 +1543,31 @@ def _resume_incomplete_apply( _flush_pending_push(path, state, entry) +def _build_review_record( + reviews: dict[str, dict[str, Any]], + reviewers: list[str], + round_no: int, +) -> dict[str, Any]: + """レビュー結果から state に保存する findings 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_judge_review(args: argparse.Namespace) -> None: """Step 5 — レビュー 2 者の判定を取り込む。 @@ -1585,22 +1610,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 ad295d4cfc64454e4c1870c39437d6e4b9812a7f Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 06:48:28 +0000 Subject: [PATCH 16/95] =?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 runtime ごとの提案結果ファイル収集を _collect_proposal_files に抽出。 Item-Id: R1-006 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../cross-refactoring/scripts/refactor.py | 57 +++++++++++-------- 1 file changed, 33 insertions(+), 24 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index b9be964c..b048cf04 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -976,30 +976,11 @@ def cmd_start_round(args: argparse.Namespace) -> None: ) -def cmd_merge_proposals(args: argparse.Namespace) -> None: - """Step 3 — 提案をマージして改善項目を作る。 - - 終了コード: 0 = 採用あり / 2 = 採用 0 件(提案ラウンドの繰り返しを終える)。 - - **同じラウンドで叩き直しても二重に項目を作らない。** 進行を止めても再開できる - ことが前提なので、統合済みなら前回と同じ結果をそのまま返す。 - """ - path, state = _load(args.id) - entry = _current_round(state) - - if entry.get("proposal_keys") is not None: - info( - f"↻ 提案ラウンド {entry['round']} は統合済みです" - f"(採用 {entry.get('adopted', 0)} 件 / 見送り {entry.get('deferred', 0)} 件)" - ) - for item_id in entry.get("items", []): - item = _find_item(state, item_id, required=False) - if item is not None: - info(f" {item_id} [{item['severity']}] {item['path']}#{item['symbol']}") - if not entry.get("adopted"): - sys.exit(2) - return - +def _collect_proposal_files( + state: dict[str, Any], + entry: dict[str, Any], +) -> dict[str, list[dict[str, Any]]]: + """各 runtime の提案結果ファイルを読み、runtime ごとの提案リストを返す。""" proposals: dict[str, list[dict[str, Any]]] = {} for runtime in state["runtimes"]: result = _result_path( @@ -1028,6 +1009,34 @@ def cmd_merge_proposals(args: argparse.Namespace) -> None: 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 — 提案をマージして改善項目を作る。 + + 終了コード: 0 = 採用あり / 2 = 採用 0 件(提案ラウンドの繰り返しを終える)。 + + **同じラウンドで叩き直しても二重に項目を作らない。** 進行を止めても再開できる + ことが前提なので、統合済みなら前回と同じ結果をそのまま返す。 + """ + path, state = _load(args.id) + entry = _current_round(state) + + if entry.get("proposal_keys") is not None: + info( + f"↻ 提案ラウンド {entry['round']} は統合済みです" + f"(採用 {entry.get('adopted', 0)} 件 / 見送り {entry.get('deferred', 0)} 件)" + ) + for item_id in entry.get("items", []): + item = _find_item(state, item_id, required=False) + if item is not None: + info(f" {item_id} [{item['severity']}] {item['path']}#{item['symbol']}") + if not entry.get("adopted"): + sys.exit(2) + return + + proposals = _collect_proposal_files(state, entry) excluded = { (d["path"], d["symbol"], d["smell"]) for d in state["deferred_items"] From e5185a394dce3d19f13222f81581c81c8dbd3fd2 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 06:50:32 +0000 Subject: [PATCH 17/95] =?UTF-8?q?Refactor:=20introduce=5Fparameter=5Fobjec?= =?UTF-8?q?t=20=E2=80=94=20plugins/ndf-shared/skills/cross-review/scripts/?= =?UTF-8?q?lib/monitor.py#monitor=5Fagent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 監視設定を MonitorConfig にまとめ、CLI 呼び出しを parameter object 経由に変更。 Item-Id: R1-007 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../cross-review/scripts/lib/monitor.py | 54 +++++++++++++++---- 1 file changed, 45 insertions(+), 9 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 cd6b3ecb..f5f9b9d2 100755 --- a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py @@ -389,6 +389,17 @@ class AgentStatus: sentinel_seen: bool = False +@dataclass +class MonitorConfig: + timeout: int + stall_timeout: int + poll: int + require_result: bool + no_early_error: bool = False + log_prefix: str = "" + stem_template: str = DEFAULT_STEM_TEMPLATE + + # ---------- 監視ロジック ---------- def _read_pidfile(p: pathlib.Path) -> Optional[int]: @@ -798,19 +809,40 @@ def update_progress_and_check_stall( def monitor_agent( agent: str, pr: int, - timeout: int, - stall_timeout: int, - poll: int, - require_result: bool, + timeout: Optional[int] = None, + stall_timeout: Optional[int] = None, + poll: Optional[int] = None, + require_result: Optional[bool] = None, no_early_error: bool = False, log_prefix: str = "", stem_template: str = DEFAULT_STEM_TEMPLATE, + config: Optional[MonitorConfig] = None, ) -> AgentStatus: """1 agent を監視する。 `no_early_error=True` のとき、EARLY_ERROR 検知 (FATAL/WARN とも) を完全に無効化し、 hard timeout / stall / sentinel / result.json のみで判定する。 """ + if config is None: + if timeout is None or stall_timeout is None or poll is None or require_result is None: + raise TypeError("timeout, stall_timeout, poll, require_result are required") + config = MonitorConfig( + timeout=timeout, + stall_timeout=stall_timeout, + poll=poll, + require_result=require_result, + no_early_error=no_early_error, + log_prefix=log_prefix, + stem_template=stem_template, + ) + timeout = config.timeout + stall_timeout = config.stall_timeout + poll = config.poll + require_result = config.require_result + no_early_error = config.no_early_error + log_prefix = config.log_prefix + stem_template = config.stem_template + paths = AgentPaths.for_(agent, pr, stem_template) started = time.monotonic() @@ -1006,11 +1038,15 @@ def run(agent: str) -> None: else _agent_stall_default(agent) results[agent] = monitor_agent( agent=agent, pr=args.pr, - timeout=args.timeout, stall_timeout=stall, - poll=args.poll, require_result=require_result, - no_early_error=args.no_early_error, - log_prefix=f"[{agent}] ", - stem_template=args.stem_template, + config=MonitorConfig( + timeout=args.timeout, + stall_timeout=stall, + poll=args.poll, + require_result=require_result, + no_early_error=args.no_early_error, + log_prefix=f"[{agent}] ", + stem_template=args.stem_template, + ), ) threads = [threading.Thread(target=run, args=(a,), daemon=False) for a in agents] From c80bff42df91640dc0f09d5b324d89772f88eeb5 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 06:51:27 +0000 Subject: [PATCH 18/95] =?UTF-8?q?Refactor:=20consolidate=5Fduplication=20?= =?UTF-8?q?=E2=80=94=20plugins/ndf-shared/skills/cross-review/scripts/lib/?= =?UTF-8?q?monitor.py#=5Fscan=5Fpatterns?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ログ末尾を安全に読む処理を _read_tail に共通化。 Item-Id: R1-008 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../cross-review/scripts/lib/monitor.py | 55 +++++++------------ 1 file changed, 21 insertions(+), 34 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 f5f9b9d2..7945f4c4 100755 --- a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py @@ -503,15 +503,8 @@ def _scan_patterns( 扱いしてしまった (例: `Error in: mcpServers.serena\\n...\\nTraceback ...` で Traceback が誤抑制された)。 """ - 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: + data = _read_tail(path, 200 * 1024) + if data is None: return None data = _strip_ansi(data) benign_patterns = EARLY_ERROR_BENIGN if benign is None else benign @@ -560,16 +553,10 @@ def _scan_claude_stdout_fatal(path: pathlib.Path) -> Optional[str]: 行うが、JSON は 1 行に多数の引用符を含むため、パリティ判定が「引用の内側」を 誤って真にして致命を取りこぼす。 """ - 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 = _strip_ansi(f.read().decode("utf-8", errors="replace")) - except OSError: + data = _read_tail(path, 200 * 1024) + if data is None: return None + data = _strip_ansi(data) for pat in CLAUDE_STDOUT_FATAL: m = pat.search(data) if m: @@ -577,17 +564,24 @@ def _scan_claude_stdout_fatal(path: pathlib.Path) -> Optional[str]: return None -def _scan_codex_sentinel(path: pathlib.Path) -> bool: +def _read_tail(path: pathlib.Path, limit: int) -> Optional[str]: + """ファイル末尾を安全に読み、壊れた UTF-8 は置換して返す。""" if not path.exists(): - return False + return None try: - # 末尾 64KB を読む(sentinel は最後の方に出る) sz = path.stat().st_size with path.open("rb") as f: - if sz > 64 * 1024: - f.seek(sz - 64 * 1024) - tail = f.read().decode("utf-8", errors="replace") + if sz > limit: + f.seek(sz - limit) + return f.read().decode("utf-8", errors="replace") except OSError: + return None + + +def _scan_codex_sentinel(path: pathlib.Path) -> bool: + # 末尾 64KB を読む(sentinel は最後の方に出る) + tail = _read_tail(path, 64 * 1024) + if tail is None: return False return bool(CODEX_SENTINEL.search(tail)) @@ -605,17 +599,10 @@ def _tail_last_nonempty_line(path: pathlib.Path, limit: int = 4096) -> str: Gemini に書かせるのは短いフェーズマーカーだけなので、末尾数 KB で十分。 壊れた UTF-8 や読み取り競合があっても monitor 自体は落とさない。 """ - if not path.exists(): - return "" - try: - sz = path.stat().st_size - with path.open("rb") as f: - if sz > limit: - f.seek(sz - limit) - data = f.read().decode("utf-8", errors="replace") - except OSError: + data = _read_tail(path, limit) + if data is None: return "" - if sz > limit and "\n" in data: + if _safe_size(path) > limit and "\n" in data: data = data.split("\n", 1)[1] for line in reversed(data.splitlines()): stripped = line.strip() From f13fb0845c04011bdf684ba248e9072f991e21d8 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 07:01:04 +0000 Subject: [PATCH 19/95] =?UTF-8?q?Revert=20"Refactor:=20consolidate=5Fdupli?= =?UTF-8?q?cation=20=E2=80=94=20plugins/ndf-shared/skills/cross-review/scr?= =?UTF-8?q?ipts/lib/monitor.py#=5Fscan=5Fpatterns"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit c80bff42df91640dc0f09d5b324d89772f88eeb5. --- .../cross-review/scripts/lib/monitor.py | 55 ++++++++++++------- 1 file changed, 34 insertions(+), 21 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 7945f4c4..f5f9b9d2 100755 --- a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py @@ -503,8 +503,15 @@ def _scan_patterns( 扱いしてしまった (例: `Error in: mcpServers.serena\\n...\\nTraceback ...` で Traceback が誤抑制された)。 """ - data = _read_tail(path, 200 * 1024) - if data is 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 data = _strip_ansi(data) benign_patterns = EARLY_ERROR_BENIGN if benign is None else benign @@ -553,10 +560,16 @@ def _scan_claude_stdout_fatal(path: pathlib.Path) -> Optional[str]: 行うが、JSON は 1 行に多数の引用符を含むため、パリティ判定が「引用の内側」を 誤って真にして致命を取りこぼす。 """ - data = _read_tail(path, 200 * 1024) - if data is 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 = _strip_ansi(f.read().decode("utf-8", errors="replace")) + except OSError: return None - data = _strip_ansi(data) for pat in CLAUDE_STDOUT_FATAL: m = pat.search(data) if m: @@ -564,24 +577,17 @@ def _scan_claude_stdout_fatal(path: pathlib.Path) -> Optional[str]: return None -def _read_tail(path: pathlib.Path, limit: int) -> Optional[str]: - """ファイル末尾を安全に読み、壊れた UTF-8 は置換して返す。""" +def _scan_codex_sentinel(path: pathlib.Path) -> bool: if not path.exists(): - return None + return False try: + # 末尾 64KB を読む(sentinel は最後の方に出る) sz = path.stat().st_size with path.open("rb") as f: - if sz > limit: - f.seek(sz - limit) - return f.read().decode("utf-8", errors="replace") + if sz > 64 * 1024: + f.seek(sz - 64 * 1024) + tail = f.read().decode("utf-8", errors="replace") except OSError: - return None - - -def _scan_codex_sentinel(path: pathlib.Path) -> bool: - # 末尾 64KB を読む(sentinel は最後の方に出る) - tail = _read_tail(path, 64 * 1024) - if tail is None: return False return bool(CODEX_SENTINEL.search(tail)) @@ -599,10 +605,17 @@ def _tail_last_nonempty_line(path: pathlib.Path, limit: int = 4096) -> str: Gemini に書かせるのは短いフェーズマーカーだけなので、末尾数 KB で十分。 壊れた UTF-8 や読み取り競合があっても monitor 自体は落とさない。 """ - data = _read_tail(path, limit) - if data is None: + if not path.exists(): + return "" + try: + sz = path.stat().st_size + with path.open("rb") as f: + if sz > limit: + f.seek(sz - limit) + data = f.read().decode("utf-8", errors="replace") + except OSError: return "" - if _safe_size(path) > limit and "\n" in data: + if sz > limit and "\n" in data: data = data.split("\n", 1)[1] for line in reversed(data.splitlines()): stripped = line.strip() From 93f014f35d9a600486cef071fafbf123b724b763 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 07:01:04 +0000 Subject: [PATCH 20/95] =?UTF-8?q?Revert=20"Refactor:=20introduce=5Fparamet?= =?UTF-8?q?er=5Fobject=20=E2=80=94=20plugins/ndf-shared/skills/cross-revie?= =?UTF-8?q?w/scripts/lib/monitor.py#monitor=5Fagent"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit e5185a394dce3d19f13222f81581c81c8dbd3fd2. --- .../cross-review/scripts/lib/monitor.py | 54 ++++--------------- 1 file changed, 9 insertions(+), 45 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 f5f9b9d2..cd6b3ecb 100755 --- a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py @@ -389,17 +389,6 @@ class AgentStatus: sentinel_seen: bool = False -@dataclass -class MonitorConfig: - timeout: int - stall_timeout: int - poll: int - require_result: bool - no_early_error: bool = False - log_prefix: str = "" - stem_template: str = DEFAULT_STEM_TEMPLATE - - # ---------- 監視ロジック ---------- def _read_pidfile(p: pathlib.Path) -> Optional[int]: @@ -809,40 +798,19 @@ def update_progress_and_check_stall( def monitor_agent( agent: str, pr: int, - timeout: Optional[int] = None, - stall_timeout: Optional[int] = None, - poll: Optional[int] = None, - require_result: Optional[bool] = None, + timeout: int, + stall_timeout: int, + poll: int, + require_result: bool, no_early_error: bool = False, log_prefix: str = "", stem_template: str = DEFAULT_STEM_TEMPLATE, - config: Optional[MonitorConfig] = None, ) -> AgentStatus: """1 agent を監視する。 `no_early_error=True` のとき、EARLY_ERROR 検知 (FATAL/WARN とも) を完全に無効化し、 hard timeout / stall / sentinel / result.json のみで判定する。 """ - if config is None: - if timeout is None or stall_timeout is None or poll is None or require_result is None: - raise TypeError("timeout, stall_timeout, poll, require_result are required") - config = MonitorConfig( - timeout=timeout, - stall_timeout=stall_timeout, - poll=poll, - require_result=require_result, - no_early_error=no_early_error, - log_prefix=log_prefix, - stem_template=stem_template, - ) - timeout = config.timeout - stall_timeout = config.stall_timeout - poll = config.poll - require_result = config.require_result - no_early_error = config.no_early_error - log_prefix = config.log_prefix - stem_template = config.stem_template - paths = AgentPaths.for_(agent, pr, stem_template) started = time.monotonic() @@ -1038,15 +1006,11 @@ def run(agent: str) -> None: else _agent_stall_default(agent) results[agent] = monitor_agent( agent=agent, pr=args.pr, - config=MonitorConfig( - timeout=args.timeout, - stall_timeout=stall, - poll=args.poll, - require_result=require_result, - no_early_error=args.no_early_error, - log_prefix=f"[{agent}] ", - stem_template=args.stem_template, - ), + timeout=args.timeout, stall_timeout=stall, + poll=args.poll, require_result=require_result, + no_early_error=args.no_early_error, + log_prefix=f"[{agent}] ", + stem_template=args.stem_template, ) threads = [threading.Thread(target=run, args=(a,), daemon=False) for a in agents] From f78ae1308dca1980daa6488c95f42f25e14a306c Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 07:01:04 +0000 Subject: [PATCH 21/95] =?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=5Fproposals"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit ad295d4cfc64454e4c1870c39437d6e4b9812a7f. --- .../cross-refactoring/scripts/refactor.py | 57 ++++++++----------- 1 file changed, 24 insertions(+), 33 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index b048cf04..b9be964c 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -976,11 +976,30 @@ def cmd_start_round(args: argparse.Namespace) -> None: ) -def _collect_proposal_files( - state: dict[str, Any], - entry: dict[str, Any], -) -> dict[str, list[dict[str, Any]]]: - """各 runtime の提案結果ファイルを読み、runtime ごとの提案リストを返す。""" +def cmd_merge_proposals(args: argparse.Namespace) -> None: + """Step 3 — 提案をマージして改善項目を作る。 + + 終了コード: 0 = 採用あり / 2 = 採用 0 件(提案ラウンドの繰り返しを終える)。 + + **同じラウンドで叩き直しても二重に項目を作らない。** 進行を止めても再開できる + ことが前提なので、統合済みなら前回と同じ結果をそのまま返す。 + """ + path, state = _load(args.id) + entry = _current_round(state) + + if entry.get("proposal_keys") is not None: + info( + f"↻ 提案ラウンド {entry['round']} は統合済みです" + f"(採用 {entry.get('adopted', 0)} 件 / 見送り {entry.get('deferred', 0)} 件)" + ) + for item_id in entry.get("items", []): + item = _find_item(state, item_id, required=False) + if item is not None: + info(f" {item_id} [{item['severity']}] {item['path']}#{item['symbol']}") + if not entry.get("adopted"): + sys.exit(2) + return + proposals: dict[str, list[dict[str, Any]]] = {} for runtime in state["runtimes"]: result = _result_path( @@ -1009,34 +1028,6 @@ def _collect_proposal_files( 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 — 提案をマージして改善項目を作る。 - - 終了コード: 0 = 採用あり / 2 = 採用 0 件(提案ラウンドの繰り返しを終える)。 - - **同じラウンドで叩き直しても二重に項目を作らない。** 進行を止めても再開できる - ことが前提なので、統合済みなら前回と同じ結果をそのまま返す。 - """ - path, state = _load(args.id) - entry = _current_round(state) - - if entry.get("proposal_keys") is not None: - info( - f"↻ 提案ラウンド {entry['round']} は統合済みです" - f"(採用 {entry.get('adopted', 0)} 件 / 見送り {entry.get('deferred', 0)} 件)" - ) - for item_id in entry.get("items", []): - item = _find_item(state, item_id, required=False) - if item is not None: - info(f" {item_id} [{item['severity']}] {item['path']}#{item['symbol']}") - if not entry.get("adopted"): - sys.exit(2) - return - - proposals = _collect_proposal_files(state, entry) excluded = { (d["path"], d["symbol"], d["smell"]) for d in state["deferred_items"] From 6ec72a88ecd3b1405fb6975c016f37eb549a8e6f Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 07:01:04 +0000 Subject: [PATCH 22/95] =?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 e3000487303020d5c2e602e4532fdf5aa4f5af1f. --- .../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 b9be964c..fdfd6eae 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1543,31 +1543,6 @@ def _resume_incomplete_apply( _flush_pending_push(path, state, entry) -def _build_review_record( - reviews: dict[str, dict[str, Any]], - reviewers: list[str], - round_no: int, -) -> dict[str, Any]: - """レビュー結果から state に保存する findings 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_judge_review(args: argparse.Namespace) -> None: """Step 5 — レビュー 2 者の判定を取り込む。 @@ -1610,7 +1585,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 a31cd0f884706ff877d9708692aeaabe7461498a Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 07:01:04 +0000 Subject: [PATCH 23/95] =?UTF-8?q?Revert=20"Refactor:=20split=5Finto=5Fpipe?= =?UTF-8?q?line=20=E2=80=94=20plugins/ndf-shared/skills/cross-refactoring/?= =?UTF-8?q?scripts/refactor.py#cmd=5Fmerge=5Ffix"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit bd3f0b70f258d7b875b5c66c6b98015e542ef13d. --- .../cross-refactoring/scripts/refactor.py | 22 +++++-------------- 1 file changed, 6 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 fdfd6eae..0a2334cd 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1833,21 +1833,6 @@ def evaluate_fix_commits( return problems, accepted -def reject_fix_range( - path: pathlib.Path, - state: dict[str, Any], - entry: dict[str, Any], - ordered_range: list[str], -) -> None: - """検証を通らない修正範囲を取り消し、次回用の起点を保存する。""" - _revert_unverified_range( - state, entry, ordered_range, - "検証を通らない変更を残さないため、この修正ラウンドの範囲を取り消します", - path, - item_id=f"R{entry['round']}-fix{entry['fix_rounds'] + 1}", - ) - - def cmd_merge_fix(args: argparse.Namespace) -> None: """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" path, state = _load(args.id) @@ -1916,7 +1901,12 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: if unassigned or problems: # **状態へ記録する前に取り消す。** 先に記録すると、取り消し済みのコミットが # 状態ファイルに残り、後の見送り処理が同じコミットをもう一度取り消そうとする。 - reject_fix_range(path, state, entry, ordered_range) + _revert_unverified_range( + state, entry, ordered_range, + "検証を通らない変更を残さないため、この修正ラウンドの範囲を取り消します", + path, + item_id=f"R{entry['round']}-fix{entry['fix_rounds'] + 1}", + ) # 取り消し後の状態を新しい起点にし、**その場で保存する**。ここで保存せずに # 落ちると、次の実行は古い起点から範囲を取り直して取り消しコミット自体を # 「未申告」と判定し、**取り消しを取り消して**しまう。 From d5c805f1be0cde88af4de80a2454b869e80d6f38 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 07:01:04 +0000 Subject: [PATCH 24/95] =?UTF-8?q?Revert=20"Refactor:=20split=5Finto=5Fpipe?= =?UTF-8?q?line=20=E2=80=94=20plugins/ndf-shared/skills/cross-refactoring/?= =?UTF-8?q?scripts/refactor.py#cmd=5Fmerge=5Ffix"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 53706cfd2092b0c9a7af4b6220214afa9dbce3dd. --- .../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 0a2334cd..00915d63 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1815,24 +1815,6 @@ def collect_fix_range_report( return ordered_range, unassigned, facts -def evaluate_fix_commits( - facts: list[dict[str, Any]], - scope: list[str], -) -> tuple[list[str], list[tuple[str, str]]]: - """修正コミット facts を検証し、問題と採用コミットを返す。""" - 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 problems, accepted - - def cmd_merge_fix(args: argparse.Namespace) -> None: """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" path, state = _load(args.id) @@ -1890,7 +1872,16 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: # 状態を記録しないだけでは、未検証の変更が Pull Request に残り続ける # (見送りの対象にもならない)。どのコミットが安全かは決められないので、 # 適用フェーズの未割当コミットと同じ扱いにする。 - problems, accepted = evaluate_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 68012f90d160a6ed71a993292f7e7a65c6bc4c95 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 07:01:04 +0000 Subject: [PATCH 25/95] =?UTF-8?q?Revert=20"Refactor:=20split=5Finto=5Fpipe?= =?UTF-8?q?line=20=E2=80=94=20plugins/ndf-shared/skills/cross-refactoring/?= =?UTF-8?q?scripts/refactor.py#cmd=5Fmerge=5Ffix"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 3685c22658f484800f5fa6134092204d5be3d9d6. --- .../cross-refactoring/scripts/refactor.py | 58 ++++++++----------- 1 file changed, 23 insertions(+), 35 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 00915d63..6fe12b58 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1782,39 +1782,6 @@ def resolve_claimed_threads(payload: dict[str, Any], state: dict[str, Any]) -> s return resolved -def collect_fix_range_report( - entry: dict[str, Any], - state: dict[str, Any], - payload: dict[str, Any], - work: str, - head_now: str, -) -> tuple[list[str], list[str], list[dict[str, Any]]]: - """修正範囲を確定し、申告外コミットと commit facts を返す。""" - baseline = state.get("baseline_test") or {} - ordered_range = commits_in_range(work, entry.get("fix_base_sha"), head_now) - if ordered_range is None: - die( - "修正の範囲を確定できませんでした" - f"(起点 {entry.get('fix_base_sha')} / HEAD {head_now})。" - "検証できない修正は採りません", - code=2, - ) - reported_shas = _reported_shas(payload) - reported_full = { - full for full in ( - _git_out(work, ["rev-parse", "--verify", f"{s}^{{commit}}"]) - for s in reported_shas - ) if full - } - unassigned = sorted(set(ordered_range) - reported_full) - facts = collect_commit_facts( - work, reported_shas, set(ordered_range), - baseline.get("command") or "true", state["head_branch"], - _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), - ) - return ordered_range, unassigned, facts - - def cmd_merge_fix(args: argparse.Namespace) -> None: """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" path, state = _load(args.id) @@ -1860,12 +1827,33 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: # 修正コミットも適用と同じ基準で、**git と実際のテスト実行から**検証する。 # 結果ファイルの申告で済ませると、手順を満たさない変更が収束済みになれてしまう。 + baseline = state.get("baseline_test") or {} # 修正の範囲も**オーケストレータが記録した起点**から取る。起点は # `judge-review` が変更要求を返したときの HEAD である。 + ordered_range = commits_in_range(work, entry.get("fix_base_sha"), head_now) + if ordered_range is None: + die( + "修正の範囲を確定できませんでした" + f"(起点 {entry.get('fix_base_sha')} / HEAD {head_now})。" + "検証できない修正は採りません", + code=2, + ) + reported_shas = _reported_shas(payload) + # 適用と同じく、**範囲のコミットは全て申告されていること**を求める。 # 申告から漏れた修正コミットは検証を受けないまま Pull Request に残る。 - ordered_range, unassigned, facts = collect_fix_range_report( - entry, state, payload, work, head_now, + reported_full = { + full for full in ( + _git_out(work, ["rev-parse", "--verify", f"{s}^{{commit}}"]) + for s in reported_shas + ) if full + } + unassigned = sorted(set(ordered_range) - reported_full) + + facts = collect_commit_facts( + work, reported_shas, set(ordered_range), + baseline.get("command") or "true", state["head_branch"], + _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), ) # **不正なコミットが 1 件でもあれば、修正ラウンドの範囲ごと取り消す。** From 8cb7435d5199a5b472037204ec6c7cf8d8d63c8d Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 07:01:04 +0000 Subject: [PATCH 26/95] =?UTF-8?q?Revert=20"Refactor:=20split=5Finto=5Fpipe?= =?UTF-8?q?line=20=E2=80=94=20plugins/ndf-shared/skills/cross-refactoring/?= =?UTF-8?q?scripts/refactor.py#cmd=5Fmerge=5Ffix"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit b535305aa54e39da6801be2e3c72b22275055991. --- .../cross-refactoring/scripts/refactor.py | 40 ++++++++----------- 1 file changed, 17 insertions(+), 23 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 6fe12b58..37ff664f 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1760,28 +1760,6 @@ def already_merged_fix(entry: dict[str, Any], merge_key: str) -> bool: return merge_key in entry.setdefault("fix_merged_keys", []) -def resolve_claimed_threads(payload: dict[str, Any], state: dict[str, Any]) -> set[str]: - """自己申告された解決 thread を 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(state["repo"], state["current_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_fix(args: argparse.Namespace) -> None: """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" path, state = _load(args.id) @@ -1821,9 +1799,25 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: # 自己申告をそのまま信じない。解決 API に失敗・未実行でも「解決済み」と # 書けてしまい、未解決の指摘が取り消し対象から外れる。GitHub 側の # `isResolved` と突き合わせ、**両方が解決と言っているものだけ**を反映する。 + raw_claimed = payload.get("resolved_thread_ids") # 文字列は 1 文字ずつに分解され、数値や真偽値は反復できずに落ちる。 # **配列であることを先に確かめる。** - resolved = resolve_claimed_threads(payload, state) + 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 da946dc7c13424375d44cd34b6330c5ebe4cf5e0 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 07:01:04 +0000 Subject: [PATCH 27/95] =?UTF-8?q?Revert=20"Refactor:=20split=5Finto=5Fpipe?= =?UTF-8?q?line=20=E2=80=94=20plugins/ndf-shared/skills/cross-refactoring/?= =?UTF-8?q?scripts/refactor.py#cmd=5Fmerge=5Ffix"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 685774bf4136800a740e849ee841822bdcdc4555. --- .../cross-refactoring/scripts/refactor.py | 17 +++++------------ 1 file changed, 5 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 37ff664f..f1927d96 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1750,16 +1750,6 @@ def cmd_abandon_items(args: argparse.Namespace) -> None: statefile.save(path, state) -def build_fix_merge_key(result: pathlib.Path, attempt: int) -> str: - """修正結果の冪等キーを試行番号と結果ファイル内容から作る。""" - return f"{attempt}:" + hashlib.sha256(result.read_bytes()).hexdigest() - - -def already_merged_fix(entry: dict[str, Any], merge_key: str) -> bool: - """同じ修正結果がすでに取り込み済みかを返す。""" - return merge_key in entry.setdefault("fix_merged_keys", []) - - def cmd_merge_fix(args: argparse.Namespace) -> None: """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" path, state = _load(args.id) @@ -1787,9 +1777,12 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: work = state["worktrees"]["work"] head_now = _git_out(work, ["rev-parse", "HEAD"]) or "" attempt = entry.get("fix_attempts", 0) - merge_key = build_fix_merge_key(result, attempt) + merge_key = ( + f"{attempt}:" + + hashlib.sha256(result.read_bytes()).hexdigest() + ) merged_keys = entry.setdefault("fix_merged_keys", []) - if already_merged_fix(entry, merge_key): + if merge_key in merged_keys: info( f"↻ この修正結果は取り込み済みです" f"(修正ラウンド {entry['fix_rounds']})" From 03178ec0a2f4c496cf54ab251bee19f46ef62446 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 07:01:04 +0000 Subject: [PATCH 28/95] =?UTF-8?q?Revert=20"Refactor:=20consolidate=5Fdupli?= =?UTF-8?q?cation=20=E2=80=94=20plugins/ndf-shared/skills/cross-refactorin?= =?UTF-8?q?g/scripts/refactor.py#cmd=5Fmerge=5Ffix"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit b884ae86804a09daafa380628f8ca395af6cd8f1. --- .../cross-refactoring/scripts/refactor.py | 66 ++++++++----------- 1 file changed, 26 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 f1927d96..5ac4ad5e 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1208,36 +1208,6 @@ def _verify_and_classify_items( return applied, failed -def _revert_unverified_range( - state: dict[str, Any], - entry: dict[str, Any], - ordered_range: list[str], - reason: str, - path: pathlib.Path, - *, - dry_run: bool = False, - base_key: str = "fix_base_sha", - item_id: Optional[str] = None, -) -> Optional[str]: - """検証できない範囲を取り消し、再実行用の起点 SHA を更新する。""" - if reason: - info(reason) - whole_range = { - "item_id": item_id or 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_range, dry_run) - new_base = None - if not dry_run: - new_base = _git_out(state["worktrees"]["work"], ["rev-parse", "HEAD"]) - entry[base_key] = new_base - statefile.save(path, state) - return new_base - - def cmd_merge_apply(args: argparse.Namespace) -> None: """Step 4 — 適用結果を検証して取り込む。 @@ -1348,11 +1318,20 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: # 範囲全体を取り消す。どのコミットが安全かを決められない以上、 # 起点まで戻すのが最も確実である。順序は `_revert_item_commits` が # git の履歴から決め直す。 - _revert_unverified_range( - state, entry, ordered_range, "", - path, dry_run=args.dry_run, base_key="apply_base_sha", - item_id=f"R{entry['round']}-range", - ) + 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, @@ -1867,15 +1846,22 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: if unassigned or problems: # **状態へ記録する前に取り消す。** 先に記録すると、取り消し済みのコミットが # 状態ファイルに残り、後の見送り処理が同じコミットをもう一度取り消そうとする。 - _revert_unverified_range( - state, entry, ordered_range, - "検証を通らない変更を残さないため、この修正ラウンドの範囲を取り消します", - path, - item_id=f"R{entry['round']}-fix{entry['fix_rounds'] + 1}", + info("検証を通らない変更を残さないため、この修正ラウンドの範囲を取り消します") + # **取り消しへ着手する前に印を立てる。** 取り消しは済んだのに push できずに + # 終わると、未検証の変更が Pull Request に残ったままになる。 + entry["pending_push"] = True + statefile.save(path, state) + _revert_item_commits( + state, + {"item_id": f"R{entry['round']}-fix{entry['fix_rounds'] + 1}", + "commits": list(ordered_range)}, + dry_run=False, ) # 取り消し後の状態を新しい起点にし、**その場で保存する**。ここで保存せずに # 落ちると、次の実行は古い起点から範囲を取り直して取り消しコミット自体を # 「未申告」と判定し、**取り消しを取り消して**しまう。 + entry["fix_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) + statefile.save(path, state) # **push は保存のあと。** ここで push して失敗すると、取り消しコミットは # ローカルに残るのに起点の更新が保存されず、叩き直しで二重に取り消してしまう。 info("⚠ 修正を取り消したため、解決の申告は採用しません") From e4f729d540282974d95266fc39fffbd5d4645fe8 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 07:01:04 +0000 Subject: [PATCH 29/95] =?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 1ba3f771b6fbfd9c9593f2611eaab3ade2bfd5c4. --- .../cross-review/scripts/lib/monitor.py | 63 +++++++------------ 1 file changed, 21 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 cd6b3ecb..a246968d 100755 --- a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py @@ -758,43 +758,6 @@ def finish_exited_process( return status -def update_progress_and_check_stall( - agent: str, - paths: AgentPaths, - status: AgentStatus, - pid: int, - alive: bool, - stall_timeout: int, - last_progress_size: int, - last_progress: float, - log_prefix: str, -) -> tuple[Optional[AgentStatus], int, float]: - """ログ進捗を更新し、stall_timeout 超過なら終了 status を返す。""" - status.err_log_size = _safe_size(paths.err_log) - status.stdout_log_size = _safe_size(paths.stdout_log) - status.progress_log_size = _safe_size(paths.progress_log) - status.progress_tail = _tail_last_nonempty_line(paths.progress_log) - progress_size = ( - status.err_log_size + status.stdout_log_size + status.progress_log_size - ) - if progress_size != last_progress_size: - last_progress_size = progress_size - last_progress = time.monotonic() - status.idle_seconds = time.monotonic() - last_progress - if status.idle_seconds >= stall_timeout: - if alive: - _kill_pid(pid) - status.status = "STALLED" - status.exit_code = 5 - status.detail = ( - f"no log progress for {stall_timeout}s " - f"(pid {pid}, last size {last_progress_size}B)" - ) - _emit_log(log_prefix, agent, status) - return status, last_progress_size, last_progress - return None, last_progress_size, last_progress - - def monitor_agent( agent: str, pr: int, @@ -911,12 +874,28 @@ def monitor_agent( # gemini は stdout 側だけ進捗が出るケースがあり、progress.log には # launcher が要求した短いフェーズマーカーが出るため、いずれかが # 更新されれば progress として扱う) - stalled, last_progress_size, last_progress = update_progress_and_check_stall( - agent, paths, status, pid, alive, stall_timeout, - last_progress_size, last_progress, log_prefix, + status.err_log_size = _safe_size(paths.err_log) + status.stdout_log_size = _safe_size(paths.stdout_log) + status.progress_log_size = _safe_size(paths.progress_log) + status.progress_tail = _tail_last_nonempty_line(paths.progress_log) + progress_size = ( + status.err_log_size + status.stdout_log_size + status.progress_log_size ) - if stalled is not None: - return stalled + if progress_size != last_progress_size: + last_progress_size = progress_size + last_progress = time.monotonic() + status.idle_seconds = time.monotonic() - last_progress + if status.idle_seconds >= stall_timeout: + if alive: + _kill_pid(pid) + status.status = "STALLED" + status.exit_code = 5 + status.detail = ( + f"no log progress for {stall_timeout}s " + f"(pid {pid}, last size {last_progress_size}B)" + ) + _emit_log(log_prefix, agent, status) + return status # poll 中の進捗ログ _emit_progress(log_prefix, agent, status) From 630516a34021f0b79924e01793516d8428a85df3 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 07:01:04 +0000 Subject: [PATCH 30/95] =?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 f2109197d795dc20550f1c2726969525239ed301. --- .../cross-review/scripts/lib/monitor.py | 39 +++++++------------ 1 file changed, 14 insertions(+), 25 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 a246968d..93c8e117 100755 --- a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py @@ -734,30 +734,6 @@ def check_early_error( return None, warned_early_error -def finish_exited_process( - agent: str, - paths: AgentPaths, - status: AgentStatus, - require_result: bool, - log_prefix: str, -) -> AgentStatus: - """終了済みプロセスの result.json 有無から最終 status を作る。""" - status.result_exists = paths.result.exists() and paths.result.stat().st_size > 0 - if status.result_exists or not require_result: - status.status = "OK" - status.exit_code = 0 - status.detail = ( - f"process exited; sentinel={status.sentinel_seen}; " - f"result_exists={status.result_exists}" - ) - else: - status.status = "NO_RESULT" - status.exit_code = 3 - status.detail = f"process exited but result.json missing: {paths.result}" - _emit_log(log_prefix, agent, status) - return status - - def monitor_agent( agent: str, pr: int, @@ -868,7 +844,20 @@ def monitor_agent( if not alive: # プロセス終了 — result.json を確認 - return finish_exited_process(agent, paths, status, require_result, log_prefix) + status.result_exists = paths.result.exists() and paths.result.stat().st_size > 0 + if status.result_exists or not require_result: + status.status = "OK" + status.exit_code = 0 + status.detail = ( + f"process exited; sentinel={status.sentinel_seen}; " + f"result_exists={status.result_exists}" + ) + else: + status.status = "NO_RESULT" + status.exit_code = 3 + status.detail = f"process exited but result.json missing: {paths.result}" + _emit_log(log_prefix, agent, status) + return status # 4. stall detection (err.log / stdout.log / progress.log をモニタ。 # gemini は stdout 側だけ進捗が出るケースがあり、progress.log には From b815d907d02d7e3532cb93136142ee8ae3a85dbd Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 07:01:04 +0000 Subject: [PATCH 31/95] =?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 dd7f9a8dfbd0f4a0cfec13fada0598676835d54d. --- .../cross-review/scripts/lib/monitor.py | 74 +++++++------------ 1 file changed, 28 insertions(+), 46 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 93c8e117..f89eda77 100755 --- a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py @@ -693,47 +693,6 @@ def try_complete_lingering_process( return None -def check_early_error( - agent: str, - paths: AgentPaths, - status: AgentStatus, - pid: int, - alive: bool, - warned_early_error: bool, - log_prefix: str, -) -> tuple[Optional[AgentStatus], bool]: - """early error の fatal/warn を判定し、fatal 時は終了 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, 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, @@ -836,11 +795,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, paths, status, pid, 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 f6530ad6c0247a843a8663d52b1c1955df9c4f0c Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 07:01:05 +0000 Subject: [PATCH 32/95] =?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 cfb9d48facdd3effe1478f86068c623c5104bf41. --- .../cross-review/scripts/lib/monitor.py | 100 +++++++----------- 1 file changed, 40 insertions(+), 60 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 f89eda77..997b28b1 100755 --- a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py @@ -638,61 +638,6 @@ def read_started_agent( return status -def try_complete_lingering_process( - agent: str, - paths: AgentPaths, - status: AgentStatus, - pid: int, - alive: bool, - cmdline_validated: bool, - started_wall: float, - log_prefix: str, -) -> Optional[AgentStatus]: - """result.json を出したまま残ったプロセスを完了扱いにできるか判定する。""" - if agent == "codex": - status.sentinel_seen = _scan_codex_sentinel(paths.err_log) - - 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, @@ -748,16 +693,29 @@ def monitor_agent( # 1. プロセス生存確認 → 死んでいたら最終判定へ (result.json 存在をチェック) alive = _pid_alive(pid) + 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 判定で返す。 - completed = try_complete_lingering_process( - agent, paths, status, pid, alive, cmdline_validated, started_wall, log_prefix, - ) - if completed is not None: - return completed + 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 向け @@ -766,6 +724,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 11d33174feb2a28bb1d32cfd528ef16c5d325e14 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 07:01:05 +0000 Subject: [PATCH 33/95] =?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 9b7f7c06ff38df695b18791bddec600a176c7f92. --- .../cross-review/scripts/lib/monitor.py | 46 +++++-------------- 1 file changed, 11 insertions(+), 35 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 997b28b1..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,31 +613,6 @@ def _tail_last_nonempty_line(path: pathlib.Path, limit: int = 4096) -> str: return "" -def read_started_agent( - agent: str, - paths: AgentPaths, - started: float, - log_prefix: str, -) -> Optional[AgentStatus]: - """pidfile を待ち、監視開始に必要な AgentStatus を返す。""" - status = AgentStatus(agent=agent) - grace_end = started + 30 - while time.monotonic() < grace_end: - if paths.pidfile.exists(): - break - time.sleep(2) - pid = _read_pidfile(paths.pidfile) - if pid is None: - status.status = "PIDFILE_BAD" - status.exit_code = 6 - status.detail = f"pidfile not found: {paths.pidfile}" - _emit_log(log_prefix, agent, status) - return None - - status.pid = pid - return status - - def monitor_agent( agent: str, pr: int, @@ -655,20 +630,21 @@ def monitor_agent( hard timeout / stall / sentinel / result.json のみで判定する。 """ paths = AgentPaths.for_(agent, pr, stem_template) + status = AgentStatus(agent=agent) started = time.monotonic() # 起動チェック: 30 秒待っても pidfile が無ければ起動失敗 - status = read_started_agent(agent, paths, started, log_prefix) - if status is None: - return AgentStatus( - agent=agent, - status="PIDFILE_BAD", - exit_code=6, - detail=f"pidfile not found: {paths.pidfile}", - ) - - pid = status.pid + grace_end = started + 30 + while time.monotonic() < grace_end: + if paths.pidfile.exists(): + break + time.sleep(2) + pid = _read_pidfile(paths.pidfile) if pid is None: + status.status = "PIDFILE_BAD" + status.exit_code = 6 + status.detail = f"pidfile not found: {paths.pidfile}" + _emit_log(log_prefix, agent, status) return status status.pid = pid From 55f6b8ed8913be5e35f4eefbba2af92ab6d9c561 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 07:01:05 +0000 Subject: [PATCH 34/95] =?UTF-8?q?Revert=20"Refactor:=20split=5Finto=5Fpipe?= =?UTF-8?q?line=20=E2=80=94=20plugins/ndf-shared/skills/cross-refactoring/?= =?UTF-8?q?scripts/refactor.py#cmd=5Fmerge=5Fapply"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 49c075ef3cfe64515e94aeafed23d60e2363259f. --- .../cross-refactoring/scripts/refactor.py | 106 +++++++----------- 1 file changed, 40 insertions(+), 66 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 5ac4ad5e..1c7efacf 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1150,64 +1150,6 @@ def _verify_ownership( ) -def _verify_and_classify_items( - entry: dict[str, Any], - state: dict[str, Any], - reported: dict[str, dict[str, Any]], - in_range: set[str], - test_command: str, - head_branch: str, - timeout: int, - scope: list[str], -) -> tuple[list[str], list[str]]: - """各改善項目のコミット facts を検証し、採用候補と失敗へ分類する。""" - applied: list[str] = [] - failed: list[str] = [] - work = state["worktrees"]["work"] - 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, 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 state.get("_merge_apply_dry_run"): - state_path = state.pop("_merge_apply_path") - dry_run = state.pop("_merge_apply_dry_run", None) - statefile.save(state_path, state) - state["_merge_apply_path"] = state_path - state["_merge_apply_dry_run"] = dry_run - return applied, failed - - def cmd_merge_apply(args: argparse.Namespace) -> None: """Step 4 — 適用結果を検証して取り込む。 @@ -1353,19 +1295,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 [] # **判定はその都度残す。** まとめて最後に保存すると、取り消しの途中で中断した # ときに適用の記録が一切残らず、どのコミットが検証を通ったのかを状態から # 復元できなくなる。再開可能性は収束ループの前提なので、ここが崩れると # 中断からの復帰手段が無くなる。 - state["_merge_apply_path"] = path - state["_merge_apply_dry_run"] = args.dry_run - applied, failed = _verify_and_classify_items( - entry, state, reported, in_range, test_command, head_branch, - _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), scope, - ) - state.pop("_merge_apply_path", None) - state.pop("_merge_apply_dry_run", None) + 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 902c648f9299b91334d3b6dbac3b69f86c8b6496 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 07:01:05 +0000 Subject: [PATCH 35/95] =?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 ffc933abd63047416ec7d0104dd4c779fd286cef. --- .../cross-refactoring/scripts/refactor.py | 106 ++++++------------ 1 file changed, 37 insertions(+), 69 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 1c7efacf..1a224c66 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1088,68 +1088,6 @@ def cmd_merge_proposals(args: argparse.Namespace) -> None: sys.exit(2) -def _verify_ownership( - entry: dict[str, Any], - reported: dict[str, dict[str, Any]], - ordered_range: list[str], - work: str, - state: dict[str, Any], -) -> Optional[str]: - """適用範囲の各コミットが、ちょうど 1 項目に所有されているか検証する。""" - in_range = set(ordered_range or []) - round_items = set(entry["items"]) - unknown_ids: list[str] = [] - raw_items = ((state.get("_merge_apply_payload") or {}).get("items")) - if not isinstance(raw_items, list): - raw_items = [] - for r in raw_items: - if not isinstance(r, dict): - continue - item_id = r.get("item_id") - if item_id is not None and item_id not in round_items: - unknown_ids.append(str(item_id)) - - 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)) - entry["_ownership_unassigned"] = unassigned - entry["_ownership_unknown_ids"] = unknown_ids - entry["_ownership_duplicated"] = duplicated - if not (unassigned or unknown_ids or duplicated): - return None - - 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])})" - ) - return ( - "、".join(causes) - + "。検証を回避した変更や、状態と実差分の食い違いを Pull Request に" - "残さないため、ラウンドごと取り消します" - ) - - def cmd_merge_apply(args: argparse.Namespace) -> None: """Step 4 — 適用結果を検証して取り込む。 @@ -1225,6 +1163,7 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: # そのまま Pull Request に残せてしまう。 round_items = set(entry["items"]) reported: dict[str, dict[str, Any]] = {} + unknown_ids: list[str] = [] raw_items = payload.get("items") if not isinstance(raw_items, list): info(f"⚠ 適用結果の items が配列ではありません({type(raw_items).__name__})") @@ -1235,6 +1174,8 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: item_id = r.get("item_id") if item_id in round_items: reported[item_id] = r + elif item_id is not None: + unknown_ids.append(str(item_id)) # **範囲のコミットは全て、いずれかの改善項目に割り当てられていること。** # 申告から漏れたコミットはテストもトレーラーも差分予算も検査されず、そのまま @@ -1245,13 +1186,40 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: # # 判定は**完全な SHA へ正規化してから**行う。申告の文字列をそのまま鍵にすると、 # 一方が完全 SHA、他方が短縮 SHA で同じコミットを指したときに重複を見逃す。 - state["_merge_apply_payload"] = payload - reason = _verify_ownership(entry, reported, ordered_range, work, state) - state.pop("_merge_apply_payload", None) - unassigned = entry.pop("_ownership_unassigned", []) - unknown_ids = entry.pop("_ownership_unknown_ids", []) - duplicated = entry.pop("_ownership_duplicated", []) - if reason: + 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: + 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) From c56bbb24e316c0e37d15dd8d29ae2c1378590d2f Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 06:31:11 +0000 Subject: [PATCH 36/95] =?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_ownership に抽出。 Item-Id: R1-001 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../cross-refactoring/scripts/refactor.py | 106 ++++++++++++------ 1 file changed, 69 insertions(+), 37 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 1a224c66..1c7efacf 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1088,6 +1088,68 @@ def cmd_merge_proposals(args: argparse.Namespace) -> None: sys.exit(2) +def _verify_ownership( + entry: dict[str, Any], + reported: dict[str, dict[str, Any]], + ordered_range: list[str], + work: str, + state: dict[str, Any], +) -> Optional[str]: + """適用範囲の各コミットが、ちょうど 1 項目に所有されているか検証する。""" + in_range = set(ordered_range or []) + round_items = set(entry["items"]) + unknown_ids: list[str] = [] + raw_items = ((state.get("_merge_apply_payload") or {}).get("items")) + if not isinstance(raw_items, list): + raw_items = [] + for r in raw_items: + if not isinstance(r, dict): + continue + item_id = r.get("item_id") + if item_id is not None and item_id not in round_items: + unknown_ids.append(str(item_id)) + + 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)) + entry["_ownership_unassigned"] = unassigned + entry["_ownership_unknown_ids"] = unknown_ids + entry["_ownership_duplicated"] = duplicated + if not (unassigned or unknown_ids or duplicated): + return None + + 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])})" + ) + return ( + "、".join(causes) + + "。検証を回避した変更や、状態と実差分の食い違いを Pull Request に" + "残さないため、ラウンドごと取り消します" + ) + + def cmd_merge_apply(args: argparse.Namespace) -> None: """Step 4 — 適用結果を検証して取り込む。 @@ -1163,7 +1225,6 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: # そのまま Pull Request に残せてしまう。 round_items = set(entry["items"]) reported: dict[str, dict[str, Any]] = {} - unknown_ids: list[str] = [] raw_items = payload.get("items") if not isinstance(raw_items, list): info(f"⚠ 適用結果の items が配列ではありません({type(raw_items).__name__})") @@ -1174,8 +1235,6 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: item_id = r.get("item_id") if item_id in round_items: reported[item_id] = r - elif item_id is not None: - unknown_ids.append(str(item_id)) # **範囲のコミットは全て、いずれかの改善項目に割り当てられていること。** # 申告から漏れたコミットはテストもトレーラーも差分予算も検査されず、そのまま @@ -1186,40 +1245,13 @@ 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)) - 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 に" - "残さないため、ラウンドごと取り消します" - ) + state["_merge_apply_payload"] = payload + reason = _verify_ownership(entry, reported, ordered_range, work, state) + state.pop("_merge_apply_payload", None) + unassigned = entry.pop("_ownership_unassigned", []) + unknown_ids = entry.pop("_ownership_unknown_ids", []) + duplicated = entry.pop("_ownership_duplicated", []) + if reason: info(f"❌ {reason}") for item_id in entry["items"]: it = _find_item(state, item_id) From d29f9888d1f001f88c10bf3e44af5ff0ea15203b Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 06:34:07 +0000 Subject: [PATCH 37/95] =?UTF-8?q?Refactor:=20split=5Finto=5Fpipeline=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 項目ごとの適用検証と分類を _verify_and_classify_items に抽出。 Item-Id: R1-001 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../cross-refactoring/scripts/refactor.py | 106 +++++++++++------- 1 file changed, 66 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 1c7efacf..5ac4ad5e 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1150,6 +1150,64 @@ def _verify_ownership( ) +def _verify_and_classify_items( + entry: dict[str, Any], + state: dict[str, Any], + reported: dict[str, dict[str, Any]], + in_range: set[str], + test_command: str, + head_branch: str, + timeout: int, + scope: list[str], +) -> tuple[list[str], list[str]]: + """各改善項目のコミット facts を検証し、採用候補と失敗へ分類する。""" + applied: list[str] = [] + failed: list[str] = [] + work = state["worktrees"]["work"] + 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, 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 state.get("_merge_apply_dry_run"): + state_path = state.pop("_merge_apply_path") + dry_run = state.pop("_merge_apply_dry_run", None) + statefile.save(state_path, state) + state["_merge_apply_path"] = state_path + state["_merge_apply_dry_run"] = dry_run + return applied, failed + + def cmd_merge_apply(args: argparse.Namespace) -> None: """Step 4 — 適用結果を検証して取り込む。 @@ -1295,51 +1353,19 @@ 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) + state["_merge_apply_path"] = path + state["_merge_apply_dry_run"] = args.dry_run + applied, failed = _verify_and_classify_items( + entry, state, reported, in_range, test_command, head_branch, + _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), scope, + ) + state.pop("_merge_apply_path", None) + state.pop("_merge_apply_dry_run", None) entry["apply"] = { "applied": applied, From 42060bcdbf36b83fe5e0741dd17c6bd0f15bebd4 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 06:41:06 +0000 Subject: [PATCH 38/95] =?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=5Ffix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 未検証範囲の pending_push 保存、revert、base sha 更新を _revert_unverified_range に共通化。 Item-Id: R1-003 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../cross-refactoring/scripts/refactor.py | 66 +++++++++++-------- 1 file changed, 40 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 5ac4ad5e..f1927d96 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1208,6 +1208,36 @@ def _verify_and_classify_items( return applied, failed +def _revert_unverified_range( + state: dict[str, Any], + entry: dict[str, Any], + ordered_range: list[str], + reason: str, + path: pathlib.Path, + *, + dry_run: bool = False, + base_key: str = "fix_base_sha", + item_id: Optional[str] = None, +) -> Optional[str]: + """検証できない範囲を取り消し、再実行用の起点 SHA を更新する。""" + if reason: + info(reason) + whole_range = { + "item_id": item_id or 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_range, dry_run) + new_base = None + if not dry_run: + new_base = _git_out(state["worktrees"]["work"], ["rev-parse", "HEAD"]) + entry[base_key] = new_base + statefile.save(path, state) + return new_base + + def cmd_merge_apply(args: argparse.Namespace) -> None: """Step 4 — 適用結果を検証して取り込む。 @@ -1318,20 +1348,11 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: # 範囲全体を取り消す。どのコミットが安全かを決められない以上、 # 起点まで戻すのが最も確実である。順序は `_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"]) + _revert_unverified_range( + state, entry, ordered_range, "", + path, dry_run=args.dry_run, base_key="apply_base_sha", + item_id=f"R{entry['round']}-range", + ) entry["apply"] = { "applied": [], "failed": list(entry["items"]), "base_sha": entry.get("apply_base_sha"), "head_sha": head_sha, @@ -1846,22 +1867,15 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: if unassigned or problems: # **状態へ記録する前に取り消す。** 先に記録すると、取り消し済みのコミットが # 状態ファイルに残り、後の見送り処理が同じコミットをもう一度取り消そうとする。 - info("検証を通らない変更を残さないため、この修正ラウンドの範囲を取り消します") - # **取り消しへ着手する前に印を立てる。** 取り消しは済んだのに push できずに - # 終わると、未検証の変更が Pull Request に残ったままになる。 - entry["pending_push"] = True - statefile.save(path, state) - _revert_item_commits( - state, - {"item_id": f"R{entry['round']}-fix{entry['fix_rounds'] + 1}", - "commits": list(ordered_range)}, - dry_run=False, + _revert_unverified_range( + state, entry, ordered_range, + "検証を通らない変更を残さないため、この修正ラウンドの範囲を取り消します", + path, + item_id=f"R{entry['round']}-fix{entry['fix_rounds'] + 1}", ) # 取り消し後の状態を新しい起点にし、**その場で保存する**。ここで保存せずに # 落ちると、次の実行は古い起点から範囲を取り直して取り消しコミット自体を # 「未申告」と判定し、**取り消しを取り消して**しまう。 - entry["fix_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) - statefile.save(path, state) # **push は保存のあと。** ここで push して失敗すると、取り消しコミットは # ローカルに残るのに起点の更新が保存されず、叩き直しで二重に取り消してしまう。 info("⚠ 修正を取り消したため、解決の申告は採用しません") From 546ad38e9ebb485453aa63a73969d1fd2257bab4 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 06:42:30 +0000 Subject: [PATCH 39/95] =?UTF-8?q?Refactor:=20split=5Finto=5Fpipeline=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 修正結果の冪等キー生成と既処理判定を build_fix_merge_key / already_merged_fix に抽出。 Item-Id: R1-004 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../cross-refactoring/scripts/refactor.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index f1927d96..37ff664f 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1750,6 +1750,16 @@ def cmd_abandon_items(args: argparse.Namespace) -> None: statefile.save(path, state) +def build_fix_merge_key(result: pathlib.Path, attempt: int) -> str: + """修正結果の冪等キーを試行番号と結果ファイル内容から作る。""" + return f"{attempt}:" + hashlib.sha256(result.read_bytes()).hexdigest() + + +def already_merged_fix(entry: dict[str, Any], merge_key: str) -> bool: + """同じ修正結果がすでに取り込み済みかを返す。""" + return merge_key in entry.setdefault("fix_merged_keys", []) + + def cmd_merge_fix(args: argparse.Namespace) -> None: """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" path, state = _load(args.id) @@ -1777,12 +1787,9 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: work = state["worktrees"]["work"] head_now = _git_out(work, ["rev-parse", "HEAD"]) or "" attempt = entry.get("fix_attempts", 0) - merge_key = ( - f"{attempt}:" - + hashlib.sha256(result.read_bytes()).hexdigest() - ) + merge_key = build_fix_merge_key(result, attempt) merged_keys = entry.setdefault("fix_merged_keys", []) - if merge_key in merged_keys: + if already_merged_fix(entry, merge_key): info( f"↻ この修正結果は取り込み済みです" f"(修正ラウンド {entry['fix_rounds']})" From c17ecea9da423d075b1c4328de8948e2fdfceeba Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 06:43:26 +0000 Subject: [PATCH 40/95] =?UTF-8?q?Refactor:=20split=5Finto=5Fpipeline=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 resolved_thread_ids の型検査と GitHub 実解決との照合を resolve_claimed_threads に抽出。 Item-Id: R1-004 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../cross-refactoring/scripts/refactor.py | 40 +++++++++++-------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 37ff664f..6fe12b58 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1760,6 +1760,28 @@ def already_merged_fix(entry: dict[str, Any], merge_key: str) -> bool: return merge_key in entry.setdefault("fix_merged_keys", []) +def resolve_claimed_threads(payload: dict[str, Any], state: dict[str, Any]) -> set[str]: + """自己申告された解決 thread を 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(state["repo"], state["current_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_fix(args: argparse.Namespace) -> None: """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" path, state = _load(args.id) @@ -1799,25 +1821,9 @@ 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 = resolve_claimed_threads(payload, state) # 修正コミットも適用と同じ基準で、**git と実際のテスト実行から**検証する。 # 結果ファイルの申告で済ませると、手順を満たさない変更が収束済みになれてしまう。 From 025b3575f2570844c8c139ddfda992d06a307972 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 06:44:26 +0000 Subject: [PATCH 41/95] =?UTF-8?q?Refactor:=20split=5Finto=5Fpipeline=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 修正範囲の確定、申告外コミット検出、commit facts 収集を collect_fix_range_report に抽出。 Item-Id: R1-004 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../cross-refactoring/scripts/refactor.py | 58 +++++++++++-------- 1 file changed, 35 insertions(+), 23 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 6fe12b58..00915d63 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1782,6 +1782,39 @@ def resolve_claimed_threads(payload: dict[str, Any], state: dict[str, Any]) -> s return resolved +def collect_fix_range_report( + entry: dict[str, Any], + state: dict[str, Any], + payload: dict[str, Any], + work: str, + head_now: str, +) -> tuple[list[str], list[str], list[dict[str, Any]]]: + """修正範囲を確定し、申告外コミットと commit facts を返す。""" + baseline = state.get("baseline_test") or {} + ordered_range = commits_in_range(work, entry.get("fix_base_sha"), head_now) + if ordered_range is None: + die( + "修正の範囲を確定できませんでした" + f"(起点 {entry.get('fix_base_sha')} / HEAD {head_now})。" + "検証できない修正は採りません", + code=2, + ) + reported_shas = _reported_shas(payload) + reported_full = { + full for full in ( + _git_out(work, ["rev-parse", "--verify", f"{s}^{{commit}}"]) + for s in reported_shas + ) if full + } + unassigned = sorted(set(ordered_range) - reported_full) + facts = collect_commit_facts( + work, reported_shas, set(ordered_range), + baseline.get("command") or "true", state["head_branch"], + _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), + ) + return ordered_range, unassigned, facts + + def cmd_merge_fix(args: argparse.Namespace) -> None: """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" path, state = _load(args.id) @@ -1827,33 +1860,12 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: # 修正コミットも適用と同じ基準で、**git と実際のテスト実行から**検証する。 # 結果ファイルの申告で済ませると、手順を満たさない変更が収束済みになれてしまう。 - baseline = state.get("baseline_test") or {} # 修正の範囲も**オーケストレータが記録した起点**から取る。起点は # `judge-review` が変更要求を返したときの HEAD である。 - ordered_range = commits_in_range(work, entry.get("fix_base_sha"), head_now) - if ordered_range is None: - die( - "修正の範囲を確定できませんでした" - f"(起点 {entry.get('fix_base_sha')} / HEAD {head_now})。" - "検証できない修正は採りません", - code=2, - ) - reported_shas = _reported_shas(payload) - # 適用と同じく、**範囲のコミットは全て申告されていること**を求める。 # 申告から漏れた修正コミットは検証を受けないまま Pull Request に残る。 - reported_full = { - full for full in ( - _git_out(work, ["rev-parse", "--verify", f"{s}^{{commit}}"]) - for s in reported_shas - ) if full - } - unassigned = sorted(set(ordered_range) - reported_full) - - facts = collect_commit_facts( - work, reported_shas, set(ordered_range), - baseline.get("command") or "true", state["head_branch"], - _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), + ordered_range, unassigned, facts = collect_fix_range_report( + entry, state, payload, work, head_now, ) # **不正なコミットが 1 件でもあれば、修正ラウンドの範囲ごと取り消す。** From 0847bb3b1badc663b1a108a6ff34b9bb9955fb2a Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 06:45:11 +0000 Subject: [PATCH 42/95] =?UTF-8?q?Refactor:=20split=5Finto=5Fpipeline=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 commit facts から problems と accepted を作る処理を evaluate_fix_commits に抽出。 Item-Id: R1-004 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 00915d63..0a2334cd 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1815,6 +1815,24 @@ def collect_fix_range_report( return ordered_range, unassigned, facts +def evaluate_fix_commits( + facts: list[dict[str, Any]], + scope: list[str], +) -> tuple[list[str], list[tuple[str, str]]]: + """修正コミット facts を検証し、問題と採用コミットを返す。""" + 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 problems, accepted + + def cmd_merge_fix(args: argparse.Namespace) -> None: """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" path, state = _load(args.id) @@ -1872,16 +1890,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"])) + problems, accepted = evaluate_fix_commits(facts, state.get("target_scope") or []) if unassigned: info( From 0ba790112189fc6d62ffdd150321bbbe42c213ee Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 06:46:01 +0000 Subject: [PATCH 43/95] =?UTF-8?q?Refactor:=20split=5Finto=5Fpipeline=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 不正な修正範囲の取り消しと起点保存を reject_fix_range に抽出。 Item-Id: R1-004 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../cross-refactoring/scripts/refactor.py | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 0a2334cd..fdfd6eae 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1833,6 +1833,21 @@ def evaluate_fix_commits( return problems, accepted +def reject_fix_range( + path: pathlib.Path, + state: dict[str, Any], + entry: dict[str, Any], + ordered_range: list[str], +) -> None: + """検証を通らない修正範囲を取り消し、次回用の起点を保存する。""" + _revert_unverified_range( + state, entry, ordered_range, + "検証を通らない変更を残さないため、この修正ラウンドの範囲を取り消します", + path, + item_id=f"R{entry['round']}-fix{entry['fix_rounds'] + 1}", + ) + + def cmd_merge_fix(args: argparse.Namespace) -> None: """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" path, state = _load(args.id) @@ -1901,12 +1916,7 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: if unassigned or problems: # **状態へ記録する前に取り消す。** 先に記録すると、取り消し済みのコミットが # 状態ファイルに残り、後の見送り処理が同じコミットをもう一度取り消そうとする。 - _revert_unverified_range( - state, entry, ordered_range, - "検証を通らない変更を残さないため、この修正ラウンドの範囲を取り消します", - path, - item_id=f"R{entry['round']}-fix{entry['fix_rounds'] + 1}", - ) + reject_fix_range(path, state, entry, ordered_range) # 取り消し後の状態を新しい起点にし、**その場で保存する**。ここで保存せずに # 落ちると、次の実行は古い起点から範囲を取り直して取り消しコミット自体を # 「未申告」と判定し、**取り消しを取り消して**しまう。 From 428df3a43e2616847c5a1364d74fb9f41d978752 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 06:47:23 +0000 Subject: [PATCH 44/95] =?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 レビュー結果から state に保存する record 構築を _build_review_record に抽出。 Item-Id: R1-005 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 fdfd6eae..b9be964c 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1543,6 +1543,31 @@ def _resume_incomplete_apply( _flush_pending_push(path, state, entry) +def _build_review_record( + reviews: dict[str, dict[str, Any]], + reviewers: list[str], + round_no: int, +) -> dict[str, Any]: + """レビュー結果から state に保存する findings 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_judge_review(args: argparse.Namespace) -> None: """Step 5 — レビュー 2 者の判定を取り込む。 @@ -1585,22 +1610,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 bd1eb7676aca0fefa58fc9bbfa194b3d17834f38 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 06:48:28 +0000 Subject: [PATCH 45/95] =?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 runtime ごとの提案結果ファイル収集を _collect_proposal_files に抽出。 Item-Id: R1-006 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../cross-refactoring/scripts/refactor.py | 57 +++++++++++-------- 1 file changed, 33 insertions(+), 24 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index b9be964c..b048cf04 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -976,30 +976,11 @@ def cmd_start_round(args: argparse.Namespace) -> None: ) -def cmd_merge_proposals(args: argparse.Namespace) -> None: - """Step 3 — 提案をマージして改善項目を作る。 - - 終了コード: 0 = 採用あり / 2 = 採用 0 件(提案ラウンドの繰り返しを終える)。 - - **同じラウンドで叩き直しても二重に項目を作らない。** 進行を止めても再開できる - ことが前提なので、統合済みなら前回と同じ結果をそのまま返す。 - """ - path, state = _load(args.id) - entry = _current_round(state) - - if entry.get("proposal_keys") is not None: - info( - f"↻ 提案ラウンド {entry['round']} は統合済みです" - f"(採用 {entry.get('adopted', 0)} 件 / 見送り {entry.get('deferred', 0)} 件)" - ) - for item_id in entry.get("items", []): - item = _find_item(state, item_id, required=False) - if item is not None: - info(f" {item_id} [{item['severity']}] {item['path']}#{item['symbol']}") - if not entry.get("adopted"): - sys.exit(2) - return - +def _collect_proposal_files( + state: dict[str, Any], + entry: dict[str, Any], +) -> dict[str, list[dict[str, Any]]]: + """各 runtime の提案結果ファイルを読み、runtime ごとの提案リストを返す。""" proposals: dict[str, list[dict[str, Any]]] = {} for runtime in state["runtimes"]: result = _result_path( @@ -1028,6 +1009,34 @@ def cmd_merge_proposals(args: argparse.Namespace) -> None: 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 — 提案をマージして改善項目を作る。 + + 終了コード: 0 = 採用あり / 2 = 採用 0 件(提案ラウンドの繰り返しを終える)。 + + **同じラウンドで叩き直しても二重に項目を作らない。** 進行を止めても再開できる + ことが前提なので、統合済みなら前回と同じ結果をそのまま返す。 + """ + path, state = _load(args.id) + entry = _current_round(state) + + if entry.get("proposal_keys") is not None: + info( + f"↻ 提案ラウンド {entry['round']} は統合済みです" + f"(採用 {entry.get('adopted', 0)} 件 / 見送り {entry.get('deferred', 0)} 件)" + ) + for item_id in entry.get("items", []): + item = _find_item(state, item_id, required=False) + if item is not None: + info(f" {item_id} [{item['severity']}] {item['path']}#{item['symbol']}") + if not entry.get("adopted"): + sys.exit(2) + return + + proposals = _collect_proposal_files(state, entry) excluded = { (d["path"], d["symbol"], d["smell"]) for d in state["deferred_items"] From 131f9d39cb1af6bdc2f54f5fafbb3794ed6e1d9b Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 06:50:32 +0000 Subject: [PATCH 46/95] =?UTF-8?q?Refactor:=20introduce=5Fparameter=5Fobjec?= =?UTF-8?q?t=20=E2=80=94=20plugins/ndf-shared/skills/cross-review/scripts/?= =?UTF-8?q?lib/monitor.py#monitor=5Fagent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 監視設定を MonitorConfig にまとめ、CLI 呼び出しを parameter object 経由に変更。 Item-Id: R1-007 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../cross-review/scripts/lib/monitor.py | 54 +++++++++++++++---- 1 file changed, 45 insertions(+), 9 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..67d1b796 100755 --- a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py @@ -389,6 +389,17 @@ class AgentStatus: sentinel_seen: bool = False +@dataclass +class MonitorConfig: + timeout: int + stall_timeout: int + poll: int + require_result: bool + no_early_error: bool = False + log_prefix: str = "" + stem_template: str = DEFAULT_STEM_TEMPLATE + + # ---------- 監視ロジック ---------- def _read_pidfile(p: pathlib.Path) -> Optional[int]: @@ -616,19 +627,40 @@ def _tail_last_nonempty_line(path: pathlib.Path, limit: int = 4096) -> str: def monitor_agent( agent: str, pr: int, - timeout: int, - stall_timeout: int, - poll: int, - require_result: bool, + timeout: Optional[int] = None, + stall_timeout: Optional[int] = None, + poll: Optional[int] = None, + require_result: Optional[bool] = None, no_early_error: bool = False, log_prefix: str = "", stem_template: str = DEFAULT_STEM_TEMPLATE, + config: Optional[MonitorConfig] = None, ) -> AgentStatus: """1 agent を監視する。 `no_early_error=True` のとき、EARLY_ERROR 検知 (FATAL/WARN とも) を完全に無効化し、 hard timeout / stall / sentinel / result.json のみで判定する。 """ + if config is None: + if timeout is None or stall_timeout is None or poll is None or require_result is None: + raise TypeError("timeout, stall_timeout, poll, require_result are required") + config = MonitorConfig( + timeout=timeout, + stall_timeout=stall_timeout, + poll=poll, + require_result=require_result, + no_early_error=no_early_error, + log_prefix=log_prefix, + stem_template=stem_template, + ) + timeout = config.timeout + stall_timeout = config.stall_timeout + poll = config.poll + require_result = config.require_result + no_early_error = config.no_early_error + log_prefix = config.log_prefix + stem_template = config.stem_template + paths = AgentPaths.for_(agent, pr, stem_template) status = AgentStatus(agent=agent) started = time.monotonic() @@ -912,11 +944,15 @@ def run(agent: str) -> None: else _agent_stall_default(agent) results[agent] = monitor_agent( agent=agent, pr=args.pr, - timeout=args.timeout, stall_timeout=stall, - poll=args.poll, require_result=require_result, - no_early_error=args.no_early_error, - log_prefix=f"[{agent}] ", - stem_template=args.stem_template, + config=MonitorConfig( + timeout=args.timeout, + stall_timeout=stall, + poll=args.poll, + require_result=require_result, + no_early_error=args.no_early_error, + log_prefix=f"[{agent}] ", + stem_template=args.stem_template, + ), ) threads = [threading.Thread(target=run, args=(a,), daemon=False) for a in agents] From db4866ec6573510b84ff01efc33450d92bf8d381 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 06:51:27 +0000 Subject: [PATCH 47/95] =?UTF-8?q?Refactor:=20consolidate=5Fduplication=20?= =?UTF-8?q?=E2=80=94=20plugins/ndf-shared/skills/cross-review/scripts/lib/?= =?UTF-8?q?monitor.py#=5Fscan=5Fpatterns?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ログ末尾を安全に読む処理を _read_tail に共通化。 Item-Id: R1-008 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../cross-review/scripts/lib/monitor.py | 55 +++++++------------ 1 file changed, 21 insertions(+), 34 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 67d1b796..74583817 100755 --- a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py @@ -503,15 +503,8 @@ def _scan_patterns( 扱いしてしまった (例: `Error in: mcpServers.serena\\n...\\nTraceback ...` で Traceback が誤抑制された)。 """ - 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: + data = _read_tail(path, 200 * 1024) + if data is None: return None data = _strip_ansi(data) benign_patterns = EARLY_ERROR_BENIGN if benign is None else benign @@ -560,16 +553,10 @@ def _scan_claude_stdout_fatal(path: pathlib.Path) -> Optional[str]: 行うが、JSON は 1 行に多数の引用符を含むため、パリティ判定が「引用の内側」を 誤って真にして致命を取りこぼす。 """ - 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 = _strip_ansi(f.read().decode("utf-8", errors="replace")) - except OSError: + data = _read_tail(path, 200 * 1024) + if data is None: return None + data = _strip_ansi(data) for pat in CLAUDE_STDOUT_FATAL: m = pat.search(data) if m: @@ -577,17 +564,24 @@ def _scan_claude_stdout_fatal(path: pathlib.Path) -> Optional[str]: return None -def _scan_codex_sentinel(path: pathlib.Path) -> bool: +def _read_tail(path: pathlib.Path, limit: int) -> Optional[str]: + """ファイル末尾を安全に読み、壊れた UTF-8 は置換して返す。""" if not path.exists(): - return False + return None try: - # 末尾 64KB を読む(sentinel は最後の方に出る) sz = path.stat().st_size with path.open("rb") as f: - if sz > 64 * 1024: - f.seek(sz - 64 * 1024) - tail = f.read().decode("utf-8", errors="replace") + if sz > limit: + f.seek(sz - limit) + return f.read().decode("utf-8", errors="replace") except OSError: + return None + + +def _scan_codex_sentinel(path: pathlib.Path) -> bool: + # 末尾 64KB を読む(sentinel は最後の方に出る) + tail = _read_tail(path, 64 * 1024) + if tail is None: return False return bool(CODEX_SENTINEL.search(tail)) @@ -605,17 +599,10 @@ def _tail_last_nonempty_line(path: pathlib.Path, limit: int = 4096) -> str: Gemini に書かせるのは短いフェーズマーカーだけなので、末尾数 KB で十分。 壊れた UTF-8 や読み取り競合があっても monitor 自体は落とさない。 """ - if not path.exists(): - return "" - try: - sz = path.stat().st_size - with path.open("rb") as f: - if sz > limit: - f.seek(sz - limit) - data = f.read().decode("utf-8", errors="replace") - except OSError: + data = _read_tail(path, limit) + if data is None: return "" - if sz > limit and "\n" in data: + if _safe_size(path) > limit and "\n" in data: data = data.split("\n", 1)[1] for line in reversed(data.splitlines()): stripped = line.strip() From 3c1f29349744c630a6c9f02927911db5ae7a939f Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 12:52:59 +0000 Subject: [PATCH 48/95] =?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 | 533 +++++++++++------- .../cross-review/scripts/lib/monitor.py | 109 ++-- .../cross-refactoring/scripts/refactor.py | 533 +++++++++++------- .../cross-review/scripts/lib/monitor.py | 109 ++-- .../cross-refactoring/scripts/refactor.py | 533 +++++++++++------- .../cross-review/scripts/lib/monitor.py | 109 ++-- 6 files changed, 1200 insertions(+), 726 deletions(-) diff --git a/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py index 1a224c66..b048cf04 100755 --- a/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py @@ -976,30 +976,11 @@ def cmd_start_round(args: argparse.Namespace) -> None: ) -def cmd_merge_proposals(args: argparse.Namespace) -> None: - """Step 3 — 提案をマージして改善項目を作る。 - - 終了コード: 0 = 採用あり / 2 = 採用 0 件(提案ラウンドの繰り返しを終える)。 - - **同じラウンドで叩き直しても二重に項目を作らない。** 進行を止めても再開できる - ことが前提なので、統合済みなら前回と同じ結果をそのまま返す。 - """ - path, state = _load(args.id) - entry = _current_round(state) - - if entry.get("proposal_keys") is not None: - info( - f"↻ 提案ラウンド {entry['round']} は統合済みです" - f"(採用 {entry.get('adopted', 0)} 件 / 見送り {entry.get('deferred', 0)} 件)" - ) - for item_id in entry.get("items", []): - item = _find_item(state, item_id, required=False) - if item is not None: - info(f" {item_id} [{item['severity']}] {item['path']}#{item['symbol']}") - if not entry.get("adopted"): - sys.exit(2) - return - +def _collect_proposal_files( + state: dict[str, Any], + entry: dict[str, Any], +) -> dict[str, list[dict[str, Any]]]: + """各 runtime の提案結果ファイルを読み、runtime ごとの提案リストを返す。""" proposals: dict[str, list[dict[str, Any]]] = {} for runtime in state["runtimes"]: result = _result_path( @@ -1028,6 +1009,34 @@ def cmd_merge_proposals(args: argparse.Namespace) -> None: 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 — 提案をマージして改善項目を作る。 + + 終了コード: 0 = 採用あり / 2 = 採用 0 件(提案ラウンドの繰り返しを終える)。 + + **同じラウンドで叩き直しても二重に項目を作らない。** 進行を止めても再開できる + ことが前提なので、統合済みなら前回と同じ結果をそのまま返す。 + """ + path, state = _load(args.id) + entry = _current_round(state) + + if entry.get("proposal_keys") is not None: + info( + f"↻ 提案ラウンド {entry['round']} は統合済みです" + f"(採用 {entry.get('adopted', 0)} 件 / 見送り {entry.get('deferred', 0)} 件)" + ) + for item_id in entry.get("items", []): + item = _find_item(state, item_id, required=False) + if item is not None: + info(f" {item_id} [{item['severity']}] {item['path']}#{item['symbol']}") + if not entry.get("adopted"): + sys.exit(2) + return + + proposals = _collect_proposal_files(state, entry) excluded = { (d["path"], d["symbol"], d["smell"]) for d in state["deferred_items"] @@ -1088,6 +1097,156 @@ def cmd_merge_proposals(args: argparse.Namespace) -> None: sys.exit(2) +def _verify_ownership( + entry: dict[str, Any], + reported: dict[str, dict[str, Any]], + ordered_range: list[str], + work: str, + state: dict[str, Any], +) -> Optional[str]: + """適用範囲の各コミットが、ちょうど 1 項目に所有されているか検証する。""" + in_range = set(ordered_range or []) + round_items = set(entry["items"]) + unknown_ids: list[str] = [] + raw_items = ((state.get("_merge_apply_payload") or {}).get("items")) + if not isinstance(raw_items, list): + raw_items = [] + for r in raw_items: + if not isinstance(r, dict): + continue + item_id = r.get("item_id") + if item_id is not None and item_id not in round_items: + unknown_ids.append(str(item_id)) + + 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)) + entry["_ownership_unassigned"] = unassigned + entry["_ownership_unknown_ids"] = unknown_ids + entry["_ownership_duplicated"] = duplicated + if not (unassigned or unknown_ids or duplicated): + return None + + 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])})" + ) + return ( + "、".join(causes) + + "。検証を回避した変更や、状態と実差分の食い違いを Pull Request に" + "残さないため、ラウンドごと取り消します" + ) + + +def _verify_and_classify_items( + entry: dict[str, Any], + state: dict[str, Any], + reported: dict[str, dict[str, Any]], + in_range: set[str], + test_command: str, + head_branch: str, + timeout: int, + scope: list[str], +) -> tuple[list[str], list[str]]: + """各改善項目のコミット facts を検証し、採用候補と失敗へ分類する。""" + applied: list[str] = [] + failed: list[str] = [] + work = state["worktrees"]["work"] + 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, 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 state.get("_merge_apply_dry_run"): + state_path = state.pop("_merge_apply_path") + dry_run = state.pop("_merge_apply_dry_run", None) + statefile.save(state_path, state) + state["_merge_apply_path"] = state_path + state["_merge_apply_dry_run"] = dry_run + return applied, failed + + +def _revert_unverified_range( + state: dict[str, Any], + entry: dict[str, Any], + ordered_range: list[str], + reason: str, + path: pathlib.Path, + *, + dry_run: bool = False, + base_key: str = "fix_base_sha", + item_id: Optional[str] = None, +) -> Optional[str]: + """検証できない範囲を取り消し、再実行用の起点 SHA を更新する。""" + if reason: + info(reason) + whole_range = { + "item_id": item_id or 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_range, dry_run) + new_base = None + if not dry_run: + new_base = _git_out(state["worktrees"]["work"], ["rev-parse", "HEAD"]) + entry[base_key] = new_base + statefile.save(path, state) + return new_base + + def cmd_merge_apply(args: argparse.Namespace) -> None: """Step 4 — 適用結果を検証して取り込む。 @@ -1163,7 +1322,6 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: # そのまま Pull Request に残せてしまう。 round_items = set(entry["items"]) reported: dict[str, dict[str, Any]] = {} - unknown_ids: list[str] = [] raw_items = payload.get("items") if not isinstance(raw_items, list): info(f"⚠ 適用結果の items が配列ではありません({type(raw_items).__name__})") @@ -1174,8 +1332,6 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: item_id = r.get("item_id") if item_id in round_items: reported[item_id] = r - elif item_id is not None: - unknown_ids.append(str(item_id)) # **範囲のコミットは全て、いずれかの改善項目に割り当てられていること。** # 申告から漏れたコミットはテストもトレーラーも差分予算も検査されず、そのまま @@ -1186,40 +1342,13 @@ 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)) - 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 に" - "残さないため、ラウンドごと取り消します" - ) + state["_merge_apply_payload"] = payload + reason = _verify_ownership(entry, reported, ordered_range, work, state) + state.pop("_merge_apply_payload", None) + unassigned = entry.pop("_ownership_unassigned", []) + unknown_ids = entry.pop("_ownership_unknown_ids", []) + duplicated = entry.pop("_ownership_duplicated", []) + if reason: info(f"❌ {reason}") for item_id in entry["items"]: it = _find_item(state, item_id) @@ -1228,20 +1357,11 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: # 範囲全体を取り消す。どのコミットが安全かを決められない以上、 # 起点まで戻すのが最も確実である。順序は `_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"]) + _revert_unverified_range( + state, entry, ordered_range, "", + path, dry_run=args.dry_run, base_key="apply_base_sha", + item_id=f"R{entry['round']}-range", + ) entry["apply"] = { "applied": [], "failed": list(entry["items"]), "base_sha": entry.get("apply_base_sha"), "head_sha": head_sha, @@ -1263,51 +1383,19 @@ 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) + state["_merge_apply_path"] = path + state["_merge_apply_dry_run"] = args.dry_run + applied, failed = _verify_and_classify_items( + entry, state, reported, in_range, test_command, head_branch, + _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), scope, + ) + state.pop("_merge_apply_path", None) + state.pop("_merge_apply_dry_run", None) entry["apply"] = { "applied": applied, @@ -1464,6 +1552,31 @@ def _resume_incomplete_apply( _flush_pending_push(path, state, entry) +def _build_review_record( + reviews: dict[str, dict[str, Any]], + reviewers: list[str], + round_no: int, +) -> dict[str, Any]: + """レビュー結果から state に保存する findings 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_judge_review(args: argparse.Namespace) -> None: """Step 5 — レビュー 2 者の判定を取り込む。 @@ -1506,22 +1619,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 者分を両方に数えることになり、担当同士の比較が成り立たない。 @@ -1671,6 +1769,104 @@ def cmd_abandon_items(args: argparse.Namespace) -> None: statefile.save(path, state) +def build_fix_merge_key(result: pathlib.Path, attempt: int) -> str: + """修正結果の冪等キーを試行番号と結果ファイル内容から作る。""" + return f"{attempt}:" + hashlib.sha256(result.read_bytes()).hexdigest() + + +def already_merged_fix(entry: dict[str, Any], merge_key: str) -> bool: + """同じ修正結果がすでに取り込み済みかを返す。""" + return merge_key in entry.setdefault("fix_merged_keys", []) + + +def resolve_claimed_threads(payload: dict[str, Any], state: dict[str, Any]) -> set[str]: + """自己申告された解決 thread を 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(state["repo"], state["current_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 collect_fix_range_report( + entry: dict[str, Any], + state: dict[str, Any], + payload: dict[str, Any], + work: str, + head_now: str, +) -> tuple[list[str], list[str], list[dict[str, Any]]]: + """修正範囲を確定し、申告外コミットと commit facts を返す。""" + baseline = state.get("baseline_test") or {} + ordered_range = commits_in_range(work, entry.get("fix_base_sha"), head_now) + if ordered_range is None: + die( + "修正の範囲を確定できませんでした" + f"(起点 {entry.get('fix_base_sha')} / HEAD {head_now})。" + "検証できない修正は採りません", + code=2, + ) + reported_shas = _reported_shas(payload) + reported_full = { + full for full in ( + _git_out(work, ["rev-parse", "--verify", f"{s}^{{commit}}"]) + for s in reported_shas + ) if full + } + unassigned = sorted(set(ordered_range) - reported_full) + facts = collect_commit_facts( + work, reported_shas, set(ordered_range), + baseline.get("command") or "true", state["head_branch"], + _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), + ) + return ordered_range, unassigned, facts + + +def evaluate_fix_commits( + facts: list[dict[str, Any]], + scope: list[str], +) -> tuple[list[str], list[tuple[str, str]]]: + """修正コミット facts を検証し、問題と採用コミットを返す。""" + 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 problems, accepted + + +def reject_fix_range( + path: pathlib.Path, + state: dict[str, Any], + entry: dict[str, Any], + ordered_range: list[str], +) -> None: + """検証を通らない修正範囲を取り消し、次回用の起点を保存する。""" + _revert_unverified_range( + state, entry, ordered_range, + "検証を通らない変更を残さないため、この修正ラウンドの範囲を取り消します", + path, + item_id=f"R{entry['round']}-fix{entry['fix_rounds'] + 1}", + ) + + def cmd_merge_fix(args: argparse.Namespace) -> None: """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" path, state = _load(args.id) @@ -1698,12 +1894,9 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: work = state["worktrees"]["work"] head_now = _git_out(work, ["rev-parse", "HEAD"]) or "" attempt = entry.get("fix_attempts", 0) - merge_key = ( - f"{attempt}:" - + hashlib.sha256(result.read_bytes()).hexdigest() - ) + merge_key = build_fix_merge_key(result, attempt) merged_keys = entry.setdefault("fix_merged_keys", []) - if merge_key in merged_keys: + if already_merged_fix(entry, merge_key): info( f"↻ この修正結果は取り込み済みです" f"(修正ラウンド {entry['fix_rounds']})" @@ -1713,71 +1906,25 @@ 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 = resolve_claimed_threads(payload, state) # 修正コミットも適用と同じ基準で、**git と実際のテスト実行から**検証する。 # 結果ファイルの申告で済ませると、手順を満たさない変更が収束済みになれてしまう。 - baseline = state.get("baseline_test") or {} # 修正の範囲も**オーケストレータが記録した起点**から取る。起点は # `judge-review` が変更要求を返したときの HEAD である。 - ordered_range = commits_in_range(work, entry.get("fix_base_sha"), head_now) - if ordered_range is None: - die( - "修正の範囲を確定できませんでした" - f"(起点 {entry.get('fix_base_sha')} / HEAD {head_now})。" - "検証できない修正は採りません", - code=2, - ) - reported_shas = _reported_shas(payload) - # 適用と同じく、**範囲のコミットは全て申告されていること**を求める。 # 申告から漏れた修正コミットは検証を受けないまま Pull Request に残る。 - reported_full = { - full for full in ( - _git_out(work, ["rev-parse", "--verify", f"{s}^{{commit}}"]) - for s in reported_shas - ) if full - } - unassigned = sorted(set(ordered_range) - reported_full) - - facts = collect_commit_facts( - work, reported_shas, set(ordered_range), - baseline.get("command") or "true", state["head_branch"], - _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), + ordered_range, unassigned, facts = collect_fix_range_report( + entry, state, payload, work, head_now, ) # **不正なコミットが 1 件でもあれば、修正ラウンドの範囲ごと取り消す。** # 状態を記録しないだけでは、未検証の変更が 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"])) + problems, accepted = evaluate_fix_commits(facts, state.get("target_scope") or []) if unassigned: info( @@ -1788,22 +1935,10 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: if unassigned or problems: # **状態へ記録する前に取り消す。** 先に記録すると、取り消し済みのコミットが # 状態ファイルに残り、後の見送り処理が同じコミットをもう一度取り消そうとする。 - info("検証を通らない変更を残さないため、この修正ラウンドの範囲を取り消します") - # **取り消しへ着手する前に印を立てる。** 取り消しは済んだのに push できずに - # 終わると、未検証の変更が Pull Request に残ったままになる。 - entry["pending_push"] = True - statefile.save(path, state) - _revert_item_commits( - state, - {"item_id": f"R{entry['round']}-fix{entry['fix_rounds'] + 1}", - "commits": list(ordered_range)}, - dry_run=False, - ) + reject_fix_range(path, state, entry, ordered_range) # 取り消し後の状態を新しい起点にし、**その場で保存する**。ここで保存せずに # 落ちると、次の実行は古い起点から範囲を取り直して取り消しコミット自体を # 「未申告」と判定し、**取り消しを取り消して**しまう。 - entry["fix_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) - statefile.save(path, state) # **push は保存のあと。** ここで push して失敗すると、取り消しコミットは # ローカルに残るのに起点の更新が保存されず、叩き直しで二重に取り消してしまう。 info("⚠ 修正を取り消したため、解決の申告は採用しません") 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..74583817 100755 --- a/plugins/ndf-claude/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-claude/skills/cross-review/scripts/lib/monitor.py @@ -389,6 +389,17 @@ class AgentStatus: sentinel_seen: bool = False +@dataclass +class MonitorConfig: + timeout: int + stall_timeout: int + poll: int + require_result: bool + no_early_error: bool = False + log_prefix: str = "" + stem_template: str = DEFAULT_STEM_TEMPLATE + + # ---------- 監視ロジック ---------- def _read_pidfile(p: pathlib.Path) -> Optional[int]: @@ -492,15 +503,8 @@ def _scan_patterns( 扱いしてしまった (例: `Error in: mcpServers.serena\\n...\\nTraceback ...` で Traceback が誤抑制された)。 """ - 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: + data = _read_tail(path, 200 * 1024) + if data is None: return None data = _strip_ansi(data) benign_patterns = EARLY_ERROR_BENIGN if benign is None else benign @@ -549,16 +553,10 @@ def _scan_claude_stdout_fatal(path: pathlib.Path) -> Optional[str]: 行うが、JSON は 1 行に多数の引用符を含むため、パリティ判定が「引用の内側」を 誤って真にして致命を取りこぼす。 """ - 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 = _strip_ansi(f.read().decode("utf-8", errors="replace")) - except OSError: + data = _read_tail(path, 200 * 1024) + if data is None: return None + data = _strip_ansi(data) for pat in CLAUDE_STDOUT_FATAL: m = pat.search(data) if m: @@ -566,17 +564,24 @@ def _scan_claude_stdout_fatal(path: pathlib.Path) -> Optional[str]: return None -def _scan_codex_sentinel(path: pathlib.Path) -> bool: +def _read_tail(path: pathlib.Path, limit: int) -> Optional[str]: + """ファイル末尾を安全に読み、壊れた UTF-8 は置換して返す。""" if not path.exists(): - return False + return None try: - # 末尾 64KB を読む(sentinel は最後の方に出る) sz = path.stat().st_size with path.open("rb") as f: - if sz > 64 * 1024: - f.seek(sz - 64 * 1024) - tail = f.read().decode("utf-8", errors="replace") + if sz > limit: + f.seek(sz - limit) + return f.read().decode("utf-8", errors="replace") except OSError: + return None + + +def _scan_codex_sentinel(path: pathlib.Path) -> bool: + # 末尾 64KB を読む(sentinel は最後の方に出る) + tail = _read_tail(path, 64 * 1024) + if tail is None: return False return bool(CODEX_SENTINEL.search(tail)) @@ -594,17 +599,10 @@ def _tail_last_nonempty_line(path: pathlib.Path, limit: int = 4096) -> str: Gemini に書かせるのは短いフェーズマーカーだけなので、末尾数 KB で十分。 壊れた UTF-8 や読み取り競合があっても monitor 自体は落とさない。 """ - if not path.exists(): + data = _read_tail(path, limit) + if data is None: return "" - try: - sz = path.stat().st_size - with path.open("rb") as f: - if sz > limit: - f.seek(sz - limit) - data = f.read().decode("utf-8", errors="replace") - except OSError: - return "" - if sz > limit and "\n" in data: + if _safe_size(path) > limit and "\n" in data: data = data.split("\n", 1)[1] for line in reversed(data.splitlines()): stripped = line.strip() @@ -616,19 +614,40 @@ def _tail_last_nonempty_line(path: pathlib.Path, limit: int = 4096) -> str: def monitor_agent( agent: str, pr: int, - timeout: int, - stall_timeout: int, - poll: int, - require_result: bool, + timeout: Optional[int] = None, + stall_timeout: Optional[int] = None, + poll: Optional[int] = None, + require_result: Optional[bool] = None, no_early_error: bool = False, log_prefix: str = "", stem_template: str = DEFAULT_STEM_TEMPLATE, + config: Optional[MonitorConfig] = None, ) -> AgentStatus: """1 agent を監視する。 `no_early_error=True` のとき、EARLY_ERROR 検知 (FATAL/WARN とも) を完全に無効化し、 hard timeout / stall / sentinel / result.json のみで判定する。 """ + if config is None: + if timeout is None or stall_timeout is None or poll is None or require_result is None: + raise TypeError("timeout, stall_timeout, poll, require_result are required") + config = MonitorConfig( + timeout=timeout, + stall_timeout=stall_timeout, + poll=poll, + require_result=require_result, + no_early_error=no_early_error, + log_prefix=log_prefix, + stem_template=stem_template, + ) + timeout = config.timeout + stall_timeout = config.stall_timeout + poll = config.poll + require_result = config.require_result + no_early_error = config.no_early_error + log_prefix = config.log_prefix + stem_template = config.stem_template + paths = AgentPaths.for_(agent, pr, stem_template) status = AgentStatus(agent=agent) started = time.monotonic() @@ -912,11 +931,15 @@ def run(agent: str) -> None: else _agent_stall_default(agent) results[agent] = monitor_agent( agent=agent, pr=args.pr, - timeout=args.timeout, stall_timeout=stall, - poll=args.poll, require_result=require_result, - no_early_error=args.no_early_error, - log_prefix=f"[{agent}] ", - stem_template=args.stem_template, + config=MonitorConfig( + timeout=args.timeout, + stall_timeout=stall, + poll=args.poll, + require_result=require_result, + no_early_error=args.no_early_error, + log_prefix=f"[{agent}] ", + stem_template=args.stem_template, + ), ) threads = [threading.Thread(target=run, args=(a,), daemon=False) for a in agents] diff --git a/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py index 1a224c66..b048cf04 100755 --- a/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py @@ -976,30 +976,11 @@ def cmd_start_round(args: argparse.Namespace) -> None: ) -def cmd_merge_proposals(args: argparse.Namespace) -> None: - """Step 3 — 提案をマージして改善項目を作る。 - - 終了コード: 0 = 採用あり / 2 = 採用 0 件(提案ラウンドの繰り返しを終える)。 - - **同じラウンドで叩き直しても二重に項目を作らない。** 進行を止めても再開できる - ことが前提なので、統合済みなら前回と同じ結果をそのまま返す。 - """ - path, state = _load(args.id) - entry = _current_round(state) - - if entry.get("proposal_keys") is not None: - info( - f"↻ 提案ラウンド {entry['round']} は統合済みです" - f"(採用 {entry.get('adopted', 0)} 件 / 見送り {entry.get('deferred', 0)} 件)" - ) - for item_id in entry.get("items", []): - item = _find_item(state, item_id, required=False) - if item is not None: - info(f" {item_id} [{item['severity']}] {item['path']}#{item['symbol']}") - if not entry.get("adopted"): - sys.exit(2) - return - +def _collect_proposal_files( + state: dict[str, Any], + entry: dict[str, Any], +) -> dict[str, list[dict[str, Any]]]: + """各 runtime の提案結果ファイルを読み、runtime ごとの提案リストを返す。""" proposals: dict[str, list[dict[str, Any]]] = {} for runtime in state["runtimes"]: result = _result_path( @@ -1028,6 +1009,34 @@ def cmd_merge_proposals(args: argparse.Namespace) -> None: 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 — 提案をマージして改善項目を作る。 + + 終了コード: 0 = 採用あり / 2 = 採用 0 件(提案ラウンドの繰り返しを終える)。 + + **同じラウンドで叩き直しても二重に項目を作らない。** 進行を止めても再開できる + ことが前提なので、統合済みなら前回と同じ結果をそのまま返す。 + """ + path, state = _load(args.id) + entry = _current_round(state) + + if entry.get("proposal_keys") is not None: + info( + f"↻ 提案ラウンド {entry['round']} は統合済みです" + f"(採用 {entry.get('adopted', 0)} 件 / 見送り {entry.get('deferred', 0)} 件)" + ) + for item_id in entry.get("items", []): + item = _find_item(state, item_id, required=False) + if item is not None: + info(f" {item_id} [{item['severity']}] {item['path']}#{item['symbol']}") + if not entry.get("adopted"): + sys.exit(2) + return + + proposals = _collect_proposal_files(state, entry) excluded = { (d["path"], d["symbol"], d["smell"]) for d in state["deferred_items"] @@ -1088,6 +1097,156 @@ def cmd_merge_proposals(args: argparse.Namespace) -> None: sys.exit(2) +def _verify_ownership( + entry: dict[str, Any], + reported: dict[str, dict[str, Any]], + ordered_range: list[str], + work: str, + state: dict[str, Any], +) -> Optional[str]: + """適用範囲の各コミットが、ちょうど 1 項目に所有されているか検証する。""" + in_range = set(ordered_range or []) + round_items = set(entry["items"]) + unknown_ids: list[str] = [] + raw_items = ((state.get("_merge_apply_payload") or {}).get("items")) + if not isinstance(raw_items, list): + raw_items = [] + for r in raw_items: + if not isinstance(r, dict): + continue + item_id = r.get("item_id") + if item_id is not None and item_id not in round_items: + unknown_ids.append(str(item_id)) + + 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)) + entry["_ownership_unassigned"] = unassigned + entry["_ownership_unknown_ids"] = unknown_ids + entry["_ownership_duplicated"] = duplicated + if not (unassigned or unknown_ids or duplicated): + return None + + 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])})" + ) + return ( + "、".join(causes) + + "。検証を回避した変更や、状態と実差分の食い違いを Pull Request に" + "残さないため、ラウンドごと取り消します" + ) + + +def _verify_and_classify_items( + entry: dict[str, Any], + state: dict[str, Any], + reported: dict[str, dict[str, Any]], + in_range: set[str], + test_command: str, + head_branch: str, + timeout: int, + scope: list[str], +) -> tuple[list[str], list[str]]: + """各改善項目のコミット facts を検証し、採用候補と失敗へ分類する。""" + applied: list[str] = [] + failed: list[str] = [] + work = state["worktrees"]["work"] + 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, 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 state.get("_merge_apply_dry_run"): + state_path = state.pop("_merge_apply_path") + dry_run = state.pop("_merge_apply_dry_run", None) + statefile.save(state_path, state) + state["_merge_apply_path"] = state_path + state["_merge_apply_dry_run"] = dry_run + return applied, failed + + +def _revert_unverified_range( + state: dict[str, Any], + entry: dict[str, Any], + ordered_range: list[str], + reason: str, + path: pathlib.Path, + *, + dry_run: bool = False, + base_key: str = "fix_base_sha", + item_id: Optional[str] = None, +) -> Optional[str]: + """検証できない範囲を取り消し、再実行用の起点 SHA を更新する。""" + if reason: + info(reason) + whole_range = { + "item_id": item_id or 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_range, dry_run) + new_base = None + if not dry_run: + new_base = _git_out(state["worktrees"]["work"], ["rev-parse", "HEAD"]) + entry[base_key] = new_base + statefile.save(path, state) + return new_base + + def cmd_merge_apply(args: argparse.Namespace) -> None: """Step 4 — 適用結果を検証して取り込む。 @@ -1163,7 +1322,6 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: # そのまま Pull Request に残せてしまう。 round_items = set(entry["items"]) reported: dict[str, dict[str, Any]] = {} - unknown_ids: list[str] = [] raw_items = payload.get("items") if not isinstance(raw_items, list): info(f"⚠ 適用結果の items が配列ではありません({type(raw_items).__name__})") @@ -1174,8 +1332,6 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: item_id = r.get("item_id") if item_id in round_items: reported[item_id] = r - elif item_id is not None: - unknown_ids.append(str(item_id)) # **範囲のコミットは全て、いずれかの改善項目に割り当てられていること。** # 申告から漏れたコミットはテストもトレーラーも差分予算も検査されず、そのまま @@ -1186,40 +1342,13 @@ 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)) - 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 に" - "残さないため、ラウンドごと取り消します" - ) + state["_merge_apply_payload"] = payload + reason = _verify_ownership(entry, reported, ordered_range, work, state) + state.pop("_merge_apply_payload", None) + unassigned = entry.pop("_ownership_unassigned", []) + unknown_ids = entry.pop("_ownership_unknown_ids", []) + duplicated = entry.pop("_ownership_duplicated", []) + if reason: info(f"❌ {reason}") for item_id in entry["items"]: it = _find_item(state, item_id) @@ -1228,20 +1357,11 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: # 範囲全体を取り消す。どのコミットが安全かを決められない以上、 # 起点まで戻すのが最も確実である。順序は `_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"]) + _revert_unverified_range( + state, entry, ordered_range, "", + path, dry_run=args.dry_run, base_key="apply_base_sha", + item_id=f"R{entry['round']}-range", + ) entry["apply"] = { "applied": [], "failed": list(entry["items"]), "base_sha": entry.get("apply_base_sha"), "head_sha": head_sha, @@ -1263,51 +1383,19 @@ 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) + state["_merge_apply_path"] = path + state["_merge_apply_dry_run"] = args.dry_run + applied, failed = _verify_and_classify_items( + entry, state, reported, in_range, test_command, head_branch, + _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), scope, + ) + state.pop("_merge_apply_path", None) + state.pop("_merge_apply_dry_run", None) entry["apply"] = { "applied": applied, @@ -1464,6 +1552,31 @@ def _resume_incomplete_apply( _flush_pending_push(path, state, entry) +def _build_review_record( + reviews: dict[str, dict[str, Any]], + reviewers: list[str], + round_no: int, +) -> dict[str, Any]: + """レビュー結果から state に保存する findings 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_judge_review(args: argparse.Namespace) -> None: """Step 5 — レビュー 2 者の判定を取り込む。 @@ -1506,22 +1619,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 者分を両方に数えることになり、担当同士の比較が成り立たない。 @@ -1671,6 +1769,104 @@ def cmd_abandon_items(args: argparse.Namespace) -> None: statefile.save(path, state) +def build_fix_merge_key(result: pathlib.Path, attempt: int) -> str: + """修正結果の冪等キーを試行番号と結果ファイル内容から作る。""" + return f"{attempt}:" + hashlib.sha256(result.read_bytes()).hexdigest() + + +def already_merged_fix(entry: dict[str, Any], merge_key: str) -> bool: + """同じ修正結果がすでに取り込み済みかを返す。""" + return merge_key in entry.setdefault("fix_merged_keys", []) + + +def resolve_claimed_threads(payload: dict[str, Any], state: dict[str, Any]) -> set[str]: + """自己申告された解決 thread を 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(state["repo"], state["current_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 collect_fix_range_report( + entry: dict[str, Any], + state: dict[str, Any], + payload: dict[str, Any], + work: str, + head_now: str, +) -> tuple[list[str], list[str], list[dict[str, Any]]]: + """修正範囲を確定し、申告外コミットと commit facts を返す。""" + baseline = state.get("baseline_test") or {} + ordered_range = commits_in_range(work, entry.get("fix_base_sha"), head_now) + if ordered_range is None: + die( + "修正の範囲を確定できませんでした" + f"(起点 {entry.get('fix_base_sha')} / HEAD {head_now})。" + "検証できない修正は採りません", + code=2, + ) + reported_shas = _reported_shas(payload) + reported_full = { + full for full in ( + _git_out(work, ["rev-parse", "--verify", f"{s}^{{commit}}"]) + for s in reported_shas + ) if full + } + unassigned = sorted(set(ordered_range) - reported_full) + facts = collect_commit_facts( + work, reported_shas, set(ordered_range), + baseline.get("command") or "true", state["head_branch"], + _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), + ) + return ordered_range, unassigned, facts + + +def evaluate_fix_commits( + facts: list[dict[str, Any]], + scope: list[str], +) -> tuple[list[str], list[tuple[str, str]]]: + """修正コミット facts を検証し、問題と採用コミットを返す。""" + 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 problems, accepted + + +def reject_fix_range( + path: pathlib.Path, + state: dict[str, Any], + entry: dict[str, Any], + ordered_range: list[str], +) -> None: + """検証を通らない修正範囲を取り消し、次回用の起点を保存する。""" + _revert_unverified_range( + state, entry, ordered_range, + "検証を通らない変更を残さないため、この修正ラウンドの範囲を取り消します", + path, + item_id=f"R{entry['round']}-fix{entry['fix_rounds'] + 1}", + ) + + def cmd_merge_fix(args: argparse.Namespace) -> None: """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" path, state = _load(args.id) @@ -1698,12 +1894,9 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: work = state["worktrees"]["work"] head_now = _git_out(work, ["rev-parse", "HEAD"]) or "" attempt = entry.get("fix_attempts", 0) - merge_key = ( - f"{attempt}:" - + hashlib.sha256(result.read_bytes()).hexdigest() - ) + merge_key = build_fix_merge_key(result, attempt) merged_keys = entry.setdefault("fix_merged_keys", []) - if merge_key in merged_keys: + if already_merged_fix(entry, merge_key): info( f"↻ この修正結果は取り込み済みです" f"(修正ラウンド {entry['fix_rounds']})" @@ -1713,71 +1906,25 @@ 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 = resolve_claimed_threads(payload, state) # 修正コミットも適用と同じ基準で、**git と実際のテスト実行から**検証する。 # 結果ファイルの申告で済ませると、手順を満たさない変更が収束済みになれてしまう。 - baseline = state.get("baseline_test") or {} # 修正の範囲も**オーケストレータが記録した起点**から取る。起点は # `judge-review` が変更要求を返したときの HEAD である。 - ordered_range = commits_in_range(work, entry.get("fix_base_sha"), head_now) - if ordered_range is None: - die( - "修正の範囲を確定できませんでした" - f"(起点 {entry.get('fix_base_sha')} / HEAD {head_now})。" - "検証できない修正は採りません", - code=2, - ) - reported_shas = _reported_shas(payload) - # 適用と同じく、**範囲のコミットは全て申告されていること**を求める。 # 申告から漏れた修正コミットは検証を受けないまま Pull Request に残る。 - reported_full = { - full for full in ( - _git_out(work, ["rev-parse", "--verify", f"{s}^{{commit}}"]) - for s in reported_shas - ) if full - } - unassigned = sorted(set(ordered_range) - reported_full) - - facts = collect_commit_facts( - work, reported_shas, set(ordered_range), - baseline.get("command") or "true", state["head_branch"], - _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), + ordered_range, unassigned, facts = collect_fix_range_report( + entry, state, payload, work, head_now, ) # **不正なコミットが 1 件でもあれば、修正ラウンドの範囲ごと取り消す。** # 状態を記録しないだけでは、未検証の変更が 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"])) + problems, accepted = evaluate_fix_commits(facts, state.get("target_scope") or []) if unassigned: info( @@ -1788,22 +1935,10 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: if unassigned or problems: # **状態へ記録する前に取り消す。** 先に記録すると、取り消し済みのコミットが # 状態ファイルに残り、後の見送り処理が同じコミットをもう一度取り消そうとする。 - info("検証を通らない変更を残さないため、この修正ラウンドの範囲を取り消します") - # **取り消しへ着手する前に印を立てる。** 取り消しは済んだのに push できずに - # 終わると、未検証の変更が Pull Request に残ったままになる。 - entry["pending_push"] = True - statefile.save(path, state) - _revert_item_commits( - state, - {"item_id": f"R{entry['round']}-fix{entry['fix_rounds'] + 1}", - "commits": list(ordered_range)}, - dry_run=False, - ) + reject_fix_range(path, state, entry, ordered_range) # 取り消し後の状態を新しい起点にし、**その場で保存する**。ここで保存せずに # 落ちると、次の実行は古い起点から範囲を取り直して取り消しコミット自体を # 「未申告」と判定し、**取り消しを取り消して**しまう。 - entry["fix_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) - statefile.save(path, state) # **push は保存のあと。** ここで push して失敗すると、取り消しコミットは # ローカルに残るのに起点の更新が保存されず、叩き直しで二重に取り消してしまう。 info("⚠ 修正を取り消したため、解決の申告は採用しません") 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..74583817 100755 --- a/plugins/ndf-codex/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-codex/skills/cross-review/scripts/lib/monitor.py @@ -389,6 +389,17 @@ class AgentStatus: sentinel_seen: bool = False +@dataclass +class MonitorConfig: + timeout: int + stall_timeout: int + poll: int + require_result: bool + no_early_error: bool = False + log_prefix: str = "" + stem_template: str = DEFAULT_STEM_TEMPLATE + + # ---------- 監視ロジック ---------- def _read_pidfile(p: pathlib.Path) -> Optional[int]: @@ -492,15 +503,8 @@ def _scan_patterns( 扱いしてしまった (例: `Error in: mcpServers.serena\\n...\\nTraceback ...` で Traceback が誤抑制された)。 """ - 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: + data = _read_tail(path, 200 * 1024) + if data is None: return None data = _strip_ansi(data) benign_patterns = EARLY_ERROR_BENIGN if benign is None else benign @@ -549,16 +553,10 @@ def _scan_claude_stdout_fatal(path: pathlib.Path) -> Optional[str]: 行うが、JSON は 1 行に多数の引用符を含むため、パリティ判定が「引用の内側」を 誤って真にして致命を取りこぼす。 """ - 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 = _strip_ansi(f.read().decode("utf-8", errors="replace")) - except OSError: + data = _read_tail(path, 200 * 1024) + if data is None: return None + data = _strip_ansi(data) for pat in CLAUDE_STDOUT_FATAL: m = pat.search(data) if m: @@ -566,17 +564,24 @@ def _scan_claude_stdout_fatal(path: pathlib.Path) -> Optional[str]: return None -def _scan_codex_sentinel(path: pathlib.Path) -> bool: +def _read_tail(path: pathlib.Path, limit: int) -> Optional[str]: + """ファイル末尾を安全に読み、壊れた UTF-8 は置換して返す。""" if not path.exists(): - return False + return None try: - # 末尾 64KB を読む(sentinel は最後の方に出る) sz = path.stat().st_size with path.open("rb") as f: - if sz > 64 * 1024: - f.seek(sz - 64 * 1024) - tail = f.read().decode("utf-8", errors="replace") + if sz > limit: + f.seek(sz - limit) + return f.read().decode("utf-8", errors="replace") except OSError: + return None + + +def _scan_codex_sentinel(path: pathlib.Path) -> bool: + # 末尾 64KB を読む(sentinel は最後の方に出る) + tail = _read_tail(path, 64 * 1024) + if tail is None: return False return bool(CODEX_SENTINEL.search(tail)) @@ -594,17 +599,10 @@ def _tail_last_nonempty_line(path: pathlib.Path, limit: int = 4096) -> str: Gemini に書かせるのは短いフェーズマーカーだけなので、末尾数 KB で十分。 壊れた UTF-8 や読み取り競合があっても monitor 自体は落とさない。 """ - if not path.exists(): + data = _read_tail(path, limit) + if data is None: return "" - try: - sz = path.stat().st_size - with path.open("rb") as f: - if sz > limit: - f.seek(sz - limit) - data = f.read().decode("utf-8", errors="replace") - except OSError: - return "" - if sz > limit and "\n" in data: + if _safe_size(path) > limit and "\n" in data: data = data.split("\n", 1)[1] for line in reversed(data.splitlines()): stripped = line.strip() @@ -616,19 +614,40 @@ def _tail_last_nonempty_line(path: pathlib.Path, limit: int = 4096) -> str: def monitor_agent( agent: str, pr: int, - timeout: int, - stall_timeout: int, - poll: int, - require_result: bool, + timeout: Optional[int] = None, + stall_timeout: Optional[int] = None, + poll: Optional[int] = None, + require_result: Optional[bool] = None, no_early_error: bool = False, log_prefix: str = "", stem_template: str = DEFAULT_STEM_TEMPLATE, + config: Optional[MonitorConfig] = None, ) -> AgentStatus: """1 agent を監視する。 `no_early_error=True` のとき、EARLY_ERROR 検知 (FATAL/WARN とも) を完全に無効化し、 hard timeout / stall / sentinel / result.json のみで判定する。 """ + if config is None: + if timeout is None or stall_timeout is None or poll is None or require_result is None: + raise TypeError("timeout, stall_timeout, poll, require_result are required") + config = MonitorConfig( + timeout=timeout, + stall_timeout=stall_timeout, + poll=poll, + require_result=require_result, + no_early_error=no_early_error, + log_prefix=log_prefix, + stem_template=stem_template, + ) + timeout = config.timeout + stall_timeout = config.stall_timeout + poll = config.poll + require_result = config.require_result + no_early_error = config.no_early_error + log_prefix = config.log_prefix + stem_template = config.stem_template + paths = AgentPaths.for_(agent, pr, stem_template) status = AgentStatus(agent=agent) started = time.monotonic() @@ -912,11 +931,15 @@ def run(agent: str) -> None: else _agent_stall_default(agent) results[agent] = monitor_agent( agent=agent, pr=args.pr, - timeout=args.timeout, stall_timeout=stall, - poll=args.poll, require_result=require_result, - no_early_error=args.no_early_error, - log_prefix=f"[{agent}] ", - stem_template=args.stem_template, + config=MonitorConfig( + timeout=args.timeout, + stall_timeout=stall, + poll=args.poll, + require_result=require_result, + no_early_error=args.no_early_error, + log_prefix=f"[{agent}] ", + stem_template=args.stem_template, + ), ) threads = [threading.Thread(target=run, args=(a,), daemon=False) for a in agents] diff --git a/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py index 1a224c66..b048cf04 100755 --- a/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py @@ -976,30 +976,11 @@ def cmd_start_round(args: argparse.Namespace) -> None: ) -def cmd_merge_proposals(args: argparse.Namespace) -> None: - """Step 3 — 提案をマージして改善項目を作る。 - - 終了コード: 0 = 採用あり / 2 = 採用 0 件(提案ラウンドの繰り返しを終える)。 - - **同じラウンドで叩き直しても二重に項目を作らない。** 進行を止めても再開できる - ことが前提なので、統合済みなら前回と同じ結果をそのまま返す。 - """ - path, state = _load(args.id) - entry = _current_round(state) - - if entry.get("proposal_keys") is not None: - info( - f"↻ 提案ラウンド {entry['round']} は統合済みです" - f"(採用 {entry.get('adopted', 0)} 件 / 見送り {entry.get('deferred', 0)} 件)" - ) - for item_id in entry.get("items", []): - item = _find_item(state, item_id, required=False) - if item is not None: - info(f" {item_id} [{item['severity']}] {item['path']}#{item['symbol']}") - if not entry.get("adopted"): - sys.exit(2) - return - +def _collect_proposal_files( + state: dict[str, Any], + entry: dict[str, Any], +) -> dict[str, list[dict[str, Any]]]: + """各 runtime の提案結果ファイルを読み、runtime ごとの提案リストを返す。""" proposals: dict[str, list[dict[str, Any]]] = {} for runtime in state["runtimes"]: result = _result_path( @@ -1028,6 +1009,34 @@ def cmd_merge_proposals(args: argparse.Namespace) -> None: 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 — 提案をマージして改善項目を作る。 + + 終了コード: 0 = 採用あり / 2 = 採用 0 件(提案ラウンドの繰り返しを終える)。 + + **同じラウンドで叩き直しても二重に項目を作らない。** 進行を止めても再開できる + ことが前提なので、統合済みなら前回と同じ結果をそのまま返す。 + """ + path, state = _load(args.id) + entry = _current_round(state) + + if entry.get("proposal_keys") is not None: + info( + f"↻ 提案ラウンド {entry['round']} は統合済みです" + f"(採用 {entry.get('adopted', 0)} 件 / 見送り {entry.get('deferred', 0)} 件)" + ) + for item_id in entry.get("items", []): + item = _find_item(state, item_id, required=False) + if item is not None: + info(f" {item_id} [{item['severity']}] {item['path']}#{item['symbol']}") + if not entry.get("adopted"): + sys.exit(2) + return + + proposals = _collect_proposal_files(state, entry) excluded = { (d["path"], d["symbol"], d["smell"]) for d in state["deferred_items"] @@ -1088,6 +1097,156 @@ def cmd_merge_proposals(args: argparse.Namespace) -> None: sys.exit(2) +def _verify_ownership( + entry: dict[str, Any], + reported: dict[str, dict[str, Any]], + ordered_range: list[str], + work: str, + state: dict[str, Any], +) -> Optional[str]: + """適用範囲の各コミットが、ちょうど 1 項目に所有されているか検証する。""" + in_range = set(ordered_range or []) + round_items = set(entry["items"]) + unknown_ids: list[str] = [] + raw_items = ((state.get("_merge_apply_payload") or {}).get("items")) + if not isinstance(raw_items, list): + raw_items = [] + for r in raw_items: + if not isinstance(r, dict): + continue + item_id = r.get("item_id") + if item_id is not None and item_id not in round_items: + unknown_ids.append(str(item_id)) + + 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)) + entry["_ownership_unassigned"] = unassigned + entry["_ownership_unknown_ids"] = unknown_ids + entry["_ownership_duplicated"] = duplicated + if not (unassigned or unknown_ids or duplicated): + return None + + 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])})" + ) + return ( + "、".join(causes) + + "。検証を回避した変更や、状態と実差分の食い違いを Pull Request に" + "残さないため、ラウンドごと取り消します" + ) + + +def _verify_and_classify_items( + entry: dict[str, Any], + state: dict[str, Any], + reported: dict[str, dict[str, Any]], + in_range: set[str], + test_command: str, + head_branch: str, + timeout: int, + scope: list[str], +) -> tuple[list[str], list[str]]: + """各改善項目のコミット facts を検証し、採用候補と失敗へ分類する。""" + applied: list[str] = [] + failed: list[str] = [] + work = state["worktrees"]["work"] + 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, 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 state.get("_merge_apply_dry_run"): + state_path = state.pop("_merge_apply_path") + dry_run = state.pop("_merge_apply_dry_run", None) + statefile.save(state_path, state) + state["_merge_apply_path"] = state_path + state["_merge_apply_dry_run"] = dry_run + return applied, failed + + +def _revert_unverified_range( + state: dict[str, Any], + entry: dict[str, Any], + ordered_range: list[str], + reason: str, + path: pathlib.Path, + *, + dry_run: bool = False, + base_key: str = "fix_base_sha", + item_id: Optional[str] = None, +) -> Optional[str]: + """検証できない範囲を取り消し、再実行用の起点 SHA を更新する。""" + if reason: + info(reason) + whole_range = { + "item_id": item_id or 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_range, dry_run) + new_base = None + if not dry_run: + new_base = _git_out(state["worktrees"]["work"], ["rev-parse", "HEAD"]) + entry[base_key] = new_base + statefile.save(path, state) + return new_base + + def cmd_merge_apply(args: argparse.Namespace) -> None: """Step 4 — 適用結果を検証して取り込む。 @@ -1163,7 +1322,6 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: # そのまま Pull Request に残せてしまう。 round_items = set(entry["items"]) reported: dict[str, dict[str, Any]] = {} - unknown_ids: list[str] = [] raw_items = payload.get("items") if not isinstance(raw_items, list): info(f"⚠ 適用結果の items が配列ではありません({type(raw_items).__name__})") @@ -1174,8 +1332,6 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: item_id = r.get("item_id") if item_id in round_items: reported[item_id] = r - elif item_id is not None: - unknown_ids.append(str(item_id)) # **範囲のコミットは全て、いずれかの改善項目に割り当てられていること。** # 申告から漏れたコミットはテストもトレーラーも差分予算も検査されず、そのまま @@ -1186,40 +1342,13 @@ 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)) - 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 に" - "残さないため、ラウンドごと取り消します" - ) + state["_merge_apply_payload"] = payload + reason = _verify_ownership(entry, reported, ordered_range, work, state) + state.pop("_merge_apply_payload", None) + unassigned = entry.pop("_ownership_unassigned", []) + unknown_ids = entry.pop("_ownership_unknown_ids", []) + duplicated = entry.pop("_ownership_duplicated", []) + if reason: info(f"❌ {reason}") for item_id in entry["items"]: it = _find_item(state, item_id) @@ -1228,20 +1357,11 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: # 範囲全体を取り消す。どのコミットが安全かを決められない以上、 # 起点まで戻すのが最も確実である。順序は `_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"]) + _revert_unverified_range( + state, entry, ordered_range, "", + path, dry_run=args.dry_run, base_key="apply_base_sha", + item_id=f"R{entry['round']}-range", + ) entry["apply"] = { "applied": [], "failed": list(entry["items"]), "base_sha": entry.get("apply_base_sha"), "head_sha": head_sha, @@ -1263,51 +1383,19 @@ 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) + state["_merge_apply_path"] = path + state["_merge_apply_dry_run"] = args.dry_run + applied, failed = _verify_and_classify_items( + entry, state, reported, in_range, test_command, head_branch, + _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), scope, + ) + state.pop("_merge_apply_path", None) + state.pop("_merge_apply_dry_run", None) entry["apply"] = { "applied": applied, @@ -1464,6 +1552,31 @@ def _resume_incomplete_apply( _flush_pending_push(path, state, entry) +def _build_review_record( + reviews: dict[str, dict[str, Any]], + reviewers: list[str], + round_no: int, +) -> dict[str, Any]: + """レビュー結果から state に保存する findings 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_judge_review(args: argparse.Namespace) -> None: """Step 5 — レビュー 2 者の判定を取り込む。 @@ -1506,22 +1619,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 者分を両方に数えることになり、担当同士の比較が成り立たない。 @@ -1671,6 +1769,104 @@ def cmd_abandon_items(args: argparse.Namespace) -> None: statefile.save(path, state) +def build_fix_merge_key(result: pathlib.Path, attempt: int) -> str: + """修正結果の冪等キーを試行番号と結果ファイル内容から作る。""" + return f"{attempt}:" + hashlib.sha256(result.read_bytes()).hexdigest() + + +def already_merged_fix(entry: dict[str, Any], merge_key: str) -> bool: + """同じ修正結果がすでに取り込み済みかを返す。""" + return merge_key in entry.setdefault("fix_merged_keys", []) + + +def resolve_claimed_threads(payload: dict[str, Any], state: dict[str, Any]) -> set[str]: + """自己申告された解決 thread を 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(state["repo"], state["current_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 collect_fix_range_report( + entry: dict[str, Any], + state: dict[str, Any], + payload: dict[str, Any], + work: str, + head_now: str, +) -> tuple[list[str], list[str], list[dict[str, Any]]]: + """修正範囲を確定し、申告外コミットと commit facts を返す。""" + baseline = state.get("baseline_test") or {} + ordered_range = commits_in_range(work, entry.get("fix_base_sha"), head_now) + if ordered_range is None: + die( + "修正の範囲を確定できませんでした" + f"(起点 {entry.get('fix_base_sha')} / HEAD {head_now})。" + "検証できない修正は採りません", + code=2, + ) + reported_shas = _reported_shas(payload) + reported_full = { + full for full in ( + _git_out(work, ["rev-parse", "--verify", f"{s}^{{commit}}"]) + for s in reported_shas + ) if full + } + unassigned = sorted(set(ordered_range) - reported_full) + facts = collect_commit_facts( + work, reported_shas, set(ordered_range), + baseline.get("command") or "true", state["head_branch"], + _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), + ) + return ordered_range, unassigned, facts + + +def evaluate_fix_commits( + facts: list[dict[str, Any]], + scope: list[str], +) -> tuple[list[str], list[tuple[str, str]]]: + """修正コミット facts を検証し、問題と採用コミットを返す。""" + 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 problems, accepted + + +def reject_fix_range( + path: pathlib.Path, + state: dict[str, Any], + entry: dict[str, Any], + ordered_range: list[str], +) -> None: + """検証を通らない修正範囲を取り消し、次回用の起点を保存する。""" + _revert_unverified_range( + state, entry, ordered_range, + "検証を通らない変更を残さないため、この修正ラウンドの範囲を取り消します", + path, + item_id=f"R{entry['round']}-fix{entry['fix_rounds'] + 1}", + ) + + def cmd_merge_fix(args: argparse.Namespace) -> None: """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" path, state = _load(args.id) @@ -1698,12 +1894,9 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: work = state["worktrees"]["work"] head_now = _git_out(work, ["rev-parse", "HEAD"]) or "" attempt = entry.get("fix_attempts", 0) - merge_key = ( - f"{attempt}:" - + hashlib.sha256(result.read_bytes()).hexdigest() - ) + merge_key = build_fix_merge_key(result, attempt) merged_keys = entry.setdefault("fix_merged_keys", []) - if merge_key in merged_keys: + if already_merged_fix(entry, merge_key): info( f"↻ この修正結果は取り込み済みです" f"(修正ラウンド {entry['fix_rounds']})" @@ -1713,71 +1906,25 @@ 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 = resolve_claimed_threads(payload, state) # 修正コミットも適用と同じ基準で、**git と実際のテスト実行から**検証する。 # 結果ファイルの申告で済ませると、手順を満たさない変更が収束済みになれてしまう。 - baseline = state.get("baseline_test") or {} # 修正の範囲も**オーケストレータが記録した起点**から取る。起点は # `judge-review` が変更要求を返したときの HEAD である。 - ordered_range = commits_in_range(work, entry.get("fix_base_sha"), head_now) - if ordered_range is None: - die( - "修正の範囲を確定できませんでした" - f"(起点 {entry.get('fix_base_sha')} / HEAD {head_now})。" - "検証できない修正は採りません", - code=2, - ) - reported_shas = _reported_shas(payload) - # 適用と同じく、**範囲のコミットは全て申告されていること**を求める。 # 申告から漏れた修正コミットは検証を受けないまま Pull Request に残る。 - reported_full = { - full for full in ( - _git_out(work, ["rev-parse", "--verify", f"{s}^{{commit}}"]) - for s in reported_shas - ) if full - } - unassigned = sorted(set(ordered_range) - reported_full) - - facts = collect_commit_facts( - work, reported_shas, set(ordered_range), - baseline.get("command") or "true", state["head_branch"], - _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), + ordered_range, unassigned, facts = collect_fix_range_report( + entry, state, payload, work, head_now, ) # **不正なコミットが 1 件でもあれば、修正ラウンドの範囲ごと取り消す。** # 状態を記録しないだけでは、未検証の変更が 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"])) + problems, accepted = evaluate_fix_commits(facts, state.get("target_scope") or []) if unassigned: info( @@ -1788,22 +1935,10 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: if unassigned or problems: # **状態へ記録する前に取り消す。** 先に記録すると、取り消し済みのコミットが # 状態ファイルに残り、後の見送り処理が同じコミットをもう一度取り消そうとする。 - info("検証を通らない変更を残さないため、この修正ラウンドの範囲を取り消します") - # **取り消しへ着手する前に印を立てる。** 取り消しは済んだのに push できずに - # 終わると、未検証の変更が Pull Request に残ったままになる。 - entry["pending_push"] = True - statefile.save(path, state) - _revert_item_commits( - state, - {"item_id": f"R{entry['round']}-fix{entry['fix_rounds'] + 1}", - "commits": list(ordered_range)}, - dry_run=False, - ) + reject_fix_range(path, state, entry, ordered_range) # 取り消し後の状態を新しい起点にし、**その場で保存する**。ここで保存せずに # 落ちると、次の実行は古い起点から範囲を取り直して取り消しコミット自体を # 「未申告」と判定し、**取り消しを取り消して**しまう。 - entry["fix_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) - statefile.save(path, state) # **push は保存のあと。** ここで push して失敗すると、取り消しコミットは # ローカルに残るのに起点の更新が保存されず、叩き直しで二重に取り消してしまう。 info("⚠ 修正を取り消したため、解決の申告は採用しません") 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..74583817 100755 --- a/plugins/ndf-kiro/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-kiro/skills/cross-review/scripts/lib/monitor.py @@ -389,6 +389,17 @@ class AgentStatus: sentinel_seen: bool = False +@dataclass +class MonitorConfig: + timeout: int + stall_timeout: int + poll: int + require_result: bool + no_early_error: bool = False + log_prefix: str = "" + stem_template: str = DEFAULT_STEM_TEMPLATE + + # ---------- 監視ロジック ---------- def _read_pidfile(p: pathlib.Path) -> Optional[int]: @@ -492,15 +503,8 @@ def _scan_patterns( 扱いしてしまった (例: `Error in: mcpServers.serena\\n...\\nTraceback ...` で Traceback が誤抑制された)。 """ - 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: + data = _read_tail(path, 200 * 1024) + if data is None: return None data = _strip_ansi(data) benign_patterns = EARLY_ERROR_BENIGN if benign is None else benign @@ -549,16 +553,10 @@ def _scan_claude_stdout_fatal(path: pathlib.Path) -> Optional[str]: 行うが、JSON は 1 行に多数の引用符を含むため、パリティ判定が「引用の内側」を 誤って真にして致命を取りこぼす。 """ - 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 = _strip_ansi(f.read().decode("utf-8", errors="replace")) - except OSError: + data = _read_tail(path, 200 * 1024) + if data is None: return None + data = _strip_ansi(data) for pat in CLAUDE_STDOUT_FATAL: m = pat.search(data) if m: @@ -566,17 +564,24 @@ def _scan_claude_stdout_fatal(path: pathlib.Path) -> Optional[str]: return None -def _scan_codex_sentinel(path: pathlib.Path) -> bool: +def _read_tail(path: pathlib.Path, limit: int) -> Optional[str]: + """ファイル末尾を安全に読み、壊れた UTF-8 は置換して返す。""" if not path.exists(): - return False + return None try: - # 末尾 64KB を読む(sentinel は最後の方に出る) sz = path.stat().st_size with path.open("rb") as f: - if sz > 64 * 1024: - f.seek(sz - 64 * 1024) - tail = f.read().decode("utf-8", errors="replace") + if sz > limit: + f.seek(sz - limit) + return f.read().decode("utf-8", errors="replace") except OSError: + return None + + +def _scan_codex_sentinel(path: pathlib.Path) -> bool: + # 末尾 64KB を読む(sentinel は最後の方に出る) + tail = _read_tail(path, 64 * 1024) + if tail is None: return False return bool(CODEX_SENTINEL.search(tail)) @@ -594,17 +599,10 @@ def _tail_last_nonempty_line(path: pathlib.Path, limit: int = 4096) -> str: Gemini に書かせるのは短いフェーズマーカーだけなので、末尾数 KB で十分。 壊れた UTF-8 や読み取り競合があっても monitor 自体は落とさない。 """ - if not path.exists(): + data = _read_tail(path, limit) + if data is None: return "" - try: - sz = path.stat().st_size - with path.open("rb") as f: - if sz > limit: - f.seek(sz - limit) - data = f.read().decode("utf-8", errors="replace") - except OSError: - return "" - if sz > limit and "\n" in data: + if _safe_size(path) > limit and "\n" in data: data = data.split("\n", 1)[1] for line in reversed(data.splitlines()): stripped = line.strip() @@ -616,19 +614,40 @@ def _tail_last_nonempty_line(path: pathlib.Path, limit: int = 4096) -> str: def monitor_agent( agent: str, pr: int, - timeout: int, - stall_timeout: int, - poll: int, - require_result: bool, + timeout: Optional[int] = None, + stall_timeout: Optional[int] = None, + poll: Optional[int] = None, + require_result: Optional[bool] = None, no_early_error: bool = False, log_prefix: str = "", stem_template: str = DEFAULT_STEM_TEMPLATE, + config: Optional[MonitorConfig] = None, ) -> AgentStatus: """1 agent を監視する。 `no_early_error=True` のとき、EARLY_ERROR 検知 (FATAL/WARN とも) を完全に無効化し、 hard timeout / stall / sentinel / result.json のみで判定する。 """ + if config is None: + if timeout is None or stall_timeout is None or poll is None or require_result is None: + raise TypeError("timeout, stall_timeout, poll, require_result are required") + config = MonitorConfig( + timeout=timeout, + stall_timeout=stall_timeout, + poll=poll, + require_result=require_result, + no_early_error=no_early_error, + log_prefix=log_prefix, + stem_template=stem_template, + ) + timeout = config.timeout + stall_timeout = config.stall_timeout + poll = config.poll + require_result = config.require_result + no_early_error = config.no_early_error + log_prefix = config.log_prefix + stem_template = config.stem_template + paths = AgentPaths.for_(agent, pr, stem_template) status = AgentStatus(agent=agent) started = time.monotonic() @@ -912,11 +931,15 @@ def run(agent: str) -> None: else _agent_stall_default(agent) results[agent] = monitor_agent( agent=agent, pr=args.pr, - timeout=args.timeout, stall_timeout=stall, - poll=args.poll, require_result=require_result, - no_early_error=args.no_early_error, - log_prefix=f"[{agent}] ", - stem_template=args.stem_template, + config=MonitorConfig( + timeout=args.timeout, + stall_timeout=stall, + poll=args.poll, + require_result=require_result, + no_early_error=args.no_early_error, + log_prefix=f"[{agent}] ", + stem_template=args.stem_template, + ), ) threads = [threading.Thread(target=run, args=(a,), daemon=False) for a in agents] From 65065037c367d39cd9c21e71e58c052305da54e8 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 13:11:30 +0000 Subject: [PATCH 49/95] =?UTF-8?q?Revert=20"Chore:=20=E7=94=9F=E6=88=90?= =?UTF-8?q?=E7=89=A9=E3=82=92=E5=90=8C=E6=9C=9F=E3=81=99=E3=82=8B=EF=BC=88?= =?UTF-8?q?cross-refactoring=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 This reverts commit 3c1f29349744c630a6c9f02927911db5ae7a939f. --- .../cross-refactoring/scripts/refactor.py | 533 +++++++----------- .../cross-review/scripts/lib/monitor.py | 109 ++-- .../cross-refactoring/scripts/refactor.py | 533 +++++++----------- .../cross-review/scripts/lib/monitor.py | 109 ++-- .../cross-refactoring/scripts/refactor.py | 533 +++++++----------- .../cross-review/scripts/lib/monitor.py | 109 ++-- 6 files changed, 726 insertions(+), 1200 deletions(-) diff --git a/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py index b048cf04..1a224c66 100755 --- a/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py @@ -976,11 +976,30 @@ def cmd_start_round(args: argparse.Namespace) -> None: ) -def _collect_proposal_files( - state: dict[str, Any], - entry: dict[str, Any], -) -> dict[str, list[dict[str, Any]]]: - """各 runtime の提案結果ファイルを読み、runtime ごとの提案リストを返す。""" +def cmd_merge_proposals(args: argparse.Namespace) -> None: + """Step 3 — 提案をマージして改善項目を作る。 + + 終了コード: 0 = 採用あり / 2 = 採用 0 件(提案ラウンドの繰り返しを終える)。 + + **同じラウンドで叩き直しても二重に項目を作らない。** 進行を止めても再開できる + ことが前提なので、統合済みなら前回と同じ結果をそのまま返す。 + """ + path, state = _load(args.id) + entry = _current_round(state) + + if entry.get("proposal_keys") is not None: + info( + f"↻ 提案ラウンド {entry['round']} は統合済みです" + f"(採用 {entry.get('adopted', 0)} 件 / 見送り {entry.get('deferred', 0)} 件)" + ) + for item_id in entry.get("items", []): + item = _find_item(state, item_id, required=False) + if item is not None: + info(f" {item_id} [{item['severity']}] {item['path']}#{item['symbol']}") + if not entry.get("adopted"): + sys.exit(2) + return + proposals: dict[str, list[dict[str, Any]]] = {} for runtime in state["runtimes"]: result = _result_path( @@ -1009,34 +1028,6 @@ def _collect_proposal_files( 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 — 提案をマージして改善項目を作る。 - - 終了コード: 0 = 採用あり / 2 = 採用 0 件(提案ラウンドの繰り返しを終える)。 - - **同じラウンドで叩き直しても二重に項目を作らない。** 進行を止めても再開できる - ことが前提なので、統合済みなら前回と同じ結果をそのまま返す。 - """ - path, state = _load(args.id) - entry = _current_round(state) - - if entry.get("proposal_keys") is not None: - info( - f"↻ 提案ラウンド {entry['round']} は統合済みです" - f"(採用 {entry.get('adopted', 0)} 件 / 見送り {entry.get('deferred', 0)} 件)" - ) - for item_id in entry.get("items", []): - item = _find_item(state, item_id, required=False) - if item is not None: - info(f" {item_id} [{item['severity']}] {item['path']}#{item['symbol']}") - if not entry.get("adopted"): - sys.exit(2) - return - - proposals = _collect_proposal_files(state, entry) excluded = { (d["path"], d["symbol"], d["smell"]) for d in state["deferred_items"] @@ -1097,156 +1088,6 @@ def cmd_merge_proposals(args: argparse.Namespace) -> None: sys.exit(2) -def _verify_ownership( - entry: dict[str, Any], - reported: dict[str, dict[str, Any]], - ordered_range: list[str], - work: str, - state: dict[str, Any], -) -> Optional[str]: - """適用範囲の各コミットが、ちょうど 1 項目に所有されているか検証する。""" - in_range = set(ordered_range or []) - round_items = set(entry["items"]) - unknown_ids: list[str] = [] - raw_items = ((state.get("_merge_apply_payload") or {}).get("items")) - if not isinstance(raw_items, list): - raw_items = [] - for r in raw_items: - if not isinstance(r, dict): - continue - item_id = r.get("item_id") - if item_id is not None and item_id not in round_items: - unknown_ids.append(str(item_id)) - - 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)) - entry["_ownership_unassigned"] = unassigned - entry["_ownership_unknown_ids"] = unknown_ids - entry["_ownership_duplicated"] = duplicated - if not (unassigned or unknown_ids or duplicated): - return None - - 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])})" - ) - return ( - "、".join(causes) - + "。検証を回避した変更や、状態と実差分の食い違いを Pull Request に" - "残さないため、ラウンドごと取り消します" - ) - - -def _verify_and_classify_items( - entry: dict[str, Any], - state: dict[str, Any], - reported: dict[str, dict[str, Any]], - in_range: set[str], - test_command: str, - head_branch: str, - timeout: int, - scope: list[str], -) -> tuple[list[str], list[str]]: - """各改善項目のコミット facts を検証し、採用候補と失敗へ分類する。""" - applied: list[str] = [] - failed: list[str] = [] - work = state["worktrees"]["work"] - 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, 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 state.get("_merge_apply_dry_run"): - state_path = state.pop("_merge_apply_path") - dry_run = state.pop("_merge_apply_dry_run", None) - statefile.save(state_path, state) - state["_merge_apply_path"] = state_path - state["_merge_apply_dry_run"] = dry_run - return applied, failed - - -def _revert_unverified_range( - state: dict[str, Any], - entry: dict[str, Any], - ordered_range: list[str], - reason: str, - path: pathlib.Path, - *, - dry_run: bool = False, - base_key: str = "fix_base_sha", - item_id: Optional[str] = None, -) -> Optional[str]: - """検証できない範囲を取り消し、再実行用の起点 SHA を更新する。""" - if reason: - info(reason) - whole_range = { - "item_id": item_id or 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_range, dry_run) - new_base = None - if not dry_run: - new_base = _git_out(state["worktrees"]["work"], ["rev-parse", "HEAD"]) - entry[base_key] = new_base - statefile.save(path, state) - return new_base - - def cmd_merge_apply(args: argparse.Namespace) -> None: """Step 4 — 適用結果を検証して取り込む。 @@ -1322,6 +1163,7 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: # そのまま Pull Request に残せてしまう。 round_items = set(entry["items"]) reported: dict[str, dict[str, Any]] = {} + unknown_ids: list[str] = [] raw_items = payload.get("items") if not isinstance(raw_items, list): info(f"⚠ 適用結果の items が配列ではありません({type(raw_items).__name__})") @@ -1332,6 +1174,8 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: item_id = r.get("item_id") if item_id in round_items: reported[item_id] = r + elif item_id is not None: + unknown_ids.append(str(item_id)) # **範囲のコミットは全て、いずれかの改善項目に割り当てられていること。** # 申告から漏れたコミットはテストもトレーラーも差分予算も検査されず、そのまま @@ -1342,13 +1186,40 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: # # 判定は**完全な SHA へ正規化してから**行う。申告の文字列をそのまま鍵にすると、 # 一方が完全 SHA、他方が短縮 SHA で同じコミットを指したときに重複を見逃す。 - state["_merge_apply_payload"] = payload - reason = _verify_ownership(entry, reported, ordered_range, work, state) - state.pop("_merge_apply_payload", None) - unassigned = entry.pop("_ownership_unassigned", []) - unknown_ids = entry.pop("_ownership_unknown_ids", []) - duplicated = entry.pop("_ownership_duplicated", []) - if reason: + 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: + 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) @@ -1357,11 +1228,20 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: # 範囲全体を取り消す。どのコミットが安全かを決められない以上、 # 起点まで戻すのが最も確実である。順序は `_revert_item_commits` が # git の履歴から決め直す。 - _revert_unverified_range( - state, entry, ordered_range, "", - path, dry_run=args.dry_run, base_key="apply_base_sha", - item_id=f"R{entry['round']}-range", - ) + 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, @@ -1383,19 +1263,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 [] # **判定はその都度残す。** まとめて最後に保存すると、取り消しの途中で中断した # ときに適用の記録が一切残らず、どのコミットが検証を通ったのかを状態から # 復元できなくなる。再開可能性は収束ループの前提なので、ここが崩れると # 中断からの復帰手段が無くなる。 - state["_merge_apply_path"] = path - state["_merge_apply_dry_run"] = args.dry_run - applied, failed = _verify_and_classify_items( - entry, state, reported, in_range, test_command, head_branch, - _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), scope, - ) - state.pop("_merge_apply_path", None) - state.pop("_merge_apply_dry_run", None) + 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, @@ -1552,31 +1464,6 @@ def _resume_incomplete_apply( _flush_pending_push(path, state, entry) -def _build_review_record( - reviews: dict[str, dict[str, Any]], - reviewers: list[str], - round_no: int, -) -> dict[str, Any]: - """レビュー結果から state に保存する findings 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_judge_review(args: argparse.Namespace) -> None: """Step 5 — レビュー 2 者の判定を取り込む。 @@ -1619,7 +1506,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 者分を両方に数えることになり、担当同士の比較が成り立たない。 @@ -1769,104 +1671,6 @@ def cmd_abandon_items(args: argparse.Namespace) -> None: statefile.save(path, state) -def build_fix_merge_key(result: pathlib.Path, attempt: int) -> str: - """修正結果の冪等キーを試行番号と結果ファイル内容から作る。""" - return f"{attempt}:" + hashlib.sha256(result.read_bytes()).hexdigest() - - -def already_merged_fix(entry: dict[str, Any], merge_key: str) -> bool: - """同じ修正結果がすでに取り込み済みかを返す。""" - return merge_key in entry.setdefault("fix_merged_keys", []) - - -def resolve_claimed_threads(payload: dict[str, Any], state: dict[str, Any]) -> set[str]: - """自己申告された解決 thread を 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(state["repo"], state["current_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 collect_fix_range_report( - entry: dict[str, Any], - state: dict[str, Any], - payload: dict[str, Any], - work: str, - head_now: str, -) -> tuple[list[str], list[str], list[dict[str, Any]]]: - """修正範囲を確定し、申告外コミットと commit facts を返す。""" - baseline = state.get("baseline_test") or {} - ordered_range = commits_in_range(work, entry.get("fix_base_sha"), head_now) - if ordered_range is None: - die( - "修正の範囲を確定できませんでした" - f"(起点 {entry.get('fix_base_sha')} / HEAD {head_now})。" - "検証できない修正は採りません", - code=2, - ) - reported_shas = _reported_shas(payload) - reported_full = { - full for full in ( - _git_out(work, ["rev-parse", "--verify", f"{s}^{{commit}}"]) - for s in reported_shas - ) if full - } - unassigned = sorted(set(ordered_range) - reported_full) - facts = collect_commit_facts( - work, reported_shas, set(ordered_range), - baseline.get("command") or "true", state["head_branch"], - _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), - ) - return ordered_range, unassigned, facts - - -def evaluate_fix_commits( - facts: list[dict[str, Any]], - scope: list[str], -) -> tuple[list[str], list[tuple[str, str]]]: - """修正コミット facts を検証し、問題と採用コミットを返す。""" - 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 problems, accepted - - -def reject_fix_range( - path: pathlib.Path, - state: dict[str, Any], - entry: dict[str, Any], - ordered_range: list[str], -) -> None: - """検証を通らない修正範囲を取り消し、次回用の起点を保存する。""" - _revert_unverified_range( - state, entry, ordered_range, - "検証を通らない変更を残さないため、この修正ラウンドの範囲を取り消します", - path, - item_id=f"R{entry['round']}-fix{entry['fix_rounds'] + 1}", - ) - - def cmd_merge_fix(args: argparse.Namespace) -> None: """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" path, state = _load(args.id) @@ -1894,9 +1698,12 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: work = state["worktrees"]["work"] head_now = _git_out(work, ["rev-parse", "HEAD"]) or "" attempt = entry.get("fix_attempts", 0) - merge_key = build_fix_merge_key(result, attempt) + merge_key = ( + f"{attempt}:" + + hashlib.sha256(result.read_bytes()).hexdigest() + ) merged_keys = entry.setdefault("fix_merged_keys", []) - if already_merged_fix(entry, merge_key): + if merge_key in merged_keys: info( f"↻ この修正結果は取り込み済みです" f"(修正ラウンド {entry['fix_rounds']})" @@ -1906,25 +1713,71 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: # 自己申告をそのまま信じない。解決 API に失敗・未実行でも「解決済み」と # 書けてしまい、未解決の指摘が取り消し対象から外れる。GitHub 側の # `isResolved` と突き合わせ、**両方が解決と言っているものだけ**を反映する。 + raw_claimed = payload.get("resolved_thread_ids") # 文字列は 1 文字ずつに分解され、数値や真偽値は反復できずに落ちる。 # **配列であることを先に確かめる。** - resolved = resolve_claimed_threads(payload, state) + 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 と実際のテスト実行から**検証する。 # 結果ファイルの申告で済ませると、手順を満たさない変更が収束済みになれてしまう。 + baseline = state.get("baseline_test") or {} # 修正の範囲も**オーケストレータが記録した起点**から取る。起点は # `judge-review` が変更要求を返したときの HEAD である。 + ordered_range = commits_in_range(work, entry.get("fix_base_sha"), head_now) + if ordered_range is None: + die( + "修正の範囲を確定できませんでした" + f"(起点 {entry.get('fix_base_sha')} / HEAD {head_now})。" + "検証できない修正は採りません", + code=2, + ) + reported_shas = _reported_shas(payload) + # 適用と同じく、**範囲のコミットは全て申告されていること**を求める。 # 申告から漏れた修正コミットは検証を受けないまま Pull Request に残る。 - ordered_range, unassigned, facts = collect_fix_range_report( - entry, state, payload, work, head_now, + reported_full = { + full for full in ( + _git_out(work, ["rev-parse", "--verify", f"{s}^{{commit}}"]) + for s in reported_shas + ) if full + } + unassigned = sorted(set(ordered_range) - reported_full) + + facts = collect_commit_facts( + work, reported_shas, set(ordered_range), + baseline.get("command") or "true", state["head_branch"], + _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), ) # **不正なコミットが 1 件でもあれば、修正ラウンドの範囲ごと取り消す。** # 状態を記録しないだけでは、未検証の変更が Pull Request に残り続ける # (見送りの対象にもならない)。どのコミットが安全かは決められないので、 # 適用フェーズの未割当コミットと同じ扱いにする。 - problems, accepted = evaluate_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( @@ -1935,10 +1788,22 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: if unassigned or problems: # **状態へ記録する前に取り消す。** 先に記録すると、取り消し済みのコミットが # 状態ファイルに残り、後の見送り処理が同じコミットをもう一度取り消そうとする。 - reject_fix_range(path, state, entry, ordered_range) + info("検証を通らない変更を残さないため、この修正ラウンドの範囲を取り消します") + # **取り消しへ着手する前に印を立てる。** 取り消しは済んだのに push できずに + # 終わると、未検証の変更が Pull Request に残ったままになる。 + entry["pending_push"] = True + statefile.save(path, state) + _revert_item_commits( + state, + {"item_id": f"R{entry['round']}-fix{entry['fix_rounds'] + 1}", + "commits": list(ordered_range)}, + dry_run=False, + ) # 取り消し後の状態を新しい起点にし、**その場で保存する**。ここで保存せずに # 落ちると、次の実行は古い起点から範囲を取り直して取り消しコミット自体を # 「未申告」と判定し、**取り消しを取り消して**しまう。 + entry["fix_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) + statefile.save(path, state) # **push は保存のあと。** ここで push して失敗すると、取り消しコミットは # ローカルに残るのに起点の更新が保存されず、叩き直しで二重に取り消してしまう。 info("⚠ 修正を取り消したため、解決の申告は採用しません") 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 74583817..401a760f 100755 --- a/plugins/ndf-claude/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-claude/skills/cross-review/scripts/lib/monitor.py @@ -389,17 +389,6 @@ class AgentStatus: sentinel_seen: bool = False -@dataclass -class MonitorConfig: - timeout: int - stall_timeout: int - poll: int - require_result: bool - no_early_error: bool = False - log_prefix: str = "" - stem_template: str = DEFAULT_STEM_TEMPLATE - - # ---------- 監視ロジック ---------- def _read_pidfile(p: pathlib.Path) -> Optional[int]: @@ -503,8 +492,15 @@ def _scan_patterns( 扱いしてしまった (例: `Error in: mcpServers.serena\\n...\\nTraceback ...` で Traceback が誤抑制された)。 """ - data = _read_tail(path, 200 * 1024) - if data is 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 data = _strip_ansi(data) benign_patterns = EARLY_ERROR_BENIGN if benign is None else benign @@ -553,10 +549,16 @@ def _scan_claude_stdout_fatal(path: pathlib.Path) -> Optional[str]: 行うが、JSON は 1 行に多数の引用符を含むため、パリティ判定が「引用の内側」を 誤って真にして致命を取りこぼす。 """ - data = _read_tail(path, 200 * 1024) - if data is 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 = _strip_ansi(f.read().decode("utf-8", errors="replace")) + except OSError: return None - data = _strip_ansi(data) for pat in CLAUDE_STDOUT_FATAL: m = pat.search(data) if m: @@ -564,24 +566,17 @@ def _scan_claude_stdout_fatal(path: pathlib.Path) -> Optional[str]: return None -def _read_tail(path: pathlib.Path, limit: int) -> Optional[str]: - """ファイル末尾を安全に読み、壊れた UTF-8 は置換して返す。""" +def _scan_codex_sentinel(path: pathlib.Path) -> bool: if not path.exists(): - return None + return False try: + # 末尾 64KB を読む(sentinel は最後の方に出る) sz = path.stat().st_size with path.open("rb") as f: - if sz > limit: - f.seek(sz - limit) - return f.read().decode("utf-8", errors="replace") + if sz > 64 * 1024: + f.seek(sz - 64 * 1024) + tail = f.read().decode("utf-8", errors="replace") except OSError: - return None - - -def _scan_codex_sentinel(path: pathlib.Path) -> bool: - # 末尾 64KB を読む(sentinel は最後の方に出る) - tail = _read_tail(path, 64 * 1024) - if tail is None: return False return bool(CODEX_SENTINEL.search(tail)) @@ -599,10 +594,17 @@ def _tail_last_nonempty_line(path: pathlib.Path, limit: int = 4096) -> str: Gemini に書かせるのは短いフェーズマーカーだけなので、末尾数 KB で十分。 壊れた UTF-8 や読み取り競合があっても monitor 自体は落とさない。 """ - data = _read_tail(path, limit) - if data is None: + if not path.exists(): return "" - if _safe_size(path) > limit and "\n" in data: + try: + sz = path.stat().st_size + with path.open("rb") as f: + if sz > limit: + f.seek(sz - limit) + data = f.read().decode("utf-8", errors="replace") + except OSError: + return "" + if sz > limit and "\n" in data: data = data.split("\n", 1)[1] for line in reversed(data.splitlines()): stripped = line.strip() @@ -614,40 +616,19 @@ def _tail_last_nonempty_line(path: pathlib.Path, limit: int = 4096) -> str: def monitor_agent( agent: str, pr: int, - timeout: Optional[int] = None, - stall_timeout: Optional[int] = None, - poll: Optional[int] = None, - require_result: Optional[bool] = None, + timeout: int, + stall_timeout: int, + poll: int, + require_result: bool, no_early_error: bool = False, log_prefix: str = "", stem_template: str = DEFAULT_STEM_TEMPLATE, - config: Optional[MonitorConfig] = None, ) -> AgentStatus: """1 agent を監視する。 `no_early_error=True` のとき、EARLY_ERROR 検知 (FATAL/WARN とも) を完全に無効化し、 hard timeout / stall / sentinel / result.json のみで判定する。 """ - if config is None: - if timeout is None or stall_timeout is None or poll is None or require_result is None: - raise TypeError("timeout, stall_timeout, poll, require_result are required") - config = MonitorConfig( - timeout=timeout, - stall_timeout=stall_timeout, - poll=poll, - require_result=require_result, - no_early_error=no_early_error, - log_prefix=log_prefix, - stem_template=stem_template, - ) - timeout = config.timeout - stall_timeout = config.stall_timeout - poll = config.poll - require_result = config.require_result - no_early_error = config.no_early_error - log_prefix = config.log_prefix - stem_template = config.stem_template - paths = AgentPaths.for_(agent, pr, stem_template) status = AgentStatus(agent=agent) started = time.monotonic() @@ -931,15 +912,11 @@ def run(agent: str) -> None: else _agent_stall_default(agent) results[agent] = monitor_agent( agent=agent, pr=args.pr, - config=MonitorConfig( - timeout=args.timeout, - stall_timeout=stall, - poll=args.poll, - require_result=require_result, - no_early_error=args.no_early_error, - log_prefix=f"[{agent}] ", - stem_template=args.stem_template, - ), + timeout=args.timeout, stall_timeout=stall, + poll=args.poll, require_result=require_result, + no_early_error=args.no_early_error, + log_prefix=f"[{agent}] ", + stem_template=args.stem_template, ) threads = [threading.Thread(target=run, args=(a,), daemon=False) for a in agents] diff --git a/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py index b048cf04..1a224c66 100755 --- a/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py @@ -976,11 +976,30 @@ def cmd_start_round(args: argparse.Namespace) -> None: ) -def _collect_proposal_files( - state: dict[str, Any], - entry: dict[str, Any], -) -> dict[str, list[dict[str, Any]]]: - """各 runtime の提案結果ファイルを読み、runtime ごとの提案リストを返す。""" +def cmd_merge_proposals(args: argparse.Namespace) -> None: + """Step 3 — 提案をマージして改善項目を作る。 + + 終了コード: 0 = 採用あり / 2 = 採用 0 件(提案ラウンドの繰り返しを終える)。 + + **同じラウンドで叩き直しても二重に項目を作らない。** 進行を止めても再開できる + ことが前提なので、統合済みなら前回と同じ結果をそのまま返す。 + """ + path, state = _load(args.id) + entry = _current_round(state) + + if entry.get("proposal_keys") is not None: + info( + f"↻ 提案ラウンド {entry['round']} は統合済みです" + f"(採用 {entry.get('adopted', 0)} 件 / 見送り {entry.get('deferred', 0)} 件)" + ) + for item_id in entry.get("items", []): + item = _find_item(state, item_id, required=False) + if item is not None: + info(f" {item_id} [{item['severity']}] {item['path']}#{item['symbol']}") + if not entry.get("adopted"): + sys.exit(2) + return + proposals: dict[str, list[dict[str, Any]]] = {} for runtime in state["runtimes"]: result = _result_path( @@ -1009,34 +1028,6 @@ def _collect_proposal_files( 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 — 提案をマージして改善項目を作る。 - - 終了コード: 0 = 採用あり / 2 = 採用 0 件(提案ラウンドの繰り返しを終える)。 - - **同じラウンドで叩き直しても二重に項目を作らない。** 進行を止めても再開できる - ことが前提なので、統合済みなら前回と同じ結果をそのまま返す。 - """ - path, state = _load(args.id) - entry = _current_round(state) - - if entry.get("proposal_keys") is not None: - info( - f"↻ 提案ラウンド {entry['round']} は統合済みです" - f"(採用 {entry.get('adopted', 0)} 件 / 見送り {entry.get('deferred', 0)} 件)" - ) - for item_id in entry.get("items", []): - item = _find_item(state, item_id, required=False) - if item is not None: - info(f" {item_id} [{item['severity']}] {item['path']}#{item['symbol']}") - if not entry.get("adopted"): - sys.exit(2) - return - - proposals = _collect_proposal_files(state, entry) excluded = { (d["path"], d["symbol"], d["smell"]) for d in state["deferred_items"] @@ -1097,156 +1088,6 @@ def cmd_merge_proposals(args: argparse.Namespace) -> None: sys.exit(2) -def _verify_ownership( - entry: dict[str, Any], - reported: dict[str, dict[str, Any]], - ordered_range: list[str], - work: str, - state: dict[str, Any], -) -> Optional[str]: - """適用範囲の各コミットが、ちょうど 1 項目に所有されているか検証する。""" - in_range = set(ordered_range or []) - round_items = set(entry["items"]) - unknown_ids: list[str] = [] - raw_items = ((state.get("_merge_apply_payload") or {}).get("items")) - if not isinstance(raw_items, list): - raw_items = [] - for r in raw_items: - if not isinstance(r, dict): - continue - item_id = r.get("item_id") - if item_id is not None and item_id not in round_items: - unknown_ids.append(str(item_id)) - - 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)) - entry["_ownership_unassigned"] = unassigned - entry["_ownership_unknown_ids"] = unknown_ids - entry["_ownership_duplicated"] = duplicated - if not (unassigned or unknown_ids or duplicated): - return None - - 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])})" - ) - return ( - "、".join(causes) - + "。検証を回避した変更や、状態と実差分の食い違いを Pull Request に" - "残さないため、ラウンドごと取り消します" - ) - - -def _verify_and_classify_items( - entry: dict[str, Any], - state: dict[str, Any], - reported: dict[str, dict[str, Any]], - in_range: set[str], - test_command: str, - head_branch: str, - timeout: int, - scope: list[str], -) -> tuple[list[str], list[str]]: - """各改善項目のコミット facts を検証し、採用候補と失敗へ分類する。""" - applied: list[str] = [] - failed: list[str] = [] - work = state["worktrees"]["work"] - 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, 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 state.get("_merge_apply_dry_run"): - state_path = state.pop("_merge_apply_path") - dry_run = state.pop("_merge_apply_dry_run", None) - statefile.save(state_path, state) - state["_merge_apply_path"] = state_path - state["_merge_apply_dry_run"] = dry_run - return applied, failed - - -def _revert_unverified_range( - state: dict[str, Any], - entry: dict[str, Any], - ordered_range: list[str], - reason: str, - path: pathlib.Path, - *, - dry_run: bool = False, - base_key: str = "fix_base_sha", - item_id: Optional[str] = None, -) -> Optional[str]: - """検証できない範囲を取り消し、再実行用の起点 SHA を更新する。""" - if reason: - info(reason) - whole_range = { - "item_id": item_id or 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_range, dry_run) - new_base = None - if not dry_run: - new_base = _git_out(state["worktrees"]["work"], ["rev-parse", "HEAD"]) - entry[base_key] = new_base - statefile.save(path, state) - return new_base - - def cmd_merge_apply(args: argparse.Namespace) -> None: """Step 4 — 適用結果を検証して取り込む。 @@ -1322,6 +1163,7 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: # そのまま Pull Request に残せてしまう。 round_items = set(entry["items"]) reported: dict[str, dict[str, Any]] = {} + unknown_ids: list[str] = [] raw_items = payload.get("items") if not isinstance(raw_items, list): info(f"⚠ 適用結果の items が配列ではありません({type(raw_items).__name__})") @@ -1332,6 +1174,8 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: item_id = r.get("item_id") if item_id in round_items: reported[item_id] = r + elif item_id is not None: + unknown_ids.append(str(item_id)) # **範囲のコミットは全て、いずれかの改善項目に割り当てられていること。** # 申告から漏れたコミットはテストもトレーラーも差分予算も検査されず、そのまま @@ -1342,13 +1186,40 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: # # 判定は**完全な SHA へ正規化してから**行う。申告の文字列をそのまま鍵にすると、 # 一方が完全 SHA、他方が短縮 SHA で同じコミットを指したときに重複を見逃す。 - state["_merge_apply_payload"] = payload - reason = _verify_ownership(entry, reported, ordered_range, work, state) - state.pop("_merge_apply_payload", None) - unassigned = entry.pop("_ownership_unassigned", []) - unknown_ids = entry.pop("_ownership_unknown_ids", []) - duplicated = entry.pop("_ownership_duplicated", []) - if reason: + 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: + 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) @@ -1357,11 +1228,20 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: # 範囲全体を取り消す。どのコミットが安全かを決められない以上、 # 起点まで戻すのが最も確実である。順序は `_revert_item_commits` が # git の履歴から決め直す。 - _revert_unverified_range( - state, entry, ordered_range, "", - path, dry_run=args.dry_run, base_key="apply_base_sha", - item_id=f"R{entry['round']}-range", - ) + 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, @@ -1383,19 +1263,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 [] # **判定はその都度残す。** まとめて最後に保存すると、取り消しの途中で中断した # ときに適用の記録が一切残らず、どのコミットが検証を通ったのかを状態から # 復元できなくなる。再開可能性は収束ループの前提なので、ここが崩れると # 中断からの復帰手段が無くなる。 - state["_merge_apply_path"] = path - state["_merge_apply_dry_run"] = args.dry_run - applied, failed = _verify_and_classify_items( - entry, state, reported, in_range, test_command, head_branch, - _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), scope, - ) - state.pop("_merge_apply_path", None) - state.pop("_merge_apply_dry_run", None) + 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, @@ -1552,31 +1464,6 @@ def _resume_incomplete_apply( _flush_pending_push(path, state, entry) -def _build_review_record( - reviews: dict[str, dict[str, Any]], - reviewers: list[str], - round_no: int, -) -> dict[str, Any]: - """レビュー結果から state に保存する findings 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_judge_review(args: argparse.Namespace) -> None: """Step 5 — レビュー 2 者の判定を取り込む。 @@ -1619,7 +1506,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 者分を両方に数えることになり、担当同士の比較が成り立たない。 @@ -1769,104 +1671,6 @@ def cmd_abandon_items(args: argparse.Namespace) -> None: statefile.save(path, state) -def build_fix_merge_key(result: pathlib.Path, attempt: int) -> str: - """修正結果の冪等キーを試行番号と結果ファイル内容から作る。""" - return f"{attempt}:" + hashlib.sha256(result.read_bytes()).hexdigest() - - -def already_merged_fix(entry: dict[str, Any], merge_key: str) -> bool: - """同じ修正結果がすでに取り込み済みかを返す。""" - return merge_key in entry.setdefault("fix_merged_keys", []) - - -def resolve_claimed_threads(payload: dict[str, Any], state: dict[str, Any]) -> set[str]: - """自己申告された解決 thread を 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(state["repo"], state["current_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 collect_fix_range_report( - entry: dict[str, Any], - state: dict[str, Any], - payload: dict[str, Any], - work: str, - head_now: str, -) -> tuple[list[str], list[str], list[dict[str, Any]]]: - """修正範囲を確定し、申告外コミットと commit facts を返す。""" - baseline = state.get("baseline_test") or {} - ordered_range = commits_in_range(work, entry.get("fix_base_sha"), head_now) - if ordered_range is None: - die( - "修正の範囲を確定できませんでした" - f"(起点 {entry.get('fix_base_sha')} / HEAD {head_now})。" - "検証できない修正は採りません", - code=2, - ) - reported_shas = _reported_shas(payload) - reported_full = { - full for full in ( - _git_out(work, ["rev-parse", "--verify", f"{s}^{{commit}}"]) - for s in reported_shas - ) if full - } - unassigned = sorted(set(ordered_range) - reported_full) - facts = collect_commit_facts( - work, reported_shas, set(ordered_range), - baseline.get("command") or "true", state["head_branch"], - _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), - ) - return ordered_range, unassigned, facts - - -def evaluate_fix_commits( - facts: list[dict[str, Any]], - scope: list[str], -) -> tuple[list[str], list[tuple[str, str]]]: - """修正コミット facts を検証し、問題と採用コミットを返す。""" - 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 problems, accepted - - -def reject_fix_range( - path: pathlib.Path, - state: dict[str, Any], - entry: dict[str, Any], - ordered_range: list[str], -) -> None: - """検証を通らない修正範囲を取り消し、次回用の起点を保存する。""" - _revert_unverified_range( - state, entry, ordered_range, - "検証を通らない変更を残さないため、この修正ラウンドの範囲を取り消します", - path, - item_id=f"R{entry['round']}-fix{entry['fix_rounds'] + 1}", - ) - - def cmd_merge_fix(args: argparse.Namespace) -> None: """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" path, state = _load(args.id) @@ -1894,9 +1698,12 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: work = state["worktrees"]["work"] head_now = _git_out(work, ["rev-parse", "HEAD"]) or "" attempt = entry.get("fix_attempts", 0) - merge_key = build_fix_merge_key(result, attempt) + merge_key = ( + f"{attempt}:" + + hashlib.sha256(result.read_bytes()).hexdigest() + ) merged_keys = entry.setdefault("fix_merged_keys", []) - if already_merged_fix(entry, merge_key): + if merge_key in merged_keys: info( f"↻ この修正結果は取り込み済みです" f"(修正ラウンド {entry['fix_rounds']})" @@ -1906,25 +1713,71 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: # 自己申告をそのまま信じない。解決 API に失敗・未実行でも「解決済み」と # 書けてしまい、未解決の指摘が取り消し対象から外れる。GitHub 側の # `isResolved` と突き合わせ、**両方が解決と言っているものだけ**を反映する。 + raw_claimed = payload.get("resolved_thread_ids") # 文字列は 1 文字ずつに分解され、数値や真偽値は反復できずに落ちる。 # **配列であることを先に確かめる。** - resolved = resolve_claimed_threads(payload, state) + 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 と実際のテスト実行から**検証する。 # 結果ファイルの申告で済ませると、手順を満たさない変更が収束済みになれてしまう。 + baseline = state.get("baseline_test") or {} # 修正の範囲も**オーケストレータが記録した起点**から取る。起点は # `judge-review` が変更要求を返したときの HEAD である。 + ordered_range = commits_in_range(work, entry.get("fix_base_sha"), head_now) + if ordered_range is None: + die( + "修正の範囲を確定できませんでした" + f"(起点 {entry.get('fix_base_sha')} / HEAD {head_now})。" + "検証できない修正は採りません", + code=2, + ) + reported_shas = _reported_shas(payload) + # 適用と同じく、**範囲のコミットは全て申告されていること**を求める。 # 申告から漏れた修正コミットは検証を受けないまま Pull Request に残る。 - ordered_range, unassigned, facts = collect_fix_range_report( - entry, state, payload, work, head_now, + reported_full = { + full for full in ( + _git_out(work, ["rev-parse", "--verify", f"{s}^{{commit}}"]) + for s in reported_shas + ) if full + } + unassigned = sorted(set(ordered_range) - reported_full) + + facts = collect_commit_facts( + work, reported_shas, set(ordered_range), + baseline.get("command") or "true", state["head_branch"], + _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), ) # **不正なコミットが 1 件でもあれば、修正ラウンドの範囲ごと取り消す。** # 状態を記録しないだけでは、未検証の変更が Pull Request に残り続ける # (見送りの対象にもならない)。どのコミットが安全かは決められないので、 # 適用フェーズの未割当コミットと同じ扱いにする。 - problems, accepted = evaluate_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( @@ -1935,10 +1788,22 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: if unassigned or problems: # **状態へ記録する前に取り消す。** 先に記録すると、取り消し済みのコミットが # 状態ファイルに残り、後の見送り処理が同じコミットをもう一度取り消そうとする。 - reject_fix_range(path, state, entry, ordered_range) + info("検証を通らない変更を残さないため、この修正ラウンドの範囲を取り消します") + # **取り消しへ着手する前に印を立てる。** 取り消しは済んだのに push できずに + # 終わると、未検証の変更が Pull Request に残ったままになる。 + entry["pending_push"] = True + statefile.save(path, state) + _revert_item_commits( + state, + {"item_id": f"R{entry['round']}-fix{entry['fix_rounds'] + 1}", + "commits": list(ordered_range)}, + dry_run=False, + ) # 取り消し後の状態を新しい起点にし、**その場で保存する**。ここで保存せずに # 落ちると、次の実行は古い起点から範囲を取り直して取り消しコミット自体を # 「未申告」と判定し、**取り消しを取り消して**しまう。 + entry["fix_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) + statefile.save(path, state) # **push は保存のあと。** ここで push して失敗すると、取り消しコミットは # ローカルに残るのに起点の更新が保存されず、叩き直しで二重に取り消してしまう。 info("⚠ 修正を取り消したため、解決の申告は採用しません") 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 74583817..401a760f 100755 --- a/plugins/ndf-codex/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-codex/skills/cross-review/scripts/lib/monitor.py @@ -389,17 +389,6 @@ class AgentStatus: sentinel_seen: bool = False -@dataclass -class MonitorConfig: - timeout: int - stall_timeout: int - poll: int - require_result: bool - no_early_error: bool = False - log_prefix: str = "" - stem_template: str = DEFAULT_STEM_TEMPLATE - - # ---------- 監視ロジック ---------- def _read_pidfile(p: pathlib.Path) -> Optional[int]: @@ -503,8 +492,15 @@ def _scan_patterns( 扱いしてしまった (例: `Error in: mcpServers.serena\\n...\\nTraceback ...` で Traceback が誤抑制された)。 """ - data = _read_tail(path, 200 * 1024) - if data is 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 data = _strip_ansi(data) benign_patterns = EARLY_ERROR_BENIGN if benign is None else benign @@ -553,10 +549,16 @@ def _scan_claude_stdout_fatal(path: pathlib.Path) -> Optional[str]: 行うが、JSON は 1 行に多数の引用符を含むため、パリティ判定が「引用の内側」を 誤って真にして致命を取りこぼす。 """ - data = _read_tail(path, 200 * 1024) - if data is 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 = _strip_ansi(f.read().decode("utf-8", errors="replace")) + except OSError: return None - data = _strip_ansi(data) for pat in CLAUDE_STDOUT_FATAL: m = pat.search(data) if m: @@ -564,24 +566,17 @@ def _scan_claude_stdout_fatal(path: pathlib.Path) -> Optional[str]: return None -def _read_tail(path: pathlib.Path, limit: int) -> Optional[str]: - """ファイル末尾を安全に読み、壊れた UTF-8 は置換して返す。""" +def _scan_codex_sentinel(path: pathlib.Path) -> bool: if not path.exists(): - return None + return False try: + # 末尾 64KB を読む(sentinel は最後の方に出る) sz = path.stat().st_size with path.open("rb") as f: - if sz > limit: - f.seek(sz - limit) - return f.read().decode("utf-8", errors="replace") + if sz > 64 * 1024: + f.seek(sz - 64 * 1024) + tail = f.read().decode("utf-8", errors="replace") except OSError: - return None - - -def _scan_codex_sentinel(path: pathlib.Path) -> bool: - # 末尾 64KB を読む(sentinel は最後の方に出る) - tail = _read_tail(path, 64 * 1024) - if tail is None: return False return bool(CODEX_SENTINEL.search(tail)) @@ -599,10 +594,17 @@ def _tail_last_nonempty_line(path: pathlib.Path, limit: int = 4096) -> str: Gemini に書かせるのは短いフェーズマーカーだけなので、末尾数 KB で十分。 壊れた UTF-8 や読み取り競合があっても monitor 自体は落とさない。 """ - data = _read_tail(path, limit) - if data is None: + if not path.exists(): return "" - if _safe_size(path) > limit and "\n" in data: + try: + sz = path.stat().st_size + with path.open("rb") as f: + if sz > limit: + f.seek(sz - limit) + data = f.read().decode("utf-8", errors="replace") + except OSError: + return "" + if sz > limit and "\n" in data: data = data.split("\n", 1)[1] for line in reversed(data.splitlines()): stripped = line.strip() @@ -614,40 +616,19 @@ def _tail_last_nonempty_line(path: pathlib.Path, limit: int = 4096) -> str: def monitor_agent( agent: str, pr: int, - timeout: Optional[int] = None, - stall_timeout: Optional[int] = None, - poll: Optional[int] = None, - require_result: Optional[bool] = None, + timeout: int, + stall_timeout: int, + poll: int, + require_result: bool, no_early_error: bool = False, log_prefix: str = "", stem_template: str = DEFAULT_STEM_TEMPLATE, - config: Optional[MonitorConfig] = None, ) -> AgentStatus: """1 agent を監視する。 `no_early_error=True` のとき、EARLY_ERROR 検知 (FATAL/WARN とも) を完全に無効化し、 hard timeout / stall / sentinel / result.json のみで判定する。 """ - if config is None: - if timeout is None or stall_timeout is None or poll is None or require_result is None: - raise TypeError("timeout, stall_timeout, poll, require_result are required") - config = MonitorConfig( - timeout=timeout, - stall_timeout=stall_timeout, - poll=poll, - require_result=require_result, - no_early_error=no_early_error, - log_prefix=log_prefix, - stem_template=stem_template, - ) - timeout = config.timeout - stall_timeout = config.stall_timeout - poll = config.poll - require_result = config.require_result - no_early_error = config.no_early_error - log_prefix = config.log_prefix - stem_template = config.stem_template - paths = AgentPaths.for_(agent, pr, stem_template) status = AgentStatus(agent=agent) started = time.monotonic() @@ -931,15 +912,11 @@ def run(agent: str) -> None: else _agent_stall_default(agent) results[agent] = monitor_agent( agent=agent, pr=args.pr, - config=MonitorConfig( - timeout=args.timeout, - stall_timeout=stall, - poll=args.poll, - require_result=require_result, - no_early_error=args.no_early_error, - log_prefix=f"[{agent}] ", - stem_template=args.stem_template, - ), + timeout=args.timeout, stall_timeout=stall, + poll=args.poll, require_result=require_result, + no_early_error=args.no_early_error, + log_prefix=f"[{agent}] ", + stem_template=args.stem_template, ) threads = [threading.Thread(target=run, args=(a,), daemon=False) for a in agents] diff --git a/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py index b048cf04..1a224c66 100755 --- a/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py @@ -976,11 +976,30 @@ def cmd_start_round(args: argparse.Namespace) -> None: ) -def _collect_proposal_files( - state: dict[str, Any], - entry: dict[str, Any], -) -> dict[str, list[dict[str, Any]]]: - """各 runtime の提案結果ファイルを読み、runtime ごとの提案リストを返す。""" +def cmd_merge_proposals(args: argparse.Namespace) -> None: + """Step 3 — 提案をマージして改善項目を作る。 + + 終了コード: 0 = 採用あり / 2 = 採用 0 件(提案ラウンドの繰り返しを終える)。 + + **同じラウンドで叩き直しても二重に項目を作らない。** 進行を止めても再開できる + ことが前提なので、統合済みなら前回と同じ結果をそのまま返す。 + """ + path, state = _load(args.id) + entry = _current_round(state) + + if entry.get("proposal_keys") is not None: + info( + f"↻ 提案ラウンド {entry['round']} は統合済みです" + f"(採用 {entry.get('adopted', 0)} 件 / 見送り {entry.get('deferred', 0)} 件)" + ) + for item_id in entry.get("items", []): + item = _find_item(state, item_id, required=False) + if item is not None: + info(f" {item_id} [{item['severity']}] {item['path']}#{item['symbol']}") + if not entry.get("adopted"): + sys.exit(2) + return + proposals: dict[str, list[dict[str, Any]]] = {} for runtime in state["runtimes"]: result = _result_path( @@ -1009,34 +1028,6 @@ def _collect_proposal_files( 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 — 提案をマージして改善項目を作る。 - - 終了コード: 0 = 採用あり / 2 = 採用 0 件(提案ラウンドの繰り返しを終える)。 - - **同じラウンドで叩き直しても二重に項目を作らない。** 進行を止めても再開できる - ことが前提なので、統合済みなら前回と同じ結果をそのまま返す。 - """ - path, state = _load(args.id) - entry = _current_round(state) - - if entry.get("proposal_keys") is not None: - info( - f"↻ 提案ラウンド {entry['round']} は統合済みです" - f"(採用 {entry.get('adopted', 0)} 件 / 見送り {entry.get('deferred', 0)} 件)" - ) - for item_id in entry.get("items", []): - item = _find_item(state, item_id, required=False) - if item is not None: - info(f" {item_id} [{item['severity']}] {item['path']}#{item['symbol']}") - if not entry.get("adopted"): - sys.exit(2) - return - - proposals = _collect_proposal_files(state, entry) excluded = { (d["path"], d["symbol"], d["smell"]) for d in state["deferred_items"] @@ -1097,156 +1088,6 @@ def cmd_merge_proposals(args: argparse.Namespace) -> None: sys.exit(2) -def _verify_ownership( - entry: dict[str, Any], - reported: dict[str, dict[str, Any]], - ordered_range: list[str], - work: str, - state: dict[str, Any], -) -> Optional[str]: - """適用範囲の各コミットが、ちょうど 1 項目に所有されているか検証する。""" - in_range = set(ordered_range or []) - round_items = set(entry["items"]) - unknown_ids: list[str] = [] - raw_items = ((state.get("_merge_apply_payload") or {}).get("items")) - if not isinstance(raw_items, list): - raw_items = [] - for r in raw_items: - if not isinstance(r, dict): - continue - item_id = r.get("item_id") - if item_id is not None and item_id not in round_items: - unknown_ids.append(str(item_id)) - - 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)) - entry["_ownership_unassigned"] = unassigned - entry["_ownership_unknown_ids"] = unknown_ids - entry["_ownership_duplicated"] = duplicated - if not (unassigned or unknown_ids or duplicated): - return None - - 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])})" - ) - return ( - "、".join(causes) - + "。検証を回避した変更や、状態と実差分の食い違いを Pull Request に" - "残さないため、ラウンドごと取り消します" - ) - - -def _verify_and_classify_items( - entry: dict[str, Any], - state: dict[str, Any], - reported: dict[str, dict[str, Any]], - in_range: set[str], - test_command: str, - head_branch: str, - timeout: int, - scope: list[str], -) -> tuple[list[str], list[str]]: - """各改善項目のコミット facts を検証し、採用候補と失敗へ分類する。""" - applied: list[str] = [] - failed: list[str] = [] - work = state["worktrees"]["work"] - 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, 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 state.get("_merge_apply_dry_run"): - state_path = state.pop("_merge_apply_path") - dry_run = state.pop("_merge_apply_dry_run", None) - statefile.save(state_path, state) - state["_merge_apply_path"] = state_path - state["_merge_apply_dry_run"] = dry_run - return applied, failed - - -def _revert_unverified_range( - state: dict[str, Any], - entry: dict[str, Any], - ordered_range: list[str], - reason: str, - path: pathlib.Path, - *, - dry_run: bool = False, - base_key: str = "fix_base_sha", - item_id: Optional[str] = None, -) -> Optional[str]: - """検証できない範囲を取り消し、再実行用の起点 SHA を更新する。""" - if reason: - info(reason) - whole_range = { - "item_id": item_id or 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_range, dry_run) - new_base = None - if not dry_run: - new_base = _git_out(state["worktrees"]["work"], ["rev-parse", "HEAD"]) - entry[base_key] = new_base - statefile.save(path, state) - return new_base - - def cmd_merge_apply(args: argparse.Namespace) -> None: """Step 4 — 適用結果を検証して取り込む。 @@ -1322,6 +1163,7 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: # そのまま Pull Request に残せてしまう。 round_items = set(entry["items"]) reported: dict[str, dict[str, Any]] = {} + unknown_ids: list[str] = [] raw_items = payload.get("items") if not isinstance(raw_items, list): info(f"⚠ 適用結果の items が配列ではありません({type(raw_items).__name__})") @@ -1332,6 +1174,8 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: item_id = r.get("item_id") if item_id in round_items: reported[item_id] = r + elif item_id is not None: + unknown_ids.append(str(item_id)) # **範囲のコミットは全て、いずれかの改善項目に割り当てられていること。** # 申告から漏れたコミットはテストもトレーラーも差分予算も検査されず、そのまま @@ -1342,13 +1186,40 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: # # 判定は**完全な SHA へ正規化してから**行う。申告の文字列をそのまま鍵にすると、 # 一方が完全 SHA、他方が短縮 SHA で同じコミットを指したときに重複を見逃す。 - state["_merge_apply_payload"] = payload - reason = _verify_ownership(entry, reported, ordered_range, work, state) - state.pop("_merge_apply_payload", None) - unassigned = entry.pop("_ownership_unassigned", []) - unknown_ids = entry.pop("_ownership_unknown_ids", []) - duplicated = entry.pop("_ownership_duplicated", []) - if reason: + 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: + 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) @@ -1357,11 +1228,20 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: # 範囲全体を取り消す。どのコミットが安全かを決められない以上、 # 起点まで戻すのが最も確実である。順序は `_revert_item_commits` が # git の履歴から決め直す。 - _revert_unverified_range( - state, entry, ordered_range, "", - path, dry_run=args.dry_run, base_key="apply_base_sha", - item_id=f"R{entry['round']}-range", - ) + 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, @@ -1383,19 +1263,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 [] # **判定はその都度残す。** まとめて最後に保存すると、取り消しの途中で中断した # ときに適用の記録が一切残らず、どのコミットが検証を通ったのかを状態から # 復元できなくなる。再開可能性は収束ループの前提なので、ここが崩れると # 中断からの復帰手段が無くなる。 - state["_merge_apply_path"] = path - state["_merge_apply_dry_run"] = args.dry_run - applied, failed = _verify_and_classify_items( - entry, state, reported, in_range, test_command, head_branch, - _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), scope, - ) - state.pop("_merge_apply_path", None) - state.pop("_merge_apply_dry_run", None) + 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, @@ -1552,31 +1464,6 @@ def _resume_incomplete_apply( _flush_pending_push(path, state, entry) -def _build_review_record( - reviews: dict[str, dict[str, Any]], - reviewers: list[str], - round_no: int, -) -> dict[str, Any]: - """レビュー結果から state に保存する findings 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_judge_review(args: argparse.Namespace) -> None: """Step 5 — レビュー 2 者の判定を取り込む。 @@ -1619,7 +1506,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 者分を両方に数えることになり、担当同士の比較が成り立たない。 @@ -1769,104 +1671,6 @@ def cmd_abandon_items(args: argparse.Namespace) -> None: statefile.save(path, state) -def build_fix_merge_key(result: pathlib.Path, attempt: int) -> str: - """修正結果の冪等キーを試行番号と結果ファイル内容から作る。""" - return f"{attempt}:" + hashlib.sha256(result.read_bytes()).hexdigest() - - -def already_merged_fix(entry: dict[str, Any], merge_key: str) -> bool: - """同じ修正結果がすでに取り込み済みかを返す。""" - return merge_key in entry.setdefault("fix_merged_keys", []) - - -def resolve_claimed_threads(payload: dict[str, Any], state: dict[str, Any]) -> set[str]: - """自己申告された解決 thread を 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(state["repo"], state["current_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 collect_fix_range_report( - entry: dict[str, Any], - state: dict[str, Any], - payload: dict[str, Any], - work: str, - head_now: str, -) -> tuple[list[str], list[str], list[dict[str, Any]]]: - """修正範囲を確定し、申告外コミットと commit facts を返す。""" - baseline = state.get("baseline_test") or {} - ordered_range = commits_in_range(work, entry.get("fix_base_sha"), head_now) - if ordered_range is None: - die( - "修正の範囲を確定できませんでした" - f"(起点 {entry.get('fix_base_sha')} / HEAD {head_now})。" - "検証できない修正は採りません", - code=2, - ) - reported_shas = _reported_shas(payload) - reported_full = { - full for full in ( - _git_out(work, ["rev-parse", "--verify", f"{s}^{{commit}}"]) - for s in reported_shas - ) if full - } - unassigned = sorted(set(ordered_range) - reported_full) - facts = collect_commit_facts( - work, reported_shas, set(ordered_range), - baseline.get("command") or "true", state["head_branch"], - _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), - ) - return ordered_range, unassigned, facts - - -def evaluate_fix_commits( - facts: list[dict[str, Any]], - scope: list[str], -) -> tuple[list[str], list[tuple[str, str]]]: - """修正コミット facts を検証し、問題と採用コミットを返す。""" - 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 problems, accepted - - -def reject_fix_range( - path: pathlib.Path, - state: dict[str, Any], - entry: dict[str, Any], - ordered_range: list[str], -) -> None: - """検証を通らない修正範囲を取り消し、次回用の起点を保存する。""" - _revert_unverified_range( - state, entry, ordered_range, - "検証を通らない変更を残さないため、この修正ラウンドの範囲を取り消します", - path, - item_id=f"R{entry['round']}-fix{entry['fix_rounds'] + 1}", - ) - - def cmd_merge_fix(args: argparse.Namespace) -> None: """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" path, state = _load(args.id) @@ -1894,9 +1698,12 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: work = state["worktrees"]["work"] head_now = _git_out(work, ["rev-parse", "HEAD"]) or "" attempt = entry.get("fix_attempts", 0) - merge_key = build_fix_merge_key(result, attempt) + merge_key = ( + f"{attempt}:" + + hashlib.sha256(result.read_bytes()).hexdigest() + ) merged_keys = entry.setdefault("fix_merged_keys", []) - if already_merged_fix(entry, merge_key): + if merge_key in merged_keys: info( f"↻ この修正結果は取り込み済みです" f"(修正ラウンド {entry['fix_rounds']})" @@ -1906,25 +1713,71 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: # 自己申告をそのまま信じない。解決 API に失敗・未実行でも「解決済み」と # 書けてしまい、未解決の指摘が取り消し対象から外れる。GitHub 側の # `isResolved` と突き合わせ、**両方が解決と言っているものだけ**を反映する。 + raw_claimed = payload.get("resolved_thread_ids") # 文字列は 1 文字ずつに分解され、数値や真偽値は反復できずに落ちる。 # **配列であることを先に確かめる。** - resolved = resolve_claimed_threads(payload, state) + 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 と実際のテスト実行から**検証する。 # 結果ファイルの申告で済ませると、手順を満たさない変更が収束済みになれてしまう。 + baseline = state.get("baseline_test") or {} # 修正の範囲も**オーケストレータが記録した起点**から取る。起点は # `judge-review` が変更要求を返したときの HEAD である。 + ordered_range = commits_in_range(work, entry.get("fix_base_sha"), head_now) + if ordered_range is None: + die( + "修正の範囲を確定できませんでした" + f"(起点 {entry.get('fix_base_sha')} / HEAD {head_now})。" + "検証できない修正は採りません", + code=2, + ) + reported_shas = _reported_shas(payload) + # 適用と同じく、**範囲のコミットは全て申告されていること**を求める。 # 申告から漏れた修正コミットは検証を受けないまま Pull Request に残る。 - ordered_range, unassigned, facts = collect_fix_range_report( - entry, state, payload, work, head_now, + reported_full = { + full for full in ( + _git_out(work, ["rev-parse", "--verify", f"{s}^{{commit}}"]) + for s in reported_shas + ) if full + } + unassigned = sorted(set(ordered_range) - reported_full) + + facts = collect_commit_facts( + work, reported_shas, set(ordered_range), + baseline.get("command") or "true", state["head_branch"], + _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), ) # **不正なコミットが 1 件でもあれば、修正ラウンドの範囲ごと取り消す。** # 状態を記録しないだけでは、未検証の変更が Pull Request に残り続ける # (見送りの対象にもならない)。どのコミットが安全かは決められないので、 # 適用フェーズの未割当コミットと同じ扱いにする。 - problems, accepted = evaluate_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( @@ -1935,10 +1788,22 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: if unassigned or problems: # **状態へ記録する前に取り消す。** 先に記録すると、取り消し済みのコミットが # 状態ファイルに残り、後の見送り処理が同じコミットをもう一度取り消そうとする。 - reject_fix_range(path, state, entry, ordered_range) + info("検証を通らない変更を残さないため、この修正ラウンドの範囲を取り消します") + # **取り消しへ着手する前に印を立てる。** 取り消しは済んだのに push できずに + # 終わると、未検証の変更が Pull Request に残ったままになる。 + entry["pending_push"] = True + statefile.save(path, state) + _revert_item_commits( + state, + {"item_id": f"R{entry['round']}-fix{entry['fix_rounds'] + 1}", + "commits": list(ordered_range)}, + dry_run=False, + ) # 取り消し後の状態を新しい起点にし、**その場で保存する**。ここで保存せずに # 落ちると、次の実行は古い起点から範囲を取り直して取り消しコミット自体を # 「未申告」と判定し、**取り消しを取り消して**しまう。 + entry["fix_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) + statefile.save(path, state) # **push は保存のあと。** ここで push して失敗すると、取り消しコミットは # ローカルに残るのに起点の更新が保存されず、叩き直しで二重に取り消してしまう。 info("⚠ 修正を取り消したため、解決の申告は採用しません") 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 74583817..401a760f 100755 --- a/plugins/ndf-kiro/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-kiro/skills/cross-review/scripts/lib/monitor.py @@ -389,17 +389,6 @@ class AgentStatus: sentinel_seen: bool = False -@dataclass -class MonitorConfig: - timeout: int - stall_timeout: int - poll: int - require_result: bool - no_early_error: bool = False - log_prefix: str = "" - stem_template: str = DEFAULT_STEM_TEMPLATE - - # ---------- 監視ロジック ---------- def _read_pidfile(p: pathlib.Path) -> Optional[int]: @@ -503,8 +492,15 @@ def _scan_patterns( 扱いしてしまった (例: `Error in: mcpServers.serena\\n...\\nTraceback ...` で Traceback が誤抑制された)。 """ - data = _read_tail(path, 200 * 1024) - if data is 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 data = _strip_ansi(data) benign_patterns = EARLY_ERROR_BENIGN if benign is None else benign @@ -553,10 +549,16 @@ def _scan_claude_stdout_fatal(path: pathlib.Path) -> Optional[str]: 行うが、JSON は 1 行に多数の引用符を含むため、パリティ判定が「引用の内側」を 誤って真にして致命を取りこぼす。 """ - data = _read_tail(path, 200 * 1024) - if data is 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 = _strip_ansi(f.read().decode("utf-8", errors="replace")) + except OSError: return None - data = _strip_ansi(data) for pat in CLAUDE_STDOUT_FATAL: m = pat.search(data) if m: @@ -564,24 +566,17 @@ def _scan_claude_stdout_fatal(path: pathlib.Path) -> Optional[str]: return None -def _read_tail(path: pathlib.Path, limit: int) -> Optional[str]: - """ファイル末尾を安全に読み、壊れた UTF-8 は置換して返す。""" +def _scan_codex_sentinel(path: pathlib.Path) -> bool: if not path.exists(): - return None + return False try: + # 末尾 64KB を読む(sentinel は最後の方に出る) sz = path.stat().st_size with path.open("rb") as f: - if sz > limit: - f.seek(sz - limit) - return f.read().decode("utf-8", errors="replace") + if sz > 64 * 1024: + f.seek(sz - 64 * 1024) + tail = f.read().decode("utf-8", errors="replace") except OSError: - return None - - -def _scan_codex_sentinel(path: pathlib.Path) -> bool: - # 末尾 64KB を読む(sentinel は最後の方に出る) - tail = _read_tail(path, 64 * 1024) - if tail is None: return False return bool(CODEX_SENTINEL.search(tail)) @@ -599,10 +594,17 @@ def _tail_last_nonempty_line(path: pathlib.Path, limit: int = 4096) -> str: Gemini に書かせるのは短いフェーズマーカーだけなので、末尾数 KB で十分。 壊れた UTF-8 や読み取り競合があっても monitor 自体は落とさない。 """ - data = _read_tail(path, limit) - if data is None: + if not path.exists(): return "" - if _safe_size(path) > limit and "\n" in data: + try: + sz = path.stat().st_size + with path.open("rb") as f: + if sz > limit: + f.seek(sz - limit) + data = f.read().decode("utf-8", errors="replace") + except OSError: + return "" + if sz > limit and "\n" in data: data = data.split("\n", 1)[1] for line in reversed(data.splitlines()): stripped = line.strip() @@ -614,40 +616,19 @@ def _tail_last_nonempty_line(path: pathlib.Path, limit: int = 4096) -> str: def monitor_agent( agent: str, pr: int, - timeout: Optional[int] = None, - stall_timeout: Optional[int] = None, - poll: Optional[int] = None, - require_result: Optional[bool] = None, + timeout: int, + stall_timeout: int, + poll: int, + require_result: bool, no_early_error: bool = False, log_prefix: str = "", stem_template: str = DEFAULT_STEM_TEMPLATE, - config: Optional[MonitorConfig] = None, ) -> AgentStatus: """1 agent を監視する。 `no_early_error=True` のとき、EARLY_ERROR 検知 (FATAL/WARN とも) を完全に無効化し、 hard timeout / stall / sentinel / result.json のみで判定する。 """ - if config is None: - if timeout is None or stall_timeout is None or poll is None or require_result is None: - raise TypeError("timeout, stall_timeout, poll, require_result are required") - config = MonitorConfig( - timeout=timeout, - stall_timeout=stall_timeout, - poll=poll, - require_result=require_result, - no_early_error=no_early_error, - log_prefix=log_prefix, - stem_template=stem_template, - ) - timeout = config.timeout - stall_timeout = config.stall_timeout - poll = config.poll - require_result = config.require_result - no_early_error = config.no_early_error - log_prefix = config.log_prefix - stem_template = config.stem_template - paths = AgentPaths.for_(agent, pr, stem_template) status = AgentStatus(agent=agent) started = time.monotonic() @@ -931,15 +912,11 @@ def run(agent: str) -> None: else _agent_stall_default(agent) results[agent] = monitor_agent( agent=agent, pr=args.pr, - config=MonitorConfig( - timeout=args.timeout, - stall_timeout=stall, - poll=args.poll, - require_result=require_result, - no_early_error=args.no_early_error, - log_prefix=f"[{agent}] ", - stem_template=args.stem_template, - ), + timeout=args.timeout, stall_timeout=stall, + poll=args.poll, require_result=require_result, + no_early_error=args.no_early_error, + log_prefix=f"[{agent}] ", + stem_template=args.stem_template, ) threads = [threading.Thread(target=run, args=(a,), daemon=False) for a in agents] From 6e654d4ef6cd135b38145a9e50388ba362a496fa Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 13:11:30 +0000 Subject: [PATCH 50/95] =?UTF-8?q?Revert=20"Refactor:=20consolidate=5Fdupli?= =?UTF-8?q?cation=20=E2=80=94=20plugins/ndf-shared/skills/cross-review/scr?= =?UTF-8?q?ipts/lib/monitor.py#=5Fscan=5Fpatterns"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit db4866ec6573510b84ff01efc33450d92bf8d381. --- .../cross-review/scripts/lib/monitor.py | 55 ++++++++++++------- 1 file changed, 34 insertions(+), 21 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 74583817..67d1b796 100755 --- a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py @@ -503,8 +503,15 @@ def _scan_patterns( 扱いしてしまった (例: `Error in: mcpServers.serena\\n...\\nTraceback ...` で Traceback が誤抑制された)。 """ - data = _read_tail(path, 200 * 1024) - if data is 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 data = _strip_ansi(data) benign_patterns = EARLY_ERROR_BENIGN if benign is None else benign @@ -553,10 +560,16 @@ def _scan_claude_stdout_fatal(path: pathlib.Path) -> Optional[str]: 行うが、JSON は 1 行に多数の引用符を含むため、パリティ判定が「引用の内側」を 誤って真にして致命を取りこぼす。 """ - data = _read_tail(path, 200 * 1024) - if data is 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 = _strip_ansi(f.read().decode("utf-8", errors="replace")) + except OSError: return None - data = _strip_ansi(data) for pat in CLAUDE_STDOUT_FATAL: m = pat.search(data) if m: @@ -564,24 +577,17 @@ def _scan_claude_stdout_fatal(path: pathlib.Path) -> Optional[str]: return None -def _read_tail(path: pathlib.Path, limit: int) -> Optional[str]: - """ファイル末尾を安全に読み、壊れた UTF-8 は置換して返す。""" +def _scan_codex_sentinel(path: pathlib.Path) -> bool: if not path.exists(): - return None + return False try: + # 末尾 64KB を読む(sentinel は最後の方に出る) sz = path.stat().st_size with path.open("rb") as f: - if sz > limit: - f.seek(sz - limit) - return f.read().decode("utf-8", errors="replace") + if sz > 64 * 1024: + f.seek(sz - 64 * 1024) + tail = f.read().decode("utf-8", errors="replace") except OSError: - return None - - -def _scan_codex_sentinel(path: pathlib.Path) -> bool: - # 末尾 64KB を読む(sentinel は最後の方に出る) - tail = _read_tail(path, 64 * 1024) - if tail is None: return False return bool(CODEX_SENTINEL.search(tail)) @@ -599,10 +605,17 @@ def _tail_last_nonempty_line(path: pathlib.Path, limit: int = 4096) -> str: Gemini に書かせるのは短いフェーズマーカーだけなので、末尾数 KB で十分。 壊れた UTF-8 や読み取り競合があっても monitor 自体は落とさない。 """ - data = _read_tail(path, limit) - if data is None: + if not path.exists(): + return "" + try: + sz = path.stat().st_size + with path.open("rb") as f: + if sz > limit: + f.seek(sz - limit) + data = f.read().decode("utf-8", errors="replace") + except OSError: return "" - if _safe_size(path) > limit and "\n" in data: + if sz > limit and "\n" in data: data = data.split("\n", 1)[1] for line in reversed(data.splitlines()): stripped = line.strip() From 62f66f04ff3a60dc1e6063702c90608f77387c16 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 13:11:30 +0000 Subject: [PATCH 51/95] =?UTF-8?q?Revert=20"Refactor:=20introduce=5Fparamet?= =?UTF-8?q?er=5Fobject=20=E2=80=94=20plugins/ndf-shared/skills/cross-revie?= =?UTF-8?q?w/scripts/lib/monitor.py#monitor=5Fagent"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 131f9d39cb1af6bdc2f54f5fafbb3794ed6e1d9b. --- .../cross-review/scripts/lib/monitor.py | 54 ++++--------------- 1 file changed, 9 insertions(+), 45 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 67d1b796..401a760f 100755 --- a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py @@ -389,17 +389,6 @@ class AgentStatus: sentinel_seen: bool = False -@dataclass -class MonitorConfig: - timeout: int - stall_timeout: int - poll: int - require_result: bool - no_early_error: bool = False - log_prefix: str = "" - stem_template: str = DEFAULT_STEM_TEMPLATE - - # ---------- 監視ロジック ---------- def _read_pidfile(p: pathlib.Path) -> Optional[int]: @@ -627,40 +616,19 @@ def _tail_last_nonempty_line(path: pathlib.Path, limit: int = 4096) -> str: def monitor_agent( agent: str, pr: int, - timeout: Optional[int] = None, - stall_timeout: Optional[int] = None, - poll: Optional[int] = None, - require_result: Optional[bool] = None, + timeout: int, + stall_timeout: int, + poll: int, + require_result: bool, no_early_error: bool = False, log_prefix: str = "", stem_template: str = DEFAULT_STEM_TEMPLATE, - config: Optional[MonitorConfig] = None, ) -> AgentStatus: """1 agent を監視する。 `no_early_error=True` のとき、EARLY_ERROR 検知 (FATAL/WARN とも) を完全に無効化し、 hard timeout / stall / sentinel / result.json のみで判定する。 """ - if config is None: - if timeout is None or stall_timeout is None or poll is None or require_result is None: - raise TypeError("timeout, stall_timeout, poll, require_result are required") - config = MonitorConfig( - timeout=timeout, - stall_timeout=stall_timeout, - poll=poll, - require_result=require_result, - no_early_error=no_early_error, - log_prefix=log_prefix, - stem_template=stem_template, - ) - timeout = config.timeout - stall_timeout = config.stall_timeout - poll = config.poll - require_result = config.require_result - no_early_error = config.no_early_error - log_prefix = config.log_prefix - stem_template = config.stem_template - paths = AgentPaths.for_(agent, pr, stem_template) status = AgentStatus(agent=agent) started = time.monotonic() @@ -944,15 +912,11 @@ def run(agent: str) -> None: else _agent_stall_default(agent) results[agent] = monitor_agent( agent=agent, pr=args.pr, - config=MonitorConfig( - timeout=args.timeout, - stall_timeout=stall, - poll=args.poll, - require_result=require_result, - no_early_error=args.no_early_error, - log_prefix=f"[{agent}] ", - stem_template=args.stem_template, - ), + timeout=args.timeout, stall_timeout=stall, + poll=args.poll, require_result=require_result, + no_early_error=args.no_early_error, + log_prefix=f"[{agent}] ", + stem_template=args.stem_template, ) threads = [threading.Thread(target=run, args=(a,), daemon=False) for a in agents] From 7517a936331ac03f3c094e7f8f9576568b60a940 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 13:11:30 +0000 Subject: [PATCH 52/95] =?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=5Fproposals"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit bd1eb7676aca0fefa58fc9bbfa194b3d17834f38. --- .../cross-refactoring/scripts/refactor.py | 57 ++++++++----------- 1 file changed, 24 insertions(+), 33 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index b048cf04..b9be964c 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -976,11 +976,30 @@ def cmd_start_round(args: argparse.Namespace) -> None: ) -def _collect_proposal_files( - state: dict[str, Any], - entry: dict[str, Any], -) -> dict[str, list[dict[str, Any]]]: - """各 runtime の提案結果ファイルを読み、runtime ごとの提案リストを返す。""" +def cmd_merge_proposals(args: argparse.Namespace) -> None: + """Step 3 — 提案をマージして改善項目を作る。 + + 終了コード: 0 = 採用あり / 2 = 採用 0 件(提案ラウンドの繰り返しを終える)。 + + **同じラウンドで叩き直しても二重に項目を作らない。** 進行を止めても再開できる + ことが前提なので、統合済みなら前回と同じ結果をそのまま返す。 + """ + path, state = _load(args.id) + entry = _current_round(state) + + if entry.get("proposal_keys") is not None: + info( + f"↻ 提案ラウンド {entry['round']} は統合済みです" + f"(採用 {entry.get('adopted', 0)} 件 / 見送り {entry.get('deferred', 0)} 件)" + ) + for item_id in entry.get("items", []): + item = _find_item(state, item_id, required=False) + if item is not None: + info(f" {item_id} [{item['severity']}] {item['path']}#{item['symbol']}") + if not entry.get("adopted"): + sys.exit(2) + return + proposals: dict[str, list[dict[str, Any]]] = {} for runtime in state["runtimes"]: result = _result_path( @@ -1009,34 +1028,6 @@ def _collect_proposal_files( 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 — 提案をマージして改善項目を作る。 - - 終了コード: 0 = 採用あり / 2 = 採用 0 件(提案ラウンドの繰り返しを終える)。 - - **同じラウンドで叩き直しても二重に項目を作らない。** 進行を止めても再開できる - ことが前提なので、統合済みなら前回と同じ結果をそのまま返す。 - """ - path, state = _load(args.id) - entry = _current_round(state) - - if entry.get("proposal_keys") is not None: - info( - f"↻ 提案ラウンド {entry['round']} は統合済みです" - f"(採用 {entry.get('adopted', 0)} 件 / 見送り {entry.get('deferred', 0)} 件)" - ) - for item_id in entry.get("items", []): - item = _find_item(state, item_id, required=False) - if item is not None: - info(f" {item_id} [{item['severity']}] {item['path']}#{item['symbol']}") - if not entry.get("adopted"): - sys.exit(2) - return - - proposals = _collect_proposal_files(state, entry) excluded = { (d["path"], d["symbol"], d["smell"]) for d in state["deferred_items"] From 5adf6f46f92944dbbe517f495266801818f64cfa Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 13:11:30 +0000 Subject: [PATCH 53/95] =?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 428df3a43e2616847c5a1364d74fb9f41d978752. --- .../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 b9be964c..fdfd6eae 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1543,31 +1543,6 @@ def _resume_incomplete_apply( _flush_pending_push(path, state, entry) -def _build_review_record( - reviews: dict[str, dict[str, Any]], - reviewers: list[str], - round_no: int, -) -> dict[str, Any]: - """レビュー結果から state に保存する findings 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_judge_review(args: argparse.Namespace) -> None: """Step 5 — レビュー 2 者の判定を取り込む。 @@ -1610,7 +1585,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 ead1b871ae4deb172f0662598fa31efece20287c Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 13:11:30 +0000 Subject: [PATCH 54/95] =?UTF-8?q?Revert=20"Refactor:=20split=5Finto=5Fpipe?= =?UTF-8?q?line=20=E2=80=94=20plugins/ndf-shared/skills/cross-refactoring/?= =?UTF-8?q?scripts/refactor.py#cmd=5Fmerge=5Ffix"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 0ba790112189fc6d62ffdd150321bbbe42c213ee. --- .../cross-refactoring/scripts/refactor.py | 22 +++++-------------- 1 file changed, 6 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 fdfd6eae..0a2334cd 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1833,21 +1833,6 @@ def evaluate_fix_commits( return problems, accepted -def reject_fix_range( - path: pathlib.Path, - state: dict[str, Any], - entry: dict[str, Any], - ordered_range: list[str], -) -> None: - """検証を通らない修正範囲を取り消し、次回用の起点を保存する。""" - _revert_unverified_range( - state, entry, ordered_range, - "検証を通らない変更を残さないため、この修正ラウンドの範囲を取り消します", - path, - item_id=f"R{entry['round']}-fix{entry['fix_rounds'] + 1}", - ) - - def cmd_merge_fix(args: argparse.Namespace) -> None: """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" path, state = _load(args.id) @@ -1916,7 +1901,12 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: if unassigned or problems: # **状態へ記録する前に取り消す。** 先に記録すると、取り消し済みのコミットが # 状態ファイルに残り、後の見送り処理が同じコミットをもう一度取り消そうとする。 - reject_fix_range(path, state, entry, ordered_range) + _revert_unverified_range( + state, entry, ordered_range, + "検証を通らない変更を残さないため、この修正ラウンドの範囲を取り消します", + path, + item_id=f"R{entry['round']}-fix{entry['fix_rounds'] + 1}", + ) # 取り消し後の状態を新しい起点にし、**その場で保存する**。ここで保存せずに # 落ちると、次の実行は古い起点から範囲を取り直して取り消しコミット自体を # 「未申告」と判定し、**取り消しを取り消して**しまう。 From 121314c886ca347f8627a198528d3e1c16a90a52 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 13:11:30 +0000 Subject: [PATCH 55/95] =?UTF-8?q?Revert=20"Refactor:=20split=5Finto=5Fpipe?= =?UTF-8?q?line=20=E2=80=94=20plugins/ndf-shared/skills/cross-refactoring/?= =?UTF-8?q?scripts/refactor.py#cmd=5Fmerge=5Ffix"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 0847bb3b1badc663b1a108a6ff34b9bb9955fb2a. --- .../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 0a2334cd..00915d63 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1815,24 +1815,6 @@ def collect_fix_range_report( return ordered_range, unassigned, facts -def evaluate_fix_commits( - facts: list[dict[str, Any]], - scope: list[str], -) -> tuple[list[str], list[tuple[str, str]]]: - """修正コミット facts を検証し、問題と採用コミットを返す。""" - 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 problems, accepted - - def cmd_merge_fix(args: argparse.Namespace) -> None: """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" path, state = _load(args.id) @@ -1890,7 +1872,16 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: # 状態を記録しないだけでは、未検証の変更が Pull Request に残り続ける # (見送りの対象にもならない)。どのコミットが安全かは決められないので、 # 適用フェーズの未割当コミットと同じ扱いにする。 - problems, accepted = evaluate_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 f370c986f2c524a25e2bb48aba140b10cbc1fbb5 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 13:11:30 +0000 Subject: [PATCH 56/95] =?UTF-8?q?Revert=20"Refactor:=20split=5Finto=5Fpipe?= =?UTF-8?q?line=20=E2=80=94=20plugins/ndf-shared/skills/cross-refactoring/?= =?UTF-8?q?scripts/refactor.py#cmd=5Fmerge=5Ffix"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 025b3575f2570844c8c139ddfda992d06a307972. --- .../cross-refactoring/scripts/refactor.py | 58 ++++++++----------- 1 file changed, 23 insertions(+), 35 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 00915d63..6fe12b58 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1782,39 +1782,6 @@ def resolve_claimed_threads(payload: dict[str, Any], state: dict[str, Any]) -> s return resolved -def collect_fix_range_report( - entry: dict[str, Any], - state: dict[str, Any], - payload: dict[str, Any], - work: str, - head_now: str, -) -> tuple[list[str], list[str], list[dict[str, Any]]]: - """修正範囲を確定し、申告外コミットと commit facts を返す。""" - baseline = state.get("baseline_test") or {} - ordered_range = commits_in_range(work, entry.get("fix_base_sha"), head_now) - if ordered_range is None: - die( - "修正の範囲を確定できませんでした" - f"(起点 {entry.get('fix_base_sha')} / HEAD {head_now})。" - "検証できない修正は採りません", - code=2, - ) - reported_shas = _reported_shas(payload) - reported_full = { - full for full in ( - _git_out(work, ["rev-parse", "--verify", f"{s}^{{commit}}"]) - for s in reported_shas - ) if full - } - unassigned = sorted(set(ordered_range) - reported_full) - facts = collect_commit_facts( - work, reported_shas, set(ordered_range), - baseline.get("command") or "true", state["head_branch"], - _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), - ) - return ordered_range, unassigned, facts - - def cmd_merge_fix(args: argparse.Namespace) -> None: """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" path, state = _load(args.id) @@ -1860,12 +1827,33 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: # 修正コミットも適用と同じ基準で、**git と実際のテスト実行から**検証する。 # 結果ファイルの申告で済ませると、手順を満たさない変更が収束済みになれてしまう。 + baseline = state.get("baseline_test") or {} # 修正の範囲も**オーケストレータが記録した起点**から取る。起点は # `judge-review` が変更要求を返したときの HEAD である。 + ordered_range = commits_in_range(work, entry.get("fix_base_sha"), head_now) + if ordered_range is None: + die( + "修正の範囲を確定できませんでした" + f"(起点 {entry.get('fix_base_sha')} / HEAD {head_now})。" + "検証できない修正は採りません", + code=2, + ) + reported_shas = _reported_shas(payload) + # 適用と同じく、**範囲のコミットは全て申告されていること**を求める。 # 申告から漏れた修正コミットは検証を受けないまま Pull Request に残る。 - ordered_range, unassigned, facts = collect_fix_range_report( - entry, state, payload, work, head_now, + reported_full = { + full for full in ( + _git_out(work, ["rev-parse", "--verify", f"{s}^{{commit}}"]) + for s in reported_shas + ) if full + } + unassigned = sorted(set(ordered_range) - reported_full) + + facts = collect_commit_facts( + work, reported_shas, set(ordered_range), + baseline.get("command") or "true", state["head_branch"], + _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), ) # **不正なコミットが 1 件でもあれば、修正ラウンドの範囲ごと取り消す。** From 7265eb175a765bd08294722d70919f9bfc892f8d Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 13:11:30 +0000 Subject: [PATCH 57/95] =?UTF-8?q?Revert=20"Refactor:=20split=5Finto=5Fpipe?= =?UTF-8?q?line=20=E2=80=94=20plugins/ndf-shared/skills/cross-refactoring/?= =?UTF-8?q?scripts/refactor.py#cmd=5Fmerge=5Ffix"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit c17ecea9da423d075b1c4328de8948e2fdfceeba. --- .../cross-refactoring/scripts/refactor.py | 40 ++++++++----------- 1 file changed, 17 insertions(+), 23 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 6fe12b58..37ff664f 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1760,28 +1760,6 @@ def already_merged_fix(entry: dict[str, Any], merge_key: str) -> bool: return merge_key in entry.setdefault("fix_merged_keys", []) -def resolve_claimed_threads(payload: dict[str, Any], state: dict[str, Any]) -> set[str]: - """自己申告された解決 thread を 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(state["repo"], state["current_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_fix(args: argparse.Namespace) -> None: """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" path, state = _load(args.id) @@ -1821,9 +1799,25 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: # 自己申告をそのまま信じない。解決 API に失敗・未実行でも「解決済み」と # 書けてしまい、未解決の指摘が取り消し対象から外れる。GitHub 側の # `isResolved` と突き合わせ、**両方が解決と言っているものだけ**を反映する。 + raw_claimed = payload.get("resolved_thread_ids") # 文字列は 1 文字ずつに分解され、数値や真偽値は反復できずに落ちる。 # **配列であることを先に確かめる。** - resolved = resolve_claimed_threads(payload, state) + 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 bcafefa64b04eaa7220cdeed00de5cada5944a19 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 13:11:30 +0000 Subject: [PATCH 58/95] =?UTF-8?q?Revert=20"Refactor:=20split=5Finto=5Fpipe?= =?UTF-8?q?line=20=E2=80=94=20plugins/ndf-shared/skills/cross-refactoring/?= =?UTF-8?q?scripts/refactor.py#cmd=5Fmerge=5Ffix"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 546ad38e9ebb485453aa63a73969d1fd2257bab4. --- .../cross-refactoring/scripts/refactor.py | 17 +++++------------ 1 file changed, 5 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 37ff664f..f1927d96 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1750,16 +1750,6 @@ def cmd_abandon_items(args: argparse.Namespace) -> None: statefile.save(path, state) -def build_fix_merge_key(result: pathlib.Path, attempt: int) -> str: - """修正結果の冪等キーを試行番号と結果ファイル内容から作る。""" - return f"{attempt}:" + hashlib.sha256(result.read_bytes()).hexdigest() - - -def already_merged_fix(entry: dict[str, Any], merge_key: str) -> bool: - """同じ修正結果がすでに取り込み済みかを返す。""" - return merge_key in entry.setdefault("fix_merged_keys", []) - - def cmd_merge_fix(args: argparse.Namespace) -> None: """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" path, state = _load(args.id) @@ -1787,9 +1777,12 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: work = state["worktrees"]["work"] head_now = _git_out(work, ["rev-parse", "HEAD"]) or "" attempt = entry.get("fix_attempts", 0) - merge_key = build_fix_merge_key(result, attempt) + merge_key = ( + f"{attempt}:" + + hashlib.sha256(result.read_bytes()).hexdigest() + ) merged_keys = entry.setdefault("fix_merged_keys", []) - if already_merged_fix(entry, merge_key): + if merge_key in merged_keys: info( f"↻ この修正結果は取り込み済みです" f"(修正ラウンド {entry['fix_rounds']})" From c8b686b9826021ac765de7985353259344941a5e Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 13:11:30 +0000 Subject: [PATCH 59/95] =?UTF-8?q?Revert=20"Refactor:=20consolidate=5Fdupli?= =?UTF-8?q?cation=20=E2=80=94=20plugins/ndf-shared/skills/cross-refactorin?= =?UTF-8?q?g/scripts/refactor.py#cmd=5Fmerge=5Ffix"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 42060bcdbf36b83fe5e0741dd17c6bd0f15bebd4. --- .../cross-refactoring/scripts/refactor.py | 66 ++++++++----------- 1 file changed, 26 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 f1927d96..5ac4ad5e 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1208,36 +1208,6 @@ def _verify_and_classify_items( return applied, failed -def _revert_unverified_range( - state: dict[str, Any], - entry: dict[str, Any], - ordered_range: list[str], - reason: str, - path: pathlib.Path, - *, - dry_run: bool = False, - base_key: str = "fix_base_sha", - item_id: Optional[str] = None, -) -> Optional[str]: - """検証できない範囲を取り消し、再実行用の起点 SHA を更新する。""" - if reason: - info(reason) - whole_range = { - "item_id": item_id or 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_range, dry_run) - new_base = None - if not dry_run: - new_base = _git_out(state["worktrees"]["work"], ["rev-parse", "HEAD"]) - entry[base_key] = new_base - statefile.save(path, state) - return new_base - - def cmd_merge_apply(args: argparse.Namespace) -> None: """Step 4 — 適用結果を検証して取り込む。 @@ -1348,11 +1318,20 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: # 範囲全体を取り消す。どのコミットが安全かを決められない以上、 # 起点まで戻すのが最も確実である。順序は `_revert_item_commits` が # git の履歴から決め直す。 - _revert_unverified_range( - state, entry, ordered_range, "", - path, dry_run=args.dry_run, base_key="apply_base_sha", - item_id=f"R{entry['round']}-range", - ) + 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, @@ -1867,15 +1846,22 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: if unassigned or problems: # **状態へ記録する前に取り消す。** 先に記録すると、取り消し済みのコミットが # 状態ファイルに残り、後の見送り処理が同じコミットをもう一度取り消そうとする。 - _revert_unverified_range( - state, entry, ordered_range, - "検証を通らない変更を残さないため、この修正ラウンドの範囲を取り消します", - path, - item_id=f"R{entry['round']}-fix{entry['fix_rounds'] + 1}", + info("検証を通らない変更を残さないため、この修正ラウンドの範囲を取り消します") + # **取り消しへ着手する前に印を立てる。** 取り消しは済んだのに push できずに + # 終わると、未検証の変更が Pull Request に残ったままになる。 + entry["pending_push"] = True + statefile.save(path, state) + _revert_item_commits( + state, + {"item_id": f"R{entry['round']}-fix{entry['fix_rounds'] + 1}", + "commits": list(ordered_range)}, + dry_run=False, ) # 取り消し後の状態を新しい起点にし、**その場で保存する**。ここで保存せずに # 落ちると、次の実行は古い起点から範囲を取り直して取り消しコミット自体を # 「未申告」と判定し、**取り消しを取り消して**しまう。 + entry["fix_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) + statefile.save(path, state) # **push は保存のあと。** ここで push して失敗すると、取り消しコミットは # ローカルに残るのに起点の更新が保存されず、叩き直しで二重に取り消してしまう。 info("⚠ 修正を取り消したため、解決の申告は採用しません") From 9b391bf227e36d7282587d7456ec8e7d3bf1dcff Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 13:11:30 +0000 Subject: [PATCH 60/95] =?UTF-8?q?Revert=20"Refactor:=20split=5Finto=5Fpipe?= =?UTF-8?q?line=20=E2=80=94=20plugins/ndf-shared/skills/cross-refactoring/?= =?UTF-8?q?scripts/refactor.py#cmd=5Fmerge=5Fapply"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit d29f9888d1f001f88c10bf3e44af5ff0ea15203b. --- .../cross-refactoring/scripts/refactor.py | 106 +++++++----------- 1 file changed, 40 insertions(+), 66 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 5ac4ad5e..1c7efacf 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1150,64 +1150,6 @@ def _verify_ownership( ) -def _verify_and_classify_items( - entry: dict[str, Any], - state: dict[str, Any], - reported: dict[str, dict[str, Any]], - in_range: set[str], - test_command: str, - head_branch: str, - timeout: int, - scope: list[str], -) -> tuple[list[str], list[str]]: - """各改善項目のコミット facts を検証し、採用候補と失敗へ分類する。""" - applied: list[str] = [] - failed: list[str] = [] - work = state["worktrees"]["work"] - 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, 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 state.get("_merge_apply_dry_run"): - state_path = state.pop("_merge_apply_path") - dry_run = state.pop("_merge_apply_dry_run", None) - statefile.save(state_path, state) - state["_merge_apply_path"] = state_path - state["_merge_apply_dry_run"] = dry_run - return applied, failed - - def cmd_merge_apply(args: argparse.Namespace) -> None: """Step 4 — 適用結果を検証して取り込む。 @@ -1353,19 +1295,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 [] # **判定はその都度残す。** まとめて最後に保存すると、取り消しの途中で中断した # ときに適用の記録が一切残らず、どのコミットが検証を通ったのかを状態から # 復元できなくなる。再開可能性は収束ループの前提なので、ここが崩れると # 中断からの復帰手段が無くなる。 - state["_merge_apply_path"] = path - state["_merge_apply_dry_run"] = args.dry_run - applied, failed = _verify_and_classify_items( - entry, state, reported, in_range, test_command, head_branch, - _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), scope, - ) - state.pop("_merge_apply_path", None) - state.pop("_merge_apply_dry_run", None) + 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 de239bea61efcc6d4a2efacd6763fc28d7dd1ad8 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 13:11:30 +0000 Subject: [PATCH 61/95] =?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 c56bbb24e316c0e37d15dd8d29ae2c1378590d2f. --- .../cross-refactoring/scripts/refactor.py | 106 ++++++------------ 1 file changed, 37 insertions(+), 69 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 1c7efacf..1a224c66 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1088,68 +1088,6 @@ def cmd_merge_proposals(args: argparse.Namespace) -> None: sys.exit(2) -def _verify_ownership( - entry: dict[str, Any], - reported: dict[str, dict[str, Any]], - ordered_range: list[str], - work: str, - state: dict[str, Any], -) -> Optional[str]: - """適用範囲の各コミットが、ちょうど 1 項目に所有されているか検証する。""" - in_range = set(ordered_range or []) - round_items = set(entry["items"]) - unknown_ids: list[str] = [] - raw_items = ((state.get("_merge_apply_payload") or {}).get("items")) - if not isinstance(raw_items, list): - raw_items = [] - for r in raw_items: - if not isinstance(r, dict): - continue - item_id = r.get("item_id") - if item_id is not None and item_id not in round_items: - unknown_ids.append(str(item_id)) - - 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)) - entry["_ownership_unassigned"] = unassigned - entry["_ownership_unknown_ids"] = unknown_ids - entry["_ownership_duplicated"] = duplicated - if not (unassigned or unknown_ids or duplicated): - return None - - 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])})" - ) - return ( - "、".join(causes) - + "。検証を回避した変更や、状態と実差分の食い違いを Pull Request に" - "残さないため、ラウンドごと取り消します" - ) - - def cmd_merge_apply(args: argparse.Namespace) -> None: """Step 4 — 適用結果を検証して取り込む。 @@ -1225,6 +1163,7 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: # そのまま Pull Request に残せてしまう。 round_items = set(entry["items"]) reported: dict[str, dict[str, Any]] = {} + unknown_ids: list[str] = [] raw_items = payload.get("items") if not isinstance(raw_items, list): info(f"⚠ 適用結果の items が配列ではありません({type(raw_items).__name__})") @@ -1235,6 +1174,8 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: item_id = r.get("item_id") if item_id in round_items: reported[item_id] = r + elif item_id is not None: + unknown_ids.append(str(item_id)) # **範囲のコミットは全て、いずれかの改善項目に割り当てられていること。** # 申告から漏れたコミットはテストもトレーラーも差分予算も検査されず、そのまま @@ -1245,13 +1186,40 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: # # 判定は**完全な SHA へ正規化してから**行う。申告の文字列をそのまま鍵にすると、 # 一方が完全 SHA、他方が短縮 SHA で同じコミットを指したときに重複を見逃す。 - state["_merge_apply_payload"] = payload - reason = _verify_ownership(entry, reported, ordered_range, work, state) - state.pop("_merge_apply_payload", None) - unassigned = entry.pop("_ownership_unassigned", []) - unknown_ids = entry.pop("_ownership_unknown_ids", []) - duplicated = entry.pop("_ownership_duplicated", []) - if reason: + 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: + 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) From 2a51865332808e2000d23ffe207831813821bc19 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 13:11:30 +0000 Subject: [PATCH 62/95] =?UTF-8?q?Reapply=20"Refactor:=20extract=5Fmethod?= =?UTF-8?q?=20=E2=80=94=20plugins/ndf-shared/skills/cross-refactoring/scri?= =?UTF-8?q?pts/refactor.py#cmd=5Fmerge=5Fapply"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 902c648f9299b91334d3b6dbac3b69f86c8b6496. --- .../cross-refactoring/scripts/refactor.py | 106 ++++++++++++------ 1 file changed, 69 insertions(+), 37 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 1a224c66..1c7efacf 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1088,6 +1088,68 @@ def cmd_merge_proposals(args: argparse.Namespace) -> None: sys.exit(2) +def _verify_ownership( + entry: dict[str, Any], + reported: dict[str, dict[str, Any]], + ordered_range: list[str], + work: str, + state: dict[str, Any], +) -> Optional[str]: + """適用範囲の各コミットが、ちょうど 1 項目に所有されているか検証する。""" + in_range = set(ordered_range or []) + round_items = set(entry["items"]) + unknown_ids: list[str] = [] + raw_items = ((state.get("_merge_apply_payload") or {}).get("items")) + if not isinstance(raw_items, list): + raw_items = [] + for r in raw_items: + if not isinstance(r, dict): + continue + item_id = r.get("item_id") + if item_id is not None and item_id not in round_items: + unknown_ids.append(str(item_id)) + + 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)) + entry["_ownership_unassigned"] = unassigned + entry["_ownership_unknown_ids"] = unknown_ids + entry["_ownership_duplicated"] = duplicated + if not (unassigned or unknown_ids or duplicated): + return None + + 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])})" + ) + return ( + "、".join(causes) + + "。検証を回避した変更や、状態と実差分の食い違いを Pull Request に" + "残さないため、ラウンドごと取り消します" + ) + + def cmd_merge_apply(args: argparse.Namespace) -> None: """Step 4 — 適用結果を検証して取り込む。 @@ -1163,7 +1225,6 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: # そのまま Pull Request に残せてしまう。 round_items = set(entry["items"]) reported: dict[str, dict[str, Any]] = {} - unknown_ids: list[str] = [] raw_items = payload.get("items") if not isinstance(raw_items, list): info(f"⚠ 適用結果の items が配列ではありません({type(raw_items).__name__})") @@ -1174,8 +1235,6 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: item_id = r.get("item_id") if item_id in round_items: reported[item_id] = r - elif item_id is not None: - unknown_ids.append(str(item_id)) # **範囲のコミットは全て、いずれかの改善項目に割り当てられていること。** # 申告から漏れたコミットはテストもトレーラーも差分予算も検査されず、そのまま @@ -1186,40 +1245,13 @@ 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)) - 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 に" - "残さないため、ラウンドごと取り消します" - ) + state["_merge_apply_payload"] = payload + reason = _verify_ownership(entry, reported, ordered_range, work, state) + state.pop("_merge_apply_payload", None) + unassigned = entry.pop("_ownership_unassigned", []) + unknown_ids = entry.pop("_ownership_unknown_ids", []) + duplicated = entry.pop("_ownership_duplicated", []) + if reason: info(f"❌ {reason}") for item_id in entry["items"]: it = _find_item(state, item_id) From 85bc32553cce1a75fa2104ab1c2929b61ce0230d Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 13:11:30 +0000 Subject: [PATCH 63/95] =?UTF-8?q?Reapply=20"Refactor:=20split=5Finto=5Fpip?= =?UTF-8?q?eline=20=E2=80=94=20plugins/ndf-shared/skills/cross-refactoring?= =?UTF-8?q?/scripts/refactor.py#cmd=5Fmerge=5Fapply"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 55f6b8ed8913be5e35f4eefbba2af92ab6d9c561. --- .../cross-refactoring/scripts/refactor.py | 106 +++++++++++------- 1 file changed, 66 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 1c7efacf..5ac4ad5e 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1150,6 +1150,64 @@ def _verify_ownership( ) +def _verify_and_classify_items( + entry: dict[str, Any], + state: dict[str, Any], + reported: dict[str, dict[str, Any]], + in_range: set[str], + test_command: str, + head_branch: str, + timeout: int, + scope: list[str], +) -> tuple[list[str], list[str]]: + """各改善項目のコミット facts を検証し、採用候補と失敗へ分類する。""" + applied: list[str] = [] + failed: list[str] = [] + work = state["worktrees"]["work"] + 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, 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 state.get("_merge_apply_dry_run"): + state_path = state.pop("_merge_apply_path") + dry_run = state.pop("_merge_apply_dry_run", None) + statefile.save(state_path, state) + state["_merge_apply_path"] = state_path + state["_merge_apply_dry_run"] = dry_run + return applied, failed + + def cmd_merge_apply(args: argparse.Namespace) -> None: """Step 4 — 適用結果を検証して取り込む。 @@ -1295,51 +1353,19 @@ 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) + state["_merge_apply_path"] = path + state["_merge_apply_dry_run"] = args.dry_run + applied, failed = _verify_and_classify_items( + entry, state, reported, in_range, test_command, head_branch, + _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), scope, + ) + state.pop("_merge_apply_path", None) + state.pop("_merge_apply_dry_run", None) entry["apply"] = { "applied": applied, From 12630ffe509a0f41921612c0a2a31ef3e8697ddc Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 13:11:30 +0000 Subject: [PATCH 64/95] =?UTF-8?q?Reapply=20"Refactor:=20extract=5Fmethod?= =?UTF-8?q?=20=E2=80=94=20plugins/ndf-shared/skills/cross-review/scripts/l?= =?UTF-8?q?ib/monitor.py#monitor=5Fagent"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 11d33174feb2a28bb1d32cfd528ef16c5d325e14. --- .../cross-review/scripts/lib/monitor.py | 46 ++++++++++++++----- 1 file changed, 35 insertions(+), 11 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..997b28b1 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,31 @@ def _tail_last_nonempty_line(path: pathlib.Path, limit: int = 4096) -> str: return "" +def read_started_agent( + agent: str, + paths: AgentPaths, + started: float, + log_prefix: str, +) -> Optional[AgentStatus]: + """pidfile を待ち、監視開始に必要な AgentStatus を返す。""" + status = AgentStatus(agent=agent) + grace_end = started + 30 + while time.monotonic() < grace_end: + if paths.pidfile.exists(): + break + time.sleep(2) + pid = _read_pidfile(paths.pidfile) + if pid is None: + status.status = "PIDFILE_BAD" + status.exit_code = 6 + status.detail = f"pidfile not found: {paths.pidfile}" + _emit_log(log_prefix, agent, status) + return None + + status.pid = pid + return status + + def monitor_agent( agent: str, pr: int, @@ -630,21 +655,20 @@ def monitor_agent( hard timeout / stall / sentinel / result.json のみで判定する。 """ paths = AgentPaths.for_(agent, pr, stem_template) - status = AgentStatus(agent=agent) started = time.monotonic() # 起動チェック: 30 秒待っても pidfile が無ければ起動失敗 - grace_end = started + 30 - while time.monotonic() < grace_end: - if paths.pidfile.exists(): - break - time.sleep(2) - pid = _read_pidfile(paths.pidfile) + status = read_started_agent(agent, paths, started, log_prefix) + if status is None: + return AgentStatus( + agent=agent, + status="PIDFILE_BAD", + exit_code=6, + detail=f"pidfile not found: {paths.pidfile}", + ) + + pid = status.pid if pid is None: - status.status = "PIDFILE_BAD" - status.exit_code = 6 - status.detail = f"pidfile not found: {paths.pidfile}" - _emit_log(log_prefix, agent, status) return status status.pid = pid From 48ee3d63ed6e36d565a1ca250f45bc89b5a561b5 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 13:11:30 +0000 Subject: [PATCH 65/95] =?UTF-8?q?Reapply=20"Refactor:=20extract=5Fmethod?= =?UTF-8?q?=20=E2=80=94=20plugins/ndf-shared/skills/cross-review/scripts/l?= =?UTF-8?q?ib/monitor.py#monitor=5Fagent"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit f6530ad6c0247a843a8663d52b1c1955df9c4f0c. --- .../cross-review/scripts/lib/monitor.py | 100 +++++++++++------- 1 file changed, 60 insertions(+), 40 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 997b28b1..f89eda77 100755 --- a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py @@ -638,6 +638,61 @@ def read_started_agent( return status +def try_complete_lingering_process( + agent: str, + paths: AgentPaths, + status: AgentStatus, + pid: int, + alive: bool, + cmdline_validated: bool, + started_wall: float, + log_prefix: str, +) -> Optional[AgentStatus]: + """result.json を出したまま残ったプロセスを完了扱いにできるか判定する。""" + if agent == "codex": + status.sentinel_seen = _scan_codex_sentinel(paths.err_log) + + 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, @@ -693,29 +748,16 @@ def monitor_agent( # 1. プロセス生存確認 → 死んでいたら最終判定へ (result.json 存在をチェック) alive = _pid_alive(pid) - 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 判定で返す。 - 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 + completed = try_complete_lingering_process( + agent, paths, status, pid, alive, cmdline_validated, started_wall, log_prefix, + ) + if completed is not None: + return completed # result.json が書かれた後もプロセスがハング��るケース (gemini で観測: # MCP サーバー切断待ち等��� exit しない)。sentinel 機構を持たない agent 向け @@ -724,28 +766,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 52f3da481cf89c20ff414efd6121752cd318fcf3 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 13:11:30 +0000 Subject: [PATCH 66/95] =?UTF-8?q?Reapply=20"Refactor:=20extract=5Fmethod?= =?UTF-8?q?=20=E2=80=94=20plugins/ndf-shared/skills/cross-review/scripts/l?= =?UTF-8?q?ib/monitor.py#monitor=5Fagent"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit b815d907d02d7e3532cb93136142ee8ae3a85dbd. --- .../cross-review/scripts/lib/monitor.py | 74 ++++++++++++------- 1 file changed, 46 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 f89eda77..93c8e117 100755 --- a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py @@ -693,6 +693,47 @@ def try_complete_lingering_process( return None +def check_early_error( + agent: str, + paths: AgentPaths, + status: AgentStatus, + pid: int, + alive: bool, + warned_early_error: bool, + log_prefix: str, +) -> tuple[Optional[AgentStatus], bool]: + """early error の fatal/warn を判定し、fatal 時は終了 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, 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, @@ -795,34 +836,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, paths, status, pid, alive, warned_early_error, log_prefix, + ) + if early_status is not None: + return early_status if not alive: # プロセス終了 — result.json を確認 From 68740f4eef36eb0c800312992e06b457405b3107 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 13:11:30 +0000 Subject: [PATCH 67/95] =?UTF-8?q?Reapply=20"Refactor:=20extract=5Fmethod?= =?UTF-8?q?=20=E2=80=94=20plugins/ndf-shared/skills/cross-review/scripts/l?= =?UTF-8?q?ib/monitor.py#monitor=5Fagent"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 630516a34021f0b79924e01793516d8428a85df3. --- .../cross-review/scripts/lib/monitor.py | 39 ++++++++++++------- 1 file changed, 25 insertions(+), 14 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 93c8e117..a246968d 100755 --- a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py @@ -734,6 +734,30 @@ def check_early_error( return None, warned_early_error +def finish_exited_process( + agent: str, + paths: AgentPaths, + status: AgentStatus, + require_result: bool, + log_prefix: str, +) -> AgentStatus: + """終了済みプロセスの result.json 有無から最終 status を作る。""" + status.result_exists = paths.result.exists() and paths.result.stat().st_size > 0 + if status.result_exists or not require_result: + status.status = "OK" + status.exit_code = 0 + status.detail = ( + f"process exited; sentinel={status.sentinel_seen}; " + f"result_exists={status.result_exists}" + ) + else: + status.status = "NO_RESULT" + status.exit_code = 3 + status.detail = f"process exited but result.json missing: {paths.result}" + _emit_log(log_prefix, agent, status) + return status + + def monitor_agent( agent: str, pr: int, @@ -844,20 +868,7 @@ def monitor_agent( if not alive: # プロセス終了 — result.json を確認 - status.result_exists = paths.result.exists() and paths.result.stat().st_size > 0 - if status.result_exists or not require_result: - status.status = "OK" - status.exit_code = 0 - status.detail = ( - f"process exited; sentinel={status.sentinel_seen}; " - f"result_exists={status.result_exists}" - ) - else: - status.status = "NO_RESULT" - status.exit_code = 3 - status.detail = f"process exited but result.json missing: {paths.result}" - _emit_log(log_prefix, agent, status) - return status + return finish_exited_process(agent, paths, status, require_result, log_prefix) # 4. stall detection (err.log / stdout.log / progress.log をモニタ。 # gemini は stdout 側だけ進捗が出るケースがあり、progress.log には From 08ccd30a860644324764b146ded8a8d0897d599b Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 13:11:30 +0000 Subject: [PATCH 68/95] =?UTF-8?q?Reapply=20"Refactor:=20extract=5Fmethod?= =?UTF-8?q?=20=E2=80=94=20plugins/ndf-shared/skills/cross-review/scripts/l?= =?UTF-8?q?ib/monitor.py#monitor=5Fagent"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit e4f729d540282974d95266fc39fffbd5d4645fe8. --- .../cross-review/scripts/lib/monitor.py | 63 ++++++++++++------- 1 file changed, 42 insertions(+), 21 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 a246968d..cd6b3ecb 100755 --- a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py @@ -758,6 +758,43 @@ def finish_exited_process( return status +def update_progress_and_check_stall( + agent: str, + paths: AgentPaths, + status: AgentStatus, + pid: int, + alive: bool, + stall_timeout: int, + last_progress_size: int, + last_progress: float, + log_prefix: str, +) -> tuple[Optional[AgentStatus], int, float]: + """ログ進捗を更新し、stall_timeout 超過なら終了 status を返す。""" + status.err_log_size = _safe_size(paths.err_log) + status.stdout_log_size = _safe_size(paths.stdout_log) + status.progress_log_size = _safe_size(paths.progress_log) + status.progress_tail = _tail_last_nonempty_line(paths.progress_log) + progress_size = ( + status.err_log_size + status.stdout_log_size + status.progress_log_size + ) + if progress_size != last_progress_size: + last_progress_size = progress_size + last_progress = time.monotonic() + status.idle_seconds = time.monotonic() - last_progress + if status.idle_seconds >= stall_timeout: + if alive: + _kill_pid(pid) + status.status = "STALLED" + status.exit_code = 5 + status.detail = ( + f"no log progress for {stall_timeout}s " + f"(pid {pid}, last size {last_progress_size}B)" + ) + _emit_log(log_prefix, agent, status) + return status, last_progress_size, last_progress + return None, last_progress_size, last_progress + + def monitor_agent( agent: str, pr: int, @@ -874,28 +911,12 @@ def monitor_agent( # gemini は stdout 側だけ進捗が出るケースがあり、progress.log には # launcher が要求した短いフェーズマーカーが出るため、いずれかが # 更新されれば progress として扱う) - status.err_log_size = _safe_size(paths.err_log) - status.stdout_log_size = _safe_size(paths.stdout_log) - status.progress_log_size = _safe_size(paths.progress_log) - status.progress_tail = _tail_last_nonempty_line(paths.progress_log) - progress_size = ( - status.err_log_size + status.stdout_log_size + status.progress_log_size + stalled, last_progress_size, last_progress = update_progress_and_check_stall( + agent, paths, status, pid, alive, stall_timeout, + last_progress_size, last_progress, log_prefix, ) - if progress_size != last_progress_size: - last_progress_size = progress_size - last_progress = time.monotonic() - status.idle_seconds = time.monotonic() - last_progress - if status.idle_seconds >= stall_timeout: - if alive: - _kill_pid(pid) - status.status = "STALLED" - status.exit_code = 5 - status.detail = ( - f"no log progress for {stall_timeout}s " - f"(pid {pid}, last size {last_progress_size}B)" - ) - _emit_log(log_prefix, agent, status) - return status + if stalled is not None: + return stalled # poll 中の進捗ログ _emit_progress(log_prefix, agent, status) From 8b9fca1c4a1f3e89540a57ebb257e6504ed260de Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 13:11:30 +0000 Subject: [PATCH 69/95] =?UTF-8?q?Reapply=20"Refactor:=20consolidate=5Fdupl?= =?UTF-8?q?ication=20=E2=80=94=20plugins/ndf-shared/skills/cross-refactori?= =?UTF-8?q?ng/scripts/refactor.py#cmd=5Fmerge=5Ffix"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 03178ec0a2f4c496cf54ab251bee19f46ef62446. --- .../cross-refactoring/scripts/refactor.py | 66 +++++++++++-------- 1 file changed, 40 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 5ac4ad5e..f1927d96 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1208,6 +1208,36 @@ def _verify_and_classify_items( return applied, failed +def _revert_unverified_range( + state: dict[str, Any], + entry: dict[str, Any], + ordered_range: list[str], + reason: str, + path: pathlib.Path, + *, + dry_run: bool = False, + base_key: str = "fix_base_sha", + item_id: Optional[str] = None, +) -> Optional[str]: + """検証できない範囲を取り消し、再実行用の起点 SHA を更新する。""" + if reason: + info(reason) + whole_range = { + "item_id": item_id or 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_range, dry_run) + new_base = None + if not dry_run: + new_base = _git_out(state["worktrees"]["work"], ["rev-parse", "HEAD"]) + entry[base_key] = new_base + statefile.save(path, state) + return new_base + + def cmd_merge_apply(args: argparse.Namespace) -> None: """Step 4 — 適用結果を検証して取り込む。 @@ -1318,20 +1348,11 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: # 範囲全体を取り消す。どのコミットが安全かを決められない以上、 # 起点まで戻すのが最も確実である。順序は `_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"]) + _revert_unverified_range( + state, entry, ordered_range, "", + path, dry_run=args.dry_run, base_key="apply_base_sha", + item_id=f"R{entry['round']}-range", + ) entry["apply"] = { "applied": [], "failed": list(entry["items"]), "base_sha": entry.get("apply_base_sha"), "head_sha": head_sha, @@ -1846,22 +1867,15 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: if unassigned or problems: # **状態へ記録する前に取り消す。** 先に記録すると、取り消し済みのコミットが # 状態ファイルに残り、後の見送り処理が同じコミットをもう一度取り消そうとする。 - info("検証を通らない変更を残さないため、この修正ラウンドの範囲を取り消します") - # **取り消しへ着手する前に印を立てる。** 取り消しは済んだのに push できずに - # 終わると、未検証の変更が Pull Request に残ったままになる。 - entry["pending_push"] = True - statefile.save(path, state) - _revert_item_commits( - state, - {"item_id": f"R{entry['round']}-fix{entry['fix_rounds'] + 1}", - "commits": list(ordered_range)}, - dry_run=False, + _revert_unverified_range( + state, entry, ordered_range, + "検証を通らない変更を残さないため、この修正ラウンドの範囲を取り消します", + path, + item_id=f"R{entry['round']}-fix{entry['fix_rounds'] + 1}", ) # 取り消し後の状態を新しい起点にし、**その場で保存する**。ここで保存せずに # 落ちると、次の実行は古い起点から範囲を取り直して取り消しコミット自体を # 「未申告」と判定し、**取り消しを取り消して**しまう。 - entry["fix_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) - statefile.save(path, state) # **push は保存のあと。** ここで push して失敗すると、取り消しコミットは # ローカルに残るのに起点の更新が保存されず、叩き直しで二重に取り消してしまう。 info("⚠ 修正を取り消したため、解決の申告は採用しません") From c6d998e9a95ca5d67aa6fc84ade5bc0be01ffb47 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 13:11:30 +0000 Subject: [PATCH 70/95] =?UTF-8?q?Reapply=20"Refactor:=20split=5Finto=5Fpip?= =?UTF-8?q?eline=20=E2=80=94=20plugins/ndf-shared/skills/cross-refactoring?= =?UTF-8?q?/scripts/refactor.py#cmd=5Fmerge=5Ffix"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit da946dc7c13424375d44cd34b6330c5ebe4cf5e0. --- .../cross-refactoring/scripts/refactor.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index f1927d96..37ff664f 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1750,6 +1750,16 @@ def cmd_abandon_items(args: argparse.Namespace) -> None: statefile.save(path, state) +def build_fix_merge_key(result: pathlib.Path, attempt: int) -> str: + """修正結果の冪等キーを試行番号と結果ファイル内容から作る。""" + return f"{attempt}:" + hashlib.sha256(result.read_bytes()).hexdigest() + + +def already_merged_fix(entry: dict[str, Any], merge_key: str) -> bool: + """同じ修正結果がすでに取り込み済みかを返す。""" + return merge_key in entry.setdefault("fix_merged_keys", []) + + def cmd_merge_fix(args: argparse.Namespace) -> None: """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" path, state = _load(args.id) @@ -1777,12 +1787,9 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: work = state["worktrees"]["work"] head_now = _git_out(work, ["rev-parse", "HEAD"]) or "" attempt = entry.get("fix_attempts", 0) - merge_key = ( - f"{attempt}:" - + hashlib.sha256(result.read_bytes()).hexdigest() - ) + merge_key = build_fix_merge_key(result, attempt) merged_keys = entry.setdefault("fix_merged_keys", []) - if merge_key in merged_keys: + if already_merged_fix(entry, merge_key): info( f"↻ この修正結果は取り込み済みです" f"(修正ラウンド {entry['fix_rounds']})" From cf17a1cbfd7f857392043b52d0cca0434afa9f5b Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 13:11:30 +0000 Subject: [PATCH 71/95] =?UTF-8?q?Reapply=20"Refactor:=20split=5Finto=5Fpip?= =?UTF-8?q?eline=20=E2=80=94=20plugins/ndf-shared/skills/cross-refactoring?= =?UTF-8?q?/scripts/refactor.py#cmd=5Fmerge=5Ffix"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 8cb7435d5199a5b472037204ec6c7cf8d8d63c8d. --- .../cross-refactoring/scripts/refactor.py | 40 +++++++++++-------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 37ff664f..6fe12b58 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1760,6 +1760,28 @@ def already_merged_fix(entry: dict[str, Any], merge_key: str) -> bool: return merge_key in entry.setdefault("fix_merged_keys", []) +def resolve_claimed_threads(payload: dict[str, Any], state: dict[str, Any]) -> set[str]: + """自己申告された解決 thread を 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(state["repo"], state["current_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_fix(args: argparse.Namespace) -> None: """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" path, state = _load(args.id) @@ -1799,25 +1821,9 @@ 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 = resolve_claimed_threads(payload, state) # 修正コミットも適用と同じ基準で、**git と実際のテスト実行から**検証する。 # 結果ファイルの申告で済ませると、手順を満たさない変更が収束済みになれてしまう。 From d7b71c31b9a5845e4b11462f666ba5a07f95cfd5 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 13:11:30 +0000 Subject: [PATCH 72/95] =?UTF-8?q?Reapply=20"Refactor:=20split=5Finto=5Fpip?= =?UTF-8?q?eline=20=E2=80=94=20plugins/ndf-shared/skills/cross-refactoring?= =?UTF-8?q?/scripts/refactor.py#cmd=5Fmerge=5Ffix"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 68012f90d160a6ed71a993292f7e7a65c6bc4c95. --- .../cross-refactoring/scripts/refactor.py | 58 +++++++++++-------- 1 file changed, 35 insertions(+), 23 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 6fe12b58..00915d63 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1782,6 +1782,39 @@ def resolve_claimed_threads(payload: dict[str, Any], state: dict[str, Any]) -> s return resolved +def collect_fix_range_report( + entry: dict[str, Any], + state: dict[str, Any], + payload: dict[str, Any], + work: str, + head_now: str, +) -> tuple[list[str], list[str], list[dict[str, Any]]]: + """修正範囲を確定し、申告外コミットと commit facts を返す。""" + baseline = state.get("baseline_test") or {} + ordered_range = commits_in_range(work, entry.get("fix_base_sha"), head_now) + if ordered_range is None: + die( + "修正の範囲を確定できませんでした" + f"(起点 {entry.get('fix_base_sha')} / HEAD {head_now})。" + "検証できない修正は採りません", + code=2, + ) + reported_shas = _reported_shas(payload) + reported_full = { + full for full in ( + _git_out(work, ["rev-parse", "--verify", f"{s}^{{commit}}"]) + for s in reported_shas + ) if full + } + unassigned = sorted(set(ordered_range) - reported_full) + facts = collect_commit_facts( + work, reported_shas, set(ordered_range), + baseline.get("command") or "true", state["head_branch"], + _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), + ) + return ordered_range, unassigned, facts + + def cmd_merge_fix(args: argparse.Namespace) -> None: """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" path, state = _load(args.id) @@ -1827,33 +1860,12 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: # 修正コミットも適用と同じ基準で、**git と実際のテスト実行から**検証する。 # 結果ファイルの申告で済ませると、手順を満たさない変更が収束済みになれてしまう。 - baseline = state.get("baseline_test") or {} # 修正の範囲も**オーケストレータが記録した起点**から取る。起点は # `judge-review` が変更要求を返したときの HEAD である。 - ordered_range = commits_in_range(work, entry.get("fix_base_sha"), head_now) - if ordered_range is None: - die( - "修正の範囲を確定できませんでした" - f"(起点 {entry.get('fix_base_sha')} / HEAD {head_now})。" - "検証できない修正は採りません", - code=2, - ) - reported_shas = _reported_shas(payload) - # 適用と同じく、**範囲のコミットは全て申告されていること**を求める。 # 申告から漏れた修正コミットは検証を受けないまま Pull Request に残る。 - reported_full = { - full for full in ( - _git_out(work, ["rev-parse", "--verify", f"{s}^{{commit}}"]) - for s in reported_shas - ) if full - } - unassigned = sorted(set(ordered_range) - reported_full) - - facts = collect_commit_facts( - work, reported_shas, set(ordered_range), - baseline.get("command") or "true", state["head_branch"], - _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), + ordered_range, unassigned, facts = collect_fix_range_report( + entry, state, payload, work, head_now, ) # **不正なコミットが 1 件でもあれば、修正ラウンドの範囲ごと取り消す。** From b9ad33994dbded1be1bbb9b55f405d00125dff7a Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 13:11:30 +0000 Subject: [PATCH 73/95] =?UTF-8?q?Reapply=20"Refactor:=20split=5Finto=5Fpip?= =?UTF-8?q?eline=20=E2=80=94=20plugins/ndf-shared/skills/cross-refactoring?= =?UTF-8?q?/scripts/refactor.py#cmd=5Fmerge=5Ffix"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit d5c805f1be0cde88af4de80a2454b869e80d6f38. --- .../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 00915d63..0a2334cd 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1815,6 +1815,24 @@ def collect_fix_range_report( return ordered_range, unassigned, facts +def evaluate_fix_commits( + facts: list[dict[str, Any]], + scope: list[str], +) -> tuple[list[str], list[tuple[str, str]]]: + """修正コミット facts を検証し、問題と採用コミットを返す。""" + 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 problems, accepted + + def cmd_merge_fix(args: argparse.Namespace) -> None: """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" path, state = _load(args.id) @@ -1872,16 +1890,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"])) + problems, accepted = evaluate_fix_commits(facts, state.get("target_scope") or []) if unassigned: info( From 7286de011623e97accd7580f1d72a8857a68923e Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 13:11:30 +0000 Subject: [PATCH 74/95] =?UTF-8?q?Reapply=20"Refactor:=20split=5Finto=5Fpip?= =?UTF-8?q?eline=20=E2=80=94=20plugins/ndf-shared/skills/cross-refactoring?= =?UTF-8?q?/scripts/refactor.py#cmd=5Fmerge=5Ffix"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit a31cd0f884706ff877d9708692aeaabe7461498a. --- .../cross-refactoring/scripts/refactor.py | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 0a2334cd..fdfd6eae 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1833,6 +1833,21 @@ def evaluate_fix_commits( return problems, accepted +def reject_fix_range( + path: pathlib.Path, + state: dict[str, Any], + entry: dict[str, Any], + ordered_range: list[str], +) -> None: + """検証を通らない修正範囲を取り消し、次回用の起点を保存する。""" + _revert_unverified_range( + state, entry, ordered_range, + "検証を通らない変更を残さないため、この修正ラウンドの範囲を取り消します", + path, + item_id=f"R{entry['round']}-fix{entry['fix_rounds'] + 1}", + ) + + def cmd_merge_fix(args: argparse.Namespace) -> None: """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" path, state = _load(args.id) @@ -1901,12 +1916,7 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: if unassigned or problems: # **状態へ記録する前に取り消す。** 先に記録すると、取り消し済みのコミットが # 状態ファイルに残り、後の見送り処理が同じコミットをもう一度取り消そうとする。 - _revert_unverified_range( - state, entry, ordered_range, - "検証を通らない変更を残さないため、この修正ラウンドの範囲を取り消します", - path, - item_id=f"R{entry['round']}-fix{entry['fix_rounds'] + 1}", - ) + reject_fix_range(path, state, entry, ordered_range) # 取り消し後の状態を新しい起点にし、**その場で保存する**。ここで保存せずに # 落ちると、次の実行は古い起点から範囲を取り直して取り消しコミット自体を # 「未申告」と判定し、**取り消しを取り消して**しまう。 From 99fb8a3efc8e87bdf6130993181b4b7799545c0e Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 13:11:30 +0000 Subject: [PATCH 75/95] =?UTF-8?q?Reapply=20"Refactor:=20extract=5Fmethod?= =?UTF-8?q?=20=E2=80=94=20plugins/ndf-shared/skills/cross-refactoring/scri?= =?UTF-8?q?pts/refactor.py#cmd=5Fjudge=5Freview"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 6ec72a88ecd3b1405fb6975c016f37eb549a8e6f. --- .../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 fdfd6eae..b9be964c 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1543,6 +1543,31 @@ def _resume_incomplete_apply( _flush_pending_push(path, state, entry) +def _build_review_record( + reviews: dict[str, dict[str, Any]], + reviewers: list[str], + round_no: int, +) -> dict[str, Any]: + """レビュー結果から state に保存する findings 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_judge_review(args: argparse.Namespace) -> None: """Step 5 — レビュー 2 者の判定を取り込む。 @@ -1585,22 +1610,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 21e8436cbdb1d4dc48f1f493feb4485d678d6a82 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 13:11:30 +0000 Subject: [PATCH 76/95] =?UTF-8?q?Reapply=20"Refactor:=20extract=5Fmethod?= =?UTF-8?q?=20=E2=80=94=20plugins/ndf-shared/skills/cross-refactoring/scri?= =?UTF-8?q?pts/refactor.py#cmd=5Fmerge=5Fproposals"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit f78ae1308dca1980daa6488c95f42f25e14a306c. --- .../cross-refactoring/scripts/refactor.py | 57 +++++++++++-------- 1 file changed, 33 insertions(+), 24 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index b9be964c..b048cf04 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -976,30 +976,11 @@ def cmd_start_round(args: argparse.Namespace) -> None: ) -def cmd_merge_proposals(args: argparse.Namespace) -> None: - """Step 3 — 提案をマージして改善項目を作る。 - - 終了コード: 0 = 採用あり / 2 = 採用 0 件(提案ラウンドの繰り返しを終える)。 - - **同じラウンドで叩き直しても二重に項目を作らない。** 進行を止めても再開できる - ことが前提なので、統合済みなら前回と同じ結果をそのまま返す。 - """ - path, state = _load(args.id) - entry = _current_round(state) - - if entry.get("proposal_keys") is not None: - info( - f"↻ 提案ラウンド {entry['round']} は統合済みです" - f"(採用 {entry.get('adopted', 0)} 件 / 見送り {entry.get('deferred', 0)} 件)" - ) - for item_id in entry.get("items", []): - item = _find_item(state, item_id, required=False) - if item is not None: - info(f" {item_id} [{item['severity']}] {item['path']}#{item['symbol']}") - if not entry.get("adopted"): - sys.exit(2) - return - +def _collect_proposal_files( + state: dict[str, Any], + entry: dict[str, Any], +) -> dict[str, list[dict[str, Any]]]: + """各 runtime の提案結果ファイルを読み、runtime ごとの提案リストを返す。""" proposals: dict[str, list[dict[str, Any]]] = {} for runtime in state["runtimes"]: result = _result_path( @@ -1028,6 +1009,34 @@ def cmd_merge_proposals(args: argparse.Namespace) -> None: 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 — 提案をマージして改善項目を作る。 + + 終了コード: 0 = 採用あり / 2 = 採用 0 件(提案ラウンドの繰り返しを終える)。 + + **同じラウンドで叩き直しても二重に項目を作らない。** 進行を止めても再開できる + ことが前提なので、統合済みなら前回と同じ結果をそのまま返す。 + """ + path, state = _load(args.id) + entry = _current_round(state) + + if entry.get("proposal_keys") is not None: + info( + f"↻ 提案ラウンド {entry['round']} は統合済みです" + f"(採用 {entry.get('adopted', 0)} 件 / 見送り {entry.get('deferred', 0)} 件)" + ) + for item_id in entry.get("items", []): + item = _find_item(state, item_id, required=False) + if item is not None: + info(f" {item_id} [{item['severity']}] {item['path']}#{item['symbol']}") + if not entry.get("adopted"): + sys.exit(2) + return + + proposals = _collect_proposal_files(state, entry) excluded = { (d["path"], d["symbol"], d["smell"]) for d in state["deferred_items"] From fe86378c33817284b874d808978b8b57e6185de5 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 13:11:30 +0000 Subject: [PATCH 77/95] =?UTF-8?q?Reapply=20"Refactor:=20introduce=5Fparame?= =?UTF-8?q?ter=5Fobject=20=E2=80=94=20plugins/ndf-shared/skills/cross-revi?= =?UTF-8?q?ew/scripts/lib/monitor.py#monitor=5Fagent"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 93f014f35d9a600486cef071fafbf123b724b763. --- .../cross-review/scripts/lib/monitor.py | 54 +++++++++++++++---- 1 file changed, 45 insertions(+), 9 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 cd6b3ecb..f5f9b9d2 100755 --- a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py @@ -389,6 +389,17 @@ class AgentStatus: sentinel_seen: bool = False +@dataclass +class MonitorConfig: + timeout: int + stall_timeout: int + poll: int + require_result: bool + no_early_error: bool = False + log_prefix: str = "" + stem_template: str = DEFAULT_STEM_TEMPLATE + + # ---------- 監視ロジック ---------- def _read_pidfile(p: pathlib.Path) -> Optional[int]: @@ -798,19 +809,40 @@ def update_progress_and_check_stall( def monitor_agent( agent: str, pr: int, - timeout: int, - stall_timeout: int, - poll: int, - require_result: bool, + timeout: Optional[int] = None, + stall_timeout: Optional[int] = None, + poll: Optional[int] = None, + require_result: Optional[bool] = None, no_early_error: bool = False, log_prefix: str = "", stem_template: str = DEFAULT_STEM_TEMPLATE, + config: Optional[MonitorConfig] = None, ) -> AgentStatus: """1 agent を監視する。 `no_early_error=True` のとき、EARLY_ERROR 検知 (FATAL/WARN とも) を完全に無効化し、 hard timeout / stall / sentinel / result.json のみで判定する。 """ + if config is None: + if timeout is None or stall_timeout is None or poll is None or require_result is None: + raise TypeError("timeout, stall_timeout, poll, require_result are required") + config = MonitorConfig( + timeout=timeout, + stall_timeout=stall_timeout, + poll=poll, + require_result=require_result, + no_early_error=no_early_error, + log_prefix=log_prefix, + stem_template=stem_template, + ) + timeout = config.timeout + stall_timeout = config.stall_timeout + poll = config.poll + require_result = config.require_result + no_early_error = config.no_early_error + log_prefix = config.log_prefix + stem_template = config.stem_template + paths = AgentPaths.for_(agent, pr, stem_template) started = time.monotonic() @@ -1006,11 +1038,15 @@ def run(agent: str) -> None: else _agent_stall_default(agent) results[agent] = monitor_agent( agent=agent, pr=args.pr, - timeout=args.timeout, stall_timeout=stall, - poll=args.poll, require_result=require_result, - no_early_error=args.no_early_error, - log_prefix=f"[{agent}] ", - stem_template=args.stem_template, + config=MonitorConfig( + timeout=args.timeout, + stall_timeout=stall, + poll=args.poll, + require_result=require_result, + no_early_error=args.no_early_error, + log_prefix=f"[{agent}] ", + stem_template=args.stem_template, + ), ) threads = [threading.Thread(target=run, args=(a,), daemon=False) for a in agents] From cbd1222eb81aa863745aa3ff748ec2f23e9ed263 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 13:11:30 +0000 Subject: [PATCH 78/95] =?UTF-8?q?Reapply=20"Refactor:=20consolidate=5Fdupl?= =?UTF-8?q?ication=20=E2=80=94=20plugins/ndf-shared/skills/cross-review/sc?= =?UTF-8?q?ripts/lib/monitor.py#=5Fscan=5Fpatterns"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit f13fb0845c04011bdf684ba248e9072f991e21d8. --- .../cross-review/scripts/lib/monitor.py | 55 +++++++------------ 1 file changed, 21 insertions(+), 34 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 f5f9b9d2..7945f4c4 100755 --- a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py @@ -503,15 +503,8 @@ def _scan_patterns( 扱いしてしまった (例: `Error in: mcpServers.serena\\n...\\nTraceback ...` で Traceback が誤抑制された)。 """ - 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: + data = _read_tail(path, 200 * 1024) + if data is None: return None data = _strip_ansi(data) benign_patterns = EARLY_ERROR_BENIGN if benign is None else benign @@ -560,16 +553,10 @@ def _scan_claude_stdout_fatal(path: pathlib.Path) -> Optional[str]: 行うが、JSON は 1 行に多数の引用符を含むため、パリティ判定が「引用の内側」を 誤って真にして致命を取りこぼす。 """ - 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 = _strip_ansi(f.read().decode("utf-8", errors="replace")) - except OSError: + data = _read_tail(path, 200 * 1024) + if data is None: return None + data = _strip_ansi(data) for pat in CLAUDE_STDOUT_FATAL: m = pat.search(data) if m: @@ -577,17 +564,24 @@ def _scan_claude_stdout_fatal(path: pathlib.Path) -> Optional[str]: return None -def _scan_codex_sentinel(path: pathlib.Path) -> bool: +def _read_tail(path: pathlib.Path, limit: int) -> Optional[str]: + """ファイル末尾を安全に読み、壊れた UTF-8 は置換して返す。""" if not path.exists(): - return False + return None try: - # 末尾 64KB を読む(sentinel は最後の方に出る) sz = path.stat().st_size with path.open("rb") as f: - if sz > 64 * 1024: - f.seek(sz - 64 * 1024) - tail = f.read().decode("utf-8", errors="replace") + if sz > limit: + f.seek(sz - limit) + return f.read().decode("utf-8", errors="replace") except OSError: + return None + + +def _scan_codex_sentinel(path: pathlib.Path) -> bool: + # 末尾 64KB を読む(sentinel は最後の方に出る) + tail = _read_tail(path, 64 * 1024) + if tail is None: return False return bool(CODEX_SENTINEL.search(tail)) @@ -605,17 +599,10 @@ def _tail_last_nonempty_line(path: pathlib.Path, limit: int = 4096) -> str: Gemini に書かせるのは短いフェーズマーカーだけなので、末尾数 KB で十分。 壊れた UTF-8 や読み取り競合があっても monitor 自体は落とさない。 """ - if not path.exists(): - return "" - try: - sz = path.stat().st_size - with path.open("rb") as f: - if sz > limit: - f.seek(sz - limit) - data = f.read().decode("utf-8", errors="replace") - except OSError: + data = _read_tail(path, limit) + if data is None: return "" - if sz > limit and "\n" in data: + if _safe_size(path) > limit and "\n" in data: data = data.split("\n", 1)[1] for line in reversed(data.splitlines()): stripped = line.strip() From 685e6e5980b30f0723309fa6d6d57bd18d5e9cb6 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 13:11:30 +0000 Subject: [PATCH 79/95] =?UTF-8?q?Revert=20"Refactor:=20consolidate=5Fdupli?= =?UTF-8?q?cation=20=E2=80=94=20plugins/ndf-shared/skills/cross-review/scr?= =?UTF-8?q?ipts/lib/monitor.py#=5Fscan=5Fpatterns"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit c80bff42df91640dc0f09d5b324d89772f88eeb5. --- .../cross-review/scripts/lib/monitor.py | 55 ++++++++++++------- 1 file changed, 34 insertions(+), 21 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 7945f4c4..f5f9b9d2 100755 --- a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py @@ -503,8 +503,15 @@ def _scan_patterns( 扱いしてしまった (例: `Error in: mcpServers.serena\\n...\\nTraceback ...` で Traceback が誤抑制された)。 """ - data = _read_tail(path, 200 * 1024) - if data is 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 data = _strip_ansi(data) benign_patterns = EARLY_ERROR_BENIGN if benign is None else benign @@ -553,10 +560,16 @@ def _scan_claude_stdout_fatal(path: pathlib.Path) -> Optional[str]: 行うが、JSON は 1 行に多数の引用符を含むため、パリティ判定が「引用の内側」を 誤って真にして致命を取りこぼす。 """ - data = _read_tail(path, 200 * 1024) - if data is 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 = _strip_ansi(f.read().decode("utf-8", errors="replace")) + except OSError: return None - data = _strip_ansi(data) for pat in CLAUDE_STDOUT_FATAL: m = pat.search(data) if m: @@ -564,24 +577,17 @@ def _scan_claude_stdout_fatal(path: pathlib.Path) -> Optional[str]: return None -def _read_tail(path: pathlib.Path, limit: int) -> Optional[str]: - """ファイル末尾を安全に読み、壊れた UTF-8 は置換して返す。""" +def _scan_codex_sentinel(path: pathlib.Path) -> bool: if not path.exists(): - return None + return False try: + # 末尾 64KB を読む(sentinel は最後の方に出る) sz = path.stat().st_size with path.open("rb") as f: - if sz > limit: - f.seek(sz - limit) - return f.read().decode("utf-8", errors="replace") + if sz > 64 * 1024: + f.seek(sz - 64 * 1024) + tail = f.read().decode("utf-8", errors="replace") except OSError: - return None - - -def _scan_codex_sentinel(path: pathlib.Path) -> bool: - # 末尾 64KB を読む(sentinel は最後の方に出る) - tail = _read_tail(path, 64 * 1024) - if tail is None: return False return bool(CODEX_SENTINEL.search(tail)) @@ -599,10 +605,17 @@ def _tail_last_nonempty_line(path: pathlib.Path, limit: int = 4096) -> str: Gemini に書かせるのは短いフェーズマーカーだけなので、末尾数 KB で十分。 壊れた UTF-8 や読み取り競合があっても monitor 自体は落とさない。 """ - data = _read_tail(path, limit) - if data is None: + if not path.exists(): + return "" + try: + sz = path.stat().st_size + with path.open("rb") as f: + if sz > limit: + f.seek(sz - limit) + data = f.read().decode("utf-8", errors="replace") + except OSError: return "" - if _safe_size(path) > limit and "\n" in data: + if sz > limit and "\n" in data: data = data.split("\n", 1)[1] for line in reversed(data.splitlines()): stripped = line.strip() From 51950c40cf3ef12a8c77211603f7a0695a6860ab Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 13:11:30 +0000 Subject: [PATCH 80/95] =?UTF-8?q?Revert=20"Refactor:=20introduce=5Fparamet?= =?UTF-8?q?er=5Fobject=20=E2=80=94=20plugins/ndf-shared/skills/cross-revie?= =?UTF-8?q?w/scripts/lib/monitor.py#monitor=5Fagent"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit e5185a394dce3d19f13222f81581c81c8dbd3fd2. --- .../cross-review/scripts/lib/monitor.py | 54 ++++--------------- 1 file changed, 9 insertions(+), 45 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 f5f9b9d2..cd6b3ecb 100755 --- a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py @@ -389,17 +389,6 @@ class AgentStatus: sentinel_seen: bool = False -@dataclass -class MonitorConfig: - timeout: int - stall_timeout: int - poll: int - require_result: bool - no_early_error: bool = False - log_prefix: str = "" - stem_template: str = DEFAULT_STEM_TEMPLATE - - # ---------- 監視ロジック ---------- def _read_pidfile(p: pathlib.Path) -> Optional[int]: @@ -809,40 +798,19 @@ def update_progress_and_check_stall( def monitor_agent( agent: str, pr: int, - timeout: Optional[int] = None, - stall_timeout: Optional[int] = None, - poll: Optional[int] = None, - require_result: Optional[bool] = None, + timeout: int, + stall_timeout: int, + poll: int, + require_result: bool, no_early_error: bool = False, log_prefix: str = "", stem_template: str = DEFAULT_STEM_TEMPLATE, - config: Optional[MonitorConfig] = None, ) -> AgentStatus: """1 agent を監視する。 `no_early_error=True` のとき、EARLY_ERROR 検知 (FATAL/WARN とも) を完全に無効化し、 hard timeout / stall / sentinel / result.json のみで判定する。 """ - if config is None: - if timeout is None or stall_timeout is None or poll is None or require_result is None: - raise TypeError("timeout, stall_timeout, poll, require_result are required") - config = MonitorConfig( - timeout=timeout, - stall_timeout=stall_timeout, - poll=poll, - require_result=require_result, - no_early_error=no_early_error, - log_prefix=log_prefix, - stem_template=stem_template, - ) - timeout = config.timeout - stall_timeout = config.stall_timeout - poll = config.poll - require_result = config.require_result - no_early_error = config.no_early_error - log_prefix = config.log_prefix - stem_template = config.stem_template - paths = AgentPaths.for_(agent, pr, stem_template) started = time.monotonic() @@ -1038,15 +1006,11 @@ def run(agent: str) -> None: else _agent_stall_default(agent) results[agent] = monitor_agent( agent=agent, pr=args.pr, - config=MonitorConfig( - timeout=args.timeout, - stall_timeout=stall, - poll=args.poll, - require_result=require_result, - no_early_error=args.no_early_error, - log_prefix=f"[{agent}] ", - stem_template=args.stem_template, - ), + timeout=args.timeout, stall_timeout=stall, + poll=args.poll, require_result=require_result, + no_early_error=args.no_early_error, + log_prefix=f"[{agent}] ", + stem_template=args.stem_template, ) threads = [threading.Thread(target=run, args=(a,), daemon=False) for a in agents] From 23b5e1763a40f9ba54c035011d8bb76fbba4176f Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 13:11:30 +0000 Subject: [PATCH 81/95] =?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=5Fproposals"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit ad295d4cfc64454e4c1870c39437d6e4b9812a7f. --- .../cross-refactoring/scripts/refactor.py | 57 ++++++++----------- 1 file changed, 24 insertions(+), 33 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index b048cf04..b9be964c 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -976,11 +976,30 @@ def cmd_start_round(args: argparse.Namespace) -> None: ) -def _collect_proposal_files( - state: dict[str, Any], - entry: dict[str, Any], -) -> dict[str, list[dict[str, Any]]]: - """各 runtime の提案結果ファイルを読み、runtime ごとの提案リストを返す。""" +def cmd_merge_proposals(args: argparse.Namespace) -> None: + """Step 3 — 提案をマージして改善項目を作る。 + + 終了コード: 0 = 採用あり / 2 = 採用 0 件(提案ラウンドの繰り返しを終える)。 + + **同じラウンドで叩き直しても二重に項目を作らない。** 進行を止めても再開できる + ことが前提なので、統合済みなら前回と同じ結果をそのまま返す。 + """ + path, state = _load(args.id) + entry = _current_round(state) + + if entry.get("proposal_keys") is not None: + info( + f"↻ 提案ラウンド {entry['round']} は統合済みです" + f"(採用 {entry.get('adopted', 0)} 件 / 見送り {entry.get('deferred', 0)} 件)" + ) + for item_id in entry.get("items", []): + item = _find_item(state, item_id, required=False) + if item is not None: + info(f" {item_id} [{item['severity']}] {item['path']}#{item['symbol']}") + if not entry.get("adopted"): + sys.exit(2) + return + proposals: dict[str, list[dict[str, Any]]] = {} for runtime in state["runtimes"]: result = _result_path( @@ -1009,34 +1028,6 @@ def _collect_proposal_files( 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 — 提案をマージして改善項目を作る。 - - 終了コード: 0 = 採用あり / 2 = 採用 0 件(提案ラウンドの繰り返しを終える)。 - - **同じラウンドで叩き直しても二重に項目を作らない。** 進行を止めても再開できる - ことが前提なので、統合済みなら前回と同じ結果をそのまま返す。 - """ - path, state = _load(args.id) - entry = _current_round(state) - - if entry.get("proposal_keys") is not None: - info( - f"↻ 提案ラウンド {entry['round']} は統合済みです" - f"(採用 {entry.get('adopted', 0)} 件 / 見送り {entry.get('deferred', 0)} 件)" - ) - for item_id in entry.get("items", []): - item = _find_item(state, item_id, required=False) - if item is not None: - info(f" {item_id} [{item['severity']}] {item['path']}#{item['symbol']}") - if not entry.get("adopted"): - sys.exit(2) - return - - proposals = _collect_proposal_files(state, entry) excluded = { (d["path"], d["symbol"], d["smell"]) for d in state["deferred_items"] From 4ad30db73a4af3fad9e769de9ffe8e7349331cb7 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 13:11:30 +0000 Subject: [PATCH 82/95] =?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 e3000487303020d5c2e602e4532fdf5aa4f5af1f. --- .../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 b9be964c..fdfd6eae 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1543,31 +1543,6 @@ def _resume_incomplete_apply( _flush_pending_push(path, state, entry) -def _build_review_record( - reviews: dict[str, dict[str, Any]], - reviewers: list[str], - round_no: int, -) -> dict[str, Any]: - """レビュー結果から state に保存する findings 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_judge_review(args: argparse.Namespace) -> None: """Step 5 — レビュー 2 者の判定を取り込む。 @@ -1610,7 +1585,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 c9aa502c394eb6aa8e59b8e4f60afb5d6c602d62 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 13:11:30 +0000 Subject: [PATCH 83/95] =?UTF-8?q?Revert=20"Refactor:=20split=5Finto=5Fpipe?= =?UTF-8?q?line=20=E2=80=94=20plugins/ndf-shared/skills/cross-refactoring/?= =?UTF-8?q?scripts/refactor.py#cmd=5Fmerge=5Ffix"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit bd3f0b70f258d7b875b5c66c6b98015e542ef13d. --- .../cross-refactoring/scripts/refactor.py | 22 +++++-------------- 1 file changed, 6 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 fdfd6eae..0a2334cd 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1833,21 +1833,6 @@ def evaluate_fix_commits( return problems, accepted -def reject_fix_range( - path: pathlib.Path, - state: dict[str, Any], - entry: dict[str, Any], - ordered_range: list[str], -) -> None: - """検証を通らない修正範囲を取り消し、次回用の起点を保存する。""" - _revert_unverified_range( - state, entry, ordered_range, - "検証を通らない変更を残さないため、この修正ラウンドの範囲を取り消します", - path, - item_id=f"R{entry['round']}-fix{entry['fix_rounds'] + 1}", - ) - - def cmd_merge_fix(args: argparse.Namespace) -> None: """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" path, state = _load(args.id) @@ -1916,7 +1901,12 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: if unassigned or problems: # **状態へ記録する前に取り消す。** 先に記録すると、取り消し済みのコミットが # 状態ファイルに残り、後の見送り処理が同じコミットをもう一度取り消そうとする。 - reject_fix_range(path, state, entry, ordered_range) + _revert_unverified_range( + state, entry, ordered_range, + "検証を通らない変更を残さないため、この修正ラウンドの範囲を取り消します", + path, + item_id=f"R{entry['round']}-fix{entry['fix_rounds'] + 1}", + ) # 取り消し後の状態を新しい起点にし、**その場で保存する**。ここで保存せずに # 落ちると、次の実行は古い起点から範囲を取り直して取り消しコミット自体を # 「未申告」と判定し、**取り消しを取り消して**しまう。 From 063cdf862191a559c0c99afb903f88c1f52d83e0 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 13:11:30 +0000 Subject: [PATCH 84/95] =?UTF-8?q?Revert=20"Refactor:=20split=5Finto=5Fpipe?= =?UTF-8?q?line=20=E2=80=94=20plugins/ndf-shared/skills/cross-refactoring/?= =?UTF-8?q?scripts/refactor.py#cmd=5Fmerge=5Ffix"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 53706cfd2092b0c9a7af4b6220214afa9dbce3dd. --- .../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 0a2334cd..00915d63 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1815,24 +1815,6 @@ def collect_fix_range_report( return ordered_range, unassigned, facts -def evaluate_fix_commits( - facts: list[dict[str, Any]], - scope: list[str], -) -> tuple[list[str], list[tuple[str, str]]]: - """修正コミット facts を検証し、問題と採用コミットを返す。""" - 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 problems, accepted - - def cmd_merge_fix(args: argparse.Namespace) -> None: """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" path, state = _load(args.id) @@ -1890,7 +1872,16 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: # 状態を記録しないだけでは、未検証の変更が Pull Request に残り続ける # (見送りの対象にもならない)。どのコミットが安全かは決められないので、 # 適用フェーズの未割当コミットと同じ扱いにする。 - problems, accepted = evaluate_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 4578ba6867a4c4be3935b4455d0d68d376949754 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 13:11:30 +0000 Subject: [PATCH 85/95] =?UTF-8?q?Revert=20"Refactor:=20split=5Finto=5Fpipe?= =?UTF-8?q?line=20=E2=80=94=20plugins/ndf-shared/skills/cross-refactoring/?= =?UTF-8?q?scripts/refactor.py#cmd=5Fmerge=5Ffix"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 3685c22658f484800f5fa6134092204d5be3d9d6. --- .../cross-refactoring/scripts/refactor.py | 58 ++++++++----------- 1 file changed, 23 insertions(+), 35 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 00915d63..6fe12b58 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1782,39 +1782,6 @@ def resolve_claimed_threads(payload: dict[str, Any], state: dict[str, Any]) -> s return resolved -def collect_fix_range_report( - entry: dict[str, Any], - state: dict[str, Any], - payload: dict[str, Any], - work: str, - head_now: str, -) -> tuple[list[str], list[str], list[dict[str, Any]]]: - """修正範囲を確定し、申告外コミットと commit facts を返す。""" - baseline = state.get("baseline_test") or {} - ordered_range = commits_in_range(work, entry.get("fix_base_sha"), head_now) - if ordered_range is None: - die( - "修正の範囲を確定できませんでした" - f"(起点 {entry.get('fix_base_sha')} / HEAD {head_now})。" - "検証できない修正は採りません", - code=2, - ) - reported_shas = _reported_shas(payload) - reported_full = { - full for full in ( - _git_out(work, ["rev-parse", "--verify", f"{s}^{{commit}}"]) - for s in reported_shas - ) if full - } - unassigned = sorted(set(ordered_range) - reported_full) - facts = collect_commit_facts( - work, reported_shas, set(ordered_range), - baseline.get("command") or "true", state["head_branch"], - _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), - ) - return ordered_range, unassigned, facts - - def cmd_merge_fix(args: argparse.Namespace) -> None: """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" path, state = _load(args.id) @@ -1860,12 +1827,33 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: # 修正コミットも適用と同じ基準で、**git と実際のテスト実行から**検証する。 # 結果ファイルの申告で済ませると、手順を満たさない変更が収束済みになれてしまう。 + baseline = state.get("baseline_test") or {} # 修正の範囲も**オーケストレータが記録した起点**から取る。起点は # `judge-review` が変更要求を返したときの HEAD である。 + ordered_range = commits_in_range(work, entry.get("fix_base_sha"), head_now) + if ordered_range is None: + die( + "修正の範囲を確定できませんでした" + f"(起点 {entry.get('fix_base_sha')} / HEAD {head_now})。" + "検証できない修正は採りません", + code=2, + ) + reported_shas = _reported_shas(payload) + # 適用と同じく、**範囲のコミットは全て申告されていること**を求める。 # 申告から漏れた修正コミットは検証を受けないまま Pull Request に残る。 - ordered_range, unassigned, facts = collect_fix_range_report( - entry, state, payload, work, head_now, + reported_full = { + full for full in ( + _git_out(work, ["rev-parse", "--verify", f"{s}^{{commit}}"]) + for s in reported_shas + ) if full + } + unassigned = sorted(set(ordered_range) - reported_full) + + facts = collect_commit_facts( + work, reported_shas, set(ordered_range), + baseline.get("command") or "true", state["head_branch"], + _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), ) # **不正なコミットが 1 件でもあれば、修正ラウンドの範囲ごと取り消す。** From 8ac48828c0f47347b7b87ee38af56e1a730e41dd Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 13:11:30 +0000 Subject: [PATCH 86/95] =?UTF-8?q?Revert=20"Refactor:=20split=5Finto=5Fpipe?= =?UTF-8?q?line=20=E2=80=94=20plugins/ndf-shared/skills/cross-refactoring/?= =?UTF-8?q?scripts/refactor.py#cmd=5Fmerge=5Ffix"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit b535305aa54e39da6801be2e3c72b22275055991. --- .../cross-refactoring/scripts/refactor.py | 40 ++++++++----------- 1 file changed, 17 insertions(+), 23 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 6fe12b58..37ff664f 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1760,28 +1760,6 @@ def already_merged_fix(entry: dict[str, Any], merge_key: str) -> bool: return merge_key in entry.setdefault("fix_merged_keys", []) -def resolve_claimed_threads(payload: dict[str, Any], state: dict[str, Any]) -> set[str]: - """自己申告された解決 thread を 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(state["repo"], state["current_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_fix(args: argparse.Namespace) -> None: """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" path, state = _load(args.id) @@ -1821,9 +1799,25 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: # 自己申告をそのまま信じない。解決 API に失敗・未実行でも「解決済み」と # 書けてしまい、未解決の指摘が取り消し対象から外れる。GitHub 側の # `isResolved` と突き合わせ、**両方が解決と言っているものだけ**を反映する。 + raw_claimed = payload.get("resolved_thread_ids") # 文字列は 1 文字ずつに分解され、数値や真偽値は反復できずに落ちる。 # **配列であることを先に確かめる。** - resolved = resolve_claimed_threads(payload, state) + 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 d2270621b41d3bd0988ce8638967767ae5cda6f1 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 13:11:30 +0000 Subject: [PATCH 87/95] =?UTF-8?q?Revert=20"Refactor:=20split=5Finto=5Fpipe?= =?UTF-8?q?line=20=E2=80=94=20plugins/ndf-shared/skills/cross-refactoring/?= =?UTF-8?q?scripts/refactor.py#cmd=5Fmerge=5Ffix"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 685774bf4136800a740e849ee841822bdcdc4555. --- .../cross-refactoring/scripts/refactor.py | 17 +++++------------ 1 file changed, 5 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 37ff664f..f1927d96 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1750,16 +1750,6 @@ def cmd_abandon_items(args: argparse.Namespace) -> None: statefile.save(path, state) -def build_fix_merge_key(result: pathlib.Path, attempt: int) -> str: - """修正結果の冪等キーを試行番号と結果ファイル内容から作る。""" - return f"{attempt}:" + hashlib.sha256(result.read_bytes()).hexdigest() - - -def already_merged_fix(entry: dict[str, Any], merge_key: str) -> bool: - """同じ修正結果がすでに取り込み済みかを返す。""" - return merge_key in entry.setdefault("fix_merged_keys", []) - - def cmd_merge_fix(args: argparse.Namespace) -> None: """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" path, state = _load(args.id) @@ -1787,9 +1777,12 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: work = state["worktrees"]["work"] head_now = _git_out(work, ["rev-parse", "HEAD"]) or "" attempt = entry.get("fix_attempts", 0) - merge_key = build_fix_merge_key(result, attempt) + merge_key = ( + f"{attempt}:" + + hashlib.sha256(result.read_bytes()).hexdigest() + ) merged_keys = entry.setdefault("fix_merged_keys", []) - if already_merged_fix(entry, merge_key): + if merge_key in merged_keys: info( f"↻ この修正結果は取り込み済みです" f"(修正ラウンド {entry['fix_rounds']})" From 027da765a860ee537f0585ad68ab70bcadcc5f52 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 13:11:30 +0000 Subject: [PATCH 88/95] =?UTF-8?q?Revert=20"Refactor:=20consolidate=5Fdupli?= =?UTF-8?q?cation=20=E2=80=94=20plugins/ndf-shared/skills/cross-refactorin?= =?UTF-8?q?g/scripts/refactor.py#cmd=5Fmerge=5Ffix"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit b884ae86804a09daafa380628f8ca395af6cd8f1. --- .../cross-refactoring/scripts/refactor.py | 66 ++++++++----------- 1 file changed, 26 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 f1927d96..5ac4ad5e 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1208,36 +1208,6 @@ def _verify_and_classify_items( return applied, failed -def _revert_unverified_range( - state: dict[str, Any], - entry: dict[str, Any], - ordered_range: list[str], - reason: str, - path: pathlib.Path, - *, - dry_run: bool = False, - base_key: str = "fix_base_sha", - item_id: Optional[str] = None, -) -> Optional[str]: - """検証できない範囲を取り消し、再実行用の起点 SHA を更新する。""" - if reason: - info(reason) - whole_range = { - "item_id": item_id or 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_range, dry_run) - new_base = None - if not dry_run: - new_base = _git_out(state["worktrees"]["work"], ["rev-parse", "HEAD"]) - entry[base_key] = new_base - statefile.save(path, state) - return new_base - - def cmd_merge_apply(args: argparse.Namespace) -> None: """Step 4 — 適用結果を検証して取り込む。 @@ -1348,11 +1318,20 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: # 範囲全体を取り消す。どのコミットが安全かを決められない以上、 # 起点まで戻すのが最も確実である。順序は `_revert_item_commits` が # git の履歴から決め直す。 - _revert_unverified_range( - state, entry, ordered_range, "", - path, dry_run=args.dry_run, base_key="apply_base_sha", - item_id=f"R{entry['round']}-range", - ) + 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, @@ -1867,15 +1846,22 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: if unassigned or problems: # **状態へ記録する前に取り消す。** 先に記録すると、取り消し済みのコミットが # 状態ファイルに残り、後の見送り処理が同じコミットをもう一度取り消そうとする。 - _revert_unverified_range( - state, entry, ordered_range, - "検証を通らない変更を残さないため、この修正ラウンドの範囲を取り消します", - path, - item_id=f"R{entry['round']}-fix{entry['fix_rounds'] + 1}", + info("検証を通らない変更を残さないため、この修正ラウンドの範囲を取り消します") + # **取り消しへ着手する前に印を立てる。** 取り消しは済んだのに push できずに + # 終わると、未検証の変更が Pull Request に残ったままになる。 + entry["pending_push"] = True + statefile.save(path, state) + _revert_item_commits( + state, + {"item_id": f"R{entry['round']}-fix{entry['fix_rounds'] + 1}", + "commits": list(ordered_range)}, + dry_run=False, ) # 取り消し後の状態を新しい起点にし、**その場で保存する**。ここで保存せずに # 落ちると、次の実行は古い起点から範囲を取り直して取り消しコミット自体を # 「未申告」と判定し、**取り消しを取り消して**しまう。 + entry["fix_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) + statefile.save(path, state) # **push は保存のあと。** ここで push して失敗すると、取り消しコミットは # ローカルに残るのに起点の更新が保存されず、叩き直しで二重に取り消してしまう。 info("⚠ 修正を取り消したため、解決の申告は採用しません") From 0f7d0dc1499cda8dcbfd8a26d0f153c08305a571 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 13:11:30 +0000 Subject: [PATCH 89/95] =?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 1ba3f771b6fbfd9c9593f2611eaab3ade2bfd5c4. --- .../cross-review/scripts/lib/monitor.py | 63 +++++++------------ 1 file changed, 21 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 cd6b3ecb..a246968d 100755 --- a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py @@ -758,43 +758,6 @@ def finish_exited_process( return status -def update_progress_and_check_stall( - agent: str, - paths: AgentPaths, - status: AgentStatus, - pid: int, - alive: bool, - stall_timeout: int, - last_progress_size: int, - last_progress: float, - log_prefix: str, -) -> tuple[Optional[AgentStatus], int, float]: - """ログ進捗を更新し、stall_timeout 超過なら終了 status を返す。""" - status.err_log_size = _safe_size(paths.err_log) - status.stdout_log_size = _safe_size(paths.stdout_log) - status.progress_log_size = _safe_size(paths.progress_log) - status.progress_tail = _tail_last_nonempty_line(paths.progress_log) - progress_size = ( - status.err_log_size + status.stdout_log_size + status.progress_log_size - ) - if progress_size != last_progress_size: - last_progress_size = progress_size - last_progress = time.monotonic() - status.idle_seconds = time.monotonic() - last_progress - if status.idle_seconds >= stall_timeout: - if alive: - _kill_pid(pid) - status.status = "STALLED" - status.exit_code = 5 - status.detail = ( - f"no log progress for {stall_timeout}s " - f"(pid {pid}, last size {last_progress_size}B)" - ) - _emit_log(log_prefix, agent, status) - return status, last_progress_size, last_progress - return None, last_progress_size, last_progress - - def monitor_agent( agent: str, pr: int, @@ -911,12 +874,28 @@ def monitor_agent( # gemini は stdout 側だけ進捗が出るケースがあり、progress.log には # launcher が要求した短いフェーズマーカーが出るため、いずれかが # 更新されれば progress として扱う) - stalled, last_progress_size, last_progress = update_progress_and_check_stall( - agent, paths, status, pid, alive, stall_timeout, - last_progress_size, last_progress, log_prefix, + status.err_log_size = _safe_size(paths.err_log) + status.stdout_log_size = _safe_size(paths.stdout_log) + status.progress_log_size = _safe_size(paths.progress_log) + status.progress_tail = _tail_last_nonempty_line(paths.progress_log) + progress_size = ( + status.err_log_size + status.stdout_log_size + status.progress_log_size ) - if stalled is not None: - return stalled + if progress_size != last_progress_size: + last_progress_size = progress_size + last_progress = time.monotonic() + status.idle_seconds = time.monotonic() - last_progress + if status.idle_seconds >= stall_timeout: + if alive: + _kill_pid(pid) + status.status = "STALLED" + status.exit_code = 5 + status.detail = ( + f"no log progress for {stall_timeout}s " + f"(pid {pid}, last size {last_progress_size}B)" + ) + _emit_log(log_prefix, agent, status) + return status # poll 中の進捗ログ _emit_progress(log_prefix, agent, status) From 514159a6c0fcba820b6ea1d5c8569f3e74b0f0a1 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 13:11:30 +0000 Subject: [PATCH 90/95] =?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 f2109197d795dc20550f1c2726969525239ed301. --- .../cross-review/scripts/lib/monitor.py | 39 +++++++------------ 1 file changed, 14 insertions(+), 25 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 a246968d..93c8e117 100755 --- a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py @@ -734,30 +734,6 @@ def check_early_error( return None, warned_early_error -def finish_exited_process( - agent: str, - paths: AgentPaths, - status: AgentStatus, - require_result: bool, - log_prefix: str, -) -> AgentStatus: - """終了済みプロセスの result.json 有無から最終 status を作る。""" - status.result_exists = paths.result.exists() and paths.result.stat().st_size > 0 - if status.result_exists or not require_result: - status.status = "OK" - status.exit_code = 0 - status.detail = ( - f"process exited; sentinel={status.sentinel_seen}; " - f"result_exists={status.result_exists}" - ) - else: - status.status = "NO_RESULT" - status.exit_code = 3 - status.detail = f"process exited but result.json missing: {paths.result}" - _emit_log(log_prefix, agent, status) - return status - - def monitor_agent( agent: str, pr: int, @@ -868,7 +844,20 @@ def monitor_agent( if not alive: # プロセス終了 — result.json を確認 - return finish_exited_process(agent, paths, status, require_result, log_prefix) + status.result_exists = paths.result.exists() and paths.result.stat().st_size > 0 + if status.result_exists or not require_result: + status.status = "OK" + status.exit_code = 0 + status.detail = ( + f"process exited; sentinel={status.sentinel_seen}; " + f"result_exists={status.result_exists}" + ) + else: + status.status = "NO_RESULT" + status.exit_code = 3 + status.detail = f"process exited but result.json missing: {paths.result}" + _emit_log(log_prefix, agent, status) + return status # 4. stall detection (err.log / stdout.log / progress.log をモニタ。 # gemini は stdout 側だけ進捗が出るケースがあり、progress.log には From 6b9d58131835a6a3bee36d80431799e24376bfeb Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 13:11:30 +0000 Subject: [PATCH 91/95] =?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 dd7f9a8dfbd0f4a0cfec13fada0598676835d54d. --- .../cross-review/scripts/lib/monitor.py | 74 +++++++------------ 1 file changed, 28 insertions(+), 46 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 93c8e117..f89eda77 100755 --- a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py @@ -693,47 +693,6 @@ def try_complete_lingering_process( return None -def check_early_error( - agent: str, - paths: AgentPaths, - status: AgentStatus, - pid: int, - alive: bool, - warned_early_error: bool, - log_prefix: str, -) -> tuple[Optional[AgentStatus], bool]: - """early error の fatal/warn を判定し、fatal 時は終了 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, 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, @@ -836,11 +795,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, paths, status, pid, 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 2e8fd8fb6127d3b9e8d479c45c661c2d75a06df3 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 13:11:30 +0000 Subject: [PATCH 92/95] =?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 cfb9d48facdd3effe1478f86068c623c5104bf41. --- .../cross-review/scripts/lib/monitor.py | 100 +++++++----------- 1 file changed, 40 insertions(+), 60 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 f89eda77..997b28b1 100755 --- a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py @@ -638,61 +638,6 @@ def read_started_agent( return status -def try_complete_lingering_process( - agent: str, - paths: AgentPaths, - status: AgentStatus, - pid: int, - alive: bool, - cmdline_validated: bool, - started_wall: float, - log_prefix: str, -) -> Optional[AgentStatus]: - """result.json を出したまま残ったプロセスを完了扱いにできるか判定する。""" - if agent == "codex": - status.sentinel_seen = _scan_codex_sentinel(paths.err_log) - - 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, @@ -748,16 +693,29 @@ def monitor_agent( # 1. プロセス生存確認 → 死んでいたら最終判定へ (result.json 存在をチェック) alive = _pid_alive(pid) + 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 判定で返す。 - completed = try_complete_lingering_process( - agent, paths, status, pid, alive, cmdline_validated, started_wall, log_prefix, - ) - if completed is not None: - return completed + 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 向け @@ -766,6 +724,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 3a01837c02b2f72d2abff5dfbe42d7d734eeda53 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 13:11:30 +0000 Subject: [PATCH 93/95] =?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 9b7f7c06ff38df695b18791bddec600a176c7f92. --- .../cross-review/scripts/lib/monitor.py | 46 +++++-------------- 1 file changed, 11 insertions(+), 35 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 997b28b1..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,31 +613,6 @@ def _tail_last_nonempty_line(path: pathlib.Path, limit: int = 4096) -> str: return "" -def read_started_agent( - agent: str, - paths: AgentPaths, - started: float, - log_prefix: str, -) -> Optional[AgentStatus]: - """pidfile を待ち、監視開始に必要な AgentStatus を返す。""" - status = AgentStatus(agent=agent) - grace_end = started + 30 - while time.monotonic() < grace_end: - if paths.pidfile.exists(): - break - time.sleep(2) - pid = _read_pidfile(paths.pidfile) - if pid is None: - status.status = "PIDFILE_BAD" - status.exit_code = 6 - status.detail = f"pidfile not found: {paths.pidfile}" - _emit_log(log_prefix, agent, status) - return None - - status.pid = pid - return status - - def monitor_agent( agent: str, pr: int, @@ -655,20 +630,21 @@ def monitor_agent( hard timeout / stall / sentinel / result.json のみで判定する。 """ paths = AgentPaths.for_(agent, pr, stem_template) + status = AgentStatus(agent=agent) started = time.monotonic() # 起動チェック: 30 秒待っても pidfile が無ければ起動失敗 - status = read_started_agent(agent, paths, started, log_prefix) - if status is None: - return AgentStatus( - agent=agent, - status="PIDFILE_BAD", - exit_code=6, - detail=f"pidfile not found: {paths.pidfile}", - ) - - pid = status.pid + grace_end = started + 30 + while time.monotonic() < grace_end: + if paths.pidfile.exists(): + break + time.sleep(2) + pid = _read_pidfile(paths.pidfile) if pid is None: + status.status = "PIDFILE_BAD" + status.exit_code = 6 + status.detail = f"pidfile not found: {paths.pidfile}" + _emit_log(log_prefix, agent, status) return status status.pid = pid From 1cccb481c8dea8a6d33dc787711ad28fc279ed87 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 13:11:30 +0000 Subject: [PATCH 94/95] =?UTF-8?q?Revert=20"Refactor:=20split=5Finto=5Fpipe?= =?UTF-8?q?line=20=E2=80=94=20plugins/ndf-shared/skills/cross-refactoring/?= =?UTF-8?q?scripts/refactor.py#cmd=5Fmerge=5Fapply"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 49c075ef3cfe64515e94aeafed23d60e2363259f. --- .../cross-refactoring/scripts/refactor.py | 106 +++++++----------- 1 file changed, 40 insertions(+), 66 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 5ac4ad5e..1c7efacf 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1150,64 +1150,6 @@ def _verify_ownership( ) -def _verify_and_classify_items( - entry: dict[str, Any], - state: dict[str, Any], - reported: dict[str, dict[str, Any]], - in_range: set[str], - test_command: str, - head_branch: str, - timeout: int, - scope: list[str], -) -> tuple[list[str], list[str]]: - """各改善項目のコミット facts を検証し、採用候補と失敗へ分類する。""" - applied: list[str] = [] - failed: list[str] = [] - work = state["worktrees"]["work"] - 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, 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 state.get("_merge_apply_dry_run"): - state_path = state.pop("_merge_apply_path") - dry_run = state.pop("_merge_apply_dry_run", None) - statefile.save(state_path, state) - state["_merge_apply_path"] = state_path - state["_merge_apply_dry_run"] = dry_run - return applied, failed - - def cmd_merge_apply(args: argparse.Namespace) -> None: """Step 4 — 適用結果を検証して取り込む。 @@ -1353,19 +1295,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 [] # **判定はその都度残す。** まとめて最後に保存すると、取り消しの途中で中断した # ときに適用の記録が一切残らず、どのコミットが検証を通ったのかを状態から # 復元できなくなる。再開可能性は収束ループの前提なので、ここが崩れると # 中断からの復帰手段が無くなる。 - state["_merge_apply_path"] = path - state["_merge_apply_dry_run"] = args.dry_run - applied, failed = _verify_and_classify_items( - entry, state, reported, in_range, test_command, head_branch, - _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), scope, - ) - state.pop("_merge_apply_path", None) - state.pop("_merge_apply_dry_run", None) + 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 bfbe628eba6327f0a39b29772733fedf626681a6 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 13:11:30 +0000 Subject: [PATCH 95/95] =?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 ffc933abd63047416ec7d0104dd4c779fd286cef. --- .../cross-refactoring/scripts/refactor.py | 106 ++++++------------ 1 file changed, 37 insertions(+), 69 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 1c7efacf..1a224c66 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1088,68 +1088,6 @@ def cmd_merge_proposals(args: argparse.Namespace) -> None: sys.exit(2) -def _verify_ownership( - entry: dict[str, Any], - reported: dict[str, dict[str, Any]], - ordered_range: list[str], - work: str, - state: dict[str, Any], -) -> Optional[str]: - """適用範囲の各コミットが、ちょうど 1 項目に所有されているか検証する。""" - in_range = set(ordered_range or []) - round_items = set(entry["items"]) - unknown_ids: list[str] = [] - raw_items = ((state.get("_merge_apply_payload") or {}).get("items")) - if not isinstance(raw_items, list): - raw_items = [] - for r in raw_items: - if not isinstance(r, dict): - continue - item_id = r.get("item_id") - if item_id is not None and item_id not in round_items: - unknown_ids.append(str(item_id)) - - 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)) - entry["_ownership_unassigned"] = unassigned - entry["_ownership_unknown_ids"] = unknown_ids - entry["_ownership_duplicated"] = duplicated - if not (unassigned or unknown_ids or duplicated): - return None - - 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])})" - ) - return ( - "、".join(causes) - + "。検証を回避した変更や、状態と実差分の食い違いを Pull Request に" - "残さないため、ラウンドごと取り消します" - ) - - def cmd_merge_apply(args: argparse.Namespace) -> None: """Step 4 — 適用結果を検証して取り込む。 @@ -1225,6 +1163,7 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: # そのまま Pull Request に残せてしまう。 round_items = set(entry["items"]) reported: dict[str, dict[str, Any]] = {} + unknown_ids: list[str] = [] raw_items = payload.get("items") if not isinstance(raw_items, list): info(f"⚠ 適用結果の items が配列ではありません({type(raw_items).__name__})") @@ -1235,6 +1174,8 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: item_id = r.get("item_id") if item_id in round_items: reported[item_id] = r + elif item_id is not None: + unknown_ids.append(str(item_id)) # **範囲のコミットは全て、いずれかの改善項目に割り当てられていること。** # 申告から漏れたコミットはテストもトレーラーも差分予算も検査されず、そのまま @@ -1245,13 +1186,40 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: # # 判定は**完全な SHA へ正規化してから**行う。申告の文字列をそのまま鍵にすると、 # 一方が完全 SHA、他方が短縮 SHA で同じコミットを指したときに重複を見逃す。 - state["_merge_apply_payload"] = payload - reason = _verify_ownership(entry, reported, ordered_range, work, state) - state.pop("_merge_apply_payload", None) - unassigned = entry.pop("_ownership_unassigned", []) - unknown_ids = entry.pop("_ownership_unknown_ids", []) - duplicated = entry.pop("_ownership_duplicated", []) - if reason: + 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: + 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)