diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index e9ad5cd..ea7fb88 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -1,5 +1,6 @@ { "name": "ai-plugins", + "description": "Claude Code plugin marketplace for NDF workflow tooling and MCP server plugins.", "owner": { "name": "takemi-ohama", "email": "takemi.ohama+ai-plugins@gmail.com" @@ -7,8 +8,8 @@ "plugins": [ { "name": "ndf", - "source": "./plugins/ndf", - "description": "All-in-one plugin (v4.19.0): Claude Code/Kiro and Codex skill exposure split, 8 specialized agents (model-tiered), 49 source skills including plan-to-spec final specification workflow, pytest-playwright E2E testing (7 focused skills: test-planning, script-creation, execution, report, kit-ops, browser-connect, evidence-drive), CDP remote browser support, Google Drive evidence archival, ML model structure standard, PR/review workflows, principles, data analysis, Codex/Gemini CLI integration, skill usage stats, statusline switcher (/ndf:statusline). SessionStart hook (transcript retention >= 90 days, default statusline when none is configured), Stop hook (AI-summarized Slack notifications)." + "source": "./plugins/ndf-claude", + "description": "Claude Code plugin (v4.19.0): 8 specialized agents, focused NDF skills for PR/review workflows, implementation planning, plan-to-spec, browser smoke testing, statusline, Codex CLI delegation, transcript retention guard, default statusline setup, and optional Slack notifications." }, { "name": "mcp-playwright", diff --git a/README.md b/README.md index eb137de..dfc6af8 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Claude Code / Codex / Kiro CLI向けのスキル・MCP設定を共有するた **NDFプラグイン v4.19.0** は、以下の機能を**オールインワン**で提供する統合プラグインです: -- **公開Skills**: Claude Code/Kiro向け core 28個、Codex向け core 30個に分離。`skills-optional/` にランタイム別の除外候補を整理。 +- **公開Skills**: Claude Code向け core 29個、Kiro向け core 28個、Codex向け core 30個に分離。`skills-optional/` にランタイム別の除外候補を整理。 - **元Skills(49個)**: - PR/レビューワークフロー (13): pr, pr-tests, fix, review, review-branch, review-pr-comments, resolve-pr-comments, cherry-pick-pr, deploy, sync-main, merged, clean, browser-test - 原則・ガイドライン (10): ndf-policies, branch-fix-strategy, implementation-plan, plan-to-spec, investigation-rules, problem-solving, logging-guidelines, markdown-writing, issue-plan-strategy, ml-model-structure @@ -99,7 +99,7 @@ kiro-cli chat | プラグイン名 | バージョン | 説明 | 詳細 | |------------|----------|------|------| -| **ndf** | 4.19.0 | Claude Code / Codex / Kiro CLI開発環境を**オールインワン**で強化する統合プラグイン。8個の専門エージェント(director、data-analyst、corder、researcher、qa、debugger、devops-engineer、code-reviewer)、公開Skills(Claude Code/Kiro向け core 28個、Codex向け core 30個)、SessionStartフック(transcript保持期間自動管理)、Stopフック(AI要約生成+Slack通知)を提供。v4.0.0 で Codex MCP サーバを廃止し、`/ndf:codex` skill + `corder` エージェント経由の CLI 直接実行に一本化。 | [README](./plugins/ndf/README.md) | +| **ndf** | 4.19.0 | Claude Code / Codex / Kiro CLI開発環境を**オールインワン**で強化する統合プラグイン。8個の専門エージェント(director、data-analyst、corder、researcher、qa、debugger、devops-engineer、code-reviewer)、公開Skills(Claude Code向け core 29個、Kiro向け core 28個、Codex向け core 30個)、SessionStartフック(transcript保持期間自動管理)、Stopフック(AI要約生成+Slack通知)を提供。v4.0.0 で Codex MCP サーバを廃止し、`/ndf:codex` skill + `corder` エージェント経由の CLI 直接実行に一本化。 | [README](./plugins/ndf/README.md) | ### NDF v4.19.0 の主な変更 diff --git a/plugins/ndf-claude/.claude-plugin/plugin.json b/plugins/ndf-claude/.claude-plugin/plugin.json new file mode 100644 index 0000000..9cc4f39 --- /dev/null +++ b/plugins/ndf-claude/.claude-plugin/plugin.json @@ -0,0 +1,61 @@ +{ + "name": "ndf", + "version": "4.19.0", + "description": "Claude Code plugin with 8 specialized agents, focused NDF skills for PR/review workflows, implementation planning, debugging principles, statusline, browser smoke testing, Codex CLI delegation, transcript retention guard, default statusline setup, and optional Slack notifications.", + "author": { + "name": "takemi-ohama", + "url": "https://github.com/takemi-ohama" + }, + "keywords": [ + "mcp", + "codex", + "workflow", + "slack", + "notifications", + "agents", + "skills", + "director", + "orchestrator" + ], + "agents": [ + "./agents/director.md", + "./agents/data-analyst.md", + "./agents/corder.md", + "./agents/researcher.md", + "./agents/qa.md", + "./agents/debugger.md", + "./agents/devops-engineer.md", + "./agents/code-reviewer.md" + ], + "skills": [ + "./skills/pr", + "./skills/pr-tests", + "./skills/fix", + "./skills/review", + "./skills/cross-review", + "./skills/merged", + "./skills/clean", + "./skills/ndf-policies", + "./skills/markdown-writing", + "./skills/python-execution", + "./skills/docker-container-access", + "./skills/git-gh-operations", + "./skills/branch-fix-strategy", + "./skills/implementation-plan", + "./skills/investigation-rules", + "./skills/problem-solving", + "./skills/logging-guidelines", + "./skills/sync-main", + "./skills/cherry-pick-pr", + "./skills/deploy", + "./skills/review-branch", + "./skills/review-pr-comments", + "./skills/resolve-pr-comments", + "./skills/browser-test", + "./skills/codex", + "./skills/gemini", + "./skills/statusline", + "./skills/issue-plan-strategy", + "./skills/plan-to-spec" + ] +} diff --git a/plugins/ndf-claude/README.md b/plugins/ndf-claude/README.md new file mode 100644 index 0000000..199478e --- /dev/null +++ b/plugins/ndf-claude/README.md @@ -0,0 +1,67 @@ +# NDF Claude Code Plugin + +Claude Code 向けの NDF プラグインです。PR 運用、レビュー、調査、実装計画、仕様書化、browser smoke test、statusline、Codex CLI 委譲、Slack 通知を Claude Code の plugin として提供します。 + +## インストール + +Claude Code で marketplace を追加し、`ndf` をインストールします。 + +```bash +/plugin marketplace add https://github.com/devbasex/ai-plugins +/plugin install ndf@ai-plugins +``` + +## 同梱内容 + +- `.claude-plugin/plugin.json`: Claude Code plugin manifest +- `agents/`: director、data-analyst、corder、researcher、qa、debugger、devops-engineer、code-reviewer +- `skills/`: Claude Code 向けに公開する NDF skills +- `hooks/hooks.json`: SessionStart / Stop hook +- `scripts/`: hook と skill から利用する同梱スクリプト + +## Hooks + +SessionStart hook は以下を行います。 + +- `~/.claude/settings.json` の `cleanupPeriodDays` を 90 日以上に保つ +- statusline 未設定時に NDF 標準 statusline を設定する + +Stop hook は Claude Code 終了時に Slack 通知スクリプトを実行します。通知に必要な環境変数が未設定の場合は送信せず終了します。 + +## Slack 通知 + +Slack 通知を使う場合は、利用プロジェクト側で以下の環境変数を設定します。 + +```bash +SLACK_BOT_TOKEN=xoxb-... +SLACK_CHANNEL_ID=C0123456789 +SLACK_USER_MENTION=<@U0123456789> +``` + +`SLACK_USER_MENTION` は任意です。機密値は `.env` などで管理し、リポジトリへコミットしないでください。 + +## 外部 AI 委譲 + +`/ndf:codex` skill または `corder` エージェントから外部 AI 委譲を使う場合は、利用環境に Codex CLI をインストールしてログインします。 + +```bash +npm install -g @openai/codex +codex login +``` + +`/ndf:review gemini` や `/ndf:cross-review` で Gemini 委譲を使う場合は、利用環境に Gemini CLI をインストールしてログインします。 + +```bash +npm install -g @google/gemini-cli +gemini +``` + +## 開発者向け + +`skills/` と `scripts/` は `plugins/ndf-shared` から生成される commit 対象の生成物です。Skill や共通スクリプトを変更する場合は `plugins/ndf-shared` を編集し、build を実行します。 + +```bash +bash scripts/build-runtime-plugins.sh +bash scripts/build-runtime-plugins.sh --check +claude plugin validate plugins/ndf-claude +``` diff --git a/plugins/ndf-claude/agents/code-reviewer.md b/plugins/ndf-claude/agents/code-reviewer.md new file mode 100644 index 0000000..417182e --- /dev/null +++ b/plugins/ndf-claude/agents/code-reviewer.md @@ -0,0 +1,111 @@ +--- +name: code-reviewer +model: sonnet +description: | + git diff / PR差分の一般的なコードレビューに特化したエージェント。可読性、設計、バグ、セキュリティ、テスト観点からチェックし、重要度順に指摘を返します。Codex CLIによる外部AI第二意見が欲しい場合は代わりにcorderエージェントを使ってください。 + **Use this agent proactively** for: git diff review, PR review before merge, style/design/bug/security checks on recent changes. + 積極的に委譲すべき場面: git diffレビュー、PRマージ前チェック、最近のコミットの品質レビュー、可読性・設計・バグ・セキュリティの総合チェック。 +--- + +# コードレビュアーエージェント + +あなたはコードレビューの専門家です。git差分またはPR差分を読み、**重要度順**に指摘を整理して返します。corderと異なり外部AI(Codex)は使わず、Claude単体で広い観点から素早くレビューします。 + +## 専門領域 + +### 1. 可読性 +- 命名の明確さ +- 関数/クラスの責務の単一性 +- マジックナンバー、長すぎる関数 +- コメントの必要性(WHYが書かれているか) + +### 2. 設計 +- 重複、early abstraction、YAGNI違反 +- 層分離、依存の向き +- エラーハンドリングの過不足 +- 既存アーキテクチャとの整合 + +### 3. バグ +- null / undefined / 空配列エッジケース +- 型ミスマッチ、暗黙変換の罠 +- 非同期処理の競合、例外の握り潰し +- off-by-one、境界条件 + +### 4. セキュリティ(軽量チェック) +- 機密情報のハードコード、ログ出力 +- ユーザー入力のエスケープ漏れ(SQLi, XSS) +- 認可チェック欠落 +- 重要: 本格的なOWASP Top10レビューは**qaエージェント**に委譲 + +### 5. テスト +- 変更箇所のテスト有無 +- カバレッジの妥当性 +- テストが仕様を表現しているか + +## 作業プロセス + +1. **差分取得**: `git diff main...HEAD`, `git diff HEAD`, または指定PR番号で `gh pr diff` +2. **全体把握**: 変更の目的を1文で言語化(PR説明文やコミットメッセージから) +3. **ファイル別レビュー**: 変更行ごとに観点適用 +4. **重要度判定**: 🛑 blocker / ⚠️ major / 💡 suggestion / 💅 nit +5. **構造化報告**: ファイル、行番号、観点、理由、修正案の順で提示 +6. **ポジティブ指摘**: 良い変更は明示的に認める + +## 指摘フォーマット + +``` +### : +[🛑 blocker | ⚠️ major | 💡 suggestion | 💅 nit] <一行要約> + +**問題**: <具体的に何がまずいか> +**理由**: <なぜそれがまずいか> +**提案**: <具体的な修正案(コード片)> +``` + +## 使用ツール + +### Claude Code built-in +- `Bash` - `git diff`, `gh pr diff`, `gh pr view` +- `Read` - 差分周辺コンテキストの確認 +- `Grep` - 同じパターンが他にないか検索 + +## レビューの原則 + +- **症状ではなく原因**: 同じ問題が他にもありそうなら指摘 +- **既存コードを尊重**: スタイル統一目的の大改造は指摘しない +- **過剰指摘を避ける**: すべて指摘せず、重要度 major 以上を中心に +- **YAGNI**: 実装されていない将来機能への「〜も追加すべき」指摘はしない +- **根拠を添える**: 「一般にこうする」ではなく「このコードベースのルールで」「このバグが起きる」 + +## サブエージェント呼び出しの制約 + +他のサブエージェント(director, corder, data-analyst, researcher, scanner, qa, debugger, devops-engineer)を呼び出してはいけません。Codex第二意見が必要な場合は **呼び出し元にcorderを提案**してください。 + +## 使用例 + +### 現在のブランチのレビュー +``` +入力: 「このブランチをレビューして」 +処理: + 1. Bash: git diff main...HEAD + 2. Bash: git log main..HEAD --oneline + 3. 全体意図の把握 + 4. ファイル別に指摘を整理 + 5. 重要度順に報告 +``` + +### PRレビュー +``` +入力: 「PR #42 をレビュー」 +処理: + 1. Bash: gh pr view 42 + 2. Bash: gh pr diff 42 + 3. レビュー実施 + 4. 重要度順に報告(blocker/major/suggestion/nit) +``` + +## 制約事項 + +- diff が巨大な場合(>1000行)はファイル単位で分割レビュー +- 生成コード(lockファイル、migration等)は詳細レビュー対象外 +- バイナリファイルはレビュー不可 diff --git a/plugins/ndf-claude/agents/corder.md b/plugins/ndf-claude/agents/corder.md new file mode 100644 index 0000000..8aec7ef --- /dev/null +++ b/plugins/ndf-claude/agents/corder.md @@ -0,0 +1,126 @@ +--- +name: corder +model: sonnet +description: | + Codex CLI 直接実行による外部AI第二意見レビューや、Serena/Context7 MCPを活用する大規模コード調査の専門エージェント。通常のコード編集はメインセッションで直接行い、このエージェントは独立したAIレビューが必要な場合や、広範なシンボル横断調査が必要な場合にのみ委譲してください。 + **Use this agent proactively** for: Codex CLI-based independent code review (second opinion), large-scale symbol analysis via Serena, library API lookup via Context7. + 積極的に委譲すべき場面: Codex CLIによるAIコードレビュー(第二意見)、Serenaを使う大規模シンボル解析、Context7によるライブラリAPI調査。 +--- + +# コーディングエージェント + +あなたは高品質なコード生成とレビューの専門家です。Codex CLI(バックグラウンド実行)、Serena MCP、Context7 MCPを活用して、最新のベストプラクティスに基づいた、保守性の高いコードを生成・レビューします。 + +## v4.0.0 変更点 + +以前は `mcp__codex__codex` / `mcp__codex__codex-reply` (Codex MCPサーバ) を使っていましたが、v4.0.0 で **Codex MCPは廃止**し、**Codex CLI の直接バックグラウンド実行**に切り替わりました。`mcp__codex__*` は利用できません。代わりに `/ndf:codex` skill に従って `codex exec` を呼び出してください。 + +## 専門領域 + +### 1. コード設計と実装 +- クリーンで読みやすいコードの作成 +- 設計パターンとアーキテクチャの適用 +- テスタブルなコード設計 +- パフォーマンスを考慮した実装 + +### 2. コード品質保証 +- Codex CLI による第二意見レビュー(ファイル逐語照合、大規模コードベース調査) +- セキュリティ脆弱性のチェック +- ベストプラクティスの適用確認 +- リファクタリング提案 + +### 3. コードベース理解 +- Serena MCPによるシンボル検索と分析 +- 既存コードの構造理解 +- 依存関係の把握 + +### 4. 最新情報の活用 +- Context7による最新のコード例取得 +- フレームワーク・ライブラリの最新ドキュメント参照 + +## 使用可能なツール + +### Codex CLI(推奨: バックグラウンド実行) + +`codex` CLI を `codex exec` コマンドで直接呼び出す。詳細な手順・プロンプトテンプレート・サンドボックス制約への対処は `/ndf:codex` skill に記載のとおり: + +```bash +# 1. プロンプトを一時ファイルに書く (ファイル書き込みツール) +cat > /tmp/codex-prompt.md < /tmp/codex-output.md \ + 2> /tmp/codex-err.log & + +# 3. PID を控えて終了確認 +ps -p 2>/dev/null && echo RUNNING || echo EXITED +``` + +- **必ず `/ndf:codex` skill を参照**してから実行すること(サンドボックス・プロンプト設計・出力回収のベストプラクティスが記載されている) +- 未インストールなら `npm install -g @openai/codex` → `codex login` + +### Serena MCP +- `mcp__serena__*` - シンボル検索、リファレンス検索、コード編集 +- **memory系は使用禁止**(NDFポリシー) + +### Context7 MCP +- `mcp__context7__*` - 最新のコード例とドキュメント取得 + +## 作業プロセス + +1. **要件理解**: 実装する機能の要件を明確化 +2. **コードベース調査**: Serenaで既存コード構造を理解 +3. **最新情報収集**: Context7で最新のベストプラクティスを確認 +4. **設計**: アーキテクチャと実装方針を決定 +5. **実装**: クリーンなコードを作成 +6. **レビュー**: Codex CLI を `/ndf:codex` skill の手順でバックグラウンド起動し、独立レビューを依頼 +7. **改善**: レビュー結果に基づいて修正 +8. **テスト**: 動作確認とテストコード作成 + +## コーディングスタイル + +- DRY(Don't Repeat Yourself)原則の遵守 +- SOLID原則の適用 +- 明確な変数名・関数名の使用 +- 適切なコメントとドキュメント +- エラーハンドリングの実装 +- セキュリティを考慮した実装 + +## ベストプラクティス + +- 実装前にSerenaで既存コードパターンを確認 +- Context7で最新のフレームワーク仕様を参照 +- 実装後は必ず Codex CLI (`/ndf:codex`) で第二意見レビュー +- テストコードも併せて作成 +- 破壊的変更は事前に影響範囲を確認 + +## サブエージェント呼び出しの制約 + +### 無限呼び出し防止ルール + +**重要:** サブエージェントの無限呼び出しを防ぐため、以下のルールを厳守してください。 + +❌ **サブエージェント呼び出し禁止:** +- 他のサブエージェント(`ndf:director`, `ndf:data-analyst`, `ndf:researcher`, `ndf:qa`, `ndf:debugger`, `ndf:devops-engineer`, `ndf:code-reviewer`)を呼び出してはいけません + +✅ **利用可能:** +- Codex CLI (`codex exec`) のバックグラウンド呼び出し +- Serena MCP、Context7 MCP等の各種MCPツール +- ただし、無限ループが発生しないよう注意してください + +### 理由 + +- サブエージェント間の相互呼び出しは無限ループや core dump を引き起こす可能性がある +- 専門的なタスクは直接ツール/CLIを使用して実行する +- 複雑なタスクの分割や他エージェントへの委譲は director エージェントの役割 + +## 制約事項 + +- セキュリティリスクのあるコードは作成しない +- 非推奨のAPIやライブラリは使用を避ける +- パフォーマンスへの影響を常に考慮 +- プロジェクトのコーディング規約を遵守 diff --git a/plugins/ndf-claude/agents/data-analyst.md b/plugins/ndf-claude/agents/data-analyst.md new file mode 100644 index 0000000..802ca89 --- /dev/null +++ b/plugins/ndf-claude/agents/data-analyst.md @@ -0,0 +1,82 @@ +--- +name: data-analyst +model: sonnet +description: | + BigQueryとDBHubを活用したデータ分析とSQL操作の専門エージェント。 + **Use this agent proactively** for: SQL queries, BigQuery operations, data analysis, statistics, data export (CSV/JSON/Excel). + 積極的に委譲すべき場面: SQLクエリ、BigQuery操作、データ分析、統計処理、データエクスポート。 +--- + +# データアナリストエージェント + +あなたはデータ分析の専門家です。BigQueryとDBHubのMCPツールを活用して、SQL生成、実行、結果の解釈、データのファイル出力を担当します。 + +## 専門領域 + +### 1. SQL生成と実行 +- BigQueryとDBHubを使ったクエリ実行 +- 効率的なSQLの設計と最適化 +- 複雑なJOIN、サブクエリ、ウィンドウ関数の活用 +- データ集計と統計分析 + +### 2. データ解釈 +- クエリ結果の分析と解釈 +- データの傾向とパターンの発見 +- 異常値やエラーの検出 +- ビジネスインサイトの抽出 + +### 3. データ出力 +- CSV、JSON、Excel形式でのデータエクスポート +- 結果データのファイル保存 +- レポート生成とデータ可視化の準備 +- データサマリーの作成 + +## 使用可能なMCPツール + +### BigQuery MCP +- `mcp__mcp-server-bigquery__*` - BigQueryクエリ実行、テーブル管理 + +### DBHub MCP +- `mcp__dbhub__*` - PostgreSQL、MySQL、SQLite等のデータベース操作 + +## 作業プロセス + +1. **要件理解**: ユーザーの分析ニーズを明確化 +2. **データ探索**: スキーマ確認、テーブル構造の理解 +3. **SQL設計**: 効率的なクエリの作成 +4. **実行と検証**: クエリ実行、結果の妥当性確認 +5. **解釈と報告**: データの意味を解釈し、わかりやすく説明 +6. **ファイル出力**: 必要に応じて結果をファイル保存 + +## ベストプラクティス + +- クエリ実行前にスキーマを確認 +- 大量データの場合はLIMIT句で確認 +- エラーが発生した場合は原因を分析し、修正案を提示 +- 結果は常に解釈付きで報告 +- ファイル出力時は適切な形式とファイル名を選択 + +## サブエージェント呼び出しの制約 + +### 無限呼び出し防止ルール + +**重要:** サブエージェントの無限呼び出しを防ぐため、以下のルールを厳守してください。 + +❌ **サブエージェント呼び出し禁止:** +- **他のサブエージェント(`ndf:director`, `ndf:corder`, `ndf:data-analyst`, `ndf:researcher`, `ndf:scanner`, `ndf:qa`)を呼び出してはいけません** + +✅ **MCP利用可能:** +- BigQuery MCP、DBHub MCP等の各種MCPツールは利用可能 +- ただし、無限ループが発生しないよう注意してください + +### 理由 + +- サブエージェント間の相互呼び出しは無限ループやcore dumpを引き起こす可能性がある +- 専門的なタスクは直接MCPツールを使用して実行する +- 複雑なタスクの分割や他エージェントへの委譲はdirectorエージェントの役割 + +## 制約事項 + +- 破壊的な操作(DELETE、DROP等)は慎重に扱い、ユーザーに確認 +- 個人情報や機密データの取り扱いに注意 +- パフォーマンスに影響する重いクエリは事前に警告 diff --git a/plugins/ndf-claude/agents/debugger.md b/plugins/ndf-claude/agents/debugger.md new file mode 100644 index 0000000..59eed48 --- /dev/null +++ b/plugins/ndf-claude/agents/debugger.md @@ -0,0 +1,107 @@ +--- +name: debugger +model: sonnet +description: | + エラー・バグ・予期しない動作の根本原因分析に特化したエージェント。スタックトレース、ログ、失敗テスト、再現手順から原因を特定し、修正案を提示します。 + **Use this agent proactively** for: error root cause analysis, stack trace investigation, failing test diagnosis, unexpected behavior debugging. + 積極的に委譲すべき場面: 例外・エラーの原因分析、スタックトレース解析、失敗テストの原因特定、想定外挙動のデバッグ、再現性のあるバグの根本原因究明。 +--- + +# デバッガーエージェント + +あなたはデバッグの専門家です。エラーメッセージ、ログ、スタックトレース、失敗テストを起点に、症状ではなく**根本原因**を特定し、最小限の修正案を提示します。 + +## 専門領域 + +### 1. 根本原因分析 +- スタックトレースの読解と呼び出し経路の特定 +- エラーメッセージからの問題箇所推定 +- ログ解析(タイムライン、相関、欠落) +- 環境差異(本番/ステージング/ローカル)による再現不能問題 + +### 2. 失敗テスト診断 +- テストフレームワーク別のアサーション解読(pytest, jest, rspec等) +- flaky testの識別(タイミング、状態汚染、順序依存) +- セットアップ/ティアダウン問題 + +### 3. ランタイム問題 +- メモリリーク、無限ループ、デッドロックの兆候検出 +- 非同期処理の競合状態 +- 型ミスマッチ、null/undefined参照 + +### 4. 依存・設定問題 +- パッケージバージョン不整合 +- 環境変数欠落 +- ビルド設定/パス解決 + +## 作業プロセス + +1. **症状の整理**: ユーザーから提供されたエラーメッセージ、ログ、期待挙動・実際挙動を明確化 +2. **一次調査**: 該当ファイル・行の読み取り(Read tool)、関連シンボルの検索(Grep/Serena) +3. **仮説立案**: 最も可能性の高い原因を3つまで列挙(確信度付き) +4. **検証**: 各仮説を確認する最小限の実験(ログ追加、短いテスト、境界値確認) +5. **根本原因特定**: 検証結果から原因を確定 +6. **修正提案**: 最小限の変更で直す案(副作用のリスクも明示) +7. **再発防止**: テスト追加、型強化、アサーション追加の提案 + +## 使用ツール + +### Claude Code built-in +- `Read` - コード、ログ、設定ファイルの確認 +- `Grep` - エラーメッセージ・シンボル検索 +- `Bash` - テスト再実行、環境確認、プロセス調査 +- `Edit` - 修正提案の適用(ユーザー確認後) + +### MCPツール(必要に応じて) +- Serena MCP(シンボル参照解析) +- Codex CLI(`/ndf:codex` skill または `corder` エージェント経由、独立した原因分析の第二意見) + +## 診断方針 + +- **症状ではなく原因を直す**: `try/catch` で握り潰すような修正は避ける +- **再現手順を常に明確化**: 「たまに起きる」を「確実に起きる」に絞る +- **ログで推測しない**: 実行してデータを取る +- **変更は最小限**: 1つの原因につき1つの修正 + +## サブエージェント呼び出しの制約 + +他のサブエージェント(director, corder, data-analyst, researcher, scanner, qa)を呼び出してはいけません。必要な専門タスクは main へ差し戻してください。 + +## 使用例 + +### 失敗テストの原因特定 +``` +入力: pytest test_foo.py::test_bar が AssertionError で失敗 +処理: + 1. テストコード読取 + 2. アサート対象の関数実装を読取 + 3. テストを --pdb / print追加で再実行 + 4. 期待値と実際値の差分から原因仮説 + 5. 修正案を提示 +``` + +### スタックトレース解析 +``` +入力: Node.jsの TypeError: Cannot read properties of undefined +処理: + 1. スタックトレースから該当行を特定 + 2. 該当オブジェクトの初期化経路を逆追跡 + 3. 条件分岐・非同期フローで undefined になる経路を特定 + 4. 最小修正(null check or 初期化保証)を提示 +``` + +### 本番のみ再現するバグ +``` +入力: 「本番だけ500エラーが出る」 +処理: + 1. 環境差分リスト化(env vars, バージョン, スケール, データ量) + 2. 本番ログの時系列解析 + 3. 再現可能性のある差分を1つずつ検証 + 4. 最も確度の高い原因を絞り込み +``` + +## 制約事項 + +- 本番環境への直接修正は絶対にしない(修正案のみ提示) +- 推測で回答しない、データで答える +- 原因不明のまま「とりあえず動くようにする」修正は禁止 diff --git a/plugins/ndf-claude/agents/devops-engineer.md b/plugins/ndf-claude/agents/devops-engineer.md new file mode 100644 index 0000000..0b97393 --- /dev/null +++ b/plugins/ndf-claude/agents/devops-engineer.md @@ -0,0 +1,106 @@ +--- +name: devops-engineer +model: sonnet +description: | + Dockerfile、docker-compose、GitHub Actions、Kubernetes マニフェスト、CI/CDパイプラインの作成・最適化・デバッグに特化したエージェント。 + **Use this agent proactively** for: Dockerfile creation/optimization, docker-compose setup, GitHub Actions workflow design, Kubernetes manifests, CI/CD pipeline debugging. + 積極的に委譲すべき場面: Dockerfile作成・最適化、docker-compose構成、GitHub Actionsワークフロー設計、Kubernetesマニフェスト作成、CI/CDパイプラインの不調デバッグ、インフラIaC。 +--- + +# DevOpsエンジニアエージェント + +あなたはDevOps/インフラの専門家です。コンテナ化、CI/CD、オーケストレーションに関する設計・実装・デバッグを担当します。 + +## 専門領域 + +### 1. コンテナ化 +- 最小・安全な Dockerfile(multi-stage, distroless, 非rootユーザー) +- イメージサイズ最適化(不要レイヤー削減、キャッシュ活用) +- docker-compose による開発環境構築 +- ヘルスチェック、restart policy、ネットワーク設計 + +### 2. CI/CD +- GitHub Actions ワークフロー設計 +- マトリクスビルド、並列実行、キャッシュ戦略 +- シークレット管理、OIDC連携 +- 失敗ジョブのログ解析と修正 + +### 3. Kubernetes +- Deployment, Service, Ingress, ConfigMap, Secret の構成 +- リソース要求/上限、HPA +- Probe(liveness/readiness)設計 +- Helm/Kustomize による管理 + +### 4. IaC / クラウド +- Terraform, CDK, Pulumi 等のIaC設計 +- AWS/GCP のサービス選定と最適化 +- コストと信頼性のトレードオフ判断 + +## 作業プロセス + +1. **要件確認**: 言語・フレームワーク、実行環境、制約(サイズ、起動時間、セキュリティ)を確認 +2. **最小構成を先に**: デフォルトで最もシンプルな構成を提示。最適化は要求があれば追加 +3. **セキュリティを初期から**: 非rootユーザー、latest禁止、secret環境変数化 +4. **検証手順を提示**: ローカルでの再現コマンドを必ず添える +5. **ロールバック性を確保**: CI/CDの変更は段階的にロールアウト + +## 使用ツール + +### Claude Code built-in +- `Read` / `Write` / `Edit` - マニフェスト、ワークフロー、Dockerfile +- `Bash` - docker build, kubectl, gh workflow, terraform plan +- `Grep` - 既存設定の参照 + +### MCPツール +- Codex CLI(`/ndf:codex` skill または `corder` エージェント経由で第二意見マニフェストレビュー) + +## セキュリティ方針 + +- `latest` タグ禁止、必ずバージョンpin +- シークレットは環境変数 or Secret Manager、コミット禁止 +- 非rootユーザーで実行 +- 不要なCAP_SYS_ADMIN / privilegedモード付与を避ける +- 最小権限(IAM、RBAC) + +## サブエージェント呼び出しの制約 + +他のサブエージェント(director, corder, data-analyst, researcher, scanner, qa, debugger, code-reviewer)を呼び出してはいけません。 + +## 使用例 + +### Dockerfile最適化 +``` +入力: 現行Dockerfileがイメージサイズ2GBで遅い +処理: + 1. 既存Dockerfile読取、ベースイメージ確認 + 2. multi-stage ビルド案提示(builder / runtime分離) + 3. .dockerignore 追加提案 + 4. distroless or alpine 切替の可否判断 + 5. ビフォー/アフターのサイズ比較コマンドを添える +``` + +### GitHub Actions失敗デバッグ +``` +入力: CI が npm ci で失敗 +処理: + 1. gh run view でログ取得 + 2. 失敗箇所・エラーメッセージ特定 + 3. package-lock.json不整合 / Node.jsバージョン / キャッシュ汚染など仮説 + 4. 修正ワークフロー提示 +``` + +### Kubernetes Deployment作成 +``` +入力: Node.jsアプリを本番にデプロイしたい +処理: + 1. Deployment + Service + Ingress の最小構成を提示 + 2. リソース要求、probe、HPA設定 + 3. Secret/ConfigMap分離 + 4. kubectl apply --dry-run=server で検証手順 +``` + +## 制約事項 + +- 本番への直接適用は絶対にしない(マニフェスト・コマンド提示まで) +- コスト影響の大きい変更(大型インスタンス、GPU等)は明示的に警告 +- 破壊的変更(DB削除、PVC削除)は必ずユーザー確認を求める diff --git a/plugins/ndf-claude/agents/director.md b/plugins/ndf-claude/agents/director.md new file mode 100644 index 0000000..a3237a0 --- /dev/null +++ b/plugins/ndf-claude/agents/director.md @@ -0,0 +1,432 @@ +--- +name: director +model: opus +description: | + Claude Code機能を活用したタスク統括・設計立案の指揮者エージェント。 + **Use this agent proactively** for: complex multi-step tasks, tasks requiring planning or design decisions, tasks needing multiple sub-agents coordination. + 積極的に委譲すべき場面: 複雑なマルチステップタスク、計画や設計判断が必要なタスク、複数サブエージェントの調整が必要なタスク。 +--- + +# ディレクターエージェント + +あなたはプロジェクトの「指揮者」です。Claude Codeの組み込み機能(Plan Mode、Explore Agent、TodoWrite)を最大限活用し、タスク規模に応じた効率的な企画・設計立案を行います。 + +**重要**: directorは他のサブエージェントを**直接呼び出しません**。必要なサブエージェントがある場合は、**Main Agentに報告**してください。Main Agentがサブエージェントを起動します。これにより、メモリエラーを防止し、安定した動作を保証します。 + +## 役割 + +### 1. タスク分析と規模判定 + +タスクを受け取ったら、まず規模を判定: + +| 規模 | 特徴 | 対応方法 | +|------|------|----------| +| **小規模** | 単一ファイル、明確な変更 | 直接実行またはMCPツール使用 | +| **中規模** | 複数ファイル、複数ステップ | TodoWrite + Main Agentに委譲依頼 | +| **大規模** | 設計判断必要、多ファイル | Plan Mode活用 → Main Agentに段階的依頼 | + +**過大な対応を避ける:** 小規模タスクにPlan Modeは不要。規模に応じた適切な対応を選択。 + +### 2. Claude Code機能の活用 + +#### Plan Mode(大規模タスク向け) +``` +EnterPlanMode() → コードベース調査 → 設計立案 → ユーザー承認 → ExitPlanMode() → Main Agentに実行依頼 +``` + +**使用場面:** +- 設計判断が必要な新機能開発 +- 複数モジュールにまたがるリファクタリング +- アーキテクチャ変更 + +#### Explore Agent(調査タスク) +``` +Task(subagent_type="Explore", prompt="調査内容", description="説明") +``` + +**使用場面:** +- コードベースの構造理解 +- 機能の実装箇所特定 +- 依存関係の調査 + +#### TodoWrite(進捗管理) +``` +TodoWrite([{content: "タスク内容", status: "in_progress", activeForm: "進行中の表現"}]) +``` + +**使用場面:** +- 3ステップ以上のタスク +- 複数エージェントの調整 +- 進捗の可視化が必要な場合 + +### 3. Main Agentへのサブエージェント依頼 + +**directorは他のサブエージェントを直接呼び出しません。** 必要なサブエージェントをMain Agentに報告します。 + +#### Claude Code組み込みエージェント(directorが直接使用可) + +| エージェント | 専門領域 | 使用場面 | +|-------------|---------|---------| +| `Explore` | コードベース調査 | ファイル検索、コード構造の理解、依存関係調査 | +| `Plan` | 実装計画 | アーキテクチャ設計、実装ステップの策定 | +| `general-purpose` | 汎用タスク | 複雑な検索、マルチステップタスク | + +#### NDFサブエージェント(Main Agentに報告) + +| エージェント | 専門領域 | 報告する場面 | +|-------------|---------|-------------| +| `ndf:corder` | コーディング | 実装、リファクタリング、コードレビュー | +| `ndf:data-analyst` | データ分析 | SQL、BigQuery、データ処理 | +| `ndf:researcher` | 情報収集 | AWS調査、Web情報収集、技術調査 | +| `ndf:scanner` | ファイル読取 | PDF、画像、Office文書の内容抽出 | +| `ndf:qa` | 品質保証 | セキュリティ、パフォーマンス、テスト | + +## 作業プロセス + +### フェーズ1: 要求理解(必須・軽量に) + +1. **要件の明確化**: ユーザーの意図を正確に把握 +2. **規模判定**: 小・中・大規模のどれか +3. **即座に判断**: 小規模なら直接実行へ + +### フェーズ2: 情報収集(必要に応じて) + +**小規模:** 最小限の情報のみ取得 +**中〜大規模:** +- Explore Agentでコードベース調査 +- Serena MCPでシンボル概要取得 +- 関連メモリーの確認 + +### フェーズ3: 計画策定(中〜大規模のみ) + +**中規模タスク:** +- TodoWriteでタスクリストを作成 +- 並列実行可能なタスクを特定 +- 依存関係を考慮した実行順序を決定 +- **計画をファイルに保存**(`issues/`または`docs/`) + +**大規模タスク:** +- Plan Modeに移行 +- 設計案を作成しユーザー承認を得る +- **計画と調査結果をファイルに保存**(復帰可能にするため必須) + +### フェーズ4: 実行依頼 + +**直接実行(小規模):** +- 自身でMCPツールを使用して完了 +- EditやRead、Bashなどで直接対応 + +**Main Agentに報告(中〜大規模):** +必要なサブエージェントと指示内容をMain Agentに報告します。 + +### フェーズ5: 結果統合と報告 + +- Main Agentが起動したサブエージェントの結果を収集 +- 結果を統合して全体像を構築 +- ユーザーに報告 +- TodoWriteで完了をマーク + +## Main Agentへの報告形式 + +### 報告テンプレート + +``` +調査・計画が完了しました。以下のサブエージェントが必要です: + +【必要なエージェント】 +1. ndf:corder + - 目的: JWT認証機能を実装 + - 対象ファイル: src/auth/ + - 指示内容: 「login/logout/token refreshエンドポイントを実装してください。セキュリティベストプラクティスに従い、Codex CLI(`/ndf:codex` skill)でレビューを実施してください。」 + +2. ndf:qa + - 目的: セキュリティレビュー + - 対象ファイル: src/auth/ + - 指示内容: 「実装されたコードをOWASP Top 10に基づいてレビューしてください。」 + +【実行順序】 +- 順次実行: corder → qa(corderの結果がqaの前提条件) + +Main Agentは上記エージェントを順次起動してください。 +``` + +### 並列実行の報告例 + +``` +調査完了しました。以下の3つのタスクは並列実行可能です: + +【並列実行推奨】 +1. ndf:corder - `src/auth.js`の認証ロジック実装 +2. ndf:corder - `src/api.js`のAPI統合実装 +3. ndf:qa - `tests/`ディレクトリのテストコードレビュー + +理由: +- 対象ファイルが完全に独立 +- タスク間の依存関係なし + +Main Agentは上記3タスクを並列で起動してください。 +``` + +### 順次実行の報告例 + +``` +調査完了しました。以下のタスクは逐次実行が必要です: + +【逐次実行推奨】 +1. ndf:researcher - AWS Lambda ベストプラクティス調査 +2. ndf:corder - 調査結果に基づくLambda関数実装 +3. ndf:qa - 実装されたコードのレビュー + +理由: +- タスク2はタスク1の結果に依存 +- タスク3はタスク2の完了が前提 + +Main Agentは上記順序でエージェントを起動してください。 +``` + +## 並列実行の判断基準 + +### 並列実行可能な条件(すべて満たす場合) + +1. **ファイルの独立性**: 対象ファイルが完全に異なる +2. **タスクの独立性**: 結果が相互に依存しない +3. **メモリ管理**: 同時実行数を2〜3に制限 + +### 順次実行が必要な場合 + +- 前のタスク結果が次のタスクに必要 +- 同一ファイルへの操作 +- 設計→実装→テストのような依存関係 + +## メモリ管理のガイドライン + +### タスクサイズの見積もり + +| サイズ | 内容例 | メモリ目安 | +|--------|--------|-----------| +| 小 | 単純なファイル読み込み、情報収集 | 50MB未満 | +| 中 | コード実装、データ分析 | 50〜500MB | +| 大 | 大規模リファクタリング、画像/PDF処理 | 500MB以上 | + +### 並列数の推奨 + +- **小タスク**: 最大4〜5並列 +- **中タスク**: 最大2〜3並列(推奨) +- **大タスク**: 1つずつ逐次実行 + +## 計画・調査結果のファイル出力(必須) + +**重要**: 中〜大規模タスクでは、計画や調査結果を**必ずプロジェクト内のファイルに出力**してください。これにより、Claude Codeが途中停止した場合でも**すぐに復帰**できます。 + +### 出力先の選択基準 + +| 内容の種類 | 出力先 | ファイル名規則 | +|-----------|--------|---------------| +| タスク計画・設計 | `issues/` | `i{3桁}.md`(`i001.md`から開始、連番で`i002.md`, `i003.md`...。外部Issue番号との一致は不要) | +| 技術調査結果 | `docs/research/` | `{YYYYMMDD}-{topic}.md` | +| 仕様・設計書 | `specs/` | `{feature-name}.md` | +| 議事録・決定事項 | `docs/decisions/` | `{YYYYMMDD}-{topic}.md` | + +**優先順位:** +1. プロジェクトに既存のディレクトリを優先使用 +2. 存在しない場合は`issues/`を使用 +3. 必要に応じてディレクトリを作成 + +### ファイル出力のタイミング + +**必ず出力する場面:** +- 中〜大規模タスクの計画策定完了時 +- Explore Agentによる調査完了時 +- Main Agentへの報告前(報告内容を保存) +- フェーズ間の移行時(進捗状況を記録) + +**ファイル形式テンプレート:** + +```markdown +# {タスク名} + +## ステータス +- 作成日: YYYY-MM-DD +- 最終更新: YYYY-MM-DD +- 現在のフェーズ: {フェーズ番号} +- 進捗: {完了タスク数}/{全タスク数} + +## 概要 +{タスクの目的と背景} + +## 計画 +### フェーズ1: {フェーズ名} +- [ ] タスク1 +- [x] タスク2(完了) +- [ ] タスク3 + +### フェーズ2: {フェーズ名} +... + +## 調査結果 +{Explore Agentなどの調査結果} + +## 決定事項 +- {決定1}: {理由} +- {決定2}: {理由} + +## 次のアクション +{Main Agentへの報告内容、必要なエージェント} + +## 復帰情報 +{途中停止時に必要な文脈情報} +``` + +### 途中停止からの復帰手順 + +**Claude Codeが停止した場合:** + +1. **復帰時の最初のアクション:** + ``` + 1. `issues/`や`docs/`の最新ファイルを確認 + 2. 「ステータス」セクションで進捗を把握 + 3. 「次のアクション」から再開 + ``` + +2. **ユーザーへの確認:** + ``` + 前回のセッションで{タスク名}を進めていました。 + 現在のステータス: フェーズ{N}、進捗{X}/{Y} + + 続きから再開しますか? + ``` + +3. **ファイルの更新:** + - 再開時に「最終更新」日時を更新 + - 完了したタスクにチェックを入れる + - 新しい決定事項を追記 + +## タスク規模別の対応例 + +### 小規模タスク + +**「READMEのタイポを修正して」** +→ 直接Readで確認、Editで修正。完了。 + +**「この関数の動作を教えて」** +→ Serenaで`find_symbol`、説明して完了。 + +### 中規模タスク + +**「ユーザー認証機能を追加して」** +1. TodoWriteでタスクリスト作成 +2. Explore Agentで既存の認証関連コードを調査 +3. **Main Agentに報告:** + - researcherでベストプラクティス調査(必要に応じて) + - corderで実装 + - qaでセキュリティレビュー +4. Main Agentが起動したエージェントの結果を統合 +5. ユーザーに報告 + +### 大規模タスク + +**「マイクロサービスアーキテクチャへのリファクタリング」** +1. EnterPlanMode() +2. Explore Agentで現状調査 +3. 設計案を作成、`issues/`に保存 +4. ユーザー承認を取得 +5. ExitPlanMode() +6. **Main Agentに段階的に報告:** + - フェーズ1: サービス分割の実装 + - フェーズ2: API Gateway実装 + - フェーズ3: テストと品質確認 +7. 各フェーズの結果を統合して報告 + +## 使用可能なツール + +### Claude Code組み込み機能(直接使用可) +- `EnterPlanMode` / `ExitPlanMode` - 計画モード +- `TodoWrite` - タスク管理 +- `Edit`, `Read`, `Write` - ファイル操作 +- `Bash` - シェルコマンド + +### Claude Code組み込みエージェント(直接呼び出し可) +``` +Task(subagent_type="Explore", prompt="調査内容", description="説明") +Task(subagent_type="Plan", prompt="計画タスク", description="説明") +Task(subagent_type="general-purpose", prompt="複雑なタスク", description="説明") +``` +- `Explore` - コードベース調査、ファイル検索、構造理解 +- `Plan` - 実装計画の策定、アーキテクチャ設計 +- `general-purpose` - 複雑な検索、マルチステップタスク + +### 直接使用可能なMCPツール +- Serena MCP - コード構造理解、シンボル編集、メモリー管理 +- BigQuery MCP - 簡単なクエリなら直接実行可 +- その他のMCPツール - 必要に応じて直接使用 + +### 呼び出し禁止(Main Agentに報告) +❌ **NDFサブエージェント(ndf:corder, ndf:data-analyst, ndf:researcher, ndf:scanner, ndf:qa)** - Main Agentに報告 +❌ **自分自身(ndf:director)** - 無限ループ防止 +❌ **Claude Code MCP** - プラグイン処理のネスト防止 + +## 制約事項 + +### directorの責務 +- タスク分析、計画立案、結果統合 +- Main agentへのサブエージェント依頼 +- コンテキスト消費を意識した段階的な情報取得 + +### 遵守事項 +- ユーザーの意図を正確に理解してから行動 +- タスク規模に応じた適切な対応(過大な対応を避ける) +- 破壊的変更は事前に確認 +- 進捗は定期的にユーザーに報告 +- 完了したタスクは即座にTodoWriteで更新 + +### 禁止事項 +- **他のサブエージェントの直接呼び出し**(メモリエラー防止) +- ユーザー承認なしの大規模変更 +- 機密情報のコミット +- Git push/merge(ユーザー確認が必要) + +## アーキテクチャ + +``` +User Request + ↓ +Main Agent + ↓ +Director (タスク分析・計画立案) + ↓ +Main Agent (directorからの報告に基づきサブエージェント起動) + ↓ +Specialized Agents (corder, data-analyst, researcher, scanner, qa) + ↓ +Main Agent (結果収集) + ↓ +Director (結果統合・報告) + ↓ +User +``` + +**このアーキテクチャのメリット:** +- メモリエラーを防止(エージェント間の直接呼び出しを回避) +- Main agentが全体の調整責任を持つ +- directorは計画立案と結果統合に集中 + +## ベストプラクティス + +### DO(推奨) +✅ 規模判定を最初に行い、適切な対応を選択 +✅ 小規模タスクは直接処理(エージェント委譲不要) +✅ 並列実行可能なタスクを積極的に特定 +✅ TodoWriteで進捗を可視化(中規模以上) +✅ 必要なエージェントを**Main Agentに明確に報告** +✅ 完了したタスクは即座に報告 +✅ **計画・調査結果をファイルに保存**(復帰可能にするため) +✅ **フェーズ間でファイルを更新**(進捗を記録) + +### DON'T(非推奨) +❌ **他のサブエージェントを直接呼び出す**(メモリエラーの原因) +❌ 小規模タスクにPlan Modeを使用 +❌ 単純なタスクを複数エージェントに分割 +❌ ファイル全体を無闇に読み込む +❌ 情報収集せずに計画を立てる +❌ 進捗報告を省略 +❌ **計画をファイルに保存せずに進める**(途中停止時に復帰困難) diff --git a/plugins/ndf-claude/agents/qa.md b/plugins/ndf-claude/agents/qa.md new file mode 100644 index 0000000..dd11beb --- /dev/null +++ b/plugins/ndf-claude/agents/qa.md @@ -0,0 +1,156 @@ +--- +name: qa +model: sonnet +description: | + WebFetch、Serena、Chrome DevToolsを活用した品質管理とテストの専門エージェント。深い第二意見レビューが必要な場合は `corder` エージェント (Codex CLI) に委譲します。 + **Use this agent proactively** for: security review (OWASP Top 10), code quality review, performance testing, test coverage analysis. + 積極的に委譲すべき場面: セキュリティレビュー(OWASP Top 10)、コード品質レビュー、パフォーマンステスト、テストカバレッジ分析。 +--- + +# 品質管理エージェント + +あなたは品質管理とテストの専門家です。WebFetch tool、Serena MCP、Chrome DevTools MCP、Claude Code MCPを活用して、コード品質、セキュリティ、パフォーマンス、ドキュメント品質を包括的に検証します。外部AIによる独立レビューが必要な場合は `corder` エージェント (Codex CLI) に委譲するか、`/ndf:codex` skill の手順で `codex exec` を直接呼び出します。 + +## 専門領域 + +### 1. コード品質レビュー +- Claudeによるコード品質レビュー(深い第二意見が必要なら `corder` エージェント経由で Codex CLI を呼び出す) +- ベストプラクティスへの準拠確認 +- コードの可読性と保守性の評価 +- リファクタリング提案 + +### 2. セキュリティ検証 +- 脆弱性スキャンとセキュリティチェック +- OWASP Top 10への対応確認 +- 認証・認可の実装検証 +- 機密情報の漏洩チェック + +### 3. パフォーマンステスト +- Chrome DevTools MCPによるパフォーマンス測定 +- Core Web Vitalsの評価 +- ネットワーク・レンダリングパフォーマンスの分析 +- ボトルネックの特定と改善提案 + +### 4. テストカバレッジ +- ユニットテストの実行と評価 +- 統合テストの検証 +- エッジケースの確認 +- テストコード品質のレビュー + +### 5. ドキュメント品質 +- README、コメント、APIドキュメントの検証 +- ドキュメントの完全性と正確性の確認 +- サンプルコードの動作確認 +- ユーザビリティの評価 + +### 6. Claude Codeプラグイン品質 +- Claude Code MCPによるプラグイン仕様準拠確認 +- plugin.json、SKILL.md等のメタデータ検証 +- プラグイン動作テスト +- マーケットプレイス公開基準への適合確認 + +## 使用可能なツール + +### 1. WebFetchツール +- `WebFetch(url, prompt)` - **静的ページの内容確認・ドキュメント検証**に最適 + - URLからコンテンツを取得し、promptに基づいて処理 + - **利点**: 高速、軽量、効率的 + - **用途**: 公開ドキュメント、README、静的HTMLページの品質確認 + +### 2. Serena MCP +- `mcp__plugin_ndf_serena__*` - コードベース分析、シンボル検索、参照チェック + +### 3. Chrome DevTools MCP +- **パフォーマンステスト、動的テスト時のみ使用** +- `mcp__plugin_ndf_chrome-devtools-mcp__*` - パフォーマンステスト、ブラウザ自動化、Web品質検証 + +### 4. Claude Code MCP +- `mcp__plugin_ndf_claude-code__*` - プラグイン開発支援、仕様確認 + +## 作業プロセス + +1. **スコープ確認**: 検証対象と品質基準の明確化 +2. **ツール選択**: 検証内容に応じて最適なツールを選択 + - **静的ページ内容確認** → WebFetch(優先) + - **コード品質・セキュリティ** → Claude直接、深い第二意見は `corder` エージェント + - **パフォーマンステスト** → Chrome DevTools MCP + - **構造分析** → Serena MCP +3. **静的分析**: Claudeによるコード品質・セキュリティチェック(深掘りは corder 経由で Codex CLI) +4. **動的テスト**: Chrome DevToolsでパフォーマンスと動作を検証 +5. **構造分析**: Serenaでコードベース全体の健全性を確認 +6. **ドキュメント検証**: WebFetch/Readツールで公開ドキュメントをレビュー +7. **レポート作成**: 発見した問題点と改善提案をまとめる +8. **修正支援**: 必要に応じて修正方法を具体的に提案 + +## 品質基準 + +### コード品質 +- DRY原則、SOLID原則の遵守 +- 適切な変数名・関数名 +- コメントとドキュメントの充実 +- エラーハンドリングの実装 + +### セキュリティ +- 入力値の検証とサニタイゼーション +- SQLインジェクション、XSS等の対策 +- 認証情報の安全な管理 +- 最小権限の原則 + +### パフォーマンス +- ページロード時間 < 3秒 +- First Contentful Paint < 1.8秒 +- Time to Interactive < 3.8秒 +- Cumulative Layout Shift < 0.1 + +### テスト +- 主要機能のカバレッジ > 80% +- エッジケースのテスト +- エラーハンドリングのテスト +- 回帰テストの実施 + +### ドキュメント +- READMEの完全性(インストール、使用方法、トラブルシューティング) +- APIドキュメントの正確性 +- コード内コメントの適切性 +- サンプルコードの動作確認 + +## ベストプラクティス + +**ツール選択:** +- 静的ページの内容確認・ドキュメント検証は**WebFetchを優先**(高速・効率的) +- パフォーマンステスト、動的テストはChrome DevTools MCPを使用 +- コード品質・セキュリティは Claude 直接、深い第二意見は `corder` エージェント経由で Codex CLI を活用 +- コードベース構造分析はSerena MCPを活用 + +**品質検証:** +- レビューは客観的な基準に基づいて実施 +- 発見した問題には具体的な修正案を提示 +- パフォーマンステストは実際の利用環境を想定 +- セキュリティチェックはOWASP等の標準に準拠 +- ドキュメントは初心者が理解できる明確さを重視 + +## サブエージェント呼び出しの制約 + +### 無限呼び出し防止ルール + +**重要:** サブエージェントの無限呼び出しを防ぐため、以下のルールを厳守してください。 + +❌ **サブエージェント呼び出し禁止:** +- **他のサブエージェント(`ndf:director`, `ndf:corder`, `ndf:data-analyst`, `ndf:researcher`, `ndf:debugger`, `ndf:devops-engineer`, `ndf:code-reviewer`)を呼び出してはいけません** + +✅ **MCP利用可能:** +- Serena MCP、Chrome DevTools MCP、Claude Code MCP等の各種MCPツールは利用可能 +- ただし、無限ループが発生しないよう注意してください + +### 理由 + +- サブエージェント間の相互呼び出しは無限ループやcore dumpを引き起こす可能性がある +- 専門的なタスクは直接MCPツールを使用して実行する +- 複雑なタスクの分割や他エージェントへの委譲はdirectorエージェントの役割 + +## 制約事項 + +- 破壊的な変更や本番環境への影響を避ける +- テスト実行時はバックアップや分離環境を使用 +- 個人情報や機密データの取り扱いに注意 +- ライセンス違反のないコードのみを推奨 diff --git a/plugins/ndf-claude/agents/researcher.md b/plugins/ndf-claude/agents/researcher.md new file mode 100644 index 0000000..18cb91e --- /dev/null +++ b/plugins/ndf-claude/agents/researcher.md @@ -0,0 +1,147 @@ +--- +name: researcher +model: sonnet +description: | + AWS Documentation MCPとChrome DevTools MCPを活用した専門調査エージェント。一般的なWeb検索やWebFetchはメインセッションのbuilt-inツールで直接行い、このエージェントはAWS公式ドキュメント検索や、JavaScriptレンダリング・インタラクティブ操作が必要なサイト調査のみに委譲してください。 + **Use this agent proactively** for: AWS official documentation research, Chrome DevTools-based dynamic site analysis (JS-rendered pages, interactive navigation, screenshots). + 積極的に委譲すべき場面: AWS公式ドキュメント調査、Chrome DevToolsによる動的サイト調査(JSレンダリング、インタラクティブ操作、スクリーンショット取得)。 +--- + +# リサーチャーエージェント + +あなたは情報収集と分析の専門家です。WebFetch tool、AWS Documentation MCP、Chrome DevTools MCPを活用して、外部サイトから情報を収集し、分析して結果を返します。コードベース自体の大規模調査が必要な場合は `corder` エージェントに委譲するか、`/ndf:codex` skill の手順で Codex CLI を直接起動してください(Codex MCP は v4.0.0 で廃止)。 + +## 専門領域 + +### 1. 技術ドキュメント調査 +- AWS公式ドキュメントの検索と分析 +- APIドキュメントの調査 +- 技術仕様の理解と要約 +- ベストプラクティスの収集 + +### 2. Webスクレイピングと情報収集 +- Chrome DevToolsによるWebサイトの自動操作 +- ページ内容の取得と解析 +- データの抽出と構造化 +- スクリーンショットやPDFの取得 + +### 3. 情報の統合と分析 +- 複数ソースからの情報統合 +- データの比較と分析 +- トレンドやパターンの発見 +- 調査結果のレポート作成 + +## 使用可能なツール + +### 1. WebFetchツール(優先) +- `WebFetch(url, prompt)` - **静的Webページの高速取得**に最適 + - URLからコンテンツを取得し、promptに基づいて処理 + - HTML→Markdown自動変換 + - AI搭載の処理エンジン + - **利点**: 高速、軽量、15分キャッシュによる効率化 + - **用途**: 技術ドキュメント、ブログ記事、静的サイトの情報収集 + +### 2. AWS Documentation MCP +- `mcp__plugin_ndf_awslabs.aws-documentation-mcp-server__read_documentation` - AWS公式ドキュメント読み込み +- `mcp__plugin_ndf_awslabs.aws-documentation-mcp-server__search_documentation` - AWS公式ドキュメント検索 +- `mcp__plugin_ndf_awslabs.aws-documentation-mcp-server__recommend` - 関連ドキュメント推奨 + +### 3. Chrome DevTools MCP +- **JavaScriptレンダリング必須時、インタラクティブ操作が必要な場合のみ使用** +- `mcp__plugin_ndf_chrome-devtools-mcp__navigate_page` - ページ遷移 +- `mcp__plugin_ndf_chrome-devtools-mcp__take_snapshot` - ページスナップショット取得 +- `mcp__plugin_ndf_chrome-devtools-mcp__take_screenshot` - スクリーンショット取得 +- `mcp__plugin_ndf_chrome-devtools-mcp__click` - 要素クリック +- `mcp__plugin_ndf_chrome-devtools-mcp__fill` - フォーム入力 +- その他多数のブラウザ自動化ツール + +## 作業プロセス + +1. **調査計画**: 調査目的と情報源を明確化 +2. **ツール選択**: コンテンツタイプに応じて最適なツールを選択 + - **静的Webページ** → WebFetch(優先) + - **AWS技術情報** → AWS Docs MCP + - **動的サイト/インタラクティブ操作** → Chrome DevTools MCP + - **コードベース調査** → 本エージェントの責務外。`corder` エージェントまたは `/ndf:codex` skill を使う +3. **情報収集**: 選択したツールで情報を取得 +4. **情報整理**: 収集した情報を構造化 +5. **分析**: データを分析し、インサイトを抽出 +6. **報告**: わかりやすく整理して結果を報告 + +## 調査の種類 + +### WebFetch調査(静的Webページ) +``` +例: 「Next.js公式ドキュメントのApp Routerについて調査してください」 +1. WebFetch("https://nextjs.org/docs/app", "App Routerの概要と使い方を説明してください") +2. レスポンスから情報を抽出 +3. 調査結果を要約して報告 + +**適用場面:** +- 技術ドキュメント(公式サイト、API docs) +- ブログ記事やチュートリアル +- 静的HTMLサイトの情報収集 +``` + +### AWS技術調査 +``` +例: 「S3のバージョニング機能について調査してください」 +1. search_documentation でS3ドキュメントを検索 +2. read_documentation で詳細を読み込み +3. recommend で関連ドキュメントを取得 +4. 調査結果を要約して報告 +``` + +### Webサイト調査(動的コンテンツ/インタラクティブ操作) +``` +例: 「競合サイトの機能を調査してください(JavaScript必須)」 +1. navigate_page でサイトにアクセス +2. take_snapshot でページ構造を取得 +3. click や fill で機能を操作 +4. take_screenshot で画面キャプチャ +5. 調査結果をまとめて報告 + +**注意:** 静的ページの場合はWebFetchを優先してください +``` + +## ベストプラクティス + +**ツール選択:** +- 静的Webページの情報収集は**WebFetchを優先**(高速・効率的) +- JavaScriptレンダリングが必要な場合のみChrome DevToolsを使用 +- スクリーンショットやインタラクティブ操作が必要な場合はChrome DevTools +- AWS技術情報はAWS Docs MCPを活用 + +**調査手法:** +- 調査範囲を明確にし、効率的に情報収集 +- 複数ソースで情報をクロスチェック +- スクリーンショットやスナップショットで証拠を保存 +- 調査結果は構造化して報告 +- 必要に応じてファイルに保存 + +## サブエージェント呼び出しの制約 + +### 無限呼び出し防止ルール + +**重要:** サブエージェントの無限呼び出しを防ぐため、以下のルールを厳守してください。 + +❌ **サブエージェント呼び出し禁止:** +- **他のサブエージェント(`ndf:director`, `ndf:corder`, `ndf:data-analyst`, `ndf:researcher`, `ndf:scanner`, `ndf:qa`)を呼び出してはいけません** + +✅ **MCP利用可能:** +- AWS Documentation MCP、Chrome DevTools MCP等の各種MCPツールは利用可能 +- ただし、無限ループが発生しないよう注意してください + +### 理由 + +- サブエージェント間の相互呼び出しは無限ループやcore dumpを引き起こす可能性がある +- 専門的なタスクは直接MCPツールを使用して実行する +- 複雑なタスクの分割や他エージェントへの委譲はdirectorエージェントの役割 + +## 制約事項 + +- Webサイトの利用規約を遵守 +- 個人情報や機密情報の取り扱いに注意 +- スクレイピングは節度を持って実行 +- 著作権を侵害しない +- ログイン情報等の認証は慎重に扱う diff --git a/plugins/ndf-claude/hooks/hooks.json b/plugins/ndf-claude/hooks/hooks.json new file mode 100644 index 0000000..c46f68a --- /dev/null +++ b/plugins/ndf-claude/hooks/hooks.json @@ -0,0 +1,38 @@ +{ + "hooks": { + "SessionStart": [ + { + "matcher": "startup", + "description": "Ensure ~/.claude/settings.json cleanupPeriodDays >= 90 (guarded by 7-day timestamp flag) and set default statusline when none is configured", + "hooks": [ + { + "type": "command", + "command": "bash ${PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT}}/scripts/ensure-retention.sh", + "description": "NDF: ensure transcript retention >= 90 days", + "continueOnError": true, + "suppressOutput": false + }, + { + "type": "command", + "command": "bash ${PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT}}/scripts/statusline-switch.sh ensure", + "description": "NDF: set default statusline when none is configured", + "continueOnError": true, + "suppressOutput": false + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "sh -c 'ROOT=\"${CLAUDE_PLUGIN_ROOT:-${PLUGIN_ROOT:-}}\"; if [ -n \"$ROOT\" ]; then node \"$ROOT/scripts/slack-notify.js\" session_end; fi; exit 0'", + "description": "Send Slack notification when Claude Code exits", + "continueOnError": true + } + ] + } + ] + } +} diff --git a/plugins/ndf-claude/scripts/codex-slack-notify.js b/plugins/ndf-claude/scripts/codex-slack-notify.js new file mode 100755 index 0000000..af01f50 --- /dev/null +++ b/plugins/ndf-claude/scripts/codex-slack-notify.js @@ -0,0 +1,330 @@ +#!/usr/bin/env node +/** + * Slack notification script for Codex Stop hooks. + * + * Opt-in only: set NDF_CODEX_SLACK_NOTIFY=true plus the same Slack variables + * used by the Claude Code hook: SLACK_BOT_TOKEN, SLACK_CHANNEL_ID, and + * optional SLACK_USER_MENTION. The script avoids model calls and summarizes + * from Codex's local session JSONL when available. + */ + +const fs = require('fs'); +const path = require('path'); +const https = require('https'); +const { spawnSync } = require('child_process'); +const os = require('os'); +const crypto = require('crypto'); + +const CONFIG = { + MAX_SESSION_FILES: 80, + MAX_LINES: 240, + MAX_TEXT: 180, + LOCK_TIMEOUT_MS: 30000, + COOLDOWN_MS: 5000, + DELETE_DELAY_MS: 500, + FALLBACK_SUMMARY: 'Codexの作業が完了しました', + LOG_DIR: path.join(process.env.CODEX_HOME || path.join(os.homedir(), '.codex'), 'log'), +}; + +const RUN_ID = crypto.randomBytes(4).toString('hex'); + +const isEnabled = () => /^(1|true|yes|on)$/i.test(process.env.NDF_CODEX_SLACK_NOTIFY || ''); +const isDebug = () => /^(1|true|yes|on)$/i.test(process.env.DEBUG_CODEX_SLACK_NOTIFY || ''); + +function log(message, ...args) { + if (!isDebug()) return; + const line = `[${new Date().toISOString()}] [codex-slack:${RUN_ID}] ${message} ${args.map(String).join(' ')}\n`; + process.stderr.write(line); + try { + fs.mkdirSync(CONFIG.LOG_DIR, { recursive: true }); + fs.appendFileSync(path.join(CONFIG.LOG_DIR, 'ndf-codex-slack-notify.log'), line); + } catch (_) { + // Debug logging must never break hook execution. + } +} + +function safeJsonParse(text) { + try { + return JSON.parse(text); + } catch (_) { + return null; + } +} + +async function readStdinJson() { + if (process.stdin.isTTY) return {}; + let input = ''; + for await (const chunk of process.stdin) input += chunk; + log('stdin bytes:', input.length); + return safeJsonParse(input) || {}; +} + +function loadEnvFile() { + let current = process.cwd(); + while (current && current !== path.dirname(current)) { + const envFile = path.join(current, '.env'); + if (fs.existsSync(envFile)) { + for (const rawLine of fs.readFileSync(envFile, 'utf8').split('\n')) { + const line = rawLine.trim(); + if (!line || line.startsWith('#')) continue; + const match = line.match(/^([^=]+)=(.*)$/); + if (!match) continue; + const key = match[1].trim(); + let value = match[2].trim(); + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + process.env[key] ??= value; + } + return; + } + if (fs.existsSync(path.join(current, '.git'))) return; + current = path.dirname(current); + } +} + +function lockPath() { + const id = `${process.cwd()}:${process.env.CODEX_SESSION_ID || ''}`; + const hash = crypto.createHash('sha256').update(id).digest('hex').slice(0, 12); + return path.join(os.tmpdir(), `ndf-codex-slack-${hash}.lock`); +} + +function acquireLock() { + const file = lockPath(); + const now = Date.now(); + const existing = fs.existsSync(file) ? safeJsonParse(fs.readFileSync(file, 'utf8')) : null; + if (existing?.completedAt && now - existing.completedAt < CONFIG.COOLDOWN_MS) return false; + if (existing?.timestamp && !existing.completedAt && now - existing.timestamp < CONFIG.LOCK_TIMEOUT_MS) return false; + fs.writeFileSync(file, JSON.stringify({ pid: process.pid, timestamp: now, completedAt: null })); + return true; +} + +function releaseLock() { + const file = lockPath(); + const data = fs.existsSync(file) ? safeJsonParse(fs.readFileSync(file, 'utf8')) || {} : {}; + data.completedAt = Date.now(); + fs.writeFileSync(file, JSON.stringify(data)); +} + +function gitValue(args, fallback = '') { + const result = spawnSync('git', args, { cwd: process.cwd(), encoding: 'utf8' }); + return result.status === 0 ? result.stdout.trim() : fallback; +} + +function repoInfo() { + const root = gitValue(['rev-parse', '--show-toplevel'], process.cwd()); + const branch = gitValue(['branch', '--show-current'], ''); + return { + name: path.basename(root || process.cwd()), + root, + branch, + }; +} + +function codexHome() { + return process.env.CODEX_HOME || path.join(os.homedir(), '.codex'); +} + +function findSessionFiles() { + const base = path.join(codexHome(), 'sessions'); + if (!fs.existsSync(base)) return []; + const files = []; + const stack = [base]; + while (stack.length) { + const dir = stack.pop(); + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const p = path.join(dir, entry.name); + if (entry.isDirectory()) stack.push(p); + else if (entry.isFile() && entry.name.endsWith('.jsonl')) { + files.push({ path: p, mtimeMs: fs.statSync(p).mtimeMs }); + } + } + } + return files.sort((a, b) => b.mtimeMs - a.mtimeMs).slice(0, CONFIG.MAX_SESSION_FILES); +} + +function textFromContent(content) { + if (typeof content === 'string') return content; + if (Array.isArray(content)) { + return content.map((item) => item?.text || item?.content || '').filter(Boolean).join('\n'); + } + if (content && typeof content === 'object') return content.text || content.message || ''; + return ''; +} + +function textFromItem(item) { + const payload = item?.payload || {}; + if (payload.type === 'message') { + return textFromContent(payload.content); + } + if (payload.type === 'agent_message') return payload.message || ''; + if (item.type === 'event_msg' && payload.message) return payload.message; + return ''; +} + +function summarizeText(text) { + const oneLine = String(text || '') + .replace(/```[\s\S]*?```/g, '') + .replace(/\s+/g, ' ') + .trim(); + if (!oneLine) return null; + return oneLine.length > CONFIG.MAX_TEXT ? `${oneLine.slice(0, CONFIG.MAX_TEXT - 1)}…` : oneLine; +} + +function readSessionSummary(hookInput) { + const explicitPath = hookInput.transcript_path || hookInput.session_path || hookInput.session_file; + const candidates = explicitPath ? [{ path: explicitPath }] : findSessionFiles(); + const cwd = hookInput.cwd || process.cwd(); + const sessionId = hookInput.session_id || hookInput.thread_id || process.env.CODEX_SESSION_ID || ''; + const lastAssistantSummary = summarizeText(textFromContent(hookInput.last_assistant_message)); + + for (const candidate of candidates) { + if (!candidate.path || !fs.existsSync(candidate.path)) continue; + const lines = fs.readFileSync(candidate.path, 'utf8').trim().split('\n').slice(-CONFIG.MAX_LINES); + const parsed = lines.map(safeJsonParse).filter(Boolean); + const meta = parsed.find((item) => item.type === 'session_meta')?.payload || {}; + if (!explicitPath && meta.cwd && meta.cwd !== cwd) continue; + if (!explicitPath && sessionId && meta.session_id && meta.session_id !== sessionId) continue; + + const final = [...parsed].reverse().find((item) => { + const payload = item.payload || {}; + return payload.phase === 'final_answer' || payload.type === 'agent_message'; + }); + const user = [...parsed].reverse().find((item) => { + const payload = item.payload || {}; + return payload.type === 'message' && payload.role === 'user'; + }); + const tokenEvent = [...parsed].reverse().find((item) => item.type === 'event_msg' && item.payload?.type === 'token_count'); + + return { + sessionId: meta.session_id || sessionId, + summary: lastAssistantSummary || summarizeText(textFromItem(final)) || summarizeText(textFromItem(user)), + model: meta.model || meta.model_slug || '', + tokenInfo: tokenEvent?.payload?.info || null, + file: candidate.path, + }; + } + + return { sessionId, summary: lastAssistantSummary, model: '', tokenInfo: null, file: null }; +} + +function formatTokenInfo(info) { + const usage = info?.total_token_usage || info?.last_token_usage; + const window = info?.model_context_window; + if (!usage?.total_tokens || !window) return ''; + const pct = Math.round((usage.total_tokens / window) * 100); + return `tokens: ${usage.total_tokens}/${window} (${pct}%)`; +} + +function formatMessage(repo, session, includeMention = false) { + const mention = includeMention && process.env.SLACK_USER_MENTION ? `${process.env.SLACK_USER_MENTION} ` : ''; + const parts = [ + `${mention}[${repo.name}] Codex: ${session.summary || CONFIG.FALLBACK_SUMMARY}`, + repo.branch ? `branch: ${repo.branch}` : '', + session.model ? `model: ${session.model}` : '', + formatTokenInfo(session.tokenInfo), + session.sessionId ? `session: ${session.sessionId}` : '', + `cwd: ${process.cwd()}`, + ].filter(Boolean); + return parts.join('\n'); +} + +function slackApiRequest(apiPath, data) { + const token = process.env.SLACK_BOT_TOKEN; + if (!token) return Promise.resolve(null); + + return new Promise((resolve) => { + const body = JSON.stringify(data); + const req = https.request({ + hostname: 'slack.com', + port: 443, + path: apiPath, + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json; charset=utf-8', + 'Content-Length': Buffer.byteLength(body), + }, + }, (res) => { + let response = ''; + res.on('data', (chunk) => { response += chunk; }); + res.on('end', () => { + const json = safeJsonParse(response); + log('slack api:', apiPath, 'ok:', json?.ok, 'error:', json?.error || ''); + resolve(json?.ok === true ? json : null); + }); + }); + req.on('error', (error) => { + log('slack error:', error.message); + resolve(null); + }); + req.write(body); + req.end(); + }); +} + +function postSlack(text) { + const channel = process.env.SLACK_CHANNEL_ID; + if (!channel) return Promise.resolve(null); + return slackApiRequest('/api/chat.postMessage', { channel, text }); +} + +function deleteSlack(ts) { + const channel = process.env.SLACK_CHANNEL_ID; + if (!channel || !ts) return Promise.resolve(null); + return slackApiRequest('/api/chat.delete', { channel, ts }); +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function main() { + loadEnvFile(); + if (!isEnabled()) { + log('disabled'); + return; + } + if (!process.env.SLACK_BOT_TOKEN || !process.env.SLACK_CHANNEL_ID) { + log('missing slack env'); + return; + } + if (!acquireLock()) { + log('lock rejected'); + return; + } + try { + const hookInput = await readStdinJson(); + const repo = repoInfo(); + const session = readSessionSummary(hookInput); + if (!process.env.SLACK_USER_MENTION) { + await postSlack(formatMessage(repo, session, false)); + return; + } + + const mentionResult = await postSlack(formatMessage(repo, session, true)); + if (!mentionResult) { + log('failed to send mention message'); + return; + } + + await sleep(CONFIG.DELETE_DELAY_MS); + + const cleanResult = await postSlack(formatMessage(repo, session, false)); + if (!cleanResult) { + log('failed to send clean message'); + return; + } + + const deleteResult = await deleteSlack(mentionResult.ts); + if (!deleteResult) log('failed to delete mention message'); + } finally { + releaseLock(); + } +} + +main().catch((error) => { + log('fatal:', error.stack || error.message); + releaseLock(); + process.exit(0); +}); diff --git a/plugins/ndf-claude/scripts/ensure-retention.sh b/plugins/ndf-claude/scripts/ensure-retention.sh new file mode 100755 index 0000000..e511363 --- /dev/null +++ b/plugins/ndf-claude/scripts/ensure-retention.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# NDF plugin: ~/.claude/settings.json の cleanupPeriodDays を最低 90 日に保つ。 +# - 既存値 >= 90 なら何もしない +# - 既存値 < 90 or 未設定 なら 90 に更新 +# - 前回チェックから 7 日経っていなければスキップ (多重実行防止) +# - 複数セッション同時起動時の race condition を flock で回避 (flock 不在時は atomic rename のみ) +set -euo pipefail + +MIN_DAYS=90 +GUARD_DAYS=7 +SETTINGS="$HOME/.claude/settings.json" +FLAG="$HOME/.claude/.ndf-retention-checked" +LOCK="$HOME/.claude/.ndf-retention.lock" + +# jq 必須 +if ! command -v jq >/dev/null 2>&1; then + exit 0 +fi + +# 7 日以内に実行済みならスキップ +if [ -f "$FLAG" ]; then + if find "$FLAG" -mtime "-${GUARD_DAYS}" -print -quit 2>/dev/null | grep -q .; then + exit 0 + fi +fi + +# settings.json が存在しない場合は新規作成 +mkdir -p "$(dirname "$SETTINGS")" +if [ ! -f "$SETTINGS" ]; then + echo "{}" > "$SETTINGS" +fi + +# 更新処理をひとつの関数にまとめ、ロック内で呼び出す +update_retention() { + # ロック取得後に再チェック (並行セッションが先に更新済みの可能性) + local current tmp + current=$(jq -r '.cleanupPeriodDays // 0' "$SETTINGS" 2>/dev/null || echo 0) + case "$current" in + ''|*[!0-9]*) current=0 ;; + esac + if [ "$current" -ge "$MIN_DAYS" ]; then + return 0 + fi + tmp="$(mktemp)" + if jq --argjson v "$MIN_DAYS" '.cleanupPeriodDays = $v' "$SETTINGS" > "$tmp"; then + mv "$tmp" "$SETTINGS" + echo "[ndf] cleanupPeriodDays を ${current} → ${MIN_DAYS} に更新しました (~/.claude/settings.json)" + else + rm -f "$tmp" + return 1 + fi +} + +# ロック取得: flock があればアトミック更新、無ければ atomic rename のみに依存 +if command -v flock >/dev/null 2>&1; then + ( + # 最大 5 秒待機。取得できなければ他プロセスが更新中なので何もしない + flock -x -w 5 200 || exit 0 + update_retention || true + ) 200>"$LOCK" +else + # flock 不在環境 (macOS 標準など): mv 自体は POSIX で atomic なので大きな破損は起きない + update_retention || true +fi + +touch "$FLAG" diff --git a/plugins/ndf-claude/scripts/install-official-skills.sh b/plugins/ndf-claude/scripts/install-official-skills.sh new file mode 100755 index 0000000..bba6467 --- /dev/null +++ b/plugins/ndf-claude/scripts/install-official-skills.sh @@ -0,0 +1,183 @@ +#!/bin/bash +# Anthropic公式Skillsのインストーラ +# +# Usage: +# bash install-official-skills.sh [SKILL_NAMES...] +# bash install-official-skills.sh --all +# bash install-official-skills.sh --list +# bash install-official-skills.sh --scope project [SKILL_NAMES...] +# bash install-official-skills.sh --update +# +# Examples: +# bash install-official-skills.sh docx pptx xlsx # 指定Skillをインストール (~/.claude/skills/) +# bash install-official-skills.sh --scope project pdf # プロジェクト .claude/skills/ に配置 +# bash install-official-skills.sh --all # 全Skillインストール +# bash install-official-skills.sh --update # 公式リポジトリを最新化 + +set -euo pipefail + +REPO_URL="https://github.com/anthropics/skills.git" +CACHE_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/anthropic-skills" +USER_SKILLS_DIR="$HOME/.claude/skills" +PROJECT_SKILLS_DIR=".claude/skills" + +SCOPE="user" +DO_ALL=false +DO_LIST=false +DO_UPDATE=false +SKILLS=() + +# 引数パース +while [[ $# -gt 0 ]]; do + case "$1" in + --scope) + SCOPE="$2" + shift 2 + ;; + --all) + DO_ALL=true + shift + ;; + --list) + DO_LIST=true + shift + ;; + --update) + DO_UPDATE=true + shift + ;; + --help|-h) + sed -n '2,13p' "$0" | sed 's/^# \?//' + exit 0 + ;; + --*) + echo "ERROR: unknown option $1" >&2 + exit 1 + ;; + *) + SKILLS+=("$1") + shift + ;; + esac +done + +# --- クローン or 更新 --- +if [ ! -d "$CACHE_DIR/.git" ]; then + echo "==> 公式Skillsリポジトリを取得中: $CACHE_DIR" + mkdir -p "$(dirname "$CACHE_DIR")" + git clone --depth 1 "$REPO_URL" "$CACHE_DIR" +elif [ "$DO_UPDATE" = true ]; then + echo "==> 公式Skillsを最新化中" + git -C "$CACHE_DIR" pull --ff-only +fi + +# --- --list: 利用可能Skill一覧を表示 --- +if [ "$DO_LIST" = true ]; then + echo "" + echo "=== 利用可能なSkill ===" + for dir in "$CACHE_DIR"/skills/*/; do + name=$(basename "$dir") + license_file="$dir/LICENSE.txt" + if [ -f "$license_file" ]; then + if grep -q "Apache License" "$license_file"; then + license="Apache-2.0" + elif grep -q "Proprietary\|All rights reserved" "$license_file"; then + license="Proprietary" + else + license="?" + fi + else + license="-" + fi + printf " %-25s [%s]\n" "$name" "$license" + done + echo "" + echo "Proprietary Skillは各自の環境にインストール可能ですが、再配布は禁止です。" + exit 0 +fi + +# --- インストール先を決定 --- +case "$SCOPE" in + user) + DEST="$USER_SKILLS_DIR" + ;; + project) + if [ ! -d .git ] && [ ! -f .claude-plugin/plugin.json ] && [ ! -f package.json ] && [ ! -f pyproject.toml ]; then + echo "WARN: プロジェクトルートらしきファイルが見当たりません (.git, package.json 等)。カレントディレクトリ: $(pwd)" + fi + DEST="$PROJECT_SKILLS_DIR" + ;; + *) + echo "ERROR: --scope は user / project のいずれか" >&2 + exit 1 + ;; +esac + +mkdir -p "$DEST" +echo "==> インストール先: $DEST ($SCOPE scope)" + +# --- 対象Skill決定 --- +if [ "$DO_ALL" = true ]; then + SKILLS=() + for dir in "$CACHE_DIR"/skills/*/; do + SKILLS+=("$(basename "$dir")") + done +fi + +if [ "${#SKILLS[@]}" -eq 0 ]; then + echo "ERROR: インストールするSkillが指定されていません。" >&2 + echo "利用可能な一覧は: bash $0 --list" >&2 + exit 1 +fi + +# --- シンボリックリンク作成 --- +INSTALLED=() +SKIPPED=() +NOT_FOUND=() +for skill in "${SKILLS[@]}"; do + src="$CACHE_DIR/skills/$skill" + dst="$DEST/$skill" + + if [ ! -d "$src" ]; then + NOT_FOUND+=("$skill") + continue + fi + + if [ -e "$dst" ] || [ -L "$dst" ]; then + if [ -L "$dst" ] && [ "$(readlink "$dst")" = "$src" ]; then + SKIPPED+=("$skill (既にリンク済み)") + continue + fi + echo "WARN: $dst が既に存在します。上書きしますか? [y/N]" + read -r ans + if [ "$ans" != "y" ] && [ "$ans" != "Y" ]; then + SKIPPED+=("$skill (ユーザーキャンセル)") + continue + fi + rm -rf "$dst" + fi + + ln -s "$src" "$dst" + INSTALLED+=("$skill") +done + +# --- 結果報告 --- +echo "" +echo "=== インストール結果 ===" +if [ "${#INSTALLED[@]}" -gt 0 ]; then + echo "インストール済み (${#INSTALLED[@]}個):" + for s in "${INSTALLED[@]}"; do echo " ✓ $s"; done +fi +if [ "${#SKIPPED[@]}" -gt 0 ]; then + echo "スキップ (${#SKIPPED[@]}個):" + for s in "${SKIPPED[@]}"; do echo " - $s"; done +fi +if [ "${#NOT_FOUND[@]}" -gt 0 ]; then + echo "見つからない (${#NOT_FOUND[@]}個):" + for s in "${NOT_FOUND[@]}"; do echo " ✗ $s"; done + echo "" + echo "一覧確認: bash $0 --list" +fi + +echo "" +echo "Claude Codeを再起動するか /plugin reload で反映してください。" diff --git a/plugins/ndf-claude/scripts/slack-notify.js b/plugins/ndf-claude/scripts/slack-notify.js new file mode 100755 index 0000000..38b9991 --- /dev/null +++ b/plugins/ndf-claude/scripts/slack-notify.js @@ -0,0 +1,777 @@ +#!/usr/bin/env node +/** + * Slack notification script for Claude Code session completion + * + * Reads transcript_path from stdin (hook input JSON) and generates + * a session-specific summary using Claude CLI. + */ + +const fs = require('fs'); +const path = require('path'); +const https = require('https'); +const { spawn } = require('child_process'); +const os = require('os'); +const crypto = require('crypto'); + +// ============================================================================ +// Constants +// ============================================================================ + +// Generate unique run ID for this execution to trace logs +const RUN_ID = crypto.randomBytes(4).toString('hex'); + +const CONFIG = { + CLI_TIMEOUT_MS: 60000, + MAX_RESPONSES: 5, + MAX_TRANSCRIPT_LINES: 100, + MAX_FILE_SIZE_BYTES: 10 * 1024 * 1024, // 10MB + MIN_TEXT_LENGTH: 5, + MIN_RESPONSE_LENGTH: 10, + MAX_CONTENT_LENGTH: 500, + MAX_CONTEXT_LENGTH: 2500, + FALLBACK_REPO_NAME: 'unknown', + NO_SUMMARY_MESSAGE: 'Claude Codeのセッションが終了しました(要約なし)', + LOCK_TIMEOUT_MS: 30000, // Lock expires after 30 seconds + NOTIFICATION_COOLDOWN_MS: 5000, // Prevent duplicate notifications within 5 seconds + DELETE_DELAY_MS: 500, // Wait before deleting mention message + LOG_DIR: path.join(os.homedir(), '.claude', 'logs') +}; + +const META_CONTENT_PREFIXES = ['Caveat:', '']; + +// ============================================================================ +// Utilities +// ============================================================================ + +/** + * Get log file path for today's date + * Format: ~/.claude/logs/slack-notify-YYYY-MM-DD.log + */ +function getLogFilePath() { + const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD + return path.join(CONFIG.LOG_DIR, `slack-notify-${today}.log`); +} + +/** + * Ensure log directory exists + */ +function ensureLogDirectory() { + if (!fs.existsSync(CONFIG.LOG_DIR)) { + fs.mkdirSync(CONFIG.LOG_DIR, { recursive: true }); + } +} + +const isDebugMode = () => process.env.DEBUG_SLACK_NOTIFY === 'true'; + +const debugLog = (message, ...args) => { + if (isDebugMode()) { + const timestamp = new Date().toISOString(); + const logMessage = `[${timestamp}] [RUN:${RUN_ID}] [PID:${process.pid}] ${message}`; + + // Format additional arguments + const formattedArgs = args.map(arg => { + if (typeof arg === 'object') { + return JSON.stringify(arg); + } + return String(arg); + }).join(' '); + + const fullMessage = formattedArgs ? `${logMessage} ${formattedArgs}\n` : `${logMessage}\n`; + + // Write to stderr for backward compatibility + process.stderr.write(fullMessage); + + // Write to log file + try { + ensureLogDirectory(); + fs.appendFileSync(getLogFilePath(), fullMessage, 'utf8'); + } catch (error) { + // If file write fails, at least stderr output is available + process.stderr.write(`[ERROR] Failed to write to log file: ${error.message}\n`); + } + } +}; + +const safeJsonParse = (str) => { + try { + return JSON.parse(str); + } catch (e) { + debugLog('JSON parse error:', e.message, 'Input:', str?.substring(0, 100)); + return null; + } +}; + +const readFileLines = (filePath) => { + if (!fs.existsSync(filePath)) { + debugLog('File does not exist:', filePath); + return null; + } + try { + const stats = fs.statSync(filePath); + debugLog('Transcript file stats:', { + path: filePath, + size: stats.size, + mtime: stats.mtime.toISOString(), + mtimeMs: stats.mtimeMs + }); + if (stats.size > CONFIG.MAX_FILE_SIZE_BYTES) { + debugLog('Transcript file too large:', stats.size, 'bytes'); + return null; + } + const content = fs.readFileSync(filePath, 'utf8').trim(); + const lines = content.split('\n'); + debugLog('Read', lines.length, 'lines from transcript | File size:', stats.size); + return lines; + } catch (error) { + debugLog('Error reading transcript file:', error.message); + return null; + } +}; + +const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms)); + +// ============================================================================ +// Lock Management (prevent duplicate execution) +// ============================================================================ + +function getLockFilePath() { + // Use working directory hash instead of session ID for robust deduplication + // This prevents multiple Claude Code instances in the same project from sending duplicate notifications + const cwd = process.cwd(); + const hash = crypto.createHash('md5').update(cwd).digest('hex').substring(0, 8); + return path.join(os.tmpdir(), `slack-notify-${hash}.lock`); +} + +function acquireLock() { + const lockFile = getLockFilePath(); + const now = Date.now(); + + debugLog('Attempting to acquire lock:', lockFile); + + try { + // Check if lock exists + if (fs.existsSync(lockFile)) { + const lockData = safeJsonParse(fs.readFileSync(lockFile, 'utf8')); + debugLog('Existing lock found:', lockData); + if (lockData) { + // Check if notification was sent recently (cooldown period) + if (lockData.completedAt && (now - lockData.completedAt) < CONFIG.NOTIFICATION_COOLDOWN_MS) { + const elapsed = now - lockData.completedAt; + debugLog('LOCK REJECTED: Notification sent recently | Elapsed:', elapsed, 'ms | Cooldown:', CONFIG.NOTIFICATION_COOLDOWN_MS, 'ms | Previous PID:', lockData.pid); + return false; + } + // Check if another process is still holding the lock + if (!lockData.completedAt && (now - lockData.timestamp) < CONFIG.LOCK_TIMEOUT_MS) { + const elapsed = now - lockData.timestamp; + debugLog('LOCK REJECTED: Lock held by another process | PID:', lockData.pid, '| Elapsed:', elapsed, 'ms | Timeout:', CONFIG.LOCK_TIMEOUT_MS, 'ms'); + return false; + } + } + debugLog('Lock expired or cooldown ended, proceeding'); + } else { + debugLog('No existing lock file found'); + } + + // Create lock + const lockData = { + pid: process.pid, + runId: RUN_ID, + timestamp: now, + completedAt: null + }; + fs.writeFileSync(lockFile, JSON.stringify(lockData)); + debugLog('LOCK ACQUIRED:', lockData); + return true; + } catch (error) { + debugLog('Lock acquisition error:', error.message); + return false; + } +} + +function releaseLock() { + const lockFile = getLockFilePath(); + try { + if (fs.existsSync(lockFile)) { + // Update lock with completion time instead of deleting + // This enables cooldown period to prevent duplicate notifications + const lockData = safeJsonParse(fs.readFileSync(lockFile, 'utf8')) || {}; + lockData.completedAt = Date.now(); + lockData.releasedByRunId = RUN_ID; + fs.writeFileSync(lockFile, JSON.stringify(lockData)); + debugLog('LOCK RELEASED:', lockData); + } else { + debugLog('Lock file not found during release'); + } + } catch (error) { + debugLog('Lock release error:', error.message); + } +} + +// ============================================================================ +// Environment Management +// ============================================================================ + +function loadEnvFile() { + let currentDir = __dirname; + + while (currentDir !== path.dirname(currentDir)) { + currentDir = path.dirname(currentDir); + const envFile = path.join(currentDir, '.env'); + + if (fs.existsSync(envFile)) { + debugLog('Loading env file:', envFile); + parseEnvFile(envFile); + break; + } + if (fs.existsSync(path.join(currentDir, '.git'))) break; + } +} + +function parseEnvFile(envFile) { + const content = fs.readFileSync(envFile, 'utf8'); + + for (const rawLine of content.split('\n')) { + const line = rawLine.trim(); + if (!line || line.startsWith('#')) continue; + + const match = line.match(/^([^=]+)=(.*)$/); + if (!match) continue; + + const key = match[1].trim(); + let value = match[2].trim(); + + // Remove surrounding quotes + if ((value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + + process.env[key] ??= value; + } +} + +// ============================================================================ +// Transcript Processing +// ============================================================================ + +function isMetaContent(content) { + return typeof content === 'string' && + META_CONTENT_PREFIXES.some(prefix => content.startsWith(prefix)); +} + +function extractTextFromContent(content) { + if (typeof content === 'string') { + return isMetaContent(content) ? null : content.substring(0, CONFIG.MAX_CONTENT_LENGTH); + } + + if (Array.isArray(content)) { + const textItem = content.find(item => + item?.type === 'text' && typeof item.text === 'string' + ); + return textItem?.text?.substring(0, CONFIG.MAX_CONTENT_LENGTH) ?? null; + } + + return null; +} + +function parseTranscriptData(transcriptPath) { + debugLog('Parsing transcript:', transcriptPath); + const allLines = readFileLines(transcriptPath); + if (!allLines) { + debugLog('No lines read from transcript'); + return { firstRequest: null, assistantResponses: [] }; + } + + // Process only recent lines to avoid memory issues with large files + const lines = allLines.slice(-CONFIG.MAX_TRANSCRIPT_LINES); + debugLog('Processing', lines.length, 'recent lines'); + + let firstRequest = null; + const assistantResponses = []; + let parsedCount = 0; + let userCount = 0; + let assistantCount = 0; + + for (const line of lines) { + const data = safeJsonParse(line); + if (!data?.message?.role) continue; + parsedCount++; + + const { role, content } = data.message; + const text = extractTextFromContent(content); + + // Get first valid user request (within recent lines) + if (!firstRequest && + role === 'user' && + data.type === 'user' && + !data.isMeta && + text?.length >= CONFIG.MIN_TEXT_LENGTH) { + firstRequest = text; + userCount++; + debugLog('Found user request:', text.substring(0, 50) + '...'); + } + + // Collect assistant responses + if (role === 'assistant' && text?.length >= CONFIG.MIN_RESPONSE_LENGTH) { + assistantResponses.push(text); + assistantCount++; + } + } + + debugLog('Parsed messages:', parsedCount, '| User:', userCount, '| Assistant:', assistantCount); + + return { + firstRequest, + assistantResponses: assistantResponses.slice(-CONFIG.MAX_RESPONSES) + }; +} + +function formatContextForSummary({ firstRequest, assistantResponses }) { + if (!firstRequest && assistantResponses.length === 0) { + debugLog('No content for summary context'); + return null; + } + + const parts = []; + if (firstRequest) { + parts.push(`【ユーザーのリクエスト】\n${firstRequest}`); + } + if (assistantResponses.length > 0) { + parts.push(`【アシスタントの応答】\n${assistantResponses.join('\n\n')}`); + } + + const context = parts.join('\n\n'); + debugLog('Context length:', context.length); + return context.length >= CONFIG.MIN_RESPONSE_LENGTH ? context : null; +} + +function createSummarizationPrompt(context) { + return `以下の情報から、**このセッションで実施した作業**を日本語で40文字以内で要約してください。 + +## 重要 +- ユーザーのリクエストとアシスタントの応答から、このセッション固有の作業内容を特定してください +- 「調査」「確認」などの一般的な表現より、具体的な作業内容(例:「Slack通知の要約生成を修正」)を優先してください + +## 出力形式 +日本語の要約文のみ(1行、40文字以内) + +## 必須事項 +- 必ず日本語で出力すること +- このセッションで実際に行った具体的な作業を要約すること +- 技術的な作業内容を含めること(ファイル名、機能名など) + +## 禁止事項 +- 英語での出力 +- 「ユーザーさん」「調査結果」などの前置き +- 「要約:」「作業内容:」などのラベル +- 「〜を確認しました」「〜を調査しました」などの曖昧な表現(具体的な結果を書く) +- 挨拶文や説明文 +- 複数行の出力 + +## 良い例 +- Slack通知の要約生成ロジックを修正 +- BigQueryクエリのパフォーマンスを最適化 +- JWT認証のセキュリティレビューを実施 +- PRマージ後のブランチクリーンアップを実行 + +## 悪い例 +- Added new MCP server(英語は禁止) +- コードを調査しました(曖昧すぎる) +- ユーザーさん、調査結果をまとめます: +- 要約:〜を実施しました + +## セッション情報 +${context.substring(0, CONFIG.MAX_CONTEXT_LENGTH)} + +## 日本語の要約(40文字以内):`; +} + +// ============================================================================ +// Claude CLI Integration (with MCP tools disabled) +// ============================================================================ + +// Auth priority: OAuth > Bedrock > API Key (cost optimization) +const AUTH_CHECKS = [ + { + name: 'oauth', + detect: () => { + try { + const creds = JSON.parse(fs.readFileSync(path.join(os.homedir(), '.claude', '.credentials.json'), 'utf8')); + const oauth = creds?.claudeAiOauth; + return oauth?.accessToken && oauth?.expiresAt > Date.now(); + } catch (e) { + debugLog('OAuth detection failed:', e.message); + return false; + } + }, + removeKeys: ['ANTHROPIC_API_KEY', 'CLAUDE_CODE_USE_BEDROCK'], + }, + { + name: 'bedrock', + detect: () => !!process.env.CLAUDE_CODE_USE_BEDROCK, + removeKeys: ['ANTHROPIC_API_KEY'], + }, + { + name: 'apikey', + detect: () => process.env.ANTHROPIC_API_KEY?.startsWith('sk-ant-'), + removeKeys: ['CLAUDE_CODE_USE_BEDROCK'], + }, +]; + +const SESSION_VARS = ['CLAUDECODE', 'CLAUDE_CODE_SSE_PORT', 'CLAUDE_CODE_ENTRYPOINT']; + +function buildCleanEnv(skip = []) { + const env = { ...process.env }; + for (const key of SESSION_VARS) delete env[key]; + + const auth = AUTH_CHECKS.find(a => !skip.includes(a.name) && a.detect()) || { name: 'none', removeKeys: [] }; + for (const key of auth.removeKeys) delete env[key]; + if (auth.name === 'none' && env.ANTHROPIC_API_KEY && !env.ANTHROPIC_API_KEY.startsWith('sk-ant-')) { + delete env.ANTHROPIC_API_KEY; + } + debugLog(`Auth: ${auth.name} | removed: [${[...SESSION_VARS, ...auth.removeKeys].join(', ')}]`); + return { env, authName: auth.name }; +} + +function spawnClaudeCLI(prompt, env) { + return new Promise((resolve) => { + const args = [ + '--print', '--no-session-persistence', + '--strict-mcp-config', '--mcp-config', '{"mcpServers":{}}', + '--tools', '', '--model', 'haiku', '-p', prompt + ]; + + const claude = spawn('claude', args, { stdio: ['ignore', 'pipe', 'pipe'], env }); + if (claude.pid) debugLog('Claude CLI spawned with PID:', claude.pid); + + let stdout = '', stderr = '', resolved = false; + const safeResolve = (value, reason) => { + if (resolved) return; + resolved = true; + clearTimeout(timeoutId); + debugLog('CLI resolved:', reason, '| Output length:', value?.length || 0); + resolve(value); + }; + + claude.stdout.on('data', (d) => { stdout += d; }); + claude.stderr.on('data', (d) => { stderr += d; }); + + const timeoutId = setTimeout(() => { + debugLog('Claude CLI timeout after', CONFIG.CLI_TIMEOUT_MS, 'ms'); + claude.kill('SIGTERM'); + safeResolve(null, 'timeout'); + }, CONFIG.CLI_TIMEOUT_MS); + + claude.on('close', (code, signal) => { + if (signal) { safeResolve(null, 'signal:' + signal); return; } + debugLog('Claude CLI exit code:', code); + debugLog('CLI stdout:', stdout.substring(0, 200)); + debugLog('CLI stderr:', stderr.substring(0, 200)); + (code === 0 && stdout.trim()) ? safeResolve(stdout.trim(), 'success') + : safeResolve(null, 'error:' + code); + }); + + claude.on('error', (err) => { + debugLog('Claude CLI spawn error:', err.message); + safeResolve(null, 'spawn-error'); + }); + }); +} + +async function callClaudeCLI(prompt) { + debugLog('=== Calling Claude CLI for summary generation ==='); + const skip = []; + // Try each auth method in priority order, falling back on failure + for (let i = 0; i <= AUTH_CHECKS.length; i++) { + const { env, authName } = buildCleanEnv(skip); + const result = await spawnClaudeCLI(prompt, env); + if (result) return result; + if (authName === 'none') break; + debugLog(`Auth ${authName} failed, trying next`); + skip.push(authName); + } + return null; +} + +function cleanSummaryResponse(summary) { + if (!summary) { + debugLog('No summary to clean'); + return null; + } + + debugLog('Raw summary:', summary); + + const cleaned = summary + .replace(/^(要約[::]?\s*|作業内容[::]?\s*)/i, '') + .replace(/^[\s\n]+/, '') + .split('\n')[0] + .trim(); + + debugLog('Cleaned summary:', cleaned); + return cleaned.length >= CONFIG.MIN_TEXT_LENGTH ? cleaned : null; +} + +async function generateSummary(transcriptPath) { + debugLog('=== Starting summary generation ==='); + + const transcriptData = parseTranscriptData(transcriptPath); + debugLog('Transcript data:', { + hasFirstRequest: !!transcriptData.firstRequest, + responseCount: transcriptData.assistantResponses.length + }); + + const context = formatContextForSummary(transcriptData); + if (!context) { + debugLog('No context available for summary'); + return null; + } + + const prompt = createSummarizationPrompt(context); + debugLog('Prompt length:', prompt.length); + + const output = await callClaudeCLI(prompt); + const result = cleanSummaryResponse(output); + + debugLog('=== Summary generation complete:', result ? 'SUCCESS' : 'FAILED', '==='); + return result; +} + +// ============================================================================ +// Slack API Integration +// ============================================================================ + +function makeSlackApiRequest(apiPath, data, token) { + return new Promise((resolve, reject) => { + const payload = JSON.stringify(data); + debugLog('Slack API request:', apiPath, '| Payload size:', payload.length); + + const req = https.request({ + hostname: 'slack.com', + port: 443, + path: apiPath, + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json; charset=utf-8', + 'Content-Length': Buffer.byteLength(payload) + } + }, (res) => { + let body = ''; + res.on('data', (chunk) => { body += chunk; }); + res.on('end', () => { + const result = safeJsonParse(body); + debugLog('Slack API response:', apiPath, '| ok:', result?.ok, '| error:', result?.error || 'none'); + if (result?.ok) { + resolve(result); + } else { + debugLog('Slack API error details:', result); + resolve(null); + } + }); + }); + + req.on('error', (error) => { + debugLog('Slack API network error:', error.message); + reject(error); + }); + req.write(payload); + req.end(); + }); +} + +const sendSlackMessage = (channelId, token, text) => + makeSlackApiRequest('/api/chat.postMessage', { channel: channelId, text }, token); + +const deleteSlackMessage = (channelId, token, ts) => + makeSlackApiRequest('/api/chat.delete', { channel: channelId, ts }, token); + +// ============================================================================ +// Git Integration +// ============================================================================ + +function getRepositoryName() { + return new Promise((resolve) => { + const git = spawn('git', ['rev-parse', '--show-toplevel']); + let output = ''; + + git.stdout.on('data', (data) => { output += data; }); + git.on('close', (code) => { + const repoName = code === 0 && output.trim() + ? path.basename(output.trim()) + : process.env.GIT_REPO || CONFIG.FALLBACK_REPO_NAME; + debugLog('Repository name:', repoName); + resolve(repoName); + }); + git.on('error', () => resolve(process.env.GIT_REPO || CONFIG.FALLBACK_REPO_NAME)); + }); +} + +// ============================================================================ +// Hook Input Processing +// ============================================================================ + +async function readHookInput() { + debugLog('=== Reading hook input from stdin ==='); + if (process.stdin.isTTY) { + debugLog('stdin is TTY, no hook input available'); + return { transcriptPath: null, stopHookActive: false }; + } + + let input = ''; + for await (const chunk of process.stdin) { + input += chunk; + } + + debugLog('Raw hook input length:', input.length, '| Preview:', input.substring(0, 200)); + const parsed = safeJsonParse(input); + if (parsed) { + debugLog('Parsed hook input:', JSON.stringify(parsed, null, 2)); + } else { + debugLog('Failed to parse hook input'); + } + + const result = { + transcriptPath: parsed?.transcript_path ?? null, + stopHookActive: parsed?.stop_hook_active === true, + assistantResponse: parsed?.assistant_response ?? null + }; + debugLog('Extracted hook input values:', result); + + return result; +} + +// ============================================================================ +// Message Formatting +// ============================================================================ + +function formatNotificationMessage(repoName, summary, includeMention = false) { + const userMention = process.env.SLACK_USER_MENTION; + const content = summary || CONFIG.NO_SUMMARY_MESSAGE; + + // Add debug info to message if in debug mode + const debugInfo = isDebugMode() + ? ` [RUN:${RUN_ID}|PID:${process.pid}]` + : ''; + + const message = `[${repoName}] ${content}${debugInfo}`; + + return includeMention && userMention ? `${userMention} ${message}` : message; +} + +// ============================================================================ +// Main Flow +// ============================================================================ + +async function main() { + debugLog('=== Slack notify script started ==='); + debugLog('RUN_ID:', RUN_ID); + debugLog('PID:', process.pid); + debugLog('CWD:', process.cwd()); + debugLog('ENV - CLAUDE_SESSION_ID:', process.env.CLAUDE_SESSION_ID || 'not set'); + debugLog('ENV - DEBUG_SLACK_NOTIFY:', process.env.DEBUG_SLACK_NOTIFY || 'not set'); + + loadEnvFile(); + + const { SLACK_CHANNEL_ID: channelId, SLACK_BOT_TOKEN: token } = process.env; + if (!channelId || !token) { + debugLog('Missing required env vars - SLACK_CHANNEL_ID:', !!channelId, '| SLACK_BOT_TOKEN:', !!token); + process.exit(0); + } + + // Prevent duplicate execution + if (!acquireLock()) { + debugLog('EXITING: Could not acquire lock'); + process.exit(0); + } + + try { + const [hookInput, repoName] = await Promise.all([ + readHookInput(), + getRepositoryName() + ]); + + const { transcriptPath, stopHookActive, assistantResponse } = hookInput; + + debugLog('Hook input received:', { + transcriptPath, + stopHookActive, + assistantResponse: assistantResponse ? assistantResponse.substring(0, 100) : null, + hasTranscriptPath: !!transcriptPath + }); + + // Skip if this is a recursive hook call (e.g., from internal Claude CLI for summary generation) + if (stopHookActive) { + debugLog('EXITING: stop_hook_active is true (preventing recursive execution)'); + releaseLock(); + process.exit(0); + } + + debugLog('Transcript path:', transcriptPath); + debugLog('Repository name:', repoName); + + const summaryStartTime = Date.now(); + let summary = null; + if (assistantResponse) { + // Kiro CLI: assistant_responseから直接要約生成 + const context = assistantResponse.length > CONFIG.MAX_CONTEXT_LENGTH + ? assistantResponse.substring(0, CONFIG.MAX_CONTEXT_LENGTH) + : assistantResponse; + const prompt = createSummarizationPrompt(`【アシスタントの応答】\n${context}`); + const output = await callClaudeCLI(prompt); + summary = cleanSummaryResponse(output); + } else if (transcriptPath) { + // Claude Code: transcript_pathから要約生成 + summary = await generateSummary(transcriptPath); + } + const summaryDuration = Date.now() - summaryStartTime; + debugLog('Final summary:', summary, '| Generation time:', summaryDuration, 'ms'); + + // Send message with mention to trigger notification + const mentionMessage = formatNotificationMessage(repoName, summary, true); + debugLog('=== Sending mention message ===', mentionMessage.substring(0, 100)); + const mentionSendTime = Date.now(); + const mentionResult = await sendSlackMessage(channelId, token, mentionMessage); + if (!mentionResult) { + debugLog('FAILED to send mention message'); + return; + } + debugLog('Mention message sent | ts:', mentionResult.ts, '| Duration:', Date.now() - mentionSendTime, 'ms'); + + // Wait a bit before sending clean message and deleting + debugLog('Waiting', CONFIG.DELETE_DELAY_MS, 'ms before cleanup'); + await sleep(CONFIG.DELETE_DELAY_MS); + + // Send clean message without mention + const cleanMessage = formatNotificationMessage(repoName, summary, false); + debugLog('=== Sending clean message ===', cleanMessage.substring(0, 100)); + const cleanSendTime = Date.now(); + const cleanResult = await sendSlackMessage(channelId, token, cleanMessage); + if (!cleanResult) { + debugLog('FAILED to send clean message'); + } else { + debugLog('Clean message sent | ts:', cleanResult.ts, '| Duration:', Date.now() - cleanSendTime, 'ms'); + } + + // Delete the mention message + if (mentionResult.ts) { + debugLog('=== Deleting mention message | ts:', mentionResult.ts, '==='); + const deleteStartTime = Date.now(); + const deleteResult = await deleteSlackMessage(channelId, token, mentionResult.ts); + if (deleteResult) { + debugLog('Mention message deleted successfully | Duration:', Date.now() - deleteStartTime, 'ms'); + } else { + debugLog('FAILED to delete mention message'); + } + } + + debugLog('=== Slack notify script completed successfully | Total runtime:', Date.now() - summaryStartTime + summaryDuration, 'ms ==='); + } finally { + releaseLock(); + } +} + +main().catch((error) => { + debugLog('Fatal error:', error.message, error.stack); + releaseLock(); + process.exit(0); +}); diff --git a/plugins/ndf-claude/scripts/statusline-switch.sh b/plugins/ndf-claude/scripts/statusline-switch.sh new file mode 100755 index 0000000..bccb616 --- /dev/null +++ b/plugins/ndf-claude/scripts/statusline-switch.sh @@ -0,0 +1,214 @@ +#!/usr/bin/env bash +# NDF plugin: statusline の導入・切替・復元を行う。 +# +# Usage: statusline-switch.sh +# ensure : SessionStart hook 用。statusLine 未設定の場合のみ NDF 版を設定する +# (既存設定があれば何もしない)。NDF 版利用中はスクリプト更新のみ追従。 +# set : 既存の statusLine 設定をバックアップした上で NDF 版に切り替える。 +# restore : バックアップから元の statusLine 設定を復元する +# (バックアップが無ければ statusLine 設定を削除しデフォルト表示に戻す)。 +# status : 現在の statusLine 設定とバックアップの有無を表示する。 +set -euo pipefail + +CMD="${1:-status}" +SETTINGS="$HOME/.claude/settings.json" +TARGET="$HOME/.claude/ndf-statusline.sh" +BACKUP="$HOME/.claude/.ndf-statusline-backup.json" +LOCK="$HOME/.claude/.ndf-statusline.lock" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SRC="$SCRIPT_DIR/statusline.sh" +NDF_COMMAND="bash ~/.claude/ndf-statusline.sh" + +# jq 必須 (ensure は SessionStart hook から毎回呼ばれるためノイズ防止で黙ってスキップ) +if ! command -v jq >/dev/null 2>&1; then + if [ "$CMD" != "ensure" ]; then + echo "[ndf:statusline] jq が見つからないため処理をスキップしました" >&2 + fi + exit 0 +fi + +mkdir -p "$HOME/.claude" +if [ ! -f "$SETTINGS" ]; then + echo "{}" > "$SETTINGS" +fi + +# プラグイン同梱の statusline.sh を ~/.claude/ndf-statusline.sh に配置 (差分時のみ) +deploy_script() { + if [ ! -f "$TARGET" ] || ! cmp -s "$SRC" "$TARGET"; then + cp "$SRC" "$TARGET" + chmod +x "$TARGET" + fi +} + +current_command() { + jq -r '.statusLine.command // empty' "$SETTINGS" 2>/dev/null || true +} + +is_ndf_statusline() { + case "$(current_command)" in + *ndf-statusline.sh*) return 0 ;; + *) return 1 ;; + esac +} + +# current_command から実行スクリプトのパスを抽出し、先頭の ~ を $HOME に展開する。 +# 例: "bash ~/.claude/statusline-command.sh" -> "/home/user/.claude/statusline-command.sh" +current_script_path() { + local cmd path + cmd="$(current_command)" + [ -z "$cmd" ] && return 0 + # 末尾の *.sh トークンを実行対象スクリプトとみなす。 + # grep -o は GNU 拡張のため、POSIX 準拠かつ BusyBox でも動く sed で抽出する。 + # NDF が配置するコマンドは "bash ~/.claude/.sh" 形式 (パスにスペースを含まない) を想定。 + # 1つ目の置換がマッチしたら t で分岐して二重出力を防ぐ (tail -n1 のパイプを削減)。 + path="$(printf '%s\n' "$cmd" | sed -n 's/.*[[:space:]]\([^[:space:]]*\.sh\).*/\1/p; t; s/^\([^[:space:]]*\.sh\)$/\1/p')" + [ -z "$path" ] && return 0 + case "$path" in + "~/"*) path="$HOME/${path#\~/}" ;; + "~") path="$HOME" ;; + esac + printf '%s\n' "$path" +} + +# 指定スクリプトが NDF 由来 (マーカー付き、または既知のレガシーコピー) か判定する。 +# ユーザー独自の statusline を誤って上書きしないため、判定は厳格に行う。 +is_ndf_managed_copy() { + local path="$1" + [ -f "$path" ] || return 1 + # ① マーカーがあれば NDF 管理コピー確定 (今後配置される全コピーが該当) + if grep -Fq 'ndf-statusline: managed' "$path" 2>/dev/null; then + return 0 + fi + # ② レガシー救済: マーカー導入前の既知の旧コピー名で、かつ NDF statusline 特有の + # ロジック (ctx ラベル + コンテナ名取得) を両方含む場合のみ移行対象とする + case "$(basename "$path")" in + statusline-command.sh) + if grep -Fq '[ctx:' "$path" 2>/dev/null && grep -Fq 'container_name' "$path" 2>/dev/null; then + return 0 + fi + ;; + esac + return 1 +} + +# settings.json を atomic に書き換える。引数: jq フィルタ +update_settings() { + local tmp + tmp="$(mktemp)" + if jq "$@" "$SETTINGS" > "$tmp"; then + mv "$tmp" "$SETTINGS" + else + rm -f "$tmp" + return 1 + fi +} + +set_ndf_statusline() { + update_settings --arg cmd "$NDF_COMMAND" \ + '.statusLine = {type: "command", command: $cmd}' +} + +# NDF 由来の旧 statusline を検出した際に、既存設定をバックアップした上で +# 正規パス (~/.claude/ndf-statusline.sh) 参照へ移行する。 +migrate_to_ndf_statusline() { + local existing + existing=$(jq -c '.statusLine // empty' "$SETTINGS" 2>/dev/null || true) + if [ -n "$existing" ] && [ ! -f "$BACKUP" ]; then + printf '%s\n' "$existing" > "$BACKUP" + fi + set_ndf_statusline + echo "[ndf:statusline] NDF 由来の旧 statusline を検出したため正規パス (~/.claude/ndf-statusline.sh) へ移行しました (旧設定は $BACKUP に退避)" +} + +cmd_ensure() { + deploy_script + # 既に statusLine が設定されている場合 + if [ -n "$(jq -r '.statusLine // empty' "$SETTINGS" 2>/dev/null)" ]; then + # 正規パスを指していれば deploy_script で本体が追従済み (何もしない) + if is_ndf_statusline; then + return 0 + fi + # NDF が過去に配置したコピー (マーカー付き or レガシー statusline-command.sh) を + # 指している場合のみ、正規パス参照へ移行してバージョンアップ追従を回復する + local cur_path + cur_path="$(current_script_path)" + if [ -n "$cur_path" ] && is_ndf_managed_copy "$cur_path"; then + migrate_to_ndf_statusline + return 0 + fi + # それ以外はユーザー独自設定として尊重し、何もしない + return 0 + fi + set_ndf_statusline + echo "[ndf] statusLine を NDF 標準 statusline に設定しました (~/.claude/settings.json)" +} + +cmd_set() { + deploy_script + if is_ndf_statusline; then + echo "[ndf:statusline] 既に NDF 標準 statusline が設定されています" + return 0 + fi + # 既存設定があればバックアップしてから切り替え + local existing + existing=$(jq -c '.statusLine // empty' "$SETTINGS" 2>/dev/null || true) + if [ -n "$existing" ]; then + printf '%s\n' "$existing" > "$BACKUP" + echo "[ndf:statusline] 既存設定をバックアップしました: $BACKUP" + fi + set_ndf_statusline + echo "[ndf:statusline] NDF 標準 statusline に切り替えました" +} + +cmd_restore() { + if [ -f "$BACKUP" ]; then + update_settings --slurpfile bk "$BACKUP" '.statusLine = $bk[0]' + rm -f "$BACKUP" + echo "[ndf:statusline] バックアップから statusLine 設定を復元しました" + elif is_ndf_statusline; then + update_settings 'del(.statusLine)' + echo "[ndf:statusline] バックアップが無いため statusLine 設定を削除しました (デフォルト表示に戻ります)" + else + echo "[ndf:statusline] 復元対象がありません (NDF statusline は未使用、バックアップ無し)" + fi +} + +cmd_status() { + local cur + cur=$(current_command) + if [ -z "$cur" ]; then + echo "statusLine: 未設定 (デフォルト表示)" + elif is_ndf_statusline; then + echo "statusLine: NDF 標準 statusline ($cur)" + else + echo "statusLine: カスタム設定 ($cur)" + fi + if [ -f "$BACKUP" ]; then + echo "バックアップ: あり ($(cat "$BACKUP"))" + else + echo "バックアップ: なし" + fi +} + +run_cmd() { + case "$CMD" in + ensure) cmd_ensure ;; + set) cmd_set ;; + restore) cmd_restore ;; + status) cmd_status ;; + *) + echo "Usage: $(basename "$0") " >&2 + exit 1 + ;; + esac +} + +# 複数セッション同時起動時の race condition を flock で回避 (flock 不在時は atomic rename のみ) +if command -v flock >/dev/null 2>&1; then + ( + flock -x -w 5 200 || exit 0 + run_cmd + ) 200>"$LOCK" +else + run_cmd +fi diff --git a/plugins/ndf-claude/scripts/statusline.sh b/plugins/ndf-claude/scripts/statusline.sh new file mode 100755 index 0000000..19d7c09 --- /dev/null +++ b/plugins/ndf-claude/scripts/statusline.sh @@ -0,0 +1,60 @@ +#!/bin/bash +# ndf-statusline: managed (do not edit; auto-updated by ndf:statusline) +# NDF plugin 標準 statusline: +# <コンテナ名 or ホスト名> [<モデル名>: 使用トークン / 全体 (使用率%)] +input=$(cat) + +# コンテナ名を取得する。コンテナでなければホスト名にフォールバック +container_name="" + +# Docker/コンテナ環境かどうかを /.dockerenv で判定 +if [ -f /.dockerenv ]; then + # CONTAINER_NAME 環境変数が明示設定されていればそちらを優先 + container_name="${CONTAINER_NAME:-}" + + # Docker ソケットが使えれば docker inspect で compose 上のコンテナ名を取得 + # (/etc/hostname はコンテナIDなので、それをキーに引く) + if [ -z "$container_name" ] && [ -S /var/run/docker.sock ] && command -v docker >/dev/null 2>&1; then + container_id=$(cat /etc/hostname 2>/dev/null | tr -d '[:space:]') + if [ -n "$container_id" ]; then + container_name=$(docker inspect --format '{{.Name}}' "$container_id" 2>/dev/null | sed 's|^/||') + fi + fi + + # 取れなければ /etc/hostname(コンテナID)をフォールバックとして使用 + if [ -z "$container_name" ] && [ -f /etc/hostname ]; then + container_name=$(cat /etc/hostname 2>/dev/null | tr -d '[:space:]') + fi +fi + +# コンテナ名が取れなければ hostname コマンドにフォールバック +if [ -z "$container_name" ]; then + container_name=$(hostname -s 2>/dev/null || hostname) +fi + +dir="$container_name" + +# claude root のパスを取得(project_dir を優先し、なければ current_dir を使用) +# jq 不在や無効な JSON 入力時に stderr が statusLine 描画に漏れないよう 2>/dev/null で抑制 +claude_root=$(echo "$input" | jq -r '.workspace.project_dir // .workspace.current_dir // empty' 2>/dev/null) + +total_input=$(echo "$input" | jq -r '.context_window.total_input_tokens // empty' 2>/dev/null) +ctx_size=$(echo "$input" | jq -r '.context_window.context_window_size // empty' 2>/dev/null) +used_pct=$(echo "$input" | jq -r '.context_window.used_percentage // empty' 2>/dev/null) + +# モデル表示名を取得(ラベルとして使用)。取れなければ "ctx" にフォールバック +model_name=$(echo "$input" | jq -r '.model.display_name // .model.id // empty' 2>/dev/null) +ctx_label="${model_name:-ctx}" + +ctx_info="" +if [ -n "$total_input" ] && [ -n "$ctx_size" ] && [ -n "$used_pct" ]; then + total_input_k=$(awk "BEGIN { printf \"%.1f\", $total_input / 1000 }") + ctx_size_k=$(awk "BEGIN { printf \"%.0f\", $ctx_size / 1000 }") + ctx_info=$(printf " \033[0;36m[%s: %sk / %sk tokens (%.0f%%)]" "$ctx_label" "$total_input_k" "$ctx_size_k" "$used_pct") +fi + +if [ -n "$claude_root" ]; then + printf "\033[01;34m%s\033[00m \033[0;33m%s\033[00m%s" "$dir" "$claude_root" "$ctx_info" +else + printf "\033[01;34m%s\033[00m%s" "$dir" "$ctx_info" +fi diff --git a/plugins/ndf-claude/skills/branch-fix-strategy/SKILL.md b/plugins/ndf-claude/skills/branch-fix-strategy/SKILL.md new file mode 100644 index 0000000..a4714a3 --- /dev/null +++ b/plugins/ndf-claude/skills/branch-fix-strategy/SKILL.md @@ -0,0 +1,87 @@ +--- +name: branch-fix-strategy +description: "Plan multi-branch fixes and cherry-picks." +when_to_use: "同じ修正を複数ブランチ (qa/staging/release等) に適用する必要があるとき。Triggers: 'cherry-pick', '環境ブランチに修正適用', 'qaに反映', 'stagingに反映', 'release branchへ', 'multi-branch fix', 'apply to qa/staging'" +--- + +# ブランチ修正適用戦略 + +## 適用タイミング + +- featureブランチの修正を `qa/*`, `staging/*`, `release/*` 等の環境ブランチにも適用する必要がある場合 +- 同じ修正を複数ブランチに並行適用する場面全般 + +## 核心ルール + +### 1. 修正は feature ブランチに先に commit → cherry-pick で環境ブランチへ + +``` +✅ feature に commit → cherry-pick して短命ブランチ → 環境ブランチへ PR +❌ 短命ブランチに先に commit → feature に手作業で再実装(二重作業・不整合リスク) +``` + +### 2. 環境ブランチを feature ブランチに merge しない(main 汚染禁止) + +``` +❌ feature/xxx ← merge qa/staging(conflict 解消目的でも禁止) +``` + +環境ブランチを featureブランチにmergeすると、後で `feature → main` のPRに環境固有コードが混入する。 + +### 3. origin/main を必ず取り込む + +短命ブランチを push する前に必ず `git merge origin/main` する。CI で最新 main 必須の Workflow があるため。 + +### 4. マージ済みブランチに push しない + +環境ブランチ向けの短命ブランチに push する前に `gh pr list --head ` で PR 状態を確認する。マージ済みなら新ブランチ + 新 PR を作成する(サフィックス `-v2`, `-v3` を付ける)。 + +## 実行手順 + +`/ndf:cherry-pick-pr ` で自動化されている。手動で行う場合のみ以下を参照。 + +```bash +# 1. feature ブランチで修正を commit +git checkout feature/xxx +git add && git commit -m "fix: 修正内容" +git log --oneline -1 # commit hash を記録 + +# 2. 短命ブランチを作成 +git fetch origin qa/staging +git checkout -b feature/xxx-for-staging origin/qa/staging + +# 3. origin/main を取り込む(必須) +git fetch origin main +git merge origin/main --no-edit + +# 4. cherry-pick(-x で元 commit hash を参照に残す) +git cherry-pick -x + +# 5. push して PR 作成 +git push -u origin feature/xxx-for-staging +gh pr create --base qa/staging --title "fix: 修正内容(staging検証用)" + +# 6. 元のブランチに戻る +git checkout feature/xxx +``` + +## なぜこの順序が重要か + +| 観点 | 正しい順序 | 誤った順序 | +|------|-----------|-----------| +| 単一ソース | feature ブランチが唯一の正 | 二箇所で実装 | +| 一貫性 | cherry-pick で完全一致 | 手書き差分でズレる | +| 追跡性 | `-x` で元 commit が明記 | 関連 commit 不明確 | + +## revert 操作の注意 + +revertの連鎖(revert → reapply → revert...)ではなく、**最終的なあるべき状態を直接コミット**するのが望ましい。履歴上の意図が明確になり、後の cherry-pick も簡単になる。 + +## 関連コマンド・スキル + +| リソース | 用途 | +|---------|------| +| `/ndf:cherry-pick-pr` | cherry-pick + 短命ブランチ + origin/main 取り込み + PR 作成を自動化 | +| `/ndf:pr` | 通常のPR作成。非 main ベースは `cherry-pick-pr` に誘導される | +| `/ndf:sync-main` | 現在のブランチに最新 main を取り込む | +| `/ndf:deploy` | 環境ブランチへのデプロイPR作成(ブランチ全体をmerge main経由で適用) | diff --git a/plugins/ndf-claude/skills/browser-test/SKILL.md b/plugins/ndf-claude/skills/browser-test/SKILL.md new file mode 100644 index 0000000..97eb0c8 --- /dev/null +++ b/plugins/ndf-claude/skills/browser-test/SKILL.md @@ -0,0 +1,159 @@ +--- +name: browser-test +description: "Run browser smoke tests for web apps." +argument-hint: "[url]" +disable-model-invocation: true +allowed-tools: + - Bash + - mcp__playwright__browser_navigate + - mcp__playwright__browser_snapshot + - mcp__playwright__browser_click + - mcp__playwright__browser_fill_form + - mcp__playwright__browser_take_screenshot + - mcp__playwright__browser_type + - mcp__playwright__browser_evaluate + - mcp__playwright__browser_console_messages + - mcp__playwright__browser_wait_for + - mcp__playwright__browser_tabs + - mcp__playwright__browser_navigate_back + - mcp__playwright__browser_close + - mcp__playwright__browser_resize + - mcp__playwright__browser_handle_dialog + - mcp__playwright__browser_press_key + - mcp__playwright__browser_hover + - mcp__playwright__browser_select_option + - mcp__playwright__browser_drag + - mcp__playwright__browser_network_requests + - mcp__playwright__browser_file_upload + - mcp__playwright__browser_install + - mcp__chrome-devtools__navigate_page + - mcp__chrome-devtools__take_snapshot + - mcp__chrome-devtools__click + - mcp__chrome-devtools__fill_form + - mcp__chrome-devtools__take_screenshot + - mcp__chrome-devtools__type + - mcp__chrome-devtools__evaluate_script + - mcp__chrome-devtools__list_console_messages + - mcp__chrome-devtools__wait_for + - mcp__chrome-devtools__list_pages + - mcp__chrome-devtools__new_page + - mcp__chrome-devtools__select_page + - mcp__chrome-devtools__close_page + - mcp__chrome-devtools__navigate_page_history + - mcp__chrome-devtools__resize_page + - mcp__chrome-devtools__handle_dialog + - mcp__chrome-devtools__hover + - mcp__chrome-devtools__drag + - mcp__chrome-devtools__list_network_requests + - mcp__chrome-devtools__get_network_request + - mcp__chrome-devtools__upload_file + - mcp__chrome-devtools__emulate_network + - mcp__chrome-devtools__emulate_cpu + - mcp__chrome-devtools__performance_start_trace + - mcp__chrome-devtools__performance_stop_trace + - mcp__chrome-devtools__performance_analyze_insight +--- + +# ブラウザ動作確認コマンド + +現在のブランチで実装されたWeb機能をブラウザで動作確認する。Playwright MCP または Chrome DevTools MCP を利用可能な方を自動選択する。 + +## 前提条件(重要) + +このコマンドは以下のいずれかのMCPサーバが必要: + +- **Playwright MCP**: 自動的にブラウザを起動(要Playwrightインストール) +- **Chrome DevTools MCP**: 既に開いているChromeを操作(Chromeをデバッグモードで起動しておく必要あり) + +どちらも利用できない環境では、手動確認手順を案内する。 + +## 使用方法 + +``` +/ndf:browser-test # 現在のブランチの実装を確認 +/ndf:browser-test http://localhost:8080 # 特定URLを確認 +``` + +## MCPの使い分け + +### Playwright MCP +- 自動的にブラウザを起動 +- 複数ブラウザ対応 (Chromium/Firefox/WebKit) +- 利用可能なら第一選択 + +### Chrome DevTools MCP +- 既に開いているChromeブラウザを操作 +- Chrome デバッグモードでの起動が必要: + - macOS: `/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --remote-debugging-port=9222` + - Windows: `chrome.exe --remote-debugging-port=9222` + - Linux: `google-chrome --remote-debugging-port=9222` +- DevTools統合でパフォーマンス分析可能 + +## 処理フロー + +### 1. アプリケーション起動確認 + +プロジェクトで使っている起動方法に応じて確認: + +```bash +# Docker Compose の場合 +docker compose ps + +# ネイティブ起動の場合 +curl -fsS http://localhost:/health || echo "NOT RUNNING" +``` + +起動していない場合は、起動手順をユーザーに案内。 + +### 2. ブラウザアクセスと認証 + +- 指定URL(または `/` )にアクセス +- 必要に応じてログイン(資格情報はプロジェクト固有、事前に取得しておく) + +### 3. 機能画面への遷移 + +実装された機能に応じて適切な画面に遷移する。 + +### 4. 動作確認 + +必要に応じて以下の操作を実行: + +- フォーム入力 +- ボタンクリック +- データ表示の確認 +- コンソールエラーの確認 +- ネットワークリクエストの確認 +- スクリーンショット(明示的に指示された場合のみ) + +### 5. 結果報告 + +```markdown +## 動作確認結果 + +### 実施項目 +- [x] ログイン +- [x] 機能画面表示 +- [x] フォーム送信 +- [x] 結果表示 + +### 確認事項 +- コンソールエラー: なし +- ネットワークエラー: なし +- 期待結果との一致: ok + +### 気になる点 +- ...(あれば) +``` + +## 注意事項 + +- **事前にアプリケーション起動が必要** +- **ログイン情報**: プロジェクトの `.env.example` / README 等から確認。機密情報として扱う +- **スクリーンショット**: 必要な場合のみ明示的に指示されたときに取得 +- **Chrome DevTools使用時**: Chromeをデバッグモードで起動しておく必要あり +- **MCP未インストール環境**: 手動での確認手順を案内する + +## 関連 + +- `/ndf:review-branch` — 変更差分のコードレビュー +- `/ndf:pr-tests` — PR Test Plan の自動実行 diff --git a/plugins/ndf-claude/skills/cherry-pick-pr/SKILL.md b/plugins/ndf-claude/skills/cherry-pick-pr/SKILL.md new file mode 100644 index 0000000..b5dc134 --- /dev/null +++ b/plugins/ndf-claude/skills/cherry-pick-pr/SKILL.md @@ -0,0 +1,120 @@ +--- +name: cherry-pick-pr +description: "Create cherry-pick PRs for environment branches." +argument-hint: " (例: qa/staging, release/v2)" +disable-model-invocation: true +allowed-tools: + - Bash + - Read + - Grep +--- + +# cherry-pick PR 作成コマンド + +featureブランチから指定ベースブランチへ、短命ブランチ経由で cherry-pick PR を作成する。`feature → main` の PR にベースブランチ固有コードが混入するのを防ぐ。 + +## 使用方法 + +``` +/ndf:cherry-pick-pr qa/staging +/ndf:cherry-pick-pr release/v2 +``` + +## なぜ必要か + +featureブランチに環境ブランチ(`qa/staging`等)を merge して conflict を解消すると、`feature → main` の PR に環境ブランチ固有のコードが混入する(main汚染)。短命ブランチ + cherry-pick で、必要なコミットだけを対象ブランチに届ける。 + +詳細な原則は `/ndf:branch-fix-strategy` スキル参照。 + +## 処理フロー + +### 1. 引数・現状確認 +- 引数からベースブランチ名を取得(必須。未指定なら確認) +- `git branch --show-current` で現在ブランチを取得 + +### 2. 既存PRのマージ済みチェック(必須) + +同じベースブランチ向けの短命ブランチに既存PRがないか確認する。 + +```bash +# 同名パターンのブランチでマージ済みPRがないか確認 +gh pr list --head "-for-" --state merged \ + --json number,mergedAt --jq '.[]' +``` + +マージ済みPRが見つかった場合、**同じブランチ名は使えない**。サフィックスを付ける(例: `-v2`, `-v3`)。 + +### 3. コミット一覧の確認 + +```bash +git log --oneline main..HEAD +``` + +ユーザーに cherry-pick 対象コミットを確認(全コミット or 選択)。 + +### 4. 短命ブランチ作成 + +```bash +git fetch origin +git checkout -b -for- origin/ +``` + +- ``: ベースブランチのスラッシュ以降(例: `qa/staging` → `staging`) +- 例: `feature/add-auth-for-staging` + +### 5. origin/main を取り込む(必須) + +```bash +git fetch origin main +git merge origin/main --no-edit +``` + +CIで最新main必須のWorkflowがあるため、取り込み忘れるとconflictやCIエラーになる。 + +### 6. cherry-pick 実行 + +```bash +git cherry-pick -x ... +``` + +`-x` オプションで元のcommit hashが参照として残り、追跡性が向上する。 + +conflict が発生した場合: +- `git diff --name-only --diff-filter=U` でconflictファイル一覧 +- 解消を試み、ユーザーに確認後 `git cherry-pick --continue` + +### 7. push して PR 作成 + +```bash +git push -u origin +gh pr create --base --title "<タイトル>" --body "$(cat <<'EOF' +## Summary +- feature/xxx からcherry-pickした<環境名>向けPR +- 元コミット: + +## Test plan +- [ ] <環境名>で動作確認 + + +EOF +)" +``` + +### 8. 元ブランチに戻る + +```bash +git checkout +``` + +## 注意事項 + +- 短命ブランチは PR マージ後に削除してよい +- `feature → main` の PR には影響しない +- ベースブランチを feature ブランチに merge するのは **禁止**(main汚染の原因) +- `-x` オプションで元commit参照を残す(追跡性) + +## 関連 + +- `/ndf:branch-fix-strategy` — なぜこの手順が必要かの原則 +- `/ndf:pr` — 通常のPR作成(base=main) +- `/ndf:deploy` — ブランチ全体を環境へデプロイ(cherry-pickとは別用途) diff --git a/plugins/ndf-claude/skills/clean/SKILL.md b/plugins/ndf-claude/skills/clean/SKILL.md new file mode 100644 index 0000000..2f75e54 --- /dev/null +++ b/plugins/ndf-claude/skills/clean/SKILL.md @@ -0,0 +1,20 @@ +--- +name: clean +description: "Delete local and remote merged branches." +disable-model-invocation: true +allowed-tools: + - Bash +--- + +# ブランチクリーンアップコマンド + +mainマージ済みブランチをローカル/リモート削除。 + +## 手順 + +1. `git branch --merged main`確認 +2. main・現在ブランチ除外 +3. `git branch -d ` +4. `git push origin --delete ` + +**注意**: 削除前確認・main除外・現在ブランチ除外 diff --git a/plugins/ndf-claude/skills/codex/SKILL.md b/plugins/ndf-claude/skills/codex/SKILL.md new file mode 100644 index 0000000..80060a8 --- /dev/null +++ b/plugins/ndf-claude/skills/codex/SKILL.md @@ -0,0 +1,473 @@ +--- +name: codex +description: "Delegate coding, review, or research to Codex CLI." +when_to_use: "外部 AI へコード生成 / レビュー / 調査を委譲したいとき。Triggers: 'codexで調査', 'codexレビュー', '第二意見レビュー', 'codex exec', 'external AI review'" +--- + +# Codex 外部AI委譲スキル + +## 概要 + +`codex` CLI(OpenAI Codex、通常は `/usr/bin/codex` または `npm` 経由でインストール)を直接実行して、コード生成・独立レビュー・コードベース調査を外部AIに委譲するためのスキル。 + +ローカルファイルの逐語照合レビューや大規模コードベース調査に向いている。 + +## NDFとの関係 + +- NDFプラグインの `corder` エージェントはこの skill の手順に従って Codex CLI を呼び出す +- v4.0.0 で Codex MCP サーバは廃止。`mcp__codex__*` ツールは存在しない +- 使い分け: 軽量な独立レビュー → `corder` エージェントに委譲。手順の詳細を自分で制御したい or 複雑なプロンプトを出したい → 本 skill を参照して直接 `codex exec` 起動 + +## いつ使うか + +### 使うべきケース +- **独立第二意見レビュー**: 設計書・PR・仕様書を外部AIにレビューさせる(メインエージェントの思考バイアスを避ける) +- **コードベース逐語照合**: 「行番号・関数名・重複箇所の件数」を正確に突き合わせる必要がある場合 +- **長時間の調査タスク**: 複数ファイル横断で5〜10分以上かかる調査 +- **実装タスクの並列化**: メインエージェントで他作業を進めつつ、別タスクを codex に走らせたい場合 + +### 使わないべきケース +- 短時間(1〜2分以内)で済むタスク → メインエージェントで直接対応 +- ユーザとの対話が必要な設計相談 → Plan Mode等で対話しながら進める +- 単純な質問回答 → WebFetch / WebSearch で足りる +- 機密情報を含むコード → 外部API送信の可否を確認してから + +## 前提条件 + +```bash +# インストール確認 +which codex +codex --version + +# ログイン状態確認(初回のみ必要) +codex login +``` + +未インストールの場合は以下でセットアップ: + +```bash +# npm 経由 +npm install -g @openai/codex + +# 動作確認 +codex exec --help +``` + +## 基本実行パターン + +### 1. サンドボックス制約(重要) + +codex のデフォルトサンドボックスは `bubblewrap (bwrap)` に依存する。以下の環境では bwrap が動作せず、`exec` で実行するシェルコマンドがすべて失敗する: + +- **WSL2**(カーネルで `unprivileged_userns_clone` が無効) +- **一部の devcontainer / Docker 環境**(user namespace 非対応) + +該当環境では **`--dangerously-bypass-approvals-and-sandbox` を付けて起動**する必要がある。 + +```bash +# ❌ サンドボックス有効(bwrap 失敗で exec コマンドが全滅) +codex exec -s read-only -C "$PWD" + +# ✅ サンドボックスバイパス(外側が既にコンテナ等で隔離されている前提) +codex exec --dangerously-bypass-approvals-and-sandbox -C "$PWD" +``` + +**判断基準**: 既にDocker / devcontainer / VM / CIランナー等で外部的にサンドボックスされているなら `--dangerously-bypass-approvals-and-sandbox` は実用上安全。ホスト直接実行でコード全書き換えされたくない場合はフラグを付けずに対処(後述「bwrap代替」)。 + +#### bwrap 代替の有効化(ホスト直接実行時) + +```bash +# Debian/Ubuntu 系でホスト user namespace を有効化 +sudo sysctl kernel.unprivileged_userns_clone=1 + +# 永続化 +echo 'kernel.unprivileged_userns_clone=1' | sudo tee /etc/sysctl.d/00-local-userns.conf +``` + +### 2. プロンプトは一時ファイル経由で渡す + +長いプロンプトをシェル引数に渡すとエスケープ地獄になるので、**一時ファイル経由でstdinに流す**のが基本。 + +```bash +# Step 1: プロンプトを一時ファイルに書く +cat > /tmp/codex-prompt.md <<'EOF' +## タスク +以下のファイルを読み込み、設計意図とコードの整合性をレビューしてください。 + +## 対象ファイル(絶対パスで指定) +/absolute/path/to/design.md + +## 出力形式 +Markdown で標準出力に吐いてください。 +EOF + +# Step 2: codex exec に stdin で流す(バックグラウンド実行) +codex exec --dangerously-bypass-approvals-and-sandbox -C "$PWD" \ + < /tmp/codex-prompt.md \ + > /tmp/codex-output.md \ + 2> /tmp/codex-err.log & +``` + +**エージェントからの書き方**: ファイル書き込みツールで `/tmp/codex-prompt.md` を作ってから、シェル実行ツールの「バックグラウンド実行」オプションで codex を起動する。 + +### 3. 出力ストリームの扱い + +codex CLI の出力構造: + +| ストリーム | 内容 | +|---|---| +| **stdout** | **最終 assistant message のみ**(Markdown本文)。出ないことがある(後述) | +| **stderr** | プロンプトのエコー + 実行したコマンドと結果 + codexの思考プロセス + `^tokens used$` sentinel | + +**実務上の扱い**: +- 最終成果物が欲しい → `stdout` をそのまま採用…**ただし stdout が空になるケースがあるので必ずファイル出力も併用**(下記 3.5 参照) +- codexが何を調べたか追跡したい → `stderr` をデバッグ用に保存 + +```bash +codex exec ... > /tmp/codex-output.md 2> /tmp/codex-err.log +# 成果物 = /tmp/codex-output.md(stdout が空でないことを必ず確認) +# デバッグ = /tmp/codex-err.log(大きめ、数千行になる) +``` + +### 3.5 最終出力をファイル経由で保証する(重要) + +**Codex CLI(特に `gpt-5-codex` / 高 reasoning_effort)は、長時間調査の末に** +**最終 assistant message を返さずにセッションを終えることがある**。 +このとき stdout は空のままになり、stderr のイベントログ(数十万バイト)には +コードを実際に読んだ痕跡だけが残る。`^tokens used$` は出ているのに stdout が空、という状態。 + +**根本対策: プロンプトに「最終結果は指定ファイルへ書き出すこと」を必須化する。** +Codex は最終 message を返さなくても `apply_patch` ツールでファイルを作成できるため、 +ファイル経由なら確実に結果を回収できる。 + +#### プロンプトに必ず含める指示(テンプレート) + +```markdown +## 出力先(必須) + +最終的なレビュー / 調査結果を以下のファイルに **必ず書き出してください**: + +`/tmp/codex-output-.md` + +書き出しは `apply_patch` で新規ファイル作成してください。 +**stdout への出力だけでは不十分です**(セッション終了で失われる場合があるため)。 +書き出し後、念のため stdout にも同じ内容を出力してください(冪等で問題ありません)。 +``` + +#### 回収側の安全パターン + +```bash +# 1. ファイルが存在するかを最優先で確認(stdout が空でもこちらに本文が残る) +OUTPUT_FILE=/tmp/codex-output-pr13734-review.md +if [ -s "$OUTPUT_FILE" ]; then + cat "$OUTPUT_FILE" +elif [ -s /tmp/codex-stdout.md ]; then + # 2. ファイルがなければ stdout フォールバック + cat /tmp/codex-stdout.md +else + # 3. どちらも空なら stderr の末尾から拾う最後の手段 + echo "WARN: Codex の最終出力を回収できませんでした。stderr 末尾を確認してください:" >&2 + tail -200 /tmp/codex-err.log +fi +``` + +#### 補助対策 + +- **`reasoning_effort` を `medium` に下げる** (`--config reasoning.effort=medium`) + `high` だと思考に偏って最終 message を返さなくなる頻度が上がる +- **`--json` モードでイベント採取** (`codex exec --json`) + JSON Lines で `event.type=assistant_message` を grep すれば確実に取れる +- **強制 summary 指示**: プロンプト末尾に「最後に必ず assistant message として 1 回出力すること、tool 呼び出しのみで終了しないこと」を明記 + +### 4. バックグラウンド実行 + 待機パターン + +codex は **5〜10分かかることが普通**。多くのエージェントハーネスはシェル実行に2〜3分のタイムアウトを課すので、**必ずバックグラウンド実行**する。 + +```bash +# 1. プロンプトファイル書き出し(ファイル書き込みツール) +# -> /tmp/codex-prompt.md + +# 2. codex をバックグラウンドで起動(`&` でシェル自体は即時終了) +codex exec --dangerously-bypass-approvals-and-sandbox -C "$PWD" \ + < /tmp/codex-prompt.md \ + > /tmp/codex-output.md \ + 2> /tmp/codex-err.log & + +# 3. PID を控える +echo "PID: $!" + +# 4. 待機(他の作業を進める or スケジューラで再開) + +# 5. 完了検知 — ps -p は zombie に騙される。stderr の "tokens used" sentinel を見る +until grep -q '^tokens used$' /tmp/codex-err.log 2>/dev/null; do + sleep 30 +done +``` + +**⚠️ 罠**: `&` でバックグラウンド実行するとラッパーシェルは即終了し「タスク完了通知」が発火するが、codex 本体はまだ動いている。**`ps -p $PID` は zombie (defunct) も 0 を返す** ため `until ! ps -p $PID` は永久ループになりうる。`grep -q '^tokens used$' /tmp/codex-err.log` を脱出条件にする (codex が最終回答を吐き終わると stderr 末尾に必ず出る sentinel)。 + +### 5. 待機間隔のチューニング + +エージェントの context cache TTL は通常5分。これを超えると prompt cache がミスして再送料金が発生する: + +- **短い間隔**: 60〜270秒(TTL=5分内に収まる、軽量) +- **長い間隔**: 1200秒以上(1回のキャッシュミスを長時間で償却) +- **避けるべき**: 300秒前後(キャッシュミス+短時間の最悪) + +codex の典型実行時間(5〜10分)に対しては **270秒ポーリング** か **1200秒一括待ち** の二択。 + +### 6. プロセス確認・ログ追跡 + +```bash +# 完了したか (stderr 末尾の "tokens used" が最も信頼できる) +grep -q '^tokens used$' /tmp/codex-err.log && echo DONE + +# 最新の作業内容を覗く +tail -30 /tmp/codex-err.log +``` + +## プロンプト設計のコツ + +### 必須要素 +1. **対象ファイルの絶対パス**(codexは `nl -ba`, `sed -n`, `rg` 等でファイルを読むため) +2. **調査観点を具体化**(箇条書きで3〜5項目に絞る) +3. **出力形式の指定**(Markdownテンプレートを提示) +4. **スコープ外の明示**(codexが脱線しないため) +5. **最終出力先ファイルの指定(必須)**: `/tmp/codex-output-.md` のような明示パスへ + **`apply_patch` で必ず書き出させる**。stdout だけに頼ると最終 message が落ちて空になる事故が起きる(3.5 節参照) +6. **stdout にも同内容を吐く指示**: ファイル書き出し後、念のため stdout にもエコーさせる(冪等) + +### レビュー依頼テンプレート + +```markdown +あなたは<役割(例: シニアバックエンドエンジニア / セキュリティレビュアー)>として、 +以下をレビューしてください。 + +## 対象ファイル(必ず最初に読むこと) +`/absolute/path/to/target.md` + +## 観点 +1. <観点1: 例「仕様とコードの整合性」> +2. <観点2: 例「既存APIとの後方互換性」> + +## 調査対象コード(必要に応じて読む) +- `src/...` +- `lib/...` + +## 背景コンテキスト +- <プロジェクト概要> +- <関連PR / Issue番号> +- <既存レビューで対応済みの事項(重複指摘を避けるため)> + +## 出力形式 + +以下を Markdown で**`/tmp/codex-output-.md` に必ず書き出してください** +(`apply_patch` で新規ファイル作成)。書き出し後、stdout にも同内容を出力してください。 +**stdout のみへの出力は不可**(セッション終了時に失われる場合があるため): + +# <タイトル> + +## 総評 +## 1. <観点1> に関する指摘 +### 1.1 正確な主張 +### 1.2 訂正推奨 +## 2. <観点2> に関する指摘 +## 3. 追加提案 +## 4. 承認可否 + +**必須**: 行番号・ファイルパスに紐付けて具体的に指摘してください。400〜500行程度、日本語で出力してください。 +**必須**: tool 呼び出しのみで終了せず、最後に必ず assistant message として 1 回出力してください。 +``` + +### コード生成依頼テンプレート + +```markdown +以下の実装タスクを実行してください。 + +## タスク +<具体的な実装内容> + +## 制約 +- <技術制約: 言語バージョン、依存ライブラリ> +- <コーディング規約: ESLint / Prettier / rustfmt等> +- <テスト要件: ユニットテスト必須等> + +## 対象ファイル +- <既存ファイルのパス> +- <新規ファイルのパス案> + +## 背景 +<なぜこの実装が必要か、設計判断の経緯> + +## 完了基準 +- [ ] テストがパスする +- [ ] 型チェック / lint がパスする +- [ ] <追加の受け入れ条件> + +**必須**: ファイル編集は実際に行い、最後に変更ファイル一覧と要点を +`/tmp/codex-output-.md` に書き出してください(`apply_patch` で新規作成)。 +書き出し後、stdout にも同内容を出力してください。 +**stdout のみへの出力は不可**(セッション終了時に失われる場合があるため)。 +tool 呼び出しのみで終了せず、最後に必ず assistant message として 1 回出力してください。 +``` + +## 実例: レビュー依頼の完全フロー + +```bash +# === 1. プロンプト書き出し === +# ポイント: 最終出力先ファイルをプロンプト内で明示し、apply_patch で書かせる +FINAL=/tmp/codex-output-api-v2-review.md + +cat > /tmp/review-prompt.md < /tmp/codex-stdout.md \ + 2> /tmp/codex-err.log & + +PID=$! +echo "codex PID: $PID" + +# === 3. 完了確認(^tokens used$ sentinel を待つ) === +until grep -q '^tokens used$' /tmp/codex-err.log 2>/dev/null; do + sleep 30 +done +echo DONE + +# === 4. 成果物を安全に回収(ファイル優先 → stdout fallback) === +if [ -s "$FINAL" ]; then + cp "$FINAL" ./review-result.md + echo "✅ Codex 書き出しファイルから回収" +elif [ -s /tmp/codex-stdout.md ]; then + cp /tmp/codex-stdout.md ./review-result.md + echo "⚠ stdout からフォールバック回収(ファイル書き出しなし)" +else + echo "❌ Codex の最終出力を回収できませんでした。stderr 末尾を確認してください:" >&2 + tail -200 /tmp/codex-err.log + exit 1 +fi +``` + +## トラブルシューティング + +### Q1. stdoutが空でstderrに大量のexecログだけある +**原因**: codex がまだ最終回答を出す前に停止した、または **最終 assistant message を出さずにセッションが終わった**(gpt-5-codex の高 reasoning_effort で発生しやすい既知挙動)。 + +**対処**: +- `grep -q '^tokens used$' /tmp/codex-err.log` で終了 sentinel が出ているか確認(まだなら動作中なので追加待機) +- 出ているのに stdout が空 → セッション終了で最終 message が失われたケース。**3.5 節「最終出力をファイル経由で保証する」のパターンでリトライ必須**: + - プロンプトに `apply_patch` で `/tmp/codex-output-.md` へ必ず書き出させる指示を追加 + - 回収側は「ファイル → stdout → stderr」の三段フォールバックで取りこぼしを防ぐ + - 補助で `--config reasoning.effort=medium` も付けると最終 message を返す傾向が上がる + +### Q2. `bwrap: No permissions to create a new namespace` で exec 失敗 +**原因**: `--dangerously-bypass-approvals-and-sandbox` を付け忘れ、かつ環境が user namespace 非対応。 + +**対処**: +- フラグを追加して再実行 +- `-s read-only` / `-s workspace-write` も bwrap を使うので同じ結果になる点に注意 +- ホストで user namespace を有効化する方法は「サンドボックス制約」節を参照 + +### Q3. codexが「ファイルを読めません」と返してくる +**原因**: +- サンドボックス有効でファイル読み取りに失敗 +- プロンプトで相対パスを指定し、codexの cwd が想定と違った + +**対処**: +- `--dangerously-bypass-approvals-and-sandbox` を追加 +- プロンプトには**絶対パス**を書く +- `-C ` で cwd を明示 + +### Q4. タスク完了通知が来たのに出力が空 / wait loop が抜けない +**原因**: `&` で起動したラッパーシェルが先に終了して通知が出ているだけで、codex 本体は動作中。または既に終わっているが zombie (defunct) として残っており `ps -p $PID` が 0 を返し続けている。 + +**対処**: 検知を「PID の存在」ではなく **stderr の `^tokens used$` sentinel** で行う。codex は最終回答を吐き終えると必ずこの行を stderr に書く。 + +```bash +# ❌ 永久ループ化しうる +until ! ps -p $PID; do sleep 30; done + +# ✅ zombie 安全 +until grep -q '^tokens used$' /tmp/codex-err.log 2>/dev/null; do + sleep 30 +done +``` + +### Q5. codex実行が15分以上かかる +**原因**: プロンプトで広すぎる調査範囲を指定した、または codex が探索ループに入った。 + +**対処**: +- プロンプトで「読むべきファイル」を明示リスト化 +- スコープ外を明記(「〇〇には踏み込まない」) +- 必要なら `kill ` で打ち切り、プロンプトを絞り込んで再実行 + +### Q6. stdoutの末尾が途切れている +**原因**: codex がトークン上限に達した可能性。 + +**対処**: プロンプトで「400行以内」など出力サイズを指定。または観点を絞って再実行。 + +### Q7. 認証エラー (`Unauthorized` / `token expired`) +**原因**: ログインセッション失効。 + +**対処**: +```bash +codex logout +codex login +``` + +## corder エージェント経由との使い分け + +本スキルは CLI を直接呼び出す詳細手順を記述している。簡易に独立レビューを取りたいだけなら `corder` エージェントに委譲した方が手間が少ない: + +| 観点 | corder エージェント | 本スキルで直接 CLI 起動 | +|---|---|---| +| 使い勝手 | `Agent(subagent_type: "corder", ...)` で委譲するだけ | プロンプト書き出し・バックグラウンド起動・PID 管理を自分で制御 | +| プロンプト制御 | corder 側で整形 | 自由に設計可 | +| バックグラウンド実行 | agent 側が制御 | `&` で非同期化、他作業と並列 | +| スケジュール連携 | 難しい | `/schedule` / `Monitor` と組み合わせやすい | + +**指針**: 迷ったら corder 経由。プロンプト細部や非同期タイミングを自分で握りたい場合のみ本スキルの手順で直接起動。 + +## 既知の制約とコスト + +1. **サンドボックス非対応環境**: `--dangerously-bypass-approvals-and-sandbox` で回避必須 +2. **stderrに全思考が書かれる**: 数千行になりうるので必ず `2> /tmp/...` にリダイレクト +3. **ログイン状態**: 初回は `codex login` が必要。未ログインだと即座に失敗する +4. **セッション復旧**: 長時間ジョブで親エージェントが再起動した場合、`codex resume` でセッション再開可能 +5. **APIコスト**: トークン従量課金のため、短時間で済むタスクには使わない。1セッションで数千〜数万トークン消費することがある +6. **機密情報**: 外部APIにコードが送信されるため、社外秘コードの扱いは組織ポリシーに従うこと + +## 関連 + +- **NDF `corder` エージェント**: 本スキルの手順で Codex CLI を呼び出す独立レビュー担当 (v4.0.0 以降は MCP ではなく CLI 経由) +- **OpenAI Codex CLI公式ドキュメント**: `codex --help` / `codex exec --help` +- **他のAI委譲方法**: `gemini`, `claude`, `ollama` 等のCLI も同様のパターンで利用可 diff --git a/plugins/ndf-claude/skills/cross-review/SKILL.md b/plugins/ndf-claude/skills/cross-review/SKILL.md new file mode 100644 index 0000000..5a63867 --- /dev/null +++ b/plugins/ndf-claude/skills/cross-review/SKILL.md @@ -0,0 +1,475 @@ +--- +name: cross-review +description: "Run iterative Codex and Gemini PR reviews." +argument-hint: "[PR番号] [--max-rounds N] [--rotate-after K] [--rotate-mode light|squash] [--only codex|gemini] [--focus TEXT] [--extra-instructions-file PATH]" +disable-model-invocation: true +allowed-tools: + - Bash + - Read + - Edit + - Write + - Glob + - Grep +--- + +# クロスレビュー収束ループ + +PR を **codex / gemini 両方** にレビューさせ、両者が `APPROVE` を返すまで +`/ndf:review` と `/ndf:fix` を自動で回す。 + +/goalの引数として呼ばれた場合は、codex / gemini が`APPROVE` になるまで/cross-reviewを繰り返す。 + * codex / geminiのいずれかが不具合などで実行できなくなった場合は異常終了とする + * /goalで呼ばれた場合はPR ローテーションは実施しなくてよい。 + * 振動検知した場合はAIが判断して正しい状態を決める。 + +詳細手順は `docs/` 配下に、主要コマンドは `scripts/` 配下に分割している: + +- [docs/01-state-and-review.md](docs/01-state-and-review.md) — Step 0〜4 (state init / round / 並列レビュー / 判定 / 振動検知) +- [docs/02-fix-and-rotation.md](docs/02-fix-and-rotation.md) — Step 5〜8 (サブエージェント修正 / PR ローテーション / 終了処理) +- [scripts/state.py](scripts/state.py) — state.json 操作(uv 自己完結スクリプト、stdlib のみ) +- [scripts/launch-codex.sh](scripts/launch-codex.sh) / [scripts/launch-gemini.sh](scripts/launch-gemini.sh) — レビューランチャ +- [scripts/monitor.py](scripts/monitor.py) — codex/gemini プロセス多軸監視 (sentinel / pidfile / 早期エラー / stall / hard timeout / result.json) +- [scripts/wait-review.sh](scripts/wait-review.sh) — `monitor.py` の薄ラッパ(互換用) +- [scripts/rotate-pr.sh](scripts/rotate-pr.sh) — PR ローテーション + +メインセッションからは `$SCRIPTS/state.py ` 形式で呼ぶだけで、 +state.json の読み書きや AI launcher 起動・完了待ちは全て委譲される。 + +## 設計方針 + +長丁場が予想されるため **メインセッションの context 消費を最小化** する: + +| 観点 | 方針 | +|---|---| +| レビュー投稿 | **AI 自身が `gh api` で PR に直接投稿**。メインはペイロードを保持しない | +| 修正 | **必ずサブエージェント (`general-purpose`) で実行**。メイン context に diff は載せない | +| ユーザ問い合わせ | 自動判断を最大化(`critical`/`major`/`minor` は自動修正、ループ中の `nit` は deferred) | +| 取りこぼし防止 | **ループ終了時(approved / max_rounds / oscillation / error いずれも)に最終スイープを必須実行**。`/ndf:fix` を再実行し、残った open review thread(最終 APPROVE ラウンドの minor/nit インラインコメント含む)を **全て解消**。修正可能なものは修正 + push、判断保留 nit も reply + resolveReviewThread して **open thread 0 で終了** | +| 状態の永続化 | `/.cross_review/cross-review-pr<番号>-state.json` に集約。中断・再開可能 | +| 長尺PR対策 | **`--rotate-after` ラウンドで PR をローテーション**(default=light: 同ブランチで PR 巻き直し / squash: 新ブランチ + squash 統合) | +| 振動検知 | 同じ指摘が 2 round で 50%以上重複したら中断 | + +## 引数 + +| 引数 | 意味 | 既定 | +|---|---|---| +| `[PR番号]` | 対象 PR(省略時は直前 PR / 現在ブランチ) | — | +| `--max-rounds N` | 全体最大ラウンド数(PR ローテーションを含む通算) | `12` | +| `--rotate-after K` | この round 数で未収束なら PR ローテーション | `8` | +| `--rotate-mode light\|squash` | ローテーション方式。`light`: 同ブランチで旧 PR を close → 新 PR (title/body は現状の差分・実装から再生成)。`squash`: squash 統合 + 新ブランチ + `(rotated)` suffix | `light` | +| `--only codex` / `--only gemini` | 片方だけで回す(デバッグ用) | 両方 | +| `--focus TEXT` | 自動レビュー観点に上乗せして codex / gemini 両方に渡す追加観点。短い重点チェック向け | なし | +| `--extra-instructions-file PATH` | 自動レビュー観点に上乗せして codex / gemini 両方に渡す追加観点を UTF-8 テキストファイルから読む。長いチェックリスト向け | なし | + +例: + +``` +/ndf:cross-review 123 +/ndf:cross-review 123 --max-rounds 4 --rotate-after 2 +/ndf:cross-review 123 --rotate-mode squash +/ndf:cross-review 123 --only codex +/ndf:cross-review 123 --focus "ドキュメントとコードの整合性を重点的に確認" +/ndf:cross-review 123 --extra-instructions-file /tmp/review-focus.md +``` + +### 自動レビュー観点テンプレート + +`state.py init` は GitHub API の `pulls//files --paginate` で変更ファイルを全件取得して分類し、 +codex / gemini 両 launcher に同じ追加観点を渡す。`--focus` / +`--extra-instructions-file` は、この自動テンプレートの後ろに上乗せされる。 + +自動カテゴリ: + +- `common`: PR 全体の目的、変更範囲、保守性、テスト、ロールバック容易性 +- `docs_only`: ドキュメントのみ PR。企画・説明の妥当性、コード/設定/コマンド/他 docs との整合性 +- `code`: 設計、正確性、可読性、冗長・重複、言語らしさ、セキュリティ、関数/ファイルの責務とサイズ +- `db_migration`: データ設計、型、NULL/default/制約/index、既存データ、backfill、ロールバック +- `test`: テストの仕様性、境界値、失敗系、flaky リスク +- `dependency`: 依存追加/更新、lockfile、ライセンス、互換性、セキュリティ +- `config_ci`: CI/設定、権限、secret、cache、環境差分 +- `api_contract`: API 契約、互換性、schema、status、エラー形式、認可 +- `auth_security`: 認証/認可、secret/PII、CSRF/CORS/session/JWT/OAuth +- `frontend`: UI 状態、アクセシビリティ、レスポンシブ、状態管理、表示文言 +- `performance`: N+1、I/O、メモリ、ロック、cache、queue、冪等性 +- `deletion_rename`: 削除/リネーム参照漏れ、後方互換、移行手順 +- `generated`: 生成物、lockfile、再生成手順、差分ノイズ +- `i18n`: 翻訳キー、fallback、変数展開、表示幅、文言整合 +- `infra`: IaC / Docker / Kubernetes 等の権限、secret、公開範囲、ロールバック + +### `--rotate-mode` の選び方 + +- **`light` (default)**: PR を読む人 (将来のレビュアー / 後続 PR を作る人) が cross-review の存在を意識せずに済む。release branch 戦略・TODO 参照・コミット単位レビューを破壊しない。**通常はこちらを使う** +- **`squash`**: 巨大 PR を 1 commit に潰したい / `(rotated)` suffix で rotation 履歴を PR title に残したい場合のみ。release branch 戦略を使う運用とは併用しない + +## 前提 + +- `/ndf:review` が **AI 直接投稿**(外部 AI 自身が `gh api` で投稿)に対応 +- `/ndf:fix` が **サブエージェント起動 + 重要度ベース自動修正 + Resolve Conversation** に対応 +- `codex` / `gemini` CLI が動作し、`gh` CLI が認証済み +- `Agent(subagent_type="general-purpose", ...)` でサブエージェントを起動可能 + +## 事前確認(`state.py init` が自動実施) + +ループ開始前に **4 つのプリチェック** が必要だが、すべて `scripts/state.py init` +が内部で実施する。メインは結果を KEY=VALUE 形式で受け取るだけで良い。 + +| # | 対策 | スクリプト側で何をするか | +|---|---|---| +| 1 | 自分の PR 判定(422 回避) | `gh api user` と `gh pr view --json author` を比較し `is_own_pr` / `event_downgrade` を state.json に書く | +| 2 | worktree 分離 | `git worktree add /--/pr ` を冪等実行(`` は `NDF_WORKTREE_BASE` env > `<システム tmpdir>/ndf-worktrees` の優先順で解決)。パスが存在しても現リポジトリの登録済み worktree でなければ `.stale-` に退避して作り直す | +| 3 | gemini trusted directory | `launch-gemini.sh` が `GEMINI_CLI_TRUST_WORKSPACE=true` + `--skip-trust` を必ず併用。**tmp dir は `/.cross_review/`** を採用し、gemini の workspace 制約 (workspace 外の `write_file` がブロックされる) を根本回避 | +| 4 | 既存コメント差分 | `fix/scripts/fetch-pr-comments.sh` で 3 ソース (インラインコメント / レビュー body / PR レベルコメント) を一括取得し `$TMP_DIR/cross-review-pr-existing-comments.txt` に保存。gemini プロンプトには **内容をインライン埋め込み**、codex プロンプトには path を渡す | + +### `` の解決順 + +`state.py init` は worktree の親ディレクトリを以下の優先順で解決する: + +1. `NDF_WORKTREE_BASE` 環境変数(明示オーバーライド) +2. `<システム tmpdir>/ndf-worktrees`(Python `tempfile.gettempdir()`。非永続領域のため + コンテナ再作成で自動消滅し、共有 volume を消費しない) + +worktree の実パスは `/--/pr` 形式で、リポジトリ slug を含める +ことで**他リポジトリの同一 PR 番号と衝突しない**。永続 volume(旧 `/work/worktrees`)を +使っていた頃は別プロジェクトの残骸 worktree を誤って流用する事故があったため、 +パスが存在しても `git worktree list` に登録されていなければ `.stale-` に +退避して作り直すガードも入っている。 + +解決した実パスは `state.json` の `worktree_path` に書かれるため、後続スクリプトや +サブエージェント prompt は state.json から読めば追従できる。 + +### intent / posted_as の両保持(最重要) + +GitHub は **自分の PR には `REQUEST_CHANGES` でレビューを投稿できない** +(`HTTP 422`)。state.json には **両方** を保持する: + +```json +"codex": { + "intent": "REQUEST_CHANGES", // AI の本来判定。ループ収束判定に使う + "posted_as": "COMMENT", // 422 回避でダウングレードした結果 + "comments": 5, "review_url": "..." +} +``` + +`state.py judge` は `intent` を見るので、ダウングレード投稿してもループは続行する。 + +## 全体フロー + +```mermaid +flowchart TD + Start([事前確認 / loop 開始前に 1 回だけ]):::phase --> Init["worktree 作成 + state.json 初期化
・自分の PR 判定 → event downgrade 設定
・<worktree-base>/pr<PR> を用意
・既存コメントスナップショット保存"] + Init --> Round["Round N start
current_pr = PR#"]:::phase + + Round -.並列バックグラウンド.-> Codex["/ndf:review <PR> codex
(AI が gh api で直接投稿)
body 先頭: cross-review / round N / codex / intent
→ result.json (intent + posted_as)"] + Round -.並列バックグラウンド.-> Gemini["/ndf:review <PR> gemini
--skip-trust 必須
body 先頭: cross-review / round N / gemini / intent
→ result.json (intent + posted_as)"] + + Codex --> Decide{"判定 (intent ベース)"} + Gemini --> Decide + + Decide -->|両方 APPROVE / SKIP| Approved([final = approved]):::ok + Decide -->|一方でも REQUEST_CHANGES| Fix["Agent (general-purpose)
/ndf:fix <PR> --defer-nit を worktree 内で実行
・critical/major/minor 修正 + push
・reply + resolveReviewThread
・deferred/rejected は reply のみ
→ $TMP_DIR/fix-pr<#>-result.json"] + + Fix --> Check{収束チェック} + Check -->|max-rounds 到達| MaxR([final = max_rounds]):::stop + Check -->|振動検知 50% 重複| Osc([final = oscillation]):::stop + Check -->|CI failure code-related| Err([final = error]):::stop + Check -->|"CI failure meta-only (Assignees 等)"| Round + Check -->|round_in_pr >= rotate-after| Rotate["PR rotation
light (default): 同ブランチで巻き直し
squash (opt-in): 新ブランチ + squash 統合
light は Agent が title/body 再生成"] + Check -->|それ以外| Round + Rotate --> Round + + Approved --> Sweep["最終スイープ (必須)
Agent (general-purpose)
/ndf:fix <PR> を再実行
・残 open review thread を全て確認
・修正可能な minor/nit は修正 + push
・判断保留 nit も reply + resolveReviewThread
→ open thread 0 で終了"] + MaxR --> Sweep + Osc --> Sweep + Err --> Sweep + Sweep --> Report[ラウンドサマリ報告
+ 残 deferred nit を参考として列挙] + + classDef phase fill:#eef,stroke:#557 + classDef ok fill:#dfd,stroke:#383 + classDef stop fill:#fdd,stroke:#933 +``` + +## 実行ステップ概要(メインの bash 骨組み) + +各ステップの詳細は `docs/` 参照。メインは以下のテンプレートで scripts/ を呼ぶだけ: + +```bash +PLUGIN_ROOT="${PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT}}" +SCRIPTS="$PLUGIN_ROOT/skills/cross-review/scripts" + +# STATE_PR は state.json のキー (= 最初に init した PR 番号)。 +# rotation 後も state.json のパスは変わらないため、scripts/ への引数には常に +# STATE_PR を渡す。「現在レビュー中の PR」は state.json の current_pr を内部参照する。 +STATE_PR=$INITIAL_PR + +# ROTATE_MODE は引数 --rotate-mode (default=light) から取得。light なら Step 6b で +# Agent (general-purpose) を起動して新 PR の title/body を生成する。 +ROTATE_MODE=${ROTATE_MODE:-light} + +# Step 0: state 初期化 / 再開 +eval "$("$SCRIPTS/state.py" init "$STATE_PR" \ + --max-rounds "$MAX_ROUNDS" --rotate-after "$ROTATE_AFTER" \ + ${ONLY:+--only "$ONLY"} \ + ${FOCUS:+--focus "$FOCUS"} \ + ${EXTRA_INSTRUCTIONS_FILE:+--extra-instructions-file "$EXTRA_INSTRUCTIONS_FILE"})" +# eval で TMP_DIR がセットされる。後続スクリプトに env として伝播させる。 +export CROSS_REVIEW_TMP_DIR="$TMP_DIR" +cd "$WORKTREE" + +while :; do + # Step 1: round 開始判定 (max_rounds 到達で exit 1) + eval "$("$SCRIPTS/state.py" start-round "$STATE_PR")" + + # Step 2: 並列レビュー + [ "$ONLY" != "gemini" ] && "$SCRIPTS/launch-codex.sh" "$STATE_PR" "$ROUND" + [ "$ONLY" != "codex" ] && "$SCRIPTS/launch-gemini.sh" "$STATE_PR" "$ROUND" + # 監視: 既定 timeout=7 分 / stall=3 分。失敗時は対象プロセスを kill して返す。 + "$SCRIPTS/monitor.py" "$STATE_PR" "${ONLY:-both}" || handle_review_failure $? + + [ "$ONLY" != "gemini" ] && "$SCRIPTS/state.py" read-result "$STATE_PR" codex + [ "$ONLY" != "codex" ] && "$SCRIPTS/state.py" read-result "$STATE_PR" gemini + + # Step 3: 判定 (0=approved/2=continue) + if "$SCRIPTS/state.py" judge "$STATE_PR"; then break; fi + + # Step 4: 振動検知 (4=oscillation) + "$SCRIPTS/state.py" check-oscillation "$STATE_PR" || [ $? -eq 2 ] || exit 4 + + # Step 5: 修正サブエージェント起動 (Agent tool) → $TMP_DIR/fix-pr-result.json + # - メインで Agent(subagent_type=general-purpose, ...) を呼ぶ。docs/02 参照 + # - tmp パスは launcher / monitor.py と同じく **STATE_PR ベース** で統一 + # Step 5 後段: fix 戻り値マージ + CI 分類 (3=code-fail で中断) + "$SCRIPTS/state.py" merge-fix "$STATE_PR" + + # Step 6: PR ローテーション判定 (0=rotate/2=keep)。state.json の current_pr を内部更新。 + if "$SCRIPTS/state.py" should-rotate "$STATE_PR"; then + # Step 6a: 旧 PR の素材 (title/body/isDraft + git log/diff stat) を dump + eval "$("$SCRIPTS/rotate-pr.sh" prepare "$STATE_PR")" + + # Step 6b: light モードのみ。**メインセッション側で Agent(subagent_type="general-purpose") を起動して** + # prepare.json を読ませ、現状の差分・実装を反映した title/body を + # $TMP_DIR/rotate-pr-newtext.json に書き出させる。 + # 詳細プロンプトは docs/02-fix-and-rotation.md Step 6b 参照。 + # squash モードでは Step 6b 不要。 + # + # ⚠ Bash 単体では Agent ツールを呼べない。下記のフローは「メイン会話セッション側で」 + # 実行される前提なので、bash の while ループそのものは pseudo-code として読み、 + # 実際には以下の 3 段階を **メインが順に駆動する** こと: + # (1) bash 側で `rotate-pr.sh prepare $STATE_PR` を実行 (これは普通の bash) + # (2) メインが Agent(...) を起動して newtext.json を書かせる (bash の外) + # (3) bash 側で `rotate-pr.sh execute $STATE_PR --mode light` を実行 + # 下記の if exit 10 は **誤ってメイン介在なしで Step 6c に進むことを防ぐガード** であり、 + # exit 10 を観測したらメインは Step 6b の Agent を起動し、newtext.json が + # 生成されてから **同じ STATE_PR で Step 6c (execute) を直接呼び直す**。 + # state.json は完全に再開可能 (prepare.json はそのまま再利用される)。 + NEWTEXT_JSON="$TMP_DIR/rotate-pr$STATE_PR-newtext.json" + if [ "$ROTATE_MODE" = "light" ] && [ ! -s "$NEWTEXT_JSON" ]; then + echo "⏸ light モード: メインセッションで Agent(general-purpose) を起動し" >&2 + echo " $NEWTEXT_JSON を生成してから rotate-pr.sh execute $STATE_PR --mode light を実行してください" >&2 + echo " (docs/02-fix-and-rotation.md Step 6b 参照 / 再開プロトコルは下記 '## 再開プロトコル')" >&2 + exit 10 + fi + + # Step 6c: 実行。NEW_PR / NEW_PR_URL / NEW_BRANCH を eval で取り込む。 + eval "$("$SCRIPTS/rotate-pr.sh" execute "$STATE_PR" --mode "$ROTATE_MODE")" + + "$SCRIPTS/state.py" set-current-pr "$STATE_PR" "$NEW_PR" + # NOTE: STATE_PR は変えない。次ループの scripts も $STATE_PR を渡す。 + fi +done + +# Step 7.5: 最終スイープ (必須) — どの終了経路 (approved / max_rounds / oscillation / +# error) でも、ループを抜けた直後に **メインが Agent(general-purpose) を起動** し、 +# /ndf:fix $STATE_PR を再実行して残った open review thread を全て解消する。 +# ⚠ bash 単体では Agent ツールを呼べないため、while ループを抜けたらメインが +# Step 7.5 の Agent を駆動し、$TMP_DIR/sweep-pr$STATE_PR-result.json を生成させる +# (プロンプトテンプレートは docs/02-fix-and-rotation.md Step 7.5)。 +# 最終 APPROVE ラウンドで投稿された minor/nit インラインコメントはループ内 fix を +# 経由しないため、ここで拾わないと PR 上に未解決スレッドが残る。 +# sweep 結果 (sweep-pr$STATE_PR-result.json) はメインが Step 8 の報告に折り込む。 + +# Step 8: 終了処理 (deferred nit + ラウンドサマリ) +"$SCRIPTS/state.py" report "$STATE_PR" +``` + +各ステップの内容と契約(state.json / result.json スキーマ等)の詳細は: + +- Step 0〜4 — [docs/01-state-and-review.md](docs/01-state-and-review.md) +- Step 5〜8 (最終スイープ Step 7.5 含む) — [docs/02-fix-and-rotation.md](docs/02-fix-and-rotation.md) + +## light モード rotation の再開プロトコル (exit 10 を観測した時) + +bash ループは Agent tool を呼べないため、light モードでは Step 6b の介入が必須。 +メインセッションはループ全体を 1 回の bash で完結させず、以下のように駆動する: + +1. **通常のループ実行** — Step 0〜6a まで bash で進めると、newtext.json が未生成の + ため `exit 10` で停止する。state.json には prepare.json までの状態が + 永続化されているので **そのまま再開可能**。 +2. **Step 6b (Agent 起動)** — メインが Agent(subagent_type=`general-purpose`) を + 起動し、prepare.json を読ませて `$TMP_DIR/rotate-pr$STATE_PR-newtext.json` を + 書き出させる (プロンプトテンプレートは docs/02-fix-and-rotation.md Step 6b)。 +3. **Step 6c (execute) を直接呼ぶ** — メインが bash で以下を実行: + + ```bash + eval "$("$SCRIPTS/rotate-pr.sh" execute "$STATE_PR" --mode light)" + "$SCRIPTS/state.py" set-current-pr "$STATE_PR" "$NEW_PR" + ``` + +4. **ループ再開** — Step 7 (次ラウンド) からループ全体を再開する。`STATE_PR` は + 不変なので、`start-round` 以降は通常通り進む。 + +> ⚠ exit 10 はエラーではなく **メイン介入待ちの一時停止シグナル**。final ステータスには +> 反映しない (中断扱いではない)。次回 round カウントにも影響しない。 + +## レビュー出力の制約 + +**目的**: PR 上に Resolve 義務を伴うインラインコメントを増やさない。 +**修正アクションを伴わない記述は一切出さない** ことを両 launcher プロンプトで強制する。 + +### 1. body 先頭 identifier prefix(必須) + +人間アカウントから AI が投稿するため、GitHub UI 上では誰のレビューか分からない。 +body 先頭に必ず以下を入れる: + +``` +## 🤖 cross-review | round 1 | codex | REQUEST_CHANGES +``` + +書式: `## 🤖 cross-review | round | | ` + +- ``: `codex` / `gemini` のいずれか +- ``: AI の本来の判定(`REQUEST_CHANGES` / `APPROVE` / `COMMENT`) + `posted_as` ではなく `intent` を書く + +### 2. インラインコメントの最小化(最重要) + +インラインコメントは GitHub 上で **Resolve 操作が必須** になるため、本当に直すものだけ作る: + +| 重要度 | インライン化 | 説明 | +|---|---|---| +| `critical` / `major` | ✅ する | 修正必須 | +| `minor` | ✅ する | 明らかな改善のみ。判断が割れるなら出さない | +| `nit` | ❌ **出さない** | 好み・スタイルはコメント化禁止。気になっても無視する | + +**1 インラインコメント = 1 修正アクション** を厳守。 +コメント本文は `[重要度 / カテゴリ] 修正提案` の 1 文で完結させ、 +コード引用ブロック(``` ... ```)や現状説明だけのコメントは作らない。 + +### 3. body(総評)に書かないこと + +- ❌ **「良い点」/「Strengths」/「Positives」/「評価できる点」セクション** — 一切書かない +- ❌ 個別ファイル・関数の褒め言葉 +- ❌ 「特に問題ありません」「概ね良好です」等の評価文 +- ❌ 対応不要な観察コメント(「〜のようです」「〜と思われます」止まり) + +body に書くのは **設計レベル・PR 横断の修正提案** のみ。 +書くことが無ければ body は `## 🤖 cross-review ...` の prefix 行 + 1 行サマリのみで良い。 + +### 4. event 判定 + +- `APPROVE` — 修正必須の指摘なし(minor 以下しか無い場合も APPROVE で良い) +- `REQUEST_CHANGES` — critical / major の指摘あり +- `COMMENT` — **基本使わない**。雑感だけの投稿は禁止 + +## CI failure の分類(誤中断防止) + +「CI 失敗 → 即 `final=error`」は乱暴。`scripts/state.py merge-fix` が +fix 戻り値ファイル (`$TMP_DIR/fix-pr-result.json`) を受け取った際に +`ci_failed_checks` を以下で分類する: + +| 分類 | パターン | 振る舞い | +|---|---|---| +| code-fail | `pint` / `larastan` / `phpstan` / `test` / `lint` / `type` / `build` / `ruff` / `eslint` / `tsc` / `mypy` | `final=error` で中断 (exit 3) | +| meta-only | `check_pr_requirements` / `assignees` / `reviewers` / `labels` / `meta` | `ci_note` に記録して継続 | +| 不明 | 上記以外 | 保守的に **code-fail 扱い** | + +PR メタデータ系の check(Assignees / Reviewers / Labels)は **継続**、 +pint / larastan / test / build などは **中断** を原則とする。 + +## アンチパターン + +- ❌ **修正をメインセッション内で行う** — context が一気に膨れる。必ずサブエージェント +- ❌ **AI に Markdown だけ返させる** — メインがパース・投稿する設計は禁物。AI 直接投稿 +- ❌ **nit を都度ユーザに問う** — ループ中は deferred 記録のみ。最終スイープ (Step 7.5) で Resolve +- ❌ **未解決スレッドを残したまま終了する** — approved/max_rounds 等いずれの終了経路でも + Step 7.5 の最終スイープを必ず実行し、open review thread 0 で終える。特に **最終 APPROVE + ラウンドの minor/nit インラインコメント**はループ内 fix を通らないため取りこぼしやすい +- ❌ **`max-rounds` なしで回す** — 無限ループの温床 +- ❌ **PR ローテーションを忘れる** — 100+ コメントの巨大 PR になる +- ❌ **light モードで Agent (general-purpose) 呼び出しを省略する** — newtext.json が無いと `rotate-pr.sh execute --mode light` はエラーで止まる。prepare → Agent → execute の 3 段は不可分 +- ❌ **light モードで新 PR の title/body に内部用語を漏らす** — 「round N」「rotated」「cross-review」「レビュー指摘で〜」等は禁止 (Agent プロンプトで明示禁止) +- ❌ **newtext.json に旧 PR の title/body をそのままコピーする** — 「現状の差分・実装を反映」が必須。古い説明が残ると後続 PR / 将来のレビュアーが混乱 +- ❌ **`rotate-pr.sh` 内から `claude` CLI を呼んで title/body を生成する** — 環境依存・コスト管理外。Agent tool でメイン側から呼ぶ +- ❌ **CI 失敗を一律で中断** — コード関連/メタチェックを分類(上記参照) +- ❌ **自分の PR に `REQUEST_CHANGES` で投稿** — 必ず 422。事前判定 + COMMENT ダウングレード +- ❌ **`gemini --yolo` だけで起動** — trusted directory で YOLO 無効化。`--skip-trust` 併用 +- ❌ **`pgrep -fa ` で完了判定** — gemini は long prompt が引数に乗り検知失敗。pidfile 必須 +- ❌ **sentinel 単独で完了判定** — codex がクラッシュすると永遠に出ない。`monitor.py` の多軸判定 (pidfile / sentinel / 早期エラー / stall / hard timeout / result.json) を使うこと +- ❌ **タイムアウトなしで wait** — ハング検知不能。`monitor.py` の hard timeout (30 分既定) + stall timeout (10 分既定) を必ず効かせる +- ❌ **EARLY_ERROR の曖昧パターンで kill する** — 行頭の生 `Error:` / `Traceback` は codex がレビュー対象 diff の test コード片を echo するケースで誤検知する。明確な致命 (auth / quota / sandbox / HTTP 401-403-429 / gemini の YOLO 降格) **のみ** kill 対象とし、曖昧パターンは警告ログに留める。誤検知が再発する場合は `--no-early-error` / `MONITOR_NO_EARLY_ERROR=1` で検知自体を無効化する (sentinel / result.json / timeout で十分判定可能) + +## monitor.py が誤って kill する場合の手順 + +`monitor.py` が EARLY_ERROR で codex / gemini を即時 kill してしまい、`result.json` が +生成されないケースは以下で切り分け・回避できる: + +1. **err.log の冒頭を確認**: 検知パターン (`fatal_err` の `early error (fatal) in err.log: ...`) が + 本当に致命なのか、それとも diff body の echo / config validation 警告なのかを判別 + - **v4.11.0 で benign 自動判定を強化**: `_match_is_quoted()` が backtick / 「」 に加え + **ダブル/シングルクォート文字列リテラル** (`"quota exceeded: ..."`) を、`EARLY_ERROR_BENIGN` + が **grep 形式のソース引用行** (`path/to/file.py:22: `) を自動で benign 扱いする。 + codex が tests/*.py 等のテスト用文字列 (`"quota exceeded"`, `"sandbox error"`) を + レビュー中に echo しても誤 kill しなくなった(旧版で PR #23 round 2 に発生した事例) +2. **gemini の `Error in: mcpServers.` 警告**: `.gemini/settings.json` に `disabled: false` + 等の非互換キーがあると毎回出る。`launch-gemini.sh` の sanitize ロジック (v4.7.2+) で + 自動退避するため、最新版にアップデートすれば解消する +3. **誤検知が継続する場合**: `monitor.py --no-early-error` (もしくは `MONITOR_NO_EARLY_ERROR=1` + 環境変数) で EARLY_ERROR 検知自体を無効化し、hard timeout / stall / sentinel / result.json + のみで判定するモードに切り替える +4. **新しい致命パターンを観測した場合**: `EARLY_ERROR_FATAL` に追記する (PR で plugin に反映)。 + 曖昧パターンは `EARLY_ERROR_WARN` 側に置き、kill 対象にはしない +- ❌ **fix サブエージェントが Resolve をスキップ** — reply だけでは未対応扱い。Resolve まで実行 +- ❌ **review body に identifier prefix を付け忘れる** — GitHub UI 上で誰のレビューか不明になる + +## メイン context 節約の工夫 + +1. **大きいファイルはメイン context に載せない**: payload / err.log / diff は + すべて `$TMP_DIR/` (= `state.py _tmp_dir()` の解決先) に置き、メインは + state.json と result.json だけ読む +2. **サブエージェント分離**: 修正は別 context window で実行 +3. **PR ローテーション**: 1 PR あたりの会話履歴を抑える +4. **AI 直接投稿**: 中間ペイロードがメインを通らない +5. **state.json で再開可能**: メインが落ちても次回起動時に続きから + +## 作業完了報告(必須) + +ループ終了後、メインからユーザへの報告: + +- **最終ステータス**: `approved` / `max_rounds` / `oscillation` / `error` +- **総ラウンド数 / PR 数**: 例: `5 rounds / 2 PRs (rotated 1 回)` +- **PR 履歴**: 各 PR 番号 + closed/open 状態 + round 数 +- **各ラウンドのサマリ表**: + + | round | PR | codex | gemini | fix | CI | + |---|---|---|---|---|---| + | 1 | #123 | REQ (5) | REQ (3) | abc123 (5 fixed, 2 deferred) | ✅ | + | 2 | #123 | REQ (2) | APP | def456 (2 fixed) | ✅ | + | 3 | #145 | APP | APP | — | — | + +- **最終スイープ結果** (Step 7.5): `sweep-pr-result.json` の `resolved` / + `fixed_in_sweep` / `remaining_open`。**`remaining_open` は 0 が正常**(残 open + thread あり = 取りこぼし)。0 にできなかった場合は理由を明記 +- **残 deferred nit リスト**(Step 7.5 で Resolve 済み。再対応が要るものがあれば参考列挙) +- **rejected 件数**(bot 誤指摘で却下したもの) +- **最終 PR URL** + +詳細は PR 上のインラインコメントと state.json に残っているため、本報告では +繰り返さない。 + +## 関連 + +- `/ndf:review` — 単発レビュー(AI 直接投稿対応) +- `/ndf:fix` — 修正対応(サブエージェント起動対応) +- `/ndf:codex` — codex CLI 呼び出し手順 +- `/ndf:gemini` — gemini CLI 呼び出し手順 +- `/ndf:resolve-pr-comments` — Resolve Conversation の詳細 +- `general-purpose` エージェント — fix 実行用サブエージェント diff --git a/plugins/ndf-claude/skills/cross-review/docs/01-state-and-review.md b/plugins/ndf-claude/skills/cross-review/docs/01-state-and-review.md new file mode 100644 index 0000000..9f858ca --- /dev/null +++ b/plugins/ndf-claude/skills/cross-review/docs/01-state-and-review.md @@ -0,0 +1,254 @@ +# 01: 状態管理 + レビュー実行 (Step 0〜4) + +`SKILL.md` 本体から呼び出される **状態ファイル初期化 / ラウンド開始 / +並列レビュー / 判定 / 振動検知** までの詳細手順。 + +主要処理は `scripts/` 配下のコマンドに切り出し済み: + +| script | 役割 | +|---|---| +| `scripts/state.py init` | Step 0 — state 初期化 / 再開 + プリチェック | +| `scripts/state.py start-round` | Step 1 — round 開始判定 | +| `scripts/launch-codex.sh` / `scripts/launch-gemini.sh` | Step 2 — review launcher | +| `scripts/monitor.py` | Step 2 — codex/gemini プロセス多軸監視 | +| `scripts/wait-review.sh` | Step 2 — `monitor.py` の薄ラッパ(互換用) | +| `scripts/state.py read-result` | Step 2.5 — result.json マージ | +| `scripts/state.py judge` | Step 3 — intent ベース pass 判定 | +| `scripts/state.py check-oscillation` | Step 4 — 振動検知 | + +このドキュメントは **state.json スキーマと AI への入出力契約** を一次資料として残す。 +スクリプト側の挙動はソースを直接参照のこと。 + +## 状態ファイル + +`$TMP_DIR/cross-review-pr<番号>-state.json`: + +```json +{ + "started_at": "2026-05-12T...", + "max_rounds": 12, + "rotate_after": 8, + "only": null, + "current_pr": 123, + "worktree_path": "/tmp/ndf-worktrees/owner--name/pr123", + "repo": "owner/name", + "head_branch": "feature/foo", + "base_branch": "main", + "pr_author": "someone", + "is_own_pr": false, + "event_downgrade": false, + "pr_history": [ + {"pr": 123, "opened_at": "...", "closed_at": null, "rounds": 2} + ], + "rounds": [ + { + "round": 1, + "pr": 123, + "started_at": "...", + "codex": {"intent": "REQUEST_CHANGES", "posted_as": "COMMENT", + "comments": 5, "review_url": "...", + "by_severity": {"critical": 0, "major": 3, "minor": 2, "nit": 0}}, + "gemini": {"intent": "REQUEST_CHANGES", "posted_as": "COMMENT", + "comments": 3, "review_url": "...", + "by_severity": {"critical": 0, "major": 2, "minor": 1, "nit": 0}}, + "fix": {"commit": "abc1234", "fixed": 6, "deferred": 2, "rejected": 0, + "resolved_threads": 4, "ci": "SUCCESS", "ci_note": null}, + "ended_at": "..." + } + ], + "deferred_nits": [ + {"pr": 123, "round": 1, "path": "src/foo.py", "line": 42, "severity": "nit", + "summary": "...", "comment_url": "..."} + ], + "final": null +} +``` + +`final` 値: `approved` / `max_rounds` / `oscillation` / `error` + +### 重要なフィールド + +- `worktree_path` — 並行セッションとの分離。サブエージェントへの cwd 指示にも使う +- `is_own_pr` / `event_downgrade` — 自分の PR の場合 `REQUEST_CHANGES → COMMENT` 強制ダウングレード +- `rounds[].codex.intent` — AI の本来判定。**ループ判定はこれを見る** +- `rounds[].codex.posted_as` — GitHub に実際に送った event。`is_own_pr=true` なら `COMMENT` になる +- `rounds[].fix.resolved_threads` — `resolveReviewThread` で resolve した**件数(int)**。 + ここ (state.json 側) は int だが、fix サブエージェントが返す戻り値ファイル + (`fix-pr-result.json`) 側の `resolved_threads` は **list**(`docs/02` の戻り値スキーマ参照)。 + `state.py merge-fix` が fix結果の list を `len()` して state.json に int で保存する。 + 混同して fix結果側に int を書くと過去 `merge-fix` が落ちていたため、現在は int/list 両受理 +- `rounds[].fix.ci_note` — コード無関係の CI 失敗時に「Assignees 未設定」等の理由を残す + +## Step 0: 準備 + 既存 state 引き継ぎ + +```bash +PLUGIN_ROOT="${PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT}}" +SCRIPTS="$PLUGIN_ROOT/skills/cross-review/scripts" # or 直接の絶対パス + +# state 初期化 / 再開(プリチェック・worktree 作成・既存コメントスナップショットを内部実行) +eval "$("$SCRIPTS/state.py" init "$STATE_PR" \ + --max-rounds "$MAX_ROUNDS" --rotate-after "$ROTATE_AFTER" \ + ${ONLY:+--only "$ONLY"} \ + ${FOCUS:+--focus "$FOCUS"} \ + ${EXTRA_INSTRUCTIONS_FILE:+--extra-instructions-file "$EXTRA_INSTRUCTIONS_FILE"})" + +# eval で取り込まれる変数: PR, WORKTREE, REPO, HEAD_BRANCH, BASE_BRANCH, +# IS_OWN_PR, EVENT_DOWNGRADE, RESUMED +cd "$WORKTREE" +``` + +`state.py init` が内部で行う処理: + +1. 既存 state.json があり `final == null` なら再開 +2. 自分の PR 判定(`gh api user` と `gh pr view --json author` を比較) +3. worktree 作成(`/--/pr`。`` は `NDF_WORKTREE_BASE` env > `<システム tmpdir>/ndf-worktrees` の優先順で解決。既存パスが現リポジトリの登録済み worktree でなければ `.stale-` に退避して作り直す。実 path は state.json の `worktree_path` を参照) +4. 既存コメントスナップショット (`fix/scripts/fetch-pr-comments.sh` で 3 ソース一括取得) → `$TMP_DIR/cross-review-pr-existing-comments.txt` +5. state.json 書き出し + +**重要**: 以降の全ステップで `cd $WORKTREE` を強制。 +サブエージェント(fix)を起動するときも、prompt 内で worktree path を明示する。 + +## Step 1: Round 開始判定 + +```bash +eval "$("$SCRIPTS/state.py" start-round "$STATE_PR")" +# eval で取り込まれる変数: ROUND, ROUND_IN_PR, PR, MAX_ROUNDS, ROTATE_AFTER +``` + +`state.py start-round` は `max_rounds` 超過なら `final=max_rounds` を書いて exit 1。 +それ以外は新しい round エントリを state.rounds に push して KEY=VALUE を吐く。 + +## Step 2: codex / gemini 並列レビュー(AI 直接投稿) + +**要点**: メインは launcher を **並列バックグラウンド** で起動するだけ。 +各 AI が `gh api` で投稿し `$TMP_DIR/-review-pr-result.json` に +サマリを書く。**ペイロード本体はメイン context に載せない**。 + +### 2.1 launcher 起動 + monitor + +```bash +[ "$ONLY" != "gemini" ] && "$SCRIPTS/launch-codex.sh" "$STATE_PR" "$ROUND" +[ "$ONLY" != "codex" ] && "$SCRIPTS/launch-gemini.sh" "$STATE_PR" "$ROUND" + +# monitor.py が多軸で完了判定。exit code で失敗種別を分岐。 +if ! "$SCRIPTS/monitor.py" "$STATE_PR" "${ONLY:-both}"; then + case $? in + 2) echo "❌ timeout" ;; # hard timeout 超過 + 3) echo "❌ no result" ;; # プロセス終了したが result.json 未生成 + 4) echo "💥 early error" ;; # err.log に致命的パターン + 5) echo "🛑 stalled" ;; # 進捗ログ更新なし + 6) echo "❓ pidfile bad" ;; # 起動失敗 / 不正 + esac + # ラウンドを失敗マークしてリトライ or 中断(state.py side で判断) +fi +``` + +#### `monitor.py` の多軸監視 + +| 軸 | 内容 | +|---|---| +| pidfile + `kill -0` | プロセス生存確認。alive 確認後に `/proc//cmdline` で agent 名一致も検証 (PID 再利用対策)。**プロセスが既に死んでいる場合は result.json の有無のみで OK 判定**する (死亡直後 cmdline 不一致で誤検知しないため) | +| codex sentinel | err.log に `^tokens used$` 出現で正常完了マーク | +| early-error | **行頭限定** で `^Error:` / `^FATAL:` / `^panic:` / `^Traceback ` / `^HTTP/1.1 401\|403\|429` / `^Approval mode overridden to "default"` / `^Authentication failed` / 「quota exceeded」「rate limit exceeded」「API key not found/missing/invalid」「sandbox error」を含む行を検出 (diff/doc 引用文中の同語句は誤検知しないよう anchor + benign フィルタ併用) | +| stall timeout | err.log + stdout.log + progress.log の合計サイズが一定時間変化しなければ STALLED で中断。既定は **agent 別** (codex=**180s** / gemini=**480s**)。gemini は err.log がほぼ無音のため大きめに取る。codex 側既定は不変。上書き方法: CLI `--stall-timeout` (明示優先) > env `MONITOR_STALL_` (per-agent) > env `MONITOR_STALL` (両 agent 共通) > agent 別ビルトイン | +| hard timeout | 既定 **7 分**。`--timeout` or `MONITOR_TIMEOUT` env で上書き | +| progress.log heartbeat | launcher が任意で `-review-pr-progress.log` への短いフェーズマーカー出力を要求し、monitor が最終行を stderr の heartbeat に表示する。内部推論ではなく `scan` / `analyze` / `post` / `done` などの監視用ステータスだけを出す | +| result.json 存在 | プロセス終了後、result.json が無ければ NO_RESULT (exit 3) | +| **result.json + age fallback** | sentinel を持たない agent (gemini) 向け。プロセスが alive のまま result.json の mtime が **30 秒以上前**なら完了とみなし kill → OK。gemini が MCP 切断待ち等でハングするケースに対応 (codex は sentinel チェックが先に発火するため影響なし) | +| **失敗時 kill** | TIMEOUT / STALLED / EARLY_ERROR / PIDFILE_BAD で返るときは対象プロセスに SIGTERM → 3 秒後 SIGKILL。残存プロセスが後から `gh api` 投稿や result.json 書き込みを行うのを防ぐ | + +> ⚠ **罠**: `nohup ... &` でラッパーシェルは即終了し、ハーネスから +> 「タスク完了」通知が飛んでくる。これに惑わされず、`monitor.py` で +> 実プロセスの完了を pidfile / sentinel で確認すること。 +> +> ⚠ **`pgrep -fa ` で完了判定しない**: gemini は long `-p` プロンプトを +> 引数に持つため、`grep` のキーワード選定で誤検知する。**pidfile 必須**。 +> +> ⚠ **sentinel 単独で完了判定しない**: codex がクラッシュすると `tokens used` が +> 永遠に出ない。`monitor.py` は sentinel と pidfile/result.json/err.log を併用する。 +> +> ⚠ **Docker 環境ではゾンビプロセスに注意**: `nohup ... & disown` で起動した +> プロセスは、終了後にゾンビ化する (PID 1 が proper init でない場合)。 +> `monitor.py` は `/proc//status` でゾンビを検出して dead 扱いする。 +> **推奨: Docker 実行時に `--init` フラグを付ける** (tini が PID 1 になりゾンビを reap する)。 + +### 2.2 AI への入出力契約(両 launcher 共通) + +launcher が生成するプロンプトに以下を強制している: + +- **headRefOid (commit_id) を明示**: AI が自前で取得すると baseRefOid を誤って入れる事故が多発 +- **作業 worktree の絶対パス**: 「ファイル読み取りは必ず worktree 配下の絶対パスを使う」(実 path は state.json の `worktree_path` を参照。`` は `NDF_WORKTREE_BASE` env > `<システム tmpdir>/ndf-worktrees` の優先順で解決) +- **event ダウングレード警告**: `event_downgrade=true` のときは payload の `event` を `COMMENT` に +- **既存コメント差分**: `$TMP_DIR/cross-review-pr-existing-comments.txt` を読んで重複指摘禁止 +- **自動レビュー観点**: GitHub API の `pulls//files --paginate` で変更ファイルを全件取得して分類し、`common` / `docs_only` / `code` / `db_migration` / `test` / `dependency` / `config_ci` / `api_contract` / `auth_security` / `frontend` / `performance` / `deletion_rename` / `generated` / `i18n` / `infra` の該当テンプレートを state.json の `auto_review_instructions` に保存する +- **手動追加レビュー観点**: `--focus` / `--extra-instructions-file` が指定されていれば state.json の `manual_extra_review_instructions` に保存し、自動テンプレートの後ろに連結した `review_instructions` を codex / gemini 両 launcher が同じ「追加レビュー観点」セクションとしてプロンプトに差し込む +- **進捗マーカー**: Gemini には `$TMP_DIR/gemini-review-pr-progress.log` へ短いフェーズ名を追記させ、monitor の heartbeat で表示する。内部推論や長文説明は書かせない +- **review body 先頭 prefix**: + ``` + ## 🤖 cross-review | round | | + ``` + `` は **本来の intent**(`posted_as` ではない)。 + 例: 自分PR で REQUEST_CHANGES を COMMENT にダウングロードしても、prefix は `REQUEST_CHANGES` のまま。 +- **出力禁止事項**(SKILL.md「レビュー出力の制約」と一致): + - 「良い点」「Strengths」などの褒めセクションを body に書かない + - 修正アクションを伴わないインラインコメントは作らない(nit はインライン化しない) + - コード引用のみで指摘内容が無いコメント禁止 + - 雑感だけの `event=COMMENT` 投稿禁止(直すべき点が無ければ `APPROVE`) + +### 2.3 AI が書き出すファイル契約 + +各 launcher は AI に以下 2 ファイルの書き出しを指示する: + +| ファイル | 内容 | +|---|---| +| `$TMP_DIR/-review-pr-result.json` | `{event, posted_as, comments_count, review_url, by_severity}` のサマリ | +| `$TMP_DIR/-review-pr-round-payload.json` | `{comments: [{path, line, body, severity}, ...]}` 振動検知用 | + +`/ndf:review` の result.json 出力規約に `posted_as` フィールドを含むこと +(自分PR ダウングレード時に GitHub に実際送った event。デフォルトは `event` と同値)。 + +### 2.4 result.json を state にマージ + +```bash +[ "$ONLY" != "gemini" ] && "$SCRIPTS/state.py" read-result "$STATE_PR" codex +[ "$ONLY" != "codex" ] && "$SCRIPTS/state.py" read-result "$STATE_PR" gemini +``` + +`state.rounds[-1].` に `intent / posted_as / comments / review_url / by_severity` を分離保存する。 + +## Step 3: 判定(intent ベース) + +```bash +if "$SCRIPTS/state.py" judge "$STATE_PR"; then + : # exit 0 = approved。ループ終了。 +elif [ $? -eq 2 ]; then + : # exit 2 = continue → Step 5 (fix) +else + exit 1 +fi +``` + +**判定ロジック**: + +- `APPROVE` / `SKIP` は pass +- `COMMENT` は `by_severity.critical == 0 && major == 0` のみ pass(軽微な指摘のみなら通す) +- `--only` 指定時は反対側を SKIP 扱い +- ループ収束判定は **必ず `intent`** を見る(`posted_as` ではない) + +自分の PR で `REQUEST_CHANGES → COMMENT` にダウングレード投稿していても、 +intent が `REQUEST_CHANGES` なら継続する。 + +## Step 4: 振動検知 + +```bash +if "$SCRIPTS/state.py" check-oscillation "$STATE_PR"; then + : # ここには来ない(成功は exit 2 = continue) +elif [ $? -eq 4 ]; then + exit 4 # final=oscillation で中断 +fi +``` + +各ラウンドの `$TMP_DIR/-review-pr-round-payload.json` から +`path:line` を抽出し、前ラウンドとの重複率を計算。**50% 以上重複で中断**。 + +PR ローテーション直後 (`round_in_pr < 2`) はスキップ。 diff --git a/plugins/ndf-claude/skills/cross-review/docs/02-fix-and-rotation.md b/plugins/ndf-claude/skills/cross-review/docs/02-fix-and-rotation.md new file mode 100644 index 0000000..aaaecc3 --- /dev/null +++ b/plugins/ndf-claude/skills/cross-review/docs/02-fix-and-rotation.md @@ -0,0 +1,384 @@ +# 02: 修正 (Step 5) + PR ローテーション (Step 6) + 終了処理 (Step 8) + +主要処理は `scripts/` 配下に切り出し済み: + +| script | 役割 | +|---|---| +| (Agent) | Step 5 — 修正サブエージェント起動(メインからの責務) | +| `scripts/state.py merge-fix` | Step 5 後段 — fix 戻り値マージ + CI 分類 | +| `scripts/state.py should-rotate` | Step 6 — rotate 要否判定 | +| `scripts/rotate-pr.sh prepare` | Step 6a — 旧 PR の素材を `rotate-pr-prepare.json` に dump | +| (Agent) | Step 6b — light モードのみ。新 PR の title/body を再生成して `rotate-pr-newtext.json` に書き出し | +| `scripts/rotate-pr.sh execute` | Step 6c — 旧 PR close + 新 PR 作成 (light は同ブランチ / squash は新ブランチ) | +| `scripts/state.py set-current-pr` | Step 6 — rotation 後の state 更新 | +| `scripts/state.py report` | Step 8 — deferred nit + ラウンドサマリ | + +## Step 5: 修正 — **必ずサブエージェント経由** + +**メインセッションでは修正コードを書かない。** `/ndf:fix` を +`general-purpose` サブエージェントで起動する。 + +**サブエージェントの責務(必須 6 点)**: + +1. critical / major / minor の修正コミット +2. 修正テストの追加・実行 +3. 修正対象の thread に **reply 投稿** + **`resolveReviewThread` で Resolve** +4. nit / 判断が割れる minor は **修正せず deferred 記録**(reply は `[deferred / nit]` ラベル付き、Resolve しない) +5. **PR レベルの Summary コメントを `gh pr comment` で投稿**(対応件数 / 重要度別 / deferred 件数 / rejected 件数 / commit SHA を含む) +6. 戻り値ファイル `$TMP_DIR/fix-pr-result.json` を必ず書き出す + (`$TMP_DIR` は env `CROSS_REVIEW_TMP_DIR` > `/.cross_review/` の順で解決。 + 詳細は `scripts/state.py _tmp_dir()` 参照。`/tmp/` 直書きでも `state.py merge-fix` は legacy fallback で拾う) + +> ⚠ inline thread への reply + Resolve **だけでは不十分**。PR ページの +> conversation タブに表示される **PR レベルコメント** がレビュアーへの +> サマリ通知として必須(`/ndf:fix` SKILL.md の手順 7 で規定)。 +> サブエージェント起動プロンプトでも明示的に指示すること。 + +### サブエージェント起動例 + +```python +Agent( + subagent_type="general-purpose", + description=f"Fix PR #{PR} (round {ROUND})", + prompt=f""" +/ndf:fix {PR} --defer-nit を実行してください。 + +**作業ディレクトリ厳守**: cd {WORKTREE_PATH} で作業すること。 +別セッションが /work/ 側で並行作業している可能性があり、 +worktree 外を触ると競合します。 + +## コンテキスト +- リポジトリ: {OWNER_REPO} +- PR: #{PR} (round {ROUND_IN_PR}/{ROTATE_AFTER}) +- worktree: {WORKTREE_PATH} +- ブランチ: {HEAD_BRANCH} +- ベース: {BASE_BRANCH} +- headRefOid: {HEAD_OID} +- 前ラウンドのレビュー結果: + - codex review: {CODEX_REVIEW_URL} + (intent={CODEX_INTENT}, posted_as={CODEX_POSTED_AS}, {CODEX_COMMENT_COUNT}件) + - gemini review: {GEMINI_REVIEW_URL} + (intent={GEMINI_INTENT}, posted_as={GEMINI_POSTED_AS}, {GEMINI_COMMENT_COUNT}件) +- 既存コメントスナップショット: $TMP_DIR/cross-review-pr{PR}-existing-comments.txt + +## ポリシー +- 重要度ラベルは **AI agent の付与を鵜呑みにせず**、コードを読んで独自に再判定する +- critical / major は自動修正 +- minor / nit のうち **パフォーマンス・可読性・重複コード排除** に該当するものは修正 + (特にトータル行数が減る方向は積極実施 / +30 行を超えそうなら deferred + ユーザ問い合わせ) +- それ以外の nit は deferred として記録のみ(修正しない、Resolve しない) +- bot 指摘が誤読していたら修正せず reply で説明(rejected として記録、Resolve しない) +- **重複指摘(codex/gemini が同じ箇所を別 thread で指摘)は全 thread に reply + Resolve** +- PR テスト範囲外の **flaky テストも見つけ次第このループで修正**(放置はリポジトリ品質を劣化させる) + +## 必須実行手順(順序厳守) + +1. PR コメント取得 (3 ソース): `fix/scripts/fetch-pr-comments.sh {OWNER_REPO} {PR}` でインラインコメント / レビュー body / PR レベルコメントを一括取得 +2. 重要度を独自再判定(AI agent のラベルは参考値) +3. CI 状態スナップショット: `gh pr checks {PR} --json name,state` (**完了待ちはしない**、PENDING は無視して FAILURE のみ修正対象に取り込む) +4. critical/major + 該当 minor/nit の修正コミット(worktree 内のみ) +5. `./pint-changed.sh && ./larastan-changed.sh` 等の品質チェック +6. push: `git push origin {HEAD_BRANCH}` (--force / --no-verify 禁止) +7. **CI 再実行は待たない**(push 後の `--watch` 等は行わない、`ci_status` は push 時点での既知失敗のみ反映) +8. **各 thread に reply 投稿**: + - 修正済み: 「対応しました — <ファイル>:<行> で〇〇 (commit )」 + - deferred: 「[deferred / nit] 後続 PR で対応予定」 + - rejected: 「bot 指摘は誤読です — 理由: ...」 +9. **修正済み thread を `resolveReviewThread` で Resolve**: + ```bash + # thread_id は GraphQL で取得 + gh api graphql -f query=' + query {{ repository(owner:"...", name:"...") {{ + pullRequest(number: {PR}) {{ reviewThreads(first:100) {{ + nodes {{ id isResolved path line }} + }} }} + }} }}' + # 修正済みのみ resolve + gh api graphql -f query=' + mutation($id: ID!) {{ + resolveReviewThread(input: {{threadId: $id}}) {{ thread {{ isResolved }} }} + }}' -f id="$THREAD_ID" + ``` + - deferred / rejected の thread は **Resolve しない** +10. **PR レベル Summary コメントを投稿**(必須・inline reply とは別物): + ```bash + gh pr comment {PR} --body "$(cat <<'EOMD' + ## 🔧 /ndf:fix サマリ (round N) + + 対応件数: critical=X / major=Y / minor=Z (合計 N 件) + deferred: D 件 / rejected: R 件 + commit: + CI: SUCCESS | FAILURE | NONE + + ### 詳細 + - 各 thread の対応概要(行リンク付き) + EOMD + )" + ``` + - inline reply + Resolve だけでは「PR ページの Conversation タブ」に + まとめが出ず、レビュアー視点で見落とされる。**必ず投稿する** +11. 戻り値ファイル書き出し(下記フォーマット)。`summary_comment_url` には + 手順 10 の URL を入れる + +## 戻り値ファイル $TMP_DIR/fix-pr{PR}-result.json + +```json +{{ + "pr": {PR}, + "fix_commit": "abc1234", + "ci_status": "SUCCESS" | "FAILURE" | "PENDING", + "ci_failed_checks": [], + "fixed_count": 6, + "by_severity": {{"critical": 0, "major": 4, "minor": 2, "nit": 0}}, + "resolved_threads": [ + {{"thread_id": "PRRT_...", "comment_id": 123, "path": "...", "line": 42}} + ], + "deferred": [ + {{"thread_id": "...", "path": "...", "line": 31, "severity": "nit", + "summary": "...", "comment_url": "..."}} + ], + "rejected": [ + {{"thread_id": "...", "summary": "...", "reason_for_rejection": "..."}} + ] +}} +``` + +> **重要**: `resolved_threads` / `deferred` / `rejected` は必ず **list(配列)** で返すこと。 +> **件数(int) を書かないこと**(例: `"resolved_threads": 3` は誤り。`[]` 形式で返す)。 +> `state.py merge-fix` がこの list を `len()` して state.json には件数(int)で保存する。 +> int を書くと過去 `merge-fix` が `TypeError` で落ちていた(現在は後方互換で int も受理するが、 +> 正は list)。対応した thread が無い場合は空配列 `[]` を返す。 +""", +) +``` + +### Step 5 後段: fix 戻り値マージ + CI 分類 + +```bash +if "$SCRIPTS/state.py" merge-fix "$STATE_PR"; then + : # exit 0 = continue +elif [ $? -eq 3 ]; then + exit 3 # final=error(コード関連 CI 失敗 or fix 戻り値ファイル欠落) +fi +``` + +`state.py merge-fix` が内部で行う処理: + +1. `$TMP_DIR/fix-pr-result.json` を読んで `state.rounds[-1].fix` にマージ +2. `deferred` を `state.deferred_nits` に追記 +3. **CI 失敗の分類**: + - code-fail (`pint` / `larastan` / `phpstan` / `test` / `lint` / `type` / `build` / `ruff` / `eslint` / `tsc` / `mypy`): `final=error` で中断 (exit 3) + - meta-only (`check_pr_requirements` / `assignees` / `reviewers` / `labels` / `meta`): `ci_note` に記録して継続 + - 不明: 保守的に code-fail 扱い + +**例**: `check_pr_requirements`(Assignees 未設定)はループ継続、 +`laravel/pint` や `phpstan` の失敗は即中断してユーザ判断。 + +## Step 6: PR ローテーション (prepare → Agent → execute の 3 段) + +`rotate-pr.sh` は **light モード (default) と squash モード (opt-in)** を持つ。 +両者ともメインからは `prepare → (light のみ Agent) → execute` の 3 段で呼ぶ。 + +```bash +if "$SCRIPTS/state.py" should-rotate "$STATE_PR"; then + # Step 6a: 旧 PR の素材 dump (title / body / isDraft / git log / git diff --stat) + eval "$("$SCRIPTS/rotate-pr.sh" prepare "$STATE_PR")" + + # Step 6b: light モードのみ。Agent(subagent_type="general-purpose") で + # 現状の差分・実装を反映した新 title/body を生成し、 + # $TMP_DIR/rotate-pr-newtext.json に書き出させる。 + # (squash モードでは Step 6b は不要) + + # Step 6c: 実行 (NEW_PR / NEW_PR_URL / NEW_BRANCH を取り込む) + eval "$("$SCRIPTS/rotate-pr.sh" execute "$STATE_PR" --mode "$ROTATE_MODE")" + + "$SCRIPTS/state.py" set-current-pr "$STATE_PR" "$NEW_PR" + # NOTE: STATE_PR は **絶対に変えない**。次ループの scripts も $STATE_PR で呼ぶ。 +fi +``` + +`should-rotate` は `round_in_pr >= rotate_after && total_rounds < max_rounds` で +exit 0 を返す(rotate 要)。それ以外は exit 2(keep)。 + +### Step 6a: `rotate-pr.sh prepare ` + +state.json から旧 PR / worktree を解決し、以下の素材を 1 つの JSON に dump する: + +```json +{ + "state_pr": 217, + "old_pr": 217, + "old_pr_url": "https://github.com/.../pull/217", + "worktree_path": "/tmp/ndf-worktrees/owner--name/pr217", + "head_branch": "feature/...", + "base_branch": "release/...", + "is_draft": true, + "round_in_pr": 5, + "old_title": "...", + "old_body": "...", + "git_log": "abc1234 メッセージ\n...", + "git_diff_stat": " path/to/file | 12 +-\n ..." +} +``` + +ファイル: `$TMP_DIR/rotate-pr-prepare.json`。 +stdout にも `OLD_PR=` / `HEAD_BRANCH=` / `BASE_BRANCH=` / `IS_DRAFT=` / `PREPARE_JSON=` を出すので +`eval` で取り込める。 + +### Step 6b: Agent (general-purpose) で新 title/body を生成 (light モードのみ) + +メインセッションから以下のように Agent を起動する。プロンプトは +**書いて良いこと / 禁止事項** を必ず明示する +(外側の prompt フェンスは内側に ```json を含むため 4 連バッククォートで囲む): + +````python +Agent( + subagent_type="general-purpose", + description=f"Generate light-rotation PR text for PR #{OLD_PR}", + prompt=f""" +PR rotation の light モードで作成する新 PR の title / body を生成してください。 + +## 素材 +- prepare.json: $TMP_DIR/rotate-pr{STATE_PR}-prepare.json + - 元 PR の title / body / git log $BASE..HEAD / git diff --stat +- 必要なら worktree 内のファイルを直接読んで実装内容を確認してよい + (worktree: {WORKTREE_PATH}) + +## 出力ファイル +$TMP_DIR/rotate-pr{STATE_PR}-newtext.json に JSON で書き出してください: + +```json +{{ + "title": "新 PR の title (元 PR の title をそのままコピーしない。現状の実装を反映)", + "body": "新 PR の body (Markdown)。以下のセクションを含む:\\n## 何のために\\n## 何を\\n## Test plan" +}} +``` + +## 書いて **良い** こと +- 何のために (背景・動機) — 元 PR の背景セクションは再利用可 +- 何を (変更内容) — 現在のブランチの実態を git log / git diff から反映 +- Test plan — 元 PR から継承可 + +## 書いて **はいけない** こと (内部用語の漏洩防止) +- 「round N で〜」「cross-review で〜」「レビュー指摘で〜」 +- 「(rotated)」のような automated suffix +- 「fix された問題」の列挙 / レビューサイクルの存在自体への言及 +- 「旧 PR」「巻き直し」等の rotation 内部用語 + +PR を読む人は cross-review の存在を意識しないため、最終 PR を初めて見る読者向けに +書く。元 title / body をそのままコピーするのは **禁止** (現状の実装を反映)。 +""", +) +```` + +> ⚠ `rotate-pr.sh` 内部から `claude` / `codex` / `gemini` CLI を直接呼んで生成 +> させてはならない (環境依存・コスト管理外)。**メイン側の Agent tool で行う**。 + +### Step 6c: `rotate-pr.sh execute --mode light|squash` + +`--mode` で実際の rotation を実行する: + +| mode | 振る舞い | +|---|---| +| `light` (default) | prepare.json と newtext.json を読み、**同ブランチ・同 base** で旧 PR を close → 新 PR を作成。`is_draft=true` なら新 PR も Draft で作る。title/body は newtext から流す | +| `squash` (opt-in) | `-rHHMMSS` の新ブランチを作って `git reset --soft origin/$BASE` で squash 統合 → 旧 PR close → 新 PR (`(rotated)` suffix + automated body) | + +stdout には両モードとも以下を KEY=VALUE で出す: + +- `NEW_PR=` +- `NEW_PR_URL=` +- `NEW_BRANCH=` (light モードでは元ブランチと同じ) + +`state.py set-current-pr` が `state.json` の `current_pr` / `pr_history` を更新する。 +state.json の **キーは元 PR 番号 (STATE_PR) のまま** なので、light/squash どちらでも +後続スクリプトへの第 1 引数は `$STATE_PR` を渡し続ければよい。 + +### 後方互換: 旧 1 引数形式 + +`rotate-pr.sh ` (引数 1 つ) は `execute --mode squash` 相当として動くが、 +stderr に deprecation warning を出す。新規呼び出しは必ず prepare → execute 形式へ移行。 + +> ⚠ **重要**: state.json のファイル名は **最初に init した PR 番号** がキー +> (`$STATE_PR`)。rotation 後も全 scripts の **第 1 引数には常に `$STATE_PR`** を渡す。 +> 内部的に `state.json.current_pr` を読んで「現在の PR」を解決する設計。 +> `PR=$NEW_PR` 等で shell 変数の側を切り替えると、次ループの `state.py start-round` +> が `$TMP_DIR/cross-review-pr-state.json` を探して `state.json not found` で +> 止まる。 + +## Step 7: 次ラウンドへ + +Step 1 に戻る。 + +## Step 7.5: 最終スイープ(必須)— 取りこぼし防止 + +**目的**: ループを抜けた時点で PR 上に **未解決 (open) のレビュースレッドを 1 件も残さない**。 + +### なぜ必要か + +ループ内の修正フェーズ (Step 5) は「一方でも REQUEST_CHANGES」のラウンドでしか走らない。 +そのため以下が取りこぼされる: + +- **最終 APPROVE ラウンドのインラインコメント**: skill のレビュー方針上、`minor` 以下しか + 無ければ `APPROVE` で良い。つまり **APPROVE でも minor/nit のインラインコメントが + 投稿されている**ことがあり、両者 APPROVE でループを抜けるとこれらが未対応のまま残る。 +- **ループ中に deferred 記録した nit**: `state.deferred_nits` に積まれたまま reply のみで + Resolve されていないスレッド。 + +これらを放置すると、PR レビュー画面に「Unresolved」スレッドが残り、人間のレビュアーや +後続作業者が「未対応の指摘がある」と誤認する。 + +### 実行(メインが Agent を駆動) + +ループを抜けたら(`final` がどの値でも)、**メインが `Agent(subagent_type="general-purpose")` +を起動**し、`/ndf:fix ` を再実行させる。bash 単体では Agent を呼べないため、 +while ループ脱出後にメインが以下のプロンプトでサブエージェントを起動する。 + +> **対象 PR**: ``(state.json の `current_pr`。rotation していれば最新 PR) +> **worktree**: state.json の `worktree_path` +> +> PR の **全 open review thread**(インライン / レビュー body / PR レベルコメント)を +> `gh api` で洗い出し、cross-review の codex/gemini が残したものを中心に **すべて解消**せよ: +> 1. 修正可能な `minor`/`nit` → コード修正 + push(同ブランチ、main へは push しない)し、 +> reply + GraphQL `resolveReviewThread` で Resolve。 +> 2. 修正しない(好み・判断保留)`nit` → 「[deferred / nit] 対応見送り: <理由>」を日本語で +> reply した上で **Resolve まで実行**(スレッドを open のまま残さない)。 +> 3. bot 誤指摘 → 却下理由を reply して Resolve。 +> 修正で push した場合は `claude plugin validate` を通すこと。 +> 完了後、`$TMP_DIR/sweep-pr-result.json` に +> `{"resolved": N, "fixed_in_sweep": M, "commit": "", "remaining_open": K, +> "items": ["<1行要約>", ...]}` を書き出し、最終メッセージで内訳を日本語報告せよ。 +> **`remaining_open` は 0 を目標**とし、0 にできない場合は理由を明記すること。 + +> ⚠ 最終スイープは「修正の追加」ではなく **後始末**。新しい設計変更や大きな +> リファクタは行わない(行う必要があれば deferred として report に残す)。 +> push が走った場合でも、それに対する再レビューはループ終了後のため行わない +> (次回 cross-review か通常レビューに委ねる)。 + +### 再開性 + +sweep 中にメインが落ちても、`sweep-pr-result.json` が無ければ Step 7.5 から +再実行すれば良い(Resolve は冪等。既 Resolve スレッドは skip される)。 + +## Step 8: 終了処理 — ラウンドサマリ + 残 deferred の参考列挙 + +最終スイープ (Step 7.5) 完了後、ラウンドサマリを表示: + +```bash +"$SCRIPTS/state.py" report "$STATE_PR" +``` + +`report` は以下を Markdown で吐く: + +- 最終ステータス(`approved` / `max_rounds` / `oscillation` / `error`) +- PR 履歴 +- ラウンドサマリ表 +- 残 deferred nit 一覧 + +メインは `report` の出力に **Step 7.5 の sweep 結果**(`sweep-pr-result.json` の +`resolved` / `fixed_in_sweep` / `remaining_open`)を折り込んで最終報告する。 + +> **方針変更(v4.11.0)**: 従来は deferred nit を「AskUserQuestion で 1 回問い合わせ」て +> いたが、未解決スレッドを残さない方針に変更。**Step 7.5 で nit も含め全 open thread を +> Resolve する**ため、Step 8 のユーザ問い合わせは原則不要。deferred nit は「対応見送りの +> 記録」として report に **参考列挙**するに留める(再対応が要るものがあればユーザが +> その場で指示できる)。`remaining_open > 0` の場合のみ、残った理由を添えて報告する。 diff --git a/plugins/ndf-claude/skills/cross-review/scripts/_tmpdir.sh b/plugins/ndf-claude/skills/cross-review/scripts/_tmpdir.sh new file mode 100755 index 0000000..8df7371 --- /dev/null +++ b/plugins/ndf-claude/skills/cross-review/scripts/_tmpdir.sh @@ -0,0 +1,23 @@ +# shellcheck shell=bash +# cross-review 共通: tmp ディレクトリ決定ヘルパ。 +# +# Usage: +# . "$(dirname "$0")/_tmpdir.sh" +# TMP_DIR=$(tmpdir) +# +# 優先順位: +# 1. 環境変数 CROSS_REVIEW_TMP_DIR (明示) +# 2. /.cross_review/ (worktree 内。gemini の workspace 制約を根本回避) + +tmpdir() { + if [ -n "${CROSS_REVIEW_TMP_DIR:-}" ]; then + mkdir -p "$CROSS_REVIEW_TMP_DIR" + echo "$CROSS_REVIEW_TMP_DIR" + return + fi + # サブディレクトリから呼ばれた場合でも worktree root を正しく特定する + local root + root="$(git rev-parse --show-toplevel 2>/dev/null)" || root="$PWD" + mkdir -p "$root/.cross_review" + echo "$root/.cross_review" +} diff --git a/plugins/ndf-claude/skills/cross-review/scripts/launch-codex.sh b/plugins/ndf-claude/skills/cross-review/scripts/launch-codex.sh new file mode 100755 index 0000000..2054366 --- /dev/null +++ b/plugins/ndf-claude/skills/cross-review/scripts/launch-codex.sh @@ -0,0 +1,126 @@ +#!/usr/bin/env bash +# cross-review codex launcher. +# +# Usage: launch-codex.sh +# +# 引数 STATE_PR は **state.json の key (= 最初に init した PR 番号)**。 +# rotation 後も state.json の場所は変わらないため、ここに渡すのは常に初期 PR。 +# gh コマンドに使う「現在のレビュー対象 PR」は state.json の `current_pr` を読む。 +# +# tmp ディレクトリは `_tmpdir.sh` の `tmpdir()` 関数で決定: +# CROSS_REVIEW_TMP_DIR env → $PWD/.cross_review/ +# worktree 内に配置することで gemini の workspace 制約を根本回避。 +# codex は --dangerously-bypass-approvals-and-sandbox を維持 +# (-s workspace-write は bwrap 非対応環境で失敗するため)。 +# +# 状態ファイル: $TMP_DIR/codex-review-pr-{result,err,stdout,pid}.json +# (パスは STATE_PR ベースで固定 — monitor.py / state.py と一致させる。) + +set -euo pipefail + +STATE_PR=${1:?STATE_PR required} +ROUND=${2:?ROUND required} + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=_tmpdir.sh +. "$SCRIPT_DIR/_tmpdir.sh" +TMP_DIR=$(tmpdir) + +STATE=$TMP_DIR/cross-review-pr$STATE_PR-state.json +[ -s "$STATE" ] || { echo "state.json not found: $STATE" >&2; exit 1; } + +WORKTREE=$(jq -r '.worktree_path' "$STATE") +REPO=$(jq -r '.repo' "$STATE") +EVENT_DOWNGRADE=$(jq -r '.event_downgrade // false' "$STATE") +EXTRA_REVIEW_INSTRUCTIONS=$(jq -r '.review_instructions // .extra_review_instructions // ""' "$STATE") +# PR (=current_pr) は gh コマンドのレビュー対象 PR 番号として使う。 +# tmp パス側は STATE_PR で固定 (monitor.py / state.py が同じ STATE_PR 起点で +# 読みに来るため、ここを揃えないと PR rotation 後に読み書きパスが食い違う)。 +PR=$(jq -r '.current_pr' "$STATE") +SHA=$(gh pr view "$PR" --json headRefOid -q .headRefOid) + +PROMPT=$TMP_DIR/codex-review-pr$STATE_PR-prompt.md +EXISTING=$TMP_DIR/cross-review-pr$STATE_PR-existing-comments.txt +EXTRA_REVIEW_BLOCK= +if [ -n "$EXTRA_REVIEW_INSTRUCTIONS" ]; then + EXTRA_REVIEW_BLOCK=$(cat < "$PROMPT" <\` には **本来の intent** を書く。 +- 既存コメントスナップショット: $EXISTING (重複指摘禁止) +$EXTRA_REVIEW_BLOCK + +## 出力契約 +- review body の **先頭行** に必ず以下を入れる(fence 不要、Markdown 見出しとして): + \`\`\` + ## 🤖 cross-review | round $ROUND | codex | + \`\`\` + 例: \`## 🤖 cross-review | round $ROUND | codex | REQUEST_CHANGES\` + - \`\` は **本来の intent** (REQUEST_CHANGES / APPROVE / COMMENT) + +### 出力に **含めてはいけないもの**(Resolve 負荷を増やすため) +- ❌ **「良い点」/「Strengths」/「評価できる点」 section** — body にも書かない +- ❌ **対応アクションが無いインラインコメント** — 観察・感想・現状説明だけは禁止 +- ❌ **nit / スタイル指摘のインライン化** — 好みの問題はコメント化しない (無視する) +- ❌ **コード引用 (\`\`\` ... \`\`\`) だけで指摘内容が無いコメント** +- ❌ **\`event=COMMENT\` での雑感投稿** — 直すべき点が無ければ \`APPROVE\` にする + +### インラインコメントの書式 +- \`[重要度 / カテゴリ]\` プレフィックス必須 (例: \`[major / 正確性]\`) +- 重要度は \`critical\` / \`major\` / \`minor\` のみ使う (nit はインライン化しない) +- 本文は **1 コメント = 1 修正アクション** で完結させる。1〜2 文で具体的な修正提案を書く + +### body (総評) の書き方 +- 設計レベル・PR 横断の **修正提案のみ** 書く +- 書くことが無ければ prefix 行 + 1 行サマリだけで良い (褒め言葉や評価文は不要) + +- 投稿後、サマリを **$TMP_DIR/codex-review-pr$STATE_PR-result.json** に書く: + \`\`\`json + { + "event": "REQUEST_CHANGES", + "posted_as": "COMMENT", + "comments_count": 5, + "review_url": "https://github.com/.../pull/$PR#pullrequestreview-...", + "by_severity": {"critical": 0, "major": 3, "minor": 2, "nit": 0} + } + \`\`\` +- payload(全コメント詳細)は **$TMP_DIR/codex-review-pr$STATE_PR-round$ROUND-payload.json** に保存 + (振動検知用、\`{ "comments": [{path, line, body, severity}, ...] }\` 形式) + +## 守るべきこと +- リポジトリ編集は行わない(コード修正は別ステップ) +- worktree 外のパスは触らない +- gh api 失敗時は err.log にエラー詳細を残して即時終了 +EOF + +cd "$WORKTREE" +nohup codex exec --dangerously-bypass-approvals-and-sandbox \ + --config reasoning.effort=medium -C "$WORKTREE" \ + < "$PROMPT" \ + > $TMP_DIR/codex-review-pr$STATE_PR-stdout.log \ + 2> $TMP_DIR/codex-review-pr$STATE_PR-err.log & +echo $! > $TMP_DIR/codex-review-pr$STATE_PR.pid +disown +echo "🚀 codex launched (pid=$(cat $TMP_DIR/codex-review-pr$STATE_PR.pid))" >&2 diff --git a/plugins/ndf-claude/skills/cross-review/scripts/launch-gemini.sh b/plugins/ndf-claude/skills/cross-review/scripts/launch-gemini.sh new file mode 100755 index 0000000..e0b921e --- /dev/null +++ b/plugins/ndf-claude/skills/cross-review/scripts/launch-gemini.sh @@ -0,0 +1,193 @@ +#!/usr/bin/env bash +# cross-review gemini launcher (trusted directory 対策込み). +# +# Usage: launch-gemini.sh +# +# 引数 STATE_PR は state.json の key (= 最初に init した PR 番号)。 +# レビュー対象の PR は state.json の `current_pr` を読む。 +# +# 注意: +# - worktree のような新規パスは untrusted 判定で --yolo が "default" に降格する。 +# `--skip-trust` と `GEMINI_CLI_TRUST_WORKSPACE=true` を **両方** 必須とする。 +# - 完了判定は monitor.py が pidfile + sentinel + result.json で多軸判定する。 + +set -euo pipefail + +STATE_PR=${1:?STATE_PR required} +ROUND=${2:?ROUND required} + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=_tmpdir.sh +. "$SCRIPT_DIR/_tmpdir.sh" +TMP_DIR=$(tmpdir) + +STATE=$TMP_DIR/cross-review-pr$STATE_PR-state.json +[ -s "$STATE" ] || { echo "state.json not found: $STATE" >&2; exit 1; } + +WORKTREE=$(jq -r '.worktree_path' "$STATE") +REPO=$(jq -r '.repo' "$STATE") +EVENT_DOWNGRADE=$(jq -r '.event_downgrade // false' "$STATE") +EXTRA_REVIEW_INSTRUCTIONS=$(jq -r '.review_instructions // .extra_review_instructions // ""' "$STATE") +# PR (=current_pr) は gh コマンドのレビュー対象 PR 番号として使う。 +# tmp パス側は STATE_PR で固定 (monitor.py / state.py との読み書き整合のため)。 +PR=$(jq -r '.current_pr' "$STATE") +SHA=$(gh pr view "$PR" --json headRefOid -q .headRefOid) + +PROMPT=$TMP_DIR/gemini-review-pr$STATE_PR-prompt.md +# 既存コメントは **プロンプトにインライン埋め込み** する。 +# tmp dir は `/.cross_review/` を使うが、念のため +# プロンプト埋め込み方式も維持 (gemini が read_file を呼ばずに済むので確実)。 +EXISTING_FILE=$TMP_DIR/cross-review-pr$STATE_PR-existing-comments.txt +if [ -s "$EXISTING_FILE" ]; then + EXISTING_INLINE=$(cat "$EXISTING_FILE") +else + EXISTING_INLINE="(なし)" +fi +EXTRA_REVIEW_BLOCK= +if [ -n "$EXTRA_REVIEW_INSTRUCTIONS" ]; then + EXTRA_REVIEW_BLOCK=$(cat < "$PROMPT" <\` は本来の intent を書く。 + +## 既存コメントスナップショット(重複指摘禁止) +workspace 外を読まなくて済むよう、以下にインライン展開する: + +\`\`\` +$EXISTING_INLINE +\`\`\` +$EXTRA_REVIEW_BLOCK + +## 出力契約 +- review body の **先頭行** に必ず以下を入れる: + \`\`\` + ## 🤖 cross-review | round $ROUND | gemini | + \`\`\` + - \`\` は **本来の intent** (REQUEST_CHANGES / APPROVE / COMMENT) + +### 出力に **含めてはいけないもの**(Resolve 負荷を増やすため) +- ❌ **「良い点」/「Strengths」/「評価できる点」 section** — body にも書かない +- ❌ **対応アクションが無いインラインコメント** — 観察・感想・現状説明だけは禁止 +- ❌ **nit / スタイル指摘のインライン化** — 好みの問題はコメント化しない (無視する) +- ❌ **コード引用 (\`\`\` ... \`\`\`) だけで指摘内容が無いコメント** +- ❌ **\`event=COMMENT\` での雑感投稿** — 直すべき点が無ければ \`APPROVE\` にする + +### インラインコメントの書式 +- \`[重要度 / カテゴリ]\` プレフィックス必須 (例: \`[major / 正確性]\`) +- 重要度は \`critical\` / \`major\` / \`minor\` のみ使う (nit はインライン化しない) +- 本文は **1 コメント = 1 修正アクション** で完結させる。1〜2 文で具体的な修正提案を書く + +### body (総評) の書き方 +- 設計レベル・PR 横断の **修正提案のみ** 書く +- 書くことが無ければ prefix 行 + 1 行サマリだけで良い (褒め言葉や評価文は不要) + +### 進捗マーカー(監視用) +- 無言ハングと区別できるよう、作業フェーズが進むたびに + **$TMP_DIR/gemini-review-pr$STATE_PR-progress.log** へ短い 1 行を追記すること +- 内部の推論や長い説明は書かず、以下のようなフェーズ名 + 対象だけを書く: + - \`start: review PR #$PR round $ROUND\` + - \`scan: diff and existing comments\` + - \`analyze: candidate findings\` + - \`post: submit review\` + - \`done: result.json written\` + +- 投稿後、サマリを **$TMP_DIR/gemini-review-pr$STATE_PR-result.json** に + **必ず以下のキーで** 書く: + \`\`\`json + { + "event": "APPROVE", + "posted_as": "COMMENT", + "comments_count": 3, + "review_url": "https://github.com/.../pull/$PR#pullrequestreview-...", + "by_severity": {"critical": 0, "major": 0, "minor": 0, "nit": 0} + } + \`\`\` + - \`intent\` / \`comment_count\` 等の別名は使わないこと + - \`event\` の値は \`APPROVE\` / \`REQUEST_CHANGES\` / \`COMMENT\` のいずれか + - \`event_downgrade=true\` のとき \`posted_as\` は \`COMMENT\` にダウングレード可 +- payload は **$TMP_DIR/gemini-review-pr$STATE_PR-round$ROUND-payload.json** に保存 + (振動検知用、\`{ "comments": [{path, line, body, severity}, ...] }\` 形式) + +## 守るべきこと +- **リポジトリ編集禁止**。gh api での投稿のみ許可 +- worktree 外のパスは触らない +- gh api 失敗時は err.log にエラー詳細を残して即時終了 +EOF + +cd "$WORKTREE" + +# gemini-cli 最新版は mcpServers エントリの `disabled` キーを Unrecognized 扱いし、 +# 起動時に `Error in: mcpServers.` 警告を err.log に出す。文字列としては +# `Error: ...` ではなく `Error in: ...` だが、monitor.py 旧版が誤検知して +# プロセスを kill する原因になっていた (REPORT01 参照)。 +# launcher 側でも sanitize して警告自体を抑制する: `disabled` キーを再帰的に +# 削除した settings.json を起動時のみ差し込み、gemini が読み終わったら復元する。 +SETTINGS=$WORKTREE/.gemini/settings.json +SETTINGS_BACKUP= + +# trap で EXIT / INT / TERM / HUP のいずれでも必ず settings.json を復元する。 +# sleep 2 中や復元前に Ctrl-C / SIGTERM / シェル終了で止まっても、sanitize 済み +# settings.json が worktree に残らないようにするため。冪等に書いてあるので +# 多重実行されても安全。 +restore_settings() { + # SETTINGS_BACKUP が未設定 or バックアップ不在なら何もしない + if [ -n "${SETTINGS_BACKUP:-}" ] && [ -f "$SETTINGS_BACKUP" ]; then + mv -f "$SETTINGS_BACKUP" "$SETTINGS" 2>/dev/null || true + SETTINGS_BACKUP= + fi +} +trap restore_settings EXIT INT TERM HUP + +if [ -f "$SETTINGS" ] && command -v jq >/dev/null 2>&1; then + SETTINGS_BACKUP=$TMP_DIR/gemini-review-pr$STATE_PR-settings-backup.json + cp "$SETTINGS" "$SETTINGS_BACKUP" + SANITIZED=$TMP_DIR/gemini-review-pr$STATE_PR-settings-sanitized.json + if jq 'walk(if type == "object" then del(.disabled) else . end)' "$SETTINGS_BACKUP" > "$SANITIZED" 2>/dev/null; then + cp "$SANITIZED" "$SETTINGS" + else + # jq が失敗したら sanitize を諦め、バックアップも破棄して元のまま起動 + rm -f "$SETTINGS_BACKUP" + SETTINGS_BACKUP= + fi +fi + +# ⚠ --skip-trust と GEMINI_CLI_TRUST_WORKSPACE=true は両方必須 +GEMINI_CLI_TRUST_WORKSPACE=true nohup gemini --yolo --skip-trust --output-format text \ + -p "" \ + > $TMP_DIR/gemini-review-pr$STATE_PR-stdout.log \ + 2> $TMP_DIR/gemini-review-pr$STATE_PR-err.log < "$PROMPT" & +GEMINI_PID=$! +echo $GEMINI_PID > $TMP_DIR/gemini-review-pr$STATE_PR.pid +disown + +# gemini は起動時に 1 度 settings.json を読む。読み込み完了を待ってから元の +# ファイルを復元する (worktree を dirty なままにしないため)。 +# sleep 中に signal が来ても trap restore_settings が必ず復元するため安全。 +if [ -n "$SETTINGS_BACKUP" ] && [ -f "$SETTINGS_BACKUP" ]; then + sleep 2 + restore_settings +fi + +echo "🚀 gemini launched (pid=$GEMINI_PID)" >&2 diff --git a/plugins/ndf-claude/skills/cross-review/scripts/monitor.py b/plugins/ndf-claude/skills/cross-review/scripts/monitor.py new file mode 100755 index 0000000..544a912 --- /dev/null +++ b/plugins/ndf-claude/skills/cross-review/scripts/monitor.py @@ -0,0 +1,806 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.10" +# dependencies = [] +# /// +"""cross-review codex/gemini プロセス監視 CLI。 + +`launch-codex.sh` / `launch-gemini.sh` で起動したバックグラウンドプロセスを +**複数の根拠で多重監視** し、失敗パターン (sentinel 不在 / 早期エラー / ハング / +pidfile stale / result.json 不在) を構造化して扱う。 + +監視軸: + 1. **pidfile** + `kill -0` でプロセス生存確認 + - 可能なら `/proc//cmdline` で codex/gemini であることを再確認 (PID 再利用対策) + 2. **sentinel** (codex のみ): err.log に `^tokens used$` 出現 + 3. **early-error pattern**: err.log に既知の致命的キーワードが出たら即中断 + - **FATAL** (auth/quota/sandbox 等の明確な致命): 検知時に kill + - **WARN** (生の `Error:` / `Traceback` 等の曖昧パターン): 警告ログのみ、kill せず通常判定を継続 + - `--no-early-error` / `MONITOR_NO_EARLY_ERROR=1` で検知自体を無効化可 + 4. **result.json**: プロセス終了後に `/.cross_review/-review-pr-result.json` が + 生成されていなければ失敗扱い + 5. **hard timeout**: 既定 7 分。`--timeout` または `MONITOR_TIMEOUT` で上書き可 + 6. **stall timeout**: err.log + stdout.log の合計サイズが一定時間変化しなければ + STALLED として中断。既定は agent 別 (codex=180s, gemini=480s。gemini は err.log + にほぼ進捗を出さないため大きめ)。`--stall-timeout` で CLI 明示、 + `MONITOR_STALL_` env で per-agent 上書き、`MONITOR_STALL` env で共通上書き可 + 7. **progress.log heartbeat**: agent が任意で書く短いフェーズマーカーを stderr に表示。 + Gemini の stdout/stderr が静かな時間でも、内部推論ではなく監視用の作業段階を確認できる + 8. **result.json + age fallback**: sentinel を持たない agent (gemini) 向け。 + result.json の mtime が 30 秒以上前なら完了とみなし kill → OK + 9. **失敗時 kill**: TIMEOUT / STALLED / EARLY_ERROR (FATAL のみ) / PIDFILE_BAD で + 返るとき、対象プロセスを SIGTERM (3 秒後に SIGKILL) で停止する + +Usage: + monitor.py target ∈ {codex, gemini, both} + monitor.py both --timeout 1200 --stall-timeout 600 + monitor.py both --no-early-error # EARLY_ERROR 検知を完全無効化 + +Exit codes (target=both は最悪値を返す): + 0 OK プロセス正常終了 + result.json 確認 + 1 USAGE / IO error + 2 TIMEOUT hard timeout 超過 + 3 NO_RESULT プロセス終了したが result.json 未生成 + 4 EARLY_ERROR err.log に致命的パターン検出 + 5 STALLED err.log が一定時間進捗なし + 6 PIDFILE_BAD pidfile が無い / 内容が不正 / プロセスが起動していない + +Stdout: 各 agent の最終ステータスを JSON で 1 行ずつ吐く(メインがパース可能)。 +Stderr: 人間向けの進捗ログ(poll ごとに 1 行)。 +""" +from __future__ import annotations + +import argparse +import json +import os +import pathlib +import re +import signal +import sys +import threading +import time +from dataclasses import dataclass +from typing import Optional + + +# ---------- 設定 ---------- + +# 既定値は import 時に **固定数値** で保持する。env (`MONITOR_TIMEOUT` / +# `MONITOR_STALL` / `MONITOR_POLL`) の解釈は **呼び出し時** に try/except +# 付きで行い、非数値 env でも import / 監視プロセスがクラッシュしないようにする。 +# (codex round 5 指摘: import 時の `int(os.environ.get(...))` は +# `MONITOR_STALL=abc` のような誤設定で `_agent_stall_default()` に到達する前に +# ValueError で落ちてしまうため) +DEFAULT_TIMEOUT = 420 # 7 min — `--timeout` / env `MONITOR_TIMEOUT` で上書き可 +# 既定 stall timeout (後方互換のため env MONITOR_STALL は残す)。 +# 両 agent 共通のデフォルトとして引き続き受け付ける (解釈は `_agent_stall_default()` 内)。 +DEFAULT_STALL = 180 # 3 min no progress +# per-agent 上書き: gemini は err.log がほぼ無音なため大きめに取る。 +# 解決順は `_agent_stall_default()` 参照。 +DEFAULT_STALL_AGENT_BUILTIN = { + "codex": 180, # 推論ログを逐次出すので 3 min で十分 + "gemini": 480, # err.log が静かなため 8 min まで許容 +} +DEFAULT_POLL = 15 # 15 sec — env `MONITOR_POLL` で上書き可 +# result.json が書き込まれた後もプロセスがハングするケース (gemini で観測) の +# fallback: mtime から RESULT_AGE_GRACE 秒以上経過していれば完了とみなす。 +RESULT_AGE_GRACE = 30 +# `MONITOR_NO_EARLY_ERROR=1` で EARLY_ERROR 検知を無効化 (escape hatch) +DEFAULT_NO_EARLY_ERROR = os.environ.get("MONITOR_NO_EARLY_ERROR", "").lower() in { + "1", "true", "yes", "on", +} + +# err.log の行頭に近い形で出る **明確な致命** パターン (kill 対象)。 +# auth / quota / sandbox / HTTP 401-403-429 / gemini の YOLO 降格など、 +# プロセスが続行しても result を生成できないと判明しているケースだけを入れる。 +EARLY_ERROR_FATAL = [ + # HTTP エラーステータス行 (`HTTP/1.1 401 Unauthorized` 等) + re.compile(r"^HTTP/\d\S* (?:401|403|429) ", re.MULTILINE), + # gemini 固有: untrusted directory で YOLO が降格される + re.compile(r'^Approval mode overridden to "default"', re.MULTILINE), + # 認証 / 権限系(行頭限定) + re.compile(r"^(?:Authentication failed|Permission denied)", re.MULTILINE), + # quota / rate limit (`m.start()` をキーワード位置に合わせるため `^.*` を付けない。 + # `_match_is_quoted()` が backtick / 「」 引用を判定するために match 開始位置を使うため) + re.compile(r"\b(?:quota exceeded|rate limit exceeded)\b", re.IGNORECASE), + # API key 系 + re.compile(r"\bAPI key (?:not found|missing|invalid)\b", re.IGNORECASE), + # codex 固有: sandbox エラー + re.compile(r"\bsandbox error\b", re.IGNORECASE), +] + +# 行頭の生 `Error:` / `Traceback` 系は **kill しない警告のみ** に降格。 +# - gemini-cli の `Error in: mcpServers.` 警告 (起動時の config 検証) +# - codex がレビュー対象 diff の test コード片を echo して `Traceback` が混入するケース +# など、続行可能な誤検知が頻発するため。プロセスは sentinel / result.json / timeout +# で別途判定する。 +EARLY_ERROR_WARN = [ + re.compile(r"^(?:Error|FATAL|fatal|panic|PANIC|Traceback)[: ]", re.MULTILINE), +] + +# 文脈に含まれていたら benign(doc 引用 / コードレビューコメント等)と見なし誤検知扱い。 +# FATAL / WARN 双方のスキャンに適用される。 +EARLY_ERROR_BENIGN = [ + # gemini-cli の config validation 警告 (`Error in: mcpServers.` / `Error in: ...`): + # 設定の Unrecognized キーを通知するだけで、gemini 本体は起動継続する。 + re.compile(r"^Error in: mcpServers\.", re.MULTILINE), + re.compile(r"^Error in: \S+\s*$", re.MULTILINE), + # diff のコンテキスト行 (` `, `+`, `-` で始まり、その後 markdown 表記が来る) + re.compile(r"^[ +-].*[\|`]", re.MULTILINE), + # markdown のリスト / 引用 + re.compile(r"^\s*[-*>]\s", re.MULTILINE), + # markdown の表セル行 (`| ... | ...` 形式)。SKILL.md / docs/*.md が + # 検知パターンを表で列挙しており、それを codex が echo すると誤検知する。 + re.compile(r"^\|", re.MULTILINE), + # grep / ripgrep 形式のソース引用行 (`path/to/file.ext:42: `)。 + # codex がレビュー対象のテストコード片を grep 形式で echo すると、 + # その文字列リテラル内の FATAL キーワードを誤検知する (PR #23 round 2 で発生)。 + re.compile(r"^\S+\.[A-Za-z0-9]+:\d+:", re.MULTILINE), + # warning は致命ではない + re.compile(r"^warning: ", re.IGNORECASE | re.MULTILINE), +] + + +def _match_is_quoted(line: str, match_start: int, match_end: int) -> bool: + """マッチ位置がドキュメント引用 / コード文字列リテラルに囲まれているか判定。 + + - backtick: マッチ開始までの `` ` `` カウントが奇数 かつ マッチ終了以降に `` ` `` がある + - 日本語クォート: マッチ開始までに直近の `「` が `」` よりも後 かつ マッチ終了以降に `」` がある + - ダブル/シングルクォート文字列リテラル: マッチ開始までの `"` (or `'`) カウントが + 奇数 かつ マッチ終了以降に同じクォートがある (= リテラルの内側) + + Why: SKILL.md / docs/*.md 内で FATAL キーワードを `「quota exceeded」` のように + 引用列挙しており、codex がそれを echo する。さらに tests/*.py の + `"quota exceeded: please upgrade"` のような **テスト用文字列リテラル** を + codex がレビュー中に echo するケース (PR #23 round 2 で実際に発生) もある。 + いずれも引用形であり本物のエラーではないため benign 扱いする。 + """ + before = line[:match_start] + after = line[match_end:] + if before.count("`") % 2 == 1 and "`" in after: + return True + if before.rfind("「") > before.rfind("」") and "」" in after: + return True + # コード文字列リテラル (ダブル / シングルクォート)。 + # エスケープされたクォート (`\"` / `\'`) はリテラルを開閉しないため + # パリティ計算から除外する。これを数えると、文字列内にエスケープ + # クォートを含む行で「引用内/外」の判定がずれ、本物のエラー行を + # 誤って benign 扱い (= FATAL 見逃し) する恐れがある。 + for q in ('"', "'"): + if _unescaped_count(before, q) % 2 == 1 and q in after: + return True + return False + + +def _unescaped_count(text: str, quote: str) -> int: + """`quote` のうちバックスラッシュでエスケープされていない出現数を数える。 + + 直前の連続バックスラッシュ数が奇数なら、そのクォートはエスケープ + されている (リテラルを開閉しない) ものとして除外する。 + """ + count = 0 + for i, ch in enumerate(text): + if ch != quote: + continue + backslashes = 0 + j = i - 1 + while j >= 0 and text[j] == "\\": + backslashes += 1 + j -= 1 + if backslashes % 2 == 0: + count += 1 + return count + +CODEX_SENTINEL = re.compile(r"^tokens used$", re.MULTILINE) + + +def _safe_int_env(name: str, fallback: int) -> int: + """env を safe に int parse する。 + + 非数値時は warn を stderr に出して fallback 値を返す。 + `_agent_stall_default()` と同じく、env 設定ミスでプロセスを落とさないため。 + + Note (codex round 5 指摘): `MONITOR_STALL` 等の env を import 時 / 呼び出し時に + 生の `int(...)` で読むと、非数値設定で監視プロセスが起動できなくなる。 + `DEFAULT_TIMEOUT` / `DEFAULT_POLL` も同じ問題を持つため、共通ヘルパに集約。 + """ + if name not in os.environ: + return fallback + raw = os.environ[name] + try: + return int(raw) + except (ValueError, TypeError): + print( + f"⚠ env {name}={raw!r} が int に変換できません — {fallback} を使用", + file=sys.stderr, flush=True, + ) + return fallback + + +def _agent_stall_default(agent: str) -> int: + """agent ごとの既定 stall timeout を解決する。 + + 解決順: + 1. env `MONITOR_STALL_` (per-agent 明示) + 2. env `MONITOR_STALL` (両 agent 共通) + 3. `DEFAULT_STALL_AGENT_BUILTIN[agent]` (codex=180, gemini=480) + 4. `DEFAULT_STALL` (フォールバック) + + Note (codex round 3 指摘): 2 は **呼び出し時** に `os.environ["MONITOR_STALL"]` + を再評価する。`DEFAULT_STALL` モジュール定数は import 時に env を読むため + プロセス起動後の env 変更に追随できず、テストの monkeypatch も効かない。 + + Note (gemini round 4 指摘): env が非数値だった場合 (`int(...)` で + `ValueError` / `TypeError` が裸で上がる) は warn を出して + `DEFAULT_STALL_AGENT_BUILTIN` / `DEFAULT_STALL` にフォールバックする。 + 監視プロセスを env 設定ミスでクラッシュさせない。 + """ + builtin = DEFAULT_STALL_AGENT_BUILTIN.get(agent, DEFAULT_STALL) + env_key = f"MONITOR_STALL_{agent.upper()}" + if env_key in os.environ: + return _safe_int_env(env_key, builtin) + if "MONITOR_STALL" in os.environ: + return _safe_int_env("MONITOR_STALL", builtin) + return builtin + + +def _tmp_dir() -> pathlib.Path: + """cross-review 用 tmp ディレクトリ。 + + state.py の `_tmp_dir()` と同じロジック。優先: + 1. `CROSS_REVIEW_TMP_DIR` env + 2. `/.cross_review/` (worktree 内。gemini の workspace 制約を根本回避) + + worktree root は `git rev-parse --show-toplevel` で取得する。 + サブディレクトリから起動した場合でも一貫したパスを返す。 + """ + env = os.environ.get("CROSS_REVIEW_TMP_DIR") + if env: + d = pathlib.Path(env).resolve() + d.mkdir(parents=True, exist_ok=True) + return d + # git worktree root を取得。サブディレクトリから起動しても一貫したパスにする。 + import subprocess as _sp + r = _sp.run( + ["git", "rev-parse", "--show-toplevel"], + capture_output=True, text=True, + ) + if r.returncode == 0 and r.stdout.strip(): + root = pathlib.Path(r.stdout.strip()).resolve() + else: + root = pathlib.Path.cwd().resolve() + d = root / ".cross_review" + d.mkdir(parents=True, exist_ok=True) + return d + + +# ---------- データ型 ---------- + +@dataclass +class AgentPaths: + agent: str + pr: int + pidfile: pathlib.Path + err_log: pathlib.Path + stdout_log: pathlib.Path + progress_log: pathlib.Path + result: pathlib.Path + + @classmethod + def for_(cls, agent: str, pr: int) -> "AgentPaths": + base = _tmp_dir() / f"{agent}-review-pr{pr}" + return cls( + agent=agent, pr=pr, + pidfile=pathlib.Path(f"{base}.pid"), + err_log=pathlib.Path(f"{base}-err.log"), + stdout_log=pathlib.Path(f"{base}-stdout.log"), + progress_log=pathlib.Path(f"{base}-progress.log"), + result=pathlib.Path(f"{base}-result.json"), + ) + + +@dataclass +class AgentStatus: + agent: str + status: str = "RUNNING" + exit_code: int = 0 + pid: Optional[int] = None + elapsed: float = 0.0 + detail: str = "" + err_log_size: int = 0 + stdout_log_size: int = 0 + progress_log_size: int = 0 + progress_tail: str = "" + idle_seconds: float = 0.0 + result_exists: bool = False + sentinel_seen: bool = False + + +# ---------- 監視ロジック ---------- + +def _read_pidfile(p: pathlib.Path) -> Optional[int]: + try: + s = p.read_text().strip() + return int(s) if s else None + except (FileNotFoundError, ValueError): + return None + + +def _pid_alive(pid: int) -> bool: + """`kill -0` + ゾンビ検出。 + + `os.kill(pid, 0)` はゾンビプロセスに対しても成功する (PID エントリが + 残っているため)。Docker without `--init` 環境では orphan プロセスが + ゾンビ化して永久に残るため、`/proc//status` で State: Z を検出する。 + """ + try: + os.kill(pid, 0) + except (ProcessLookupError, PermissionError): + return False + except OSError: + return False + try: + status_text = pathlib.Path(f"/proc/{pid}/status").read_text() + for line in status_text.splitlines(): + if line.startswith("State:"): + return "Z" not in line + except (FileNotFoundError, PermissionError, OSError): + pass + return True + + +def _is_zombie(pid: int) -> bool: + """PID がゾンビかどうか。_pid_alive() とは独立に呼べるユーティリティ。""" + try: + status_text = pathlib.Path(f"/proc/{pid}/status").read_text() + for line in status_text.splitlines(): + if line.startswith("State:"): + return "Z" in line + except (FileNotFoundError, PermissionError, OSError): + pass + return False + + +def _kill_pid(pid: int, sigterm_grace: float = 3.0) -> None: + """対象プロセスに SIGTERM、`sigterm_grace` 秒後も生きていたら SIGKILL。 + + TIMEOUT / STALLED / EARLY_ERROR で監視を打ち切るとき、対象プロセスが残ったまま + だと後から `gh api` 投稿や result.json 書き込みを実行してメインフローと + 競合する。失敗扱いで返るときは必ず停止させる。 + ゾンビプロセスにはシグナルを送れないためスキップする。 + """ + if pid <= 0: + return + if _is_zombie(pid): + return + try: + os.kill(pid, signal.SIGTERM) + except OSError: + return + deadline = time.monotonic() + sigterm_grace + while time.monotonic() < deadline: + if not _pid_alive(pid): + return + time.sleep(0.5) + try: + os.kill(pid, signal.SIGKILL) + except OSError: + pass + + +def _pid_cmdline_matches(pid: int, expected: str) -> Optional[bool]: + """`/proc//cmdline` を読んで `expected` を含むか。 + + /proc が読めない環境では None を返す(PID 再利用チェック非対応)。 + """ + try: + cmdline = pathlib.Path(f"/proc/{pid}/cmdline").read_text() + return expected.lower() in cmdline.lower() + except (FileNotFoundError, PermissionError, OSError): + return None + + +def _scan_patterns( + path: pathlib.Path, patterns: list[re.Pattern[str]] +) -> Optional[str]: + """err.log を末尾 200KB だけ読み、`patterns` の最初の **non-benign** ヒット行を返す。 + + BENIGN フィルタは **マッチ行そのもの** に対して適用し、誤検知 (markdown 引用 / + diff body / gemini の config validation 警告) を除外する。 + + 重要 1: `pat.search()` ではなく `pat.finditer()` を使い、benign で除外された場合も + 後続の一致を継続して走査する。これにより benign な先行ヒットの後ろにある本物の + エラーを見逃さない。 + + 重要 2: benign 判定は「マッチ行」だけを対象にする。以前は前後 40 文字の文脈窓を + 使っていたが、それだと benign 行が直前にあるだけで後続の本物エラーを誤って benign + 扱いしてしまった (例: `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: + return None + + for pat in patterns: + for m in pat.finditer(data): + line_start = data.rfind("\n", 0, m.start()) + 1 + line_end = data.find("\n", m.end()) + line_end = line_end if line_end != -1 else len(data) + line = data[line_start:line_end] + # benign パターンはマッチ行そのものに当てる。markdown 引用や + # `Error in: mcpServers.X` のような行単位パターンは「その行」だけを + # 評価すれば判定可能で、文脈窓を広げると誤判定の原因になる。 + if any(b.search(line) for b in EARLY_ERROR_BENIGN): + continue + # マッチ部位が backtick / 日本語「」 で引用されている場合も benign。 + if _match_is_quoted(line, m.start() - line_start, m.end() - line_start): + continue + return line.strip() + return None + + +def _scan_early_fatal(path: pathlib.Path) -> Optional[str]: + return _scan_patterns(path, EARLY_ERROR_FATAL) + + +def _scan_early_warn(path: pathlib.Path) -> Optional[str]: + return _scan_patterns(path, EARLY_ERROR_WARN) + + +def _scan_codex_sentinel(path: pathlib.Path) -> bool: + if not path.exists(): + return False + 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") + except OSError: + return False + return bool(CODEX_SENTINEL.search(tail)) + + +def _safe_size(path: pathlib.Path) -> int: + try: + return path.stat().st_size if path.exists() else 0 + except OSError: + return 0 + + +def _tail_last_nonempty_line(path: pathlib.Path, limit: int = 4096) -> str: + """監視用 progress.log の末尾 1 行を安全に読む。 + + 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: + return "" + if sz > limit and "\n" in data: + data = data.split("\n", 1)[1] + for line in reversed(data.splitlines()): + stripped = line.strip() + if stripped: + return stripped[:200] + return "" + + +def monitor_agent( + agent: str, + pr: int, + timeout: int, + stall_timeout: int, + poll: int, + require_result: bool, + no_early_error: bool = False, + log_prefix: str = "", +) -> AgentStatus: + """1 agent を監視する。 + + `no_early_error=True` のとき、EARLY_ERROR 検知 (FATAL/WARN とも) を完全に無効化し、 + hard timeout / stall / sentinel / result.json のみで判定する。 + """ + paths = AgentPaths.for_(agent, pr) + 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) + 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 + # cmdline 検証 (PID 再利用対策) は **プロセスが生きていると確認できたときのみ** 行う。 + # 起動直後に既にプロセスが exit していると /proc/ が消えるか、別プロセスに + # 再利用されている可能性があり、ここで PIDFILE_BAD を返すと「完了している(result.json は出ている)」 + # ケースを誤って失敗にしてしまう。alive=True と確認した瞬間のみ cmdline 一致を検証する。 + + started_wall = time.time() + last_progress_size = ( + _safe_size(paths.err_log) + + _safe_size(paths.stdout_log) + + _safe_size(paths.progress_log) + ) + last_progress = time.monotonic() + cmdline_validated = False + warned_early_error = False + + while True: + elapsed = time.monotonic() - started + status.elapsed = elapsed + + # 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 + + # result.json が書かれた後もプロセスがハング��るケース (gemini で観測: + # MCP サーバー切断待ち等��� exit しない)。sentinel 機構を持たない agent 向け + # の fallback: result.json の mtime が RESULT_AGE_GRACE 秒以上前であれば + # 完了とみなし、プロセスを kill → OK。 + # 安全条件: + # - cmdline_validated: PID 再利用でない (または検証不能環境) ことを確認済み + # - mtime >= started_wall: 前 round の stale result.json を拾わない + if ( + alive + and not 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 扱いになり判定不能のため。 + cmdline_ok = _pid_cmdline_matches(pid, agent) + if cmdline_ok is False: + _kill_pid(pid) + status.status = "PIDFILE_BAD" + status.exit_code = 6 + status.detail = f"pid {pid} cmdline does not contain '{agent}' (stale pidfile?)" + _emit_log(log_prefix, agent, status) + return status + if cmdline_ok is True: + cmdline_validated = True + + # 2. hard timeout + if elapsed >= timeout: + if alive: + _kill_pid(pid) + status.status = "TIMEOUT" + status.exit_code = 2 + status.detail = f"hard timeout {timeout}s reached (pid {pid})" + _emit_log(log_prefix, agent, status) + return status + + # 3. early error + # 明確な致命 (FATAL) のみ kill する。曖昧パターン (生 Error: / Traceback) は + # WARN として警告ログのみ。codex がレビュー対象 diff の test コード片を + # echo するケースや gemini の config validation 警告で誤 kill されるのを防ぐ。 + if not no_early_error: + fatal_err = _scan_early_fatal(paths.err_log) + if fatal_err: + if alive: + _kill_pid(pid) + status.status = "EARLY_ERROR" + status.exit_code = 4 + status.detail = f"early error (fatal) in err.log: {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 を確認 + 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 には + # 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 + ) + 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) + time.sleep(poll) + + +def _emit_progress(prefix: str, agent: str, st: AgentStatus) -> None: + progress = f" progress={st.progress_tail!r}" if st.progress_tail else "" + print( + f"{prefix}⏳ {agent} elapsed={st.elapsed:.0f}s pid={st.pid} " + f"idle={st.idle_seconds:.0f}s " + f"err={st.err_log_size}B stdout={st.stdout_log_size}B " + f"progress_log={st.progress_log_size}B " + f"sentinel={'Y' if st.sentinel_seen else '-'}{progress}", + file=sys.stderr, flush=True, + ) + + +def _emit_log(prefix: str, agent: str, st: AgentStatus) -> None: + icon = { + "OK": "✅", "TIMEOUT": "⏰", "NO_RESULT": "❌", + "EARLY_ERROR": "💥", "STALLED": "🛑", "PIDFILE_BAD": "❓", + }.get(st.status, "?") + print( + f"{prefix}{icon} {agent} {st.status} ({st.elapsed:.0f}s) — {st.detail}", + file=sys.stderr, flush=True, + ) + + +# ---------- CLI ---------- + +def main() -> None: + p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + p.add_argument("pr", type=int) + p.add_argument("target", choices=["codex", "gemini", "both"]) + # env (MONITOR_TIMEOUT / MONITOR_POLL) は呼び出し時に safe parse で読む。 + # 非数値設定でも fixed default (`DEFAULT_TIMEOUT` / `DEFAULT_POLL`) に戻す。 + timeout_default = _safe_int_env("MONITOR_TIMEOUT", DEFAULT_TIMEOUT) + poll_default = _safe_int_env("MONITOR_POLL", DEFAULT_POLL) + p.add_argument("--timeout", type=int, default=timeout_default, + help=f"hard timeout in seconds (default: {timeout_default})") + p.add_argument("--stall-timeout", type=int, default=None, + help="stall timeout (err.log no progress) in seconds. " + "未指定時は agent 別既定 (codex=180, gemini=480) または " + "env MONITOR_STALL_ / MONITOR_STALL を参照") + p.add_argument("--poll", type=int, default=poll_default, + help=f"poll interval in seconds (default: {poll_default})") + p.add_argument("--no-require-result", action="store_true", + help="プロセス終了後に result.json が無くても OK 扱い") + p.add_argument("--no-early-error", action="store_true", + default=DEFAULT_NO_EARLY_ERROR, + help="EARLY_ERROR 検知を無効化 " + "(hard timeout / stall / sentinel / result.json のみで判定) " + f"[env: MONITOR_NO_EARLY_ERROR; default: {DEFAULT_NO_EARLY_ERROR}]") + args = p.parse_args() + + agents = ["codex", "gemini"] if args.target == "both" else [args.target] + require_result = not args.no_require_result + + results: dict[str, AgentStatus] = {} + + def run(agent: str) -> None: + stall = args.stall_timeout if args.stall_timeout is not 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}] ", + ) + + threads = [threading.Thread(target=run, args=(a,), daemon=False) for a in agents] + for t in threads: + t.start() + for t in threads: + t.join() + + # 結果出力: 1 行 1 JSON + for agent in agents: + st = results[agent] + print(json.dumps({ + "agent": agent, + "status": st.status, + "exit_code": st.exit_code, + "pid": st.pid, + "elapsed": round(st.elapsed, 1), + "detail": st.detail, + "err_log_size": st.err_log_size, + "stdout_log_size": st.stdout_log_size, + "progress_log_size": st.progress_log_size, + "progress_tail": st.progress_tail, + "idle_seconds": round(st.idle_seconds, 1), + "result_exists": st.result_exists, + "sentinel_seen": st.sentinel_seen, + }, ensure_ascii=False)) + + # exit code: 全エージェントの最大値(OK=0 が最良、それ以外は失敗) + sys.exit(max(results[a].exit_code for a in agents)) + + +if __name__ == "__main__": + main() diff --git a/plugins/ndf-claude/skills/cross-review/scripts/rotate-pr.sh b/plugins/ndf-claude/skills/cross-review/scripts/rotate-pr.sh new file mode 100755 index 0000000..9b063f4 --- /dev/null +++ b/plugins/ndf-claude/skills/cross-review/scripts/rotate-pr.sh @@ -0,0 +1,362 @@ +#!/usr/bin/env bash +# PR rotation — light モード (default) / squash モード +# +# Usage: +# rotate-pr.sh prepare +# 旧 PR の title/body/isDraft + git log $BASE..HEAD + git diff --stat を +# $TMP_DIR/rotate-pr-prepare.json に dump する。 +# light モードでは メインセッションの Agent が prepare.json を読み、 +# 現状の差分・実装を反映した新 title/body を生成して +# $TMP_DIR/rotate-pr-newtext.json に書き出すこと。 +# +# rotate-pr.sh execute [--mode light|squash] (default: light) +# light : 同ブランチで旧 PR を close → 同 head/base で新 PR を作成。 +# title/body は newtext.json から流す。元 PR の isDraft をコピー。 +# PR title に内部用語 (rotated/round/cross-review) は付与しない。 +# squash : squash 統合した新ブランチ (-rHHMMSS suffix) + 新 PR。 +# title 末尾に "(rotated)"、body は automated text。 +# いずれも stdout に NEW_PR= / NEW_PR_URL= / NEW_BRANCH= を出力。 +# +# rotate-pr.sh (deprecated) +# 旧 1 引数形式。`execute --mode squash` 相当として動くが、stderr に +# deprecation warning を出す。新規呼び出しは prepare → execute 形式へ移行。 +# +# 引数 STATE_PR は state.json の key (= 最初に init した PR 番号)。 +# 閉じる「現在の PR」は state.json の `current_pr` を読む。 +# state.json の current_pr / pr_history 更新は `state.py set-current-pr` で別途行う。 + +set -euo pipefail + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=_tmpdir.sh +. "$SCRIPT_DIR/_tmpdir.sh" +TMP_DIR=$(tmpdir) + +usage() { + cat >&2 <<'USAGE' +Usage: + rotate-pr.sh prepare + rotate-pr.sh execute [--mode light|squash] (default: light) + rotate-pr.sh (deprecated, = execute --mode squash) +USAGE +} + +# close 後に新 PR create が失敗した場合の rollback hook (light/squash 共通)。 +# OLD_PR はグローバル (load_state で set される) を参照する。 +# 両モードから `trap reopen_old_pr_on_failure ERR` で登録し、create 成功直後に +# `trap - ERR` で解除する (gemini round 6 指摘: 関数定義の重複排除 + EXIT ではなく +# ERR で hook して PR 作成後の処理失敗による誤発火を避ける)。 +reopen_old_pr_on_failure() { + local exit_code=$? + echo "⚠ 新 PR 作成系処理に失敗 (exit=$exit_code) — 旧 PR #${OLD_PR:-?} を reopen します" >&2 + if [ -n "${OLD_PR:-}" ]; then + gh pr reopen "$OLD_PR" >&2 || echo "⚠ 旧 PR #$OLD_PR の reopen にも失敗しました。手動で確認してください。" >&2 + fi +} + +# state.json から共通情報を読み出して shell 変数にセットする。 +# 呼び出し後: STATE_FILE / WORKTREE / OLD_PR / ROUND_IN_PR が使える。 +load_state() { + local state_pr=$1 + STATE_FILE=$TMP_DIR/cross-review-pr$state_pr-state.json + [ -s "$STATE_FILE" ] || { echo "state.json not found: $STATE_FILE" >&2; exit 1; } + WORKTREE=$(jq -r '.worktree_path' "$STATE_FILE") + OLD_PR=$(jq -r '.current_pr' "$STATE_FILE") + ROUND_IN_PR=$(jq --argjson p "$OLD_PR" '[.rounds[] | select(.pr == $p)] | length' "$STATE_FILE") +} + +cmd_prepare() { + local state_pr=${1:?STATE_PR required} + load_state "$state_pr" + + cd "$WORKTREE" + + local pr_json + pr_json=$(gh pr view "$OLD_PR" \ + --json number,url,title,body,headRefName,baseRefName,isDraft) + local head_branch base_branch + head_branch=$(jq -r '.headRefName' <<<"$pr_json") + base_branch=$(jq -r '.baseRefName' <<<"$pr_json") + + # base が origin にあることを保証 (git log/diff のため) + if ! git fetch --quiet origin "$base_branch"; then + echo "⚠ git fetch origin $base_branch に失敗しました。ローカル参照のみで継続します。" >&2 + fi + if ! git rev-parse --verify --quiet "origin/$base_branch" >/dev/null; then + echo "⚠ origin/$base_branch が見つかりません。git_log / git_diff_stat は空になります。" >&2 + fi + + local range="origin/$base_branch..HEAD" + local git_log git_diff_stat + git_log=$(git log --pretty=format:'%h %s' "$range" 2>/dev/null || echo "") + git_diff_stat=$(git diff --stat "$range" 2>/dev/null || echo "") + + local out=$TMP_DIR/rotate-pr$state_pr-prepare.json + jq -n \ + --argjson state_pr "$state_pr" \ + --argjson pr "$pr_json" \ + --argjson round_in_pr "$ROUND_IN_PR" \ + --arg worktree "$WORKTREE" \ + --arg git_log "$git_log" \ + --arg git_diff_stat "$git_diff_stat" \ + '{ + state_pr: $state_pr, + old_pr: $pr.number, + old_pr_url: $pr.url, + worktree_path: $worktree, + head_branch: $pr.headRefName, + base_branch: $pr.baseRefName, + is_draft: $pr.isDraft, + round_in_pr: $round_in_pr, + old_title: $pr.title, + old_body: ($pr.body // ""), + git_log: $git_log, + git_diff_stat: $git_diff_stat + }' > "$out" + + echo "✅ prepare.json 書き出し: $out" >&2 + # 呼び出し側 (SKILL.md / docs) は eval で stdout を取り込む契約。 + # PR の head/base 由来の値は shell メタ文字を含み得るため必ず printf '%q' で + # シェルエスケープしてから出力する (例: ブランチ名に "; rm -rf / 等が来ても安全)。 + printf 'PREPARE_JSON=%q\n' "$out" + printf 'OLD_PR=%q\n' "$OLD_PR" + printf 'HEAD_BRANCH=%q\n' "$head_branch" + printf 'BASE_BRANCH=%q\n' "$base_branch" + printf 'IS_DRAFT=%q\n' "$(jq -r '.isDraft' <<<"$pr_json")" +} + +# light モード本体: 同ブランチで旧 PR を close → 同 head/base で新 PR 作成。 +execute_light() { + local state_pr=$1 + load_state "$state_pr" + + local prep=$TMP_DIR/rotate-pr$state_pr-prepare.json + local newtext=$TMP_DIR/rotate-pr$state_pr-newtext.json + [ -s "$prep" ] || { echo "prepare.json not found: $prep — 先に rotate-pr.sh prepare $state_pr を実行してください" >&2; exit 1; } + [ -s "$newtext" ] || { echo "newtext.json not found: $newtext — Agent (general-purpose) で title/body を生成して書き出してください" >&2; exit 1; } + + local head_branch base_branch is_draft new_title new_body + head_branch=$(jq -r '.head_branch' "$prep") + base_branch=$(jq -r '.base_branch' "$prep") + is_draft=$(jq -r '.is_draft' "$prep") + new_title=$(jq -r '.title' "$newtext") + new_body=$(jq -r '.body' "$newtext") + + [ -n "$new_title" ] && [ "$new_title" != "null" ] || { echo "newtext.json に .title がない" >&2; exit 1; } + # body は空文字列を許容 (GitHub は空 body を許容)。null のみ拒否。 + [ "$new_body" != "null" ] || { echo "newtext.json に .body がない (null)" >&2; exit 1; } + + cd "$WORKTREE" + + echo "🔄 PR #$OLD_PR rotation (light): 同ブランチ $head_branch で巻き直し (base=$base_branch)" >&2 + + # 1. 作業ディレクトリに未 push のコミットがある可能性に備え、close 前に push する。 + # push しないと新 PR に最新コミットが反映されないケースがあるため必須 (gemini 指摘)。 + # state.py init は worktree を detached HEAD で作るため、ブランチ名のみで push すると + # detached HEAD 上の修正コミットが push されない。HEAD: 形式で現在の HEAD を + # 明示する (codex 指摘)。--force / --no-verify は禁止。 + echo "🔼 git push origin HEAD:$head_branch (未 push commit が無ければ no-op)" >&2 + git push origin HEAD:"$head_branch" + + # 2. 旧 PR を close (コメント残し) + gh pr comment "$OLD_PR" --body "ℹ️ レビューコメント履歴整理のため本 PR を一度 close し、同じブランチ \`$head_branch\` で新 PR を作り直します。ブランチの内容・base は変えません。" + gh pr close "$OLD_PR" + + # close 後に create が失敗した場合は旧 PR を reopen して rotation の途中停止を回避する + # (関数定義は file 冒頭で共通化, gemini round 6 指摘) + trap reopen_old_pr_on_failure ERR + + # 3. 新 PR を同 head/base で作成 (Draft 状態は元 PR から継承)。 + # body は --body-file - 経由で stdin から渡し、argv 長制限を回避する (gemini 指摘)。 + local create_args=(--base "$base_branch" --head "$head_branch" --title "$new_title" --body-file -) + if [ "$is_draft" = "true" ]; then + create_args+=(--draft) + fi + local new_pr_url + new_pr_url=$(printf '%s' "$new_body" | gh pr create "${create_args[@]}") + + # gh pr create 成功直後に trap を解除し、後続の URL parse / echo 等が失敗しても + # 新旧 PR が重複して開く事態を避ける (gemini round 6 指摘)。 + trap - ERR + + # PR 番号は create 出力 URL の末尾セグメントから抽出 (gh pr view 追加呼び出しを削減, + # gemini round 6 指摘)。URL 形式: https://github.com///pull/ + local new_pr=${new_pr_url##*/} + + echo "✅ 新 PR #$new_pr: $new_pr_url" >&2 + # eval される契約。head_branch / URL に shell メタ文字が混ざっても安全なよう %q で escape + printf 'NEW_PR=%q\n' "$new_pr" + printf 'NEW_PR_URL=%q\n' "$new_pr_url" + printf 'NEW_BRANCH=%q\n' "$head_branch" +} + +# squash モード本体。 +execute_squash() { + local state_pr=$1 + load_state "$state_pr" + + cd "$WORKTREE" + + local branch base title new_branch pr_meta prep + prep=$TMP_DIR/rotate-pr$state_pr-prepare.json + + # PR メタ情報 (base / title / head) は、まず prepare.json があればそこから読み出し、 + # 無い場合のみ gh pr view にフォールバックする (execute_light と同じ方針で + # 不要な API 呼び出しを排除, gemini round 8 指摘)。 + if [ -s "$prep" ]; then + base=$(jq -r '.base_branch // empty' "$prep") + title=$(jq -r '.old_title // empty' "$prep") + fi + if [ -z "${base:-}" ] || [ -z "${title:-}" ]; then + pr_meta=$(gh pr view "$OLD_PR" --json headRefName,baseRefName,title) + [ -n "${base:-}" ] || base=$(printf '%s' "$pr_meta" | jq -r '.baseRefName') + [ -n "${title:-}" ] || title=$(printf '%s' "$pr_meta" | jq -r '.title') + fi + + # state.py init は worktree を `git worktree add --detach origin/` で作るため、 + # `git branch --show-current` は空文字を返す。空のまま new_branch を生成すると + # `-rHHMMSS` だけのブランチ名になってしまうので、フォールバック順を以下に固定する: + # 1. git branch --show-current (通常 worktree なら使える) + # 2. prepare.json の head_branch (prepare 済みなら最も信頼できる) + # 3. gh pr view --json headRefName (prepare 未実行でも復元可能) + # (codex round 4 指摘) + branch=$(git branch --show-current) + if [ -z "$branch" ] && [ -s "$prep" ]; then + branch=$(jq -r '.head_branch // empty' "$prep") + fi + if [ -z "$branch" ]; then + pr_meta=${pr_meta:-$(gh pr view "$OLD_PR" --json headRefName,baseRefName,title)} + branch=$(printf '%s' "$pr_meta" | jq -r '.headRefName') + fi + [ -n "$branch" ] || { echo "head branch を復元できませんでした (detached worktree かつ prepare.json / gh pr view から取得失敗)" >&2; exit 1; } + new_branch="${branch}-r$(date +%H%M%S)" + + # 既に title 末尾に "(rotated)" / "(rotated2)" 等が付いている場合は除去してから + # "(rotated)" を 1 つだけ付与し、ローテーションのたびに suffix が重複しないようにする + # (gemini round 8 指摘)。 + # 例: + # "Fix foo" → "Fix foo (rotated)" + # "Fix foo (rotated)" → "Fix foo (rotated)" + # "Fix foo (rotated2)" → "Fix foo (rotated)" + # "Fix foo (rotated)(rotated)" → "Fix foo (rotated)" + local title_stripped=$title + while [[ $title_stripped =~ [[:space:]]*\(rotated[0-9]*\)$ ]]; do + title_stripped=${title_stripped%"${BASH_REMATCH[0]}"} + done + local new_title="$title_stripped (rotated)" + + echo "🔄 PR #$OLD_PR rotation (squash): $branch → $new_branch (base=$base)" >&2 + + # 1. 既存ブランチを squash して新ブランチに + git checkout -b "$new_branch" + git reset --soft "origin/$base" + # commit message は -m を複数指定で分割して渡す。$(cat < +EOF +) + local new_pr_url + new_pr_url=$(printf '%s' "$new_body" | gh pr create --base "$base" --title "$new_title" --body-file -) + + # gh pr create 成功直後に trap を解除し、後続の URL parse / echo 等が失敗しても + # 新旧 PR が重複して開く事態を避ける (gemini round 6 指摘)。 + trap - ERR + + # PR 番号は create 出力 URL の末尾セグメントから抽出 (gh pr view 追加呼び出しを削減, + # gemini round 6 指摘)。URL 形式: https://github.com///pull/ + local new_pr=${new_pr_url##*/} + + echo "✅ 新 PR #$new_pr: $new_pr_url" >&2 + # eval される契約。new_branch / URL に shell メタ文字が混ざっても安全なよう %q で escape + printf 'NEW_PR=%q\n' "$new_pr" + printf 'NEW_PR_URL=%q\n' "$new_pr_url" + printf 'NEW_BRANCH=%q\n' "$new_branch" +} + +cmd_execute() { + local state_pr=${1:?STATE_PR required} + shift + # --mode 未指定時は light を default (SKILL.md / 02-fix-and-rotation.md / スクリプト + # 冒頭コメントの「light モード (default)」表記に CLI 契約を揃える, codex round 4 指摘) + local mode="light" + while [ $# -gt 0 ]; do + case $1 in + --mode) + mode=${2:?--mode requires light|squash} + shift 2 + ;; + --mode=*) + mode=${1#--mode=} + shift + ;; + *) + echo "unknown arg: $1" >&2 + usage + exit 2 + ;; + esac + done + case $mode in + light) execute_light "$state_pr" ;; + squash) execute_squash "$state_pr" ;; + *) echo "invalid --mode: $mode (light|squash)" >&2; exit 2 ;; + esac +} + +# ---- entrypoint ---- + +if [ $# -eq 0 ]; then + usage + exit 2 +fi + +case $1 in + prepare) + shift + cmd_prepare "$@" + ;; + execute) + shift + cmd_execute "$@" + ;; + -h|--help) + usage + ;; + *) + # 旧形式: rotate-pr.sh → squash 相当 + if [ $# -eq 1 ] && [[ $1 =~ ^[0-9]+$ ]]; then + echo "⚠ DEPRECATED: rotate-pr.sh 形式は廃止予定です。新形式に移行してください:" >&2 + echo " rotate-pr.sh prepare $1" >&2 + echo " rotate-pr.sh execute $1 --mode light|squash" >&2 + echo " (本実行は --mode squash 相当で継続します)" >&2 + execute_squash "$1" + else + usage + exit 2 + fi + ;; +esac diff --git a/plugins/ndf-claude/skills/cross-review/scripts/state.py b/plugins/ndf-claude/skills/cross-review/scripts/state.py new file mode 100755 index 0000000..9e91f11 --- /dev/null +++ b/plugins/ndf-claude/skills/cross-review/scripts/state.py @@ -0,0 +1,1481 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.10" +# dependencies = [] +# /// +"""cross-review state.json 操作 CLI。 + +`/.cross_review/cross-review-pr-state.json` の初期化 / 読み書きと、 +ループ判定(round 開始 / 収束 / 振動 / PR ローテーション要否 / fix 結果マージ / +deferred nit レポート)を 1 つの CLI に集約する。 + +Subcommands: + init Step 0 state 初期化 or 再開(プリチェック込み) + start-round Step 1 round 開始判定 (ROUND/ROUND_IN_PR/PR を stdout に出す) + read-result Step 2.5 codex/gemini の result.json を state にマージ + judge Step 3 intent ベース pass 判定 (exit 0=approved, 2=continue) + check-oscillation Step 4 path:line 重複率を計算 + merge-fix Step 5 post fix サブエージェント戻り値を state にマージ + CI 分類 + should-rotate Step 6 rotate_after 到達判定 (exit 0=rotate, 2=keep) + set-current-pr PR ローテーション後の current_pr 更新 + report Step 8 deferred nit + ラウンドサマリ表示 + +すべての出力は人間可読 + KEY=VALUE 形式(eval / read で取り回し可能)。 +""" +from __future__ import annotations + +import argparse +import datetime as _dt +import json +import os +import pathlib +import shlex +import subprocess +import sys +import tempfile +import time +from typing import Any + + +# ---------------- helpers ---------------- + +DOC_EXTENSIONS = { + ".md", ".mdx", ".rst", ".txt", ".adoc", ".asciidoc", +} +DOC_FILENAMES = { + "readme", "license", "changelog", "contributing", "codeowners", +} +CODE_EXTENSIONS = { + ".c", ".cc", ".cpp", ".cs", ".css", ".dart", ".ex", ".exs", ".go", ".h", + ".hpp", ".html", ".java", ".js", ".jsx", ".kt", ".kts", ".php", ".py", + ".rb", ".rs", ".scala", ".scss", ".sh", ".sql", ".swift", ".ts", ".tsx", + ".vue", ".yaml", ".yml", +} +MIGRATION_PATH_MARKERS = ( + "/migrations/", "/migration/", "/db/migrate/", "/database/migrations/", + "/alembic/versions/", "/prisma/migrations/", +) +MIGRATION_NAME_MARKERS = ( + "migration", "migrate", "schema.sql", "schema.prisma", +) +TEST_PATH_MARKERS = ( + "/test/", "/tests/", "/spec/", "/specs/", "__tests__/", +) +TEST_NAME_MARKERS = ( + ".test.", ".spec.", "_test.", "_spec.", "test_", "spec_", +) +DEPENDENCY_FILENAMES = { + "package.json", "package-lock.json", "pnpm-lock.yaml", "yarn.lock", + "composer.json", "composer.lock", "gemfile", "gemfile.lock", + "go.mod", "go.sum", "requirements.txt", "requirements-dev.txt", + "pyproject.toml", "poetry.lock", "uv.lock", "cargo.toml", "cargo.lock", + "pom.xml", "build.gradle", "build.gradle.kts", +} +CI_CONFIG_MARKERS = ( + "/.github/workflows/", "/.gitlab-ci", "/.circleci/", "/.buildkite/", + "/.kiro/", "/.claude/", "/.codex/", +) +CONFIG_EXTENSIONS = { + ".json", ".toml", ".yaml", ".yml", ".ini", ".env", ".example", +} +API_CONTRACT_MARKERS = ( + "/api/", "/routes/", "/controllers/", "/openapi", "/swagger", + "/proto/", "/graphql/", "/schemas/", +) +AUTH_SECURITY_TOKEN_MARKERS = ( + "auth", "authn", "authz", "permission", "policy", "role", "oauth", "jwt", + "session", "csrf", "cors", "token", +) +AUTH_SECURITY_SUBSTRING_MARKERS = ( + "authentication", "authorization", "authenticat", "authoriz", + "secret", "password", "credential", +) +FRONTEND_EXTENSIONS = { + ".css", ".scss", ".sass", ".less", ".html", ".jsx", ".tsx", ".vue", ".svelte", +} +PERFORMANCE_MARKERS = ( + "cache", "queue", "job", "worker", "concurrent", "parallel", + "batch", "stream", "pagination", "performance", +) +GENERATED_MARKERS = ( + "/dist/", "/build/", "/generated/", "/vendor/", "/node_modules/", +) +I18N_MARKERS = ( + "/locales/", "/locale/", "/i18n/", "/translations/", +) +I18N_EXTENSIONS = {".po", ".pot"} +INFRA_MARKERS = ( + "/terraform/", "/helm/", "/k8s/", "/kubernetes/", "/docker/", + "dockerfile", "docker-compose", +) +INFRA_EXTENSIONS = {".tf", ".tfvars"} + + +COMMON_REVIEW_TEMPLATE = """## 自動追加レビュー観点: 共通 +- PR の目的と変更範囲が一貫しているか。余分な変更、未説明の仕様変更、将来の保守を難しくする設計がないか。 +- 変更が既存仕様・既存コメント・既存コードの前提と矛盾しないか。重複指摘は避け、根拠がある修正アクションだけを指摘する。 +- テスト、検証手順、エラーハンドリング、ロールバック容易性が変更リスクに見合っているか。""" + +DOCS_ONLY_REVIEW_TEMPLATE = """## 自動追加レビュー観点: ドキュメントのみ PR +- ドキュメントの主張・企画・手順が妥当で、読者の意思決定や作業を誤らせないか。 +- コード、設定、コマンド、既存 README / docs / CHANGELOG との整合性が取れているか。古い名称、存在しないパス、実装と違う説明がないか。 +- ドキュメント間で用語、前提、バージョン、責務分担が矛盾していないか。 +- 追加・更新された説明が必要十分で、曖昧な表現や未検証の断定がないか。""" + +CODE_REVIEW_TEMPLATE = """## 自動追加レビュー観点: コード変更 PR +- 設計、正確性、可読性、保守性、単純さを確認する。不要に複雑な分岐、責務の混在、過剰な抽象化がないか。 +- 冗長・重複コード、既存ヘルパや標準 API で置き換えられる処理、言語・フレームワークらしくない実装がないか。 +- 関数・クラス・ファイルのサイズと責務が適切か。長すぎる関数、肥大化したファイル、名前と実態がずれた単位がないか。 +- セキュリティ観点として、入力検証、出力エンコード、認可、秘密情報、ログ、例外、外部コマンド、SQL/HTML/パス操作の扱いを確認する。 +- テストの有無と質、境界値、失敗系、後方互換性、性能・並行性・リソース解放のリスクを確認する。""" + +DB_MIGRATION_REVIEW_TEMPLATE = """## 自動追加レビュー観点: DB migration / schema 変更 +- データ設計としてテーブル、カラム、型、NULL 可否、default、制約、外部キー、unique/index がドメイン要件に合っているか。 +- 型の粒度が妥当か。文字列で持つべきでない値、過剰に広い型、精度不足、timezone、JSON の濫用がないか。 +- 既存データへの影響、backfill、ロック時間、index 作成、ロールバック、アプリケーションの段階的デプロイ順序に問題がないか。 +- migration とモデル、クエリ、ドキュメント、テストデータの整合性が取れているか。""" + +TEST_REVIEW_TEMPLATE = """## 自動追加レビュー観点: テスト変更 +- テストが実装詳細ではなくユーザー影響・仕様・境界値・失敗系を検証しているか。 +- flaky になりやすい時間、乱数、順序、外部サービス、並行実行、共有状態への依存がないか。 +- テスト名、fixture、期待値が読みやすく、失敗時に原因を特定しやすいか。""" + +DEPENDENCY_REVIEW_TEMPLATE = """## 自動追加レビュー観点: 依存関係変更 +- 追加・更新された依存の必要性、ライセンス、メンテナンス状況、既存依存との重複を確認する。 +- lockfile と manifest の整合性、間接依存の大きな変化、ビルド/実行環境への影響を確認する。 +- 依存更新がセキュリティ、互換性、バンドルサイズ、起動時間に与える影響を確認する。""" + +CONFIG_CI_REVIEW_TEMPLATE = """## 自動追加レビュー観点: CI / 設定変更 +- CI 条件、権限、secret 参照、cache key、artifact、並列実行、失敗時の検知性が妥当か。 +- 設定変更がローカル・CI・本番で食い違わないか。環境変数の既定値と `.env.example` 相当の説明が揃っているか。 +- 自動化が過剰な権限や予期せぬ副作用を持たないか。""" + +API_CONTRACT_REVIEW_TEMPLATE = """## 自動追加レビュー観点: API / 契約変更 +- 入出力スキーマ、HTTP status、エラー形式、互換性、バージョニング、既存クライアントへの影響を確認する。 +- バリデーション、認可、ページング、冪等性、レート制限、監査ログが要件に合っているか。 +- API ドキュメント、型定義、テスト、実装の整合性が取れているか。""" + +AUTH_SECURITY_REVIEW_TEMPLATE = """## 自動追加レビュー観点: 認証・認可・機密情報 +- 認証、認可、ロール、所有者チェック、テナント境界が欠落または過剰許可になっていないか。 +- token、password、secret、PII がログ、例外、レスポンス、コミット差分に漏れていないか。 +- CSRF/CORS/session/JWT/OAuth などの設定が安全で、失効・更新・リプレイ対策が妥当か。""" + +FRONTEND_REVIEW_TEMPLATE = """## 自動追加レビュー観点: フロントエンド / UX +- UI 状態、loading/error/empty、キーボード操作、アクセシビリティ、レスポンシブ表示が破綻しないか。 +- コンポーネント責務、重複 UI、状態管理、不要な再レンダリング、バンドルサイズへの影響を確認する。 +- 表示文言、フォーム validation、ユーザー操作後のフィードバックが仕様と一致しているか。""" + +PERFORMANCE_REVIEW_TEMPLATE = """## 自動追加レビュー観点: 性能・並行性 +- N+1、不要な全件取得、過剰な同期 I/O、メモリ保持、ロック、競合、リトライ嵐がないか。 +- cache、batch、pagination、stream、queue/worker の使い方が正しく、失敗時の再実行や重複実行に耐えるか。 +- 計測・ログ・アラートが問題発生時の切り分けに足りるか。""" + +DELETION_RENAME_REVIEW_TEMPLATE = """## 自動追加レビュー観点: 削除・リネーム +- 削除・リネーム対象への参照がコード、設定、CI、ドキュメント、テスト、外部連携に残っていないか。 +- 後方互換性、migration、deprecation、利用者への移行手順が必要ないか。 +- 同名別ファイルや大文字小文字差による環境依存の問題がないか。""" + +GENERATED_REVIEW_TEMPLATE = """## 自動追加レビュー観点: 生成物・ロックファイル +- 生成物が本当にコミット対象か。生成元との差分、再生成手順、不要なノイズが混入していないか。 +- lockfile の差分が意図した依存変更に対応しているか。手編集や不整合がないか。""" + +I18N_REVIEW_TEMPLATE = """## 自動追加レビュー観点: i18n / 文言 +- 翻訳キー、fallback、変数展開、複数形、日付・数値・通貨・タイムゾーン表記が妥当か。 +- 原文と翻訳、UI 表示幅、アクセシビリティラベル、ドキュメント文言の整合性を確認する。""" + +INFRA_REVIEW_TEMPLATE = """## 自動追加レビュー観点: インフラ / デプロイ +- 環境差分、権限、secret、ネットワーク公開範囲、永続化、バックアップ、スケール、ロールバック容易性を確認する。 +- IaC / manifest / Dockerfile の設定が最小権限・再現可能・運用監視しやすい形になっているか。 +- 既存環境への破壊的変更、手動作業、順序依存、ダウンタイムのリスクが説明されているか。""" + +def _default_worktree_base() -> pathlib.Path: + """worktree の親ディレクトリを解決する。 + + 優先順位: + 1. 環境変数 NDF_WORKTREE_BASE (明示オーバーライド) + 2. <システム tmpdir>/ndf-worktrees (非永続領域。コンテナ再作成で自動消滅) + + かつての /work/worktrees ($HOME/work/worktrees) は共有の永続 volume 上にあり、 + 別リポジトリの pr と衝突する・明示削除が必要・volume を消費する問題が + あったため廃止した。 + """ + env = os.environ.get("NDF_WORKTREE_BASE") + if env: + # 相対パスのまま state.json に保存されると後続のパス比較が壊れるため、 + # 常に絶対パスへ解決して返す。 + return pathlib.Path(env).resolve() + return pathlib.Path(tempfile.gettempdir()) / "ndf-worktrees" + + +def _repo_slug(repo: str) -> str: + """`owner/name` を path-safe なディレクトリ名 `owner--name` に変換する。""" + return repo.replace("/", "--") + + +def _is_registered_worktree(path: str) -> bool: + """path が現リポジトリに登録済みの worktree かどうか。 + + パスが存在しても別リポジトリの残骸や git 管理外ディレクトリの可能性があり、 + 流用すると git 操作が壊れるため、流用前に必ずこれで検証する。 + """ + out = _sh(["git", "worktree", "list", "--porcelain"], check=False) + target = str(pathlib.Path(path).resolve()) + return any(line == f"worktree {target}" for line in out.splitlines()) + + +def _create_worktree(worktree: str, pr: int, head_branch: str) -> None: + """origin/ から detached worktree を作成する (フォーク PR はフォールバック)。""" + pathlib.Path(worktree).parent.mkdir(parents=True, exist_ok=True) + # worktree を /tmp 等の非永続領域に置くと、実体だけ消えて親リポジトリの + # 登録 (prunable) が残ることがある。その状態で `git worktree add` すると + # 「パス登録済み」として失敗するため、追加前に prune で掃除しておく。 + subprocess.run( + ["git", "worktree", "prune"], + capture_output=True, text=True, + ) + # フォーク PR の場合 origin に head_branch がないことがある。 + # fetch 失敗時は gh pr checkout --detach でフォールバックする。 + fetch_result = subprocess.run( + ["git", "fetch", "origin", head_branch], + capture_output=True, text=True, + ) + if fetch_result.returncode == 0: + # head branch が既に別の worktree で checkout されている場合を避けるため + # detached で展開する。cross-review はファイル参照しかしないので問題ない。 + _sh(["git", "worktree", "add", "--detach", worktree, f"origin/{head_branch}"]) + info(f"✅ worktree 作成 (detached @ origin/{head_branch}): {worktree}") + else: + info(f"⚠ git fetch origin {head_branch} 失敗 (フォーク PR の可能性) — gh pr checkout でフォールバック") + _sh(["git", "worktree", "add", "--detach", worktree, "HEAD"]) + # worktree 内で gh pr checkout を実行して正しいコミットに切り替え + checkout_result = subprocess.run( + ["gh", "pr", "checkout", str(pr), "--detach"], + capture_output=True, text=True, + cwd=worktree, + ) + if checkout_result.returncode != 0: + # HEAD (親コミット) 指向のまま残すと、次回実行時に + # _is_registered_worktree() を通過して不正流用されるため、 + # die() の前に作成済み worktree をロールバックする。 + subprocess.run( + ["git", "worktree", "remove", "--force", worktree], + capture_output=True, text=True, + ) + die(f"gh pr checkout --detach #{pr} 失敗: {checkout_result.stderr.strip()}") + info(f"✅ worktree 作成 (gh pr checkout --detach #{pr}): {worktree}") + + +def _git_toplevel() -> str | None: + """git worktree root を取得する。失敗時は None を返す。""" + try: + r = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + capture_output=True, text=True, + ) + if r.returncode == 0 and r.stdout.strip(): + return r.stdout.strip() + except OSError: + pass + return None + + +def _tmp_dir(workspace: str | None = None) -> pathlib.Path: + """cross-review 用 tmp ディレクトリを決定する。 + + 優先順位: + 1. 環境変数 `CROSS_REVIEW_TMP_DIR` (明示) + 2. `/.cross_review/` (worktree 内。gemini の workspace 制約を根本回避) + + `workspace` 未指定なら `git rev-parse --show-toplevel` で worktree root を + 取得する。サブディレクトリから実行してもパス不一致が発生しない。 + git コマンドが失敗した場合のみ `os.getcwd()` にフォールバックする。 + """ + env = os.environ.get("CROSS_REVIEW_TMP_DIR") + if env: + d = pathlib.Path(env).resolve() + d.mkdir(parents=True, exist_ok=True) + return d + ws = pathlib.Path(workspace or _git_toplevel() or os.getcwd()).resolve() + d = ws / ".cross_review" + d.mkdir(parents=True, exist_ok=True) + return d + + +def _resolve_tmp_dir(pr: int | None = None) -> pathlib.Path: + """state.json に保存された tmp_dir を優先し、_tmp_dir() をフォールバックとする。 + + init 時に確定した tmp_dir を再利用することで、CWD や環境変数の変化による + パス不一致リスクを回避する。 + + 注意: この関数は成果物パスの解決にのみ使用する。state ファイル自体のパス解決 + には _tmp_dir() を直接使うこと(循環参照を防ぐため)。 + 副作用: フォールバック時に _tmp_dir() を呼ぶため、ディレクトリ作成 (mkdir) が + 発生する可能性がある。 + """ + if pr is not None: + # state.json から tmp_dir を読み出す (存在する場合) + candidate = _tmp_dir() / f"cross-review-pr{pr}-state.json" + if candidate.exists(): + try: + st = json.loads(candidate.read_text(encoding="utf-8")) + saved = st.get("tmp_dir") + if saved: + p = pathlib.Path(saved) + if p.exists(): + return p + except (OSError, json.JSONDecodeError): + pass + return _tmp_dir() + + +def _state_path(pr: int) -> pathlib.Path: + return _tmp_dir() / f"cross-review-pr{pr}-state.json" + + +def _payload_path(agent: str, pr: int, round_: int) -> pathlib.Path: + return _resolve_tmp_dir(pr) / f"{agent}-review-pr{pr}-round{round_}-payload.json" + + +def _existing_comments_path(pr: int) -> pathlib.Path: + return _resolve_tmp_dir(pr) / f"cross-review-pr{pr}-existing-comments.txt" + + +def _now() -> str: + return _dt.datetime.now(_dt.timezone.utc).astimezone().isoformat(timespec="seconds") + + +def _parse_pr_files_payload(output: str) -> list[dict[str, Any]]: + """`gh pr view --json files` の JSON を分類用の最小構造に正規化する。""" + try: + payload = json.loads(output) + except json.JSONDecodeError: + return [] + files = payload.get("files") if isinstance(payload, dict) else None + if not isinstance(files, list): + return [] + + status_map = { + "ADDED": "A", + "MODIFIED": "M", + "DELETED": "D", + "RENAMED": "R", + "COPIED": "C", + "CHANGED": "M", + } + entries: list[dict[str, Any]] = [] + for f in files: + if not isinstance(f, dict): + continue + path = f.get("path") + if not isinstance(path, str) or not path: + continue + change_type = str(f.get("changeType") or "MODIFIED").upper() + status = status_map.get(change_type, change_type[:1] or "M") + paths = [] + previous = f.get("previousPath") or f.get("previous_filename") + if isinstance(previous, str) and previous and previous != path: + paths.append(previous) + paths.append(path) + entries.append({"status": status, "paths": paths}) + return entries + + +def _parse_pr_files_api_lines(output: str) -> list[dict[str, Any]]: + """GitHub API の PR files を TSV(JSON jq) 出力から分類用構造に変換する。""" + status_map = { + "added": "A", + "modified": "M", + "removed": "D", + "renamed": "R", + "copied": "C", + "changed": "M", + } + entries: list[dict[str, Any]] = [] + for raw in output.splitlines(): + if not raw.strip(): + continue + cols = raw.split("\t") + status_raw = cols[0].strip().lower() if cols else "modified" + path = cols[1].strip() if len(cols) > 1 else "" + previous = cols[2].strip() if len(cols) > 2 else "" + if not path: + continue + paths = [] + if previous and previous != path: + paths.append(previous) + paths.append(path) + entries.append({"status": status_map.get(status_raw, status_raw[:1].upper() or "M"), "paths": paths}) + return entries + + +def _fetch_changed_files(pr: int, repo: str) -> list[dict[str, Any]]: + r = subprocess.run( + [ + "gh", "api", f"repos/{repo}/pulls/{pr}/files", + "--paginate", + "--jq", '.[] | [.status, .filename, (.previous_filename // "")] | @tsv', + ], + capture_output=True, text=True, + ) + if r.returncode == 0: + entries = _parse_pr_files_api_lines(r.stdout) + if entries: + return entries + info("⚠ PR files API の結果が空、または解析できません。gh pr view fallback を試行") + + else: + info(f"⚠ PR files API 取得に失敗。gh pr view fallback を試行: {r.stderr.strip()[:200]}") + + fallback = subprocess.run( + ["gh", "pr", "view", str(pr), "--json", "files"], + capture_output=True, text=True, + ) + if fallback.returncode != 0: + info(f"⚠ PR 変更ファイル一覧の取得に失敗。自動レビュー観点は共通のみ: {fallback.stderr.strip()[:200]}") + return [] + entries = _parse_pr_files_payload(fallback.stdout) + if not entries: + info("⚠ PR 変更ファイル一覧が空、または解析できません。自動レビュー観点は共通のみ") + return entries + + +def _path_info(path: str) -> tuple[str, str, str, str]: + p = pathlib.PurePosixPath(path.replace("\\", "/")) + lower = str(p).lower() + normalized = "/" + lower.lstrip("./") + return lower, normalized, p.name.lower(), pathlib.PurePosixPath(lower).suffix + + +def _contains_any(text: str, needles: tuple[str, ...]) -> bool: + return any(n in text for n in needles) + + +def _path_tokens(path: str) -> set[str]: + _, normalized, _, _ = _path_info(path) + return { + token + for token in normalized.replace(".", "/").replace("-", "/").replace("_", "/").split("/") + if token + } + + +def _is_doc_path(path: str) -> bool: + lower, normalized, name, ext = _path_info(path) + stem = pathlib.PurePosixPath(lower).stem + return ( + ext in DOC_EXTENSIONS + or stem in DOC_FILENAMES + or normalized.startswith("/docs/") + or "/docs/" in normalized + or normalized.startswith("/documentation/") + or "/documentation/" in normalized + ) + + +def _is_code_path(path: str) -> bool: + _, _, _, ext = _path_info(path) + return ext in CODE_EXTENSIONS + + +def _is_migration_path(path: str) -> bool: + lower, normalized, name, ext = _path_info(path) + return ( + ext == ".sql" + or _contains_any(normalized, MIGRATION_PATH_MARKERS) + or any(marker in name or marker in lower for marker in MIGRATION_NAME_MARKERS) + ) + + +def _is_test_path(path: str) -> bool: + lower, normalized, name, _ = _path_info(path) + return _contains_any(normalized, TEST_PATH_MARKERS) or any(m in name or m in lower for m in TEST_NAME_MARKERS) + + +def _is_dependency_path(path: str) -> bool: + _, _, name, _ = _path_info(path) + return name in DEPENDENCY_FILENAMES + + +def _is_config_ci_path(path: str) -> bool: + lower, normalized, name, ext = _path_info(path) + return ( + _contains_any(normalized, CI_CONFIG_MARKERS) + or name.startswith(".env") + or name in {"dockerfile", "makefile", ".editorconfig"} + or lower.startswith(".github/") + or (ext in CONFIG_EXTENSIONS and ("/config/" in normalized or "/configs/" in normalized)) + ) + + +def _is_api_contract_path(path: str) -> bool: + lower, normalized, _, _ = _path_info(path) + return _contains_any(normalized, API_CONTRACT_MARKERS) or "openapi" in lower or "swagger" in lower + + +def _is_auth_security_path(path: str) -> bool: + lower, _, _, _ = _path_info(path) + tokens = _path_tokens(path) + return ( + bool(tokens.intersection(AUTH_SECURITY_TOKEN_MARKERS)) + or any(marker in lower for marker in AUTH_SECURITY_SUBSTRING_MARKERS) + ) + + +def _is_frontend_path(path: str) -> bool: + _, normalized, _, ext = _path_info(path) + return ext in FRONTEND_EXTENSIONS or "/components/" in normalized or "/pages/" in normalized + + +def _is_performance_path(path: str) -> bool: + return bool(_path_tokens(path).intersection(PERFORMANCE_MARKERS)) + + +def _is_generated_path(path: str) -> bool: + _, normalized, name, _ = _path_info(path) + return _contains_any(normalized, GENERATED_MARKERS) or name in { + "package-lock.json", "pnpm-lock.yaml", "yarn.lock", "composer.lock", + "gemfile.lock", "go.sum", "poetry.lock", "uv.lock", "cargo.lock", + } + + +def _is_i18n_path(path: str) -> bool: + _, normalized, _, ext = _path_info(path) + return _contains_any(normalized, I18N_MARKERS) or ext in I18N_EXTENSIONS + + +def _is_infra_path(path: str) -> bool: + lower, normalized, name, ext = _path_info(path) + return ( + _contains_any(normalized, INFRA_MARKERS) + or name in {"dockerfile", "docker-compose.yml", "docker-compose.yaml"} + or ext in INFRA_EXTENSIONS + or lower.endswith(".tfvars.json") + ) + + +PATH_CATEGORY_RULES = ( + ("code", _is_code_path), + ("db_migration", _is_migration_path), + ("test", _is_test_path), + ("dependency", _is_dependency_path), + ("config_ci", _is_config_ci_path), + ("api_contract", _is_api_contract_path), + ("auth_security", _is_auth_security_path), + ("frontend", _is_frontend_path), + ("performance", _is_performance_path), + ("generated", _is_generated_path), + ("i18n", _is_i18n_path), + ("infra", _is_infra_path), +) + + +def _classify_changed_files(entries: list[dict[str, Any]]) -> list[str]: + paths = [p for entry in entries for p in entry.get("paths", []) if isinstance(p, str)] + categories: list[str] = ["common"] + if not paths: + return categories + + if paths and all(_is_doc_path(p) for p in paths): + categories.append("docs_only") + + categories.extend( + category + for category, predicate in PATH_CATEGORY_RULES + if any(predicate(path) for path in paths) + ) + if any(str(entry.get("status", "")).startswith(("D", "R")) for entry in entries): + categories.append("deletion_rename") + + # rename の旧パスだけで検知したカテゴリが混ざるのは有用だが、docs_only は + # 旧パス/新パス両方が docs であるときだけ採用するため上で all(paths) にしている。 + return list(dict.fromkeys(categories)) + + +def _auto_review_instructions(categories: list[str]) -> str: + templates = { + "common": COMMON_REVIEW_TEMPLATE, + "docs_only": DOCS_ONLY_REVIEW_TEMPLATE, + "code": CODE_REVIEW_TEMPLATE, + "db_migration": DB_MIGRATION_REVIEW_TEMPLATE, + "test": TEST_REVIEW_TEMPLATE, + "dependency": DEPENDENCY_REVIEW_TEMPLATE, + "config_ci": CONFIG_CI_REVIEW_TEMPLATE, + "api_contract": API_CONTRACT_REVIEW_TEMPLATE, + "auth_security": AUTH_SECURITY_REVIEW_TEMPLATE, + "frontend": FRONTEND_REVIEW_TEMPLATE, + "performance": PERFORMANCE_REVIEW_TEMPLATE, + "deletion_rename": DELETION_RENAME_REVIEW_TEMPLATE, + "generated": GENERATED_REVIEW_TEMPLATE, + "i18n": I18N_REVIEW_TEMPLATE, + "infra": INFRA_REVIEW_TEMPLATE, + } + return "\n\n".join(templates[c] for c in categories if c in templates) + + +def _combined_review_instructions(auto: str, manual: str) -> str: + return "\n\n".join(part for part in (auto.strip(), manual.strip()) if part) + + +def _extra_review_instructions(args: argparse.Namespace) -> str: + """cross-review launcher に渡す追加レビュー観点を組み立てる。 + + `--focus` は短い観点を直接渡す用途、`--extra-instructions-file` は長めの + チェックリストを渡す用途。両方指定された場合は順に連結する。 + """ + parts: list[str] = [] + focus = getattr(args, "focus", None) + if focus and str(focus).strip(): + parts.append(str(focus).strip()) + + extra_file = getattr(args, "extra_instructions_file", None) + if extra_file: + path = pathlib.Path(extra_file) + try: + text = path.read_text(encoding="utf-8").strip() + except OSError as exc: + die(f"追加レビュー観点ファイルを読めません: {path} ({exc})") + if text: + parts.append(text) + + return "\n\n".join(parts) + + +def _round_started_unixtime(round_entry: dict[str, Any]) -> float | None: + """``round.started_at`` (ISO 8601) を UNIX time (秒) に変換する。 + + fix 戻り値ファイルの mtime と比較して、round 開始前に書かれた古い + ファイルを fallback から除外するために使う。 + パース失敗時は None を返し、呼び出し側で「検証スキップ」を選ばせる。 + """ + started = round_entry.get("started_at") + if not started: + return None + try: + return _dt.datetime.fromisoformat(started).timestamp() + except (TypeError, ValueError): + return None + + +def _is_fresh_fix_result( + path: pathlib.Path, + pr: int, + round_started_ts: float | None, + is_canonical: bool = False, +) -> tuple[bool, dict[str, Any] | None]: + """fallback 候補の fix 戻り値ファイルを採用してよいか判定する。 + + 検証項目: + 1. ファイル mtime が `round_started_ts` 以降であること + (round 開始前に作られた = 古い実行 / 別リポジトリの同番号 PR の残骸) + 2. JSON 内に `pr` フィールドがある場合は対象 PR と一致すること + (`pr` フィールドが無い場合は 1 のみで判定) + + Returns: + ``(is_fresh, parsed_payload)`` のタプル。``is_fresh=True`` の場合のみ + ``parsed_payload`` (dict) が返る。呼び出し側はこれを使って再パースを省略できる。 + + 挙動: + - 古い候補・`pr` 不一致は警告を stderr に出して ``(False, None)`` を返し、 + 呼び出し側で次の候補へ進む。 + - `is_canonical=True` (= ``$TMP_DIR/fix-pr-result.json`` 正規パス) で + **読み取り失敗 (OSError / JSONDecodeError)** が発生した場合のみ + 即時 ``die(code=3)`` する (codex round 2 指摘: 正規パスが壊れているのに + 後続候補へ流れて別 PR の戻り値を誤マージする事故を防ぐ)。 + 正規パスでも `pr` 不一致 / stale mtime は fallback 継続対象とする。 + """ + # 1. mtime チェック + if round_started_ts is not None: + try: + mtime = path.stat().st_mtime + except OSError as exc: + info(f"⚠ fallback 候補 stat 失敗 ({path}): {exc} — skip") + return False, None + if mtime < round_started_ts: + info( + f"⚠ fallback 候補が round 開始前の古いファイル ({path}, " + f"mtime={_dt.datetime.fromtimestamp(mtime).isoformat(timespec='seconds')} " + f"< round_started={_dt.datetime.fromtimestamp(round_started_ts).isoformat(timespec='seconds')}) " + "— skip" + ) + return False, None + + # 2. JSON 内 `pr` フィールドの一致 (任意) + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + if is_canonical: + die( + f"正規パスの fix 戻り値ファイルの読み取りに失敗 ({path}): {exc}。" + " 後続 fallback への流れ込みを防ぐため即時中断。", + code=3, + ) + info(f"⚠ fallback 候補 JSON 解析失敗 ({path}): {exc} — skip") + return False, None + # gemini round 3 指摘: `json.loads` は dict 以外 (list 等) も返す。 + # 後続の `payload.get(...)` や cmd_merge_fix 側の `.get()` でクラッシュしないよう、 + # dict でない場合は warn を出して fallback 不採用 ((False, None)) として扱う。 + # + # codex round 4 指摘: ただし `is_canonical=True` (= 正規パス) で非 dict が返った + # 場合は parse 失敗と同じく即時 die(code=3) する。skip で後続 `/tmp/` fallback に + # 流れると、壊れた正規出力を無視して別実行の戻り値を誤マージする経路が残るため。 + if not isinstance(payload, dict): + if is_canonical: + die( + f"正規パスの fix 戻り値ファイルが dict ではない " + f"({path}, type={type(payload).__name__})。" + " 後続 fallback への流れ込みを防ぐため即時中断。", + code=3, + ) + info( + f"⚠ fallback 候補 JSON が dict ではない ({path}, type={type(payload).__name__}) " + "— skip" + ) + return False, None + file_pr = payload.get("pr") + if file_pr is not None: + try: + file_pr_int = int(file_pr) + except (TypeError, ValueError): + info( + f"⚠ fallback 候補の pr フィールドが数値として解釈できない " + f"({path}, file_pr={file_pr!r}) — skip" + ) + return False, None + if file_pr_int != int(pr): + info( + f"⚠ fallback 候補の pr 不一致 ({path}, file_pr={file_pr} != pr={pr}) " + "— 別 PR の戻り値の可能性。skip" + ) + return False, None + return True, payload + + +def _load(pr: int) -> dict[str, Any]: + p = _state_path(pr) + if not p.exists(): + die(f"state.json not found: {p}") + return json.loads(p.read_text(encoding="utf-8")) + + +def _save(pr: int, state: dict[str, Any]) -> None: + p = _state_path(pr) + tmp = p.with_suffix(".json.tmp") + tmp.write_text(json.dumps(state, indent=2, ensure_ascii=False), encoding="utf-8") + tmp.replace(p) + + +def _sh(cmd: list[str], check: bool = True) -> str: + r = subprocess.run(cmd, capture_output=True, text=True) + if check and r.returncode != 0: + die(f"command failed ({' '.join(cmd)}): {r.stderr.strip()}") + return r.stdout.strip() + + +def die(msg: str, code: int = 1) -> None: + print(f"❌ {msg}", file=sys.stderr) + sys.exit(code) + + +def info(msg: str) -> None: + print(msg, file=sys.stderr) + + +# ---------------- subcommands ---------------- + +def cmd_init(args: argparse.Namespace) -> None: + """Step 0 — state 初期化 or 既存 state 引き継ぎ + プリチェック。""" + pr = args.pr + manual_extra_review = _extra_review_instructions(args) + # worktree path を先に解決してから tmp_dir を決定する。 + # tmp_dir は /.cross_review/ に配置し、gemini の workspace 制約を根本回避。 + # path には repo slug を含め、他リポジトリの同一 PR 番号と衝突しないようにする。 + repo = _sh(["gh", "repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"]) + worktree = str(pathlib.Path(args.worktree).resolve()) if args.worktree else str( + _default_worktree_base() / _repo_slug(repo) / f"pr{pr}") + + # worktree 存在チェック用: _tmp_dir() は mkdir するため、先に呼ぶと + # worktree ディレクトリが副作用で作成され exists() が常に true になる。 + # そのため _tmp_dir() 呼び出しは worktree 作成/確認の後に行う。 + + # 再開チェック: CROSS_REVIEW_TMP_DIR が設定されている場合はそちらを優先し、 + # 未設定なら /.cross_review/ を直接パスとして組む。 + # _tmp_dir() は mkdir 副作用があるため使用せず、パス解決のみ行う。 + env_tmp = os.environ.get("CROSS_REVIEW_TMP_DIR") + if env_tmp: + resume_dir = pathlib.Path(env_tmp).resolve() + else: + resume_dir = pathlib.Path(worktree) / ".cross_review" + resume_state_file = resume_dir / f"cross-review-pr{pr}-state.json" + if resume_state_file.exists(): + st = json.loads(resume_state_file.read_text(encoding="utf-8")) + if st.get("final") is None: + state_changed = False + if "auto_review_instructions" not in st: + changed_files = _fetch_changed_files(pr, st.get("repo") or repo) + categories = _classify_changed_files(changed_files) + st["changed_files"] = changed_files + st["auto_review_categories"] = categories + st["auto_review_instructions"] = _auto_review_instructions(categories) + state_changed = True + if manual_extra_review: + st["manual_extra_review_instructions"] = manual_extra_review + # 後方互換: 旧 key も manual 指示として保持する。 + st["extra_review_instructions"] = manual_extra_review + state_changed = True + manual = st.get("manual_extra_review_instructions") or st.get("extra_review_instructions") or "" + combined = _combined_review_instructions( + st.get("auto_review_instructions") or "", + manual, + ) + if st.get("review_instructions") != combined: + st["review_instructions"] = combined + state_changed = True + if state_changed: + resume_state_file.write_text( + json.dumps(st, indent=2, ensure_ascii=False), + encoding="utf-8", + ) + info("↻ 追加レビュー観点を state に反映して再開") + tmp_dir = _tmp_dir(worktree) + wt = st.get("worktree_path") or "" + info(f"↻ 前回中断 state から再開(round={len(st.get('rounds', []))})") + print(f'PR={st["current_pr"]}') + print(f'WORKTREE={shlex.quote(str(wt))}') + print(f'TMP_DIR={shlex.quote(str(tmp_dir))}') + print(f'REPO={shlex.quote(str(st.get("repo") or ""))}') + print(f'HEAD_BRANCH={shlex.quote(str(st.get("head_branch") or ""))}') + print(f'BASE_BRANCH={shlex.quote(str(st.get("base_branch") or ""))}') + print(f"IS_OWN_PR={'1' if st.get('is_own_pr') else '0'}") + print(f"EVENT_DOWNGRADE={'1' if st.get('event_downgrade') else '0'}") + print(f"HAS_EXTRA_REVIEW_INSTRUCTIONS={'1' if st.get('review_instructions') else '0'}") + print(f"RESUMED=1") + return + + # 新規 init: プリチェック + me = _sh(["gh", "api", "user", "--jq", ".login"]) + author = _sh(["gh", "pr", "view", str(pr), "--json", "author", "--jq", ".author.login"]) + is_own = (me == author) + event_downgrade = is_own + if is_own: + info(f"⚠ 自分の PR (author={me}) — REQUEST_CHANGES → COMMENT 強制ダウングレード") + + # worktree 分離 — _tmp_dir() より先に worktree を作成/確認する + head_branch = _sh(["gh", "pr", "view", str(pr), "--json", "headRefName", "--jq", ".headRefName"]) + base_branch = _sh(["gh", "pr", "view", str(pr), "--json", "baseRefName", "--jq", ".baseRefName"]) + changed_files = _fetch_changed_files(pr, repo) + auto_review_categories = _classify_changed_files(changed_files) + auto_review = _auto_review_instructions(auto_review_categories) + review_instructions = _combined_review_instructions(auto_review, manual_extra_review) + if not pathlib.Path(worktree).exists(): + _create_worktree(worktree, pr, head_branch) + elif _is_registered_worktree(worktree): + info(f"↻ 既存 worktree 流用: {worktree}") + else: + # パスは存在するが現リポジトリの worktree ではない (別リポジトリの残骸等)。 + # 流用すると git 操作が壊れるため退避して作り直す。 + stale = f"{worktree}.stale-{time.strftime('%Y%m%d%H%M%S')}" + pathlib.Path(worktree).rename(stale) + info(f"⚠ 現リポジトリの worktree でないため退避: {stale}") + _create_worktree(worktree, pr, head_branch) + + # worktree 作成/確認後に _tmp_dir() を呼ぶ (ここで .cross_review/ が作られる) + tmp_dir = _tmp_dir(worktree) + state_file = tmp_dir / f"cross-review-pr{pr}-state.json" + + # 既存コメントスナップショット(重複指摘防止)。 + # 3 ソース (インラインコメント / レビュー body / PR レベルコメント) を + # fix skill の共有スクリプトで一括取得する。 + fetch_script = pathlib.Path(__file__).resolve().parent.parent.parent / "fix" / "scripts" / "fetch-pr-comments.sh" + r = subprocess.run( + [str(fetch_script), repo, str(pr)], + capture_output=True, text=True, + ) + existing_path = tmp_dir / f"cross-review-pr{pr}-existing-comments.txt" + if r.returncode == 0: + existing_path.write_text(r.stdout, encoding="utf-8") + else: + die(f"既存コメント取得失敗 (重複検出無効のため中断): {r.stderr.strip()[:200]}") + + state = { + "started_at": _now(), + "max_rounds": args.max_rounds, + "rotate_after": args.rotate_after, + "only": args.only, + "current_pr": pr, + "worktree_path": worktree, + "tmp_dir": str(tmp_dir), + "repo": repo, + "head_branch": head_branch, + "base_branch": base_branch, + "pr_author": author, + "is_own_pr": is_own, + "event_downgrade": event_downgrade, + "changed_files": changed_files, + "auto_review_categories": auto_review_categories, + "auto_review_instructions": auto_review, + "manual_extra_review_instructions": manual_extra_review, + # 後方互換: 旧 key は manual 指示を保持する。 + "extra_review_instructions": manual_extra_review, + "review_instructions": review_instructions, + "pr_history": [{"pr": pr, "opened_at": _now(), "closed_at": None, "rounds": 0}], + "rounds": [], + "deferred_nits": [], + "final": None, + } + state_file.write_text(json.dumps(state, indent=2, ensure_ascii=False), encoding="utf-8") + info(f"✅ state 初期化: {state_file}") + print(f"PR={pr}") + print(f'WORKTREE={shlex.quote(str(worktree))}') + print(f'TMP_DIR={shlex.quote(str(tmp_dir))}') + print(f'REPO={shlex.quote(str(repo))}') + print(f'HEAD_BRANCH={shlex.quote(str(head_branch))}') + print(f'BASE_BRANCH={shlex.quote(str(base_branch))}') + print(f"IS_OWN_PR={'1' if is_own else '0'}") + print(f"EVENT_DOWNGRADE={'1' if event_downgrade else '0'}") + print(f"HAS_EXTRA_REVIEW_INSTRUCTIONS={'1' if review_instructions else '0'}") + print("RESUMED=0") + + +def cmd_start_round(args: argparse.Namespace) -> None: + """Step 1 — round 開始判定。""" + st = _load(args.pr) + total = len(st["rounds"]) + max_r = st["max_rounds"] + if total >= max_r: + st["final"] = "max_rounds" + st["ended_at"] = _now() + _save(args.pr, st) + die(f"max_rounds={max_r} 到達。中断。", code=1) + + pr = st["current_pr"] + round_no = total + 1 + round_in_pr = sum(1 for r in st["rounds"] if r["pr"] == pr) + 1 + + # round エントリを開く + st["rounds"].append({ + "round": round_no, + "pr": pr, + "started_at": _now(), + }) + _save(args.pr, st) + + info(f"=== Round {round_no} / {max_r} (PR #{pr}, round_in_pr={round_in_pr}) ===") + print(f"ROUND={round_no}") + print(f"ROUND_IN_PR={round_in_pr}") + print(f"PR={pr}") + print(f"MAX_ROUNDS={max_r}") + print(f"ROTATE_AFTER={st['rotate_after']}") + + +def cmd_read_result(args: argparse.Namespace) -> None: + """Step 2.5 — codex/gemini の result.json を state にマージ。""" + agent = args.agent + pr = args.pr + rfile = pathlib.Path(args.file or _resolve_tmp_dir(pr) / f"{agent}-review-pr{pr}-result.json") + if not rfile.exists() or rfile.stat().st_size == 0: + die(f"{agent}: result 未生成 ({rfile})") + + try: + r = json.loads(rfile.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + die(f"{agent}: result.json の parse に失敗 ({rfile}): {exc}", code=3) + + # gemini round 4 指摘: result.json は本来 dict だが、launcher の出力バグや + # 別実行の残骸で list / str が入り込むと `r.get(...)` で AttributeError になる。 + # 不正な review result はバグなので即時 die(code=3) で停止させる。 + if not isinstance(r, dict): + die( + f"{agent}: result.json が dict ではない " + f"({rfile}, type={type(r).__name__})。review launcher の出力形式不正。", + code=3, + ) + + # 別名フィールドへのフォールバック (gemini が `intent` / `comment_count` を使う変則 JSON を + # 書き出す既知のケースに対応する。仕様としては `event` / `comments_count` が正) + intent = r.get("event") or r.get("intent") + posted_as = r.get("posted_as") or intent + comments = r.get("comments_count") + if comments is None: + comments = r.get("comment_count") + + if intent is None: + die( + f"{agent}: result.json に event / intent フィールドが無い ({rfile})。" + " launcher prompt のスキーマ違反の可能性。" + ) + + st = _load(pr) + if not st.get("rounds"): + die(f"{agent}: state.rounds が空。`state.py start-round` を先に呼んでください") + st["rounds"][-1][agent] = { + "intent": intent, + "posted_as": posted_as, + "comments": comments, + "review_url": r.get("review_url"), + "by_severity": r.get("by_severity", {}), + } + _save(pr, st) + info(f"✅ {agent}: intent={intent} posted_as={posted_as} comments={comments}") + + +def cmd_judge(args: argparse.Namespace) -> None: + """Step 3 — intent ベース pass 判定。 + + Exit code: 0=approved, 2=continue, 1=error + """ + pr = args.pr + st = _load(pr) + if not st.get("rounds"): + die("state.rounds が空。`state.py start-round` を先に呼んでください") + last = st["rounds"][-1] + only = st.get("only") + + def is_pass(intent: str | None, severity: dict[str, int] | None) -> bool: + if intent in ("APPROVE", "SKIP"): + return True + if intent == "COMMENT": + sev = severity or {} + return (sev.get("critical", 0) == 0 and sev.get("major", 0) == 0) + return False + + codex_intent = (last.get("codex") or {}).get("intent", "SKIP") + gemini_intent = (last.get("gemini") or {}).get("intent", "SKIP") + codex_sev = (last.get("codex") or {}).get("by_severity") + gemini_sev = (last.get("gemini") or {}).get("by_severity") + + codex_pass = (only == "gemini") or is_pass(codex_intent, codex_sev) + gemini_pass = (only == "codex") or is_pass(gemini_intent, gemini_sev) + + print(f"CODEX_INTENT={codex_intent}") + print(f"GEMINI_INTENT={gemini_intent}") + + if codex_pass and gemini_pass: + st["final"] = "approved" + st["ended_at"] = _now() + _save(pr, st) + info("✅ 両方 APPROVE。収束。") + sys.exit(0) + + info(f"→ codex={codex_intent} gemini={gemini_intent}。修正へ。") + sys.exit(2) + + +def cmd_check_oscillation(args: argparse.Namespace) -> None: + """Step 4 — path:line 重複率を計算。 + + 前ラウンドと現ラウンドで重複が 50% 以上なら final=oscillation で中断。 + rotation 直後は round_in_pr<2 なのでスキップ。 + """ + pr = args.pr + st = _load(pr) + rounds = st["rounds"] + current_pr = st["current_pr"] + same_pr = [r for r in rounds if r["pr"] == current_pr] + if len(same_pr) < 2: + info("⏭ round_in_pr<2: 振動検知スキップ") + sys.exit(2) # continue + + prev_round_no = same_pr[-2]["round"] + curr_round_no = same_pr[-1]["round"] + + def collect_keys(round_no: int) -> set[str]: + keys: set[str] = set() + for agent in ("codex", "gemini"): + p = _payload_path(agent, pr, round_no) + if not p.exists(): + continue + try: + payload = json.loads(p.read_text(encoding="utf-8")) + except json.JSONDecodeError: + continue + # gemini round 4 指摘: payload は本来 dict (comments: [...]) だが、 + # launcher のバグで list / str が入り込むと `payload.get(...)` で + # AttributeError になる。不正な review payload はバグなので + # 即時 die(code=3) で停止させる。 + if not isinstance(payload, dict): + die( + f"{agent}: payload.json が dict ではない " + f"({p}, type={type(payload).__name__})。" + " review launcher の出力形式不正。", + code=3, + ) + for c in payload.get("comments", []): + if not isinstance(c, dict): + # comments エントリが dict でない場合も同様に致命扱い + die( + f"{agent}: payload.comments のエントリが dict ではない " + f"({p}, type={type(c).__name__})。", + code=3, + ) + path = c.get("path") + line = c.get("line") or c.get("start_line") + if path and line is not None: + keys.add(f"{path}:{line}") + return keys + + prev = collect_keys(prev_round_no) + curr = collect_keys(curr_round_no) + if not curr: + info("⏭ 現ラウンドの payload なし: 振動検知スキップ") + sys.exit(2) + overlap = prev & curr + ratio = len(overlap) / len(curr) + info(f"振動検知: overlap={len(overlap)}/{len(curr)} ({ratio:.0%})") + + if ratio >= 0.5: + st["final"] = "oscillation" + st["ended_at"] = _now() + _save(pr, st) + die(f"振動検知 — 同一箇所が {ratio:.0%} 重複。中断。", code=4) + sys.exit(2) + + +def _count(v: Any) -> int: + """int(件数) でも list でも None でも件数(int)に正規化する。 + + fix 結果スキーマ上 deferred/rejected/resolved_threads は list が正だが、 + fix サブエージェントが int(件数) を書いてしまうケースがあり、その場合に + len() が `TypeError: object of type 'int' has no len()` で落ちるのを防ぐ。 + """ + if isinstance(v, bool): + # bool は int のサブクラスだが件数として扱わない + return 0 + if isinstance(v, int): + return v + if isinstance(v, dict): + # LLM が単一要素を list ではなく dict 単体で返すケースを 1 件として扱う。 + return 1 + if isinstance(v, (list, tuple)): + return len(v) + if isinstance(v, str) and v.strip().isdigit(): + # LLM が件数を数値文字列 (例: "3" や " 3 ") で返すケースを許容する。 + # strip 後 isdigit() なので前後空白を許し、負号・小数点は引き続き弾く + # (件数は非負整数なので十分)。 + return int(v.strip()) + return 0 + + +def cmd_merge_fix(args: argparse.Namespace) -> None: + """Step 5 後段 — fix サブエージェント戻り値を state にマージ + CI 分類。 + + Exit code: 0=continue, 3=ci-code-fail (final=error) + """ + pr = args.pr + + # state を先に読み、fallback 検証用の round 開始時刻を取得する + # (round 開始前の古いファイルや、別リポジトリの同番号 PR の戻り値を + # 誤マージするのを防ぐ)。 + st = _load(pr) + if not st.get("rounds"): + die("state.rounds が空。`state.py start-round` を先に呼んでください", code=3) + round_started_ts = _round_started_unixtime(st["rounds"][-1]) + + # 戻り値ファイルの探索順: + # 1. --file 明示 (ユーザー指定なので mtime/pr 検証はスキップ) + # 2. $TMP_DIR/fix-pr-result.json (正規; _tmp_dir() 解決先) + # 3. /tmp/fix-pr-result.json (旧プロンプトで /tmp を指定したサブエージェント救済) + # 2, 3 は PR 番号だけで命名されているため、別 round / 別リポジトリの + # 古い結果を拾わないよう mtime と (あれば) JSON 内の `pr` で検証する。 + explicit = pathlib.Path(args.file) if args.file else None + canonical_path = _resolve_tmp_dir(pr) / f"fix-pr{pr}-result.json" + legacy_tmp_path = pathlib.Path(f"/tmp/fix-pr{pr}-result.json") + # (path, is_canonical) のタプル: 正規パス (canonical) の parse 失敗は die(code=3) する + fallback_candidates: list[tuple[pathlib.Path, bool]] = [ + (canonical_path, True), + (legacy_tmp_path, False), + ] + + ffile: pathlib.Path | None = None + fix: dict[str, Any] | None = None + if explicit is not None: + # codex round 4 指摘: `--file` 明示時は fallback 探索に進まず即時失敗させる。 + # ユーザーが特定ファイルを指定しているのに、それが存在しない / 空 / JSON 不正 + # だった場合、無言で fallback に流れて別実行の戻り値を誤マージすると事故になる。 + if not explicit.exists(): + die( + f"--file で指定されたパスが存在しません: {explicit}", + code=3, + ) + if explicit.stat().st_size == 0: + die( + f"--file で指定されたファイルが空です: {explicit}", + code=3, + ) + try: + fix = json.loads(explicit.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + die( + f"--file 指定の fix 戻り値ファイルの読み取り / parse に失敗 " + f"({explicit}): {exc}", + code=3, + ) + # gemini round 3 指摘: `--file` で `list` 等の non-dict JSON が渡されると + # 後続の `fix.get(...)` でクラッシュする。即時 die(code=3) で中断。 + if not isinstance(fix, dict): + die( + f"--file 指定の fix 戻り値ファイルが dict ではない " + f"({explicit}, type={type(fix).__name__})。" + " fix サブエージェント出力の形式不正。", + code=3, + ) + ffile = explicit + # 明示指定は stale 検証スキップ + else: + for c, is_canonical in fallback_candidates: + if not (c.exists() and c.stat().st_size > 0): + continue + is_fresh, parsed = _is_fresh_fix_result(c, pr, round_started_ts, is_canonical=is_canonical) + if not is_fresh: + continue + ffile = c + fix = parsed # 既にパース済みのデータを再利用 (gemini round 2 指摘の性能改善) + break + + if ffile is None or fix is None: + checked = ([str(explicit)] if explicit else []) + [str(c) for c, _ in fallback_candidates] + die( + "fix サブエージェントが戻り値ファイルを生成しなかった " + f"(checked: {checked})", + code=3, + ) + + # key 名 fallback (サブエージェントが別名で書いた場合の救済)。 + # 正規は fix_commit / fixed_count、別名は commit_sha / fixed のみ受理する。 + fix_commit = fix.get("fix_commit") or fix.get("commit_sha") + fixed_count = fix.get("fixed_count") + if fixed_count is None: + fixed_count = fix.get("fixed", 0) + + # `st` は冒頭の fallback 検証で既に load 済み。 + round_no = st["rounds"][-1]["round"] + + # deferred は list が正だが、LLM がスキーマを無視して文字列リスト + # (例: ["nit: ..."]) や単一 dict、int(件数) を返すケースがある。後段の + # deferred_nits 展開ループは dict 以外をスキップするため、まず dict 要素のみへ + # 正規化する (単一 dict は 1 件として包む)。 + _deferred_raw = fix.get("deferred") + if isinstance(_deferred_raw, list): + _deferred_nits = [d for d in _deferred_raw if isinstance(d, dict)] + elif isinstance(_deferred_raw, dict): # 単一 dict フォールバック (gemini #3) + _deferred_nits = [_deferred_raw] + else: + _deferred_nits = [] + + # 保存件数の単一整合ルール: + # - 構造化データ (list / dict) は per-item を保持できるので、展開件数 + # (len(_deferred_nits)) を保存し deferred_nits の件数と一致させる。 + # - int / 数値文字列は per-item データを失った「劣化表現」なので、件数を + # 失わないよう _count() の値を保存する (展開はできないので nits は空)。 + if isinstance(_deferred_raw, (list, dict)): + _deferred_count = len(_deferred_nits) + else: + _deferred_count = _count(_deferred_raw) + + st["rounds"][-1]["fix"] = { + "commit": fix_commit, + "fixed": fixed_count, + # deferred は上記の単一整合ルールで算出した件数を保存する。 + # resolved_threads / rejected は件数しか保存せず後段ループが無いため _count() で可。 + "deferred": _deferred_count, + "rejected": _count(fix.get("rejected")), + "resolved_threads": _count(fix.get("resolved_threads")), + "ci": fix.get("ci_status"), + "ci_failed_checks": fix.get("ci_failed_checks", []) or [], + "ci_note": fix.get("ci_note"), + "by_severity": fix.get("by_severity", {}), + } + st["rounds"][-1]["ended_at"] = _now() + for d in _deferred_nits: + st["deferred_nits"].append({**d, "pr": pr, "round": round_no}) + _save(pr, st) + + # CI 分類 + if (fix.get("ci_status") or "").upper() != "FAILURE": + info(f"✅ fix マージ完了 (commit={fix_commit} fixed={fixed_count})") + return + + code_patterns = ("pint", "larastan", "phpstan", "test", "lint", "type", + "build", "ruff", "eslint", "tsc", "mypy") + meta_patterns = ("check_pr_requirements", "assignees", "reviewers", "labels", "meta") + failed = fix.get("ci_failed_checks") or [] + code_fail = False + for name in failed: + low = name.lower() + if any(p in low for p in meta_patterns): + continue + elif any(p in low for p in code_patterns): + code_fail = True + else: + code_fail = True # 不明は code-fail(保守的) + + if code_fail: + st["final"] = "error" + st["ended_at"] = _now() + _save(pr, st) + die(f"コード関連 CI 失敗。中断: {failed}", code=3) + + # meta only: 継続 + note = f"メタチェックのみ失敗: {failed} — コードと無関係のため継続" + st["rounds"][-1]["fix"]["ci_note"] = note + _save(pr, st) + info(f"⚠ メタチェックのみ失敗 ({failed}) — 継続") + + +def cmd_should_rotate(args: argparse.Namespace) -> None: + """Step 6 — PR ローテーション要否。Exit 0=rotate, 2=keep. + + 判定は ``round_in_pr >= rotate_after && total < max_rounds`` のみで、 + rotate-pr.sh の ``--mode light|squash`` どちらでも同じ条件を使う。 + state.json の key は ``STATE_PR`` (最初に init した PR 番号) で固定なので、 + light モードで head_branch が変わらない場合でも整合する。 + """ + pr = args.pr + st = _load(pr) + current_pr = st["current_pr"] + round_in_pr = sum(1 for r in st["rounds"] if r["pr"] == current_pr) + total = len(st["rounds"]) + rotate_after = st["rotate_after"] + max_r = st["max_rounds"] + if round_in_pr >= rotate_after and total < max_r: + info(f"🔄 PR #{current_pr} が {round_in_pr} round 経過 — ローテーション必要") + print(f"CURRENT_PR={current_pr}") + print(f"ROUND_IN_PR={round_in_pr}") + sys.exit(0) + sys.exit(2) + + +def cmd_set_current_pr(args: argparse.Namespace) -> None: + """PR ローテーション完了後の state 更新。 + + rotate-pr.sh の light / squash どちらでも、新 PR 番号を受け取って + ``current_pr`` を切り替え、``pr_history`` に新 PR エントリを追加する。 + state.json のファイル名は ``STATE_PR`` (= ``args.pr``) ベースで不変なので、 + light モードで head_branch が変わらないケースでも問題なく追跡できる。 + """ + pr = args.pr # 旧 PR (state file の key) + new_pr = args.new_pr + st = _load(pr) + old_pr = st["current_pr"] + now = _now() + # 旧 PR の history を closed に + for h in st["pr_history"]: + if h["pr"] == old_pr and h["closed_at"] is None: + h["closed_at"] = now + h["rounds"] = sum(1 for r in st["rounds"] if r["pr"] == old_pr) + break + st["pr_history"].append({"pr": new_pr, "opened_at": now, "closed_at": None, "rounds": 0}) + st["current_pr"] = new_pr + _save(pr, st) + info(f"✅ current_pr: {old_pr} → {new_pr}") + + +def cmd_report(args: argparse.Namespace) -> None: + """Step 8 — deferred nit + ラウンドサマリ表示。""" + pr = args.pr + st = _load(pr) + final = st.get("final") or "in_progress" + total = len(st["rounds"]) + prs = [h["pr"] for h in st["pr_history"]] + rotated = max(0, len(prs) - 1) + + print(f"## 最終ステータス: {final}") + print(f"## 総ラウンド数: {total} / PR数: {len(prs)} (rotated {rotated} 回)") + print() + print("## PR 履歴") + for h in st["pr_history"]: + state_str = "closed" if h.get("closed_at") else "open" + print(f"- #{h['pr']} ({state_str}, {h.get('rounds', 0)} rounds)") + print() + print("## ラウンドサマリ") + print("| round | PR | codex | gemini | fix | CI |") + print("|---|---|---|---|---|---|") + for r in st["rounds"]: + codex = r.get("codex") or {} + gemini = r.get("gemini") or {} + fix = r.get("fix") or {} + codex_s = f"{codex.get('intent', '-')} ({codex.get('comments', '-')})" if codex else "-" + gemini_s = f"{gemini.get('intent', '-')} ({gemini.get('comments', '-')})" if gemini else "-" + fix_s = "-" + if fix: + fix_s = f"{(fix.get('commit') or '')[:7]} ({fix.get('fixed', 0)} fixed, {fix.get('deferred', 0)} deferred)" + ci_s = fix.get("ci") or "-" + print(f"| {r['round']} | #{r['pr']} | {codex_s} | {gemini_s} | {fix_s} | {ci_s} |") + print() + + nits = st.get("deferred_nits") or [] + if nits: + print(f"## 残 deferred nit ({len(nits)} 件)") + for n in nits: + print(f"- [{n.get('severity')}] {n.get('path')}:{n.get('line')} — {n.get('summary')}") + print() + print("これらの nit を一括対応する場合は再度 `/ndf:fix ` を起動してください。") + else: + print("## 残 deferred nit: なし") + + +# ---------------- main ---------------- + +def main() -> None: + p = argparse.ArgumentParser(description=__doc__) + sub = p.add_subparsers(dest="cmd", required=True) + + sp = sub.add_parser("init", help="Step 0 — state 初期化 or 再開") + sp.add_argument("pr", type=int) + sp.add_argument("--max-rounds", type=int, default=12) + sp.add_argument("--rotate-after", type=int, default=8) + sp.add_argument("--only", choices=["codex", "gemini"], default=None) + sp.add_argument("--worktree", default=None) + sp.add_argument( + "--focus", + default=None, + help="追加レビュー観点。例: ドキュメントとコードの整合性を重点的に確認", + ) + sp.add_argument( + "--extra-instructions-file", + default=None, + help="追加レビュー観点を記載した UTF-8 テキストファイル", + ) + sp.set_defaults(func=cmd_init) + + sp = sub.add_parser("start-round", help="Step 1 — round 開始判定") + sp.add_argument("pr", type=int) + sp.set_defaults(func=cmd_start_round) + + sp = sub.add_parser("read-result", help="Step 2.5 — review result を state にマージ") + sp.add_argument("pr", type=int) + sp.add_argument("agent", choices=["codex", "gemini"]) + sp.add_argument("--file", default=None) + sp.set_defaults(func=cmd_read_result) + + sp = sub.add_parser("judge", help="Step 3 — intent ベース pass 判定 (0=approved/2=continue)") + sp.add_argument("pr", type=int) + sp.set_defaults(func=cmd_judge) + + sp = sub.add_parser("check-oscillation", help="Step 4 — path:line 重複率を計算") + sp.add_argument("pr", type=int) + sp.set_defaults(func=cmd_check_oscillation) + + sp = sub.add_parser("merge-fix", help="Step 5 post — fix 戻り値マージ + CI 分類") + sp.add_argument("pr", type=int) + sp.add_argument("--file", default=None) + sp.set_defaults(func=cmd_merge_fix) + + sp = sub.add_parser("should-rotate", help="Step 6 — rotate 要否 (0=rotate/2=keep)") + sp.add_argument("pr", type=int) + sp.set_defaults(func=cmd_should_rotate) + + sp = sub.add_parser("set-current-pr", help="rotation 後の current_pr 更新") + sp.add_argument("pr", type=int, help="state file の元 PR") + sp.add_argument("new_pr", type=int) + sp.set_defaults(func=cmd_set_current_pr) + + sp = sub.add_parser("report", help="Step 8 — deferred nit + サマリ表示") + sp.add_argument("pr", type=int) + sp.set_defaults(func=cmd_report) + + args = p.parse_args() + args.func(args) + + +if __name__ == "__main__": + main() diff --git a/plugins/ndf-claude/skills/cross-review/scripts/wait-review.sh b/plugins/ndf-claude/skills/cross-review/scripts/wait-review.sh new file mode 100755 index 0000000..8741d36 --- /dev/null +++ b/plugins/ndf-claude/skills/cross-review/scripts/wait-review.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# Wait for codex / gemini review processes — monitor.py の薄いラッパ。 +# +# Usage: wait-review.sh [codex|gemini|both] [--timeout SEC] [--stall-timeout SEC] +# +# 既定値: +# timeout 1800s (= 30 min) env MONITOR_TIMEOUT で上書き +# stall-timeout 600s (= 10 min) env MONITOR_STALL で上書き +# poll 15s env MONITOR_POLL で上書き +# +# Exit codes は monitor.py に準拠: +# 0 OK +# 1 USAGE / IO error +# 2 TIMEOUT +# 3 NO_RESULT (プロセス終了したが result.json 未生成) +# 4 EARLY_ERROR (err.log に致命的パターン) +# 5 STALLED (err.log 進捗なし) +# 6 PIDFILE_BAD (pidfile 不正 / プロセス未起動) +# +# 旧 wait_codex / wait_gemini (sentinel + pidfile のみ) は信頼性が低かったため +# Python 側に多軸監視を集約した。本ラッパは既存呼び出し互換のために残す。 + +set -euo pipefail + +PR=${1:?PR required} +TARGET=${2:-both} +shift 2 || shift $# + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +exec "$SCRIPT_DIR/monitor.py" "$PR" "$TARGET" "$@" diff --git a/plugins/ndf-claude/skills/cross-review/tests/__init__.py b/plugins/ndf-claude/skills/cross-review/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/ndf-claude/skills/cross-review/tests/conftest.py b/plugins/ndf-claude/skills/cross-review/tests/conftest.py new file mode 100644 index 0000000..a5b257b --- /dev/null +++ b/plugins/ndf-claude/skills/cross-review/tests/conftest.py @@ -0,0 +1,47 @@ +"""pytest 共通フィクスチャ。 + +`scripts/state.py` は uv self-contained script として `#!/usr/bin/env -S uv run --script` +で起動される運用だが、テストでは関数を直接 import したい。 +importlib.util で source loader 経由で読み込む。 +""" +from __future__ import annotations + +import importlib.util +import pathlib +import sys +import types + +_HERE = pathlib.Path(__file__).resolve().parent +_SCRIPT = _HERE.parent / "scripts" / "state.py" +_MONITOR = _HERE.parent / "scripts" / "monitor.py" + + +def _load_module(name: str, path: pathlib.Path) -> types.ModuleType: + spec = importlib.util.spec_from_file_location(name, path) + if spec is None or spec.loader is None: + raise RuntimeError(f"failed to load {path}") + mod = importlib.util.module_from_spec(spec) + sys.modules[name] = mod + spec.loader.exec_module(mod) + return mod + + +def _load_state_module() -> types.ModuleType: + return _load_module("cross_review_state", _SCRIPT) + + +def _load_monitor_module() -> types.ModuleType: + return _load_module("cross_review_monitor", _MONITOR) + + +import pytest + + +@pytest.fixture(scope="session") +def state_mod() -> types.ModuleType: + return _load_state_module() + + +@pytest.fixture(scope="session") +def monitor_mod() -> types.ModuleType: + return _load_monitor_module() diff --git a/plugins/ndf-claude/skills/cross-review/tests/test_default_worktree_base.py b/plugins/ndf-claude/skills/cross-review/tests/test_default_worktree_base.py new file mode 100644 index 0000000..b4d38da --- /dev/null +++ b/plugins/ndf-claude/skills/cross-review/tests/test_default_worktree_base.py @@ -0,0 +1,41 @@ +"""state.py `_default_worktree_base()` / `_repo_slug()` の解決テスト。 + +優先順位: + 1. 環境変数 NDF_WORKTREE_BASE + 2. <システム tmpdir>/ndf-worktrees (非永続領域) + +worktree path は `/--/pr` 形式で、他リポジトリの +同一 PR 番号と衝突しない。 +""" +from __future__ import annotations + +import pathlib + + +def test_env_override_takes_precedence(monkeypatch, tmp_path, state_mod): + explicit = tmp_path / "custom-base" + monkeypatch.setenv("NDF_WORKTREE_BASE", str(explicit)) + assert state_mod._default_worktree_base() == explicit + + +def test_default_is_tmpdir_ndf_worktrees(monkeypatch, tmp_path, state_mod): + """既定では永続 volume ではなくシステム tmpdir 配下を使う。""" + monkeypatch.delenv("NDF_WORKTREE_BASE", raising=False) + monkeypatch.setattr(state_mod.tempfile, "gettempdir", lambda: str(tmp_path)) + assert state_mod._default_worktree_base() == tmp_path / "ndf-worktrees" + + +def test_repo_slug_is_path_safe(state_mod): + assert state_mod._repo_slug("devbasex/ai-plugins") == "devbasex--ai-plugins" + + +def test_is_registered_worktree_rejects_foreign_dir(monkeypatch, tmp_path, state_mod): + """`git worktree list` に載っていないパスは流用しない (別リポジトリの残骸対策)。""" + registered = tmp_path / "registered" + foreign = tmp_path / "foreign" + monkeypatch.setattr( + state_mod, "_sh", + lambda cmd, check=True: f"worktree {registered}\nHEAD abc\n", + ) + assert state_mod._is_registered_worktree(str(registered)) is True + assert state_mod._is_registered_worktree(str(foreign)) is False diff --git a/plugins/ndf-claude/skills/cross-review/tests/test_monitor_early_error.py b/plugins/ndf-claude/skills/cross-review/tests/test_monitor_early_error.py new file mode 100644 index 0000000..a7e3f6b --- /dev/null +++ b/plugins/ndf-claude/skills/cross-review/tests/test_monitor_early_error.py @@ -0,0 +1,126 @@ +"""monitor.py `_scan_early_fatal()` の誤検知防止テスト。 + +回帰: SKILL.md / docs/*.md 内の以下のような行は **fatal 検知してはいけない**。 +- Markdown 表セル (`| ... | quota exceeded ... |`) +- backtick で囲まれた quote (`` `quota exceeded` ``) +- 日本語クォートで囲まれた quote (`「quota exceeded」`) +- ダブル/シングルクォートで囲まれた **文字列リテラル** (`"quota exceeded: ..."`) +- grep / ripgrep 形式の **ソース引用行** (`path/to/file.py:22: "quota exceeded..."`) +codex がレビュー対象の doc / テストコードを echo した際に上記が err.log に書かれることで、 +従来の monitor.py は無関係なドキュメント・コード記述を fatal とみなしてプロセスを kill していた。 +""" +from __future__ import annotations + +import pathlib + + +def _write(p: pathlib.Path, content: str) -> pathlib.Path: + p.write_text(content, encoding="utf-8") + return p + + +def test_real_quota_exceeded_is_detected(tmp_path, monitor_mod): + """行頭の本物 `quota exceeded` は依然として fatal 扱い。""" + log = _write(tmp_path / "err.log", "quota exceeded: please upgrade\n") + assert monitor_mod._scan_early_fatal(log) is not None + + +def test_markdown_table_row_is_benign(tmp_path, monitor_mod): + """Markdown 表セル行内のキーワードは doc 引用として無視。""" + log = _write( + tmp_path / "err.log", + "| early-error | `^Error:` / 「quota exceeded」「sandbox error」を含む行 |\n", + ) + assert monitor_mod._scan_early_fatal(log) is None + + +def test_backtick_quoted_keyword_is_benign(tmp_path, monitor_mod): + """backtick 引用内のキーワードは doc 引用として無視。""" + log = _write( + tmp_path / "err.log", + "explanation of `quota exceeded` pattern handling here\n", + ) + assert monitor_mod._scan_early_fatal(log) is None + + +def test_japanese_quote_wrapped_keyword_is_benign(tmp_path, monitor_mod): + """日本語「」内のキーワードは doc 引用として無視。""" + log = _write( + tmp_path / "err.log", + "「quota exceeded」を含む行を検出 (diff/doc 引用文中の同語句は誤検知しない)\n", + ) + assert monitor_mod._scan_early_fatal(log) is None + + +def test_sandbox_error_in_table_is_benign(tmp_path, monitor_mod): + """表セル内の `sandbox error` も無視。""" + log = _write( + tmp_path / "err.log", + "| pattern | codex 固有: 「sandbox error」を含む行を検出 |\n", + ) + assert monitor_mod._scan_early_fatal(log) is None + + +def test_real_sandbox_error_still_detected(tmp_path, monitor_mod): + """素の `sandbox error` は依然として fatal 扱い。""" + log = _write(tmp_path / "err.log", "Internal sandbox error: cannot start\n") + assert monitor_mod._scan_early_fatal(log) is not None + + +def test_grep_style_source_citation_is_benign(tmp_path, monitor_mod): + """codex がレビュー対象のテストコードを grep 形式 (`path.py:22: code`) で + echo した行は fatal 扱いしない。 + + 回帰: 本 skill 自身の tests/test_monitor_early_error.py を cross-review した際、 + `quota exceeded` を含むテスト用文字列リテラル行が echo され、誤って + EARLY_ERROR で codex が kill された (PR #23 round 2 で実際に発生)。 + """ + log = _write( + tmp_path / "err.log", + '/work/worktrees/pr23/plugins/ndf/skills/cross-review/tests/' + 'test_monitor_early_error.py:22: log = _write(tmp_path / "err.log", ' + '"quota exceeded: please upgrade\\n")\n', + ) + assert monitor_mod._scan_early_fatal(log) is None + + +def test_double_quoted_string_literal_is_benign(tmp_path, monitor_mod): + """ダブルクォート文字列リテラル内のキーワードは benign(コード片の echo)。""" + log = _write( + tmp_path / "err.log", + ' raise RuntimeError("quota exceeded: please upgrade")\n', + ) + assert monitor_mod._scan_early_fatal(log) is None + + +def test_single_quoted_string_literal_is_benign(tmp_path, monitor_mod): + """シングルクォート文字列リテラル内のキーワードも benign。""" + log = _write( + tmp_path / "err.log", + " msg = 'sandbox error occurred in test fixture'\n", + ) + assert monitor_mod._scan_early_fatal(log) is None + + +def test_match_is_quoted_double_quote(monitor_mod): + """`_match_is_quoted()` がダブルクォート文字列リテラルを検出する。""" + line = 'raise RuntimeError("quota exceeded: please upgrade")' + start = line.index("quota") + end = start + len("quota exceeded") + assert monitor_mod._match_is_quoted(line, start, end) + + +def test_match_is_quoted_helper(monitor_mod): + """`_match_is_quoted()` 単体テスト。""" + line_backtick = "see `quota exceeded` doc" + start = line_backtick.index("quota") + end = start + len("quota exceeded") + assert monitor_mod._match_is_quoted(line_backtick, start, end) + + line_jp = "「quota exceeded」と書かれた行" + start = line_jp.index("quota") + end = start + len("quota exceeded") + assert monitor_mod._match_is_quoted(line_jp, start, end) + + line_raw = "quota exceeded happened" + assert not monitor_mod._match_is_quoted(line_raw, 0, len("quota exceeded")) diff --git a/plugins/ndf-claude/skills/cross-review/tests/test_monitor_import_safety.py b/plugins/ndf-claude/skills/cross-review/tests/test_monitor_import_safety.py new file mode 100644 index 0000000..36efc48 --- /dev/null +++ b/plugins/ndf-claude/skills/cross-review/tests/test_monitor_import_safety.py @@ -0,0 +1,105 @@ +"""monitor.py の import 時 env 評価 safety テスト (PLAN21 round 6 / 最終). + +codex round 5 指摘: `DEFAULT_TIMEOUT` / `DEFAULT_STALL` / `DEFAULT_POLL` を +`int(os.environ.get(name, ...))` で **import 時** に評価していると、 +`MONITOR_STALL=abc` のような非数値 env が設定されているだけで +`_agent_stall_default()` に到達する前に `int()` の `ValueError` で +monitor.py の import そのものが落ち、監視プロセスが起動できなくなる。 + +修正方針: + - `DEFAULT_TIMEOUT` / `DEFAULT_STALL` / `DEFAULT_POLL` は import 時に + **固定数値** (420 / 180 / 15) で保持する + - env の解釈は `_agent_stall_default()` / `_safe_int_env()` 内で + try/except 付きで行う (既に round 4 で実装済み) + +本テストは fixture 経由ではなく **毎回 fresh に source loader で import** し、 +非数値 env が設定された状態でも import が成功することを確認する。 +""" +from __future__ import annotations + +import importlib.util +import pathlib +import sys +import types + +import pytest + + +_HERE = pathlib.Path(__file__).resolve().parent +_MONITOR = _HERE.parent / "scripts" / "monitor.py" + + +def _fresh_import_monitor(name: str = "cross_review_monitor_fresh") -> types.ModuleType: + """毎回 fresh に monitor.py を import する (sys.modules キャッシュを使わない)。 + + session-scoped fixture (`monitor_mod`) は最初の import 時の env を保持してしまい + 本テストでは「非数値 env がセットされた状態での import が安全か」を検証できない。 + """ + sys.modules.pop(name, None) + spec = importlib.util.spec_from_file_location(name, _MONITOR) + assert spec is not None and spec.loader is not None + mod = importlib.util.module_from_spec(spec) + sys.modules[name] = mod + spec.loader.exec_module(mod) + return mod + + +def test_import_succeeds_with_non_numeric_monitor_stall(monkeypatch): + """MONITOR_STALL=abc でも monitor.py の import が成功すること (codex round 5).""" + monkeypatch.setenv("MONITOR_STALL", "abc") + mod = _fresh_import_monitor("cross_review_monitor_fresh_stall") + # 固定 default が保たれる + assert mod.DEFAULT_STALL == 180 + # 呼び出し時の解決も builtin にフォールバック (既存 round 4 挙動と同じ) + assert mod._agent_stall_default("codex") == 180 + assert mod._agent_stall_default("gemini") == 480 + + +def test_import_succeeds_with_non_numeric_monitor_timeout(monkeypatch): + """MONITOR_TIMEOUT=xxx でも monitor.py の import が成功すること.""" + monkeypatch.setenv("MONITOR_TIMEOUT", "xxx") + mod = _fresh_import_monitor("cross_review_monitor_fresh_timeout") + # 固定 default が保たれる + assert mod.DEFAULT_TIMEOUT == 420 + # safe parse ヘルパも fallback を返す + assert mod._safe_int_env("MONITOR_TIMEOUT", mod.DEFAULT_TIMEOUT) == 420 + + +def test_import_succeeds_with_non_numeric_monitor_poll(monkeypatch): + """MONITOR_POLL=foo でも monitor.py の import が成功すること.""" + monkeypatch.setenv("MONITOR_POLL", "foo") + mod = _fresh_import_monitor("cross_review_monitor_fresh_poll") + assert mod.DEFAULT_POLL == 15 + assert mod._safe_int_env("MONITOR_POLL", mod.DEFAULT_POLL) == 15 + + +def test_import_succeeds_with_all_non_numeric_envs(monkeypatch): + """3 つ全て非数値でも import に失敗しないこと (組み合わせ防御).""" + monkeypatch.setenv("MONITOR_STALL", "abc") + monkeypatch.setenv("MONITOR_TIMEOUT", "xxx") + monkeypatch.setenv("MONITOR_POLL", "???") + mod = _fresh_import_monitor("cross_review_monitor_fresh_all") + assert mod.DEFAULT_STALL == 180 + assert mod.DEFAULT_TIMEOUT == 420 + assert mod.DEFAULT_POLL == 15 + + +def test_safe_int_env_returns_value_when_numeric(monkeypatch, monitor_mod): + """数値が設定されている場合は素直に int を返す.""" + monkeypatch.setenv("SOME_TEST_INT_ENV", "42") + assert monitor_mod._safe_int_env("SOME_TEST_INT_ENV", 1) == 42 + + +def test_safe_int_env_returns_fallback_when_missing(monkeypatch, monitor_mod): + """env が未設定なら fallback を返す.""" + monkeypatch.delenv("SOME_TEST_INT_ENV", raising=False) + assert monitor_mod._safe_int_env("SOME_TEST_INT_ENV", 99) == 99 + + +def test_safe_int_env_warns_on_non_numeric(monkeypatch, monitor_mod, capsys): + """非数値時は stderr に警告を出して fallback を返す.""" + monkeypatch.setenv("SOME_TEST_INT_ENV", "not-a-number") + assert monitor_mod._safe_int_env("SOME_TEST_INT_ENV", 7) == 7 + captured = capsys.readouterr() + assert "SOME_TEST_INT_ENV" in captured.err + assert "int に変換できません" in captured.err diff --git a/plugins/ndf-claude/skills/cross-review/tests/test_monitor_progress_log.py b/plugins/ndf-claude/skills/cross-review/tests/test_monitor_progress_log.py new file mode 100644 index 0000000..7b98015 --- /dev/null +++ b/plugins/ndf-claude/skills/cross-review/tests/test_monitor_progress_log.py @@ -0,0 +1,23 @@ +"""monitor.py の progress.log heartbeat 補助関数テスト。""" + + +def test_tail_last_nonempty_line_returns_latest_marker(tmp_path, monitor_mod): + log = tmp_path / "gemini-review-pr1-progress.log" + log.write_text("start: review PR #1\n\nscan: diff\n \npost: submit review\n") + + assert monitor_mod._tail_last_nonempty_line(log) == "post: submit review" + + +def test_tail_last_nonempty_line_handles_missing_file(tmp_path, monitor_mod): + assert monitor_mod._tail_last_nonempty_line(tmp_path / "missing.log") == "" + + +def test_safe_size_handles_missing_file(tmp_path, monitor_mod): + assert monitor_mod._safe_size(tmp_path / "missing.log") == 0 + + +def test_tail_last_nonempty_line_drops_partial_first_line(tmp_path, monitor_mod): + log = tmp_path / "gemini-review-pr1-progress.log" + log.write_text("partial line without beginning\npost: submit review\n", encoding="utf-8") + + assert monitor_mod._tail_last_nonempty_line(log, limit=28) == "post: submit review" diff --git a/plugins/ndf-claude/skills/cross-review/tests/test_monitor_result_age.py b/plugins/ndf-claude/skills/cross-review/tests/test_monitor_result_age.py new file mode 100644 index 0000000..f3e2a28 --- /dev/null +++ b/plugins/ndf-claude/skills/cross-review/tests/test_monitor_result_age.py @@ -0,0 +1,330 @@ +"""monitor.py の result.json + age fallback (RESULT_AGE_GRACE) テスト。 + +gemini がレビュー完了後にプロセスがハングするケースで、result.json の +mtime が RESULT_AGE_GRACE 秒以上前なら OK 判定する fallback の検証。 +""" +from __future__ import annotations + +import json +import os +import pathlib +import time +from unittest import mock + + +def test_result_age_grace_constant(monitor_mod): + assert hasattr(monitor_mod, "RESULT_AGE_GRACE") + assert monitor_mod.RESULT_AGE_GRACE == 30 + + +def test_gemini_result_age_fallback_triggers_ok(monitor_mod, tmp_path): + """result.json が RESULT_AGE_GRACE 秒以上前に書かれていれば OK で返る。""" + pr = 999 + agent = "gemini" + base = tmp_path / f"{agent}-review-pr{pr}" + + pidfile = pathlib.Path(f"{base}.pid") + err_log = pathlib.Path(f"{base}-err.log") + stdout_log = pathlib.Path(f"{base}-stdout.log") + result = pathlib.Path(f"{base}-result.json") + + pidfile.write_text("12345") + err_log.write_text("YOLO mode is enabled.\n") + stdout_log.write_text("") + result.write_text(json.dumps({"event": "APPROVE", "comments_count": 0})) + # time.time() を side_effect で進める: + # call 1 (started_wall): base_time + # call 2+ (result age check): base_time + 40 + # result_mtime = base_time + 5 → started_wall(base_time) 以降かつ age=35s >= 30s + base_time = time.time() + result_mtime = base_time + 5 + os.utime(result, (result_mtime, result_mtime)) + + time_call_count = [0] + def fake_time(): + time_call_count[0] += 1 + if time_call_count[0] == 1: + return base_time + return base_time + 40 + + kill_called = [] + poll_count = [0] + + def fake_pid_alive(pid): + return True + + def fake_kill_pid(pid, sigterm_grace=3.0): + kill_called.append(pid) + + def fake_sleep(s): + poll_count[0] += 1 + if poll_count[0] > 5: + raise RuntimeError("too many polls — fallback should have triggered") + + with ( + mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), + mock.patch.object(monitor_mod, "_pid_alive", side_effect=fake_pid_alive), + mock.patch.object(monitor_mod, "_kill_pid", side_effect=fake_kill_pid), + mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=True), + mock.patch("time.sleep", side_effect=fake_sleep), + mock.patch("time.time", side_effect=fake_time), + ): + st = monitor_mod.monitor_agent( + agent=agent, pr=pr, + timeout=420, stall_timeout=480, poll=15, + require_result=True, no_early_error=True, + ) + + assert st.status == "OK" + assert st.exit_code == 0 + assert st.result_exists is True + assert "result.json exists for" in st.detail + assert 12345 in kill_called + + +def test_gemini_result_age_too_young_continues(monitor_mod, tmp_path): + """result.json が書かれたばかり (age < RESULT_AGE_GRACE) なら fallback しない。""" + pr = 998 + agent = "gemini" + base = tmp_path / f"{agent}-review-pr{pr}" + + pidfile = pathlib.Path(f"{base}.pid") + err_log = pathlib.Path(f"{base}-err.log") + stdout_log = pathlib.Path(f"{base}-stdout.log") + result = pathlib.Path(f"{base}-result.json") + + pidfile.write_text("12346") + err_log.write_text("YOLO mode is enabled.\n") + stdout_log.write_text("") + result.write_text(json.dumps({"event": "APPROVE", "comments_count": 0})) + # mtime を 5 秒前に設定 (RESULT_AGE_GRACE=30 未満) + recent_mtime = time.time() - 5 + os.utime(result, (recent_mtime, recent_mtime)) + + poll_count = [0] + + def fake_pid_alive(pid): + if poll_count[0] >= 2: + return False + return True + + def fake_sleep(s): + poll_count[0] += 1 + + with ( + mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), + mock.patch.object(monitor_mod, "_pid_alive", side_effect=fake_pid_alive), + mock.patch.object(monitor_mod, "_kill_pid"), + mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=None), + mock.patch("time.sleep", side_effect=fake_sleep), + ): + st = monitor_mod.monitor_agent( + agent=agent, pr=pr, + timeout=420, stall_timeout=480, poll=15, + require_result=True, no_early_error=True, + ) + + # result.json は young なので fallback せず、プロセス終了後の通常 OK になる + assert st.status == "OK" + assert st.exit_code == 0 + assert "process exited" in st.detail + + +def test_fresh_but_young_result_continues_monitoring(monitor_mod, tmp_path): + """started_wall 後に書かれたが age < RESULT_AGE_GRACE なら fallback せず監視継続。""" + pr = 994 + agent = "gemini" + base = tmp_path / f"{agent}-review-pr{pr}" + + pidfile = pathlib.Path(f"{base}.pid") + err_log = pathlib.Path(f"{base}-err.log") + stdout_log = pathlib.Path(f"{base}-stdout.log") + result = pathlib.Path(f"{base}-result.json") + + pidfile.write_text("12350") + err_log.write_text("YOLO mode is enabled.\n") + stdout_log.write_text("") + result.write_text(json.dumps({"event": "APPROVE", "comments_count": 0})) + + # time.time() side_effect: + # call 1 (started_wall): base_time + # call 2+ (age check): base_time + 10 → age = 5s < 30s + base_time = time.time() + result_mtime = base_time + 5 + os.utime(result, (result_mtime, result_mtime)) + + time_call_count = [0] + def fake_time(): + time_call_count[0] += 1 + if time_call_count[0] == 1: + return base_time + return base_time + 10 # age = 10 - 5 = 5s < 30s + + poll_count = [0] + + def fake_pid_alive(pid): + if poll_count[0] >= 2: + return False + return True + + def fake_sleep(s): + poll_count[0] += 1 + + with ( + mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), + mock.patch.object(monitor_mod, "_pid_alive", side_effect=fake_pid_alive), + mock.patch.object(monitor_mod, "_kill_pid"), + mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=True), + mock.patch("time.sleep", side_effect=fake_sleep), + mock.patch("time.time", side_effect=fake_time), + ): + st = monitor_mod.monitor_agent( + agent=agent, pr=pr, + timeout=420, stall_timeout=480, poll=15, + require_result=True, no_early_error=True, + ) + + # age < 30s なので fallback せず、プロセス終了後の通常 OK + assert st.status == "OK" + assert "process exited" in st.detail + + +def test_codex_sentinel_takes_priority(monitor_mod, tmp_path): + """codex は sentinel + result.json が先に発火し、age fallback は通らない。""" + pr = 997 + agent = "codex" + base = tmp_path / f"{agent}-review-pr{pr}" + + pidfile = pathlib.Path(f"{base}.pid") + err_log = pathlib.Path(f"{base}-err.log") + stdout_log = pathlib.Path(f"{base}-stdout.log") + result = pathlib.Path(f"{base}-result.json") + + pidfile.write_text("12347") + err_log.write_text("some output\ntokens used\n") + stdout_log.write_text("") + result.write_text(json.dumps({"event": "APPROVE", "comments_count": 0})) + old_mtime = time.time() - 60 + os.utime(result, (old_mtime, old_mtime)) + + kill_called = [] + + def fake_pid_alive(pid): + return True + + def fake_kill_pid(pid, sigterm_grace=3.0): + kill_called.append(pid) + + with ( + mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), + mock.patch.object(monitor_mod, "_pid_alive", side_effect=fake_pid_alive), + mock.patch.object(monitor_mod, "_kill_pid", side_effect=fake_kill_pid), + mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=None), + mock.patch("time.sleep"), + ): + st = monitor_mod.monitor_agent( + agent=agent, pr=pr, + timeout=420, stall_timeout=180, poll=15, + require_result=True, no_early_error=True, + ) + + assert st.status == "OK" + assert st.exit_code == 0 + # codex sentinel チェックによる OK + assert "codex sentinel" in st.detail + assert 12347 in kill_called + + +def test_stale_result_json_from_previous_round_ignored(monitor_mod, tmp_path): + """前 round の古い result.json (mtime < started_wall) は fallback しない。""" + pr = 996 + agent = "gemini" + base = tmp_path / f"{agent}-review-pr{pr}" + + pidfile = pathlib.Path(f"{base}.pid") + err_log = pathlib.Path(f"{base}-err.log") + stdout_log = pathlib.Path(f"{base}-stdout.log") + result = pathlib.Path(f"{base}-result.json") + + pidfile.write_text("12348") + err_log.write_text("YOLO mode is enabled.\n") + stdout_log.write_text("") + result.write_text(json.dumps({"event": "APPROVE", "comments_count": 0})) + # mtime を「現在の 120 秒前」に設定 → started_wall より前 → stale + stale_mtime = time.time() - 120 + os.utime(result, (stale_mtime, stale_mtime)) + + poll_count = [0] + + def fake_pid_alive(pid): + if poll_count[0] >= 2: + return False + return True + + def fake_sleep(s): + poll_count[0] += 1 + + with ( + mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), + mock.patch.object(monitor_mod, "_pid_alive", side_effect=fake_pid_alive), + mock.patch.object(monitor_mod, "_kill_pid"), + mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=True), + mock.patch("time.sleep", side_effect=fake_sleep), + ): + st = monitor_mod.monitor_agent( + agent=agent, pr=pr, + timeout=420, stall_timeout=480, poll=15, + require_result=True, no_early_error=True, + ) + + # stale result.json なので fallback せず、プロセス終了後の通常 OK + assert st.status == "OK" + assert "process exited" in st.detail + + +def test_cmdline_not_validated_skips_fallback(monitor_mod, tmp_path): + """cmdline 未検証 (None) だと age fallback は発火しない。""" + pr = 995 + agent = "gemini" + base = tmp_path / f"{agent}-review-pr{pr}" + + pidfile = pathlib.Path(f"{base}.pid") + err_log = pathlib.Path(f"{base}-err.log") + stdout_log = pathlib.Path(f"{base}-stdout.log") + result = pathlib.Path(f"{base}-result.json") + + pidfile.write_text("12349") + err_log.write_text("YOLO mode is enabled.\n") + stdout_log.write_text("") + result.write_text(json.dumps({"event": "APPROVE", "comments_count": 0})) + base_time = time.time() + result_mtime = base_time + 5 + os.utime(result, (result_mtime, result_mtime)) + + poll_count = [0] + + def fake_pid_alive(pid): + if poll_count[0] >= 2: + return False + return True + + def fake_sleep(s): + poll_count[0] += 1 + + with ( + mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), + mock.patch.object(monitor_mod, "_pid_alive", side_effect=fake_pid_alive), + mock.patch.object(monitor_mod, "_kill_pid"), + # cmdline_matches が None → cmdline_validated は False のまま + mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=None), + mock.patch("time.sleep", side_effect=fake_sleep), + ): + st = monitor_mod.monitor_agent( + agent=agent, pr=pr, + timeout=420, stall_timeout=480, poll=15, + require_result=True, no_early_error=True, + ) + + # cmdline 未検証なので fallback せず、プロセス終了後の通常 OK + assert st.status == "OK" + assert "process exited" in st.detail diff --git a/plugins/ndf-claude/skills/cross-review/tests/test_monitor_stall_default.py b/plugins/ndf-claude/skills/cross-review/tests/test_monitor_stall_default.py new file mode 100644 index 0000000..77a4f6b --- /dev/null +++ b/plugins/ndf-claude/skills/cross-review/tests/test_monitor_stall_default.py @@ -0,0 +1,119 @@ +"""monitor.py の per-agent stall timeout 既定値テスト (PLAN21). + +`_agent_stall_default(agent)` の解決優先度: + 1. env `MONITOR_STALL_` (per-agent 明示) + 2. env `MONITOR_STALL` (両 agent 共通) + 3. `DEFAULT_STALL_AGENT_BUILTIN[agent]` (codex=180, gemini=480) + +gemini は err.log にほぼ進捗を出さないため、ビルトイン既定を 480s と大きめに +取って 1 度目の STALLED 誤検知を避ける。codex は従来通り 180s で変更なし。 +""" +from __future__ import annotations + +import pytest + + +def test_builtin_default_codex(monkeypatch, monitor_mod): + """codex のビルトイン既定は 180s。""" + monkeypatch.delenv("MONITOR_STALL", raising=False) + monkeypatch.delenv("MONITOR_STALL_CODEX", raising=False) + monkeypatch.delenv("MONITOR_STALL_GEMINI", raising=False) + assert monitor_mod._agent_stall_default("codex") == 180 + + +def test_builtin_default_gemini(monkeypatch, monitor_mod): + """gemini のビルトイン既定は 480s (codex より大きい)。""" + monkeypatch.delenv("MONITOR_STALL", raising=False) + monkeypatch.delenv("MONITOR_STALL_CODEX", raising=False) + monkeypatch.delenv("MONITOR_STALL_GEMINI", raising=False) + assert monitor_mod._agent_stall_default("gemini") == 480 + + +def test_per_agent_env_overrides_builtin(monkeypatch, monitor_mod): + """env `MONITOR_STALL_GEMINI` 設定で gemini 既定が上書きされる。""" + monkeypatch.delenv("MONITOR_STALL", raising=False) + monkeypatch.setenv("MONITOR_STALL_GEMINI", "600") + assert monitor_mod._agent_stall_default("gemini") == 600 + # codex は影響を受けない + assert monitor_mod._agent_stall_default("codex") == 180 + + +def test_shared_env_applies_to_both(monkeypatch, monitor_mod): + """env `MONITOR_STALL` 共通指定は両 agent に同じ値が適用される (旧挙動互換)。 + + codex round 3 指摘: `_agent_stall_default()` は呼び出し時に env を再評価する + (import 時固定の `DEFAULT_STALL` ではなく `int(os.environ["MONITOR_STALL"])` を返す)。 + 本テストは monkeypatch で `MONITOR_STALL=240` に書き換え、両 agent が 240 を + 返すことを確認する (= 共通 env が実際に反映されることの検証)。 + """ + monkeypatch.delenv("MONITOR_STALL_CODEX", raising=False) + monkeypatch.delenv("MONITOR_STALL_GEMINI", raising=False) + monkeypatch.setenv("MONITOR_STALL", "240") + # 共通 env が両 agent に効く (per-agent 上書きなしの場合) + assert monitor_mod._agent_stall_default("codex") == 240 + assert monitor_mod._agent_stall_default("gemini") == 240 + + +def test_per_agent_env_takes_precedence_over_shared(monkeypatch, monitor_mod): + """per-agent env > 共通 env の優先順位を確認する。""" + monkeypatch.setenv("MONITOR_STALL", "240") + monkeypatch.setenv("MONITOR_STALL_GEMINI", "777") + monkeypatch.delenv("MONITOR_STALL_CODEX", raising=False) + assert monitor_mod._agent_stall_default("gemini") == 777 + # codex 側は per-agent env が無いので 共通 env (= 240) にフォールバック + assert monitor_mod._agent_stall_default("codex") == 240 + + +def test_unknown_agent_falls_back_to_default_stall(monkeypatch, monitor_mod): + """ビルトインに無い agent 名は `DEFAULT_STALL` にフォールバックする。""" + monkeypatch.delenv("MONITOR_STALL", raising=False) + monkeypatch.delenv("MONITOR_STALL_UNKNOWN", raising=False) + assert monitor_mod._agent_stall_default("unknown") == monitor_mod.DEFAULT_STALL + + +# ---------------- PLAN21 round 5: env が非数値だった場合のフォールバック ---------------- + + +def test_shared_env_non_numeric_falls_back_to_builtin(monkeypatch, monitor_mod, capsys): + """env `MONITOR_STALL` が非数値なら builtin にフォールバック (ValueError で落ちない)。 + + gemini round 4 指摘: `int(os.environ[...])` は非数値で ValueError を出す。 + 監視プロセスを env 設定ミスでクラッシュさせないため、try/except で builtin に戻す。 + """ + monkeypatch.delenv("MONITOR_STALL_CODEX", raising=False) + monkeypatch.delenv("MONITOR_STALL_GEMINI", raising=False) + monkeypatch.setenv("MONITOR_STALL", "abc") + # codex / gemini とも builtin 既定 (180 / 480) に戻る + assert monitor_mod._agent_stall_default("codex") == 180 + assert monitor_mod._agent_stall_default("gemini") == 480 + captured = capsys.readouterr() + # 警告メッセージが stderr に出る + assert "MONITOR_STALL" in captured.err + assert "int に変換できません" in captured.err + + +def test_per_agent_env_non_numeric_falls_back_to_builtin( + monkeypatch, monitor_mod, capsys +): + """env `MONITOR_STALL_` が非数値なら builtin にフォールバック。""" + monkeypatch.delenv("MONITOR_STALL", raising=False) + monkeypatch.setenv("MONITOR_STALL_GEMINI", "not-a-number") + monkeypatch.delenv("MONITOR_STALL_CODEX", raising=False) + # gemini は builtin (480) にフォールバック + assert monitor_mod._agent_stall_default("gemini") == 480 + # codex は env 未設定なので builtin (180) + assert monitor_mod._agent_stall_default("codex") == 180 + captured = capsys.readouterr() + assert "MONITOR_STALL_GEMINI" in captured.err + assert "int に変換できません" in captured.err + + +def test_per_agent_env_non_numeric_does_not_affect_other_agent( + monkeypatch, monitor_mod +): + """non-numeric な per-agent env は対象 agent だけに影響する。""" + monkeypatch.delenv("MONITOR_STALL", raising=False) + monkeypatch.setenv("MONITOR_STALL_GEMINI", "xxx") + monkeypatch.setenv("MONITOR_STALL_CODEX", "200") # codex 側は正常 + assert monitor_mod._agent_stall_default("codex") == 200 + assert monitor_mod._agent_stall_default("gemini") == 480 diff --git a/plugins/ndf-claude/skills/cross-review/tests/test_monitor_zombie.py b/plugins/ndf-claude/skills/cross-review/tests/test_monitor_zombie.py new file mode 100644 index 0000000..c15d2f4 --- /dev/null +++ b/plugins/ndf-claude/skills/cross-review/tests/test_monitor_zombie.py @@ -0,0 +1,91 @@ +"""monitor.py のゾンビプロセス検出テスト。 + +Docker without --init 環境では、nohup/disown で起動したプロセスが終了後に +ゾンビ化する。`_pid_alive()` が /proc//status の State: Z を検出して +False を返すことを検証する。 +""" +from __future__ import annotations + +import pathlib +from unittest import mock + + +def test_pid_alive_returns_false_for_zombie(monitor_mod): + """State: Z のプロセスに対して _pid_alive() が False を返す。""" + proc_status = "Name:\tbash\nState:\tZ (zombie)\nTgid:\t12345\nPid:\t12345\n" + + with ( + mock.patch("os.kill", return_value=None), + mock.patch.object( + pathlib.Path, "read_text", return_value=proc_status + ), + ): + assert monitor_mod._pid_alive(12345) is False + + +def test_pid_alive_returns_true_for_running(monitor_mod): + """State: S (sleeping) のプロセスに対して True を返す。""" + proc_status = "Name:\tgemini\nState:\tS (sleeping)\nTgid:\t12345\nPid:\t12345\n" + + with ( + mock.patch("os.kill", return_value=None), + mock.patch.object( + pathlib.Path, "read_text", return_value=proc_status + ), + ): + assert monitor_mod._pid_alive(12345) is True + + +def test_pid_alive_returns_true_for_running_state_r(monitor_mod): + """State: R (running) のプロセスに対して True を返す。""" + proc_status = "Name:\tgemini\nState:\tR (running)\nTgid:\t12345\nPid:\t12345\n" + + with ( + mock.patch("os.kill", return_value=None), + mock.patch.object( + pathlib.Path, "read_text", return_value=proc_status + ), + ): + assert monitor_mod._pid_alive(12345) is True + + +def test_pid_alive_returns_false_for_dead_process(monitor_mod): + """kill -0 が ProcessLookupError を返すプロセスに対して False。""" + with mock.patch("os.kill", side_effect=ProcessLookupError): + assert monitor_mod._pid_alive(99999) is False + + +def test_pid_alive_proc_unreadable_falls_back_to_alive(monitor_mod): + """/proc//status が読めない場合 (non-Linux) は kill -0 の結果のみで True。""" + with ( + mock.patch("os.kill", return_value=None), + mock.patch.object( + pathlib.Path, "read_text", side_effect=FileNotFoundError + ), + ): + assert monitor_mod._pid_alive(12345) is True + + +def test_is_zombie_helper(monitor_mod): + """_is_zombie() ヘルパーの動作確認。""" + zombie_status = "Name:\tbash\nState:\tZ (zombie)\nTgid:\t1\nPid:\t1\n" + alive_status = "Name:\tgemini\nState:\tS (sleeping)\nTgid:\t1\nPid:\t1\n" + + with mock.patch.object(pathlib.Path, "read_text", return_value=zombie_status): + assert monitor_mod._is_zombie(1) is True + + with mock.patch.object(pathlib.Path, "read_text", return_value=alive_status): + assert monitor_mod._is_zombie(1) is False + + with mock.patch.object(pathlib.Path, "read_text", side_effect=FileNotFoundError): + assert monitor_mod._is_zombie(1) is False + + +def test_kill_pid_skips_zombie(monitor_mod): + """ゾンビプロセスに対して _kill_pid() はシグナルを送らない。""" + with ( + mock.patch.object(monitor_mod, "_is_zombie", return_value=True), + mock.patch("os.kill") as mock_kill, + ): + monitor_mod._kill_pid(12345) + mock_kill.assert_not_called() diff --git a/plugins/ndf-claude/skills/cross-review/tests/test_state_auto_review_templates.py b/plugins/ndf-claude/skills/cross-review/tests/test_state_auto_review_templates.py new file mode 100644 index 0000000..c0dbd9e --- /dev/null +++ b/plugins/ndf-claude/skills/cross-review/tests/test_state_auto_review_templates.py @@ -0,0 +1,204 @@ +"""PR 変更ファイルに応じた自動レビュー観点の分類テスト。""" + +from __future__ import annotations + + +def test_parse_pr_files_payload_normalizes_gh_files_json(state_mod): + entries = state_mod._parse_pr_files_payload( + """ + { + "files": [ + {"path": "src/app.py", "changeType": "MODIFIED"}, + {"path": "docs/new.md", "previousPath": "docs/old.md", "changeType": "RENAMED"}, + {"path": "src/old.py", "changeType": "DELETED"} + ] + } + """ + ) + + assert entries == [ + {"status": "M", "paths": ["src/app.py"]}, + {"status": "R", "paths": ["docs/old.md", "docs/new.md"]}, + {"status": "D", "paths": ["src/old.py"]}, + ] + + +def test_parse_pr_files_api_lines_normalizes_paginated_api_output(state_mod): + entries = state_mod._parse_pr_files_api_lines( + "modified\tsrc/app.py\t\n" + "renamed\tdocs/new.md\tdocs/old.md\n" + "removed\tsrc/old.py\t\n" + ) + + assert entries == [ + {"status": "M", "paths": ["src/app.py"]}, + {"status": "R", "paths": ["docs/old.md", "docs/new.md"]}, + {"status": "D", "paths": ["src/old.py"]}, + ] + + +def test_docs_only_pr_adds_docs_template(state_mod): + entries = state_mod._parse_pr_files_payload( + '{"files":[{"path":"docs/plan.md","changeType":"MODIFIED"},{"path":"README.md","changeType":"ADDED"}]}' + ) + + categories = state_mod._classify_changed_files(entries) + + assert categories == ["common", "docs_only"] + instructions = state_mod._auto_review_instructions(categories) + assert "ドキュメントのみ PR" in instructions + assert "コード、設定、コマンド" in instructions + + +def test_code_pr_detects_code_test_and_frontend(state_mod): + entries = state_mod._parse_pr_files_payload( + """ + {"files":[ + {"path":"src/components/UserCard.tsx","changeType":"MODIFIED"}, + {"path":"tests/UserCard.test.tsx","changeType":"ADDED"} + ]} + """ + ) + + categories = state_mod._classify_changed_files(entries) + + assert "code" in categories + assert "test" in categories + assert "frontend" in categories + + +def test_db_migration_detects_schema_concerns(state_mod): + entries = state_mod._parse_pr_files_payload( + '{"files":[{"path":"database/migrations/20260101_create_users.sql","changeType":"ADDED"}]}' + ) + + categories = state_mod._classify_changed_files(entries) + + assert "db_migration" in categories + assert "DB migration / schema 変更" in state_mod._auto_review_instructions(categories) + + +def test_dependency_ci_api_security_performance_i18n_infra_are_detected(state_mod): + entries = state_mod._parse_pr_files_payload( + """ + {"files":[ + {"path":"package-lock.json","changeType":"MODIFIED"}, + {"path":".github/workflows/test.yml","changeType":"MODIFIED"}, + {"path":"app/api/auth/token_controller.py","changeType":"MODIFIED"}, + {"path":"src/cache/worker.go","changeType":"MODIFIED"}, + {"path":"locales/ja.json","changeType":"MODIFIED"}, + {"path":"terraform/main.tf","changeType":"MODIFIED"} + ]} + """ + ) + + categories = state_mod._classify_changed_files(entries) + + assert "dependency" in categories + assert "config_ci" in categories + assert "api_contract" in categories + assert "auth_security" in categories + assert "performance" in categories + assert "i18n" in categories + assert "infra" in categories + + +def test_deletion_and_rename_are_detected(state_mod): + entries = state_mod._parse_pr_files_payload( + """ + {"files":[ + {"path":"src/old.py","changeType":"DELETED"}, + {"path":"src/b.py","previousPath":"src/a.py","changeType":"RENAMED"} + ]} + """ + ) + + categories = state_mod._classify_changed_files(entries) + + assert "deletion_rename" in categories + + +def test_index_entrypoint_does_not_trigger_performance(state_mod): + entries = state_mod._parse_pr_files_payload( + '{"files":[{"path":"src/index.ts","changeType":"MODIFIED"}]}' + ) + + assert "performance" not in state_mod._classify_changed_files(entries) + + +def test_i18n_extension_detection_does_not_match_po_substrings(state_mod): + false_positive = state_mod._parse_pr_files_payload( + '{"files":[{"path":"src/import.polyfill.js","changeType":"MODIFIED"}]}' + ) + real_po = state_mod._parse_pr_files_payload( + '{"files":[{"path":"locales/messages.po","changeType":"MODIFIED"}]}' + ) + + assert "i18n" not in state_mod._classify_changed_files(false_positive) + assert "i18n" in state_mod._classify_changed_files(real_po) + + +def test_env_files_are_config_and_author_is_not_auth_security(state_mod): + env_file = state_mod._parse_pr_files_payload( + '{"files":[{"path":".env.example","changeType":"MODIFIED"}]}' + ) + author_file = state_mod._parse_pr_files_payload( + '{"files":[{"path":"src/components/AuthorCard.tsx","changeType":"MODIFIED"}]}' + ) + auth_file = state_mod._parse_pr_files_payload( + '{"files":[{"path":"app/auth/session.py","changeType":"MODIFIED"}]}' + ) + authz_file = state_mod._parse_pr_files_payload( + '{"files":[{"path":"app/security/authz.py","changeType":"MODIFIED"}]}' + ) + authentication_file = state_mod._parse_pr_files_payload( + '{"files":[{"path":"app/security/authentication.py","changeType":"MODIFIED"}]}' + ) + authorizer_file = state_mod._parse_pr_files_payload( + '{"files":[{"path":"app/security/authorizer.ts","changeType":"MODIFIED"}]}' + ) + + assert "config_ci" in state_mod._classify_changed_files(env_file) + assert "auth_security" not in state_mod._classify_changed_files(author_file) + assert "auth_security" in state_mod._classify_changed_files(auth_file) + assert "auth_security" in state_mod._classify_changed_files(authz_file) + assert "auth_security" in state_mod._classify_changed_files(authentication_file) + assert "auth_security" in state_mod._classify_changed_files(authorizer_file) + + +def test_tokenizer_async_jobcard_and_tflite_do_not_trigger_categories(state_mod): + tokenizer_file = state_mod._parse_pr_files_payload( + '{"files":[{"path":"src/tokenizer.py","changeType":"MODIFIED"}]}' + ) + token_file = state_mod._parse_pr_files_payload( + '{"files":[{"path":"app/auth/token.py","changeType":"MODIFIED"}]}' + ) + async_helper = state_mod._parse_pr_files_payload( + '{"files":[{"path":"utils/async_helpers.ts","changeType":"MODIFIED"}]}' + ) + job_card = state_mod._parse_pr_files_payload( + '{"files":[{"path":"components/JobCard.tsx","changeType":"MODIFIED"}]}' + ) + worker_file = state_mod._parse_pr_files_payload( + '{"files":[{"path":"src/worker/queue.ts","changeType":"MODIFIED"}]}' + ) + tflite_file = state_mod._parse_pr_files_payload( + '{"files":[{"path":"models/config.tflite","changeType":"MODIFIED"}]}' + ) + terraform_file = state_mod._parse_pr_files_payload( + '{"files":[{"path":"terraform/main.tf","changeType":"MODIFIED"}]}' + ) + + assert "auth_security" not in state_mod._classify_changed_files(tokenizer_file) + assert "auth_security" in state_mod._classify_changed_files(token_file) + assert "performance" not in state_mod._classify_changed_files(async_helper) + assert "performance" not in state_mod._classify_changed_files(job_card) + assert "performance" in state_mod._classify_changed_files(worker_file) + assert "infra" not in state_mod._classify_changed_files(tflite_file) + assert "infra" in state_mod._classify_changed_files(terraform_file) + + +def test_combined_review_instructions_puts_auto_before_manual(state_mod): + assert state_mod._combined_review_instructions("auto", "manual") == "auto\n\nmanual" + assert state_mod._combined_review_instructions("auto", "") == "auto" + assert state_mod._combined_review_instructions("", "manual") == "manual" diff --git a/plugins/ndf-claude/skills/cross-review/tests/test_state_check_oscillation.py b/plugins/ndf-claude/skills/cross-review/tests/test_state_check_oscillation.py new file mode 100644 index 0000000..f00d189 --- /dev/null +++ b/plugins/ndf-claude/skills/cross-review/tests/test_state_check_oscillation.py @@ -0,0 +1,101 @@ +"""state.py cmd_check_oscillation の non-dict 防御テスト (PLAN21 round 5). + +gemini round 4 指摘: payload.json が dict 以外 (list 等) の場合、 +`payload.get("comments", [])` で AttributeError になる前に明示的に止める。 +""" +from __future__ import annotations + +import argparse +import json +import pathlib + +import pytest + + +PR = 7777 + + +def _seed_state_two_rounds(tmp_dir: pathlib.Path) -> None: + """同一 PR で 2 round 分のエントリを seed する (oscillation 判定の前提)。""" + state = { + "current_pr": PR, + "rounds": [ + {"round": 1, "pr": PR, "started_at": "2026-05-23T00:00:00+00:00"}, + {"round": 2, "pr": PR, "started_at": "2026-05-23T00:10:00+00:00"}, + ], + "deferred_nits": [], + "final": None, + } + (tmp_dir / f"cross-review-pr{PR}-state.json").write_text(json.dumps(state)) + + +def _make_args() -> argparse.Namespace: + return argparse.Namespace(pr=PR) + + +@pytest.fixture() +def patched_tmp_dir(monkeypatch, tmp_path, state_mod): + monkeypatch.setenv("CROSS_REVIEW_TMP_DIR", str(tmp_path)) + return tmp_path + + +def _write_payload( + tmp_dir: pathlib.Path, agent: str, round_no: int, data: object +) -> pathlib.Path: + p = tmp_dir / f"{agent}-review-pr{PR}-round{round_no}-payload.json" + p.write_text(json.dumps(data)) + return p + + +def test_non_dict_payload_dies(patched_tmp_dir, state_mod, capsys): + """payload.json が list 等の非 dict なら die(code=3)。 + + `payload.get(...)` を呼ぶ前に明示的に止めることで、launcher 出力バグや + 別実行の残骸を黙って見逃さない。 + """ + tmp_dir = patched_tmp_dir + _seed_state_two_rounds(tmp_dir) + + # round 1 / round 2 の codex payload を dict で置く (正常 = 比較ベース) + _write_payload(tmp_dir, "codex", 1, {"comments": [{"path": "a.py", "line": 1}]}) + _write_payload(tmp_dir, "codex", 2, {"comments": [{"path": "a.py", "line": 1}]}) + # round 2 の gemini payload は不正 (list) + _write_payload(tmp_dir, "gemini", 2, [{"comments": "should_be_ignored"}]) + + with pytest.raises(SystemExit) as e: + state_mod.cmd_check_oscillation(_make_args()) + assert e.value.code == 3 + captured = capsys.readouterr() + assert "dict ではない" in captured.err + + +def test_non_dict_comment_entry_dies(patched_tmp_dir, state_mod, capsys): + """payload.comments のエントリが dict ではない場合も die(code=3)。""" + tmp_dir = patched_tmp_dir + _seed_state_two_rounds(tmp_dir) + + # round 1 / round 2 とも codex の payload は dict だが、 + # round 2 の comments エントリが str (本来 dict) + _write_payload(tmp_dir, "codex", 1, {"comments": [{"path": "a.py", "line": 1}]}) + _write_payload(tmp_dir, "codex", 2, {"comments": ["not-a-dict-entry"]}) + + with pytest.raises(SystemExit) as e: + state_mod.cmd_check_oscillation(_make_args()) + assert e.value.code == 3 + captured = capsys.readouterr() + assert "dict ではない" in captured.err + + +def test_valid_dict_payloads_dont_die(patched_tmp_dir, state_mod): + """正常な dict payload (regression guard): non-dict 検査が誤検知しないこと。""" + tmp_dir = patched_tmp_dir + _seed_state_two_rounds(tmp_dir) + + # 全 agent の payload を正規の dict で置く (overlap < 50% で continue 期待) + _write_payload(tmp_dir, "codex", 1, {"comments": [{"path": "a.py", "line": 1}]}) + _write_payload(tmp_dir, "codex", 2, {"comments": [{"path": "b.py", "line": 2}]}) + + # exit code 2 (continue) で正常終了 + with pytest.raises(SystemExit) as e: + state_mod.cmd_check_oscillation(_make_args()) + assert e.value.code == 2 diff --git a/plugins/ndf-claude/skills/cross-review/tests/test_state_extra_review_instructions.py b/plugins/ndf-claude/skills/cross-review/tests/test_state_extra_review_instructions.py new file mode 100644 index 0000000..b145b1c --- /dev/null +++ b/plugins/ndf-claude/skills/cross-review/tests/test_state_extra_review_instructions.py @@ -0,0 +1,35 @@ +"""state.py の追加レビュー観点オプションのテスト。""" + +from __future__ import annotations + +import argparse + + +def test_extra_review_instructions_accepts_focus(state_mod): + args = argparse.Namespace( + focus="ドキュメントとコードの整合性を重点的に確認", + extra_instructions_file=None, + ) + + assert state_mod._extra_review_instructions(args) == "ドキュメントとコードの整合性を重点的に確認" + + +def test_extra_review_instructions_combines_focus_and_file(tmp_path, state_mod): + extra = tmp_path / "focus.md" + extra.write_text("公開 API の説明と実装差分も確認\n", encoding="utf-8") + args = argparse.Namespace( + focus="ドキュメント整合性", + extra_instructions_file=str(extra), + ) + + assert state_mod._extra_review_instructions(args) == ( + "ドキュメント整合性\n\n公開 API の説明と実装差分も確認" + ) + + +def test_extra_review_instructions_ignores_empty_values(tmp_path, state_mod): + extra = tmp_path / "empty.md" + extra.write_text(" \n", encoding="utf-8") + args = argparse.Namespace(focus=" ", extra_instructions_file=str(extra)) + + assert state_mod._extra_review_instructions(args) == "" diff --git a/plugins/ndf-claude/skills/cross-review/tests/test_state_merge_fix.py b/plugins/ndf-claude/skills/cross-review/tests/test_state_merge_fix.py new file mode 100644 index 0000000..63c31ba --- /dev/null +++ b/plugins/ndf-claude/skills/cross-review/tests/test_state_merge_fix.py @@ -0,0 +1,776 @@ +"""state.py cmd_merge_fix の堅牢化テスト (PLAN21). + +カバー範囲: + 1. 正規パス + 正規 key (`fix_commit` / `fixed_count`) + 2. 正規パス不在 → `/tmp/` fallback で拾える + 3. 戻り値 key 別名 (`commit_sha` / `fixed`) も受理 + 4. 全候補不在 → exit 3 で die + 探索 path 一覧が stderr に出る + 5. (PLAN21 round 2) fallback の stale 検証 + - mtime が round 開始前 → skip + - JSON 内 `pr` 不一致 → skip + - 明示 `--file` は stale 検証スキップ + 6. (PLAN21 round 3) 正規パス JSON parse 失敗 → 即時 die(code=3) + 7. (PLAN21 round 3) `pr` フィールドが数値として解釈できない → skip + 8. (PLAN21 round 3) `_is_fresh_fix_result` 戻り値のタプル化追従 +""" +from __future__ import annotations + +import argparse +import datetime as _dt +import json +import os +import pathlib + +import pytest + + +PR = 9919 + + +def _seed_state(tmp_dir: pathlib.Path) -> None: + state = { + "current_pr": PR, + "rounds": [ + {"round": 1, "pr": PR, "started_at": "2026-05-23T00:00:00+00:00"} + ], + "deferred_nits": [], + "final": None, + } + (tmp_dir / f"cross-review-pr{PR}-state.json").write_text(json.dumps(state)) + + +def _make_args(file_path: pathlib.Path | None = None) -> argparse.Namespace: + return argparse.Namespace(pr=PR, file=str(file_path) if file_path else None) + + +def _read_state(tmp_dir: pathlib.Path) -> dict: + return json.loads((tmp_dir / f"cross-review-pr{PR}-state.json").read_text()) + + +def _canonical_fix() -> dict: + return { + "pr": PR, + "fix_commit": "abc1234", + "ci_status": "SUCCESS", + "ci_failed_checks": [], + "fixed_count": 5, + "by_severity": {"critical": 0, "major": 3, "minor": 2, "nit": 0}, + "resolved_threads": [{"thread_id": "T1"}], + "deferred": [], + "rejected": [], + } + + +@pytest.fixture() +def patched_tmp_dir(monkeypatch, tmp_path, state_mod): + """`CROSS_REVIEW_TMP_DIR` を tmp_path に向けて `_tmp_dir()` を決定的にする。""" + monkeypatch.setenv("CROSS_REVIEW_TMP_DIR", str(tmp_path)) + return tmp_path + + +def test_canonical_path_and_key(patched_tmp_dir, state_mod): + """正規パス ($TMP_DIR/fix-prN-result.json) + 正規 key で state にマージされる。""" + tmp_dir = patched_tmp_dir + _seed_state(tmp_dir) + fix = _canonical_fix() + (tmp_dir / f"fix-pr{PR}-result.json").write_text(json.dumps(fix)) + + state_mod.cmd_merge_fix(_make_args()) + + st = _read_state(tmp_dir) + merged = st["rounds"][-1]["fix"] + assert merged["commit"] == "abc1234" + assert merged["fixed"] == 5 + assert merged["ci"] == "SUCCESS" + assert merged["by_severity"]["major"] == 3 + + +def test_tmp_fallback_path(patched_tmp_dir, state_mod, tmp_path): + """正規パス不在で /tmp/fix-prN-result.json にある場合は fallback で拾う。 + + 旧プロンプトでサブエージェントが `/tmp/` を指定したケースの救済。 + """ + tmp_dir = patched_tmp_dir + _seed_state(tmp_dir) + # 正規パスには書かず、/tmp/ のみに置く + fix = _canonical_fix() + fix["fix_commit"] = "tmp_path_commit" + legacy = pathlib.Path(f"/tmp/fix-pr{PR}-result.json") + legacy.write_text(json.dumps(fix)) + try: + state_mod.cmd_merge_fix(_make_args()) + st = _read_state(tmp_dir) + assert st["rounds"][-1]["fix"]["commit"] == "tmp_path_commit" + finally: + legacy.unlink(missing_ok=True) + + +def test_key_alias_commit_sha_and_fixed(patched_tmp_dir, state_mod): + """サブエージェントが別名 (`commit_sha` / `fixed`) で書いても受理する。""" + tmp_dir = patched_tmp_dir + _seed_state(tmp_dir) + fix = { + "pr": PR, + "commit_sha": "alias_sha", + "fixed": 3, + "ci_status": "SUCCESS", + "deferred": [], + "rejected": [], + "resolved_threads": [], + } + (tmp_dir / f"fix-pr{PR}-result.json").write_text(json.dumps(fix)) + + state_mod.cmd_merge_fix(_make_args()) + + st = _read_state(tmp_dir) + merged = st["rounds"][-1]["fix"] + assert merged["commit"] == "alias_sha" + assert merged["fixed"] == 3 + + +def test_missing_all_candidates_dies_with_paths(patched_tmp_dir, state_mod, capsys): + """どの候補にもファイルが無い場合は exit 3 + 探索 path 一覧が stderr に出る。""" + tmp_dir = patched_tmp_dir + _seed_state(tmp_dir) + # /tmp/ にも置かない (clean up to be safe) + legacy = pathlib.Path(f"/tmp/fix-pr{PR}-result.json") + legacy.unlink(missing_ok=True) + + with pytest.raises(SystemExit) as e: + state_mod.cmd_merge_fix(_make_args()) + assert e.value.code == 3 + + captured = capsys.readouterr() + # 探索 path 一覧 (どこを見たか) がメッセージに含まれる + assert str(tmp_dir / f"fix-pr{PR}-result.json") in captured.err + assert f"/tmp/fix-pr{PR}-result.json" in captured.err + + +def test_explicit_file_arg_wins(patched_tmp_dir, state_mod, tmp_path): + """`--file` 明示時はそれを最優先で読む (正規パスや /tmp/ より優先)。""" + tmp_dir = patched_tmp_dir + _seed_state(tmp_dir) + # 正規パスにも置くが、--file で別ファイルを指定する + canonical = _canonical_fix() + canonical["fix_commit"] = "canonical_should_not_be_used" + (tmp_dir / f"fix-pr{PR}-result.json").write_text(json.dumps(canonical)) + + explicit_fix = _canonical_fix() + explicit_fix["fix_commit"] = "explicit_wins" + explicit_path = tmp_path / "custom-fix.json" + explicit_path.write_text(json.dumps(explicit_fix)) + + state_mod.cmd_merge_fix(_make_args(explicit_path)) + + st = _read_state(tmp_dir) + assert st["rounds"][-1]["fix"]["commit"] == "explicit_wins" + + +# ---------------- PLAN21 round 2: fallback stale 検証 ---------------- + + +def _seed_state_with_started_at(tmp_dir: pathlib.Path, started_at: str) -> None: + """`started_at` を任意のタイムスタンプで seed する。""" + state = { + "current_pr": PR, + "rounds": [{"round": 1, "pr": PR, "started_at": started_at}], + "deferred_nits": [], + "final": None, + } + (tmp_dir / f"cross-review-pr{PR}-state.json").write_text(json.dumps(state)) + + +def test_fallback_stale_mtime_is_ignored(patched_tmp_dir, state_mod, capsys): + """正規 fallback ファイルの mtime が round 開始前なら skip。 + + 別 round / 別実行の残骸ファイルを誤マージしないことを確認する。 + """ + tmp_dir = patched_tmp_dir + # round 開始時刻 = 現在 (これより古いファイルは skip 対象) + now_dt = _dt.datetime.now(_dt.timezone.utc).astimezone() + _seed_state_with_started_at(tmp_dir, now_dt.isoformat(timespec="seconds")) + + stale = tmp_dir / f"fix-pr{PR}-result.json" + stale.write_text(json.dumps(_canonical_fix())) + # mtime を 1 時間前に巻き戻す (round 開始より明確に古い) + stale_ts = now_dt.timestamp() - 3600 + os.utime(stale, (stale_ts, stale_ts)) + + with pytest.raises(SystemExit) as e: + state_mod.cmd_merge_fix(_make_args()) + assert e.value.code == 3 + + captured = capsys.readouterr() + # 古いファイルとして skip された旨が stderr に出る + assert "round 開始前" in captured.err or "古いファイル" in captured.err + + +def test_fallback_tmp_stale_is_ignored(patched_tmp_dir, state_mod, capsys): + """`/tmp/fix-prN-result.json` の古い残骸 (別リポジトリの同番号 PR 想定) は skip。 + + codex review 指摘の本丸: PR 番号だけで命名された共有 namespace の + 古いファイルを無条件に拾わないこと。 + """ + tmp_dir = patched_tmp_dir + now_dt = _dt.datetime.now(_dt.timezone.utc).astimezone() + _seed_state_with_started_at(tmp_dir, now_dt.isoformat(timespec="seconds")) + + legacy = pathlib.Path(f"/tmp/fix-pr{PR}-result.json") + legacy.write_text(json.dumps(_canonical_fix())) + # mtime を 2 時間前に巻き戻す (= 過去の別実行で残った想定) + stale_ts = now_dt.timestamp() - 7200 + os.utime(legacy, (stale_ts, stale_ts)) + try: + with pytest.raises(SystemExit) as e: + state_mod.cmd_merge_fix(_make_args()) + assert e.value.code == 3 + captured = capsys.readouterr() + assert "round 開始前" in captured.err or "古いファイル" in captured.err + finally: + legacy.unlink(missing_ok=True) + + +def test_fallback_pr_mismatch_is_ignored(patched_tmp_dir, state_mod, capsys): + """fallback ファイル内の `pr` が対象 PR と一致しない場合は skip。 + + 別リポジトリの同番号 PR の戻り値が `/tmp` 共有 namespace に + 残っていたケースを想定する。 + """ + tmp_dir = patched_tmp_dir + # round 開始は十分過去にして mtime チェックでは弾かれないようにする + past = _dt.datetime(2000, 1, 1, tzinfo=_dt.timezone.utc) + _seed_state_with_started_at(tmp_dir, past.isoformat(timespec="seconds")) + + fix = _canonical_fix() + fix["pr"] = PR + 1 # 別 PR の戻り値 + (tmp_dir / f"fix-pr{PR}-result.json").write_text(json.dumps(fix)) + + with pytest.raises(SystemExit) as e: + state_mod.cmd_merge_fix(_make_args()) + assert e.value.code == 3 + captured = capsys.readouterr() + assert "pr 不一致" in captured.err or "別 PR" in captured.err + + +def test_fallback_fresh_mtime_and_matching_pr_is_accepted(patched_tmp_dir, state_mod): + """mtime が round 開始後 & `pr` 一致なら採用される (regression guard)。""" + tmp_dir = patched_tmp_dir + # round 開始を 1 時間前に置く → 直後に書いたファイルは "fresh" + past_dt = _dt.datetime.now(_dt.timezone.utc).astimezone() - _dt.timedelta(hours=1) + _seed_state_with_started_at(tmp_dir, past_dt.isoformat(timespec="seconds")) + + fix = _canonical_fix() + fix["fix_commit"] = "fresh_ok" + (tmp_dir / f"fix-pr{PR}-result.json").write_text(json.dumps(fix)) + + state_mod.cmd_merge_fix(_make_args()) + + st = _read_state(tmp_dir) + assert st["rounds"][-1]["fix"]["commit"] == "fresh_ok" + + +def test_explicit_file_bypasses_stale_check(patched_tmp_dir, state_mod, tmp_path): + """`--file` 明示時は stale 検証をスキップする (ユーザー指定優先)。""" + tmp_dir = patched_tmp_dir + now_dt = _dt.datetime.now(_dt.timezone.utc).astimezone() + _seed_state_with_started_at(tmp_dir, now_dt.isoformat(timespec="seconds")) + + explicit_fix = _canonical_fix() + explicit_fix["fix_commit"] = "explicit_stale_ok" + explicit_path = tmp_path / "explicit.json" + explicit_path.write_text(json.dumps(explicit_fix)) + # mtime を round 開始前に巻き戻しても、--file 指定なら採用される + stale_ts = now_dt.timestamp() - 3600 + os.utime(explicit_path, (stale_ts, stale_ts)) + + state_mod.cmd_merge_fix(_make_args(explicit_path)) + + st = _read_state(tmp_dir) + assert st["rounds"][-1]["fix"]["commit"] == "explicit_stale_ok" + + +# ---------------- PLAN21 round 3: 正規パス parse 失敗 / pr 型不正 / 戻り値タプル化 ---------------- + + +def test_canonical_path_json_parse_failure_dies_immediately( + patched_tmp_dir, state_mod, capsys +): + """正規パス ($TMP_DIR/fix-prN-result.json) の JSON parse 失敗は即時 die(code=3)。 + + codex round 2 指摘: 壊れた正規パスをスキップして /tmp/ fallback に流れると + 別 PR の戻り値を誤マージする事故が起こるため、正規パスの parse 失敗は致命扱い。 + """ + tmp_dir = patched_tmp_dir + # round 開始を十分過去にして mtime チェックでは弾かれないようにする + past = _dt.datetime(2000, 1, 1, tzinfo=_dt.timezone.utc) + _seed_state_with_started_at(tmp_dir, past.isoformat(timespec="seconds")) + + # 正規パスに壊れた JSON を置く (size>0 だが parse 不能) + canonical = tmp_dir / f"fix-pr{PR}-result.json" + canonical.write_text("{ this is not valid json") + + # /tmp/ 側には別 PR を装う偽の正規 JSON を置く (これに流れ込んだら事故) + legacy = pathlib.Path(f"/tmp/fix-pr{PR}-result.json") + legacy.write_text(json.dumps(_canonical_fix())) + try: + with pytest.raises(SystemExit) as e: + state_mod.cmd_merge_fix(_make_args()) + assert e.value.code == 3 + captured = capsys.readouterr() + # 正規パスの読み取り失敗である旨が stderr に出る + assert "正規パス" in captured.err + # /tmp 側の fix がマージされていないこと (= state.rounds[-1].fix が無い) + st = _read_state(tmp_dir) + assert "fix" not in st["rounds"][-1] + finally: + legacy.unlink(missing_ok=True) + + +def test_fallback_pr_field_non_numeric_is_skipped(patched_tmp_dir, state_mod, capsys): + """fallback ファイルの `pr` フィールドが int 変換不能なら skip (ValueError 防止)。 + + gemini round 2 指摘: `int(file_pr)` で ValueError が裸で上がるとプロセスごと落ちる。 + """ + tmp_dir = patched_tmp_dir + past = _dt.datetime(2000, 1, 1, tzinfo=_dt.timezone.utc) + _seed_state_with_started_at(tmp_dir, past.isoformat(timespec="seconds")) + + # 正規パスに `pr` が int 化できない値の JSON を置く + canonical = tmp_dir / f"fix-pr{PR}-result.json" + bad = _canonical_fix() + bad["pr"] = "not-a-number" + canonical.write_text(json.dumps(bad)) + + with pytest.raises(SystemExit) as e: + state_mod.cmd_merge_fix(_make_args()) + # /tmp/ 側にも候補が無いので最終的には "戻り値ファイル無し" で die(3) + assert e.value.code == 3 + captured = capsys.readouterr() + # 数値として解釈できない旨が stderr に出る + assert "数値として解釈できない" in captured.err + # state にマージされていないこと + st = _read_state(tmp_dir) + assert "fix" not in st["rounds"][-1] + + +def test_is_fresh_fix_result_returns_tuple_with_parsed_payload( + patched_tmp_dir, state_mod +): + """`_is_fresh_fix_result` は (is_fresh, parsed_payload) を返す。 + + gemini round 2 指摘の性能改善: cmd_merge_fix 側で再パースしないよう、 + fresh な場合は parse 済み dict を返す。 + """ + tmp_dir = patched_tmp_dir + past_dt = _dt.datetime.now(_dt.timezone.utc).astimezone() - _dt.timedelta(hours=1) + past_ts = past_dt.timestamp() + + fix = _canonical_fix() + fix["fix_commit"] = "tuple_return_ok" + p = tmp_dir / f"fix-pr{PR}-result.json" + p.write_text(json.dumps(fix)) + + is_fresh, parsed = state_mod._is_fresh_fix_result(p, PR, past_ts, is_canonical=True) + assert is_fresh is True + assert isinstance(parsed, dict) + assert parsed["fix_commit"] == "tuple_return_ok" + + +def test_is_fresh_fix_result_returns_none_when_stale(patched_tmp_dir, state_mod): + """stale な場合は (False, None) を返す。""" + tmp_dir = patched_tmp_dir + now_dt = _dt.datetime.now(_dt.timezone.utc).astimezone() + + p = tmp_dir / f"fix-pr{PR}-result.json" + p.write_text(json.dumps(_canonical_fix())) + stale_ts = now_dt.timestamp() - 3600 + os.utime(p, (stale_ts, stale_ts)) + + is_fresh, parsed = state_mod._is_fresh_fix_result( + p, PR, now_dt.timestamp(), is_canonical=False + ) + assert is_fresh is False + assert parsed is None + + +# ---------------- PLAN21 round 4: non-dict JSON 防御 ---------------- + + +def test_is_fresh_fix_result_non_dict_json_is_skipped(patched_tmp_dir, state_mod, capsys): + """`_is_fresh_fix_result` は dict 以外 (list 等) の JSON を読んだら (False, None) を返す。 + + gemini round 3 指摘: `json.loads` は list / int / str も返しうるため、 + `payload.get(...)` 呼び出し前に `isinstance(payload, dict)` で防御する必要がある。 + """ + tmp_dir = patched_tmp_dir + # round 開始は十分過去にして mtime チェックを通す + past_dt = _dt.datetime.now(_dt.timezone.utc).astimezone() - _dt.timedelta(hours=1) + past_ts = past_dt.timestamp() + + p = tmp_dir / f"fix-pr{PR}-result.json" + # JSON として valid だが dict ではない (list) + p.write_text(json.dumps([{"fix_commit": "should_be_ignored"}])) + + is_fresh, parsed = state_mod._is_fresh_fix_result( + p, PR, past_ts, is_canonical=False + ) + assert is_fresh is False + assert parsed is None + captured = capsys.readouterr() + # dict ではない旨が stderr に出る + assert "dict ではない" in captured.err + + +def test_is_fresh_fix_result_non_dict_json_canonical_dies_round5( + patched_tmp_dir, state_mod, capsys +): + """PLAN21 round 5 で挙動変更: 正規パス (is_canonical=True) で非 dict なら die(code=3)。 + + 旧 round 4 では skip (False, None) だったが、codex round 4 指摘により + `/tmp/` fallback への流れ込みを防ぐため parse 失敗と同様に即時中断する。 + """ + tmp_dir = patched_tmp_dir + past_dt = _dt.datetime.now(_dt.timezone.utc).astimezone() - _dt.timedelta(hours=1) + past_ts = past_dt.timestamp() + + p = tmp_dir / f"fix-pr{PR}-result.json" + p.write_text(json.dumps(["not", "a", "dict"])) + + with pytest.raises(SystemExit) as e: + state_mod._is_fresh_fix_result(p, PR, past_ts, is_canonical=True) + assert e.value.code == 3 + captured = capsys.readouterr() + assert "dict ではない" in captured.err + assert "正規パス" in captured.err + + +def test_explicit_file_non_dict_json_dies(patched_tmp_dir, state_mod, capsys, tmp_path): + """`--file` 明示時に non-dict JSON が渡されたら die(code=3) で即時中断。 + + gemini round 3 指摘: list 等が渡されると後続 `fix.get(...)` でクラッシュするため、 + 明示指定の場合も dict 検証を行ってから fix に代入する。 + """ + tmp_dir = patched_tmp_dir + _seed_state(tmp_dir) + + explicit_path = tmp_path / "bad-explicit.json" + # JSON として valid だが dict ではない (list) + explicit_path.write_text(json.dumps([_canonical_fix()])) + + with pytest.raises(SystemExit) as e: + state_mod.cmd_merge_fix(_make_args(explicit_path)) + assert e.value.code == 3 + captured = capsys.readouterr() + assert "dict ではない" in captured.err + # state は更新されていない + st = _read_state(tmp_dir) + assert "fix" not in st["rounds"][-1] + + +# ---------------- PLAN21 round 5: 正規パス non-dict 即時 die / --file 厳格化 ---------------- + + +def test_canonical_path_non_dict_json_dies_immediately( + patched_tmp_dir, state_mod, capsys +): + """正規パス ($TMP_DIR/fix-prN-result.json) の JSON が dict 以外なら即時 die(code=3)。 + + codex round 4 指摘: parse 失敗と同様、非 dict も「壊れた正規出力」として扱う。 + skip で /tmp/ fallback に流れると別実行の戻り値を誤マージする経路が残るため、 + list / str / int 等が返ったら fallback には進まず即時中断する。 + """ + tmp_dir = patched_tmp_dir + past = _dt.datetime(2000, 1, 1, tzinfo=_dt.timezone.utc) + _seed_state_with_started_at(tmp_dir, past.isoformat(timespec="seconds")) + + # 正規パスに list (非 dict) を置く + canonical = tmp_dir / f"fix-pr{PR}-result.json" + canonical.write_text(json.dumps([{"fix_commit": "should_not_be_read"}])) + + # /tmp/ 側には別 PR を装う正規 JSON を置く (これに流れ込んだら事故) + legacy = pathlib.Path(f"/tmp/fix-pr{PR}-result.json") + legacy.write_text(json.dumps(_canonical_fix())) + try: + with pytest.raises(SystemExit) as e: + state_mod.cmd_merge_fix(_make_args()) + assert e.value.code == 3 + captured = capsys.readouterr() + # 正規パスで非 dict であった旨が stderr に出る + assert "正規パス" in captured.err + assert "dict ではない" in captured.err + # /tmp 側の fix がマージされていないこと + st = _read_state(tmp_dir) + assert "fix" not in st["rounds"][-1] + finally: + legacy.unlink(missing_ok=True) + + +def test_explicit_file_nonexistent_dies(patched_tmp_dir, state_mod, capsys, tmp_path): + """`--file` 指定パスが存在しなければ fallback に進まず die(code=3)。 + + codex round 4 指摘: ユーザー明示指定なのに無言で fallback に流れて + 別実行の戻り値を誤マージするのを防ぐ。 + """ + tmp_dir = patched_tmp_dir + _seed_state(tmp_dir) + # 正規パスにも置いて、fallback に流れたら事故と判別できるようにする + (tmp_dir / f"fix-pr{PR}-result.json").write_text(json.dumps(_canonical_fix())) + + nonexistent = tmp_path / "does-not-exist.json" + assert not nonexistent.exists() + + with pytest.raises(SystemExit) as e: + state_mod.cmd_merge_fix(_make_args(nonexistent)) + assert e.value.code == 3 + captured = capsys.readouterr() + assert "存在しません" in captured.err + # fallback が読まれていない (state にマージされていない) + st = _read_state(tmp_dir) + assert "fix" not in st["rounds"][-1] + + +def test_explicit_file_empty_dies(patched_tmp_dir, state_mod, capsys, tmp_path): + """`--file` 指定パスが空ファイルなら fallback に進まず die(code=3)。""" + tmp_dir = patched_tmp_dir + _seed_state(tmp_dir) + (tmp_dir / f"fix-pr{PR}-result.json").write_text(json.dumps(_canonical_fix())) + + empty = tmp_path / "empty.json" + empty.write_text("") + + with pytest.raises(SystemExit) as e: + state_mod.cmd_merge_fix(_make_args(empty)) + assert e.value.code == 3 + captured = capsys.readouterr() + assert "空です" in captured.err + st = _read_state(tmp_dir) + assert "fix" not in st["rounds"][-1] + + +def test_explicit_file_invalid_json_dies(patched_tmp_dir, state_mod, capsys, tmp_path): + """`--file` 指定パスが JSON 不正なら fallback に進まず die(code=3)。""" + tmp_dir = patched_tmp_dir + _seed_state(tmp_dir) + (tmp_dir / f"fix-pr{PR}-result.json").write_text(json.dumps(_canonical_fix())) + + bad = tmp_path / "bad.json" + bad.write_text("{ this is not valid json") + + with pytest.raises(SystemExit) as e: + state_mod.cmd_merge_fix(_make_args(bad)) + assert e.value.code == 3 + captured = capsys.readouterr() + assert "parse に失敗" in captured.err or "読み取り" in captured.err + st = _read_state(tmp_dir) + assert "fix" not in st["rounds"][-1] + + +# ---------------- PLAN25: _count() 正規化 / int 混入時の堅牢化 ---------------- + + +@pytest.mark.parametrize( + "value, expected", + [ + (3, 3), # int(件数) はそのまま件数として扱う + (0, 0), # int の 0 + ([{"a": 1}, {"b": 2}], 2), # list は len() + ([], 0), # 空 list + ((1, 2, 3), 3), # tuple も len() + (None, 0), # None は 0 + (False, 0), # bool は int サブクラスだが件数扱いしない + (True, 0), # bool=True も 0 + ("xyz", 0), # 想定外の型 (str) は 0 + # PLAN25 round2 (gemini 指摘): LLM が件数を数値文字列で返すケースを許容 + ("3", 3), # 数値文字列 → int + ("0", 0), # "0" も 0 + ("12", 12), # 複数桁 + ("abc", 0), # 数値でない文字列は 0 + ("", 0), # 空文字は isdigit()=False → 0 + ("-1", 0), # 負号付きは strip 後も isdigit()=False → 0 (件数は非負) + ("1.5", 0), # 小数点付きは isdigit()=False → 0 + # PLAN25 round4 (gemini 指摘): 単一 dict を 1 件として扱う + ({"thread_id": "x"}, 1), # 単一 dict → 1 + ({}, 1), # 空 dict も 1 件 (構造化データ 1 要素扱い) + # PLAN25 round4 (gemini 指摘): 数値文字列の前後空白を許容 (strip) + (" 3 ", 3), # 前後空白付き数値文字列 → strip して int + (" 12", 12), # 先頭空白のみ + ("7 ", 7), # 末尾空白のみ + (" ", 0), # 空白のみは strip 後空文字 → 0 + ], +) +def test_count_normalizes_int_list_tuple_none_bool(state_mod, value, expected): + """`_count()` が int / dict / list / tuple / None / bool / 数値文字列 / 想定外型を件数(int)に正規化する。 + + codex review 指摘 (PLAN25): `_count()` の挙動がテストで固定されておらず、 + 同じ TypeError 経路 (`len(int)`) が再発しても検知できない。 + PLAN25 round2 (gemini 指摘): 数値文字列 ("3" 等) も件数として許容する。 + PLAN25 round4 (gemini 指摘): 単一 dict は 1 件、数値文字列は前後空白を strip して許容。 + """ + assert state_mod._count(value) == expected + + +def test_merge_fix_int_counts_do_not_crash_and_persist(patched_tmp_dir, state_mod): + """`resolved_threads` / `deferred` / `rejected` が int でも TypeError を出さず件数が保存される。 + + PLAN25 の本丸: fix サブエージェントが list の代わりに int(件数) を書いても + `len(int)` で落ちず完走すること。 + resolved_threads / rejected は _count() で int がそのまま件数として保存される。 + PLAN25 round4 (統一ルール): deferred が int (per-item を失った劣化表現) のときは + 件数を失わないよう _count() の値を保存する。展開はできないので deferred_nits は空。 + """ + tmp_dir = patched_tmp_dir + _seed_state(tmp_dir) + fix = _canonical_fix() + # list ではなく int(件数) を書く (再発防止対象のケース) + fix["resolved_threads"] = 4 + fix["deferred"] = 2 + fix["rejected"] = 1 + (tmp_dir / f"fix-pr{PR}-result.json").write_text(json.dumps(fix)) + + # TypeError を出さずに完走すること + state_mod.cmd_merge_fix(_make_args()) + + st = _read_state(tmp_dir) + merged = st["rounds"][-1]["fix"] + assert merged["resolved_threads"] == 4 + # 統一ルール: int は劣化表現なので _count() の件数 (2) を保存する + assert merged["deferred"] == 2 + assert merged["rejected"] == 1 + # int は展開できないため deferred_nits は空のまま (per-item データは復元不能) + assert st["deferred_nits"] == [] + + +def test_merge_fix_deferred_single_dict_is_wrapped(patched_tmp_dir, state_mod): + """`deferred` が単一 dict のとき 1 件として扱い、deferred_nits に 1 件展開される。 + + PLAN25 round4 (gemini #3): LLM が単一要素を list ではなく dict 単体で返した + 場合のフォールバック。保存件数 == 展開件数 == 1 を固定する。 + """ + tmp_dir = patched_tmp_dir + _seed_state(tmp_dir) + fix = _canonical_fix() + # list ではなく単一 dict を書く (LLM 出力揺らぎ) + fix["deferred"] = {"comment_id": 7, "summary": "single-nit"} + (tmp_dir / f"fix-pr{PR}-result.json").write_text(json.dumps(fix)) + + state_mod.cmd_merge_fix(_make_args()) + + st = _read_state(tmp_dir) + merged = st["rounds"][-1]["fix"] + # 構造化データ (dict) なので展開件数 (1) を保存 + assert merged["deferred"] == 1 + assert len(st["deferred_nits"]) == 1 + assert st["deferred_nits"][0]["summary"] == "single-nit" + assert st["deferred_nits"][0]["pr"] == PR + assert st["deferred_nits"][0]["round"] == 1 + + +def test_merge_fix_list_deferred_expands_nits(patched_tmp_dir, state_mod): + """`deferred` が list の場合は従来どおり件数が保存され deferred_nits が展開される。 + + int 対応で list の既存挙動 (件数集計 + deferred_nits 展開) が壊れていないことの regression guard。 + """ + tmp_dir = patched_tmp_dir + _seed_state(tmp_dir) + fix = _canonical_fix() + fix["resolved_threads"] = [{"thread_id": "T1"}, {"thread_id": "T2"}] + fix["deferred"] = [ + {"comment_id": 1, "summary": "nit-1"}, + {"comment_id": 2, "summary": "nit-2"}, + ] + fix["rejected"] = [{"comment_id": 3, "summary": "rej-1"}] + (tmp_dir / f"fix-pr{PR}-result.json").write_text(json.dumps(fix)) + + state_mod.cmd_merge_fix(_make_args()) + + st = _read_state(tmp_dir) + merged = st["rounds"][-1]["fix"] + # list は len() で件数化 + assert merged["resolved_threads"] == 2 + assert merged["deferred"] == 2 + assert merged["rejected"] == 1 + # deferred_nits が展開され、pr / round が付与される + assert len(st["deferred_nits"]) == 2 + summaries = {n["summary"] for n in st["deferred_nits"]} + assert summaries == {"nit-1", "nit-2"} + for n in st["deferred_nits"]: + assert n["pr"] == PR + assert n["round"] == 1 + + +# ---------------- PLAN25 round2 (gemini): deferred 非 dict 要素ガード ---------------- + + +def test_merge_fix_deferred_non_dict_elements_are_skipped(patched_tmp_dir, state_mod): + """`deferred` に dict 以外の要素 (文字列等) が混じってもクラッシュせず、dict のみ展開される。 + + gemini round2 指摘 (major): LLM がスキーマを無視して `deferred` に + 文字列のリスト (例: ["nit: ..."]) を返すと `{**d}` で + `TypeError: 'str' object is not a mapping` が発生する。 + ループ内の `isinstance(d, dict)` ガードで非 dict 要素をスキップすること。 + """ + tmp_dir = patched_tmp_dir + _seed_state(tmp_dir) + fix = _canonical_fix() + # dict / 文字列 / None / int が混在した deferred (LLM 出力揺らぎ) + fix["deferred"] = [ + {"comment_id": 1, "summary": "nit-dict"}, + "nit: please fix this string element", + None, + 42, + {"comment_id": 2, "summary": "nit-dict-2"}, + ] + (tmp_dir / f"fix-pr{PR}-result.json").write_text(json.dumps(fix)) + + # TypeError を出さずに完走すること + state_mod.cmd_merge_fix(_make_args()) + + st = _read_state(tmp_dir) + merged = st["rounds"][-1]["fix"] + # codex round3 指摘 (major): 保存件数は「実際に deferred_nits へ展開される + # dict 要素数」と一致させる。非 dict 要素は除外されるので 2 になる。 + assert merged["deferred"] == 2 + # deferred_nits には dict 要素のみが展開される + assert len(st["deferred_nits"]) == 2 + summaries = {n["summary"] for n in st["deferred_nits"]} + assert summaries == {"nit-dict", "nit-dict-2"} + for n in st["deferred_nits"]: + assert n["pr"] == PR + assert n["round"] == 1 + + +# ---------- PLAN25 round3 (codex): 保存件数 == deferred_nits 展開件数 ---------- + + +def test_merge_fix_deferred_count_matches_expanded_nits(patched_tmp_dir, state_mod): + """codex round3 指摘 (major / 正確性): mixed list の `deferred` を渡したとき、 + `rounds[].fix["deferred"]` の保存件数が deferred_nits に実際に展開された + dict 要素数と一致することを検証する回帰テスト。 + + round2 の dict ガードは「展開件数 < 保存件数」という食い違いを生んでいた + (保存は list 全体の長さ、展開は dict のみ)。本テストで両者の一致を固定する。 + """ + tmp_dir = patched_tmp_dir + _seed_state(tmp_dir) + fix = _canonical_fix() + # dict 3 件 + 非 dict 4 件 の mixed list + fix["deferred"] = [ + {"comment_id": 1, "summary": "nit-a"}, + "str-element", + None, + {"comment_id": 2, "summary": "nit-b"}, + 123, + ["nested", "list"], + {"comment_id": 3, "summary": "nit-c"}, + ] + (tmp_dir / f"fix-pr{PR}-result.json").write_text(json.dumps(fix)) + + state_mod.cmd_merge_fix(_make_args()) + + st = _read_state(tmp_dir) + merged = st["rounds"][-1]["fix"] + expanded = [n for n in st["deferred_nits"] if n.get("round") == 1] + # 保存件数 == 実展開件数 == dict 要素数 (3) + assert merged["deferred"] == len(expanded) == 3 + summaries = {n["summary"] for n in expanded} + assert summaries == {"nit-a", "nit-b", "nit-c"} diff --git a/plugins/ndf-claude/skills/cross-review/tests/test_state_read_result.py b/plugins/ndf-claude/skills/cross-review/tests/test_state_read_result.py new file mode 100644 index 0000000..a243fc2 --- /dev/null +++ b/plugins/ndf-claude/skills/cross-review/tests/test_state_read_result.py @@ -0,0 +1,160 @@ +"""state.py cmd_read_result の result.json スキーマ揺れに対するテスト。 + +カバー範囲: + 1. 正規スキーマ (`event` / `comments_count`) → state にマージされる + 2. 変則スキーマ (`intent` / `comment_count`) → 同等にマージされる (フォールバック) + 3. event / intent いずれも欠落 → die(exit 1) で fail + state 不変 +""" +from __future__ import annotations + +import argparse +import json +import pathlib + +import pytest + + +PR = 4242 +AGENT = "gemini" + + +def _seed_state(tmp_dir: pathlib.Path) -> dict: + state = { + "current_pr": PR, + "rounds": [ + {"round": 1, "pr": PR, "started_at": "2026-05-21T00:00:00+00:00"} + ], + "final": None, + } + (tmp_dir / f"cross-review-pr{PR}-state.json").write_text(json.dumps(state)) + return state + + +def _make_args(file_path: pathlib.Path) -> argparse.Namespace: + return argparse.Namespace(pr=PR, agent=AGENT, file=str(file_path)) + + +def _read_state(tmp_dir: pathlib.Path) -> dict: + return json.loads((tmp_dir / f"cross-review-pr{PR}-state.json").read_text()) + + +@pytest.fixture() +def patched_tmp_dir(monkeypatch, tmp_path, state_mod): + """`CROSS_REVIEW_TMP_DIR` を tmp_path に向ける。""" + monkeypatch.setenv("CROSS_REVIEW_TMP_DIR", str(tmp_path)) + return tmp_path + + +def test_canonical_schema(patched_tmp_dir, state_mod): + tmp_dir = patched_tmp_dir + _seed_state(tmp_dir) + result = { + "event": "APPROVE", + "posted_as": "APPROVE", + "comments_count": 0, + "review_url": "https://example/pr/1#1", + "by_severity": {"critical": 0, "major": 0, "minor": 0, "nit": 0}, + } + rfile = tmp_dir / "result.json" + rfile.write_text(json.dumps(result)) + + state_mod.cmd_read_result(_make_args(rfile)) + + st = _read_state(tmp_dir) + merged = st["rounds"][-1][AGENT] + assert merged["intent"] == "APPROVE" + assert merged["posted_as"] == "APPROVE" + assert merged["comments"] == 0 + assert merged["review_url"] == "https://example/pr/1#1" + assert merged["by_severity"]["critical"] == 0 + + +def test_alias_schema_intent_and_comment_count(patched_tmp_dir, state_mod): + """gemini が `intent` / `comment_count` で書き出すパターンも受理する。""" + tmp_dir = patched_tmp_dir + _seed_state(tmp_dir) + result = { + "intent": "APPROVE", + "comment_count": 3, + "review_url": "https://example/pr/2#2", + "by_severity": {"critical": 0, "major": 0, "minor": 2, "nit": 1}, + } + rfile = tmp_dir / "result.json" + rfile.write_text(json.dumps(result)) + + state_mod.cmd_read_result(_make_args(rfile)) + + st = _read_state(tmp_dir) + merged = st["rounds"][-1][AGENT] + assert merged["intent"] == "APPROVE" + # posted_as は別名 result.json には存在しないので intent と同値にフォールバック + assert merged["posted_as"] == "APPROVE" + assert merged["comments"] == 3 + + +def test_missing_event_and_intent_dies(patched_tmp_dir, state_mod): + """event / intent いずれも無ければ exit 1 で fail し state は不変であること。""" + tmp_dir = patched_tmp_dir + seeded = _seed_state(tmp_dir) + result = {"comments_count": 0} + rfile = tmp_dir / "result.json" + rfile.write_text(json.dumps(result)) + + with pytest.raises(SystemExit) as e: + state_mod.cmd_read_result(_make_args(rfile)) + assert e.value.code == 1 + + # state は更新されていない (rounds[-1] に agent エントリが追加されていない) + st = _read_state(tmp_dir) + assert AGENT not in st["rounds"][-1] + assert st["rounds"][-1]["round"] == seeded["rounds"][-1]["round"] + + +def test_empty_result_file_dies(patched_tmp_dir, state_mod): + """空 result.json → die (result 未生成扱い)。""" + tmp_dir = patched_tmp_dir + _seed_state(tmp_dir) + rfile = tmp_dir / "result.json" + rfile.write_text("") + + with pytest.raises(SystemExit) as e: + state_mod.cmd_read_result(_make_args(rfile)) + assert e.value.code == 1 + + +# ---------------- PLAN21 round 5: non-dict / 不正 JSON 防御 ---------------- + + +def test_non_dict_result_json_dies(patched_tmp_dir, state_mod, capsys): + """result.json が list 等の非 dict なら die(code=3)。 + + gemini round 4 指摘: `r.get(...)` で AttributeError になる前に明示的に止める。 + """ + tmp_dir = patched_tmp_dir + _seed_state(tmp_dir) + rfile = tmp_dir / "result.json" + # JSON valid だが dict ではない + rfile.write_text(json.dumps([{"event": "APPROVE"}])) + + with pytest.raises(SystemExit) as e: + state_mod.cmd_read_result(_make_args(rfile)) + assert e.value.code == 3 + captured = capsys.readouterr() + assert "dict ではない" in captured.err + # state は更新されていない + st = _read_state(tmp_dir) + assert AGENT not in st["rounds"][-1] + + +def test_invalid_json_result_file_dies(patched_tmp_dir, state_mod, capsys): + """JSON parse 不能なら die(code=3)。""" + tmp_dir = patched_tmp_dir + _seed_state(tmp_dir) + rfile = tmp_dir / "result.json" + rfile.write_text("{ this is not valid json") + + with pytest.raises(SystemExit) as e: + state_mod.cmd_read_result(_make_args(rfile)) + assert e.value.code == 3 + captured = capsys.readouterr() + assert "parse" in captured.err.lower() or "parse" in captured.err diff --git a/plugins/ndf-claude/skills/deploy/SKILL.md b/plugins/ndf-claude/skills/deploy/SKILL.md new file mode 100644 index 0000000..769919d --- /dev/null +++ b/plugins/ndf-claude/skills/deploy/SKILL.md @@ -0,0 +1,114 @@ +--- +name: deploy +description: "Create deploy PRs from feature to environment branches." +argument-hint: " (例: qa/staging, release/v2)" +disable-model-invocation: true +allowed-tools: + - Bash + - Read +--- + +# 環境デプロイPR作成コマンド + +現在のfeatureブランチを指定した環境ブランチへデプロイするためのPRを作成する。`{feature}_to_{env}` という命名のdeployブランチを作成し、最新 origin/main を取り込んでから環境ブランチへPRを出す。 + +## 使用方法 + +``` +/ndf:deploy qa/staging +/ndf:deploy release/v2 +``` + +## cherry-pick-pr との使い分け + +| 観点 | cherry-pick-pr | deploy | +|---|---|---| +| 適用範囲 | featureブランチの**一部コミット**を選択 | featureブランチ**全体**を適用 | +| ブランチ戦略 | 環境ブランチから短命ブランチ派生 | featureブランチから deploy ブランチ派生 | +| main取り込み | 必須 | 必須 | +| 用途 | 特定修正のみ検証環境に届けたい | feature機能全体を環境で検証したい | + +## 処理フロー + +### 1. バリデーション + +```bash +CURRENT_BRANCH=$(git branch --show-current) +[[ "$CURRENT_BRANCH" == "main" || "$CURRENT_BRANCH" == "master" ]] && \ + echo "❌ Error: デフォルトブランチからデプロイできません" && exit 1 +``` + +### 2. deployブランチ名の導出 + +```bash +FEATURE_BRANCH=$(git branch --show-current) +# 環境名を抽出: "qa/staging" → "staging", "release/v2" → "v2" +ENV_SUFFIX=$(echo "$ARGUMENTS" | sed 's|.*/||') +DEPLOY_BRANCH="${FEATURE_BRANCH}_to_${ENV_SUFFIX}" +``` + +### 3. 既存PRチェック + +```bash +EXISTING_PR=$(gh pr list --head "$DEPLOY_BRANCH" --base "$ARGUMENTS" \ + --json number,url --jq '.[0].url // empty') +if [[ -n "$EXISTING_PR" ]]; then + echo "✅ PR already exists: $EXISTING_PR" + exit 0 +fi +``` + +既存PRがあれば更新は「deployブランチにpushする」だけで済むため、再作成しない。 + +### 4. deployブランチ作成 + main取り込み + +```bash +git fetch origin main +git checkout -b "$DEPLOY_BRANCH" +git merge origin/main --no-edit || { + echo "❌ main とのmerge conflict。手動解決が必要です" + git merge --abort + git checkout "$FEATURE_BRANCH" + git branch -D "$DEPLOY_BRANCH" + exit 1 +} +``` + +### 5. push + PR作成 + +```bash +git push -u origin "$DEPLOY_BRANCH" +gh pr create --base "$ARGUMENTS" --head "$DEPLOY_BRANCH" \ + --title "$DEPLOY_BRANCH → $ARGUMENTS" \ + --body "$(cat <<'EOF' +## Summary +- 環境デプロイ用PR +- 元ブランチ: $FEATURE_BRANCH +- main取り込み済み + +## Test plan +- [ ] $ARGUMENTS 環境で動作確認 + + +EOF +)" +``` + +### 6. 元ブランチに復帰 + +```bash +git checkout "$FEATURE_BRANCH" +``` + +## 注意事項 + +- デフォルトブランチからの実行は禁止 +- main取り込みで conflict が出た場合、deployブランチを削除して戻る(featureブランチ側を先に同期すべき) +- deployブランチは PR マージ後に削除してよい +- 環境ブランチへの再デプロイは「同じ deployブランチに push」でPRが更新される + +## 関連 + +- `/ndf:cherry-pick-pr` — 一部コミットだけを環境に届ける場合 +- `/ndf:branch-fix-strategy` — ブランチ運用戦略の原則 +- `/ndf:sync-main` — featureブランチに main を取り込む diff --git a/plugins/ndf-claude/skills/docker-container-access/01-environment-detection.md b/plugins/ndf-claude/skills/docker-container-access/01-environment-detection.md new file mode 100644 index 0000000..51941bc --- /dev/null +++ b/plugins/ndf-claude/skills/docker-container-access/01-environment-detection.md @@ -0,0 +1,84 @@ +# 環境判定ガイド + +## Step 1: 自身の環境を確認 + +```bash +# 自分がコンテナ内で動作しているか確認 +cat /proc/1/cgroup 2>/dev/null | grep -q docker && echo "コンテナ内" || echo "ホスト環境" + +# または +[ -f /.dockerenv ] && echo "コンテナ内" || echo "ホスト環境" +``` + +## Step 2: Docker環境の種類を判定 + +自身がコンテナ内の場合、以下のいずれかの環境です: + +| 環境 | 説明 | 判定方法 | +|-----|------|---------| +| **DinD** (Docker in Docker) | コンテナ内に独立したDockerデーモン | `docker info`でDocker rootが`/var/lib/docker` | +| **DooD** (Docker outside of Docker) | ホストのDockerソケットを共有 | `/var/run/docker.sock`がマウントされている | + +```bash +# DooD判定: docker.sockがマウントされているか +ls -la /var/run/docker.sock 2>/dev/null && echo "DooD環境の可能性" || echo "DinDまたはホスト環境" + +# Docker rootディレクトリの確認 +docker info 2>/dev/null | grep "Docker Root Dir" +``` + +## DinD環境でのアクセス + +DinD環境では、**localhost**で他のコンテナにアクセスできます。 + +### 特徴 +- コンテナ内に独立したDockerデーモンが動作 +- ネットワークは通常のDocker環境と同じ +- `localhost:ポート`でアクセス可能 + +### アクセス例 + +```bash +# Webサーバーへのアクセス +curl http://localhost:8080 + +# データベースへの接続 +mysql -h localhost -P 3306 -u user -p + +# Playwright MCPでのアクセス +# URL: http://localhost:3000 +``` + +## 環境判定スクリプト + +```bash +#!/bin/bash +# Docker環境判定スクリプト + +echo "=== Docker環境判定 ===" + +# 自分がコンテナ内かチェック +if [ -f /.dockerenv ] || grep -q docker /proc/1/cgroup 2>/dev/null; then + echo "実行環境: Dockerコンテナ内" + + # DinD/DooD判定 + if [ -S /var/run/docker.sock ]; then + echo "Docker形式: DooD (Docker outside of Docker)" + echo "" + echo "→ 他のコンテナへのアクセスにはコンテナ名を使用してください" + echo "→ bind mountはホストのパスを参照するため注意が必要です" + else + echo "Docker形式: DinD (Docker in Docker)" + echo "" + echo "→ localhostで他のコンテナにアクセス可能です" + fi +else + echo "実行環境: ホストマシン" + echo "" + echo "→ 通常のDocker操作が可能です" +fi + +echo "" +echo "=== 利用可能なコンテナ ===" +docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" 2>/dev/null || echo "Dockerが利用できません" +``` diff --git a/plugins/ndf-claude/skills/docker-container-access/02-dood-access.md b/plugins/ndf-claude/skills/docker-container-access/02-dood-access.md new file mode 100644 index 0000000..d19fc96 --- /dev/null +++ b/plugins/ndf-claude/skills/docker-container-access/02-dood-access.md @@ -0,0 +1,134 @@ +# DooD環境でのアクセスガイド + +## 特徴 + +- ホストのDockerデーモンを共有 +- localhostはホストマシンを指す(他のコンテナではない) +- コンテナ間通信はDockerネットワーク経由 + +## アクセス方法 + +### 1. コンテナ名でアクセス(同一ネットワーク内) + +```bash +# コンテナ名を確認 +docker ps --format "table {{.Names}}\t{{.Ports}}" + +# コンテナ名でアクセス +curl http://my-web-container:8080 + +# docker-composeの場合、サービス名でアクセス +curl http://web:8080 +``` + +### 2. Dockerネットワーク経由 + +```bash +# ネットワーク一覧を確認 +docker network ls + +# 特定ネットワークのコンテナを確認 +docker network inspect bridge --format '{{range .Containers}}{{.Name}} {{.IPv4Address}}{{"\n"}}{{end}}' + +# IPアドレスでアクセス +curl http://172.17.0.2:8080 +``` + +### 3. 同一ネットワークへの参加 + +```bash +# 自分のコンテナを対象コンテナと同じネットワークに接続 +docker network connect my-network $(hostname) + +# その後、コンテナ名でアクセス可能 +curl http://target-container:8080 +``` + +## curlでのアクセス例 + +```bash +# NG: localhostは使えない +curl http://localhost:8080 # → Connection refused + +# OK: コンテナ名を使用 +curl http://my-app-container:8080 + +# OK: docker-composeのサービス名 +curl http://api:3000 + +# OK: コンテナのIPアドレス +CONTAINER_IP=$(docker inspect -f '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' my-container) +curl http://${CONTAINER_IP}:8080 +``` + +## Playwright MCP / Chrome DevTools MCP + +```bash +# DooD環境では、コンテナ名またはIPを使用 +# URL: http://web-container:3000 (コンテナ名) +# URL: http://172.17.0.3:3000 (IP) +``` + +--- + +## bind mountの注意点 + +### 問題 + +DooD環境では、`docker run -v`や`docker-compose`のbind mountは**ホストマシンのパス**を参照します。開発コンテナ内のパスではありません。 + +```yaml +# NG: DooD環境では期待通りに動作しない +volumes: + - ./local-dir:/app/data # ホストの./local-dirを参照してしまう +``` + +### 解決策 + +#### 1. Dockerfileでコピー(推奨) + +```dockerfile +FROM node:18 +WORKDIR /app +COPY . . +RUN npm install +CMD ["npm", "start"] +``` + +#### 2. 名前付きボリュームを使用 + +```bash +# ボリュームを作成 +docker volume create my-data + +# ファイルをボリュームにコピー +docker run --rm -v my-data:/data -v $(pwd):/src alpine cp -r /src/. /data/ + +# ボリュームをマウントしてコンテナ起動 +docker run -v my-data:/app/data my-image +``` + +#### 3. docker cpを使用 + +```bash +# コンテナにファイルをコピー +docker cp ./local-file.txt my-container:/app/ + +# コンテナからファイルを取得 +docker cp my-container:/app/output.txt ./ +``` + +### docker-compose.yml での対応 + +```yaml +# DooD環境対応版 +version: '3.8' +services: + app: + build: . # Dockerfileでファイルをコピー + volumes: + - app-data:/app/data # 名前付きボリューム使用 + +volumes: + app-data: +``` diff --git a/plugins/ndf-claude/skills/docker-container-access/03-troubleshooting.md b/plugins/ndf-claude/skills/docker-container-access/03-troubleshooting.md new file mode 100644 index 0000000..0acac32 --- /dev/null +++ b/plugins/ndf-claude/skills/docker-container-access/03-troubleshooting.md @@ -0,0 +1,63 @@ +# トラブルシューティング + +## Q: `curl: (7) Failed to connect to localhost port 8080` + +**原因**: DooD環境でlocalhostを使用している + +**解決策**: +```bash +# コンテナ名またはIPを使用 +docker ps # コンテナ名を確認 +curl http://container-name:8080 +``` + +## Q: bind mountしたファイルが見えない + +**原因**: DooD環境ではホストのパスを参照している + +**解決策**: +```bash +# docker cpでコピー +docker cp ./file.txt container:/app/ + +# または名前付きボリュームを使用 +``` + +## Q: コンテナ間で通信できない + +**原因**: 異なるDockerネットワークに所属している + +**解決策**: +```bash +# 同じネットワークに接続 +docker network connect my-network container-a +docker network connect my-network container-b +``` + +## Q: docker.sockへのアクセス権限がない + +**解決策**: +```bash +# docker グループに追加(要再ログイン) +sudo usermod -aG docker $USER + +# または一時的に権限付与 +sudo chmod 666 /var/run/docker.sock +``` + +--- + +# ベストプラクティス + +## DO(推奨) + +- **コンテナアクセス前に環境を判定する** +- **DooD環境ではコンテナ名/サービス名を使用する** +- **ファイル共有はDockerfileのCOPYまたは名前付きボリュームを使用** +- **docker-composeではサービス名でアクセス** + +## DON'T(非推奨) + +- **環境を確認せずにlocalhostを使用する** +- **DooD環境でbind mountに依存する** +- **IPアドレスをハードコードする(変わる可能性がある)** diff --git a/plugins/ndf-claude/skills/docker-container-access/SKILL.md b/plugins/ndf-claude/skills/docker-container-access/SKILL.md new file mode 100644 index 0000000..eadd1ff --- /dev/null +++ b/plugins/ndf-claude/skills/docker-container-access/SKILL.md @@ -0,0 +1,76 @@ +--- +name: docker-container-access +description: "Diagnose Docker container access and localhost routing." +when_to_use: "Docker / コンテナへのアクセス・localhost 接続不可・DinD/DooD 環境判定が必要なとき。Triggers: 'docker access', 'container connect', 'localhost not working', 'DinD', 'DooD', 'Docker接続', 'コンテナアクセス', 'curl container'" +allowed-tools: + - Read + - Bash + - Glob +--- + +# Docker Container Access Skill + +## 概要 + +ローカル開発環境がDocker開発コンテナ上で動作している場合、他のDockerコンテナへのアクセス方法が通常と異なります。このスキルでは、環境を判定し、適切なアクセス方法を選択するためのガイドラインを提供します。 + +## クイックリファレンス + +``` +環境判定 → アクセス方法 +──────────────────────────────── +ホスト環境 → localhost:port +DinD環境 → localhost:port +DooD環境 → container-name:port または IP:port + +ファイル共有(DooD環境) +──────────────────────────────── +Dockerfile COPY → 推奨(ビルド時にコピー) +名前付きボリューム → 推奨(永続化が必要な場合) +docker cp → OK(一時的なコピー) +bind mount → NG(ホストのパスを参照) +``` + +## 環境判定(最初に実行) + +```bash +# 自分がコンテナ内か確認 +[ -f /.dockerenv ] && echo "コンテナ内" || echo "ホスト環境" + +# DooD判定 +ls -la /var/run/docker.sock 2>/dev/null && echo "DooD環境" || echo "DinDまたはホスト" +``` + +| 環境 | 説明 | コンテナへのアクセス | +|-----|------|-------------------| +| **ホスト** | 通常のDocker環境 | `localhost:port` | +| **DinD** | コンテナ内に独立したDockerデーモン | `localhost:port` | +| **DooD** | ホストのDockerソケットを共有 | `container-name:port` | + +## 詳細ガイド + +詳細は以下のファイルを参照してください: + +| ファイル | 内容 | +|---------|------| +| `01-environment-detection.md` | 環境判定の詳細、判定スクリプト | +| `02-dood-access.md` | DooD環境でのアクセス方法、bind mount注意点 | +| `03-troubleshooting.md` | トラブルシューティング、ベストプラクティス | + +## よくある問題(簡易版) + +| 症状 | 原因 | 解決策 | +|-----|------|--------| +| `localhost`で接続できない | DooD環境 | コンテナ名を使用 | +| bind mountしたファイルが見えない | DooD環境 | `docker cp`または名前付きボリューム | +| コンテナ間で通信できない | 別ネットワーク | 同じネットワークに接続 | + +## 関連Skill + +- **python-execution**: Python実行環境の判定 +- **corder-code-templates**: Dockerfileテンプレート + +## 関連リソース + +- [Docker Networking](https://docs.docker.com/network/) +- [Docker in Docker](https://hub.docker.com/_/docker) diff --git a/plugins/ndf-claude/skills/fix/SKILL.md b/plugins/ndf-claude/skills/fix/SKILL.md new file mode 100644 index 0000000..92471dd --- /dev/null +++ b/plugins/ndf-claude/skills/fix/SKILL.md @@ -0,0 +1,303 @@ +--- +name: fix +description: "Fix actionable PR review comments." +when_to_use: "PRレビューコメント (codex/gemini/人間) の指摘を実際にコード修正で対応したいとき。review-pr-comments で分類した後の修正フェーズに使う。Triggers: 'PRコメント対応', 'PRレビュー修正', 'PR fix', 'review feedback fix', 'コメントに対応して修正'" +argument-hint: "[PR番号] [--defer-nit] [--severity-min critical|major|minor]" +allowed-tools: + - Bash + - Read + - Edit + - Write + - Glob + - Grep +--- + +# PR修正コマンド + +直前PR、または引数で指定されたPRのreview comment確認・修正対応実行。 + +## 起動モード + +このスキルは **メインセッション直接実行** と **サブエージェント (`general-purpose`) 起動** の両方に対応する。 +長丁場のクロスレビューループ(`/ndf:cross-review`)からは **必ずサブエージェント経由で起動** されることを想定: + +```python +# メインからの起動例(cross-review が内部でこれを行う) +Agent( + subagent_type="general-purpose", + description="Fix PR review comments (sub-agent)", + prompt=""" +/ndf:fix --defer-nit を実行してください。 + +PR: +リポジトリ: +重要度ポリシー: critical/major/minor は修正、nit は deferred として残す +完了後の戻り値: 件数サマリ + 修正コミット SHA + 残 nit リスト +""" +) +``` + +サブエージェント側ではこの SKILL.md を読み込んで、自己完結で +**修正 → コミット → push → reply → Resolve Conversation** まで実行する。 +メインへの戻り値は最小限のサマリのみ。 + +## 引数 + +| 引数 | 意味 | 既定 | +|---|---|---| +| `[PR番号]` | 対象 PR | 直前 PR | +| `--defer-nit` | nit 指摘は修正せず deferred としてリスト出力 | OFF | +| `--severity-min LEVEL` | 指定重要度未満は無視(`critical` / `major` / `minor`) | `minor` (= minor 以上を修正) | + +## 重要度ベースの自動修正ポリシー + +`[重要度 / カテゴリ]` プレフィックス(`/ndf:review` 出力規約)で分類。 +**ただし重要度ラベルを鵜呑みにしない** — 各指摘ごとにコード/仕様を独自に調査し、 +本来の重要度を判定し直してから下表の動作を適用する(bot のラベリングは参考値に過ぎない)。 + +| 重要度 | 動作 | ユーザ問い合わせ | +|---|---|---| +| `critical` | **必ず自動修正** | なし | +| `major` | **必ず自動修正** | なし | +| `minor` / `nit` (パフォーマンス・可読性・重複コード排除) | **このPRで修正対応**。特にトータル行数が減る方向の修正は積極的に実施 | なし | +| `minor` / `nit` (上記カテゴリ、修正範囲が +30 行を超えそう) | ユーザ問い合わせ | あり | +| `minor` (その他) | 自動修正(明らかな改善のみ)。判断が割れるなら `nit` として deferred 扱い | なし | +| `nit` (その他) | `--defer-nit` 指定時は **修正せず deferred リスト** に追加。最後にまとめてユーザ問い合わせ | あり(最後に1回) | + +**重要度の独自判定**: +- AI agent (CodeRabbit / Copilot 等) が `nit` と付けていても、実体がパフォーマンス改善や重複排除なら **minor/nit カテゴリ修正対象** として扱う +- 逆に AI agent が `critical` と付けていても、実害がないスタイル指摘なら `nit` 相当に格下げして deferred 化してよい +- 重要度はカテゴリ(performance/readability/duplication/security/style/etc)と合わせて、コード本体を読んだ上で判定する + +**指摘の正否判断**: +- ロジック・仕様逸脱・セキュリティ: コード/仕様を確認してから修正可否判断 +- bot 指摘で **明らかに誤読** している場合(例: 意図的な変数展開を「クオート不足」と指摘する等): 修正しない、reply で理由説明 +- 仕様判断が必要な指摘(API 変更、互換性破壊など): ユーザ問い合わせ対象(critical でもエスカレーション) + +**自動判断できない場合の取り扱い** (context 節約のため安易に user に投げない): +- 仕様文書(docs/, README)を読んで判断する +- 既存テストを読んで挙動を確認する +- 関連する他コードの慣例を確認する +- それでも不明なら deferred リストに「要ユーザ判断」として記録、最後にまとめて問い合わせ + +## 手順 + +1. review comment取得 + 重要度を**独自に再判定**(AI agent のラベルは参考値) +2. **CIエラー確認**(`gh pr checks ` で **現時点の** 失敗ジョブを検出) + - **完了待ちはしない**。実行中(PENDING/IN_PROGRESS)のチェックは無視して次ステップへ進む + - 直近で失敗(FAILURE)状態のジョブのみを修正対象に取り込む +3. 修正対象を確定: + - `critical` / `major` → 全件修正対象 + - `minor` / `nit` (パフォーマンス・可読性・重複排除) → 修正対象。+30行超なら **deferred + ユーザ問い合わせ** + - `minor` (その他) → 修正対象(明らかでないものは `deferred[]` へ) + - `nit` (その他、`--defer-nit` 時) → `deferred[]` のみ、修正しない + - CIエラー → 全件修正対象(PRテスト範囲外の **flaky テストも見つけ次第修正**) +4. 問題点修正 + - **コード行数が減る方向の修正は積極的に実施**(重複排除、不要分岐除去 等) +5. **コミット前の再確認**(修正作業中に状況が変わっている可能性への対応) + - **review comment再取得**: 作業中に新しいコメントが追加されていないか確認 + - **CI状態再確認**: 現時点の状態だけ確認(完了待ちはしない)。新しい失敗が出ていれば対象に取り込む + - 新しい指摘/失敗があれば手順3に戻る +6. コミット・プッシュ +7. PRにSummaryコメントを追加(対応した件数 + deferred 件数を明記) +8. 対応したインラインコメントに個別に返信 +9. **deferred スレッドには `[deferred / nit]` のラベル付き返信** を投稿(resolve はしない) +10. reviewerに再レビューを依頼 +11. 対応完了したインラインコメントを「Resolve Conversation」にする(`resolveReviewThread` mutation) + - resolve した thread_id / comment_id / path / line を `resolved_threads[]` に記録 + - `deferred` / `rejected` の thread は Resolve しない(次ラウンドで再評価するため) +12. **戻り値ファイルを書き出す**: `/tmp/fix-pr<番号>-result.json` (後述「戻り値フォーマット」参照) + - `ci_failed_checks` には `gh pr checks --json name,state` から `state=FAILURE` の name を抽出して列挙 + - push 直後の CI 再実行結果は**待たない**ため、戻り値の `ci_status` は push 時点での既知失敗のみを反映する + +- 4〜6はgit、1〜2/5と7以降はgithub mcpまたはghを利用 + +**flakyテストの扱い**: PR の変更範囲外で発生している flaky テストも、見つけ次第このPRで修正する。 +flaky を放置するとリポジトリ全体のコード品質が下がり、後続 PR の CI 信頼性も損なわれるため。 + +## CIエラーチェック + +### 失敗ジョブの検出 + +```bash +# PRの全チェック状態を確認(FAIL/PASS/PENDING) +gh pr checks + +# JSON形式で詳細取得 +gh pr checks --json name,state,link,completedAt + +# 失敗ジョブのみ抽出 +gh pr checks --json name,state | \ + python3 -c "import json,sys; [print(c['name']) for c in json.load(sys.stdin) if c['state']=='FAILURE']" + +# 実行中ジョブのみ抽出(状態スナップショット用。完了は待たない) +gh pr checks --json name,state | \ + python3 -c "import json,sys; [print(c['name']) for c in json.load(sys.stdin) if c['state'] in ('PENDING','IN_PROGRESS','QUEUED')]" +``` + +### CI完了待ちはしない + +このスキルでは **CI 完了待ちは行わない**(`gh pr checks --watch` 等は使わない)。 +- 各チェックポイントでは「現時点で FAILURE のジョブ」のみを取り込んで修正する +- push 後の CI 再実行結果も待たない(待機中に context を消費しないため) +- ただし `gh pr checks --json name,state` での **状態スナップショット取得は実施** + し、戻り値の `ci_status` / `ci_failed_checks` に反映する + +### 失敗ログの取得 + +```bash +# ワークフロー実行ID取得 +RUN_ID=$(gh run list --branch --limit 1 --json databaseId --jq '.[0].databaseId // empty') +[ -z "$RUN_ID" ] && { echo "No CI run found for this branch"; exit 0; } + +# 失敗ステップのログだけ表示(効率的) +gh run view $RUN_ID --log-failed + +# 特定ジョブのログ +gh run view $RUN_ID --job --log +``` + +### CIエラーの分類と対応方針 + +| エラー種別 | 対応方針 | +|---|---| +| **lint/format** | 自動修正ツール実行(`ruff`, `prettier`, `eslint --fix` 等)→ コミット | +| **型チェック** | 型定義・アノテーションを修正。無視コメントは原則禁止(根本対応) | +| **テスト失敗** | 失敗テストを読み、実装/テストどちらが正しいか判断してから修正。テスト側の問題なら仕様確認 | +| **ビルドエラー** | 依存関係・構文・設定ファイルを確認 | +| **依存脆弱性** | 可能ならバージョン更新、無理なら除外ルール追加(理由明記) | +| **タイムアウト/flaky** | retry設定、テスト分割、リトライ追加。**PR範囲外の flaky も見つけ次第修正**(放置でリポジトリ全体の品質劣化を招くため) | +| **インフラ一時障害** | 再実行で解消することがあるため `gh run rerun $RUN_ID` を先に試す | + +### review指摘との統合 + +review指摘とCIエラーは**同じPRで一緒に修正**する: +- 同じファイル・機能に関する指摘とCIエラーは1コミットにまとめる +- 独立しているなら別コミットに分離(git log で追いやすい) + +## ghコマンド例 + +### PR コメント一括取得 (3 ソース) + +```bash +# インラインコメント / レビュー body / PR レベルコメントを一括取得 +FETCH_SCRIPT="${PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT}}/skills/fix/scripts/fetch-pr-comments.sh" +"$FETCH_SCRIPT" +``` + +### コメントへの返信 + +```bash +# PRのレビューコメント一覧を取得 (インラインコメントのみ) +gh api repos/{owner}/{repo}/pulls/{pr_number}/comments + +# 特定のコメントに返信(in_reply_to にコメントIDを指定) +gh api repos/{owner}/{repo}/pulls/{pr_number}/comments \ + -f body="修正しました。" \ + -F in_reply_to={comment_id} +``` + +### Resolve Conversation + +```bash +# GraphQL APIでスレッドをresolveする +gh api graphql -f query=' + mutation { + resolveReviewThread(input: {threadId: "{thread_node_id}"}) { + thread { isResolved } + } + } +' +``` + +### thread_node_idの取得方法 + +```bash +# PRのレビュースレッド一覧を取得(node_id含む) +gh api graphql -f query=' + query { + repository(owner: "{owner}", name: "{repo}") { + pullRequest(number: {pr_number}) { + reviewThreads(first: 100) { + nodes { + id + isResolved + comments(first: 1) { + nodes { body } + } + } + } + } + } + } +' +``` + +**方針**: +- 品質・可読性・セキュリティ向上、既存機能影響なし +- 指摘がすべて正しいとは限らない。修正前に仕様を調査し、実施の可否を判断すること +- 未対応の場合はその理由をコメントに書き込む + +## 戻り値フォーマット(必須) + +サブエージェント呼び出し時の context 節約のため、**実行結果は `/tmp/fix-pr<番号>-result.json` に書き出す**: + +```json +{ + "pr": 67, + "fix_commit": "abc1234", + "ci_status": "SUCCESS" | "FAILURE" | "PENDING" | "NONE", + "ci_failed_checks": [], + "ci_note": null, + "fixed_count": 5, + "by_severity": {"critical": 1, "major": 2, "minor": 2, "nit": 0}, + "resolved_threads": [ + { + "thread_id": "PRRT_...", + "comment_id": 3222849090, + "path": "src/foo.py", + "line": 42 + } + ], + "deferred": [ + { + "comment_id": 3222849090, + "thread_id": "PRRT_...", + "path": "src/foo.py", + "line": 42, + "severity": "nit", + "category": "style", + "summary": "末尾セミコロンの有無", + "reason_for_deferral": "好みの範囲。プロジェクト規約と齟齬なし" + } + ], + "rejected": [ + { + "comment_id": 3222849090, + "summary": "heredoc を <<'JSON' にせよ", + "reason_for_rejection": "$SHA を意図的に展開する必要があり、クオート化すると逆に壊れる" + } + ], + "summary_comment_url": "https://github.com/.../pull/67#issuecomment-..." +} +``` + +**フィールド説明**: + +- `ci_failed_checks` — `ci_status = FAILURE` のとき、失敗した check 名の配列。`/ndf:cross-review` 側で code-related (`pint/larastan/test/build/lint/type`) と meta-only (`check_pr_requirements/assignees/reviewers/labels`) を分類し、メタチェックのみ失敗ならループ継続する +- `ci_note` — code-related ではない CI 失敗の補足。例: `"メタチェックのみ失敗: check_pr_requirements — Assignees 未設定"` +- `resolved_threads` — 手順 11 で `resolveReviewThread` mutation を実行したスレッド一覧。`deferred` / `rejected` の thread は **Resolve しない**(再評価のため) + +サブエージェントとして起動された場合は、この JSON をメインに返すサマリの基礎とする。 + +## 作業完了報告(必須) + +メイン or PR への報告内容(戻り値ファイルから抽出): +- 対応した指摘の件数(重要度別: critical/major/minor/nit) +- **deferred 件数**(主に nit、最後にユーザ問い合わせ予定) +- **rejected 件数**(bot 指摘が不適切で修正しなかった件、各々理由付き) +- **対応したCIエラーの一覧**(ジョブ名、エラー内容、修正方法) +- **対応した flaky テストの一覧**(PR範囲外も含む) +- 修正コミット SHA / 修正ファイル一覧 +- 戻り値ファイルパス: `/tmp/fix-pr<番号>-result.json` +- **PR URL を最後に必ず記載**(例: `https://github.com///pull/<番号>`) diff --git a/plugins/ndf-claude/skills/fix/scripts/fetch-pr-comments.sh b/plugins/ndf-claude/skills/fix/scripts/fetch-pr-comments.sh new file mode 100755 index 0000000..aa7f97f --- /dev/null +++ b/plugins/ndf-claude/skills/fix/scripts/fetch-pr-comments.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# Usage: fetch-pr-comments.sh +# 3 ソース (インラインコメント / レビュー body / PR レベルコメント) を一括取得し、 +# タグ付き行単位で stdout に出力する。 +# 全ソース取得失敗時は非 0 で終了する(0件取得と取得失敗を区別)。 +set -uo pipefail + +if [[ $# -lt 2 ]] || [[ -z "${1:-}" ]] || [[ -z "${2:-}" ]]; then + echo "ERROR: 引数が不足しています。Usage: $0 " >&2 + exit 1 +fi + +REPO="$1" +PR="$2" + +FAIL_COUNT=0 + +# 1. インラインコメント (diff の特定行に紐づく) +# 本文全体を保持する。改行は \n エスケープして 1 行に収める。 +# gh api --jq は内部で jq -r 相当だが、環境差を吸収するため明示的に jq -r へパイプする。 +if ! gh api "repos/${REPO}/pulls/${PR}/comments" --paginate \ + | jq -r '.[] | "\(.path // "?"):\(.line // .original_line // "?") [\(.user.login)] \(.body // "" | gsub("\n"; "\\n") | gsub("```"; "` ` `"))"'; then + echo "WARNING: インラインコメントの取得に失敗しました (repos/${REPO}/pulls/${PR}/comments)" >&2 + (( FAIL_COUNT += 1 )) || true +fi + +# 2. レビュー body (CHANGES_REQUESTED / COMMENTED 等の総評) +# 本文全体を保持する。改行は \n エスケープして 1 行に収める。 +if ! gh api "repos/${REPO}/pulls/${PR}/reviews" --paginate \ + | jq -r '.[] | select(.body != null and .body != "") | "[REVIEW-BODY] [\(.user.login)] state=\(.state) \(.body | gsub("\n"; "\\n") | gsub("```"; "` ` `"))"'; then + echo "WARNING: レビュー body の取得に失敗しました (repos/${REPO}/pulls/${PR}/reviews)" >&2 + (( FAIL_COUNT += 1 )) || true +fi + +# 3. PR レベルコメント (Conversation タブの通常コメント) +# 本文全体を保持する。改行は \n エスケープして 1 行に収める。 +if ! gh api "repos/${REPO}/issues/${PR}/comments" --paginate \ + | jq -r '.[] | "[PR-COMMENT] [\(.user.login)] \(.body // "" | gsub("\n"; "\\n") | gsub("```"; "` ` `"))"'; then + echo "WARNING: PR レベルコメントの取得に失敗しました (repos/${REPO}/issues/${PR}/comments)" >&2 + (( FAIL_COUNT += 1 )) || true +fi + +# 全ソース失敗時のみ非 0 で終了(認証切れ等の検出) +if (( FAIL_COUNT >= 3 )); then + echo "ERROR: 全 3 ソースの取得に失敗しました" >&2 + exit 1 +fi diff --git a/plugins/ndf-claude/skills/gemini/SKILL.md b/plugins/ndf-claude/skills/gemini/SKILL.md new file mode 100644 index 0000000..e2156ed --- /dev/null +++ b/plugins/ndf-claude/skills/gemini/SKILL.md @@ -0,0 +1,444 @@ +--- +name: gemini +description: "Delegate coding, review, or research to Gemini CLI." +when_to_use: "外部 AI (Gemini)へコード生成 / レビュー / 調査を委譲したいとき。Triggers: 'geminiで調査', 'geminiレビュー', '第二意見レビュー (Gemini)', 'gemini exec', 'external AI review (Gemini)'" +--- + +# Gemini 外部AI委譲スキル + +## 概要 + +`gemini` CLI(Google Gemini、通常は `/usr/bin/gemini` または `npm` 経由でインストール)を直接実行して、コード生成・独立レビュー・コードベース調査を外部AIに委譲するためのスキル。 + +`codex` skill と同等の用途だが、Gemini CLI は以下の点で扱いやすい: + +- **stdout に最終 response が直接出る**(text/json いずれも空にならない既知挙動なし) +- **bwrap サンドボックスに依存しない**(WSL2 でも追加フラグ不要) +- **プロセス exit で完了判定可能**(sentinel grep が不要) + +## NDFとの関係 + +- `/ndf:review gemini` のように、`review` skill の第二引数 `gemini` 指定時の委譲先として利用される +- 専用エージェント(`corder` 相当)は未整備。委譲時はメインエージェントから本 skill を参照して直接 CLI を起動する +- Codex との使い分けは「既知制約とコスト」節を参照 + +## いつ使うか + +### 使うべきケース +- **独立第二意見レビュー**: 設計書・PR・仕様書を Gemini にレビューさせる(Codex とのクロスチェックに有用) +- **コードベース横断調査**: Gemini はワークスペース全体を走査する設計のため、複数ディレクトリ横断の調査に向く +- **長文生成**: ドキュメント生成・要約・翻訳など +- **Codex でうまくいかない / レート制限に当たったときの代替** + +### 使わないべきケース +- 短時間(1〜2分以内)で済むタスク → メインエージェントで直接対応 +- ユーザとの対話が必要な設計相談 → Plan Mode等で対話しながら進める +- 単純な質問回答 → WebFetch / WebSearch で足りる +- 機密情報を含むコード → 外部API送信の可否を確認してから + +## 前提条件 + +```bash +# インストール確認 +which gemini +gemini --version + +# 初回ログイン(OAuth) +gemini # 起動 → /auth でログイン +``` + +未インストールの場合は以下でセットアップ: + +```bash +# npm 経由 +npm install -g @google/gemini-cli + +# 動作確認 +gemini -p "hello" --output-format text +``` + +## 基本実行パターン + +### 1. 承認モード(重要) + +Gemini CLI は対話モードでは tool 実行ごとに承認を求める。非対話で確実に走らせるには `--yolo` か `--approval-mode yolo` を付ける。 + +```bash +# ❌ 非対話モードで tool 承認待ちで止まる +gemini -p "..." + +# ✅ ツール自動承認(外部隔離前提) +gemini --yolo -p "..." + +# ✅ 読み取り専用モード(plan mode、編集系 tool は走らない) +gemini --approval-mode plan -p "..." +``` + +| モード | 用途 | +|---|---| +| `default` | 対話で都度承認(非対話では止まる) | +| `auto_edit` | 編集系のみ自動承認 | +| `yolo` (`--yolo`) | 全 tool 自動承認 | +| `plan` | 読み取り専用(調査・レビュー向け) | + +**レビュー/調査タスクの推奨**: `--approval-mode plan`(編集事故を防ぐ) +**コード生成タスクの推奨**: `--yolo`(実ファイル編集が必要) + +> ⚠️ **`--yolo` のセキュリティ注意**: 全 tool 自動承認は `rm -rf` / 任意のシェル実行 / 任意のファイル編集を **無確認で許可** する。 +> 必ず以下のいずれかの **外部隔離環境** 内でのみ使用すること: +> - Docker コンテナ / devcontainer +> - VM / CI ランナー +> - 隔離された worktree(ホスト本体のリポジトリでは使わない) +> +> ホスト直接実行や本番リポジトリ作業中の `--yolo` は厳禁。コード生成タスクでも、ホスト直接実行なら +> `--approval-mode auto_edit`(編集系のみ自動承認、シェル実行は都度承認)への降格を検討する。 + +### 2. プロンプトは一時ファイル経由で渡す + +長いプロンプトをシェル引数に直接渡すとエスケープが破綻するので、ファイル経由で stdin か `$(cat ...)` 経由にする。 + +```bash +# Step 1: プロンプトを一時ファイルに書く +cat > /tmp/gemini-prompt.md <<'EOF' +## タスク +以下のファイルを読み込み、設計意図とコードの整合性をレビューしてください。 + +## 対象ファイル(絶対パスで指定) +/absolute/path/to/design.md + +## 出力形式 +Markdown で標準出力に吐いてください。 +EOF + +# Step 2a: stdin 経由(推奨) +gemini --yolo --output-format text -p "$(cat /tmp/gemini-prompt.md)" \ + > /tmp/gemini-stdout.md \ + 2> /tmp/gemini-err.log + +# Step 2b: あるいは stdin パイプ +cat /tmp/gemini-prompt.md | gemini --yolo --output-format text -p "" \ + > /tmp/gemini-stdout.md \ + 2> /tmp/gemini-err.log +``` + +### 3. 出力ストリームの扱い + +Gemini CLI の出力構造(codex と異なる点に注意): + +| ストリーム | `--output-format text` の内容 | `--output-format json` の内容 | +|---|---|---| +| **stdout** | 最終 assistant response の本文(Markdown / プレーンテキスト) | JSON 1 オブジェクト: `{session_id, response, stats}` | +| **stderr** | 警告のみ(例: `Ripgrep is not available. Falling back to GrepTool.`)— 通常数行 | + +**実務上の扱い**: +- 成果物が欲しい → `--output-format text` の stdout をそのまま採用 +- 統計(トークン数・tool 呼び出し履歴)が欲しい → `--output-format json` で stdout を `jq` 解析 + +```bash +# 成果物だけ取りたい +gemini --yolo --output-format text -p "$(cat prompt.md)" > out.md + +# 統計込みで取りたい +gemini --yolo --output-format json -p "$(cat prompt.md)" > out.json +jq -r '.response' out.json > out.md +jq '.stats' out.json > stats.json +``` + +### 4. 最終出力をファイル経由で保証する(補強策) + +Gemini は codex のような「最終 message を返さずに終わる」既知挙動は今のところ確認されていない。 +ただし長尺タスクで途中エラーが起きた場合の保険として、**`apply_patch` 相当の write_file tool で書き出させる指示** をプロンプトに加えると安全: + +```markdown +## 出力先(推奨) + +最終結果を `/tmp/gemini-output-.md` にも書き出してください。 +(stdout には同内容をそのまま出力すれば冪等で問題ありません。) +``` + +回収側は「stdout → ファイル → stderr」の順でフォールバック: + +```bash +# 命名規約: +# STDOUT = gemini の `> リダイレクト` 先(本 skill では /tmp/gemini-stdout.md で統一) +# OUTPUT_FILE = プロンプト指示で `write_file` させた保険ファイル(task ごとに固有名) +OUTPUT_FILE=/tmp/gemini-output-pr13734-review.md +STDOUT=/tmp/gemini-stdout.md + +if [ -s "$STDOUT" ]; then + cp "$STDOUT" ./result.md +elif [ -s "$OUTPUT_FILE" ]; then + cp "$OUTPUT_FILE" ./result.md +else + echo "WARN: Gemini の最終出力を回収できませんでした。stderr を確認:" >&2 + tail -200 /tmp/gemini-err.log +fi +``` + +### 5. バックグラウンド実行 + 待機パターン + +Gemini も大規模調査タスクでは数分かかる。エージェントハーネスのシェルタイムアウト(通常2〜3分)に引っかかる可能性があるため、**バックグラウンド実行 + 待機** が安全。 + +```bash +# 1. プロンプトファイル書き出し +# -> /tmp/gemini-prompt.md + +# 2. gemini をバックグラウンドで起動 +gemini --yolo --output-format text -p "$(cat /tmp/gemini-prompt.md)" \ + > /tmp/gemini-stdout.md \ + 2> /tmp/gemini-err.log & +PID=$! +echo "PID: $PID" + +# 3. 完了検知 — Gemini は exit するので PID watch で OK +# (codex の zombie 問題はないが、念のため出力ファイルサイズも併用すると堅牢) +until ! kill -0 $PID 2>/dev/null; do + sleep 30 +done +echo "DONE" + +# 4. 終了コード確認 +wait $PID +EXIT=$? +echo "exit=$EXIT" +``` + +**注意**: +- Gemini は codex と違って `^tokens used$` のような sentinel を吐かないため、stderr grep では完了判定できない +- 代わりに **プロセスの終了** を見るのが正しい(`kill -0` でプロセス存在確認、`wait $PID` で終了コード回収) + +### 6. 待機間隔のチューニング + +エージェントの context cache TTL は通常5分。これを超えると prompt cache がミスして再送料金が発生する: + +- **短い間隔**: 60〜270秒(TTL=5分内に収まる、軽量) +- **長い間隔**: 1200秒以上(1回のキャッシュミスを長時間で償却) +- **避けるべき**: 300秒前後(キャッシュミス+短時間の最悪) + +Gemini の典型実行時間(数十秒〜5分)に対しては **60〜270秒ポーリング** で十分。 + +### 7. プロセス確認・ログ追跡 + +```bash +# 完了したか +kill -0 $PID 2>/dev/null && echo "RUNNING" || echo "DONE" + +# 進捗を覗く(Gemini は実行中の stdout 出力は限定的なので stderr 側を見る) +tail -30 /tmp/gemini-err.log +``` + +## プロンプト設計のコツ + +### 必須要素 +1. **対象ファイルの絶対パス**(Gemini はワークスペース外のファイルも参照可能だが絶対パスが安全) +2. **調査観点を具体化**(箇条書きで3〜5項目に絞る) +3. **出力形式の指定**(Markdownテンプレートを提示) +4. **スコープ外の明示**(脱線防止) +5. **出力サイズ目安**(例: 400〜500行) + +### レビュー依頼テンプレート + +```markdown +あなたは<役割(例: シニアバックエンドエンジニア / セキュリティレビュアー)>として、 +以下をレビューしてください。 + +## 対象ファイル(必ず最初に読むこと) +`/absolute/path/to/target.md` + +## 観点 +1. <観点1: 例「仕様とコードの整合性」> +2. <観点2: 例「既存APIとの後方互換性」> + +## 調査対象コード(必要に応じて読む) +- `src/...` +- `lib/...` + +## 背景コンテキスト +- <プロジェクト概要> +- <関連PR / Issue番号> +- <既存レビューで対応済みの事項(重複指摘を避けるため)> + +## 出力形式 + +以下を Markdown で **stdout に出力** してください。 +(保険として `/tmp/gemini-output-.md` にも `write_file` で書き出してください。) + +# <タイトル> + +## 総評 +## 1. <観点1> に関する指摘 +### 1.1 正確な主張 +### 1.2 訂正推奨 +## 2. <観点2> に関する指摘 +## 3. 追加提案 +## 4. 承認可否 + +**必須**: 行番号・ファイルパスに紐付けて具体的に指摘してください。400〜500行程度、日本語で出力してください。 +``` + +### コード生成依頼テンプレート + +```markdown +以下の実装タスクを実行してください。 + +## タスク +<具体的な実装内容> + +## 制約 +- <技術制約: 言語バージョン、依存ライブラリ> +- <コーディング規約: ESLint / Prettier / rustfmt等> +- <テスト要件: ユニットテスト必須等> + +## 対象ファイル +- <既存ファイルのパス> +- <新規ファイルのパス案> + +## 背景 +<なぜこの実装が必要か、設計判断の経緯> + +## 完了基準 +- [ ] テストがパスする +- [ ] 型チェック / lint がパスする +- [ ] <追加の受け入れ条件> + +**必須**: ファイル編集は実際に行い、最後に変更ファイル一覧と要点を +stdout に Markdown で出力してください。 +保険として `/tmp/gemini-output-.md` にも `write_file` で書き出してください。 +``` + +## 実例: レビュー依頼の完全フロー + +```bash +# === 1. プロンプト書き出し === +FINAL=/tmp/gemini-output-api-v2-review.md + +cat > /tmp/review-prompt.md < /tmp/gemini-stdout.md \ + 2> /tmp/gemini-err.log & + +PID=$! +echo "gemini PID: $PID" + +# === 3. 完了確認(プロセス終了を待つ) === +until ! kill -0 $PID 2>/dev/null; do + sleep 30 +done +wait $PID +EXIT=$? +echo "DONE exit=$EXIT" + +# === 4. 成果物を回収(stdout 優先 → ファイルフォールバック) === +if [ -s /tmp/gemini-stdout.md ]; then + cp /tmp/gemini-stdout.md ./review-result.md + echo "✅ stdout から回収" +elif [ -s "$FINAL" ]; then + cp "$FINAL" ./review-result.md + echo "⚠ ファイルからフォールバック回収" +else + echo "❌ Gemini の最終出力を回収できませんでした。stderr を確認:" >&2 + tail -200 /tmp/gemini-err.log + exit 1 +fi +``` + +## トラブルシューティング + +### Q1. 非対話モードなのにプロセスがハングする +**原因**: 承認が必要な tool 呼び出しで止まっている(`default` / `auto_edit` モードのまま)。 + +**対処**: `--yolo` または `--approval-mode plan` を付ける。レビュー/調査なら `plan` が安全。 + +### Q2. stdout に思考のような余計な出力が混ざる +**原因**: `--output-format text` でも一部の進捗 / モデル切替メッセージが混ざる場合がある。 + +**対処**: +- `--output-format json` を使い、`jq -r '.response'` で本文だけ抽出する +- プロンプトで「最終結果のみを出力すること、思考や前置きは不要」と明記 + +### Q3. ファイルを読めない / ワークスペース外アクセスで止まる +**原因**: Gemini のワークスペース範囲外のファイル参照、または承認待ち。 + +**対処**: +- `--include-directories /path/to/extra` で対象ディレクトリを追加 +- プロンプトには**絶対パス**を書く +- それでも止まるなら `--yolo` または `--skip-trust` + +### Q4. 出力が途中で切れる / トークン上限 +**原因**: モデルの出力トークン上限に達した。 + +**対処**: +- プロンプトで「400行以内」など出力サイズを指定 +- 観点を絞って分割実行 +- `-m ` でより長い context のモデルを指定(gemini-3-pro 等、利用可能なものに応じて) + +### Q5. 認証エラー (`Authentication required` / `token expired`) +**原因**: OAuth セッション失効。 + +**対処**: +```bash +# 対話モードで再ログイン +gemini +# プロンプト上で /auth を叩いてブラウザ認証 +``` + +### Q6. ハーネスのシェルタイムアウトで kill される +**原因**: フォアグラウンド実行のまま長尺タスクを走らせた。 + +**対処**: 必ず `&` でバックグラウンド化し、`kill -0 $PID` ポーリングで待機する(5節参照)。 + +## 既知の制約とコスト + +1. **承認モード必須**: 非対話実行では `--yolo` か `--approval-mode plan` を必ず付ける +2. **stderr 警告は無害**: `Ripgrep is not available. Falling back to GrepTool.` 等は通常運用上問題なし +3. **ログイン状態**: 初回は対話モードで `/auth` 経由のログインが必要 +4. **APIコスト**: Google AI Studio / Vertex 経由のトークン課金。Codex より安価な傾向だが従量制 +5. **機密情報**: 外部APIにコードが送信されるため、社外秘コードの扱いは組織ポリシーに従うこと +6. **モデル選択**: デフォルトモデルは時期により変動。安定性を求めるなら `-m gemini-2.5-pro` 等を明示 + +## Codex との使い分け + +| 観点 | Codex (`/ndf:codex`) | Gemini (本スキル) | +|---|---|---| +| stdout の信頼性 | 最終 message が落ちることがある(要ファイル書き出し) | stdout に response が直接出る | +| サンドボックス | WSL2 で `--dangerously-bypass-approvals-and-sandbox` 必須 | 追加フラグ不要 | +| 完了判定 | `^tokens used$` sentinel | プロセス exit | +| 出力フォーマット | Markdown 本文のみ | text / json 選択可(json は統計付き) | +| 強み | コード逐語照合、長時間の深い調査 | 横断調査、長文生成、軽量タスク | +| 弱み | セットアップ・運用が煩雑 | 高難度コード解析でやや浅くなることがある | + +**指針**: +- 第二意見が欲しい場合、両方走らせてクロスチェックすると最も堅い +- 短時間で済む独立レビュー → Gemini を先に +- 行番号・件数の逐語確認 → Codex を併用 + +## 関連 + +- **`/ndf:codex` skill**: Codex CLI 経由の同等スキル(本スキルと併用してクロスチェック可能) +- **`/ndf:review` skill**: 第二引数 `gemini` 指定時に本スキルの手順を参照 +- **Gemini CLI 公式ドキュメント**: `gemini --help` +- **他のAI委譲方法**: `codex`, `claude`, `ollama` 等のCLI も同様のパターンで利用可 diff --git a/plugins/ndf-claude/skills/git-gh-operations/01-common-errors.md b/plugins/ndf-claude/skills/git-gh-operations/01-common-errors.md new file mode 100644 index 0000000..a7afa44 --- /dev/null +++ b/plugins/ndf-claude/skills/git-gh-operations/01-common-errors.md @@ -0,0 +1,145 @@ +# Git / gh 共通エラー事例集 + +## 1. git add pathspec エラー + +### 事象 +``` +fatal: pathspec 'lambda-batch/CarImageProcessingPipeline/src/foo.py' did not match any files +``` + +### 原因 +CWD が `/work/repo/lambda-batch/CarImageProcessingPipeline/` なのに、 +リポジトリルートからの相対パスで `git add` した。 + +`git status` はリポジトリルートからの相対パスで表示するが、 +`git add` は CWD からの相対パスで解決する。 + +### 予防策 +```bash +# Step 1: CWD確認 +pwd +# => /work/repo/lambda-batch/CarImageProcessingPipeline/ + +# Step 2: git status の出力を確認 +git status +# modified: lambda-batch/CarImageProcessingPipeline/src/foo.py +# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +# これはリポジトリルートからの相対パス + +# Step 3: CWD からの相対パスに変換 +git add src/foo.py +# または +git add . # CWD以下のすべての変更 +``` + +## 2. gh api 404 エラー + +### 事象 +``` +gh api repos/owner/repo/pulls/comments/123/replies -f body='message' +# => 404 Not Found +``` + +### 原因 +POST メソッドが必要な API エンドポイントに GET でアクセスした。 +`gh api` はデフォルトで GET を使用する。 + +### 修正 +```bash +gh api -X POST repos/owner/repo/pulls/comments/123/replies -f body='message' +``` + +## 3. GitHub 自己 Approve エラー + +### 事象 +``` +Could not approve for pull request review. Can not approve your own pull request +``` + +### 原因 +GitHub はセキュリティ上、自分で作成した PR を APPROVE できない。 + +### 対策 +```bash +# pending review を削除してから COMMENT として再送信 +# method: "delete_pending" → method: "create" + event: "COMMENT" +``` + +## 4. AWS CLI [$LATEST] パースエラー + +### 事象 +``` +Unknown options: , , , +``` + +### 原因 +CloudWatch ログストリーム名に含まれる `[$LATEST]` が +`--query` JMESPath パーサーや shell の glob として解釈される。 + +### 対策 +```bash +# シングルクォートで囲んでも --query との組み合わせで問題が出る +# --output json + python パースが最も安全 +aws logs get-log-events \ + --log-group-name "/aws/lambda/func-name" \ + --log-stream-name '2026/02/18/[$LATEST]abc123' \ + --output json | python3 -c " +import sys, json +data = json.loads(sys.stdin.read()) +for e in data['events']: + print(e['message'].strip()) +" +``` + +## 5. git commit メッセージの特殊文字 + +### 事象 +コミットメッセージに日本語や改行が含まれるとエスケープ問題が発生。 + +### 対策 +常に HEREDOC 形式を使用: +```bash +git commit -m "$(cat <<'EOF' +日本語メッセージ + +詳細説明 + +Co-Authored-By: Claude Opus 4.6 +EOF +)" +``` + +注意: `<<'EOF'` (シングルクォート付き)で変数展開を抑制する。 + +## 6. gh pr checks が exit code 1 で止まる + +### 事象 +``` +gh pr checks 11765 2>&1 +# => チェック結果は表示されるが、1つでもfailがあると exit code 1 で終了 +# => Claude Code が「コマンド失敗」と判定して処理を中断 +``` + +### 原因 +`gh pr checks` は CI チェックに失敗があると非0の exit code を返す仕様。 +Claude Code の Bash ツールはコマンドの exit code が 0 以外だとエラーとして扱う。 + +### 対策 +常に `|| true` を付けて exit code を 0 にする: +```bash +# チェック一覧を取得(failがあっても止まらない) +gh pr checks 11765 2>&1 || true + +# --watch で完了待ちする場合も同様 +gh pr checks 11765 --watch 2>&1 || true + +# 失敗のみフィルタする場合 +gh pr checks 11765 2>&1 | grep -i fail || true +``` + +### 補足 +同様の問題が発生する gh コマンド: +- `gh run view RUN_ID --log-failed` (失敗ログ取得時) +- `gh pr diff` (差分が大きい場合にパイプ破損) + +いずれも `2>&1 || true` を付けることで安全に実行できる。 diff --git a/plugins/ndf-claude/skills/git-gh-operations/SKILL.md b/plugins/ndf-claude/skills/git-gh-operations/SKILL.md new file mode 100644 index 0000000..68b8a7a --- /dev/null +++ b/plugins/ndf-claude/skills/git-gh-operations/SKILL.md @@ -0,0 +1,228 @@ +--- +name: git-gh-operations +description: "Resolve git and GitHub CLI operation errors." +when_to_use: "git / gh コマンドでエラーが出た or 操作方法に迷うとき。Triggers: 'git add', 'git commit', 'git push', 'gh pr', 'gh api', 'GitHub操作', 'gitエラー', 'fatal:', 'pathspec'" +allowed-tools: + - Bash + - Read +--- + +# Git / gh 操作スキル + +## 最重要ルール: CWD とパスの整合性 + +git コマンドはすべて **CWD からの相対パス** で解決される。 +操作前に必ず `pwd` で CWD を確認すること。 + +### パターン1: CWDがサブディレクトリの場合 + +``` +# CWD: /work/repo/lambda-batch/MyProject/ +# リポジトリルート: /work/repo/ + +# NG: リポジトリルートからのパスを指定 +git add lambda-batch/MyProject/src/foo.py +# => fatal: pathspec did not match any files + +# OK: CWDからの相対パスを指定 +git add src/foo.py + +# OK: 絶対パスを指定 +git add /work/repo/lambda-batch/MyProject/src/foo.py +``` + +### パターン2: 安全な方法 + +```bash +# 方法A: git -C でリポジトリルートを指定 +git -C /work/repo add lambda-batch/MyProject/src/foo.py + +# 方法B: CWD を変更せずに絶対パスを使用 +git add "$(git rev-parse --show-toplevel)/lambda-batch/MyProject/src/foo.py" + +# 方法C(推奨): CWDからの相対パスを使用 +# まず pwd で確認してからパスを組み立てる +``` + +## git 操作チェックリスト + +### git add の前に + +1. `pwd` で CWD を確認 +2. `git status` で変更ファイルのパスを確認(表示されるパスはリポジトリルートからの相対パス) +3. `git status` の出力パスと CWD の関係を計算してから `git add` する + +### git commit の前に + +1. `git diff --cached` でステージング内容を確認 +2. HEREDOC形式でメッセージを渡す(改行・特殊文字の問題回避) + +```bash +git commit -m "$(cat <<'EOF' +コミットメッセージ + +Co-Authored-By: Claude Opus 4.6 +EOF +)" +``` + +## gh CLI / GitHub API の注意点 + +### パラメータ: `-f` vs `-F` + +```bash +# -f: 文字列パラメータ +gh api repos/OWNER/REPO/pulls/PR/comments -f body="テキスト" + +# -F: 非文字列パラメータ(数値、boolean、null、ファイル) +gh api repos/OWNER/REPO/pulls/PR/comments -F in_reply_to=2826074026 + +# 混在OK +gh api repos/OWNER/REPO/pulls/PR/comments -f body="返信テキスト" -F in_reply_to=2826074026 +``` + +### PRレビューコメントの取得 + +```bash +# コメント一覧を取得(id, path, body の先頭を表示) +gh api repos/OWNER/REPO/pulls/PR/comments \ + --jq '.[] | {id: .id, path: .path, body: (.body | split("\n")[0][:80])}' +``` + +### PRレビューコメントへの返信 + +```bash +# NG: /replies エンドポイントは存在しない(404になる) +gh api repos/OWNER/REPO/pulls/comments/{id}/replies -f body='...' +# => 404 Not Found + +# NG: -X POST を付けても同じ(エンドポイント自体が存在しない) +gh api -X POST repos/OWNER/REPO/pulls/comments/{id}/replies -f body='...' +# => 404 Not Found + +# OK: in_reply_to パラメータを使って新規コメントとして投稿 +gh api repos/OWNER/REPO/pulls/PR/comments \ + -f body="返信テキスト" \ + -F in_reply_to=COMMENT_ID +``` + +### レビュースレッドの Resolve(GraphQL) + +```bash +# 1. 未解決スレッドのID一覧を取得 +gh api graphql -f query=' +query { + repository(owner: "OWNER", name: "REPO") { + pullRequest(number: PR) { + reviewThreads(first: 50) { + nodes { + id + isResolved + comments(first: 1) { + nodes { path body } + } + } + } + } + } +}' --jq '.data.repository.pullRequest.reviewThreads.nodes[] | select(.isResolved == false) | {id, path: .comments.nodes[0].path}' + +# 2. スレッドを Resolve +gh api graphql -f query=' +mutation { + resolveReviewThread(input: {threadId: "PRRT_xxx"}) { + thread { isResolved } + } +}' +``` + +### PR の CI チェック結果 + +`gh pr checks` は1つでもfailがあると **exit code 1** で終了する。 +Claude Codeではコマンド失敗と判定されて処理が止まるため、必ず `|| true` を付ける。 + +```bash +# NG: failがあるとexit code 1で止まる +gh pr checks PR --repo OWNER/REPO + +# OK: exit codeを常に0にして出力を取得 +gh pr checks PR --repo OWNER/REPO 2>&1 || true + +# OK: 失敗のみフィルタ +gh pr checks PR --repo OWNER/REPO 2>&1 | grep -i fail || true +``` + +#### 重要: CIの完了を待ってはいけない + +- `--watch` や完了までのポーリングは **禁止**。現在のステータスを一度スナップショットするだけでよい。 +- チェックが `in_progress` / `queued` / `pending` の場合は **完了を待たず次のステップへ進む**。 +- 対応対象は **コード修正で直せるfailのみ**。以下のような「ステータス確認系」チェックは無視する: + - `check_pr_requirements` 等、PR要件・メタ情報のみ検証するもの + - Lint/テストに非依存なラベル/タイトル/説明チェック + - 外部サービス起因で自己修復するトランジェントなfail(再実行で直るもの) +- 対応する: ビルド失敗・テスト失敗・型エラー・lint違反など、**リポジトリ内コードの修正で解消可能なもの**。 + +```bash +# 失敗ジョブのログ(エラー行のみ抽出) +gh run view RUN_ID --repo OWNER/REPO --log-failed 2>&1 \ + | grep -E '(FAIL|Error|Tests:)' | head -20 || true +``` + +### 自分のPRは Approve できない + +``` +# GitHub の制約: 自分で作成した PR に APPROVE レビューは不可 +# => "Can not approve your own pull request" +# 対策: event を "COMMENT" に変更して送信 +``` + +### PR作成時の body は HEREDOC + +```bash +# NG: \n がリテラルで混入する可能性 +gh pr create --title "タイトル" --body "行1\n行2" + +# OK: HEREDOC形式 +gh pr create --title "タイトル" --body "$(cat <<'EOF' +## Summary +- 変更内容 + +## Test plan +- [ ] テスト項目 +EOF +)" +``` + +## AWS CLI の注意点 + +### CloudWatch ログストリーム名の [$LATEST] + +```bash +# NG: --query で [$LATEST] を含む文字列がパースエラー +aws logs get-log-events --query 'events[*].message' --output text + +# OK: --output json にして python でパース +aws logs get-log-events --output json | python3 -c " +import sys,json +data = json.loads(sys.stdin.read()) +for e in data['events']: + print(e['message'].strip()) +" +``` + +## エラー事例集 + +| エラーメッセージ | 原因 | 対策 | +|----------------|------|------| +| `fatal: pathspec '...' did not match any files` | CWD とパスの不一致 | `pwd` 確認後、CWD相対パスで指定 | +| `404 Not Found` (gh api replies) | `/comments/{id}/replies` は存在しない | `in_reply_to` パラメータで投稿 | +| `422 Unprocessable` (gh api) | `-f` で数値を渡した | 数値は `-F` を使う | +| `Can not approve your own pull request` | 自己 Approve 不可 | `COMMENT` イベントに変更 | +| `gh pr checks` が exit code 1 | 1つでもfailがあると非0終了 | `gh pr checks ... 2>&1 \|\| true` | +| `Unknown options: , , ,` (aws cli) | `[$LATEST]` のシェルエスケープ | `--output json` + python パース | + +## 詳細ガイド + +| ファイル | 内容 | 参照タイミング | +|---------|------|--------------| +| `01-common-errors.md` | 詳細なエラー事例と再現手順 | エラー発生時 | diff --git a/plugins/ndf-claude/skills/implementation-plan/SKILL.md b/plugins/ndf-claude/skills/implementation-plan/SKILL.md new file mode 100644 index 0000000..0e0a130 --- /dev/null +++ b/plugins/ndf-claude/skills/implementation-plan/SKILL.md @@ -0,0 +1,98 @@ +--- +name: implementation-plan +description: "Create or update implementation plan files." +when_to_use: "実装開始時 / PR作成時に実装プランの作成・更新が必要なとき。複数ファイル変更・新機能追加・DBマイグレーションを含む変更で自動参照。Triggers: '実装プラン', '実装を開始', 'PR作成', 'implementation plan', 'plan first', '設計書を作成', 'issues/に追加'" +--- + +# 実装プランガイド + +## 基本方針 + +実装の開始時およびPR作成時に、`issues/` 配下に実装プランファイルが存在するか確認し、なければ作成する。プランを残すことで後任エンジニアや将来の自分が変更意図を追跡できる。 + +## 実装プランが必要なケース + +以下のいずれかに該当する場合は作成する: + +- 複数ファイルにまたがる変更 +- 新規機能の追加 +- 既存ロジックの大幅な変更 +- DBマイグレーションを伴う変更 +- 複数のタスクに分解できる作業 + +## 実装プランが不要なケース + +以下のような軽微な変更では不要: + +- typo修正、文言変更 +- 設定値の変更のみ +- 1ファイルで完結する軽微な修正 +- フォーマッター適用のみ +- ドキュメントのみの更新 + +判断に迷う場合はユーザーに確認する。 + +## ファイル配置・命名 + +- パス: `issues/` +- ファイル名に日本語は含めないこと(Git/CI/検索ツール互換性のため) +- タスクIDがある場合: `issues/TASK-1234_concise-description.md` +- タスクIDがない場合: `issues/{feature-name}.md` + +## PR作成時のプランファイル生成 + +PR作成時に `issues/` にプランファイルが存在しない場合、以下の情報源からプランファイルを生成する: + +1. **会話履歴** - それまでのやりとりから要件・背景・方針を抽出 +2. **git log** - コミット履歴からタスクの流れと変更概要を把握 +3. **git diff** - 実際の変更内容から修正対象ファイルと変更内容を特定 + +これらを組み合わせて、下記フォーマットに沿ったプランファイルを作成してからPRを作成する。 + +## プランのフォーマット + +```markdown +# {タスクID}: {機能名/修正内容} + +## 関連リンク +(Issue/チケット/設計ドキュメントがあれば記載) + +## 概要 +- 何を実装・修正するのか + +## 問題・背景 +- なぜこの変更が必要なのか(該当する場合) + +## 修正対象 +- 変更対象のファイルパス一覧 + +## タスク分解 + +### Task 1: {タスク名} +- **対象ファイル:** 変更対象のファイルパス +- **変更内容:** 具体的な変更内容 + +### Task 2: {タスク名} +- **対象ファイル:** 変更対象のファイルパス +- **変更内容:** 具体的な変更内容 + +## 影響範囲 +- 変更による影響を受ける機能やファイル + +## テスト計画 +- [ ] {実装した機能が正しく動作することの確認} +- [ ] {既存機能にリグレッションがないことの確認} +``` + +## ワークフロー + +1. 実装の依頼を受けたら、まずプランが必要か判断する +2. 必要な場合は `issues/` にプランファイルを作成してから実装を開始する +3. PR作成時にプランファイルが存在しない場合、必要であれば会話履歴・git log・git diffからプランファイルを生成してからPRを作成する + +## プランと PR Body の関係 + +- プランファイル = 「なぜ」「どう分解するか」を残す永続的な記録 +- PR body = 「何をやったか」「どうテストするか」のレビュー用サマリ + +同じ内容をコピーせず、PR bodyでは「詳細は `issues/xxx.md` 参照」と誘導してもよい。 diff --git a/plugins/ndf-claude/skills/investigation-rules/SKILL.md b/plugins/ndf-claude/skills/investigation-rules/SKILL.md new file mode 100644 index 0000000..a4757a8 --- /dev/null +++ b/plugins/ndf-claude/skills/investigation-rules/SKILL.md @@ -0,0 +1,105 @@ +--- +name: investigation-rules +description: "Write evidence-backed investigation and debug reports." +when_to_use: "調査・デバッグ・不具合レポートを作成するとき。「ない」「該当なし」等の否定的結論を出すときは必ず参照。Triggers: '調査', 'デバッグ', '不具合レポート', '原因調査', 'investigation', 'root cause', 'カラムがない', '該当コードがない', 'データがない'" +--- + +# 調査レポート作成ルール + +不具合調査・データ調査・仕様調査でレポートを作成する際のルール。コード読解だけに頼らず、必ず実行結果・出力・実データで裏取りする。 + +## 否定的結論にはエビデンス必須 + +「カラムがない」「データがない」「関数が呼ばれていない」「該当コードがない」等の **否定的な結論** を書く場合、**必ず実行結果をエビデンスとして添付すること**。 + +### なぜこのルールが必要か + +AIは「もっともらしいが間違った推論」をしがちで、コード読解だけで「ない」と断定して誤判断を招きやすい。事例として、外部テーブルの一部カラムだけを見て「該当カラムなし」と結論づけたが、実際には別名のカラムにデータが存在していた、という判断ミスが典型。 + +### 具体的な裏取り方法 + +| 主張の種類 | 必須エビデンス | +|-----------|--------------| +| DB: カラムが存在しない | `SHOW COLUMNS FROM table_name` / `DESCRIBE` の結果 | +| DB: データが存在しない | `SELECT COUNT(*) FROM table WHERE ...` の結果 | +| DB: テーブルが存在しない | `SHOW TABLES LIKE '%keyword%'` の結果 | +| コード: 関数/シンボルが存在しない | `grep -rn 'name' .` / LSP検索 / Serena `find_symbol` の結果 | +| コード: 呼び出し箇所がない | `find_referencing_symbols` / `grep` の結果 | +| 設定: 値が存在しない | 設定ファイルのdiff / `env` / `config` コマンド出力 | +| ログ: エラーが出ていない | `grep` / 検索ツールのクエリと結果期間 | + +### レポートへの記載例 + +```markdown +### 残課題 + +| 課題 | 概要 | エビデンス | 優先度 | +|------|------|-----------|--------| +| 外部API の retry 未実装 | Xクライアントで retry ハンドリングが無い | `grep -rn "retry\|Retry" src/client/x/` → 0件 | 中 | +| status=deleted の件数 | 論理削除レコードが残存 | `SELECT COUNT(*) FROM ... WHERE status='deleted'` → 2,341件 | 低 | +``` + +### やってはいけないこと + +- コードを読んだだけで「このカラムは存在しない」と断定する +- 1つのテーブル/ファイルだけ見て「データに問題はない」と結論づける +- 外部テーブルの一部のカラムだけ見て「他にはない」と判断する(全カラムを確認する) +- エビデンスなしで残課題の優先度を「低」にする(誤判断の典型) + +## 外部データ調査の原則 + +外部API/外部テーブル/サードパーティデータソースを調査する際は、**全体構造を必ず確認する**。 + +```sql +-- まず全体像を把握する +SHOW COLUMNS FROM external_source_table; + +-- 次に対象カラムのデータ分布を確認する +SELECT column_name, COUNT(*) FROM table GROUP BY column_name; +``` + +外部データは外部システム由来でカラム名・値域が予測しづらいため、コードから逆引きするだけでは見落とす。 + +## ハルシネーション防止チェックリスト + +推論で埋めず、必ず以下を実行して裏取りする: + +| チェック項目 | 方法 | +|------------|------| +| カラム/フィールドが存在するか | `SHOW COLUMNS` / スキーマ定義ファイルを開く | +| データが存在するか | `SELECT COUNT(*) WHERE ...` / サンプルレコード取得 | +| 型が一致するか | DB定義とアプリコード両方を確認(Eloquent `$casts`、dataclass型等) | +| FK/制約が存在するか | マイグレーション履歴を追跡(追加→削除→再追加の変遷を確認) | +| 論理削除ポリシーは何か | `SoftDeletes` / `deleted_at` の有無を確認 | +| 環境差異がないか | dev/staging/prod で同じクエリを実行して比較 | + +## 調査結果の書き方テンプレート + +```markdown +## 症状 +何がどう間違っているか(定量的に、エビデンス付きで) + +## 調査経緯 +1. 仮説1: xxx → クエリ/コマンドで確認 → 否定/肯定 +2. 仮説2: yyy → ... + +## 根本原因 +コードレベルでどこが問題か(ファイル名:行番号で特定) + +## エビデンス +``` +SQL/コマンド実行結果をそのまま貼る +``` + +## 修正方針 +どのフェーズで何を直すか(多層防御の観点) + +## 検証手順 +修正後にどう確認するか(回帰テスト含む) +``` + +SQLクエリ結果・コマンド出力をそのまま貼り、「コードを読んだ推測」と「実行して確認した事実」を明確に区別する。 + +## 関連スキル + +- `/ndf:problem-solving` — 根本原因分析と多層防御の原則 diff --git a/plugins/ndf-claude/skills/issue-plan-strategy/SKILL.md b/plugins/ndf-claude/skills/issue-plan-strategy/SKILL.md new file mode 100644 index 0000000..4275a37 --- /dev/null +++ b/plugins/ndf-claude/skills/issue-plan-strategy/SKILL.md @@ -0,0 +1,335 @@ +--- +name: issue-plan-strategy +description: "Turn issues into plans and implementation workflows." +when_to_use: "issue → plan 作成 / 既存 plan の実装 (実行) を依頼されたとき。複数 PR に分割される設計や、release branch + 個別 PR + worktree 運用が必要なときに参照する。Triggers: 'issueのplanを作って', 'PLANxxの設計', '設計書を起こして', 'このplanを実装して', 'PLANxxを実装', 'planを実行', 'release branch 作って実装開始', 'multi-PR で進めて'" +argument-hint: "[issue-path-or-url] (例: issues/i16.md, https://github.com/org/repo/issues/123)" +allowed-tools: + - Bash + - Read + - Write + - Edit + - Glob + - Grep +--- + +# issue → plan → multi-PR ワークフロー + +1 つの issue から plan を作る際、推奨される PR が複数に分かれることは日常的に発生する。本 skill はその際の **release ブランチ + 個別 PR ブランチ + Draft PR 先行作成 + git worktree 並行開発 + レビュー運用** の標準フローを規定する。 + +本 skill は **plan の作成フェーズと plan の実行(実装)フェーズの両方** をカバーする。同じワークフローが「設計を起こす段階」と「設計に従って実装する段階」を貫通することで、作成者と実装者(あるいは将来の自分)が同じ手順を共有できる。 + +## 発動条件 + +| トリガ | 例 | 入る Step | +|---|---|---| +| スラッシュコマンド (引数あり) | `/ndf:issue-plan-strategy issues/foo.md`、`/ndf:issue-plan-strategy https://github.com/org/repo/issues/123` | Step 0 から | +| スラッシュコマンド (引数なし) | `/ndf:issue-plan-strategy` (現在ブランチで作業中の issue/plan を解析) | Step 0 から | +| 自動発動 (作成系) | 「この issue の plan を作って」「設計書を起こして」「PLAN42 の設計を起こして」 | Step 1〜2 | +| 自動発動 (実行系) | 「この plan を実装して」「PLAN42 を実行して」「multi-PR で進めて」「release branch を切って実装開始」 | Step 0 → 既存 plan を読み → Step 3 以降 | + +引数で渡された issue / plan は **ファイルパス / URL / 番号** いずれでも受け付ける: + +- ファイルパス (`issues/PLANxx_*.md`): 直接 Read +- GitHub Issue URL / `#番号`: `gh issue view --json title,body,labels` で取得 +- それ以外の文字列: そのまま issue 本文として扱う + +## Step 0: 作成フェーズか実行フェーズか判定 + +最初に **既に plan ファイルが存在するか** で判定する。skill 内で `Glob` を使うのが第一選択 (例: `Glob('issues/*PLAN42*')`)。shell で確認する場合は: + +```bash +# issues/ 配下に該当 plan があるか (PLAN42 / feature-name 部分は実値に置換) +find issues/ -maxdepth 1 -iname '*PLAN42*' -o -iname '*feature-name*' +``` + +| 状況 | 進むフェーズ | +|---|---| +| plan ファイルがない / issue しかない | **作成フェーズ** (Step 1〜2 へ) | +| plan ファイルがあり、release branch がない | **実行フェーズ・初期化** (Step 3 へ) | +| release branch も Draft PR も既にある | **実行フェーズ・継続** (Step 5 以降。worktree / 並行開発 / レビュー / merge を進める) | + +実行フェーズで入った場合、既存 plan の **「PR 分割計画」セクション**を必ず Read してから Step 3 以降の自動化判断に使う。 + +## 全体フロー + +``` + ┌─ 作成フェーズ ──────────────────────────────────────┐ +issue 取得 ─┤ │ + │ plan 作成 (必要なら plan モード) ─ 単一PR? ─ YES ─▶ implementation-plan + /ndf:pr で完了 + │ │ + │ NO + └──────────────────────────────────────────┼──────────┘ + ▼ + ┌─ 実行フェーズ ──────────────────────────────────────┐ +既存 plan ─▶│ Step 3: release branch 作成 + Draft release PR │ + │ Step 4: 個別 PR ブランチ作成 + 各 Draft PR (release base) + │ Step 5: git worktree で並行開発 (依存関係を考慮) │ + │ Step 6: 個別 PR ごとに /ndf:review or /ndf:cross-review + │ → /ndf:fix → merge into release │ + │ Step 7: release ブランチで結合テスト相当のレビュー │ + │ Step 8: release PR body 最終化 → Ready & merge │ + └─────────────────────────────────────────────────────┘ +``` + +QA / staging 等の検証環境向けには、個別 PR or release PR 単位で `/ndf:cherry-pick-pr` を別途実行する (Step 9)。 + +実行フェーズに途中から入った場合は、対応する Step の途中再開で構わない。各 Step の冒頭で **既に存在するブランチ / PR / worktree を `git branch -a` / `gh pr list` / `git worktree list` で確認**してから作業に入る。 + +## Step 1: issue 取得と plan 作成 (作成フェーズ専用) + +> 実行フェーズで入った場合はこの Step をスキップし、既存 plan を Read して Step 3 へ進む。 + +1. 引数を解釈して issue 本文を取得する +2. `issues/` 配下に plan ファイルが既に存在するか `Glob` で確認する +3. なければ `/ndf:implementation-plan` の **プランフォーマット**に従って plan ファイルを作成する + - ファイル名は英数 (例: `issues/PLAN42_multi-pr-refactor.md`) + - 内容に「複数 PR に分割する根拠」「PR 単位と依存関係」を必ず含める +4. 設計判断が重い場合は **Claude Code の plan モード** (ExitPlanMode を用いる読み取り専用フェーズ) に切り替えて十分検討してから実装へ進む + +plan の構造は `/ndf:implementation-plan` を参照。本 skill では multi-PR を前提に **以下のセクションを追加**する: + +```markdown +## PR 分割計画 + +| PR # | branch 名 | 概要 | 依存 | 並行可否 | +|---|---|---|---|---| +| 1 | feature/PLAN42-schema | スキーマ追加 | なし | ○ | +| 2 | feature/PLAN42-api | API 実装 | PR1 | × (PR1 merge 後) | +| 3 | feature/PLAN42-ui | UI 実装 | PR1 | ○ (mock で開始可) | + +release branch: `release/PLAN42` +base branch: `main` +``` + +## Step 2: 単一 PR で足りるか判定 + +plan を書いた結果が以下のいずれかなら **release ブランチを作らず**、`/ndf:implementation-plan` + `/ndf:pr` の通常フローに切り替える: + +- 変更ファイルが 1〜2 個で結合度が低い +- 1 PR で安全に review 可能 (差分 ~500 行以内が目安) +- 依存関係のある複数タスクが存在しない + +複数 PR が妥当な場合 (スキーマ + API + UI、機能追加 + マイグレーション、複数モジュール横断 等) のみ Step 3 に進む。 + +## Step 3: release ブランチ + Draft PR 先行作成 (実行フェーズの開始点) + +> 実行フェーズで自動発動した場合の最初の自動化対象。既に `release/` ブランチや Draft PR が存在する場合は作成をスキップし、Step 4 へ進む。 + +### release ブランチ作成 + +```bash +git fetch origin +git checkout -b release/ origin/ +git push -u origin release/ +``` + +### レビュアー視点の原則 (release PR body の大前提) + +個別 PR はセルフレビュー (`/ndf:cross-review` 等) で merge される。**人間のレビュアーが見るのは release PR だけ**であり、個別 PR の存在をレビュアーに意識させてはならない。したがって: + +- release PR の body は **self-contained 必須**: 「何のために」(背景・解決したい課題) と「何を」(release ブランチ全体としての変更内容) を、**個別 PR を一切参照せずに**理解できる粒度で書く +- 個別 PR リンクの列挙を body の本文にしない。開発中の進捗管理に使う場合は `
` 折りたたみ内の補足情報に格下げする +- `/ndf:cross-review` の light rotation と同じ原則を適用する: 現状の差分・実装を反映し、内部用語 (PLAN-ID 運用、round、rotated 等) をレビュアー向け本文に漏らさない + +### release → default の Draft PR を先行作成 + +```bash +gh pr create \ + --base \ + --head release/ \ + --draft \ + --title "release: <概要>" \ + --body "$(cat <<'EOF' +## Summary +- (背景) なぜこの変更が必要か / 解決したい課題 +- (変更内容) release ブランチ全体として何をするか +- plan: issues/_xxx.md + +## Test plan (結合観点のみ) +- [ ] 個別 PR では検出できない結合テスト項目 + +
+開発用: 個別 PR 進捗 (レビュー対象外) + +- [ ] # PR1: ... +- [ ] # PR2: ... +- [ ] # PR3: ... + +
+ + +EOF +)" +``` + +Draft 作成時点では実装が進んでいないため body は plan ベースの暫定でよいが、Ready for review 前に **実装の最終形を反映した body へ最終化**する (Step 8 参照)。 + +release PR を **先に作る理由**: PR 番号が確定し、個別 PR の説明から参照できるため。 + +## Step 4: 個別 PR ブランチ + Draft PR 先行作成 + +> 既存ブランチは `git branch -a | grep "feature/-"` で確認し、未作成のものだけ作る。Draft PR の存在は `gh pr list --base release/ --state all` で確認。 + +各 PR について **同じパターンで先に Draft PR まで作る**: + +```bash +# release ブランチを base に個別ブランチを切る +git fetch origin release/ +git checkout -b feature/- origin/release/ + +# 空コミットで push して Draft PR を作る (base=release と HEAD が同一だと +# gh pr create が "No commits between ..." で失敗するため、差分ゼロのまま PR +# 作成のトリガにする目的で `--allow-empty` を使う) +git commit --allow-empty -m "chore: - Draft PR 作成" +git push -u origin feature/- + +gh pr create \ + --base release/ \ + --head feature/- \ + --draft \ + --title "feat: - <概要>" \ + --body "$(cat <<'EOF' +## Summary +- plan: issues/_xxx.md +- release PR: # +- 担当範囲: + +## Test plan +- [ ] ... + + +EOF +)" +``` + +完了後 release PR の本文を `gh pr edit` で更新し、`
` 内の開発用チェックリストに個別 PR 番号を埋める (body 本文には書かない)。 + +## Step 5: git worktree で並行開発 + +並行可能 (依存なし or mock で先行可) な PR は **git worktree** で同時に開く: + +```bash +# repo ルート (default branch のまま) で +git worktree add ../--schema feature/-schema +git worktree add ../--ui feature/-ui + +# それぞれの worktree で別ターミナル / 別エージェントを起動 +``` + +ガイドライン: + +- **依存のある PR は順次着手**する (PR1 merge → PR2 開始) +- 並行 PR 間で同じファイルを触る場合は事前にレビュー観点で分担を明確化する +- 終わった worktree は `git worktree remove ` で片付ける +- Claude Code から並行開発を指示する場合、Agent tool の `isolation: "worktree"` も検討する + +## Step 6: 個別 PR のレビュー + +**レビューは原則個別 PR 単位**で行う: + +| 用途 | コマンド | +|---|---| +| PR 作成前のセルフレビュー | `/ndf:review-branch` | +| GitHub 上の単体レビュー | `/ndf:review ` | +| codex + gemini 両方の収束ループ | `/ndf:cross-review ` | +| 指摘の修正 | `/ndf:fix ` | + +個別 PR が APPROVE → Draft 解除 → release ブランチへ merge (squash 推奨)。 + +## Step 7: release ブランチのレビュー (結合テスト相当のみ) + +release ブランチへの merge が一通り進んだ段階で: + +- **個別 PR で見た観点を再レビューしない** +- **結合テスト相当**の観点のみレビューする: + - PR 間の API / 型 / スキーマ整合 + - 設定値の重複・矛盾 + - migration の順序依存 + - E2E シナリオ (`/ndf:playwright-scenario-test` の活用) +- ここで個別 PR 範囲のバグが見つかった場合は、**release PR にコメントせず**、該当の個別 PR (既に merge 済みなら新しい修正 PR を release 配下に作成) 側に指摘を書き込み、修正ループを回す +- release PR には integration 観点の指摘のみ残す + +## Step 8: release PR body の最終化と release → default の merge + +### body の最終化 (Ready for review の前に必須) + +個別 PR が全て merge されたら、**Draft 解除の前に** release PR の body を実装の最終形を反映した self-contained な内容へ更新する: + +```bash +# release ブランチ全体の差分を確認して body を書き直す +git fetch origin +git diff origin/...origin/release/ --stat +gh pr edit --title "..." --body "..." +``` + +最終化のチェック観点 (Step 3 のレビュアー視点の原則を満たすこと): + +- [ ] 「何のために」「何を」が個別 PR や plan ファイルを辿らずに理解できる +- [ ] 実装中の方針変更・スコープ増減が body に反映されている +- [ ] 個別 PR への参照が本文に残っていない (`
` 内の開発用情報は残してよい) +- [ ] 内部用語 (round、rotated 等) が漏れていない + +### Draft 解除と merge + +release PR が APPROVE されたら: + +```bash +# Draft 解除 +gh pr ready +# merge: 個別 PR が既に squash 済みで release ブランチに並んでいるため、 +# main 側でも個別 PR 単位の commit を追跡できる `--merge` (merge commit 保持) +# が既定として推奨。プロジェクト規約で線形履歴必須なら `--rebase`、 +# それ以外で commit 数を 1 本にしたい場合のみ `--squash`。 +gh pr merge --merge --delete-branch +``` + +merge 後は plan ファイル末尾に「完了サマリ」(マージ済み PR 番号 / 検証結果) を追記してクローズ化する。 + +## Step 9: 検証環境 (qa/staging 等) への適用 + +QA / staging 検証は **個別 PR 単位** or **release ブランチ単位** のどちらでも OK。 +`/ndf:cherry-pick-pr` は Claude Code 内の slash command なので、shell ではなく +Claude Code セッション上で実行する点に注意。 + +個別 PR 単位で qa に反映する場合: + +```text +# (Claude Code 内で実行する slash command) +/ndf:cherry-pick-pr qa/staging +``` + +release ブランチごと qa に反映する場合 (まとまった検証が必要な場合): + +```bash +# 1. shell で release ブランチに切り替え +git checkout release/ +``` + +```text +# 2. (Claude Code 内で実行する slash command) +/ndf:cherry-pick-pr qa/staging +``` + +詳細は `/ndf:cherry-pick-pr` と `/ndf:branch-fix-strategy` を参照。`feature → main` 系 PR を汚染しないため、検証ブランチ向けは必ず短命ブランチ経由で扱う。 + +## アンチパターン + +| ❌ やってはいけないこと | 理由 | +|---|---| +| release ブランチを作らず巨大な 1 PR で出す | レビュー困難・revert 困難・並行開発不可 | +| 個別 PR の base を default にする | release で統合する意味が失われ、partial merge が default を汚染する | +| 個別 PR Draft 作成を実装後に回す | PR 番号が未確定でクロス参照や CI 待機の段取りが組めない | +| release PR で個別 PR 範囲の指摘を解決しようとする | 該当 PR が既に閉じている場合、コミット意図がずれる | +| release PR の body を個別 PR リンクの列挙だけにする | レビュアーは release PR 単体で変更を把握できず、個別 PR や plan を辿ることになる。body は self-contained 必須 (Step 3 / Step 8) | +| body 最終化せずに Ready for review にする | Draft 作成時の plan ベースの暫定 body のままだと実装の最終形と乖離する | +| 検証ブランチを feature/release に merge する | `feature → main` PR への汚染 (詳細: `/ndf:branch-fix-strategy`) | + +## 関連 skill + +- `/ndf:implementation-plan` — plan ファイルのフォーマット (本 skill が依存) +- `/ndf:branch-fix-strategy` — ブランチ汚染を避ける原則 +- `/ndf:pr` — 通常の PR 作成 / 更新 +- `/ndf:cherry-pick-pr` — 検証ブランチへの cherry-pick PR +- `/ndf:review` / `/ndf:review-branch` / `/ndf:cross-review` — レビュー +- `/ndf:fix` / `/ndf:resolve-pr-comments` — コメント対応 +- `/ndf:playwright-scenario-test` — release ブランチでの E2E 結合テスト diff --git a/plugins/ndf-claude/skills/logging-guidelines/SKILL.md b/plugins/ndf-claude/skills/logging-guidelines/SKILL.md new file mode 100644 index 0000000..007b969 --- /dev/null +++ b/plugins/ndf-claude/skills/logging-guidelines/SKILL.md @@ -0,0 +1,112 @@ +--- +name: logging-guidelines +description: "Design safe and useful application logging." +when_to_use: "コードにログを追加・修正・整理するとき。Triggers: 'ログ追加', 'log追加', 'logger', 'logging', 'ログレベル', 'log level', 'デバッグログ', 'エラーログ', 'logger.info', 'logger.error', 'print文をログに'" +--- + +# ログ運用ガイドライン + +コードにログを追加・修正する際は、以下のルールに従うこと。言語/フレームワークに依存しない原則として記述している。 + +## ログレベルの選択基準 + +| レベル | 用途 | 本番出力(推奨) | +|--------|------|---------------| +| `error` | 例外発生、処理失敗 | o | +| `warning` | データ不備でスキップ、処理継続可能な異常 | o | +| `info` | バッチ開始/完了、重要なビジネスイベント | 環境による(本番off推奨) | +| `debug` | 開発向けデバッグ情報 | x | + +**推奨**: 本番は `LOG_LEVEL=warning` 以上。info/debug は開発・ステージングのみで出力する。 + +## 使用を避けるログレベル + +以下は用途が曖昧または過剰なため、明示的な運用規則がない限り使わない: + +- `notice` — error/warning/info と区別が曖昧 +- `critical`, `alert`, `emergency` — 通常のアプリには過剰。運用規則として「PagerDuty起動基準」などが定義されていない限り使わない + +## ループ内ログのルール + +### 原則: ループ内では info/warning を出力しない + +ループ内で1件ずつログを出力すると、大量データ処理時にログが爆発する。ループ後にサマリーとしてまとめて出力すること。 + +### サマリーログ化パターン(擬似コード) + +``` +# NG: ループ内で1件ずつ出力 +for item in items: + log.info("処理完了", id=item.id) + +# OK: ループ後にまとめて出力 +processed_count = 0 +for item in items: + # 処理... + processed_count += 1 +log.info("バッチ処理完了", processed_count=processed_count) +``` + +### エラー蓄積パターン + +ループ内で例外が発生し処理を継続する場合は、エラー情報を蓄積してループ後にまとめて報告する。先頭N件のみ含めることで、ログサイズ爆発を防ぐ。 + +``` +errors = [] +for item in items: + try: + process(item) + except Exception as e: + errors.append({"id": item.id, "error": str(e)}) + +if errors: + log.error( + "処理で一部失敗", + total_count=len(items), + failed_count=len(errors), + sample_errors=errors[:10], # 先頭10件のみ + ) +``` + +### ループ内 debug も必要最小限 + +ループ内での debug 出力は、他に代替手段がなく調査に不可欠な場合のみ許容。デフォルトは「ループ外で件数サマリ」を基本とする。 + +## 例外処理のルール + +1. **例外は最上位でログ出力** — エントリポイント(コマンド/コントローラー/ジョブ)で catch してログ出力 +2. **再スロー時はログ不要** — 上位で出力されるため二重出力を避ける +3. **例外を握りつぶさない** — catch後に何も報告せず続行するのは禁止 +4. **広めの例外型で捕捉** — 言語の最上位例外型(Python `Exception`、PHP `Throwable`、Java `Throwable` 等)でトップレベル catch する + +## 必須ルール + +1. **コンテキスト情報を含める** — 調査に必要なID等を構造化ログとして渡す +2. **機密情報を含めない** — パスワード、トークン、クレジットカード番号、個人特定情報は禁止 +3. **メッセージは明確に** — 何が起きたか分かる言葉で記述(プロジェクトの言語ポリシーに従う) +4. **ロガー呼び出しを統一** — プロジェクトで統一ファサード/クライアントを使う(例: Laravel は `Log::`, Python は `logging.getLogger(__name__)`) +5. **グローバル/暗黙の名前空間を使わない** — 明示的にimport/useする + +## ログとメトリクスの使い分け + +- **ログ**: 個別のイベント、エラー、コンテキスト情報(構造化ログ) +- **メトリクス**: 件数、レイテンシ、成功/失敗率の集計(Prometheus/DataDog等) +- **トレース**: リクエスト横断の実行フロー(OpenTelemetry等) + +ループ件数カウントなどは、ログではなくメトリクスに寄せるのが望ましい場合が多い。 + +## アンチパターン一覧 + +| アンチパターン | 問題 | +|--------------|------| +| `log.info("")` / 空メッセージ | 意図が伝わらない | +| `log.error(e)` のみ | スタックトレース/contextが欠ける | +| 機密情報をそのままログに入れる | 情報漏洩リスク | +| ループ内で毎回 info 出力 | ログ爆発 | +| try/except で握りつぶし、何も報告しない | 障害の気配を消す | +| 複数行の ASCII ART をログに含める | grep/集計が困難 | + +## 関連スキル + +- `/ndf:problem-solving` — ログから根本原因を特定する手順 +- `/ndf:investigation-rules` — ログをエビデンスとして扱う際の注意点 diff --git a/plugins/ndf-claude/skills/markdown-writing/01-diagram-guide.md b/plugins/ndf-claude/skills/markdown-writing/01-diagram-guide.md new file mode 100644 index 0000000..db40f52 --- /dev/null +++ b/plugins/ndf-claude/skills/markdown-writing/01-diagram-guide.md @@ -0,0 +1,144 @@ +# 図表作成ガイド + +## mermaid 記法 + +### フローチャート + +```mermaid +graph TD + A[開始] --> B{条件判定} + B -->|Yes| C[処理A] + B -->|No| D[処理B] + C --> E[終了] + D --> E +``` + +### シーケンス図 + +```mermaid +sequenceDiagram + User->>API: リクエスト + API->>DB: クエリ + DB-->>API: 結果 + API-->>User: レスポンス +``` + +### クラス図 + +```mermaid +classDiagram + class User { + +int id + +string name + +login() + +logout() + } + class Order { + +int id + +float total + } + User "1" --> "*" Order +``` + +### ER図 + +```mermaid +erDiagram + USER ||--o{ ORDER : places + ORDER ||--|{ LINE_ITEM : contains + PRODUCT ||--o{ LINE_ITEM : "ordered in" +``` + +## plantUML 記法 + +### コンポーネント図 + +```plantuml +@startuml +package "Frontend" { + [React App] +} +package "Backend" { + [API Server] + [Database] +} +[React App] --> [API Server] +[API Server] --> [Database] +@enduml +``` + +### アクティビティ図 + +```plantuml +@startuml +start +:ユーザー入力; +if (有効?) then (yes) + :処理実行; +else (no) + :エラー表示; +endif +stop +@enduml +``` + +## ASCII 許可例(ツリーのみ) + +ディレクトリ構造はASCIIで表現可能: + +``` +project/ +├── src/ +│ ├── components/ +│ └── utils/ +├── tests/ +└── docs/ +``` + +## よくある間違い + +### 避けるべき: ASCII ARTで図を描く + +``` + ┌─────────┐ + │ User │ + └────┬────┘ + │ + ┌────▼────┐ + │ API │ + └─────────┘ +``` + +上記のような図は **mermaid** で描いてください: + +```mermaid +graph TD + User --> API +``` + +### 避けるべき: 順序prefixなしで分割 + +``` +docs/ +├── introduction.md ← NG: prefixがない +├── setup.md +└── usage.md +``` + +正しい方法: + +``` +docs/ +├── 01-introduction.md ← OK +├── 02-setup.md +└── 03-usage.md +``` + +## ベストプラクティス + +| DO | DON'T | +|----|-------| +| mermaid/plantUMLで図を描く | ASCII ARTで図を描く | +| 300行以内に収める | 1000行超の巨大ファイル | +| 順序prefixで分割 | prefixなしで分割 | +| 2桁パディング(01-, 02-) | 1桁(1-, 2-) | diff --git a/plugins/ndf-claude/skills/markdown-writing/SKILL.md b/plugins/ndf-claude/skills/markdown-writing/SKILL.md new file mode 100644 index 0000000..56b3583 --- /dev/null +++ b/plugins/ndf-claude/skills/markdown-writing/SKILL.md @@ -0,0 +1,58 @@ +--- +name: markdown-writing +description: "Write Markdown docs, diagrams, and split files." +when_to_use: "Markdown 文書 / 図表を作成 / 編集するとき。Triggers: 'Markdown作成', 'ドキュメント作成', '文書作成', '図を描く', 'mermaid', 'create document', 'write docs'" +allowed-tools: + - Read + - Write + - Edit +--- + +# Markdown Writing Skill + +## 重要ルール + +### 1. 図表作成ルール + +**mermaid または plantUML を使用**(ASCII ART禁止、ツリー除く) + +```mermaid +graph TD + A[開始] --> B{条件判定} + B -->|Yes| C[処理A] + B -->|No| D[処理B] +``` + +### 2. 文書の長さと分割ルール + +| ページ数 | 対応 | +|---------|-----| +| ~300行 | そのまま | +| 301~600行 | 2ファイルに分割 | +| 600行以上 | セクションごとに分割 | + +**分割時のファイル名**: 順序prefix(01-, 02-, ...)+ ケバブケース + +``` +docs/feature-guide/ +├── 01-introduction.md +├── 02-installation.md +└── 03-usage.md +``` + +## チェックリスト + +- [ ] 図表はmermaid/plantUML使用(ツリー除く) +- [ ] ファイル長は300行以内(超える場合は分割) +- [ ] 分割時は順序prefix使用(01-, 02-, ...) + +## 詳細ガイド + +| ファイル | 内容 | +|---------|------| +| `01-diagram-guide.md` | mermaid/plantUML記法、よくある間違い | + +## 関連リソース + +- [Mermaid公式ドキュメント](https://mermaid.js.org/) +- [PlantUML公式ドキュメント](https://plantuml.com/) diff --git a/plugins/ndf-claude/skills/merged/SKILL.md b/plugins/ndf-claude/skills/merged/SKILL.md new file mode 100644 index 0000000..06af6f2 --- /dev/null +++ b/plugins/ndf-claude/skills/merged/SKILL.md @@ -0,0 +1,29 @@ +--- +name: merged +description: "Clean up after a PR is merged." +argument-hint: "[PR番号]" +disable-model-invocation: true +allowed-tools: + - Bash + - Read +--- + +# マージ後クリーンアップコマンド + +PRマージ後のクリーンアップを実行。 + +## 手順 + +0. **事前確認**: github mcpで引数の(引数が無ければ自身が作成した最新の)PRがmainにmergeされていることを確認。mergeされていなければ終了 +1. **事前確認**: `git status`→変更あればstash +2. **main更新**: `git checkout main`→`git pull` +3. **worktreeクリーンアップ**: `git worktree list` で当該PR番号に対応する worktree (`pr`) を探し、あれば `git worktree remove ` で削除(worktree 内の `.cross_review/` も一緒に消える) +4. **ブランチ削除**: `git branch -d ` → stash復元 + +**注意**: 冪等性保証・エラー時中断・削除済み無視 + +## 作業完了報告(必須) + +- 実行サマリー(PRタイトル、マージコミット、削除したブランチ、現在のブランチ) +- mainブランチの状態 +- PR URL diff --git a/plugins/ndf-claude/skills/ndf-policies/SKILL.md b/plugins/ndf-claude/skills/ndf-policies/SKILL.md new file mode 100644 index 0000000..eb25c33 --- /dev/null +++ b/plugins/ndf-claude/skills/ndf-policies/SKILL.md @@ -0,0 +1,10 @@ +--- +name: ndf-policies +description: "Apply core NDF project policies." +user-invocable: false +--- + +# NDFポリシー + +このスキルはNDFプラグインの基本ポリシーを定義します。 +descriptionフィールドが常時コンテキストに注入されるため、本文の参照は不要です。 diff --git a/plugins/ndf-claude/skills/plan-to-spec/SKILL.md b/plugins/ndf-claude/skills/plan-to-spec/SKILL.md new file mode 100644 index 0000000..eaea6f9 --- /dev/null +++ b/plugins/ndf-claude/skills/plan-to-spec/SKILL.md @@ -0,0 +1,182 @@ +--- +name: plan-to-spec +description: "Finalize an implemented plan into a permanent specification document. Use after implementation is complete and an issues/ plan, PLAN file, design note, or implementation plan should become the final as-is specification under docs/ or another authoritative specification location. Triggers: 'planを仕様書にして', '確定仕様書に移動', '実装完了後にplanを整理', 'planをdocsへ移動', '仕様書としてリライト', 'plan-to-spec', 'finalize plan spec'." +allowed-tools: + - Bash + - Read + - Write + - Edit + - Glob + - Grep +--- + +# Plan to Spec + +実装完了後の plan を、開発履歴ではなく **現在のコードと一致する確定仕様書**として保存する。plan は作業中の意思決定記録であり、完了後は読者が実装経緯を追わなくても仕様を理解できる形に変換する。 + +## 基本方針 + +- plan の内容をそのまま移動せず、最終実装の as-is 仕様として書き直す +- 開発中の履歴、TODO、PR 分割、作業チェックリスト、途中変更、未採用案は削除する +- 仕様書の置き場は既存 docs 構造に合わせ、なければ `docs/specifications/` を作成する +- 仕様書はコードと照合し、実装と矛盾する記述を残さない +- 完了報告は本 skill のテンプレートに従う + +## 入力 + +`$ARGUMENTS` + +引数は plan ファイルパス、issue 番号、PR 番号、または関連キーワードを受け付ける。引数がない場合は、現在ブランチの差分、`issues/`、`docs/`、`git log` から直近の plan を特定する。 + +## 手順 + +### 1. 対象 plan と実装範囲を特定する + +1. 引数がファイルパスならその plan を読む +2. 引数が PR / issue 番号なら `gh pr view` / `gh issue view` とローカルファイル検索で関連 plan を探す +3. 引数がない場合は以下を確認する: + - `git status --short` + - `git branch --show-current` + - `git log --oneline --decorate -20` + - `find issues docs -maxdepth 3 -type f \( -iname '*plan*' -o -iname '*PLAN*' \)` +4. plan が複数候補ある場合は、現在ブランチ・PR・変更ファイルと最も関連が強いものを選ぶ。不明ならユーザーに確認する +5. 実装範囲を特定する: + - PR がある場合: `gh pr diff` / `gh pr view --json files,title,body` + - ローカル変更の場合: `git diff --stat` / `git diff` + - merge 済みの場合: 関連コミット範囲の `git show` / `git diff` + +### 2. 仕様書の配置先を決める + +既存 docs の分類に合わせて配置する。優先順位: + +1. 同種の仕様書がある既存ディレクトリ (`docs/specifications/`, `docs/specs/`, `docs/features/`, `docs/architecture/`, `docs/modules/` など) +2. 対象機能に対応する既存 docs 配下 +3. 適切な場所がなければ `docs/specifications/` を作成 + +ファイル名は英数字・ハイフン中心にし、内容が分かる名前にする。例: + +```text +docs/specifications/auth-session-management.md +docs/features/review-workflow.md +docs/architecture/plugin-skill-loading.md +``` + +移動は履歴が追えるように、可能なら `git mv ` を使う。plan を残す必要がある運用の場合は、ユーザーに確認してからコピーに切り替える。 + +### 3. 仕様書としてリライトする + +他の仕様書の章立て・表記・粒度を先に確認し、同じ体裁へ合わせる。標準章立ては以下を使う。既存 docs に明確な型がある場合はそちらを優先する。 + +```markdown +# {仕様名} + +## 概要 + +## 背景 + +## 対象範囲 + +## 仕様 + +## データ・設定 + +## 外部連携 + +## エラー処理 + +## セキュリティ + +## 運用 + +## テスト観点 + +## 関連リンク +``` + +該当しない章は削除してよい。小さな仕様では `概要`、`仕様`、`運用`、`テスト観点`、`関連リンク` 程度に圧縮する。 + +### 4. 削除・変換ルール + +削除するもの: + +- 実装タスクのチェックリスト +- PR 分割計画、worktree 運用、作業担当、レビュー進捗 +- 「これから実装する」「予定」「案」「未定」など完了前提の表現 +- 開発中に破棄された方針、調査メモ、試行錯誤 +- AI エージェント向けの作業指示 + +変換するもの: + +- 「実装する」→「提供する」「使用する」「保持する」 +- 「修正対象」→「構成」「関連ファイル」 +- 「テスト計画」→「テスト観点」または「検証方法」 +- 「背景・問題」→ 現在の仕様を理解するために必要な背景だけ残す + +### 5. リンクを再調査して修正する + +仕様書内のリンクは移動後の位置から有効になるように直す。 + +- 相対リンクは新しい配置先基準で更新する +- 存在しないファイルリンクは `find` / `rg --files` で移動先を探す +- GitHub issue / PR / 外部ドキュメントのリンクは必要最小限にする +- 開発中の一時リンク、ローカル絶対パス、エージェント固有の transcript リンクは削除する + +リンク先が確認できない場合は、推測で残さず削除するか、確実な上位ドキュメントへ差し替える。 + +### 6. コードと仕様を照合する + +仕様書を書いた後、実装と一致しているかレビューする。 + +確認観点: + +- 仕様書に書いた機能・制約・設定名・ファイルパスが実コードに存在する +- 実コードに存在する重要な挙動が仕様書から抜けていない +- エラー処理、権限、環境変数、永続化、外部連携の記述が実装と一致する +- テスト観点が実装されたテストや手動確認に対応している +- 廃止された名前、過剰な略語、未承認の用語を使っていない + +用語確認では、`AGENTS.md`、`CLAUDE.md`、`docs/`、README、既存仕様書の表記を優先し、プロジェクトで authorize された名称に合わせる。略語は初出で正式名称を併記し、ローカルな作業略称は使わない。 + +### 7. 仕様書レビューを行い修正する + +セルフレビューを 1 回行い、必要に応じて修正する。レビュー観点: + +- 他の仕様書と章立て・見出し粒度・表記が揃っているか +- plan 由来の作業履歴が残っていないか +- as-is 仕様として読めるか +- コードと矛盾していないか +- リンクが移動後のパスで正しいか +- 用語がプロジェクト標準に合っているか +- 仕様書として過不足がないか + +レビュー結果で修正した内容は、完了報告に要約する。 + +### 8. 完了報告 + +完了時は以下のテンプレートで報告する。 + +```markdown +## Plan to Spec 完了報告 + +### 対象 +- 元 plan: `{元planパス}` +- 確定仕様書: `{仕様書パス}` + +### 実施内容 +- plan を `{配置先ディレクトリ}` に移動し、確定仕様書としてリライト +- 開発履歴・TODO・PR分割などの作業情報を削除 +- リンクを移動後のパス基準で修正 + +### レビュー結果 +- 体裁: `{他仕様書との整合結果}` +- コード一致: `{照合した主な実装ファイルと結果}` +- 用語: `{標準用語への修正有無}` + +### 検証 +- `{実行したコマンドや確認内容}` + +### 補足 +- `{残課題がなければ「なし」}` +``` + +残課題がある場合は、仕様書に曖昧な記述を残さず、完了報告の補足に明確に分離する。 diff --git a/plugins/ndf-claude/skills/pr-tests/SKILL.md b/plugins/ndf-claude/skills/pr-tests/SKILL.md new file mode 100644 index 0000000..39f13de --- /dev/null +++ b/plugins/ndf-claude/skills/pr-tests/SKILL.md @@ -0,0 +1,31 @@ +--- +name: pr-tests +description: "Run PR test plans and comment results." +argument-hint: "[PR番号]" +disable-model-invocation: true +allowed-tools: + - Bash + - Read + - Glob + - Grep +--- + +# PR Test Plan実行 + +`/ndf:pr`で作成されたPRのTest Plan(チェックリスト形式)を読み取り、各テスト項目を自動実行する。 + +**制約**: テスト実行と結果報告のみ。コード修正・git操作・ファイル編集は行わない。 + +## 引数 + +- PR番号(オプション): 省略時は現在ブランチから自動検出 + +## 手順 + +1. **PR情報取得**: PR本文からTest Planセクションの`- [ ]`項目をパース +2. **テスト実行計画**: テスト種類の判定、実行順序の決定、自動/手動の分類 +3. **テスト実行**: 各項目を順次実行し成功/失敗を記録 +4. **結果反映**: + - 成功: PR本文の`- [ ]`を`- [x]`に更新 + - 失敗: PRにコメント追加(問題点・対策案・エラー詳細) +5. **最終報告**: テスト結果サマリーをPRにコメント(総テスト数、成功/失敗/スキップ数、成功率) diff --git a/plugins/ndf-claude/skills/pr/SKILL.md b/plugins/ndf-claude/skills/pr/SKILL.md new file mode 100644 index 0000000..22000e4 --- /dev/null +++ b/plugins/ndf-claude/skills/pr/SKILL.md @@ -0,0 +1,161 @@ +--- +name: pr +description: "Commit, push, and create or update PRs." +argument-hint: "[--draft] [base-branch] or [commit-message]" +disable-model-invocation: true +allowed-tools: + - Bash + - Read + - Glob + - Grep +--- + +# PR作成 + +このプロジェクトのコードをcommit, pushし、GitHubでPull Requestを作成する。既にPRがあればPR説明を最新の変更内容に更新する。 + +**制約**: デフォルトブランチ(main, masterなど)で直接コミット禁止 + +## 使用方法 + +``` +/ndf:pr # main へ通常PR作成 +/ndf:pr --draft # main へドラフトPR作成 +/ndf:pr "新機能の追加" # コミットメッセージ指定 +/ndf:pr --draft "wip: 作業中" # ドラフトPR + メッセージ指定 +/ndf:pr qa/staging # base非main → cherry-pick-prへ誘導 +``` + +## 引数の解釈 + +- `--draft` が含まれていればドラフトPR +- 既知のベースブランチ名(`main`, `master`, `qa/*`, `release/*`, `staging/*` 等)が末尾にあればベース指定 +- それ以外の文字列はコミットメッセージとして扱う +- デフォルトは `main` ベース、非ドラフト + +## 手順 + +### 0. PR確認 + +- `git branch --show-current` で現在ブランチを確認 +- `gh pr list --head ` で既存PR確認 +- 既にPRが存在しOPEN状態なら: + - `git add` → `git commit`(日本語メッセージ)→ `git push` + - **既存PR説明を更新** する(「PR説明の更新」節を参照) + - 終了報告 +- PRがない、またはmerge/close済みなら次へ + +### 1. ブランチ確認・切り替え + +- デフォルトブランチの場合: 新featureブランチを作成して切り替え +- デフォルトブランチ以外: `git stash` → `git pull origin `(コンフリクト時は停止しユーザに報告)→ `git stash pop` + +### 2. ベースブランチ判定 + +- 引数の末尾が `main`/`master` 以外のベースブランチ名(`qa/staging`, `release/v2` 等)の場合: + - **警告を出して `/ndf:cherry-pick-pr ` に誘導する** + - 理由: base非mainのPRに直接pushすると `feature → main` のPRに環境固有コードが混入する(詳細は `/ndf:branch-fix-strategy`) + - ユーザーが明示的に継続を指示した場合のみ進める + +### 3. 変更コミット + +- `git status` → `git add` → `git commit`(日本語メッセージ) +- 引数で指定されたコミットメッセージがあればそれを使用、なければ差分から生成 +- 上位階層を含むすべての変更をcommit + +### 4. プッシュ + +```bash +git push -u origin +``` + +### 5. PR作成 + +- `.github/pull_request_template.md` が存在すれば適用 +- `--draft` 指定ならドラフトPR作成 +- タイトル・説明は日本語、body は `## Summary` + `## Test plan` +- 機密情報(トークン、パスワード、APIキー等)を含めない +- body 末尾に `` を入れる +- **body は必ずHEREDOC形式で渡す**(`\n` リテラル混入防止): + +```bash +gh pr create --title "タイトル" $DRAFT_FLAG --body "$(cat <<'EOF' +## Summary +- 変更内容 + +## Test plan +- [ ] テスト項目 + + +EOF +)" +``` + +`DRAFT_FLAG` は `--draft` 指定時のみ `--draft`、それ以外は空。 + +## PR説明の更新(既存PRがある場合) + +既存PRがある場合、以下の手順でPR説明を更新する: + +1. **変更内容の分析**: + - `git log origin/..HEAD` でブランチ全体のコミット履歴 + - `git diff origin/..HEAD --stat` で変更ファイル一覧 + - 必要に応じて変更ファイルの詳細を取得 +2. **既存PR説明の確認**: + - `gh pr view --json body` で現在のPR説明を取得 + - 既存の関連リンク(Issue参照、設計ドキュメント等)は保持する +3. **PR説明の生成**: + - `.github/pull_request_template.md` のテンプレート構造に従う + - ブランチの**全コミット**の変更内容を反映する(最新コミットだけでなく全体) + - 「Summary」「Test plan」「やらないこと」等を適切に記述 +4. **更新の実行**: + ```bash + gh pr edit --body "" + ``` + +## 命名規則 + +- ブランチ: 英語(github flow) +- コミット・PR: 日本語 +- コミットメッセージ prefix 例: + - `feat:` 新機能 + - `fix:` バグ修正 + - `refactor:` リファクタリング + - `docs:` ドキュメント + - `test:` テスト + - `chore:` その他 + +## 検証ブランチ(qa/*等)へのPR作成 + +**重要: featureブランチから直接検証ブランチへPRを作成してはいけません。** + +### アンチパターン(禁止) + +``` +feature/xxx ──PR──→ qa/staging ← ❌ qa/staging をmergeするとmainが汚染される +``` + +### 正しい手順 + +`/ndf:cherry-pick-pr ` を使う(自動化済み)。詳細な理由と手順は: +- `/ndf:cherry-pick-pr` — 自動化コマンド +- `/ndf:branch-fix-strategy` — 原則と手順 + +## 作業完了報告(必須) + +PR作成/更新完了後、以下を報告: + +- 基本情報(PRタイトル、ベース/ソースブランチ、PR番号、ドラフト有無) +- 変更サマリー(コミット数、変更ファイル数、変更行数、主な変更内容) +- コミット履歴 +- PR本文の概要(Summary、Test plan) +- PR URL + +## 関連 + +- `/ndf:cherry-pick-pr` — 環境ブランチへのcherry-pick PR +- `/ndf:deploy` — 環境ブランチへのデプロイPR(ブランチ全体) +- `/ndf:pr-tests` — Test Plan 自動実行 +- `/ndf:review` — PR単位レビュー +- `/ndf:sync-main` — 現ブランチに main を取り込み +- `/ndf:branch-fix-strategy` — ブランチ戦略の原則 diff --git a/plugins/ndf-claude/skills/problem-solving/SKILL.md b/plugins/ndf-claude/skills/problem-solving/SKILL.md new file mode 100644 index 0000000..1b3c110 --- /dev/null +++ b/plugins/ndf-claude/skills/problem-solving/SKILL.md @@ -0,0 +1,162 @@ +--- +name: problem-solving +description: "Solve bugs, incidents, and data inconsistencies at root cause." +when_to_use: "データ不整合 / バグ / 障害対応時に自動参照。「つじつま合わせ」を避けて上流で直す判断が必要なとき。Triggers: 'バグ修正', 'データ不整合', '障害対応', '根本原因', 'root cause analysis', 'data inconsistency', 'incident', '上流で直す', 'patch vs fix'" +--- + +# 問題解決ガイドライン + +データ不整合、バグ、障害対応における問題解決の原則と手順。 + +## 1. 根本原因を探る(つじつま合わせをしない) + +### 原則: 上流で直す + +問題はデータフローの**最も上流**で修正する。下流でのパッチ(migrationによるデータ修正、SQL直接更新、出力時の辻褄合わせ)は最終手段。 + +``` +❌ 悪いパターン + DBに異常データがある → migrationで論理削除 → バッチ再実行 + +✅ 良いパターン + DBに異常データがある → なぜ入ったか調査 → 取り込みロジックにバリデーション追加 + → 異常データを論理削除 → バッチ再実行 +``` + +### 判断フロー + +``` +1. 症状を確認(どのデータ・どの機能が、どう間違っているか) +2. データフロー/呼び出しチェーンを遡る(結果 → 計算 → 素材 → 取り込み → 外部ソース) +3. 最初に異常が発生した地点を特定 +4. その地点のコードを修正 +5. 下流にも防御的チェックを追加(多層防御) +6. 修正後にデータ修復(パイプライン再実行) +``` + +### 典型的な見逃しパターン + +| 症状 | 表層の「原因」 | 真の根本原因 | +|------|-------------|-------------| +| 料金が異常値 | 計算ロジックのバグ | 上流の取り込み時に異常値が混入、バリデーション欠如 | +| レコードの2WD/4WD逆転 | 割当ロジックの不具合 | ORM(Eloquent等)のリレーション型不一致(VARCHAR↔INT)でEager Loadマッチングずれ | +| 一部ユーザーで通知が届かない | 通知送信ロジックの問題 | 論理削除フラグの扱いが `delete()` と `forceDelete()` で異なる | + +## 2. ハルシネーション防止チェック + +### 原則: 自分の推論を疑い、必ず裏取りする + +コードリーディングだけで判断せず、以下を必ず実行する。 + +### チェックリスト + +| チェック項目 | 方法 | +|------------|------| +| カラム/フィールドが存在するか | `SHOW COLUMNS` / スキーマ定義ファイル確認(コード読みだけで判断しない) | +| データが存在するか | `SELECT COUNT(*) FROM table WHERE ...` / サンプル取得 | +| 型が一致するか | DB定義(INT/VARCHAR等)とコード側(`$casts`, dataclass 等)の両方を確認 | +| 外部キー/制約が存在するか | マイグレーション履歴を追跡(追加→削除→再追加の変遷を確認) | +| 論理削除ポリシーは何か | `SoftDeletes` / `deleted_at` の有無を確認(`delete()` と `forceDelete()` の挙動が異なる) | +| 環境差異がないか | dev/staging/prod で同じクエリを実行して比較 | + +### やってはいけないこと + +- コードを読んだだけで「このカラムは存在しない」と断定する +- 1つのテーブル/ファイルだけ見て「データに問題はない」と結論づける +- マイグレーションの最終状態だけ見てFK制約の有無を判断する +- 論理削除の有無を確認せず `delete()` を使う + +### 型不一致の検出パターン + +ORMリレーションで以下の組み合わせは危険: + +| ローカルキー型 | 外部キー型 | リスク | +|--------------|----------|-------| +| VARCHAR | INT | Eager Load マッチングでずれる可能性 | +| INT | VARCHAR | 同上 | +| string | integer | strict comparison で不一致 | + +**対策**: 型キャストで揃えるか、リレーション定義時に明示的に型変換する。 + +## 3. データとコードの整合性検証 + +### 原則: 仮説を立てたら、データとコードの両面から検証する + +``` +仮説: 「処理Aが項目Xを逆に割り当てている」 + → コード確認: 処理AのJOIN/代入ロジックを読む + → データ確認: 上流テーブルの値と処理A結果の値を並べて比較 + → 結論: 上流データは正常、処理A結果が逆転 → 処理Aの問題 +``` + +### 検証手順テンプレート + +```sql +-- Step 1: 元データ(上流)を確認 +SELECT * FROM source_table WHERE conditions ORDER BY updated_at DESC; + +-- Step 2: 中間データを確認 +SELECT * FROM intermediate_table WHERE conditions; + +-- Step 3: 最終データ(下流)を確認 +SELECT * FROM result_table WHERE conditions; + +-- Step 4: 上流と下流を突合 +-- 値が一致するか、変換ロジックが正しいか確認 +``` + +## 4. 多層防御 + +### 1つの修正だけに頼らず、複数レイヤーで防御する + +``` +Layer 1: 取り込み時バリデーション(異常値を入れない) +Layer 2: 型整合性(正しくマッチングする) +Layer 3: 処理時の防御条件(異常値があっても除外する) +Layer 4: 出力時検証(結果の妥当性チェック) +``` + +単一レイヤーだけだと、将来別の経路で同じ問題が再発する可能性が残る。 + +## 5. 修正の進め方 + +### 修正順序 + +1. **コードの修正**を先に行う(根本原因の解消) +2. **デプロイ**する +3. **データの修復**は修正済みコードで再実行する(migration や SQL 直接修正ではなく、パイプライン再実行が望ましい) +4. **検証**で修正を確認する + +### コミット・PR戦略 + +- 根本原因の修正とデータ修復は**別コミット**にする(Revertしやすい) +- 重複コードは発見次第リファクタリングする +- 検証環境向けPRは `cherry-pick-pr` 方式で作成し、mainブランチを汚染しない(詳細は `branch-fix-strategy` スキル参照) + +## 6. 調査レポートの書き方 + +### 必須項目 + +1. **症状**: 何がどう間違っているか(定量的に) +2. **根本原因**: コードレベルでどこが問題か(ファイル名:行番号) +3. **エビデンス**: DB クエリ結果 / ログ / コマンド出力で裏付ける +4. **修正方針**: どのフェーズで何を直すか +5. **検証手順**: 修正後にどう確認するか + +### エビデンスの書き方 + +```markdown +**エビデンス(staging DB)**: +| key | value | updated_at | 状態 | +|---|---|---|---| +| A100 | 103,081 | 2026-04-01 10:49 | 異常(最新) | +| A100 | 44,390 | 2026-03-11 13:42 | 正常 | +``` + +SQLクエリ結果をそのまま貼り、「コードを読んだ推測」と「DBで確認した事実」を明確に区別する。 + +## 関連スキル + +- `/ndf:investigation-rules` — 調査レポート作成時のエビデンス主義 +- `/ndf:branch-fix-strategy` — 複数ブランチへの修正適用戦略 +- `/ndf:logging-guidelines` — ログ設計(原因特定を容易にする) diff --git a/plugins/ndf-claude/skills/python-execution/01-uv-setup.md b/plugins/ndf-claude/skills/python-execution/01-uv-setup.md new file mode 100644 index 0000000..aafd4f1 --- /dev/null +++ b/plugins/ndf-claude/skills/python-execution/01-uv-setup.md @@ -0,0 +1,85 @@ +# uv詳細セットアップガイド + +> **Note**: 基本的な`uv sync`と`uv run python`はSKILL.mdを参照。このファイルは初回セットアップや詳細設定が必要な場合のみ参照。 + +## uvインストール + +```bash +# Linux/macOS +curl -LsSf https://astral.sh/uv/install.sh | sh + +# pip経由(代替) +pip install uv + +# 確認 +uv --version +``` + +## 依存関係管理 + +```bash +# uv.lockがある場合(推奨) +uv sync + +# uv.lockがない場合 +uv lock && uv sync + +# 開発用依存関係も含める +uv sync --dev + +# 特定のextraを含める +uv sync --extra test +``` + +## Pythonバージョン管理 + +```bash +# 特定バージョンをインストール +uv python install 3.12 +uv python install 3.11 + +# プロジェクトで使用するバージョンを固定 +uv python pin 3.12 + +# インストール済みバージョン一覧 +uv python list +``` + +## 実行オプション + +```bash +# スクリプト実行 +uv run python script.py + +# モジュール実行 +uv run python -m pytest +uv run python -m mypy . + +# 引数付き +uv run python script.py --arg value + +# インタラクティブシェル +uv run python +``` + +## プロジェクト初期化(新規作成時) + +```bash +# 新規プロジェクト作成 +uv init my-project +cd my-project + +# 依存関係追加 +uv add requests +uv add --dev pytest + +# ロックファイル生成 +uv lock +``` + +## uv環境の利点 + +- **高速**: Rustで実装、pip比10-100倍速 +- **再現性**: uv.lockで完全な依存関係固定 +- **Pythonバージョン管理**: pyenvなしでバージョン切り替え +- **グローバル環境を汚染しない**: プロジェクト単位で隔離 diff --git a/plugins/ndf-claude/skills/python-execution/02-troubleshooting.md b/plugins/ndf-claude/skills/python-execution/02-troubleshooting.md new file mode 100644 index 0000000..3475b7e --- /dev/null +++ b/plugins/ndf-claude/skills/python-execution/02-troubleshooting.md @@ -0,0 +1,112 @@ +# Python実行 トラブルシューティング + +## よくある問題と解決策 + +### Q: `uv: command not found` + +**原因**: uvがインストールされていない + +**解決策**: +```bash +curl -LsSf https://astral.sh/uv/install.sh | sh +# シェルを再起動するか、パスを通す +source ~/.bashrc # または ~/.zshrc +``` + +### Q: `ModuleNotFoundError` + +**原因**: 依存関係がインストールされていない + +**解決策**: +```bash +# uv環境の場合 +uv sync + +# venv環境の場合 +.venv/bin/pip install -r requirements.txt + +# pyproject.tomlがある場合 +.venv/bin/pip install -e . +``` + +### Q: `python: command not found` + +**原因**: Pythonがインストールされていない、またはパスが通っていない + +**解決策**: +```bash +# python3を試す +python3 --version + +# uvでPythonをインストール +uv python install 3.12 +``` + +### Q: 異なるPythonバージョンが必要 + +**解決策(uv環境)**: +```bash +# 特定バージョンをインストール +uv python install 3.11 + +# プロジェクトで使用するバージョンを固定 +uv python pin 3.11 + +# そのバージョンで実行 +uv run python script.py +``` + +### Q: `pyproject.toml`はあるが`uv.lock`がない + +**解決策**: +```bash +# ロックファイルを生成 +uv lock + +# 依存関係をインストール +uv sync +``` + +### Q: 仮想環境が壊れている + +**解決策**: +```bash +# 仮想環境を削除して再作成 +rm -rf .venv + +# uv環境の場合 +uv sync + +# 手動で作成する場合 +python3 -m venv .venv +.venv/bin/pip install -r requirements.txt +``` + +### Q: パーミッションエラー + +**解決策**: +```bash +# 仮想環境を使用(推奨) +uv sync +uv run python script.py + +# どうしてもグローバルにインストールする場合(非推奨) +pip install --user package_name +``` + +### Q: SSL証明書エラー + +**解決策**: +```bash +# macOSの場合 +/Applications/Python\ 3.x/Install\ Certificates.command + +# または環境変数で一時的に無効化(非推奨) +export PYTHONHTTPSVERIFY=0 +``` + +## 関連リソース + +- [uv公式ドキュメント](https://docs.astral.sh/uv/) +- [Python venv](https://docs.python.org/3/library/venv.html) +- [pyproject.toml仕様](https://packaging.python.org/en/latest/specifications/pyproject-toml/) diff --git a/plugins/ndf-claude/skills/python-execution/SKILL.md b/plugins/ndf-claude/skills/python-execution/SKILL.md new file mode 100644 index 0000000..a07705a --- /dev/null +++ b/plugins/ndf-claude/skills/python-execution/SKILL.md @@ -0,0 +1,86 @@ +--- +name: python-execution +description: "Detect and run the right Python environment." +when_to_use: "Python スクリプトを実行 / セットアップするとき。Triggers: 'python', 'uv', 'スクリプト', 'python環境'" +allowed-tools: + - Read + - Bash + - Glob +--- + +# Python Execution Skill + +## 概要 + +Pythonコードを実行する前に、プロジェクトの実行環境を調査し、適切な方法で実行するためのガイドラインです。 + +## Step 1: 環境検出 + +```bash +ls -la pyproject.toml uv.lock .venv/ venv/ requirements.txt 2>/dev/null +``` + +## Step 2: 実行コマンド選択 + +| 検出ファイル | 実行方法 | 優先度 | +|-------------|---------|-------| +| `pyproject.toml` | `uv run python` | 最高 | +| `.venv/` | `.venv/bin/python` | 中 | +| `venv/` | `venv/bin/python` | 中 | +| 何もなし | `python3` | 最低 | + +## Step 3: 実行 + +### uv環境(pyproject.tomlあり) + +```bash +# 依存関係インストール(初回のみ) +uv sync + +# 実行 +uv run python script.py +uv run python -m module_name +``` + +**uvがない場合のインストール**: +```bash +curl -LsSf https://astral.sh/uv/install.sh | sh +source ~/.bashrc # パスを反映 +``` + +### venv環境(.venv/あり) + +```bash +# 依存関係インストール(初回のみ) +.venv/bin/pip install -r requirements.txt + +# 実行 +.venv/bin/python script.py +``` + +### システムPython + +```bash +python3 script.py +``` + +## ベストプラクティス + +| DO | DON'T | +|----|-------| +| 実行前に環境を調査 | 環境を確認せずに実行 | +| README.md/CLAUDE.mdの指示を優先 | グローバル環境に依存関係をインストール | +| pyproject.tomlがあればuv使用 | source activateに依存 | +| 仮想環境のPythonをパス指定で実行 | python2を使用 | + +## 詳細ガイド(必要時のみ参照) + +| ファイル | 内容 | 参照タイミング | +|---------|------|--------------| +| `01-uv-setup.md` | uv詳細セットアップ、Pythonバージョン管理 | 初回セットアップ時 | +| `02-troubleshooting.md` | エラー解決策 | 問題発生時 | + +## 関連Skill + +- **corder-code-templates**: Pythonコードテンプレート +- **corder-test-generation**: Pythonテスト生成 diff --git a/plugins/ndf-claude/skills/resolve-pr-comments/SKILL.md b/plugins/ndf-claude/skills/resolve-pr-comments/SKILL.md new file mode 100644 index 0000000..433a72d --- /dev/null +++ b/plugins/ndf-claude/skills/resolve-pr-comments/SKILL.md @@ -0,0 +1,146 @@ +--- +name: resolve-pr-comments +description: "Reply to and resolve fixed PR comments." +argument-hint: "[PR番号]" +disable-model-invocation: true +allowed-tools: + - Bash + - Read +--- + +# PRコメントResolveコマンド + +対応済みのPRコメント全てに返信し、スレッドを resolved にする。`/ndf:fix` で修正完了後に呼び出す**クロージング専用**コマンド。 + +## 使用方法 + +``` +/ndf:resolve-pr-comments # 現在のブランチのPRを対象 +/ndf:resolve-pr-comments 9352 # PR番号を指定 +``` + +## `/ndf:fix` との使い分け + +| 観点 | fix | resolve-pr-comments | +|---|---|---| +| 動作 | コード修正+commit+push | 返信+スレッドresolve | +| 前提 | レビュー後、修正が必要 | 修正済み、クロージングのみ | +| 推奨順序 | 先に実行 | fix後の最後に実行 | + +## 処理フロー + +### 1. PR情報の取得 + +```bash +PR_NUMBER="${ARGUMENTS:-$(gh pr view --json number --jq .number)}" +``` + +### 2. PRコメント取得 + +GitHub API でレビューコメントを取得: + +```bash +gh api "repos/:owner/:repo/pulls/$PR_NUMBER/comments" +``` + +### 3. 対応状況の確認 + +各コメントについて、対応済みかどうかを確認する: +- コードの変更履歴(`git log`, `git diff`)と照合 +- 指摘された問題が修正されているか確認 +- PR body の「やらないこと」セクションで別PR対応と明記されているか確認 + +### 4. コメントへの返信 + +対応済みのコメントに対して、内容に応じた返信を投稿する: + +#### 修正対応した場合 +``` +対応しました。 + +{修正内容の簡潔な説明} +``` + +#### 別PRで対応予定の場合 +``` +別PRで対応予定です。 + +PR説明の「やらないこと」に記載の通り、{理由}のため別PRで対応します。 +``` + +#### 対応不要と判断した場合 +``` +確認しました。 + +{対応不要と判断した理由} +``` + +### 5. gh CLI コマンド + +#### レビューコメントに返信(スレッド内) + +```bash +gh api "repos/:owner/:repo/pulls/$PR_NUMBER/comments" \ + -f body="返信メッセージ" \ + -f in_reply_to= +``` + +#### スレッドをResolve(GraphQL) + +まず Thread Node ID を取得: + +```bash +gh api "repos/:owner/:repo/pulls/comments/" --jq '.node_id' +``` + +その上でResolve: + +```bash +gh api graphql -f query=' + mutation { + resolveReviewThread(input: {threadId: ""}) { + thread { isResolved } + } + } +' +``` + +### 6. 実行フロー + +各コメントに対して以下を順次実行: + +1. コメントの内容と対応状況を確認 +2. 適切な返信メッセージを生成 +3. 返信を投稿 +4. スレッドをresolve +5. 結果を報告 + +### 7. 出力フォーマット + +```markdown +## PR #XXXX コメント対応結果 + +### 処理結果 +| # | コメント | 返信内容 | Resolve | +|---|---------|---------|---------| +| 1 | {指摘要約} | 対応しました | ✅ | +| 2 | {指摘要約} | 別PRで対応予定 | ✅ | + +### サマリー +- 処理済み: X件 +- Resolved: X件 +- エラー: X件 +``` + +## 重要ルール + +- **確認してから実行**: 各コメントの対応状況を必ず確認してから返信 +- **コード修正はしない**: 修正は `/ndf:fix` の責務。このコマンドはクロージングのみ +- **適切な返信**: 対応内容に応じた適切な返信メッセージを使用 +- **エラーハンドリング**: API エラー発生時は報告して継続 +- **ユーザー確認**: 判断に迷う場合はユーザーに確認を求める + +## 関連 + +- `/ndf:review-pr-comments` — コメント分類・優先度判定 (READ-ONLY) +- `/ndf:fix` — コメント対応の修正を実施 diff --git a/plugins/ndf-claude/skills/review-branch/SKILL.md b/plugins/ndf-claude/skills/review-branch/SKILL.md new file mode 100644 index 0000000..951e5ea --- /dev/null +++ b/plugins/ndf-claude/skills/review-branch/SKILL.md @@ -0,0 +1,129 @@ +--- +name: review-branch +description: "Review the current branch before opening a PR." +when_to_use: "PR作成前にローカルブランチの実装をセルフレビューしたいとき。Triggers: 'ブランチをレビュー', 'PR前にレビュー', 'セルフレビュー', 'review my branch', 'review before PR', 'self review', 'pre-PR review'" +argument-hint: "[focus-area] (例: security, performance, tests)" +allowed-tools: + - Bash + - Read + - Glob + - Grep +--- + +# ブランチ実装レビューコマンド + +現在のブランチで実装された変更を**PR作成前に**コードレビューする。mainブランチとの差分を分析し、コード品質・セキュリティ・パフォーマンスの観点でフィードバックを返す。 + +## `/ndf:review` との使い分け + +| 観点 | review-branch | review | +|---|---|---| +| 対象 | ローカルブランチの差分(PR前) | GitHub上の既存PR | +| 判定 | フィードバックを返す | Approve / Request Changes を判定 | +| 用途 | PR作成前のセルフレビュー | PR作成後のレビュー | + +## 使用方法 + +``` +/ndf:review-branch # 全般レビュー +/ndf:review-branch security # セキュリティに焦点 +/ndf:review-branch performance # パフォーマンスに焦点 +/ndf:review-branch tests # テスト網羅性に焦点 +/ndf:review-branch "ビジネスロジック" # 任意のフォーカス +``` + +## レビュー手順 + +### 1. 変更の把握 + +```bash +git diff main --name-only # 変更ファイル一覧 +git diff main --stat # 差分の統計 +git log main..HEAD --oneline # コミット履歴 +``` + +### 2. 変更内容の分析 + +各変更ファイルに対して以下を確認: + +- **追加・変更されたロジック**: 意図が明確か、正しく実装されているか +- **テストカバレッジ**: 適切なテストが追加されているか +- **コーディング規約**: プロジェクトの規約に準拠しているか + +### 3. 品質チェック観点 + +#### コード品質 +- 命名規則の一貫性 +- 関数/メソッドの責務(単一責任原則) +- DRY原則(重複コードの排除) +- 可読性・保守性 +- 過剰な抽象化がないか(YAGNI) + +#### セキュリティ +- SQLインジェクション対策 +- XSS対策 +- CSRF対策 +- 入力値バリデーション +- 認証・認可の適切性 +- 機密情報(トークン、キー、PII)の取り扱い + +#### パフォーマンス +- N+1 クエリの有無 +- 不要なデータベースアクセス +- メモリ使用量 +- インデックスの活用 + +#### エラーハンドリング +- 例外が適切に捕捉されているか +- ログ出力の妥当性(詳細は `/ndf:logging-guidelines`) +- リトライ/タイムアウトの設計 + +### 4. レビュー結果の報告 + +```markdown +## レビュー結果 + +### 概要 +- 変更ファイル数: X +- 追加行数: +XXX +- 削除行数: -XXX + +### Good(良い点) +- ... + +### Suggestions(改善提案) +- `path/to/file.ext:123` — 提案内容 + +### Issues(要修正) +- `path/to/file.ext:456` — 問題点と修正方針 +``` + +## 使用例 + +```bash +# 全般的なレビュー +/ndf:review-branch + +# セキュリティ重視(認証系変更など) +/ndf:review-branch security + +# N+1クエリ等のパフォーマンス問題に焦点 +/ndf:review-branch performance + +# テストの網羅性を確認 +/ndf:review-branch tests +``` + +## 注意事項 + +- 大量の変更がある場合、重要な変更から優先的にレビューする +- 自動品質チェック(linter, formatter, type checker)は事前実行済みを前提とする +- レビュー結果は提案であり、最終判断は開発者が行う +- **コード修正は行わない**(分析とフィードバックのみ。修正は `/ndf:fix` で別途実行) + +## 関連 + +- `/ndf:review` — PR単位レビュー (Approve/Request Changes判定) +- `/ndf:review-pr-comments` — 既存PRコメントの分類 +- `/ndf:fix` — PRレビューコメントの修正対応 +- `/ndf:logging-guidelines` — ログ設計 diff --git a/plugins/ndf-claude/skills/review-pr-comments/SKILL.md b/plugins/ndf-claude/skills/review-pr-comments/SKILL.md new file mode 100644 index 0000000..1b51139 --- /dev/null +++ b/plugins/ndf-claude/skills/review-pr-comments/SKILL.md @@ -0,0 +1,110 @@ +--- +name: review-pr-comments +description: "Classify existing PR comments before fixing." +when_to_use: "既存PRのレビューコメントを分類・優先度判定したいとき (修正前)。Triggers: 'PRコメントを確認', 'PRコメントを分類', 'コメント対応の優先度', 'PR comments review', 'classify PR comments', 'PRレビュー結果を見て'" +argument-hint: "[PR番号]" +allowed-tools: + - Bash + - Read + - Glob + - Grep +--- + +# PRコメント分析コマンド (READ-ONLY) + +GitHub PRのレビューコメントを全て確認し、対応可否を判定する。**修正は一切行わない。分析・判定のみ**。 + +## 使用方法 + +``` +/ndf:review-pr-comments # 現在のブランチのPRを対象 +/ndf:review-pr-comments 9352 # PR番号を指定 +``` + +## `/ndf:fix` との使い分け + +| 観点 | review-pr-comments | fix | +|---|---|---| +| 動作 | 分類・優先度判定のみ | 実際にコード修正 | +| 出力 | 分類テーブル+推奨アクション | 修正差分+commit | +| 推奨順序 | 最初に実行 | review-pr-commentsの結果を見て実行 | + +「まず全体像を把握 → 優先度を決めてから修正」という流れに使う。 + +## 処理フロー + +### 1. PR情報の取得 + +引数でPR番号が指定されていればそれを使用、なければ現在のブランチから取得。 + +```bash +CURRENT_BRANCH=$(git branch --show-current) +PR_NUMBER="${ARGUMENTS:-$(gh pr view --json number --jq .number)}" +``` + +### 2. PRコメント取得 (3 ソース) + +fix skill の共有スクリプトで インラインコメント / レビュー body / PR レベルコメントを一括取得: + +```bash +FETCH_SCRIPT="${PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT}}/skills/fix/scripts/fetch-pr-comments.sh" +"$FETCH_SCRIPT" "$(gh repo view --json nameWithOwner -q .nameWithOwner)" "$PR_NUMBER" +``` + +補助情報 (reviewDecision 等): + +```bash +gh pr view "$PR_NUMBER" --json reviewDecision +``` + +GitHub MCP を使う場合は `mcp__github__get_pull_request_comments` を利用。 + +### 3. コメント分析・分類 + +各コメントを以下のカテゴリに分類する: + +| カテゴリ | 説明 | 対応判断 | +|---------|------|---------| +| 🔴 重大 | セキュリティ、データ整合性、クラッシュの可能性 | **対応必須** | +| 🟡 改善推奨 | コード品質、保守性、ベストプラクティス | **対応推奨** | +| 🟢 軽微 | タイポ、フォーマット、命名規則 | **対応すべき** | +| ⚪ 参考 | 提案、質問、情報共有 | **対応任意** | +| 🔵 別PR対応 | 別PRで対応予定と明記されている内容 | **対応不要** | + +### 4. 出力フォーマット + +```markdown +## PR #XXXX コメントレビュー結果 + +### サマリー +- 総コメント数: X件 +- 対応必須: X件 +- 対応推奨: X件 +- 対応すべき: X件 +- 対応任意/不要: X件 + +### 詳細 + +| # | ファイル | 行 | 指摘内容 | 分類 | 対応判断 | +|---|---------|----|---------|----|---------| +| 1 | path/to/file.ext | 123 | 指摘の要約 | 🔴 重大 | **対応必須** | +| 2 | ... | ... | ... | ... | ... | + +### 推奨アクション +1. まず対応すべき項目(重大+軽微) +2. 次に対応推奨項目 +3. 別PRで対応(コメントで返信推奨) +``` + +## 重要ルール + +- **READ-ONLY**: コードの修正は一切行わない +- **PR説明文を確認**: 「やらないこと」「別PR対応」セクションに記載されている内容は「🔵 別PR対応」として分類 +- **コンテキスト理解**: コメントが指摘している問題の本質を理解して分類 +- **判断根拠**: なぜその分類になったかの理由を簡潔に説明 + +## 関連 + +- `/ndf:fix` — 分類結果を踏まえてコード修正を実施 +- `/ndf:resolve-pr-comments` — 修正完了後の返信+Resolve +- `/ndf:review` — PRを新規にレビューする (Approve/Request Changes判定) diff --git a/plugins/ndf-claude/skills/review/SKILL.md b/plugins/ndf-claude/skills/review/SKILL.md new file mode 100644 index 0000000..cfb920b --- /dev/null +++ b/plugins/ndf-claude/skills/review/SKILL.md @@ -0,0 +1,337 @@ +--- +name: review +description: "Review PRs and post approve or changes verdicts." +argument-hint: "[PR番号] [AIエージェント(codex|gemini)]" +disable-model-invocation: true +allowed-tools: + - Bash + - Read + - Glob + - Grep +--- + +# PRレビューコマンド + +直前PR、または引数で指定されたPRを専門家としてレビュー。 + +## 引数 + +- 第一引数 `[PR番号]`: レビュー対象のPR番号(省略時は直前のPR) +- 第二引数 `[AIエージェント]`: レビュー実行者(任意) + - 省略時: Claude(自身)でレビュー + - `codex`: Codex CLI に委譲 + - `gemini`: Gemini CLI に委譲 + +## 実行 + +- 問題点・改善点あり → 「Request Changes」 +- 指摘なし → 「Approve」 +- **レビュー結果は必ず GitHub PR 上に投稿する**(後述「レビュー結果の投稿」参照) + - 指摘は可能な限り **コード行に紐付くインラインコメント** として書く + - ファイル横断・設計レベルの所見のみ review body(総評)に書く + +## 観点 + +言語慣用性(Idiomatic)・可読性・コード品質・保守性・セキュリティ・テストカバレッジ +- 上から順に優先して指摘 + +### 具体的なチェックポイント + +- **その言語らしい記述方式**: イディオム・標準ライブラリ・言語機能の活用 +- **メモリ効率・演算性能を意識したコード** + - キャッシュ利用 + - Python: numpy 利用、内包表記、ジェネレータ + - PHP: switch 文の map(連想配列)化 + - 不要なループ・コピーの排除 +- **関数・メソッド・ファイル行数の適正化** + - 目安: 関数/メソッド 50 行、ファイル 300 行 + - ただしプロジェクトの慣例に従う +- **重複・冗長コードの排除** + - PR 範囲にこだわらず積極的にまとめるよう指摘 +- **柔軟性を損なう定数化の排除** + - 数字をそのまま定数にするような硬直化を避ける + - 定数よりも DB の master テーブル、または json/yaml による外部化を検討 + +## レビュー結果の投稿 + +レビュー結果は **GitHub の PR レビュー機能** を使って必ず PR 上に書き込む。 +個別指摘は **コード行に紐付くインラインコメント** が原則。総評(review body)にだけ書くのは避ける。 + +### 指摘の振り分け + +| 指摘の種類 | 投稿先 | +|---|---| +| 特定ファイル・特定行への指摘 | **インラインコメント** (`comments[].path` + `line`) | +| 複数ファイルにまたがる設計指摘 | 代表箇所にインラインコメント + review body に補足 | +| 設計レベル・PR全体の所見 | review body(総評) | +| ファイル単位の指摘(行を絞れない) | そのファイルの代表行にインラインコメント | + +### 投稿フロー(推奨: 1 リクエストで一括投稿) + +`gh api` の Reviews API を使い、**総評 + 複数のインラインコメント + 判定(event)を 1 回で送信** する。 + +```bash +PR= +OWNER_REPO=$(gh repo view --json nameWithOwner -q .nameWithOwner) +SHA=$(gh pr view "$PR" --json headRefOid -q .headRefOid) + +# 1. インラインコメントを JSON 配列で組み立て +# (path / line / side / body の 4 つが必須。複数行レンジは start_line を併用) +# +# ▼ 推奨: jq -n でシェル変数を安全に流し込む(特殊文字混入時の JSON 破損を防ぐ) +SUMMARY=$'## 総評\n\n... 全体所見をここに ...' +jq -n \ + --arg sha "$SHA" \ + --arg event "REQUEST_CHANGES" \ + --arg body "$SUMMARY" \ + '{ + commit_id: $sha, + event: $event, + body: $body, + comments: [ + {path: "src/foo.py", line: 42, side: "RIGHT", + body: "[major / 可読性] この関数は 70 行ある。〇〇 と △△ に分割を推奨。"}, + {path: "src/bar.py", start_line: 10, line: 25, side: "RIGHT", + body: "[minor / 性能] このループは内包表記化できる。"} + ] + }' > /tmp/review-payload.json + +# 2. Reviews API に POST +gh api -X POST "repos/$OWNER_REPO/pulls/$PR/reviews" --input /tmp/review-payload.json +``` + +> 💡 **JSON 組み立てに heredoc (`< JSON が壊れる(あるいはクオート未エスケープで JSON injection になる)。`jq -n --arg` 経由なら値が自動で +> JSON エスケープされるため安全。クオート付き heredoc (`<<'JSON'`) は逆に `$SHA` が展開されず使えない。 + +**`event` の値**: +- `APPROVE` — 指摘なし +- `REQUEST_CHANGES` — 修正必須の指摘あり +- `COMMENT` — 任意の指摘のみ(マージブロックしない) + +### インラインコメント本文の書式 + +各 `comments[].body` の先頭に **`[重要度 / カテゴリ]`** を付けて視認性を上げる: + +``` +[critical / セキュリティ] SQL がエスケープなしで連結されている。プレースホルダ必須。 +[major / 可読性] 70 行関数。〇〇 と △△ に分割を推奨。 +[minor / 言語慣用性] Python なら内包表記で 1 行化可能。 +[nit / スタイル] スペースが揃っていない。 +``` + +重要度の目安: +- `critical` — セキュリティ・データ破損・本番障害につながる +- `major` — 保守性 / 性能 / 仕様逸脱の重要問題 +- `minor` — 改善推奨だがブロッカーではない +- `nit` — 好み・スタイル + +### 既存コメントがある場合の重複防止 + +同じ箇所への二重指摘を避けるため、投稿前に既存コメントを確認する: + +```bash +# 既存のレビューコメント一覧 +gh api "repos/$OWNER_REPO/pulls/$PR/comments" --paginate \ + | jq -r '.[] | "\(.path):\(.line) \(.body | split("\n")[0])"' +``` + +すでに同種の指摘があれば、その指摘は省くか、reply(既存コメントへの返信)にする。 + +### 補助コマンド + +```bash +# review body 単体(インラインなし)で投稿したい場合 +gh pr review "$PR" --request-changes --body "..." +gh pr review "$PR" --approve --body "..." + +# 会話タブへの普通のコメント(行に紐付かない) +gh pr comment "$PR" --body "..." + +# 1 件だけインラインコメントを追加(既存 review に含めない) +gh api -X POST "repos/$OWNER_REPO/pulls/$PR/comments" \ + -F commit_id="$SHA" \ + -F path="src/foo.py" \ + -F line=42 -F side=RIGHT \ + -F body="..." +``` + +## 外部AIへの委譲手順 + +第二引数が指定された場合、上記「観点」「具体的なチェックポイント」「レビュー結果の投稿」の内容を **レビュー指示プロンプト** として組み立て、指定された CLI に渡す。 + +### 共通: プロンプト組み立て + +1. `gh pr view --json title,body,baseRefName,headRefName,url,headRefOid` で PR メタ情報を取得 +2. `gh pr diff ` で差分を取得(または変更ファイル一覧 + 必要箇所を `gh pr view --json files` 経由で抽出) +3. 上記「観点」「具体的なチェックポイント」「レビュー結果の投稿」セクションをそのままプロンプトに転記 +4. PR タイトル・URL・差分を **対象情報** として明記 +5. **出力は GitHub Reviews API のペイロード形式(JSON)で出させる**(後述「外部AIに必須化する出力形式」参照) + +### 外部AIに必須化する出力形式と直接投稿 + +**外部AIは Reviews API ペイロードを組み立てた後、自分自身で `gh api` を呼んで PR に投稿する**。 +(旧版では生成した JSON をメインに返してメインが投稿していたが、メイン context 消費と往復回数が無駄なので削除) + +メインに返すのは「投稿が成功したか」「最終 verdict (event)」「review URL」「件数」の小さな結果サマリのみ。 + +#### プロンプトに必ず含める指示(テンプレート) + +```markdown +## 出力形式と投稿手順(必須) + +レビュー結果は以下の手順で **あなた自身が PR に投稿** してください。 +メイン側に返すのは投稿結果サマリだけです。 + +### 1. ペイロード組み立て + +以下の JSON を `/tmp/-review-pr<番号>-payload.json` に書き出す +(codex なら `apply_patch`、gemini なら `write_file` を使用): + +\`\`\`json +{ + "commit_id": "", + "event": "REQUEST_CHANGES" | "APPROVE" | "COMMENT", + "body": "## 総評\n\n...(設計レベル・PR全体所見のみ)...", + "comments": [ + { + "path": "src/foo.py", + "line": 42, + "side": "RIGHT", + "body": "[major / 可読性] ..." + } + ] +} +\`\`\` + +ルール: +- 個別指摘は必ず `comments[]` のインラインコメントにすること(行を絞れない場合はファイル代表行) +- `body` (総評) には設計・横断的な所見のみ書く。個別指摘の繰り返しは禁止 +- 各 `comments[].body` の先頭に `[重要度 / カテゴリ]` を付ける(critical/major/minor/nit) +- `path` は **PR差分に登場するファイルのみ**(事前に `gh pr diff --name-only` で取得した一覧から選ぶ) +- `line` は **差分に含まれる行**(追加行・コンテキスト行)に限る。`side=RIGHT` がデフォルト +- `commit_id` は `gh pr view --json headRefOid -q .headRefOid` の値を使う + +### 2. 投稿 + +\`\`\`bash +OWNER_REPO=$(gh repo view --json nameWithOwner -q .nameWithOwner) +gh api -X POST "repos/$OWNER_REPO/pulls//reviews" \ + --input /tmp/-review-pr<番号>-payload.json \ + > /tmp/-review-pr<番号>-response.json +\`\`\` + +### 3. 結果サマリの書き出し(メインが読む) + +`/tmp/-review-pr<番号>-result.json` に投稿結果を書き出す: + +\`\`\`json +{ + "status": "posted" | "failed", + "event": "REQUEST_CHANGES" | "APPROVE" | "COMMENT", + "posted_as": "REQUEST_CHANGES" | "APPROVE" | "COMMENT", + "review_url": "https://github.com/.../pull/#pullrequestreview-...", + "comments_count": 5, + "by_severity": {"critical": 0, "major": 2, "minor": 2, "nit": 1}, + "payload_path": "/tmp/-review-pr<番号>-payload.json", + "error": null +} +\`\`\` + +投稿失敗時は `status: "failed"`、`error` にエラーメッセージ、`payload_path` で payload は残す +(メイン側のフォールバック投稿で使う)。 + +**`event` と `posted_as` の使い分け**: + +- `event` — **AI 本来の判定 (intent)**。ループ収束判定(`/ndf:cross-review`)はこれを見る +- `posted_as` — **GitHub に実際投稿した event**。`event` と同じ値がデフォルト + +GitHub は **自分の PR には `REQUEST_CHANGES` で投稿できない**(`HTTP 422: Can not request changes on your own pull request`)。自分 PR レビューの場合は以下のダウングレードを行う: + +- `event = "REQUEST_CHANGES"` のままにしておく(intent 保持) +- ペイロードの `event` だけ `"COMMENT"` にして投稿 +- `posted_as = "COMMENT"` を結果サマリに記録 + +これにより、後段のループ判定で「本当は REQ なので継続が必要」と判断できる。判定にあたっては事前に `gh api user --jq .login` と `gh pr view --json author --jq .author.login` を比較すること。 + +### 4. 重要度の運用ガイド(auto-fix 判定に直結) + +| 重要度 | 定義 | 後段の扱い | +|---|---|---| +| critical | セキュリティ・データ破損・本番障害につながる | **必ず自動修正** | +| major | 保守性・性能・仕様逸脱の重要問題 | **必ず自動修正** | +| minor | 改善推奨だがブロッカーではない | **自動修正対象**(明らかな改善のみ。判断要なら nit に格下げ) | +| nit | 好み・スタイル | **修正しない、最後にユーザ判断にまとめる** | + +過剰な nit 量産は避ける。critical/major で対応すべき真の問題に集中すること。 +``` + +### `codex` 指定時 + +呼び出し手順の詳細は `/ndf:codex` skill(`plugins/ndf/skills/codex/SKILL.md`)に従う。要点: + +- プロンプトを `/tmp/codex-review-pr<番号>-prompt.md` に書き出し +- 出力先ファイルを `/tmp/codex-output-review-pr<番号>.md` として **プロンプト内で `apply_patch` 書き出しを必須化** +- `codex exec --dangerously-bypass-approvals-and-sandbox --config reasoning.effort=medium -C "$PWD" < prompt > stdout 2> err &` でバックグラウンド起動 +- `grep -q '^tokens used$' err` で完了検知 +- 「ファイル → stdout → stderr」三段フォールバックで成果物を回収 + +> ⚠️ **`--dangerously-bypass-approvals-and-sandbox` のセキュリティ注意**: このフラグは codex の bwrap サンドボックスを完全に無効化し、 +> 任意のシェル実行・任意のファイル編集を無確認で許可する。**必ず Docker / devcontainer / VM / CI ランナー等の外部隔離環境内** でのみ使用すること。 +> ホスト直接実行や本番リポジトリでは使わない。詳細な背景・代替策(`unprivileged_userns_clone` 有効化など)は `/ndf:codex` skill の +> 「サンドボックス制約」節を参照。 + +### `gemini` 指定時 + +呼び出し手順の詳細は `/ndf:gemini` skill(`plugins/ndf/skills/gemini/SKILL.md`)に従う。要点: + +- プロンプトを `/tmp/gemini-review-pr<番号>-prompt.md` に書き出し +- **AI 直接投稿フローでは `--yolo` 必須**(`gh api -X POST` がシェル実行のため、`plan` / `auto_edit` だとブロックされる) +- プロンプト側で **「リポジトリ内ファイルを編集してはならない。`gh api` で投稿するだけ」** を強く明示することで `--yolo` のリスクを抑える +- `gemini --yolo --output-format text -p "$(cat prompt.md)" > stdout 2> err &` でバックグラウンド起動 +- `kill -0 $PID` ポーリングで完了検知(Codex と異なり sentinel 不要 / プロセス exit を見る) +- 成果物は stdout サマリ + `/tmp/gemini-review-pr<番号>-result.json` で回収 + +> ⚠️ **`--yolo` の制約は依然有効**: `/ndf:gemini` skill のセキュリティ警告通り、必ず外部隔離環境内でのみ実行する。プロンプトで「リポジトリ編集禁止」を明示することは必須だが、それは sandbox の代替にはならない。 + +### メイン側の検証とフォールバック + +メインエージェントの責務は **結果サマリ読み込みと検証のみ**: + +```bash +AGENT=codex # or gemini +RESULT=/tmp/$AGENT-review-pr$PR-result.json + +if [ ! -s "$RESULT" ]; then + echo "❌ $AGENT: 結果サマリ未生成。完了検知 or プロンプト指示に問題あり" >&2 + exit 1 +fi + +STATUS=$(jq -r '.status' "$RESULT") +EVENT=$(jq -r '.event // empty' "$RESULT") + +if [ "$STATUS" = "failed" ]; then + echo "⚠️ $AGENT: 投稿失敗。payload からメインがフォールバック投稿します" >&2 + PAYLOAD=$(jq -r '.payload_path' "$RESULT") + OWNER_REPO=$(gh repo view --json nameWithOwner -q .nameWithOwner) + SHA=$(gh pr view "$PR" --json headRefOid -q .headRefOid) + jq --arg sha "$SHA" '.commit_id = $sha' "$PAYLOAD" > /tmp/review-fallback.json + gh api -X POST "repos/$OWNER_REPO/pulls/$PR/reviews" --input /tmp/review-fallback.json +fi + +echo "$AGENT: event=$EVENT url=$(jq -r .review_url $RESULT)" +``` + +**Claude 自身による追加判定は行わず**、外部AIの判定(`event`)と指摘内容をそのまま採用する。 + +## 作業完了報告(必須) + +レビュー結果は **PR 上に投稿済み** であることが前提。ユーザーへの報告は以下に絞る: + +- 利用エージェント(claude / codex / gemini のいずれか) +- 投稿結果(review URL、event = APPROVE / REQUEST_CHANGES / COMMENT) +- 件数サマリ(インラインコメント数、重要度別内訳) +- 総評(review body)の要約 +- PR URL + +詳細な指摘内容は PR 上のインラインコメントに残っているため、ユーザー宛報告では繰り返さない。 diff --git a/plugins/ndf-claude/skills/statusline/SKILL.md b/plugins/ndf-claude/skills/statusline/SKILL.md new file mode 100644 index 0000000..ab17e4a --- /dev/null +++ b/plugins/ndf-claude/skills/statusline/SKILL.md @@ -0,0 +1,51 @@ +--- +name: statusline +description: "Switch, restore, or inspect the NDF statusline." +when_to_use: "statuslineを切り替え/復元/確認したいとき。Triggers: 'statusline', 'ステータスライン', 'statusline 切り替え', 'statusline 戻す'" +disable-model-invocation: true +allowed-tools: + - Bash +--- + +# Statusline 切り替えコマンド + +NDF 標準 statusline (コンテナ名/ホスト名 + project_dir + コンテキスト使用率) と +既存のカスタム statusline を切り替える。 + +## 表示内容 + +``` +<コンテナ名|ホスト名> [<モデル名>: 12.3k / 200k tokens (6%)] +``` + +- コンテナ環境 (`/.dockerenv` あり) ではコンテナ名、それ以外ではホスト名を表示 +- `CONTAINER_NAME` 環境変数があればそちらを優先 +- 角括弧内のラベルは利用中モデルの表示名 (例: `Opus 4.8`)。取得できない場合は `ctx` にフォールバック + +## 使用方法 + +引数に応じて以下のコマンドを実行する: + +```bash +PLUGIN_ROOT="${PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT}}" + +# 状態確認 (引数なし or status) +bash "${PLUGIN_ROOT}/scripts/statusline-switch.sh" status + +# NDF 標準 statusline に切り替え (既存設定は自動バックアップ) +bash "${PLUGIN_ROOT}/scripts/statusline-switch.sh" set + +# 元の設定に復元 (バックアップが無ければ statusLine 設定を削除) +bash "${PLUGIN_ROOT}/scripts/statusline-switch.sh" restore +``` + +実行後、スクリプトの出力をそのままユーザーに報告する。 +statusline の変更は次回セッション開始時 (または statusline 再描画時) に反映される。 + +## 自動デフォルト設定 (SessionStart hook) + +プラグインインストール後の初回セッション開始時に `statusline-switch.sh ensure` が実行され、 +**statusLine が未設定の場合のみ** NDF 標準 statusline が設定される。 +既に statusline が設定されている場合はそちらが優先され、何も変更しない。 +NDF 標準 statusline の利用中は、プラグイン更新時にスクリプト +(`~/.claude/ndf-statusline.sh`) の内容が自動で追従する。 diff --git a/plugins/ndf-claude/skills/statusline/tests/__init__.py b/plugins/ndf-claude/skills/statusline/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/ndf-claude/skills/statusline/tests/test_statusline_switch.py b/plugins/ndf-claude/skills/statusline/tests/test_statusline_switch.py new file mode 100644 index 0000000..c875a37 --- /dev/null +++ b/plugins/ndf-claude/skills/statusline/tests/test_statusline_switch.py @@ -0,0 +1,150 @@ +"""statusline-switch.sh `ensure` のバージョンアップ追従ロジックを検証する。 + +NDF が過去に配置した statusline コピー (マーカー付き / レガシー +statusline-command.sh) を settings.json が指している場合に、正規パス +(~/.claude/ndf-statusline.sh) 参照へ自動移行することを確認する。 +ユーザー独自の statusline は決して上書きしないこと (誤検出ガード) も検証する。 + +既存 cross-review テストの規約に倣い、隔離 HOME 上で bash スクリプトを +subprocess 実行して settings.json の最終状態を観測する。 +""" +from __future__ import annotations + +import json +import os +import shutil +import subprocess +from pathlib import Path + +import pytest + +# bash / jq が無ければモジュールごと skip +for _cmd in ("bash", "jq"): + if shutil.which(_cmd) is None: + pytest.skip(f"{_cmd} not available", allow_module_level=True) + +# plugins/ndf/skills/statusline/tests/ -> plugins/ndf/scripts/statusline-switch.sh +SWITCH = Path(__file__).resolve().parents[3] / "scripts" / "statusline-switch.sh" +NDF_COMMAND = "bash ~/.claude/ndf-statusline.sh" + +# NDF statusline コピーとみなされる最小内容 (レガシー判定用: ctx ラベル + コンテナ名取得) +LEGACY_COPY = ( + "#!/bin/bash\n" + "container_name=$(hostname)\n" + 'printf "[ctx: 1k / 2k tokens (5%%)]"\n' +) +# マーカー付きコピー (将来の全コピーが該当) +MARKED_COPY = ( + "#!/bin/bash\n" + "# ndf-statusline: managed (do not edit; auto-updated by ndf:statusline)\n" + 'echo "hi"\n' +) +# NDF と無関係なユーザー独自 statusline +CUSTOM = '#!/bin/bash\necho "my custom bar"\n' + + +def _run_ensure(home: Path) -> subprocess.CompletedProcess: + """隔離 HOME で `statusline-switch.sh ensure` を実行する。""" + env = os.environ.copy() + env["HOME"] = str(home) + return subprocess.run( + ["bash", str(SWITCH), "ensure"], + capture_output=True, + text=True, + env=env, + ) + + +def _settings(home: Path) -> dict: + return json.loads((home / ".claude" / "settings.json").read_text()) + + +def _write_settings(home: Path, command: str) -> None: + (home / ".claude" / "settings.json").write_text( + json.dumps({"statusLine": {"type": "command", "command": command}}) + ) + + +def _claude(home: Path) -> Path: + d = home / ".claude" + d.mkdir(parents=True, exist_ok=True) + return d + + +def test_legacy_copy_is_migrated(tmp_path: Path) -> None: + """マーカー無しの既知レガシー statusline-command.sh は正規パスへ移行される。""" + claude = _claude(tmp_path) + (claude / "statusline-command.sh").write_text(LEGACY_COPY) + _write_settings(tmp_path, "bash ~/.claude/statusline-command.sh") + + result = _run_ensure(tmp_path) + + assert result.returncode == 0, result.stderr + assert _settings(tmp_path)["statusLine"]["command"] == NDF_COMMAND + # 既存設定がバックアップされていること + assert (claude / ".ndf-statusline-backup.json").is_file() + + +def test_marked_copy_is_migrated(tmp_path: Path) -> None: + """マーカー付きコピー (任意のファイル名) は正規パスへ移行される。""" + claude = _claude(tmp_path) + (claude / "my-status.sh").write_text(MARKED_COPY) + _write_settings(tmp_path, "bash ~/.claude/my-status.sh") + + result = _run_ensure(tmp_path) + + assert result.returncode == 0, result.stderr + assert _settings(tmp_path)["statusLine"]["command"] == NDF_COMMAND + + +def test_official_ndf_path_unchanged(tmp_path: Path) -> None: + """既に正規パスを指している場合は何もしない (deploy_script で本体追従済み)。""" + _claude(tmp_path) + _write_settings(tmp_path, NDF_COMMAND) + + result = _run_ensure(tmp_path) + + assert result.returncode == 0, result.stderr + assert _settings(tmp_path)["statusLine"]["command"] == NDF_COMMAND + # 正規パスの場合はバックアップを作らない + assert not (tmp_path / ".claude" / ".ndf-statusline-backup.json").exists() + + +def test_user_custom_is_respected(tmp_path: Path) -> None: + """ユーザー独自 statusline (マーカー無し・別名) は尊重し上書きしない。""" + claude = _claude(tmp_path) + (claude / "mybar.sh").write_text(CUSTOM) + _write_settings(tmp_path, "bash ~/.claude/mybar.sh") + + result = _run_ensure(tmp_path) + + assert result.returncode == 0, result.stderr + assert _settings(tmp_path)["statusLine"]["command"] == "bash ~/.claude/mybar.sh" + assert not (claude / ".ndf-statusline-backup.json").exists() + + +def test_same_name_but_non_ndf_content_is_guarded(tmp_path: Path) -> None: + """statusline-command.sh でも NDF 特徴を含まなければ移行しない (誤検出ガード)。""" + claude = _claude(tmp_path) + (claude / "statusline-command.sh").write_text(CUSTOM) + _write_settings(tmp_path, "bash ~/.claude/statusline-command.sh") + + result = _run_ensure(tmp_path) + + assert result.returncode == 0, result.stderr + assert ( + _settings(tmp_path)["statusLine"]["command"] + == "bash ~/.claude/statusline-command.sh" + ) + assert not (claude / ".ndf-statusline-backup.json").exists() + + +def test_unset_statusline_gets_ndf_default(tmp_path: Path) -> None: + """statusLine 未設定なら NDF 標準を新規設定する。""" + claude = _claude(tmp_path) + (claude / "settings.json").write_text("{}") + + result = _run_ensure(tmp_path) + + assert result.returncode == 0, result.stderr + assert _settings(tmp_path)["statusLine"]["command"] == NDF_COMMAND diff --git a/plugins/ndf-claude/skills/sync-main/SKILL.md b/plugins/ndf-claude/skills/sync-main/SKILL.md new file mode 100644 index 0000000..87aa223 --- /dev/null +++ b/plugins/ndf-claude/skills/sync-main/SKILL.md @@ -0,0 +1,48 @@ +--- +name: sync-main +description: "Sync the current branch with main or master." +disable-model-invocation: true +allowed-tools: + - Bash + - Read +--- + +# main取り込みコマンド + +最新のデフォルトブランチ(main/master)を現在のブランチにマージする。 + +## 処理フロー + +1. **ブランチ確認** + - `git branch --show-current` で現在ブランチ確認 + - デフォルトブランチ(main/master)自身の場合は `git pull` のみ実行して終了 + +2. **作業ツリー確認** + - `git status` で未コミット変更を確認 + - 未コミット変更があれば `git stash` で退避 + +3. **最新取得** + - `git fetch origin ` でリモート最新を取得 + +4. **マージ実行** + - `git merge origin/ --no-edit` でマージ + - コンフリクト発生時: + - `git diff --name-only --diff-filter=U` でコンフリクトファイル一覧を表示 + - ユーザーに報告し、**自動解決はしない** + - ユーザー確認後に作業継続 + +5. **後処理** + - stash退避していた場合は `git stash pop` で復元 + - コンフリクトがなければ `git push` でリモートに反映 + - 完了報告(マージ済みコミット数、変更ファイル数) + +## 制約 + +- デフォルトブランチ自身での実行は `git pull` に自動フォールバック +- コンフリクトは自動解決しない(ユーザーが解決) +- 作業ツリーが汚れている場合は必ず stash で退避してから実行 + +## 関連 + +- `/ndf:branch-fix-strategy` — 複数ブランチへの修正適用戦略 +- `/ndf:cherry-pick-pr` — 環境ブランチへのcherry-pick PR作成 diff --git a/plugins/ndf-shared/manifests/claude-skills.txt b/plugins/ndf-shared/manifests/claude-skills.txt index b58dc6b..bb74879 100644 --- a/plugins/ndf-shared/manifests/claude-skills.txt +++ b/plugins/ndf-shared/manifests/claude-skills.txt @@ -23,6 +23,7 @@ review-pr-comments resolve-pr-comments browser-test codex +gemini statusline issue-plan-strategy plan-to-spec