diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json index 959f663..fc7c6b7 100644 --- a/.agents/plugins/marketplace.json +++ b/.agents/plugins/marketplace.json @@ -5,7 +5,7 @@ "name": "ndf", "source": { "source": "local", - "path": "./plugins/ndf" + "path": "./plugins/ndf-codex" }, "policy": { "installation": "AVAILABLE", @@ -15,6 +15,156 @@ "interface": { "displayName": "NDF" } + }, + { + "name": "mcp-playwright", + "source": { + "source": "local", + "path": "./plugins/mcp/codex/mcp-playwright" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Productivity", + "interface": { + "displayName": "Playwright MCP" + } + }, + { + "name": "mcp-chrome-devtools", + "source": { + "source": "local", + "path": "./plugins/mcp/codex/mcp-chrome-devtools" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Productivity", + "interface": { + "displayName": "Chrome DevTools MCP" + } + }, + { + "name": "mcp-bigquery", + "source": { + "source": "local", + "path": "./plugins/mcp/codex/mcp-bigquery" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Productivity", + "interface": { + "displayName": "BigQuery MCP" + } + }, + { + "name": "mcp-aws-docs", + "source": { + "source": "local", + "path": "./plugins/mcp/codex/mcp-aws-docs" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Productivity", + "interface": { + "displayName": "AWS Docs MCP" + } + }, + { + "name": "mcp-dbhub", + "source": { + "source": "local", + "path": "./plugins/mcp/codex/mcp-dbhub" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Productivity", + "interface": { + "displayName": "DBHub MCP" + } + }, + { + "name": "mcp-notion", + "source": { + "source": "local", + "path": "./plugins/mcp/codex/mcp-notion" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Productivity", + "interface": { + "displayName": "Notion MCP" + } + }, + { + "name": "mcp-devin", + "source": { + "source": "local", + "path": "./plugins/mcp/codex/mcp-devin" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Productivity", + "interface": { + "displayName": "Devin MCP" + } + }, + { + "name": "mcp-redash", + "source": { + "source": "local", + "path": "./plugins/mcp/codex/mcp-redash" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Productivity", + "interface": { + "displayName": "Redash MCP" + } + }, + { + "name": "mcp-markitdown", + "source": { + "source": "local", + "path": "./plugins/mcp/codex/mcp-markitdown" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Productivity", + "interface": { + "displayName": "MarkItDown MCP" + } + }, + { + "name": "mcp-serena", + "source": { + "source": "local", + "path": "./plugins/mcp/codex/mcp-serena" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Productivity", + "interface": { + "displayName": "Serena MCP" + } } ] } diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index e9ad5cd..9b6e319 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,57 +8,57 @@ "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", - "source": "./plugins/mcp-playwright", + "source": "./plugins/mcp/claude/mcp-playwright", "description": "Playwright MCP auto-setup plugin with Chromium browser installation and configuration" }, { "name": "mcp-chrome-devtools", - "source": "./plugins/mcp-chrome-devtools", + "source": "./plugins/mcp/claude/mcp-chrome-devtools", "description": "Chrome DevTools MCP server for browser automation and testing" }, { "name": "mcp-bigquery", - "source": "./plugins/mcp-bigquery", + "source": "./plugins/mcp/claude/mcp-bigquery", "description": "BigQuery MCP server for data analysis and SQL queries" }, { "name": "mcp-aws-docs", - "source": "./plugins/mcp-aws-docs", + "source": "./plugins/mcp/claude/mcp-aws-docs", "description": "AWS Documentation MCP server for accessing official AWS documentation" }, { "name": "mcp-dbhub", - "source": "./plugins/mcp-dbhub", + "source": "./plugins/mcp/claude/mcp-dbhub", "description": "DBHub MCP server for database operations and SQL queries" }, { "name": "mcp-notion", - "source": "./plugins/mcp-notion", + "source": "./plugins/mcp/claude/mcp-notion", "description": "Notion MCP server for note management and knowledge base operations" }, { "name": "mcp-devin", - "source": "./plugins/mcp-devin", + "source": "./plugins/mcp/claude/mcp-devin", "description": "Devin MCP server for accessing repository documentation and code knowledge via DeepWiki" }, { "name": "mcp-redash", - "source": "./plugins/mcp-redash", + "source": "./plugins/mcp/claude/mcp-redash", "description": "Redash MCP server with multi-environment support (arbitrary suffix)" }, { "name": "mcp-markitdown", - "source": "./plugins/mcp-markitdown", + "source": "./plugins/mcp/claude/mcp-markitdown", "description": "MarkItDown MCP server for converting documents (PDF, Office, images, etc.) to Markdown" }, { "name": "mcp-serena", - "source": "./plugins/mcp-serena", + "source": "./plugins/mcp/claude/mcp-serena", "description": "Serena MCP server for semantic code intelligence (symbol search, reference search, refactoring)" } ] diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..4f6113a --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(git rev-parse --show-toplevel)" + +bash "$ROOT_DIR/scripts/build-runtime-plugins.sh" --check + +generated_paths='^(plugins/ndf-claude|plugins/ndf-codex|plugins/ndf-kiro|plugins/mcp/claude|plugins/mcp/codex|plugins/mcp/kiro)/' +unstaged_generated="$(git -C "$ROOT_DIR" diff --name-only | grep -E "$generated_paths" || true)" +if [ -n "$unstaged_generated" ]; then + echo "Generated runtime files have unstaged changes. Run build and git add the generated files:" >&2 + echo "$unstaged_generated" >&2 + exit 1 +fi diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 0000000..f46772e --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(git rev-parse --show-toplevel)" + +bash "$ROOT_DIR/scripts/validate-runtime-plugins.sh" diff --git a/.github/workflows/runtime-plugin-authenticated-smoke.yml b/.github/workflows/runtime-plugin-authenticated-smoke.yml new file mode 100644 index 0000000..c66a909 --- /dev/null +++ b/.github/workflows/runtime-plugin-authenticated-smoke.yml @@ -0,0 +1,45 @@ +name: Runtime plugin authenticated smoke + +on: + workflow_dispatch: + inputs: + runtime: + description: Runtime to test + required: true + default: all + type: choice + options: + - all + - claude + - codex + - kiro + +jobs: + authenticated-smoke: + runs-on: ubuntu-latest + environment: runtime-smoke + steps: + - uses: actions/checkout@v4 + - name: Run authenticated runtime smoke + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + BIGQUERY_PROJECT: ${{ secrets.BIGQUERY_PROJECT }} + BIGQUERY_LOCATION: ${{ secrets.BIGQUERY_LOCATION }} + BIGQUERY_DATASET: ${{ secrets.BIGQUERY_DATASET }} + BIGQUERY_KEY_FILE_JSON: ${{ secrets.BIGQUERY_KEY_FILE_JSON }} + run: | + extra_args=() + if [ -n "$BIGQUERY_KEY_FILE_JSON" ]; then + mkdir -p tmp/runtime-smoke-secrets + export BIGQUERY_KEY_FILE="$PWD/tmp/runtime-smoke-secrets/bigquery-key-file.json" + printf '%s' "$BIGQUERY_KEY_FILE_JSON" > "$BIGQUERY_KEY_FILE" + extra_args+=(--secret-file "bigquery-key-file=$BIGQUERY_KEY_FILE") + fi + bash scripts/runtime-smoke-test.sh --runtime "${{ inputs.runtime }}" --with-secrets=auto "${extra_args[@]}" + - name: Upload smoke artifacts + uses: actions/upload-artifact@v4 + if: always() + with: + name: runtime-smoke-authenticated + path: tmp/runtime-smoke diff --git a/.github/workflows/runtime-plugin-smoke.yml b/.github/workflows/runtime-plugin-smoke.yml new file mode 100644 index 0000000..9d1169e --- /dev/null +++ b/.github/workflows/runtime-plugin-smoke.yml @@ -0,0 +1,37 @@ +name: Runtime plugin smoke + +on: + pull_request: + paths: + - "plugins/**" + - "scripts/runtime-smoke-test.sh" + - "tests/runtime-smoke/**" + - ".github/workflows/runtime-plugin-smoke.yml" + - ".github/workflows/runtime-plugin-authenticated-smoke.yml" + push: + branches: + - main + - "release/**" + paths: + - "plugins/**" + - "scripts/runtime-smoke-test.sh" + - "tests/runtime-smoke/**" + - ".github/workflows/runtime-plugin-smoke.yml" + +jobs: + runtime-smoke: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + runtime: [claude, codex, kiro] + steps: + - uses: actions/checkout@v4 + - name: Run unauthenticated runtime smoke + run: bash scripts/runtime-smoke-test.sh --runtime "${{ matrix.runtime }}" --with-secrets=off + - name: Upload smoke artifacts + uses: actions/upload-artifact@v4 + if: always() + with: + name: runtime-smoke-${{ matrix.runtime }} + path: tmp/runtime-smoke/${{ matrix.runtime }} diff --git a/.github/workflows/runtime-plugin-validate.yml b/.github/workflows/runtime-plugin-validate.yml new file mode 100644 index 0000000..2b6a2cd --- /dev/null +++ b/.github/workflows/runtime-plugin-validate.yml @@ -0,0 +1,67 @@ +name: Runtime plugin validation + +on: + pull_request: + paths: + - ".agents/**" + - ".claude-plugin/**" + - ".github/workflows/runtime-plugin-validate.yml" + - ".githooks/**" + - "AGENTS.md" + - "CLAUDE.md" + - "KIRO.md" + - "README.md" + - "docs/**" + - "plugins/**" + - "scripts/**" + push: + branches: + - main + - "release/**" + paths: + - ".agents/**" + - ".claude-plugin/**" + - ".github/workflows/runtime-plugin-validate.yml" + - ".githooks/**" + - "AGENTS.md" + - "CLAUDE.md" + - "KIRO.md" + - "README.md" + - "docs/**" + - "plugins/**" + - "scripts/**" + +jobs: + runtime-plugin-build-check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "22" + - uses: actions/setup-python@v5 + with: + python-version: "3.x" + - run: bash scripts/build-runtime-plugins.sh --check + + runtime-plugin-validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "22" + - uses: actions/setup-python@v5 + with: + python-version: "3.x" + - run: npm install -g @anthropic-ai/claude-code + - run: bash scripts/validate-runtime-plugins.sh + + markdown-link-check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.x" + - run: python3 scripts/check-markdown-links.py --root . diff --git a/.gitignore b/.gitignore index 091cf93..3f40ed2 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,7 @@ yarn-error.log* # Temporary files *.tmp *.bak +tmp/ # Python __pycache__/ @@ -40,7 +41,7 @@ __pycache__/ .venv/ # Plugin-generated context files -# (none currently) +.kiro/ # cross-review worktree tmp .cross_review/ diff --git a/.kiro/skills/clean b/.kiro/skills/clean deleted file mode 120000 index bb0f97e..0000000 --- a/.kiro/skills/clean +++ /dev/null @@ -1 +0,0 @@ -../../plugins/ndf/skills/clean \ No newline at end of file diff --git a/.kiro/skills/cleanup b/.kiro/skills/cleanup deleted file mode 120000 index f591fd7..0000000 --- a/.kiro/skills/cleanup +++ /dev/null @@ -1 +0,0 @@ -../../plugins/ndf/skills/cleanup \ No newline at end of file diff --git a/.kiro/skills/corder-code-templates b/.kiro/skills/corder-code-templates deleted file mode 120000 index d2208e8..0000000 --- a/.kiro/skills/corder-code-templates +++ /dev/null @@ -1 +0,0 @@ -../../plugins/ndf/skills/corder-code-templates \ No newline at end of file diff --git a/.kiro/skills/corder-test-generation b/.kiro/skills/corder-test-generation deleted file mode 120000 index a49175a..0000000 --- a/.kiro/skills/corder-test-generation +++ /dev/null @@ -1 +0,0 @@ -../../plugins/ndf/skills/corder-test-generation \ No newline at end of file diff --git a/.kiro/skills/data-analyst-export b/.kiro/skills/data-analyst-export deleted file mode 120000 index 4e10342..0000000 --- a/.kiro/skills/data-analyst-export +++ /dev/null @@ -1 +0,0 @@ -../../plugins/ndf/skills/data-analyst-export \ No newline at end of file diff --git a/.kiro/skills/data-analyst-sql-optimization b/.kiro/skills/data-analyst-sql-optimization deleted file mode 120000 index 993cf61..0000000 --- a/.kiro/skills/data-analyst-sql-optimization +++ /dev/null @@ -1 +0,0 @@ -../../plugins/ndf/skills/data-analyst-sql-optimization \ No newline at end of file diff --git a/.kiro/skills/deepwiki-transfer b/.kiro/skills/deepwiki-transfer deleted file mode 120000 index fffa4e3..0000000 --- a/.kiro/skills/deepwiki-transfer +++ /dev/null @@ -1 +0,0 @@ -../../plugins/ndf/skills/deepwiki-transfer \ No newline at end of file diff --git a/.kiro/skills/docker-container-access b/.kiro/skills/docker-container-access deleted file mode 120000 index b704d77..0000000 --- a/.kiro/skills/docker-container-access +++ /dev/null @@ -1 +0,0 @@ -../../plugins/ndf/skills/docker-container-access \ No newline at end of file diff --git a/.kiro/skills/fix b/.kiro/skills/fix deleted file mode 120000 index 1a3e7be..0000000 --- a/.kiro/skills/fix +++ /dev/null @@ -1 +0,0 @@ -../../plugins/ndf/skills/fix \ No newline at end of file diff --git a/.kiro/skills/git-gh-operations b/.kiro/skills/git-gh-operations deleted file mode 120000 index 09511f1..0000000 --- a/.kiro/skills/git-gh-operations +++ /dev/null @@ -1 +0,0 @@ -../../plugins/ndf/skills/git-gh-operations \ No newline at end of file diff --git a/.kiro/skills/google-auth b/.kiro/skills/google-auth deleted file mode 120000 index 5919638..0000000 --- a/.kiro/skills/google-auth +++ /dev/null @@ -1 +0,0 @@ -../../plugins/ndf/skills/google-auth \ No newline at end of file diff --git a/.kiro/skills/knowledge-reorg b/.kiro/skills/knowledge-reorg deleted file mode 120000 index ee9e9be..0000000 --- a/.kiro/skills/knowledge-reorg +++ /dev/null @@ -1 +0,0 @@ -../../plugins/ndf/skills/knowledge-reorg \ No newline at end of file diff --git a/.kiro/skills/markdown-writing b/.kiro/skills/markdown-writing deleted file mode 120000 index b280e81..0000000 --- a/.kiro/skills/markdown-writing +++ /dev/null @@ -1 +0,0 @@ -../../plugins/ndf/skills/markdown-writing \ No newline at end of file diff --git a/.kiro/skills/merged b/.kiro/skills/merged deleted file mode 120000 index 4189643..0000000 --- a/.kiro/skills/merged +++ /dev/null @@ -1 +0,0 @@ -../../plugins/ndf/skills/merged \ No newline at end of file diff --git a/.kiro/skills/ndf-policies b/.kiro/skills/ndf-policies deleted file mode 120000 index e562c77..0000000 --- a/.kiro/skills/ndf-policies +++ /dev/null @@ -1 +0,0 @@ -../../plugins/ndf/skills/ndf-policies \ No newline at end of file diff --git a/.kiro/skills/pr b/.kiro/skills/pr deleted file mode 120000 index 9d1f744..0000000 --- a/.kiro/skills/pr +++ /dev/null @@ -1 +0,0 @@ -../../plugins/ndf/skills/pr \ No newline at end of file diff --git a/.kiro/skills/pr-tests b/.kiro/skills/pr-tests deleted file mode 120000 index 7b27f95..0000000 --- a/.kiro/skills/pr-tests +++ /dev/null @@ -1 +0,0 @@ -../../plugins/ndf/skills/pr-tests \ No newline at end of file diff --git a/.kiro/skills/python-execution b/.kiro/skills/python-execution deleted file mode 120000 index 67f9ceb..0000000 --- a/.kiro/skills/python-execution +++ /dev/null @@ -1 +0,0 @@ -../../plugins/ndf/skills/python-execution \ No newline at end of file diff --git a/.kiro/skills/qa-security-scan b/.kiro/skills/qa-security-scan deleted file mode 120000 index 58e5700..0000000 --- a/.kiro/skills/qa-security-scan +++ /dev/null @@ -1 +0,0 @@ -../../plugins/ndf/skills/qa-security-scan \ No newline at end of file diff --git a/.kiro/skills/researcher-report-templates b/.kiro/skills/researcher-report-templates deleted file mode 120000 index a198fd0..0000000 --- a/.kiro/skills/researcher-report-templates +++ /dev/null @@ -1 +0,0 @@ -../../plugins/ndf/skills/researcher-report-templates \ No newline at end of file diff --git a/.kiro/skills/review b/.kiro/skills/review deleted file mode 120000 index 2860043..0000000 --- a/.kiro/skills/review +++ /dev/null @@ -1 +0,0 @@ -../../plugins/ndf/skills/review \ No newline at end of file diff --git a/.kiro/skills/scanner-pdf-analysis b/.kiro/skills/scanner-pdf-analysis deleted file mode 120000 index 2e38afd..0000000 --- a/.kiro/skills/scanner-pdf-analysis +++ /dev/null @@ -1 +0,0 @@ -../../plugins/ndf/skills/scanner-pdf-analysis \ No newline at end of file diff --git a/.kiro/skills/skill-development b/.kiro/skills/skill-development deleted file mode 120000 index bc58081..0000000 --- a/.kiro/skills/skill-development +++ /dev/null @@ -1 +0,0 @@ -../../plugins/ndf/skills/skill-development \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 945ede8..f56470f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ ## プロジェクト概要 -**Claude Codeプラグインマーケットプレイス**の開発プロジェクトです。チーム全体でClaude Codeの導入を加速するための事前設定されたプラグインを提供します。 +**Claude Code / Codex / Kiro CLI 向けプラグインマーケットプレイス**の開発プロジェクトです。チーム全体で AI 開発ツールの導入を加速するための事前設定されたプラグインを提供します。 **リポジトリ**: https://github.com/devbasex/ai-plugins @@ -40,8 +40,15 @@ ai-plugins/ ├── .claude-plugin/ │ └── marketplace.json # マーケットプレイス定義(必須) ├── plugins/ -│ ├── ndf/ # NDFプラグイン(メイン) -│ └── {plugin-name}/ # その他のプラグイン +│ ├── ndf-shared/ # NDF共通編集元(直接installしない) +│ ├── ndf-claude/ # Claude Code版NDF +│ ├── ndf-codex/ # Codex版NDF +│ ├── ndf-kiro/ # Kiro CLI版NDF +│ └── mcp/ +│ ├── shared/ # MCPプラグイン共通編集元 +│ ├── claude/ # Claude Code版MCP配布物 +│ ├── codex/ # Codex版MCP配布物 +│ └── kiro/ # Kiro CLI版MCP配布物 ├── docs/ # リポジトリ知識 ├── AGENTS.md # 共通エントリポイント ├── CLAUDE.md # Claude Code固有設定 @@ -59,18 +66,20 @@ ai-plugins/ | [docs/specifications/](docs/specifications/) | 完了済みplan/issue由来の確定仕様 | | [docs/claude-code-skills-survey.md](docs/claude-code-skills-survey.md) | Claude Code Skills調査レポート | | [docs/development-history/](docs/development-history/) | 開発履歴と知見 | -| [plugins/ndf/README.md](plugins/ndf/README.md) | NDFプラグインドキュメント | +| [plugins/ndf-claude/README.md](plugins/ndf-claude/README.md) | Claude Code版NDFプラグイン | +| [plugins/ndf-codex/README.md](plugins/ndf-codex/README.md) | Codex版NDFプラグイン | +| [plugins/ndf-kiro/README.md](plugins/ndf-kiro/README.md) | Kiro CLI版NDF installer | ## NDFプラグインについて -**NDFプラグイン**は、このマーケットプレイスの主要プラグインです(v4.19.0): -- 8個の専門サブエージェント(director、data-analyst、corder、researcher、qa、debugger、devops-engineer、code-reviewer) -- 49個のsource Skills(PR/コードレビューワークフロー、AIクロスレビュー (codex/gemini)、plan-to-spec確定仕様化、原則・ガイドライン、MLモデル構造標準 (ml-model-structure)、issue→multi-PR ワークフロー戦略、SQL最適化、データエクスポート、skill利用統計、statusline切替 (/ndf:statusline)、Codex CLI連携、Playwrightシナリオテスト (CDPリモート接続・Google Driveエビデンス保管含む)、Google Drive/Chat連携 等) -- SessionStartフック(transcript保持期間自動管理 `cleanupPeriodDays >= 90`、statusLine未設定時のNDF標準statusline自動設定) -- Stopフック(AI要約生成 + 自動Slack通知) +**NDFプラグイン**は、このマーケットプレイスの主要プラグインです(v4.19.0)。plugin 名は全ランタイムで `ndf` を維持し、配布物は `plugins/ndf-claude` / `plugins/ndf-codex` / `plugins/ndf-kiro` に分離しています。 +- 共通編集元は `plugins/ndf-shared/` +- Claude Code版は 8個の専門サブエージェント、公開Skills、SessionStart/Stopフックを提供 +- Codex版は Codex向け公開Skillsと任意Slack通知hookを提供 +- Kiro版は installer で `.kiro/skills/` と `.kiro/agents/default.json` を生成 - 外部AI委譲は `/ndf:codex` skill と `corder` エージェント経由で Codex CLI を呼び出し(v4.0.0 で Codex MCP サーバは廃止) -詳細は `plugins/ndf/README.md` および `docs/ndf-plugin-reference.md` を参照。 +詳細は各 runtime README と `docs/ndf-plugin-reference.md` を参照。 ## ベストプラクティス diff --git a/CLAUDE.md b/CLAUDE.md index fd38389..26753da 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,7 +17,7 @@ Serena MCPは**mcp-serena**プラグインとして提供されます(NDFと **Serena memoryは使用禁止**。知識は `docs/` に、手順は `skills/` に配置してください。 -詳細は `plugins/mcp-serena/docs/serena-guide.md` を参照。 +詳細は `plugins/mcp/shared/mcp-serena/docs/serena-guide.md` を参照。 ## 知識アーキテクチャ diff --git a/KIRO.md b/KIRO.md index 031e939..4b6caba 100644 --- a/KIRO.md +++ b/KIRO.md @@ -15,19 +15,19 @@ ```bash # 基本(Skills + agentSpawnフックのみ) -bash scripts/install-kiro.sh +bash plugins/ndf-kiro/install.sh # Slack通知も有効化 -bash scripts/install-kiro.sh --with-slack +bash plugins/ndf-kiro/install.sh --with-slack # 全部入り(Slack通知 + Kiro 側 Codex MCP 設定生成) # 注: NDF v4.0.0 本体は Codex MCP に依存せず、/ndf:codex skill 経由で # CLI 直接実行に一本化。--with-codex は Kiro セッションで # `mcp__codex__*` を直接呼びたい場合のみ有効化すればよい。 -bash scripts/install-kiro.sh --with-slack --with-codex +bash plugins/ndf-kiro/install.sh --with-slack --with-codex ``` -インストーラーは `plugin.json` からskills一覧を読み取り、`.kiro/agents/default.json` を生成します。 +インストーラーは `plugins/ndf-kiro/skills/` から `.kiro/skills/` への symlink と `.kiro/agents/default.json` を生成します。`plugins/ndf-kiro/skills/` は `plugins/ndf-shared/manifests/kiro-skills.txt` から build された生成物です。 ### Slack通知の設定(オプション) @@ -43,7 +43,7 @@ SLACK_USER_MENTION=<@U0123456789> - スキルはdescriptionのキーワードに基づいて自動参照されます - ワークフロー系スキル(pr, fix, review等)は手動で指示してください - 例: 「prスキルの手順に従ってPRを作成して」 -- plugin.jsonにskillが追加された場合、再度インストーラーを実行してください +- `plugins/ndf-shared/manifests/kiro-skills.txt` に skill が追加された場合、`bash scripts/build-runtime-plugins.sh` 後に再度インストーラーを実行してください ## Kiro CLIでのコード探索 @@ -55,7 +55,7 @@ SLACK_USER_MENTION=<@U0123456789> ls -la plugins/ # 特定のプラグインディレクトリを確認 -ls -la plugins/ndf/ +ls -la plugins/ndf-kiro/ ``` #### 2. コードインテリジェンスの活用 @@ -66,7 +66,7 @@ ls -la plugins/ndf/ code search_symbols "ndf" # 特定のファイル内のシンボル一覧 -code get_document_symbols plugins/ndf/.claude-plugin/plugin.json +code get_document_symbols plugins/ndf-kiro/agents/default.json.template ``` **パターン検索**: @@ -82,10 +82,10 @@ grep "MCP" --include="*.md" ``` # ファイル全体を読む -fs_read plugins/ndf/.claude-plugin/plugin.json +fs_read plugins/ndf-kiro/agents/default.json.template # 特定の行範囲を読む -fs_read plugins/ndf/README.md --start_line=1 --end_line=50 +fs_read plugins/ndf-kiro/README.md --start_line=1 --end_line=50 ``` ## Kiro CLI特有の注意事項 diff --git a/README.md b/README.md index eb137de..3a0d40c 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,9 @@ Claude Code / Codex / Kiro CLI向けのスキル・MCP設定を共有するた このマーケットプレイスは、チーム全体でAI開発ツール(Claude Code / Codex / Kiro CLI)の導入を加速するための事前設定されたプラグインを提供します。 -**NDFプラグイン v4.19.0** は、以下の機能を**オールインワン**で提供する統合プラグインです: +**NDFプラグイン v4.19.0** は、同じ `ndf@ai-plugins` という名前で Claude Code / Codex / Kiro CLI へ配布されるランタイム別プラグインです。共通ソースは `plugins/ndf-shared/` に集約し、利用者が install する配布物は `plugins/ndf-claude/` / `plugins/ndf-codex/` / `plugins/ndf-kiro/` に分かれています。 -- **公開Skills**: Claude Code/Kiro向け core 28個、Codex向け core 30個に分離。`skills-optional/` にランタイム別の除外候補を整理。 +- **公開Skills**: Claude Code向け core 29個、Kiro向け core 28個、Codex向け core 30個に分離。 - **元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 @@ -21,7 +21,8 @@ Claude Code / Codex / Kiro CLI向けのスキル・MCP設定を共有するた - **自動フック**: SessionStart (transcript保持期間を最低90日に保つ) + Stop (AI要約生成+Slack通知) - **外部AI委譲**: `/ndf:codex` skill + `corder` エージェント経由で Codex CLI をバックグラウンド実行 (v4.0.0 で Codex MCP サーバは廃止) - **AIクロスレビュー強化**: `/ndf:cross-review` は codex/gemini 両方に PR レビューを委譲し、Gemini の進捗 heartbeat、`--focus` / `--extra-instructions-file`、PR 種別別の自動レビュー観点テンプレートに対応 -- **Kiro CLI対応**: インストーラーによるワンコマンドセットアップ +- **Kiro CLI対応**: `plugins/ndf-kiro/install.sh` によるワンコマンドセットアップ +- **MCPプラグイン**: `plugins/mcp/shared/` を編集元とし、Claude / Codex / Kiro 向け配布物を `plugins/mcp/{claude,codex,kiro}/` に生成 ## 利用方法 @@ -67,16 +68,16 @@ cd ai-plugins ```bash # 基本(Skills + agentSpawnフックのみ) -bash scripts/install-kiro.sh +bash plugins/ndf-kiro/install.sh # Slack通知も有効化 -bash scripts/install-kiro.sh --with-slack +bash plugins/ndf-kiro/install.sh --with-slack # 全部入り(Slack + Codex CLI 連携) -bash scripts/install-kiro.sh --with-slack --with-codex +bash plugins/ndf-kiro/install.sh --with-slack --with-codex ``` -インストーラーは `plugin.json` からskills一覧を読み取り、`.kiro/agents/default.json` を自動生成します。 +インストーラーは `plugins/ndf-kiro/skills/` から `.kiro/skills/` への symlink と `.kiro/agents/default.json` を生成します。 #### 3. Slack通知の設定(オプション) @@ -99,7 +100,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 向けに runtime 別配布物を提供する NDF プラグイン。8個の専門エージェント(Claude版)、公開Skills(Claude Code向け core 29個、Kiro向け core 28個、Codex向け core 30個)、Claude SessionStart/Stopフック、Codex/Kiro向け通知・実行補助を提供。v4.0.0 で Codex MCP サーバを廃止し、`/ndf:codex` skill + `corder` エージェント経由の CLI 直接実行に一本化。 | [Claude](./plugins/ndf-claude/README.md) / [Codex](./plugins/ndf-codex/README.md) / [Kiro](./plugins/ndf-kiro/README.md) | ### NDF v4.19.0 の主な変更 @@ -120,21 +121,43 @@ ai-plugins/ ├── .claude-plugin/ │ └── marketplace.json # Claude Codeマーケットプレイスメタデータ ├── plugins/ -│ └── {plugin-name}/ -│ ├── .codex-plugin/ -│ │ └── plugin.json # Codexプラグインメタデータ -│ ├── .claude-plugin/ -│ │ └── plugin.json # Claude Codeプラグインメタデータ -│ ├── agents/ # サブエージェント (*.md) -│ ├── skills/ # 全Skillの実体(Claude Code/Kiroはmanifest配列で公開対象を指定) -│ ├── skills-codex/ # Codex向け公開Skill(marketplace cache対応の実ディレクトリ) -│ └── skills-optional/ # ランタイム別除外候補リスト -│ └── {skill-name}/ -│ └── SKILL.md # エントリポイント(必須) +│ ├── ndf-shared/ # NDF共通編集元(直接installしない) +│ ├── ndf-claude/ # Claude Code版NDF配布物 +│ ├── ndf-codex/ # Codex版NDF配布物 +│ ├── ndf-kiro/ # Kiro CLI版NDF配布物/installer +│ └── mcp/ +│ ├── shared/ # MCPプラグイン共通編集元 +│ ├── claude/ # Claude Code版MCP配布物 +│ ├── codex/ # Codex版MCP配布物 +│ └── kiro/ # Kiro CLI版MCP配布物/installer ├── README.md └── CLAUDE.md # AIエージェント向けガイドライン ``` +#### Runtime plugin の検証 + +共通ソースや runtime 配布物を変更した場合は、生成物同期と manifest / link 検証を実行します。 + +```bash +bash scripts/build-runtime-plugins.sh +bash scripts/validate-runtime-plugins.sh +``` + +実ランタイムのインストール経路を確認する場合は、Docker コンテナ内で smoke test を実行します。 + +```bash +bash scripts/runtime-smoke-test.sh +bash scripts/runtime-smoke-test.sh --runtime claude +bash scripts/runtime-smoke-test.sh --runtime codex +bash scripts/runtime-smoke-test.sh --runtime kiro +``` + +ローカル hook を使う場合は以下を実行します。 + +```bash +bash scripts/install-dev-hooks.sh +``` + #### 新しいプラグインの作成手順 **1. プラグインディレクトリを作成:** diff --git a/docs/claude-code-skills-survey.md b/docs/claude-code-skills-survey.md index 5f41265..73b1893 100644 --- a/docs/claude-code-skills-survey.md +++ b/docs/claude-code-skills-survey.md @@ -622,7 +622,7 @@ ### 1. Skillsの配置場所 ``` -plugins/ndf/ +plugins/ndf-shared/ ├── skills/ │ ├── director/ │ │ ├── brainstorming/SKILL.md diff --git a/docs/external-reviews/pr-57-codex-business-review.md b/docs/external-reviews/pr-57-codex-business-review.md index 732bef5..c8772b9 100644 --- a/docs/external-reviews/pr-57-codex-business-review.md +++ b/docs/external-reviews/pr-57-codex-business-review.md @@ -9,7 +9,7 @@ | **実行時間** | 約 8 分 / tokens 129,705 | | **背景** | 技術レビュー (Codex 3 ラウンド + Amazon Q + Gemini) 完了後、事業観点のみに絞って依頼 | | **結論** | ⚠️ **コード品質ではなく、事業的な梱包がまだ追いついていない**。power-user 向け merge は合理的、新看板機能として広報するなら周辺メッセージ修正を同時に。 | -| **直近 1ヶ月の最重要アクション** | (1) `marketplace.json` / `plugin.json` / `plugins/ndf/README.md` の storefront 文言更新 / (2) `browser-test` と `playwright-scenario-test` の役割分担を docs で明確化 / (3) 10分で動くデモケースの公開 | +| **直近 1ヶ月の最重要アクション** | (1) `marketplace.json` / `plugin.json` / `plugins/ndf-claude/README.md` の storefront 文言更新 / (2) `browser-test` と `playwright-scenario-test` の役割分担を docs で明確化 / (3) 10分で動くデモケースの公開 | | **PR コメント** | https://github.com/takemi-ohama/ai-plugins/pull/57#issuecomment-4321871998 | --- @@ -27,7 +27,7 @@ ### 1.1 強み この skill の本質は、Playwright そのものの代替ではなく、**Playwright を AI エージェント運用に最適化した“実務レイヤー”** にあります。 -Playwright 公式はブラウザ自動化基盤として強いですが、`/work/ai-plugins/plugins/ndf/skills/playwright-scenario-test/SKILL.md` を読む限り、本 skill はそこに **テスト設計の方法論、page role 分類、証跡標準化、Drive 共有、Markdown レポート** を足しています。 +Playwright 公式はブラウザ自動化基盤として強いですが、`/work/ai-plugins/plugins/ndf-shared/skills/playwright-scenario-test/SKILL.md` を読む限り、本 skill はそこに **テスト設計の方法論、page role 分類、証跡標準化、Drive 共有、Markdown レポート** を足しています。 つまり競争軸は「ブラウザを動かせるか」ではなく、**“AI が再現性の高い E2E を短時間で作り、レビュー可能な証跡まで揃えられるか”** です。 この位置づけは、Playwright / Selenium / Puppeteer のような実行基盤とは競争しつつも、実は真正面からは競合しません。 むしろ「それらをどう現場の開発運用に落とすか」の層を取ろうとしている点が重要です。 @@ -60,7 +60,7 @@ NDF はその上に載せられる可能性があるが、現状は **ローカ ### 1.2 弱み / 競合との差 弱みは明確で、**今のままだと“何でも入った NDF の一機能”に見えてしまい、単独価値として発見されにくい**ことです。 -`plugins/ndf/.claude-plugin/plugin.json` の description には機能が多く並びますが、焦点が広すぎます。 +`plugins/ndf-claude/.claude-plugin/plugin.json` の description には機能が多く並びますが、焦点が広すぎます。 利用者は「PR ワークフロー」「Codex 連携」「Slack 通知」「Google Drive」「E2E テスト」のどれを主価値として認識すべきか迷います。 `plugin.json` の `keywords` に `playwright` `pytest` `e2e` `qa` `accessibility` `testing` が入っていないのも機会損失です。 @@ -185,7 +185,7 @@ OSS として広げるなら、**“Claude Code plugin でありつつ、中身 ## 3. Skill のディスカバラビリティと活性化 ### 3.1 description / Triggers の妥当性 -`/work/ai-plugins/plugins/ndf/skills/playwright-scenario-test/SKILL.md` の `description` は情報量が豊富で、技術的にはかなり良いです。 +`/work/ai-plugins/plugins/ndf-shared/skills/playwright-scenario-test/SKILL.md` の `description` は情報量が豊富で、技術的にはかなり良いです。 ただし business 観点では、**少し盛り込みすぎ**です。 利用者に価値を伝える文章としては強いが、AI の auto-invoke trigger としては信号が散っています。 @@ -194,7 +194,7 @@ OSS として広げるなら、**“Claude Code plugin でありつつ、中身 AI にとっても人にとっても、「これは E2E planning skill なのか、動画証跡 skill なのか、品質ゲート skill なのか」が一読で定まりにくい。 `when_to_use` で Triggers を分離した設計自体は正しいです。 -`plugins/ndf/CLAUDE.md` でもその改善意図が記録されており、方向性は良いです。 +`CLAUDE.md` でもその改善意図が記録されており、方向性は良いです。 ただし trigger phrase はまだ改善余地があります。 現状の trigger は、技術者寄りの言葉に少し偏っています。 @@ -225,7 +225,7 @@ Claude Code plugin モデルと相性は良いです。 ただし問題は、**scenario-test 単体の価値に最短で到達できない**ことです。 利用者はまず NDF 全体を理解させられます。 -`plugins/ndf/README.md` も大きな統合プラグインの説明が先に来るため、testing use case の人には情報密度が高すぎます。 +`plugins/ndf-claude/README.md` も大きな統合プラグインの説明が先に来るため、testing use case の人には情報密度が高すぎます。 `marketplace.json` の `ndf` description が古いのは、導線上かなり痛いです。 ここは marketplace 上の第一印象なので、**PR #57 の価値を最初に伝える場所**です。 @@ -335,7 +335,7 @@ Drive 共有、HUD 動画、a11y/CWV は、単体では売りにくいです。 ### 6.1 v0.4.0 で優先すべきこと (Top 3) **Top 1: プロダクトの見せ方を刷新すること** -`marketplace.json`、`plugin.json`、`plugins/ndf/README.md` の scenario-test 訴求を更新し、 +`marketplace.json`、`plugin.json`、`plugins/ndf-claude/README.md` の scenario-test 訴求を更新し、 「AI が pytest-playwright テストを書き、証跡を残し、共有まで行う skill」 と一文で伝わるようにするべきです。 これは最優先です。 @@ -405,7 +405,7 @@ NDF は test management ではなく **AI-assisted test execution workflow** に - 逆に、これを「広く採用される新看板機能」として出すなら、周辺メッセージ修正を同時にやるべきです。 - 直近 1 ヶ月の最重要アクション 1: -- `marketplace.json` `plugin.json` `plugins/ndf/README.md` を更新し、scenario-test の価値を storefront レベルで伝える。 +- `marketplace.json` `plugin.json` `plugins/ndf-claude/README.md` を更新し、scenario-test の価値を storefront レベルで伝える。 - ここで「AI が pytest-playwright テストと証跡を自動生成する」という一文を明示する。 - 直近 1 ヶ月の最重要アクション 2: @@ -443,17 +443,17 @@ NDF は test management ではなく **AI-assisted test execution workflow** に - [CLAUDE.md](/work/ai-plugins/CLAUDE.md) - [docs/project-overview.md](/work/ai-plugins/docs/project-overview.md) - [docs/ndf-plugin-reference.md](/work/ai-plugins/docs/ndf-plugin-reference.md) -- [plugins/ndf/README.md](/work/ai-plugins/plugins/ndf/README.md) -- [plugins/ndf/CLAUDE.md](/work/ai-plugins/plugins/ndf/CLAUDE.md) -- [plugins/ndf/.claude-plugin/plugin.json](/work/ai-plugins/plugins/ndf/.claude-plugin/plugin.json) +- [plugins/ndf-claude/README.md](/work/ai-plugins/plugins/ndf-claude/README.md) +- [CLAUDE.md](/work/ai-plugins/CLAUDE.md) +- [plugins/ndf-claude/.claude-plugin/plugin.json](/work/ai-plugins/plugins/ndf-claude/.claude-plugin/plugin.json) - [.claude-plugin/marketplace.json](/work/ai-plugins/.claude-plugin/marketplace.json) -- [plugins/ndf/skills/playwright-scenario-test/SKILL.md](/work/ai-plugins/plugins/ndf/skills/playwright-scenario-test/SKILL.md) -- [plugins/ndf/skills/playwright-scenario-test/docs/README.md](/work/ai-plugins/plugins/ndf/skills/playwright-scenario-test/docs/README.md) -- [plugins/ndf/skills/playwright-scenario-test/docs/01-methodology.md](/work/ai-plugins/plugins/ndf/skills/playwright-scenario-test/docs/01-methodology.md) -- [plugins/ndf/skills/playwright-scenario-test/docs/02-page-roles.md](/work/ai-plugins/plugins/ndf/skills/playwright-scenario-test/docs/02-page-roles.md) -- [plugins/ndf/skills/playwright-scenario-test/docs/06-pytest-playwright.md](/work/ai-plugins/plugins/ndf/skills/playwright-scenario-test/docs/06-pytest-playwright.md) -- [plugins/ndf/skills/playwright-scenario-test/pyproject.toml](/work/ai-plugins/plugins/ndf/skills/playwright-scenario-test/pyproject.toml) -- [plugins/ndf/skills/playwright-scenario-test/templates/conftest.py.template](/work/ai-plugins/plugins/ndf/skills/playwright-scenario-test/templates/conftest.py.template) +- [plugins/ndf-shared/skills/playwright-scenario-test/SKILL.md](/work/ai-plugins/plugins/ndf-shared/skills/playwright-scenario-test/SKILL.md) +- [plugins/ndf-shared/skills/playwright-scenario-test/docs/README.md](/work/ai-plugins/plugins/ndf-shared/skills/playwright-scenario-test/docs/README.md) +- [plugins/ndf-shared/skills/playwright-scenario-test/docs/01-methodology.md](/work/ai-plugins/plugins/ndf-shared/skills/playwright-scenario-test/docs/01-methodology.md) +- [plugins/ndf-shared/skills/playwright-scenario-test/docs/02-page-roles.md](/work/ai-plugins/plugins/ndf-shared/skills/playwright-scenario-test/docs/02-page-roles.md) +- [plugins/ndf-shared/skills/playwright-scenario-test/docs/06-pytest-playwright.md](/work/ai-plugins/plugins/ndf-shared/skills/playwright-scenario-test/docs/06-pytest-playwright.md) +- [plugins/ndf-shared/skills/playwright-scenario-test/pyproject.toml](/work/ai-plugins/plugins/ndf-shared/skills/playwright-scenario-test/pyproject.toml) +- [plugins/ndf-shared/skills/playwright-scenario-test/templates/conftest.py.template](/work/ai-plugins/plugins/ndf-shared/skills/playwright-scenario-test/templates/conftest.py.template) ## 外部比較で参照した公式情報 - Playwright Python: https://playwright.dev/python/ diff --git a/docs/external-reviews/pr-57-gemini-technical-review.md b/docs/external-reviews/pr-57-gemini-technical-review.md index 8b6f72d..1902b25 100644 --- a/docs/external-reviews/pr-57-gemini-technical-review.md +++ b/docs/external-reviews/pr-57-gemini-technical-review.md @@ -24,46 +24,46 @@ ## 1. pytest-playwright 慣用性 (Idiomatic) に関する指摘 ### 1.1 `autouse` フィクスチャの条件付きスキップ [重要度: Major] -- ファイル: `/work/ai-plugins/plugins/ndf/skills/playwright-scenario-test/scenario_test/fixtures/a11y.py:59` +- ファイル: `/work/ai-plugins/plugins/ndf-shared/skills/playwright-scenario-test/scenario_test/fixtures/a11y.py:59` - 状況: `_ndf_a11y_autouse` が `autouse=True` ですが、内部で `if "page" not in request.fixturenames: return` を行っています。 - 評価: これは非常に優れた設計です。`autouse=True` なフィクスチャが不用意に `page` を要求すると、全てのテスト(DB テストや単体テスト等)でブラウザが起動してしまいます。`request.fixturenames` を確認することで、Playwright を使うテストのみに計測を限定し、テストスイート全体の速度低下を防いでいます。`cwv.py` でも同様の処理が行われており、一貫性があります。 ### 1.2 `browser_context_args` による HAR/Trace の個別制御 [重要度: Major] -- ファイル: `/work/ai-plugins/plugins/ndf/skills/playwright-scenario-test/scenario_test/fixtures/evidence.py:165` +- ファイル: `/work/ai-plugins/plugins/ndf-shared/skills/playwright-scenario-test/scenario_test/fixtures/evidence.py:165` - 状況: `browser_context_args` を `function` スコープで override し、テストごとに `record_har_path` を動的に注入しています。 - 評価: `pytest-playwright` の機能を活かした最も慣用的な方法です。旧実装の session 単位 HAR を廃止し、テストごとの隔離(Isolation)を担保している点は、デバッグの容易性を大きく向上させます。 ## 2. 可読性に関する指摘 ### 2.1 独自マーカーのドキュメント化 [重要度: Minor] -- ファイル: `/work/ai-plugins/plugins/ndf/skills/playwright-scenario-test/scenario_test/pytest_plugin.py:75` +- ファイル: `/work/ai-plugins/plugins/ndf-shared/skills/playwright-scenario-test/scenario_test/pytest_plugin.py:75` - 指摘: `_NDF_MARKERS` を定義し、`pytest_configure` で `config.addinivalue_line("markers", ...)` を行っています。 - 評価: これにより `pytest --markers` を実行した際に NDF 独自のマーカー(`page_role`, `role`, `phase`, `priority`)の説明が表示されるようになり、利用者にとってのセルフドキュメンテーションが効いています。 ## 3. コード品質に関する指摘 ### 3.1 エラーメッセージの切り詰め [重要度: Minor] -- ファイル: `/work/ai-plugins/plugins/ndf/skills/playwright-scenario-test/scenario_test/pytest_report.py:114` +- ファイル: `/work/ai-plugins/plugins/ndf-shared/skills/playwright-scenario-test/scenario_test/pytest_report.py:114` - 状況: `lines.append(e.error_message[:2000])` - 評価: 長大なスタックトレースやログによって `report.md` が肥大化するのを防ぐ適切な配慮です。 ### 3.2 ログイン送信ボタンのフォールバック順序 [重要度: Major] -- ファイル: `/work/ai-plugins/plugins/ndf/skills/playwright-scenario-test/scenario_test/fixtures/auth.py:53` +- ファイル: `/work/ai-plugins/plugins/ndf-shared/skills/playwright-scenario-test/scenario_test/fixtures/auth.py:53` - 評価: `_submit_login_form` において、明示的なセレクタ → 汎用的な role/text マッチ → パスワード欄での Enter キー、という順序で試行しています。この「最後に Enter を押す」というフォールバックは、ボタンの特定が困難な難解な UI においてもログインを成功させる可能性を高める、実戦的な工夫です。 ## 4. 保守性に関する指摘 ### 4.1 テンプレートの充実 [重要度: Major] -- ファイル: `/work/ai-plugins/plugins/ndf/skills/playwright-scenario-test/templates/` +- ファイル: `/work/ai-plugins/plugins/ndf-shared/skills/playwright-scenario-test/templates/` - 評価: `conftest.py` だけでなく、`test_auth.py.template` などの役割別テンプレートが用意されており、利用者が迷わずに導入できるようになっています。特に `SKILL.md` のクイックスタートが「雛形をコピーして動かす」という手順で完結しており、OSS 的な使い勝手が非常に良いです。 ## 5. セキュリティに関する指摘 ### 5.1 `localStorage` 注入時の origin 跨ぎ [重要度: Minor] -- ファイル: `/work/ai-plugins/plugins/ndf/skills/playwright-scenario-test/scenario_test/fixtures/auth.py:192` +- ファイル: `/work/ai-plugins/plugins/ndf-shared/skills/playwright-scenario-test/scenario_test/fixtures/auth.py:192` - 状況: `state.get("origins")` をループして `page.goto(url)` してから `localStorage.setItem` を実行しています。 - 懸念: 外部のオリジン(広告や計測タグ等)が `storage_state` に含まれている場合、それらに対しても `goto` が試行されます。 - 評価: ただし、`try...except` で囲まれており、失敗しても認証自体(Cookie)には影響しないようになっているため、実用上の問題はありません。 ## 6. テストカバレッジに関する指摘 ### 6.1 `pytester` による高度な検証 [重要度: Major] -- ファイル: `/work/ai-plugins/plugins/ndf/skills/playwright-scenario-test/tests/test_pytester_integration.py` +- ファイル: `/work/ai-plugins/plugins/ndf-shared/skills/playwright-scenario-test/tests/test_pytester_integration.py` - 評価: `pytest` の内部挙動に依存する「HAR の teardown 時フラッシュ」や「xdist 時の開始時刻計算」を、`pytester` を用いて隔離環境で本物の pytest を走らせて検証している点は非常に高く評価できます。これにより、プラグインとしての堅牢性が担保されています。 ## 7. 良い点 (Codex / Amazon Q が触れていない点を中心に) diff --git a/docs/ndf-plugin-reference.md b/docs/ndf-plugin-reference.md index 7cfacab..a0b5def 100644 --- a/docs/ndf-plugin-reference.md +++ b/docs/ndf-plugin-reference.md @@ -2,219 +2,119 @@ ## 概要 -NDF プラグインは、Claude Code / Codex / Kiro CLI 向けのオールインワン開発支援プラグイン。エージェント、Skills、フックを統合して提供する。 +NDF は Claude Code / Codex / Kiro CLI 向けの開発支援プラグイン群です。marketplace 上の plugin 名は全ランタイムで `ndf` を維持し、配布物はランタイム別ディレクトリに分けます。 -**現行バージョン**: **v4.19.0** — `plan-to-spec` skill を追加し、実装完了後の plan を `docs/` 配下の確定仕様書へ移動・リライト・レビューする標準フローと完了報告テンプレートを定義した。**v4.18.0** で `cross-review` は Gemini の進捗ログを heartbeat に表示し、`--focus` / `--extra-instructions-file` と PR 種別別の自動レビュー観点テンプレートを追加した。Codex plugin 対応として Claude Code / Kiro と Codex の公開 Skill セットを分離し、Codex では `skills-codex/` の core 30 個のみを公開する。Skill 実体は `skills/` に集約し、Claude Code / Kiro は manifest 配列指定で公開対象を制御する。Codex 向け公開ディレクトリは marketplace cache で欠落しないよう実ディレクトリとして同梱する。詳細は [CHANGELOG.md](../plugins/ndf/CHANGELOG.md)。`/ndf:codex` skill + `corder` エージェント経由の Codex CLI 直接実行に一本化、Serena MCP は別プラグイン `mcp-serena` に分離済み、Playwright シナリオ E2E、Google Drive / Chat 連携 skill を提供。 +| 用途 | ディレクトリ | 配布方法 | +|---|---|---| +| 共通編集元 | `plugins/ndf-shared/` | 直接 install しない | +| Claude Code | `plugins/ndf-claude/` | `.claude-plugin/marketplace.json` の `ndf` | +| Codex | `plugins/ndf-codex/` | `.agents/plugins/marketplace.json` の `ndf` | +| Kiro CLI | `plugins/ndf-kiro/` | `plugins/ndf-kiro/install.sh` | + +旧 monolithic NDF ディレクトリは廃止済みです。Skill や共通スクリプトを変更する場合は `plugins/ndf-shared/` を編集し、`bash scripts/build-runtime-plugins.sh` で runtime 配布物を再生成します。 ## ディレクトリ構造 -``` -plugins/ndf/ -├── .claude-plugin/ -│ └── plugin.json # プラグインメタデータ -├── hooks/ -│ └── hooks.json # SessionStart (保持期間管理/デフォルトstatusline) / Stop (Slack通知) -├── scripts/ -│ ├── ensure-retention.sh # cleanupPeriodDays >= 90 を保つ -│ ├── statusline.sh # NDF標準statusline本体 -│ ├── statusline-switch.sh # statusline の導入・切替・復元 (ensure/set/restore/status) -│ └── slack-notify.js # Slack通知スクリプト -├── agents/ # 専門エージェント(8個) -├── skills/ # 全Skill実体(49個。Claude Code/Kiroはmanifest配列で公開対象を指定) -├── skills-codex/ # Codex向け公開Skill(core 30個。marketplace cache対応の実ディレクトリ) -├── skills-optional/ # ランタイム別除外候補リスト -├── CLAUDE.md # プラグイン開発者向けガイド -└── README.md # 利用者向けドキュメント +```text +plugins/ +├── ndf-shared/ +│ ├── skills/ +│ ├── scripts/ +│ └── manifests/ +├── ndf-claude/ +│ ├── .claude-plugin/plugin.json +│ ├── agents/ +│ ├── hooks/ +│ ├── skills/ +│ └── scripts/ +├── ndf-codex/ +│ ├── .codex-plugin/plugin.json +│ ├── hooks/ +│ ├── skills/ +│ └── scripts/ +└── ndf-kiro/ + ├── install.sh + ├── agents/default.json.template + ├── prompts/ + ├── skills/ + └── scripts/ ``` -## 機能 +生成物は commit 対象です。利用者が plugin install 時に build を実行する必要はありません。 -### 1. MCP サーバー +## Runtime 別の同梱内容 -NDF プラグイン本体はコア MCP サーバを**同梱しない**(v4.0.0 で Codex MCP を廃止)。関連 MCP は個別プラグインとして提供: +| Runtime | 同梱内容 | +|---|---| +| Claude Code | 8個の専門エージェント、Claude向け公開Skills、SessionStart/Stop hook、statusline、Slack通知スクリプト | +| Codex | Codex向け公開Skills、Stop hook、任意Slack通知スクリプト | +| Kiro CLI | Kiro向け公開Skills、agent config template、workflow prompts、installer | -| MCP | 提供プラグイン | 用途 | -|---|---|---| -| Serena MCP | `mcp-serena` | セマンティックコード操作 | -| GitHub MCP | Anthropic 公式 | GitHub 操作 | -| Context7 MCP | Anthropic 公式 | 最新ライブラリドキュメント | -| Chrome DevTools MCP | `mcp-chrome-devtools` | ブラウザ自動化・パフォーマンス | -| BigQuery MCP | `mcp-bigquery` | BigQuery データ分析 | -| AWS Docs MCP | `mcp-aws-docs` | AWS 公式ドキュメント | -| DBHub MCP | `mcp-dbhub` | 汎用データベース | -| Notion MCP | `mcp-notion` | Notion 連携 | +Claude Code 専用の agents / statusline / transcript retention hook は Codex 版と Kiro 版には含めません。Codex 版と Kiro 版は、それぞれの runtime が読むディレクトリだけで完結します。 -### 2. ワークフロー Skills(スラッシュコマンド) +## Skills -`/ndf:` でユーザーから直接起動する Skill 群: +NDF の Skill 実装は `plugins/ndf-shared/skills/` が編集元です。公開セットは manifest で管理します。 -| Skill | 用途 | +| Manifest | 出力先 | |---|---| -| `/ndf:pr` | commit+push+PR 作成 / 既存 PR 説明更新 | -| `/ndf:pr-tests` | PR の Test Plan を自動実行 | -| `/ndf:fix` | レビューコメントの修正対応 | -| `/ndf:review` | PR を Approve/Request Changes 判定 | -| `/ndf:review-branch` | PR 前のローカル差分レビュー | -| `/ndf:review-pr-comments` | PR コメントの分類 (READ-ONLY) | -| `/ndf:resolve-pr-comments` | 対応済みコメント返信+Resolve | -| `/ndf:cherry-pick-pr` | 環境ブランチへの cherry-pick PR | -| `/ndf:deploy` | 環境ブランチへのデプロイ PR | -| `/ndf:sync-main` | main を現ブランチに取り込み | -| `/ndf:merged` | マージ後のクリーンアップ | -| `/ndf:clean` | マージ済みブランチ一括削除 | -| `/ndf:browser-test` | Playwright/Chrome DevTools での動作確認 | -| `/ndf:skill-stats` | Skill 利用統計の集計(期間/プロジェクト別) | -| `/ndf:statusline` | NDF標準statuslineの切り替え・復元・状態確認 (`set`/`restore`/`status`) | - -### 3. 原則・ガイドライン Skills(モデル起動型) - -該当文脈で自動的に参照される Skill 群: - -| Skill | 対象領域 | -|---|---| -| `ndf-policies` | プラグイン共通ポリシー(常時注入) | -| `branch-fix-strategy` | 複数ブランチ適用戦略 (cherry-pick) | -| `implementation-plan` | `issues/` 配下の実装プラン管理 | -| `plan-to-spec` | 実装完了後の plan を確定仕様書へ移動・リライト・レビュー | -| `investigation-rules` | 調査時のエビデンス主義 | -| `problem-solving` | 根本原因分析・多層防御 | -| `logging-guidelines` | ログ運用 (言語非依存) | -| `markdown-writing` | Markdown 文書の体裁 | -| `issue-plan-strategy` | issue→plan 作成・multi-PR 実行のワークフロー戦略 | -| `ml-model-structure` | ML モデル構築・推論API の標準ディレクトリ構造 (版内 feature SSoT / train↔serve 契約) | - -### 4. 補助 Skills - -| Skill | 用途 | -|---|---| -| `data-analyst-sql-optimization` | SQL 最適化パターン | -| `data-analyst-export` | CSV/JSON/Excel 出力 | -| `qa-security-scan` | OWASP Top 10 チェック | -| `python-execution` | Python 実行環境の自動判定 | -| `docker-container-access` | Docker コンテナ接続判定 | -| `git-gh-operations` | git/gh 操作パターン | -| `google-auth` | Google API OAuth2 | -| `codex` | Codex CLI 直接実行ガイド | -| `deepwiki-transfer` | DeepWiki 知識転送 | -| `knowledge-reorg` | 知識再編成 | -| `mcp-builder` | MCP サーバ作成(Anthropic 公式) | -| `official-skills-autoloader` | Anthropic 公式 Skill の自動ロード | -| `playwright-test-planning` | E2E テスト計画立案 (HTSM/ISTQB) | -| `playwright-script-creation` | E2E テストスクリプト作成 | -| `playwright-execution` | E2E 実行+エビデンス収集 (動画/a11y/CWV) | -| `playwright-report` | E2E テスト結果の Markdown レポート | -| `playwright-kit-ops` | playwright_kit スクリプト操作 | -| `playwright-scenario-test` | フル E2E ワークフロー統括 | -| `google-drive` | Google Drive/Docs 操作 | -| `google-chat` | Google Chat メッセージ取得 | -| `cross-review` | codex/gemini 両方でレビュー自動ループ | -| `gemini` | gemini CLI 直接実行 | - -### 5. 専門エージェント(8個、モデル階層化) - -| エージェント | モデル | 役割 | -|-------------|------|------| -| **director** | opus | タスク統括・設計立案 | -| **corder** | sonnet | Codex CLI 経由の独立レビュー・大規模調査 | -| **data-analyst** | sonnet | データ分析・SQL | -| **researcher** | sonnet | AWS Docs / Chrome DevTools 調査 | -| **qa** | sonnet | 品質・セキュリティ検証 | -| **debugger** | sonnet | 根本原因分析 | -| **devops-engineer** | sonnet | Docker/CI/CD/K8s | -| **code-reviewer** | sonnet | git diff/PR レビュー(Codex 非使用) | - -### 6. 自動フック - -| イベント | 用途 | -|---|---| -| `SessionStart` (matcher: `startup`) | `~/.claude/settings.json` の `cleanupPeriodDays` を最低 90 日に保つ (7日タイムスタンプガード + flock でアトミック更新) | -| `SessionStart` (matcher: `startup`) | `statusLine` 未設定時のみ NDF 標準 statusline を設定 (既存設定優先、NDF 版利用中はスクリプト更新のみ追従) | -| `Stop` | AI 要約を生成して Slack に通知 (`SLACK_BOT_TOKEN` 設定時のみ) | +| `plugins/ndf-shared/manifests/claude-skills.txt` | `plugins/ndf-claude/skills/` | +| `plugins/ndf-shared/manifests/codex-skills.txt` | `plugins/ndf-codex/skills/` | +| `plugins/ndf-shared/manifests/kiro-skills.txt` | `plugins/ndf-kiro/skills/` | -## 環境変数 +主な Skill 領域: -### Slack 通知(推奨) -- `SLACK_BOT_TOKEN` — Bot User OAuth Token (`xoxb-...`) -- `SLACK_CHANNEL_ID` — 通知先チャンネル (`C...`) -- `SLACK_USER_MENTION` — メンション対象ユーザー (`<@U...>`) +- PR / review workflow: `pr`, `pr-tests`, `fix`, `review`, `cross-review`, `resolve-pr-comments` +- branch / release workflow: `deploy`, `cherry-pick-pr`, `sync-main`, `merged`, `clean` +- planning / documentation: `implementation-plan`, `issue-plan-strategy`, `plan-to-spec`, `markdown-writing` +- quality / execution: `playwright-*`, `python-execution`, `docker-container-access`, `git-gh-operations` +- external services: `google-drive`, `google-chat`, `data-analyst-*` +- policy: `ndf-policies`, `problem-solving`, `logging-guidelines` -### Codex CLI(`/ndf:codex` / `corder` エージェント利用時) -- `CODEX_HOME` — Codex CLI のホーム (default: `~/.codex`) -- `OPENAI_API_KEY` — `codex login` 済みなら不要 +## MCP Plugins -### 個別 MCP プラグイン(利用する場合) -各プラグイン README を参照。 +MCP plugin も runtime 別に配布します。共通編集元は `plugins/mcp/shared//`、配布物は `plugins/mcp/claude|codex|kiro//` です。 -## 実装上の知見 +Claude Code と Codex は marketplace から同じ plugin 名で install します。Kiro CLI は `plugins/mcp/kiro//install.sh` で対象プロジェクトの `.mcp.json` と必要な Kiro agent 設定を更新します。 -### Stop Hook 無限ループ防止 - -Stop hook 内で Claude CLI を呼び出す際は `--settings` で hooks と plugins を両方無効化する: +## Build / Validation ```bash -claude -p --settings '{"disableAllHooks": true, "disableAllPlugins": true}' --output-format text +bash scripts/build-runtime-plugins.sh +bash scripts/build-runtime-plugins.sh --check +bash scripts/validate-runtime-plugins.sh +claude plugin validate plugins/ndf-claude +python3 -m json.tool plugins/ndf-codex/.codex-plugin/plugin.json >/dev/null +bash plugins/ndf-kiro/install.sh --dry-run +bash scripts/runtime-smoke-test.sh --runtime claude +bash scripts/runtime-smoke-test.sh --runtime codex +bash scripts/runtime-smoke-test.sh --runtime kiro ``` -- `CLAUDE_DISABLE_HOOKS` 環境変数 → 存在しない -- `stop_hook_active` フィールド → 実際には送信されない -- `--settings` で両方無効化 → 確実に動作 - -### 要約生成の3段階フォールバック - -1. **Claude CLI**(優先) — AI による高品質要約 -2. **transcript 解析**(フォールバック1) — セッションログから抽出 -3. **git diff**(フォールバック2) — ファイル変更から推測 - -### 保持期間管理の実装 - -`SessionStart` hook で `~/.claude/settings.json` の `cleanupPeriodDays` を検査し、90 未満なら 90 に更新する: - -- 実行は `~/.claude/.ndf-retention-checked` の 7 日タイムスタンプで抑止 -- 書き込みは `flock` で排他ロックし、並列セッションでの lost update を防止 (flock 不在環境は atomic rename に依存) -- Claude Code の公開 API には「プラグインインストール時」hook が存在しないため、`SessionStart + startup` matcher が実用上の最適解 - -### デフォルト statusline の実装(v4.14.0) - -`SessionStart` hook で `statusline-switch.sh ensure` を実行する: - -- プラグイン同梱の `statusline.sh` を `~/.claude/ndf-statusline.sh` にコピー(差分時のみ。プラグイン更新に追従させるため、settings からはプラグインキャッシュパスではなく固定パスを参照) -- `statusLine` が未設定の場合のみ `bash ~/.claude/ndf-statusline.sh` を設定。既存設定があれば何もしない(既存優先) -- `/ndf:statusline set` は既存設定を `~/.claude/.ndf-statusline-backup.json` にバックアップしてから切り替え、`restore` で復元(バックアップ無しなら `statusLine` キーを削除) -- 書き込みは `flock` + atomic rename(保持期間管理と同パターン) - -### Codex の扱い(v4.0.0) - -- Codex MCP サーバは廃止 -- `/ndf:codex` skill に CLI 直接実行の詳細手順(サンドボックス、プロンプト設計、バックグラウンド実行、stderr/stdout 回収)を記載 -- `corder` エージェントは本 skill を参照して `codex exec` を呼び出す - -### バージョン変遷(抜粋) - -| バージョン | 主な変更 | -|-----------|---------| -| v1.0.0 | 初期リリース | -| v2.0.0 | 公式プラグインへの MCP 重複解消 | -| v2.6.0 | NDF コア MCP 最小化 (Serena + Codex) | -| v2.7.0 | commands → skills 統合 (Claude Code 2.1.3 対応) | -| v3.0.0 | Serena MCP 分離 (`mcp-serena`)、memory 系 Skill 廃止、CLAUDE.ndf.md 注入廃止 | -| v3.1.0 | Kiro CLI 対応、`google-auth` skill 追加 | -| v3.5.0 | Agent/Skill 再編、モデル階層化、公式 Skill 連携 | -| v3.6.0 | 汎用 skill 13 個追加 (原則系・PR ワークフロー系・codex) | -| v3.7.0 | transcript 保持期間自動管理 hook、`/ndf:skill-stats` skill | -| **v4.0.0 (BREAKING)** | **Codex MCP 廃止 → CLI 直接実行一本化**、レガシー CLAUDE.ndf.md 救済機構削除、skill-stats にプロジェクト別/日付範囲フィルタ追加 | -| **v4.1.0** | `playwright-scenario-test` / `google-drive` / `google-chat` skill 追加、`google-auth` v0.2.0 (永続トークン `~/.config/gcloud/google_token.json` + `get_credentials()` API + 手動 copy-paste フロー) | -| **v4.10.0** | `ml-model-structure` skill 追加 (MLモデル構築・推論API開発の標準ディレクトリ構造、版内 feature SSoT / train↔serve 契約) | -| **v4.11.0** | `/ndf:cross-review` 堅牢性改善: monitor.py EARLY_ERROR 誤検知解消 (文字列リテラル / grep ソース引用行を benign 判定) + ループ終了時の最終スイープ (残 open thread 全 Resolve) 必須化 | -| **v4.12.0** | Playwright E2E に `playwright-browser-connect` (CDP リモートブラウザ接続) / `playwright-evidence-drive` (Google Drive エビデンスアーカイブ) の 2 skill 追加 (45→47個) | -| **v4.12.1** | `/ndf:cross-review` デフォルト調整: `--max-rounds` 6→12 / `--rotate-after` 5→8、`--rotate-mode squash` 説明から「既存挙動」表記を削除 | -| **v4.13.0** | `issue-plan-strategy`: release PR body の self-contained 必須化 (レビュアー視点の原則明文化、子 PR チェックリストを `
` に格下げ、Ready 前の body 最終化ステップ追加) | -| **v4.14.0** | `statusline` skill + デフォルト statusline 設定 hook 追加 (47→48個)。statusLine 未設定時のみ NDF 標準 statusline を自動設定、`/ndf:statusline set\|restore\|status` で切替・復元 | -| **v4.15.0** | cross-review: worktree 生成先を `<システム tmpdir>/ndf-worktrees/--/pr` に変更 (非永続化 + リポジトリ別分離)。未登録パスの残骸は `.stale-` に退避して作り直すガード追加 | -| **v4.16.0** | statusline: `[ctx:` の固定ラベルを利用モデル表示名 (例 `Opus 4.8`) に置換。モデル名が取れない場合は `ctx` にフォールバック | -| **v4.16.1** | statusline: NDF 由来の旧コピー (マーカー付き / レガシー `statusline-command.sh`) を `settings.json` が指す場合、SessionStart で正規パス (`~/.claude/ndf-statusline.sh`) へ自動移行しバージョンアップ追従を回復。ユーザー独自 statusline は誤検出ガードで保護。`skills/statusline/tests/` 新設 | -| **v4.17.0** | Codex plugin 対応: Claude Code / Kiro と Codex の公開 Skill セットを分離。Claude Code / Kiro は manifest 配列指定、Codex は marketplace cache 対応の `skills-codex/` 実ディレクトリで core 28 個を公開 | -| **v4.17.1** | `cross-review` の公開漏れ修正。Claude Code/Kiro manifest と Codex `skills-codex/` に追加し、Codex core 29 個として公開 | -| **v4.17.2** | Codex Stop hook の Slack 通知を Claude 版に合わせ、メンション付き投稿後にメンションなし投稿を残してメンション付き投稿を削除 | -| **v4.17.3** | Slack 終了通知 hook の失敗時終了コードを安定化し、Stop hook 全体を `code 1` にしないよう修正 | -| **v4.18.0** | `cross-review`: Gemini 進捗ログの heartbeat 表示、`--focus` / `--extra-instructions-file`、PR 種別別の自動レビュー観点テンプレートを追加 | -| **v4.19.0** | `plan-to-spec` skill を追加。実装完了後の plan を確定仕様書へ移動・リライト・レビューする標準フローと完了報告テンプレートを定義 | +`--check` は `plugins/ndf-*` と `plugins/mcp/claude|codex|kiro` の生成物が共通編集元と同期していることを検証します。`validate-runtime-plugins.sh` は生成物同期、JSON / manifest、marketplace source、Kiro installer、Markdown ローカルリンクをまとめて確認します。 + +`runtime-smoke-test.sh` は Docker コンテナ内に repo copy、`/tmp/runtime-home`、`/tmp/runtime-project`、`/tmp/runtime-secrets` を分離して作成し、Claude / Codex / Kiro それぞれで `ndf` と `mcp-bigquery` 相当の実 install、Skill / MCP / hook / agent config の assertion、JUnit / log artifact 出力を確認します。PR CI は `--with-secrets=off` の非認証 smoke のみを実行し、認証付き smoke は `runtime-plugin-authenticated-smoke.yml` の protected workflow で実行します。 + +ローカル hook を使う場合は `bash scripts/install-dev-hooks.sh` で `.githooks/` を有効化します。 + +## 外部 AI 委譲 + +Codex MCP サーバは廃止済みです。外部 AI 委譲は `/ndf:codex` Skill と Claude Code 版の `corder` エージェントから Codex CLI を直接呼び出す方式を標準とします。 + +## Slack 通知 + +| Runtime | 通知方法 | +|---|---| +| Claude Code | Stop hook で `plugins/ndf-claude/scripts/slack-notify.js` を実行 | +| Codex | `NDF_CODEX_SLACK_NOTIFY=true` の場合のみ Stop hook で通知 | +| Kiro CLI | `plugins/ndf-kiro/install.sh --with-slack` で通知 hook を生成 | + +機密情報は環境変数で管理し、リポジトリにはコミットしません。 + +## 関連ドキュメント + +- [Claude Code版 README](../plugins/ndf-claude/README.md) +- [Codex版 README](../plugins/ndf-codex/README.md) +- [Kiro CLI版 README](../plugins/ndf-kiro/README.md) +- [共通編集元 README](../plugins/ndf-shared/README.md) +- [runtime plugin container test plan](../issues/runtime-plugin-container-test-plan.md) diff --git a/docs/official-skills-installation.md b/docs/official-skills-installation.md index 2873d7d..d012dda 100644 --- a/docs/official-skills-installation.md +++ b/docs/official-skills-installation.md @@ -11,19 +11,19 @@ NDFプラグイン同梱のインストーラを使う方法: ```bash # 利用可能Skill一覧を表示(ライセンス分類付き) -bash plugins/ndf/scripts/install-official-skills.sh --list +bash plugins/ndf-shared/scripts/install-official-skills.sh --list # 必要なSkillをユーザー領域(~/.claude/skills/)にインストール -bash plugins/ndf/scripts/install-official-skills.sh docx pptx xlsx +bash plugins/ndf-shared/scripts/install-official-skills.sh docx pptx xlsx # プロジェクト .claude/skills/ に配置する場合 -bash plugins/ndf/scripts/install-official-skills.sh --scope project pdf +bash plugins/ndf-shared/scripts/install-official-skills.sh --scope project pdf # 全Skillをインストール -bash plugins/ndf/scripts/install-official-skills.sh --all +bash plugins/ndf-shared/scripts/install-official-skills.sh --all # 公式リポジトリを最新化 -bash plugins/ndf/scripts/install-official-skills.sh --update +bash plugins/ndf-shared/scripts/install-official-skills.sh --update ``` ### インストーラの動作 @@ -107,11 +107,11 @@ cp -r ~/work/anthropic-skills/skills/docx .claude/skills/ ### NDFが同梱している公式Skill(Apache-2.0のみ) -- `mcp-builder` → `plugins/ndf/skills/mcp-builder/`(LICENSE.txt同梱) +- `mcp-builder` → `plugins/ndf-shared/skills/mcp-builder/`(LICENSE.txt同梱) ### NDFが提供するインストーラ -- `plugins/ndf/scripts/install-official-skills.sh` → Apache-2.0 / プロプライエタリいずれも個人利用者環境にインストール可能 +- `plugins/ndf-shared/scripts/install-official-skills.sh` → Apache-2.0 / プロプライエタリいずれも個人利用者環境にインストール可能 ### NDFに独自実装を作らない方針 @@ -130,5 +130,5 @@ cp -r ~/work/anthropic-skills/skills/docx .claude/skills/ - 公式リポジトリ: https://github.com/anthropics/skills - Skills公式ドキュメント: https://docs.claude.com/en/docs/claude-code/skills -- NDFインストーラ: `plugins/ndf/scripts/install-official-skills.sh` +- NDFインストーラ: `plugins/ndf-shared/scripts/install-official-skills.sh` - Skillフロントマター仕様: [docs/claude-code-skills-official-reference.md](./claude-code-skills-official-reference.md) diff --git a/docs/plugin-development-guide.md b/docs/plugin-development-guide.md index 37495b2..e81ed49 100644 --- a/docs/plugin-development-guide.md +++ b/docs/plugin-development-guide.md @@ -16,12 +16,14 @@ "plugins": [ { "name": "ndf", - "source": "./plugins/ndf" + "source": "./plugins/ndf-claude" } ] } ``` +Codex 用 marketplace では同じ plugin 名 `ndf` の source を `./plugins/ndf-codex` にします。Kiro CLI 向け配布物は `plugins/ndf-kiro/` で管理します。 + ## プラグイン構造 各プラグインは以下の構造を持ちます: @@ -91,8 +93,8 @@ plugins/{plugin-name}/ 1. **既存プラグインを参考に構造を理解** ```bash - ls -la plugins/ndf/ - cat plugins/ndf/.claude-plugin/plugin.json + ls -la plugins/ndf-claude/ + cat plugins/ndf-claude/.claude-plugin/plugin.json ``` 2. **ディレクトリ構造を作成** @@ -139,6 +141,21 @@ plugins/{plugin-name}/ /plugin install {plugin-name}@ai-plugins ``` +### Runtime plugin 検証 + +NDF / MCP の runtime 別配布物を変更した場合は、以下を実行します。 + +```bash +bash scripts/build-runtime-plugins.sh +bash scripts/validate-runtime-plugins.sh +``` + +ローカル hook は任意で導入できます。 + +```bash +bash scripts/install-dev-hooks.sh +``` + ### 検証チェックリスト - [ ] marketplace.jsonが正しい形式 diff --git a/docs/project-overview.md b/docs/project-overview.md index 46f1764..61e0e86 100644 --- a/docs/project-overview.md +++ b/docs/project-overview.md @@ -28,12 +28,15 @@ ai-plugins/ ├── .claude-plugin/ │ └── marketplace.json # Claude Codeマーケットプレイスメタデータ ├── plugins/ -│ ├── ndf/ # NDFプラグイン(メイン) -│ │ ├── skills/ # 全Skill実体(Claude Code/Kiroはmanifest配列で公開対象を指定) -│ │ ├── skills-codex/ # Codex向け公開Skill(実ディレクトリ) -│ │ └── skills-optional/ # ランタイム別除外候補リスト -│ ├── mcp-serena/ # Serena MCPプラグイン -│ └── {plugin-name}/ # その他のプラグイン +│ ├── ndf-shared/ # NDF共通ソース +│ ├── ndf-claude/ # Claude Code版NDF +│ ├── ndf-codex/ # Codex版NDF +│ ├── ndf-kiro/ # Kiro CLI版NDF +│ └── mcp/ +│ ├── shared/ # MCPプラグイン共通ソース +│ ├── claude/ # Claude Code版MCPプラグイン +│ ├── codex/ # Codex版MCPプラグイン +│ └── kiro/ # Kiro CLI版MCPプラグイン ├── docs/ # リポジトリ知識 ├── AGENTS.md # 共通エントリポイント ├── CLAUDE.md # Claude Code固有設定 diff --git a/docs/specifications/issues-derived-specifications.md b/docs/specifications/issues-derived-specifications.md index 766b86e..b66a2c9 100644 --- a/docs/specifications/issues-derived-specifications.md +++ b/docs/specifications/issues-derived-specifications.md @@ -2,7 +2,7 @@ `issues/` 配下に残っていた完了済み issue、plan、report から、現行実装と一致する仕様だけを抽出した確定仕様書群。 -`issues/` は作業中の調査、実装計画、依頼文、報告書を含む開発記録として扱う。運用判断では本索引から参照する仕様、`docs/ndf-plugin-reference.md`、`plugins/ndf/README.md`、`plugins/ndf/CHANGELOG.md` を優先する。 +`issues/` は作業中の調査、実装計画、依頼文、報告書を含む開発記録として扱う。運用判断では本索引から参照する仕様、`docs/ndf-plugin-reference.md`、`plugins/ndf-claude/README.md`、`docs/ndf-plugin-reference.md` を優先する。 Skill の挙動仕様は本ディレクトリでは管理しない。Skill に関する詳細は、対象 Skill の `SKILL.md` を正とする。 @@ -29,5 +29,5 @@ Skill に関する完了済み issue / plan / report は、該当 Skill の実 - [NDF Plugin リファレンス](../ndf-plugin-reference.md) - [プラグイン開発ガイド](../plugin-development-guide.md) - [AI Plugins プロジェクト概要](../project-overview.md) -- [NDF README](../../plugins/ndf/README.md) -- [NDF CHANGELOG](../../plugins/ndf/CHANGELOG.md) +- [NDF README](../../plugins/ndf-claude/README.md) +- [NDF CHANGELOG](../../docs/ndf-plugin-reference.md) diff --git a/docs/specifications/ndf-knowledge-and-kiro.md b/docs/specifications/ndf-knowledge-and-kiro.md index 08bf5cf..4971ff8 100644 --- a/docs/specifications/ndf-knowledge-and-kiro.md +++ b/docs/specifications/ndf-knowledge-and-kiro.md @@ -4,11 +4,11 @@ NDF の知識配置、Serena MCP 分離、Kiro CLI 対応に関する確定仕様。 -Skill の挙動仕様は本ディレクトリでは管理しない。Skill は `plugins/ndf/skills/*/SKILL.md` または `plugins/ndf/skills-codex/*/SKILL.md` を正とする。 +Skill の挙動仕様は本ディレクトリでは管理しない。Skill は `plugins/ndf-shared/skills/*/SKILL.md` を編集元とし、Kiro では build 済みの `plugins/ndf-kiro/skills/*/SKILL.md` を配布物として使う。 ## 仕様化の扱い -本仕様は、完了済み issues / plans の内容を統合した現行仕様である。元の `issues/*` ファイルは完了後に削除されるため、マージ後の正は本ファイル、`AGENTS.md`、`KIRO.md`、`plugins/ndf/README.md`、`docs/ndf-plugin-reference.md` とする。過去の検討履歴が必要な場合は、この仕様を追加した commit の git 履歴を参照する。 +本仕様は、完了済み issues / plans の内容を統合した現行仕様である。元の `issues/*` ファイルは完了後に削除されるため、マージ後の正は本ファイル、`AGENTS.md`、`KIRO.md`、`plugins/ndf-claude/README.md`、`docs/ndf-plugin-reference.md` とする。過去の検討履歴が必要な場合は、この仕様を追加した commit の git 履歴を参照する。 ## NDF 知識構造 @@ -29,7 +29,7 @@ AI エージェント向けの知識は以下の層で管理する。 Kiro CLI 用設定は `.kiro/agents/default.json` で管理する。agent 設定は `AGENTS.md` と `README.md` を `file://` resource として読み込み、`.kiro/skills/**/SKILL.md` を `skill://` resource として参照する。 -Kiro CLI では `scripts/install-kiro.sh` が `.kiro/skills/` と `.kiro/agents/default.json` を生成する。`agentSpawn` hook は初期化時の案内に使い、`--with-slack` 指定時のみ `stop` hook 相当の Slack 通知を有効化する。Kiro の stop hook payload に `assistant_response` が含まれる場合、`plugins/ndf/scripts/slack-notify.js` は transcript よりも `assistant_response` を優先して要約に使う。 +Kiro CLI では `plugins/ndf-kiro/install.sh` が `plugins/ndf-kiro/skills/` から `.kiro/skills/` への symlink と `.kiro/agents/default.json` を生成する。`agentSpawn` hook は初期化時の案内に使い、`--with-slack` 指定時のみ `stop` hook 相当の Slack 通知を有効化する。Kiro の stop hook payload に `assistant_response` が含まれる場合、`plugins/ndf-kiro/scripts/slack-notify.js` は transcript よりも `assistant_response` を優先して要約に使う。 Codex 連携は MCP サーバではなく `/ndf:codex` skill と `corder` エージェント経由の Codex CLI 直接実行を標準とする。Kiro 用 `--with-codex` は Kiro セッションから Codex CLI を扱う場合の補助設定である。 @@ -59,6 +59,6 @@ Codex 連携は MCP サーバではなく `/ndf:codex` skill と `corder` エー ## 関連リンク -- [mcp-serena README](../../plugins/mcp-serena/README.md) +- [mcp-serena README](../../plugins/mcp/shared/mcp-serena/README.md) - [NDF Plugin リファレンス](../ndf-plugin-reference.md) -- [NDF README](../../plugins/ndf/README.md) +- [NDF README](../../plugins/ndf-claude/README.md) diff --git a/docs/superpowers/plans/2026-05-25-playwright-skill-restructure.md b/docs/superpowers/plans/2026-05-25-playwright-skill-restructure.md index e45db06..b61dc07 100644 --- a/docs/superpowers/plans/2026-05-25-playwright-skill-restructure.md +++ b/docs/superpowers/plans/2026-05-25-playwright-skill-restructure.md @@ -18,40 +18,40 @@ | ファイル | 責務 | |---|---| -| `plugins/ndf/skills/playwright-script-creation/SKILL.md` | Phase 2 スキル: テストスクリプト作成ガイド | -| `plugins/ndf/skills/playwright-execution/SKILL.md` | Phase 3 スキル: テスト実行+エビデンス収集 (3 スキル統合) | -| `plugins/ndf/skills/playwright-kit-ops/tests/test_video_default.py` | `--pwk-no-video` オプションと動画デフォルト ON のテスト | +| `plugins/ndf-shared/skills/playwright-script-creation/SKILL.md` | Phase 2 スキル: テストスクリプト作成ガイド | +| `plugins/ndf-shared/skills/playwright-execution/SKILL.md` | Phase 3 スキル: テスト実行+エビデンス収集 (3 スキル統合) | +| `plugins/ndf-shared/skills/playwright-kit-ops/tests/test_video_default.py` | `--pwk-no-video` オプションと動画デフォルト ON のテスト | ### 変更 | ファイル | 変更内容 | |---|---| -| `plugins/ndf/skills/playwright-kit-ops/playwright_kit/pytest_plugin.py` | `--pwk-no-video` CLI オプション追加 + `pytest_configure` で動画デフォルト ON | -| `plugins/ndf/skills/playwright-kit-ops/templates/run.sh` | `--video=on` フォールバック追加 | -| `plugins/ndf/skills/playwright-kit-ops/templates/conftest.py.template` | テストスクリプト存在チェック追加 | -| `plugins/ndf/skills/playwright-test-planning/SKILL.md` | 次フェーズ導線追加 | -| `plugins/ndf/skills/playwright-report/SKILL.md` | Drive 共有セクション削除 | -| `plugins/ndf/skills/playwright-scenario-test/SKILL.md` | 5 スキル案内テーブル更新 + 大原則記載 | -| `plugins/ndf/.claude-plugin/plugin.json` | skills 配列更新 (3 削除 + 2 追加) | +| `plugins/ndf-shared/skills/playwright-kit-ops/playwright_kit/pytest_plugin.py` | `--pwk-no-video` CLI オプション追加 + `pytest_configure` で動画デフォルト ON | +| `plugins/ndf-shared/skills/playwright-kit-ops/templates/run.sh` | `--video=on` フォールバック追加 | +| `plugins/ndf-shared/skills/playwright-kit-ops/templates/conftest.py.template` | テストスクリプト存在チェック追加 | +| `plugins/ndf-shared/skills/playwright-test-planning/SKILL.md` | 次フェーズ導線追加 | +| `plugins/ndf-shared/skills/playwright-report/SKILL.md` | Drive 共有セクション削除 | +| `plugins/ndf-shared/skills/playwright-scenario-test/SKILL.md` | 5 スキル案内テーブル更新 + 大原則記載 | +| `plugins/ndf-claude/.claude-plugin/plugin.json` | skills 配列更新 (3 削除 + 2 追加) | ### 削除 | ディレクトリ | 理由 | |---|---| -| `plugins/ndf/skills/playwright-evidence/` | `playwright-execution` に統合 | -| `plugins/ndf/skills/playwright-overlay/` | `playwright-execution` に統合 | -| `plugins/ndf/skills/playwright-quality/` | `playwright-execution` に統合 | +| `plugins/ndf-shared/skills/playwright-evidence/` | `playwright-execution` に統合 | +| `plugins/ndf-shared/skills/playwright-overlay/` | `playwright-execution` に統合 | +| `plugins/ndf-shared/skills/playwright-quality/` | `playwright-execution` に統合 | --- ## Task 1: `--pwk-no-video` CLI オプション追加 + 動画デフォルト ON のテスト **Files:** -- Create: `plugins/ndf/skills/playwright-kit-ops/tests/test_video_default.py` +- Create: `plugins/ndf-shared/skills/playwright-kit-ops/tests/test_video_default.py` - [ ] **Step 1: テストファイルを作成** -`plugins/ndf/skills/playwright-kit-ops/tests/test_video_default.py` に以下を書く: +`plugins/ndf-shared/skills/playwright-kit-ops/tests/test_video_default.py` に以下を書く: ```python """--pwk-no-video オプションと動画デフォルト ON の検証。 @@ -121,14 +121,14 @@ def test_explicit_video_flag_takes_precedence(pytester): - [ ] **Step 2: テストを実行して FAIL を確認** -Run: `cd /work/ai-plugins/plugins/ndf/skills/playwright-kit-ops && uv run pytest tests/test_video_default.py -v` +Run: `cd /work/ai-plugins/plugins/ndf-shared/skills/playwright-kit-ops && uv run pytest tests/test_video_default.py -v` Expected: `test_pwk_no_video_option_registered` → FAIL (`--pwk-no-video` がまだ登録されていない)。`test_video_default_on` → FAIL (デフォルトが `on` ではない)。`test_pwk_no_video_sets_off` → FAIL。`test_explicit_video_flag_takes_precedence` → 結果は pytest-playwright の状態に依存。 - [ ] **Step 3: コミット** ```bash -git add plugins/ndf/skills/playwright-kit-ops/tests/test_video_default.py +git add plugins/ndf-shared/skills/playwright-kit-ops/tests/test_video_default.py git commit -m "test: --pwk-no-video オプションと動画デフォルト ON の failing tests 追加" ``` @@ -137,12 +137,12 @@ git commit -m "test: --pwk-no-video オプションと動画デフォルト ON ## Task 2: `pytest_plugin.py` に `--pwk-no-video` を実装して動画デフォルト ON にする **Files:** -- Modify: `plugins/ndf/skills/playwright-kit-ops/playwright_kit/pytest_plugin.py:44-93` (pytest_addoption) -- Modify: `plugins/ndf/skills/playwright-kit-ops/playwright_kit/pytest_plugin.py:110-137` (pytest_configure) +- Modify: `plugins/ndf-shared/skills/playwright-kit-ops/playwright_kit/pytest_plugin.py:44-93` (pytest_addoption) +- Modify: `plugins/ndf-shared/skills/playwright-kit-ops/playwright_kit/pytest_plugin.py:110-137` (pytest_configure) - [ ] **Step 1: `pytest_addoption` に `--pwk-no-video` を追加** -`plugins/ndf/skills/playwright-kit-ops/playwright_kit/pytest_plugin.py` の `pytest_addoption` 関数内、`--pwk-overlay` の直前に追加する: +`plugins/ndf-shared/skills/playwright-kit-ops/playwright_kit/pytest_plugin.py` の `pytest_addoption` 関数内、`--pwk-overlay` の直前に追加する: ```python group.addoption( @@ -177,20 +177,20 @@ git commit -m "test: --pwk-no-video オプションと動画デフォルト ON - [ ] **Step 3: テストを実行して PASS を確認** -Run: `cd /work/ai-plugins/plugins/ndf/skills/playwright-kit-ops && uv run pytest tests/test_video_default.py -v` +Run: `cd /work/ai-plugins/plugins/ndf-shared/skills/playwright-kit-ops && uv run pytest tests/test_video_default.py -v` Expected: 4 テスト全て PASS。 - [ ] **Step 4: 既存テストが壊れていないことを確認** -Run: `cd /work/ai-plugins/plugins/ndf/skills/playwright-kit-ops && uv run pytest -q` +Run: `cd /work/ai-plugins/plugins/ndf-shared/skills/playwright-kit-ops && uv run pytest -q` Expected: 全テスト PASS (テスト数は変動する可能性あり)。 - [ ] **Step 5: コミット** ```bash -git add plugins/ndf/skills/playwright-kit-ops/playwright_kit/pytest_plugin.py +git add plugins/ndf-shared/skills/playwright-kit-ops/playwright_kit/pytest_plugin.py git commit -m "feat: --pwk-no-video オプション追加 + 動画デフォルト ON" ``` @@ -199,11 +199,11 @@ git commit -m "feat: --pwk-no-video オプション追加 + 動画デフォル ## Task 3: `run.sh` テンプレートに `--video=on` フォールバックを追加 **Files:** -- Modify: `plugins/ndf/skills/playwright-kit-ops/templates/run.sh:80-89` (pytest 実行部分) +- Modify: `plugins/ndf-shared/skills/playwright-kit-ops/templates/run.sh:80-89` (pytest 実行部分) - [ ] **Step 1: `run.sh` の pytest 実行部分を変更** -`plugins/ndf/skills/playwright-kit-ops/templates/run.sh` の末尾 pytest 実行部分を以下に置換する: +`plugins/ndf-shared/skills/playwright-kit-ops/templates/run.sh` の末尾 pytest 実行部分を以下に置換する: ```bash # --- 3) pytest 実行 ------------------------------------------------ @@ -236,7 +236,7 @@ exec uv run pytest \ - [ ] **Step 3: コミット** ```bash -git add plugins/ndf/skills/playwright-kit-ops/templates/run.sh +git add plugins/ndf-shared/skills/playwright-kit-ops/templates/run.sh git commit -m "feat: run.sh に --video=on デフォルト + --pwk-no-video 対応追加" ``` @@ -245,11 +245,11 @@ git commit -m "feat: run.sh に --video=on デフォルト + --pwk-no-video 対 ## Task 4: conftest.py テンプレートにテストスクリプト存在チェックを追加 **Files:** -- Modify: `plugins/ndf/skills/playwright-kit-ops/templates/conftest.py.template` +- Modify: `plugins/ndf-shared/skills/playwright-kit-ops/templates/conftest.py.template` - [ ] **Step 1: テストスクリプト存在チェックを追加** -`plugins/ndf/skills/playwright-kit-ops/templates/conftest.py.template` の末尾に以下を追加する: +`plugins/ndf-shared/skills/playwright-kit-ops/templates/conftest.py.template` の末尾に以下を追加する: ```python @@ -268,7 +268,7 @@ def pytest_collection_modifyitems(session, config, items): - [ ] **Step 2: コミット** ```bash -git add plugins/ndf/skills/playwright-kit-ops/templates/conftest.py.template +git add plugins/ndf-shared/skills/playwright-kit-ops/templates/conftest.py.template git commit -m "feat: conftest テンプレートにテストスクリプト存在チェック追加" ``` @@ -277,16 +277,16 @@ git commit -m "feat: conftest テンプレートにテストスクリプト存 ## Task 5: 旧スキルディレクトリを削除 **Files:** -- Delete: `plugins/ndf/skills/playwright-evidence/` (ディレクトリごと) -- Delete: `plugins/ndf/skills/playwright-overlay/` (ディレクトリごと) -- Delete: `plugins/ndf/skills/playwright-quality/` (ディレクトリごと) +- Delete: `plugins/ndf-shared/skills/playwright-evidence/` (ディレクトリごと) +- Delete: `plugins/ndf-shared/skills/playwright-overlay/` (ディレクトリごと) +- Delete: `plugins/ndf-shared/skills/playwright-quality/` (ディレクトリごと) - [ ] **Step 1: 3 ディレクトリを削除** ```bash -git rm -r plugins/ndf/skills/playwright-evidence -git rm -r plugins/ndf/skills/playwright-overlay -git rm -r plugins/ndf/skills/playwright-quality +git rm -r plugins/ndf-shared/skills/playwright-evidence +git rm -r plugins/ndf-shared/skills/playwright-overlay +git rm -r plugins/ndf-shared/skills/playwright-quality ``` - [ ] **Step 2: コミット** @@ -300,11 +300,11 @@ git commit -m "refactor: playwright-evidence/overlay/quality を削除 (playwrig ## Task 6: `playwright-execution/SKILL.md` を新規作成 **Files:** -- Create: `plugins/ndf/skills/playwright-execution/SKILL.md` +- Create: `plugins/ndf-shared/skills/playwright-execution/SKILL.md` - [ ] **Step 1: SKILL.md を作成** -`plugins/ndf/skills/playwright-execution/SKILL.md`: +`plugins/ndf-shared/skills/playwright-execution/SKILL.md`: ```markdown --- @@ -411,7 +411,7 @@ reports// - [ ] **Step 2: コミット** ```bash -git add plugins/ndf/skills/playwright-execution/SKILL.md +git add plugins/ndf-shared/skills/playwright-execution/SKILL.md git commit -m "feat: playwright-execution スキル追加 (evidence+overlay+quality 統合)" ``` @@ -420,11 +420,11 @@ git commit -m "feat: playwright-execution スキル追加 (evidence+overlay+qual ## Task 7: `playwright-script-creation/SKILL.md` を新規作成 **Files:** -- Create: `plugins/ndf/skills/playwright-script-creation/SKILL.md` +- Create: `plugins/ndf-shared/skills/playwright-script-creation/SKILL.md` - [ ] **Step 1: SKILL.md を作成** -`plugins/ndf/skills/playwright-script-creation/SKILL.md`: +`plugins/ndf-shared/skills/playwright-script-creation/SKILL.md`: ```markdown --- @@ -540,7 +540,7 @@ fixture / marker の完全な一覧は `playwright_kit/pytest_plugin.py` の `_P - [ ] **Step 2: コミット** ```bash -git add plugins/ndf/skills/playwright-script-creation/SKILL.md +git add plugins/ndf-shared/skills/playwright-script-creation/SKILL.md git commit -m "feat: playwright-script-creation スキル追加 (テストスクリプト作成ガイド)" ``` @@ -549,11 +549,11 @@ git commit -m "feat: playwright-script-creation スキル追加 (テストスク ## Task 8: `playwright-test-planning/SKILL.md` を改修 **Files:** -- Modify: `plugins/ndf/skills/playwright-test-planning/SKILL.md` +- Modify: `plugins/ndf-shared/skills/playwright-test-planning/SKILL.md` - [ ] **Step 1: ワークフロー末尾に次フェーズ導線を追加** -`plugins/ndf/skills/playwright-test-planning/SKILL.md` のワークフロー `[E]` の後に以下を追加する: +`plugins/ndf-shared/skills/playwright-test-planning/SKILL.md` のワークフロー `[E]` の後に以下を追加する: ```markdown ▼ @@ -577,7 +577,7 @@ git commit -m "feat: playwright-script-creation スキル追加 (テストスク - [ ] **Step 3: コミット** ```bash -git add plugins/ndf/skills/playwright-test-planning/SKILL.md +git add plugins/ndf-shared/skills/playwright-test-planning/SKILL.md git commit -m "Update: playwright-test-planning にスクリプト作成フェーズへの導線追加" ``` @@ -586,11 +586,11 @@ git commit -m "Update: playwright-test-planning にスクリプト作成フェ ## Task 9: `playwright-report/SKILL.md` から Drive 共有を削除 **Files:** -- Modify: `plugins/ndf/skills/playwright-report/SKILL.md` +- Modify: `plugins/ndf-shared/skills/playwright-report/SKILL.md` - [ ] **Step 1: Drive 関連セクションを削除** -`plugins/ndf/skills/playwright-report/SKILL.md` から以下のセクションを削除する: +`plugins/ndf-shared/skills/playwright-report/SKILL.md` から以下のセクションを削除する: - `## Google Drive アップロード` セクション全体 (「### テスト実行時に自動アップロード」と「### 手動アップロード」を含む) - [ ] **Step 2: 関連 Skill セクションを更新** @@ -608,7 +608,7 @@ git commit -m "Update: playwright-test-planning にスクリプト作成フェ - [ ] **Step 3: コミット** ```bash -git add plugins/ndf/skills/playwright-report/SKILL.md +git add plugins/ndf-shared/skills/playwright-report/SKILL.md git commit -m "Update: playwright-report から Drive 共有セクションを削除" ``` @@ -617,11 +617,11 @@ git commit -m "Update: playwright-report から Drive 共有セクションを ## Task 10: `playwright-scenario-test/SKILL.md` (orchestrator) を改修 **Files:** -- Modify: `plugins/ndf/skills/playwright-scenario-test/SKILL.md` +- Modify: `plugins/ndf-shared/skills/playwright-scenario-test/SKILL.md` - [ ] **Step 1: SKILL.md を全面改修** -`plugins/ndf/skills/playwright-scenario-test/SKILL.md` の内容を以下に置換する (frontmatter を含めて全体を差し替え): +`plugins/ndf-shared/skills/playwright-scenario-test/SKILL.md` の内容を以下に置換する (frontmatter を含めて全体を差し替え): ```markdown --- @@ -695,7 +695,7 @@ Web アプリの E2E シナリオを **理論ベース** で計画し、**再現 - [ ] **Step 2: コミット** ```bash -git add plugins/ndf/skills/playwright-scenario-test/SKILL.md +git add plugins/ndf-shared/skills/playwright-scenario-test/SKILL.md git commit -m "Update: playwright-scenario-test orchestrator を 5 スキル構成に改修" ``` @@ -704,11 +704,11 @@ git commit -m "Update: playwright-scenario-test orchestrator を 5 スキル構 ## Task 11: `plugin.json` を更新 **Files:** -- Modify: `plugins/ndf/.claude-plugin/plugin.json` +- Modify: `plugins/ndf-claude/.claude-plugin/plugin.json` - [ ] **Step 1: skills 配列を更新** -`plugins/ndf/.claude-plugin/plugin.json` の `skills` 配列から以下を削除: +`plugins/ndf-claude/.claude-plugin/plugin.json` の `skills` 配列から以下を削除: ```json "./skills/playwright-evidence", "./skills/playwright-overlay", @@ -738,7 +738,7 @@ git commit -m "Update: playwright-scenario-test orchestrator を 5 スキル構 - [ ] **Step 4: コミット** ```bash -git add plugins/ndf/.claude-plugin/plugin.json +git add plugins/ndf-claude/.claude-plugin/plugin.json git commit -m "Update: plugin.json を 5 スキル構成に更新 (v4.9.0)" ``` @@ -756,7 +756,7 @@ Expected: 検証が通ること。エラーがあればその場で修正する - [ ] **Step 2: 全テストを実行** -Run: `cd /work/ai-plugins/plugins/ndf/skills/playwright-kit-ops && uv run pytest -q` +Run: `cd /work/ai-plugins/plugins/ndf-shared/skills/playwright-kit-ops && uv run pytest -q` Expected: 全テスト PASS。 diff --git a/issues/agent-runtime-plugin-split.md b/issues/agent-runtime-plugin-split.md index 2f0ceaa..0f71799 100644 --- a/issues/agent-runtime-plugin-split.md +++ b/issues/agent-runtime-plugin-split.md @@ -538,6 +538,28 @@ base branch: `main` - ブラウザ認証しかできない runtime、または `--with-secrets=off` の非認証 smoke では、login prompt / 認証 URL 表示まで到達すれば合格とする - 詳細は `issues/runtime-plugin-container-test-plan.md` に従う +## 実装進捗 + +2026-07-07 時点の進捗。 + +| 状態 | PR | branch | 内容 | 備考 | +|---|---:|---|---|---| +| 最終確認待ち | #45 | `release/runtime-plugin-split` | release PR | Draft/Open。Task 1-8 の個別 PR 集約後、main merge 前の最終確認待ち | +| merge 済み | #46 | `feature/runtime-split-shared-builder` | Task 1: `ndf-shared` と `scripts/build-runtime-plugins.sh` を追加 | cross-review approved。`release/runtime-plugin-split` へ merge 済み。worktree / local branch cleanup 済み | +| merge 済み | #47 | `feature/runtime-split-claude` | Task 2: `plugins/ndf-claude` を追加し、Claude marketplace を切替 | cross-review approved。`release/runtime-plugin-split` へ merge 済み。worktree / local branch cleanup 済み | +| merge 済み | #48 | `feature/runtime-split-codex` | Task 3: `plugins/ndf-codex` を追加し、Codex marketplace を切替 | cross-review approved。`release/runtime-plugin-split` へ merge 済み。worktree / local branch cleanup 済み | +| merge 済み | #49 | `feature/runtime-split-kiro` | Task 4: `plugins/ndf-kiro` を作成し、Kiro installer / docs / templates を移動 | cross-review approved。`release/runtime-plugin-split` へ merge 済み。worktree / local branch cleanup 済み | +| merge 済み | #50 | `feature/runtime-split-docs-cleanup` | Task 5: README / AGENTS / docs / specs 更新、旧 `plugins/ndf` の削除 | cross-review approved。`release/runtime-plugin-split` へ merge 済み。merge 後検証済み | +| merge 済み | #51 / #54 | `feature/runtime-split-mcp-plugins` | Task 6: MCP plugin を runtime 別に分離 | #51 はローテーションで close。#54 が cross-review approved、`release/runtime-plugin-split` へ merge 済み | +| merge 済み | #52 | `feature/runtime-split-validation` | Task 7: validate script、dev hook、CI、リンク検証 | cross-review approved。`release/runtime-plugin-split` へ merge 済み。merge 後検証済み | +| merge 済み | #53 | `feature/runtime-split-container-smoke` | Task 8: runtime smoke test | cross-review approved。`release/runtime-plugin-split` へ merge 済み。merge 後検証済み | + +再開時の前提: + +- `release/runtime-plugin-split` には PR #46 / #47 / #48 / #49 / #50 / #52 / #53 / #54 が merge 済み。 +- Task 1-8 の個別 PR は cross-review と merge 後検証まで完了済み。 +- 残る判断は release PR #45 を最終確認し、main へ merge するタイミングのみ。 + ## 影響範囲 | 領域 | 影響 | @@ -585,25 +607,25 @@ base branch: `main` 詳細なテスト設計は `issues/runtime-plugin-container-test-plan.md` に分離する。この親計画では以下を完了条件として追跡する。 -- [ ] `bash scripts/build-runtime-plugins.sh --check` -- [ ] `bash scripts/validate-runtime-plugins.sh` -- [ ] `bash scripts/runtime-smoke-test.sh --runtime claude` -- [ ] `bash scripts/runtime-smoke-test.sh --runtime codex` -- [ ] `bash scripts/runtime-smoke-test.sh --runtime kiro` -- [ ] `.github/workflows/runtime-plugin-validate.yml` が build check / validate / link check を実行する -- [ ] `.github/workflows/runtime-plugin-smoke.yml` が軽量コンテナで runtime smoke を実行する -- [ ] `rg "plugins/ndf($|[^-])|plugins/ndf/" README.md AGENTS.md CLAUDE.md KIRO.md docs plugins scripts .claude-plugin .agents` で旧 NDF パス残存を確認 -- [ ] `rg "plugins/mcp-[a-z]" README.md AGENTS.md CLAUDE.md KIRO.md docs plugins scripts .claude-plugin .agents` で旧 MCP パス残存を確認 +- [x] `bash scripts/build-runtime-plugins.sh --check` +- [x] `bash scripts/validate-runtime-plugins.sh` +- [x] `bash scripts/runtime-smoke-test.sh --runtime claude` +- [x] `bash scripts/runtime-smoke-test.sh --runtime codex` +- [x] `bash scripts/runtime-smoke-test.sh --runtime kiro` +- [x] `.github/workflows/runtime-plugin-validate.yml` が build check / validate / link check を実行する +- [x] `.github/workflows/runtime-plugin-smoke.yml` が軽量コンテナで runtime smoke を実行する +- [x] `rg "plugins/ndf($|[^-])|plugins/ndf/" README.md AGENTS.md CLAUDE.md KIRO.md docs plugins scripts .claude-plugin .agents` で旧 NDF パス残存を確認 +- [x] `rg "plugins/mcp-[a-z]" README.md AGENTS.md CLAUDE.md KIRO.md docs plugins scripts .claude-plugin .agents` で旧 MCP パス残存を確認 ## 完了の定義 -- [ ] Claude / Codex / Kiro の配布単位が別ディレクトリになっている -- [ ] `plugins/ndf` が削除または README stub のみに縮退している -- [ ] marketplace source がランタイム別ディレクトリを指している -- [ ] Kiro installer が Claude manifest に依存していない -- [ ] 共通 Skill の編集元と生成先の同期チェックがある -- [ ] MCP plugin が `plugins/mcp/shared|claude|codex|kiro` に分離されている -- [ ] Claude / Codex / Kiro の各導入手順で `mcp-bigquery@ai-plugins` が同じ名前で使える -- [ ] README / docs が新構成を案内している -- [ ] 各ランタイムの検証手順が通っている -- [ ] 各ランタイムの実インストール smoke test がコンテナ内で通っている +- [x] Claude / Codex / Kiro の配布単位が別ディレクトリになっている +- [x] `plugins/ndf` が削除または README stub のみに縮退している +- [x] marketplace source がランタイム別ディレクトリを指している +- [x] Kiro installer が Claude manifest に依存していない +- [x] 共通 Skill の編集元と生成先の同期チェックがある +- [x] MCP plugin が `plugins/mcp/shared|claude|codex|kiro` に分離されている +- [x] Claude / Codex / Kiro の各導入手順で `mcp-bigquery@ai-plugins` が同じ名前で使える +- [x] README / docs が新構成を案内している +- [x] 各ランタイムの検証手順が通っている +- [x] 各ランタイムの実インストール smoke test がコンテナ内で通っている diff --git a/issues/runtime-plugin-container-test-plan.md b/issues/runtime-plugin-container-test-plan.md index cfd21f5..e2a6854 100644 --- a/issues/runtime-plugin-container-test-plan.md +++ b/issues/runtime-plugin-container-test-plan.md @@ -453,16 +453,16 @@ base branch: `release/runtime-plugin-split` (親計画の PR7 merge 後。親計 ## 完了の定義 -- [ ] `scripts/runtime-smoke-test.sh` で runtime を選択できる -- [ ] Claude / Codex / Kiro の container image が分かれている -- [ ] smoke は host HOME と credential directory を mount しない -- [ ] secret が存在する場合は許可リストに従って `--rm --tmpfs /tmp/runtime-secrets` container へ注入され、container 内 path へ env が再設定される -- [ ] `--with-secrets=auto|required` と `--keep-container` の併用が拒否される -- [ ] `ndf@ai-plugins` の実 install を確認する -- [ ] `mcp-bigquery@ai-plugins` 相当の実 install を全 runtime で確認する -- [ ] Skill / MCP / hook / agents または Kiro agent config の assertion がある -- [ ] hook script は fixture payload で非認証実行できる -- [ ] secret が存在する場合は認証付き Skill / MCP smoke が実行される -- [ ] ブラウザ認証しかない runtime、または `--with-secrets=off` の非認証 smoke だけ、認証準備完了までを合格条件にする -- [ ] CI で smoke test log と JUnit を artifact 化する -- [ ] secret 値が log / JUnit / artifact に出力されない +- [x] `scripts/runtime-smoke-test.sh` で runtime を選択できる +- [x] Claude / Codex / Kiro の container image が分かれている +- [x] smoke は host HOME と credential directory を mount しない +- [x] secret が存在する場合は許可リストに従って `--rm --tmpfs /tmp/runtime-secrets` container へ注入され、container 内 path へ env が再設定される +- [x] `--with-secrets=auto|required` と `--keep-container` の併用が拒否される +- [x] `ndf@ai-plugins` の実 install を確認する +- [x] `mcp-bigquery@ai-plugins` 相当の実 install を全 runtime で確認する +- [x] Skill / MCP / hook / agents または Kiro agent config の assertion がある +- [x] hook script は fixture payload で非認証実行できる +- [x] secret が存在する場合は認証付き Skill / MCP smoke が実行される +- [x] ブラウザ認証しかない runtime、または `--with-secrets=off` の非認証 smoke だけ、認証準備完了までを合格条件にする +- [x] CI で smoke test log と JUnit を artifact 化する +- [x] secret 値が log / JUnit / artifact に出力されない diff --git a/plugins/mcp-aws-docs/.claude-plugin/plugin.json b/plugins/mcp/claude/mcp-aws-docs/.claude-plugin/plugin.json similarity index 100% rename from plugins/mcp-aws-docs/.claude-plugin/plugin.json rename to plugins/mcp/claude/mcp-aws-docs/.claude-plugin/plugin.json diff --git a/plugins/mcp-aws-docs/.mcp.json b/plugins/mcp/claude/mcp-aws-docs/.mcp.json similarity index 100% rename from plugins/mcp-aws-docs/.mcp.json rename to plugins/mcp/claude/mcp-aws-docs/.mcp.json diff --git a/plugins/mcp-aws-docs/README.md b/plugins/mcp/claude/mcp-aws-docs/README.md similarity index 94% rename from plugins/mcp-aws-docs/README.md rename to plugins/mcp/claude/mcp-aws-docs/README.md index 6ad040d..38a9388 100644 --- a/plugins/mcp-aws-docs/README.md +++ b/plugins/mcp/claude/mcp-aws-docs/README.md @@ -15,10 +15,16 @@ AWS公式ドキュメントにアクセスするためのMCPサーバープラ ## インストール +### Claude Code + ```bash /plugin install mcp-aws-docs@ai-plugins ``` +### 必要な環境変数 + +- 追加の環境変数は不要です。 + ## 使用方法 ### 基本的な使用例 diff --git a/plugins/mcp-aws-docs/docs/aws-docs-guide.md b/plugins/mcp/claude/mcp-aws-docs/docs/aws-docs-guide.md similarity index 100% rename from plugins/mcp-aws-docs/docs/aws-docs-guide.md rename to plugins/mcp/claude/mcp-aws-docs/docs/aws-docs-guide.md diff --git a/plugins/mcp-bigquery/.claude-plugin/plugin.json b/plugins/mcp/claude/mcp-bigquery/.claude-plugin/plugin.json similarity index 100% rename from plugins/mcp-bigquery/.claude-plugin/plugin.json rename to plugins/mcp/claude/mcp-bigquery/.claude-plugin/plugin.json diff --git a/plugins/mcp-bigquery/.mcp.json b/plugins/mcp/claude/mcp-bigquery/.mcp.json similarity index 100% rename from plugins/mcp-bigquery/.mcp.json rename to plugins/mcp/claude/mcp-bigquery/.mcp.json diff --git a/plugins/mcp-bigquery/README.md b/plugins/mcp/claude/mcp-bigquery/README.md similarity index 93% rename from plugins/mcp-bigquery/README.md rename to plugins/mcp/claude/mcp-bigquery/README.md index 28a629f..255fbd5 100644 --- a/plugins/mcp-bigquery/README.md +++ b/plugins/mcp/claude/mcp-bigquery/README.md @@ -40,10 +40,19 @@ BIGQUERY_KEY_FILE=/path/to/service-account-key.json ## インストール +### Claude Code + ```bash /plugin install mcp-bigquery@ai-plugins ``` +### 必要な環境変数 + +- `BIGQUERY_DATASET` +- `BIGQUERY_KEY_FILE` +- `BIGQUERY_LOCATION` +- `BIGQUERY_PROJECT` + ## 使用方法 ### 基本的な使用例 diff --git a/plugins/mcp-bigquery/docs/bigquery-guide.md b/plugins/mcp/claude/mcp-bigquery/docs/bigquery-guide.md similarity index 100% rename from plugins/mcp-bigquery/docs/bigquery-guide.md rename to plugins/mcp/claude/mcp-bigquery/docs/bigquery-guide.md diff --git a/plugins/mcp-chrome-devtools/.claude-plugin/plugin.json b/plugins/mcp/claude/mcp-chrome-devtools/.claude-plugin/plugin.json similarity index 100% rename from plugins/mcp-chrome-devtools/.claude-plugin/plugin.json rename to plugins/mcp/claude/mcp-chrome-devtools/.claude-plugin/plugin.json diff --git a/plugins/mcp-chrome-devtools/.mcp.json b/plugins/mcp/claude/mcp-chrome-devtools/.mcp.json similarity index 100% rename from plugins/mcp-chrome-devtools/.mcp.json rename to plugins/mcp/claude/mcp-chrome-devtools/.mcp.json diff --git a/plugins/mcp-chrome-devtools/README.md b/plugins/mcp/claude/mcp-chrome-devtools/README.md similarity index 94% rename from plugins/mcp-chrome-devtools/README.md rename to plugins/mcp/claude/mcp-chrome-devtools/README.md index 58c5b79..06224d6 100644 --- a/plugins/mcp-chrome-devtools/README.md +++ b/plugins/mcp/claude/mcp-chrome-devtools/README.md @@ -17,10 +17,16 @@ Chrome DevTools MCPサーバーをClaude Codeで利用するためのプラグ ## インストール +### Claude Code + ```bash /plugin install mcp-chrome-devtools@ai-plugins ``` +### 必要な環境変数 + +- 追加の環境変数は不要です。 + ## 使用方法 ### 基本的な使用例 diff --git a/plugins/mcp-chrome-devtools/docs/chrome-devtools-guide.md b/plugins/mcp/claude/mcp-chrome-devtools/docs/chrome-devtools-guide.md similarity index 100% rename from plugins/mcp-chrome-devtools/docs/chrome-devtools-guide.md rename to plugins/mcp/claude/mcp-chrome-devtools/docs/chrome-devtools-guide.md diff --git a/plugins/mcp-dbhub/.claude-plugin/plugin.json b/plugins/mcp/claude/mcp-dbhub/.claude-plugin/plugin.json similarity index 100% rename from plugins/mcp-dbhub/.claude-plugin/plugin.json rename to plugins/mcp/claude/mcp-dbhub/.claude-plugin/plugin.json diff --git a/plugins/mcp-dbhub/.env.example b/plugins/mcp/claude/mcp-dbhub/.env.example similarity index 100% rename from plugins/mcp-dbhub/.env.example rename to plugins/mcp/claude/mcp-dbhub/.env.example diff --git a/plugins/mcp-dbhub/.mcp.json b/plugins/mcp/claude/mcp-dbhub/.mcp.json similarity index 100% rename from plugins/mcp-dbhub/.mcp.json rename to plugins/mcp/claude/mcp-dbhub/.mcp.json diff --git a/plugins/mcp-dbhub/README.md b/plugins/mcp/claude/mcp-dbhub/README.md similarity index 98% rename from plugins/mcp-dbhub/README.md rename to plugins/mcp/claude/mcp-dbhub/README.md index ca14a33..2e20099 100644 --- a/plugins/mcp-dbhub/README.md +++ b/plugins/mcp/claude/mcp-dbhub/README.md @@ -16,10 +16,16 @@ ## インストール +### Claude Code + ```bash /plugin install mcp-dbhub@ai-plugins ``` +### 必要な環境変数 + +- 追加の環境変数は不要です。 + ## 設定 2つの設定方法があり、`dbhub.toml`が優先されます。 diff --git a/plugins/mcp-dbhub/dbhub.toml.example b/plugins/mcp/claude/mcp-dbhub/dbhub.toml.example similarity index 100% rename from plugins/mcp-dbhub/dbhub.toml.example rename to plugins/mcp/claude/mcp-dbhub/dbhub.toml.example diff --git a/plugins/mcp-dbhub/docs/dbhub-guide.md b/plugins/mcp/claude/mcp-dbhub/docs/dbhub-guide.md similarity index 100% rename from plugins/mcp-dbhub/docs/dbhub-guide.md rename to plugins/mcp/claude/mcp-dbhub/docs/dbhub-guide.md diff --git a/plugins/mcp-devin/.claude-plugin/plugin.json b/plugins/mcp/claude/mcp-devin/.claude-plugin/plugin.json similarity index 100% rename from plugins/mcp-devin/.claude-plugin/plugin.json rename to plugins/mcp/claude/mcp-devin/.claude-plugin/plugin.json diff --git a/plugins/mcp-devin/.mcp.json b/plugins/mcp/claude/mcp-devin/.mcp.json similarity index 100% rename from plugins/mcp-devin/.mcp.json rename to plugins/mcp/claude/mcp-devin/.mcp.json diff --git a/plugins/mcp-devin/README.md b/plugins/mcp/claude/mcp-devin/README.md similarity index 97% rename from plugins/mcp-devin/README.md rename to plugins/mcp/claude/mcp-devin/README.md index ee833d3..96d5e6a 100644 --- a/plugins/mcp-devin/README.md +++ b/plugins/mcp/claude/mcp-devin/README.md @@ -27,10 +27,16 @@ DEVIN_API_KEY=your-devin-api-key ## インストール +### Claude Code + ```bash /plugin install mcp-devin@ai-plugins ``` +### 必要な環境変数 + +- `DEVIN_API_KEY` + ## 使用方法 ### 基本的な使用例 diff --git a/plugins/mcp-markitdown/.claude-plugin/plugin.json b/plugins/mcp/claude/mcp-markitdown/.claude-plugin/plugin.json similarity index 100% rename from plugins/mcp-markitdown/.claude-plugin/plugin.json rename to plugins/mcp/claude/mcp-markitdown/.claude-plugin/plugin.json diff --git a/plugins/mcp-markitdown/.mcp.json b/plugins/mcp/claude/mcp-markitdown/.mcp.json similarity index 100% rename from plugins/mcp-markitdown/.mcp.json rename to plugins/mcp/claude/mcp-markitdown/.mcp.json diff --git a/plugins/mcp-markitdown/README.md b/plugins/mcp/claude/mcp-markitdown/README.md similarity index 95% rename from plugins/mcp-markitdown/README.md rename to plugins/mcp/claude/mcp-markitdown/README.md index 0ed24c3..0b040c3 100644 --- a/plugins/mcp-markitdown/README.md +++ b/plugins/mcp/claude/mcp-markitdown/README.md @@ -14,10 +14,16 @@ ## インストール +### Claude Code + ```bash /plugin install mcp-markitdown@ai-plugins ``` +### 必要な環境変数 + +- 追加の環境変数は不要です。 + ## 使用例 ```bash diff --git a/plugins/mcp-markitdown/docs/markitdown-guide.md b/plugins/mcp/claude/mcp-markitdown/docs/markitdown-guide.md similarity index 100% rename from plugins/mcp-markitdown/docs/markitdown-guide.md rename to plugins/mcp/claude/mcp-markitdown/docs/markitdown-guide.md diff --git a/plugins/mcp-notion/.claude-plugin/plugin.json b/plugins/mcp/claude/mcp-notion/.claude-plugin/plugin.json similarity index 100% rename from plugins/mcp-notion/.claude-plugin/plugin.json rename to plugins/mcp/claude/mcp-notion/.claude-plugin/plugin.json diff --git a/plugins/mcp-notion/.mcp.json b/plugins/mcp/claude/mcp-notion/.mcp.json similarity index 100% rename from plugins/mcp-notion/.mcp.json rename to plugins/mcp/claude/mcp-notion/.mcp.json diff --git a/plugins/mcp-notion/README.md b/plugins/mcp/claude/mcp-notion/README.md similarity index 95% rename from plugins/mcp-notion/README.md rename to plugins/mcp/claude/mcp-notion/README.md index db4f2a7..8b7487f 100644 --- a/plugins/mcp-notion/README.md +++ b/plugins/mcp/claude/mcp-notion/README.md @@ -31,10 +31,16 @@ NOTION_TOKEN= ## インストール +### Claude Code + ```bash /plugin install mcp-notion@ai-plugins ``` +### 必要な環境変数 + +- 追加の環境変数は不要です。 + ## 使用方法 ### 基本的な使用例 diff --git a/plugins/mcp-playwright/.claude-plugin/plugin.json b/plugins/mcp/claude/mcp-playwright/.claude-plugin/plugin.json similarity index 100% rename from plugins/mcp-playwright/.claude-plugin/plugin.json rename to plugins/mcp/claude/mcp-playwright/.claude-plugin/plugin.json diff --git a/plugins/mcp-playwright/.mcp.json b/plugins/mcp/claude/mcp-playwright/.mcp.json similarity index 100% rename from plugins/mcp-playwright/.mcp.json rename to plugins/mcp/claude/mcp-playwright/.mcp.json diff --git a/plugins/mcp-playwright/README.md b/plugins/mcp/claude/mcp-playwright/README.md similarity index 95% rename from plugins/mcp-playwright/README.md rename to plugins/mcp/claude/mcp-playwright/README.md index 8e9ef19..6d7d0c3 100644 --- a/plugins/mcp-playwright/README.md +++ b/plugins/mcp/claude/mcp-playwright/README.md @@ -15,11 +15,15 @@ Playwright MCPを自動的にセットアップし、ブラウザ自動化機能 ## インストール -このプラグインをインストールすると、以下が自動的に設定されます: +### Claude Code -1. Playwright MCP設定(`.mcp.json`) -2. SessionStart hookによる自動セットアップ -3. Chromiumブラウザのインストール +```bash +/plugin install mcp-playwright@ai-plugins +``` + +### 必要な環境変数 + +- `HOME` ## 使用方法 diff --git a/plugins/mcp/claude/mcp-playwright/hooks/hooks.json b/plugins/mcp/claude/mcp-playwright/hooks/hooks.json new file mode 100644 index 0000000..5e7d5ca --- /dev/null +++ b/plugins/mcp/claude/mcp-playwright/hooks/hooks.json @@ -0,0 +1,22 @@ +{ + "description": "Playwright MCP auto-setup hook", + "hooks": { + "SessionStart": [ + { + "description": "Ensure Playwright browser is installed", + "matcher": "startup|resume", + "priority": 100, + "enabled": true, + "hooks": [ + { + "type": "command", + "command": "node ${PLUGIN_ROOT:-${CODEX_PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT}}}/scripts/ensure-playwright.js", + "description": "Auto-install Playwright chromium on first use", + "continueOnError": false, + "suppressOutput": false + } + ] + } + ] + } +} diff --git a/plugins/mcp-playwright/scripts/ensure-playwright.js b/plugins/mcp/claude/mcp-playwright/scripts/ensure-playwright.js similarity index 68% rename from plugins/mcp-playwright/scripts/ensure-playwright.js rename to plugins/mcp/claude/mcp-playwright/scripts/ensure-playwright.js index 1a4c98d..b8e354a 100755 --- a/plugins/mcp-playwright/scripts/ensure-playwright.js +++ b/plugins/mcp/claude/mcp-playwright/scripts/ensure-playwright.js @@ -1,18 +1,27 @@ #!/usr/bin/env node -const { execSync } = require('child_process'); +const { execFileSync } = require('child_process'); const fs = require('fs'); const path = require('path'); const os = require('os'); -const PLUGIN_ROOT = process.env.CLAUDE_PLUGIN_ROOT || __dirname; +const PLUGIN_ROOT = process.env.PLUGIN_ROOT || process.env.CODEX_PLUGIN_ROOT || process.env.CLAUDE_PLUGIN_ROOT || __dirname; const FLAG_FILE = path.join(os.homedir(), '.claude-playwright-installed'); -const BROWSER_PATH = path.join(os.homedir(), '.cache', 'ms-playwright'); +const BROWSER_PATH = process.env.PLAYWRIGHT_BROWSERS_PATH || path.join(os.homedir(), '.cache', 'ms-playwright'); const TIMEOUT_MS = 5 * 60 * 1000; // 5分タイムアウト +const PLAYWRIGHT_VERSION_PATTERN = /^[~^]?\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/; +const EXEC_FILE_OPTIONS = process.platform === 'win32' ? { shell: true } : {}; -// 既にインストール済みかチェック(冪等性) -if (fs.existsSync(FLAG_FILE)) { - process.exit(0); +function readInstalledFlag() { + if (!fs.existsSync(FLAG_FILE)) { + return null; + } + try { + const flagData = JSON.parse(fs.readFileSync(FLAG_FILE, 'utf-8')); + return flagData && typeof flagData === 'object' ? flagData : null; + } catch { + return null; + } } console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); @@ -26,10 +35,11 @@ console.log(''); try { // @playwright/mcpが依存するPlaywrightのバージョンを取得 console.log('🔍 @playwright/mcpが使用するPlaywrightバージョンを確認中...'); - const depsOutput = execSync('npm view @playwright/mcp@latest dependencies --json', { + const depsOutput = execFileSync('npm', ['view', '@playwright/mcp@latest', 'dependencies', '--json'], { encoding: 'utf-8', cwd: PLUGIN_ROOT, - timeout: 30000 + timeout: 30000, + ...EXEC_FILE_OPTIONS }); const deps = JSON.parse(depsOutput); @@ -38,16 +48,30 @@ try { if (!playwrightVersion) { throw new Error('@playwright/mcpの依存関係からPlaywrightバージョンを取得できませんでした'); } + if (!PLAYWRIGHT_VERSION_PATTERN.test(playwrightVersion)) { + throw new Error(`不正なPlaywrightバージョンです: ${playwrightVersion}`); + } console.log(`✓ Playwright ${playwrightVersion} を使用します`); console.log(''); + const installedFlag = readInstalledFlag(); + if ( + installedFlag && + installedFlag.playwrightVersion === playwrightVersion && + installedFlag.browserPath === BROWSER_PATH + ) { + console.log('既に同じ Playwright バージョンでセットアップ済みです。'); + process.exit(0); + } + // @playwright/mcpと互換性のあるバージョンのChromiumをインストール console.log(`📦 Playwright ${playwrightVersion} でChromiumをインストール中...`); - execSync(`npx -y playwright@${playwrightVersion} install chromium`, { + execFileSync('npx', ['-y', `playwright@${playwrightVersion}`, 'install', 'chromium'], { stdio: 'inherit', cwd: PLUGIN_ROOT, timeout: TIMEOUT_MS, + ...EXEC_FILE_OPTIONS, env: { ...process.env, PLAYWRIGHT_BROWSERS_PATH: BROWSER_PATH, diff --git a/plugins/mcp-redash/.claude-plugin/plugin.json b/plugins/mcp/claude/mcp-redash/.claude-plugin/plugin.json similarity index 100% rename from plugins/mcp-redash/.claude-plugin/plugin.json rename to plugins/mcp/claude/mcp-redash/.claude-plugin/plugin.json diff --git a/plugins/mcp-redash/.env.example b/plugins/mcp/claude/mcp-redash/.env.example similarity index 100% rename from plugins/mcp-redash/.env.example rename to plugins/mcp/claude/mcp-redash/.env.example diff --git a/plugins/mcp-redash/.mcp.json b/plugins/mcp/claude/mcp-redash/.mcp.json similarity index 100% rename from plugins/mcp-redash/.mcp.json rename to plugins/mcp/claude/mcp-redash/.mcp.json diff --git a/plugins/mcp-redash/README.md b/plugins/mcp/claude/mcp-redash/README.md similarity index 92% rename from plugins/mcp-redash/README.md rename to plugins/mcp/claude/mcp-redash/README.md index 727b1c8..d923b45 100644 --- a/plugins/mcp-redash/README.md +++ b/plugins/mcp/claude/mcp-redash/README.md @@ -17,10 +17,17 @@ Redash MCP サーバーをマルチ環境対応で利用するための Claude C ## インストール +### Claude Code + ```bash /plugin install mcp-redash@ai-plugins ``` +### 必要な環境変数 + +- `REDASH_API_KEY` +- `REDASH_URL` + ## 環境変数 プロジェクトの `.env` に設定してください。 @@ -74,15 +81,16 @@ REDASH_API_KEY=your-api-key | dev | redash-dev | REDASH_DEV_URL, REDASH_DEV_API_KEY | | stg | redash-stg | REDASH_STG_URL, REDASH_STG_API_KEY | | prod2 | redash-prod2 | REDASH_PROD2_URL, REDASH_PROD2_API_KEY | -| sandbox | redash-sandbox | REDASH_SANDBOX_URL, REDASH_SANDBOX_API_KEY | +| sandbox-1 | redash-sandbox-1 | REDASH_SANDBOX_1_URL, REDASH_SANDBOX_1_API_KEY | -- suffix は英数字で自由に指定可能 +- suffix は英小文字・数字・ハイフンで自由に指定可能 +- 環境変数名では suffix 内のハイフンをアンダースコアに変換 - `default` は予約語のため使用不可(plugin 同梱の `redash` が該当) ## ファイル構成 ``` -plugins/mcp-redash/ +plugins/mcp/shared/mcp-redash/ ├── .claude-plugin/ │ └── plugin.json # プラグインメタデータ ├── .mcp.json # MCP 設定(redash のみ同梱) diff --git a/plugins/mcp-redash/scripts/redash-mcp-config.js b/plugins/mcp/claude/mcp-redash/scripts/redash-mcp-config.js old mode 100644 new mode 100755 similarity index 50% rename from plugins/mcp-redash/scripts/redash-mcp-config.js rename to plugins/mcp/claude/mcp-redash/scripts/redash-mcp-config.js index 83ce0f6..a62d173 --- a/plugins/mcp-redash/scripts/redash-mcp-config.js +++ b/plugins/mcp/claude/mcp-redash/scripts/redash-mcp-config.js @@ -15,13 +15,15 @@ const fs = require('fs'); const path = require('path'); -const MCP_JSON_PATH = path.resolve(process.cwd(), '.mcp.json'); const MCP_PACKAGE = '@suthio/redash-mcp'; +const PROJECT_ROOT = resolveProjectRoot(); +const MCP_JSON_PATH = path.join(PROJECT_ROOT, '.mcp.json'); +const KIRO_AGENT_PATH = path.join(PROJECT_ROOT, '.kiro', 'agents', 'default.json'); // --- helpers --- function envName(suffix) { - const upper = suffix.toUpperCase(); + const upper = suffix.toUpperCase().replaceAll('-', '_'); return { url: `REDASH_${upper}_URL`, key: `REDASH_${upper}_API_KEY`, @@ -32,6 +34,14 @@ function mcpName(suffix) { return `redash-${suffix}`; } +function validateSuffix(suffix) { + if (!/^[a-z0-9][a-z0-9-]*$/.test(suffix)) { + console.error('エラー: suffix は英小文字・数字・ハイフンのみで指定してください。'); + console.error('例: dev, stg, prod2, sandbox-1'); + process.exit(1); + } +} + function mcpEntry(suffix) { const env = envName(suffix); return { @@ -44,6 +54,95 @@ function mcpEntry(suffix) { }; } +function isKiroRuntime() { + const normalizedDir = path.normalize(__dirname); + const runtimePath = path.join('mcp', 'kiro') + path.sep; + return Boolean(process.env.KIRO_WORKSPACE_ROOT) || normalizedDir.includes(runtimePath); +} + +function isCodexRuntime() { + const normalizedDir = path.normalize(__dirname); + const runtimePath = path.join('mcp', 'codex') + path.sep; + return Boolean(process.env.CODEX_WORKSPACE_ROOT) || normalizedDir.includes(runtimePath); +} + +function resolveProjectRoot() { + const envNames = [ + 'PROJECT_ROOT', + 'WORKSPACE_ROOT', + 'GIT_WORK_TREE', + 'CLAUDE_PROJECT_DIR', + 'CODEX_WORKSPACE_ROOT', + 'KIRO_WORKSPACE_ROOT', + ]; + + for (const name of envNames) { + const value = process.env[name]; + if (value && fs.existsSync(value) && fs.statSync(value).isDirectory()) { + return path.resolve(value); + } + } + + let dir = process.cwd(); + let nearestMcpDir = null; + while (true) { + if (!nearestMcpDir && fs.existsSync(path.join(dir, '.mcp.json'))) { + nearestMcpDir = dir; + } + if (fs.existsSync(path.join(dir, '.git'))) { + return dir; + } + + const parent = path.dirname(dir); + if (parent === dir) { + return nearestMcpDir || process.cwd(); + } + dir = parent; + } +} + +function createMcpJson() { + return isCodexRuntime() ? {} : { mcpServers: {} }; +} + +function normalizeWritableMcpJson(data) { + if (Object.prototype.hasOwnProperty.call(data, 'mcpServers')) { + return data; + } + if (Object.prototype.hasOwnProperty.call(data, 'mcp_servers')) { + const normalized = { ...data, mcpServers: data.mcp_servers }; + delete normalized.mcp_servers; + return normalized; + } + return data; +} + +function serverMap(data, create) { + if (!data || typeof data !== 'object' || Array.isArray(data)) { + return null; + } + + if (Object.prototype.hasOwnProperty.call(data, 'mcpServers')) { + if (!data.mcpServers && create) { + data.mcpServers = {}; + } + return data.mcpServers && typeof data.mcpServers === 'object' && !Array.isArray(data.mcpServers) + ? data.mcpServers + : null; + } + + if (Object.prototype.hasOwnProperty.call(data, 'mcp_servers')) { + if (!data.mcp_servers && create) { + data.mcp_servers = {}; + } + return data.mcp_servers && typeof data.mcp_servers === 'object' && !Array.isArray(data.mcp_servers) + ? data.mcp_servers + : null; + } + + return data; +} + function readMcpJson() { if (!fs.existsSync(MCP_JSON_PATH)) { return null; @@ -60,6 +159,46 @@ function writeMcpJson(data) { fs.writeFileSync(MCP_JSON_PATH, JSON.stringify(data, null, 2) + '\n', 'utf-8'); } +function syncKiroAgent(data) { + if (!isKiroRuntime()) { + return; + } + + const servers = serverMap(data, false) || {}; + let agent = {}; + if (fs.existsSync(KIRO_AGENT_PATH)) { + try { + agent = JSON.parse(fs.readFileSync(KIRO_AGENT_PATH, 'utf-8')); + } catch { + console.error('エラー: .kiro/agents/default.json の JSON が壊れています。手動で修正してください。'); + process.exit(1); + } + } else { + agent = { + name: 'default', + resources: ['skill://.kiro/skills/**/SKILL.md'], + }; + } + + if (!agent.mcpServers || typeof agent.mcpServers !== 'object' || Array.isArray(agent.mcpServers)) { + agent.mcpServers = {}; + } + + for (const name of Object.keys(agent.mcpServers)) { + if (isRedashServer(name)) { + delete agent.mcpServers[name]; + } + } + for (const [name, server] of Object.entries(servers)) { + if (isRedashServer(name)) { + agent.mcpServers[name] = server; + } + } + + fs.mkdirSync(path.dirname(KIRO_AGENT_PATH), { recursive: true }); + fs.writeFileSync(KIRO_AGENT_PATH, JSON.stringify(agent, null, 2) + '\n', 'utf-8'); +} + function isRedashServer(name) { return name === 'redash' || name.startsWith('redash-'); } @@ -72,6 +211,7 @@ function cmdAdd(suffix) { console.error('suffix には dev, stg, prod2, sandbox などを指定してください。'); process.exit(1); } + validateSuffix(suffix); const name = mcpName(suffix); let data = readMcpJson(); @@ -82,23 +222,31 @@ function cmdAdd(suffix) { } if (data === null) { - data = { mcpServers: {} }; + data = createMcpJson(); + } else { + data = normalizeWritableMcpJson(data); } - if (!data.mcpServers) { - data.mcpServers = {}; + const servers = serverMap(data, true); + if (!servers) { + console.error('エラー: .mcp.json の MCP server 定義形式を解釈できません。'); + process.exit(1); } - if (data.mcpServers[name]) { + if (servers[name]) { console.log(`${name} は既に登録されています。変更はありません。`); process.exit(0); } - data.mcpServers[name] = mcpEntry(suffix); + servers[name] = mcpEntry(suffix); writeMcpJson(data); + syncKiroAgent(data); const env = envName(suffix); console.log(`${name} を .mcp.json に追加しました。`); + if (isKiroRuntime()) { + console.log(`${name} を .kiro/agents/default.json に同期しました。`); + } console.log(''); console.log('必要な環境変数:'); console.log(` ${env.url}`); @@ -112,6 +260,7 @@ function cmdRemove(suffix) { console.error('エラー: デフォルトの redash は plugin 同梱のため削除できません。'); process.exit(1); } + validateSuffix(suffix); const name = mcpName(suffix); let data = readMcpJson(); @@ -121,15 +270,24 @@ function cmdRemove(suffix) { process.exit(1); } - if (data === null || !data.mcpServers || !data.mcpServers[name]) { + if (data !== null) { + data = normalizeWritableMcpJson(data); + } + + const servers = serverMap(data, false); + if (data === null || !servers || !servers[name]) { console.log(`${name} は登録されていません。変更はありません。`); process.exit(0); } - delete data.mcpServers[name]; + delete servers[name]; writeMcpJson(data); + syncKiroAgent(data); console.log(`${name} を .mcp.json から削除しました。`); + if (isKiroRuntime()) { + console.log(`${name} を .kiro/agents/default.json から削除しました。`); + } } function cmdList() { @@ -142,8 +300,9 @@ function cmdList() { console.error('警告: .mcp.json の JSON が壊れています。'); return; } - if (data && data.mcpServers) { - const names = Object.keys(data.mcpServers) + const servers = serverMap(data, false); + if (servers) { + const names = Object.keys(servers) .filter((n) => isRedashServer(n) && n !== 'redash') .sort(); for (const name of names) { @@ -168,8 +327,9 @@ function cmdStatus() { console.error('警告: .mcp.json の JSON が壊れています。'); return; } - if (data && data.mcpServers) { - const names = Object.keys(data.mcpServers) + const servers = serverMap(data, false); + if (servers) { + const names = Object.keys(servers) .filter((n) => isRedashServer(n) && n !== 'redash') .sort(); for (const name of names) { diff --git a/plugins/mcp/claude/mcp-redash/skills/redash-add/SKILL.md b/plugins/mcp/claude/mcp-redash/skills/redash-add/SKILL.md new file mode 100644 index 0000000..ce1fc02 --- /dev/null +++ b/plugins/mcp/claude/mcp-redash/skills/redash-add/SKILL.md @@ -0,0 +1,27 @@ +--- +name: redash-add +description: 任意 suffix の Redash MCP を project .mcp.json に追加する +disable-model-invocation: true +user-invocable: true +arguments: + - name: suffix + description: "環境識別子(dev, stg, prod2, sandbox など)" +allowed-tools: + - Bash +--- + +# /redash-add + +任意 suffix の Redash MCP をプロジェクトの `.mcp.json` に追加します。 + +## 実行方法 + +以下のコマンドを実行してください。`$ARGUMENTS` にはユーザーが指定した suffix が入ります。 + +```bash +node "${PLUGIN_ROOT:-${CODEX_PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT}}}/scripts/redash-mcp-config.js" add "$ARGUMENTS" +``` + +## 実行後 + +コマンドの出力をそのままユーザーに表示してください。 diff --git a/plugins/mcp-redash/skills/redash-guide/SKILL.md b/plugins/mcp/claude/mcp-redash/skills/redash-guide/SKILL.md similarity index 89% rename from plugins/mcp-redash/skills/redash-guide/SKILL.md rename to plugins/mcp/claude/mcp-redash/skills/redash-guide/SKILL.md index 5a134fe..0cbdbdd 100644 --- a/plugins/mcp-redash/skills/redash-guide/SKILL.md +++ b/plugins/mcp/claude/mcp-redash/skills/redash-guide/SKILL.md @@ -62,9 +62,10 @@ REDASH_STG_API_KEY=your-stg-api-key | dev | redash-dev | REDASH_DEV_URL, REDASH_DEV_API_KEY | | stg | redash-stg | REDASH_STG_URL, REDASH_STG_API_KEY | | prod2 | redash-prod2 | REDASH_PROD2_URL, REDASH_PROD2_API_KEY | -| sandbox | redash-sandbox | REDASH_SANDBOX_URL, REDASH_SANDBOX_API_KEY | +| sandbox-1 | redash-sandbox-1 | REDASH_SANDBOX_1_URL, REDASH_SANDBOX_1_API_KEY | -- suffix は英数字で自由に指定可能 +- suffix は英小文字・数字・ハイフンで自由に指定可能 +- 環境変数名では suffix 内のハイフンをアンダースコアに変換 - `default` は予約語のため使用不可(plugin 同梱の `redash` が該当) ## 環境の削除 diff --git a/plugins/mcp/claude/mcp-redash/skills/redash-list/SKILL.md b/plugins/mcp/claude/mcp-redash/skills/redash-list/SKILL.md new file mode 100644 index 0000000..798754e --- /dev/null +++ b/plugins/mcp/claude/mcp-redash/skills/redash-list/SKILL.md @@ -0,0 +1,24 @@ +--- +name: redash-list +description: 現在有効な Redash MCP を一覧表示する +disable-model-invocation: true +user-invocable: true +allowed-tools: + - Bash +--- + +# /redash-list + +現在有効な Redash MCP の一覧を表示します。 + +## 実行方法 + +以下のコマンドを実行してください。 + +```bash +node "${PLUGIN_ROOT:-${CODEX_PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT}}}/scripts/redash-mcp-config.js" list +``` + +## 実行後 + +コマンドの出力をそのままユーザーに表示してください。 diff --git a/plugins/mcp/claude/mcp-redash/skills/redash-remove/SKILL.md b/plugins/mcp/claude/mcp-redash/skills/redash-remove/SKILL.md new file mode 100644 index 0000000..00fe202 --- /dev/null +++ b/plugins/mcp/claude/mcp-redash/skills/redash-remove/SKILL.md @@ -0,0 +1,27 @@ +--- +name: redash-remove +description: 指定 suffix の Redash MCP を project .mcp.json から削除する +disable-model-invocation: true +user-invocable: true +arguments: + - name: suffix + description: "削除する環境識別子(dev, stg, prod2, sandbox など)" +allowed-tools: + - Bash +--- + +# /redash-remove + +指定 suffix の Redash MCP をプロジェクトの `.mcp.json` から削除します。 + +## 実行方法 + +以下のコマンドを実行してください。`$ARGUMENTS` にはユーザーが指定した suffix が入ります。 + +```bash +node "${PLUGIN_ROOT:-${CODEX_PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT}}}/scripts/redash-mcp-config.js" remove "$ARGUMENTS" +``` + +## 実行後 + +コマンドの出力をそのままユーザーに表示してください。 diff --git a/plugins/mcp/claude/mcp-redash/skills/redash-status/SKILL.md b/plugins/mcp/claude/mcp-redash/skills/redash-status/SKILL.md new file mode 100644 index 0000000..45a4034 --- /dev/null +++ b/plugins/mcp/claude/mcp-redash/skills/redash-status/SKILL.md @@ -0,0 +1,24 @@ +--- +name: redash-status +description: Redash MCP の設定状況と環境変数の確認 +disable-model-invocation: true +user-invocable: true +allowed-tools: + - Bash +--- + +# /redash-status + +Redash MCP の設定状況を詳細表示します。各 MCP が必要とする環境変数と、未設定の警告を確認できます。 + +## 実行方法 + +以下のコマンドを実行してください。 + +```bash +node "${PLUGIN_ROOT:-${CODEX_PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT}}}/scripts/redash-mcp-config.js" status +``` + +## 実行後 + +コマンドの出力をそのままユーザーに表示してください。 diff --git a/plugins/mcp-serena/.claude-plugin/plugin.json b/plugins/mcp/claude/mcp-serena/.claude-plugin/plugin.json similarity index 100% rename from plugins/mcp-serena/.claude-plugin/plugin.json rename to plugins/mcp/claude/mcp-serena/.claude-plugin/plugin.json diff --git a/plugins/mcp-serena/.mcp.json b/plugins/mcp/claude/mcp-serena/.mcp.json similarity index 77% rename from plugins/mcp-serena/.mcp.json rename to plugins/mcp/claude/mcp-serena/.mcp.json index 6ff356e..17de95a 100644 --- a/plugins/mcp-serena/.mcp.json +++ b/plugins/mcp/claude/mcp-serena/.mcp.json @@ -14,9 +14,8 @@ "False" ], "env": { - "SERENA_HOME": "${SERENA_HOME:-.serena}" - }, - "envFile": "${workspaceFolder}/.env" + "SERENA_HOME": ".serena" + } } } } diff --git a/plugins/mcp-serena/README.md b/plugins/mcp/claude/mcp-serena/README.md similarity index 95% rename from plugins/mcp-serena/README.md rename to plugins/mcp/claude/mcp-serena/README.md index 8c8ddb1..9042f65 100644 --- a/plugins/mcp-serena/README.md +++ b/plugins/mcp/claude/mcp-serena/README.md @@ -4,10 +4,16 @@ Serena MCPサーバーを提供するプラグインです。セマンティッ ## インストール +### Claude Code + ```bash /plugin install mcp-serena@ai-plugins ``` +### 必要な環境変数 + +- `SERENA_HOME` + ## 提供機能 - **シンボル検索**: クラス、関数、メソッドの定義を検索 diff --git a/plugins/mcp-serena/docs/serena-guide.md b/plugins/mcp/claude/mcp-serena/docs/serena-guide.md similarity index 100% rename from plugins/mcp-serena/docs/serena-guide.md rename to plugins/mcp/claude/mcp-serena/docs/serena-guide.md diff --git a/plugins/mcp-serena/hooks/hooks.json b/plugins/mcp/claude/mcp-serena/hooks/hooks.json similarity index 100% rename from plugins/mcp-serena/hooks/hooks.json rename to plugins/mcp/claude/mcp-serena/hooks/hooks.json diff --git a/plugins/mcp/codex/mcp-aws-docs/.codex-plugin/plugin.json b/plugins/mcp/codex/mcp-aws-docs/.codex-plugin/plugin.json new file mode 100644 index 0000000..05e0e4e --- /dev/null +++ b/plugins/mcp/codex/mcp-aws-docs/.codex-plugin/plugin.json @@ -0,0 +1,17 @@ +{ + "name": "mcp-aws-docs", + "version": "1.0.0", + "description": "AWS Documentation MCP server for accessing official AWS documentation", + "author": { + "name": "takemi-ohama", + "url": "https://github.com/takemi-ohama" + }, + "keywords": [ + "mcp", + "aws", + "documentation", + "docs", + "cloud" + ], + "mcpServers": "./.mcp.json" +} diff --git a/plugins/mcp/codex/mcp-aws-docs/.mcp.json b/plugins/mcp/codex/mcp-aws-docs/.mcp.json new file mode 100644 index 0000000..4cceb0c --- /dev/null +++ b/plugins/mcp/codex/mcp-aws-docs/.mcp.json @@ -0,0 +1,11 @@ +{ + "awslabs.aws-docs": { + "type": "stdio", + "command": "uvx", + "args": [ + "awslabs.aws-documentation-mcp-server@latest" + ], + "env": {}, + "envFile": "${workspaceFolder}/.env" + } +} diff --git a/plugins/mcp/codex/mcp-aws-docs/README.md b/plugins/mcp/codex/mcp-aws-docs/README.md new file mode 100644 index 0000000..06a3928 --- /dev/null +++ b/plugins/mcp/codex/mcp-aws-docs/README.md @@ -0,0 +1,63 @@ +# AWS Documentation MCP + +AWS公式ドキュメントにアクセスするためのMCPサーバープラグインです。 + +## 概要 + +このプラグインは、AWS公式ドキュメントを検索・取得する機能を提供します。 + +## 機能 + +- AWS公式ドキュメントの検索 +- ドキュメントページの取得 +- 関連ドキュメントの推薦 +- サービス別ドキュメントへのアクセス + +## インストール + +### Codex + +```bash +codex plugin add mcp-aws-docs@ai-plugins +``` + +### 必要な環境変数 + +- 追加の環境変数は不要です。 + +## 使用方法 + +### 基本的な使用例 + +```bash +# AWS Lambdaのドキュメントを検索 +mcp__plugin_aws-docs-mcp__awslabs.aws-docs__search_documentation "AWS Lambda best practices" + +# 特定のドキュメントページを読み込み +mcp__plugin_aws-docs-mcp__awslabs.aws-docs__read_documentation "https://docs.aws.amazon.com/lambda/..." +``` + +## 推奨される使用シーン + +- AWSサービスの調査 +- ベストプラクティスの確認 +- API仕様の参照 +- トラブルシューティング + +## ndf:researcherエージェントとの連携 + +AWS Docs MCPは、NDFプラグインの`ndf:researcher`エージェントと連携して使用することを推奨します。 + +```bash +# researcherエージェントにAWSドキュメント調査を依頼 +Task( + subagent_type="ndf:researcher", + prompt="Research AWS Lambda best practices for performance", + description="Research Lambda practices" +) +``` + +## 参考リンク + +- [aws-documentation-mcp-server](https://pypi.org/project/awslabs.aws-documentation-mcp-server/) +- [AWS Documentation](https://docs.aws.amazon.com/) diff --git a/plugins/mcp/codex/mcp-aws-docs/docs/aws-docs-guide.md b/plugins/mcp/codex/mcp-aws-docs/docs/aws-docs-guide.md new file mode 100644 index 0000000..57a2328 --- /dev/null +++ b/plugins/mcp/codex/mcp-aws-docs/docs/aws-docs-guide.md @@ -0,0 +1,29 @@ +# AWS Docs MCP ガイド + +AWS Documentation MCPサーバーでAWS公式ドキュメントを検索・参照します。 + +## 主要機能 + +- AWSサービスのドキュメント検索 +- ベストプラクティスの参照 +- API仕様の確認 +- トラブルシューティング情報の取得 + +## 用途 + +- **researcherエージェント**: AWS公式ドキュメントの調査、ベストプラクティスの確認 + +## 使用例 + +``` +# Lambda関数のベストプラクティスを検索 +mcp__plugin_mcp-aws-docs_awslabs.aws-docs__search query="Lambda best practices" + +# S3バケットポリシーのドキュメントを参照 +mcp__plugin_mcp-aws-docs_awslabs.aws-docs__search query="S3 bucket policy" +``` + +## 注意事項 + +- 認証情報は不要(公開ドキュメントへのアクセス) +- 静的な公式ドキュメントの参照に特化。動的コンテンツにはChrome DevTools MCPを使用 diff --git a/plugins/mcp/codex/mcp-bigquery/.codex-plugin/plugin.json b/plugins/mcp/codex/mcp-bigquery/.codex-plugin/plugin.json new file mode 100644 index 0000000..e867e7c --- /dev/null +++ b/plugins/mcp/codex/mcp-bigquery/.codex-plugin/plugin.json @@ -0,0 +1,18 @@ +{ + "name": "mcp-bigquery", + "version": "1.0.0", + "description": "BigQuery MCP server for data analysis and SQL queries", + "author": { + "name": "takemi-ohama", + "url": "https://github.com/takemi-ohama" + }, + "keywords": [ + "mcp", + "bigquery", + "sql", + "data", + "analysis", + "gcp" + ], + "mcpServers": "./.mcp.json" +} diff --git a/plugins/mcp/codex/mcp-bigquery/.mcp.json b/plugins/mcp/codex/mcp-bigquery/.mcp.json new file mode 100644 index 0000000..cd6647d --- /dev/null +++ b/plugins/mcp/codex/mcp-bigquery/.mcp.json @@ -0,0 +1,19 @@ +{ + "bigquery": { + "type": "stdio", + "command": "uvx", + "args": [ + "mcp-server-bigquery@latest", + "--project", + "${BIGQUERY_PROJECT}", + "--location", + "${BIGQUERY_LOCATION}", + "--dataset", + "${BIGQUERY_DATASET}", + "--key-file", + "${BIGQUERY_KEY_FILE}" + ], + "env": {}, + "envFile": "${workspaceFolder}/.env" + } +} diff --git a/plugins/mcp/codex/mcp-bigquery/README.md b/plugins/mcp/codex/mcp-bigquery/README.md new file mode 100644 index 0000000..bd24273 --- /dev/null +++ b/plugins/mcp/codex/mcp-bigquery/README.md @@ -0,0 +1,91 @@ +# BigQuery MCP + +BigQuery MCPサーバーをCodexで利用するためのプラグインです。 + +## 概要 + +このプラグインは、Google Cloud BigQueryへのアクセスを提供し、SQLクエリの実行やデータ分析を可能にします。 + +## 機能 + +- BigQueryテーブルの一覧取得 +- SQLクエリの実行 +- クエリ結果の取得 +- テーブルスキーマの取得 +- データセットの操作 + +## 前提条件 + +- Google Cloud Projectの作成 +- BigQuery APIの有効化 +- サービスアカウントキーの作成 + +## 環境変数の設定 + +プロジェクトルートに`.env`ファイルを作成し、以下の環境変数を設定してください: + +```bash +# BigQuery Project ID +BIGQUERY_PROJECT=your-project-id + +# BigQuery Location (例: asia-northeast1) +BIGQUERY_LOCATION=asia-northeast1 + +# 利用するデータセット(カンマ区切り) +BIGQUERY_DATASETS=dataset1,dataset2 + +# サービスアカウントキーファイルのパス +BIGQUERY_KEY_FILE=/path/to/service-account-key.json +``` + +## インストール + +### Codex + +```bash +codex plugin add mcp-bigquery@ai-plugins +``` + +### 必要な環境変数 + +- `BIGQUERY_DATASET` +- `BIGQUERY_KEY_FILE` +- `BIGQUERY_LOCATION` +- `BIGQUERY_PROJECT` + +## 使用方法 + +### 基本的な使用例 + +```bash +# テーブル一覧を取得 +mcp__plugin_bigquery-mcp__bigquery__list_tables + +# SQLクエリを実行 +mcp__plugin_bigquery-mcp__bigquery__execute_query "SELECT * FROM dataset.table LIMIT 10" +``` + +## 推奨される使用シーン + +- データ分析 +- レポート生成 +- SQLクエリの最適化 +- データ抽出 + +## ndf:data-analystエージェントとの連携 + +BigQuery MCPは、NDFプラグインの`ndf:data-analyst`エージェントと連携して使用することを推奨します。 + +```bash +# data-analystエージェントにBigQueryクエリを依頼 +Task( + subagent_type="ndf:data-analyst", + prompt="Analyze last month's sales data in BigQuery", + description="Analyze sales data" +) +``` + +## 参考リンク + +- [mcp-server-bigquery](https://pypi.org/project/mcp-server-bigquery/) +- [Google Cloud BigQuery](https://cloud.google.com/bigquery) diff --git a/plugins/mcp/codex/mcp-bigquery/docs/bigquery-guide.md b/plugins/mcp/codex/mcp-bigquery/docs/bigquery-guide.md new file mode 100644 index 0000000..2e7d1fd --- /dev/null +++ b/plugins/mcp/codex/mcp-bigquery/docs/bigquery-guide.md @@ -0,0 +1,31 @@ +# BigQuery MCP ガイド + +BigQuery MCPサーバーでGoogle BigQueryのデータ分析を行います。 + +## 環境変数(必須) + +| 変数 | 説明 | +|------|------| +| `BIGQUERY_PROJECT` | GCPプロジェクトID | +| `BIGQUERY_LOCATION` | データセットのロケーション(例: `asia-northeast1`) | +| `BIGQUERY_DATASET` | デフォルトデータセット名 | +| `BIGQUERY_KEY_FILE` | サービスアカウントキーファイルのパス | + +## 主要ツール + +- `execute-query` - SQLクエリを実行し結果を取得 +- `list-tables` - データセット内のテーブル一覧を取得 +- `describe-table` - テーブルのスキーマ情報を取得 + +## 使用例 + +``` +# テーブル一覧の取得 +mcp__plugin_mcp-bigquery_bigquery__list-tables + +# テーブルのスキーマ確認 +mcp__plugin_mcp-bigquery_bigquery__describe-table table_name="users" + +# SQLクエリ実行 +mcp__plugin_mcp-bigquery_bigquery__execute-query query="SELECT * FROM users LIMIT 10" +``` diff --git a/plugins/mcp/codex/mcp-chrome-devtools/.codex-plugin/plugin.json b/plugins/mcp/codex/mcp-chrome-devtools/.codex-plugin/plugin.json new file mode 100644 index 0000000..7b6c830 --- /dev/null +++ b/plugins/mcp/codex/mcp-chrome-devtools/.codex-plugin/plugin.json @@ -0,0 +1,18 @@ +{ + "name": "mcp-chrome-devtools", + "version": "1.0.0", + "description": "Chrome DevTools MCP server for browser automation and testing", + "author": { + "name": "takemi-ohama", + "url": "https://github.com/takemi-ohama" + }, + "keywords": [ + "mcp", + "chrome", + "devtools", + "browser", + "automation", + "testing" + ], + "mcpServers": "./.mcp.json" +} diff --git a/plugins/mcp/codex/mcp-chrome-devtools/.mcp.json b/plugins/mcp/codex/mcp-chrome-devtools/.mcp.json new file mode 100644 index 0000000..e02eb65 --- /dev/null +++ b/plugins/mcp/codex/mcp-chrome-devtools/.mcp.json @@ -0,0 +1,15 @@ +{ + "chrome-devtools": { + "type": "stdio", + "command": "npx", + "args": [ + "-y", + "chrome-devtools-mcp@latest", + "--isolated", + "--chromeArg=--no-sandbox", + "--chromeArg=--disable-setuid-sandbox", + "--chromeArg=--disable-dev-shm-usage" + ], + "envFile": "${workspaceFolder}/.env" + } +} diff --git a/plugins/mcp/codex/mcp-chrome-devtools/README.md b/plugins/mcp/codex/mcp-chrome-devtools/README.md new file mode 100644 index 0000000..4ea98f7 --- /dev/null +++ b/plugins/mcp/codex/mcp-chrome-devtools/README.md @@ -0,0 +1,55 @@ +# Chrome DevTools MCP + +Chrome DevTools MCPサーバーをCodexで利用するためのプラグインです。 + +## 概要 + +このプラグインは、ブラウザ自動化とテストのためのChrome DevTools Protocol (CDP)へのアクセスを提供します。 + +## 機能 + +- ブラウザの自動操作 +- スクリーンショット撮影 +- ページナビゲーション +- JavaScriptの実行 +- ネットワークリクエストの監視 +- コンソールログの取得 + +## インストール + +### Codex + +```bash +codex plugin add mcp-chrome-devtools@ai-plugins +``` + +### 必要な環境変数 + +- 追加の環境変数は不要です。 + +## 使用方法 + +### 基本的な使用例 + +```bash +# ページにアクセスしてスクリーンショットを撮影 +mcp__plugin_chrome-devtools-mcp__chrome-devtools__navigate_page "https://example.com" +mcp__plugin_chrome-devtools-mcp__chrome-devtools__take_screenshot +``` + +## 推奨される使用シーン + +- Webアプリケーションのテスト +- スクレイピング +- パフォーマンス測定 +- UIの自動検証 + +## 注意事項 + +- Dockerコンテナ内で実行する場合、`--no-sandbox`オプションが必要です +- DinD/DooD環境では接続方法が異なる場合があります(`ndf:docker-container-access` Skillを参照) + +## 参考リンク + +- [chrome-devtools-mcp](https://www.npmjs.com/package/chrome-devtools-mcp) +- [Chrome DevTools Protocol](https://chromedevtools.github.io/devtools-protocol/) diff --git a/plugins/mcp/codex/mcp-chrome-devtools/docs/chrome-devtools-guide.md b/plugins/mcp/codex/mcp-chrome-devtools/docs/chrome-devtools-guide.md new file mode 100644 index 0000000..466323f --- /dev/null +++ b/plugins/mcp/codex/mcp-chrome-devtools/docs/chrome-devtools-guide.md @@ -0,0 +1,21 @@ +# Chrome DevTools MCP ガイド + +Chrome DevTools MCPサーバーでブラウザの操作・デバッグ・パフォーマンス測定を行います。 + +## 主要機能 + +- ページナビゲーション・DOM操作 +- ネットワークリクエストの監視 +- コンソールログの取得 +- パフォーマンスプロファイリング +- スクリーンショット取得 + +## 用途 + +- **researcherエージェント**: JavaScript動的コンテンツの取得、Webスクレイピング +- **qaエージェント**: Webアプリケーションのパフォーマンス測定、UI検証 + +## 注意事項 + +- Docker環境(DinD/DooD)では`--no-sandbox`オプションが必要(設定済み) +- localhostへのアクセスはDocker環境に依存。詳細は `docker-container-access` スキルを参照 diff --git a/plugins/mcp/codex/mcp-dbhub/.codex-plugin/plugin.json b/plugins/mcp/codex/mcp-dbhub/.codex-plugin/plugin.json new file mode 100644 index 0000000..edd420e --- /dev/null +++ b/plugins/mcp/codex/mcp-dbhub/.codex-plugin/plugin.json @@ -0,0 +1,18 @@ +{ + "name": "mcp-dbhub", + "version": "1.2.0", + "description": "DBHub MCP server for database operations and SQL queries", + "author": { + "name": "takemi-ohama", + "url": "https://github.com/takemi-ohama" + }, + "keywords": [ + "mcp", + "database", + "sql", + "dbhub", + "mysql", + "postgres" + ], + "mcpServers": "./.mcp.json" +} diff --git a/plugins/mcp/codex/mcp-dbhub/.env.example b/plugins/mcp/codex/mcp-dbhub/.env.example new file mode 100644 index 0000000..007c192 --- /dev/null +++ b/plugins/mcp/codex/mcp-dbhub/.env.example @@ -0,0 +1,12 @@ +# .env - DBHub環境変数設定 +# このファイルを .env にコピーしてプロジェクトルートに配置してください。 +# dbhub.toml が存在しない場合のフォールバックとして使用されます。 + +# データベース接続文字列 +DBHUB_DSN=mysql://user:password@db-host:3306/dbname + +# SSH踏み台設定(必要な場合のみ) +# DBHUB_SSH_HOST=bastion.example.com +# DBHUB_SSH_PORT=22 +# DBHUB_SSH_USER=ubuntu +# DBHUB_SSH_KEY=~/.ssh/id_rsa diff --git a/plugins/mcp/codex/mcp-dbhub/.mcp.json b/plugins/mcp/codex/mcp-dbhub/.mcp.json new file mode 100644 index 0000000..b070fd0 --- /dev/null +++ b/plugins/mcp/codex/mcp-dbhub/.mcp.json @@ -0,0 +1,10 @@ +{ + "dbhub": { + "type": "stdio", + "command": "npx", + "args": [ + "--yes", + "@bytebase/dbhub" + ] + } +} diff --git a/plugins/mcp/codex/mcp-dbhub/README.md b/plugins/mcp/codex/mcp-dbhub/README.md new file mode 100644 index 0000000..63f2ab8 --- /dev/null +++ b/plugins/mcp/codex/mcp-dbhub/README.md @@ -0,0 +1,153 @@ +# DBHub MCP + +データベース操作とSQLクエリを実行するためのMCPサーバープラグインです。 + +## 概要 + +このプラグインは、MySQL、PostgreSQL、SQLiteなどの各種データベースへのアクセスを提供します。 + +## 機能 + +- データベース接続管理 +- SQLクエリの実行 +- テーブルスキーマの取得 +- データベース一覧の取得 +- 複数データベースへの同時接続(TOML設定) + +## インストール + +### Codex + +```bash +codex plugin add mcp-dbhub@ai-plugins +``` + +### 必要な環境変数 + +- 追加の環境変数は不要です。 + +## 設定 + +2つの設定方法があり、`dbhub.toml`が優先されます。 + +### 方法1: dbhub.toml(複数DB対応・推奨) + +プロジェクトルートに `dbhub.toml` を配置すると自動検出されます。 + +```toml +# 単一データベース +[[sources]] +id = "mydb" +description = "本番 MySQL" +dsn = "mysql://user:password@db-host:3306/dbname" +``` + +```toml +# 複数データベース +[[sources]] +id = "production" +description = "本番 MySQL" +dsn = "mysql://user:password@prod-db:3306/myapp" + +[[sources]] +id = "staging" +description = "ステージング MySQL" +dsn = "mysql://user:password@staging-db:3306/myapp_stg" +``` + +複数ソース定義時、ツール名に自動でサフィックスが付与されます(例: `execute_sql_production`, `execute_sql_staging`)。 + +#### SSH踏み台設定(TOML内) + +ソースごとにSSH踏み台を定義できます。 + +```toml +[[sources]] +id = "production" +description = "本番 MySQL(踏み台経由)" +dsn = "mysql://user:password@db.internal:3306/myapp" +ssh_host = "bastion.example.com" +ssh_port = 22 +ssh_user = "ubuntu" +ssh_key = "~/.ssh/id_rsa" +# ssh_passphrase = "鍵のパスフレーズ(暗号化されている場合)" +``` + +マルチホップSSH(多段踏み台)にも対応しています。 + +```toml +[[sources]] +id = "prod_multihop" +dsn = "postgres://user:password@10.0.1.100:5432/myapp" +ssh_host = "internal-server.company.com" +ssh_user = "deploy" +ssh_key = "~/.ssh/id_ed25519" +ssh_proxy_jump = "bastion.company.com,admin@jump2.internal:2222" +``` + +| フィールド | 必須 | 説明 | +|-----------|------|------| +| `ssh_host` | Yes | 踏み台ホスト名 | +| `ssh_port` | No | ポート(デフォルト: 22) | +| `ssh_user` | Yes | SSHユーザー名 | +| `ssh_key` | ※ | 秘密鍵パス(`~`展開可) | +| `ssh_password` | ※ | パスワード認証用 | +| `ssh_passphrase` | No | 秘密鍵のパスフレーズ | +| `ssh_proxy_jump` | No | マルチホップ(カンマ区切り) | + +※ `ssh_key` または `ssh_password` のいずれかが必須 + +### 方法2: .env(単一DB・簡易設定) + +`dbhub.toml`が存在しない場合、`.env`の環境変数にフォールバックします。 + +```bash +# データベース接続文字列 +DBHUB_DSN=mysql://user:password@db-host:3306/dbname + +# SSH踏み台(必要な場合) +DBHUB_SSH_HOST=bastion.example.com +DBHUB_SSH_PORT=22 +DBHUB_SSH_USER=ubuntu +DBHUB_SSH_KEY=~/.ssh/id_rsa +``` + +DSNのホスト名は踏み台から見た内部ホスト名を指定してください。 + +### 優先順位 + +1. `dbhub.toml`(プロジェクトルートに存在すれば自動読み込み) +2. `.env`の`DBHUB_DSN`環境変数(tomlが無い場合のフォールバック) + +## 使用例 + +```bash +# SQLクエリを実行 +mcp__plugin_mcp-dbhub_dbhub__execute_sql "SELECT * FROM users LIMIT 10" + +# テーブル一覧を取得 +mcp__plugin_mcp-dbhub_dbhub__search_objects --object_type table +``` + +## ndf:data-analystエージェントとの連携 + +DBHub MCPは、NDFプラグインの`ndf:data-analyst`エージェントと連携して使用することを推奨します。 + +```bash +Task( + subagent_type="ndf:data-analyst", + prompt="Analyze user activity in the local database", + description="Analyze user activity" +) +``` + +## 注意事項 + +- `dbhub.toml`と`.env`は`.gitignore`に追加し、認証情報をコミットしないでください +- SSH踏み台経由の場合、DSNのホスト名は踏み台から見た内部ホスト名を指定します + +## 参考リンク + +- [takemi-ohama/dbhub](https://github.com/takemi-ohama/dbhub) - 現在使用中のフォーク版(SSH keep-alive対応) +- [@bytebase/dbhub](https://www.npmjs.com/package/@bytebase/dbhub) - 本家パッケージ +- [Bytebase](https://www.bytebase.com/) diff --git a/plugins/mcp/codex/mcp-dbhub/dbhub.toml.example b/plugins/mcp/codex/mcp-dbhub/dbhub.toml.example new file mode 100644 index 0000000..4400680 --- /dev/null +++ b/plugins/mcp/codex/mcp-dbhub/dbhub.toml.example @@ -0,0 +1,38 @@ +# dbhub.toml - データベース接続設定 +# このファイルを dbhub.toml にコピーしてプロジェクトルートに配置してください。 +# dbhub.toml は .gitignore に追加し、認証情報をコミットしないでください。 + +# 単一データベース(SSH踏み台なし) +[[sources]] +id = "mydb" +description = "開発用 MySQL" +dsn = "mysql://user:password@localhost:3306/mydb" + +# 単一データベース(SSH踏み台経由) +# [[sources]] +# id = "production" +# description = "本番 MySQL(踏み台経由)" +# dsn = "mysql://user:password@db.internal:3306/myapp" +# ssh_host = "bastion.example.com" +# ssh_port = 22 +# ssh_user = "ubuntu" +# ssh_key = "~/.ssh/id_rsa" + +# 複数データベース +# [[sources]] +# id = "production" +# description = "本番 MySQL" +# dsn = "mysql://user:password@prod-db.internal:3306/myapp" +# ssh_host = "bastion.example.com" +# ssh_port = 22 +# ssh_user = "ubuntu" +# ssh_key = "~/.ssh/id_rsa" +# +# [[sources]] +# id = "staging" +# description = "ステージング MySQL" +# dsn = "mysql://user:password@staging-db.internal:3306/myapp_stg" +# ssh_host = "bastion.example.com" +# ssh_port = 22 +# ssh_user = "ubuntu" +# ssh_key = "~/.ssh/id_rsa" diff --git a/plugins/mcp/codex/mcp-dbhub/docs/dbhub-guide.md b/plugins/mcp/codex/mcp-dbhub/docs/dbhub-guide.md new file mode 100644 index 0000000..1fb3d8e --- /dev/null +++ b/plugins/mcp/codex/mcp-dbhub/docs/dbhub-guide.md @@ -0,0 +1,30 @@ +# DBHub MCP ガイド + +DBHub MCPサーバーで各種データベースへのSQL操作を行います。 + +## 接続設定 + +DBHubは接続文字列(DSN)でデータベースに接続します。 +環境変数またはツール引数で接続先を指定してください。 + +## 主要ツール + +- `execute_sql_{suffix}` - 指定環境でSQLクエリを実行 +- `search_objects_{suffix}` - データベースオブジェクト(テーブル、カラム等)を検索 + +suffixはインストール時の設定に依存します(例: `production`, `alpha`, `delta`)。 + +## 使用例 + +``` +# テーブル・カラムの検索 +mcp__plugin_mcp-dbhub_dbhub__search_objects_production keyword="users" + +# SQLクエリ実行 +mcp__plugin_mcp-dbhub_dbhub__execute_sql_production sql="SELECT * FROM users LIMIT 10" +``` + +## 注意事項 + +- 本番環境への書き込みクエリ(INSERT, UPDATE, DELETE)は慎重に実行してください +- 大量データの取得時はLIMIT句を使用してください diff --git a/plugins/mcp/codex/mcp-devin/.codex-plugin/plugin.json b/plugins/mcp/codex/mcp-devin/.codex-plugin/plugin.json new file mode 100644 index 0000000..ad76b7e --- /dev/null +++ b/plugins/mcp/codex/mcp-devin/.codex-plugin/plugin.json @@ -0,0 +1,19 @@ +{ + "name": "mcp-devin", + "version": "1.0.0", + "description": "Devin MCP server for accessing repository documentation and code knowledge via DeepWiki", + "author": { + "name": "takemi-ohama", + "url": "https://github.com/takemi-ohama" + }, + "keywords": [ + "mcp", + "devin", + "deepwiki", + "documentation", + "repository", + "wiki", + "code-knowledge" + ], + "mcpServers": "./.mcp.json" +} diff --git a/plugins/mcp/codex/mcp-devin/.mcp.json b/plugins/mcp/codex/mcp-devin/.mcp.json new file mode 100644 index 0000000..8d2f5d2 --- /dev/null +++ b/plugins/mcp/codex/mcp-devin/.mcp.json @@ -0,0 +1,13 @@ +{ + "devin": { + "type": "http", + "url": "https://mcp.devin.ai/mcp", + "headers": { + "Authorization": "Bearer ${DEVIN_API_KEY}" + }, + "env": { + "DEVIN_API_KEY": "${DEVIN_API_KEY}" + }, + "envFile": "${workspaceFolder}/.env" + } +} diff --git a/plugins/mcp/codex/mcp-devin/README.md b/plugins/mcp/codex/mcp-devin/README.md new file mode 100644 index 0000000..1b46df1 --- /dev/null +++ b/plugins/mcp/codex/mcp-devin/README.md @@ -0,0 +1,90 @@ +# Devin MCP + +リポジトリのドキュメントとコード知識にアクセスするためのMCPサーバープラグインです。 + +## 概要 + +このプラグインは、[Devin MCP](https://mcp.devin.ai/mcp)サーバーを利用して、GitHubなどのリポジトリのドキュメント構造や内容をAIエージェントから直接参照できるようにします。Devin APIキー(`DEVIN_API_KEY`)が必要です。 + +## 機能 + +- **`read_wiki_structure`** - リポジトリのドキュメントトピック一覧を取得 +- **`read_wiki_contents`** - ドキュメントの内容を閲覧 +- **`ask_question`** - リポジトリに関するAI質問回答 + +## 前提条件 + +- Devin APIキー(環境変数 `DEVIN_API_KEY`) + +## 環境変数の設定 + +プロジェクトルートの`.env`ファイルに以下を設定してください: + +```bash +# Devin API Key +DEVIN_API_KEY=your-devin-api-key +``` + +## インストール + +### Codex + +```bash +codex plugin add mcp-devin@ai-plugins +``` + +### 必要な環境変数 + +- `DEVIN_API_KEY` + +## 使用方法 + +### 基本的な使用例 + +```bash +# リポジトリのドキュメント構造を取得 +mcp__plugin_mcp-devin__devin__read_wiki_structure "owner/repo" + +# ドキュメント内容を読み取り +mcp__plugin_mcp-devin__devin__read_wiki_contents "owner/repo" "topic" + +# リポジトリについて質問 +mcp__plugin_mcp-devin__devin__ask_question "owner/repo" "How does authentication work?" +``` + +### CLIで直接追加する場合 + +```bash +claude mcp add -s user -t http devin https://mcp.devin.ai/mcp --header "Authorization: Bearer $DEVIN_API_KEY" +``` + +## 推奨される使用シーン + +- OSSライブラリの仕様・アーキテクチャ調査 +- 依存パッケージの内部実装の理解 +- リポジトリのドキュメント横断検索 +- 技術選定時のコード比較調査 + +## NDFエージェントとの連携 + +DeepWiki MCPは、NDFプラグインの`ndf:researcher`エージェントと連携して使用することを推奨します。 + +```bash +Task( + subagent_type="ndf:researcher", + prompt="DeepWikiを使ってlangchain/langchainリポジトリのRAG実装を調査してください", + description="Research RAG implementation" +) +``` + +## 注意事項 + +- `DEVIN_API_KEY` の設定が必要です +- HTTPトランスポートを使用しています(Devin公式MCPサーバー `https://mcp.devin.ai/mcp`) +- `Authorization: Bearer {DEVIN_API_KEY}` ヘッダーで認証します +- レート制限がある場合があります + +## 参考リンク + +- [DeepWiki](https://deepwiki.com) +- [DeepWiki MCP ドキュメント](https://docs.devin.ai/ja/work-with-devin/deepwiki-mcp) diff --git a/plugins/mcp/codex/mcp-markitdown/.codex-plugin/plugin.json b/plugins/mcp/codex/mcp-markitdown/.codex-plugin/plugin.json new file mode 100644 index 0000000..8fcc244 --- /dev/null +++ b/plugins/mcp/codex/mcp-markitdown/.codex-plugin/plugin.json @@ -0,0 +1,21 @@ +{ + "name": "mcp-markitdown", + "version": "1.0.0", + "description": "MarkItDown MCP server for converting documents (PDF, Office, images, etc.) to Markdown", + "author": { + "name": "takemi-ohama", + "url": "https://github.com/takemi-ohama" + }, + "keywords": [ + "mcp", + "markitdown", + "markdown", + "pdf", + "docx", + "pptx", + "xlsx", + "images", + "converter" + ], + "mcpServers": "./.mcp.json" +} diff --git a/plugins/mcp/codex/mcp-markitdown/.mcp.json b/plugins/mcp/codex/mcp-markitdown/.mcp.json new file mode 100644 index 0000000..e4cdea6 --- /dev/null +++ b/plugins/mcp/codex/mcp-markitdown/.mcp.json @@ -0,0 +1,9 @@ +{ + "markitdown": { + "type": "stdio", + "command": "uvx", + "args": [ + "markitdown-mcp" + ] + } +} diff --git a/plugins/mcp/codex/mcp-markitdown/README.md b/plugins/mcp/codex/mcp-markitdown/README.md new file mode 100644 index 0000000..1926651 --- /dev/null +++ b/plugins/mcp/codex/mcp-markitdown/README.md @@ -0,0 +1,57 @@ +# MarkItDown MCP + +各種ドキュメント(PDF、Office、画像など)をMarkdownに変換するMCPサーバープラグインです。 + +## 概要 + +[Microsoft MarkItDown](https://github.com/microsoft/markitdown) のMCPサーバーラッパーです。HTTP/HTTPS URL、ローカルファイル、Data URIを指定してドキュメントをMarkdownに変換できます。 + +## 機能 + +- `convert_to_markdown(uri)` ツールを提供 +- 対応フォーマット: PDF, Word (.docx), Excel (.xlsx), PowerPoint (.pptx), 画像, HTML, CSV, JSON, XML など +- HTTP/HTTPS URL、ローカルファイルパス(`file://`)、Data URI に対応 + +## インストール + +### Codex + +```bash +codex plugin add mcp-markitdown@ai-plugins +``` + +### 必要な環境変数 + +- 追加の環境変数は不要です。 + +## 使用例 + +```bash +# URLからドキュメントを変換 +mcp__plugin_mcp-markitdown_markitdown__convert_to_markdown uri="https://example.com/document.pdf" + +# ローカルファイルを変換 +mcp__plugin_mcp-markitdown_markitdown__convert_to_markdown uri="file:///path/to/document.docx" +``` + +## ndf:scannerエージェントとの連携 + +MarkItDown MCPは、NDFプラグインの`ndf:scanner`エージェントと連携して使用することを推奨します。 + +```bash +Task( + subagent_type="ndf:scanner", + prompt="Convert /path/to/document.pdf to Markdown and summarize key points.", + description="Convert and summarize PDF" +) +``` + +## 注意事項 + +- Python環境が必要です(`uvx` 経由で自動インストール) +- 大きなファイルの変換には時間がかかる場合があります + +## 参考リンク + +- [microsoft/markitdown](https://github.com/microsoft/markitdown) - 本家リポジトリ +- [markitdown-mcp (PyPI)](https://pypi.org/project/markitdown-mcp/) - PyPIパッケージ diff --git a/plugins/mcp/codex/mcp-markitdown/docs/markitdown-guide.md b/plugins/mcp/codex/mcp-markitdown/docs/markitdown-guide.md new file mode 100644 index 0000000..7ba0cf0 --- /dev/null +++ b/plugins/mcp/codex/mcp-markitdown/docs/markitdown-guide.md @@ -0,0 +1,30 @@ +# MarkItDown MCP ガイド + +MarkItDown MCPサーバーで各種ドキュメントをMarkdown形式に変換します。 + +## 対応フォーマット + +- PDF +- Microsoft Office(Word, Excel, PowerPoint) +- 画像(OCR) +- HTML +- その他のドキュメント形式 + +## 主要ツール + +- `convert_to_markdown` - ファイルをMarkdownに変換 + +## 使用例 + +``` +# PDFをMarkdownに変換 +mcp__plugin_mcp-markitdown_markitdown__convert_to_markdown uri="file:///path/to/document.pdf" + +# Excelファイルを変換 +mcp__plugin_mcp-markitdown_markitdown__convert_to_markdown uri="file:///path/to/data.xlsx" +``` + +## 使い分け + +- scannerエージェントがPDF/Office/画像ファイルの読み取りに使用 +- Read toolで直接読めないファイル形式の変換に有用 diff --git a/plugins/mcp/codex/mcp-notion/.codex-plugin/plugin.json b/plugins/mcp/codex/mcp-notion/.codex-plugin/plugin.json new file mode 100644 index 0000000..e0c51f0 --- /dev/null +++ b/plugins/mcp/codex/mcp-notion/.codex-plugin/plugin.json @@ -0,0 +1,17 @@ +{ + "name": "mcp-notion", + "version": "1.0.0", + "description": "Notion MCP server for note management and knowledge base operations", + "author": { + "name": "takemi-ohama", + "url": "https://github.com/takemi-ohama" + }, + "keywords": [ + "mcp", + "notion", + "notes", + "knowledge-base", + "documentation" + ], + "mcpServers": "./.mcp.json" +} diff --git a/plugins/mcp/codex/mcp-notion/.mcp.json b/plugins/mcp/codex/mcp-notion/.mcp.json new file mode 100644 index 0000000..6be6e00 --- /dev/null +++ b/plugins/mcp/codex/mcp-notion/.mcp.json @@ -0,0 +1,8 @@ +{ + "notion": { + "type": "http", + "url": "https://mcp.notion.com/mcp", + "env": {}, + "envFile": "${workspaceFolder}/.env" + } +} diff --git a/plugins/mcp/codex/mcp-notion/README.md b/plugins/mcp/codex/mcp-notion/README.md new file mode 100644 index 0000000..e4865de --- /dev/null +++ b/plugins/mcp/codex/mcp-notion/README.md @@ -0,0 +1,75 @@ +# Notion MCP + +NotionワークスペースにアクセスするためのMCPサーバープラグインです。 + +## 概要 + +このプラグインは、Notionのページ、データベース、ブロックへのアクセスを提供します。 + +## 機能 + +- Notionページの作成・読み取り・更新 +- データベースのクエリ +- ブロックの操作 +- ページ階層の取得 +- コメントの追加 + +## 前提条件 + +- Notionアカウント +- Notion Integration(内部統合)の作成 +- 統合トークンの取得 + +## 環境変数の設定 + +プロジェクトルートに`.env`ファイルを作成し、以下の環境変数を設定してください: + +```bash +# Notion Integration Token +NOTION_TOKEN= +``` + +## インストール + +### Codex + +```bash +codex plugin add mcp-notion@ai-plugins +``` + +### 必要な環境変数 + +- 追加の環境変数は不要です。 + +## 使用方法 + +### 基本的な使用例 + +```bash +# ページを読み取り +mcp__plugin_notion-mcp__notion__read_page "page-id" + +# データベースをクエリ +mcp__plugin_notion-mcp__notion__query_database "database-id" + +# 新しいページを作成 +mcp__plugin_notion-mcp__notion__create_page "parent-page-id" "New Page Title" +``` + +## 推奨される使用シーン + +- ドキュメント管理 +- ナレッジベースの構築 +- タスク管理 +- プロジェクト情報の同期 + +## 注意事項 + +- Notion IntegrationをワークスペースやページにShareする必要があります +- HTTPトランスポートを使用しています(公式のNotion MCPサーバー) + +## 参考リンク + +- [Notion MCP Server](https://mcp.notion.com) +- [Notion API](https://developers.notion.com/) +- [Notion Integration Setup](https://www.notion.so/help/create-integrations-with-the-notion-api) diff --git a/plugins/mcp/codex/mcp-playwright/.codex-plugin/plugin.json b/plugins/mcp/codex/mcp-playwright/.codex-plugin/plugin.json new file mode 100644 index 0000000..3aa2c15 --- /dev/null +++ b/plugins/mcp/codex/mcp-playwright/.codex-plugin/plugin.json @@ -0,0 +1,18 @@ +{ + "name": "mcp-playwright", + "version": "1.0.1", + "description": "Playwright MCPを自動セットアップするプラグイン。Chromiumブラウザの自動インストールとMCP設定を提供します。", + "author": { + "name": "NDF Team", + "url": "https://github.com/devbasex/ai-plugins" + }, + "keywords": [ + "mcp", + "playwright", + "browser", + "automation", + "testing" + ], + "mcpServers": "./.mcp.json", + "hooks": "./hooks/hooks.json" +} diff --git a/plugins/mcp/codex/mcp-playwright/.mcp.json b/plugins/mcp/codex/mcp-playwright/.mcp.json new file mode 100644 index 0000000..5da8fab --- /dev/null +++ b/plugins/mcp/codex/mcp-playwright/.mcp.json @@ -0,0 +1,18 @@ +{ + "playwright": { + "type": "stdio", + "command": "npx", + "args": [ + "-y", + "@playwright/mcp@latest", + "--browser", + "chromium", + "--isolated", + "--no-sandbox" + ], + "env": { + "PLAYWRIGHT_BROWSERS_PATH": "${HOME}/.cache/ms-playwright" + }, + "envFile": "${workspaceFolder}/.env" + } +} diff --git a/plugins/mcp/codex/mcp-playwright/README.md b/plugins/mcp/codex/mcp-playwright/README.md new file mode 100644 index 0000000..5635b95 --- /dev/null +++ b/plugins/mcp/codex/mcp-playwright/README.md @@ -0,0 +1,148 @@ +# playwright Plugin + +Playwright MCPを自動的にセットアップし、ブラウザ自動化機能をCodexで利用するためのプラグインです。 + +## 概要 + +このプラグインは、Playwright MCPサーバーとChromiumブラウザを自動的にインストール・設定します。Codexでブラウザ自動化タスク(Webスクレイピング、E2Eテスト、スクリーンショット取得など)を実行できるようになります。 + +## 機能 + +- **自動セットアップ**: 初回起動時にPlaywright Chromiumブラウザを自動インストール +- **バージョン互換性**: @playwright/mcpと互換性のあるPlaywrightバージョンを自動検出 +- **冪等性**: 既にインストール済みの場合はスキップ(高速起動) +- **環境変数対応**: PLAYWRIGHT_BROWSERS_PATHで共有キャッシュを利用 + +## インストール + +### Codex + +```bash +codex plugin add mcp-playwright@ai-plugins +``` + +### 必要な環境変数 + +- `HOME` + +## 使用方法 + +### 基本的な使い方 + +プラグインをインストールして、Codexを起動すると自動的にセットアップが実行されます。 + +```bash +codex +``` + +初回起動時、以下のようなメッセージが表示されます: + +``` +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +🎭 Playwright MCP Plugin: 初回セットアップ +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Playwright Chromiumブラウザをインストール中... +ネットワーク環境により1-2分かかる場合があります。 + +🔍 @playwright/mcpが使用するPlaywrightバージョンを確認中... +✓ Playwright 1.49.1 を使用します + +📦 Playwright 1.49.1 でChromiumをインストール中... +[インストールログ...] + +✅ セットアップ完了!Playwright Chromiumの準備ができました。 + Playwrightバージョン: 1.49.1 + ブラウザパス: ~/.cache/ms-playwright +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +``` + +### Playwright MCPツールの使用例 + +セットアップ完了後、CodexのMCPツール一覧に表示される `playwright` サーバーの `browser_*` ツールを利用できます。 + +## 設定 + +### MCP設定(`.mcp.json`) + +Codex runtimeでは、プラグイン内の `.mcp.json` は direct server map 形式で提供されます: + +```json +{ + "playwright": { + "type": "stdio", + "command": "npx", + "args": [ + "-y", + "@playwright/mcp@latest", + "--browser", + "chromium", + "--isolated", + "--no-sandbox" + ], + "env": { + "PLAYWRIGHT_BROWSERS_PATH": "${HOME}/.cache/ms-playwright" + } + } +} +``` + +### ブラウザキャッシュパス + +デフォルトでは、Chromiumブラウザは `~/.cache/ms-playwright` にインストールされます。 + +別の場所を使用したい場合は、環境変数を設定してください: + +```bash +export PLAYWRIGHT_BROWSERS_PATH=/path/to/custom/location +``` + +## トラブルシューティング + +### インストールが失敗する場合 + +1. **手動インストール**: + ```bash + # 互換性のあるバージョンを確認 + npm view @playwright/mcp@latest dependencies + + # Chromiumをインストール + PLAYWRIGHT_BROWSERS_PATH=~/.cache/ms-playwright npx playwright@ install chromium + ``` + +2. **キャッシュをクリア**: + ```bash + rm ~/.claude-playwright-installed + ``` + +3. **ログを確認**: + プラグインは詳細なログを出力するので、エラーメッセージを確認してください。 + +### ブラウザが見つからない場合 + +環境変数 `PLAYWRIGHT_BROWSERS_PATH` が正しく設定されているか確認してください: + +```bash +echo $PLAYWRIGHT_BROWSERS_PATH +ls -la ~/.cache/ms-playwright +``` + +### タイムアウトエラー + +ネットワーク環境が遅い場合、インストールに時間がかかることがあります。 +デフォルトのタイムアウトは5分です。 + +## 参考資料 + +- [Playwright公式ドキュメント](https://playwright.dev/) +- [@playwright/mcp README](https://www.npmjs.com/package/@playwright/mcp) +- [Playwright Browser Management](https://playwright.dev/docs/browsers) + +## ライセンス + +MIT License + +## サポート + +問題が発生した場合は、GitHubリポジトリでissueを作成してください: +https://github.com/devbasex/ai-plugins/issues diff --git a/plugins/mcp/codex/mcp-playwright/hooks/hooks.json b/plugins/mcp/codex/mcp-playwright/hooks/hooks.json new file mode 100644 index 0000000..5e7d5ca --- /dev/null +++ b/plugins/mcp/codex/mcp-playwright/hooks/hooks.json @@ -0,0 +1,22 @@ +{ + "description": "Playwright MCP auto-setup hook", + "hooks": { + "SessionStart": [ + { + "description": "Ensure Playwright browser is installed", + "matcher": "startup|resume", + "priority": 100, + "enabled": true, + "hooks": [ + { + "type": "command", + "command": "node ${PLUGIN_ROOT:-${CODEX_PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT}}}/scripts/ensure-playwright.js", + "description": "Auto-install Playwright chromium on first use", + "continueOnError": false, + "suppressOutput": false + } + ] + } + ] + } +} diff --git a/plugins/mcp/codex/mcp-playwright/scripts/ensure-playwright.js b/plugins/mcp/codex/mcp-playwright/scripts/ensure-playwright.js new file mode 100755 index 0000000..b8e354a --- /dev/null +++ b/plugins/mcp/codex/mcp-playwright/scripts/ensure-playwright.js @@ -0,0 +1,114 @@ +#!/usr/bin/env node + +const { execFileSync } = require('child_process'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +const PLUGIN_ROOT = process.env.PLUGIN_ROOT || process.env.CODEX_PLUGIN_ROOT || process.env.CLAUDE_PLUGIN_ROOT || __dirname; +const FLAG_FILE = path.join(os.homedir(), '.claude-playwright-installed'); +const BROWSER_PATH = process.env.PLAYWRIGHT_BROWSERS_PATH || path.join(os.homedir(), '.cache', 'ms-playwright'); +const TIMEOUT_MS = 5 * 60 * 1000; // 5分タイムアウト +const PLAYWRIGHT_VERSION_PATTERN = /^[~^]?\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/; +const EXEC_FILE_OPTIONS = process.platform === 'win32' ? { shell: true } : {}; + +function readInstalledFlag() { + if (!fs.existsSync(FLAG_FILE)) { + return null; + } + try { + const flagData = JSON.parse(fs.readFileSync(FLAG_FILE, 'utf-8')); + return flagData && typeof flagData === 'object' ? flagData : null; + } catch { + return null; + } +} + +console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); +console.log('🎭 Playwright MCP Plugin: 初回セットアップ'); +console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); +console.log(''); +console.log('Playwright Chromiumブラウザをインストール中...'); +console.log('ネットワーク環境により1-2分かかる場合があります。'); +console.log(''); + +try { + // @playwright/mcpが依存するPlaywrightのバージョンを取得 + console.log('🔍 @playwright/mcpが使用するPlaywrightバージョンを確認中...'); + const depsOutput = execFileSync('npm', ['view', '@playwright/mcp@latest', 'dependencies', '--json'], { + encoding: 'utf-8', + cwd: PLUGIN_ROOT, + timeout: 30000, + ...EXEC_FILE_OPTIONS + }); + + const deps = JSON.parse(depsOutput); + const playwrightVersion = deps.playwright || deps['playwright-core']; + + if (!playwrightVersion) { + throw new Error('@playwright/mcpの依存関係からPlaywrightバージョンを取得できませんでした'); + } + if (!PLAYWRIGHT_VERSION_PATTERN.test(playwrightVersion)) { + throw new Error(`不正なPlaywrightバージョンです: ${playwrightVersion}`); + } + + console.log(`✓ Playwright ${playwrightVersion} を使用します`); + console.log(''); + + const installedFlag = readInstalledFlag(); + if ( + installedFlag && + installedFlag.playwrightVersion === playwrightVersion && + installedFlag.browserPath === BROWSER_PATH + ) { + console.log('既に同じ Playwright バージョンでセットアップ済みです。'); + process.exit(0); + } + + // @playwright/mcpと互換性のあるバージョンのChromiumをインストール + console.log(`📦 Playwright ${playwrightVersion} でChromiumをインストール中...`); + execFileSync('npx', ['-y', `playwright@${playwrightVersion}`, 'install', 'chromium'], { + stdio: 'inherit', + cwd: PLUGIN_ROOT, + timeout: TIMEOUT_MS, + ...EXEC_FILE_OPTIONS, + env: { + ...process.env, + PLAYWRIGHT_BROWSERS_PATH: BROWSER_PATH, + PLAYWRIGHT_SKIP_BROWSER_GC: '1' + } + }); + + // インストール成功フラグを作成 + const flagData = { + installed: new Date().toISOString(), + plugin: 'playwright', + browser: 'chromium', + browserPath: BROWSER_PATH, + playwrightVersion: playwrightVersion + }; + + fs.writeFileSync(FLAG_FILE, JSON.stringify(flagData, null, 2)); + + console.log(''); + console.log('✅ セットアップ完了!Playwright Chromiumの準備ができました。'); + console.log(` Playwrightバージョン: ${playwrightVersion}`); + console.log(` ブラウザパス: ${BROWSER_PATH}`); + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + process.exit(0); + +} catch (error) { + console.error(''); + console.error('❌ Playwright Chromiumのインストールに失敗しました'); + console.error(''); + console.error('エラー:', error.message); + console.error(''); + console.error('手動でインストールするには以下を実行してください:'); + console.error(` npm view @playwright/mcp@latest dependencies`); + console.error(` PLAYWRIGHT_BROWSERS_PATH=${BROWSER_PATH} npx playwright@ install chromium`); + console.error(''); + console.error('トラブルシューティング:'); + console.error(' https://playwright.dev/docs/browsers'); + console.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + process.exit(1); +} diff --git a/plugins/mcp/codex/mcp-redash/.codex-plugin/plugin.json b/plugins/mcp/codex/mcp-redash/.codex-plugin/plugin.json new file mode 100644 index 0000000..c6fc88f --- /dev/null +++ b/plugins/mcp/codex/mcp-redash/.codex-plugin/plugin.json @@ -0,0 +1,19 @@ +{ + "name": "mcp-redash", + "version": "1.0.0", + "description": "Redash MCP server with multi-environment support (arbitrary suffix)", + "author": { + "name": "takemi-ohama", + "url": "https://github.com/takemi-ohama" + }, + "keywords": [ + "mcp", + "redash", + "sql", + "data", + "visualization", + "multi-environment" + ], + "skills": "./skills/", + "mcpServers": "./.mcp.json" +} diff --git a/plugins/mcp/codex/mcp-redash/.env.example b/plugins/mcp/codex/mcp-redash/.env.example new file mode 100644 index 0000000..810b944 --- /dev/null +++ b/plugins/mcp/codex/mcp-redash/.env.example @@ -0,0 +1,16 @@ +# Redash MCP - 環境変数設定 +# +# デフォルト(必須) +REDASH_URL=https://redash.example.com +REDASH_API_KEY=your-api-key + +# 追加環境(/redash-add で追加した場合のみ必要) +# suffix を大文字にして REDASH_{SUFFIX}_URL, REDASH_{SUFFIX}_API_KEY の形式 +# +# 例: /redash-add dev +# REDASH_DEV_URL=https://redash-dev.example.com +# REDASH_DEV_API_KEY=your-dev-api-key +# +# 例: /redash-add stg +# REDASH_STG_URL=https://redash-stg.example.com +# REDASH_STG_API_KEY=your-stg-api-key diff --git a/plugins/mcp/codex/mcp-redash/.mcp.json b/plugins/mcp/codex/mcp-redash/.mcp.json new file mode 100644 index 0000000..8930d6d --- /dev/null +++ b/plugins/mcp/codex/mcp-redash/.mcp.json @@ -0,0 +1,13 @@ +{ + "redash": { + "command": "npx", + "args": [ + "-y", + "@suthio/redash-mcp" + ], + "env": { + "REDASH_URL": "${REDASH_URL}", + "REDASH_API_KEY": "${REDASH_API_KEY}" + } + } +} diff --git a/plugins/mcp/codex/mcp-redash/README.md b/plugins/mcp/codex/mcp-redash/README.md new file mode 100644 index 0000000..93decca --- /dev/null +++ b/plugins/mcp/codex/mcp-redash/README.md @@ -0,0 +1,147 @@ +# mcp-redash + +Redash MCP サーバーをマルチ環境対応で利用するための Codex プラグイン。 + +## 概要 + +[redash-mcp](https://github.com/suthio/redash-mcp) を Codex から利用するためのプラグインです。 + +- 1つだけ使う場合は最小設定(`REDASH_URL` と `REDASH_API_KEY` のみ) +- 必要になったら suffix 付きで環境を増やせる +- MCP 定義は必要になるまで作られない + +## 前提条件 + +- Codex +- Node.js(npx が使える環境) + +## インストール + +### Codex + +```bash +codex plugin add mcp-redash@ai-plugins +``` + +### 必要な環境変数 + +- `REDASH_API_KEY` +- `REDASH_URL` + +## 環境変数 + +プロジェクトの `.env` に設定してください。 + +```bash +REDASH_URL=https://redash.example.com +REDASH_API_KEY=your-api-key +``` + +## 使い方 + +### 基本(1環境) + +プラグインインストール後、`redash` MCP が自動的に有効になります。環境変数を設定するだけで利用開始できます。 + +### 複数環境 + +```bash +# dev 環境を追加 +/redash-add dev + +# .env に環境変数を設定 +# REDASH_DEV_URL=https://redash-dev.example.com +# REDASH_DEV_API_KEY=your-dev-api-key + +# 一覧確認 +/redash-list + +# 状態確認(環境変数の設定漏れチェック) +/redash-status + +# 不要になったら削除 +/redash-remove dev +``` + +## スラッシュコマンド + +| コマンド | 説明 | +|---------|------| +| `/redash-add ` | 任意 suffix の Redash MCP を project `.mcp.json` に追加 | +| `/redash-remove ` | 指定 suffix の Redash MCP を project `.mcp.json` から削除 | +| `/redash-list` | 有効な Redash MCP を一覧表示 | +| `/redash-status` | 設定状況の詳細確認(環境変数の未設定警告付き) | +| `/redash-guide` | 使い方ガイドを表示 | + +## suffix と環境変数の対応 + +| suffix | MCP 名 | 環境変数 | +|--------|--------|----------| +| (なし) | redash | REDASH_URL, REDASH_API_KEY | +| dev | redash-dev | REDASH_DEV_URL, REDASH_DEV_API_KEY | +| stg | redash-stg | REDASH_STG_URL, REDASH_STG_API_KEY | +| prod2 | redash-prod2 | REDASH_PROD2_URL, REDASH_PROD2_API_KEY | +| sandbox-1 | redash-sandbox-1 | REDASH_SANDBOX_1_URL, REDASH_SANDBOX_1_API_KEY | + +- suffix は英小文字・数字・ハイフンで自由に指定可能 +- 環境変数名では suffix 内のハイフンをアンダースコアに変換 +- `default` は予約語のため使用不可(plugin 同梱の `redash` が該当) + +## ファイル構成 + +``` +plugins/mcp/shared/mcp-redash/ +├── .claude-plugin/ +│ └── plugin.json # プラグインメタデータ +├── .mcp.json # MCP 設定(redash のみ同梱) +├── .env.example # 環境変数テンプレート +├── scripts/ +│ └── redash-mcp-config.js # JSON 編集スクリプト(Node.js) +├── skills/ +│ ├── redash-add/SKILL.md # /redash-add コマンド +│ ├── redash-remove/SKILL.md # /redash-remove コマンド +│ ├── redash-list/SKILL.md # /redash-list コマンド +│ ├── redash-status/SKILL.md # /redash-status コマンド +│ └── redash-guide/SKILL.md # 使い方ガイド +└── README.md +``` + +## 設計意図 + +> 本プラグインは +> 「1つだけ使う場合は最小設定」 +> 「必要になったら suffix 付きで増やす」 +> という運用を前提にしている。 +> +> そのため MCP 定義は **必要になるまで作られない**。 + +- plugin 同梱: `redash`(常に `/mcp` に表示) +- project `.mcp.json`: `/redash-add` で追加したもののみ表示 + +## トラブルシューティング + +### `/mcp` に redash が表示されない + +- プラグインが正しくインストールされているか確認 +- `/plugin list` で `mcp-redash@ai-plugins` が表示されるか確認 + +### 環境変数が反映されない + +- `.env` ファイルがプロジェクトルートにあるか確認 +- 変数名が正しい形式か確認(`REDASH_{SUFFIX}_URL`) +- `/redash-status` で設定状況を確認 + +### `/redash-add` でエラーが出る + +- `.mcp.json` の JSON が壊れていないか確認(手動編集した場合) +- `default` は予約語のため suffix に使用不可 + +### suffix 付き MCP が動かない + +- 対応する環境変数が `.env` に設定されているか `/redash-status` で確認 +- Redash の URL と API Key が正しいか確認 + +## 参考リンク + +- [redash-mcp](https://github.com/suthio/redash-mcp) - Redash MCP サーバー +- [Redash](https://redash.io/) - データ可視化ツール diff --git a/plugins/mcp/codex/mcp-redash/scripts/redash-mcp-config.js b/plugins/mcp/codex/mcp-redash/scripts/redash-mcp-config.js new file mode 100755 index 0000000..a62d173 --- /dev/null +++ b/plugins/mcp/codex/mcp-redash/scripts/redash-mcp-config.js @@ -0,0 +1,383 @@ +#!/usr/bin/env node + +/** + * redash-mcp-config.js + * + * project root の .mcp.json を操作して Redash MCP の追加・削除・一覧・状態確認を行う。 + * + * Usage: + * node redash-mcp-config.js add + * node redash-mcp-config.js remove + * node redash-mcp-config.js list + * node redash-mcp-config.js status + */ + +const fs = require('fs'); +const path = require('path'); + +const MCP_PACKAGE = '@suthio/redash-mcp'; +const PROJECT_ROOT = resolveProjectRoot(); +const MCP_JSON_PATH = path.join(PROJECT_ROOT, '.mcp.json'); +const KIRO_AGENT_PATH = path.join(PROJECT_ROOT, '.kiro', 'agents', 'default.json'); + +// --- helpers --- + +function envName(suffix) { + const upper = suffix.toUpperCase().replaceAll('-', '_'); + return { + url: `REDASH_${upper}_URL`, + key: `REDASH_${upper}_API_KEY`, + }; +} + +function mcpName(suffix) { + return `redash-${suffix}`; +} + +function validateSuffix(suffix) { + if (!/^[a-z0-9][a-z0-9-]*$/.test(suffix)) { + console.error('エラー: suffix は英小文字・数字・ハイフンのみで指定してください。'); + console.error('例: dev, stg, prod2, sandbox-1'); + process.exit(1); + } +} + +function mcpEntry(suffix) { + const env = envName(suffix); + return { + command: 'npx', + args: ['-y', MCP_PACKAGE], + env: { + REDASH_URL: `\${${env.url}}`, + REDASH_API_KEY: `\${${env.key}}`, + }, + }; +} + +function isKiroRuntime() { + const normalizedDir = path.normalize(__dirname); + const runtimePath = path.join('mcp', 'kiro') + path.sep; + return Boolean(process.env.KIRO_WORKSPACE_ROOT) || normalizedDir.includes(runtimePath); +} + +function isCodexRuntime() { + const normalizedDir = path.normalize(__dirname); + const runtimePath = path.join('mcp', 'codex') + path.sep; + return Boolean(process.env.CODEX_WORKSPACE_ROOT) || normalizedDir.includes(runtimePath); +} + +function resolveProjectRoot() { + const envNames = [ + 'PROJECT_ROOT', + 'WORKSPACE_ROOT', + 'GIT_WORK_TREE', + 'CLAUDE_PROJECT_DIR', + 'CODEX_WORKSPACE_ROOT', + 'KIRO_WORKSPACE_ROOT', + ]; + + for (const name of envNames) { + const value = process.env[name]; + if (value && fs.existsSync(value) && fs.statSync(value).isDirectory()) { + return path.resolve(value); + } + } + + let dir = process.cwd(); + let nearestMcpDir = null; + while (true) { + if (!nearestMcpDir && fs.existsSync(path.join(dir, '.mcp.json'))) { + nearestMcpDir = dir; + } + if (fs.existsSync(path.join(dir, '.git'))) { + return dir; + } + + const parent = path.dirname(dir); + if (parent === dir) { + return nearestMcpDir || process.cwd(); + } + dir = parent; + } +} + +function createMcpJson() { + return isCodexRuntime() ? {} : { mcpServers: {} }; +} + +function normalizeWritableMcpJson(data) { + if (Object.prototype.hasOwnProperty.call(data, 'mcpServers')) { + return data; + } + if (Object.prototype.hasOwnProperty.call(data, 'mcp_servers')) { + const normalized = { ...data, mcpServers: data.mcp_servers }; + delete normalized.mcp_servers; + return normalized; + } + return data; +} + +function serverMap(data, create) { + if (!data || typeof data !== 'object' || Array.isArray(data)) { + return null; + } + + if (Object.prototype.hasOwnProperty.call(data, 'mcpServers')) { + if (!data.mcpServers && create) { + data.mcpServers = {}; + } + return data.mcpServers && typeof data.mcpServers === 'object' && !Array.isArray(data.mcpServers) + ? data.mcpServers + : null; + } + + if (Object.prototype.hasOwnProperty.call(data, 'mcp_servers')) { + if (!data.mcp_servers && create) { + data.mcp_servers = {}; + } + return data.mcp_servers && typeof data.mcp_servers === 'object' && !Array.isArray(data.mcp_servers) + ? data.mcp_servers + : null; + } + + return data; +} + +function readMcpJson() { + if (!fs.existsSync(MCP_JSON_PATH)) { + return null; + } + const raw = fs.readFileSync(MCP_JSON_PATH, 'utf-8'); + try { + return JSON.parse(raw); + } catch { + return undefined; // 破損 + } +} + +function writeMcpJson(data) { + fs.writeFileSync(MCP_JSON_PATH, JSON.stringify(data, null, 2) + '\n', 'utf-8'); +} + +function syncKiroAgent(data) { + if (!isKiroRuntime()) { + return; + } + + const servers = serverMap(data, false) || {}; + let agent = {}; + if (fs.existsSync(KIRO_AGENT_PATH)) { + try { + agent = JSON.parse(fs.readFileSync(KIRO_AGENT_PATH, 'utf-8')); + } catch { + console.error('エラー: .kiro/agents/default.json の JSON が壊れています。手動で修正してください。'); + process.exit(1); + } + } else { + agent = { + name: 'default', + resources: ['skill://.kiro/skills/**/SKILL.md'], + }; + } + + if (!agent.mcpServers || typeof agent.mcpServers !== 'object' || Array.isArray(agent.mcpServers)) { + agent.mcpServers = {}; + } + + for (const name of Object.keys(agent.mcpServers)) { + if (isRedashServer(name)) { + delete agent.mcpServers[name]; + } + } + for (const [name, server] of Object.entries(servers)) { + if (isRedashServer(name)) { + agent.mcpServers[name] = server; + } + } + + fs.mkdirSync(path.dirname(KIRO_AGENT_PATH), { recursive: true }); + fs.writeFileSync(KIRO_AGENT_PATH, JSON.stringify(agent, null, 2) + '\n', 'utf-8'); +} + +function isRedashServer(name) { + return name === 'redash' || name.startsWith('redash-'); +} + +// --- commands --- + +function cmdAdd(suffix) { + if (!suffix || suffix === 'default') { + console.error('エラー: デフォルトの redash は plugin 同梱のため追加できません。'); + console.error('suffix には dev, stg, prod2, sandbox などを指定してください。'); + process.exit(1); + } + validateSuffix(suffix); + + const name = mcpName(suffix); + let data = readMcpJson(); + + if (data === undefined) { + console.error('エラー: .mcp.json の JSON が壊れています。手動で修正してください。'); + process.exit(1); + } + + if (data === null) { + data = createMcpJson(); + } else { + data = normalizeWritableMcpJson(data); + } + + const servers = serverMap(data, true); + if (!servers) { + console.error('エラー: .mcp.json の MCP server 定義形式を解釈できません。'); + process.exit(1); + } + + if (servers[name]) { + console.log(`${name} は既に登録されています。変更はありません。`); + process.exit(0); + } + + servers[name] = mcpEntry(suffix); + writeMcpJson(data); + syncKiroAgent(data); + + const env = envName(suffix); + console.log(`${name} を .mcp.json に追加しました。`); + if (isKiroRuntime()) { + console.log(`${name} を .kiro/agents/default.json に同期しました。`); + } + console.log(''); + console.log('必要な環境変数:'); + console.log(` ${env.url}`); + console.log(` ${env.key}`); + console.log(''); + console.log('プロジェクトの .env に設定してください。'); +} + +function cmdRemove(suffix) { + if (!suffix || suffix === 'default') { + console.error('エラー: デフォルトの redash は plugin 同梱のため削除できません。'); + process.exit(1); + } + validateSuffix(suffix); + + const name = mcpName(suffix); + let data = readMcpJson(); + + if (data === undefined) { + console.error('エラー: .mcp.json の JSON が壊れています。手動で修正してください。'); + process.exit(1); + } + + if (data !== null) { + data = normalizeWritableMcpJson(data); + } + + const servers = serverMap(data, false); + if (data === null || !servers || !servers[name]) { + console.log(`${name} は登録されていません。変更はありません。`); + process.exit(0); + } + + delete servers[name]; + writeMcpJson(data); + syncKiroAgent(data); + + console.log(`${name} を .mcp.json から削除しました。`); + if (isKiroRuntime()) { + console.log(`${name} を .kiro/agents/default.json から削除しました。`); + } +} + +function cmdList() { + // plugin 同梱分 + console.log('redash (plugin bundled)'); + + // project .mcp.json 分 + const data = readMcpJson(); + if (data === undefined) { + console.error('警告: .mcp.json の JSON が壊れています。'); + return; + } + const servers = serverMap(data, false); + if (servers) { + const names = Object.keys(servers) + .filter((n) => isRedashServer(n) && n !== 'redash') + .sort(); + for (const name of names) { + console.log(`${name.padEnd(14)}(project)`); + } + } +} + +function cmdStatus() { + console.log('=== Redash MCP Status ==='); + console.log(''); + + // plugin 同梱 + console.log('[redash] (plugin bundled)'); + console.log(' 環境変数: REDASH_URL, REDASH_API_KEY'); + printEnvWarnings(['REDASH_URL', 'REDASH_API_KEY']); + console.log(''); + + // project .mcp.json 分 + const data = readMcpJson(); + if (data === undefined) { + console.error('警告: .mcp.json の JSON が壊れています。'); + return; + } + const servers = serverMap(data, false); + if (servers) { + const names = Object.keys(servers) + .filter((n) => isRedashServer(n) && n !== 'redash') + .sort(); + for (const name of names) { + const suffix = name.replace('redash-', ''); + const env = envName(suffix); + console.log(`[${name}] (project)`); + console.log(` 環境変数: ${env.url}, ${env.key}`); + printEnvWarnings([env.url, env.key]); + console.log(''); + } + if (names.length === 0) { + console.log('追加の Redash MCP はありません。'); + console.log('/redash-add で追加できます。'); + } + } else { + console.log('追加の Redash MCP はありません。'); + console.log('/redash-add で追加できます。'); + } +} + +function printEnvWarnings(vars) { + const missing = vars.filter((v) => !process.env[v]); + if (missing.length > 0) { + for (const v of missing) { + console.log(` ⚠ ${v} が未設定です`); + } + } +} + +// --- main --- + +const [, , command, ...rest] = process.argv; +const suffix = rest[0] ? rest[0].toLowerCase() : ''; + +switch (command) { + case 'add': + cmdAdd(suffix); + break; + case 'remove': + cmdRemove(suffix); + break; + case 'list': + cmdList(); + break; + case 'status': + cmdStatus(); + break; + default: + console.error('Usage: redash-mcp-config.js [suffix]'); + process.exit(1); +} diff --git a/plugins/mcp/codex/mcp-redash/skills/redash-add/SKILL.md b/plugins/mcp/codex/mcp-redash/skills/redash-add/SKILL.md new file mode 100644 index 0000000..ce1fc02 --- /dev/null +++ b/plugins/mcp/codex/mcp-redash/skills/redash-add/SKILL.md @@ -0,0 +1,27 @@ +--- +name: redash-add +description: 任意 suffix の Redash MCP を project .mcp.json に追加する +disable-model-invocation: true +user-invocable: true +arguments: + - name: suffix + description: "環境識別子(dev, stg, prod2, sandbox など)" +allowed-tools: + - Bash +--- + +# /redash-add + +任意 suffix の Redash MCP をプロジェクトの `.mcp.json` に追加します。 + +## 実行方法 + +以下のコマンドを実行してください。`$ARGUMENTS` にはユーザーが指定した suffix が入ります。 + +```bash +node "${PLUGIN_ROOT:-${CODEX_PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT}}}/scripts/redash-mcp-config.js" add "$ARGUMENTS" +``` + +## 実行後 + +コマンドの出力をそのままユーザーに表示してください。 diff --git a/plugins/mcp/codex/mcp-redash/skills/redash-guide/SKILL.md b/plugins/mcp/codex/mcp-redash/skills/redash-guide/SKILL.md new file mode 100644 index 0000000..0cbdbdd --- /dev/null +++ b/plugins/mcp/codex/mcp-redash/skills/redash-guide/SKILL.md @@ -0,0 +1,88 @@ +--- +name: redash-guide +description: Redash MCP プラグインの使い方ガイド +disable-model-invocation: false +user-invocable: true +allowed-tools: [] +--- + +# Redash MCP プラグイン ガイド + +## 基本設定(1環境のみ) + +Redash を1つだけ使う場合は、プロジェクトの `.env` に以下を設定するだけです。 + +```bash +REDASH_URL=https://redash.example.com +REDASH_API_KEY=your-api-key +``` + +プラグインインストール後、`/mcp` に `redash` が自動的に表示されます。 + +## 複数環境の追加 + +dev / stg / prod など複数の Redash を使う場合: + +### 1. 環境を追加 + +``` +/redash-add dev +/redash-add stg +``` + +### 2. 環境変数を設定 + +`.env` に追加分の変数を設定します。 + +```bash +# デフォルト +REDASH_URL=https://redash.example.com +REDASH_API_KEY=your-api-key + +# dev 環境 +REDASH_DEV_URL=https://redash-dev.example.com +REDASH_DEV_API_KEY=your-dev-api-key + +# stg 環境 +REDASH_STG_URL=https://redash-stg.example.com +REDASH_STG_API_KEY=your-stg-api-key +``` + +### 3. 確認 + +``` +/redash-list # 有効な MCP 一覧 +/redash-status # 環境変数の設定状況 +``` + +## suffix 命名ルール + +| suffix | MCP 名 | 環境変数 | +|--------|--------|----------| +| dev | redash-dev | REDASH_DEV_URL, REDASH_DEV_API_KEY | +| stg | redash-stg | REDASH_STG_URL, REDASH_STG_API_KEY | +| prod2 | redash-prod2 | REDASH_PROD2_URL, REDASH_PROD2_API_KEY | +| sandbox-1 | redash-sandbox-1 | REDASH_SANDBOX_1_URL, REDASH_SANDBOX_1_API_KEY | + +- suffix は英小文字・数字・ハイフンで自由に指定可能 +- 環境変数名では suffix 内のハイフンをアンダースコアに変換 +- `default` は予約語のため使用不可(plugin 同梱の `redash` が該当) + +## 環境の削除 + +不要になった環境は削除できます。 + +``` +/redash-remove dev +``` + +plugin 同梱の `redash` は削除できません。 + +## スラッシュコマンド一覧 + +| コマンド | 説明 | +|---------|------| +| `/redash-add ` | Redash MCP を追加 | +| `/redash-remove ` | Redash MCP を削除 | +| `/redash-list` | 有効な MCP 一覧 | +| `/redash-status` | 設定状況の詳細確認 | diff --git a/plugins/mcp/codex/mcp-redash/skills/redash-list/SKILL.md b/plugins/mcp/codex/mcp-redash/skills/redash-list/SKILL.md new file mode 100644 index 0000000..798754e --- /dev/null +++ b/plugins/mcp/codex/mcp-redash/skills/redash-list/SKILL.md @@ -0,0 +1,24 @@ +--- +name: redash-list +description: 現在有効な Redash MCP を一覧表示する +disable-model-invocation: true +user-invocable: true +allowed-tools: + - Bash +--- + +# /redash-list + +現在有効な Redash MCP の一覧を表示します。 + +## 実行方法 + +以下のコマンドを実行してください。 + +```bash +node "${PLUGIN_ROOT:-${CODEX_PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT}}}/scripts/redash-mcp-config.js" list +``` + +## 実行後 + +コマンドの出力をそのままユーザーに表示してください。 diff --git a/plugins/mcp/codex/mcp-redash/skills/redash-remove/SKILL.md b/plugins/mcp/codex/mcp-redash/skills/redash-remove/SKILL.md new file mode 100644 index 0000000..00fe202 --- /dev/null +++ b/plugins/mcp/codex/mcp-redash/skills/redash-remove/SKILL.md @@ -0,0 +1,27 @@ +--- +name: redash-remove +description: 指定 suffix の Redash MCP を project .mcp.json から削除する +disable-model-invocation: true +user-invocable: true +arguments: + - name: suffix + description: "削除する環境識別子(dev, stg, prod2, sandbox など)" +allowed-tools: + - Bash +--- + +# /redash-remove + +指定 suffix の Redash MCP をプロジェクトの `.mcp.json` から削除します。 + +## 実行方法 + +以下のコマンドを実行してください。`$ARGUMENTS` にはユーザーが指定した suffix が入ります。 + +```bash +node "${PLUGIN_ROOT:-${CODEX_PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT}}}/scripts/redash-mcp-config.js" remove "$ARGUMENTS" +``` + +## 実行後 + +コマンドの出力をそのままユーザーに表示してください。 diff --git a/plugins/mcp/codex/mcp-redash/skills/redash-status/SKILL.md b/plugins/mcp/codex/mcp-redash/skills/redash-status/SKILL.md new file mode 100644 index 0000000..45a4034 --- /dev/null +++ b/plugins/mcp/codex/mcp-redash/skills/redash-status/SKILL.md @@ -0,0 +1,24 @@ +--- +name: redash-status +description: Redash MCP の設定状況と環境変数の確認 +disable-model-invocation: true +user-invocable: true +allowed-tools: + - Bash +--- + +# /redash-status + +Redash MCP の設定状況を詳細表示します。各 MCP が必要とする環境変数と、未設定の警告を確認できます。 + +## 実行方法 + +以下のコマンドを実行してください。 + +```bash +node "${PLUGIN_ROOT:-${CODEX_PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT}}}/scripts/redash-mcp-config.js" status +``` + +## 実行後 + +コマンドの出力をそのままユーザーに表示してください。 diff --git a/plugins/mcp/codex/mcp-serena/.codex-plugin/plugin.json b/plugins/mcp/codex/mcp-serena/.codex-plugin/plugin.json new file mode 100644 index 0000000..f47d957 --- /dev/null +++ b/plugins/mcp/codex/mcp-serena/.codex-plugin/plugin.json @@ -0,0 +1,19 @@ +{ + "name": "mcp-serena", + "version": "1.0.0", + "description": "Serena MCP server for semantic code intelligence (symbol search, reference search, refactoring)", + "author": { + "name": "takemi-ohama", + "url": "https://github.com/takemi-ohama" + }, + "keywords": [ + "mcp", + "serena", + "lsp", + "symbol-search", + "refactoring", + "code-intelligence" + ], + "mcpServers": "./.mcp.json", + "hooks": "./hooks/hooks.json" +} diff --git a/plugins/mcp/codex/mcp-serena/.mcp.json b/plugins/mcp/codex/mcp-serena/.mcp.json new file mode 100644 index 0000000..e0aca4e --- /dev/null +++ b/plugins/mcp/codex/mcp-serena/.mcp.json @@ -0,0 +1,19 @@ +{ + "serena": { + "type": "stdio", + "command": "uvx", + "args": [ + "--from", + "git+https://github.com/oraios/serena", + "serena", + "start-mcp-server", + "--context", + "ide-assistant", + "--enable-web-dashboard", + "False" + ], + "env": { + "SERENA_HOME": ".serena" + } + } +} diff --git a/plugins/mcp/codex/mcp-serena/README.md b/plugins/mcp/codex/mcp-serena/README.md new file mode 100644 index 0000000..6b0d202 --- /dev/null +++ b/plugins/mcp/codex/mcp-serena/README.md @@ -0,0 +1,35 @@ +# mcp-serena + +Serena MCPサーバーを提供するプラグインです。セマンティックコードインテリジェンス機能(シンボル検索、リファレンス検索、安全なリファクタリング)を利用できます。 + +## インストール + +### Codex + +```bash +codex plugin add mcp-serena@ai-plugins +``` + +### 必要な環境変数 + +- `SERENA_HOME` + +## 提供機能 + +- **シンボル検索**: クラス、関数、メソッドの定義を検索 +- **リファレンス検索**: シンボルの参照箇所を特定 +- **シンボル概要**: ファイル内のシンボル一覧を取得(ファイル全体を読まずに構造を把握) +- **セマンティック編集**: シンボル単位でのコード置換・挿入 +- **パターン検索**: 正規表現によるコード検索 + +## 環境変数 + +| 変数 | 説明 | デフォルト | +|------|------|-----------| +| `SERENA_HOME` | Serena設定ディレクトリ | `.serena` | + +## 注意事項 + +- プロジェクトで使用する前に `activate_project` でアクティベートが必要です +- Serenaのメモリー機能は使用しません。知識は `docs/` に、手順は `skills/` に配置してください +- 詳細は `docs/serena-guide.md` を参照 diff --git a/plugins/mcp/codex/mcp-serena/docs/serena-guide.md b/plugins/mcp/codex/mcp-serena/docs/serena-guide.md new file mode 100644 index 0000000..3cd249c --- /dev/null +++ b/plugins/mcp/codex/mcp-serena/docs/serena-guide.md @@ -0,0 +1,73 @@ +# Serena MCP ガイド + +Serena MCPはセマンティックコードインテリジェンスツールです。 +コード操作専用。メモリー機能は使用しません。 + +## アクティベーション + +プロジェクトで使用する前にアクティベートが必要です。 + +``` +mcp__plugin_mcp-serena_serena__activate_project /path/to/project +mcp__plugin_mcp-serena_serena__check_onboarding_performed +``` + +## コード探索(推奨順序) + +### 1. ディレクトリ構造の確認 + +``` +mcp__plugin_mcp-serena_serena__list_dir relative_path="src/" recursive=false +``` + +### 2. シンボル概要の取得(ファイル全体を読む前に) + +``` +mcp__plugin_mcp-serena_serena__get_symbols_overview relative_path="path/to/file.py" +``` + +### 3. シンボル検索 + +``` +mcp__plugin_mcp-serena_serena__find_symbol name_path="/ClassName/method_name" relative_path="src/" include_body=true +``` + +### 4. パターン検索(シンボル名が不明な場合) + +``` +mcp__plugin_mcp-serena_serena__search_for_pattern substring_pattern="keyword" relative_path="src/" +``` + +### 5. リファレンス検索 + +``` +mcp__plugin_mcp-serena_serena__find_referencing_symbols name_path="/SymbolName" relative_path="src/" +``` + +## コード編集 + +### シンボル単位の置換 + +``` +mcp__plugin_mcp-serena_serena__replace_symbol_body name_path="/Class/method" relative_path="file.py" body="new code" +``` + +### シンボルの前後に挿入 + +``` +mcp__plugin_mcp-serena_serena__insert_before_symbol name_path="/FirstSymbol" relative_path="file.py" content="new code" +mcp__plugin_mcp-serena_serena__insert_after_symbol name_path="/LastSymbol" relative_path="file.py" content="new code" +``` + +### シンボル名の変更 + +``` +mcp__plugin_mcp-serena_serena__rename_symbol name_path="/OldName" relative_path="file.py" new_name="NewName" +``` + +## 使い分けの原則 + +- ファイル全体を読む前に `get_symbols_overview` で概要を確認 +- シンボル名が分かっている場合は `find_symbol` で直接アクセス +- シンボル名が不明な場合は `search_for_pattern` で候補を特定 +- 編集は `replace_symbol_body` でシンボル単位で行う(行ベース編集より安全) diff --git a/plugins/mcp/codex/mcp-serena/hooks/hooks.json b/plugins/mcp/codex/mcp-serena/hooks/hooks.json new file mode 100644 index 0000000..171ecaf --- /dev/null +++ b/plugins/mcp/codex/mcp-serena/hooks/hooks.json @@ -0,0 +1,19 @@ +{ + "description": "mcp-serena: Auto-activate Serena on session start", + "hooks": { + "SessionStart": [ + { + "description": "Remind to activate Serena project", + "hooks": [ + { + "type": "command", + "command": "echo '[mcp-serena] Serena MCPが利用可能です。activate_project でプロジェクトをアクティベートしてください。'", + "description": "Output Serena activation reminder", + "continueOnError": true, + "suppressOutput": false + } + ] + } + ] + } +} diff --git a/plugins/mcp/kiro/mcp-aws-docs/.mcp.json b/plugins/mcp/kiro/mcp-aws-docs/.mcp.json new file mode 100644 index 0000000..01ff167 --- /dev/null +++ b/plugins/mcp/kiro/mcp-aws-docs/.mcp.json @@ -0,0 +1,13 @@ +{ + "mcpServers": { + "awslabs.aws-docs": { + "type": "stdio", + "command": "uvx", + "args": [ + "awslabs.aws-documentation-mcp-server@latest" + ], + "env": {}, + "envFile": "${workspaceFolder}/.env" + } + } +} diff --git a/plugins/mcp/kiro/mcp-aws-docs/README.md b/plugins/mcp/kiro/mcp-aws-docs/README.md new file mode 100644 index 0000000..4e08ecb --- /dev/null +++ b/plugins/mcp/kiro/mcp-aws-docs/README.md @@ -0,0 +1,65 @@ +# AWS Documentation MCP + +AWS公式ドキュメントにアクセスするためのMCPサーバープラグインです。 + +## 概要 + +このプラグインは、AWS公式ドキュメントを検索・取得する機能を提供します。 + +## 機能 + +- AWS公式ドキュメントの検索 +- ドキュメントページの取得 +- 関連ドキュメントの推薦 +- サービス別ドキュメントへのアクセス + +## インストール + +### Kiro CLI + +Kiro では repository clone 後、対象 plugin の installer を project root で実行します。 + +```bash +bash plugins/mcp/kiro/mcp-aws-docs/install.sh +``` + +### 必要な環境変数 + +- 追加の環境変数は不要です。 + +## 使用方法 + +### 基本的な使用例 + +```bash +# AWS Lambdaのドキュメントを検索 +mcp__plugin_aws-docs-mcp__awslabs.aws-docs__search_documentation "AWS Lambda best practices" + +# 特定のドキュメントページを読み込み +mcp__plugin_aws-docs-mcp__awslabs.aws-docs__read_documentation "https://docs.aws.amazon.com/lambda/..." +``` + +## 推奨される使用シーン + +- AWSサービスの調査 +- ベストプラクティスの確認 +- API仕様の参照 +- トラブルシューティング + +## ndf:researcherエージェントとの連携 + +AWS Docs MCPは、NDFプラグインの`ndf:researcher`エージェントと連携して使用することを推奨します。 + +```bash +# researcherエージェントにAWSドキュメント調査を依頼 +Task( + subagent_type="ndf:researcher", + prompt="Research AWS Lambda best practices for performance", + description="Research Lambda practices" +) +``` + +## 参考リンク + +- [aws-documentation-mcp-server](https://pypi.org/project/awslabs.aws-documentation-mcp-server/) +- [AWS Documentation](https://docs.aws.amazon.com/) diff --git a/plugins/mcp/kiro/mcp-aws-docs/docs/aws-docs-guide.md b/plugins/mcp/kiro/mcp-aws-docs/docs/aws-docs-guide.md new file mode 100644 index 0000000..57a2328 --- /dev/null +++ b/plugins/mcp/kiro/mcp-aws-docs/docs/aws-docs-guide.md @@ -0,0 +1,29 @@ +# AWS Docs MCP ガイド + +AWS Documentation MCPサーバーでAWS公式ドキュメントを検索・参照します。 + +## 主要機能 + +- AWSサービスのドキュメント検索 +- ベストプラクティスの参照 +- API仕様の確認 +- トラブルシューティング情報の取得 + +## 用途 + +- **researcherエージェント**: AWS公式ドキュメントの調査、ベストプラクティスの確認 + +## 使用例 + +``` +# Lambda関数のベストプラクティスを検索 +mcp__plugin_mcp-aws-docs_awslabs.aws-docs__search query="Lambda best practices" + +# S3バケットポリシーのドキュメントを参照 +mcp__plugin_mcp-aws-docs_awslabs.aws-docs__search query="S3 bucket policy" +``` + +## 注意事項 + +- 認証情報は不要(公開ドキュメントへのアクセス) +- 静的な公式ドキュメントの参照に特化。動的コンテンツにはChrome DevTools MCPを使用 diff --git a/plugins/mcp/kiro/mcp-aws-docs/install.sh b/plugins/mcp/kiro/mcp-aws-docs/install.sh new file mode 100755 index 0000000..26e6b00 --- /dev/null +++ b/plugins/mcp/kiro/mcp-aws-docs/install.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PLUGIN_NAME="$(basename "$SCRIPT_DIR")" +PROJECT_ROOT="$(pwd)" +KIRO_DIR="$PROJECT_ROOT/.kiro" +KIRO_AGENT_FILE="$KIRO_DIR/agents/default.json" +KIRO_PLUGIN_LINK="$KIRO_DIR/mcp_runtime/$PLUGIN_NAME" +KIRO_SKILLS_DIR="$KIRO_DIR/skills" +TARGET_MCP="$PROJECT_ROOT/.mcp.json" +SOURCE_MCP="$SCRIPT_DIR/.mcp.json" +SOURCE_HOOKS="$SCRIPT_DIR/hooks/hooks.json" +SOURCE_SKILLS="$SCRIPT_DIR/skills" +DRY_RUN=false + +usage() { + cat <<'USAGE' +Usage: bash install.sh [--project PATH] [--dry-run] + +Install this MCP plugin into the current project's Kiro settings. +USAGE +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --project) + [ "$#" -ge 2 ] || { echo "ERROR: --project requires a path" >&2; exit 2; } + PROJECT_ROOT="$(cd "$2" && pwd)" + KIRO_DIR="$PROJECT_ROOT/.kiro" + KIRO_AGENT_FILE="$KIRO_DIR/agents/default.json" + KIRO_PLUGIN_LINK="$KIRO_DIR/mcp_runtime/$PLUGIN_NAME" + KIRO_SKILLS_DIR="$KIRO_DIR/skills" + TARGET_MCP="$PROJECT_ROOT/.mcp.json" + shift + ;; + --dry-run) DRY_RUN=true ;; + -h|--help) usage; exit 0 ;; + *) echo "Unknown option: $1" >&2; usage >&2; exit 2 ;; + esac + shift +done + +if [ ! -f "$SOURCE_MCP" ]; then + echo "ERROR: source .mcp.json not found: $SOURCE_MCP" >&2 + exit 1 +fi + +if [ "$DRY_RUN" = true ]; then + echo "Would merge $SOURCE_MCP into $TARGET_MCP" + echo "Would link plugin runtime into $KIRO_PLUGIN_LINK" + if [ -d "$SOURCE_SKILLS" ]; then + echo "Would link skills from $SOURCE_SKILLS into $KIRO_SKILLS_DIR" + fi + if [ -f "$SOURCE_HOOKS" ]; then + echo "Would merge hooks from $SOURCE_HOOKS into $KIRO_AGENT_FILE" + fi + exit 0 +fi + +mkdir -p "$KIRO_DIR/mcp_runtime" "$KIRO_SKILLS_DIR" "$(dirname "$KIRO_AGENT_FILE")" +if [ -e "$KIRO_PLUGIN_LINK" ] && [ ! -L "$KIRO_PLUGIN_LINK" ]; then + echo "ERROR: $KIRO_PLUGIN_LINK already exists and is not a symlink" >&2 + exit 1 +fi +ln -sfn "$SCRIPT_DIR" "$KIRO_PLUGIN_LINK" + +if [ -d "$SOURCE_SKILLS" ]; then + find "$SOURCE_SKILLS" -mindepth 1 -maxdepth 1 -type d | sort | while IFS= read -r skill_dir; do + skill_name="$(basename "$skill_dir")" + skill_link="$KIRO_SKILLS_DIR/$skill_name" + [ -f "$skill_dir/SKILL.md" ] || continue + if [ -e "$skill_link" ] && [ ! -L "$skill_link" ]; then + echo "ERROR: $skill_link already exists and is not a symlink" >&2 + exit 1 + fi + ln -sfn "$skill_dir" "$skill_link" + echo "Linked Kiro skill: $skill_name" + done +fi + +python3 - "$SOURCE_MCP" "$TARGET_MCP" "$SOURCE_HOOKS" "$KIRO_AGENT_FILE" <<'PY' +import json +import sys +from pathlib import Path + +source_path = Path(sys.argv[1]) +target_path = Path(sys.argv[2]) +hooks_path = Path(sys.argv[3]) +agent_path = Path(sys.argv[4]) +source = json.loads(source_path.read_text()) +target = json.loads(target_path.read_text()) if target_path.exists() else {} +source_servers = source.get("mcpServers", {}) +target.setdefault("mcpServers", {}) +target["mcpServers"].update(source_servers) +target_path.write_text(json.dumps(target, indent=2, ensure_ascii=False) + "\n") + +if agent_path.exists(): + agent = json.loads(agent_path.read_text()) +else: + agent = { + "name": "default", + "resources": ["skill://.kiro/skills/**/SKILL.md"], + } + +agent.setdefault("mcpServers", {}) +agent["mcpServers"].update(source_servers) + +if hooks_path.exists(): + source_hooks = json.loads(hooks_path.read_text()).get("hooks", {}) + target_hooks = agent.setdefault("hooks", {}) + event_map = { + "SessionStart": "agentSpawn", + "Stop": "stop", + } + for source_event, groups in source_hooks.items(): + target_event = event_map.get(source_event, source_event) + target_list = target_hooks.setdefault(target_event, []) + existing = { + entry.get("command") + for entry in target_list + if isinstance(entry, dict) + } + for group in groups: + for hook in group.get("hooks", []): + command = hook.get("command") + if not command or command in existing: + continue + entry = {"command": command} + if "timeout_ms" in hook: + entry["timeout_ms"] = hook["timeout_ms"] + target_list.append(entry) + existing.add(command) + +agent_path.write_text(json.dumps(agent, indent=2, ensure_ascii=False) + "\n") + +print(f"Installed MCP servers: {', '.join(sorted(source_servers))}") +print(f"Updated: {target_path}") +print(f"Updated: {agent_path}") +PY diff --git a/plugins/mcp/kiro/mcp-bigquery/.mcp.json b/plugins/mcp/kiro/mcp-bigquery/.mcp.json new file mode 100644 index 0000000..ed2ecd4 --- /dev/null +++ b/plugins/mcp/kiro/mcp-bigquery/.mcp.json @@ -0,0 +1,21 @@ +{ + "mcpServers": { + "bigquery": { + "type": "stdio", + "command": "uvx", + "args": [ + "mcp-server-bigquery@latest", + "--project", + "${BIGQUERY_PROJECT}", + "--location", + "${BIGQUERY_LOCATION}", + "--dataset", + "${BIGQUERY_DATASET}", + "--key-file", + "${BIGQUERY_KEY_FILE}" + ], + "env": {}, + "envFile": "${workspaceFolder}/.env" + } + } +} diff --git a/plugins/mcp/kiro/mcp-bigquery/README.md b/plugins/mcp/kiro/mcp-bigquery/README.md new file mode 100644 index 0000000..788fb8a --- /dev/null +++ b/plugins/mcp/kiro/mcp-bigquery/README.md @@ -0,0 +1,93 @@ +# BigQuery MCP + +BigQuery MCPサーバーをKiroで利用するためのプラグインです。 + +## 概要 + +このプラグインは、Google Cloud BigQueryへのアクセスを提供し、SQLクエリの実行やデータ分析を可能にします。 + +## 機能 + +- BigQueryテーブルの一覧取得 +- SQLクエリの実行 +- クエリ結果の取得 +- テーブルスキーマの取得 +- データセットの操作 + +## 前提条件 + +- Google Cloud Projectの作成 +- BigQuery APIの有効化 +- サービスアカウントキーの作成 + +## 環境変数の設定 + +プロジェクトルートに`.env`ファイルを作成し、以下の環境変数を設定してください: + +```bash +# BigQuery Project ID +BIGQUERY_PROJECT=your-project-id + +# BigQuery Location (例: asia-northeast1) +BIGQUERY_LOCATION=asia-northeast1 + +# 利用するデータセット(カンマ区切り) +BIGQUERY_DATASETS=dataset1,dataset2 + +# サービスアカウントキーファイルのパス +BIGQUERY_KEY_FILE=/path/to/service-account-key.json +``` + +## インストール + +### Kiro CLI + +Kiro では repository clone 後、対象 plugin の installer を project root で実行します。 + +```bash +bash plugins/mcp/kiro/mcp-bigquery/install.sh +``` + +### 必要な環境変数 + +- `BIGQUERY_DATASET` +- `BIGQUERY_KEY_FILE` +- `BIGQUERY_LOCATION` +- `BIGQUERY_PROJECT` + +## 使用方法 + +### 基本的な使用例 + +```bash +# テーブル一覧を取得 +mcp__plugin_bigquery-mcp__bigquery__list_tables + +# SQLクエリを実行 +mcp__plugin_bigquery-mcp__bigquery__execute_query "SELECT * FROM dataset.table LIMIT 10" +``` + +## 推奨される使用シーン + +- データ分析 +- レポート生成 +- SQLクエリの最適化 +- データ抽出 + +## ndf:data-analystエージェントとの連携 + +BigQuery MCPは、NDFプラグインの`ndf:data-analyst`エージェントと連携して使用することを推奨します。 + +```bash +# data-analystエージェントにBigQueryクエリを依頼 +Task( + subagent_type="ndf:data-analyst", + prompt="Analyze last month's sales data in BigQuery", + description="Analyze sales data" +) +``` + +## 参考リンク + +- [mcp-server-bigquery](https://pypi.org/project/mcp-server-bigquery/) +- [Google Cloud BigQuery](https://cloud.google.com/bigquery) diff --git a/plugins/mcp/kiro/mcp-bigquery/docs/bigquery-guide.md b/plugins/mcp/kiro/mcp-bigquery/docs/bigquery-guide.md new file mode 100644 index 0000000..2e7d1fd --- /dev/null +++ b/plugins/mcp/kiro/mcp-bigquery/docs/bigquery-guide.md @@ -0,0 +1,31 @@ +# BigQuery MCP ガイド + +BigQuery MCPサーバーでGoogle BigQueryのデータ分析を行います。 + +## 環境変数(必須) + +| 変数 | 説明 | +|------|------| +| `BIGQUERY_PROJECT` | GCPプロジェクトID | +| `BIGQUERY_LOCATION` | データセットのロケーション(例: `asia-northeast1`) | +| `BIGQUERY_DATASET` | デフォルトデータセット名 | +| `BIGQUERY_KEY_FILE` | サービスアカウントキーファイルのパス | + +## 主要ツール + +- `execute-query` - SQLクエリを実行し結果を取得 +- `list-tables` - データセット内のテーブル一覧を取得 +- `describe-table` - テーブルのスキーマ情報を取得 + +## 使用例 + +``` +# テーブル一覧の取得 +mcp__plugin_mcp-bigquery_bigquery__list-tables + +# テーブルのスキーマ確認 +mcp__plugin_mcp-bigquery_bigquery__describe-table table_name="users" + +# SQLクエリ実行 +mcp__plugin_mcp-bigquery_bigquery__execute-query query="SELECT * FROM users LIMIT 10" +``` diff --git a/plugins/mcp/kiro/mcp-bigquery/install.sh b/plugins/mcp/kiro/mcp-bigquery/install.sh new file mode 100755 index 0000000..26e6b00 --- /dev/null +++ b/plugins/mcp/kiro/mcp-bigquery/install.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PLUGIN_NAME="$(basename "$SCRIPT_DIR")" +PROJECT_ROOT="$(pwd)" +KIRO_DIR="$PROJECT_ROOT/.kiro" +KIRO_AGENT_FILE="$KIRO_DIR/agents/default.json" +KIRO_PLUGIN_LINK="$KIRO_DIR/mcp_runtime/$PLUGIN_NAME" +KIRO_SKILLS_DIR="$KIRO_DIR/skills" +TARGET_MCP="$PROJECT_ROOT/.mcp.json" +SOURCE_MCP="$SCRIPT_DIR/.mcp.json" +SOURCE_HOOKS="$SCRIPT_DIR/hooks/hooks.json" +SOURCE_SKILLS="$SCRIPT_DIR/skills" +DRY_RUN=false + +usage() { + cat <<'USAGE' +Usage: bash install.sh [--project PATH] [--dry-run] + +Install this MCP plugin into the current project's Kiro settings. +USAGE +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --project) + [ "$#" -ge 2 ] || { echo "ERROR: --project requires a path" >&2; exit 2; } + PROJECT_ROOT="$(cd "$2" && pwd)" + KIRO_DIR="$PROJECT_ROOT/.kiro" + KIRO_AGENT_FILE="$KIRO_DIR/agents/default.json" + KIRO_PLUGIN_LINK="$KIRO_DIR/mcp_runtime/$PLUGIN_NAME" + KIRO_SKILLS_DIR="$KIRO_DIR/skills" + TARGET_MCP="$PROJECT_ROOT/.mcp.json" + shift + ;; + --dry-run) DRY_RUN=true ;; + -h|--help) usage; exit 0 ;; + *) echo "Unknown option: $1" >&2; usage >&2; exit 2 ;; + esac + shift +done + +if [ ! -f "$SOURCE_MCP" ]; then + echo "ERROR: source .mcp.json not found: $SOURCE_MCP" >&2 + exit 1 +fi + +if [ "$DRY_RUN" = true ]; then + echo "Would merge $SOURCE_MCP into $TARGET_MCP" + echo "Would link plugin runtime into $KIRO_PLUGIN_LINK" + if [ -d "$SOURCE_SKILLS" ]; then + echo "Would link skills from $SOURCE_SKILLS into $KIRO_SKILLS_DIR" + fi + if [ -f "$SOURCE_HOOKS" ]; then + echo "Would merge hooks from $SOURCE_HOOKS into $KIRO_AGENT_FILE" + fi + exit 0 +fi + +mkdir -p "$KIRO_DIR/mcp_runtime" "$KIRO_SKILLS_DIR" "$(dirname "$KIRO_AGENT_FILE")" +if [ -e "$KIRO_PLUGIN_LINK" ] && [ ! -L "$KIRO_PLUGIN_LINK" ]; then + echo "ERROR: $KIRO_PLUGIN_LINK already exists and is not a symlink" >&2 + exit 1 +fi +ln -sfn "$SCRIPT_DIR" "$KIRO_PLUGIN_LINK" + +if [ -d "$SOURCE_SKILLS" ]; then + find "$SOURCE_SKILLS" -mindepth 1 -maxdepth 1 -type d | sort | while IFS= read -r skill_dir; do + skill_name="$(basename "$skill_dir")" + skill_link="$KIRO_SKILLS_DIR/$skill_name" + [ -f "$skill_dir/SKILL.md" ] || continue + if [ -e "$skill_link" ] && [ ! -L "$skill_link" ]; then + echo "ERROR: $skill_link already exists and is not a symlink" >&2 + exit 1 + fi + ln -sfn "$skill_dir" "$skill_link" + echo "Linked Kiro skill: $skill_name" + done +fi + +python3 - "$SOURCE_MCP" "$TARGET_MCP" "$SOURCE_HOOKS" "$KIRO_AGENT_FILE" <<'PY' +import json +import sys +from pathlib import Path + +source_path = Path(sys.argv[1]) +target_path = Path(sys.argv[2]) +hooks_path = Path(sys.argv[3]) +agent_path = Path(sys.argv[4]) +source = json.loads(source_path.read_text()) +target = json.loads(target_path.read_text()) if target_path.exists() else {} +source_servers = source.get("mcpServers", {}) +target.setdefault("mcpServers", {}) +target["mcpServers"].update(source_servers) +target_path.write_text(json.dumps(target, indent=2, ensure_ascii=False) + "\n") + +if agent_path.exists(): + agent = json.loads(agent_path.read_text()) +else: + agent = { + "name": "default", + "resources": ["skill://.kiro/skills/**/SKILL.md"], + } + +agent.setdefault("mcpServers", {}) +agent["mcpServers"].update(source_servers) + +if hooks_path.exists(): + source_hooks = json.loads(hooks_path.read_text()).get("hooks", {}) + target_hooks = agent.setdefault("hooks", {}) + event_map = { + "SessionStart": "agentSpawn", + "Stop": "stop", + } + for source_event, groups in source_hooks.items(): + target_event = event_map.get(source_event, source_event) + target_list = target_hooks.setdefault(target_event, []) + existing = { + entry.get("command") + for entry in target_list + if isinstance(entry, dict) + } + for group in groups: + for hook in group.get("hooks", []): + command = hook.get("command") + if not command or command in existing: + continue + entry = {"command": command} + if "timeout_ms" in hook: + entry["timeout_ms"] = hook["timeout_ms"] + target_list.append(entry) + existing.add(command) + +agent_path.write_text(json.dumps(agent, indent=2, ensure_ascii=False) + "\n") + +print(f"Installed MCP servers: {', '.join(sorted(source_servers))}") +print(f"Updated: {target_path}") +print(f"Updated: {agent_path}") +PY diff --git a/plugins/mcp/kiro/mcp-chrome-devtools/.mcp.json b/plugins/mcp/kiro/mcp-chrome-devtools/.mcp.json new file mode 100644 index 0000000..88e4c76 --- /dev/null +++ b/plugins/mcp/kiro/mcp-chrome-devtools/.mcp.json @@ -0,0 +1,17 @@ +{ + "mcpServers": { + "chrome-devtools": { + "type": "stdio", + "command": "npx", + "args": [ + "-y", + "chrome-devtools-mcp@latest", + "--isolated", + "--chromeArg=--no-sandbox", + "--chromeArg=--disable-setuid-sandbox", + "--chromeArg=--disable-dev-shm-usage" + ], + "envFile": "${workspaceFolder}/.env" + } + } +} diff --git a/plugins/mcp/kiro/mcp-chrome-devtools/README.md b/plugins/mcp/kiro/mcp-chrome-devtools/README.md new file mode 100644 index 0000000..aee4d1a --- /dev/null +++ b/plugins/mcp/kiro/mcp-chrome-devtools/README.md @@ -0,0 +1,57 @@ +# Chrome DevTools MCP + +Chrome DevTools MCPサーバーをKiroで利用するためのプラグインです。 + +## 概要 + +このプラグインは、ブラウザ自動化とテストのためのChrome DevTools Protocol (CDP)へのアクセスを提供します。 + +## 機能 + +- ブラウザの自動操作 +- スクリーンショット撮影 +- ページナビゲーション +- JavaScriptの実行 +- ネットワークリクエストの監視 +- コンソールログの取得 + +## インストール + +### Kiro CLI + +Kiro では repository clone 後、対象 plugin の installer を project root で実行します。 + +```bash +bash plugins/mcp/kiro/mcp-chrome-devtools/install.sh +``` + +### 必要な環境変数 + +- 追加の環境変数は不要です。 + +## 使用方法 + +### 基本的な使用例 + +```bash +# ページにアクセスしてスクリーンショットを撮影 +mcp__plugin_chrome-devtools-mcp__chrome-devtools__navigate_page "https://example.com" +mcp__plugin_chrome-devtools-mcp__chrome-devtools__take_screenshot +``` + +## 推奨される使用シーン + +- Webアプリケーションのテスト +- スクレイピング +- パフォーマンス測定 +- UIの自動検証 + +## 注意事項 + +- Dockerコンテナ内で実行する場合、`--no-sandbox`オプションが必要です +- DinD/DooD環境では接続方法が異なる場合があります(`ndf:docker-container-access` Skillを参照) + +## 参考リンク + +- [chrome-devtools-mcp](https://www.npmjs.com/package/chrome-devtools-mcp) +- [Chrome DevTools Protocol](https://chromedevtools.github.io/devtools-protocol/) diff --git a/plugins/mcp/kiro/mcp-chrome-devtools/docs/chrome-devtools-guide.md b/plugins/mcp/kiro/mcp-chrome-devtools/docs/chrome-devtools-guide.md new file mode 100644 index 0000000..466323f --- /dev/null +++ b/plugins/mcp/kiro/mcp-chrome-devtools/docs/chrome-devtools-guide.md @@ -0,0 +1,21 @@ +# Chrome DevTools MCP ガイド + +Chrome DevTools MCPサーバーでブラウザの操作・デバッグ・パフォーマンス測定を行います。 + +## 主要機能 + +- ページナビゲーション・DOM操作 +- ネットワークリクエストの監視 +- コンソールログの取得 +- パフォーマンスプロファイリング +- スクリーンショット取得 + +## 用途 + +- **researcherエージェント**: JavaScript動的コンテンツの取得、Webスクレイピング +- **qaエージェント**: Webアプリケーションのパフォーマンス測定、UI検証 + +## 注意事項 + +- Docker環境(DinD/DooD)では`--no-sandbox`オプションが必要(設定済み) +- localhostへのアクセスはDocker環境に依存。詳細は `docker-container-access` スキルを参照 diff --git a/plugins/mcp/kiro/mcp-chrome-devtools/install.sh b/plugins/mcp/kiro/mcp-chrome-devtools/install.sh new file mode 100755 index 0000000..26e6b00 --- /dev/null +++ b/plugins/mcp/kiro/mcp-chrome-devtools/install.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PLUGIN_NAME="$(basename "$SCRIPT_DIR")" +PROJECT_ROOT="$(pwd)" +KIRO_DIR="$PROJECT_ROOT/.kiro" +KIRO_AGENT_FILE="$KIRO_DIR/agents/default.json" +KIRO_PLUGIN_LINK="$KIRO_DIR/mcp_runtime/$PLUGIN_NAME" +KIRO_SKILLS_DIR="$KIRO_DIR/skills" +TARGET_MCP="$PROJECT_ROOT/.mcp.json" +SOURCE_MCP="$SCRIPT_DIR/.mcp.json" +SOURCE_HOOKS="$SCRIPT_DIR/hooks/hooks.json" +SOURCE_SKILLS="$SCRIPT_DIR/skills" +DRY_RUN=false + +usage() { + cat <<'USAGE' +Usage: bash install.sh [--project PATH] [--dry-run] + +Install this MCP plugin into the current project's Kiro settings. +USAGE +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --project) + [ "$#" -ge 2 ] || { echo "ERROR: --project requires a path" >&2; exit 2; } + PROJECT_ROOT="$(cd "$2" && pwd)" + KIRO_DIR="$PROJECT_ROOT/.kiro" + KIRO_AGENT_FILE="$KIRO_DIR/agents/default.json" + KIRO_PLUGIN_LINK="$KIRO_DIR/mcp_runtime/$PLUGIN_NAME" + KIRO_SKILLS_DIR="$KIRO_DIR/skills" + TARGET_MCP="$PROJECT_ROOT/.mcp.json" + shift + ;; + --dry-run) DRY_RUN=true ;; + -h|--help) usage; exit 0 ;; + *) echo "Unknown option: $1" >&2; usage >&2; exit 2 ;; + esac + shift +done + +if [ ! -f "$SOURCE_MCP" ]; then + echo "ERROR: source .mcp.json not found: $SOURCE_MCP" >&2 + exit 1 +fi + +if [ "$DRY_RUN" = true ]; then + echo "Would merge $SOURCE_MCP into $TARGET_MCP" + echo "Would link plugin runtime into $KIRO_PLUGIN_LINK" + if [ -d "$SOURCE_SKILLS" ]; then + echo "Would link skills from $SOURCE_SKILLS into $KIRO_SKILLS_DIR" + fi + if [ -f "$SOURCE_HOOKS" ]; then + echo "Would merge hooks from $SOURCE_HOOKS into $KIRO_AGENT_FILE" + fi + exit 0 +fi + +mkdir -p "$KIRO_DIR/mcp_runtime" "$KIRO_SKILLS_DIR" "$(dirname "$KIRO_AGENT_FILE")" +if [ -e "$KIRO_PLUGIN_LINK" ] && [ ! -L "$KIRO_PLUGIN_LINK" ]; then + echo "ERROR: $KIRO_PLUGIN_LINK already exists and is not a symlink" >&2 + exit 1 +fi +ln -sfn "$SCRIPT_DIR" "$KIRO_PLUGIN_LINK" + +if [ -d "$SOURCE_SKILLS" ]; then + find "$SOURCE_SKILLS" -mindepth 1 -maxdepth 1 -type d | sort | while IFS= read -r skill_dir; do + skill_name="$(basename "$skill_dir")" + skill_link="$KIRO_SKILLS_DIR/$skill_name" + [ -f "$skill_dir/SKILL.md" ] || continue + if [ -e "$skill_link" ] && [ ! -L "$skill_link" ]; then + echo "ERROR: $skill_link already exists and is not a symlink" >&2 + exit 1 + fi + ln -sfn "$skill_dir" "$skill_link" + echo "Linked Kiro skill: $skill_name" + done +fi + +python3 - "$SOURCE_MCP" "$TARGET_MCP" "$SOURCE_HOOKS" "$KIRO_AGENT_FILE" <<'PY' +import json +import sys +from pathlib import Path + +source_path = Path(sys.argv[1]) +target_path = Path(sys.argv[2]) +hooks_path = Path(sys.argv[3]) +agent_path = Path(sys.argv[4]) +source = json.loads(source_path.read_text()) +target = json.loads(target_path.read_text()) if target_path.exists() else {} +source_servers = source.get("mcpServers", {}) +target.setdefault("mcpServers", {}) +target["mcpServers"].update(source_servers) +target_path.write_text(json.dumps(target, indent=2, ensure_ascii=False) + "\n") + +if agent_path.exists(): + agent = json.loads(agent_path.read_text()) +else: + agent = { + "name": "default", + "resources": ["skill://.kiro/skills/**/SKILL.md"], + } + +agent.setdefault("mcpServers", {}) +agent["mcpServers"].update(source_servers) + +if hooks_path.exists(): + source_hooks = json.loads(hooks_path.read_text()).get("hooks", {}) + target_hooks = agent.setdefault("hooks", {}) + event_map = { + "SessionStart": "agentSpawn", + "Stop": "stop", + } + for source_event, groups in source_hooks.items(): + target_event = event_map.get(source_event, source_event) + target_list = target_hooks.setdefault(target_event, []) + existing = { + entry.get("command") + for entry in target_list + if isinstance(entry, dict) + } + for group in groups: + for hook in group.get("hooks", []): + command = hook.get("command") + if not command or command in existing: + continue + entry = {"command": command} + if "timeout_ms" in hook: + entry["timeout_ms"] = hook["timeout_ms"] + target_list.append(entry) + existing.add(command) + +agent_path.write_text(json.dumps(agent, indent=2, ensure_ascii=False) + "\n") + +print(f"Installed MCP servers: {', '.join(sorted(source_servers))}") +print(f"Updated: {target_path}") +print(f"Updated: {agent_path}") +PY diff --git a/plugins/mcp/kiro/mcp-dbhub/.env.example b/plugins/mcp/kiro/mcp-dbhub/.env.example new file mode 100644 index 0000000..007c192 --- /dev/null +++ b/plugins/mcp/kiro/mcp-dbhub/.env.example @@ -0,0 +1,12 @@ +# .env - DBHub環境変数設定 +# このファイルを .env にコピーしてプロジェクトルートに配置してください。 +# dbhub.toml が存在しない場合のフォールバックとして使用されます。 + +# データベース接続文字列 +DBHUB_DSN=mysql://user:password@db-host:3306/dbname + +# SSH踏み台設定(必要な場合のみ) +# DBHUB_SSH_HOST=bastion.example.com +# DBHUB_SSH_PORT=22 +# DBHUB_SSH_USER=ubuntu +# DBHUB_SSH_KEY=~/.ssh/id_rsa diff --git a/plugins/mcp/kiro/mcp-dbhub/.mcp.json b/plugins/mcp/kiro/mcp-dbhub/.mcp.json new file mode 100644 index 0000000..dc078ac --- /dev/null +++ b/plugins/mcp/kiro/mcp-dbhub/.mcp.json @@ -0,0 +1,12 @@ +{ + "mcpServers": { + "dbhub": { + "type": "stdio", + "command": "npx", + "args": [ + "--yes", + "@bytebase/dbhub" + ] + } + } +} diff --git a/plugins/mcp/kiro/mcp-dbhub/README.md b/plugins/mcp/kiro/mcp-dbhub/README.md new file mode 100644 index 0000000..a01c0b2 --- /dev/null +++ b/plugins/mcp/kiro/mcp-dbhub/README.md @@ -0,0 +1,155 @@ +# DBHub MCP + +データベース操作とSQLクエリを実行するためのMCPサーバープラグインです。 + +## 概要 + +このプラグインは、MySQL、PostgreSQL、SQLiteなどの各種データベースへのアクセスを提供します。 + +## 機能 + +- データベース接続管理 +- SQLクエリの実行 +- テーブルスキーマの取得 +- データベース一覧の取得 +- 複数データベースへの同時接続(TOML設定) + +## インストール + +### Kiro CLI + +Kiro では repository clone 後、対象 plugin の installer を project root で実行します。 + +```bash +bash plugins/mcp/kiro/mcp-dbhub/install.sh +``` + +### 必要な環境変数 + +- 追加の環境変数は不要です。 + +## 設定 + +2つの設定方法があり、`dbhub.toml`が優先されます。 + +### 方法1: dbhub.toml(複数DB対応・推奨) + +プロジェクトルートに `dbhub.toml` を配置すると自動検出されます。 + +```toml +# 単一データベース +[[sources]] +id = "mydb" +description = "本番 MySQL" +dsn = "mysql://user:password@db-host:3306/dbname" +``` + +```toml +# 複数データベース +[[sources]] +id = "production" +description = "本番 MySQL" +dsn = "mysql://user:password@prod-db:3306/myapp" + +[[sources]] +id = "staging" +description = "ステージング MySQL" +dsn = "mysql://user:password@staging-db:3306/myapp_stg" +``` + +複数ソース定義時、ツール名に自動でサフィックスが付与されます(例: `execute_sql_production`, `execute_sql_staging`)。 + +#### SSH踏み台設定(TOML内) + +ソースごとにSSH踏み台を定義できます。 + +```toml +[[sources]] +id = "production" +description = "本番 MySQL(踏み台経由)" +dsn = "mysql://user:password@db.internal:3306/myapp" +ssh_host = "bastion.example.com" +ssh_port = 22 +ssh_user = "ubuntu" +ssh_key = "~/.ssh/id_rsa" +# ssh_passphrase = "鍵のパスフレーズ(暗号化されている場合)" +``` + +マルチホップSSH(多段踏み台)にも対応しています。 + +```toml +[[sources]] +id = "prod_multihop" +dsn = "postgres://user:password@10.0.1.100:5432/myapp" +ssh_host = "internal-server.company.com" +ssh_user = "deploy" +ssh_key = "~/.ssh/id_ed25519" +ssh_proxy_jump = "bastion.company.com,admin@jump2.internal:2222" +``` + +| フィールド | 必須 | 説明 | +|-----------|------|------| +| `ssh_host` | Yes | 踏み台ホスト名 | +| `ssh_port` | No | ポート(デフォルト: 22) | +| `ssh_user` | Yes | SSHユーザー名 | +| `ssh_key` | ※ | 秘密鍵パス(`~`展開可) | +| `ssh_password` | ※ | パスワード認証用 | +| `ssh_passphrase` | No | 秘密鍵のパスフレーズ | +| `ssh_proxy_jump` | No | マルチホップ(カンマ区切り) | + +※ `ssh_key` または `ssh_password` のいずれかが必須 + +### 方法2: .env(単一DB・簡易設定) + +`dbhub.toml`が存在しない場合、`.env`の環境変数にフォールバックします。 + +```bash +# データベース接続文字列 +DBHUB_DSN=mysql://user:password@db-host:3306/dbname + +# SSH踏み台(必要な場合) +DBHUB_SSH_HOST=bastion.example.com +DBHUB_SSH_PORT=22 +DBHUB_SSH_USER=ubuntu +DBHUB_SSH_KEY=~/.ssh/id_rsa +``` + +DSNのホスト名は踏み台から見た内部ホスト名を指定してください。 + +### 優先順位 + +1. `dbhub.toml`(プロジェクトルートに存在すれば自動読み込み) +2. `.env`の`DBHUB_DSN`環境変数(tomlが無い場合のフォールバック) + +## 使用例 + +```bash +# SQLクエリを実行 +mcp__plugin_mcp-dbhub_dbhub__execute_sql "SELECT * FROM users LIMIT 10" + +# テーブル一覧を取得 +mcp__plugin_mcp-dbhub_dbhub__search_objects --object_type table +``` + +## ndf:data-analystエージェントとの連携 + +DBHub MCPは、NDFプラグインの`ndf:data-analyst`エージェントと連携して使用することを推奨します。 + +```bash +Task( + subagent_type="ndf:data-analyst", + prompt="Analyze user activity in the local database", + description="Analyze user activity" +) +``` + +## 注意事項 + +- `dbhub.toml`と`.env`は`.gitignore`に追加し、認証情報をコミットしないでください +- SSH踏み台経由の場合、DSNのホスト名は踏み台から見た内部ホスト名を指定します + +## 参考リンク + +- [takemi-ohama/dbhub](https://github.com/takemi-ohama/dbhub) - 現在使用中のフォーク版(SSH keep-alive対応) +- [@bytebase/dbhub](https://www.npmjs.com/package/@bytebase/dbhub) - 本家パッケージ +- [Bytebase](https://www.bytebase.com/) diff --git a/plugins/mcp/kiro/mcp-dbhub/dbhub.toml.example b/plugins/mcp/kiro/mcp-dbhub/dbhub.toml.example new file mode 100644 index 0000000..4400680 --- /dev/null +++ b/plugins/mcp/kiro/mcp-dbhub/dbhub.toml.example @@ -0,0 +1,38 @@ +# dbhub.toml - データベース接続設定 +# このファイルを dbhub.toml にコピーしてプロジェクトルートに配置してください。 +# dbhub.toml は .gitignore に追加し、認証情報をコミットしないでください。 + +# 単一データベース(SSH踏み台なし) +[[sources]] +id = "mydb" +description = "開発用 MySQL" +dsn = "mysql://user:password@localhost:3306/mydb" + +# 単一データベース(SSH踏み台経由) +# [[sources]] +# id = "production" +# description = "本番 MySQL(踏み台経由)" +# dsn = "mysql://user:password@db.internal:3306/myapp" +# ssh_host = "bastion.example.com" +# ssh_port = 22 +# ssh_user = "ubuntu" +# ssh_key = "~/.ssh/id_rsa" + +# 複数データベース +# [[sources]] +# id = "production" +# description = "本番 MySQL" +# dsn = "mysql://user:password@prod-db.internal:3306/myapp" +# ssh_host = "bastion.example.com" +# ssh_port = 22 +# ssh_user = "ubuntu" +# ssh_key = "~/.ssh/id_rsa" +# +# [[sources]] +# id = "staging" +# description = "ステージング MySQL" +# dsn = "mysql://user:password@staging-db.internal:3306/myapp_stg" +# ssh_host = "bastion.example.com" +# ssh_port = 22 +# ssh_user = "ubuntu" +# ssh_key = "~/.ssh/id_rsa" diff --git a/plugins/mcp/kiro/mcp-dbhub/docs/dbhub-guide.md b/plugins/mcp/kiro/mcp-dbhub/docs/dbhub-guide.md new file mode 100644 index 0000000..1fb3d8e --- /dev/null +++ b/plugins/mcp/kiro/mcp-dbhub/docs/dbhub-guide.md @@ -0,0 +1,30 @@ +# DBHub MCP ガイド + +DBHub MCPサーバーで各種データベースへのSQL操作を行います。 + +## 接続設定 + +DBHubは接続文字列(DSN)でデータベースに接続します。 +環境変数またはツール引数で接続先を指定してください。 + +## 主要ツール + +- `execute_sql_{suffix}` - 指定環境でSQLクエリを実行 +- `search_objects_{suffix}` - データベースオブジェクト(テーブル、カラム等)を検索 + +suffixはインストール時の設定に依存します(例: `production`, `alpha`, `delta`)。 + +## 使用例 + +``` +# テーブル・カラムの検索 +mcp__plugin_mcp-dbhub_dbhub__search_objects_production keyword="users" + +# SQLクエリ実行 +mcp__plugin_mcp-dbhub_dbhub__execute_sql_production sql="SELECT * FROM users LIMIT 10" +``` + +## 注意事項 + +- 本番環境への書き込みクエリ(INSERT, UPDATE, DELETE)は慎重に実行してください +- 大量データの取得時はLIMIT句を使用してください diff --git a/plugins/mcp/kiro/mcp-dbhub/install.sh b/plugins/mcp/kiro/mcp-dbhub/install.sh new file mode 100755 index 0000000..26e6b00 --- /dev/null +++ b/plugins/mcp/kiro/mcp-dbhub/install.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PLUGIN_NAME="$(basename "$SCRIPT_DIR")" +PROJECT_ROOT="$(pwd)" +KIRO_DIR="$PROJECT_ROOT/.kiro" +KIRO_AGENT_FILE="$KIRO_DIR/agents/default.json" +KIRO_PLUGIN_LINK="$KIRO_DIR/mcp_runtime/$PLUGIN_NAME" +KIRO_SKILLS_DIR="$KIRO_DIR/skills" +TARGET_MCP="$PROJECT_ROOT/.mcp.json" +SOURCE_MCP="$SCRIPT_DIR/.mcp.json" +SOURCE_HOOKS="$SCRIPT_DIR/hooks/hooks.json" +SOURCE_SKILLS="$SCRIPT_DIR/skills" +DRY_RUN=false + +usage() { + cat <<'USAGE' +Usage: bash install.sh [--project PATH] [--dry-run] + +Install this MCP plugin into the current project's Kiro settings. +USAGE +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --project) + [ "$#" -ge 2 ] || { echo "ERROR: --project requires a path" >&2; exit 2; } + PROJECT_ROOT="$(cd "$2" && pwd)" + KIRO_DIR="$PROJECT_ROOT/.kiro" + KIRO_AGENT_FILE="$KIRO_DIR/agents/default.json" + KIRO_PLUGIN_LINK="$KIRO_DIR/mcp_runtime/$PLUGIN_NAME" + KIRO_SKILLS_DIR="$KIRO_DIR/skills" + TARGET_MCP="$PROJECT_ROOT/.mcp.json" + shift + ;; + --dry-run) DRY_RUN=true ;; + -h|--help) usage; exit 0 ;; + *) echo "Unknown option: $1" >&2; usage >&2; exit 2 ;; + esac + shift +done + +if [ ! -f "$SOURCE_MCP" ]; then + echo "ERROR: source .mcp.json not found: $SOURCE_MCP" >&2 + exit 1 +fi + +if [ "$DRY_RUN" = true ]; then + echo "Would merge $SOURCE_MCP into $TARGET_MCP" + echo "Would link plugin runtime into $KIRO_PLUGIN_LINK" + if [ -d "$SOURCE_SKILLS" ]; then + echo "Would link skills from $SOURCE_SKILLS into $KIRO_SKILLS_DIR" + fi + if [ -f "$SOURCE_HOOKS" ]; then + echo "Would merge hooks from $SOURCE_HOOKS into $KIRO_AGENT_FILE" + fi + exit 0 +fi + +mkdir -p "$KIRO_DIR/mcp_runtime" "$KIRO_SKILLS_DIR" "$(dirname "$KIRO_AGENT_FILE")" +if [ -e "$KIRO_PLUGIN_LINK" ] && [ ! -L "$KIRO_PLUGIN_LINK" ]; then + echo "ERROR: $KIRO_PLUGIN_LINK already exists and is not a symlink" >&2 + exit 1 +fi +ln -sfn "$SCRIPT_DIR" "$KIRO_PLUGIN_LINK" + +if [ -d "$SOURCE_SKILLS" ]; then + find "$SOURCE_SKILLS" -mindepth 1 -maxdepth 1 -type d | sort | while IFS= read -r skill_dir; do + skill_name="$(basename "$skill_dir")" + skill_link="$KIRO_SKILLS_DIR/$skill_name" + [ -f "$skill_dir/SKILL.md" ] || continue + if [ -e "$skill_link" ] && [ ! -L "$skill_link" ]; then + echo "ERROR: $skill_link already exists and is not a symlink" >&2 + exit 1 + fi + ln -sfn "$skill_dir" "$skill_link" + echo "Linked Kiro skill: $skill_name" + done +fi + +python3 - "$SOURCE_MCP" "$TARGET_MCP" "$SOURCE_HOOKS" "$KIRO_AGENT_FILE" <<'PY' +import json +import sys +from pathlib import Path + +source_path = Path(sys.argv[1]) +target_path = Path(sys.argv[2]) +hooks_path = Path(sys.argv[3]) +agent_path = Path(sys.argv[4]) +source = json.loads(source_path.read_text()) +target = json.loads(target_path.read_text()) if target_path.exists() else {} +source_servers = source.get("mcpServers", {}) +target.setdefault("mcpServers", {}) +target["mcpServers"].update(source_servers) +target_path.write_text(json.dumps(target, indent=2, ensure_ascii=False) + "\n") + +if agent_path.exists(): + agent = json.loads(agent_path.read_text()) +else: + agent = { + "name": "default", + "resources": ["skill://.kiro/skills/**/SKILL.md"], + } + +agent.setdefault("mcpServers", {}) +agent["mcpServers"].update(source_servers) + +if hooks_path.exists(): + source_hooks = json.loads(hooks_path.read_text()).get("hooks", {}) + target_hooks = agent.setdefault("hooks", {}) + event_map = { + "SessionStart": "agentSpawn", + "Stop": "stop", + } + for source_event, groups in source_hooks.items(): + target_event = event_map.get(source_event, source_event) + target_list = target_hooks.setdefault(target_event, []) + existing = { + entry.get("command") + for entry in target_list + if isinstance(entry, dict) + } + for group in groups: + for hook in group.get("hooks", []): + command = hook.get("command") + if not command or command in existing: + continue + entry = {"command": command} + if "timeout_ms" in hook: + entry["timeout_ms"] = hook["timeout_ms"] + target_list.append(entry) + existing.add(command) + +agent_path.write_text(json.dumps(agent, indent=2, ensure_ascii=False) + "\n") + +print(f"Installed MCP servers: {', '.join(sorted(source_servers))}") +print(f"Updated: {target_path}") +print(f"Updated: {agent_path}") +PY diff --git a/plugins/mcp/kiro/mcp-devin/.mcp.json b/plugins/mcp/kiro/mcp-devin/.mcp.json new file mode 100644 index 0000000..fe323ee --- /dev/null +++ b/plugins/mcp/kiro/mcp-devin/.mcp.json @@ -0,0 +1,15 @@ +{ + "mcpServers": { + "devin": { + "type": "http", + "url": "https://mcp.devin.ai/mcp", + "headers": { + "Authorization": "Bearer ${DEVIN_API_KEY}" + }, + "env": { + "DEVIN_API_KEY": "${DEVIN_API_KEY}" + }, + "envFile": "${workspaceFolder}/.env" + } + } +} diff --git a/plugins/mcp/kiro/mcp-devin/README.md b/plugins/mcp/kiro/mcp-devin/README.md new file mode 100644 index 0000000..b65175d --- /dev/null +++ b/plugins/mcp/kiro/mcp-devin/README.md @@ -0,0 +1,92 @@ +# Devin MCP + +リポジトリのドキュメントとコード知識にアクセスするためのMCPサーバープラグインです。 + +## 概要 + +このプラグインは、[Devin MCP](https://mcp.devin.ai/mcp)サーバーを利用して、GitHubなどのリポジトリのドキュメント構造や内容をAIエージェントから直接参照できるようにします。Devin APIキー(`DEVIN_API_KEY`)が必要です。 + +## 機能 + +- **`read_wiki_structure`** - リポジトリのドキュメントトピック一覧を取得 +- **`read_wiki_contents`** - ドキュメントの内容を閲覧 +- **`ask_question`** - リポジトリに関するAI質問回答 + +## 前提条件 + +- Devin APIキー(環境変数 `DEVIN_API_KEY`) + +## 環境変数の設定 + +プロジェクトルートの`.env`ファイルに以下を設定してください: + +```bash +# Devin API Key +DEVIN_API_KEY=your-devin-api-key +``` + +## インストール + +### Kiro CLI + +Kiro では repository clone 後、対象 plugin の installer を project root で実行します。 + +```bash +bash plugins/mcp/kiro/mcp-devin/install.sh +``` + +### 必要な環境変数 + +- `DEVIN_API_KEY` + +## 使用方法 + +### 基本的な使用例 + +```bash +# リポジトリのドキュメント構造を取得 +mcp__plugin_mcp-devin__devin__read_wiki_structure "owner/repo" + +# ドキュメント内容を読み取り +mcp__plugin_mcp-devin__devin__read_wiki_contents "owner/repo" "topic" + +# リポジトリについて質問 +mcp__plugin_mcp-devin__devin__ask_question "owner/repo" "How does authentication work?" +``` + +### CLIで直接追加する場合 + +```bash +claude mcp add -s user -t http devin https://mcp.devin.ai/mcp --header "Authorization: Bearer $DEVIN_API_KEY" +``` + +## 推奨される使用シーン + +- OSSライブラリの仕様・アーキテクチャ調査 +- 依存パッケージの内部実装の理解 +- リポジトリのドキュメント横断検索 +- 技術選定時のコード比較調査 + +## NDFエージェントとの連携 + +DeepWiki MCPは、NDFプラグインの`ndf:researcher`エージェントと連携して使用することを推奨します。 + +```bash +Task( + subagent_type="ndf:researcher", + prompt="DeepWikiを使ってlangchain/langchainリポジトリのRAG実装を調査してください", + description="Research RAG implementation" +) +``` + +## 注意事項 + +- `DEVIN_API_KEY` の設定が必要です +- HTTPトランスポートを使用しています(Devin公式MCPサーバー `https://mcp.devin.ai/mcp`) +- `Authorization: Bearer {DEVIN_API_KEY}` ヘッダーで認証します +- レート制限がある場合があります + +## 参考リンク + +- [DeepWiki](https://deepwiki.com) +- [DeepWiki MCP ドキュメント](https://docs.devin.ai/ja/work-with-devin/deepwiki-mcp) diff --git a/plugins/mcp/kiro/mcp-devin/install.sh b/plugins/mcp/kiro/mcp-devin/install.sh new file mode 100755 index 0000000..26e6b00 --- /dev/null +++ b/plugins/mcp/kiro/mcp-devin/install.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PLUGIN_NAME="$(basename "$SCRIPT_DIR")" +PROJECT_ROOT="$(pwd)" +KIRO_DIR="$PROJECT_ROOT/.kiro" +KIRO_AGENT_FILE="$KIRO_DIR/agents/default.json" +KIRO_PLUGIN_LINK="$KIRO_DIR/mcp_runtime/$PLUGIN_NAME" +KIRO_SKILLS_DIR="$KIRO_DIR/skills" +TARGET_MCP="$PROJECT_ROOT/.mcp.json" +SOURCE_MCP="$SCRIPT_DIR/.mcp.json" +SOURCE_HOOKS="$SCRIPT_DIR/hooks/hooks.json" +SOURCE_SKILLS="$SCRIPT_DIR/skills" +DRY_RUN=false + +usage() { + cat <<'USAGE' +Usage: bash install.sh [--project PATH] [--dry-run] + +Install this MCP plugin into the current project's Kiro settings. +USAGE +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --project) + [ "$#" -ge 2 ] || { echo "ERROR: --project requires a path" >&2; exit 2; } + PROJECT_ROOT="$(cd "$2" && pwd)" + KIRO_DIR="$PROJECT_ROOT/.kiro" + KIRO_AGENT_FILE="$KIRO_DIR/agents/default.json" + KIRO_PLUGIN_LINK="$KIRO_DIR/mcp_runtime/$PLUGIN_NAME" + KIRO_SKILLS_DIR="$KIRO_DIR/skills" + TARGET_MCP="$PROJECT_ROOT/.mcp.json" + shift + ;; + --dry-run) DRY_RUN=true ;; + -h|--help) usage; exit 0 ;; + *) echo "Unknown option: $1" >&2; usage >&2; exit 2 ;; + esac + shift +done + +if [ ! -f "$SOURCE_MCP" ]; then + echo "ERROR: source .mcp.json not found: $SOURCE_MCP" >&2 + exit 1 +fi + +if [ "$DRY_RUN" = true ]; then + echo "Would merge $SOURCE_MCP into $TARGET_MCP" + echo "Would link plugin runtime into $KIRO_PLUGIN_LINK" + if [ -d "$SOURCE_SKILLS" ]; then + echo "Would link skills from $SOURCE_SKILLS into $KIRO_SKILLS_DIR" + fi + if [ -f "$SOURCE_HOOKS" ]; then + echo "Would merge hooks from $SOURCE_HOOKS into $KIRO_AGENT_FILE" + fi + exit 0 +fi + +mkdir -p "$KIRO_DIR/mcp_runtime" "$KIRO_SKILLS_DIR" "$(dirname "$KIRO_AGENT_FILE")" +if [ -e "$KIRO_PLUGIN_LINK" ] && [ ! -L "$KIRO_PLUGIN_LINK" ]; then + echo "ERROR: $KIRO_PLUGIN_LINK already exists and is not a symlink" >&2 + exit 1 +fi +ln -sfn "$SCRIPT_DIR" "$KIRO_PLUGIN_LINK" + +if [ -d "$SOURCE_SKILLS" ]; then + find "$SOURCE_SKILLS" -mindepth 1 -maxdepth 1 -type d | sort | while IFS= read -r skill_dir; do + skill_name="$(basename "$skill_dir")" + skill_link="$KIRO_SKILLS_DIR/$skill_name" + [ -f "$skill_dir/SKILL.md" ] || continue + if [ -e "$skill_link" ] && [ ! -L "$skill_link" ]; then + echo "ERROR: $skill_link already exists and is not a symlink" >&2 + exit 1 + fi + ln -sfn "$skill_dir" "$skill_link" + echo "Linked Kiro skill: $skill_name" + done +fi + +python3 - "$SOURCE_MCP" "$TARGET_MCP" "$SOURCE_HOOKS" "$KIRO_AGENT_FILE" <<'PY' +import json +import sys +from pathlib import Path + +source_path = Path(sys.argv[1]) +target_path = Path(sys.argv[2]) +hooks_path = Path(sys.argv[3]) +agent_path = Path(sys.argv[4]) +source = json.loads(source_path.read_text()) +target = json.loads(target_path.read_text()) if target_path.exists() else {} +source_servers = source.get("mcpServers", {}) +target.setdefault("mcpServers", {}) +target["mcpServers"].update(source_servers) +target_path.write_text(json.dumps(target, indent=2, ensure_ascii=False) + "\n") + +if agent_path.exists(): + agent = json.loads(agent_path.read_text()) +else: + agent = { + "name": "default", + "resources": ["skill://.kiro/skills/**/SKILL.md"], + } + +agent.setdefault("mcpServers", {}) +agent["mcpServers"].update(source_servers) + +if hooks_path.exists(): + source_hooks = json.loads(hooks_path.read_text()).get("hooks", {}) + target_hooks = agent.setdefault("hooks", {}) + event_map = { + "SessionStart": "agentSpawn", + "Stop": "stop", + } + for source_event, groups in source_hooks.items(): + target_event = event_map.get(source_event, source_event) + target_list = target_hooks.setdefault(target_event, []) + existing = { + entry.get("command") + for entry in target_list + if isinstance(entry, dict) + } + for group in groups: + for hook in group.get("hooks", []): + command = hook.get("command") + if not command or command in existing: + continue + entry = {"command": command} + if "timeout_ms" in hook: + entry["timeout_ms"] = hook["timeout_ms"] + target_list.append(entry) + existing.add(command) + +agent_path.write_text(json.dumps(agent, indent=2, ensure_ascii=False) + "\n") + +print(f"Installed MCP servers: {', '.join(sorted(source_servers))}") +print(f"Updated: {target_path}") +print(f"Updated: {agent_path}") +PY diff --git a/plugins/mcp/kiro/mcp-markitdown/.mcp.json b/plugins/mcp/kiro/mcp-markitdown/.mcp.json new file mode 100644 index 0000000..844df81 --- /dev/null +++ b/plugins/mcp/kiro/mcp-markitdown/.mcp.json @@ -0,0 +1,11 @@ +{ + "mcpServers": { + "markitdown": { + "type": "stdio", + "command": "uvx", + "args": [ + "markitdown-mcp" + ] + } + } +} diff --git a/plugins/mcp/kiro/mcp-markitdown/README.md b/plugins/mcp/kiro/mcp-markitdown/README.md new file mode 100644 index 0000000..34f80b8 --- /dev/null +++ b/plugins/mcp/kiro/mcp-markitdown/README.md @@ -0,0 +1,59 @@ +# MarkItDown MCP + +各種ドキュメント(PDF、Office、画像など)をMarkdownに変換するMCPサーバープラグインです。 + +## 概要 + +[Microsoft MarkItDown](https://github.com/microsoft/markitdown) のMCPサーバーラッパーです。HTTP/HTTPS URL、ローカルファイル、Data URIを指定してドキュメントをMarkdownに変換できます。 + +## 機能 + +- `convert_to_markdown(uri)` ツールを提供 +- 対応フォーマット: PDF, Word (.docx), Excel (.xlsx), PowerPoint (.pptx), 画像, HTML, CSV, JSON, XML など +- HTTP/HTTPS URL、ローカルファイルパス(`file://`)、Data URI に対応 + +## インストール + +### Kiro CLI + +Kiro では repository clone 後、対象 plugin の installer を project root で実行します。 + +```bash +bash plugins/mcp/kiro/mcp-markitdown/install.sh +``` + +### 必要な環境変数 + +- 追加の環境変数は不要です。 + +## 使用例 + +```bash +# URLからドキュメントを変換 +mcp__plugin_mcp-markitdown_markitdown__convert_to_markdown uri="https://example.com/document.pdf" + +# ローカルファイルを変換 +mcp__plugin_mcp-markitdown_markitdown__convert_to_markdown uri="file:///path/to/document.docx" +``` + +## ndf:scannerエージェントとの連携 + +MarkItDown MCPは、NDFプラグインの`ndf:scanner`エージェントと連携して使用することを推奨します。 + +```bash +Task( + subagent_type="ndf:scanner", + prompt="Convert /path/to/document.pdf to Markdown and summarize key points.", + description="Convert and summarize PDF" +) +``` + +## 注意事項 + +- Python環境が必要です(`uvx` 経由で自動インストール) +- 大きなファイルの変換には時間がかかる場合があります + +## 参考リンク + +- [microsoft/markitdown](https://github.com/microsoft/markitdown) - 本家リポジトリ +- [markitdown-mcp (PyPI)](https://pypi.org/project/markitdown-mcp/) - PyPIパッケージ diff --git a/plugins/mcp/kiro/mcp-markitdown/docs/markitdown-guide.md b/plugins/mcp/kiro/mcp-markitdown/docs/markitdown-guide.md new file mode 100644 index 0000000..7ba0cf0 --- /dev/null +++ b/plugins/mcp/kiro/mcp-markitdown/docs/markitdown-guide.md @@ -0,0 +1,30 @@ +# MarkItDown MCP ガイド + +MarkItDown MCPサーバーで各種ドキュメントをMarkdown形式に変換します。 + +## 対応フォーマット + +- PDF +- Microsoft Office(Word, Excel, PowerPoint) +- 画像(OCR) +- HTML +- その他のドキュメント形式 + +## 主要ツール + +- `convert_to_markdown` - ファイルをMarkdownに変換 + +## 使用例 + +``` +# PDFをMarkdownに変換 +mcp__plugin_mcp-markitdown_markitdown__convert_to_markdown uri="file:///path/to/document.pdf" + +# Excelファイルを変換 +mcp__plugin_mcp-markitdown_markitdown__convert_to_markdown uri="file:///path/to/data.xlsx" +``` + +## 使い分け + +- scannerエージェントがPDF/Office/画像ファイルの読み取りに使用 +- Read toolで直接読めないファイル形式の変換に有用 diff --git a/plugins/mcp/kiro/mcp-markitdown/install.sh b/plugins/mcp/kiro/mcp-markitdown/install.sh new file mode 100755 index 0000000..26e6b00 --- /dev/null +++ b/plugins/mcp/kiro/mcp-markitdown/install.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PLUGIN_NAME="$(basename "$SCRIPT_DIR")" +PROJECT_ROOT="$(pwd)" +KIRO_DIR="$PROJECT_ROOT/.kiro" +KIRO_AGENT_FILE="$KIRO_DIR/agents/default.json" +KIRO_PLUGIN_LINK="$KIRO_DIR/mcp_runtime/$PLUGIN_NAME" +KIRO_SKILLS_DIR="$KIRO_DIR/skills" +TARGET_MCP="$PROJECT_ROOT/.mcp.json" +SOURCE_MCP="$SCRIPT_DIR/.mcp.json" +SOURCE_HOOKS="$SCRIPT_DIR/hooks/hooks.json" +SOURCE_SKILLS="$SCRIPT_DIR/skills" +DRY_RUN=false + +usage() { + cat <<'USAGE' +Usage: bash install.sh [--project PATH] [--dry-run] + +Install this MCP plugin into the current project's Kiro settings. +USAGE +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --project) + [ "$#" -ge 2 ] || { echo "ERROR: --project requires a path" >&2; exit 2; } + PROJECT_ROOT="$(cd "$2" && pwd)" + KIRO_DIR="$PROJECT_ROOT/.kiro" + KIRO_AGENT_FILE="$KIRO_DIR/agents/default.json" + KIRO_PLUGIN_LINK="$KIRO_DIR/mcp_runtime/$PLUGIN_NAME" + KIRO_SKILLS_DIR="$KIRO_DIR/skills" + TARGET_MCP="$PROJECT_ROOT/.mcp.json" + shift + ;; + --dry-run) DRY_RUN=true ;; + -h|--help) usage; exit 0 ;; + *) echo "Unknown option: $1" >&2; usage >&2; exit 2 ;; + esac + shift +done + +if [ ! -f "$SOURCE_MCP" ]; then + echo "ERROR: source .mcp.json not found: $SOURCE_MCP" >&2 + exit 1 +fi + +if [ "$DRY_RUN" = true ]; then + echo "Would merge $SOURCE_MCP into $TARGET_MCP" + echo "Would link plugin runtime into $KIRO_PLUGIN_LINK" + if [ -d "$SOURCE_SKILLS" ]; then + echo "Would link skills from $SOURCE_SKILLS into $KIRO_SKILLS_DIR" + fi + if [ -f "$SOURCE_HOOKS" ]; then + echo "Would merge hooks from $SOURCE_HOOKS into $KIRO_AGENT_FILE" + fi + exit 0 +fi + +mkdir -p "$KIRO_DIR/mcp_runtime" "$KIRO_SKILLS_DIR" "$(dirname "$KIRO_AGENT_FILE")" +if [ -e "$KIRO_PLUGIN_LINK" ] && [ ! -L "$KIRO_PLUGIN_LINK" ]; then + echo "ERROR: $KIRO_PLUGIN_LINK already exists and is not a symlink" >&2 + exit 1 +fi +ln -sfn "$SCRIPT_DIR" "$KIRO_PLUGIN_LINK" + +if [ -d "$SOURCE_SKILLS" ]; then + find "$SOURCE_SKILLS" -mindepth 1 -maxdepth 1 -type d | sort | while IFS= read -r skill_dir; do + skill_name="$(basename "$skill_dir")" + skill_link="$KIRO_SKILLS_DIR/$skill_name" + [ -f "$skill_dir/SKILL.md" ] || continue + if [ -e "$skill_link" ] && [ ! -L "$skill_link" ]; then + echo "ERROR: $skill_link already exists and is not a symlink" >&2 + exit 1 + fi + ln -sfn "$skill_dir" "$skill_link" + echo "Linked Kiro skill: $skill_name" + done +fi + +python3 - "$SOURCE_MCP" "$TARGET_MCP" "$SOURCE_HOOKS" "$KIRO_AGENT_FILE" <<'PY' +import json +import sys +from pathlib import Path + +source_path = Path(sys.argv[1]) +target_path = Path(sys.argv[2]) +hooks_path = Path(sys.argv[3]) +agent_path = Path(sys.argv[4]) +source = json.loads(source_path.read_text()) +target = json.loads(target_path.read_text()) if target_path.exists() else {} +source_servers = source.get("mcpServers", {}) +target.setdefault("mcpServers", {}) +target["mcpServers"].update(source_servers) +target_path.write_text(json.dumps(target, indent=2, ensure_ascii=False) + "\n") + +if agent_path.exists(): + agent = json.loads(agent_path.read_text()) +else: + agent = { + "name": "default", + "resources": ["skill://.kiro/skills/**/SKILL.md"], + } + +agent.setdefault("mcpServers", {}) +agent["mcpServers"].update(source_servers) + +if hooks_path.exists(): + source_hooks = json.loads(hooks_path.read_text()).get("hooks", {}) + target_hooks = agent.setdefault("hooks", {}) + event_map = { + "SessionStart": "agentSpawn", + "Stop": "stop", + } + for source_event, groups in source_hooks.items(): + target_event = event_map.get(source_event, source_event) + target_list = target_hooks.setdefault(target_event, []) + existing = { + entry.get("command") + for entry in target_list + if isinstance(entry, dict) + } + for group in groups: + for hook in group.get("hooks", []): + command = hook.get("command") + if not command or command in existing: + continue + entry = {"command": command} + if "timeout_ms" in hook: + entry["timeout_ms"] = hook["timeout_ms"] + target_list.append(entry) + existing.add(command) + +agent_path.write_text(json.dumps(agent, indent=2, ensure_ascii=False) + "\n") + +print(f"Installed MCP servers: {', '.join(sorted(source_servers))}") +print(f"Updated: {target_path}") +print(f"Updated: {agent_path}") +PY diff --git a/plugins/mcp/kiro/mcp-notion/.mcp.json b/plugins/mcp/kiro/mcp-notion/.mcp.json new file mode 100644 index 0000000..fc5b4de --- /dev/null +++ b/plugins/mcp/kiro/mcp-notion/.mcp.json @@ -0,0 +1,10 @@ +{ + "mcpServers": { + "notion": { + "type": "http", + "url": "https://mcp.notion.com/mcp", + "env": {}, + "envFile": "${workspaceFolder}/.env" + } + } +} diff --git a/plugins/mcp/kiro/mcp-notion/README.md b/plugins/mcp/kiro/mcp-notion/README.md new file mode 100644 index 0000000..3190047 --- /dev/null +++ b/plugins/mcp/kiro/mcp-notion/README.md @@ -0,0 +1,77 @@ +# Notion MCP + +NotionワークスペースにアクセスするためのMCPサーバープラグインです。 + +## 概要 + +このプラグインは、Notionのページ、データベース、ブロックへのアクセスを提供します。 + +## 機能 + +- Notionページの作成・読み取り・更新 +- データベースのクエリ +- ブロックの操作 +- ページ階層の取得 +- コメントの追加 + +## 前提条件 + +- Notionアカウント +- Notion Integration(内部統合)の作成 +- 統合トークンの取得 + +## 環境変数の設定 + +プロジェクトルートに`.env`ファイルを作成し、以下の環境変数を設定してください: + +```bash +# Notion Integration Token +NOTION_TOKEN= +``` + +## インストール + +### Kiro CLI + +Kiro では repository clone 後、対象 plugin の installer を project root で実行します。 + +```bash +bash plugins/mcp/kiro/mcp-notion/install.sh +``` + +### 必要な環境変数 + +- 追加の環境変数は不要です。 + +## 使用方法 + +### 基本的な使用例 + +```bash +# ページを読み取り +mcp__plugin_notion-mcp__notion__read_page "page-id" + +# データベースをクエリ +mcp__plugin_notion-mcp__notion__query_database "database-id" + +# 新しいページを作成 +mcp__plugin_notion-mcp__notion__create_page "parent-page-id" "New Page Title" +``` + +## 推奨される使用シーン + +- ドキュメント管理 +- ナレッジベースの構築 +- タスク管理 +- プロジェクト情報の同期 + +## 注意事項 + +- Notion IntegrationをワークスペースやページにShareする必要があります +- HTTPトランスポートを使用しています(公式のNotion MCPサーバー) + +## 参考リンク + +- [Notion MCP Server](https://mcp.notion.com) +- [Notion API](https://developers.notion.com/) +- [Notion Integration Setup](https://www.notion.so/help/create-integrations-with-the-notion-api) diff --git a/plugins/mcp/kiro/mcp-notion/install.sh b/plugins/mcp/kiro/mcp-notion/install.sh new file mode 100755 index 0000000..26e6b00 --- /dev/null +++ b/plugins/mcp/kiro/mcp-notion/install.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PLUGIN_NAME="$(basename "$SCRIPT_DIR")" +PROJECT_ROOT="$(pwd)" +KIRO_DIR="$PROJECT_ROOT/.kiro" +KIRO_AGENT_FILE="$KIRO_DIR/agents/default.json" +KIRO_PLUGIN_LINK="$KIRO_DIR/mcp_runtime/$PLUGIN_NAME" +KIRO_SKILLS_DIR="$KIRO_DIR/skills" +TARGET_MCP="$PROJECT_ROOT/.mcp.json" +SOURCE_MCP="$SCRIPT_DIR/.mcp.json" +SOURCE_HOOKS="$SCRIPT_DIR/hooks/hooks.json" +SOURCE_SKILLS="$SCRIPT_DIR/skills" +DRY_RUN=false + +usage() { + cat <<'USAGE' +Usage: bash install.sh [--project PATH] [--dry-run] + +Install this MCP plugin into the current project's Kiro settings. +USAGE +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --project) + [ "$#" -ge 2 ] || { echo "ERROR: --project requires a path" >&2; exit 2; } + PROJECT_ROOT="$(cd "$2" && pwd)" + KIRO_DIR="$PROJECT_ROOT/.kiro" + KIRO_AGENT_FILE="$KIRO_DIR/agents/default.json" + KIRO_PLUGIN_LINK="$KIRO_DIR/mcp_runtime/$PLUGIN_NAME" + KIRO_SKILLS_DIR="$KIRO_DIR/skills" + TARGET_MCP="$PROJECT_ROOT/.mcp.json" + shift + ;; + --dry-run) DRY_RUN=true ;; + -h|--help) usage; exit 0 ;; + *) echo "Unknown option: $1" >&2; usage >&2; exit 2 ;; + esac + shift +done + +if [ ! -f "$SOURCE_MCP" ]; then + echo "ERROR: source .mcp.json not found: $SOURCE_MCP" >&2 + exit 1 +fi + +if [ "$DRY_RUN" = true ]; then + echo "Would merge $SOURCE_MCP into $TARGET_MCP" + echo "Would link plugin runtime into $KIRO_PLUGIN_LINK" + if [ -d "$SOURCE_SKILLS" ]; then + echo "Would link skills from $SOURCE_SKILLS into $KIRO_SKILLS_DIR" + fi + if [ -f "$SOURCE_HOOKS" ]; then + echo "Would merge hooks from $SOURCE_HOOKS into $KIRO_AGENT_FILE" + fi + exit 0 +fi + +mkdir -p "$KIRO_DIR/mcp_runtime" "$KIRO_SKILLS_DIR" "$(dirname "$KIRO_AGENT_FILE")" +if [ -e "$KIRO_PLUGIN_LINK" ] && [ ! -L "$KIRO_PLUGIN_LINK" ]; then + echo "ERROR: $KIRO_PLUGIN_LINK already exists and is not a symlink" >&2 + exit 1 +fi +ln -sfn "$SCRIPT_DIR" "$KIRO_PLUGIN_LINK" + +if [ -d "$SOURCE_SKILLS" ]; then + find "$SOURCE_SKILLS" -mindepth 1 -maxdepth 1 -type d | sort | while IFS= read -r skill_dir; do + skill_name="$(basename "$skill_dir")" + skill_link="$KIRO_SKILLS_DIR/$skill_name" + [ -f "$skill_dir/SKILL.md" ] || continue + if [ -e "$skill_link" ] && [ ! -L "$skill_link" ]; then + echo "ERROR: $skill_link already exists and is not a symlink" >&2 + exit 1 + fi + ln -sfn "$skill_dir" "$skill_link" + echo "Linked Kiro skill: $skill_name" + done +fi + +python3 - "$SOURCE_MCP" "$TARGET_MCP" "$SOURCE_HOOKS" "$KIRO_AGENT_FILE" <<'PY' +import json +import sys +from pathlib import Path + +source_path = Path(sys.argv[1]) +target_path = Path(sys.argv[2]) +hooks_path = Path(sys.argv[3]) +agent_path = Path(sys.argv[4]) +source = json.loads(source_path.read_text()) +target = json.loads(target_path.read_text()) if target_path.exists() else {} +source_servers = source.get("mcpServers", {}) +target.setdefault("mcpServers", {}) +target["mcpServers"].update(source_servers) +target_path.write_text(json.dumps(target, indent=2, ensure_ascii=False) + "\n") + +if agent_path.exists(): + agent = json.loads(agent_path.read_text()) +else: + agent = { + "name": "default", + "resources": ["skill://.kiro/skills/**/SKILL.md"], + } + +agent.setdefault("mcpServers", {}) +agent["mcpServers"].update(source_servers) + +if hooks_path.exists(): + source_hooks = json.loads(hooks_path.read_text()).get("hooks", {}) + target_hooks = agent.setdefault("hooks", {}) + event_map = { + "SessionStart": "agentSpawn", + "Stop": "stop", + } + for source_event, groups in source_hooks.items(): + target_event = event_map.get(source_event, source_event) + target_list = target_hooks.setdefault(target_event, []) + existing = { + entry.get("command") + for entry in target_list + if isinstance(entry, dict) + } + for group in groups: + for hook in group.get("hooks", []): + command = hook.get("command") + if not command or command in existing: + continue + entry = {"command": command} + if "timeout_ms" in hook: + entry["timeout_ms"] = hook["timeout_ms"] + target_list.append(entry) + existing.add(command) + +agent_path.write_text(json.dumps(agent, indent=2, ensure_ascii=False) + "\n") + +print(f"Installed MCP servers: {', '.join(sorted(source_servers))}") +print(f"Updated: {target_path}") +print(f"Updated: {agent_path}") +PY diff --git a/plugins/mcp/kiro/mcp-playwright/.mcp.json b/plugins/mcp/kiro/mcp-playwright/.mcp.json new file mode 100644 index 0000000..2313c0d --- /dev/null +++ b/plugins/mcp/kiro/mcp-playwright/.mcp.json @@ -0,0 +1,20 @@ +{ + "mcpServers": { + "playwright": { + "type": "stdio", + "command": "npx", + "args": [ + "-y", + "@playwright/mcp@latest", + "--browser", + "chromium", + "--isolated", + "--no-sandbox" + ], + "env": { + "PLAYWRIGHT_BROWSERS_PATH": "${HOME}/.cache/ms-playwright" + }, + "envFile": "${workspaceFolder}/.env" + } + } +} diff --git a/plugins/mcp/kiro/mcp-playwright/README.md b/plugins/mcp/kiro/mcp-playwright/README.md new file mode 100644 index 0000000..db35ba0 --- /dev/null +++ b/plugins/mcp/kiro/mcp-playwright/README.md @@ -0,0 +1,166 @@ +# playwright Plugin + +Playwright MCPを自動的にセットアップし、ブラウザ自動化機能を提供するプラグインです。 + +## 概要 + +このプラグインは、Playwright MCPサーバーとChromiumブラウザを自動的にインストール・設定します。Kiroでブラウザ自動化タスク(Webスクレイピング、E2Eテスト、スクリーンショット取得など)を簡単に実行できるようになります。 + +## 機能 + +- **自動セットアップ**: 初回起動時にPlaywright Chromiumブラウザを自動インストール +- **バージョン互換性**: @playwright/mcpと互換性のあるPlaywrightバージョンを自動検出 +- **冪等性**: 既にインストール済みの場合はスキップ(高速起動) +- **環境変数対応**: PLAYWRIGHT_BROWSERS_PATHで共有キャッシュを利用 + +## インストール + +### Kiro CLI + +Kiro では repository clone 後、対象 plugin の installer を project root で実行します。 + +```bash +bash plugins/mcp/kiro/mcp-playwright/install.sh +``` + +### 必要な環境変数 + +- `HOME` + +## 使用方法 + +### 基本的な使い方 + +プラグインをインストールして、Kiroを起動するだけで自動的にセットアップが実行されます。 + +```bash +# Kiroを起動 +kiro +``` + +初回起動時、以下のようなメッセージが表示されます: + +``` +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +🎭 Playwright MCP Plugin: 初回セットアップ +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Playwright Chromiumブラウザをインストール中... +ネットワーク環境により1-2分かかる場合があります。 + +🔍 @playwright/mcpが使用するPlaywrightバージョンを確認中... +✓ Playwright 1.49.1 を使用します + +📦 Playwright 1.49.1 でChromiumをインストール中... +[インストールログ...] + +✅ セットアップ完了!Playwright Chromiumの準備ができました。 + Playwrightバージョン: 1.49.1 + ブラウザパス: ~/.cache/ms-playwright +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +``` + +### Playwright MCPツールの使用例 + +セットアップ完了後、以下のようなタスクが実行できます: + +``` +// Webページにアクセスしてスクリーンショットを取得 +mcp__plugin_playwright__browser_navigate(url: "https://example.com") +mcp__plugin_playwright__browser_take_screenshot() + +// フォームに入力してボタンをクリック +mcp__plugin_playwright__browser_fill(selector: "#email", value: "test@example.com") +mcp__plugin_playwright__browser_click(selector: "button[type='submit']") + +// ページのスナップショットを取得(アクセシビリティツリー) +mcp__plugin_playwright__browser_snapshot() +``` + +## 設定 + +### MCP設定(`.mcp.json`) + +プラグインは以下のMCP設定を提供します: + +```json +{ + "mcpServers": { + "playwright": { + "type": "stdio", + "command": "npx", + "args": [ + "-y", + "@playwright/mcp@latest", + "--browser", + "chromium", + "--isolated", + "--no-sandbox" + ], + "env": { + "PLAYWRIGHT_BROWSERS_PATH": "${HOME}/.cache/ms-playwright" + } + } + } +} +``` + +### ブラウザキャッシュパス + +デフォルトでは、Chromiumブラウザは `~/.cache/ms-playwright` にインストールされます。 + +別の場所を使用したい場合は、環境変数を設定してください: + +```bash +export PLAYWRIGHT_BROWSERS_PATH=/path/to/custom/location +``` + +## トラブルシューティング + +### インストールが失敗する場合 + +1. **手動インストール**: + ```bash + # 互換性のあるバージョンを確認 + npm view @playwright/mcp@latest dependencies + + # Chromiumをインストール + PLAYWRIGHT_BROWSERS_PATH=~/.cache/ms-playwright npx playwright@ install chromium + ``` + +2. **キャッシュをクリア**: + ```bash + rm ~/.claude-playwright-installed + ``` + +3. **ログを確認**: + プラグインは詳細なログを出力するので、エラーメッセージを確認してください。 + +### ブラウザが見つからない場合 + +環境変数 `PLAYWRIGHT_BROWSERS_PATH` が正しく設定されているか確認してください: + +```bash +echo $PLAYWRIGHT_BROWSERS_PATH +ls -la ~/.cache/ms-playwright +``` + +### タイムアウトエラー + +ネットワーク環境が遅い場合、インストールに時間がかかることがあります。 +デフォルトのタイムアウトは5分です。 + +## 参考資料 + +- [Playwright公式ドキュメント](https://playwright.dev/) +- [@playwright/mcp README](https://www.npmjs.com/package/@playwright/mcp) +- [Playwright Browser Management](https://playwright.dev/docs/browsers) + +## ライセンス + +MIT License + +## サポート + +問題が発生した場合は、GitHubリポジトリでissueを作成してください: +https://github.com/devbasex/ai-plugins/issues diff --git a/plugins/mcp-playwright/hooks/hooks.json b/plugins/mcp/kiro/mcp-playwright/hooks/hooks.json similarity index 82% rename from plugins/mcp-playwright/hooks/hooks.json rename to plugins/mcp/kiro/mcp-playwright/hooks/hooks.json index 7b163ae..567b670 100644 --- a/plugins/mcp-playwright/hooks/hooks.json +++ b/plugins/mcp/kiro/mcp-playwright/hooks/hooks.json @@ -10,7 +10,7 @@ "hooks": [ { "type": "command", - "command": "node ${CLAUDE_PLUGIN_ROOT}/scripts/ensure-playwright.js", + "command": "node ${PLUGIN_ROOT:-.kiro/mcp_runtime/mcp-playwright}/scripts/ensure-playwright.js", "description": "Auto-install Playwright chromium on first use", "continueOnError": false, "suppressOutput": false diff --git a/plugins/mcp/kiro/mcp-playwright/install.sh b/plugins/mcp/kiro/mcp-playwright/install.sh new file mode 100755 index 0000000..26e6b00 --- /dev/null +++ b/plugins/mcp/kiro/mcp-playwright/install.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PLUGIN_NAME="$(basename "$SCRIPT_DIR")" +PROJECT_ROOT="$(pwd)" +KIRO_DIR="$PROJECT_ROOT/.kiro" +KIRO_AGENT_FILE="$KIRO_DIR/agents/default.json" +KIRO_PLUGIN_LINK="$KIRO_DIR/mcp_runtime/$PLUGIN_NAME" +KIRO_SKILLS_DIR="$KIRO_DIR/skills" +TARGET_MCP="$PROJECT_ROOT/.mcp.json" +SOURCE_MCP="$SCRIPT_DIR/.mcp.json" +SOURCE_HOOKS="$SCRIPT_DIR/hooks/hooks.json" +SOURCE_SKILLS="$SCRIPT_DIR/skills" +DRY_RUN=false + +usage() { + cat <<'USAGE' +Usage: bash install.sh [--project PATH] [--dry-run] + +Install this MCP plugin into the current project's Kiro settings. +USAGE +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --project) + [ "$#" -ge 2 ] || { echo "ERROR: --project requires a path" >&2; exit 2; } + PROJECT_ROOT="$(cd "$2" && pwd)" + KIRO_DIR="$PROJECT_ROOT/.kiro" + KIRO_AGENT_FILE="$KIRO_DIR/agents/default.json" + KIRO_PLUGIN_LINK="$KIRO_DIR/mcp_runtime/$PLUGIN_NAME" + KIRO_SKILLS_DIR="$KIRO_DIR/skills" + TARGET_MCP="$PROJECT_ROOT/.mcp.json" + shift + ;; + --dry-run) DRY_RUN=true ;; + -h|--help) usage; exit 0 ;; + *) echo "Unknown option: $1" >&2; usage >&2; exit 2 ;; + esac + shift +done + +if [ ! -f "$SOURCE_MCP" ]; then + echo "ERROR: source .mcp.json not found: $SOURCE_MCP" >&2 + exit 1 +fi + +if [ "$DRY_RUN" = true ]; then + echo "Would merge $SOURCE_MCP into $TARGET_MCP" + echo "Would link plugin runtime into $KIRO_PLUGIN_LINK" + if [ -d "$SOURCE_SKILLS" ]; then + echo "Would link skills from $SOURCE_SKILLS into $KIRO_SKILLS_DIR" + fi + if [ -f "$SOURCE_HOOKS" ]; then + echo "Would merge hooks from $SOURCE_HOOKS into $KIRO_AGENT_FILE" + fi + exit 0 +fi + +mkdir -p "$KIRO_DIR/mcp_runtime" "$KIRO_SKILLS_DIR" "$(dirname "$KIRO_AGENT_FILE")" +if [ -e "$KIRO_PLUGIN_LINK" ] && [ ! -L "$KIRO_PLUGIN_LINK" ]; then + echo "ERROR: $KIRO_PLUGIN_LINK already exists and is not a symlink" >&2 + exit 1 +fi +ln -sfn "$SCRIPT_DIR" "$KIRO_PLUGIN_LINK" + +if [ -d "$SOURCE_SKILLS" ]; then + find "$SOURCE_SKILLS" -mindepth 1 -maxdepth 1 -type d | sort | while IFS= read -r skill_dir; do + skill_name="$(basename "$skill_dir")" + skill_link="$KIRO_SKILLS_DIR/$skill_name" + [ -f "$skill_dir/SKILL.md" ] || continue + if [ -e "$skill_link" ] && [ ! -L "$skill_link" ]; then + echo "ERROR: $skill_link already exists and is not a symlink" >&2 + exit 1 + fi + ln -sfn "$skill_dir" "$skill_link" + echo "Linked Kiro skill: $skill_name" + done +fi + +python3 - "$SOURCE_MCP" "$TARGET_MCP" "$SOURCE_HOOKS" "$KIRO_AGENT_FILE" <<'PY' +import json +import sys +from pathlib import Path + +source_path = Path(sys.argv[1]) +target_path = Path(sys.argv[2]) +hooks_path = Path(sys.argv[3]) +agent_path = Path(sys.argv[4]) +source = json.loads(source_path.read_text()) +target = json.loads(target_path.read_text()) if target_path.exists() else {} +source_servers = source.get("mcpServers", {}) +target.setdefault("mcpServers", {}) +target["mcpServers"].update(source_servers) +target_path.write_text(json.dumps(target, indent=2, ensure_ascii=False) + "\n") + +if agent_path.exists(): + agent = json.loads(agent_path.read_text()) +else: + agent = { + "name": "default", + "resources": ["skill://.kiro/skills/**/SKILL.md"], + } + +agent.setdefault("mcpServers", {}) +agent["mcpServers"].update(source_servers) + +if hooks_path.exists(): + source_hooks = json.loads(hooks_path.read_text()).get("hooks", {}) + target_hooks = agent.setdefault("hooks", {}) + event_map = { + "SessionStart": "agentSpawn", + "Stop": "stop", + } + for source_event, groups in source_hooks.items(): + target_event = event_map.get(source_event, source_event) + target_list = target_hooks.setdefault(target_event, []) + existing = { + entry.get("command") + for entry in target_list + if isinstance(entry, dict) + } + for group in groups: + for hook in group.get("hooks", []): + command = hook.get("command") + if not command or command in existing: + continue + entry = {"command": command} + if "timeout_ms" in hook: + entry["timeout_ms"] = hook["timeout_ms"] + target_list.append(entry) + existing.add(command) + +agent_path.write_text(json.dumps(agent, indent=2, ensure_ascii=False) + "\n") + +print(f"Installed MCP servers: {', '.join(sorted(source_servers))}") +print(f"Updated: {target_path}") +print(f"Updated: {agent_path}") +PY diff --git a/plugins/mcp/kiro/mcp-playwright/scripts/ensure-playwright.js b/plugins/mcp/kiro/mcp-playwright/scripts/ensure-playwright.js new file mode 100755 index 0000000..b8e354a --- /dev/null +++ b/plugins/mcp/kiro/mcp-playwright/scripts/ensure-playwright.js @@ -0,0 +1,114 @@ +#!/usr/bin/env node + +const { execFileSync } = require('child_process'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +const PLUGIN_ROOT = process.env.PLUGIN_ROOT || process.env.CODEX_PLUGIN_ROOT || process.env.CLAUDE_PLUGIN_ROOT || __dirname; +const FLAG_FILE = path.join(os.homedir(), '.claude-playwright-installed'); +const BROWSER_PATH = process.env.PLAYWRIGHT_BROWSERS_PATH || path.join(os.homedir(), '.cache', 'ms-playwright'); +const TIMEOUT_MS = 5 * 60 * 1000; // 5分タイムアウト +const PLAYWRIGHT_VERSION_PATTERN = /^[~^]?\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/; +const EXEC_FILE_OPTIONS = process.platform === 'win32' ? { shell: true } : {}; + +function readInstalledFlag() { + if (!fs.existsSync(FLAG_FILE)) { + return null; + } + try { + const flagData = JSON.parse(fs.readFileSync(FLAG_FILE, 'utf-8')); + return flagData && typeof flagData === 'object' ? flagData : null; + } catch { + return null; + } +} + +console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); +console.log('🎭 Playwright MCP Plugin: 初回セットアップ'); +console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); +console.log(''); +console.log('Playwright Chromiumブラウザをインストール中...'); +console.log('ネットワーク環境により1-2分かかる場合があります。'); +console.log(''); + +try { + // @playwright/mcpが依存するPlaywrightのバージョンを取得 + console.log('🔍 @playwright/mcpが使用するPlaywrightバージョンを確認中...'); + const depsOutput = execFileSync('npm', ['view', '@playwright/mcp@latest', 'dependencies', '--json'], { + encoding: 'utf-8', + cwd: PLUGIN_ROOT, + timeout: 30000, + ...EXEC_FILE_OPTIONS + }); + + const deps = JSON.parse(depsOutput); + const playwrightVersion = deps.playwright || deps['playwright-core']; + + if (!playwrightVersion) { + throw new Error('@playwright/mcpの依存関係からPlaywrightバージョンを取得できませんでした'); + } + if (!PLAYWRIGHT_VERSION_PATTERN.test(playwrightVersion)) { + throw new Error(`不正なPlaywrightバージョンです: ${playwrightVersion}`); + } + + console.log(`✓ Playwright ${playwrightVersion} を使用します`); + console.log(''); + + const installedFlag = readInstalledFlag(); + if ( + installedFlag && + installedFlag.playwrightVersion === playwrightVersion && + installedFlag.browserPath === BROWSER_PATH + ) { + console.log('既に同じ Playwright バージョンでセットアップ済みです。'); + process.exit(0); + } + + // @playwright/mcpと互換性のあるバージョンのChromiumをインストール + console.log(`📦 Playwright ${playwrightVersion} でChromiumをインストール中...`); + execFileSync('npx', ['-y', `playwright@${playwrightVersion}`, 'install', 'chromium'], { + stdio: 'inherit', + cwd: PLUGIN_ROOT, + timeout: TIMEOUT_MS, + ...EXEC_FILE_OPTIONS, + env: { + ...process.env, + PLAYWRIGHT_BROWSERS_PATH: BROWSER_PATH, + PLAYWRIGHT_SKIP_BROWSER_GC: '1' + } + }); + + // インストール成功フラグを作成 + const flagData = { + installed: new Date().toISOString(), + plugin: 'playwright', + browser: 'chromium', + browserPath: BROWSER_PATH, + playwrightVersion: playwrightVersion + }; + + fs.writeFileSync(FLAG_FILE, JSON.stringify(flagData, null, 2)); + + console.log(''); + console.log('✅ セットアップ完了!Playwright Chromiumの準備ができました。'); + console.log(` Playwrightバージョン: ${playwrightVersion}`); + console.log(` ブラウザパス: ${BROWSER_PATH}`); + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + process.exit(0); + +} catch (error) { + console.error(''); + console.error('❌ Playwright Chromiumのインストールに失敗しました'); + console.error(''); + console.error('エラー:', error.message); + console.error(''); + console.error('手動でインストールするには以下を実行してください:'); + console.error(` npm view @playwright/mcp@latest dependencies`); + console.error(` PLAYWRIGHT_BROWSERS_PATH=${BROWSER_PATH} npx playwright@ install chromium`); + console.error(''); + console.error('トラブルシューティング:'); + console.error(' https://playwright.dev/docs/browsers'); + console.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + process.exit(1); +} diff --git a/plugins/mcp/kiro/mcp-redash/.env.example b/plugins/mcp/kiro/mcp-redash/.env.example new file mode 100644 index 0000000..810b944 --- /dev/null +++ b/plugins/mcp/kiro/mcp-redash/.env.example @@ -0,0 +1,16 @@ +# Redash MCP - 環境変数設定 +# +# デフォルト(必須) +REDASH_URL=https://redash.example.com +REDASH_API_KEY=your-api-key + +# 追加環境(/redash-add で追加した場合のみ必要) +# suffix を大文字にして REDASH_{SUFFIX}_URL, REDASH_{SUFFIX}_API_KEY の形式 +# +# 例: /redash-add dev +# REDASH_DEV_URL=https://redash-dev.example.com +# REDASH_DEV_API_KEY=your-dev-api-key +# +# 例: /redash-add stg +# REDASH_STG_URL=https://redash-stg.example.com +# REDASH_STG_API_KEY=your-stg-api-key diff --git a/plugins/mcp/kiro/mcp-redash/.mcp.json b/plugins/mcp/kiro/mcp-redash/.mcp.json new file mode 100644 index 0000000..80c370f --- /dev/null +++ b/plugins/mcp/kiro/mcp-redash/.mcp.json @@ -0,0 +1,12 @@ +{ + "mcpServers": { + "redash": { + "command": "npx", + "args": ["-y", "@suthio/redash-mcp"], + "env": { + "REDASH_URL": "${REDASH_URL}", + "REDASH_API_KEY": "${REDASH_API_KEY}" + } + } + } +} diff --git a/plugins/mcp/kiro/mcp-redash/README.md b/plugins/mcp/kiro/mcp-redash/README.md new file mode 100644 index 0000000..63a4abe --- /dev/null +++ b/plugins/mcp/kiro/mcp-redash/README.md @@ -0,0 +1,149 @@ +# mcp-redash + +Redash MCP サーバーをマルチ環境対応で利用するための Kiro プラグイン。 + +## 概要 + +[redash-mcp](https://github.com/suthio/redash-mcp) を Kiro から利用するためのプラグインです。 + +- 1つだけ使う場合は最小設定(`REDASH_URL` と `REDASH_API_KEY` のみ) +- 必要になったら suffix 付きで環境を増やせる +- MCP 定義は必要になるまで作られない + +## 前提条件 + +- Kiro +- Node.js(npx が使える環境) + +## インストール + +### Kiro CLI + +Kiro では repository clone 後、対象 plugin の installer を project root で実行します。 + +```bash +bash plugins/mcp/kiro/mcp-redash/install.sh +``` + +### 必要な環境変数 + +- `REDASH_API_KEY` +- `REDASH_URL` + +## 環境変数 + +プロジェクトの `.env` に設定してください。 + +```bash +REDASH_URL=https://redash.example.com +REDASH_API_KEY=your-api-key +``` + +## 使い方 + +### 基本(1環境) + +プラグインインストール後、`redash` MCP が自動的に有効になります。環境変数を設定するだけで利用開始できます。 + +### 複数環境 + +```bash +# dev 環境を追加 +/redash-add dev + +# .env に環境変数を設定 +# REDASH_DEV_URL=https://redash-dev.example.com +# REDASH_DEV_API_KEY=your-dev-api-key + +# 一覧確認 +/redash-list + +# 状態確認(環境変数の設定漏れチェック) +/redash-status + +# 不要になったら削除 +/redash-remove dev +``` + +## スラッシュコマンド + +| コマンド | 説明 | +|---------|------| +| `/redash-add ` | 任意 suffix の Redash MCP を project `.mcp.json` に追加 | +| `/redash-remove ` | 指定 suffix の Redash MCP を project `.mcp.json` から削除 | +| `/redash-list` | 有効な Redash MCP を一覧表示 | +| `/redash-status` | 設定状況の詳細確認(環境変数の未設定警告付き) | +| `/redash-guide` | 使い方ガイドを表示 | + +## suffix と環境変数の対応 + +| suffix | MCP 名 | 環境変数 | +|--------|--------|----------| +| (なし) | redash | REDASH_URL, REDASH_API_KEY | +| dev | redash-dev | REDASH_DEV_URL, REDASH_DEV_API_KEY | +| stg | redash-stg | REDASH_STG_URL, REDASH_STG_API_KEY | +| prod2 | redash-prod2 | REDASH_PROD2_URL, REDASH_PROD2_API_KEY | +| sandbox-1 | redash-sandbox-1 | REDASH_SANDBOX_1_URL, REDASH_SANDBOX_1_API_KEY | + +- suffix は英小文字・数字・ハイフンで自由に指定可能 +- 環境変数名では suffix 内のハイフンをアンダースコアに変換 +- `default` は予約語のため使用不可(plugin 同梱の `redash` が該当) + +## ファイル構成 + +``` +plugins/mcp/shared/mcp-redash/ +├── .claude-plugin/ +│ └── plugin.json # プラグインメタデータ +├── .mcp.json # MCP 設定(redash のみ同梱) +├── .env.example # 環境変数テンプレート +├── scripts/ +│ └── redash-mcp-config.js # JSON 編集スクリプト(Node.js) +├── skills/ +│ ├── redash-add/SKILL.md # /redash-add コマンド +│ ├── redash-remove/SKILL.md # /redash-remove コマンド +│ ├── redash-list/SKILL.md # /redash-list コマンド +│ ├── redash-status/SKILL.md # /redash-status コマンド +│ └── redash-guide/SKILL.md # 使い方ガイド +└── README.md +``` + +## 設計意図 + +> 本プラグインは +> 「1つだけ使う場合は最小設定」 +> 「必要になったら suffix 付きで増やす」 +> という運用を前提にしている。 +> +> そのため MCP 定義は **必要になるまで作られない**。 + +- plugin 同梱: `redash`(常に `/mcp` に表示) +- project `.mcp.json`: `/redash-add` で追加したもののみ表示 + +## トラブルシューティング + +### `/mcp` に redash が表示されない + +- プラグインが正しくインストールされているか確認 +- `/plugin list` で `mcp-redash@ai-plugins` が表示されるか確認 + +### 環境変数が反映されない + +- `.env` ファイルがプロジェクトルートにあるか確認 +- 変数名が正しい形式か確認(`REDASH_{SUFFIX}_URL`) +- `/redash-status` で設定状況を確認 + +### `/redash-add` でエラーが出る + +- `.mcp.json` の JSON が壊れていないか確認(手動編集した場合) +- `default` は予約語のため suffix に使用不可 + +### suffix 付き MCP が動かない + +- 対応する環境変数が `.env` に設定されているか `/redash-status` で確認 +- Redash の URL と API Key が正しいか確認 + +## 参考リンク + +- [redash-mcp](https://github.com/suthio/redash-mcp) - Redash MCP サーバー +- [Redash](https://redash.io/) - データ可視化ツール diff --git a/plugins/mcp/kiro/mcp-redash/install.sh b/plugins/mcp/kiro/mcp-redash/install.sh new file mode 100755 index 0000000..26e6b00 --- /dev/null +++ b/plugins/mcp/kiro/mcp-redash/install.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PLUGIN_NAME="$(basename "$SCRIPT_DIR")" +PROJECT_ROOT="$(pwd)" +KIRO_DIR="$PROJECT_ROOT/.kiro" +KIRO_AGENT_FILE="$KIRO_DIR/agents/default.json" +KIRO_PLUGIN_LINK="$KIRO_DIR/mcp_runtime/$PLUGIN_NAME" +KIRO_SKILLS_DIR="$KIRO_DIR/skills" +TARGET_MCP="$PROJECT_ROOT/.mcp.json" +SOURCE_MCP="$SCRIPT_DIR/.mcp.json" +SOURCE_HOOKS="$SCRIPT_DIR/hooks/hooks.json" +SOURCE_SKILLS="$SCRIPT_DIR/skills" +DRY_RUN=false + +usage() { + cat <<'USAGE' +Usage: bash install.sh [--project PATH] [--dry-run] + +Install this MCP plugin into the current project's Kiro settings. +USAGE +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --project) + [ "$#" -ge 2 ] || { echo "ERROR: --project requires a path" >&2; exit 2; } + PROJECT_ROOT="$(cd "$2" && pwd)" + KIRO_DIR="$PROJECT_ROOT/.kiro" + KIRO_AGENT_FILE="$KIRO_DIR/agents/default.json" + KIRO_PLUGIN_LINK="$KIRO_DIR/mcp_runtime/$PLUGIN_NAME" + KIRO_SKILLS_DIR="$KIRO_DIR/skills" + TARGET_MCP="$PROJECT_ROOT/.mcp.json" + shift + ;; + --dry-run) DRY_RUN=true ;; + -h|--help) usage; exit 0 ;; + *) echo "Unknown option: $1" >&2; usage >&2; exit 2 ;; + esac + shift +done + +if [ ! -f "$SOURCE_MCP" ]; then + echo "ERROR: source .mcp.json not found: $SOURCE_MCP" >&2 + exit 1 +fi + +if [ "$DRY_RUN" = true ]; then + echo "Would merge $SOURCE_MCP into $TARGET_MCP" + echo "Would link plugin runtime into $KIRO_PLUGIN_LINK" + if [ -d "$SOURCE_SKILLS" ]; then + echo "Would link skills from $SOURCE_SKILLS into $KIRO_SKILLS_DIR" + fi + if [ -f "$SOURCE_HOOKS" ]; then + echo "Would merge hooks from $SOURCE_HOOKS into $KIRO_AGENT_FILE" + fi + exit 0 +fi + +mkdir -p "$KIRO_DIR/mcp_runtime" "$KIRO_SKILLS_DIR" "$(dirname "$KIRO_AGENT_FILE")" +if [ -e "$KIRO_PLUGIN_LINK" ] && [ ! -L "$KIRO_PLUGIN_LINK" ]; then + echo "ERROR: $KIRO_PLUGIN_LINK already exists and is not a symlink" >&2 + exit 1 +fi +ln -sfn "$SCRIPT_DIR" "$KIRO_PLUGIN_LINK" + +if [ -d "$SOURCE_SKILLS" ]; then + find "$SOURCE_SKILLS" -mindepth 1 -maxdepth 1 -type d | sort | while IFS= read -r skill_dir; do + skill_name="$(basename "$skill_dir")" + skill_link="$KIRO_SKILLS_DIR/$skill_name" + [ -f "$skill_dir/SKILL.md" ] || continue + if [ -e "$skill_link" ] && [ ! -L "$skill_link" ]; then + echo "ERROR: $skill_link already exists and is not a symlink" >&2 + exit 1 + fi + ln -sfn "$skill_dir" "$skill_link" + echo "Linked Kiro skill: $skill_name" + done +fi + +python3 - "$SOURCE_MCP" "$TARGET_MCP" "$SOURCE_HOOKS" "$KIRO_AGENT_FILE" <<'PY' +import json +import sys +from pathlib import Path + +source_path = Path(sys.argv[1]) +target_path = Path(sys.argv[2]) +hooks_path = Path(sys.argv[3]) +agent_path = Path(sys.argv[4]) +source = json.loads(source_path.read_text()) +target = json.loads(target_path.read_text()) if target_path.exists() else {} +source_servers = source.get("mcpServers", {}) +target.setdefault("mcpServers", {}) +target["mcpServers"].update(source_servers) +target_path.write_text(json.dumps(target, indent=2, ensure_ascii=False) + "\n") + +if agent_path.exists(): + agent = json.loads(agent_path.read_text()) +else: + agent = { + "name": "default", + "resources": ["skill://.kiro/skills/**/SKILL.md"], + } + +agent.setdefault("mcpServers", {}) +agent["mcpServers"].update(source_servers) + +if hooks_path.exists(): + source_hooks = json.loads(hooks_path.read_text()).get("hooks", {}) + target_hooks = agent.setdefault("hooks", {}) + event_map = { + "SessionStart": "agentSpawn", + "Stop": "stop", + } + for source_event, groups in source_hooks.items(): + target_event = event_map.get(source_event, source_event) + target_list = target_hooks.setdefault(target_event, []) + existing = { + entry.get("command") + for entry in target_list + if isinstance(entry, dict) + } + for group in groups: + for hook in group.get("hooks", []): + command = hook.get("command") + if not command or command in existing: + continue + entry = {"command": command} + if "timeout_ms" in hook: + entry["timeout_ms"] = hook["timeout_ms"] + target_list.append(entry) + existing.add(command) + +agent_path.write_text(json.dumps(agent, indent=2, ensure_ascii=False) + "\n") + +print(f"Installed MCP servers: {', '.join(sorted(source_servers))}") +print(f"Updated: {target_path}") +print(f"Updated: {agent_path}") +PY diff --git a/plugins/mcp/kiro/mcp-redash/scripts/redash-mcp-config.js b/plugins/mcp/kiro/mcp-redash/scripts/redash-mcp-config.js new file mode 100755 index 0000000..a62d173 --- /dev/null +++ b/plugins/mcp/kiro/mcp-redash/scripts/redash-mcp-config.js @@ -0,0 +1,383 @@ +#!/usr/bin/env node + +/** + * redash-mcp-config.js + * + * project root の .mcp.json を操作して Redash MCP の追加・削除・一覧・状態確認を行う。 + * + * Usage: + * node redash-mcp-config.js add + * node redash-mcp-config.js remove + * node redash-mcp-config.js list + * node redash-mcp-config.js status + */ + +const fs = require('fs'); +const path = require('path'); + +const MCP_PACKAGE = '@suthio/redash-mcp'; +const PROJECT_ROOT = resolveProjectRoot(); +const MCP_JSON_PATH = path.join(PROJECT_ROOT, '.mcp.json'); +const KIRO_AGENT_PATH = path.join(PROJECT_ROOT, '.kiro', 'agents', 'default.json'); + +// --- helpers --- + +function envName(suffix) { + const upper = suffix.toUpperCase().replaceAll('-', '_'); + return { + url: `REDASH_${upper}_URL`, + key: `REDASH_${upper}_API_KEY`, + }; +} + +function mcpName(suffix) { + return `redash-${suffix}`; +} + +function validateSuffix(suffix) { + if (!/^[a-z0-9][a-z0-9-]*$/.test(suffix)) { + console.error('エラー: suffix は英小文字・数字・ハイフンのみで指定してください。'); + console.error('例: dev, stg, prod2, sandbox-1'); + process.exit(1); + } +} + +function mcpEntry(suffix) { + const env = envName(suffix); + return { + command: 'npx', + args: ['-y', MCP_PACKAGE], + env: { + REDASH_URL: `\${${env.url}}`, + REDASH_API_KEY: `\${${env.key}}`, + }, + }; +} + +function isKiroRuntime() { + const normalizedDir = path.normalize(__dirname); + const runtimePath = path.join('mcp', 'kiro') + path.sep; + return Boolean(process.env.KIRO_WORKSPACE_ROOT) || normalizedDir.includes(runtimePath); +} + +function isCodexRuntime() { + const normalizedDir = path.normalize(__dirname); + const runtimePath = path.join('mcp', 'codex') + path.sep; + return Boolean(process.env.CODEX_WORKSPACE_ROOT) || normalizedDir.includes(runtimePath); +} + +function resolveProjectRoot() { + const envNames = [ + 'PROJECT_ROOT', + 'WORKSPACE_ROOT', + 'GIT_WORK_TREE', + 'CLAUDE_PROJECT_DIR', + 'CODEX_WORKSPACE_ROOT', + 'KIRO_WORKSPACE_ROOT', + ]; + + for (const name of envNames) { + const value = process.env[name]; + if (value && fs.existsSync(value) && fs.statSync(value).isDirectory()) { + return path.resolve(value); + } + } + + let dir = process.cwd(); + let nearestMcpDir = null; + while (true) { + if (!nearestMcpDir && fs.existsSync(path.join(dir, '.mcp.json'))) { + nearestMcpDir = dir; + } + if (fs.existsSync(path.join(dir, '.git'))) { + return dir; + } + + const parent = path.dirname(dir); + if (parent === dir) { + return nearestMcpDir || process.cwd(); + } + dir = parent; + } +} + +function createMcpJson() { + return isCodexRuntime() ? {} : { mcpServers: {} }; +} + +function normalizeWritableMcpJson(data) { + if (Object.prototype.hasOwnProperty.call(data, 'mcpServers')) { + return data; + } + if (Object.prototype.hasOwnProperty.call(data, 'mcp_servers')) { + const normalized = { ...data, mcpServers: data.mcp_servers }; + delete normalized.mcp_servers; + return normalized; + } + return data; +} + +function serverMap(data, create) { + if (!data || typeof data !== 'object' || Array.isArray(data)) { + return null; + } + + if (Object.prototype.hasOwnProperty.call(data, 'mcpServers')) { + if (!data.mcpServers && create) { + data.mcpServers = {}; + } + return data.mcpServers && typeof data.mcpServers === 'object' && !Array.isArray(data.mcpServers) + ? data.mcpServers + : null; + } + + if (Object.prototype.hasOwnProperty.call(data, 'mcp_servers')) { + if (!data.mcp_servers && create) { + data.mcp_servers = {}; + } + return data.mcp_servers && typeof data.mcp_servers === 'object' && !Array.isArray(data.mcp_servers) + ? data.mcp_servers + : null; + } + + return data; +} + +function readMcpJson() { + if (!fs.existsSync(MCP_JSON_PATH)) { + return null; + } + const raw = fs.readFileSync(MCP_JSON_PATH, 'utf-8'); + try { + return JSON.parse(raw); + } catch { + return undefined; // 破損 + } +} + +function writeMcpJson(data) { + fs.writeFileSync(MCP_JSON_PATH, JSON.stringify(data, null, 2) + '\n', 'utf-8'); +} + +function syncKiroAgent(data) { + if (!isKiroRuntime()) { + return; + } + + const servers = serverMap(data, false) || {}; + let agent = {}; + if (fs.existsSync(KIRO_AGENT_PATH)) { + try { + agent = JSON.parse(fs.readFileSync(KIRO_AGENT_PATH, 'utf-8')); + } catch { + console.error('エラー: .kiro/agents/default.json の JSON が壊れています。手動で修正してください。'); + process.exit(1); + } + } else { + agent = { + name: 'default', + resources: ['skill://.kiro/skills/**/SKILL.md'], + }; + } + + if (!agent.mcpServers || typeof agent.mcpServers !== 'object' || Array.isArray(agent.mcpServers)) { + agent.mcpServers = {}; + } + + for (const name of Object.keys(agent.mcpServers)) { + if (isRedashServer(name)) { + delete agent.mcpServers[name]; + } + } + for (const [name, server] of Object.entries(servers)) { + if (isRedashServer(name)) { + agent.mcpServers[name] = server; + } + } + + fs.mkdirSync(path.dirname(KIRO_AGENT_PATH), { recursive: true }); + fs.writeFileSync(KIRO_AGENT_PATH, JSON.stringify(agent, null, 2) + '\n', 'utf-8'); +} + +function isRedashServer(name) { + return name === 'redash' || name.startsWith('redash-'); +} + +// --- commands --- + +function cmdAdd(suffix) { + if (!suffix || suffix === 'default') { + console.error('エラー: デフォルトの redash は plugin 同梱のため追加できません。'); + console.error('suffix には dev, stg, prod2, sandbox などを指定してください。'); + process.exit(1); + } + validateSuffix(suffix); + + const name = mcpName(suffix); + let data = readMcpJson(); + + if (data === undefined) { + console.error('エラー: .mcp.json の JSON が壊れています。手動で修正してください。'); + process.exit(1); + } + + if (data === null) { + data = createMcpJson(); + } else { + data = normalizeWritableMcpJson(data); + } + + const servers = serverMap(data, true); + if (!servers) { + console.error('エラー: .mcp.json の MCP server 定義形式を解釈できません。'); + process.exit(1); + } + + if (servers[name]) { + console.log(`${name} は既に登録されています。変更はありません。`); + process.exit(0); + } + + servers[name] = mcpEntry(suffix); + writeMcpJson(data); + syncKiroAgent(data); + + const env = envName(suffix); + console.log(`${name} を .mcp.json に追加しました。`); + if (isKiroRuntime()) { + console.log(`${name} を .kiro/agents/default.json に同期しました。`); + } + console.log(''); + console.log('必要な環境変数:'); + console.log(` ${env.url}`); + console.log(` ${env.key}`); + console.log(''); + console.log('プロジェクトの .env に設定してください。'); +} + +function cmdRemove(suffix) { + if (!suffix || suffix === 'default') { + console.error('エラー: デフォルトの redash は plugin 同梱のため削除できません。'); + process.exit(1); + } + validateSuffix(suffix); + + const name = mcpName(suffix); + let data = readMcpJson(); + + if (data === undefined) { + console.error('エラー: .mcp.json の JSON が壊れています。手動で修正してください。'); + process.exit(1); + } + + if (data !== null) { + data = normalizeWritableMcpJson(data); + } + + const servers = serverMap(data, false); + if (data === null || !servers || !servers[name]) { + console.log(`${name} は登録されていません。変更はありません。`); + process.exit(0); + } + + delete servers[name]; + writeMcpJson(data); + syncKiroAgent(data); + + console.log(`${name} を .mcp.json から削除しました。`); + if (isKiroRuntime()) { + console.log(`${name} を .kiro/agents/default.json から削除しました。`); + } +} + +function cmdList() { + // plugin 同梱分 + console.log('redash (plugin bundled)'); + + // project .mcp.json 分 + const data = readMcpJson(); + if (data === undefined) { + console.error('警告: .mcp.json の JSON が壊れています。'); + return; + } + const servers = serverMap(data, false); + if (servers) { + const names = Object.keys(servers) + .filter((n) => isRedashServer(n) && n !== 'redash') + .sort(); + for (const name of names) { + console.log(`${name.padEnd(14)}(project)`); + } + } +} + +function cmdStatus() { + console.log('=== Redash MCP Status ==='); + console.log(''); + + // plugin 同梱 + console.log('[redash] (plugin bundled)'); + console.log(' 環境変数: REDASH_URL, REDASH_API_KEY'); + printEnvWarnings(['REDASH_URL', 'REDASH_API_KEY']); + console.log(''); + + // project .mcp.json 分 + const data = readMcpJson(); + if (data === undefined) { + console.error('警告: .mcp.json の JSON が壊れています。'); + return; + } + const servers = serverMap(data, false); + if (servers) { + const names = Object.keys(servers) + .filter((n) => isRedashServer(n) && n !== 'redash') + .sort(); + for (const name of names) { + const suffix = name.replace('redash-', ''); + const env = envName(suffix); + console.log(`[${name}] (project)`); + console.log(` 環境変数: ${env.url}, ${env.key}`); + printEnvWarnings([env.url, env.key]); + console.log(''); + } + if (names.length === 0) { + console.log('追加の Redash MCP はありません。'); + console.log('/redash-add で追加できます。'); + } + } else { + console.log('追加の Redash MCP はありません。'); + console.log('/redash-add で追加できます。'); + } +} + +function printEnvWarnings(vars) { + const missing = vars.filter((v) => !process.env[v]); + if (missing.length > 0) { + for (const v of missing) { + console.log(` ⚠ ${v} が未設定です`); + } + } +} + +// --- main --- + +const [, , command, ...rest] = process.argv; +const suffix = rest[0] ? rest[0].toLowerCase() : ''; + +switch (command) { + case 'add': + cmdAdd(suffix); + break; + case 'remove': + cmdRemove(suffix); + break; + case 'list': + cmdList(); + break; + case 'status': + cmdStatus(); + break; + default: + console.error('Usage: redash-mcp-config.js [suffix]'); + process.exit(1); +} diff --git a/plugins/mcp-redash/skills/redash-add/SKILL.md b/plugins/mcp/kiro/mcp-redash/skills/redash-add/SKILL.md similarity index 86% rename from plugins/mcp-redash/skills/redash-add/SKILL.md rename to plugins/mcp/kiro/mcp-redash/skills/redash-add/SKILL.md index 67b442e..e780e63 100644 --- a/plugins/mcp-redash/skills/redash-add/SKILL.md +++ b/plugins/mcp/kiro/mcp-redash/skills/redash-add/SKILL.md @@ -19,7 +19,7 @@ allowed-tools: 以下のコマンドを実行してください。`$ARGUMENTS` にはユーザーが指定した suffix が入ります。 ```bash -node "${CLAUDE_PLUGIN_ROOT}/scripts/redash-mcp-config.js" add $ARGUMENTS +node "${PLUGIN_ROOT:-.kiro/mcp_runtime/mcp-redash}/scripts/redash-mcp-config.js" add "$ARGUMENTS" ``` ## 実行後 diff --git a/plugins/mcp/kiro/mcp-redash/skills/redash-guide/SKILL.md b/plugins/mcp/kiro/mcp-redash/skills/redash-guide/SKILL.md new file mode 100644 index 0000000..0cbdbdd --- /dev/null +++ b/plugins/mcp/kiro/mcp-redash/skills/redash-guide/SKILL.md @@ -0,0 +1,88 @@ +--- +name: redash-guide +description: Redash MCP プラグインの使い方ガイド +disable-model-invocation: false +user-invocable: true +allowed-tools: [] +--- + +# Redash MCP プラグイン ガイド + +## 基本設定(1環境のみ) + +Redash を1つだけ使う場合は、プロジェクトの `.env` に以下を設定するだけです。 + +```bash +REDASH_URL=https://redash.example.com +REDASH_API_KEY=your-api-key +``` + +プラグインインストール後、`/mcp` に `redash` が自動的に表示されます。 + +## 複数環境の追加 + +dev / stg / prod など複数の Redash を使う場合: + +### 1. 環境を追加 + +``` +/redash-add dev +/redash-add stg +``` + +### 2. 環境変数を設定 + +`.env` に追加分の変数を設定します。 + +```bash +# デフォルト +REDASH_URL=https://redash.example.com +REDASH_API_KEY=your-api-key + +# dev 環境 +REDASH_DEV_URL=https://redash-dev.example.com +REDASH_DEV_API_KEY=your-dev-api-key + +# stg 環境 +REDASH_STG_URL=https://redash-stg.example.com +REDASH_STG_API_KEY=your-stg-api-key +``` + +### 3. 確認 + +``` +/redash-list # 有効な MCP 一覧 +/redash-status # 環境変数の設定状況 +``` + +## suffix 命名ルール + +| suffix | MCP 名 | 環境変数 | +|--------|--------|----------| +| dev | redash-dev | REDASH_DEV_URL, REDASH_DEV_API_KEY | +| stg | redash-stg | REDASH_STG_URL, REDASH_STG_API_KEY | +| prod2 | redash-prod2 | REDASH_PROD2_URL, REDASH_PROD2_API_KEY | +| sandbox-1 | redash-sandbox-1 | REDASH_SANDBOX_1_URL, REDASH_SANDBOX_1_API_KEY | + +- suffix は英小文字・数字・ハイフンで自由に指定可能 +- 環境変数名では suffix 内のハイフンをアンダースコアに変換 +- `default` は予約語のため使用不可(plugin 同梱の `redash` が該当) + +## 環境の削除 + +不要になった環境は削除できます。 + +``` +/redash-remove dev +``` + +plugin 同梱の `redash` は削除できません。 + +## スラッシュコマンド一覧 + +| コマンド | 説明 | +|---------|------| +| `/redash-add ` | Redash MCP を追加 | +| `/redash-remove ` | Redash MCP を削除 | +| `/redash-list` | 有効な MCP 一覧 | +| `/redash-status` | 設定状況の詳細確認 | diff --git a/plugins/mcp-redash/skills/redash-list/SKILL.md b/plugins/mcp/kiro/mcp-redash/skills/redash-list/SKILL.md similarity index 82% rename from plugins/mcp-redash/skills/redash-list/SKILL.md rename to plugins/mcp/kiro/mcp-redash/skills/redash-list/SKILL.md index fe81518..ac510d7 100644 --- a/plugins/mcp-redash/skills/redash-list/SKILL.md +++ b/plugins/mcp/kiro/mcp-redash/skills/redash-list/SKILL.md @@ -16,7 +16,7 @@ allowed-tools: 以下のコマンドを実行してください。 ```bash -node "${CLAUDE_PLUGIN_ROOT}/scripts/redash-mcp-config.js" list +node "${PLUGIN_ROOT:-.kiro/mcp_runtime/mcp-redash}/scripts/redash-mcp-config.js" list ``` ## 実行後 diff --git a/plugins/mcp-redash/skills/redash-remove/SKILL.md b/plugins/mcp/kiro/mcp-redash/skills/redash-remove/SKILL.md similarity index 86% rename from plugins/mcp-redash/skills/redash-remove/SKILL.md rename to plugins/mcp/kiro/mcp-redash/skills/redash-remove/SKILL.md index 31a6270..0a5acbc 100644 --- a/plugins/mcp-redash/skills/redash-remove/SKILL.md +++ b/plugins/mcp/kiro/mcp-redash/skills/redash-remove/SKILL.md @@ -19,7 +19,7 @@ allowed-tools: 以下のコマンドを実行してください。`$ARGUMENTS` にはユーザーが指定した suffix が入ります。 ```bash -node "${CLAUDE_PLUGIN_ROOT}/scripts/redash-mcp-config.js" remove $ARGUMENTS +node "${PLUGIN_ROOT:-.kiro/mcp_runtime/mcp-redash}/scripts/redash-mcp-config.js" remove "$ARGUMENTS" ``` ## 実行後 diff --git a/plugins/mcp-redash/skills/redash-status/SKILL.md b/plugins/mcp/kiro/mcp-redash/skills/redash-status/SKILL.md similarity index 85% rename from plugins/mcp-redash/skills/redash-status/SKILL.md rename to plugins/mcp/kiro/mcp-redash/skills/redash-status/SKILL.md index 2b1f909..5f3e6a9 100644 --- a/plugins/mcp-redash/skills/redash-status/SKILL.md +++ b/plugins/mcp/kiro/mcp-redash/skills/redash-status/SKILL.md @@ -16,7 +16,7 @@ Redash MCP の設定状況を詳細表示します。各 MCP が必要とする 以下のコマンドを実行してください。 ```bash -node "${CLAUDE_PLUGIN_ROOT}/scripts/redash-mcp-config.js" status +node "${PLUGIN_ROOT:-.kiro/mcp_runtime/mcp-redash}/scripts/redash-mcp-config.js" status ``` ## 実行後 diff --git a/plugins/mcp/kiro/mcp-serena/.mcp.json b/plugins/mcp/kiro/mcp-serena/.mcp.json new file mode 100644 index 0000000..17de95a --- /dev/null +++ b/plugins/mcp/kiro/mcp-serena/.mcp.json @@ -0,0 +1,21 @@ +{ + "mcpServers": { + "serena": { + "type": "stdio", + "command": "uvx", + "args": [ + "--from", + "git+https://github.com/oraios/serena", + "serena", + "start-mcp-server", + "--context", + "ide-assistant", + "--enable-web-dashboard", + "False" + ], + "env": { + "SERENA_HOME": ".serena" + } + } + } +} diff --git a/plugins/mcp/kiro/mcp-serena/README.md b/plugins/mcp/kiro/mcp-serena/README.md new file mode 100644 index 0000000..feb79ad --- /dev/null +++ b/plugins/mcp/kiro/mcp-serena/README.md @@ -0,0 +1,37 @@ +# mcp-serena + +Serena MCPサーバーを提供するプラグインです。セマンティックコードインテリジェンス機能(シンボル検索、リファレンス検索、安全なリファクタリング)を利用できます。 + +## インストール + +### Kiro CLI + +Kiro では repository clone 後、対象 plugin の installer を project root で実行します。 + +```bash +bash plugins/mcp/kiro/mcp-serena/install.sh +``` + +### 必要な環境変数 + +- `SERENA_HOME` + +## 提供機能 + +- **シンボル検索**: クラス、関数、メソッドの定義を検索 +- **リファレンス検索**: シンボルの参照箇所を特定 +- **シンボル概要**: ファイル内のシンボル一覧を取得(ファイル全体を読まずに構造を把握) +- **セマンティック編集**: シンボル単位でのコード置換・挿入 +- **パターン検索**: 正規表現によるコード検索 + +## 環境変数 + +| 変数 | 説明 | デフォルト | +|------|------|-----------| +| `SERENA_HOME` | Serena設定ディレクトリ | `.serena` | + +## 注意事項 + +- プロジェクトで使用する前に `activate_project` でアクティベートが必要です +- Serenaのメモリー機能は使用しません。知識は `docs/` に、手順は `skills/` に配置してください +- 詳細は `docs/serena-guide.md` を参照 diff --git a/plugins/mcp/kiro/mcp-serena/docs/serena-guide.md b/plugins/mcp/kiro/mcp-serena/docs/serena-guide.md new file mode 100644 index 0000000..3cd249c --- /dev/null +++ b/plugins/mcp/kiro/mcp-serena/docs/serena-guide.md @@ -0,0 +1,73 @@ +# Serena MCP ガイド + +Serena MCPはセマンティックコードインテリジェンスツールです。 +コード操作専用。メモリー機能は使用しません。 + +## アクティベーション + +プロジェクトで使用する前にアクティベートが必要です。 + +``` +mcp__plugin_mcp-serena_serena__activate_project /path/to/project +mcp__plugin_mcp-serena_serena__check_onboarding_performed +``` + +## コード探索(推奨順序) + +### 1. ディレクトリ構造の確認 + +``` +mcp__plugin_mcp-serena_serena__list_dir relative_path="src/" recursive=false +``` + +### 2. シンボル概要の取得(ファイル全体を読む前に) + +``` +mcp__plugin_mcp-serena_serena__get_symbols_overview relative_path="path/to/file.py" +``` + +### 3. シンボル検索 + +``` +mcp__plugin_mcp-serena_serena__find_symbol name_path="/ClassName/method_name" relative_path="src/" include_body=true +``` + +### 4. パターン検索(シンボル名が不明な場合) + +``` +mcp__plugin_mcp-serena_serena__search_for_pattern substring_pattern="keyword" relative_path="src/" +``` + +### 5. リファレンス検索 + +``` +mcp__plugin_mcp-serena_serena__find_referencing_symbols name_path="/SymbolName" relative_path="src/" +``` + +## コード編集 + +### シンボル単位の置換 + +``` +mcp__plugin_mcp-serena_serena__replace_symbol_body name_path="/Class/method" relative_path="file.py" body="new code" +``` + +### シンボルの前後に挿入 + +``` +mcp__plugin_mcp-serena_serena__insert_before_symbol name_path="/FirstSymbol" relative_path="file.py" content="new code" +mcp__plugin_mcp-serena_serena__insert_after_symbol name_path="/LastSymbol" relative_path="file.py" content="new code" +``` + +### シンボル名の変更 + +``` +mcp__plugin_mcp-serena_serena__rename_symbol name_path="/OldName" relative_path="file.py" new_name="NewName" +``` + +## 使い分けの原則 + +- ファイル全体を読む前に `get_symbols_overview` で概要を確認 +- シンボル名が分かっている場合は `find_symbol` で直接アクセス +- シンボル名が不明な場合は `search_for_pattern` で候補を特定 +- 編集は `replace_symbol_body` でシンボル単位で行う(行ベース編集より安全) diff --git a/plugins/mcp/kiro/mcp-serena/hooks/hooks.json b/plugins/mcp/kiro/mcp-serena/hooks/hooks.json new file mode 100644 index 0000000..171ecaf --- /dev/null +++ b/plugins/mcp/kiro/mcp-serena/hooks/hooks.json @@ -0,0 +1,19 @@ +{ + "description": "mcp-serena: Auto-activate Serena on session start", + "hooks": { + "SessionStart": [ + { + "description": "Remind to activate Serena project", + "hooks": [ + { + "type": "command", + "command": "echo '[mcp-serena] Serena MCPが利用可能です。activate_project でプロジェクトをアクティベートしてください。'", + "description": "Output Serena activation reminder", + "continueOnError": true, + "suppressOutput": false + } + ] + } + ] + } +} diff --git a/plugins/mcp/kiro/mcp-serena/install.sh b/plugins/mcp/kiro/mcp-serena/install.sh new file mode 100755 index 0000000..26e6b00 --- /dev/null +++ b/plugins/mcp/kiro/mcp-serena/install.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PLUGIN_NAME="$(basename "$SCRIPT_DIR")" +PROJECT_ROOT="$(pwd)" +KIRO_DIR="$PROJECT_ROOT/.kiro" +KIRO_AGENT_FILE="$KIRO_DIR/agents/default.json" +KIRO_PLUGIN_LINK="$KIRO_DIR/mcp_runtime/$PLUGIN_NAME" +KIRO_SKILLS_DIR="$KIRO_DIR/skills" +TARGET_MCP="$PROJECT_ROOT/.mcp.json" +SOURCE_MCP="$SCRIPT_DIR/.mcp.json" +SOURCE_HOOKS="$SCRIPT_DIR/hooks/hooks.json" +SOURCE_SKILLS="$SCRIPT_DIR/skills" +DRY_RUN=false + +usage() { + cat <<'USAGE' +Usage: bash install.sh [--project PATH] [--dry-run] + +Install this MCP plugin into the current project's Kiro settings. +USAGE +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --project) + [ "$#" -ge 2 ] || { echo "ERROR: --project requires a path" >&2; exit 2; } + PROJECT_ROOT="$(cd "$2" && pwd)" + KIRO_DIR="$PROJECT_ROOT/.kiro" + KIRO_AGENT_FILE="$KIRO_DIR/agents/default.json" + KIRO_PLUGIN_LINK="$KIRO_DIR/mcp_runtime/$PLUGIN_NAME" + KIRO_SKILLS_DIR="$KIRO_DIR/skills" + TARGET_MCP="$PROJECT_ROOT/.mcp.json" + shift + ;; + --dry-run) DRY_RUN=true ;; + -h|--help) usage; exit 0 ;; + *) echo "Unknown option: $1" >&2; usage >&2; exit 2 ;; + esac + shift +done + +if [ ! -f "$SOURCE_MCP" ]; then + echo "ERROR: source .mcp.json not found: $SOURCE_MCP" >&2 + exit 1 +fi + +if [ "$DRY_RUN" = true ]; then + echo "Would merge $SOURCE_MCP into $TARGET_MCP" + echo "Would link plugin runtime into $KIRO_PLUGIN_LINK" + if [ -d "$SOURCE_SKILLS" ]; then + echo "Would link skills from $SOURCE_SKILLS into $KIRO_SKILLS_DIR" + fi + if [ -f "$SOURCE_HOOKS" ]; then + echo "Would merge hooks from $SOURCE_HOOKS into $KIRO_AGENT_FILE" + fi + exit 0 +fi + +mkdir -p "$KIRO_DIR/mcp_runtime" "$KIRO_SKILLS_DIR" "$(dirname "$KIRO_AGENT_FILE")" +if [ -e "$KIRO_PLUGIN_LINK" ] && [ ! -L "$KIRO_PLUGIN_LINK" ]; then + echo "ERROR: $KIRO_PLUGIN_LINK already exists and is not a symlink" >&2 + exit 1 +fi +ln -sfn "$SCRIPT_DIR" "$KIRO_PLUGIN_LINK" + +if [ -d "$SOURCE_SKILLS" ]; then + find "$SOURCE_SKILLS" -mindepth 1 -maxdepth 1 -type d | sort | while IFS= read -r skill_dir; do + skill_name="$(basename "$skill_dir")" + skill_link="$KIRO_SKILLS_DIR/$skill_name" + [ -f "$skill_dir/SKILL.md" ] || continue + if [ -e "$skill_link" ] && [ ! -L "$skill_link" ]; then + echo "ERROR: $skill_link already exists and is not a symlink" >&2 + exit 1 + fi + ln -sfn "$skill_dir" "$skill_link" + echo "Linked Kiro skill: $skill_name" + done +fi + +python3 - "$SOURCE_MCP" "$TARGET_MCP" "$SOURCE_HOOKS" "$KIRO_AGENT_FILE" <<'PY' +import json +import sys +from pathlib import Path + +source_path = Path(sys.argv[1]) +target_path = Path(sys.argv[2]) +hooks_path = Path(sys.argv[3]) +agent_path = Path(sys.argv[4]) +source = json.loads(source_path.read_text()) +target = json.loads(target_path.read_text()) if target_path.exists() else {} +source_servers = source.get("mcpServers", {}) +target.setdefault("mcpServers", {}) +target["mcpServers"].update(source_servers) +target_path.write_text(json.dumps(target, indent=2, ensure_ascii=False) + "\n") + +if agent_path.exists(): + agent = json.loads(agent_path.read_text()) +else: + agent = { + "name": "default", + "resources": ["skill://.kiro/skills/**/SKILL.md"], + } + +agent.setdefault("mcpServers", {}) +agent["mcpServers"].update(source_servers) + +if hooks_path.exists(): + source_hooks = json.loads(hooks_path.read_text()).get("hooks", {}) + target_hooks = agent.setdefault("hooks", {}) + event_map = { + "SessionStart": "agentSpawn", + "Stop": "stop", + } + for source_event, groups in source_hooks.items(): + target_event = event_map.get(source_event, source_event) + target_list = target_hooks.setdefault(target_event, []) + existing = { + entry.get("command") + for entry in target_list + if isinstance(entry, dict) + } + for group in groups: + for hook in group.get("hooks", []): + command = hook.get("command") + if not command or command in existing: + continue + entry = {"command": command} + if "timeout_ms" in hook: + entry["timeout_ms"] = hook["timeout_ms"] + target_list.append(entry) + existing.add(command) + +agent_path.write_text(json.dumps(agent, indent=2, ensure_ascii=False) + "\n") + +print(f"Installed MCP servers: {', '.join(sorted(source_servers))}") +print(f"Updated: {target_path}") +print(f"Updated: {agent_path}") +PY diff --git a/plugins/mcp/shared/mcp-aws-docs/.claude-plugin/plugin.json b/plugins/mcp/shared/mcp-aws-docs/.claude-plugin/plugin.json new file mode 100644 index 0000000..849646c --- /dev/null +++ b/plugins/mcp/shared/mcp-aws-docs/.claude-plugin/plugin.json @@ -0,0 +1,10 @@ +{ + "name": "mcp-aws-docs", + "version": "1.0.0", + "description": "AWS Documentation MCP server for accessing official AWS documentation", + "author": { + "name": "takemi-ohama", + "url": "https://github.com/takemi-ohama" + }, + "keywords": ["mcp", "aws", "documentation", "docs", "cloud"] +} diff --git a/plugins/mcp/shared/mcp-aws-docs/.mcp.json b/plugins/mcp/shared/mcp-aws-docs/.mcp.json new file mode 100644 index 0000000..01ff167 --- /dev/null +++ b/plugins/mcp/shared/mcp-aws-docs/.mcp.json @@ -0,0 +1,13 @@ +{ + "mcpServers": { + "awslabs.aws-docs": { + "type": "stdio", + "command": "uvx", + "args": [ + "awslabs.aws-documentation-mcp-server@latest" + ], + "env": {}, + "envFile": "${workspaceFolder}/.env" + } + } +} diff --git a/plugins/mcp/shared/mcp-aws-docs/README.md b/plugins/mcp/shared/mcp-aws-docs/README.md new file mode 100644 index 0000000..d030a73 --- /dev/null +++ b/plugins/mcp/shared/mcp-aws-docs/README.md @@ -0,0 +1,77 @@ +# AWS Documentation MCP + +AWS公式ドキュメントにアクセスするためのMCPサーバープラグインです。 + +## 概要 + +このプラグインは、AWS公式ドキュメントを検索・取得する機能を提供します。 + +## 機能 + +- AWS公式ドキュメントの検索 +- ドキュメントページの取得 +- 関連ドキュメントの推薦 +- サービス別ドキュメントへのアクセス + +## インストール + +### Claude Code + +```bash +/plugin install mcp-aws-docs@ai-plugins +``` + +### Codex + +```bash +codex plugin add mcp-aws-docs@ai-plugins +``` + +### Kiro CLI + +Kiro では repository clone 後、対象 plugin の installer を project root で実行します。 + +```bash +bash plugins/mcp/kiro/mcp-aws-docs/install.sh +``` + +### 必要な環境変数 + +- 追加の環境変数は不要です。 + +## 使用方法 + +### 基本的な使用例 + +```bash +# AWS Lambdaのドキュメントを検索 +mcp__plugin_aws-docs-mcp__awslabs.aws-docs__search_documentation "AWS Lambda best practices" + +# 特定のドキュメントページを読み込み +mcp__plugin_aws-docs-mcp__awslabs.aws-docs__read_documentation "https://docs.aws.amazon.com/lambda/..." +``` + +## 推奨される使用シーン + +- AWSサービスの調査 +- ベストプラクティスの確認 +- API仕様の参照 +- トラブルシューティング + +## ndf:researcherエージェントとの連携 + +AWS Docs MCPは、NDFプラグインの`ndf:researcher`エージェントと連携して使用することを推奨します。 + +```bash +# researcherエージェントにAWSドキュメント調査を依頼 +Task( + subagent_type="ndf:researcher", + prompt="Research AWS Lambda best practices for performance", + description="Research Lambda practices" +) +``` + +## 参考リンク + +- [aws-documentation-mcp-server](https://pypi.org/project/awslabs.aws-documentation-mcp-server/) +- [AWS Documentation](https://docs.aws.amazon.com/) diff --git a/plugins/mcp/shared/mcp-aws-docs/docs/aws-docs-guide.md b/plugins/mcp/shared/mcp-aws-docs/docs/aws-docs-guide.md new file mode 100644 index 0000000..57a2328 --- /dev/null +++ b/plugins/mcp/shared/mcp-aws-docs/docs/aws-docs-guide.md @@ -0,0 +1,29 @@ +# AWS Docs MCP ガイド + +AWS Documentation MCPサーバーでAWS公式ドキュメントを検索・参照します。 + +## 主要機能 + +- AWSサービスのドキュメント検索 +- ベストプラクティスの参照 +- API仕様の確認 +- トラブルシューティング情報の取得 + +## 用途 + +- **researcherエージェント**: AWS公式ドキュメントの調査、ベストプラクティスの確認 + +## 使用例 + +``` +# Lambda関数のベストプラクティスを検索 +mcp__plugin_mcp-aws-docs_awslabs.aws-docs__search query="Lambda best practices" + +# S3バケットポリシーのドキュメントを参照 +mcp__plugin_mcp-aws-docs_awslabs.aws-docs__search query="S3 bucket policy" +``` + +## 注意事項 + +- 認証情報は不要(公開ドキュメントへのアクセス) +- 静的な公式ドキュメントの参照に特化。動的コンテンツにはChrome DevTools MCPを使用 diff --git a/plugins/mcp/shared/mcp-bigquery/.claude-plugin/plugin.json b/plugins/mcp/shared/mcp-bigquery/.claude-plugin/plugin.json new file mode 100644 index 0000000..179920b --- /dev/null +++ b/plugins/mcp/shared/mcp-bigquery/.claude-plugin/plugin.json @@ -0,0 +1,10 @@ +{ + "name": "mcp-bigquery", + "version": "1.0.0", + "description": "BigQuery MCP server for data analysis and SQL queries", + "author": { + "name": "takemi-ohama", + "url": "https://github.com/takemi-ohama" + }, + "keywords": ["mcp", "bigquery", "sql", "data", "analysis", "gcp"] +} diff --git a/plugins/mcp/shared/mcp-bigquery/.mcp.json b/plugins/mcp/shared/mcp-bigquery/.mcp.json new file mode 100644 index 0000000..ed2ecd4 --- /dev/null +++ b/plugins/mcp/shared/mcp-bigquery/.mcp.json @@ -0,0 +1,21 @@ +{ + "mcpServers": { + "bigquery": { + "type": "stdio", + "command": "uvx", + "args": [ + "mcp-server-bigquery@latest", + "--project", + "${BIGQUERY_PROJECT}", + "--location", + "${BIGQUERY_LOCATION}", + "--dataset", + "${BIGQUERY_DATASET}", + "--key-file", + "${BIGQUERY_KEY_FILE}" + ], + "env": {}, + "envFile": "${workspaceFolder}/.env" + } + } +} diff --git a/plugins/mcp/shared/mcp-bigquery/README.md b/plugins/mcp/shared/mcp-bigquery/README.md new file mode 100644 index 0000000..6608b5b --- /dev/null +++ b/plugins/mcp/shared/mcp-bigquery/README.md @@ -0,0 +1,105 @@ +# BigQuery MCP + +BigQuery MCPサーバーをClaude Codeで利用するためのプラグインです。 + +## 概要 + +このプラグインは、Google Cloud BigQueryへのアクセスを提供し、SQLクエリの実行やデータ分析を可能にします。 + +## 機能 + +- BigQueryテーブルの一覧取得 +- SQLクエリの実行 +- クエリ結果の取得 +- テーブルスキーマの取得 +- データセットの操作 + +## 前提条件 + +- Google Cloud Projectの作成 +- BigQuery APIの有効化 +- サービスアカウントキーの作成 + +## 環境変数の設定 + +プロジェクトルートに`.env`ファイルを作成し、以下の環境変数を設定してください: + +```bash +# BigQuery Project ID +BIGQUERY_PROJECT=your-project-id + +# BigQuery Location (例: asia-northeast1) +BIGQUERY_LOCATION=asia-northeast1 + +# 利用するデータセット(カンマ区切り) +BIGQUERY_DATASETS=dataset1,dataset2 + +# サービスアカウントキーファイルのパス +BIGQUERY_KEY_FILE=/path/to/service-account-key.json +``` + +## インストール + +### Claude Code + +```bash +/plugin install mcp-bigquery@ai-plugins +``` + +### Codex + +```bash +codex plugin add mcp-bigquery@ai-plugins +``` + +### Kiro CLI + +Kiro では repository clone 後、対象 plugin の installer を project root で実行します。 + +```bash +bash plugins/mcp/kiro/mcp-bigquery/install.sh +``` + +### 必要な環境変数 + +- `BIGQUERY_DATASET` +- `BIGQUERY_KEY_FILE` +- `BIGQUERY_LOCATION` +- `BIGQUERY_PROJECT` + +## 使用方法 + +### 基本的な使用例 + +```bash +# テーブル一覧を取得 +mcp__plugin_bigquery-mcp__bigquery__list_tables + +# SQLクエリを実行 +mcp__plugin_bigquery-mcp__bigquery__execute_query "SELECT * FROM dataset.table LIMIT 10" +``` + +## 推奨される使用シーン + +- データ分析 +- レポート生成 +- SQLクエリの最適化 +- データ抽出 + +## ndf:data-analystエージェントとの連携 + +BigQuery MCPは、NDFプラグインの`ndf:data-analyst`エージェントと連携して使用することを推奨します。 + +```bash +# data-analystエージェントにBigQueryクエリを依頼 +Task( + subagent_type="ndf:data-analyst", + prompt="Analyze last month's sales data in BigQuery", + description="Analyze sales data" +) +``` + +## 参考リンク + +- [mcp-server-bigquery](https://pypi.org/project/mcp-server-bigquery/) +- [Google Cloud BigQuery](https://cloud.google.com/bigquery) diff --git a/plugins/mcp/shared/mcp-bigquery/docs/bigquery-guide.md b/plugins/mcp/shared/mcp-bigquery/docs/bigquery-guide.md new file mode 100644 index 0000000..2e7d1fd --- /dev/null +++ b/plugins/mcp/shared/mcp-bigquery/docs/bigquery-guide.md @@ -0,0 +1,31 @@ +# BigQuery MCP ガイド + +BigQuery MCPサーバーでGoogle BigQueryのデータ分析を行います。 + +## 環境変数(必須) + +| 変数 | 説明 | +|------|------| +| `BIGQUERY_PROJECT` | GCPプロジェクトID | +| `BIGQUERY_LOCATION` | データセットのロケーション(例: `asia-northeast1`) | +| `BIGQUERY_DATASET` | デフォルトデータセット名 | +| `BIGQUERY_KEY_FILE` | サービスアカウントキーファイルのパス | + +## 主要ツール + +- `execute-query` - SQLクエリを実行し結果を取得 +- `list-tables` - データセット内のテーブル一覧を取得 +- `describe-table` - テーブルのスキーマ情報を取得 + +## 使用例 + +``` +# テーブル一覧の取得 +mcp__plugin_mcp-bigquery_bigquery__list-tables + +# テーブルのスキーマ確認 +mcp__plugin_mcp-bigquery_bigquery__describe-table table_name="users" + +# SQLクエリ実行 +mcp__plugin_mcp-bigquery_bigquery__execute-query query="SELECT * FROM users LIMIT 10" +``` diff --git a/plugins/mcp/shared/mcp-chrome-devtools/.claude-plugin/plugin.json b/plugins/mcp/shared/mcp-chrome-devtools/.claude-plugin/plugin.json new file mode 100644 index 0000000..45fc51f --- /dev/null +++ b/plugins/mcp/shared/mcp-chrome-devtools/.claude-plugin/plugin.json @@ -0,0 +1,10 @@ +{ + "name": "mcp-chrome-devtools", + "version": "1.0.0", + "description": "Chrome DevTools MCP server for browser automation and testing", + "author": { + "name": "takemi-ohama", + "url": "https://github.com/takemi-ohama" + }, + "keywords": ["mcp", "chrome", "devtools", "browser", "automation", "testing"] +} diff --git a/plugins/mcp/shared/mcp-chrome-devtools/.mcp.json b/plugins/mcp/shared/mcp-chrome-devtools/.mcp.json new file mode 100644 index 0000000..88e4c76 --- /dev/null +++ b/plugins/mcp/shared/mcp-chrome-devtools/.mcp.json @@ -0,0 +1,17 @@ +{ + "mcpServers": { + "chrome-devtools": { + "type": "stdio", + "command": "npx", + "args": [ + "-y", + "chrome-devtools-mcp@latest", + "--isolated", + "--chromeArg=--no-sandbox", + "--chromeArg=--disable-setuid-sandbox", + "--chromeArg=--disable-dev-shm-usage" + ], + "envFile": "${workspaceFolder}/.env" + } + } +} diff --git a/plugins/mcp/shared/mcp-chrome-devtools/README.md b/plugins/mcp/shared/mcp-chrome-devtools/README.md new file mode 100644 index 0000000..b62f5a1 --- /dev/null +++ b/plugins/mcp/shared/mcp-chrome-devtools/README.md @@ -0,0 +1,69 @@ +# Chrome DevTools MCP + +Chrome DevTools MCPサーバーをClaude Codeで利用するためのプラグインです。 + +## 概要 + +このプラグインは、ブラウザ自動化とテストのためのChrome DevTools Protocol (CDP)へのアクセスを提供します。 + +## 機能 + +- ブラウザの自動操作 +- スクリーンショット撮影 +- ページナビゲーション +- JavaScriptの実行 +- ネットワークリクエストの監視 +- コンソールログの取得 + +## インストール + +### Claude Code + +```bash +/plugin install mcp-chrome-devtools@ai-plugins +``` + +### Codex + +```bash +codex plugin add mcp-chrome-devtools@ai-plugins +``` + +### Kiro CLI + +Kiro では repository clone 後、対象 plugin の installer を project root で実行します。 + +```bash +bash plugins/mcp/kiro/mcp-chrome-devtools/install.sh +``` + +### 必要な環境変数 + +- 追加の環境変数は不要です。 + +## 使用方法 + +### 基本的な使用例 + +```bash +# ページにアクセスしてスクリーンショットを撮影 +mcp__plugin_chrome-devtools-mcp__chrome-devtools__navigate_page "https://example.com" +mcp__plugin_chrome-devtools-mcp__chrome-devtools__take_screenshot +``` + +## 推奨される使用シーン + +- Webアプリケーションのテスト +- スクレイピング +- パフォーマンス測定 +- UIの自動検証 + +## 注意事項 + +- Dockerコンテナ内で実行する場合、`--no-sandbox`オプションが必要です +- DinD/DooD環境では接続方法が異なる場合があります(`ndf:docker-container-access` Skillを参照) + +## 参考リンク + +- [chrome-devtools-mcp](https://www.npmjs.com/package/chrome-devtools-mcp) +- [Chrome DevTools Protocol](https://chromedevtools.github.io/devtools-protocol/) diff --git a/plugins/mcp/shared/mcp-chrome-devtools/docs/chrome-devtools-guide.md b/plugins/mcp/shared/mcp-chrome-devtools/docs/chrome-devtools-guide.md new file mode 100644 index 0000000..466323f --- /dev/null +++ b/plugins/mcp/shared/mcp-chrome-devtools/docs/chrome-devtools-guide.md @@ -0,0 +1,21 @@ +# Chrome DevTools MCP ガイド + +Chrome DevTools MCPサーバーでブラウザの操作・デバッグ・パフォーマンス測定を行います。 + +## 主要機能 + +- ページナビゲーション・DOM操作 +- ネットワークリクエストの監視 +- コンソールログの取得 +- パフォーマンスプロファイリング +- スクリーンショット取得 + +## 用途 + +- **researcherエージェント**: JavaScript動的コンテンツの取得、Webスクレイピング +- **qaエージェント**: Webアプリケーションのパフォーマンス測定、UI検証 + +## 注意事項 + +- Docker環境(DinD/DooD)では`--no-sandbox`オプションが必要(設定済み) +- localhostへのアクセスはDocker環境に依存。詳細は `docker-container-access` スキルを参照 diff --git a/plugins/mcp/shared/mcp-dbhub/.claude-plugin/plugin.json b/plugins/mcp/shared/mcp-dbhub/.claude-plugin/plugin.json new file mode 100644 index 0000000..8c80622 --- /dev/null +++ b/plugins/mcp/shared/mcp-dbhub/.claude-plugin/plugin.json @@ -0,0 +1,10 @@ +{ + "name": "mcp-dbhub", + "version": "1.2.0", + "description": "DBHub MCP server for database operations and SQL queries", + "author": { + "name": "takemi-ohama", + "url": "https://github.com/takemi-ohama" + }, + "keywords": ["mcp", "database", "sql", "dbhub", "mysql", "postgres"] +} diff --git a/plugins/mcp/shared/mcp-dbhub/.env.example b/plugins/mcp/shared/mcp-dbhub/.env.example new file mode 100644 index 0000000..007c192 --- /dev/null +++ b/plugins/mcp/shared/mcp-dbhub/.env.example @@ -0,0 +1,12 @@ +# .env - DBHub環境変数設定 +# このファイルを .env にコピーしてプロジェクトルートに配置してください。 +# dbhub.toml が存在しない場合のフォールバックとして使用されます。 + +# データベース接続文字列 +DBHUB_DSN=mysql://user:password@db-host:3306/dbname + +# SSH踏み台設定(必要な場合のみ) +# DBHUB_SSH_HOST=bastion.example.com +# DBHUB_SSH_PORT=22 +# DBHUB_SSH_USER=ubuntu +# DBHUB_SSH_KEY=~/.ssh/id_rsa diff --git a/plugins/mcp/shared/mcp-dbhub/.mcp.json b/plugins/mcp/shared/mcp-dbhub/.mcp.json new file mode 100644 index 0000000..dc078ac --- /dev/null +++ b/plugins/mcp/shared/mcp-dbhub/.mcp.json @@ -0,0 +1,12 @@ +{ + "mcpServers": { + "dbhub": { + "type": "stdio", + "command": "npx", + "args": [ + "--yes", + "@bytebase/dbhub" + ] + } + } +} diff --git a/plugins/mcp/shared/mcp-dbhub/README.md b/plugins/mcp/shared/mcp-dbhub/README.md new file mode 100644 index 0000000..32ea657 --- /dev/null +++ b/plugins/mcp/shared/mcp-dbhub/README.md @@ -0,0 +1,167 @@ +# DBHub MCP + +データベース操作とSQLクエリを実行するためのMCPサーバープラグインです。 + +## 概要 + +このプラグインは、MySQL、PostgreSQL、SQLiteなどの各種データベースへのアクセスを提供します。 + +## 機能 + +- データベース接続管理 +- SQLクエリの実行 +- テーブルスキーマの取得 +- データベース一覧の取得 +- 複数データベースへの同時接続(TOML設定) + +## インストール + +### Claude Code + +```bash +/plugin install mcp-dbhub@ai-plugins +``` + +### Codex + +```bash +codex plugin add mcp-dbhub@ai-plugins +``` + +### Kiro CLI + +Kiro では repository clone 後、対象 plugin の installer を project root で実行します。 + +```bash +bash plugins/mcp/kiro/mcp-dbhub/install.sh +``` + +### 必要な環境変数 + +- 追加の環境変数は不要です。 + +## 設定 + +2つの設定方法があり、`dbhub.toml`が優先されます。 + +### 方法1: dbhub.toml(複数DB対応・推奨) + +プロジェクトルートに `dbhub.toml` を配置すると自動検出されます。 + +```toml +# 単一データベース +[[sources]] +id = "mydb" +description = "本番 MySQL" +dsn = "mysql://user:password@db-host:3306/dbname" +``` + +```toml +# 複数データベース +[[sources]] +id = "production" +description = "本番 MySQL" +dsn = "mysql://user:password@prod-db:3306/myapp" + +[[sources]] +id = "staging" +description = "ステージング MySQL" +dsn = "mysql://user:password@staging-db:3306/myapp_stg" +``` + +複数ソース定義時、ツール名に自動でサフィックスが付与されます(例: `execute_sql_production`, `execute_sql_staging`)。 + +#### SSH踏み台設定(TOML内) + +ソースごとにSSH踏み台を定義できます。 + +```toml +[[sources]] +id = "production" +description = "本番 MySQL(踏み台経由)" +dsn = "mysql://user:password@db.internal:3306/myapp" +ssh_host = "bastion.example.com" +ssh_port = 22 +ssh_user = "ubuntu" +ssh_key = "~/.ssh/id_rsa" +# ssh_passphrase = "鍵のパスフレーズ(暗号化されている場合)" +``` + +マルチホップSSH(多段踏み台)にも対応しています。 + +```toml +[[sources]] +id = "prod_multihop" +dsn = "postgres://user:password@10.0.1.100:5432/myapp" +ssh_host = "internal-server.company.com" +ssh_user = "deploy" +ssh_key = "~/.ssh/id_ed25519" +ssh_proxy_jump = "bastion.company.com,admin@jump2.internal:2222" +``` + +| フィールド | 必須 | 説明 | +|-----------|------|------| +| `ssh_host` | Yes | 踏み台ホスト名 | +| `ssh_port` | No | ポート(デフォルト: 22) | +| `ssh_user` | Yes | SSHユーザー名 | +| `ssh_key` | ※ | 秘密鍵パス(`~`展開可) | +| `ssh_password` | ※ | パスワード認証用 | +| `ssh_passphrase` | No | 秘密鍵のパスフレーズ | +| `ssh_proxy_jump` | No | マルチホップ(カンマ区切り) | + +※ `ssh_key` または `ssh_password` のいずれかが必須 + +### 方法2: .env(単一DB・簡易設定) + +`dbhub.toml`が存在しない場合、`.env`の環境変数にフォールバックします。 + +```bash +# データベース接続文字列 +DBHUB_DSN=mysql://user:password@db-host:3306/dbname + +# SSH踏み台(必要な場合) +DBHUB_SSH_HOST=bastion.example.com +DBHUB_SSH_PORT=22 +DBHUB_SSH_USER=ubuntu +DBHUB_SSH_KEY=~/.ssh/id_rsa +``` + +DSNのホスト名は踏み台から見た内部ホスト名を指定してください。 + +### 優先順位 + +1. `dbhub.toml`(プロジェクトルートに存在すれば自動読み込み) +2. `.env`の`DBHUB_DSN`環境変数(tomlが無い場合のフォールバック) + +## 使用例 + +```bash +# SQLクエリを実行 +mcp__plugin_mcp-dbhub_dbhub__execute_sql "SELECT * FROM users LIMIT 10" + +# テーブル一覧を取得 +mcp__plugin_mcp-dbhub_dbhub__search_objects --object_type table +``` + +## ndf:data-analystエージェントとの連携 + +DBHub MCPは、NDFプラグインの`ndf:data-analyst`エージェントと連携して使用することを推奨します。 + +```bash +Task( + subagent_type="ndf:data-analyst", + prompt="Analyze user activity in the local database", + description="Analyze user activity" +) +``` + +## 注意事項 + +- `dbhub.toml`と`.env`は`.gitignore`に追加し、認証情報をコミットしないでください +- SSH踏み台経由の場合、DSNのホスト名は踏み台から見た内部ホスト名を指定します + +## 参考リンク + +- [takemi-ohama/dbhub](https://github.com/takemi-ohama/dbhub) - 現在使用中のフォーク版(SSH keep-alive対応) +- [@bytebase/dbhub](https://www.npmjs.com/package/@bytebase/dbhub) - 本家パッケージ +- [Bytebase](https://www.bytebase.com/) diff --git a/plugins/mcp/shared/mcp-dbhub/dbhub.toml.example b/plugins/mcp/shared/mcp-dbhub/dbhub.toml.example new file mode 100644 index 0000000..4400680 --- /dev/null +++ b/plugins/mcp/shared/mcp-dbhub/dbhub.toml.example @@ -0,0 +1,38 @@ +# dbhub.toml - データベース接続設定 +# このファイルを dbhub.toml にコピーしてプロジェクトルートに配置してください。 +# dbhub.toml は .gitignore に追加し、認証情報をコミットしないでください。 + +# 単一データベース(SSH踏み台なし) +[[sources]] +id = "mydb" +description = "開発用 MySQL" +dsn = "mysql://user:password@localhost:3306/mydb" + +# 単一データベース(SSH踏み台経由) +# [[sources]] +# id = "production" +# description = "本番 MySQL(踏み台経由)" +# dsn = "mysql://user:password@db.internal:3306/myapp" +# ssh_host = "bastion.example.com" +# ssh_port = 22 +# ssh_user = "ubuntu" +# ssh_key = "~/.ssh/id_rsa" + +# 複数データベース +# [[sources]] +# id = "production" +# description = "本番 MySQL" +# dsn = "mysql://user:password@prod-db.internal:3306/myapp" +# ssh_host = "bastion.example.com" +# ssh_port = 22 +# ssh_user = "ubuntu" +# ssh_key = "~/.ssh/id_rsa" +# +# [[sources]] +# id = "staging" +# description = "ステージング MySQL" +# dsn = "mysql://user:password@staging-db.internal:3306/myapp_stg" +# ssh_host = "bastion.example.com" +# ssh_port = 22 +# ssh_user = "ubuntu" +# ssh_key = "~/.ssh/id_rsa" diff --git a/plugins/mcp/shared/mcp-dbhub/docs/dbhub-guide.md b/plugins/mcp/shared/mcp-dbhub/docs/dbhub-guide.md new file mode 100644 index 0000000..1fb3d8e --- /dev/null +++ b/plugins/mcp/shared/mcp-dbhub/docs/dbhub-guide.md @@ -0,0 +1,30 @@ +# DBHub MCP ガイド + +DBHub MCPサーバーで各種データベースへのSQL操作を行います。 + +## 接続設定 + +DBHubは接続文字列(DSN)でデータベースに接続します。 +環境変数またはツール引数で接続先を指定してください。 + +## 主要ツール + +- `execute_sql_{suffix}` - 指定環境でSQLクエリを実行 +- `search_objects_{suffix}` - データベースオブジェクト(テーブル、カラム等)を検索 + +suffixはインストール時の設定に依存します(例: `production`, `alpha`, `delta`)。 + +## 使用例 + +``` +# テーブル・カラムの検索 +mcp__plugin_mcp-dbhub_dbhub__search_objects_production keyword="users" + +# SQLクエリ実行 +mcp__plugin_mcp-dbhub_dbhub__execute_sql_production sql="SELECT * FROM users LIMIT 10" +``` + +## 注意事項 + +- 本番環境への書き込みクエリ(INSERT, UPDATE, DELETE)は慎重に実行してください +- 大量データの取得時はLIMIT句を使用してください diff --git a/plugins/mcp/shared/mcp-devin/.claude-plugin/plugin.json b/plugins/mcp/shared/mcp-devin/.claude-plugin/plugin.json new file mode 100644 index 0000000..f994c84 --- /dev/null +++ b/plugins/mcp/shared/mcp-devin/.claude-plugin/plugin.json @@ -0,0 +1,10 @@ +{ + "name": "mcp-devin", + "version": "1.0.0", + "description": "Devin MCP server for accessing repository documentation and code knowledge via DeepWiki", + "author": { + "name": "takemi-ohama", + "url": "https://github.com/takemi-ohama" + }, + "keywords": ["mcp", "devin", "deepwiki", "documentation", "repository", "wiki", "code-knowledge"] +} diff --git a/plugins/mcp/shared/mcp-devin/.mcp.json b/plugins/mcp/shared/mcp-devin/.mcp.json new file mode 100644 index 0000000..fe323ee --- /dev/null +++ b/plugins/mcp/shared/mcp-devin/.mcp.json @@ -0,0 +1,15 @@ +{ + "mcpServers": { + "devin": { + "type": "http", + "url": "https://mcp.devin.ai/mcp", + "headers": { + "Authorization": "Bearer ${DEVIN_API_KEY}" + }, + "env": { + "DEVIN_API_KEY": "${DEVIN_API_KEY}" + }, + "envFile": "${workspaceFolder}/.env" + } + } +} diff --git a/plugins/mcp/shared/mcp-devin/README.md b/plugins/mcp/shared/mcp-devin/README.md new file mode 100644 index 0000000..3c1d032 --- /dev/null +++ b/plugins/mcp/shared/mcp-devin/README.md @@ -0,0 +1,104 @@ +# Devin MCP + +リポジトリのドキュメントとコード知識にアクセスするためのMCPサーバープラグインです。 + +## 概要 + +このプラグインは、[Devin MCP](https://mcp.devin.ai/mcp)サーバーを利用して、GitHubなどのリポジトリのドキュメント構造や内容をAIエージェントから直接参照できるようにします。Devin APIキー(`DEVIN_API_KEY`)が必要です。 + +## 機能 + +- **`read_wiki_structure`** - リポジトリのドキュメントトピック一覧を取得 +- **`read_wiki_contents`** - ドキュメントの内容を閲覧 +- **`ask_question`** - リポジトリに関するAI質問回答 + +## 前提条件 + +- Devin APIキー(環境変数 `DEVIN_API_KEY`) + +## 環境変数の設定 + +プロジェクトルートの`.env`ファイルに以下を設定してください: + +```bash +# Devin API Key +DEVIN_API_KEY=your-devin-api-key +``` + +## インストール + +### Claude Code + +```bash +/plugin install mcp-devin@ai-plugins +``` + +### Codex + +```bash +codex plugin add mcp-devin@ai-plugins +``` + +### Kiro CLI + +Kiro では repository clone 後、対象 plugin の installer を project root で実行します。 + +```bash +bash plugins/mcp/kiro/mcp-devin/install.sh +``` + +### 必要な環境変数 + +- `DEVIN_API_KEY` + +## 使用方法 + +### 基本的な使用例 + +```bash +# リポジトリのドキュメント構造を取得 +mcp__plugin_mcp-devin__devin__read_wiki_structure "owner/repo" + +# ドキュメント内容を読み取り +mcp__plugin_mcp-devin__devin__read_wiki_contents "owner/repo" "topic" + +# リポジトリについて質問 +mcp__plugin_mcp-devin__devin__ask_question "owner/repo" "How does authentication work?" +``` + +### CLIで直接追加する場合 + +```bash +claude mcp add -s user -t http devin https://mcp.devin.ai/mcp --header "Authorization: Bearer $DEVIN_API_KEY" +``` + +## 推奨される使用シーン + +- OSSライブラリの仕様・アーキテクチャ調査 +- 依存パッケージの内部実装の理解 +- リポジトリのドキュメント横断検索 +- 技術選定時のコード比較調査 + +## NDFエージェントとの連携 + +DeepWiki MCPは、NDFプラグインの`ndf:researcher`エージェントと連携して使用することを推奨します。 + +```bash +Task( + subagent_type="ndf:researcher", + prompt="DeepWikiを使ってlangchain/langchainリポジトリのRAG実装を調査してください", + description="Research RAG implementation" +) +``` + +## 注意事項 + +- `DEVIN_API_KEY` の設定が必要です +- HTTPトランスポートを使用しています(Devin公式MCPサーバー `https://mcp.devin.ai/mcp`) +- `Authorization: Bearer {DEVIN_API_KEY}` ヘッダーで認証します +- レート制限がある場合があります + +## 参考リンク + +- [DeepWiki](https://deepwiki.com) +- [DeepWiki MCP ドキュメント](https://docs.devin.ai/ja/work-with-devin/deepwiki-mcp) diff --git a/plugins/mcp/shared/mcp-markitdown/.claude-plugin/plugin.json b/plugins/mcp/shared/mcp-markitdown/.claude-plugin/plugin.json new file mode 100644 index 0000000..96f0f42 --- /dev/null +++ b/plugins/mcp/shared/mcp-markitdown/.claude-plugin/plugin.json @@ -0,0 +1,10 @@ +{ + "name": "mcp-markitdown", + "version": "1.0.0", + "description": "MarkItDown MCP server for converting documents (PDF, Office, images, etc.) to Markdown", + "author": { + "name": "takemi-ohama", + "url": "https://github.com/takemi-ohama" + }, + "keywords": ["mcp", "markitdown", "markdown", "pdf", "docx", "pptx", "xlsx", "images", "converter"] +} diff --git a/plugins/mcp/shared/mcp-markitdown/.mcp.json b/plugins/mcp/shared/mcp-markitdown/.mcp.json new file mode 100644 index 0000000..844df81 --- /dev/null +++ b/plugins/mcp/shared/mcp-markitdown/.mcp.json @@ -0,0 +1,11 @@ +{ + "mcpServers": { + "markitdown": { + "type": "stdio", + "command": "uvx", + "args": [ + "markitdown-mcp" + ] + } + } +} diff --git a/plugins/mcp/shared/mcp-markitdown/README.md b/plugins/mcp/shared/mcp-markitdown/README.md new file mode 100644 index 0000000..5988fd2 --- /dev/null +++ b/plugins/mcp/shared/mcp-markitdown/README.md @@ -0,0 +1,71 @@ +# MarkItDown MCP + +各種ドキュメント(PDF、Office、画像など)をMarkdownに変換するMCPサーバープラグインです。 + +## 概要 + +[Microsoft MarkItDown](https://github.com/microsoft/markitdown) のMCPサーバーラッパーです。HTTP/HTTPS URL、ローカルファイル、Data URIを指定してドキュメントをMarkdownに変換できます。 + +## 機能 + +- `convert_to_markdown(uri)` ツールを提供 +- 対応フォーマット: PDF, Word (.docx), Excel (.xlsx), PowerPoint (.pptx), 画像, HTML, CSV, JSON, XML など +- HTTP/HTTPS URL、ローカルファイルパス(`file://`)、Data URI に対応 + +## インストール + +### Claude Code + +```bash +/plugin install mcp-markitdown@ai-plugins +``` + +### Codex + +```bash +codex plugin add mcp-markitdown@ai-plugins +``` + +### Kiro CLI + +Kiro では repository clone 後、対象 plugin の installer を project root で実行します。 + +```bash +bash plugins/mcp/kiro/mcp-markitdown/install.sh +``` + +### 必要な環境変数 + +- 追加の環境変数は不要です。 + +## 使用例 + +```bash +# URLからドキュメントを変換 +mcp__plugin_mcp-markitdown_markitdown__convert_to_markdown uri="https://example.com/document.pdf" + +# ローカルファイルを変換 +mcp__plugin_mcp-markitdown_markitdown__convert_to_markdown uri="file:///path/to/document.docx" +``` + +## ndf:scannerエージェントとの連携 + +MarkItDown MCPは、NDFプラグインの`ndf:scanner`エージェントと連携して使用することを推奨します。 + +```bash +Task( + subagent_type="ndf:scanner", + prompt="Convert /path/to/document.pdf to Markdown and summarize key points.", + description="Convert and summarize PDF" +) +``` + +## 注意事項 + +- Python環境が必要です(`uvx` 経由で自動インストール) +- 大きなファイルの変換には時間がかかる場合があります + +## 参考リンク + +- [microsoft/markitdown](https://github.com/microsoft/markitdown) - 本家リポジトリ +- [markitdown-mcp (PyPI)](https://pypi.org/project/markitdown-mcp/) - PyPIパッケージ diff --git a/plugins/mcp/shared/mcp-markitdown/docs/markitdown-guide.md b/plugins/mcp/shared/mcp-markitdown/docs/markitdown-guide.md new file mode 100644 index 0000000..7ba0cf0 --- /dev/null +++ b/plugins/mcp/shared/mcp-markitdown/docs/markitdown-guide.md @@ -0,0 +1,30 @@ +# MarkItDown MCP ガイド + +MarkItDown MCPサーバーで各種ドキュメントをMarkdown形式に変換します。 + +## 対応フォーマット + +- PDF +- Microsoft Office(Word, Excel, PowerPoint) +- 画像(OCR) +- HTML +- その他のドキュメント形式 + +## 主要ツール + +- `convert_to_markdown` - ファイルをMarkdownに変換 + +## 使用例 + +``` +# PDFをMarkdownに変換 +mcp__plugin_mcp-markitdown_markitdown__convert_to_markdown uri="file:///path/to/document.pdf" + +# Excelファイルを変換 +mcp__plugin_mcp-markitdown_markitdown__convert_to_markdown uri="file:///path/to/data.xlsx" +``` + +## 使い分け + +- scannerエージェントがPDF/Office/画像ファイルの読み取りに使用 +- Read toolで直接読めないファイル形式の変換に有用 diff --git a/plugins/mcp/shared/mcp-notion/.claude-plugin/plugin.json b/plugins/mcp/shared/mcp-notion/.claude-plugin/plugin.json new file mode 100644 index 0000000..dc75247 --- /dev/null +++ b/plugins/mcp/shared/mcp-notion/.claude-plugin/plugin.json @@ -0,0 +1,10 @@ +{ + "name": "mcp-notion", + "version": "1.0.0", + "description": "Notion MCP server for note management and knowledge base operations", + "author": { + "name": "takemi-ohama", + "url": "https://github.com/takemi-ohama" + }, + "keywords": ["mcp", "notion", "notes", "knowledge-base", "documentation"] +} diff --git a/plugins/mcp/shared/mcp-notion/.mcp.json b/plugins/mcp/shared/mcp-notion/.mcp.json new file mode 100644 index 0000000..fc5b4de --- /dev/null +++ b/plugins/mcp/shared/mcp-notion/.mcp.json @@ -0,0 +1,10 @@ +{ + "mcpServers": { + "notion": { + "type": "http", + "url": "https://mcp.notion.com/mcp", + "env": {}, + "envFile": "${workspaceFolder}/.env" + } + } +} diff --git a/plugins/mcp/shared/mcp-notion/README.md b/plugins/mcp/shared/mcp-notion/README.md new file mode 100644 index 0000000..d6a2d6b --- /dev/null +++ b/plugins/mcp/shared/mcp-notion/README.md @@ -0,0 +1,89 @@ +# Notion MCP + +NotionワークスペースにアクセスするためのMCPサーバープラグインです。 + +## 概要 + +このプラグインは、Notionのページ、データベース、ブロックへのアクセスを提供します。 + +## 機能 + +- Notionページの作成・読み取り・更新 +- データベースのクエリ +- ブロックの操作 +- ページ階層の取得 +- コメントの追加 + +## 前提条件 + +- Notionアカウント +- Notion Integration(内部統合)の作成 +- 統合トークンの取得 + +## 環境変数の設定 + +プロジェクトルートに`.env`ファイルを作成し、以下の環境変数を設定してください: + +```bash +# Notion Integration Token +NOTION_TOKEN= +``` + +## インストール + +### Claude Code + +```bash +/plugin install mcp-notion@ai-plugins +``` + +### Codex + +```bash +codex plugin add mcp-notion@ai-plugins +``` + +### Kiro CLI + +Kiro では repository clone 後、対象 plugin の installer を project root で実行します。 + +```bash +bash plugins/mcp/kiro/mcp-notion/install.sh +``` + +### 必要な環境変数 + +- 追加の環境変数は不要です。 + +## 使用方法 + +### 基本的な使用例 + +```bash +# ページを読み取り +mcp__plugin_notion-mcp__notion__read_page "page-id" + +# データベースをクエリ +mcp__plugin_notion-mcp__notion__query_database "database-id" + +# 新しいページを作成 +mcp__plugin_notion-mcp__notion__create_page "parent-page-id" "New Page Title" +``` + +## 推奨される使用シーン + +- ドキュメント管理 +- ナレッジベースの構築 +- タスク管理 +- プロジェクト情報の同期 + +## 注意事項 + +- Notion IntegrationをワークスペースやページにShareする必要があります +- HTTPトランスポートを使用しています(公式のNotion MCPサーバー) + +## 参考リンク + +- [Notion MCP Server](https://mcp.notion.com) +- [Notion API](https://developers.notion.com/) +- [Notion Integration Setup](https://www.notion.so/help/create-integrations-with-the-notion-api) diff --git a/plugins/mcp/shared/mcp-playwright/.claude-plugin/plugin.json b/plugins/mcp/shared/mcp-playwright/.claude-plugin/plugin.json new file mode 100644 index 0000000..cca6d67 --- /dev/null +++ b/plugins/mcp/shared/mcp-playwright/.claude-plugin/plugin.json @@ -0,0 +1,10 @@ +{ + "name": "mcp-playwright", + "version": "1.0.1", + "description": "Playwright MCPを自動セットアップするプラグイン。Chromiumブラウザの自動インストールとMCP設定を提供します。", + "author": { + "name": "NDF Team", + "url": "https://github.com/devbasex/ai-plugins" + }, + "keywords": ["mcp", "playwright", "browser", "automation", "testing"] +} diff --git a/plugins/mcp/shared/mcp-playwright/.mcp.json b/plugins/mcp/shared/mcp-playwright/.mcp.json new file mode 100644 index 0000000..2313c0d --- /dev/null +++ b/plugins/mcp/shared/mcp-playwright/.mcp.json @@ -0,0 +1,20 @@ +{ + "mcpServers": { + "playwright": { + "type": "stdio", + "command": "npx", + "args": [ + "-y", + "@playwright/mcp@latest", + "--browser", + "chromium", + "--isolated", + "--no-sandbox" + ], + "env": { + "PLAYWRIGHT_BROWSERS_PATH": "${HOME}/.cache/ms-playwright" + }, + "envFile": "${workspaceFolder}/.env" + } + } +} diff --git a/plugins/mcp/shared/mcp-playwright/README.codex.md b/plugins/mcp/shared/mcp-playwright/README.codex.md new file mode 100644 index 0000000..5635b95 --- /dev/null +++ b/plugins/mcp/shared/mcp-playwright/README.codex.md @@ -0,0 +1,148 @@ +# playwright Plugin + +Playwright MCPを自動的にセットアップし、ブラウザ自動化機能をCodexで利用するためのプラグインです。 + +## 概要 + +このプラグインは、Playwright MCPサーバーとChromiumブラウザを自動的にインストール・設定します。Codexでブラウザ自動化タスク(Webスクレイピング、E2Eテスト、スクリーンショット取得など)を実行できるようになります。 + +## 機能 + +- **自動セットアップ**: 初回起動時にPlaywright Chromiumブラウザを自動インストール +- **バージョン互換性**: @playwright/mcpと互換性のあるPlaywrightバージョンを自動検出 +- **冪等性**: 既にインストール済みの場合はスキップ(高速起動) +- **環境変数対応**: PLAYWRIGHT_BROWSERS_PATHで共有キャッシュを利用 + +## インストール + +### Codex + +```bash +codex plugin add mcp-playwright@ai-plugins +``` + +### 必要な環境変数 + +- `HOME` + +## 使用方法 + +### 基本的な使い方 + +プラグインをインストールして、Codexを起動すると自動的にセットアップが実行されます。 + +```bash +codex +``` + +初回起動時、以下のようなメッセージが表示されます: + +``` +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +🎭 Playwright MCP Plugin: 初回セットアップ +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Playwright Chromiumブラウザをインストール中... +ネットワーク環境により1-2分かかる場合があります。 + +🔍 @playwright/mcpが使用するPlaywrightバージョンを確認中... +✓ Playwright 1.49.1 を使用します + +📦 Playwright 1.49.1 でChromiumをインストール中... +[インストールログ...] + +✅ セットアップ完了!Playwright Chromiumの準備ができました。 + Playwrightバージョン: 1.49.1 + ブラウザパス: ~/.cache/ms-playwright +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +``` + +### Playwright MCPツールの使用例 + +セットアップ完了後、CodexのMCPツール一覧に表示される `playwright` サーバーの `browser_*` ツールを利用できます。 + +## 設定 + +### MCP設定(`.mcp.json`) + +Codex runtimeでは、プラグイン内の `.mcp.json` は direct server map 形式で提供されます: + +```json +{ + "playwright": { + "type": "stdio", + "command": "npx", + "args": [ + "-y", + "@playwright/mcp@latest", + "--browser", + "chromium", + "--isolated", + "--no-sandbox" + ], + "env": { + "PLAYWRIGHT_BROWSERS_PATH": "${HOME}/.cache/ms-playwright" + } + } +} +``` + +### ブラウザキャッシュパス + +デフォルトでは、Chromiumブラウザは `~/.cache/ms-playwright` にインストールされます。 + +別の場所を使用したい場合は、環境変数を設定してください: + +```bash +export PLAYWRIGHT_BROWSERS_PATH=/path/to/custom/location +``` + +## トラブルシューティング + +### インストールが失敗する場合 + +1. **手動インストール**: + ```bash + # 互換性のあるバージョンを確認 + npm view @playwright/mcp@latest dependencies + + # Chromiumをインストール + PLAYWRIGHT_BROWSERS_PATH=~/.cache/ms-playwright npx playwright@ install chromium + ``` + +2. **キャッシュをクリア**: + ```bash + rm ~/.claude-playwright-installed + ``` + +3. **ログを確認**: + プラグインは詳細なログを出力するので、エラーメッセージを確認してください。 + +### ブラウザが見つからない場合 + +環境変数 `PLAYWRIGHT_BROWSERS_PATH` が正しく設定されているか確認してください: + +```bash +echo $PLAYWRIGHT_BROWSERS_PATH +ls -la ~/.cache/ms-playwright +``` + +### タイムアウトエラー + +ネットワーク環境が遅い場合、インストールに時間がかかることがあります。 +デフォルトのタイムアウトは5分です。 + +## 参考資料 + +- [Playwright公式ドキュメント](https://playwright.dev/) +- [@playwright/mcp README](https://www.npmjs.com/package/@playwright/mcp) +- [Playwright Browser Management](https://playwright.dev/docs/browsers) + +## ライセンス + +MIT License + +## サポート + +問題が発生した場合は、GitHubリポジトリでissueを作成してください: +https://github.com/devbasex/ai-plugins/issues diff --git a/plugins/mcp/shared/mcp-playwright/README.md b/plugins/mcp/shared/mcp-playwright/README.md new file mode 100644 index 0000000..8662f56 --- /dev/null +++ b/plugins/mcp/shared/mcp-playwright/README.md @@ -0,0 +1,178 @@ +# playwright Plugin + +Playwright MCPを自動的にセットアップし、ブラウザ自動化機能を提供するプラグインです。 + +## 概要 + +このプラグインは、Playwright MCPサーバーとChromiumブラウザを自動的にインストール・設定します。Claude Codeでブラウザ自動化タスク(Webスクレイピング、E2Eテスト、スクリーンショット取得など)を簡単に実行できるようになります。 + +## 機能 + +- **自動セットアップ**: 初回起動時にPlaywright Chromiumブラウザを自動インストール +- **バージョン互換性**: @playwright/mcpと互換性のあるPlaywrightバージョンを自動検出 +- **冪等性**: 既にインストール済みの場合はスキップ(高速起動) +- **環境変数対応**: PLAYWRIGHT_BROWSERS_PATHで共有キャッシュを利用 + +## インストール + +### Claude Code + +```bash +/plugin install mcp-playwright@ai-plugins +``` + +### Codex + +```bash +codex plugin add mcp-playwright@ai-plugins +``` + +### Kiro CLI + +Kiro では repository clone 後、対象 plugin の installer を project root で実行します。 + +```bash +bash plugins/mcp/kiro/mcp-playwright/install.sh +``` + +### 必要な環境変数 + +- `HOME` + +## 使用方法 + +### 基本的な使い方 + +プラグインをインストールして、Claude Codeを起動するだけで自動的にセットアップが実行されます。 + +```bash +# Claude Codeを起動 +claude +``` + +初回起動時、以下のようなメッセージが表示されます: + +``` +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +🎭 Playwright MCP Plugin: 初回セットアップ +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Playwright Chromiumブラウザをインストール中... +ネットワーク環境により1-2分かかる場合があります。 + +🔍 @playwright/mcpが使用するPlaywrightバージョンを確認中... +✓ Playwright 1.49.1 を使用します + +📦 Playwright 1.49.1 でChromiumをインストール中... +[インストールログ...] + +✅ セットアップ完了!Playwright Chromiumの準備ができました。 + Playwrightバージョン: 1.49.1 + ブラウザパス: ~/.cache/ms-playwright +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +``` + +### Playwright MCPツールの使用例 + +セットアップ完了後、以下のようなタスクが実行できます: + +``` +// Webページにアクセスしてスクリーンショットを取得 +mcp__plugin_playwright__browser_navigate(url: "https://example.com") +mcp__plugin_playwright__browser_take_screenshot() + +// フォームに入力してボタンをクリック +mcp__plugin_playwright__browser_fill(selector: "#email", value: "test@example.com") +mcp__plugin_playwright__browser_click(selector: "button[type='submit']") + +// ページのスナップショットを取得(アクセシビリティツリー) +mcp__plugin_playwright__browser_snapshot() +``` + +## 設定 + +### MCP設定(`.mcp.json`) + +プラグインは以下のMCP設定を提供します: + +```json +{ + "mcpServers": { + "playwright": { + "type": "stdio", + "command": "npx", + "args": [ + "-y", + "@playwright/mcp@latest", + "--browser", + "chromium", + "--isolated", + "--no-sandbox" + ], + "env": { + "PLAYWRIGHT_BROWSERS_PATH": "${HOME}/.cache/ms-playwright" + } + } + } +} +``` + +### ブラウザキャッシュパス + +デフォルトでは、Chromiumブラウザは `~/.cache/ms-playwright` にインストールされます。 + +別の場所を使用したい場合は、環境変数を設定してください: + +```bash +export PLAYWRIGHT_BROWSERS_PATH=/path/to/custom/location +``` + +## トラブルシューティング + +### インストールが失敗する場合 + +1. **手動インストール**: + ```bash + # 互換性のあるバージョンを確認 + npm view @playwright/mcp@latest dependencies + + # Chromiumをインストール + PLAYWRIGHT_BROWSERS_PATH=~/.cache/ms-playwright npx playwright@ install chromium + ``` + +2. **キャッシュをクリア**: + ```bash + rm ~/.claude-playwright-installed + ``` + +3. **ログを確認**: + プラグインは詳細なログを出力するので、エラーメッセージを確認してください。 + +### ブラウザが見つからない場合 + +環境変数 `PLAYWRIGHT_BROWSERS_PATH` が正しく設定されているか確認してください: + +```bash +echo $PLAYWRIGHT_BROWSERS_PATH +ls -la ~/.cache/ms-playwright +``` + +### タイムアウトエラー + +ネットワーク環境が遅い場合、インストールに時間がかかることがあります。 +デフォルトのタイムアウトは5分です。 + +## 参考資料 + +- [Playwright公式ドキュメント](https://playwright.dev/) +- [@playwright/mcp README](https://www.npmjs.com/package/@playwright/mcp) +- [Playwright Browser Management](https://playwright.dev/docs/browsers) + +## ライセンス + +MIT License + +## サポート + +問題が発生した場合は、GitHubリポジトリでissueを作成してください: +https://github.com/devbasex/ai-plugins/issues diff --git a/plugins/mcp/shared/mcp-playwright/hooks/hooks.json b/plugins/mcp/shared/mcp-playwright/hooks/hooks.json new file mode 100644 index 0000000..5e7d5ca --- /dev/null +++ b/plugins/mcp/shared/mcp-playwright/hooks/hooks.json @@ -0,0 +1,22 @@ +{ + "description": "Playwright MCP auto-setup hook", + "hooks": { + "SessionStart": [ + { + "description": "Ensure Playwright browser is installed", + "matcher": "startup|resume", + "priority": 100, + "enabled": true, + "hooks": [ + { + "type": "command", + "command": "node ${PLUGIN_ROOT:-${CODEX_PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT}}}/scripts/ensure-playwright.js", + "description": "Auto-install Playwright chromium on first use", + "continueOnError": false, + "suppressOutput": false + } + ] + } + ] + } +} diff --git a/plugins/mcp/shared/mcp-playwright/scripts/ensure-playwright.js b/plugins/mcp/shared/mcp-playwright/scripts/ensure-playwright.js new file mode 100755 index 0000000..b8e354a --- /dev/null +++ b/plugins/mcp/shared/mcp-playwright/scripts/ensure-playwright.js @@ -0,0 +1,114 @@ +#!/usr/bin/env node + +const { execFileSync } = require('child_process'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +const PLUGIN_ROOT = process.env.PLUGIN_ROOT || process.env.CODEX_PLUGIN_ROOT || process.env.CLAUDE_PLUGIN_ROOT || __dirname; +const FLAG_FILE = path.join(os.homedir(), '.claude-playwright-installed'); +const BROWSER_PATH = process.env.PLAYWRIGHT_BROWSERS_PATH || path.join(os.homedir(), '.cache', 'ms-playwright'); +const TIMEOUT_MS = 5 * 60 * 1000; // 5分タイムアウト +const PLAYWRIGHT_VERSION_PATTERN = /^[~^]?\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/; +const EXEC_FILE_OPTIONS = process.platform === 'win32' ? { shell: true } : {}; + +function readInstalledFlag() { + if (!fs.existsSync(FLAG_FILE)) { + return null; + } + try { + const flagData = JSON.parse(fs.readFileSync(FLAG_FILE, 'utf-8')); + return flagData && typeof flagData === 'object' ? flagData : null; + } catch { + return null; + } +} + +console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); +console.log('🎭 Playwright MCP Plugin: 初回セットアップ'); +console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); +console.log(''); +console.log('Playwright Chromiumブラウザをインストール中...'); +console.log('ネットワーク環境により1-2分かかる場合があります。'); +console.log(''); + +try { + // @playwright/mcpが依存するPlaywrightのバージョンを取得 + console.log('🔍 @playwright/mcpが使用するPlaywrightバージョンを確認中...'); + const depsOutput = execFileSync('npm', ['view', '@playwright/mcp@latest', 'dependencies', '--json'], { + encoding: 'utf-8', + cwd: PLUGIN_ROOT, + timeout: 30000, + ...EXEC_FILE_OPTIONS + }); + + const deps = JSON.parse(depsOutput); + const playwrightVersion = deps.playwright || deps['playwright-core']; + + if (!playwrightVersion) { + throw new Error('@playwright/mcpの依存関係からPlaywrightバージョンを取得できませんでした'); + } + if (!PLAYWRIGHT_VERSION_PATTERN.test(playwrightVersion)) { + throw new Error(`不正なPlaywrightバージョンです: ${playwrightVersion}`); + } + + console.log(`✓ Playwright ${playwrightVersion} を使用します`); + console.log(''); + + const installedFlag = readInstalledFlag(); + if ( + installedFlag && + installedFlag.playwrightVersion === playwrightVersion && + installedFlag.browserPath === BROWSER_PATH + ) { + console.log('既に同じ Playwright バージョンでセットアップ済みです。'); + process.exit(0); + } + + // @playwright/mcpと互換性のあるバージョンのChromiumをインストール + console.log(`📦 Playwright ${playwrightVersion} でChromiumをインストール中...`); + execFileSync('npx', ['-y', `playwright@${playwrightVersion}`, 'install', 'chromium'], { + stdio: 'inherit', + cwd: PLUGIN_ROOT, + timeout: TIMEOUT_MS, + ...EXEC_FILE_OPTIONS, + env: { + ...process.env, + PLAYWRIGHT_BROWSERS_PATH: BROWSER_PATH, + PLAYWRIGHT_SKIP_BROWSER_GC: '1' + } + }); + + // インストール成功フラグを作成 + const flagData = { + installed: new Date().toISOString(), + plugin: 'playwright', + browser: 'chromium', + browserPath: BROWSER_PATH, + playwrightVersion: playwrightVersion + }; + + fs.writeFileSync(FLAG_FILE, JSON.stringify(flagData, null, 2)); + + console.log(''); + console.log('✅ セットアップ完了!Playwright Chromiumの準備ができました。'); + console.log(` Playwrightバージョン: ${playwrightVersion}`); + console.log(` ブラウザパス: ${BROWSER_PATH}`); + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + process.exit(0); + +} catch (error) { + console.error(''); + console.error('❌ Playwright Chromiumのインストールに失敗しました'); + console.error(''); + console.error('エラー:', error.message); + console.error(''); + console.error('手動でインストールするには以下を実行してください:'); + console.error(` npm view @playwright/mcp@latest dependencies`); + console.error(` PLAYWRIGHT_BROWSERS_PATH=${BROWSER_PATH} npx playwright@ install chromium`); + console.error(''); + console.error('トラブルシューティング:'); + console.error(' https://playwright.dev/docs/browsers'); + console.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + process.exit(1); +} diff --git a/plugins/mcp/shared/mcp-redash/.claude-plugin/plugin.json b/plugins/mcp/shared/mcp-redash/.claude-plugin/plugin.json new file mode 100644 index 0000000..0066b10 --- /dev/null +++ b/plugins/mcp/shared/mcp-redash/.claude-plugin/plugin.json @@ -0,0 +1,17 @@ +{ + "name": "mcp-redash", + "version": "1.0.0", + "description": "Redash MCP server with multi-environment support (arbitrary suffix)", + "author": { + "name": "takemi-ohama", + "url": "https://github.com/takemi-ohama" + }, + "keywords": ["mcp", "redash", "sql", "data", "visualization", "multi-environment"], + "skills": [ + "./skills/redash-add", + "./skills/redash-remove", + "./skills/redash-list", + "./skills/redash-status", + "./skills/redash-guide" + ] +} diff --git a/plugins/mcp/shared/mcp-redash/.env.example b/plugins/mcp/shared/mcp-redash/.env.example new file mode 100644 index 0000000..810b944 --- /dev/null +++ b/plugins/mcp/shared/mcp-redash/.env.example @@ -0,0 +1,16 @@ +# Redash MCP - 環境変数設定 +# +# デフォルト(必須) +REDASH_URL=https://redash.example.com +REDASH_API_KEY=your-api-key + +# 追加環境(/redash-add で追加した場合のみ必要) +# suffix を大文字にして REDASH_{SUFFIX}_URL, REDASH_{SUFFIX}_API_KEY の形式 +# +# 例: /redash-add dev +# REDASH_DEV_URL=https://redash-dev.example.com +# REDASH_DEV_API_KEY=your-dev-api-key +# +# 例: /redash-add stg +# REDASH_STG_URL=https://redash-stg.example.com +# REDASH_STG_API_KEY=your-stg-api-key diff --git a/plugins/mcp/shared/mcp-redash/.mcp.json b/plugins/mcp/shared/mcp-redash/.mcp.json new file mode 100644 index 0000000..80c370f --- /dev/null +++ b/plugins/mcp/shared/mcp-redash/.mcp.json @@ -0,0 +1,12 @@ +{ + "mcpServers": { + "redash": { + "command": "npx", + "args": ["-y", "@suthio/redash-mcp"], + "env": { + "REDASH_URL": "${REDASH_URL}", + "REDASH_API_KEY": "${REDASH_API_KEY}" + } + } + } +} diff --git a/plugins/mcp/shared/mcp-redash/README.md b/plugins/mcp/shared/mcp-redash/README.md new file mode 100644 index 0000000..1e55d22 --- /dev/null +++ b/plugins/mcp/shared/mcp-redash/README.md @@ -0,0 +1,161 @@ +# mcp-redash + +Redash MCP サーバーをマルチ環境対応で利用するための Claude Code プラグイン。 + +## 概要 + +[redash-mcp](https://github.com/suthio/redash-mcp) を Claude Code から利用するためのプラグインです。 + +- 1つだけ使う場合は最小設定(`REDASH_URL` と `REDASH_API_KEY` のみ) +- 必要になったら suffix 付きで環境を増やせる +- MCP 定義は必要になるまで作られない + +## 前提条件 + +- Claude Code +- Node.js(npx が使える環境) + +## インストール + +### Claude Code + +```bash +/plugin install mcp-redash@ai-plugins +``` + +### Codex + +```bash +codex plugin add mcp-redash@ai-plugins +``` + +### Kiro CLI + +Kiro では repository clone 後、対象 plugin の installer を project root で実行します。 + +```bash +bash plugins/mcp/kiro/mcp-redash/install.sh +``` + +### 必要な環境変数 + +- `REDASH_API_KEY` +- `REDASH_URL` + +## 環境変数 + +プロジェクトの `.env` に設定してください。 + +```bash +REDASH_URL=https://redash.example.com +REDASH_API_KEY=your-api-key +``` + +## 使い方 + +### 基本(1環境) + +プラグインインストール後、`redash` MCP が自動的に有効になります。環境変数を設定するだけで利用開始できます。 + +### 複数環境 + +```bash +# dev 環境を追加 +/redash-add dev + +# .env に環境変数を設定 +# REDASH_DEV_URL=https://redash-dev.example.com +# REDASH_DEV_API_KEY=your-dev-api-key + +# 一覧確認 +/redash-list + +# 状態確認(環境変数の設定漏れチェック) +/redash-status + +# 不要になったら削除 +/redash-remove dev +``` + +## スラッシュコマンド + +| コマンド | 説明 | +|---------|------| +| `/redash-add ` | 任意 suffix の Redash MCP を project `.mcp.json` に追加 | +| `/redash-remove ` | 指定 suffix の Redash MCP を project `.mcp.json` から削除 | +| `/redash-list` | 有効な Redash MCP を一覧表示 | +| `/redash-status` | 設定状況の詳細確認(環境変数の未設定警告付き) | +| `/redash-guide` | 使い方ガイドを表示 | + +## suffix と環境変数の対応 + +| suffix | MCP 名 | 環境変数 | +|--------|--------|----------| +| (なし) | redash | REDASH_URL, REDASH_API_KEY | +| dev | redash-dev | REDASH_DEV_URL, REDASH_DEV_API_KEY | +| stg | redash-stg | REDASH_STG_URL, REDASH_STG_API_KEY | +| prod2 | redash-prod2 | REDASH_PROD2_URL, REDASH_PROD2_API_KEY | +| sandbox-1 | redash-sandbox-1 | REDASH_SANDBOX_1_URL, REDASH_SANDBOX_1_API_KEY | + +- suffix は英小文字・数字・ハイフンで自由に指定可能 +- 環境変数名では suffix 内のハイフンをアンダースコアに変換 +- `default` は予約語のため使用不可(plugin 同梱の `redash` が該当) + +## ファイル構成 + +``` +plugins/mcp/shared/mcp-redash/ +├── .claude-plugin/ +│ └── plugin.json # プラグインメタデータ +├── .mcp.json # MCP 設定(redash のみ同梱) +├── .env.example # 環境変数テンプレート +├── scripts/ +│ └── redash-mcp-config.js # JSON 編集スクリプト(Node.js) +├── skills/ +│ ├── redash-add/SKILL.md # /redash-add コマンド +│ ├── redash-remove/SKILL.md # /redash-remove コマンド +│ ├── redash-list/SKILL.md # /redash-list コマンド +│ ├── redash-status/SKILL.md # /redash-status コマンド +│ └── redash-guide/SKILL.md # 使い方ガイド +└── README.md +``` + +## 設計意図 + +> 本プラグインは +> 「1つだけ使う場合は最小設定」 +> 「必要になったら suffix 付きで増やす」 +> という運用を前提にしている。 +> +> そのため MCP 定義は **必要になるまで作られない**。 + +- plugin 同梱: `redash`(常に `/mcp` に表示) +- project `.mcp.json`: `/redash-add` で追加したもののみ表示 + +## トラブルシューティング + +### `/mcp` に redash が表示されない + +- プラグインが正しくインストールされているか確認 +- `/plugin list` で `mcp-redash@ai-plugins` が表示されるか確認 + +### 環境変数が反映されない + +- `.env` ファイルがプロジェクトルートにあるか確認 +- 変数名が正しい形式か確認(`REDASH_{SUFFIX}_URL`) +- `/redash-status` で設定状況を確認 + +### `/redash-add` でエラーが出る + +- `.mcp.json` の JSON が壊れていないか確認(手動編集した場合) +- `default` は予約語のため suffix に使用不可 + +### suffix 付き MCP が動かない + +- 対応する環境変数が `.env` に設定されているか `/redash-status` で確認 +- Redash の URL と API Key が正しいか確認 + +## 参考リンク + +- [redash-mcp](https://github.com/suthio/redash-mcp) - Redash MCP サーバー +- [Redash](https://redash.io/) - データ可視化ツール diff --git a/plugins/mcp/shared/mcp-redash/scripts/redash-mcp-config.js b/plugins/mcp/shared/mcp-redash/scripts/redash-mcp-config.js new file mode 100755 index 0000000..a62d173 --- /dev/null +++ b/plugins/mcp/shared/mcp-redash/scripts/redash-mcp-config.js @@ -0,0 +1,383 @@ +#!/usr/bin/env node + +/** + * redash-mcp-config.js + * + * project root の .mcp.json を操作して Redash MCP の追加・削除・一覧・状態確認を行う。 + * + * Usage: + * node redash-mcp-config.js add + * node redash-mcp-config.js remove + * node redash-mcp-config.js list + * node redash-mcp-config.js status + */ + +const fs = require('fs'); +const path = require('path'); + +const MCP_PACKAGE = '@suthio/redash-mcp'; +const PROJECT_ROOT = resolveProjectRoot(); +const MCP_JSON_PATH = path.join(PROJECT_ROOT, '.mcp.json'); +const KIRO_AGENT_PATH = path.join(PROJECT_ROOT, '.kiro', 'agents', 'default.json'); + +// --- helpers --- + +function envName(suffix) { + const upper = suffix.toUpperCase().replaceAll('-', '_'); + return { + url: `REDASH_${upper}_URL`, + key: `REDASH_${upper}_API_KEY`, + }; +} + +function mcpName(suffix) { + return `redash-${suffix}`; +} + +function validateSuffix(suffix) { + if (!/^[a-z0-9][a-z0-9-]*$/.test(suffix)) { + console.error('エラー: suffix は英小文字・数字・ハイフンのみで指定してください。'); + console.error('例: dev, stg, prod2, sandbox-1'); + process.exit(1); + } +} + +function mcpEntry(suffix) { + const env = envName(suffix); + return { + command: 'npx', + args: ['-y', MCP_PACKAGE], + env: { + REDASH_URL: `\${${env.url}}`, + REDASH_API_KEY: `\${${env.key}}`, + }, + }; +} + +function isKiroRuntime() { + const normalizedDir = path.normalize(__dirname); + const runtimePath = path.join('mcp', 'kiro') + path.sep; + return Boolean(process.env.KIRO_WORKSPACE_ROOT) || normalizedDir.includes(runtimePath); +} + +function isCodexRuntime() { + const normalizedDir = path.normalize(__dirname); + const runtimePath = path.join('mcp', 'codex') + path.sep; + return Boolean(process.env.CODEX_WORKSPACE_ROOT) || normalizedDir.includes(runtimePath); +} + +function resolveProjectRoot() { + const envNames = [ + 'PROJECT_ROOT', + 'WORKSPACE_ROOT', + 'GIT_WORK_TREE', + 'CLAUDE_PROJECT_DIR', + 'CODEX_WORKSPACE_ROOT', + 'KIRO_WORKSPACE_ROOT', + ]; + + for (const name of envNames) { + const value = process.env[name]; + if (value && fs.existsSync(value) && fs.statSync(value).isDirectory()) { + return path.resolve(value); + } + } + + let dir = process.cwd(); + let nearestMcpDir = null; + while (true) { + if (!nearestMcpDir && fs.existsSync(path.join(dir, '.mcp.json'))) { + nearestMcpDir = dir; + } + if (fs.existsSync(path.join(dir, '.git'))) { + return dir; + } + + const parent = path.dirname(dir); + if (parent === dir) { + return nearestMcpDir || process.cwd(); + } + dir = parent; + } +} + +function createMcpJson() { + return isCodexRuntime() ? {} : { mcpServers: {} }; +} + +function normalizeWritableMcpJson(data) { + if (Object.prototype.hasOwnProperty.call(data, 'mcpServers')) { + return data; + } + if (Object.prototype.hasOwnProperty.call(data, 'mcp_servers')) { + const normalized = { ...data, mcpServers: data.mcp_servers }; + delete normalized.mcp_servers; + return normalized; + } + return data; +} + +function serverMap(data, create) { + if (!data || typeof data !== 'object' || Array.isArray(data)) { + return null; + } + + if (Object.prototype.hasOwnProperty.call(data, 'mcpServers')) { + if (!data.mcpServers && create) { + data.mcpServers = {}; + } + return data.mcpServers && typeof data.mcpServers === 'object' && !Array.isArray(data.mcpServers) + ? data.mcpServers + : null; + } + + if (Object.prototype.hasOwnProperty.call(data, 'mcp_servers')) { + if (!data.mcp_servers && create) { + data.mcp_servers = {}; + } + return data.mcp_servers && typeof data.mcp_servers === 'object' && !Array.isArray(data.mcp_servers) + ? data.mcp_servers + : null; + } + + return data; +} + +function readMcpJson() { + if (!fs.existsSync(MCP_JSON_PATH)) { + return null; + } + const raw = fs.readFileSync(MCP_JSON_PATH, 'utf-8'); + try { + return JSON.parse(raw); + } catch { + return undefined; // 破損 + } +} + +function writeMcpJson(data) { + fs.writeFileSync(MCP_JSON_PATH, JSON.stringify(data, null, 2) + '\n', 'utf-8'); +} + +function syncKiroAgent(data) { + if (!isKiroRuntime()) { + return; + } + + const servers = serverMap(data, false) || {}; + let agent = {}; + if (fs.existsSync(KIRO_AGENT_PATH)) { + try { + agent = JSON.parse(fs.readFileSync(KIRO_AGENT_PATH, 'utf-8')); + } catch { + console.error('エラー: .kiro/agents/default.json の JSON が壊れています。手動で修正してください。'); + process.exit(1); + } + } else { + agent = { + name: 'default', + resources: ['skill://.kiro/skills/**/SKILL.md'], + }; + } + + if (!agent.mcpServers || typeof agent.mcpServers !== 'object' || Array.isArray(agent.mcpServers)) { + agent.mcpServers = {}; + } + + for (const name of Object.keys(agent.mcpServers)) { + if (isRedashServer(name)) { + delete agent.mcpServers[name]; + } + } + for (const [name, server] of Object.entries(servers)) { + if (isRedashServer(name)) { + agent.mcpServers[name] = server; + } + } + + fs.mkdirSync(path.dirname(KIRO_AGENT_PATH), { recursive: true }); + fs.writeFileSync(KIRO_AGENT_PATH, JSON.stringify(agent, null, 2) + '\n', 'utf-8'); +} + +function isRedashServer(name) { + return name === 'redash' || name.startsWith('redash-'); +} + +// --- commands --- + +function cmdAdd(suffix) { + if (!suffix || suffix === 'default') { + console.error('エラー: デフォルトの redash は plugin 同梱のため追加できません。'); + console.error('suffix には dev, stg, prod2, sandbox などを指定してください。'); + process.exit(1); + } + validateSuffix(suffix); + + const name = mcpName(suffix); + let data = readMcpJson(); + + if (data === undefined) { + console.error('エラー: .mcp.json の JSON が壊れています。手動で修正してください。'); + process.exit(1); + } + + if (data === null) { + data = createMcpJson(); + } else { + data = normalizeWritableMcpJson(data); + } + + const servers = serverMap(data, true); + if (!servers) { + console.error('エラー: .mcp.json の MCP server 定義形式を解釈できません。'); + process.exit(1); + } + + if (servers[name]) { + console.log(`${name} は既に登録されています。変更はありません。`); + process.exit(0); + } + + servers[name] = mcpEntry(suffix); + writeMcpJson(data); + syncKiroAgent(data); + + const env = envName(suffix); + console.log(`${name} を .mcp.json に追加しました。`); + if (isKiroRuntime()) { + console.log(`${name} を .kiro/agents/default.json に同期しました。`); + } + console.log(''); + console.log('必要な環境変数:'); + console.log(` ${env.url}`); + console.log(` ${env.key}`); + console.log(''); + console.log('プロジェクトの .env に設定してください。'); +} + +function cmdRemove(suffix) { + if (!suffix || suffix === 'default') { + console.error('エラー: デフォルトの redash は plugin 同梱のため削除できません。'); + process.exit(1); + } + validateSuffix(suffix); + + const name = mcpName(suffix); + let data = readMcpJson(); + + if (data === undefined) { + console.error('エラー: .mcp.json の JSON が壊れています。手動で修正してください。'); + process.exit(1); + } + + if (data !== null) { + data = normalizeWritableMcpJson(data); + } + + const servers = serverMap(data, false); + if (data === null || !servers || !servers[name]) { + console.log(`${name} は登録されていません。変更はありません。`); + process.exit(0); + } + + delete servers[name]; + writeMcpJson(data); + syncKiroAgent(data); + + console.log(`${name} を .mcp.json から削除しました。`); + if (isKiroRuntime()) { + console.log(`${name} を .kiro/agents/default.json から削除しました。`); + } +} + +function cmdList() { + // plugin 同梱分 + console.log('redash (plugin bundled)'); + + // project .mcp.json 分 + const data = readMcpJson(); + if (data === undefined) { + console.error('警告: .mcp.json の JSON が壊れています。'); + return; + } + const servers = serverMap(data, false); + if (servers) { + const names = Object.keys(servers) + .filter((n) => isRedashServer(n) && n !== 'redash') + .sort(); + for (const name of names) { + console.log(`${name.padEnd(14)}(project)`); + } + } +} + +function cmdStatus() { + console.log('=== Redash MCP Status ==='); + console.log(''); + + // plugin 同梱 + console.log('[redash] (plugin bundled)'); + console.log(' 環境変数: REDASH_URL, REDASH_API_KEY'); + printEnvWarnings(['REDASH_URL', 'REDASH_API_KEY']); + console.log(''); + + // project .mcp.json 分 + const data = readMcpJson(); + if (data === undefined) { + console.error('警告: .mcp.json の JSON が壊れています。'); + return; + } + const servers = serverMap(data, false); + if (servers) { + const names = Object.keys(servers) + .filter((n) => isRedashServer(n) && n !== 'redash') + .sort(); + for (const name of names) { + const suffix = name.replace('redash-', ''); + const env = envName(suffix); + console.log(`[${name}] (project)`); + console.log(` 環境変数: ${env.url}, ${env.key}`); + printEnvWarnings([env.url, env.key]); + console.log(''); + } + if (names.length === 0) { + console.log('追加の Redash MCP はありません。'); + console.log('/redash-add で追加できます。'); + } + } else { + console.log('追加の Redash MCP はありません。'); + console.log('/redash-add で追加できます。'); + } +} + +function printEnvWarnings(vars) { + const missing = vars.filter((v) => !process.env[v]); + if (missing.length > 0) { + for (const v of missing) { + console.log(` ⚠ ${v} が未設定です`); + } + } +} + +// --- main --- + +const [, , command, ...rest] = process.argv; +const suffix = rest[0] ? rest[0].toLowerCase() : ''; + +switch (command) { + case 'add': + cmdAdd(suffix); + break; + case 'remove': + cmdRemove(suffix); + break; + case 'list': + cmdList(); + break; + case 'status': + cmdStatus(); + break; + default: + console.error('Usage: redash-mcp-config.js [suffix]'); + process.exit(1); +} diff --git a/plugins/mcp/shared/mcp-redash/skills/redash-add/SKILL.md b/plugins/mcp/shared/mcp-redash/skills/redash-add/SKILL.md new file mode 100644 index 0000000..ce1fc02 --- /dev/null +++ b/plugins/mcp/shared/mcp-redash/skills/redash-add/SKILL.md @@ -0,0 +1,27 @@ +--- +name: redash-add +description: 任意 suffix の Redash MCP を project .mcp.json に追加する +disable-model-invocation: true +user-invocable: true +arguments: + - name: suffix + description: "環境識別子(dev, stg, prod2, sandbox など)" +allowed-tools: + - Bash +--- + +# /redash-add + +任意 suffix の Redash MCP をプロジェクトの `.mcp.json` に追加します。 + +## 実行方法 + +以下のコマンドを実行してください。`$ARGUMENTS` にはユーザーが指定した suffix が入ります。 + +```bash +node "${PLUGIN_ROOT:-${CODEX_PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT}}}/scripts/redash-mcp-config.js" add "$ARGUMENTS" +``` + +## 実行後 + +コマンドの出力をそのままユーザーに表示してください。 diff --git a/plugins/mcp/shared/mcp-redash/skills/redash-guide/SKILL.md b/plugins/mcp/shared/mcp-redash/skills/redash-guide/SKILL.md new file mode 100644 index 0000000..0cbdbdd --- /dev/null +++ b/plugins/mcp/shared/mcp-redash/skills/redash-guide/SKILL.md @@ -0,0 +1,88 @@ +--- +name: redash-guide +description: Redash MCP プラグインの使い方ガイド +disable-model-invocation: false +user-invocable: true +allowed-tools: [] +--- + +# Redash MCP プラグイン ガイド + +## 基本設定(1環境のみ) + +Redash を1つだけ使う場合は、プロジェクトの `.env` に以下を設定するだけです。 + +```bash +REDASH_URL=https://redash.example.com +REDASH_API_KEY=your-api-key +``` + +プラグインインストール後、`/mcp` に `redash` が自動的に表示されます。 + +## 複数環境の追加 + +dev / stg / prod など複数の Redash を使う場合: + +### 1. 環境を追加 + +``` +/redash-add dev +/redash-add stg +``` + +### 2. 環境変数を設定 + +`.env` に追加分の変数を設定します。 + +```bash +# デフォルト +REDASH_URL=https://redash.example.com +REDASH_API_KEY=your-api-key + +# dev 環境 +REDASH_DEV_URL=https://redash-dev.example.com +REDASH_DEV_API_KEY=your-dev-api-key + +# stg 環境 +REDASH_STG_URL=https://redash-stg.example.com +REDASH_STG_API_KEY=your-stg-api-key +``` + +### 3. 確認 + +``` +/redash-list # 有効な MCP 一覧 +/redash-status # 環境変数の設定状況 +``` + +## suffix 命名ルール + +| suffix | MCP 名 | 環境変数 | +|--------|--------|----------| +| dev | redash-dev | REDASH_DEV_URL, REDASH_DEV_API_KEY | +| stg | redash-stg | REDASH_STG_URL, REDASH_STG_API_KEY | +| prod2 | redash-prod2 | REDASH_PROD2_URL, REDASH_PROD2_API_KEY | +| sandbox-1 | redash-sandbox-1 | REDASH_SANDBOX_1_URL, REDASH_SANDBOX_1_API_KEY | + +- suffix は英小文字・数字・ハイフンで自由に指定可能 +- 環境変数名では suffix 内のハイフンをアンダースコアに変換 +- `default` は予約語のため使用不可(plugin 同梱の `redash` が該当) + +## 環境の削除 + +不要になった環境は削除できます。 + +``` +/redash-remove dev +``` + +plugin 同梱の `redash` は削除できません。 + +## スラッシュコマンド一覧 + +| コマンド | 説明 | +|---------|------| +| `/redash-add ` | Redash MCP を追加 | +| `/redash-remove ` | Redash MCP を削除 | +| `/redash-list` | 有効な MCP 一覧 | +| `/redash-status` | 設定状況の詳細確認 | diff --git a/plugins/mcp/shared/mcp-redash/skills/redash-list/SKILL.md b/plugins/mcp/shared/mcp-redash/skills/redash-list/SKILL.md new file mode 100644 index 0000000..798754e --- /dev/null +++ b/plugins/mcp/shared/mcp-redash/skills/redash-list/SKILL.md @@ -0,0 +1,24 @@ +--- +name: redash-list +description: 現在有効な Redash MCP を一覧表示する +disable-model-invocation: true +user-invocable: true +allowed-tools: + - Bash +--- + +# /redash-list + +現在有効な Redash MCP の一覧を表示します。 + +## 実行方法 + +以下のコマンドを実行してください。 + +```bash +node "${PLUGIN_ROOT:-${CODEX_PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT}}}/scripts/redash-mcp-config.js" list +``` + +## 実行後 + +コマンドの出力をそのままユーザーに表示してください。 diff --git a/plugins/mcp/shared/mcp-redash/skills/redash-remove/SKILL.md b/plugins/mcp/shared/mcp-redash/skills/redash-remove/SKILL.md new file mode 100644 index 0000000..00fe202 --- /dev/null +++ b/plugins/mcp/shared/mcp-redash/skills/redash-remove/SKILL.md @@ -0,0 +1,27 @@ +--- +name: redash-remove +description: 指定 suffix の Redash MCP を project .mcp.json から削除する +disable-model-invocation: true +user-invocable: true +arguments: + - name: suffix + description: "削除する環境識別子(dev, stg, prod2, sandbox など)" +allowed-tools: + - Bash +--- + +# /redash-remove + +指定 suffix の Redash MCP をプロジェクトの `.mcp.json` から削除します。 + +## 実行方法 + +以下のコマンドを実行してください。`$ARGUMENTS` にはユーザーが指定した suffix が入ります。 + +```bash +node "${PLUGIN_ROOT:-${CODEX_PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT}}}/scripts/redash-mcp-config.js" remove "$ARGUMENTS" +``` + +## 実行後 + +コマンドの出力をそのままユーザーに表示してください。 diff --git a/plugins/mcp/shared/mcp-redash/skills/redash-status/SKILL.md b/plugins/mcp/shared/mcp-redash/skills/redash-status/SKILL.md new file mode 100644 index 0000000..45a4034 --- /dev/null +++ b/plugins/mcp/shared/mcp-redash/skills/redash-status/SKILL.md @@ -0,0 +1,24 @@ +--- +name: redash-status +description: Redash MCP の設定状況と環境変数の確認 +disable-model-invocation: true +user-invocable: true +allowed-tools: + - Bash +--- + +# /redash-status + +Redash MCP の設定状況を詳細表示します。各 MCP が必要とする環境変数と、未設定の警告を確認できます。 + +## 実行方法 + +以下のコマンドを実行してください。 + +```bash +node "${PLUGIN_ROOT:-${CODEX_PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT}}}/scripts/redash-mcp-config.js" status +``` + +## 実行後 + +コマンドの出力をそのままユーザーに表示してください。 diff --git a/plugins/mcp/shared/mcp-serena/.claude-plugin/plugin.json b/plugins/mcp/shared/mcp-serena/.claude-plugin/plugin.json new file mode 100644 index 0000000..fce6136 --- /dev/null +++ b/plugins/mcp/shared/mcp-serena/.claude-plugin/plugin.json @@ -0,0 +1,10 @@ +{ + "name": "mcp-serena", + "version": "1.0.0", + "description": "Serena MCP server for semantic code intelligence (symbol search, reference search, refactoring)", + "author": { + "name": "takemi-ohama", + "url": "https://github.com/takemi-ohama" + }, + "keywords": ["mcp", "serena", "lsp", "symbol-search", "refactoring", "code-intelligence"] +} diff --git a/plugins/mcp/shared/mcp-serena/.mcp.json b/plugins/mcp/shared/mcp-serena/.mcp.json new file mode 100644 index 0000000..17de95a --- /dev/null +++ b/plugins/mcp/shared/mcp-serena/.mcp.json @@ -0,0 +1,21 @@ +{ + "mcpServers": { + "serena": { + "type": "stdio", + "command": "uvx", + "args": [ + "--from", + "git+https://github.com/oraios/serena", + "serena", + "start-mcp-server", + "--context", + "ide-assistant", + "--enable-web-dashboard", + "False" + ], + "env": { + "SERENA_HOME": ".serena" + } + } + } +} diff --git a/plugins/mcp/shared/mcp-serena/README.md b/plugins/mcp/shared/mcp-serena/README.md new file mode 100644 index 0000000..f8ebfe5 --- /dev/null +++ b/plugins/mcp/shared/mcp-serena/README.md @@ -0,0 +1,49 @@ +# mcp-serena + +Serena MCPサーバーを提供するプラグインです。セマンティックコードインテリジェンス機能(シンボル検索、リファレンス検索、安全なリファクタリング)を利用できます。 + +## インストール + +### Claude Code + +```bash +/plugin install mcp-serena@ai-plugins +``` + +### Codex + +```bash +codex plugin add mcp-serena@ai-plugins +``` + +### Kiro CLI + +Kiro では repository clone 後、対象 plugin の installer を project root で実行します。 + +```bash +bash plugins/mcp/kiro/mcp-serena/install.sh +``` + +### 必要な環境変数 + +- `SERENA_HOME` + +## 提供機能 + +- **シンボル検索**: クラス、関数、メソッドの定義を検索 +- **リファレンス検索**: シンボルの参照箇所を特定 +- **シンボル概要**: ファイル内のシンボル一覧を取得(ファイル全体を読まずに構造を把握) +- **セマンティック編集**: シンボル単位でのコード置換・挿入 +- **パターン検索**: 正規表現によるコード検索 + +## 環境変数 + +| 変数 | 説明 | デフォルト | +|------|------|-----------| +| `SERENA_HOME` | Serena設定ディレクトリ | `.serena` | + +## 注意事項 + +- プロジェクトで使用する前に `activate_project` でアクティベートが必要です +- Serenaのメモリー機能は使用しません。知識は `docs/` に、手順は `skills/` に配置してください +- 詳細は `docs/serena-guide.md` を参照 diff --git a/plugins/mcp/shared/mcp-serena/docs/serena-guide.md b/plugins/mcp/shared/mcp-serena/docs/serena-guide.md new file mode 100644 index 0000000..3cd249c --- /dev/null +++ b/plugins/mcp/shared/mcp-serena/docs/serena-guide.md @@ -0,0 +1,73 @@ +# Serena MCP ガイド + +Serena MCPはセマンティックコードインテリジェンスツールです。 +コード操作専用。メモリー機能は使用しません。 + +## アクティベーション + +プロジェクトで使用する前にアクティベートが必要です。 + +``` +mcp__plugin_mcp-serena_serena__activate_project /path/to/project +mcp__plugin_mcp-serena_serena__check_onboarding_performed +``` + +## コード探索(推奨順序) + +### 1. ディレクトリ構造の確認 + +``` +mcp__plugin_mcp-serena_serena__list_dir relative_path="src/" recursive=false +``` + +### 2. シンボル概要の取得(ファイル全体を読む前に) + +``` +mcp__plugin_mcp-serena_serena__get_symbols_overview relative_path="path/to/file.py" +``` + +### 3. シンボル検索 + +``` +mcp__plugin_mcp-serena_serena__find_symbol name_path="/ClassName/method_name" relative_path="src/" include_body=true +``` + +### 4. パターン検索(シンボル名が不明な場合) + +``` +mcp__plugin_mcp-serena_serena__search_for_pattern substring_pattern="keyword" relative_path="src/" +``` + +### 5. リファレンス検索 + +``` +mcp__plugin_mcp-serena_serena__find_referencing_symbols name_path="/SymbolName" relative_path="src/" +``` + +## コード編集 + +### シンボル単位の置換 + +``` +mcp__plugin_mcp-serena_serena__replace_symbol_body name_path="/Class/method" relative_path="file.py" body="new code" +``` + +### シンボルの前後に挿入 + +``` +mcp__plugin_mcp-serena_serena__insert_before_symbol name_path="/FirstSymbol" relative_path="file.py" content="new code" +mcp__plugin_mcp-serena_serena__insert_after_symbol name_path="/LastSymbol" relative_path="file.py" content="new code" +``` + +### シンボル名の変更 + +``` +mcp__plugin_mcp-serena_serena__rename_symbol name_path="/OldName" relative_path="file.py" new_name="NewName" +``` + +## 使い分けの原則 + +- ファイル全体を読む前に `get_symbols_overview` で概要を確認 +- シンボル名が分かっている場合は `find_symbol` で直接アクセス +- シンボル名が不明な場合は `search_for_pattern` で候補を特定 +- 編集は `replace_symbol_body` でシンボル単位で行う(行ベース編集より安全) diff --git a/plugins/mcp/shared/mcp-serena/hooks/hooks.json b/plugins/mcp/shared/mcp-serena/hooks/hooks.json new file mode 100644 index 0000000..171ecaf --- /dev/null +++ b/plugins/mcp/shared/mcp-serena/hooks/hooks.json @@ -0,0 +1,19 @@ +{ + "description": "mcp-serena: Auto-activate Serena on session start", + "hooks": { + "SessionStart": [ + { + "description": "Remind to activate Serena project", + "hooks": [ + { + "type": "command", + "command": "echo '[mcp-serena] Serena MCPが利用可能です。activate_project でプロジェクトをアクティベートしてください。'", + "description": "Output Serena activation reminder", + "continueOnError": true, + "suppressOutput": false + } + ] + } + ] + } +} diff --git a/plugins/ndf/.claude-plugin/plugin.json b/plugins/ndf-claude/.claude-plugin/plugin.json similarity index 77% rename from plugins/ndf/.claude-plugin/plugin.json rename to plugins/ndf-claude/.claude-plugin/plugin.json index 7145417..9cc4f39 100644 --- a/plugins/ndf/.claude-plugin/plugin.json +++ b/plugins/ndf-claude/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "ndf", "version": "4.19.0", - "description": "Integrated plugin with 8 specialized agents and a focused core skill set for PR/review workflows, implementation planning, debugging principles, statusline, browser smoke testing, and Codex CLI delegation. Additional niche skills remain under skills-optional for maintainers. Transcript retention is automatically kept at >= 90 days. Default statusline is set when none is configured.", + "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" @@ -53,6 +53,7 @@ "./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/agents/code-reviewer.md b/plugins/ndf-claude/agents/code-reviewer.md similarity index 100% rename from plugins/ndf/agents/code-reviewer.md rename to plugins/ndf-claude/agents/code-reviewer.md diff --git a/plugins/ndf/agents/corder.md b/plugins/ndf-claude/agents/corder.md similarity index 100% rename from plugins/ndf/agents/corder.md rename to plugins/ndf-claude/agents/corder.md diff --git a/plugins/ndf/agents/data-analyst.md b/plugins/ndf-claude/agents/data-analyst.md similarity index 100% rename from plugins/ndf/agents/data-analyst.md rename to plugins/ndf-claude/agents/data-analyst.md diff --git a/plugins/ndf/agents/debugger.md b/plugins/ndf-claude/agents/debugger.md similarity index 100% rename from plugins/ndf/agents/debugger.md rename to plugins/ndf-claude/agents/debugger.md diff --git a/plugins/ndf/agents/devops-engineer.md b/plugins/ndf-claude/agents/devops-engineer.md similarity index 100% rename from plugins/ndf/agents/devops-engineer.md rename to plugins/ndf-claude/agents/devops-engineer.md diff --git a/plugins/ndf/agents/director.md b/plugins/ndf-claude/agents/director.md similarity index 100% rename from plugins/ndf/agents/director.md rename to plugins/ndf-claude/agents/director.md diff --git a/plugins/ndf/agents/qa.md b/plugins/ndf-claude/agents/qa.md similarity index 100% rename from plugins/ndf/agents/qa.md rename to plugins/ndf-claude/agents/qa.md diff --git a/plugins/ndf/agents/researcher.md b/plugins/ndf-claude/agents/researcher.md similarity index 100% rename from plugins/ndf/agents/researcher.md rename to plugins/ndf-claude/agents/researcher.md diff --git a/plugins/ndf/hooks/hooks.json b/plugins/ndf-claude/hooks/hooks.json similarity index 100% rename from plugins/ndf/hooks/hooks.json rename to plugins/ndf-claude/hooks/hooks.json diff --git a/plugins/ndf/scripts/codex-slack-notify.js b/plugins/ndf-claude/scripts/codex-slack-notify.js similarity index 100% rename from plugins/ndf/scripts/codex-slack-notify.js rename to plugins/ndf-claude/scripts/codex-slack-notify.js diff --git a/plugins/ndf/scripts/ensure-retention.sh b/plugins/ndf-claude/scripts/ensure-retention.sh similarity index 100% rename from plugins/ndf/scripts/ensure-retention.sh rename to plugins/ndf-claude/scripts/ensure-retention.sh diff --git a/plugins/ndf/scripts/install-official-skills.sh b/plugins/ndf-claude/scripts/install-official-skills.sh similarity index 100% rename from plugins/ndf/scripts/install-official-skills.sh rename to plugins/ndf-claude/scripts/install-official-skills.sh diff --git a/plugins/ndf/scripts/slack-notify.js b/plugins/ndf-claude/scripts/slack-notify.js similarity index 100% rename from plugins/ndf/scripts/slack-notify.js rename to plugins/ndf-claude/scripts/slack-notify.js diff --git a/plugins/ndf/scripts/statusline-switch.sh b/plugins/ndf-claude/scripts/statusline-switch.sh similarity index 100% rename from plugins/ndf/scripts/statusline-switch.sh rename to plugins/ndf-claude/scripts/statusline-switch.sh diff --git a/plugins/ndf/scripts/statusline.sh b/plugins/ndf-claude/scripts/statusline.sh similarity index 100% rename from plugins/ndf/scripts/statusline.sh rename to plugins/ndf-claude/scripts/statusline.sh diff --git a/plugins/ndf/skills-codex/branch-fix-strategy/SKILL.md b/plugins/ndf-claude/skills/branch-fix-strategy/SKILL.md similarity index 100% rename from plugins/ndf/skills-codex/branch-fix-strategy/SKILL.md rename to plugins/ndf-claude/skills/branch-fix-strategy/SKILL.md diff --git a/plugins/ndf/skills/browser-test/SKILL.md b/plugins/ndf-claude/skills/browser-test/SKILL.md similarity index 100% rename from plugins/ndf/skills/browser-test/SKILL.md rename to plugins/ndf-claude/skills/browser-test/SKILL.md diff --git a/plugins/ndf/skills-codex/cherry-pick-pr/SKILL.md b/plugins/ndf-claude/skills/cherry-pick-pr/SKILL.md similarity index 100% rename from plugins/ndf/skills-codex/cherry-pick-pr/SKILL.md rename to plugins/ndf-claude/skills/cherry-pick-pr/SKILL.md diff --git a/plugins/ndf/skills-codex/clean/SKILL.md b/plugins/ndf-claude/skills/clean/SKILL.md similarity index 100% rename from plugins/ndf/skills-codex/clean/SKILL.md rename to plugins/ndf-claude/skills/clean/SKILL.md diff --git a/plugins/ndf/skills/codex/SKILL.md b/plugins/ndf-claude/skills/codex/SKILL.md similarity index 100% rename from plugins/ndf/skills/codex/SKILL.md rename to plugins/ndf-claude/skills/codex/SKILL.md diff --git a/plugins/ndf/skills-codex/cross-review/SKILL.md b/plugins/ndf-claude/skills/cross-review/SKILL.md similarity index 100% rename from plugins/ndf/skills-codex/cross-review/SKILL.md rename to plugins/ndf-claude/skills/cross-review/SKILL.md diff --git a/plugins/ndf/skills-codex/cross-review/docs/01-state-and-review.md b/plugins/ndf-claude/skills/cross-review/docs/01-state-and-review.md similarity index 100% rename from plugins/ndf/skills-codex/cross-review/docs/01-state-and-review.md rename to plugins/ndf-claude/skills/cross-review/docs/01-state-and-review.md diff --git a/plugins/ndf/skills-codex/cross-review/docs/02-fix-and-rotation.md b/plugins/ndf-claude/skills/cross-review/docs/02-fix-and-rotation.md similarity index 100% rename from plugins/ndf/skills-codex/cross-review/docs/02-fix-and-rotation.md rename to plugins/ndf-claude/skills/cross-review/docs/02-fix-and-rotation.md diff --git a/plugins/ndf/skills-codex/cross-review/scripts/_tmpdir.sh b/plugins/ndf-claude/skills/cross-review/scripts/_tmpdir.sh similarity index 100% rename from plugins/ndf/skills-codex/cross-review/scripts/_tmpdir.sh rename to plugins/ndf-claude/skills/cross-review/scripts/_tmpdir.sh diff --git a/plugins/ndf/skills-codex/cross-review/scripts/launch-codex.sh b/plugins/ndf-claude/skills/cross-review/scripts/launch-codex.sh similarity index 100% rename from plugins/ndf/skills-codex/cross-review/scripts/launch-codex.sh rename to plugins/ndf-claude/skills/cross-review/scripts/launch-codex.sh diff --git a/plugins/ndf/skills-codex/cross-review/scripts/launch-gemini.sh b/plugins/ndf-claude/skills/cross-review/scripts/launch-gemini.sh similarity index 100% rename from plugins/ndf/skills-codex/cross-review/scripts/launch-gemini.sh rename to plugins/ndf-claude/skills/cross-review/scripts/launch-gemini.sh diff --git a/plugins/ndf/skills-codex/cross-review/scripts/monitor.py b/plugins/ndf-claude/skills/cross-review/scripts/monitor.py similarity index 100% rename from plugins/ndf/skills-codex/cross-review/scripts/monitor.py rename to plugins/ndf-claude/skills/cross-review/scripts/monitor.py diff --git a/plugins/ndf/skills-codex/cross-review/scripts/rotate-pr.sh b/plugins/ndf-claude/skills/cross-review/scripts/rotate-pr.sh similarity index 100% rename from plugins/ndf/skills-codex/cross-review/scripts/rotate-pr.sh rename to plugins/ndf-claude/skills/cross-review/scripts/rotate-pr.sh diff --git a/plugins/ndf/skills-codex/cross-review/scripts/state.py b/plugins/ndf-claude/skills/cross-review/scripts/state.py similarity index 99% rename from plugins/ndf/skills-codex/cross-review/scripts/state.py rename to plugins/ndf-claude/skills/cross-review/scripts/state.py index c386e70..9e91f11 100755 --- a/plugins/ndf/skills-codex/cross-review/scripts/state.py +++ b/plugins/ndf-claude/skills/cross-review/scripts/state.py @@ -1299,11 +1299,10 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: meta_patterns = ("check_pr_requirements", "assignees", "reviewers", "labels", "meta") failed = fix.get("ci_failed_checks") or [] code_fail = False - meta_fail = False for name in failed: low = name.lower() if any(p in low for p in meta_patterns): - meta_fail = True + continue elif any(p in low for p in code_patterns): code_fail = True else: diff --git a/plugins/ndf/skills-codex/cross-review/scripts/wait-review.sh b/plugins/ndf-claude/skills/cross-review/scripts/wait-review.sh similarity index 100% rename from plugins/ndf/skills-codex/cross-review/scripts/wait-review.sh rename to plugins/ndf-claude/skills/cross-review/scripts/wait-review.sh diff --git a/plugins/ndf/skills-codex/cross-review/tests/__init__.py b/plugins/ndf-claude/skills/cross-review/tests/__init__.py similarity index 100% rename from plugins/ndf/skills-codex/cross-review/tests/__init__.py rename to plugins/ndf-claude/skills/cross-review/tests/__init__.py diff --git a/plugins/ndf/skills-codex/cross-review/tests/conftest.py b/plugins/ndf-claude/skills/cross-review/tests/conftest.py similarity index 100% rename from plugins/ndf/skills-codex/cross-review/tests/conftest.py rename to plugins/ndf-claude/skills/cross-review/tests/conftest.py diff --git a/plugins/ndf/skills-codex/cross-review/tests/test_default_worktree_base.py b/plugins/ndf-claude/skills/cross-review/tests/test_default_worktree_base.py similarity index 100% rename from plugins/ndf/skills-codex/cross-review/tests/test_default_worktree_base.py rename to plugins/ndf-claude/skills/cross-review/tests/test_default_worktree_base.py diff --git a/plugins/ndf/skills-codex/cross-review/tests/test_monitor_early_error.py b/plugins/ndf-claude/skills/cross-review/tests/test_monitor_early_error.py similarity index 98% rename from plugins/ndf/skills-codex/cross-review/tests/test_monitor_early_error.py rename to plugins/ndf-claude/skills/cross-review/tests/test_monitor_early_error.py index a7e3f6b..d669c2d 100644 --- a/plugins/ndf/skills-codex/cross-review/tests/test_monitor_early_error.py +++ b/plugins/ndf-claude/skills/cross-review/tests/test_monitor_early_error.py @@ -77,7 +77,7 @@ def test_grep_style_source_citation_is_benign(tmp_path, monitor_mod): """ log = _write( tmp_path / "err.log", - '/work/worktrees/pr23/plugins/ndf/skills/cross-review/tests/' + '/work/worktrees/pr23/plugins/ndf-shared/skills/cross-review/tests/' 'test_monitor_early_error.py:22: log = _write(tmp_path / "err.log", ' '"quota exceeded: please upgrade\\n")\n', ) diff --git a/plugins/ndf/skills-codex/cross-review/tests/test_monitor_import_safety.py b/plugins/ndf-claude/skills/cross-review/tests/test_monitor_import_safety.py similarity index 100% rename from plugins/ndf/skills-codex/cross-review/tests/test_monitor_import_safety.py rename to plugins/ndf-claude/skills/cross-review/tests/test_monitor_import_safety.py diff --git a/plugins/ndf/skills-codex/cross-review/tests/test_monitor_progress_log.py b/plugins/ndf-claude/skills/cross-review/tests/test_monitor_progress_log.py similarity index 100% rename from plugins/ndf/skills-codex/cross-review/tests/test_monitor_progress_log.py rename to plugins/ndf-claude/skills/cross-review/tests/test_monitor_progress_log.py diff --git a/plugins/ndf/skills-codex/cross-review/tests/test_monitor_result_age.py b/plugins/ndf-claude/skills/cross-review/tests/test_monitor_result_age.py similarity index 100% rename from plugins/ndf/skills-codex/cross-review/tests/test_monitor_result_age.py rename to plugins/ndf-claude/skills/cross-review/tests/test_monitor_result_age.py diff --git a/plugins/ndf/skills-codex/cross-review/tests/test_monitor_stall_default.py b/plugins/ndf-claude/skills/cross-review/tests/test_monitor_stall_default.py similarity index 100% rename from plugins/ndf/skills-codex/cross-review/tests/test_monitor_stall_default.py rename to plugins/ndf-claude/skills/cross-review/tests/test_monitor_stall_default.py diff --git a/plugins/ndf/skills-codex/cross-review/tests/test_monitor_zombie.py b/plugins/ndf-claude/skills/cross-review/tests/test_monitor_zombie.py similarity index 100% rename from plugins/ndf/skills-codex/cross-review/tests/test_monitor_zombie.py rename to plugins/ndf-claude/skills/cross-review/tests/test_monitor_zombie.py diff --git a/plugins/ndf/skills-codex/cross-review/tests/test_state_auto_review_templates.py b/plugins/ndf-claude/skills/cross-review/tests/test_state_auto_review_templates.py similarity index 100% rename from plugins/ndf/skills-codex/cross-review/tests/test_state_auto_review_templates.py rename to plugins/ndf-claude/skills/cross-review/tests/test_state_auto_review_templates.py diff --git a/plugins/ndf/skills-codex/cross-review/tests/test_state_check_oscillation.py b/plugins/ndf-claude/skills/cross-review/tests/test_state_check_oscillation.py similarity index 100% rename from plugins/ndf/skills-codex/cross-review/tests/test_state_check_oscillation.py rename to plugins/ndf-claude/skills/cross-review/tests/test_state_check_oscillation.py diff --git a/plugins/ndf/skills-codex/cross-review/tests/test_state_extra_review_instructions.py b/plugins/ndf-claude/skills/cross-review/tests/test_state_extra_review_instructions.py similarity index 100% rename from plugins/ndf/skills-codex/cross-review/tests/test_state_extra_review_instructions.py rename to plugins/ndf-claude/skills/cross-review/tests/test_state_extra_review_instructions.py diff --git a/plugins/ndf/skills-codex/cross-review/tests/test_state_merge_fix.py b/plugins/ndf-claude/skills/cross-review/tests/test_state_merge_fix.py similarity index 100% rename from plugins/ndf/skills-codex/cross-review/tests/test_state_merge_fix.py rename to plugins/ndf-claude/skills/cross-review/tests/test_state_merge_fix.py diff --git a/plugins/ndf/skills-codex/cross-review/tests/test_state_read_result.py b/plugins/ndf-claude/skills/cross-review/tests/test_state_read_result.py similarity index 100% rename from plugins/ndf/skills-codex/cross-review/tests/test_state_read_result.py rename to plugins/ndf-claude/skills/cross-review/tests/test_state_read_result.py diff --git a/plugins/ndf/skills-codex/deploy/SKILL.md b/plugins/ndf-claude/skills/deploy/SKILL.md similarity index 100% rename from plugins/ndf/skills-codex/deploy/SKILL.md rename to plugins/ndf-claude/skills/deploy/SKILL.md diff --git a/plugins/ndf/skills-codex/docker-container-access/01-environment-detection.md b/plugins/ndf-claude/skills/docker-container-access/01-environment-detection.md similarity index 100% rename from plugins/ndf/skills-codex/docker-container-access/01-environment-detection.md rename to plugins/ndf-claude/skills/docker-container-access/01-environment-detection.md diff --git a/plugins/ndf/skills-codex/docker-container-access/02-dood-access.md b/plugins/ndf-claude/skills/docker-container-access/02-dood-access.md similarity index 100% rename from plugins/ndf/skills-codex/docker-container-access/02-dood-access.md rename to plugins/ndf-claude/skills/docker-container-access/02-dood-access.md diff --git a/plugins/ndf/skills-codex/docker-container-access/03-troubleshooting.md b/plugins/ndf-claude/skills/docker-container-access/03-troubleshooting.md similarity index 100% rename from plugins/ndf/skills-codex/docker-container-access/03-troubleshooting.md rename to plugins/ndf-claude/skills/docker-container-access/03-troubleshooting.md diff --git a/plugins/ndf/skills-codex/docker-container-access/SKILL.md b/plugins/ndf-claude/skills/docker-container-access/SKILL.md similarity index 100% rename from plugins/ndf/skills-codex/docker-container-access/SKILL.md rename to plugins/ndf-claude/skills/docker-container-access/SKILL.md diff --git a/plugins/ndf/skills/fix/SKILL.md b/plugins/ndf-claude/skills/fix/SKILL.md similarity index 100% rename from plugins/ndf/skills/fix/SKILL.md rename to plugins/ndf-claude/skills/fix/SKILL.md diff --git a/plugins/ndf/skills-codex/fix/scripts/fetch-pr-comments.sh b/plugins/ndf-claude/skills/fix/scripts/fetch-pr-comments.sh similarity index 100% rename from plugins/ndf/skills-codex/fix/scripts/fetch-pr-comments.sh rename to plugins/ndf-claude/skills/fix/scripts/fetch-pr-comments.sh diff --git a/plugins/ndf/skills/gemini/SKILL.md b/plugins/ndf-claude/skills/gemini/SKILL.md similarity index 100% rename from plugins/ndf/skills/gemini/SKILL.md rename to plugins/ndf-claude/skills/gemini/SKILL.md diff --git a/plugins/ndf/skills-codex/git-gh-operations/01-common-errors.md b/plugins/ndf-claude/skills/git-gh-operations/01-common-errors.md similarity index 100% rename from plugins/ndf/skills-codex/git-gh-operations/01-common-errors.md rename to plugins/ndf-claude/skills/git-gh-operations/01-common-errors.md diff --git a/plugins/ndf/skills-codex/git-gh-operations/SKILL.md b/plugins/ndf-claude/skills/git-gh-operations/SKILL.md similarity index 100% rename from plugins/ndf/skills-codex/git-gh-operations/SKILL.md rename to plugins/ndf-claude/skills/git-gh-operations/SKILL.md diff --git a/plugins/ndf/skills-codex/implementation-plan/SKILL.md b/plugins/ndf-claude/skills/implementation-plan/SKILL.md similarity index 100% rename from plugins/ndf/skills-codex/implementation-plan/SKILL.md rename to plugins/ndf-claude/skills/implementation-plan/SKILL.md diff --git a/plugins/ndf/skills-codex/investigation-rules/SKILL.md b/plugins/ndf-claude/skills/investigation-rules/SKILL.md similarity index 100% rename from plugins/ndf/skills-codex/investigation-rules/SKILL.md rename to plugins/ndf-claude/skills/investigation-rules/SKILL.md diff --git a/plugins/ndf/skills-codex/issue-plan-strategy/SKILL.md b/plugins/ndf-claude/skills/issue-plan-strategy/SKILL.md similarity index 100% rename from plugins/ndf/skills-codex/issue-plan-strategy/SKILL.md rename to plugins/ndf-claude/skills/issue-plan-strategy/SKILL.md diff --git a/plugins/ndf/skills-codex/logging-guidelines/SKILL.md b/plugins/ndf-claude/skills/logging-guidelines/SKILL.md similarity index 100% rename from plugins/ndf/skills-codex/logging-guidelines/SKILL.md rename to plugins/ndf-claude/skills/logging-guidelines/SKILL.md diff --git a/plugins/ndf/skills-codex/markdown-writing/01-diagram-guide.md b/plugins/ndf-claude/skills/markdown-writing/01-diagram-guide.md similarity index 100% rename from plugins/ndf/skills-codex/markdown-writing/01-diagram-guide.md rename to plugins/ndf-claude/skills/markdown-writing/01-diagram-guide.md diff --git a/plugins/ndf/skills-codex/markdown-writing/SKILL.md b/plugins/ndf-claude/skills/markdown-writing/SKILL.md similarity index 100% rename from plugins/ndf/skills-codex/markdown-writing/SKILL.md rename to plugins/ndf-claude/skills/markdown-writing/SKILL.md diff --git a/plugins/ndf/skills-codex/merged/SKILL.md b/plugins/ndf-claude/skills/merged/SKILL.md similarity index 100% rename from plugins/ndf/skills-codex/merged/SKILL.md rename to plugins/ndf-claude/skills/merged/SKILL.md diff --git a/plugins/ndf/skills-codex/ndf-policies/SKILL.md b/plugins/ndf-claude/skills/ndf-policies/SKILL.md similarity index 100% rename from plugins/ndf/skills-codex/ndf-policies/SKILL.md rename to plugins/ndf-claude/skills/ndf-policies/SKILL.md diff --git a/plugins/ndf/skills-codex/plan-to-spec/SKILL.md b/plugins/ndf-claude/skills/plan-to-spec/SKILL.md similarity index 100% rename from plugins/ndf/skills-codex/plan-to-spec/SKILL.md rename to plugins/ndf-claude/skills/plan-to-spec/SKILL.md diff --git a/plugins/ndf/skills-codex/pr-tests/SKILL.md b/plugins/ndf-claude/skills/pr-tests/SKILL.md similarity index 100% rename from plugins/ndf/skills-codex/pr-tests/SKILL.md rename to plugins/ndf-claude/skills/pr-tests/SKILL.md diff --git a/plugins/ndf/skills-codex/pr/SKILL.md b/plugins/ndf-claude/skills/pr/SKILL.md similarity index 100% rename from plugins/ndf/skills-codex/pr/SKILL.md rename to plugins/ndf-claude/skills/pr/SKILL.md diff --git a/plugins/ndf/skills-codex/problem-solving/SKILL.md b/plugins/ndf-claude/skills/problem-solving/SKILL.md similarity index 100% rename from plugins/ndf/skills-codex/problem-solving/SKILL.md rename to plugins/ndf-claude/skills/problem-solving/SKILL.md diff --git a/plugins/ndf/skills-codex/python-execution/01-uv-setup.md b/plugins/ndf-claude/skills/python-execution/01-uv-setup.md similarity index 100% rename from plugins/ndf/skills-codex/python-execution/01-uv-setup.md rename to plugins/ndf-claude/skills/python-execution/01-uv-setup.md diff --git a/plugins/ndf/skills-codex/python-execution/02-troubleshooting.md b/plugins/ndf-claude/skills/python-execution/02-troubleshooting.md similarity index 100% rename from plugins/ndf/skills-codex/python-execution/02-troubleshooting.md rename to plugins/ndf-claude/skills/python-execution/02-troubleshooting.md diff --git a/plugins/ndf/skills-codex/python-execution/SKILL.md b/plugins/ndf-claude/skills/python-execution/SKILL.md similarity index 100% rename from plugins/ndf/skills-codex/python-execution/SKILL.md rename to plugins/ndf-claude/skills/python-execution/SKILL.md diff --git a/plugins/ndf/skills-codex/resolve-pr-comments/SKILL.md b/plugins/ndf-claude/skills/resolve-pr-comments/SKILL.md similarity index 100% rename from plugins/ndf/skills-codex/resolve-pr-comments/SKILL.md rename to plugins/ndf-claude/skills/resolve-pr-comments/SKILL.md diff --git a/plugins/ndf/skills-codex/review-branch/SKILL.md b/plugins/ndf-claude/skills/review-branch/SKILL.md similarity index 100% rename from plugins/ndf/skills-codex/review-branch/SKILL.md rename to plugins/ndf-claude/skills/review-branch/SKILL.md diff --git a/plugins/ndf/skills/review-pr-comments/SKILL.md b/plugins/ndf-claude/skills/review-pr-comments/SKILL.md similarity index 100% rename from plugins/ndf/skills/review-pr-comments/SKILL.md rename to plugins/ndf-claude/skills/review-pr-comments/SKILL.md diff --git a/plugins/ndf/skills/review/SKILL.md b/plugins/ndf-claude/skills/review/SKILL.md similarity index 98% rename from plugins/ndf/skills/review/SKILL.md rename to plugins/ndf-claude/skills/review/SKILL.md index cfb920b..5dfeb4a 100644 --- a/plugins/ndf/skills/review/SKILL.md +++ b/plugins/ndf-claude/skills/review/SKILL.md @@ -268,7 +268,7 @@ GitHub は **自分の PR には `REQUEST_CHANGES` で投稿できない**(`HT ### `codex` 指定時 -呼び出し手順の詳細は `/ndf:codex` skill(`plugins/ndf/skills/codex/SKILL.md`)に従う。要点: +呼び出し手順の詳細は、利用 runtime に `/ndf:codex` skill が同梱されている場合はその skill に従う。要点: - プロンプトを `/tmp/codex-review-pr<番号>-prompt.md` に書き出し - 出力先ファイルを `/tmp/codex-output-review-pr<番号>.md` として **プロンプト内で `apply_patch` 書き出しを必須化** @@ -283,7 +283,7 @@ GitHub は **自分の PR には `REQUEST_CHANGES` で投稿できない**(`HT ### `gemini` 指定時 -呼び出し手順の詳細は `/ndf:gemini` skill(`plugins/ndf/skills/gemini/SKILL.md`)に従う。要点: +呼び出し手順の詳細は、利用 runtime に `/ndf:gemini` skill が同梱されている場合はその skill に従う。要点: - プロンプトを `/tmp/gemini-review-pr<番号>-prompt.md` に書き出し - **AI 直接投稿フローでは `--yolo` 必須**(`gh api -X POST` がシェル実行のため、`plan` / `auto_edit` だとブロックされる) diff --git a/plugins/ndf/skills/statusline/SKILL.md b/plugins/ndf-claude/skills/statusline/SKILL.md similarity index 100% rename from plugins/ndf/skills/statusline/SKILL.md rename to plugins/ndf-claude/skills/statusline/SKILL.md diff --git a/plugins/ndf/skills-codex/playwright-kit-ops/tests/__init__.py b/plugins/ndf-claude/skills/statusline/tests/__init__.py similarity index 100% rename from plugins/ndf/skills-codex/playwright-kit-ops/tests/__init__.py rename to plugins/ndf-claude/skills/statusline/tests/__init__.py diff --git a/plugins/ndf/skills/statusline/tests/test_statusline_switch.py b/plugins/ndf-claude/skills/statusline/tests/test_statusline_switch.py similarity index 98% rename from plugins/ndf/skills/statusline/tests/test_statusline_switch.py rename to plugins/ndf-claude/skills/statusline/tests/test_statusline_switch.py index c875a37..3fe6648 100644 --- a/plugins/ndf/skills/statusline/tests/test_statusline_switch.py +++ b/plugins/ndf-claude/skills/statusline/tests/test_statusline_switch.py @@ -23,7 +23,7 @@ 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 +# plugins/ndf-shared/skills/statusline/tests/ -> plugins/ndf-shared/scripts/statusline-switch.sh SWITCH = Path(__file__).resolve().parents[3] / "scripts" / "statusline-switch.sh" NDF_COMMAND = "bash ~/.claude/ndf-statusline.sh" diff --git a/plugins/ndf/skills-codex/sync-main/SKILL.md b/plugins/ndf-claude/skills/sync-main/SKILL.md similarity index 100% rename from plugins/ndf/skills-codex/sync-main/SKILL.md rename to plugins/ndf-claude/skills/sync-main/SKILL.md diff --git a/plugins/ndf-codex/.codex-plugin/plugin.json b/plugins/ndf-codex/.codex-plugin/plugin.json new file mode 100644 index 0000000..ddd954f --- /dev/null +++ b/plugins/ndf-codex/.codex-plugin/plugin.json @@ -0,0 +1,7 @@ +{ + "name": "ndf", + "version": "4.19.0", + "description": "Codex plugin with focused NDF skills for PR/review workflows, cross-review, implementation planning, Playwright testing, Docker container access, GitHub operations, and optional Slack completion notifications.", + "skills": "./skills/", + "hooks": "./hooks/hooks.json" +} diff --git a/plugins/ndf-codex/README.md b/plugins/ndf-codex/README.md new file mode 100644 index 0000000..ff0e11e --- /dev/null +++ b/plugins/ndf-codex/README.md @@ -0,0 +1,58 @@ +# NDF Codex Plugin + +Codex CLI 向けの NDF プラグインです。PR 運用、レビュー、cross-review、実装計画、仕様書化、Playwright テスト運用、Docker container access、GitHub 操作補助などの Codex 用 skills と、Codex 終了時の任意 Slack 通知 hook を提供します。 + +## インストール + +Codex で marketplace を追加し、`ndf` をインストールします。 + +```bash +codex plugin marketplace add https://github.com/devbasex/ai-plugins +codex plugin add ndf@ai-plugins +``` + +## 同梱内容 + +- `.codex-plugin/plugin.json`: Codex plugin manifest +- `skills/`: Codex 向けに公開する NDF skills +- `hooks/hooks.json`: Codex Stop hook +- `scripts/`: hook と skill から利用する同梱スクリプト + +Claude Code 専用の agents、statusline 自動設定、transcript retention 自動設定は含めません。Codex runtime が読むファイルはこの `plugins/ndf-codex` 配下だけで完結します。 + +## Slack 通知 + +Codex 版の Stop hook は `NDF_CODEX_SLACK_NOTIFY=true` が設定されている場合だけ Slack 通知を送ります。通知を使う場合は、利用プロジェクト側で以下の環境変数を設定します。 + +```bash +NDF_CODEX_SLACK_NOTIFY=true +SLACK_BOT_TOKEN=xoxb-... +SLACK_CHANNEL_ID=C0123456789 +SLACK_USER_MENTION=<@U0123456789> +``` + +`SLACK_USER_MENTION` は任意です。機密値は `.env` などで管理し、リポジトリへコミットしないでください。 + +Codex の hook は初回実行前に Codex 側の hooks trust 設定が必要になる場合があります。`/hooks` で対象 hook を確認し、利用するプロジェクトで明示的に有効化してください。 + +## 検証 + +Codex plugin schema を検証できる CLI が利用できる場合は、Codex 側の validate / install smoke を実行してください。CLI に検証コマンドが無い環境では、manifest JSON と参照パスの存在を確認します。 + +```bash +python3 -m json.tool plugins/ndf-codex/.codex-plugin/plugin.json >/dev/null +python3 -m json.tool plugins/ndf-codex/hooks/hooks.json >/dev/null +test -d plugins/ndf-codex/skills +test -d plugins/ndf-codex/scripts +``` + +install smoke を行う場合は、別の一時プロジェクトで marketplace から `ndf@ai-plugins` を追加し、代表 skill が読み込まれることと Stop hook が trust 対象として表示されることを確認します。 + +## 開発者向け + +`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 +``` diff --git a/plugins/ndf/hooks/codex-hooks.json b/plugins/ndf-codex/hooks/hooks.json similarity index 100% rename from plugins/ndf/hooks/codex-hooks.json rename to plugins/ndf-codex/hooks/hooks.json diff --git a/plugins/ndf-codex/scripts/codex-slack-notify.js b/plugins/ndf-codex/scripts/codex-slack-notify.js new file mode 100755 index 0000000..af01f50 --- /dev/null +++ b/plugins/ndf-codex/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-codex/scripts/ensure-retention.sh b/plugins/ndf-codex/scripts/ensure-retention.sh new file mode 100755 index 0000000..e511363 --- /dev/null +++ b/plugins/ndf-codex/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-codex/scripts/install-official-skills.sh b/plugins/ndf-codex/scripts/install-official-skills.sh new file mode 100755 index 0000000..bba6467 --- /dev/null +++ b/plugins/ndf-codex/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-codex/scripts/slack-notify.js b/plugins/ndf-codex/scripts/slack-notify.js new file mode 100755 index 0000000..38b9991 --- /dev/null +++ b/plugins/ndf-codex/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-codex/scripts/statusline-switch.sh b/plugins/ndf-codex/scripts/statusline-switch.sh new file mode 100755 index 0000000..bccb616 --- /dev/null +++ b/plugins/ndf-codex/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-codex/scripts/statusline.sh b/plugins/ndf-codex/scripts/statusline.sh new file mode 100755 index 0000000..19d7c09 --- /dev/null +++ b/plugins/ndf-codex/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/skills/branch-fix-strategy/SKILL.md b/plugins/ndf-codex/skills/branch-fix-strategy/SKILL.md similarity index 100% rename from plugins/ndf/skills/branch-fix-strategy/SKILL.md rename to plugins/ndf-codex/skills/branch-fix-strategy/SKILL.md diff --git a/plugins/ndf/skills/cherry-pick-pr/SKILL.md b/plugins/ndf-codex/skills/cherry-pick-pr/SKILL.md similarity index 100% rename from plugins/ndf/skills/cherry-pick-pr/SKILL.md rename to plugins/ndf-codex/skills/cherry-pick-pr/SKILL.md diff --git a/plugins/ndf/skills/clean/SKILL.md b/plugins/ndf-codex/skills/clean/SKILL.md similarity index 100% rename from plugins/ndf/skills/clean/SKILL.md rename to plugins/ndf-codex/skills/clean/SKILL.md diff --git a/plugins/ndf-codex/skills/cross-review/SKILL.md b/plugins/ndf-codex/skills/cross-review/SKILL.md new file mode 100644 index 0000000..208437b --- /dev/null +++ b/plugins/ndf-codex/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:-${CODEX_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-codex/skills/cross-review/docs/01-state-and-review.md b/plugins/ndf-codex/skills/cross-review/docs/01-state-and-review.md new file mode 100644 index 0000000..b2a1cd4 --- /dev/null +++ b/plugins/ndf-codex/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:-${CODEX_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/skills/cross-review/docs/02-fix-and-rotation.md b/plugins/ndf-codex/skills/cross-review/docs/02-fix-and-rotation.md similarity index 100% rename from plugins/ndf/skills/cross-review/docs/02-fix-and-rotation.md rename to plugins/ndf-codex/skills/cross-review/docs/02-fix-and-rotation.md diff --git a/plugins/ndf/skills/cross-review/scripts/_tmpdir.sh b/plugins/ndf-codex/skills/cross-review/scripts/_tmpdir.sh similarity index 100% rename from plugins/ndf/skills/cross-review/scripts/_tmpdir.sh rename to plugins/ndf-codex/skills/cross-review/scripts/_tmpdir.sh diff --git a/plugins/ndf/skills/cross-review/scripts/launch-codex.sh b/plugins/ndf-codex/skills/cross-review/scripts/launch-codex.sh similarity index 100% rename from plugins/ndf/skills/cross-review/scripts/launch-codex.sh rename to plugins/ndf-codex/skills/cross-review/scripts/launch-codex.sh diff --git a/plugins/ndf/skills/cross-review/scripts/launch-gemini.sh b/plugins/ndf-codex/skills/cross-review/scripts/launch-gemini.sh similarity index 100% rename from plugins/ndf/skills/cross-review/scripts/launch-gemini.sh rename to plugins/ndf-codex/skills/cross-review/scripts/launch-gemini.sh diff --git a/plugins/ndf/skills/cross-review/scripts/monitor.py b/plugins/ndf-codex/skills/cross-review/scripts/monitor.py similarity index 100% rename from plugins/ndf/skills/cross-review/scripts/monitor.py rename to plugins/ndf-codex/skills/cross-review/scripts/monitor.py diff --git a/plugins/ndf/skills/cross-review/scripts/rotate-pr.sh b/plugins/ndf-codex/skills/cross-review/scripts/rotate-pr.sh similarity index 100% rename from plugins/ndf/skills/cross-review/scripts/rotate-pr.sh rename to plugins/ndf-codex/skills/cross-review/scripts/rotate-pr.sh diff --git a/plugins/ndf/skills/cross-review/scripts/state.py b/plugins/ndf-codex/skills/cross-review/scripts/state.py similarity index 99% rename from plugins/ndf/skills/cross-review/scripts/state.py rename to plugins/ndf-codex/skills/cross-review/scripts/state.py index c386e70..9e91f11 100755 --- a/plugins/ndf/skills/cross-review/scripts/state.py +++ b/plugins/ndf-codex/skills/cross-review/scripts/state.py @@ -1299,11 +1299,10 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: meta_patterns = ("check_pr_requirements", "assignees", "reviewers", "labels", "meta") failed = fix.get("ci_failed_checks") or [] code_fail = False - meta_fail = False for name in failed: low = name.lower() if any(p in low for p in meta_patterns): - meta_fail = True + continue elif any(p in low for p in code_patterns): code_fail = True else: diff --git a/plugins/ndf/skills/cross-review/scripts/wait-review.sh b/plugins/ndf-codex/skills/cross-review/scripts/wait-review.sh similarity index 100% rename from plugins/ndf/skills/cross-review/scripts/wait-review.sh rename to plugins/ndf-codex/skills/cross-review/scripts/wait-review.sh diff --git a/plugins/ndf/skills/cross-review/tests/__init__.py b/plugins/ndf-codex/skills/cross-review/tests/__init__.py similarity index 100% rename from plugins/ndf/skills/cross-review/tests/__init__.py rename to plugins/ndf-codex/skills/cross-review/tests/__init__.py diff --git a/plugins/ndf/skills/cross-review/tests/conftest.py b/plugins/ndf-codex/skills/cross-review/tests/conftest.py similarity index 100% rename from plugins/ndf/skills/cross-review/tests/conftest.py rename to plugins/ndf-codex/skills/cross-review/tests/conftest.py diff --git a/plugins/ndf/skills/cross-review/tests/test_default_worktree_base.py b/plugins/ndf-codex/skills/cross-review/tests/test_default_worktree_base.py similarity index 100% rename from plugins/ndf/skills/cross-review/tests/test_default_worktree_base.py rename to plugins/ndf-codex/skills/cross-review/tests/test_default_worktree_base.py diff --git a/plugins/ndf/skills/cross-review/tests/test_monitor_early_error.py b/plugins/ndf-codex/skills/cross-review/tests/test_monitor_early_error.py similarity index 98% rename from plugins/ndf/skills/cross-review/tests/test_monitor_early_error.py rename to plugins/ndf-codex/skills/cross-review/tests/test_monitor_early_error.py index a7e3f6b..d669c2d 100644 --- a/plugins/ndf/skills/cross-review/tests/test_monitor_early_error.py +++ b/plugins/ndf-codex/skills/cross-review/tests/test_monitor_early_error.py @@ -77,7 +77,7 @@ def test_grep_style_source_citation_is_benign(tmp_path, monitor_mod): """ log = _write( tmp_path / "err.log", - '/work/worktrees/pr23/plugins/ndf/skills/cross-review/tests/' + '/work/worktrees/pr23/plugins/ndf-shared/skills/cross-review/tests/' 'test_monitor_early_error.py:22: log = _write(tmp_path / "err.log", ' '"quota exceeded: please upgrade\\n")\n', ) diff --git a/plugins/ndf/skills/cross-review/tests/test_monitor_import_safety.py b/plugins/ndf-codex/skills/cross-review/tests/test_monitor_import_safety.py similarity index 100% rename from plugins/ndf/skills/cross-review/tests/test_monitor_import_safety.py rename to plugins/ndf-codex/skills/cross-review/tests/test_monitor_import_safety.py diff --git a/plugins/ndf/skills/cross-review/tests/test_monitor_progress_log.py b/plugins/ndf-codex/skills/cross-review/tests/test_monitor_progress_log.py similarity index 100% rename from plugins/ndf/skills/cross-review/tests/test_monitor_progress_log.py rename to plugins/ndf-codex/skills/cross-review/tests/test_monitor_progress_log.py diff --git a/plugins/ndf/skills/cross-review/tests/test_monitor_result_age.py b/plugins/ndf-codex/skills/cross-review/tests/test_monitor_result_age.py similarity index 100% rename from plugins/ndf/skills/cross-review/tests/test_monitor_result_age.py rename to plugins/ndf-codex/skills/cross-review/tests/test_monitor_result_age.py diff --git a/plugins/ndf/skills/cross-review/tests/test_monitor_stall_default.py b/plugins/ndf-codex/skills/cross-review/tests/test_monitor_stall_default.py similarity index 100% rename from plugins/ndf/skills/cross-review/tests/test_monitor_stall_default.py rename to plugins/ndf-codex/skills/cross-review/tests/test_monitor_stall_default.py diff --git a/plugins/ndf/skills/cross-review/tests/test_monitor_zombie.py b/plugins/ndf-codex/skills/cross-review/tests/test_monitor_zombie.py similarity index 100% rename from plugins/ndf/skills/cross-review/tests/test_monitor_zombie.py rename to plugins/ndf-codex/skills/cross-review/tests/test_monitor_zombie.py diff --git a/plugins/ndf/skills/cross-review/tests/test_state_auto_review_templates.py b/plugins/ndf-codex/skills/cross-review/tests/test_state_auto_review_templates.py similarity index 100% rename from plugins/ndf/skills/cross-review/tests/test_state_auto_review_templates.py rename to plugins/ndf-codex/skills/cross-review/tests/test_state_auto_review_templates.py diff --git a/plugins/ndf/skills/cross-review/tests/test_state_check_oscillation.py b/plugins/ndf-codex/skills/cross-review/tests/test_state_check_oscillation.py similarity index 100% rename from plugins/ndf/skills/cross-review/tests/test_state_check_oscillation.py rename to plugins/ndf-codex/skills/cross-review/tests/test_state_check_oscillation.py diff --git a/plugins/ndf/skills/cross-review/tests/test_state_extra_review_instructions.py b/plugins/ndf-codex/skills/cross-review/tests/test_state_extra_review_instructions.py similarity index 100% rename from plugins/ndf/skills/cross-review/tests/test_state_extra_review_instructions.py rename to plugins/ndf-codex/skills/cross-review/tests/test_state_extra_review_instructions.py diff --git a/plugins/ndf/skills/cross-review/tests/test_state_merge_fix.py b/plugins/ndf-codex/skills/cross-review/tests/test_state_merge_fix.py similarity index 100% rename from plugins/ndf/skills/cross-review/tests/test_state_merge_fix.py rename to plugins/ndf-codex/skills/cross-review/tests/test_state_merge_fix.py diff --git a/plugins/ndf/skills/cross-review/tests/test_state_read_result.py b/plugins/ndf-codex/skills/cross-review/tests/test_state_read_result.py similarity index 100% rename from plugins/ndf/skills/cross-review/tests/test_state_read_result.py rename to plugins/ndf-codex/skills/cross-review/tests/test_state_read_result.py diff --git a/plugins/ndf/skills/deploy/SKILL.md b/plugins/ndf-codex/skills/deploy/SKILL.md similarity index 100% rename from plugins/ndf/skills/deploy/SKILL.md rename to plugins/ndf-codex/skills/deploy/SKILL.md diff --git a/plugins/ndf/skills/docker-container-access/01-environment-detection.md b/plugins/ndf-codex/skills/docker-container-access/01-environment-detection.md similarity index 100% rename from plugins/ndf/skills/docker-container-access/01-environment-detection.md rename to plugins/ndf-codex/skills/docker-container-access/01-environment-detection.md diff --git a/plugins/ndf/skills/docker-container-access/02-dood-access.md b/plugins/ndf-codex/skills/docker-container-access/02-dood-access.md similarity index 100% rename from plugins/ndf/skills/docker-container-access/02-dood-access.md rename to plugins/ndf-codex/skills/docker-container-access/02-dood-access.md diff --git a/plugins/ndf/skills/docker-container-access/03-troubleshooting.md b/plugins/ndf-codex/skills/docker-container-access/03-troubleshooting.md similarity index 100% rename from plugins/ndf/skills/docker-container-access/03-troubleshooting.md rename to plugins/ndf-codex/skills/docker-container-access/03-troubleshooting.md diff --git a/plugins/ndf/skills/docker-container-access/SKILL.md b/plugins/ndf-codex/skills/docker-container-access/SKILL.md similarity index 100% rename from plugins/ndf/skills/docker-container-access/SKILL.md rename to plugins/ndf-codex/skills/docker-container-access/SKILL.md diff --git a/plugins/ndf/skills-codex/fix/SKILL.md b/plugins/ndf-codex/skills/fix/SKILL.md similarity index 99% rename from plugins/ndf/skills-codex/fix/SKILL.md rename to plugins/ndf-codex/skills/fix/SKILL.md index f7ce931..3b001bb 100644 --- a/plugins/ndf/skills-codex/fix/SKILL.md +++ b/plugins/ndf-codex/skills/fix/SKILL.md @@ -181,7 +181,7 @@ review指摘とCIエラーは**同じPRで一緒に修正**する: ```bash # インラインコメント / レビュー body / PR レベルコメントを一括取得 -FETCH_SCRIPT="${PLUGIN_ROOT:-${CODEX_PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT}}}/skills-codex/fix/scripts/fetch-pr-comments.sh" +FETCH_SCRIPT="${PLUGIN_ROOT:-${CODEX_PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT}}}/skills/fix/scripts/fetch-pr-comments.sh" "$FETCH_SCRIPT" ``` diff --git a/plugins/ndf/skills/fix/scripts/fetch-pr-comments.sh b/plugins/ndf-codex/skills/fix/scripts/fetch-pr-comments.sh similarity index 100% rename from plugins/ndf/skills/fix/scripts/fetch-pr-comments.sh rename to plugins/ndf-codex/skills/fix/scripts/fetch-pr-comments.sh diff --git a/plugins/ndf/skills/git-gh-operations/01-common-errors.md b/plugins/ndf-codex/skills/git-gh-operations/01-common-errors.md similarity index 100% rename from plugins/ndf/skills/git-gh-operations/01-common-errors.md rename to plugins/ndf-codex/skills/git-gh-operations/01-common-errors.md diff --git a/plugins/ndf/skills/git-gh-operations/SKILL.md b/plugins/ndf-codex/skills/git-gh-operations/SKILL.md similarity index 100% rename from plugins/ndf/skills/git-gh-operations/SKILL.md rename to plugins/ndf-codex/skills/git-gh-operations/SKILL.md diff --git a/plugins/ndf/skills/implementation-plan/SKILL.md b/plugins/ndf-codex/skills/implementation-plan/SKILL.md similarity index 100% rename from plugins/ndf/skills/implementation-plan/SKILL.md rename to plugins/ndf-codex/skills/implementation-plan/SKILL.md diff --git a/plugins/ndf/skills/investigation-rules/SKILL.md b/plugins/ndf-codex/skills/investigation-rules/SKILL.md similarity index 100% rename from plugins/ndf/skills/investigation-rules/SKILL.md rename to plugins/ndf-codex/skills/investigation-rules/SKILL.md diff --git a/plugins/ndf/skills/issue-plan-strategy/SKILL.md b/plugins/ndf-codex/skills/issue-plan-strategy/SKILL.md similarity index 100% rename from plugins/ndf/skills/issue-plan-strategy/SKILL.md rename to plugins/ndf-codex/skills/issue-plan-strategy/SKILL.md diff --git a/plugins/ndf/skills/logging-guidelines/SKILL.md b/plugins/ndf-codex/skills/logging-guidelines/SKILL.md similarity index 100% rename from plugins/ndf/skills/logging-guidelines/SKILL.md rename to plugins/ndf-codex/skills/logging-guidelines/SKILL.md diff --git a/plugins/ndf/skills/markdown-writing/01-diagram-guide.md b/plugins/ndf-codex/skills/markdown-writing/01-diagram-guide.md similarity index 100% rename from plugins/ndf/skills/markdown-writing/01-diagram-guide.md rename to plugins/ndf-codex/skills/markdown-writing/01-diagram-guide.md diff --git a/plugins/ndf/skills/markdown-writing/SKILL.md b/plugins/ndf-codex/skills/markdown-writing/SKILL.md similarity index 100% rename from plugins/ndf/skills/markdown-writing/SKILL.md rename to plugins/ndf-codex/skills/markdown-writing/SKILL.md diff --git a/plugins/ndf/skills/merged/SKILL.md b/plugins/ndf-codex/skills/merged/SKILL.md similarity index 100% rename from plugins/ndf/skills/merged/SKILL.md rename to plugins/ndf-codex/skills/merged/SKILL.md diff --git a/plugins/ndf/skills/ndf-policies/SKILL.md b/plugins/ndf-codex/skills/ndf-policies/SKILL.md similarity index 100% rename from plugins/ndf/skills/ndf-policies/SKILL.md rename to plugins/ndf-codex/skills/ndf-policies/SKILL.md diff --git a/plugins/ndf/skills/plan-to-spec/SKILL.md b/plugins/ndf-codex/skills/plan-to-spec/SKILL.md similarity index 100% rename from plugins/ndf/skills/plan-to-spec/SKILL.md rename to plugins/ndf-codex/skills/plan-to-spec/SKILL.md diff --git a/plugins/ndf/skills-codex/playwright-execution/SKILL.md b/plugins/ndf-codex/skills/playwright-execution/SKILL.md similarity index 100% rename from plugins/ndf/skills-codex/playwright-execution/SKILL.md rename to plugins/ndf-codex/skills/playwright-execution/SKILL.md diff --git a/plugins/ndf/skills-codex/playwright-kit-ops/.gitignore b/plugins/ndf-codex/skills/playwright-kit-ops/.gitignore similarity index 100% rename from plugins/ndf/skills-codex/playwright-kit-ops/.gitignore rename to plugins/ndf-codex/skills/playwright-kit-ops/.gitignore diff --git a/plugins/ndf/skills/playwright-kit-ops/SKILL.md b/plugins/ndf-codex/skills/playwright-kit-ops/SKILL.md similarity index 98% rename from plugins/ndf/skills/playwright-kit-ops/SKILL.md rename to plugins/ndf-codex/skills/playwright-kit-ops/SKILL.md index 991e993..327c1b0 100644 --- a/plugins/ndf/skills/playwright-kit-ops/SKILL.md +++ b/plugins/ndf-codex/skills/playwright-kit-ops/SKILL.md @@ -64,7 +64,7 @@ skill を同梱しないため、Drive 系コマンドや `--pwk-drive-folder` `GOOGLE_AUTH_SCRIPTS` を `google-auth/scripts` の実パスへ設定する。 ```bash -export GOOGLE_AUTH_SCRIPTS=/path/to/plugins/ndf/skills/google-auth/scripts +export GOOGLE_AUTH_SCRIPTS=/path/to/plugins/ndf-shared/skills/google-auth/scripts cd scenario-test uv sync --extra drive ``` diff --git a/plugins/ndf/skills-codex/playwright-kit-ops/playwright_kit/__init__.py b/plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/__init__.py similarity index 100% rename from plugins/ndf/skills-codex/playwright-kit-ops/playwright_kit/__init__.py rename to plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/__init__.py diff --git a/plugins/ndf/skills-codex/playwright-kit-ops/playwright_kit/accessibility.py b/plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/accessibility.py similarity index 100% rename from plugins/ndf/skills-codex/playwright-kit-ops/playwright_kit/accessibility.py rename to plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/accessibility.py diff --git a/plugins/ndf/skills-codex/playwright-kit-ops/playwright_kit/body_check.py b/plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/body_check.py similarity index 100% rename from plugins/ndf/skills-codex/playwright-kit-ops/playwright_kit/body_check.py rename to plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/body_check.py diff --git a/plugins/ndf/skills-codex/playwright-kit-ops/playwright_kit/config.py b/plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/config.py similarity index 100% rename from plugins/ndf/skills-codex/playwright-kit-ops/playwright_kit/config.py rename to plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/config.py diff --git a/plugins/ndf/skills-codex/playwright-kit-ops/playwright_kit/fixtures/__init__.py b/plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/fixtures/__init__.py similarity index 100% rename from plugins/ndf/skills-codex/playwright-kit-ops/playwright_kit/fixtures/__init__.py rename to plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/fixtures/__init__.py diff --git a/plugins/ndf/skills-codex/playwright-kit-ops/playwright_kit/fixtures/accessibility.py b/plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/fixtures/accessibility.py similarity index 100% rename from plugins/ndf/skills-codex/playwright-kit-ops/playwright_kit/fixtures/accessibility.py rename to plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/fixtures/accessibility.py diff --git a/plugins/ndf/skills-codex/playwright-kit-ops/playwright_kit/fixtures/auth.py b/plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/fixtures/auth.py similarity index 100% rename from plugins/ndf/skills-codex/playwright-kit-ops/playwright_kit/fixtures/auth.py rename to plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/fixtures/auth.py diff --git a/plugins/ndf/skills-codex/playwright-kit-ops/playwright_kit/fixtures/body_check.py b/plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/fixtures/body_check.py similarity index 100% rename from plugins/ndf/skills-codex/playwright-kit-ops/playwright_kit/fixtures/body_check.py rename to plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/fixtures/body_check.py diff --git a/plugins/ndf/skills-codex/playwright-kit-ops/playwright_kit/fixtures/evidence.py b/plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/fixtures/evidence.py similarity index 100% rename from plugins/ndf/skills-codex/playwright-kit-ops/playwright_kit/fixtures/evidence.py rename to plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/fixtures/evidence.py diff --git a/plugins/ndf/skills-codex/playwright-kit-ops/playwright_kit/fixtures/web_vitals.py b/plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/fixtures/web_vitals.py similarity index 100% rename from plugins/ndf/skills-codex/playwright-kit-ops/playwright_kit/fixtures/web_vitals.py rename to plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/fixtures/web_vitals.py diff --git a/plugins/ndf/skills-codex/playwright-kit-ops/playwright_kit/overlay.py b/plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/overlay.py similarity index 100% rename from plugins/ndf/skills-codex/playwright-kit-ops/playwright_kit/overlay.py rename to plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/overlay.py diff --git a/plugins/ndf/skills-codex/playwright-kit-ops/playwright_kit/pytest_plugin.py b/plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/pytest_plugin.py similarity index 100% rename from plugins/ndf/skills-codex/playwright-kit-ops/playwright_kit/pytest_plugin.py rename to plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/pytest_plugin.py diff --git a/plugins/ndf/skills-codex/playwright-kit-ops/playwright_kit/pytest_report.py b/plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/pytest_report.py similarity index 100% rename from plugins/ndf/skills-codex/playwright-kit-ops/playwright_kit/pytest_report.py rename to plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/pytest_report.py diff --git a/plugins/ndf/skills/playwright-kit-ops/playwright_kit/uploaders/__init__.py b/plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/uploaders/__init__.py similarity index 98% rename from plugins/ndf/skills/playwright-kit-ops/playwright_kit/uploaders/__init__.py rename to plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/uploaders/__init__.py index 283d2f0..3d8f8e9 100644 --- a/plugins/ndf/skills/playwright-kit-ops/playwright_kit/uploaders/__init__.py +++ b/plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/uploaders/__init__.py @@ -45,7 +45,7 @@ def _ensure_google_auth_on_path() -> None: "Google Drive 連携には optional skill `google-auth` が必要です。\n" "Codex 公開セットには同梱していないため、Drive 系コマンドを使う前に " "`GOOGLE_AUTH_SCRIPTS` を google-auth/scripts へ設定してください。\n" - "例: export GOOGLE_AUTH_SCRIPTS=/path/to/plugins/ndf/skills/google-auth/scripts\n" + "例: export GOOGLE_AUTH_SCRIPTS=/path/to/plugins/ndf-shared/skills/google-auth/scripts\n" "検索した候補:\n - " f"{searched}" ) diff --git a/plugins/ndf/skills-codex/playwright-kit-ops/playwright_kit/video.py b/plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/video.py similarity index 100% rename from plugins/ndf/skills-codex/playwright-kit-ops/playwright_kit/video.py rename to plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/video.py diff --git a/plugins/ndf/skills-codex/playwright-kit-ops/playwright_kit/web_vitals.py b/plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/web_vitals.py similarity index 100% rename from plugins/ndf/skills-codex/playwright-kit-ops/playwright_kit/web_vitals.py rename to plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/web_vitals.py diff --git a/plugins/ndf/skills-codex/playwright-kit-ops/pyproject.toml b/plugins/ndf-codex/skills/playwright-kit-ops/pyproject.toml similarity index 100% rename from plugins/ndf/skills-codex/playwright-kit-ops/pyproject.toml rename to plugins/ndf-codex/skills/playwright-kit-ops/pyproject.toml diff --git a/plugins/ndf/skills/playwright-kit-ops/scripts/_drive_auth.py b/plugins/ndf-codex/skills/playwright-kit-ops/scripts/_drive_auth.py similarity index 95% rename from plugins/ndf/skills/playwright-kit-ops/scripts/_drive_auth.py rename to plugins/ndf-codex/skills/playwright-kit-ops/scripts/_drive_auth.py index 647ab52..ddc873c 100644 --- a/plugins/ndf/skills/playwright-kit-ops/scripts/_drive_auth.py +++ b/plugins/ndf-codex/skills/playwright-kit-ops/scripts/_drive_auth.py @@ -43,7 +43,7 @@ def _ensure_google_auth_on_path() -> None: "Google Drive 連携には optional skill `google-auth` が必要です。\n" "Codex 公開セットには同梱していないため、Drive 系コマンドを使う前に " "`GOOGLE_AUTH_SCRIPTS` を google-auth/scripts へ設定してください。\n" - "例: export GOOGLE_AUTH_SCRIPTS=/path/to/plugins/ndf/skills/google-auth/scripts\n" + "例: export GOOGLE_AUTH_SCRIPTS=/path/to/plugins/ndf-shared/skills/google-auth/scripts\n" "検索した候補:\n - " f"{searched}" ) diff --git a/plugins/ndf/skills-codex/playwright-kit-ops/scripts/build_gdoc_with_drive_links.py b/plugins/ndf-codex/skills/playwright-kit-ops/scripts/build_gdoc_with_drive_links.py similarity index 100% rename from plugins/ndf/skills-codex/playwright-kit-ops/scripts/build_gdoc_with_drive_links.py rename to plugins/ndf-codex/skills/playwright-kit-ops/scripts/build_gdoc_with_drive_links.py diff --git a/plugins/ndf/skills-codex/playwright-kit-ops/scripts/check_cwv.py b/plugins/ndf-codex/skills/playwright-kit-ops/scripts/check_cwv.py similarity index 100% rename from plugins/ndf/skills-codex/playwright-kit-ops/scripts/check_cwv.py rename to plugins/ndf-codex/skills/playwright-kit-ops/scripts/check_cwv.py diff --git a/plugins/ndf/skills-codex/playwright-kit-ops/scripts/classify_page_role.py b/plugins/ndf-codex/skills/playwright-kit-ops/scripts/classify_page_role.py similarity index 100% rename from plugins/ndf/skills-codex/playwright-kit-ops/scripts/classify_page_role.py rename to plugins/ndf-codex/skills/playwright-kit-ops/scripts/classify_page_role.py diff --git a/plugins/ndf/skills-codex/playwright-kit-ops/scripts/gdrive_upload_dir.py b/plugins/ndf-codex/skills/playwright-kit-ops/scripts/gdrive_upload_dir.py similarity index 100% rename from plugins/ndf/skills-codex/playwright-kit-ops/scripts/gdrive_upload_dir.py rename to plugins/ndf-codex/skills/playwright-kit-ops/scripts/gdrive_upload_dir.py diff --git a/plugins/ndf/skills-codex/playwright-kit-ops/scripts/init_project.bat b/plugins/ndf-codex/skills/playwright-kit-ops/scripts/init_project.bat similarity index 100% rename from plugins/ndf/skills-codex/playwright-kit-ops/scripts/init_project.bat rename to plugins/ndf-codex/skills/playwright-kit-ops/scripts/init_project.bat diff --git a/plugins/ndf/skills-codex/playwright-kit-ops/scripts/init_project.sh b/plugins/ndf-codex/skills/playwright-kit-ops/scripts/init_project.sh similarity index 100% rename from plugins/ndf/skills-codex/playwright-kit-ops/scripts/init_project.sh rename to plugins/ndf-codex/skills/playwright-kit-ops/scripts/init_project.sh diff --git a/plugins/ndf/skills-codex/playwright-kit-ops/scripts/record_scenario.py b/plugins/ndf-codex/skills/playwright-kit-ops/scripts/record_scenario.py similarity index 100% rename from plugins/ndf/skills-codex/playwright-kit-ops/scripts/record_scenario.py rename to plugins/ndf-codex/skills/playwright-kit-ops/scripts/record_scenario.py diff --git a/plugins/ndf/skills-codex/playwright-kit-ops/scripts/run_a11y_scan.py b/plugins/ndf-codex/skills/playwright-kit-ops/scripts/run_a11y_scan.py similarity index 100% rename from plugins/ndf/skills-codex/playwright-kit-ops/scripts/run_a11y_scan.py rename to plugins/ndf-codex/skills/playwright-kit-ops/scripts/run_a11y_scan.py diff --git a/plugins/ndf/skills-codex/playwright-kit-ops/scripts/upload_evidence.py b/plugins/ndf-codex/skills/playwright-kit-ops/scripts/upload_evidence.py similarity index 98% rename from plugins/ndf/skills-codex/playwright-kit-ops/scripts/upload_evidence.py rename to plugins/ndf-codex/skills/playwright-kit-ops/scripts/upload_evidence.py index 0a3cd84..ebe2abc 100644 --- a/plugins/ndf/skills-codex/playwright-kit-ops/scripts/upload_evidence.py +++ b/plugins/ndf-codex/skills/playwright-kit-ops/scripts/upload_evidence.py @@ -49,7 +49,7 @@ def _ensure_google_auth_on_path() -> None: "Google Drive 連携には optional skill `google-auth` が必要です。\n" "Codex 公開セットには同梱していないため、Drive 系コマンドを使う前に " "`GOOGLE_AUTH_SCRIPTS` を google-auth/scripts へ設定してください。\n" - "例: export GOOGLE_AUTH_SCRIPTS=/path/to/plugins/ndf/skills/google-auth/scripts\n" + "例: export GOOGLE_AUTH_SCRIPTS=/path/to/plugins/ndf-shared/skills/google-auth/scripts\n" "検索した候補:\n - " f"{searched}" ) diff --git a/plugins/ndf/skills-codex/playwright-kit-ops/scripts/upload_md_as_gdoc.py b/plugins/ndf-codex/skills/playwright-kit-ops/scripts/upload_md_as_gdoc.py similarity index 100% rename from plugins/ndf/skills-codex/playwright-kit-ops/scripts/upload_md_as_gdoc.py rename to plugins/ndf-codex/skills/playwright-kit-ops/scripts/upload_md_as_gdoc.py diff --git a/plugins/ndf/skills-codex/playwright-kit-ops/templates/conftest.py.template b/plugins/ndf-codex/skills/playwright-kit-ops/templates/conftest.py.template similarity index 100% rename from plugins/ndf/skills-codex/playwright-kit-ops/templates/conftest.py.template rename to plugins/ndf-codex/skills/playwright-kit-ops/templates/conftest.py.template diff --git a/plugins/ndf/skills-codex/playwright-kit-ops/templates/pyproject.toml.runtime b/plugins/ndf-codex/skills/playwright-kit-ops/templates/pyproject.toml.runtime similarity index 100% rename from plugins/ndf/skills-codex/playwright-kit-ops/templates/pyproject.toml.runtime rename to plugins/ndf-codex/skills/playwright-kit-ops/templates/pyproject.toml.runtime diff --git a/plugins/ndf/skills-codex/playwright-kit-ops/templates/run.bat b/plugins/ndf-codex/skills/playwright-kit-ops/templates/run.bat similarity index 100% rename from plugins/ndf/skills-codex/playwright-kit-ops/templates/run.bat rename to plugins/ndf-codex/skills/playwright-kit-ops/templates/run.bat diff --git a/plugins/ndf/skills-codex/playwright-kit-ops/templates/run.sh b/plugins/ndf-codex/skills/playwright-kit-ops/templates/run.sh similarity index 100% rename from plugins/ndf/skills-codex/playwright-kit-ops/templates/run.sh rename to plugins/ndf-codex/skills/playwright-kit-ops/templates/run.sh diff --git a/plugins/ndf/skills-codex/playwright-kit-ops/templates/runtime-README.md b/plugins/ndf-codex/skills/playwright-kit-ops/templates/runtime-README.md similarity index 98% rename from plugins/ndf/skills-codex/playwright-kit-ops/templates/runtime-README.md rename to plugins/ndf-codex/skills/playwright-kit-ops/templates/runtime-README.md index 035bb4c..945db44 100644 --- a/plugins/ndf/skills-codex/playwright-kit-ops/templates/runtime-README.md +++ b/plugins/ndf-codex/skills/playwright-kit-ops/templates/runtime-README.md @@ -101,7 +101,7 @@ Drive 連携は optional dependency です。`--pwk-drive-folder` や Drive 系 Drive extra を同期してください。 ```bash -export GOOGLE_AUTH_SCRIPTS=/path/to/plugins/ndf/skills/google-auth/scripts +export GOOGLE_AUTH_SCRIPTS=/path/to/plugins/ndf-shared/skills/google-auth/scripts uv sync --extra drive ``` diff --git a/plugins/ndf/skills-codex/playwright-kit-ops/templates/runtime-gitignore b/plugins/ndf-codex/skills/playwright-kit-ops/templates/runtime-gitignore similarity index 100% rename from plugins/ndf/skills-codex/playwright-kit-ops/templates/runtime-gitignore rename to plugins/ndf-codex/skills/playwright-kit-ops/templates/runtime-gitignore diff --git a/plugins/ndf/skills-codex/playwright-kit-ops/templates/scenario.config.yaml b/plugins/ndf-codex/skills/playwright-kit-ops/templates/scenario.config.yaml similarity index 100% rename from plugins/ndf/skills-codex/playwright-kit-ops/templates/scenario.config.yaml rename to plugins/ndf-codex/skills/playwright-kit-ops/templates/scenario.config.yaml diff --git a/plugins/ndf/skills-codex/playwright-kit-ops/templates/test_auth.py.template b/plugins/ndf-codex/skills/playwright-kit-ops/templates/test_auth.py.template similarity index 100% rename from plugins/ndf/skills-codex/playwright-kit-ops/templates/test_auth.py.template rename to plugins/ndf-codex/skills/playwright-kit-ops/templates/test_auth.py.template diff --git a/plugins/ndf/skills-codex/playwright-kit-ops/templates/test_dashboard.py.template b/plugins/ndf-codex/skills/playwright-kit-ops/templates/test_dashboard.py.template similarity index 100% rename from plugins/ndf/skills-codex/playwright-kit-ops/templates/test_dashboard.py.template rename to plugins/ndf-codex/skills/playwright-kit-ops/templates/test_dashboard.py.template diff --git a/plugins/ndf/skills-codex/playwright-kit-ops/templates/test_form.py.template b/plugins/ndf-codex/skills/playwright-kit-ops/templates/test_form.py.template similarity index 100% rename from plugins/ndf/skills-codex/playwright-kit-ops/templates/test_form.py.template rename to plugins/ndf-codex/skills/playwright-kit-ops/templates/test_form.py.template diff --git a/plugins/ndf/skills-codex/playwright-kit-ops/templates/test_list.py.template b/plugins/ndf-codex/skills/playwright-kit-ops/templates/test_list.py.template similarity index 100% rename from plugins/ndf/skills-codex/playwright-kit-ops/templates/test_list.py.template rename to plugins/ndf-codex/skills/playwright-kit-ops/templates/test_list.py.template diff --git a/plugins/ndf/skills/playwright-kit-ops/tests/__init__.py b/plugins/ndf-codex/skills/playwright-kit-ops/tests/__init__.py similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/tests/__init__.py rename to plugins/ndf-codex/skills/playwright-kit-ops/tests/__init__.py diff --git a/plugins/ndf/skills-codex/playwright-kit-ops/tests/conftest.py b/plugins/ndf-codex/skills/playwright-kit-ops/tests/conftest.py similarity index 100% rename from plugins/ndf/skills-codex/playwright-kit-ops/tests/conftest.py rename to plugins/ndf-codex/skills/playwright-kit-ops/tests/conftest.py diff --git a/plugins/ndf/skills-codex/playwright-kit-ops/tests/test_a11y_cwv_routing.py b/plugins/ndf-codex/skills/playwright-kit-ops/tests/test_a11y_cwv_routing.py similarity index 100% rename from plugins/ndf/skills-codex/playwright-kit-ops/tests/test_a11y_cwv_routing.py rename to plugins/ndf-codex/skills/playwright-kit-ops/tests/test_a11y_cwv_routing.py diff --git a/plugins/ndf/skills-codex/playwright-kit-ops/tests/test_auth_cache.py b/plugins/ndf-codex/skills/playwright-kit-ops/tests/test_auth_cache.py similarity index 100% rename from plugins/ndf/skills-codex/playwright-kit-ops/tests/test_auth_cache.py rename to plugins/ndf-codex/skills/playwright-kit-ops/tests/test_auth_cache.py diff --git a/plugins/ndf/skills-codex/playwright-kit-ops/tests/test_body_check.py b/plugins/ndf-codex/skills/playwright-kit-ops/tests/test_body_check.py similarity index 100% rename from plugins/ndf/skills-codex/playwright-kit-ops/tests/test_body_check.py rename to plugins/ndf-codex/skills/playwright-kit-ops/tests/test_body_check.py diff --git a/plugins/ndf/skills-codex/playwright-kit-ops/tests/test_config_basic_auth.py b/plugins/ndf-codex/skills/playwright-kit-ops/tests/test_config_basic_auth.py similarity index 100% rename from plugins/ndf/skills-codex/playwright-kit-ops/tests/test_config_basic_auth.py rename to plugins/ndf-codex/skills/playwright-kit-ops/tests/test_config_basic_auth.py diff --git a/plugins/ndf/skills-codex/playwright-kit-ops/tests/test_evidence_fixture.py b/plugins/ndf-codex/skills/playwright-kit-ops/tests/test_evidence_fixture.py similarity index 100% rename from plugins/ndf/skills-codex/playwright-kit-ops/tests/test_evidence_fixture.py rename to plugins/ndf-codex/skills/playwright-kit-ops/tests/test_evidence_fixture.py diff --git a/plugins/ndf/skills-codex/playwright-kit-ops/tests/test_makereport_user_properties.py b/plugins/ndf-codex/skills/playwright-kit-ops/tests/test_makereport_user_properties.py similarity index 100% rename from plugins/ndf/skills-codex/playwright-kit-ops/tests/test_makereport_user_properties.py rename to plugins/ndf-codex/skills/playwright-kit-ops/tests/test_makereport_user_properties.py diff --git a/plugins/ndf/skills-codex/playwright-kit-ops/tests/test_pytest_plugin_bootstrap.py b/plugins/ndf-codex/skills/playwright-kit-ops/tests/test_pytest_plugin_bootstrap.py similarity index 100% rename from plugins/ndf/skills-codex/playwright-kit-ops/tests/test_pytest_plugin_bootstrap.py rename to plugins/ndf-codex/skills/playwright-kit-ops/tests/test_pytest_plugin_bootstrap.py diff --git a/plugins/ndf/skills-codex/playwright-kit-ops/tests/test_pytest_report.py b/plugins/ndf-codex/skills/playwright-kit-ops/tests/test_pytest_report.py similarity index 100% rename from plugins/ndf/skills-codex/playwright-kit-ops/tests/test_pytest_report.py rename to plugins/ndf-codex/skills/playwright-kit-ops/tests/test_pytest_report.py diff --git a/plugins/ndf/skills-codex/playwright-kit-ops/tests/test_pytest_terminal_summary.py b/plugins/ndf-codex/skills/playwright-kit-ops/tests/test_pytest_terminal_summary.py similarity index 100% rename from plugins/ndf/skills-codex/playwright-kit-ops/tests/test_pytest_terminal_summary.py rename to plugins/ndf-codex/skills/playwright-kit-ops/tests/test_pytest_terminal_summary.py diff --git a/plugins/ndf/skills-codex/playwright-kit-ops/tests/test_pytester_integration.py b/plugins/ndf-codex/skills/playwright-kit-ops/tests/test_pytester_integration.py similarity index 100% rename from plugins/ndf/skills-codex/playwright-kit-ops/tests/test_pytester_integration.py rename to plugins/ndf-codex/skills/playwright-kit-ops/tests/test_pytester_integration.py diff --git a/plugins/ndf/skills-codex/playwright-kit-ops/tests/test_same_origin.py b/plugins/ndf-codex/skills/playwright-kit-ops/tests/test_same_origin.py similarity index 100% rename from plugins/ndf/skills-codex/playwright-kit-ops/tests/test_same_origin.py rename to plugins/ndf-codex/skills/playwright-kit-ops/tests/test_same_origin.py diff --git a/plugins/ndf/skills-codex/playwright-kit-ops/tests/test_upload_evidence.py b/plugins/ndf-codex/skills/playwright-kit-ops/tests/test_upload_evidence.py similarity index 100% rename from plugins/ndf/skills-codex/playwright-kit-ops/tests/test_upload_evidence.py rename to plugins/ndf-codex/skills/playwright-kit-ops/tests/test_upload_evidence.py diff --git a/plugins/ndf/skills-codex/playwright-kit-ops/tests/test_video_default.py b/plugins/ndf-codex/skills/playwright-kit-ops/tests/test_video_default.py similarity index 100% rename from plugins/ndf/skills-codex/playwright-kit-ops/tests/test_video_default.py rename to plugins/ndf-codex/skills/playwright-kit-ops/tests/test_video_default.py diff --git a/plugins/ndf/skills-codex/playwright-kit-ops/uv.lock b/plugins/ndf-codex/skills/playwright-kit-ops/uv.lock similarity index 100% rename from plugins/ndf/skills-codex/playwright-kit-ops/uv.lock rename to plugins/ndf-codex/skills/playwright-kit-ops/uv.lock diff --git a/plugins/ndf/skills-codex/playwright-report/SKILL.md b/plugins/ndf-codex/skills/playwright-report/SKILL.md similarity index 100% rename from plugins/ndf/skills-codex/playwright-report/SKILL.md rename to plugins/ndf-codex/skills/playwright-report/SKILL.md diff --git a/plugins/ndf/skills-codex/playwright-script-creation/SKILL.md b/plugins/ndf-codex/skills/playwright-script-creation/SKILL.md similarity index 100% rename from plugins/ndf/skills-codex/playwright-script-creation/SKILL.md rename to plugins/ndf-codex/skills/playwright-script-creation/SKILL.md diff --git a/plugins/ndf/skills-codex/playwright-test-planning/SKILL.md b/plugins/ndf-codex/skills/playwright-test-planning/SKILL.md similarity index 100% rename from plugins/ndf/skills-codex/playwright-test-planning/SKILL.md rename to plugins/ndf-codex/skills/playwright-test-planning/SKILL.md diff --git a/plugins/ndf/skills-codex/playwright-test-planning/docs/01-methodology.md b/plugins/ndf-codex/skills/playwright-test-planning/docs/01-methodology.md similarity index 100% rename from plugins/ndf/skills-codex/playwright-test-planning/docs/01-methodology.md rename to plugins/ndf-codex/skills/playwright-test-planning/docs/01-methodology.md diff --git a/plugins/ndf/skills-codex/playwright-test-planning/docs/02-page-roles.md b/plugins/ndf-codex/skills/playwright-test-planning/docs/02-page-roles.md similarity index 100% rename from plugins/ndf/skills-codex/playwright-test-planning/docs/02-page-roles.md rename to plugins/ndf-codex/skills/playwright-test-planning/docs/02-page-roles.md diff --git a/plugins/ndf/skills-codex/playwright-test-planning/docs/03-test-techniques.md b/plugins/ndf-codex/skills/playwright-test-planning/docs/03-test-techniques.md similarity index 100% rename from plugins/ndf/skills-codex/playwright-test-planning/docs/03-test-techniques.md rename to plugins/ndf-codex/skills/playwright-test-planning/docs/03-test-techniques.md diff --git a/plugins/ndf/skills-codex/playwright-test-planning/docs/04-playwright-mapping.md b/plugins/ndf-codex/skills/playwright-test-planning/docs/04-playwright-mapping.md similarity index 100% rename from plugins/ndf/skills-codex/playwright-test-planning/docs/04-playwright-mapping.md rename to plugins/ndf-codex/skills/playwright-test-planning/docs/04-playwright-mapping.md diff --git a/plugins/ndf/skills-codex/playwright-test-planning/docs/05-bug-report.md b/plugins/ndf-codex/skills/playwright-test-planning/docs/05-bug-report.md similarity index 100% rename from plugins/ndf/skills-codex/playwright-test-planning/docs/05-bug-report.md rename to plugins/ndf-codex/skills/playwright-test-planning/docs/05-bug-report.md diff --git a/plugins/ndf/skills/playwright-test-planning/docs/06-pytest-playwright.md b/plugins/ndf-codex/skills/playwright-test-planning/docs/06-pytest-playwright.md similarity index 99% rename from plugins/ndf/skills/playwright-test-planning/docs/06-pytest-playwright.md rename to plugins/ndf-codex/skills/playwright-test-planning/docs/06-pytest-playwright.md index 776c2a4..717d4be 100644 --- a/plugins/ndf/skills/playwright-test-planning/docs/06-pytest-playwright.md +++ b/plugins/ndf-codex/skills/playwright-test-planning/docs/06-pytest-playwright.md @@ -158,7 +158,7 @@ v0.5.0 から、利用者プロジェクトに `init_project.sh` で埋め込ん ```bash # 1) 初期化 (Skill ディレクトリ内で 1 度だけ) # Claude Code/Kiro: /skills/playwright-kit-ops -# Codex: /skills-codex/playwright-kit-ops +# Codex: /skills/playwright-kit-ops cd /playwright-kit-ops ./scripts/init_project.sh /path/to/your-app # → /path/to/your-app/scenario-test/ 一式が作成される diff --git a/plugins/ndf/skills-codex/playwright-test-planning/docs/README.md b/plugins/ndf-codex/skills/playwright-test-planning/docs/README.md similarity index 100% rename from plugins/ndf/skills-codex/playwright-test-planning/docs/README.md rename to plugins/ndf-codex/skills/playwright-test-planning/docs/README.md diff --git a/plugins/ndf/skills-codex/playwright-test-planning/docs/checklists/checklist-auth.md b/plugins/ndf-codex/skills/playwright-test-planning/docs/checklists/checklist-auth.md similarity index 100% rename from plugins/ndf/skills-codex/playwright-test-planning/docs/checklists/checklist-auth.md rename to plugins/ndf-codex/skills/playwright-test-planning/docs/checklists/checklist-auth.md diff --git a/plugins/ndf/skills-codex/playwright-test-planning/docs/checklists/checklist-cart-checkout.md b/plugins/ndf-codex/skills/playwright-test-planning/docs/checklists/checklist-cart-checkout.md similarity index 100% rename from plugins/ndf/skills-codex/playwright-test-planning/docs/checklists/checklist-cart-checkout.md rename to plugins/ndf-codex/skills/playwright-test-planning/docs/checklists/checklist-cart-checkout.md diff --git a/plugins/ndf/skills-codex/playwright-test-planning/docs/checklists/checklist-common.md b/plugins/ndf-codex/skills/playwright-test-planning/docs/checklists/checklist-common.md similarity index 100% rename from plugins/ndf/skills-codex/playwright-test-planning/docs/checklists/checklist-common.md rename to plugins/ndf-codex/skills/playwright-test-planning/docs/checklists/checklist-common.md diff --git a/plugins/ndf/skills-codex/playwright-test-planning/docs/checklists/checklist-dashboard.md b/plugins/ndf-codex/skills/playwright-test-planning/docs/checklists/checklist-dashboard.md similarity index 100% rename from plugins/ndf/skills-codex/playwright-test-planning/docs/checklists/checklist-dashboard.md rename to plugins/ndf-codex/skills/playwright-test-planning/docs/checklists/checklist-dashboard.md diff --git a/plugins/ndf/skills-codex/playwright-test-planning/docs/checklists/checklist-edit.md b/plugins/ndf-codex/skills/playwright-test-planning/docs/checklists/checklist-edit.md similarity index 100% rename from plugins/ndf/skills-codex/playwright-test-planning/docs/checklists/checklist-edit.md rename to plugins/ndf-codex/skills/playwright-test-planning/docs/checklists/checklist-edit.md diff --git a/plugins/ndf/skills-codex/playwright-test-planning/docs/checklists/checklist-form.md b/plugins/ndf-codex/skills/playwright-test-planning/docs/checklists/checklist-form.md similarity index 100% rename from plugins/ndf/skills-codex/playwright-test-planning/docs/checklists/checklist-form.md rename to plugins/ndf-codex/skills/playwright-test-planning/docs/checklists/checklist-form.md diff --git a/plugins/ndf/skills-codex/playwright-test-planning/docs/checklists/checklist-item.md b/plugins/ndf-codex/skills/playwright-test-planning/docs/checklists/checklist-item.md similarity index 100% rename from plugins/ndf/skills-codex/playwright-test-planning/docs/checklists/checklist-item.md rename to plugins/ndf-codex/skills/playwright-test-planning/docs/checklists/checklist-item.md diff --git a/plugins/ndf/skills-codex/playwright-test-planning/docs/checklists/checklist-list.md b/plugins/ndf-codex/skills/playwright-test-planning/docs/checklists/checklist-list.md similarity index 100% rename from plugins/ndf/skills-codex/playwright-test-planning/docs/checklists/checklist-list.md rename to plugins/ndf-codex/skills/playwright-test-planning/docs/checklists/checklist-list.md diff --git a/plugins/ndf/skills-codex/playwright-test-planning/docs/checklists/checklist-lp.md b/plugins/ndf-codex/skills/playwright-test-planning/docs/checklists/checklist-lp.md similarity index 100% rename from plugins/ndf/skills-codex/playwright-test-planning/docs/checklists/checklist-lp.md rename to plugins/ndf-codex/skills/playwright-test-planning/docs/checklists/checklist-lp.md diff --git a/plugins/ndf/skills-codex/playwright-test-planning/docs/checklists/checklist-modal-wizard.md b/plugins/ndf-codex/skills/playwright-test-planning/docs/checklists/checklist-modal-wizard.md similarity index 100% rename from plugins/ndf/skills-codex/playwright-test-planning/docs/checklists/checklist-modal-wizard.md rename to plugins/ndf-codex/skills/playwright-test-planning/docs/checklists/checklist-modal-wizard.md diff --git a/plugins/ndf/skills-codex/playwright-test-planning/docs/checklists/checklist-search.md b/plugins/ndf-codex/skills/playwright-test-planning/docs/checklists/checklist-search.md similarity index 100% rename from plugins/ndf/skills-codex/playwright-test-planning/docs/checklists/checklist-search.md rename to plugins/ndf-codex/skills/playwright-test-planning/docs/checklists/checklist-search.md diff --git a/plugins/ndf/skills/pr-tests/SKILL.md b/plugins/ndf-codex/skills/pr-tests/SKILL.md similarity index 100% rename from plugins/ndf/skills/pr-tests/SKILL.md rename to plugins/ndf-codex/skills/pr-tests/SKILL.md diff --git a/plugins/ndf/skills/pr/SKILL.md b/plugins/ndf-codex/skills/pr/SKILL.md similarity index 100% rename from plugins/ndf/skills/pr/SKILL.md rename to plugins/ndf-codex/skills/pr/SKILL.md diff --git a/plugins/ndf/skills/problem-solving/SKILL.md b/plugins/ndf-codex/skills/problem-solving/SKILL.md similarity index 100% rename from plugins/ndf/skills/problem-solving/SKILL.md rename to plugins/ndf-codex/skills/problem-solving/SKILL.md diff --git a/plugins/ndf/skills/python-execution/01-uv-setup.md b/plugins/ndf-codex/skills/python-execution/01-uv-setup.md similarity index 100% rename from plugins/ndf/skills/python-execution/01-uv-setup.md rename to plugins/ndf-codex/skills/python-execution/01-uv-setup.md diff --git a/plugins/ndf/skills/python-execution/02-troubleshooting.md b/plugins/ndf-codex/skills/python-execution/02-troubleshooting.md similarity index 100% rename from plugins/ndf/skills/python-execution/02-troubleshooting.md rename to plugins/ndf-codex/skills/python-execution/02-troubleshooting.md diff --git a/plugins/ndf/skills/python-execution/SKILL.md b/plugins/ndf-codex/skills/python-execution/SKILL.md similarity index 100% rename from plugins/ndf/skills/python-execution/SKILL.md rename to plugins/ndf-codex/skills/python-execution/SKILL.md diff --git a/plugins/ndf/skills/resolve-pr-comments/SKILL.md b/plugins/ndf-codex/skills/resolve-pr-comments/SKILL.md similarity index 100% rename from plugins/ndf/skills/resolve-pr-comments/SKILL.md rename to plugins/ndf-codex/skills/resolve-pr-comments/SKILL.md diff --git a/plugins/ndf/skills/review-branch/SKILL.md b/plugins/ndf-codex/skills/review-branch/SKILL.md similarity index 100% rename from plugins/ndf/skills/review-branch/SKILL.md rename to plugins/ndf-codex/skills/review-branch/SKILL.md diff --git a/plugins/ndf/skills-codex/review-pr-comments/SKILL.md b/plugins/ndf-codex/skills/review-pr-comments/SKILL.md similarity index 98% rename from plugins/ndf/skills-codex/review-pr-comments/SKILL.md rename to plugins/ndf-codex/skills/review-pr-comments/SKILL.md index 392dfe6..d275cb0 100644 --- a/plugins/ndf/skills-codex/review-pr-comments/SKILL.md +++ b/plugins/ndf-codex/skills/review-pr-comments/SKILL.md @@ -47,7 +47,7 @@ PR_NUMBER="${ARGUMENTS:-$(gh pr view --json number --jq .number)}" fix skill の共有スクリプトで インラインコメント / レビュー body / PR レベルコメントを一括取得: ```bash -FETCH_SCRIPT="${PLUGIN_ROOT:-${CODEX_PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT}}}/skills-codex/fix/scripts/fetch-pr-comments.sh" +FETCH_SCRIPT="${PLUGIN_ROOT:-${CODEX_PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT}}}/skills/fix/scripts/fetch-pr-comments.sh" "$FETCH_SCRIPT" "$(gh repo view --json nameWithOwner -q .nameWithOwner)" "$PR_NUMBER" ``` diff --git a/plugins/ndf/skills-codex/review/SKILL.md b/plugins/ndf-codex/skills/review/SKILL.md similarity index 98% rename from plugins/ndf/skills-codex/review/SKILL.md rename to plugins/ndf-codex/skills/review/SKILL.md index cfb920b..5dfeb4a 100644 --- a/plugins/ndf/skills-codex/review/SKILL.md +++ b/plugins/ndf-codex/skills/review/SKILL.md @@ -268,7 +268,7 @@ GitHub は **自分の PR には `REQUEST_CHANGES` で投稿できない**(`HT ### `codex` 指定時 -呼び出し手順の詳細は `/ndf:codex` skill(`plugins/ndf/skills/codex/SKILL.md`)に従う。要点: +呼び出し手順の詳細は、利用 runtime に `/ndf:codex` skill が同梱されている場合はその skill に従う。要点: - プロンプトを `/tmp/codex-review-pr<番号>-prompt.md` に書き出し - 出力先ファイルを `/tmp/codex-output-review-pr<番号>.md` として **プロンプト内で `apply_patch` 書き出しを必須化** @@ -283,7 +283,7 @@ GitHub は **自分の PR には `REQUEST_CHANGES` で投稿できない**(`HT ### `gemini` 指定時 -呼び出し手順の詳細は `/ndf:gemini` skill(`plugins/ndf/skills/gemini/SKILL.md`)に従う。要点: +呼び出し手順の詳細は、利用 runtime に `/ndf:gemini` skill が同梱されている場合はその skill に従う。要点: - プロンプトを `/tmp/gemini-review-pr<番号>-prompt.md` に書き出し - **AI 直接投稿フローでは `--yolo` 必須**(`gh api -X POST` がシェル実行のため、`plan` / `auto_edit` だとブロックされる) diff --git a/plugins/ndf/skills/sync-main/SKILL.md b/plugins/ndf-codex/skills/sync-main/SKILL.md similarity index 100% rename from plugins/ndf/skills/sync-main/SKILL.md rename to plugins/ndf-codex/skills/sync-main/SKILL.md diff --git a/plugins/ndf-kiro/README.md b/plugins/ndf-kiro/README.md new file mode 100644 index 0000000..89ba28b --- /dev/null +++ b/plugins/ndf-kiro/README.md @@ -0,0 +1,35 @@ +# NDF Kiro Plugin + +Kiro CLI 向けの NDF 配布物です。`plugins/ndf-shared` から生成された `skills/`、Kiro agent template、workflow prompt、通知用 script を同梱します。 + +## インストール + +リポジトリ root で実行します。 + +```bash +# 基本(Skills + agentSpawn hook) +bash plugins/ndf-kiro/install.sh + +# Slack通知も有効化 +bash plugins/ndf-kiro/install.sh --with-slack + +# Slack通知 + Kiro側 Codex MCP 設定も生成 +bash plugins/ndf-kiro/install.sh --with-slack --with-codex +``` + +動作確認だけを行う場合: + +```bash +bash plugins/ndf-kiro/install.sh --dry-run +``` + +installer は `.claude-plugin/plugin.json` を読みません。公開 Skill は build 済みの `plugins/ndf-kiro/skills/` を source として `.kiro/skills/` へ symlink します。 + +## 開発 + +Skill を変更する場合は `plugins/ndf-shared/skills/` を編集し、runtime plugin を再生成します。 + +```bash +bash scripts/build-runtime-plugins.sh +bash scripts/build-runtime-plugins.sh --check +``` diff --git a/.kiro/agents/default.json b/plugins/ndf-kiro/agents/default.json.template similarity index 66% rename from .kiro/agents/default.json rename to plugins/ndf-kiro/agents/default.json.template index c4e739d..4d83a2f 100644 --- a/.kiro/agents/default.json +++ b/plugins/ndf-kiro/agents/default.json.template @@ -12,21 +12,6 @@ { "command": "if [ -f \"${PWD}/CLAUDE.ndf.md\" ] || [ -f \"$HOME/.claude/CLAUDE.ndf.md\" ]; then echo \"[NDF] CLAUDE.ndf.md が検出されました。廃止済みです。cleanup を実行して削除してください。\"; fi" } - ], - "stop": [ - { - "command": "node plugins/ndf/scripts/slack-notify.js session_end", - "timeout_ms": 70000 - } ] - }, - "mcpServers": { - "codex": { - "command": "codex", - "args": [ - "mcp-server" - ], - "env": {} - } } -} \ No newline at end of file +} diff --git a/plugins/ndf-kiro/install.sh b/plugins/ndf-kiro/install.sh new file mode 100755 index 0000000..8c8a382 --- /dev/null +++ b/plugins/ndf-kiro/install.sh @@ -0,0 +1,184 @@ +#!/usr/bin/env bash +# NDF Plugin Installer for Kiro CLI +# Usage: bash plugins/ndf-kiro/install.sh [--project PATH] [--with-slack] [--with-codex] [--dry-run] +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_ROOT="$(pwd)" +PLUGIN_DIR="$SCRIPT_DIR" +KIRO_DIR="$PROJECT_ROOT/.kiro" +SKILLS_DIR="$KIRO_DIR/skills" +PROMPTS_DIR="$KIRO_DIR/prompts" +AGENT_FILE="$KIRO_DIR/agents/default.json" +TEMPLATE_FILE="$PLUGIN_DIR/agents/default.json.template" +PLUGIN_SKILLS_DIR="$PLUGIN_DIR/skills" +PLUGIN_PROMPTS_DIR="$PLUGIN_DIR/prompts" + +# Parse options +WITH_SLACK=false +WITH_CODEX=false +DRY_RUN=false +while [ "$#" -gt 0 ]; do + case "$1" in + --project) + [ "$#" -ge 2 ] || { echo "ERROR: --project requires a path" >&2; exit 2; } + PROJECT_ROOT="$(cd "$2" && pwd)" + KIRO_DIR="$PROJECT_ROOT/.kiro" + SKILLS_DIR="$KIRO_DIR/skills" + PROMPTS_DIR="$KIRO_DIR/prompts" + AGENT_FILE="$KIRO_DIR/agents/default.json" + shift + ;; + --with-slack) WITH_SLACK=true ;; + --with-codex) WITH_CODEX=true ;; + --dry-run) DRY_RUN=true ;; + --help|-h) + echo "Usage: bash plugins/ndf-kiro/install.sh [OPTIONS]" + echo "" + echo "Options:" + echo " --project PATH install into PATH instead of current directory" + echo " --with-slack stopフックにSlack通知を追加" + echo " --with-codex Codex CLI直接実行用プロンプトを追加" + echo " --dry-run 書き込みを行わず実行内容を表示" + echo " -h, --help このヘルプを表示" + exit 0 + ;; + *) + echo "ERROR: unknown option: $1" >&2 + exit 2 + ;; + esac + shift +done + +echo "=== NDF Plugin Installer for Kiro CLI ===" + +if [ ! -d "$PLUGIN_SKILLS_DIR" ]; then + echo "ERROR: $PLUGIN_SKILLS_DIR が見つかりません。先に scripts/build-runtime-plugins.sh を実行してください。" >&2 + exit 1 +fi +if [ ! -f "$TEMPLATE_FILE" ]; then + echo "ERROR: $TEMPLATE_FILE が見つかりません" >&2 + exit 1 +fi + +# --- Step 1: Create symlinks in .kiro/skills/ --- +echo "Skills シンボリックリンクを作成中..." +SKILL_COUNT=0 +if [ "$DRY_RUN" = false ]; then + mkdir -p "$SKILLS_DIR" + while IFS= read -r skill_link; do + target="$(readlink "$skill_link")" + case "$target" in + /*) target_abs="$target" ;; + *) target_abs="$(realpath -m "$(dirname "$skill_link")/$target")" ;; + esac + plugin_skills_abs="$(realpath -m "$PLUGIN_SKILLS_DIR")" + case "$target_abs" in + "$plugin_skills_abs"/*) rm -f "$skill_link" ;; + esac + done < <(find "$SKILLS_DIR" -mindepth 1 -maxdepth 1 -type l | sort) +fi + +while IFS= read -r src_dir; do + skill_name="$(basename "$src_dir")" + + if [ ! -f "$src_dir/SKILL.md" ]; then + echo " SKIP: $skill_name (SKILL.mdなし)" + continue + fi + + if [ "$DRY_RUN" = false ]; then + ln -sfn "$PLUGIN_SKILLS_DIR/$skill_name" "$SKILLS_DIR/$skill_name" + fi + echo " linked: $skill_name" + SKILL_COUNT=$((SKILL_COUNT + 1)) +done < <(find "$PLUGIN_SKILLS_DIR" -mindepth 1 -maxdepth 1 -type d | sort) + +# --- Step 2: Create prompts in .kiro/prompts/ for workflow skills --- +echo "ワークフロープロンプトを作成中..." +if [ "$DRY_RUN" = false ]; then + mkdir -p "$PROMPTS_DIR" + if [ "$WITH_CODEX" = false ]; then + rm -f "$PROMPTS_DIR/codex.md" + fi +fi + +while IFS= read -r prompt_file; do + prompt_name="$(basename "$prompt_file")" + [ "$prompt_name" = "codex.md" ] && [ "$WITH_CODEX" = false ] && continue + if [ "$DRY_RUN" = false ]; then + cp "$prompt_file" "$PROMPTS_DIR/$prompt_name" + fi + echo " prompt: ${prompt_name%.md}" +done < <(find "$PLUGIN_PROMPTS_DIR" -maxdepth 1 -type f -name '*.md' | sort) + +if [ "$WITH_CODEX" = true ] && [ ! -f "$PLUGIN_PROMPTS_DIR/codex.md" ]; then + echo "ERROR: $PLUGIN_PROMPTS_DIR/codex.md が見つかりません" >&2 + exit 1 +fi + +if [ "$WITH_SLACK" = true ]; then echo "Slack通知: 有効"; else echo "Slack通知: 無効 (--with-slack で有効化)"; fi +if [ "$WITH_CODEX" = true ]; then echo "Codex CLI連携: 有効"; else echo "Codex CLI連携: 無効 (--with-codex で有効化)"; fi + +if [ "$DRY_RUN" = true ]; then + echo "" + echo "DRY RUN: 書き込みは行いませんでした" + echo " エージェント設定: $AGENT_FILE" + echo " Skills数: $SKILL_COUNT" + exit 0 +fi + +# --- Step 3: Generate agent config --- +mkdir -p "$KIRO_DIR/agents" + +if [ -f "$AGENT_FILE" ]; then + cp "$AGENT_FILE" "${AGENT_FILE}.bak" + echo "既存設定をバックアップ: ${AGENT_FILE}.bak" +fi + +python3 - "$TEMPLATE_FILE" "$WITH_SLACK" "$WITH_CODEX" "$AGENT_FILE" "$SCRIPT_DIR" <<'PY' +import json +import shlex +import sys +from pathlib import Path + +template_file, with_slack, with_codex, agent_file, script_dir = sys.argv[1:6] +with open(template_file, encoding="utf-8") as f: + config = json.load(f) + +hooks = config.setdefault("hooks", {}) +if with_slack == "true": + slack_script = Path(script_dir) / "scripts" / "slack-notify.js" + hooks["stop"] = [ + { + "command": f"node {shlex.quote(str(slack_script))} session_end", + "timeout_ms": 70000, + } + ] +else: + hooks.pop("stop", None) + +if with_codex == "true": + config["mcpServers"] = { + "codex": { + "command": "codex", + "args": ["mcp-server"], + "env": {}, + } + } +else: + config.pop("mcpServers", None) + +with open(agent_file, "w", encoding="utf-8") as f: + json.dump(config, f, indent=2, ensure_ascii=False) + f.write("\n") +PY + +echo "" +echo "=== インストール完了 ===" +echo " エージェント設定: $AGENT_FILE" +echo " Skills数: $SKILL_COUNT (シンボリックリンク: .kiro/skills/)" +echo "" +echo "Kiro CLIを起動して動作確認してください:" +echo " kiro-cli chat" diff --git a/.kiro/prompts/clean.md b/plugins/ndf-kiro/prompts/clean.md similarity index 100% rename from .kiro/prompts/clean.md rename to plugins/ndf-kiro/prompts/clean.md diff --git a/plugins/ndf-kiro/prompts/codex.md b/plugins/ndf-kiro/prompts/codex.md new file mode 100644 index 0000000..ada0330 --- /dev/null +++ b/plugins/ndf-kiro/prompts/codex.md @@ -0,0 +1,3 @@ +Codex CLIにコード生成・レビュー・調査を委譲してください。 + +codexスキルの手順に従って実行してください。引数があればそのまま使用します。 diff --git a/.kiro/prompts/fix.md b/plugins/ndf-kiro/prompts/fix.md similarity index 100% rename from .kiro/prompts/fix.md rename to plugins/ndf-kiro/prompts/fix.md diff --git a/.kiro/prompts/merged.md b/plugins/ndf-kiro/prompts/merged.md similarity index 100% rename from .kiro/prompts/merged.md rename to plugins/ndf-kiro/prompts/merged.md diff --git a/.kiro/prompts/pr-tests.md b/plugins/ndf-kiro/prompts/pr-tests.md similarity index 100% rename from .kiro/prompts/pr-tests.md rename to plugins/ndf-kiro/prompts/pr-tests.md diff --git a/.kiro/prompts/pr.md b/plugins/ndf-kiro/prompts/pr.md similarity index 100% rename from .kiro/prompts/pr.md rename to plugins/ndf-kiro/prompts/pr.md diff --git a/.kiro/prompts/review.md b/plugins/ndf-kiro/prompts/review.md similarity index 100% rename from .kiro/prompts/review.md rename to plugins/ndf-kiro/prompts/review.md diff --git a/plugins/ndf-kiro/scripts/codex-slack-notify.js b/plugins/ndf-kiro/scripts/codex-slack-notify.js new file mode 100755 index 0000000..af01f50 --- /dev/null +++ b/plugins/ndf-kiro/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-kiro/scripts/ensure-retention.sh b/plugins/ndf-kiro/scripts/ensure-retention.sh new file mode 100755 index 0000000..e511363 --- /dev/null +++ b/plugins/ndf-kiro/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-kiro/scripts/install-official-skills.sh b/plugins/ndf-kiro/scripts/install-official-skills.sh new file mode 100755 index 0000000..bba6467 --- /dev/null +++ b/plugins/ndf-kiro/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-kiro/scripts/slack-notify.js b/plugins/ndf-kiro/scripts/slack-notify.js new file mode 100755 index 0000000..38b9991 --- /dev/null +++ b/plugins/ndf-kiro/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-kiro/scripts/statusline-switch.sh b/plugins/ndf-kiro/scripts/statusline-switch.sh new file mode 100755 index 0000000..bccb616 --- /dev/null +++ b/plugins/ndf-kiro/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-kiro/scripts/statusline.sh b/plugins/ndf-kiro/scripts/statusline.sh new file mode 100755 index 0000000..19d7c09 --- /dev/null +++ b/plugins/ndf-kiro/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-kiro/skills/branch-fix-strategy/SKILL.md b/plugins/ndf-kiro/skills/branch-fix-strategy/SKILL.md new file mode 100644 index 0000000..a4714a3 --- /dev/null +++ b/plugins/ndf-kiro/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-kiro/skills/browser-test/SKILL.md b/plugins/ndf-kiro/skills/browser-test/SKILL.md new file mode 100644 index 0000000..97eb0c8 --- /dev/null +++ b/plugins/ndf-kiro/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-kiro/skills/cherry-pick-pr/SKILL.md b/plugins/ndf-kiro/skills/cherry-pick-pr/SKILL.md new file mode 100644 index 0000000..b5dc134 --- /dev/null +++ b/plugins/ndf-kiro/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-kiro/skills/clean/SKILL.md b/plugins/ndf-kiro/skills/clean/SKILL.md new file mode 100644 index 0000000..2f75e54 --- /dev/null +++ b/plugins/ndf-kiro/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-kiro/skills/codex/SKILL.md b/plugins/ndf-kiro/skills/codex/SKILL.md new file mode 100644 index 0000000..80060a8 --- /dev/null +++ b/plugins/ndf-kiro/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-kiro/skills/cross-review/SKILL.md b/plugins/ndf-kiro/skills/cross-review/SKILL.md new file mode 100644 index 0000000..709c281 --- /dev/null +++ b/plugins/ndf-kiro/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:-plugins/ndf-kiro}" +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-kiro/skills/cross-review/docs/01-state-and-review.md b/plugins/ndf-kiro/skills/cross-review/docs/01-state-and-review.md new file mode 100644 index 0000000..8b5c1fa --- /dev/null +++ b/plugins/ndf-kiro/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:-plugins/ndf-kiro}" +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-kiro/skills/cross-review/docs/02-fix-and-rotation.md b/plugins/ndf-kiro/skills/cross-review/docs/02-fix-and-rotation.md new file mode 100644 index 0000000..aaaecc3 --- /dev/null +++ b/plugins/ndf-kiro/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-kiro/skills/cross-review/scripts/_tmpdir.sh b/plugins/ndf-kiro/skills/cross-review/scripts/_tmpdir.sh new file mode 100755 index 0000000..8df7371 --- /dev/null +++ b/plugins/ndf-kiro/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-kiro/skills/cross-review/scripts/launch-codex.sh b/plugins/ndf-kiro/skills/cross-review/scripts/launch-codex.sh new file mode 100755 index 0000000..2054366 --- /dev/null +++ b/plugins/ndf-kiro/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-kiro/skills/cross-review/scripts/launch-gemini.sh b/plugins/ndf-kiro/skills/cross-review/scripts/launch-gemini.sh new file mode 100755 index 0000000..e0b921e --- /dev/null +++ b/plugins/ndf-kiro/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-kiro/skills/cross-review/scripts/monitor.py b/plugins/ndf-kiro/skills/cross-review/scripts/monitor.py new file mode 100755 index 0000000..544a912 --- /dev/null +++ b/plugins/ndf-kiro/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-kiro/skills/cross-review/scripts/rotate-pr.sh b/plugins/ndf-kiro/skills/cross-review/scripts/rotate-pr.sh new file mode 100755 index 0000000..9b063f4 --- /dev/null +++ b/plugins/ndf-kiro/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-kiro/skills/cross-review/scripts/state.py b/plugins/ndf-kiro/skills/cross-review/scripts/state.py new file mode 100755 index 0000000..9e91f11 --- /dev/null +++ b/plugins/ndf-kiro/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-kiro/skills/cross-review/scripts/wait-review.sh b/plugins/ndf-kiro/skills/cross-review/scripts/wait-review.sh new file mode 100755 index 0000000..8741d36 --- /dev/null +++ b/plugins/ndf-kiro/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/skills/statusline/tests/__init__.py b/plugins/ndf-kiro/skills/cross-review/tests/__init__.py similarity index 100% rename from plugins/ndf/skills/statusline/tests/__init__.py rename to plugins/ndf-kiro/skills/cross-review/tests/__init__.py diff --git a/plugins/ndf-kiro/skills/cross-review/tests/conftest.py b/plugins/ndf-kiro/skills/cross-review/tests/conftest.py new file mode 100644 index 0000000..a5b257b --- /dev/null +++ b/plugins/ndf-kiro/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-kiro/skills/cross-review/tests/test_default_worktree_base.py b/plugins/ndf-kiro/skills/cross-review/tests/test_default_worktree_base.py new file mode 100644 index 0000000..b4d38da --- /dev/null +++ b/plugins/ndf-kiro/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-kiro/skills/cross-review/tests/test_monitor_early_error.py b/plugins/ndf-kiro/skills/cross-review/tests/test_monitor_early_error.py new file mode 100644 index 0000000..d669c2d --- /dev/null +++ b/plugins/ndf-kiro/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-shared/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-kiro/skills/cross-review/tests/test_monitor_import_safety.py b/plugins/ndf-kiro/skills/cross-review/tests/test_monitor_import_safety.py new file mode 100644 index 0000000..36efc48 --- /dev/null +++ b/plugins/ndf-kiro/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-kiro/skills/cross-review/tests/test_monitor_progress_log.py b/plugins/ndf-kiro/skills/cross-review/tests/test_monitor_progress_log.py new file mode 100644 index 0000000..7b98015 --- /dev/null +++ b/plugins/ndf-kiro/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-kiro/skills/cross-review/tests/test_monitor_result_age.py b/plugins/ndf-kiro/skills/cross-review/tests/test_monitor_result_age.py new file mode 100644 index 0000000..f3e2a28 --- /dev/null +++ b/plugins/ndf-kiro/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-kiro/skills/cross-review/tests/test_monitor_stall_default.py b/plugins/ndf-kiro/skills/cross-review/tests/test_monitor_stall_default.py new file mode 100644 index 0000000..77a4f6b --- /dev/null +++ b/plugins/ndf-kiro/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-kiro/skills/cross-review/tests/test_monitor_zombie.py b/plugins/ndf-kiro/skills/cross-review/tests/test_monitor_zombie.py new file mode 100644 index 0000000..c15d2f4 --- /dev/null +++ b/plugins/ndf-kiro/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-kiro/skills/cross-review/tests/test_state_auto_review_templates.py b/plugins/ndf-kiro/skills/cross-review/tests/test_state_auto_review_templates.py new file mode 100644 index 0000000..c0dbd9e --- /dev/null +++ b/plugins/ndf-kiro/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-kiro/skills/cross-review/tests/test_state_check_oscillation.py b/plugins/ndf-kiro/skills/cross-review/tests/test_state_check_oscillation.py new file mode 100644 index 0000000..f00d189 --- /dev/null +++ b/plugins/ndf-kiro/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-kiro/skills/cross-review/tests/test_state_extra_review_instructions.py b/plugins/ndf-kiro/skills/cross-review/tests/test_state_extra_review_instructions.py new file mode 100644 index 0000000..b145b1c --- /dev/null +++ b/plugins/ndf-kiro/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-kiro/skills/cross-review/tests/test_state_merge_fix.py b/plugins/ndf-kiro/skills/cross-review/tests/test_state_merge_fix.py new file mode 100644 index 0000000..63c31ba --- /dev/null +++ b/plugins/ndf-kiro/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-kiro/skills/cross-review/tests/test_state_read_result.py b/plugins/ndf-kiro/skills/cross-review/tests/test_state_read_result.py new file mode 100644 index 0000000..a243fc2 --- /dev/null +++ b/plugins/ndf-kiro/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-kiro/skills/deploy/SKILL.md b/plugins/ndf-kiro/skills/deploy/SKILL.md new file mode 100644 index 0000000..769919d --- /dev/null +++ b/plugins/ndf-kiro/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-kiro/skills/docker-container-access/01-environment-detection.md b/plugins/ndf-kiro/skills/docker-container-access/01-environment-detection.md new file mode 100644 index 0000000..51941bc --- /dev/null +++ b/plugins/ndf-kiro/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-kiro/skills/docker-container-access/02-dood-access.md b/plugins/ndf-kiro/skills/docker-container-access/02-dood-access.md new file mode 100644 index 0000000..d19fc96 --- /dev/null +++ b/plugins/ndf-kiro/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-kiro/skills/docker-container-access/03-troubleshooting.md b/plugins/ndf-kiro/skills/docker-container-access/03-troubleshooting.md new file mode 100644 index 0000000..0acac32 --- /dev/null +++ b/plugins/ndf-kiro/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-kiro/skills/docker-container-access/SKILL.md b/plugins/ndf-kiro/skills/docker-container-access/SKILL.md new file mode 100644 index 0000000..eadd1ff --- /dev/null +++ b/plugins/ndf-kiro/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-kiro/skills/fix/SKILL.md b/plugins/ndf-kiro/skills/fix/SKILL.md new file mode 100644 index 0000000..4e1e6c5 --- /dev/null +++ b/plugins/ndf-kiro/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:-plugins/ndf-kiro}/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-kiro/skills/fix/scripts/fetch-pr-comments.sh b/plugins/ndf-kiro/skills/fix/scripts/fetch-pr-comments.sh new file mode 100755 index 0000000..aa7f97f --- /dev/null +++ b/plugins/ndf-kiro/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-kiro/skills/git-gh-operations/01-common-errors.md b/plugins/ndf-kiro/skills/git-gh-operations/01-common-errors.md new file mode 100644 index 0000000..a7afa44 --- /dev/null +++ b/plugins/ndf-kiro/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-kiro/skills/git-gh-operations/SKILL.md b/plugins/ndf-kiro/skills/git-gh-operations/SKILL.md new file mode 100644 index 0000000..68b8a7a --- /dev/null +++ b/plugins/ndf-kiro/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-kiro/skills/implementation-plan/SKILL.md b/plugins/ndf-kiro/skills/implementation-plan/SKILL.md new file mode 100644 index 0000000..0e0a130 --- /dev/null +++ b/plugins/ndf-kiro/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-kiro/skills/investigation-rules/SKILL.md b/plugins/ndf-kiro/skills/investigation-rules/SKILL.md new file mode 100644 index 0000000..a4757a8 --- /dev/null +++ b/plugins/ndf-kiro/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-kiro/skills/issue-plan-strategy/SKILL.md b/plugins/ndf-kiro/skills/issue-plan-strategy/SKILL.md new file mode 100644 index 0000000..4275a37 --- /dev/null +++ b/plugins/ndf-kiro/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-kiro/skills/logging-guidelines/SKILL.md b/plugins/ndf-kiro/skills/logging-guidelines/SKILL.md new file mode 100644 index 0000000..007b969 --- /dev/null +++ b/plugins/ndf-kiro/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-kiro/skills/markdown-writing/01-diagram-guide.md b/plugins/ndf-kiro/skills/markdown-writing/01-diagram-guide.md new file mode 100644 index 0000000..db40f52 --- /dev/null +++ b/plugins/ndf-kiro/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-kiro/skills/markdown-writing/SKILL.md b/plugins/ndf-kiro/skills/markdown-writing/SKILL.md new file mode 100644 index 0000000..56b3583 --- /dev/null +++ b/plugins/ndf-kiro/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-kiro/skills/merged/SKILL.md b/plugins/ndf-kiro/skills/merged/SKILL.md new file mode 100644 index 0000000..06af6f2 --- /dev/null +++ b/plugins/ndf-kiro/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-kiro/skills/ndf-policies/SKILL.md b/plugins/ndf-kiro/skills/ndf-policies/SKILL.md new file mode 100644 index 0000000..eb25c33 --- /dev/null +++ b/plugins/ndf-kiro/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-kiro/skills/plan-to-spec/SKILL.md b/plugins/ndf-kiro/skills/plan-to-spec/SKILL.md new file mode 100644 index 0000000..eaea6f9 --- /dev/null +++ b/plugins/ndf-kiro/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-kiro/skills/pr-tests/SKILL.md b/plugins/ndf-kiro/skills/pr-tests/SKILL.md new file mode 100644 index 0000000..39f13de --- /dev/null +++ b/plugins/ndf-kiro/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-kiro/skills/pr/SKILL.md b/plugins/ndf-kiro/skills/pr/SKILL.md new file mode 100644 index 0000000..22000e4 --- /dev/null +++ b/plugins/ndf-kiro/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-kiro/skills/problem-solving/SKILL.md b/plugins/ndf-kiro/skills/problem-solving/SKILL.md new file mode 100644 index 0000000..1b3c110 --- /dev/null +++ b/plugins/ndf-kiro/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-kiro/skills/python-execution/01-uv-setup.md b/plugins/ndf-kiro/skills/python-execution/01-uv-setup.md new file mode 100644 index 0000000..aafd4f1 --- /dev/null +++ b/plugins/ndf-kiro/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-kiro/skills/python-execution/02-troubleshooting.md b/plugins/ndf-kiro/skills/python-execution/02-troubleshooting.md new file mode 100644 index 0000000..3475b7e --- /dev/null +++ b/plugins/ndf-kiro/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-kiro/skills/python-execution/SKILL.md b/plugins/ndf-kiro/skills/python-execution/SKILL.md new file mode 100644 index 0000000..a07705a --- /dev/null +++ b/plugins/ndf-kiro/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-kiro/skills/resolve-pr-comments/SKILL.md b/plugins/ndf-kiro/skills/resolve-pr-comments/SKILL.md new file mode 100644 index 0000000..433a72d --- /dev/null +++ b/plugins/ndf-kiro/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-kiro/skills/review-branch/SKILL.md b/plugins/ndf-kiro/skills/review-branch/SKILL.md new file mode 100644 index 0000000..951e5ea --- /dev/null +++ b/plugins/ndf-kiro/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-kiro/skills/review-pr-comments/SKILL.md b/plugins/ndf-kiro/skills/review-pr-comments/SKILL.md new file mode 100644 index 0000000..96924dc --- /dev/null +++ b/plugins/ndf-kiro/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:-plugins/ndf-kiro}/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-kiro/skills/review/SKILL.md b/plugins/ndf-kiro/skills/review/SKILL.md new file mode 100644 index 0000000..5dfeb4a --- /dev/null +++ b/plugins/ndf-kiro/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` 指定時 + +呼び出し手順の詳細は、利用 runtime に `/ndf:codex` skill が同梱されている場合はその skill に従う。要点: + +- プロンプトを `/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` 指定時 + +呼び出し手順の詳細は、利用 runtime に `/ndf:gemini` skill が同梱されている場合はその skill に従う。要点: + +- プロンプトを `/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-kiro/skills/statusline/SKILL.md b/plugins/ndf-kiro/skills/statusline/SKILL.md new file mode 100644 index 0000000..c9e5ef5 --- /dev/null +++ b/plugins/ndf-kiro/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:-plugins/ndf-kiro}" + +# 状態確認 (引数なし 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-kiro/skills/statusline/tests/__init__.py b/plugins/ndf-kiro/skills/statusline/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/ndf-kiro/skills/statusline/tests/test_statusline_switch.py b/plugins/ndf-kiro/skills/statusline/tests/test_statusline_switch.py new file mode 100644 index 0000000..3fe6648 --- /dev/null +++ b/plugins/ndf-kiro/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-shared/skills/statusline/tests/ -> plugins/ndf-shared/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-kiro/skills/sync-main/SKILL.md b/plugins/ndf-kiro/skills/sync-main/SKILL.md new file mode 100644 index 0000000..87aa223 --- /dev/null +++ b/plugins/ndf-kiro/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/README.md b/plugins/ndf-shared/README.md new file mode 100644 index 0000000..3401d84 --- /dev/null +++ b/plugins/ndf-shared/README.md @@ -0,0 +1,27 @@ +# NDF Shared Source + +`plugins/ndf-shared` is the editable source for NDF runtime plugins. + +Runtime plugin directories are generated artifacts. Edit shared skills and +scripts here first, then run: + +```bash +bash scripts/build-runtime-plugins.sh +``` + +Check generated files without modifying the working tree: + +```bash +bash scripts/build-runtime-plugins.sh --check +``` + +## Layout + +- `skills/` - shared Skill implementations. +- `scripts/` - shared helper scripts copied into runtime plugins. +- `manifests/claude-skills.txt` - Claude Code published Skill set. +- `manifests/codex-skills.txt` - Codex published Skill set. +- `manifests/kiro-skills.txt` - Kiro published Skill set. + +Generated runtime files must be committed with shared source changes so users +can install plugins without running the build script. diff --git a/plugins/ndf-shared/manifests/claude-skills.txt b/plugins/ndf-shared/manifests/claude-skills.txt new file mode 100644 index 0000000..bb74879 --- /dev/null +++ b/plugins/ndf-shared/manifests/claude-skills.txt @@ -0,0 +1,29 @@ +pr +pr-tests +fix +review +cross-review +merged +clean +ndf-policies +markdown-writing +python-execution +docker-container-access +git-gh-operations +branch-fix-strategy +implementation-plan +investigation-rules +problem-solving +logging-guidelines +sync-main +cherry-pick-pr +deploy +review-branch +review-pr-comments +resolve-pr-comments +browser-test +codex +gemini +statusline +issue-plan-strategy +plan-to-spec diff --git a/plugins/ndf-shared/manifests/codex-skills.txt b/plugins/ndf-shared/manifests/codex-skills.txt new file mode 100644 index 0000000..403632f --- /dev/null +++ b/plugins/ndf-shared/manifests/codex-skills.txt @@ -0,0 +1,30 @@ +branch-fix-strategy +cherry-pick-pr +clean +cross-review +deploy +docker-container-access +fix +git-gh-operations +implementation-plan +investigation-rules +issue-plan-strategy +logging-guidelines +markdown-writing +merged +ndf-policies +plan-to-spec +playwright-execution +playwright-kit-ops +playwright-report +playwright-script-creation +playwright-test-planning +pr +pr-tests +problem-solving +python-execution +resolve-pr-comments +review +review-branch +review-pr-comments +sync-main diff --git a/plugins/ndf-shared/manifests/kiro-skills.txt b/plugins/ndf-shared/manifests/kiro-skills.txt new file mode 100644 index 0000000..b58dc6b --- /dev/null +++ b/plugins/ndf-shared/manifests/kiro-skills.txt @@ -0,0 +1,28 @@ +pr +pr-tests +fix +review +cross-review +merged +clean +ndf-policies +markdown-writing +python-execution +docker-container-access +git-gh-operations +branch-fix-strategy +implementation-plan +investigation-rules +problem-solving +logging-guidelines +sync-main +cherry-pick-pr +deploy +review-branch +review-pr-comments +resolve-pr-comments +browser-test +codex +statusline +issue-plan-strategy +plan-to-spec diff --git a/plugins/ndf-shared/scripts/codex-slack-notify.js b/plugins/ndf-shared/scripts/codex-slack-notify.js new file mode 100755 index 0000000..af01f50 --- /dev/null +++ b/plugins/ndf-shared/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-shared/scripts/ensure-retention.sh b/plugins/ndf-shared/scripts/ensure-retention.sh new file mode 100755 index 0000000..e511363 --- /dev/null +++ b/plugins/ndf-shared/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-shared/scripts/install-official-skills.sh b/plugins/ndf-shared/scripts/install-official-skills.sh new file mode 100755 index 0000000..bba6467 --- /dev/null +++ b/plugins/ndf-shared/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-shared/scripts/slack-notify.js b/plugins/ndf-shared/scripts/slack-notify.js new file mode 100755 index 0000000..38b9991 --- /dev/null +++ b/plugins/ndf-shared/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-shared/scripts/statusline-switch.sh b/plugins/ndf-shared/scripts/statusline-switch.sh new file mode 100755 index 0000000..bccb616 --- /dev/null +++ b/plugins/ndf-shared/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-shared/scripts/statusline.sh b/plugins/ndf-shared/scripts/statusline.sh new file mode 100755 index 0000000..19d7c09 --- /dev/null +++ b/plugins/ndf-shared/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-shared/skills/branch-fix-strategy/SKILL.md b/plugins/ndf-shared/skills/branch-fix-strategy/SKILL.md new file mode 100644 index 0000000..a4714a3 --- /dev/null +++ b/plugins/ndf-shared/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-shared/skills/browser-test/SKILL.md b/plugins/ndf-shared/skills/browser-test/SKILL.md new file mode 100644 index 0000000..97eb0c8 --- /dev/null +++ b/plugins/ndf-shared/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-shared/skills/cherry-pick-pr/SKILL.md b/plugins/ndf-shared/skills/cherry-pick-pr/SKILL.md new file mode 100644 index 0000000..b5dc134 --- /dev/null +++ b/plugins/ndf-shared/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-shared/skills/clean/SKILL.md b/plugins/ndf-shared/skills/clean/SKILL.md new file mode 100644 index 0000000..2f75e54 --- /dev/null +++ b/plugins/ndf-shared/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-shared/skills/codex/SKILL.md b/plugins/ndf-shared/skills/codex/SKILL.md new file mode 100644 index 0000000..80060a8 --- /dev/null +++ b/plugins/ndf-shared/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/skills/cross-review/SKILL.md b/plugins/ndf-shared/skills/cross-review/SKILL.md similarity index 100% rename from plugins/ndf/skills/cross-review/SKILL.md rename to plugins/ndf-shared/skills/cross-review/SKILL.md diff --git a/plugins/ndf/skills/cross-review/docs/01-state-and-review.md b/plugins/ndf-shared/skills/cross-review/docs/01-state-and-review.md similarity index 100% rename from plugins/ndf/skills/cross-review/docs/01-state-and-review.md rename to plugins/ndf-shared/skills/cross-review/docs/01-state-and-review.md diff --git a/plugins/ndf-shared/skills/cross-review/docs/02-fix-and-rotation.md b/plugins/ndf-shared/skills/cross-review/docs/02-fix-and-rotation.md new file mode 100644 index 0000000..aaaecc3 --- /dev/null +++ b/plugins/ndf-shared/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-shared/skills/cross-review/scripts/_tmpdir.sh b/plugins/ndf-shared/skills/cross-review/scripts/_tmpdir.sh new file mode 100755 index 0000000..8df7371 --- /dev/null +++ b/plugins/ndf-shared/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-shared/skills/cross-review/scripts/launch-codex.sh b/plugins/ndf-shared/skills/cross-review/scripts/launch-codex.sh new file mode 100755 index 0000000..2054366 --- /dev/null +++ b/plugins/ndf-shared/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-shared/skills/cross-review/scripts/launch-gemini.sh b/plugins/ndf-shared/skills/cross-review/scripts/launch-gemini.sh new file mode 100755 index 0000000..e0b921e --- /dev/null +++ b/plugins/ndf-shared/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-shared/skills/cross-review/scripts/monitor.py b/plugins/ndf-shared/skills/cross-review/scripts/monitor.py new file mode 100755 index 0000000..544a912 --- /dev/null +++ b/plugins/ndf-shared/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-shared/skills/cross-review/scripts/rotate-pr.sh b/plugins/ndf-shared/skills/cross-review/scripts/rotate-pr.sh new file mode 100755 index 0000000..9b063f4 --- /dev/null +++ b/plugins/ndf-shared/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-shared/skills/cross-review/scripts/state.py b/plugins/ndf-shared/skills/cross-review/scripts/state.py new file mode 100755 index 0000000..9e91f11 --- /dev/null +++ b/plugins/ndf-shared/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-shared/skills/cross-review/scripts/wait-review.sh b/plugins/ndf-shared/skills/cross-review/scripts/wait-review.sh new file mode 100755 index 0000000..8741d36 --- /dev/null +++ b/plugins/ndf-shared/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-shared/skills/cross-review/tests/__init__.py b/plugins/ndf-shared/skills/cross-review/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/ndf-shared/skills/cross-review/tests/conftest.py b/plugins/ndf-shared/skills/cross-review/tests/conftest.py new file mode 100644 index 0000000..a5b257b --- /dev/null +++ b/plugins/ndf-shared/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-shared/skills/cross-review/tests/test_default_worktree_base.py b/plugins/ndf-shared/skills/cross-review/tests/test_default_worktree_base.py new file mode 100644 index 0000000..b4d38da --- /dev/null +++ b/plugins/ndf-shared/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-shared/skills/cross-review/tests/test_monitor_early_error.py b/plugins/ndf-shared/skills/cross-review/tests/test_monitor_early_error.py new file mode 100644 index 0000000..d669c2d --- /dev/null +++ b/plugins/ndf-shared/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-shared/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-shared/skills/cross-review/tests/test_monitor_import_safety.py b/plugins/ndf-shared/skills/cross-review/tests/test_monitor_import_safety.py new file mode 100644 index 0000000..36efc48 --- /dev/null +++ b/plugins/ndf-shared/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-shared/skills/cross-review/tests/test_monitor_progress_log.py b/plugins/ndf-shared/skills/cross-review/tests/test_monitor_progress_log.py new file mode 100644 index 0000000..7b98015 --- /dev/null +++ b/plugins/ndf-shared/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-shared/skills/cross-review/tests/test_monitor_result_age.py b/plugins/ndf-shared/skills/cross-review/tests/test_monitor_result_age.py new file mode 100644 index 0000000..f3e2a28 --- /dev/null +++ b/plugins/ndf-shared/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-shared/skills/cross-review/tests/test_monitor_stall_default.py b/plugins/ndf-shared/skills/cross-review/tests/test_monitor_stall_default.py new file mode 100644 index 0000000..77a4f6b --- /dev/null +++ b/plugins/ndf-shared/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-shared/skills/cross-review/tests/test_monitor_zombie.py b/plugins/ndf-shared/skills/cross-review/tests/test_monitor_zombie.py new file mode 100644 index 0000000..c15d2f4 --- /dev/null +++ b/plugins/ndf-shared/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-shared/skills/cross-review/tests/test_state_auto_review_templates.py b/plugins/ndf-shared/skills/cross-review/tests/test_state_auto_review_templates.py new file mode 100644 index 0000000..c0dbd9e --- /dev/null +++ b/plugins/ndf-shared/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-shared/skills/cross-review/tests/test_state_check_oscillation.py b/plugins/ndf-shared/skills/cross-review/tests/test_state_check_oscillation.py new file mode 100644 index 0000000..f00d189 --- /dev/null +++ b/plugins/ndf-shared/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-shared/skills/cross-review/tests/test_state_extra_review_instructions.py b/plugins/ndf-shared/skills/cross-review/tests/test_state_extra_review_instructions.py new file mode 100644 index 0000000..b145b1c --- /dev/null +++ b/plugins/ndf-shared/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-shared/skills/cross-review/tests/test_state_merge_fix.py b/plugins/ndf-shared/skills/cross-review/tests/test_state_merge_fix.py new file mode 100644 index 0000000..63c31ba --- /dev/null +++ b/plugins/ndf-shared/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-shared/skills/cross-review/tests/test_state_read_result.py b/plugins/ndf-shared/skills/cross-review/tests/test_state_read_result.py new file mode 100644 index 0000000..a243fc2 --- /dev/null +++ b/plugins/ndf-shared/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/skills/data-analyst-export/01-formats.md b/plugins/ndf-shared/skills/data-analyst-export/01-formats.md similarity index 100% rename from plugins/ndf/skills/data-analyst-export/01-formats.md rename to plugins/ndf-shared/skills/data-analyst-export/01-formats.md diff --git a/plugins/ndf/skills/data-analyst-export/02-examples.md b/plugins/ndf-shared/skills/data-analyst-export/02-examples.md similarity index 100% rename from plugins/ndf/skills/data-analyst-export/02-examples.md rename to plugins/ndf-shared/skills/data-analyst-export/02-examples.md diff --git a/plugins/ndf/skills/data-analyst-export/SKILL.md b/plugins/ndf-shared/skills/data-analyst-export/SKILL.md similarity index 100% rename from plugins/ndf/skills/data-analyst-export/SKILL.md rename to plugins/ndf-shared/skills/data-analyst-export/SKILL.md diff --git a/plugins/ndf/skills/data-analyst-export/scripts/export-csv.js b/plugins/ndf-shared/skills/data-analyst-export/scripts/export-csv.js similarity index 100% rename from plugins/ndf/skills/data-analyst-export/scripts/export-csv.js rename to plugins/ndf-shared/skills/data-analyst-export/scripts/export-csv.js diff --git a/plugins/ndf/skills/data-analyst-sql-optimization/01-patterns.md b/plugins/ndf-shared/skills/data-analyst-sql-optimization/01-patterns.md similarity index 100% rename from plugins/ndf/skills/data-analyst-sql-optimization/01-patterns.md rename to plugins/ndf-shared/skills/data-analyst-sql-optimization/01-patterns.md diff --git a/plugins/ndf/skills/data-analyst-sql-optimization/02-examples.md b/plugins/ndf-shared/skills/data-analyst-sql-optimization/02-examples.md similarity index 100% rename from plugins/ndf/skills/data-analyst-sql-optimization/02-examples.md rename to plugins/ndf-shared/skills/data-analyst-sql-optimization/02-examples.md diff --git a/plugins/ndf/skills/data-analyst-sql-optimization/SKILL.md b/plugins/ndf-shared/skills/data-analyst-sql-optimization/SKILL.md similarity index 100% rename from plugins/ndf/skills/data-analyst-sql-optimization/SKILL.md rename to plugins/ndf-shared/skills/data-analyst-sql-optimization/SKILL.md diff --git a/plugins/ndf/skills/deepwiki-transfer/SKILL.md b/plugins/ndf-shared/skills/deepwiki-transfer/SKILL.md similarity index 100% rename from plugins/ndf/skills/deepwiki-transfer/SKILL.md rename to plugins/ndf-shared/skills/deepwiki-transfer/SKILL.md diff --git a/plugins/ndf/skills/deepwiki-transfer/scripts/fetch_wiki.py b/plugins/ndf-shared/skills/deepwiki-transfer/scripts/fetch_wiki.py similarity index 100% rename from plugins/ndf/skills/deepwiki-transfer/scripts/fetch_wiki.py rename to plugins/ndf-shared/skills/deepwiki-transfer/scripts/fetch_wiki.py diff --git a/plugins/ndf/skills/deepwiki-transfer/scripts/pyproject.toml b/plugins/ndf-shared/skills/deepwiki-transfer/scripts/pyproject.toml similarity index 100% rename from plugins/ndf/skills/deepwiki-transfer/scripts/pyproject.toml rename to plugins/ndf-shared/skills/deepwiki-transfer/scripts/pyproject.toml diff --git a/plugins/ndf/skills/deepwiki-transfer/scripts/split_pages.py b/plugins/ndf-shared/skills/deepwiki-transfer/scripts/split_pages.py similarity index 100% rename from plugins/ndf/skills/deepwiki-transfer/scripts/split_pages.py rename to plugins/ndf-shared/skills/deepwiki-transfer/scripts/split_pages.py diff --git a/plugins/ndf/skills/deepwiki-transfer/scripts/validate_gfm.py b/plugins/ndf-shared/skills/deepwiki-transfer/scripts/validate_gfm.py similarity index 100% rename from plugins/ndf/skills/deepwiki-transfer/scripts/validate_gfm.py rename to plugins/ndf-shared/skills/deepwiki-transfer/scripts/validate_gfm.py diff --git a/plugins/ndf-shared/skills/deploy/SKILL.md b/plugins/ndf-shared/skills/deploy/SKILL.md new file mode 100644 index 0000000..769919d --- /dev/null +++ b/plugins/ndf-shared/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-shared/skills/docker-container-access/01-environment-detection.md b/plugins/ndf-shared/skills/docker-container-access/01-environment-detection.md new file mode 100644 index 0000000..51941bc --- /dev/null +++ b/plugins/ndf-shared/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-shared/skills/docker-container-access/02-dood-access.md b/plugins/ndf-shared/skills/docker-container-access/02-dood-access.md new file mode 100644 index 0000000..d19fc96 --- /dev/null +++ b/plugins/ndf-shared/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-shared/skills/docker-container-access/03-troubleshooting.md b/plugins/ndf-shared/skills/docker-container-access/03-troubleshooting.md new file mode 100644 index 0000000..0acac32 --- /dev/null +++ b/plugins/ndf-shared/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-shared/skills/docker-container-access/SKILL.md b/plugins/ndf-shared/skills/docker-container-access/SKILL.md new file mode 100644 index 0000000..eadd1ff --- /dev/null +++ b/plugins/ndf-shared/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-shared/skills/fix/SKILL.md b/plugins/ndf-shared/skills/fix/SKILL.md new file mode 100644 index 0000000..92471dd --- /dev/null +++ b/plugins/ndf-shared/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-shared/skills/fix/scripts/fetch-pr-comments.sh b/plugins/ndf-shared/skills/fix/scripts/fetch-pr-comments.sh new file mode 100755 index 0000000..aa7f97f --- /dev/null +++ b/plugins/ndf-shared/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-shared/skills/gemini/SKILL.md b/plugins/ndf-shared/skills/gemini/SKILL.md new file mode 100644 index 0000000..e2156ed --- /dev/null +++ b/plugins/ndf-shared/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-shared/skills/git-gh-operations/01-common-errors.md b/plugins/ndf-shared/skills/git-gh-operations/01-common-errors.md new file mode 100644 index 0000000..a7afa44 --- /dev/null +++ b/plugins/ndf-shared/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-shared/skills/git-gh-operations/SKILL.md b/plugins/ndf-shared/skills/git-gh-operations/SKILL.md new file mode 100644 index 0000000..68b8a7a --- /dev/null +++ b/plugins/ndf-shared/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/skills/google-auth/SKILL.md b/plugins/ndf-shared/skills/google-auth/SKILL.md similarity index 100% rename from plugins/ndf/skills/google-auth/SKILL.md rename to plugins/ndf-shared/skills/google-auth/SKILL.md diff --git a/plugins/ndf/skills/google-auth/pyproject.toml b/plugins/ndf-shared/skills/google-auth/pyproject.toml similarity index 100% rename from plugins/ndf/skills/google-auth/pyproject.toml rename to plugins/ndf-shared/skills/google-auth/pyproject.toml diff --git a/plugins/ndf/skills/google-auth/scripts/google_auth.py b/plugins/ndf-shared/skills/google-auth/scripts/google_auth.py similarity index 100% rename from plugins/ndf/skills/google-auth/scripts/google_auth.py rename to plugins/ndf-shared/skills/google-auth/scripts/google_auth.py diff --git a/plugins/ndf/skills/google-auth/uv.lock b/plugins/ndf-shared/skills/google-auth/uv.lock similarity index 100% rename from plugins/ndf/skills/google-auth/uv.lock rename to plugins/ndf-shared/skills/google-auth/uv.lock diff --git a/plugins/ndf/skills/google-chat/SKILL.md b/plugins/ndf-shared/skills/google-chat/SKILL.md similarity index 100% rename from plugins/ndf/skills/google-chat/SKILL.md rename to plugins/ndf-shared/skills/google-chat/SKILL.md diff --git a/plugins/ndf/skills/google-chat/pyproject.toml b/plugins/ndf-shared/skills/google-chat/pyproject.toml similarity index 100% rename from plugins/ndf/skills/google-chat/pyproject.toml rename to plugins/ndf-shared/skills/google-chat/pyproject.toml diff --git a/plugins/ndf/skills/google-chat/scripts/gchat_read.py b/plugins/ndf-shared/skills/google-chat/scripts/gchat_read.py similarity index 100% rename from plugins/ndf/skills/google-chat/scripts/gchat_read.py rename to plugins/ndf-shared/skills/google-chat/scripts/gchat_read.py diff --git a/plugins/ndf/skills/google-chat/uv.lock b/plugins/ndf-shared/skills/google-chat/uv.lock similarity index 100% rename from plugins/ndf/skills/google-chat/uv.lock rename to plugins/ndf-shared/skills/google-chat/uv.lock diff --git a/plugins/ndf/skills/google-drive/SKILL.md b/plugins/ndf-shared/skills/google-drive/SKILL.md similarity index 100% rename from plugins/ndf/skills/google-drive/SKILL.md rename to plugins/ndf-shared/skills/google-drive/SKILL.md diff --git a/plugins/ndf/skills/google-drive/pyproject.toml b/plugins/ndf-shared/skills/google-drive/pyproject.toml similarity index 100% rename from plugins/ndf/skills/google-drive/pyproject.toml rename to plugins/ndf-shared/skills/google-drive/pyproject.toml diff --git a/plugins/ndf/skills/google-drive/scripts/gdrive_fetch.py b/plugins/ndf-shared/skills/google-drive/scripts/gdrive_fetch.py similarity index 100% rename from plugins/ndf/skills/google-drive/scripts/gdrive_fetch.py rename to plugins/ndf-shared/skills/google-drive/scripts/gdrive_fetch.py diff --git a/plugins/ndf/skills/google-drive/uv.lock b/plugins/ndf-shared/skills/google-drive/uv.lock similarity index 100% rename from plugins/ndf/skills/google-drive/uv.lock rename to plugins/ndf-shared/skills/google-drive/uv.lock diff --git a/plugins/ndf-shared/skills/implementation-plan/SKILL.md b/plugins/ndf-shared/skills/implementation-plan/SKILL.md new file mode 100644 index 0000000..0e0a130 --- /dev/null +++ b/plugins/ndf-shared/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-shared/skills/investigation-rules/SKILL.md b/plugins/ndf-shared/skills/investigation-rules/SKILL.md new file mode 100644 index 0000000..a4757a8 --- /dev/null +++ b/plugins/ndf-shared/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-shared/skills/issue-plan-strategy/SKILL.md b/plugins/ndf-shared/skills/issue-plan-strategy/SKILL.md new file mode 100644 index 0000000..4275a37 --- /dev/null +++ b/plugins/ndf-shared/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/skills/knowledge-reorg/SKILL.md b/plugins/ndf-shared/skills/knowledge-reorg/SKILL.md similarity index 100% rename from plugins/ndf/skills/knowledge-reorg/SKILL.md rename to plugins/ndf-shared/skills/knowledge-reorg/SKILL.md diff --git a/plugins/ndf-shared/skills/logging-guidelines/SKILL.md b/plugins/ndf-shared/skills/logging-guidelines/SKILL.md new file mode 100644 index 0000000..007b969 --- /dev/null +++ b/plugins/ndf-shared/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-shared/skills/markdown-writing/01-diagram-guide.md b/plugins/ndf-shared/skills/markdown-writing/01-diagram-guide.md new file mode 100644 index 0000000..db40f52 --- /dev/null +++ b/plugins/ndf-shared/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-shared/skills/markdown-writing/SKILL.md b/plugins/ndf-shared/skills/markdown-writing/SKILL.md new file mode 100644 index 0000000..56b3583 --- /dev/null +++ b/plugins/ndf-shared/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/skills/mcp-builder/LICENSE.txt b/plugins/ndf-shared/skills/mcp-builder/LICENSE.txt similarity index 100% rename from plugins/ndf/skills/mcp-builder/LICENSE.txt rename to plugins/ndf-shared/skills/mcp-builder/LICENSE.txt diff --git a/plugins/ndf/skills/mcp-builder/SKILL.md b/plugins/ndf-shared/skills/mcp-builder/SKILL.md similarity index 100% rename from plugins/ndf/skills/mcp-builder/SKILL.md rename to plugins/ndf-shared/skills/mcp-builder/SKILL.md diff --git a/plugins/ndf/skills/mcp-builder/reference/evaluation.md b/plugins/ndf-shared/skills/mcp-builder/reference/evaluation.md similarity index 100% rename from plugins/ndf/skills/mcp-builder/reference/evaluation.md rename to plugins/ndf-shared/skills/mcp-builder/reference/evaluation.md diff --git a/plugins/ndf/skills/mcp-builder/reference/mcp_best_practices.md b/plugins/ndf-shared/skills/mcp-builder/reference/mcp_best_practices.md similarity index 100% rename from plugins/ndf/skills/mcp-builder/reference/mcp_best_practices.md rename to plugins/ndf-shared/skills/mcp-builder/reference/mcp_best_practices.md diff --git a/plugins/ndf/skills/mcp-builder/reference/node_mcp_server.md b/plugins/ndf-shared/skills/mcp-builder/reference/node_mcp_server.md similarity index 100% rename from plugins/ndf/skills/mcp-builder/reference/node_mcp_server.md rename to plugins/ndf-shared/skills/mcp-builder/reference/node_mcp_server.md diff --git a/plugins/ndf/skills/mcp-builder/reference/python_mcp_server.md b/plugins/ndf-shared/skills/mcp-builder/reference/python_mcp_server.md similarity index 100% rename from plugins/ndf/skills/mcp-builder/reference/python_mcp_server.md rename to plugins/ndf-shared/skills/mcp-builder/reference/python_mcp_server.md diff --git a/plugins/ndf/skills/mcp-builder/scripts/connections.py b/plugins/ndf-shared/skills/mcp-builder/scripts/connections.py similarity index 100% rename from plugins/ndf/skills/mcp-builder/scripts/connections.py rename to plugins/ndf-shared/skills/mcp-builder/scripts/connections.py diff --git a/plugins/ndf/skills/mcp-builder/scripts/evaluation.py b/plugins/ndf-shared/skills/mcp-builder/scripts/evaluation.py similarity index 100% rename from plugins/ndf/skills/mcp-builder/scripts/evaluation.py rename to plugins/ndf-shared/skills/mcp-builder/scripts/evaluation.py diff --git a/plugins/ndf/skills/mcp-builder/scripts/example_evaluation.xml b/plugins/ndf-shared/skills/mcp-builder/scripts/example_evaluation.xml similarity index 100% rename from plugins/ndf/skills/mcp-builder/scripts/example_evaluation.xml rename to plugins/ndf-shared/skills/mcp-builder/scripts/example_evaluation.xml diff --git a/plugins/ndf/skills/mcp-builder/scripts/requirements.txt b/plugins/ndf-shared/skills/mcp-builder/scripts/requirements.txt similarity index 100% rename from plugins/ndf/skills/mcp-builder/scripts/requirements.txt rename to plugins/ndf-shared/skills/mcp-builder/scripts/requirements.txt diff --git a/plugins/ndf-shared/skills/merged/SKILL.md b/plugins/ndf-shared/skills/merged/SKILL.md new file mode 100644 index 0000000..06af6f2 --- /dev/null +++ b/plugins/ndf-shared/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/skills/ml-model-structure/SKILL.md b/plugins/ndf-shared/skills/ml-model-structure/SKILL.md similarity index 100% rename from plugins/ndf/skills/ml-model-structure/SKILL.md rename to plugins/ndf-shared/skills/ml-model-structure/SKILL.md diff --git a/plugins/ndf/skills/ml-model-structure/references/inference-and-contract.md b/plugins/ndf-shared/skills/ml-model-structure/references/inference-and-contract.md similarity index 100% rename from plugins/ndf/skills/ml-model-structure/references/inference-and-contract.md rename to plugins/ndf-shared/skills/ml-model-structure/references/inference-and-contract.md diff --git a/plugins/ndf-shared/skills/ndf-policies/SKILL.md b/plugins/ndf-shared/skills/ndf-policies/SKILL.md new file mode 100644 index 0000000..eb25c33 --- /dev/null +++ b/plugins/ndf-shared/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/skills/official-skills-autoloader/SKILL.md b/plugins/ndf-shared/skills/official-skills-autoloader/SKILL.md similarity index 92% rename from plugins/ndf/skills/official-skills-autoloader/SKILL.md rename to plugins/ndf-shared/skills/official-skills-autoloader/SKILL.md index a184bbe..40a852e 100644 --- a/plugins/ndf/skills/official-skills-autoloader/SKILL.md +++ b/plugins/ndf-shared/skills/official-skills-autoloader/SKILL.md @@ -93,7 +93,7 @@ Read(file_path="$HOME/.claude/skills//SKILL.md") - cache: `~/.cache/anthropic-skills/` (XDG準拠) - リンク先: `~/.claude/skills//` (ユーザー領域) -- プロジェクト単位で配置したい場合は `plugins/ndf/scripts/install-official-skills.sh --scope project ` を直接実行 +- プロジェクト単位で配置したい場合は `plugins/ndf-shared/scripts/install-official-skills.sh --scope project ` を直接実行 ### 再読込 @@ -101,9 +101,9 @@ Read(file_path="$HOME/.claude/skills//SKILL.md") ### 手動管理したい場合 -- 一覧表示: `bash plugins/ndf/scripts/install-official-skills.sh --list` -- 更新: `bash plugins/ndf/scripts/install-official-skills.sh --update` -- 明示的なインストール: `bash plugins/ndf/scripts/install-official-skills.sh ` +- 一覧表示: `bash plugins/ndf-shared/scripts/install-official-skills.sh --list` +- 更新: `bash plugins/ndf-shared/scripts/install-official-skills.sh --update` +- 明示的なインストール: `bash plugins/ndf-shared/scripts/install-official-skills.sh ` ## エラーハンドリング diff --git a/plugins/ndf-shared/skills/plan-to-spec/SKILL.md b/plugins/ndf-shared/skills/plan-to-spec/SKILL.md new file mode 100644 index 0000000..eaea6f9 --- /dev/null +++ b/plugins/ndf-shared/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/skills/playwright-browser-connect/SKILL.md b/plugins/ndf-shared/skills/playwright-browser-connect/SKILL.md similarity index 100% rename from plugins/ndf/skills/playwright-browser-connect/SKILL.md rename to plugins/ndf-shared/skills/playwright-browser-connect/SKILL.md diff --git a/plugins/ndf/skills/playwright-browser-connect/scripts/start-host-chrome.sh b/plugins/ndf-shared/skills/playwright-browser-connect/scripts/start-host-chrome.sh similarity index 100% rename from plugins/ndf/skills/playwright-browser-connect/scripts/start-host-chrome.sh rename to plugins/ndf-shared/skills/playwright-browser-connect/scripts/start-host-chrome.sh diff --git a/plugins/ndf/skills/playwright-evidence-drive/SKILL.md b/plugins/ndf-shared/skills/playwright-evidence-drive/SKILL.md similarity index 100% rename from plugins/ndf/skills/playwright-evidence-drive/SKILL.md rename to plugins/ndf-shared/skills/playwright-evidence-drive/SKILL.md diff --git a/plugins/ndf/skills/playwright-execution/SKILL.md b/plugins/ndf-shared/skills/playwright-execution/SKILL.md similarity index 100% rename from plugins/ndf/skills/playwright-execution/SKILL.md rename to plugins/ndf-shared/skills/playwright-execution/SKILL.md diff --git a/plugins/ndf/skills/playwright-kit-ops/.gitignore b/plugins/ndf-shared/skills/playwright-kit-ops/.gitignore similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/.gitignore rename to plugins/ndf-shared/skills/playwright-kit-ops/.gitignore diff --git a/plugins/ndf/skills-codex/playwright-kit-ops/SKILL.md b/plugins/ndf-shared/skills/playwright-kit-ops/SKILL.md similarity index 98% rename from plugins/ndf/skills-codex/playwright-kit-ops/SKILL.md rename to plugins/ndf-shared/skills/playwright-kit-ops/SKILL.md index 991e993..327c1b0 100644 --- a/plugins/ndf/skills-codex/playwright-kit-ops/SKILL.md +++ b/plugins/ndf-shared/skills/playwright-kit-ops/SKILL.md @@ -64,7 +64,7 @@ skill を同梱しないため、Drive 系コマンドや `--pwk-drive-folder` `GOOGLE_AUTH_SCRIPTS` を `google-auth/scripts` の実パスへ設定する。 ```bash -export GOOGLE_AUTH_SCRIPTS=/path/to/plugins/ndf/skills/google-auth/scripts +export GOOGLE_AUTH_SCRIPTS=/path/to/plugins/ndf-shared/skills/google-auth/scripts cd scenario-test uv sync --extra drive ``` diff --git a/plugins/ndf/skills/playwright-kit-ops/playwright_kit/__init__.py b/plugins/ndf-shared/skills/playwright-kit-ops/playwright_kit/__init__.py similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/playwright_kit/__init__.py rename to plugins/ndf-shared/skills/playwright-kit-ops/playwright_kit/__init__.py diff --git a/plugins/ndf/skills/playwright-kit-ops/playwright_kit/accessibility.py b/plugins/ndf-shared/skills/playwright-kit-ops/playwright_kit/accessibility.py similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/playwright_kit/accessibility.py rename to plugins/ndf-shared/skills/playwright-kit-ops/playwright_kit/accessibility.py diff --git a/plugins/ndf/skills/playwright-kit-ops/playwright_kit/body_check.py b/plugins/ndf-shared/skills/playwright-kit-ops/playwright_kit/body_check.py similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/playwright_kit/body_check.py rename to plugins/ndf-shared/skills/playwright-kit-ops/playwright_kit/body_check.py diff --git a/plugins/ndf/skills/playwright-kit-ops/playwright_kit/config.py b/plugins/ndf-shared/skills/playwright-kit-ops/playwright_kit/config.py similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/playwright_kit/config.py rename to plugins/ndf-shared/skills/playwright-kit-ops/playwright_kit/config.py diff --git a/plugins/ndf/skills/playwright-kit-ops/playwright_kit/fixtures/__init__.py b/plugins/ndf-shared/skills/playwright-kit-ops/playwright_kit/fixtures/__init__.py similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/playwright_kit/fixtures/__init__.py rename to plugins/ndf-shared/skills/playwright-kit-ops/playwright_kit/fixtures/__init__.py diff --git a/plugins/ndf/skills/playwright-kit-ops/playwright_kit/fixtures/accessibility.py b/plugins/ndf-shared/skills/playwright-kit-ops/playwright_kit/fixtures/accessibility.py similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/playwright_kit/fixtures/accessibility.py rename to plugins/ndf-shared/skills/playwright-kit-ops/playwright_kit/fixtures/accessibility.py diff --git a/plugins/ndf/skills/playwright-kit-ops/playwright_kit/fixtures/auth.py b/plugins/ndf-shared/skills/playwright-kit-ops/playwright_kit/fixtures/auth.py similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/playwright_kit/fixtures/auth.py rename to plugins/ndf-shared/skills/playwright-kit-ops/playwright_kit/fixtures/auth.py diff --git a/plugins/ndf/skills/playwright-kit-ops/playwright_kit/fixtures/body_check.py b/plugins/ndf-shared/skills/playwright-kit-ops/playwright_kit/fixtures/body_check.py similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/playwright_kit/fixtures/body_check.py rename to plugins/ndf-shared/skills/playwright-kit-ops/playwright_kit/fixtures/body_check.py diff --git a/plugins/ndf/skills/playwright-kit-ops/playwright_kit/fixtures/evidence.py b/plugins/ndf-shared/skills/playwright-kit-ops/playwright_kit/fixtures/evidence.py similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/playwright_kit/fixtures/evidence.py rename to plugins/ndf-shared/skills/playwright-kit-ops/playwright_kit/fixtures/evidence.py diff --git a/plugins/ndf/skills/playwright-kit-ops/playwright_kit/fixtures/web_vitals.py b/plugins/ndf-shared/skills/playwright-kit-ops/playwright_kit/fixtures/web_vitals.py similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/playwright_kit/fixtures/web_vitals.py rename to plugins/ndf-shared/skills/playwright-kit-ops/playwright_kit/fixtures/web_vitals.py diff --git a/plugins/ndf/skills/playwright-kit-ops/playwright_kit/overlay.py b/plugins/ndf-shared/skills/playwright-kit-ops/playwright_kit/overlay.py similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/playwright_kit/overlay.py rename to plugins/ndf-shared/skills/playwright-kit-ops/playwright_kit/overlay.py diff --git a/plugins/ndf/skills/playwright-kit-ops/playwright_kit/pytest_plugin.py b/plugins/ndf-shared/skills/playwright-kit-ops/playwright_kit/pytest_plugin.py similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/playwright_kit/pytest_plugin.py rename to plugins/ndf-shared/skills/playwright-kit-ops/playwright_kit/pytest_plugin.py diff --git a/plugins/ndf/skills/playwright-kit-ops/playwright_kit/pytest_report.py b/plugins/ndf-shared/skills/playwright-kit-ops/playwright_kit/pytest_report.py similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/playwright_kit/pytest_report.py rename to plugins/ndf-shared/skills/playwright-kit-ops/playwright_kit/pytest_report.py diff --git a/plugins/ndf/skills-codex/playwright-kit-ops/playwright_kit/uploaders/__init__.py b/plugins/ndf-shared/skills/playwright-kit-ops/playwright_kit/uploaders/__init__.py similarity index 98% rename from plugins/ndf/skills-codex/playwright-kit-ops/playwright_kit/uploaders/__init__.py rename to plugins/ndf-shared/skills/playwright-kit-ops/playwright_kit/uploaders/__init__.py index 283d2f0..3d8f8e9 100644 --- a/plugins/ndf/skills-codex/playwright-kit-ops/playwright_kit/uploaders/__init__.py +++ b/plugins/ndf-shared/skills/playwright-kit-ops/playwright_kit/uploaders/__init__.py @@ -45,7 +45,7 @@ def _ensure_google_auth_on_path() -> None: "Google Drive 連携には optional skill `google-auth` が必要です。\n" "Codex 公開セットには同梱していないため、Drive 系コマンドを使う前に " "`GOOGLE_AUTH_SCRIPTS` を google-auth/scripts へ設定してください。\n" - "例: export GOOGLE_AUTH_SCRIPTS=/path/to/plugins/ndf/skills/google-auth/scripts\n" + "例: export GOOGLE_AUTH_SCRIPTS=/path/to/plugins/ndf-shared/skills/google-auth/scripts\n" "検索した候補:\n - " f"{searched}" ) diff --git a/plugins/ndf/skills/playwright-kit-ops/playwright_kit/video.py b/plugins/ndf-shared/skills/playwright-kit-ops/playwright_kit/video.py similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/playwright_kit/video.py rename to plugins/ndf-shared/skills/playwright-kit-ops/playwright_kit/video.py diff --git a/plugins/ndf/skills/playwright-kit-ops/playwright_kit/web_vitals.py b/plugins/ndf-shared/skills/playwright-kit-ops/playwright_kit/web_vitals.py similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/playwright_kit/web_vitals.py rename to plugins/ndf-shared/skills/playwright-kit-ops/playwright_kit/web_vitals.py diff --git a/plugins/ndf/skills/playwright-kit-ops/pyproject.toml b/plugins/ndf-shared/skills/playwright-kit-ops/pyproject.toml similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/pyproject.toml rename to plugins/ndf-shared/skills/playwright-kit-ops/pyproject.toml diff --git a/plugins/ndf/skills-codex/playwright-kit-ops/scripts/_drive_auth.py b/plugins/ndf-shared/skills/playwright-kit-ops/scripts/_drive_auth.py similarity index 95% rename from plugins/ndf/skills-codex/playwright-kit-ops/scripts/_drive_auth.py rename to plugins/ndf-shared/skills/playwright-kit-ops/scripts/_drive_auth.py index 647ab52..ddc873c 100644 --- a/plugins/ndf/skills-codex/playwright-kit-ops/scripts/_drive_auth.py +++ b/plugins/ndf-shared/skills/playwright-kit-ops/scripts/_drive_auth.py @@ -43,7 +43,7 @@ def _ensure_google_auth_on_path() -> None: "Google Drive 連携には optional skill `google-auth` が必要です。\n" "Codex 公開セットには同梱していないため、Drive 系コマンドを使う前に " "`GOOGLE_AUTH_SCRIPTS` を google-auth/scripts へ設定してください。\n" - "例: export GOOGLE_AUTH_SCRIPTS=/path/to/plugins/ndf/skills/google-auth/scripts\n" + "例: export GOOGLE_AUTH_SCRIPTS=/path/to/plugins/ndf-shared/skills/google-auth/scripts\n" "検索した候補:\n - " f"{searched}" ) diff --git a/plugins/ndf/skills/playwright-kit-ops/scripts/build_gdoc_with_drive_links.py b/plugins/ndf-shared/skills/playwright-kit-ops/scripts/build_gdoc_with_drive_links.py similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/scripts/build_gdoc_with_drive_links.py rename to plugins/ndf-shared/skills/playwright-kit-ops/scripts/build_gdoc_with_drive_links.py diff --git a/plugins/ndf/skills/playwright-kit-ops/scripts/check_cwv.py b/plugins/ndf-shared/skills/playwright-kit-ops/scripts/check_cwv.py similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/scripts/check_cwv.py rename to plugins/ndf-shared/skills/playwright-kit-ops/scripts/check_cwv.py diff --git a/plugins/ndf/skills/playwright-kit-ops/scripts/classify_page_role.py b/plugins/ndf-shared/skills/playwright-kit-ops/scripts/classify_page_role.py similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/scripts/classify_page_role.py rename to plugins/ndf-shared/skills/playwright-kit-ops/scripts/classify_page_role.py diff --git a/plugins/ndf/skills/playwright-kit-ops/scripts/gdrive_upload_dir.py b/plugins/ndf-shared/skills/playwright-kit-ops/scripts/gdrive_upload_dir.py similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/scripts/gdrive_upload_dir.py rename to plugins/ndf-shared/skills/playwright-kit-ops/scripts/gdrive_upload_dir.py diff --git a/plugins/ndf/skills/playwright-kit-ops/scripts/init_project.bat b/plugins/ndf-shared/skills/playwright-kit-ops/scripts/init_project.bat similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/scripts/init_project.bat rename to plugins/ndf-shared/skills/playwright-kit-ops/scripts/init_project.bat diff --git a/plugins/ndf/skills/playwright-kit-ops/scripts/init_project.sh b/plugins/ndf-shared/skills/playwright-kit-ops/scripts/init_project.sh similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/scripts/init_project.sh rename to plugins/ndf-shared/skills/playwright-kit-ops/scripts/init_project.sh diff --git a/plugins/ndf/skills/playwright-kit-ops/scripts/record_scenario.py b/plugins/ndf-shared/skills/playwright-kit-ops/scripts/record_scenario.py similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/scripts/record_scenario.py rename to plugins/ndf-shared/skills/playwright-kit-ops/scripts/record_scenario.py diff --git a/plugins/ndf/skills/playwright-kit-ops/scripts/run_a11y_scan.py b/plugins/ndf-shared/skills/playwright-kit-ops/scripts/run_a11y_scan.py similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/scripts/run_a11y_scan.py rename to plugins/ndf-shared/skills/playwright-kit-ops/scripts/run_a11y_scan.py diff --git a/plugins/ndf/skills/playwright-kit-ops/scripts/upload_evidence.py b/plugins/ndf-shared/skills/playwright-kit-ops/scripts/upload_evidence.py similarity index 98% rename from plugins/ndf/skills/playwright-kit-ops/scripts/upload_evidence.py rename to plugins/ndf-shared/skills/playwright-kit-ops/scripts/upload_evidence.py index 0a3cd84..ebe2abc 100644 --- a/plugins/ndf/skills/playwright-kit-ops/scripts/upload_evidence.py +++ b/plugins/ndf-shared/skills/playwright-kit-ops/scripts/upload_evidence.py @@ -49,7 +49,7 @@ def _ensure_google_auth_on_path() -> None: "Google Drive 連携には optional skill `google-auth` が必要です。\n" "Codex 公開セットには同梱していないため、Drive 系コマンドを使う前に " "`GOOGLE_AUTH_SCRIPTS` を google-auth/scripts へ設定してください。\n" - "例: export GOOGLE_AUTH_SCRIPTS=/path/to/plugins/ndf/skills/google-auth/scripts\n" + "例: export GOOGLE_AUTH_SCRIPTS=/path/to/plugins/ndf-shared/skills/google-auth/scripts\n" "検索した候補:\n - " f"{searched}" ) diff --git a/plugins/ndf/skills/playwright-kit-ops/scripts/upload_md_as_gdoc.py b/plugins/ndf-shared/skills/playwright-kit-ops/scripts/upload_md_as_gdoc.py similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/scripts/upload_md_as_gdoc.py rename to plugins/ndf-shared/skills/playwright-kit-ops/scripts/upload_md_as_gdoc.py diff --git a/plugins/ndf/skills/playwright-kit-ops/templates/conftest.py.template b/plugins/ndf-shared/skills/playwright-kit-ops/templates/conftest.py.template similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/templates/conftest.py.template rename to plugins/ndf-shared/skills/playwright-kit-ops/templates/conftest.py.template diff --git a/plugins/ndf/skills/playwright-kit-ops/templates/pyproject.toml.runtime b/plugins/ndf-shared/skills/playwright-kit-ops/templates/pyproject.toml.runtime similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/templates/pyproject.toml.runtime rename to plugins/ndf-shared/skills/playwright-kit-ops/templates/pyproject.toml.runtime diff --git a/plugins/ndf/skills/playwright-kit-ops/templates/run.bat b/plugins/ndf-shared/skills/playwright-kit-ops/templates/run.bat similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/templates/run.bat rename to plugins/ndf-shared/skills/playwright-kit-ops/templates/run.bat diff --git a/plugins/ndf/skills/playwright-kit-ops/templates/run.sh b/plugins/ndf-shared/skills/playwright-kit-ops/templates/run.sh similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/templates/run.sh rename to plugins/ndf-shared/skills/playwright-kit-ops/templates/run.sh diff --git a/plugins/ndf/skills/playwright-kit-ops/templates/runtime-README.md b/plugins/ndf-shared/skills/playwright-kit-ops/templates/runtime-README.md similarity index 98% rename from plugins/ndf/skills/playwright-kit-ops/templates/runtime-README.md rename to plugins/ndf-shared/skills/playwright-kit-ops/templates/runtime-README.md index 035bb4c..945db44 100644 --- a/plugins/ndf/skills/playwright-kit-ops/templates/runtime-README.md +++ b/plugins/ndf-shared/skills/playwright-kit-ops/templates/runtime-README.md @@ -101,7 +101,7 @@ Drive 連携は optional dependency です。`--pwk-drive-folder` や Drive 系 Drive extra を同期してください。 ```bash -export GOOGLE_AUTH_SCRIPTS=/path/to/plugins/ndf/skills/google-auth/scripts +export GOOGLE_AUTH_SCRIPTS=/path/to/plugins/ndf-shared/skills/google-auth/scripts uv sync --extra drive ``` diff --git a/plugins/ndf/skills/playwright-kit-ops/templates/runtime-gitignore b/plugins/ndf-shared/skills/playwright-kit-ops/templates/runtime-gitignore similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/templates/runtime-gitignore rename to plugins/ndf-shared/skills/playwright-kit-ops/templates/runtime-gitignore diff --git a/plugins/ndf/skills/playwright-kit-ops/templates/scenario.config.yaml b/plugins/ndf-shared/skills/playwright-kit-ops/templates/scenario.config.yaml similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/templates/scenario.config.yaml rename to plugins/ndf-shared/skills/playwright-kit-ops/templates/scenario.config.yaml diff --git a/plugins/ndf/skills/playwright-kit-ops/templates/test_auth.py.template b/plugins/ndf-shared/skills/playwright-kit-ops/templates/test_auth.py.template similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/templates/test_auth.py.template rename to plugins/ndf-shared/skills/playwright-kit-ops/templates/test_auth.py.template diff --git a/plugins/ndf/skills/playwright-kit-ops/templates/test_dashboard.py.template b/plugins/ndf-shared/skills/playwright-kit-ops/templates/test_dashboard.py.template similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/templates/test_dashboard.py.template rename to plugins/ndf-shared/skills/playwright-kit-ops/templates/test_dashboard.py.template diff --git a/plugins/ndf/skills/playwright-kit-ops/templates/test_form.py.template b/plugins/ndf-shared/skills/playwright-kit-ops/templates/test_form.py.template similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/templates/test_form.py.template rename to plugins/ndf-shared/skills/playwright-kit-ops/templates/test_form.py.template diff --git a/plugins/ndf/skills/playwright-kit-ops/templates/test_list.py.template b/plugins/ndf-shared/skills/playwright-kit-ops/templates/test_list.py.template similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/templates/test_list.py.template rename to plugins/ndf-shared/skills/playwright-kit-ops/templates/test_list.py.template diff --git a/plugins/ndf-shared/skills/playwright-kit-ops/tests/__init__.py b/plugins/ndf-shared/skills/playwright-kit-ops/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/ndf/skills/playwright-kit-ops/tests/conftest.py b/plugins/ndf-shared/skills/playwright-kit-ops/tests/conftest.py similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/tests/conftest.py rename to plugins/ndf-shared/skills/playwright-kit-ops/tests/conftest.py diff --git a/plugins/ndf/skills/playwright-kit-ops/tests/test_a11y_cwv_routing.py b/plugins/ndf-shared/skills/playwright-kit-ops/tests/test_a11y_cwv_routing.py similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/tests/test_a11y_cwv_routing.py rename to plugins/ndf-shared/skills/playwright-kit-ops/tests/test_a11y_cwv_routing.py diff --git a/plugins/ndf/skills/playwright-kit-ops/tests/test_auth_cache.py b/plugins/ndf-shared/skills/playwright-kit-ops/tests/test_auth_cache.py similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/tests/test_auth_cache.py rename to plugins/ndf-shared/skills/playwright-kit-ops/tests/test_auth_cache.py diff --git a/plugins/ndf/skills/playwright-kit-ops/tests/test_body_check.py b/plugins/ndf-shared/skills/playwright-kit-ops/tests/test_body_check.py similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/tests/test_body_check.py rename to plugins/ndf-shared/skills/playwright-kit-ops/tests/test_body_check.py diff --git a/plugins/ndf/skills/playwright-kit-ops/tests/test_config_basic_auth.py b/plugins/ndf-shared/skills/playwright-kit-ops/tests/test_config_basic_auth.py similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/tests/test_config_basic_auth.py rename to plugins/ndf-shared/skills/playwright-kit-ops/tests/test_config_basic_auth.py diff --git a/plugins/ndf/skills/playwright-kit-ops/tests/test_evidence_fixture.py b/plugins/ndf-shared/skills/playwright-kit-ops/tests/test_evidence_fixture.py similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/tests/test_evidence_fixture.py rename to plugins/ndf-shared/skills/playwright-kit-ops/tests/test_evidence_fixture.py diff --git a/plugins/ndf/skills/playwright-kit-ops/tests/test_makereport_user_properties.py b/plugins/ndf-shared/skills/playwright-kit-ops/tests/test_makereport_user_properties.py similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/tests/test_makereport_user_properties.py rename to plugins/ndf-shared/skills/playwright-kit-ops/tests/test_makereport_user_properties.py diff --git a/plugins/ndf/skills/playwright-kit-ops/tests/test_pytest_plugin_bootstrap.py b/plugins/ndf-shared/skills/playwright-kit-ops/tests/test_pytest_plugin_bootstrap.py similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/tests/test_pytest_plugin_bootstrap.py rename to plugins/ndf-shared/skills/playwright-kit-ops/tests/test_pytest_plugin_bootstrap.py diff --git a/plugins/ndf/skills/playwright-kit-ops/tests/test_pytest_report.py b/plugins/ndf-shared/skills/playwright-kit-ops/tests/test_pytest_report.py similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/tests/test_pytest_report.py rename to plugins/ndf-shared/skills/playwright-kit-ops/tests/test_pytest_report.py diff --git a/plugins/ndf/skills/playwright-kit-ops/tests/test_pytest_terminal_summary.py b/plugins/ndf-shared/skills/playwright-kit-ops/tests/test_pytest_terminal_summary.py similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/tests/test_pytest_terminal_summary.py rename to plugins/ndf-shared/skills/playwright-kit-ops/tests/test_pytest_terminal_summary.py diff --git a/plugins/ndf/skills/playwright-kit-ops/tests/test_pytester_integration.py b/plugins/ndf-shared/skills/playwright-kit-ops/tests/test_pytester_integration.py similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/tests/test_pytester_integration.py rename to plugins/ndf-shared/skills/playwright-kit-ops/tests/test_pytester_integration.py diff --git a/plugins/ndf/skills/playwright-kit-ops/tests/test_same_origin.py b/plugins/ndf-shared/skills/playwright-kit-ops/tests/test_same_origin.py similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/tests/test_same_origin.py rename to plugins/ndf-shared/skills/playwright-kit-ops/tests/test_same_origin.py diff --git a/plugins/ndf/skills/playwright-kit-ops/tests/test_upload_evidence.py b/plugins/ndf-shared/skills/playwright-kit-ops/tests/test_upload_evidence.py similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/tests/test_upload_evidence.py rename to plugins/ndf-shared/skills/playwright-kit-ops/tests/test_upload_evidence.py diff --git a/plugins/ndf/skills/playwright-kit-ops/tests/test_video_default.py b/plugins/ndf-shared/skills/playwright-kit-ops/tests/test_video_default.py similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/tests/test_video_default.py rename to plugins/ndf-shared/skills/playwright-kit-ops/tests/test_video_default.py diff --git a/plugins/ndf/skills/playwright-kit-ops/uv.lock b/plugins/ndf-shared/skills/playwright-kit-ops/uv.lock similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/uv.lock rename to plugins/ndf-shared/skills/playwright-kit-ops/uv.lock diff --git a/plugins/ndf/skills/playwright-report/SKILL.md b/plugins/ndf-shared/skills/playwright-report/SKILL.md similarity index 100% rename from plugins/ndf/skills/playwright-report/SKILL.md rename to plugins/ndf-shared/skills/playwright-report/SKILL.md diff --git a/plugins/ndf/skills/playwright-scenario-test/SKILL.md b/plugins/ndf-shared/skills/playwright-scenario-test/SKILL.md similarity index 100% rename from plugins/ndf/skills/playwright-scenario-test/SKILL.md rename to plugins/ndf-shared/skills/playwright-scenario-test/SKILL.md diff --git a/plugins/ndf/skills/playwright-script-creation/SKILL.md b/plugins/ndf-shared/skills/playwright-script-creation/SKILL.md similarity index 100% rename from plugins/ndf/skills/playwright-script-creation/SKILL.md rename to plugins/ndf-shared/skills/playwright-script-creation/SKILL.md diff --git a/plugins/ndf/skills/playwright-test-planning/SKILL.md b/plugins/ndf-shared/skills/playwright-test-planning/SKILL.md similarity index 100% rename from plugins/ndf/skills/playwright-test-planning/SKILL.md rename to plugins/ndf-shared/skills/playwright-test-planning/SKILL.md diff --git a/plugins/ndf/skills/playwright-test-planning/docs/01-methodology.md b/plugins/ndf-shared/skills/playwright-test-planning/docs/01-methodology.md similarity index 100% rename from plugins/ndf/skills/playwright-test-planning/docs/01-methodology.md rename to plugins/ndf-shared/skills/playwright-test-planning/docs/01-methodology.md diff --git a/plugins/ndf/skills/playwright-test-planning/docs/02-page-roles.md b/plugins/ndf-shared/skills/playwright-test-planning/docs/02-page-roles.md similarity index 100% rename from plugins/ndf/skills/playwright-test-planning/docs/02-page-roles.md rename to plugins/ndf-shared/skills/playwright-test-planning/docs/02-page-roles.md diff --git a/plugins/ndf/skills/playwright-test-planning/docs/03-test-techniques.md b/plugins/ndf-shared/skills/playwright-test-planning/docs/03-test-techniques.md similarity index 100% rename from plugins/ndf/skills/playwright-test-planning/docs/03-test-techniques.md rename to plugins/ndf-shared/skills/playwright-test-planning/docs/03-test-techniques.md diff --git a/plugins/ndf/skills/playwright-test-planning/docs/04-playwright-mapping.md b/plugins/ndf-shared/skills/playwright-test-planning/docs/04-playwright-mapping.md similarity index 100% rename from plugins/ndf/skills/playwright-test-planning/docs/04-playwright-mapping.md rename to plugins/ndf-shared/skills/playwright-test-planning/docs/04-playwright-mapping.md diff --git a/plugins/ndf/skills/playwright-test-planning/docs/05-bug-report.md b/plugins/ndf-shared/skills/playwright-test-planning/docs/05-bug-report.md similarity index 100% rename from plugins/ndf/skills/playwright-test-planning/docs/05-bug-report.md rename to plugins/ndf-shared/skills/playwright-test-planning/docs/05-bug-report.md diff --git a/plugins/ndf/skills-codex/playwright-test-planning/docs/06-pytest-playwright.md b/plugins/ndf-shared/skills/playwright-test-planning/docs/06-pytest-playwright.md similarity index 99% rename from plugins/ndf/skills-codex/playwright-test-planning/docs/06-pytest-playwright.md rename to plugins/ndf-shared/skills/playwright-test-planning/docs/06-pytest-playwright.md index 776c2a4..717d4be 100644 --- a/plugins/ndf/skills-codex/playwright-test-planning/docs/06-pytest-playwright.md +++ b/plugins/ndf-shared/skills/playwright-test-planning/docs/06-pytest-playwright.md @@ -158,7 +158,7 @@ v0.5.0 から、利用者プロジェクトに `init_project.sh` で埋め込ん ```bash # 1) 初期化 (Skill ディレクトリ内で 1 度だけ) # Claude Code/Kiro: /skills/playwright-kit-ops -# Codex: /skills-codex/playwright-kit-ops +# Codex: /skills/playwright-kit-ops cd /playwright-kit-ops ./scripts/init_project.sh /path/to/your-app # → /path/to/your-app/scenario-test/ 一式が作成される diff --git a/plugins/ndf/skills/playwright-test-planning/docs/README.md b/plugins/ndf-shared/skills/playwright-test-planning/docs/README.md similarity index 100% rename from plugins/ndf/skills/playwright-test-planning/docs/README.md rename to plugins/ndf-shared/skills/playwright-test-planning/docs/README.md diff --git a/plugins/ndf/skills/playwright-test-planning/docs/checklists/checklist-auth.md b/plugins/ndf-shared/skills/playwright-test-planning/docs/checklists/checklist-auth.md similarity index 100% rename from plugins/ndf/skills/playwright-test-planning/docs/checklists/checklist-auth.md rename to plugins/ndf-shared/skills/playwright-test-planning/docs/checklists/checklist-auth.md diff --git a/plugins/ndf/skills/playwright-test-planning/docs/checklists/checklist-cart-checkout.md b/plugins/ndf-shared/skills/playwright-test-planning/docs/checklists/checklist-cart-checkout.md similarity index 100% rename from plugins/ndf/skills/playwright-test-planning/docs/checklists/checklist-cart-checkout.md rename to plugins/ndf-shared/skills/playwright-test-planning/docs/checklists/checklist-cart-checkout.md diff --git a/plugins/ndf/skills/playwright-test-planning/docs/checklists/checklist-common.md b/plugins/ndf-shared/skills/playwright-test-planning/docs/checklists/checklist-common.md similarity index 100% rename from plugins/ndf/skills/playwright-test-planning/docs/checklists/checklist-common.md rename to plugins/ndf-shared/skills/playwright-test-planning/docs/checklists/checklist-common.md diff --git a/plugins/ndf/skills/playwright-test-planning/docs/checklists/checklist-dashboard.md b/plugins/ndf-shared/skills/playwright-test-planning/docs/checklists/checklist-dashboard.md similarity index 100% rename from plugins/ndf/skills/playwright-test-planning/docs/checklists/checklist-dashboard.md rename to plugins/ndf-shared/skills/playwright-test-planning/docs/checklists/checklist-dashboard.md diff --git a/plugins/ndf/skills/playwright-test-planning/docs/checklists/checklist-edit.md b/plugins/ndf-shared/skills/playwright-test-planning/docs/checklists/checklist-edit.md similarity index 100% rename from plugins/ndf/skills/playwright-test-planning/docs/checklists/checklist-edit.md rename to plugins/ndf-shared/skills/playwright-test-planning/docs/checklists/checklist-edit.md diff --git a/plugins/ndf/skills/playwright-test-planning/docs/checklists/checklist-form.md b/plugins/ndf-shared/skills/playwright-test-planning/docs/checklists/checklist-form.md similarity index 100% rename from plugins/ndf/skills/playwright-test-planning/docs/checklists/checklist-form.md rename to plugins/ndf-shared/skills/playwright-test-planning/docs/checklists/checklist-form.md diff --git a/plugins/ndf/skills/playwright-test-planning/docs/checklists/checklist-item.md b/plugins/ndf-shared/skills/playwright-test-planning/docs/checklists/checklist-item.md similarity index 100% rename from plugins/ndf/skills/playwright-test-planning/docs/checklists/checklist-item.md rename to plugins/ndf-shared/skills/playwright-test-planning/docs/checklists/checklist-item.md diff --git a/plugins/ndf/skills/playwright-test-planning/docs/checklists/checklist-list.md b/plugins/ndf-shared/skills/playwright-test-planning/docs/checklists/checklist-list.md similarity index 100% rename from plugins/ndf/skills/playwright-test-planning/docs/checklists/checklist-list.md rename to plugins/ndf-shared/skills/playwright-test-planning/docs/checklists/checklist-list.md diff --git a/plugins/ndf/skills/playwright-test-planning/docs/checklists/checklist-lp.md b/plugins/ndf-shared/skills/playwright-test-planning/docs/checklists/checklist-lp.md similarity index 100% rename from plugins/ndf/skills/playwright-test-planning/docs/checklists/checklist-lp.md rename to plugins/ndf-shared/skills/playwright-test-planning/docs/checklists/checklist-lp.md diff --git a/plugins/ndf/skills/playwright-test-planning/docs/checklists/checklist-modal-wizard.md b/plugins/ndf-shared/skills/playwright-test-planning/docs/checklists/checklist-modal-wizard.md similarity index 100% rename from plugins/ndf/skills/playwright-test-planning/docs/checklists/checklist-modal-wizard.md rename to plugins/ndf-shared/skills/playwright-test-planning/docs/checklists/checklist-modal-wizard.md diff --git a/plugins/ndf/skills/playwright-test-planning/docs/checklists/checklist-search.md b/plugins/ndf-shared/skills/playwright-test-planning/docs/checklists/checklist-search.md similarity index 100% rename from plugins/ndf/skills/playwright-test-planning/docs/checklists/checklist-search.md rename to plugins/ndf-shared/skills/playwright-test-planning/docs/checklists/checklist-search.md diff --git a/plugins/ndf-shared/skills/pr-tests/SKILL.md b/plugins/ndf-shared/skills/pr-tests/SKILL.md new file mode 100644 index 0000000..39f13de --- /dev/null +++ b/plugins/ndf-shared/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-shared/skills/pr/SKILL.md b/plugins/ndf-shared/skills/pr/SKILL.md new file mode 100644 index 0000000..22000e4 --- /dev/null +++ b/plugins/ndf-shared/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-shared/skills/problem-solving/SKILL.md b/plugins/ndf-shared/skills/problem-solving/SKILL.md new file mode 100644 index 0000000..1b3c110 --- /dev/null +++ b/plugins/ndf-shared/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-shared/skills/python-execution/01-uv-setup.md b/plugins/ndf-shared/skills/python-execution/01-uv-setup.md new file mode 100644 index 0000000..aafd4f1 --- /dev/null +++ b/plugins/ndf-shared/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-shared/skills/python-execution/02-troubleshooting.md b/plugins/ndf-shared/skills/python-execution/02-troubleshooting.md new file mode 100644 index 0000000..3475b7e --- /dev/null +++ b/plugins/ndf-shared/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-shared/skills/python-execution/SKILL.md b/plugins/ndf-shared/skills/python-execution/SKILL.md new file mode 100644 index 0000000..a07705a --- /dev/null +++ b/plugins/ndf-shared/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/skills/qa-security-scan/01-owasp-checklist.md b/plugins/ndf-shared/skills/qa-security-scan/01-owasp-checklist.md similarity index 100% rename from plugins/ndf/skills/qa-security-scan/01-owasp-checklist.md rename to plugins/ndf-shared/skills/qa-security-scan/01-owasp-checklist.md diff --git a/plugins/ndf/skills/qa-security-scan/02-auth-checklist.md b/plugins/ndf-shared/skills/qa-security-scan/02-auth-checklist.md similarity index 100% rename from plugins/ndf/skills/qa-security-scan/02-auth-checklist.md rename to plugins/ndf-shared/skills/qa-security-scan/02-auth-checklist.md diff --git a/plugins/ndf/skills/qa-security-scan/03-report-template.md b/plugins/ndf-shared/skills/qa-security-scan/03-report-template.md similarity index 100% rename from plugins/ndf/skills/qa-security-scan/03-report-template.md rename to plugins/ndf-shared/skills/qa-security-scan/03-report-template.md diff --git a/plugins/ndf/skills/qa-security-scan/SKILL.md b/plugins/ndf-shared/skills/qa-security-scan/SKILL.md similarity index 100% rename from plugins/ndf/skills/qa-security-scan/SKILL.md rename to plugins/ndf-shared/skills/qa-security-scan/SKILL.md diff --git a/plugins/ndf-shared/skills/resolve-pr-comments/SKILL.md b/plugins/ndf-shared/skills/resolve-pr-comments/SKILL.md new file mode 100644 index 0000000..433a72d --- /dev/null +++ b/plugins/ndf-shared/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-shared/skills/review-branch/SKILL.md b/plugins/ndf-shared/skills/review-branch/SKILL.md new file mode 100644 index 0000000..951e5ea --- /dev/null +++ b/plugins/ndf-shared/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-shared/skills/review-pr-comments/SKILL.md b/plugins/ndf-shared/skills/review-pr-comments/SKILL.md new file mode 100644 index 0000000..1b51139 --- /dev/null +++ b/plugins/ndf-shared/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-shared/skills/review/SKILL.md b/plugins/ndf-shared/skills/review/SKILL.md new file mode 100644 index 0000000..5dfeb4a --- /dev/null +++ b/plugins/ndf-shared/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` 指定時 + +呼び出し手順の詳細は、利用 runtime に `/ndf:codex` skill が同梱されている場合はその skill に従う。要点: + +- プロンプトを `/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` 指定時 + +呼び出し手順の詳細は、利用 runtime に `/ndf:gemini` skill が同梱されている場合はその skill に従う。要点: + +- プロンプトを `/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/skills/skill-stats/SKILL.md b/plugins/ndf-shared/skills/skill-stats/SKILL.md similarity index 100% rename from plugins/ndf/skills/skill-stats/SKILL.md rename to plugins/ndf-shared/skills/skill-stats/SKILL.md diff --git a/plugins/ndf/skills/skill-stats/scripts/skill-stats.py b/plugins/ndf-shared/skills/skill-stats/scripts/skill-stats.py similarity index 99% rename from plugins/ndf/skills/skill-stats/scripts/skill-stats.py rename to plugins/ndf-shared/skills/skill-stats/scripts/skill-stats.py index 4a7e718..069488a 100755 --- a/plugins/ndf/skills/skill-stats/scripts/skill-stats.py +++ b/plugins/ndf-shared/skills/skill-stats/scripts/skill-stats.py @@ -28,7 +28,7 @@ def plugin_root_default() -> pathlib.Path: env = os.environ.get("CLAUDE_PLUGIN_ROOT") if env: return pathlib.Path(env) - # scripts/skill-stats.py -> plugins/ndf/skills/skill-stats/scripts/ + # scripts/skill-stats.py -> plugins/ndf-shared/skills/skill-stats/scripts/ return pathlib.Path(__file__).resolve().parents[3] diff --git a/plugins/ndf-shared/skills/statusline/SKILL.md b/plugins/ndf-shared/skills/statusline/SKILL.md new file mode 100644 index 0000000..ab17e4a --- /dev/null +++ b/plugins/ndf-shared/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-shared/skills/statusline/tests/__init__.py b/plugins/ndf-shared/skills/statusline/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/ndf-shared/skills/statusline/tests/test_statusline_switch.py b/plugins/ndf-shared/skills/statusline/tests/test_statusline_switch.py new file mode 100644 index 0000000..3fe6648 --- /dev/null +++ b/plugins/ndf-shared/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-shared/skills/statusline/tests/ -> plugins/ndf-shared/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-shared/skills/sync-main/SKILL.md b/plugins/ndf-shared/skills/sync-main/SKILL.md new file mode 100644 index 0000000..87aa223 --- /dev/null +++ b/plugins/ndf-shared/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/.codex-plugin/plugin.json b/plugins/ndf/.codex-plugin/plugin.json deleted file mode 100644 index f5ee875..0000000 --- a/plugins/ndf/.codex-plugin/plugin.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "ndf", - "version": "4.19.0", - "description": "NDF workflows for PRs, reviews, testing, data analysis, Google integrations, and external AI delegation.", - "skills": "./skills-codex/", - "hooks": "./hooks/codex-hooks.json" -} diff --git a/plugins/ndf/AGENTS.md b/plugins/ndf/AGENTS.md deleted file mode 100644 index cdf38e4..0000000 --- a/plugins/ndf/AGENTS.md +++ /dev/null @@ -1,86 +0,0 @@ -# NDF Plugin - 開発者向けガイドライン - -## 概要 - -**NDFプラグインの開発・メンテナンス**を行うAIエージェント向けガイドライン。 - -## プラグイン情報 - -- **名前**: ndf -- **現在バージョン**: 4.19.0 -- **種類**: 統合プラグイン(Skills + Agents + Hooks / v4.0.0 で Codex MCP 廃止) -- **リポジトリ**: https://github.com/devbasex/ai-plugins - -> **Note (v3.0.0)**: Serena MCPは`mcp-serena`プラグインに分離。memory系スキルは廃止。CLAUDE.ndf.md注入は廃止。 - -## 開発ルール - -- ドキュメント・コミットメッセージ・PR説明は**日本語** -- **mainブランチへの直接コミット禁止**(featureブランチ+PR) -- **セマンティックバージョニング**: MAJOR(破壊的変更)、MINOR(新機能)、PATCH(バグ修正) - -## ディレクトリ構造 - -``` -plugins/ndf/ -├── .claude-plugin/ -│ └── plugin.json # プラグインメタデータ -├── .codex-plugin/ -│ └── plugin.json # Codexプラグインメタデータ -├── hooks/ -│ ├── hooks.json # Claude Codeプロジェクトフック定義 -│ └── codex-hooks.json # Codex hook定義 -├── scripts/ -│ └── slack-notify.js # Slack通知スクリプト -├── agents/ # サブエージェント(8個、モデル階層化) -│ ├── director.md # opus: 計画・統括 -│ ├── corder.md # sonnet: Codex第二意見レビュー -│ ├── data-analyst.md # sonnet: BigQuery/SQL -│ ├── researcher.md # sonnet: AWS Docs/Chrome DevTools -│ ├── qa.md # sonnet: セキュリティ/品質 -│ ├── debugger.md # sonnet: 根本原因分析 -│ ├── devops-engineer.md # sonnet: Docker/CI/K8s -│ └── code-reviewer.md # sonnet: diff/PRレビュー -├── skills/ # 全Skill実体(49個、Claude Code/Kiroはmanifest配列で公開対象を指定) -├── skills-codex/ # Codex向け公開Skill(core 30個、marketplace cache向け実ディレクトリ) -├── skills-optional/ # ランタイム別除外候補リスト -├── AGENTS.md # このファイル(開発者向け) -└── README.md # プラグイン説明書 -``` - -## 一般的な開発タスク - -### 新しいスキルの追加 - -1. `skills/{skill-name}/SKILL.md` を作成(YAMLフロントマター必須) -2. Claude Code/Kiroで初期公開する場合は `.claude-plugin/plugin.json` の `skills` 配列に `"./skills/{skill-name}"` を追加 -3. Codexで初期公開する場合は `skills-codex/{skill-name}` に実ディレクトリとしてコピーする(`.codex-plugin/plugin.json` は `./skills-codex/` ディレクトリを参照) -4. 低頻度・保守用に留める場合は `skills-optional/README.md` の候補リストへ追加 -5. plugin.json のバージョンをMINOR上げ -6. テスト・コミット - -### 新しいサブエージェントの追加 - -1. `agents/{agent-name}.md` を作成(YAMLフロントマター必須) -2. `plugin.json` の `agents` 配列に追加 -3. バージョンMINOR上げ → テスト・コミット - -## 検証チェックリスト - -- [ ] plugin.jsonが有効なJSON -- [ ] バージョン番号が適切にインクリメント -- [ ] すべてのスキル/エージェントファイルが存在 -- [ ] YAMLフロントマターが正しい -- [ ] README.md が最新 - -## トラブルシューティング - -| 問題 | 対処 | -|------|------| -| エージェントが認識されない | plugin.jsonのagents配列、ファイルパス、YAMLフロントマターを確認 | -| スキルが表示されない | plugin.jsonのskills配列、SKILL.mdのフロントマターを確認、`/plugin reload ndf` | -| フックが動作しない | hooks.jsonの構文、スクリプト実行権限を確認 | - -## 開発履歴 - -バージョン履歴は [CHANGELOG.md](CHANGELOG.md) を参照。 diff --git a/plugins/ndf/CHANGELOG.md b/plugins/ndf/CHANGELOG.md deleted file mode 100644 index 579e185..0000000 --- a/plugins/ndf/CHANGELOG.md +++ /dev/null @@ -1,1002 +0,0 @@ -# NDF Plugin CHANGELOG - -### v4.19.0 (plan-to-spec: plan の確定仕様書化フローを追加) - -- **`plan-to-spec` skill 追加**: - 実装完了後の plan を `docs/` 配下などの確定仕様書置き場へ移動し、 - 開発履歴・TODO・PR 分割などを削除した as-is 仕様書としてリライトする - 標準フローを定義した。 -- **仕様書レビュー手順**: - 他仕様書との体裁統一、コードとの一致確認、authorize された用語への統一、 - リンク再調査を必須観点として明文化した。 -- **完了報告テンプレート**: - 元 plan、確定仕様書、実施内容、レビュー結果、検証、補足を共通形式で - 報告するテンプレートを追加した。 -- **plugin.json: version 4.18.0 → 4.19.0**。marketplace.json / README / - AGENTS.md / ndf-plugin-reference の version 表記と公開 Skill 数を整合。 - -### v4.18.0 (cross-review: 監視性とレビュー観点テンプレート強化) - -- **`cross-review` / Gemini 監視性改善**: - `launch-gemini.sh` が Gemini に短い進捗マーカー - (`gemini-review-pr-progress.log`) の出力を要求し、`monitor.py` が - heartbeat に stdout / stderr / progress log サイズ、無更新秒数、最新フェーズを表示するようにした。 - 内部推論ではなく `scan` / `analyze` / `post` / `done` などの作業段階だけを出す。 -- **`cross-review` / 追加レビュー観点**: - `state.py init` に `--focus` と `--extra-instructions-file` を追加し、 - codex / gemini 両 launcher が同じ追加観点をプロンプトへ差し込めるようにした。 -- **`cross-review` / 自動レビュー観点テンプレート**: - GitHub API の `pulls//files --paginate` の変更ファイルから PR 種別を分類し、ドキュメントのみ、 - コード、DB migration、テスト、依存関係、CI/設定、API 契約、認証/認可、frontend、 - 性能、削除/リネーム、生成物/i18n/infra の該当テンプレートを自動で追加するようにした。 -- **`monitor.py`**: stall 判定対象を err.log + stdout.log + progress.log に拡張。 -- **plugin.json: version 4.17.3 → 4.18.0**。marketplace.json / README / - AGENTS.md / ndf-plugin-reference の version 表記を整合。 - -### v4.17.3 (Stop hook: Slack通知失敗時の終了コードを安定化) - -Codex 環境で Claude 用 Slack 通知 hook が混入した場合や、Slack API / -ネットワークが一時失敗した場合に、Stop hook 全体が `code 1` で失敗表示に -なる問題を修正した。 - -- **`hooks/codex-hooks.json`**: Codex 用通知スクリプトを `sh -c` 経由で起動し、 - `PLUGIN_ROOT` がない環境では no-op、通知処理の失敗も Stop hook 失敗にしない -- **`hooks/hooks.json`**: Claude 用 Stop hook に `continueOnError` を追加 -- **`scripts/slack-notify.js`**: Slack 通知失敗・致命例外時も終了通知 hook としては - `exit 0` を返し、作業完了フローを阻害しない -- **plugin.json: version 4.17.2 → 4.17.3**。marketplace.json / README / - AGENTS.md / ndf-plugin-reference の version 表記を整合 - -### v4.17.2 (Codex Slack通知: メンション付き投稿の削除を修正) - -Codex Stop hook の Slack 通知で、メンション付きメッセージ削除ロジックが -動いていなかった問題を修正した。 - -- **`scripts/codex-slack-notify.js`**: Claude 版と同様に - `chat.postMessage` の戻り値 `ts` を保持し、メンション付き投稿後に - メンションなし投稿を送信してから `chat.delete` でメンション付き投稿を削除 -- **plugin.json: version 4.17.1 → 4.17.2**。marketplace.json / README / - AGENTS.md / ndf-plugin-reference の version 表記を整合 - -### v4.17.1 (Codex/Claude: cross-review 公開漏れ修正) - -NDF の主要 PR ワークフローである `cross-review` が、Claude Code/Kiro の -manifest と Codex の `skills-codex/` 公開セットから漏れていた問題を修正した。 - -- **`.claude-plugin/plugin.json`**: `./skills/cross-review` を公開 Skill に追加 -- **`skills-codex/cross-review/`**: marketplace cache で欠落しないよう実ディレクトリとして同梱 -- **`skills-optional/README.md`**: `cross-review` を optional から除外 -- **plugin.json: version 4.17.0 → 4.17.1**。marketplace.json / README / AGENTS.md / - ndf-plugin-reference の version 表記と公開 Skill 数を整合 - -### v4.17.0 (Codex plugin 対応: ランタイム別 Skill 公開構成) - -Claude Code / Kiro と Codex で公開する Skill セットを分離し、Codex plugin から -不要な Claude Code 専用 Skill を読み込まない構成にした。 - -- **`.codex-plugin/plugin.json`**: Codex 向け manifest を追加し、`skills-codex/` - を Codex の公開 Skill ディレクトリとして指定 -- **`.claude-plugin/plugin.json`**: Claude Code / Kiro 向け公開 Skill を core 26 個に整理 -- **`skills-codex/`**: Codex 向け core 28 個を marketplace cache で欠落しない - 実ディレクトリとして同梱 - (`playwright-kit-ops` を含め、Playwright 系 skill から参照するスクリプト群も同梱) -- **`skills-optional/README.md`**: ランタイム別の除外候補を整理 -- **`scripts/install-kiro.sh`**: manifest-selected skill set を読むように更新し、 - Codex CLI 連携表示を MCP 前提から CLI 直接実行前提へ修正 -- **plugin.json: version 4.16.1 → 4.17.0**。marketplace.json / README / AGENTS.md / - ndf-plugin-reference の version 表記を整合 - -### v4.16.1 (statusline: NDF 由来の旧コピーをバージョンアップ追従させる) - -`settings.json` の `statusLine` が NDF の過去配置コピー (マーカー付き / -レガシー `statusline-command.sh`) を指している場合、SessionStart hook が -それを検出できずスクリプト更新が届かない問題を修正した。 - -- **`statusline.sh`**: 先頭に識別マーカー `# ndf-statusline: managed` を追加。 - `deploy_script` が配置する `~/.claude/ndf-statusline.sh` にも自動的に入る -- **`statusline-switch.sh`**: `cmd_ensure` に移行分岐を追加 - - `current_script_path`: `statusLine.command` から実行スクリプトのパスを抽出 - (GNU 拡張の `grep -o` を避け、BusyBox でも動く `sed` で抽出) - - `is_ndf_managed_copy`: ① マーカーがあれば NDF 管理コピー確定、 - ② レガシー救済として既知名 `statusline-command.sh` かつ NDF 特有ロジック - (`[ctx:` + `container_name`) を両方含む場合のみ対象 - (マーカー/特徴の検出は `grep -F` の固定文字列検索で正規表現メタ文字を回避) - - `migrate_to_ndf_statusline`: 既存設定を `~/.claude/.ndf-statusline-backup.json` - に退避し、正規パス参照へ移行。以後は `deploy_script` の更新ルートに乗る - - マーカーも特徴も持たないユーザー独自 statusline は誤検出ガードで一切上書きしない -- **`skills/statusline/tests/`**: 新設。移行/尊重/誤検出ガードの 6 シナリオを - pytest で検証 (`uv run --with pytest python -m pytest`) -- **plugin.json: version 4.16.0 → 4.16.1**。marketplace.json / AGENTS.md / - ndf-plugin-reference の version 表記を整合 - -### v4.16.0 (statusline: 固定 `ctx:` ラベルを利用モデル名に置換) - -statusline の `[ctx: ...]` 部分の固定ラベル `ctx:` が常時同じ文字で表示スペースの -無駄になっていたため、利用中モデルの表示名に置き換えた。 - -- **`statusline.sh`**: `model.display_name` (なければ `model.id`) を取得し、 - 角括弧内のラベルに使用。`[Opus 4.8: 44.6k / 1000k tokens (4%)]` のように表示 - - モデル名が取得できない場合は従来どおり `ctx` にフォールバック - - トークン情報 (`context_window`) が無い場合の非表示挙動は従来どおり -- **`statusline` skill SKILL.md**: 表示例をモデル名ラベルに更新 -- **plugin.json: version 4.15.0 → 4.16.0**。marketplace.json / AGENTS.md / - ndf-plugin-reference の version 表記を整合。 - -### v4.15.0 (cross-review: worktree を非永続領域 + リポジトリ別パスに変更) - -cross-review の worktree 生成先 `/work/worktrees/pr` が共有の永続 volume 上にあり、 -(1) 別リポジトリの同一 PR 番号と衝突する (2) 明示削除しないと残り続ける -(3) volume スペースを消費する、という問題に対応。実際に別プロジェクトの残骸 -worktree を流用して git 操作が壊れる事故が発生した。 - -- **デフォルト生成先を `<システム tmpdir>/ndf-worktrees` に変更** (`state.py`): - - 解決順: `NDF_WORKTREE_BASE` env > `tempfile.gettempdir()/ndf-worktrees` - - 非永続領域のためコンテナ再作成で自動消滅し、明示削除も不要 - - 旧 `/work/worktrees` / `$HOME/work/worktrees` フォールバックは廃止 -- **パスにリポジトリ slug を含める**: `/--/pr` 形式とし、 - 他リポジトリの同一 PR 番号と衝突しないようにした -- **残骸 worktree の流用ガード追加**: パスが存在しても `git worktree list` に - 登録されていなければ `.stale-` に退避して作り直す - (`_is_registered_worktree()` / `_create_worktree()` に分離) -- **テスト更新**: `test_default_worktree_base.py` を新解決順 + repo slug + - 残骸ガードのテストに刷新 (102 passed) -- **plugin.json: version 4.14.0 → 4.15.0**。marketplace.json / AGENTS.md / - ndf-plugin-reference の version 表記を整合。 - -### v4.14.0 (statusline skill + デフォルト statusline 設定 hook 追加) - -コンテナ名 (非コンテナ環境ではホスト名) + project_dir + コンテキスト使用率を表示する -NDF 標準 statusline をプラグインに同梱し、インストールするだけでデフォルトになるようにした。 - -- **`statusline` skill 追加** (`/ndf:statusline`、47→48個): - - `status` (デフォルト): 現在の statusLine 設定とバックアップの有無を表示 - - `set`: 既存設定を `~/.claude/.ndf-statusline-backup.json` にバックアップして - NDF 標準 statusline に切り替え - - `restore`: バックアップから復元 (バックアップ無しなら statusLine キーを削除) -- **SessionStart hook 追加** (`scripts/statusline-switch.sh ensure`): - - `statusLine` が**未設定の場合のみ** NDF 標準 statusline を設定。 - 既に設定されている場合はそちらを優先し、何も変更しない - - プラグイン同梱の `scripts/statusline.sh` を `~/.claude/ndf-statusline.sh` に - 差分時のみコピーし、プラグイン更新へ自動追従 - (settings からはプラグインキャッシュパスでなく固定パスを参照) - - 書き込みは flock + atomic rename (ensure-retention.sh と同パターン) -- **plugin.json: version 4.13.0 → 4.14.0**。marketplace.json / README / AGENTS.md / - ndf-plugin-reference の version 表記を整合。 - -### v4.13.0 (issue-plan-strategy: release PR body の self-contained 必須化) - -release ブランチ戦略において、release PR の body が「個別 PR リンクの列挙 + plan 参照」 -だけになり、人間のレビュアーが PR 単体で変更を把握できない問題に対応 (issue #28)。 - -- **レビュアー視点の原則を明文化** (`skills/issue-plan-strategy/SKILL.md` Step 3): - - 個別 PR はセルフレビューで merge される。人間のレビュアーが見るのは release PR - だけであり、個別 PR の存在をレビュアーに意識させない -- **release PR body テンプレートを self-contained 化**: - - 「何のために (背景)」「何を (release 全体の変更内容)」を本文の中心に変更 - - 個別 PR チェックリストは `
` 折りたたみ内の開発用補足情報に格下げ -- **body 最終化ステップを Step 8 に追加**: - - 個別 PR 全 merge 後・Draft 解除前に、実装の最終形を反映した body へ更新する - 工程とチェック観点を明記 (cross-review light rotation と同等の原則を適用) -- **アンチパターン 2 件追加**: 「body を個別 PR リンクの列挙だけにする」 - 「body 最終化せずに Ready for review にする」 -- **plugin.json: version 4.12.1 → 4.13.0**。marketplace.json / README / AGENTS.md / - ndf-plugin-reference の version 表記を整合。 - -### v4.12.1 (cross-review デフォルト値調整・説明文整理) - -`/ndf:cross-review` のローテーション/打ち切り関連デフォルトを長尺 PR 向けに緩和し、 -`--rotate-mode` の説明文を整理。 - -- **デフォルト値変更** (`skills/cross-review/scripts/state.py`): - - `--max-rounds` の既定値を `6 → 12` に変更 - - `--rotate-after` の既定値を `5 → 8` に変更 - - 長尺・指摘の多い PR で max_rounds 打ち切りや早期ローテーションに - 到達しにくくし、収束まで回しきれるようにする狙い -- **`--rotate-mode squash` 説明文の整理**: - - SKILL.md / docs / scripts から「既存挙動」の文言を全削除 - - 反映: `SKILL.md` / `docs/01-state-and-review.md` (サンプル state.json) / - `docs/02-fix-and-rotation.md` / `scripts/rotate-pr.sh` -- **plugin.json: version 4.12.0 → 4.12.1**。marketplace.json / README / AGENTS.md / - ndf-plugin-reference の version 表記を整合。 - -### v4.12.0 (playwright-browser-connect / playwright-evidence-drive Skill 追加) - -`/ndf:playwright-browser-connect` と `/ndf:playwright-evidence-drive` を新規 Skill -として追加。CDP リモートブラウザ接続と Google Drive エビデンスアーカイブを -playwright-scenario-test エコシステムから独立した専門 Skill に分離。 - -- **新規 Skill `playwright-browser-connect`**: - - `scenario.config.yaml` の `browser.mode: cdp-remote` で既存 Chrome に - CDP (Chrome DevTools Protocol) 接続するワークフローを提供 - - `conftest.py.template` に browser fixture を追加 (mode 切り替え) - - `config.py` に `BrowserConfig` dataclass を追加 -- **新規 Skill `playwright-evidence-drive`**: - - テスト実行後のエビデンス (video/trace/screenshot/HAR) を Google Drive に - 自動アーカイブする手順を提供 - - 認証は `ndf:google-auth` に委譲 -- **conftest.py.template の改善** (PR #19 クロスレビュー対応): - - `browser_type_launch_args` を browser fixture の依存に追加し、 - `--headed` / `slow_mo` / `channel` 等が local モードで反映されるよう修正 - - CDP 接続時に Chromium 以外のブラウザが選択された場合の fail-fast チェックを追加 - - 変数名 `b` → `browser` に改名 (可読性向上) -- **config.py の修正** (PR #19 クロスレビュー対応): - - `BrowserConfig.cdp_endpoint` の既定値を `ws://localhost:9222` → - `http://localhost:9222` に変更 (Playwright の `connect_over_cdp()` は - HTTP endpoint から `/json/version` 経由で WebSocket URL を自動解決する) -- **plugin.json: version 4.11.0 → 4.12.0**。marketplace.json / README / AGENTS.md / - ndf-plugin-reference の version 表記を整合。 -- Skills: 45個 → **47個** (main の ml-model-structure 追加分とマージ) - -### v4.11.0 (fix/cross-review-early-error-and-final-sweep: cross-review 堅牢性改善) - -`/ndf:cross-review` の 2 つの運用課題を修正。 - -- **monitor.py の EARLY_ERROR 誤検知を解消** - (`skills/cross-review/scripts/monitor.py`): - - `_match_is_quoted()` を拡張し、backtick / 「」 に加え **ダブル/シングルクォート - 文字列リテラル** (`"quota exceeded: ..."`) も benign 判定。 - - `EARLY_ERROR_BENIGN` に **grep/ripgrep 形式のソース引用行** (`path/file.py:22: `) - パターンを追加。 - - codex がレビュー中に tests/*.py のテスト用文字列 (`"quota exceeded"` 等) を echo - しても誤 kill しなくなった(PR #23 round 2 で実際に発生した事例の回帰防止テスト追加)。 -- **ループ終了時の最終スイープ (Step 7.5) を必須化** - (`skills/cross-review/SKILL.md` / `docs/02-fix-and-rotation.md`): - - approved / max_rounds / oscillation / error いずれの終了経路でも `/ndf:fix` を再実行し、 - 残った open review thread(最終 APPROVE ラウンドの minor/nit インラインコメント含む)を - **すべて Resolve** してから終了する。deferred nit も reply + resolveReviewThread で解消。 - - 従来の「deferred nit を最後にユーザ問い合わせ」を「最終スイープで全 thread Resolve + - report に参考列挙」へ変更。 -- **plugin.json: version 4.10.0 → 4.11.0**。marketplace.json / README / AGENTS.md / - ndf-plugin-reference の version 表記を整合(skill 数 45 は不変)。 - -### v4.10.0 (feature/add-ml-model-structure-skill: ml-model-structure skill 追加) - -機械学習モデル構築・推論API開発の標準ディレクトリ構造を提供する新規 skill を追加。 -skill 数を 45 個に更新し、関連ドキュメントのバージョン・skill 数表記を整合。 - -- **新規 skill `ml-model-structure` 追加** - (`skills/ml-model-structure/`): - - 機械学習モデルの版ごと自己完結構造 + 版内 feature SSoT + 推論コンテナ規約。 - - `references/inference-and-contract.md` に train↔serve 契約・推論コンテナ規約を分離。 -- **plugin.json: version 4.9.0 → 4.10.0、skills 配列に `ml-model-structure` を追加**、 - description を 45 skills に更新。 -- **marketplace.json / README / AGENTS.md / ndf-plugin-reference: skill 数・version 表記を整合**。 - -### v4.7.6 (fix/review-pr-comments: PRコメント取得を3ソース対応に拡張) - -PRレビューコメント取得が review body / PR レベルコメントを含む3ソースに対応。 -cross-review の state.py も堅牢性改善。 - -- **fetch-pr-comments.sh: 3ソース対応の新規スクリプト追加** - (`skills/fix/scripts/fetch-pr-comments.sh`): - - review comments / review body / PR-level comments の3ソースから取得。 - - `gh api` の `-q` フラグ誤用を修正し raw JSON → `jq -r` パイプに統一。 - - 取得失敗時に `die()` で中断するエラーハンドリング追加。 -- **state.py: 再開検知・パス解決の堅牢化** - (`skills/cross-review/scripts/state.py`): - - `cmd_init` の再開検知で `CROSS_REVIEW_TMP_DIR` 環境変数を考慮。 - - `_state_path()` が `_tmp_dir()` を直接使用するよう修正(読み込み元と参照先の乖離防止)。 - - `_resolve_tmp_dir` から mkdir 副作用を除去(`p.exists()` チェックに変更)。 - - `st.get()` の None フォールバックを `or ""` で安全化。 -- **fix/SKILL.md, review-pr-comments/SKILL.md: 3ソース対応の説明更新**。 -- **cross-review SKILL.md, docs: パス記述の微修正**。 - -### v4.7.5 (cross-review: TMP_DIR を worktree 内 .cross_review/ に統一) - -レビュースクリプトが一時ファイルを書き出す先を、従来の `~/.gemini/tmp/` や `/tmp/` -から **worktree 内 `.cross_review/`** に統一する PATCH リリース。 - -- **_tmpdir.sh: `_tmp_dir()` の解決先を `.cross_review/` に変更** - (`skills/cross-review/scripts/_tmpdir.sh`): - - workspace 未指定時に `git rev-parse --show-toplevel` でフォールバックし、 - worktree ルート直下の `.cross_review/` を使用。 - - worktree 外のゴミファイル散乱と、環境依存のパス不整合を解消。 -- **launch-codex.sh / launch-gemini.sh: TMP_DIR パス統一**: - - 各ランチャーが `.cross_review/` を前提とするよう修正。 -- **monitor.py / state.py: パス参照の整合**: - - `$TMP_DIR` 解決先の変更に伴い、ファイル探索パスを `.cross_review/` 体系に統一。 -- **SKILL.md / docs: ドキュメントのパス記述統一**: - - `SKILL.md`、`docs/02-fix-and-rotation.md`、`skills/merged/SKILL.md` 内の - 旧パス記述を `.cross_review` 体系に修正。 -- **.gitignore: `.cross_review/` を追加**。 - -#### 既存ユーザへの影響 - -- `CROSS_REVIEW_TMP_DIR` env を明示しているユーザ: **挙動不変**。 -- 未指定ユーザ: 一時ファイルの書き出し先が `~/.gemini/tmp/` → worktree 内 - `.cross_review/` に変更。worktree 内に閉じるため管理が容易になる。 - -### v4.7.4 (cross-review: gemini stall 既定の per-agent 化 + fix 戻り値マージの堅牢化) - -`/ndf:cross-review` を実走させた際に、cross-review ループ自体は完走するが -メインセッション側で手動補正が必要だった運用不整合の残り 2 件を恒久対応する -PATCH リリース。 - -- **monitor.py: per-agent stall timeout 既定の導入** (`skills/cross-review/scripts/monitor.py`): - - 従来は `MONITOR_STALL=180s` の単一既定値だったため、err.log にほぼ進捗を出さない - gemini を 1 度目に毎回 STALLED 扱いで kill していた (孤児プロセスも残存)。 - - 既定を agent 別ビルトインに変更: **codex=180s (変更なし) / gemini=480s**。 - - 解決順: CLI `--stall-timeout` (明示優先) > env `MONITOR_STALL_` (per-agent) > - env `MONITOR_STALL` (両 agent 共通、後方互換) > agent 別ビルトイン。 - - `--stall-timeout` の argparse default を `int` → `None` に変更し、未指定時のみ - per-agent 解決を行う。 -- **state.py merge-fix: fix 戻り値ファイルの探索 fallback と key 別名対応** - (`skills/cross-review/scripts/state.py`): - - サブエージェントが `/tmp/fix-pr-result.json` に書き、`merge-fix` は - `$TMP_DIR` (= `~/.gemini/tmp//`) のみ参照する不整合で - exit 3 (ci-code-fail 扱い) になる事象を解消。探索順を **(1) `--file` 明示 - → (2) `$TMP_DIR/` → (3) `/tmp/`** の 3 段に変更。 - - 全候補不在時の die メッセージに探索 path 一覧を含め、原因切り分け可能にした。 - - サブエージェントが `commit_sha` / `fixed` 別名キーで書き出した場合も - `fix_commit` / `fixed_count` と同等に受理する key alias を追加 (silent な - `fixed=0` 記録の救済)。 -- **SKILL.md / docs のパス記述統一**: - - `SKILL.md` 内の `/tmp/fix-pr<#>-result.json` ハードコード 3 箇所を - `$TMP_DIR/fix-pr<#>-result.json` に統一。 - - 「すべて `/tmp/` に置き」の汎用記述も `$TMP_DIR/` (= `_tmp_dir()` 解決先) に修正。 - - `docs/02-fix-and-rotation.md` に `$TMP_DIR` の解決順 - (env `CROSS_REVIEW_TMP_DIR` > `~/.gemini/tmp//` > `/tmp/`) を 1 行明示。 - - `docs/01-state-and-review.md` の monitor 説明表に per-agent stall 既定の - 解決順を追記。 -- **pytest 追加** (`skills/cross-review/tests/`): - - `test_monitor_stall_default.py` — codex/gemini ビルトイン + env 上書き 6 ケース。 - - `test_state_merge_fix.py` — 正規 / `/tmp/` fallback / key 別名 / 全候補不在 / - `--file` 明示の 5 ケース。 -- **関連 issue / plan**: - - 完了済み開発記録として削除済み。詳細な検討履歴が必要な場合は、本バージョン追加時の git 履歴を参照。 - -#### 既存ユーザへの影響 - -- `--stall-timeout` を CLI で明示しているユーザ: **挙動不変**。 -- `MONITOR_STALL` env を指定しているユーザ: **挙動不変** (両 agent に同じ値が適用)。 -- 何も指定していないユーザ: gemini 側の既定 stall timeout が 180s → 480s に - **緩和**される (kill されにくくなる方向のため非破壊)。 -- 正規パス (`$TMP_DIR/fix-pr-result.json`) + 正規 key (`fix_commit` / - `fixed_count`) で書き出していたユーザ: **挙動不変**。 -- 旧プロンプトで `/tmp/` ハードコードや別名 key を使っていたユーザ: **silent fail - していたケースが拾われるようになる** (修復方向の変更)。 - -### v4.7.3 (cross-review: macOS 対応 worktree base + result.json スキーマ堅牢化) - -`/ndf:cross-review` を非 Linux コンテナ環境 (macOS / WSL 等) でも素直に動かせるよう、 -worktree のデフォルトパス解決を環境適応型に変更。あわせて gemini が変則スキーマで -result.json を書き出すケースで intent が silent に None マージされて judge が空回り -する不具合を恒久対応する PATCH リリース。 - -- **worktree デフォルトパスの環境適応** (`skills/cross-review/scripts/state.py`): - - `state.py init` が以下の優先順で worktree 親ディレクトリを解決: - 1. `NDF_WORKTREE_BASE` 環境変数 (明示オーバーライド) - 2. `/work/worktrees` (Linux コンテナ環境互換。書込可ならこちらを使用) - 3. `$HOME/work/worktrees` (macOS / WSL 等のフォールバック) - - 既存の Linux コンテナ環境では `/work/worktrees` が引き続き使われ挙動不変。 - - SKILL.md / docs から `/work/worktrees/pr` のハードコードを除去し、 - `/pr` 表記に統一 (state.json サンプル中の解決例 1 箇所のみ残存)。 -- **gemini result.json スキーマの明示化** (`skills/cross-review/scripts/launch-gemini.sh`): - - 「フォーマットは launch-codex.sh と同じ」という曖昧指示を、codex と同一の - フィールド列挙ブロック (`event` / `posted_as` / `comments_count` / `review_url` / - `by_severity`) に置き換え。`intent` / `comment_count` 等の別名を使わないことを明記。 -- **`state.py read-result` の堅牢化**: - - 仕様 (`event` / `comments_count`) を優先しつつ、別名 (`intent` / `comment_count`) - も拾えるようフォールバックを追加。 - - `event` / `intent` いずれも欠落している場合は `die()` (exit 1) で fail する。 - 旧挙動 (silent な `intent=None` マージで judge が空回り) は **破壊的に修正**。 -- **monitor.py EARLY_ERROR 誤検知の修正** (`skills/cross-review/scripts/monitor.py`): - - SKILL.md / `docs/01-state-and-review.md` の Markdown 表セル内で FATAL キーワード - (`「quota exceeded」`「sandbox error」等) を列挙しており、codex がレビュー時に - それを echo すると err.log 上で `_scan_early_fatal()` が誤発火してプロセスを - kill していた。以下 2 段の防御で恒久対応: - 1. `EARLY_ERROR_BENIGN` に Markdown 表セル行 (`^\|`) を追加。 - 2. マッチ位置が backtick / 日本語「」で引用されている場合に benign 扱いする - `_match_is_quoted()` ヘルパを追加し、`_scan_patterns()` から呼ぶ。 - - FATAL パターンから `^.*` プレフィックスを外し、`m.start()` をキーワード位置に - 合わせて引用判定が機能するように修正。 -- **pytest 追加** (`skills/cross-review/tests/`): - - `test_state_read_result.py` — 正規/変則/欠落スキーマ 4 ケース。 - - `test_default_worktree_base.py` — env / legacy / fallback の 3 ケース。 - - `test_monitor_early_error.py` — Markdown 表 / backtick / 日本語クォート引用の - benign 判定と、本物 fatal が依然検知される回帰テスト 7 ケース。 - - ローカル実行: `uv run --with pytest pytest plugins/ndf/skills/cross-review/tests`。 -- **関連 issue / plan**: - - 完了済み開発記録として削除済み。詳細な検討履歴が必要な場合は、本バージョン追加時の git 履歴を参照。 - -#### 既存ユーザへの影響 - -- `/work/worktrees` が書ける環境 (大半の Linux コンテナ環境): **挙動不変**。 -- macOS / WSL 等で `/work` が書けない環境: `--worktree` 引数なしでも - `$HOME/work/worktrees/pr` に自動フォールバックして init が成功する。 -- gemini が変則スキーマ (`intent` / `comment_count`) で result.json を書く現象を - 観測していたユーザ: フォールバックで自動的に取り込まれるようになる。 -- `result.json` から `event` / `intent` が両方欠落しているケースは exit 1 で - 早期 fail する (旧: judge 段階まで silent に None が伝播)。 - -### v4.7.0 (fix / cross-review: 修正ポリシー刷新 + CI 完了待ち廃止) - -`/ndf:fix` と `/ndf:cross-review` の修正方針を見直し、PR の最終的なコード品質を -上げる方向にシフトする MINOR リリース。`/ndf:fix` の自動実行範囲が広がるため、 -利用側スクリプトが `--severity-min nit` 相当の挙動を前提にしている場合は要確認。 - -- **修正対象の拡張** (`skills/fix/SKILL.md`): - - minor / nit のうち **パフォーマンス・可読性・重複コード排除** に該当する指摘は - このPR内で対応する (旧: nit は基本 deferred)。 - - 特にトータルのコード行数が減る方向の修正 (重複排除 / 不要分岐除去) は積極実施。 - - ただし修正範囲が **+30 行を超えそうな場合は deferred + ユーザ問い合わせ** - (スコープ膨張による副作用とレビュー負担を抑える)。 -- **重要度ラベルの独自再判定**: - - AI agent (CodeRabbit / Copilot / codex / gemini) が付けた `[critical/major/minor/nit]` - ラベルを鵜呑みにせず、コード本体を読んだ上でカテゴリ - (performance/readability/duplication/security/style/...) と合わせて再判定する。 - - 例: AI が `nit` と付けていても実体が重複排除なら修正対象。AI が `critical` と - 付けていても実害がないスタイル指摘なら deferred 化してよい。 -- **CI 完了待ちを廃止**: - - `/ndf:fix` 内の `gh pr checks --watch` および "PENDING を完了まで待つ" 手順を削除。 - - 各チェックポイントでは **その時点で FAILURE のジョブのみ** を修正対象に取り込み、 - 実行中チェックは無視して次ステップへ進む。 - - 戻り値 `ci_status` / `ci_failed_checks` は push 時点での既知失敗のみを反映する - (メイン context の節約と、長時間ブロック回避が目的)。 - - cross-review 側の judge ロジック (`state.py`) は変更不要 — `ci_status != FAILURE` - なら継続判定するため、PENDING も成功扱いになる。 -- **PR テスト範囲外の flaky テストも修正対象**: - - 放置するとリポジトリ全体の CI 信頼性が劣化し、後続 PR にも波及するため、 - `/ndf:fix` 実行時に見つけ次第このループで修正する。 -- **作業完了報告に PR URL 必須**: - - `/ndf:fix` の最終報告末尾に `https://github.com///pull/<番号>` を - 必ず記載する(メインからの追跡性向上)。 -- **`cross-review` 側の同期**: - - `skills/cross-review/docs/02-fix-and-rotation.md` のサブエージェント起動プロンプト - (ポリシーと「必須実行手順」)を上記方針に合わせて更新。 - - 手順 3 を「CI 状態スナップショット (完了待ちしない)」、手順 7 を - 「CI 再実行は待たない」に書き換え。SKILL.md の手順番号繰り上がり - (旧 8 → 新 7) も反映。 - -#### 既存ユーザへの影響 - -- `/ndf:fix` 単体実行: minor/nit カテゴリのうち performance/readability/duplication - に該当するものが自動修正されるため、これまで deferred だった指摘が修正コミットに - 入る場合がある。+30 行を超える場合はユーザ問い合わせで止まる。 -- `/ndf:cross-review` 自動ループ: CI 完了を待たなくなった分、各 round の所要時間が - 短縮される。一方で push 直後の CI 失敗は次 round の review 段階で再検出される。 -- `result.json` の `ci_status` が `PENDING` になる頻度が増える。下流で `ci_status` - を見ているスクリプトがある場合は確認推奨(state.py の judge は変更不要)。 - -### v4.6.2 (cross-review: state.py init の TMP_DIR 計算順序バグ修正 + AGENTS.md リネーム) - -`/ndf:cross-review` で gemini が **workspace 制約違反で payload を書けず -hard timeout (420s) で常時失敗** していた不具合を修正する PATCH リリース。 -合わせて `claude plugin validate` の警告 (plugin root の `CLAUDE.md` は -project context として読み込まれない) に従い `plugins/ndf/CLAUDE.md` を -`plugins/ndf/AGENTS.md` にリネーム。 - -- 修正 (`skills/cross-review/scripts/state.py` `cmd_init`): - - 旧実装は `_tmp_dir(args.worktree)` を `args.worktree=None` のまま呼び、 - `os.getcwd()` の basename (= 親リポジトリ名) で `~/.gemini/tmp//` - を採用していた。一方、`launch-gemini.sh` は `cd "$WORKTREE"` してから - gemini を起動するため、gemini は `~/.gemini/tmp/` - (= `~/.gemini/tmp/pr`) しか workspace 内として許可せず、 - `write_file` が `Path not in workspace` で拒否されていた。 - - 修正後は `worktree = args.worktree or f"/work/worktrees/pr{pr}"` を - 先に解決してから `_tmp_dir(worktree)` に渡し、tmp_dir の basename を - worktree basename と一致させる。 - - 副次効果: `state_file` の path も `~/.gemini/tmp/pr/` 配下に揃うため、 - cross-review が PR ごとに完全に分離した tmp 空間で動く。 -- リネーム: `plugins/ndf/CLAUDE.md` → `plugins/ndf/AGENTS.md`。 - Claude Code は plugin root の `CLAUDE.md` を project context として - 読み込まないため、`claude plugin validate` が警告を出していた。 - リポジトリ root の `AGENTS.md` (本体) + `CLAUDE.md` (Claude 固有) と - 揃え、プラグイン側は `AGENTS.md` に一本化する。 - - `plugins/ndf/README.md` のリンクも更新。 - - `claude plugin validate` の警告 0 件を確認。 - -#### 既存ユーザへの影響 - -- 旧 tmp_dir (`~/.gemini/tmp//`) に途中状態 (`state.json`) が残っている - 場合、v4.6.2 以降は `~/.gemini/tmp/pr/` を参照するため state を見失う。 - 対応: `mv ~/.gemini/tmp//cross-review-pr-* ~/.gemini/tmp/pr/` - で移行するか、`/ndf:cross-review ` を再 init する。 -- cross-review は 1 PR 単位の短命ステートのため、影響は実行中ループのみ。 - -### v4.6.1 (cross-review skill 主要処理のスクリプト化) - -`cross-review` skill の主要 bash 処理を `scripts/` 配下に外出し、SKILL.md / -docs/01,02 から冗長なインライン bash を排除する PATCH リリース。 -SKILL の I/O 契約 (state.json / result.json / payload.json スキーマ) は不変。 - -- 新規追加: - - `scripts/state.py` — state.json 操作 CLI (uv 自己完結 / stdlib のみ) - サブコマンド: `init` / `start-round` / `read-result` / `judge` / - `check-oscillation` / `merge-fix` / `should-rotate` / `set-current-pr` / - `report` - - `scripts/launch-codex.sh` / `scripts/launch-gemini.sh` — レビューランチャ - (pidfile + sentinel ベース、trusted directory 対策込み) - - `scripts/monitor.py` — codex/gemini プロセス多軸監視 CLI - (uv 自己完結 / stdlib のみ)。pidfile + `/proc` cmdline 検証 / codex sentinel / - 早期エラーパターン検出 / err.log stall timeout / hard timeout / result.json - 存在確認の 6 軸を並列スレッドで判定。exit code で失敗種別を区別 - (OK=0 / TIMEOUT=2 / NO_RESULT=3 / EARLY_ERROR=4 / STALLED=5 / PIDFILE_BAD=6)。 - sentinel 単独で完了判定する旧 `wait-review.sh` の取りこぼし - (codex クラッシュ時の無限ハング、gemini の untrusted directory 静かな失敗、 - pidfile stale 等) を解消。 - - `scripts/wait-review.sh` — `monitor.py` の薄ラッパ(旧 CLI 互換のため残置) - - `scripts/rotate-pr.sh` — PR ローテーション (squash + 新ブランチ + 新 PR) -- SKILL.md / docs/01,02 を「スクリプト呼び出し」形式に置換。state.json と - result.json のスキーマは docs に残し、実装は scripts/ にカプセル化。 - -PR #72 の実機テストで得た codex / gemini からの指摘および追加で見つかったバグの -対応(同 v4.6.1 内で実施): - -- **`monitor.py`**: - - cmdline 検証順序を「alive 確認後のみ」に変更。プロセスが既に死んでいる場合は - cmdline 不一致でも PIDFILE_BAD にならず、result.json の有無で OK 判定する - (旧実装は完了済 launcher を誤って失敗扱いしていた)。 - - EARLY_ERROR パターンを **行頭限定** + benign フィルタに改修。diff / doc 引用に - `401 Unauthorized` などのキーワードが含まれても誤検知しなくなった。 - - TIMEOUT / STALLED / EARLY_ERROR / PIDFILE_BAD で返るとき、対象プロセスに - SIGTERM (3 秒後に SIGKILL) を送信。残存プロセスが後から `gh api` 投稿や - result.json 書き込みを行ってメインと競合する問題を解消。 - - stall 判定を err.log のみから **err.log + stdout.log の合計サイズ** に拡張。 - - **デフォルト値変更**: hard timeout 30 分 → **7 分**、stall timeout 10 分 → **3 分**。 - - 未使用 import `field` を削除。 -- **`state.py`**: - - `gh api --paginate` の JSON ストリーミング処理を `--jq` ベースに変更 - (旧: `json.loads(r.stdout)` は複数ページで JSONDecodeError → 空配列 → - 既存コメントスナップショットが空になり重複指摘禁止が無効化されていた)。 - - `st["rounds"][-1]` への参照前に空チェックを追加し、初期化失敗時の - IndexError を防止 (read-result / judge / merge-fix の 3 箇所)。 - - 未使用 import `os` を削除。 -- **`launch-codex.sh` / `launch-gemini.sh` / `rotate-pr.sh`**: - - 引数を `STATE_PR` (= state.json の key, 初期 PR) に統一。レビュー対象の - 「現在の PR」は state.json の `current_pr` を内部で読む。 - 旧実装は PR rotation 後にメイン側で `PR=$NEW_PR` に切り替えると state.json - パスが見つからなくなる設計矛盾があった。 -- **tmp ディレクトリの gemini workspace 制約対応**: - - 全 scripts の tmp パスを `/tmp/` 直書きから `$CROSS_REVIEW_TMP_DIR` 経由に変更。 - 未設定なら `~/.gemini/tmp//` を自動採用、最終フォールバックは `/tmp/`。 - gemini CLI は `--yolo --skip-trust` でも workspace 外の `read_file` / - `write_file` がブロックされる (`Path not in workspace`) ため、gemini 公式の - project temp directory に揃えることで result.json / payload.json の書き出しを - 成立させる。 - - 共通ヘルパ `scripts/_tmpdir.sh` を追加 (bash) / `state.py` と `monitor.py` に - `_tmp_dir()` 関数を追加 (Python)。`state.py init` は採用した `TMP_DIR` を - state.json に記録し、stdout の `TMP_DIR=` で呼び出し側に通知。 - - SKILL.md のテンプレートで `eval "$(state.py init ...)"` 後に - `export CROSS_REVIEW_TMP_DIR="$TMP_DIR"` を行い、後続スクリプトに env として - 伝播させる手順を追加。 -- **`SKILL.md` / `docs/01,02`**: - - bash テンプレートを `$STATE_PR` 固定で書き直し、rotation 後も同じ変数で - 全 scripts を呼ぶ手順に統一。 - - 新デフォルト (timeout=7 分 / stall=3 分) を反映。 - -### v4.6.0 (cross-review skill 改訂 + review/fix の result.json 拡張) - -実運用で得た失敗パターンの対策を `cross-review` skill に反映し、関連する -`/ndf:review` と `/ndf:fix` の result.json schema を拡張する MINOR リリース。 - -- **`cross-review` skill 改訂**: - - **「事前確認」セクション新設**: ループ開始前の 4 プリチェック - 1. 自分の PR 判定 → `event` ダウングレード設定 (GitHub の - `HTTP 422: Can not request changes on your own pull request` を回避) - 2. **worktree 分離** (`/work/worktrees/pr`) で並行セッション競合回避 - 3. **gemini の trusted directory 対策**: - `GEMINI_CLI_TRUST_WORKSPACE=true` + `--skip-trust` 両方併用必須 - (worktree のような新規パスは untrusted 判定 → YOLO が "default" に降格される) - 4. 既存コメントスナップショット保存 → launcher プロンプトに添付して重複指摘禁止 - - **state.json schema 拡張**: - - `worktree_path` / `pr_author` / `is_own_pr` / `event_downgrade` を追加 - - 各 round の `codex` / `gemini` を **`intent` + `posted_as` の二重保持** に変更 - (ループ収束判定は `intent`、GitHub 投稿実体は `posted_as`) - - 各 round に `by_severity` (`{critical, major, minor, nit}` 件数) を追加 - - 各 round の `fix` に `resolved_threads` / `ci_note` を追加 - - **レビュー body 必須 identifier prefix**: - `## 🤖 cross-review | round | | ` を先頭固定化 - (人間アカウントから AI が投稿するため、GitHub UI で発信元を可視化) - - **CI failure の分類**: `pint/larastan/test/build/lint/type/tsc/mypy` 等は - code-fail として中断、`check_pr_requirements/assignees/reviewers/labels` - 等のメタチェックのみ失敗ならループ継続 - - **waiter を pidfile + sentinel ベースに**: - - codex は `^tokens used$` sentinel - - gemini は long `-p` プロンプトが引数に乗るため `pgrep -fa` 不可、 - `pidfile + kill -0` ポーリング必須 - - **`Step 5` サブエージェント責務を 5 点明示**: - 修正コミット / テスト / **reply + `resolveReviewThread` で Resolve** / - deferred は記録のみ・Resolve しない / 戻り値ファイル書き出し -- **`/ndf:review` SKILL.md 更新**: - - 結果サマリ (`/tmp/-review-pr<番号>-result.json`) に `posted_as` - フィールドを追加 - - `event` (intent / 本来の判定) と `posted_as` (実投稿) の使い分けを文書化 - - 自分 PR ダウングレードフローを `intent="REQUEST_CHANGES"` / - `posted_as="COMMENT"` で記録する手順を明記 -- **`/ndf:fix` SKILL.md 更新**: - - 戻り値ファイル `/tmp/fix-pr<番号>-result.json` に - `resolved_threads` (配列) / `ci_failed_checks` (配列) / `ci_note` (string) - を追加 - - 手順 12 に「resolve した thread_id / comment_id / path / line を - `resolved_threads[]` に記録」「`deferred` / `rejected` の thread は - Resolve しない」を明記 - - 手順 13 に `ci_failed_checks` の収集元 (`gh pr checks --json name,state`) - を補記 -- **アンチパターン 5 件追加**: - - 自分の PR に `REQUEST_CHANGES` で投稿 - - `gemini --yolo` 単独起動 (`--skip-trust` 併用必須) - - `pgrep -fa ` で完了判定 - - fix サブエージェントが Resolve をスキップ - - review body に identifier prefix を付け忘れる -- Skills: 39個 (変化なし、`cross-review` / `review` / `fix` の中身を更新) - -### v4.5.0 (playwright-scenario-test v0.5.0 — Skill 非依存 self-contained 構成 / 名前空間 rename) - -> **注意**: 互換性破壊リリース。Python パッケージ名・fixture 名・CLI option・ -> 環境変数・内部クラス名がすべて変わる。既存利用者は manual に書き換え必要。 -> 詳細な検討履歴が必要な場合は、本バージョン追加時の git 履歴を参照。 - -- **`playwright-scenario-test` v0.5.0** (Skill 非依存化 + 名前空間整理): - - **目的**: 旧 v0.4.0 までは Skill ディレクトリで `uv sync` する必要があり、 - Skill が消えるとテストが動かない / CI 別マシンで再現性が低い問題があった。 - v0.5.0 では `scripts/init_project.sh` で **利用者プロジェクト直下に - `scenario-test/` (all-in-one ランタイム)** を埋め込み、Skill 非依存で動作させる。 - - **新規 init / launcher**: - - `scripts/init_project.sh` / `scripts/init_project.bat`: - `//` に playwright_kit / scripts / uv.lock / - runtime templates を rsync ベースでコピーし、初回 uv sync + - playwright install chromium まで実行。`--runtime-dir ` で配置先名 - カスタマイズ可、`--dry-run` で予定差分のみ表示、`scenario.config.yaml` / - `tests/test_*.py` は既存があれば上書きしない (利用者編集物保護) - - `templates/run.sh` / `templates/run.bat`: - `$(dirname BASH_SOURCE)` / `%~dp0` で自身の位置を解決し CWD をランタイム - 内に固定。初回のみ `uv sync` + `playwright install chromium`。 - `--help` / `$@` で pytest 引数素通し - - `templates/pyproject.toml.runtime` / `templates/runtime-gitignore` / - `templates/runtime-README.md`: 利用者プロジェクト埋め込み用テンプレート - - **破壊的 rename** (Phase 0 / 0a): - - **Python パッケージ**: `scenario_test` → `playwright_kit` - - **pytest entry-point**: `ndf-scenario-test` → `playwright-kit` - - **fixture**: `ndf_config` → `pwk_config`, `ndf_role_` → - `pwk_role_`, `ndf_evidence` → `pwk_evidence`, - `ndf_a11y_scan` → `pwk_accessibility_scan`, - `ndf_cwv_measure` → `pwk_web_vitals_measure`, - `ndf_body_check_scan` → `pwk_body_check_scan`, - `ndf_out_dir` → `pwk_out_dir` - - **CLI option**: `--ndf-config` → `--pwk-config`, `--ndf-out-dir` → - `--pwk-out-dir`, `--ndf-no-evidence` → `--pwk-no-evidence`, - `--ndf-har-mode` → `--pwk-har-mode`, `--ndf-hud` → `--pwk-overlay`, - `--ndf-drive-folder` → `--pwk-drive-folder` - - **env var**: `NDF_CONFIG` → `PWK_CONFIG` - - **内部クラス**: `NdfTestEntry` → `PwkTestEntry`, `NdfEvidence` → `PwkEvidence` - - **モジュール rename** (Phase 0a, ドメイン用語の整理): - - `scenario_test/a11y.py` → `playwright_kit/accessibility.py` - (a11y は WCAG ドメイン用語のため平易な英語に) - - `scenario_test/cwv.py` → `playwright_kit/web_vitals.py` - (CWV → Core Web Vitals) - - `scenario_test/hud.py` → `playwright_kit/overlay.py` - (HUD = Heads-Up Display は造語的、overlay の方が直観的) - - **config schema**: `accessibility:` / `web_vitals:` キーに統一 - (旧 `a11y:` / `cwv:` は廃止) - - **EvidenceCollectors fields**: `cwv_metrics` → `web_vitals_metrics`, - `cwv_passed` → `web_vitals_passed` - - **保持するもの** (W3C / 業界標準): - - `LCP` / `CLS` / `TTFB` / `longest_task` / `HAR` / `axe-core` — - データフィールド名・外部仕様名としてそのまま使用 (各 docstring と - SKILL.md の用語集セクションで正式名称を併記) - - **SKILL.md / docs**: クイックスタートを `init_project.sh → run.sh` フローに - 全面書き換え、用語集セクションを SKILL.md 上部に新設、ディレクトリ図を - rename 後 + init 後構造に更新、開発者向け「Skill 単体で uv sync する旧運用」 - 節を別出し - - **検証**: - - 159 件 pure 関数テスト pass (config / fixtures / pytest plugin / report 全般) - - 擬似環境 (`/tmp/...`) に init 後、Skill ディレクトリを `mv` で隠した - 状態で `./scenario-test/run.sh --collect-only` が完走 (8 件 collect, exit 0) - - `--runtime-dir e2e` 配置で複数ランタイム共存 (`scenario-test/` + `e2e/`) - が独立に動作 - - 再 init で `tests/test_*.py` の利用者編集が保護される (skip) - - `--dry-run` で実際にはコピーされない -- Skills: 39個 (変化なし、playwright-scenario-test の中身が刷新) - -### v4.4.0 (issue-plan-strategy skill 追加) - -- **新規 Skill `issue-plan-strategy`**: - - 1 つの issue から plan を起こし、推奨 PR が複数に分かれる場合の標準ワークフローを規定 - - スラッシュコマンド (`/ndf:issue-plan-strategy `) でも、 - **(a) issue から plan 作成依頼** / **(b) 既存 plan の実装(実行)依頼** の両方で自動発動する - (description / Triggers に発動条件を明記) - - Step 0 で plan ファイル / release branch / Draft PR の有無を見て **作成フェーズ vs 実行フェーズ** - を切り分け、実行フェーズで入った場合は Step 1 をスキップして Step 3 以降の自動化に直行 - - フロー: issue 取得 → (作成) plan (必要なら plan モード) → 単一/複数 PR 判定 → - (実行) release branch + Draft release PR 先行作成 → 個別 PR ブランチ + Draft PR 先行作成 → - git worktree で並行開発 → 個別 PR レビュー (`/ndf:review` / `/ndf:cross-review`) → - release ブランチで結合テスト相当のレビューのみ → release → default merge - - 検証環境 (qa/staging) は個別 PR or release PR 単位で `/ndf:cherry-pick-pr` に委譲 - - 関連 skill (`implementation-plan` / `branch-fix-strategy` / `pr` / `cherry-pick-pr` / - `review` / `cross-review` / `playwright-scenario-test`) との責務分担を明記し、 - 本 skill は **multi-PR 運用のメタ手順**に専念 -- Skills: 38個 → **39個** - -### v4.3.1 (Fix: cross-review / gemini skill 登録漏れ修正) - -- PR #67 で追加された `cross-review` / `gemini` skill が `plugin.json` の - `skills` 配列に登録されておらず、`/ndf:cross-review` / `/ndf:gemini` を - slash command として呼び出せなかった問題を修正 -- 併せて description と CLAUDE.md / AGENTS.md / README.md の skill 数を - 36個 → 38個 に更新 -- Skills: 36個 → **38個** (実体は v4.3.0 時点で既に存在、登録のみ追加) - -### v4.3.0 (playwright-scenario-test v0.4.0 — body_check 復活) - -- **`playwright-scenario-test` v0.4.0**: 旧 v0.2.x の自前 YAML runner にあった - **`body_check`** 機能 (PHP / SSR がフロントに漏れさせる ``Fatal error`` / - ``Uncaught`` / ``STRICT:`` / ``Warning:`` / ``Notice:`` / ``File not found`` - 等を **テスト失敗として検出**) を **default 有効** で復活させた。 - まだ開発・検証段階の skill のため互換性は重視せず、config 無記述でも - PHP プロジェクトで素直に効くよう default で `enabled=True` + PHP 系 - パターンを内蔵する。詳細な検討履歴が必要な場合は、本バージョン追加時の git 履歴を参照。 - - **新モジュール**: - - `scenario_test/body_check.py` — 純粋関数 `scan_body` / `is_html_response` - と `BodyViolation` dataclass。substring match で fatal / warning / - not_found カテゴリの violation を返す - - `scenario_test/fixtures/body_check.py` — autouse fixture - (`_ndf_body_check_autouse`) と明示呼び出し用 helper (`ndf_body_check_scan`)。 - `page.on("response", ...)` で全 HTML レスポンスを監視し、teardown で - `case_dir/body_check.jsonl` に書き出してから violation 数次第で - `pytest.fail` - - **新 config schema** (`scenario.config.yaml`, 省略可): - ```yaml - body_check: - enabled: true # default: true (機能無効化したい場合のみ false) - fatal_patterns: ["Fatal error", "Uncaught", "Parse error"] - warning_patterns: ["STRICT:", "Warning:", "Notice:", "Deprecated:"] - warning_head_chars: 300 # warning_patterns は本文先頭 N 文字のみ走査 (PLAN18 のフィールド名 warning_head_bytes も alias で受理) - not_found_patterns: ["File not found"] - fail_on_match: true # false で情報収集モード - ``` - 各キーは**省略すると dataclass の default**が効く (PHP 系パターン内蔵)。 - 明示的に空リストを書けばそのカテゴリのみ無効化できる。 - - **新 marker**: `@pytest.mark.no_body_check` で個別テスト opt-out - - **report.md**: - - サマリ表に `body_check` カラムを追加 (違反件数) - - 違反があれば「body_check 違反の詳細」セクション (URL / pattern / snippet) - を生成 (PASS でも `fail_on_match=false` の情報収集モードで表示) - - **設計ポイント**: - - 既存利用者の test 挙動を変えないため `body_check.enabled` の default は - **False** (opt-in)。設定を書かない限り従来挙動 (検出ロジックなし) が維持される - - autouse fixture は a11y / cwv と同じく `request.fixturenames` ガードで - `page` を要求していない test を skip - - body_check が teardown で `pytest.fail` した場合、call phase は passed - のまま teardown report が failed/error になるため、`_collect_entries` で - teardown 失敗を call entry に反映 (`outcome` を passed → failed に昇格) - - **検証**: 既存 + body_check / report / collect_entries 昇格 / sessionfinish - upload を含めて **147 件 pure 関数テスト pass** (`uv run pytest -q`) -- Skills: 36個 (変化なし、playwright-scenario-test の中身に opt-in 機能追加) - -### v4.2.0 (playwright-scenario-test v0.3.0 — pure pytest-playwright 完全移行) - -> **注意**: 互換性破壊リリース。v0.2.5 までの自前 YAML DSL は **完全廃止** し、 -> 利用者は通常の pytest-playwright テストを書く形に移行する。詳細な検討履歴が必要な場合は、 -> 本バージョン追加時の git 履歴を参照。 - -- **`playwright-scenario-test` v0.3.0** (pure pytest-playwright): - - **アーキテクチャ全面刷新**: 自前 DSL (testcase YAML / runner / dispatcher / - locator_steps / cli) をすべて削除。代わりに pytest plugin として実装し、 - 利用者は `def test_xxx(page, ndf_role_admin): ...` を直接書く - - **新モジュール**: - - `scenario_test/pytest_plugin.py` — pytest11 entry-point。 - `pytest_addoption` (`--ndf-config` / `--ndf-out-dir` / `--ndf-no-evidence` - / `--ndf-hud` / `--ndf-drive-folder`)、markers (`page_role` / `role` / - `phase` / `priority`)、`pytest_runtest_makereport` / - `pytest_terminal_summary` / `pytest_sessionfinish` hook - - `scenario_test/pytest_report.py` — `report.md` 生成 (`NdfTestEntry` + - `render_markdown` + `write_report`) - - `scenario_test/fixtures/auth.py` — `ndf_config` (session) / - `ndf_role_` (動的生成、storage_state cache 付) - - `scenario_test/fixtures/evidence.py` — `ndf_evidence` / - `browser_context_args` override (HAR inject) / `ndf_out_dir` - - `scenario_test/fixtures/a11y.py` — `_ndf_a11y_autouse` (page_role marker - が付いた test に限り axe-core 自動実行) + `ndf_a11y_scan` 明示ヘルパ - - `scenario_test/fixtures/cwv.py` — `_ndf_cwv_autouse` (page_role marker - autouse で LCP/CLS/TTFB/longest_task 計測) - - **削除**: - - `scenario_test/testcase.py` の `Step` / `LocatorSpec` / - `KNOWN_STEP_KINDS` / `discover_testcases` 等 - - `scenario_test/locator_steps.py` / `runner.py` / `cli.py` / - `playwright_executor.py` / `report.py` (旧) - - `scripts/record_to_yaml.py` / `generate_test_plan.py` (DSL 雛形版) - - `templates/testcase-*.yaml.template` 6 ファイル + `config.example.yaml` - - **新規 templates** (pytest 雛形): - - `templates/scenario.config.yaml` — base_url / roles / a11y / CWV 設定 - - `templates/conftest.py.template` - - `templates/test_auth.py.template` / `test_list.py.template` / - `test_form.py.template` / `test_dashboard.py.template` - - **依存追加** (main): `pytest>=8.0`, `pytest-playwright>=0.5`, - `pytest-xdist>=3.0` - - **設計上の重要ポイント**: - - autouse fixture が `page` を直接要求すると pytest-playwright が全 test を - browser parametrize する問題を、`request.fixturenames` ガード + - `getfixturevalue` 遅延取得で回避 - - `ndf_role_` の login は session 内 1 回だけ実行し storage_state を - cache。新 context には cookies/origins を inject して再ログイン回避 - - **検証**: 旧 126 + 新規 26 = **152 件 pure 関数テスト pass** - - SKILL.md は pytest 中心の構成に全面書き直し -- Skills: 36個 (変化なし、playwright-scenario-test の中身が刷新) - -### v4.1.1 (playwright-scenario-test v0.2.5 — locator-first DSL 中間版) - -> **Note**: 当初は v0.3.0 として実装したが、Codex の独立レビューで「pure -> pytest-playwright 移行の方が OSS 品質として優れている」と判断 (locator DSL は -> 自前で再実装が必要、artifact 契約も pytest plugin の方が自然) し、v0.3.0 の -> 番号は **pure pytest 完全移行** に予約しなおした (PLAN17 参照)。本リリースは -> evidence/a11y/CWV/slug 衝突回避などの本質的改善のみを v0.2.5 として暫定提供。 - -- **`playwright-scenario-test` v0.2.5** (locator-first / web-first 中間版): - - **testcase YAML スキーマを刷新**: 旧 `path/method/data/extract` 構造を廃止。明示的 `kind` (`goto/click/fill/select/check/press/hover/extract/wait_for/wait_ms/expect_visible/expect_hidden/expect_text/expect_no_text/expect_url/expect_count/expect_aria_snapshot`) に統一 - - **Locator 表現**: `{role: button, name: 保存}` / `{label: メールアドレス}` / `{testid: ...}` / `{css: ...}` 等の dict で記述。Playwright 公式の `get_by_*` 系 API に 1 対 1 対応 - - **assertion は `expect()` のみ**: 自前 `body_check` と HTML 文字列 match を全廃。`expect_no_text` step で代替 - - **新モジュール**: - - `scenario_test/locator_steps.py` — YAML step kind → Locator/expect API dispatcher (テーブル駆動) - - `scenario_test/evidence.py` — 1 testcase 分の HAR/trace/console/pageerror/axe/CWV を集中管理 - - `scenario_test/a11y.py` — axe-core ランナー内蔵モジュール (page_role に応じて自動実行) - - `scenario_test/cwv.py` — Core Web Vitals ランナー内蔵モジュール (page_role に応じて自動実行) - - **削除**: - - `scenario_test/nav_helpers.py` (全関数を locator-first で置換) - - `scripts/trace_link.py` (`upload_evidence.py` に統合) - - 古い `path` ベース step / `body_check` / `slug` config / `enable_scroll_demo` config - - **新規 scripts**: - - `scripts/upload_evidence.py` — trace/HAR/video の Drive アップ統合 (拡張子から自動判定) - - `scripts/record_to_yaml.py` — Playwright codegen Python 出力 → 新スキーマ YAML 変換 - - **Min-2 (slug 衝突)**: `_default_test_id(role, url)` で URL 全 path + sha1[:6] suffix を付与し衝突回避 - - **Maj-7 (責務分離)**: `playwright_executor.py` 678 → 約 380 行へ縮小 (evidence は `EvidenceCollectors` へ完全分離) - - **report.md** に axe-core 違反 / Core Web Vitals metrics を表示 - - templates/ を 6 ファイル全部新スキーマで書き直し - - docs/ から「v0.3.0 以降で検討」記述を解消、`navigate_post` / `find_click_target` / `detect_body_errors` 等の旧 helper 言及を削除 -- Skills: 36個 (変化なし) - -### v4.1.0 -- **`playwright-scenario-test` v0.2.0** (理論ベース化): - - `docs/` 配下に方法論を 6 ファイル (総論 / page role / 技法 / Playwright API / bug report) + checklists 11 ファイル (lp/list/item/edit/form/search/dashboard/auth/cart-checkout/modal-wizard/common) として整備 - - 出典: HTSM v6.3 (James Bach), ISTQB CTFL 4.2, ISO/IEC/IEEE 29119-3:2021, WCAG 2.2, OWASP Top 10:2025, FEW HICCUPPS, Hendrickson Cheat Sheet - - 新規スクリプト: `classify_page_role.py` (a11y tree から自動 role 判定), `generate_test_plan.py` (Pairwise 込み YAML 自動生成), `run_a11y_scan.py` (axe-core), `check_cwv.py` (LCP/CLS/TTFB), `record_scenario.py` (Playwright codegen ラッパー), `trace_link.py` (trace.zip → playwright.dev URL) - - 役割別 testcase YAML テンプレート 4 件 (list/edit/form/auth) を追加 - - SKILL.md は実行手順とナビゲーションに集中 (332 → 245 行) - - pyproject.toml に optional-dependencies `a11y` (axe-playwright-python) を追加 -- **公式 Agent Skill 仕様準拠**: 14 skill の frontmatter を Pattern A (description 単体に Triggers 埋め込み) から Pattern B (description + 公式 `when_to_use` フィールド分離) へ移行。対象 skill: codex / data-analyst-export / data-analyst-sql-optimization / deepwiki-transfer / docker-container-access / git-gh-operations / google-auth / google-chat / google-drive / markdown-writing / official-skills-autoloader / playwright-scenario-test / python-execution / qa-security-scan / skill-stats。`description` は概要に集中、`when_to_use` に Trigger phrase を分離して auto-invoke 精度向上 (公式 1,536 字上限内)。`mcp-builder` は Anthropic 公式 (Apache-2.0) のため改変せず。 -- **コード品質改善**: - - `google-chat/scripts/gchat_read.py`: `DEFAULT_SPACE_ID` のハードコード (`AAQA6AWG1iE`) を撤去。env `GCHAT_DEFAULT_SPACE` で指定するか `--space` を required にする運用に変更 - - `google-auth/SKILL.md`: `allowed-tools` から不要な `Bash(pip *)` を削除 (uv で完結) - - `google-drive/scripts/gdrive_fetch.py`: `upload_file` 内の dead な再 `import os` を削除 - - `playwright-scenario-test/SKILL.md`: 不足していた `allowed-tools` (Read / Bash(uv *) / Bash(python *)) を追加 -- **新規 Skill `playwright-scenario-test`** (self-contained uv project): - - Playwright + curl で Web シナリオ E2E テストを並列実行 - - HUD オーバーレイ (カーソル + 字幕) 焼き込み済み動画 + Markdown レポート生成 - - Drive アップロード自動化までサポート - - 外部プロジェクトは `config.yaml` + `testcases/*.yaml` のみで利用可能 -- **新規 Skill `google-drive`**: - - Google Drive / Docs のファイルエクスポート / ダウンロード / アップロード (公開共有リンク付与) - - 認証は `ndf:google-auth` の `get_credentials()` に委譲 -- **新規 Skill `google-chat`**: - - Google Chat スペースのメッセージ・スペース一覧取得 (Chat API) - - 認証は `ndf:google-auth` の `get_credentials()` に委譲 - - 旧 uttarov 版のハードコードパス (`/work/uttarov2-doc/...`) を撤廃し、 - sibling-skill discovery (`GOOGLE_AUTH_SCRIPTS` env / `~/.claude/skills/google-auth/scripts` / - 隣接スキル) でフォールバック -- **`google-auth` v0.2.0 (互換性破壊)**: - - Python ライブラリ用法 (`from google_auth import get_credentials`) を追加 - - `--manual` 手動 copy-paste フロー (ローカルサーバ不要、コンテナ環境対応) - - トークン自動リフレッシュ + スコープ不足検出 / 自動マージ - - `--show` / `--clear` サブコマンド - - **トークン保存先を `/tmp/google_token.json` → `~/.config/gcloud/google_token.json` に変更** - (env `GOOGLE_TOKEN_FILE` で上書き可) - - `client_secret` パスは `--client-secret` → env `GOOGLE_CLIENT_SECRET` → - `${CLAUDE_SKILL_DIR}/client_secret.json` → CWD の順 -- Skills: 33個 → **36個** - -### v4.0.0 (BREAKING: Codex MCP廃止 + レガシー救済機構削除) -- **Codex MCP サーバを削除** (`.mcp.json` から `codex` エントリを削除) - - 理由: `/ndf:codex` skill (CLI直接実行) で十分であり、MCP 経由の制約 (ホスト側ファイル読み取り制限等) よりも CLI 直接実行の方が有用 - - 影響: `mcp__codex__codex` / `mcp__codex__codex-reply` は利用不可 - - 代替: `/ndf:codex` skill の手順で `codex exec` をバックグラウンド実行、または `corder` エージェント経由で呼び出し -- **corder エージェントを CLI ベースに書き換え** - - MCP 呼び出しを `/ndf:codex` skill 参照に変更 - - Serena / Context7 MCP は引き続き利用 -- 他エージェント (researcher, qa, devops-engineer, debugger, code-reviewer, director) の description から Codex MCP 言及を削除 / CLI ベースに更新 -- `skills/codex` の MCP 版との使い分け節を corder エージェントとの使い分けに書き換え -- `skills/qa-security-scan/03-report-template.md` の JS 疑似コードを `codex exec` bash 例に置換 -- **レガシー CLAUDE.ndf.md 救済機構を削除** (v3.0.0 で本体廃止、以降の救済装置を除去) - - `hooks/hooks.json` の CLAUDE.ndf.md 検出 hook を削除 - - `skills/cleanup/` を削除 (`plugin.json` の参照も削除) - - まだ残っているユーザーは今後手動で `CLAUDE.ndf.md` を削除してください -- Skills: 34個 → **33個** (`cleanup` 削除) - -### v3.7.0 -- **Transcript保持期間の自動管理**: - - `SessionStart` hook (matcher: `startup`) + `scripts/ensure-retention.sh` を追加 - - `~/.claude/settings.json` の `cleanupPeriodDays` を最低 90 日に保つ (既に 90 以上ならそのまま) - - 7 日タイムスタンプガード (`~/.claude/.ndf-retention-checked`) で多重実行防止 - - Claude Code 本体の公開 API/ドキュメントには「プラグインインストール時」hook が存在しないため、`SessionStart + startup` matcher が事実上の最適解 -- **`/ndf:skill-stats` skillを追加**: - - `~/.claude/projects/**/*.jsonl` transcript から NDF skill 利用統計を集計 - - 項目: 呼び出し数 / 関連話題数 / ヒット数 / ヒット率 - - 関連話題判定は SKILL.md frontmatter の `Triggers: '..', '..'` 行を使用 (明示されていない skill は計算対象外) - - Python 実装、標準ライブラリのみ - - skill description の網羅性を評価するツールとして機能 -- Skills: 33個 → **34個** - -### v3.6.0 -- carmo-system-consoleから汎用skill/commandを抽出してNDFに統合 -- **新規Skills(13個)**: - - 原則系(5個): `branch-fix-strategy`, `implementation-plan`, `investigation-rules`, `problem-solving`, `logging-guidelines` - - ワークフロー系(7個): `sync-main`, `cherry-pick-pr`, `deploy`, `review-branch`, `review-pr-comments`, `resolve-pr-comments`, `browser-test` - - 外部AI委譲(1個): `codex`(CLI直接実行、MCP版corderとの使い分け) -- **既存Skill改修**: - - `pr`: `--draft`フラグ対応、既存PR説明の自動更新、base非mainの場合`cherry-pick-pr`誘導、`.github/pull_request_template.md`サポート -- Skills: 20個 → **33個** -- PR/コードレビュー系の責務分担を明確化: - - `review` = PR単位レビュー(Approve/Request Changes判定) - - `review-branch` = ローカル差分レビュー(PR前のセルフレビュー) - - `review-pr-comments` = 既存PRコメントの分類(READ-ONLY) - - `fix` = コメント対応の修正実施 - - `resolve-pr-comments` = 修正完了後の返信+Resolve(クロージング) - -### v3.5.0 (破壊的変更: scanner削除) -- Claude Code Read toolのmultimodal/PDF native対応、および v3.4.0で追加された `official-skills-autoloader` により冗長になったAgent/Skillを整理 -- **削除Agent**: - - `scanner` (Office抽出) → autoloader + 公式docx/pptx/xlsx Skillで代替 -- **削除Skills**: - - `scanner-pdf-analysis` → Read tool の PDF native対応で代替 - - `scanner-excel-extraction` → autoloader + 公式xlsx Skillで代替(plugin.jsonのdangling ref整理) - - `skill-development` → 公式 `skill-creator` Skillで代替(autoloader取得可能) - - `corder-code-templates`, `corder-test-generation` → Claude本体のコード生成能力で代替 - - `researcher-report-templates` → researcher agent description/Claude本体で代替 -- Agents: 9個 → **8個** -- Skills: 25個 → **20個** -- 移行ガイド: `/ndf:scanner` を呼んでいた処理は、autoloaderまたはRead toolへ切替 - -### v3.4.0 -- Anthropic公式の定番Skill `mcp-builder` を取込(Apache-2.0、LICENSE.txt同梱) -- 公式Skillインストーラ `plugins/ndf/scripts/install-official-skills.sh` を追加 - - `--list`: 利用可能Skill一覧(ライセンス分類付き) - - `--scope user/project`: インストール先選択 - - `--all` / 個別指定: 選択的インストール - - `--update`: 公式リポジトリの最新化 - - シンボリックリンク方式で軽量 -- プロプライエタリSkill(docx/pptx/xlsx/pdf)は再配布せず、上記インストーラで個人利用者環境に配置 -- インストール手順・ライセンス方針を `docs/official-skills-installation.md` にまとめ -- `official-skills-autoloader` Skillを追加: Word/Excel/PowerPoint/PDF等の要求時に必要な公式Skillを自動ダウンロード→読込して即使用可能(利用者はインストール作業不要) -- Skills: 23個 → 25個 - -### v3.3.0 -- 定番サブエージェント3個を追加(いずれも `model: sonnet`) - - **debugger**: エラー・バグの根本原因分析 - - **devops-engineer**: Dockerfile/CI/CD/Kubernetes - - **code-reviewer**: git diff / PR一般レビュー(corderと差別化: Codex非使用) -- Agents: 6個 → 9個 - -### v3.2.0 -- サブエージェントに `model:` 指定を追加し、コスト最適化 - - director: `opus`(計画・設計判断) - - corder, data-analyst, researcher, qa: `sonnet` - - scanner: `haiku` -- scannerエージェントをOffice専用に縮小 - - 画像・PDFはClaude Code built-inのRead tool(multimodal, pages)で処理する方針に変更 -- corderのdescriptionを「Codex第二意見レビュー/大規模調査」用途に明確化 -- researcherのdescriptionをAWS Docs / Chrome DevTools専用に縮小 - -### v3.1.0 -- Kiro CLI対応(`.kiro/` 配下のインストーラ、プロンプト、スキルリンク) -- `google-auth` スキル追加 - -### v3.0.0 (破壊的変更) -- Serena MCPを`mcp-serena`プラグインに分離 -- memory系スキル5個を廃止(serena, memory-handling, serena-memory-strategy, mem-capture, mem-review) -- CLAUDE.ndf.md注入仕組みを廃止(inject-plugin-guide.js削除) -- `ndf-policies`スキル追加(ポリシー常時注入) -- `/ndf:cleanup`スキル追加(CLAUDE.ndf.md後始末) -- SessionStartフックをCLAUDE.ndf.md検出警告に変更 -- Skills: 25個→23個 - -### v2.8.0 -- `deepwiki-transfer`スキル追加 -- Skills: 23個→25個(knowledge-reorg含む) - -### v2.7.0 -- commandsをskillsに統合(Claude Code 2.1.3対応) - -### v2.6.0 -- NDFプラグインのMCP構成を最適化し個別プラグイン化 - -### v2.0.0 -- GitHub MCP, Serena MCP, Context7 MCPを公式プラグインに移行 diff --git a/plugins/ndf/README.md b/plugins/ndf/README.md deleted file mode 100644 index a35e59b..0000000 --- a/plugins/ndf/README.md +++ /dev/null @@ -1,859 +0,0 @@ -# NDF Plugin - -Claude Code / Codex / Kiro CLI 開発環境を**オールインワン**で強化する統合プラグインです。 - -## 概要 - -このプラグイン1つで、以下の機能を利用できます: - -1. **コアMCP**: なし (v4.0.0 で Codex MCP 廃止 / Serena MCP は `mcp-serena` プラグインに分離) -2. **Skills**: Claude Code/Kiro向け core 28個、Codex向け core 30個を公開。元の49個は `skills/` に保持し、ランタイム別の除外候補は `skills-optional/README.md` で管理。 -3. **専門エージェント**: 8つの特化型AIエージェント(director、data-analyst、corder、researcher、qa、debugger、devops-engineer、code-reviewer) -4. **自動フック**: Slack通知、デフォルトstatusline設定(未設定時のみ) - -## インストール - -### 前提条件 - -- 利用するランタイムのいずれか: - - Claude Code - - Codex CLI - - Kiro CLI -- Slack通知を使う場合: Node.js(同梱 hook が `node` で通知スクリプトを実行) -- `/ndf:codex` skill または `corder` エージェントから外部AI委譲を使う場合: Codex CLI(`npm install -g @openai/codex` → `codex login`) - -NDF本体は v4.0.0 以降、MCPサーバーを同梱しません。Serena、BigQuery、Chrome DevTools、AWS Docs、DBHub、Notion などの MCP は必要に応じて個別プラグインとしてインストールしてください。Python / `uvx` などは、それらの個別 MCP プラグインを使う場合の要件です。 - -### 公式プラグインのインストール(Claude Code / 任意) - -GitHub、Context7 MCPは公式プラグインとして提供されています: - -```bash -# Claude Codeで実行 -/plugin install github@anthropics/claude-plugins-official -/plugin install context7@anthropics/claude-plugins-official -``` - -### 追加MCPプラグインのインストール(Claude Code / オプション) - -用途に応じて個別のMCPプラグインをインストールできます。以下は Claude Code の例です。先に `ai-plugins` marketplace を追加してください。 - -```bash -# ブラウザ自動化とテスト -/plugin install mcp-chrome-devtools@ai-plugins - -# データ分析 -/plugin install mcp-bigquery@ai-plugins - -# AWS公式ドキュメント調査 -/plugin install mcp-aws-docs@ai-plugins - -# データベース操作 -/plugin install mcp-dbhub@ai-plugins - -# Notion統合 -/plugin install mcp-notion@ai-plugins -``` - -### Claude Codeでのインストール - -#### ステップ1: マーケットプレイスの追加 - -```bash -# Claude Codeで実行 -/plugin marketplace add https://github.com/devbasex/ai-plugins -``` - -#### ステップ2: プラグインのインストール - -```bash -# Claude Codeで実行 -/plugin install ndf@ai-plugins -``` - -### Codexでのインストール - -```bash -codex plugin marketplace add https://github.com/devbasex/ai-plugins -codex plugin add ndf@ai-plugins -``` - -Codex版ではSkillsに加えて、Codex向けSlack終了通知hookを同梱します。通知は明示的に `NDF_CODEX_SLACK_NOTIFY=true` を設定した場合のみ送信されます。Claude Code向けのstatusline設定、transcript保持期間設定、Claude CLIによるSlack要約通知hookはCodexでは自動有効化しません。 - -Claude Code/Kiro版で初期表示するSkillsは、PR運用・レビュー・調査・実装計画・仕様書化・browser smoke test・statusline・Codex CLI委譲・cross-reviewなどの core 28個に絞っています。Codex版は、Codex自体からの単発再委譲・Claude専用statusline・Claude transcript統計を外し、PR cross-review と Playwrightの計画・作成・実行・レポート・ツール操作、仕様書化を含めた core 30個にしています。Google連携、DeepWiki転送、MLモデル構造、高度なPlaywright連携などは通常利用時のskills context budgetを圧迫しないよう初期公開から外し、`skills-optional/README.md` に整理しています。 - -Codex向けSlack通知を使う場合は、Claude Code向けSlack通知と同じ環境変数を使います。プロジェクトの `.env` などに以下を設定してください。 - -```bash -NDF_CODEX_SLACK_NOTIFY=true -SLACK_BOT_TOKEN=xoxb-... -SLACK_CHANNEL_ID=C0123456789 -SLACK_USER_MENTION=<@U0123456789> # オプション -``` - -Codexのhookは初回実行前に `/hooks` で信頼設定が必要です。 - -### Kiro CLIでのインストール - -Kiro CLI では、リポジトリ付属のインストーラーで `.kiro/skills/` と `.kiro/agents/default.json` を生成します。 - -```bash -# 基本(manifest-selected skills + agentSpawn hook) -bash scripts/install-kiro.sh - -# Slack通知も有効化 -bash scripts/install-kiro.sh --with-slack - -# Codex CLI連携プロンプトも追加 -bash scripts/install-kiro.sh --with-codex -``` - -インストール後、Kiro CLI を起動します。 - -```bash -kiro-cli chat -``` - -### .envファイルの作成(Slack通知・外部連携を使う場合) - -Slack通知や個別MCPプラグインを使う場合は、プロジェクトルートに `.env` ファイルを作成し、必要な認証情報を設定します。NDF本体の基本 skill だけを使う場合、`.env` は必須ではありません。 - -```bash -# Slack通知 (オプション) -# Slack Appセットアップ手順は下記の詳細設定を参照 -SLACK_BOT_TOKEN= -SLACK_CHANNEL_ID= -SLACK_USER_MENTION= # 例: <@U0123456789> -# NDF_CODEX_SLACK_NOTIFY=true # Codex版でSlack通知を有効化する場合のみ - -# 注意: -# - Serena MCP は別プラグイン `mcp-serena` として提供 -# - Codex CLI 連携は `/ndf:codex` skill から使う場合のみ必要: `npm install -g @openai/codex` → `codex login` -# (v4.0.0 で Codex MCP サーバは廃止。skill 経由の CLI 直接実行に一本化) -# - GitHub MCP、Context7 MCPは公式プラグインを使用してください -# - 追加のMCP(BigQuery、Notion、AWS Docs、DBHub、Chrome DevTools)は -# 個別プラグインとしてインストール可能です(下記参照) -``` - -#### .envファイルの保護 - -`.env` ファイルには機密情報が含まれるため、必ず `.gitignore` に追加してください: - -```bash -echo ".env" >> .gitignore -``` - -#### 各認証情報の詳細設定 - -
-DSN(DATABASE_DSN)の設定方法(DBHub MCP用) - -DBHub MCPは複数のデータベースに対応しています。環境変数名は`DSN`または`DATABASE_DSN`のどちらでも使用可能です。 - -**PostgreSQL:** -```bash -DSN="postgres://USERNAME:PASSWORD@HOST:PORT/DATABASE?sslmode=disable" -``` - -SSL接続が必要な場合: -```bash -DSN="postgres://USERNAME:PASSWORD@HOST:PORT/DATABASE?sslmode=require" -``` - -**MySQL / MariaDB:** -```bash -DSN="mysql://USERNAME:PASSWORD@HOST:PORT/DATABASE" -``` - -SSH踏み台サーバー経由で接続する場合: -1. SSHトンネルを作成(ローカルポート転送) - ```bash - ssh -L 3307:DB_HOST:3306 USER@BASTION_HOST -N - ``` -2. ローカルポート経由で接続 - ```bash - DSN="mysql://USERNAME:PASSWORD@localhost:3307/DATABASE" - ``` - -**SQLite:** -```bash -DSN="sqlite:///PATH/TO/DATABASE.db" -``` - -**SQL Server:** -```bash -DSN="sqlserver://USERNAME:PASSWORD@HOST:PORT?database=DATABASE" -``` - -**注意事項:** -- パスワードに特殊文字が含まれる場合は、URLエンコードが必要(例: `@` → `%40`, `#` → `%23`) -- ローカルデータベースの場合は `localhost` を使用 -- `DSN`と`DATABASE_DSN`は同じ意味(どちらを使用してもOK) - -
- -
-SLACK_BOT_TOKEN と SLACK_CHANNEL_ID の設定方法 - -Slack通知を有効にするには、Slack Appを作成してBot Tokenとチャンネル IDを取得する必要があります。 - -**ステップ1: Slack Appの作成** - -1. https://api.slack.com/apps にアクセス -2. "Create New App" をクリック -3. "From scratch" を選択 -4. App名を入力(例: "Claude Code Notifier") -5. Workspaceを選択 - -**ステップ2: Bot Token Scopesの追加** - -1. 左メニューから "OAuth & Permissions" を選択 -2. "Scopes" セクションまでスクロール -3. "Bot Token Scopes" で以下を追加: - - `chat:write` (必須 - メッセージ投稿用) - - `chat:write.public` (推奨 - 公開チャンネル投稿用) - - `channels:read` (オプション - チャンネル情報取得用) - -**ステップ3: Workspaceへのインストール** - -1. ページ上部の "Install to Workspace" をクリック -2. 権限を確認して "Allow" をクリック -3. **Bot User OAuth Token** をコピー(`xoxb-`で始まる) - → これを `.env` の `SLACK_BOT_TOKEN` に設定 - -**ステップ4: Botをチャンネルに追加** - -1. Slackで通知先チャンネルを開く -2. チャンネル名をクリック → "Integrations" タブ -3. "Add apps" をクリック -4. 作成したアプリを選択して追加 - -**ステップ5: チャンネルIDの取得** - -1. 通知先チャンネルを開く -2. チャンネル名をクリック -3. 下部の「その他」→ チャンネルIDをコピー(`C`で始まる) - → これを `.env` の `SLACK_CHANNEL_ID` に設定 - -**ステップ6: ユーザーIDの取得(オプション - 通知音用)** - -1. Slackでプロフィールを開く -2. "その他" → "メンバーIDをコピー"(`U`で始まる) -3. メンション形式で設定: - ```bash - SLACK_USER_MENTION="<@U0123456789>" - ``` - -**最終的な.env設定例:** -```bash -SLACK_BOT_TOKEN="xoxb-YOUR-BOT-TOKEN-HERE" -SLACK_CHANNEL_ID="C0123456789" -SLACK_USER_MENTION="<@U0123456789>" # オプション -``` - -**セキュリティ注意:** -- Bot Tokenは絶対にGitにコミットしない -- トークンが漏洩した場合は即座に無効化(Revoke) -- 最小限の権限(Scope)のみを付与 - -
- -### ランタイムを再起動 - -`.env` ファイルに値を入力したら、利用中のランタイムを再起動してフックと環境変数を読み込みます。個別MCPプラグインを追加した場合は、そのランタイム側でも MCP 設定を再読み込みしてください。 - -## 利用方法 - -セットアップが完了したら、利用中のランタイムで自然言語またはスラッシュコマンドからリクエストできます: - -``` -このリポジトリのオープンなPRを確認して -``` - -利用可能な skill、hook、個別MCPプラグインはランタイムとインストール内容によって異なります。 - -Claude Code / Codex では、ワークフロースキルをスラッシュコマンドで呼び出せます: - -``` -/ndf:pr -/ndf:review 123 -/ndf:merged -``` - -Kiro CLI では `scripts/install-kiro.sh` が `.kiro/prompts/` に作成したプロンプト、または自然言語から同じ skill 手順を参照します。 - -## 機能詳細 - -### 1. MCP統合 - -このプラグイン自体は v4.0.0 で **コアMCP サーバを同梱しなくなりました**。関連MCPは個別プラグインとしてインストールしてください。 - -> **Note (v4.0.0 BREAKING)**: Codex MCP サーバは削除。Codex CLI は `/ndf:codex` skill から直接呼び出す方式に一本化。Serena MCP は別プラグイン `mcp-serena` へ分離済み。 - -**外部AI委譲(MCP 非使用):** -- 🔧 **Codex CLI** - `/ndf:codex` skill または `corder` エージェント経由で `codex exec` をバックグラウンド実行 - -**個別プラグインとして提供(5つ):** -- 📦 **Chrome DevTools MCP** (`mcp-chrome-devtools`) - Web調査、パフォーマンステスト -- 📦 **BigQuery MCP** (`mcp-bigquery`) - BigQueryデータ分析 -- 📦 **AWS Docs MCP** (`mcp-aws-docs`) - AWS公式ドキュメント検索 -- 📦 **DBHub MCP** (`mcp-dbhub`) - データベース操作 -- 📦 **Notion MCP** (`mcp-notion`) - Notionドキュメント管理 - -> **Tip**: GitHub MCP、Context7 MCPは公式プラグインからインストールして使用してください: -> ```bash -> /plugin install github@anthropics/claude-plugins-official -> /plugin install context7@anthropics/claude-plugins-official -> ``` - -#### 追加MCPのインストール方法 - -必要に応じて個別のMCPプラグインをインストールできます。以下は Claude Code の例です。先に `ai-plugins` marketplace を追加してください。 - -```bash -# ブラウザ自動化とテスト -/plugin install mcp-chrome-devtools@ai-plugins - -# データ分析 -/plugin install mcp-bigquery@ai-plugins - -# AWS公式ドキュメント調査 -/plugin install mcp-aws-docs@ai-plugins - -# データベース操作 -/plugin install mcp-dbhub@ai-plugins - -# Notion統合 -/plugin install mcp-notion@ai-plugins -``` - -各プラグインのインストール後、対応する環境変数を `.env` に設定して利用中のランタイムを再起動してください。 - -**注意事項:** -- 各MCPプラグインは独立しているため、必要なものだけをインストールできます -- コンテキスト使用量を最適化するため、使わないMCPはインストールしないことを推奨します -- 各プラグインの詳細な設定方法は、個別のREADMEを参照してください - -### 2. 専門エージェント (8種類) - -**サブエージェントの活用方針:** -- **複雑なタスクは`director`に委譲** - directorがMain Agentに報告し、Main Agentが他のエージェントを起動 -- **単純なタスクは専門エージェントに直接委譲** -- **directorはMain Agentに報告する** - メモリエラー防止のため直接呼び出しは行わない - -詳細はメインセッションに自動注入される `ndf-policies` skill および `plugins/ndf/AGENTS.md` を参照してください。 - -#### `director` エージェント(指揮者) -**専門領域:** タスク統括・設計立案・エージェント調整 - -**特徴:** -- **Main Agentに報告** - 必要なエージェントをMain Agentに報告し、Main Agentがサブエージェントを起動(メモリエラー防止) -- Claude Code機能(Plan Mode、Explore Agent、TodoWrite)を活用 -- タスク規模に応じた適切な対応(小規模→直接処理、大規模→Plan Mode) -- Main agentのコンテキスト消費を最小化 -- **計画・調査結果をファイルに保存** - 途中停止からの復帰を可能に - -**機能:** -- タスク分析と規模判定(小/中/大) -- 複数エージェントの並列/順次実行の計画立案 -- 進捗管理(TodoWrite) -- 設計計画の策定と**ファイル保存**(`issues/`, `docs/`, `specs/`) -- **Main Agentへの報告** - 必要なエージェントと実行順序を明示 - -**使用例:** -``` -@director ユーザー認証機能を追加してください。ベストプラクティスを調査し、実装してセキュリティレビューも行ってください -``` -→ directorが調査・計画後、Main Agentに「researcher → corder → qaの順次実行が必要」と報告 -→ Main Agentが報告に基づきエージェントを起動 - -#### `data-analyst` エージェント -**専門領域:** データ分析とSQL操作 - -**使用MCPツール:** -- BigQuery MCP -- DBHub MCP - -**機能:** -- SQL生成と実行 -- クエリ結果の分析と解釈 -- データの傾向とパターン発見 -- CSV/JSON/Excel形式でのデータ出力 -- レポート生成とデータ可視化の準備 - -**使用例:** -``` -@data-analyst BigQueryで過去1ヶ月の売上データを分析してください -``` - -#### `corder` エージェント -**専門領域:** 高品質コード生成 - -**使用MCPツール:** -- Codex CLI (`/ndf:codex` skill または `corder` エージェント経由、MCP非使用)(コードレビュー) - -> **Note**: Serena MCP、Context7 MCPはNDF本体に同梱されません。必要な場合は `mcp-serena` または公式プラグインを別途インストールしてください。 - -**機能:** -- クリーンで読みやすいコードの作成 -- 設計パターンとアーキテクチャの適用 -- AIによるコードレビューと品質保証 -- セキュリティ脆弱性のチェック -- リファクタリング提案 - -**使用例:** -``` -@corder ユーザー認証機能を実装してください。セキュリティとテストも考慮して -``` - -#### `researcher` エージェント -**専門領域:** 情報収集と分析 - -**使用MCPツール:** -- Codex CLI (`/ndf:codex` skill または `corder` エージェント経由、MCP非使用)(コードベース分析) -- AWS Documentation MCP(AWS公式ドキュメント) -- Chrome DevTools MCP(Webスクレイピング) - -**機能:** -- 技術ドキュメントの調査と要約 -- Webサイトからの情報収集 -- コードベースのアーキテクチャ分析 -- 複数ソースからの情報統合 -- スクリーンショットとPDF取得 - -**使用例:** -``` -@researcher AWS Lambda関数のベストプラクティスを調査してください -``` - -#### `qa` エージェント -**専門領域:** 品質管理とテスト - -**使用MCPツール:** -- Codex CLI (`/ndf:codex` skill または `corder` エージェント経由、MCP非使用)(コードレビュー、セキュリティチェック) -- Serena MCP(コードベース分析、別途 `mcp-serena` プラグインが必要) -- Chrome DevTools MCP(パフォーマンステスト、別途 `mcp-chrome-devtools` プラグインが必要) -- Claude Code / Codex / Kiro のプラグイン検証コマンド - -**機能:** -- コード品質レビューとリファクタリング提案 -- セキュリティ脆弱性検出(OWASP Top 10対応) -- パフォーマンステスト(Core Web Vitals評価) -- テストカバレッジとエッジケース検証 -- ドキュメント品質チェック -- プラグイン仕様準拠確認 - -**使用例:** -``` -@qa このコードの品質とセキュリティをレビューしてください -@qa Webアプリケーションのパフォーマンスを測定してください -@qa プラグイン仕様に準拠しているか確認してください -``` - -#### `debugger` エージェント -**専門領域:** エラー・バグの根本原因分析 - -**機能:** -- スタックトレース・ログ・失敗テストからの原因特定 -- 症状ではなく根本原因の究明 -- 再現手順の整理 -- 最小限の修正案の提示 - -**使用例:** -``` -@debugger このスタックトレースから例外の原因を特定してください -``` - -#### `devops-engineer` エージェント -**専門領域:** コンテナ・CI/CD・インフラ - -**機能:** -- Dockerfile / docker-compose の作成・最適化 -- GitHub Actions ワークフロー設計 -- Kubernetes マニフェスト作成 -- CI/CD パイプラインのデバッグ -- インフラ IaC - -**使用例:** -``` -@devops-engineer このアプリのDockerfileを最適化してください -``` - -#### `code-reviewer` エージェント -**専門領域:** git diff / PR差分の一般コードレビュー - -**機能:** -- 可読性・設計・バグ・セキュリティ・テスト観点のチェック -- 重要度順の指摘整理 -- Claude 単体で外部AIを使わず素早くレビュー(外部AI第二意見が必要な場合は `corder` を利用) - -**使用例:** -``` -@code-reviewer 直近のコミットの差分をレビューしてください -``` - -### 3. PR/コードレビューワークフロースキル(13個) - -開発の各段階で使用するスキル群です。`/ndf:*` のスラッシュコマンドで呼び出します。 - -#### commit / push / PR作成 -| スキル | 用途 | 引数 | -|--------|------|------| -| `/ndf:pr` | commit, push, PR作成/既存PR説明更新を一括実行 | `[--draft] [base-branch] or [msg]` | -| `/ndf:pr-tests` | PRのTest Planを自動実行 | `[PR番号]` | -| `/ndf:cherry-pick-pr` | 環境ブランチへのcherry-pick PR作成 | `` | -| `/ndf:deploy` | 環境ブランチへのデプロイPR作成(ブランチ全体をmain経由で) | `` | -| `/ndf:sync-main` | 最新mainを現在のブランチに取り込み | - | - -#### レビュー/修正 -| スキル | 用途 | 引数 | -|--------|------|------| -| `/ndf:review` | PR単位レビュー(Approve/Request Changes判定) | `[PR番号]` | -| `/ndf:review-branch` | ローカル差分レビュー(PR前、mainとの比較) | `[focus-area]` | -| `/ndf:review-pr-comments` | PRコメント分類・優先度判定(READ-ONLY) | `[PR番号]` | -| `/ndf:fix` | PRレビューコメントの修正対応 | `[PR番号]` | -| `/ndf:resolve-pr-comments` | 対応済みコメント返信+スレッドResolve | `[PR番号]` | -| `/ndf:browser-test` | ブラウザで動作確認(Playwright/Chrome DevTools MCP必要) | `[url]` | - -#### マージ後クリーンアップ -| スキル | 用途 | 引数 | -|--------|------|------| -| `/ndf:merged` | PRマージ後のローカルブランチクリーンアップ | `[PR番号]` | -| `/ndf:clean` | マージ済みブランチの一括削除 | - | - -### 4. 原則・ガイドライン系スキル(10個) - -モデル起動型のガイドラインスキル。該当する文脈で自動参照される。 - -| スキル名 | 概要 | -|---------|------| -| `ndf-policies` | プラグイン基本ポリシー(常時注入) | -| `branch-fix-strategy` | 複数ブランチへの修正適用戦略(cherry-pick) | -| `implementation-plan` | `issues/`配下の実装プラン管理 | -| `plan-to-spec` | 実装完了後のplanを確定仕様書へ移動・リライト・レビュー | -| `investigation-rules` | 調査レポートのエビデンス主義 | -| `problem-solving` | 根本原因分析・上流修正・多層防御 | -| `logging-guidelines` | ログ運用ガイドライン(言語非依存) | -| `markdown-writing` | Markdown文書作成(mermaid/plantUML) | -| `issue-plan-strategy` | issue→plan作成・multi-PR実行のワークフロー戦略 | -| `ml-model-structure` | MLモデル構築・推論API開発の標準ディレクトリ構造(版内feature SSoT / train↔serve契約) | - -### 5. データ分析・品質・環境系スキル(12個) - -Claudeが自律的に判断して起動するスキル群。 - -| カテゴリ | スキル名 | 概要 | -|---------|---------|------| -| Data Analyst | `data-analyst-sql-optimization` | SQL最適化パターン(N+1、INDEX、JOIN) | -| | `data-analyst-export` | CSV/JSON/Excel/Markdownエクスポート | -| QA | `qa-security-scan` | OWASP Top 10セキュリティスキャン | -| Dev環境 | `python-execution` | Python実行環境の自動判定 | -| | `docker-container-access` | Dockerコンテナアクセス判定 | -| | `git-gh-operations` | git/gh操作の共通パターン | -| | `google-auth` | Google API OAuth2認証 | -| | `codex` | Codex CLI直接実行(corderエージェントとの使い分け) | -| 知識管理 | `deepwiki-transfer` | DeepWikiからの知識転送 | -| | `knowledge-reorg` | 知識再編成 | -| 公式連携 | `mcp-builder` | MCPサーバー作成ガイド(Anthropic公式、Apache-2.0) | -| | `official-skills-autoloader` | Anthropic公式Skillの自動ロード | - -### 6. E2Eテスト(Playwright)系スキル(6個) - -pytest-playwright ベースの E2E テストワークフロー。 - -| スキル名 | 概要 | -|---------|------| -| `playwright-test-planning` | HTSM/ISTQB に基づくテスト計画立案・page role 分類 | -| `playwright-script-creation` | 再現可能なテストスクリプト作成 | -| `playwright-execution` | テスト実行+エビデンス収集(動画/trace/a11y/CWV) | -| `playwright-report` | テスト結果の Markdown レポート生成 | -| `playwright-kit-ops` | playwright_kit スクリプト操作(init/分類/スキャン) | -| `playwright-scenario-test` | フル E2E ワークフロー統括(計画→作成→実行→レポート) | - -### 7. 外部サービス連携系スキル(2個) - -| スキル名 | 概要 | -|---------|------| -| `google-drive` | Google Drive/Docs のエクスポート・アップロード(公開共有リンク付与) | -| `google-chat` | Google Chat スペースのメッセージ取得・スペース一覧 | - -### 8. AIクロスレビュー系スキル(2個) - -外部 AI に第二意見レビュー/調査を委譲する。 - -| スキル名 | 概要 | -|---------|------| -| `cross-review` | PR を codex/gemini 両方でレビューし APPROVE まで自動ループ。Gemini 進捗 heartbeat、追加レビュー観点、自動レビュー観点テンプレートに対応 | -| `gemini` | gemini CLI 直接実行(コード生成/レビュー/調査) | - -`cross-review` の代表的な呼び出し例: - -```bash -/ndf:cross-review 123 -/ndf:cross-review 123 --focus "ドキュメントとコードの整合性を重点的に確認" -/ndf:cross-review 123 --extra-instructions-file /tmp/review-focus.md -``` - -v4.18.0 以降、`cross-review` は PR の変更ファイルを GitHub API で全件取得し、 -docs only / code / DB migration / test / dependency / CI設定 / API契約 / 認証認可 / -frontend / performance / deletion / generated / i18n / infra を自動分類する。 -分類結果に応じて、ドキュメント妥当性、コード品質、DB設計、型、セキュリティ、 -可読性、重複、言語らしさ、関数/ファイルサイズ、設定差分などの観点を codex / -gemini 両方のプロンプトに追加する。`--focus` と `--extra-instructions-file` は、 -この自動テンプレートの後ろに上乗せされる。 - -Gemini 実行中は `progress.log` の短い進捗マーカーを `monitor.py` が heartbeat に -表示する。内部推論は出さず、`scan` / `analyze` / `post` / `done` などの作業段階、 -無更新秒数、stdout/stderr/progress log サイズを確認できる。 - -### 9. 運用系スキル(2個) - -| スキル名 | 概要 | -|---------|------| -| `skill-stats` | Skill 利用統計の集計(呼び出し数/ヒット率) | -| `statusline` | NDF標準statusline(コンテナ名/ホスト名 + project_dir + コンテキスト使用率)の切り替え・復元・状態確認 | - -### 10. 自動フック - -ランタイムごとに有効化される hook が異なります。 - -| ランタイム | SessionStart | Stop | -|-----------|--------------|------| -| Claude Code | transcript保持期間を90日以上に補正、statusline未設定時にNDF標準statuslineを設定 | Slack通知(`SLACK_BOT_TOKEN`設定時) | -| Codex | なし | Codex向けSlack通知(`NDF_CODEX_SLACK_NOTIFY=true`設定時) | -| Kiro CLI | `scripts/install-kiro.sh` が生成する `agentSpawn` hook | `--with-slack` 指定時のみSlack通知 | - -#### Claude Code: SessionStart - -セッション開始時に `~/.claude/settings.json` を検査し、`statusLine` が**未設定の場合のみ** -NDF標準statusline(コンテナ名/ホスト名 + project_dir + コンテキスト使用率)を設定します。 - -- 既にstatuslineが設定されている場合はそちらを優先し、何も変更しません -- `/ndf:statusline set` / `/ndf:statusline restore` でいつでも切り替え・復元できます(既存設定は自動バックアップ) -- NDF標準statusline利用中は、プラグイン更新時にスクリプト(`~/.claude/ndf-statusline.sh`)が自動で追従します -- Codex / Kiro CLI ではこの statusline hook は有効化されません - -#### Stop: Slack通知 - -作業終了時にSlackへ要約通知を送信します。Claude Code / Kiro CLI と Codex では通知の有効化条件が異なります。 - -**Claude Code / Kiro CLI の機能:** -- 会話内容をAIが自動要約(40文字) -- Claude Code では Claude CLI + `--no-session-persistence`を使用(要約生成時に追加のセッションログを作成しない) -- Claude Code では認証設定を自動継承(API KeyでもBedrockでも対応) -- Kiro CLI では hook payload の `assistant_response` を優先して要約に使用 -- 会話履歴、transcriptから最適な情報源を自動選択 -- リポジトリ名とタイムスタンプも含めて通知 - -**Codex の機能:** -- `NDF_CODEX_SLACK_NOTIFY=true` の場合のみ通知 -- Codex transcript から作業要約を生成 -- Claude Code向け statusline / transcript保持期間 hook は実行しない - -**設定:** -- `.env`に`SLACK_BOT_TOKEN`、`SLACK_CHANNEL_ID`、`SLACK_USER_MENTION`を設定 -- Codex版では追加で `NDF_CODEX_SLACK_NOTIFY=true` を設定 -- 詳細な設定手順は上記の[SLACK_BOT_TOKENとSLACK_CHANNEL_IDの設定方法](#各認証情報の詳細設定)を参照 -- 設定後、利用中のランタイムを再起動して有効化 - -**注意:** -- プラグイン更新後も再起動が必要です - -## 環境変数リファレンス - -NDF本体と、併用できる個別MCPプラグインの環境変数一覧です。太字は該当機能を使う場合の必須、通常テキストはオプションです。 - -### 📋 環境変数テンプレート - -プロジェクトルートに以下の`.env`ファイルを作成してください: - -```bash -# ============================================ -# mcp-serena(個別プラグイン) -# ============================================ -# SERENA_HOME=.serena - -# ============================================ -# Codex CLI - /ndf:codex skill 経由で使用 (v4.0.0でMCPサーバは廃止) -# ============================================ -# すべてオプション。npm install -g @openai/codex → codex login で利用可 -# CODEX_HOME=/path/to/codex/home -# OPENAI_API_KEY=your-openai-api-key -# OPENAI_BASE_URL=https://api.openai.com/v1 - -# ============================================ -# Slack通知 - 自動フック -# ============================================ -SLACK_BOT_TOKEN=xoxb-your-slack-bot-token -SLACK_CHANNEL_ID=C0123456789 -SLACK_USER_MENTION=<@U0123456789> -# NDF_CODEX_SLACK_NOTIFY=true # Codex版でSlack通知を送る場合のみ -``` - -**追加のMCPプラグインが必要な場合:** - -以下のMCPはClaude Code向けの個別プラグインとしてインストール可能です。必要に応じて環境変数を設定してください。 - -```bash -# ============================================ -# Chrome DevTools MCP (mcp-chrome-devtools) -# ============================================ -# 専用環境変数なし - -# ============================================ -# Notion MCP (mcp-notion) -# ============================================ -NOTION_TOKEN=your-notion-token-here - -# ============================================ -# AWS Docs MCP (mcp-aws-docs) -# ============================================ -# FASTMCP_LOG_LEVEL=ERROR -# AWS_DOCUMENTATION_PARTITION=aws - -# ============================================ -# BigQuery MCP (mcp-bigquery) -# ============================================ -BIGQUERY_PROJECT=your-gcp-project-id -BIGQUERY_LOCATION=US -# BIGQUERY_KEY_FILE=/path/to/service-account-key.json - -# ============================================ -# DBHub MCP (mcp-dbhub) -# ============================================ -DSN=mysql://user:password@host:3306/database -``` - -### 📊 個別MCPプラグイン・外部ツール別環境変数詳細 - -> **Note (v4.0.0以降)**: NDFプラグイン本体はコアMCPを同梱しません。GitHub MCP、Context7 MCPは公式プラグイン(`anthropics/claude-plugins-official`)から、Serena、BigQuery、Notion、AWS Docs、DBHub、Chrome DevToolsは個別プラグインとしてインストールしてください。Codex連携はMCPではなく Codex CLI 直接実行です。 - -#### 1. Serena MCP(個別プラグイン: `mcp-serena`) - -| 環境変数 | 必須/オプション | デフォルト値 | 説明 | -|---------|--------------|------------|------| -| SERENA_HOME | オプション | `.serena` | Serenaのホームディレクトリ | - -**注意:** -- NDF本体には同梱されません -- プロジェクトで使用する前に Serena 側で project activation が必要です - -#### 2. Codex CLI(`/ndf:codex` skill 経由、v4.0.0 でMCPサーバは廃止) - -| 環境変数 | 必須/オプション | デフォルト値 | 説明 | -|---------|--------------|------------|------| -| CODEX_HOME | オプション | `~/.codex` | Codex CLIのホームディレクトリ | -| OPENAI_API_KEY | オプション | - | OpenAI APIキー (`codex login` 済みなら不要) | -| OPENAI_BASE_URL | オプション | `https://api.openai.com/v1` | OpenAI APIのベースURL | - -**注意:** `/ndf:codex` skill や `corder` エージェントを使う場合、事前に Codex CLI をインストールし、`codex login` を実行してください。 - -```bash -npm install -g @openai/codex -codex login -``` - ---- - -**以下のMCPはClaude Code向けの個別プラグインとしてインストール可能です:** - -#### 3. Chrome DevTools MCP (mcp-chrome-devtools) - -```bash -/plugin install mcp-chrome-devtools@ai-plugins -``` - -**専用環境変数なし**。 - -#### 4. Notion MCP (mcp-notion) - -```bash -/plugin install mcp-notion@ai-plugins -``` - -| 環境変数 | 必須/オプション | デフォルト値 | 説明 | -|---------|--------------|------------|------| -| **NOTION_TOKEN** | **必須** | - | Notion Internal Integration Token
取得: https://www.notion.so/my-integrations | - -#### 5. AWS Docs MCP (mcp-aws-docs) - -```bash -/plugin install mcp-aws-docs@ai-plugins -``` - -| 環境変数 | 必須/オプション | デフォルト値 | 説明 | -|---------|--------------|------------|------| -| FASTMCP_LOG_LEVEL | オプション | `ERROR` | ログレベル | -| AWS_DOCUMENTATION_PARTITION | オプション | `aws` | AWSパーティション | - -**注意:** 認証不要でAWS公式ドキュメントにアクセスできます。 - -#### 6. BigQuery MCP (mcp-bigquery) - -```bash -/plugin install mcp-bigquery@ai-plugins -``` - -| 環境変数 | 必須/オプション | デフォルト値 | 説明 | -|---------|--------------|------------|------| -| **BIGQUERY_PROJECT** | **必須** | - | GCPプロジェクトID | -| BIGQUERY_LOCATION | オプション | `US` | BigQueryデータセットのロケーション | -| BIGQUERY_KEY_FILE | オプション | - | サービスアカウントキーファイルのパス | - -#### 7. DBHub MCP (mcp-dbhub) - -```bash -/plugin install mcp-dbhub@ai-plugins -``` - -| 環境変数 | 必須/オプション | デフォルト値 | 説明 | -|---------|--------------|------------|------| -| **DSN** | **必須** | - | データベース接続文字列
例: `mysql://user:pass@host:3306/db` | - -DSN形式の詳細については、各プラグインのREADMEを参照してください。 - -### 🔐 Slack通知の環境変数 - -| 環境変数 | 必須/オプション | デフォルト値 | 説明 | -|---------|--------------|------------|------| -| SLACK_BOT_TOKEN | 必須(Slack通知用) | - | Slack Bot User OAuth Token(`xoxb-`で始まる) | -| SLACK_CHANNEL_ID | 必須(Slack通知用) | - | 通知先チャンネルID(`C`で始まる) | -| SLACK_USER_MENTION | オプション | - | メンション対象ユーザーID(`<@U0123456789>`形式) | -| NDF_CODEX_SLACK_NOTIFY | Codex版のみ必須(Codex Slack通知用) | `false` | `true` の場合のみ Codex Stop hook がSlack通知を送信 | - -詳細な設定手順は[SLACK_BOT_TOKENとSLACK_CHANNEL_IDの設定方法](#各認証情報の詳細設定)を参照してください。 - -### 🛡️ セキュリティのベストプラクティス - -- ✅ `.env` ファイルを `.gitignore` に追加 -- ✅ 最小限のスコープ/権限を使用 -- ✅ トークンを定期的にローテーション -- ✅ チーム内で環境変数を安全に共有(1Password、AWS Secrets Manager等) -- ❌ トークンをコードやドキュメントにコミットしない -- ❌ トークンをSlack/メール等で平文送信しない - -## セキュリティのベストプラクティス - -- ✅ 環境変数でトークンを管理 -- ✅ `.env` ファイルを `.gitignore` に追加 -- ✅ 最小限のスコープ/権限を使用 -- ✅ トークンを定期的にローテーション -- ❌ トークンをコードやドキュメントにコミットしない - -## サポート - -問題が発生した場合: -1. 上記のトラブルシューティングセクションを確認 -2. GitHubリポジトリでイシューを作成: https://github.com/devbasex/ai-plugins/issues - -## ライセンス - -MIT License - -## 作者 - -takemi-ohama - https://github.com/takemi-ohama - -## 貢献 - -プルリクエストを歓迎します。大きな変更の場合は、まずイシューを開いて変更内容を議論してください。 diff --git a/plugins/ndf/skills-optional/README.md b/plugins/ndf/skills-optional/README.md deleted file mode 100644 index a2c747b..0000000 --- a/plugins/ndf/skills-optional/README.md +++ /dev/null @@ -1,42 +0,0 @@ -# Optional NDF Skills - -These skills remain in `../skills/` but are not exposed by default in at least one target runtime. - -Add a skill implementation under `../skills/`, then expose it in `.claude-plugin/plugin.json` or `skills-codex/` only when it is actively needed by that runtime. - -## Optional Skills - -- `data-analyst-export` -- `data-analyst-sql-optimization` -- `deepwiki-transfer` -- `gemini` -- `google-auth` -- `google-chat` -- `google-drive` -- `knowledge-reorg` -- `mcp-builder` -- `ml-model-structure` -- `official-skills-autoloader` -- `playwright-browser-connect` -- `playwright-evidence-drive` -- `playwright-scenario-test` -- `qa-security-scan` -- `skill-stats` - -## Codex Exclusions - -These Claude-specific or self-delegation skills are intentionally not exposed in Codex: - -- `browser-test` -- `codex` -- `statusline` - -## Claude/Kiro Exclusions - -These minimal Playwright skills are exposed in Codex but kept optional for Claude Code and Kiro by default: - -- `playwright-test-planning` -- `playwright-script-creation` -- `playwright-execution` -- `playwright-kit-ops` -- `playwright-report` diff --git a/scripts/build-runtime-plugins.sh b/scripts/build-runtime-plugins.sh new file mode 100755 index 0000000..c7fd647 --- /dev/null +++ b/scripts/build-runtime-plugins.sh @@ -0,0 +1,616 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +CHECK=false + +usage() { + cat <<'EOF' +Usage: bash scripts/build-runtime-plugins.sh [--check] + +Synchronize runtime plugin generated files from shared plugin sources. + +Options: + --check Compare generated output with the working tree and fail on drift. + -h, --help +EOF +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --check) CHECK=true ;; + -h|--help) usage; exit 0 ;; + *) echo "Unknown option: $1" >&2; usage >&2; exit 2 ;; + esac + shift +done + +SHARED_DIR="$ROOT_DIR/plugins/ndf-shared" +MCP_SHARED_DIR="$ROOT_DIR/plugins/mcp/shared" + +copy_tree() { + local source_dir="$1" + local dest_dir="$2" + local tmp_dir + local diff_file + + if [ ! -d "$source_dir" ]; then + echo "ERROR: source directory not found: $source_dir" >&2 + exit 1 + fi + + tmp_dir="$(mktemp -d)" + cp -a "$source_dir/." "$tmp_dir/" + find "$tmp_dir" \( \ + -name .venv -o \ + -name .pytest_cache -o \ + -name __pycache__ -o \ + -name '*.pyc' -o \ + -name '*.pyo' \ + \) -exec rm -rf {} + 2>/dev/null || true + + if [ "$CHECK" = true ]; then + if [ ! -d "$dest_dir" ]; then + echo "Generated directory missing: ${dest_dir#$ROOT_DIR/}" >&2 + rm -rf "$tmp_dir" + return 1 + fi + diff_file="$(mktemp)" + if ! diff -ruN "$tmp_dir" "$dest_dir" >"$diff_file"; then + echo "Generated directory is out of date: ${dest_dir#$ROOT_DIR/}" >&2 + cat "$diff_file" >&2 + rm -f "$diff_file" + rm -rf "$tmp_dir" + return 1 + fi + rm -f "$diff_file" + rm -rf "$tmp_dir" + else + rm -rf "$dest_dir" + mkdir -p "$(dirname "$dest_dir")" + mv "$tmp_dir" "$dest_dir" + fi +} + +rewrite_codex_skill_paths() { + local skills_dir="$1" + local script_dir="$2" + local file + + for file in \ + "$skills_dir/fix/SKILL.md" \ + "$skills_dir/review-pr-comments/SKILL.md" + do + [ -f "$file" ] || continue + sed "s#\${PLUGIN_ROOT:-\${CLAUDE_PLUGIN_ROOT}}/skills/fix/scripts/fetch-pr-comments.sh#\${PLUGIN_ROOT:-\${CODEX_PLUGIN_ROOT:-\${CLAUDE_PLUGIN_ROOT}}}/$script_dir/fix/scripts/fetch-pr-comments.sh#g" \ + "$file" >"$file.tmp" + mv "$file.tmp" "$file" + done + + for file in \ + "$skills_dir/cross-review/SKILL.md" \ + "$skills_dir/cross-review/docs/01-state-and-review.md" + do + [ -f "$file" ] || continue + sed \ + -e 's#${PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT}}#${PLUGIN_ROOT:-${CODEX_PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT}}}#g' \ + -e "s#skills/cross-review/scripts#$script_dir/cross-review/scripts#g" \ + "$file" >"$file.tmp" + mv "$file.tmp" "$file" + done +} + +rewrite_kiro_skill_paths() { + local skills_dir="$1" + local file + + for file in \ + "$skills_dir/fix/SKILL.md" \ + "$skills_dir/review-pr-comments/SKILL.md" + do + [ -f "$file" ] || continue + sed 's#${PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT}}/skills/fix/scripts/fetch-pr-comments.sh#${PLUGIN_ROOT:-plugins/ndf-kiro}/skills/fix/scripts/fetch-pr-comments.sh#g' \ + "$file" >"$file.tmp" + mv "$file.tmp" "$file" + done + + for file in \ + "$skills_dir/statusline/SKILL.md" \ + "$skills_dir/cross-review/SKILL.md" \ + "$skills_dir/cross-review/docs/01-state-and-review.md" + do + [ -f "$file" ] || continue + sed 's#${PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT}}#${PLUGIN_ROOT:-plugins/ndf-kiro}#g' \ + "$file" >"$file.tmp" + mv "$file.tmp" "$file" + done +} + +sync_skills() { + local manifest="$1" + local source_dir="$2" + local dest_dir="$3" + local variant="${4:-}" + local tmp_dir + local diff_file + local skill + + if [ ! -f "$manifest" ]; then + echo "ERROR: manifest not found: $manifest" >&2 + exit 1 + fi + if [ ! -d "$source_dir" ]; then + echo "ERROR: source directory not found: $source_dir" >&2 + exit 1 + fi + + tmp_dir="$(mktemp -d)" + while IFS= read -r skill || [ -n "$skill" ]; do + skill="${skill%%#*}" + skill="${skill#"${skill%%[![:space:]]*}"}" + skill="${skill%"${skill##*[![:space:]]}"}" + [ -z "$skill" ] && continue + + case "$skill" in + */*|*..*|'') + echo "ERROR: invalid skill name in ${manifest#$ROOT_DIR/}: $skill" >&2 + rm -rf "$tmp_dir" + exit 1 + ;; + esac + + if [ ! -f "$source_dir/$skill/SKILL.md" ]; then + echo "ERROR: missing SKILL.md for $skill in ${source_dir#$ROOT_DIR/}" >&2 + rm -rf "$tmp_dir" + exit 1 + fi + cp -a "$source_dir/$skill" "$tmp_dir/$skill" + done < "$manifest" + + find "$tmp_dir" \( \ + -name .venv -o \ + -name .pytest_cache -o \ + -name __pycache__ -o \ + -name '*.pyc' -o \ + -name '*.pyo' \ + \) -exec rm -rf {} + 2>/dev/null || true + + if [ "$variant" = codex-runtime ]; then + rewrite_codex_skill_paths "$tmp_dir" skills + elif [ "$variant" = kiro-runtime ]; then + rewrite_kiro_skill_paths "$tmp_dir" + fi + + if [ "$CHECK" = true ]; then + if [ ! -d "$dest_dir" ]; then + echo "Generated directory missing: ${dest_dir#$ROOT_DIR/}" >&2 + rm -rf "$tmp_dir" + return 1 + fi + diff_file="$(mktemp)" + if ! diff -ruN "$tmp_dir" "$dest_dir" >"$diff_file"; then + echo "Generated directory is out of date: ${dest_dir#$ROOT_DIR/}" >&2 + cat "$diff_file" >&2 + rm -f "$diff_file" + rm -rf "$tmp_dir" + return 1 + fi + rm -f "$diff_file" + rm -rf "$tmp_dir" + else + rm -rf "$dest_dir" + mkdir -p "$(dirname "$dest_dir")" + mv "$tmp_dir" "$dest_dir" + fi +} + +sync_runtime_if_present() { + local runtime="$1" + local manifest="$2" + local plugin_dir="$ROOT_DIR/plugins/ndf-$runtime" + + [ -d "$plugin_dir" ] || return 0 + if [ "$runtime" = codex ]; then + sync_skills "$manifest" "$SHARED_DIR/skills" "$plugin_dir/skills" codex-runtime + elif [ "$runtime" = kiro ]; then + sync_skills "$manifest" "$SHARED_DIR/skills" "$plugin_dir/skills" kiro-runtime + else + sync_skills "$manifest" "$SHARED_DIR/skills" "$plugin_dir/skills" + fi + copy_tree "$SHARED_DIR/scripts" "$plugin_dir/scripts" +} + +write_codex_mcp_manifest() { + local plugin_dir="$1" + local claude_manifest="$plugin_dir/.claude-plugin/plugin.json" + local codex_manifest="$plugin_dir/.codex-plugin/plugin.json" + + if [ ! -f "$claude_manifest" ]; then + echo "ERROR: Claude manifest not found: $claude_manifest" >&2 + exit 1 + fi + + mkdir -p "$(dirname "$codex_manifest")" + python3 - "$plugin_dir" "$claude_manifest" "$codex_manifest" <<'PY' +import json +import sys +from pathlib import Path + +plugin_dir = Path(sys.argv[1]) +claude_manifest = Path(sys.argv[2]) +codex_manifest = Path(sys.argv[3]) + +manifest = json.loads(claude_manifest.read_text()) +manifest["mcpServers"] = "./.mcp.json" +if (plugin_dir / "skills").is_dir(): + manifest["skills"] = "./skills/" +if (plugin_dir / "hooks" / "hooks.json").is_file(): + manifest["hooks"] = "./hooks/hooks.json" + +codex_manifest.write_text(json.dumps(manifest, indent=2, ensure_ascii=False) + "\n") +PY +} + +write_codex_mcp_config() { + local plugin_dir="$1" + local mcp_config="$plugin_dir/.mcp.json" + + if [ ! -f "$mcp_config" ]; then + echo "ERROR: MCP config not found: $mcp_config" >&2 + exit 1 + fi + + python3 - "$mcp_config" <<'PY' +import json +import sys +from pathlib import Path + +config_path = Path(sys.argv[1]) +config = json.loads(config_path.read_text()) +servers = config.get("mcpServers") +if not isinstance(servers, dict): + raise SystemExit(f"ERROR: mcpServers object not found: {config_path}") + +config_path.write_text(json.dumps(servers, indent=2, ensure_ascii=False) + "\n") +PY +} + +rewrite_mcp_readme_for_runtime() { + local runtime="$1" + local readme="$2" + + [ -f "$readme" ] || return 0 + + python3 - "$runtime" "$readme" <<'PY' +import sys +from pathlib import Path + +runtime = sys.argv[1] +readme_path = Path(sys.argv[2]) +text = readme_path.read_text() + +runtime_titles = {"Claude Code", "Codex", "Kiro CLI"} +keep_title = {"claude": "Claude Code", "codex": "Codex", "kiro": "Kiro CLI"}[runtime] + +lines = text.splitlines(keepends=True) +rewritten = [] +in_install = False +keep_subsection = True + +for line in lines: + if line.startswith("## "): + in_install = line.strip() == "## インストール" + keep_subsection = True + rewritten.append(line) + continue + + if in_install and line.startswith("### "): + title = line[4:].strip() + keep_subsection = title not in runtime_titles or title == keep_title + + if keep_subsection: + rewritten.append(line) + +text = "".join(rewritten) +runtime_name = {"claude": "Claude Code", "codex": "Codex", "kiro": "Kiro"}[runtime] +runtime_command = {"claude": "claude", "codex": "codex", "kiro": "kiro"}[runtime] + +text = text.replace("Claude Code", runtime_name) +text = "\n".join( + runtime_command if line == "claude" else line + for line in text.split("\n") +) + +readme_path.write_text(text) +PY +} + +apply_mcp_readme_template() { + local runtime="$1" + local plugin_dir="$2" + local readme_template="$plugin_dir/README.$runtime.md" + + if [ -f "$readme_template" ]; then + mv "$readme_template" "$plugin_dir/README.md" + else + rewrite_mcp_readme_for_runtime "$runtime" "$plugin_dir/README.md" + fi + rm -f "$plugin_dir"/README.claude.md "$plugin_dir"/README.codex.md "$plugin_dir"/README.kiro.md +} + +rewrite_kiro_mcp_paths() { + local plugin_dir="$1" + local plugin_name="$2" + local root_expr="\${PLUGIN_ROOT:-.kiro/mcp_runtime/$plugin_name}" + local file + + while IFS= read -r file; do + sed "s#\${PLUGIN_ROOT:-\${CODEX_PLUGIN_ROOT:-\${CLAUDE_PLUGIN_ROOT}}}#$root_expr#g" \ + "$file" >"$file.tmp" + mv "$file.tmp" "$file" + done < <(find "$plugin_dir" -type f \( -name 'SKILL.md' -o -name 'hooks.json' \)) +} + +write_kiro_mcp_installer() { + local plugin_dir="$1" + local plugin_name="$2" + local installer="$plugin_dir/install.sh" + + cat > "$installer" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PLUGIN_NAME="$(basename "$SCRIPT_DIR")" +PROJECT_ROOT="$(pwd)" +KIRO_DIR="$PROJECT_ROOT/.kiro" +KIRO_AGENT_FILE="$KIRO_DIR/agents/default.json" +KIRO_PLUGIN_LINK="$KIRO_DIR/mcp_runtime/$PLUGIN_NAME" +KIRO_SKILLS_DIR="$KIRO_DIR/skills" +TARGET_MCP="$PROJECT_ROOT/.mcp.json" +SOURCE_MCP="$SCRIPT_DIR/.mcp.json" +SOURCE_HOOKS="$SCRIPT_DIR/hooks/hooks.json" +SOURCE_SKILLS="$SCRIPT_DIR/skills" +DRY_RUN=false + +usage() { + cat <<'USAGE' +Usage: bash install.sh [--project PATH] [--dry-run] + +Install this MCP plugin into the current project's Kiro settings. +USAGE +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --project) + [ "$#" -ge 2 ] || { echo "ERROR: --project requires a path" >&2; exit 2; } + PROJECT_ROOT="$(cd "$2" && pwd)" + KIRO_DIR="$PROJECT_ROOT/.kiro" + KIRO_AGENT_FILE="$KIRO_DIR/agents/default.json" + KIRO_PLUGIN_LINK="$KIRO_DIR/mcp_runtime/$PLUGIN_NAME" + KIRO_SKILLS_DIR="$KIRO_DIR/skills" + TARGET_MCP="$PROJECT_ROOT/.mcp.json" + shift + ;; + --dry-run) DRY_RUN=true ;; + -h|--help) usage; exit 0 ;; + *) echo "Unknown option: $1" >&2; usage >&2; exit 2 ;; + esac + shift +done + +if [ ! -f "$SOURCE_MCP" ]; then + echo "ERROR: source .mcp.json not found: $SOURCE_MCP" >&2 + exit 1 +fi + +if [ "$DRY_RUN" = true ]; then + echo "Would merge $SOURCE_MCP into $TARGET_MCP" + echo "Would link plugin runtime into $KIRO_PLUGIN_LINK" + if [ -d "$SOURCE_SKILLS" ]; then + echo "Would link skills from $SOURCE_SKILLS into $KIRO_SKILLS_DIR" + fi + if [ -f "$SOURCE_HOOKS" ]; then + echo "Would merge hooks from $SOURCE_HOOKS into $KIRO_AGENT_FILE" + fi + exit 0 +fi + +mkdir -p "$KIRO_DIR/mcp_runtime" "$KIRO_SKILLS_DIR" "$(dirname "$KIRO_AGENT_FILE")" +if [ -e "$KIRO_PLUGIN_LINK" ] && [ ! -L "$KIRO_PLUGIN_LINK" ]; then + echo "ERROR: $KIRO_PLUGIN_LINK already exists and is not a symlink" >&2 + exit 1 +fi +ln -sfn "$SCRIPT_DIR" "$KIRO_PLUGIN_LINK" + +if [ -d "$SOURCE_SKILLS" ]; then + find "$SOURCE_SKILLS" -mindepth 1 -maxdepth 1 -type d | sort | while IFS= read -r skill_dir; do + skill_name="$(basename "$skill_dir")" + skill_link="$KIRO_SKILLS_DIR/$skill_name" + [ -f "$skill_dir/SKILL.md" ] || continue + if [ -e "$skill_link" ] && [ ! -L "$skill_link" ]; then + echo "ERROR: $skill_link already exists and is not a symlink" >&2 + exit 1 + fi + ln -sfn "$skill_dir" "$skill_link" + echo "Linked Kiro skill: $skill_name" + done +fi + +python3 - "$SOURCE_MCP" "$TARGET_MCP" "$SOURCE_HOOKS" "$KIRO_AGENT_FILE" <<'PY' +import json +import sys +from pathlib import Path + +source_path = Path(sys.argv[1]) +target_path = Path(sys.argv[2]) +hooks_path = Path(sys.argv[3]) +agent_path = Path(sys.argv[4]) +source = json.loads(source_path.read_text()) +target = json.loads(target_path.read_text()) if target_path.exists() else {} +source_servers = source.get("mcpServers", {}) +target.setdefault("mcpServers", {}) +target["mcpServers"].update(source_servers) +target_path.write_text(json.dumps(target, indent=2, ensure_ascii=False) + "\n") + +if agent_path.exists(): + agent = json.loads(agent_path.read_text()) +else: + agent = { + "name": "default", + "resources": ["skill://.kiro/skills/**/SKILL.md"], + } + +agent.setdefault("mcpServers", {}) +agent["mcpServers"].update(source_servers) + +if hooks_path.exists(): + source_hooks = json.loads(hooks_path.read_text()).get("hooks", {}) + target_hooks = agent.setdefault("hooks", {}) + event_map = { + "SessionStart": "agentSpawn", + "Stop": "stop", + } + for source_event, groups in source_hooks.items(): + target_event = event_map.get(source_event, source_event) + target_list = target_hooks.setdefault(target_event, []) + existing = { + entry.get("command") + for entry in target_list + if isinstance(entry, dict) + } + for group in groups: + for hook in group.get("hooks", []): + command = hook.get("command") + if not command or command in existing: + continue + entry = {"command": command} + if "timeout_ms" in hook: + entry["timeout_ms"] = hook["timeout_ms"] + target_list.append(entry) + existing.add(command) + +agent_path.write_text(json.dumps(agent, indent=2, ensure_ascii=False) + "\n") + +print(f"Installed MCP servers: {', '.join(sorted(source_servers))}") +print(f"Updated: {target_path}") +print(f"Updated: {agent_path}") +PY +EOF + chmod +x "$installer" +} + +sync_mcp_runtime() { + local runtime="$1" + local source_dir="$2" + local dest_dir="$3" + local tmp_dir + local diff_file + + if [ ! -d "$source_dir" ]; then + echo "ERROR: source directory not found: $source_dir" >&2 + exit 1 + fi + + tmp_dir="$(mktemp -d)" + cp -a "$source_dir/." "$tmp_dir/" + find "$tmp_dir" \( \ + -name .venv -o \ + -name .pytest_cache -o \ + -name __pycache__ -o \ + -name '*.pyc' -o \ + -name '*.pyo' \ + \) -exec rm -rf {} + 2>/dev/null || true + apply_mcp_readme_template "$runtime" "$tmp_dir" + + case "$runtime" in + claude) + rm -rf "$tmp_dir/.codex-plugin" + ;; + codex) + write_codex_mcp_manifest "$tmp_dir" + write_codex_mcp_config "$tmp_dir" + rm -rf "$tmp_dir/.claude-plugin" + ;; + kiro) + rm -rf "$tmp_dir/.claude-plugin" "$tmp_dir/.codex-plugin" + rewrite_kiro_mcp_paths "$tmp_dir" "$(basename "$dest_dir")" + write_kiro_mcp_installer "$tmp_dir" "$(basename "$dest_dir")" + ;; + *) + echo "ERROR: unknown MCP runtime: $runtime" >&2 + rm -rf "$tmp_dir" + exit 1 + ;; + esac + + if [ "$CHECK" = true ]; then + if [ ! -d "$dest_dir" ]; then + echo "Generated directory missing: ${dest_dir#$ROOT_DIR/}" >&2 + rm -rf "$tmp_dir" + return 1 + fi + diff_file="$(mktemp)" + if ! diff -ruN "$tmp_dir" "$dest_dir" >"$diff_file"; then + echo "Generated directory is out of date: ${dest_dir#$ROOT_DIR/}" >&2 + cat "$diff_file" >&2 + rm -f "$diff_file" + rm -rf "$tmp_dir" + return 1 + fi + rm -f "$diff_file" + rm -rf "$tmp_dir" + else + rm -rf "$dest_dir" + mkdir -p "$(dirname "$dest_dir")" + mv "$tmp_dir" "$dest_dir" + fi +} + +clean_mcp_runtime_outputs() { + local runtime="$1" + local runtime_dir="$ROOT_DIR/plugins/mcp/$runtime" + local plugin_dir plugin_name + + [ -d "$runtime_dir" ] || return 0 + for plugin_dir in "$runtime_dir"/*; do + [ -d "$plugin_dir" ] || continue + plugin_name="$(basename "$plugin_dir")" + [ -d "$MCP_SHARED_DIR/$plugin_name" ] && continue + + if [ "$CHECK" = true ]; then + echo "Generated plugin directory is stale: ${plugin_dir#$ROOT_DIR/}" >&2 + return 1 + fi + rm -rf "$plugin_dir" + done +} + +sync_mcp_plugins() { + local plugin_dir plugin_name + + [ -d "$MCP_SHARED_DIR" ] || return 0 + clean_mcp_runtime_outputs claude + clean_mcp_runtime_outputs codex + clean_mcp_runtime_outputs kiro + + for plugin_dir in "$MCP_SHARED_DIR"/*; do + [ -d "$plugin_dir" ] || continue + plugin_name="$(basename "$plugin_dir")" + sync_mcp_runtime claude "$plugin_dir" "$ROOT_DIR/plugins/mcp/claude/$plugin_name" + sync_mcp_runtime codex "$plugin_dir" "$ROOT_DIR/plugins/mcp/codex/$plugin_name" + sync_mcp_runtime kiro "$plugin_dir" "$ROOT_DIR/plugins/mcp/kiro/$plugin_name" + done +} + +sync_runtime_if_present claude "$SHARED_DIR/manifests/claude-skills.txt" +sync_runtime_if_present codex "$SHARED_DIR/manifests/codex-skills.txt" +sync_runtime_if_present kiro "$SHARED_DIR/manifests/kiro-skills.txt" +sync_mcp_plugins + +if [ "$CHECK" = true ]; then + echo "runtime plugin generated files are up to date" +else + echo "runtime plugin generated files synchronized" +fi diff --git a/scripts/check-markdown-links.py b/scripts/check-markdown-links.py new file mode 100755 index 0000000..5dd6bc9 --- /dev/null +++ b/scripts/check-markdown-links.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +"""Check repository-relative Markdown links. + +External URLs, mailto links, pure anchors, and absolute filesystem paths are +ignored. This keeps the check stable in CI while still catching broken links +between repository documents. +""" + +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path +from urllib.parse import unquote, urlparse + + +LINK_RE = re.compile(r"(?]*href=[\"']([^\"']+)[\"']", re.IGNORECASE) +TITLE_RE = re.compile(r"\s+(?:\"[^\"]*\"|'[^']*'|\([^)]*\))\s*$") + + +def iter_markdown_files(root: Path) -> list[Path]: + roots = [root / "README.md", root / "AGENTS.md", root / "CLAUDE.md", root / "KIRO.md", root / "docs", root / "plugins"] + files: list[Path] = [] + for item in roots: + if item.is_file(): + files.append(item) + elif item.is_dir(): + files.extend(item.rglob("*.md")) + return sorted(set(files)) + + +def strip_title(target: str) -> str: + target = target.strip() + if target.startswith("<") and target.endswith(">"): + target = target[1:-1].strip() + return TITLE_RE.sub("", target) + + +def should_skip(target: str) -> bool: + if not target or target.startswith("#"): + return True + parsed = urlparse(target) + if parsed.scheme or target.startswith("//"): + return True + if target.startswith("/"): + return True + if "{" in target or "}" in target: + return True + return False + + +def target_path(root: Path, source: Path, raw_target: str) -> Path | None: + target = strip_title(raw_target) + if should_skip(target): + return None + path_part = target.split("#", 1)[0] + if not path_part: + return None + path_part = unquote(path_part) + return (source.parent / path_part).resolve() + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--root", default=".", help="repository root") + args = parser.parse_args() + + root = Path(args.root).resolve() + failures: list[str] = [] + + for md in iter_markdown_files(root): + raw_lines = md.read_text(encoding="utf-8").splitlines() + filtered: list[str] = [] + in_fence = False + for line in raw_lines: + if line.lstrip().startswith("```"): + in_fence = not in_fence + continue + if in_fence or line.lstrip().startswith(">"): + continue + filtered.append(line) + text = "\n".join(filtered) + targets = [m.group(1) for m in LINK_RE.finditer(text)] + targets.extend(m.group(1) for m in INLINE_HTML_RE.finditer(text)) + for raw in targets: + resolved = target_path(root, md, raw) + if resolved is None: + continue + try: + resolved.relative_to(root) + except ValueError: + failures.append(f"{md.relative_to(root)}: link escapes repository: {raw}") + continue + if not resolved.exists(): + failures.append(f"{md.relative_to(root)}: missing link target: {raw}") + + if failures: + print("Markdown link check failed:", file=sys.stderr) + for failure in failures: + print(f"- {failure}", file=sys.stderr) + return 1 + + print("Markdown local links are valid") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/install-dev-hooks.sh b/scripts/install-dev-hooks.sh new file mode 100755 index 0000000..1a4830f --- /dev/null +++ b/scripts/install-dev-hooks.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +if [ ! -d "$ROOT_DIR/.git" ] && ! git -C "$ROOT_DIR" rev-parse --git-dir >/dev/null 2>&1; then + echo "ERROR: not a git repository: $ROOT_DIR" >&2 + exit 1 +fi + +git -C "$ROOT_DIR" config core.hooksPath .githooks +echo "Installed development hooks: core.hooksPath=.githooks" diff --git a/scripts/install-kiro.sh b/scripts/install-kiro.sh deleted file mode 100755 index 91d35c1..0000000 --- a/scripts/install-kiro.sh +++ /dev/null @@ -1,127 +0,0 @@ -#!/bin/bash -# NDF Plugin Installer for Kiro CLI -# Usage: bash scripts/install-kiro.sh [--with-slack] [--with-codex] -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" -PLUGIN_DIR="$PROJECT_ROOT/plugins/ndf" -KIRO_DIR="$PROJECT_ROOT/.kiro" -SKILLS_DIR="$KIRO_DIR/skills" -AGENT_FILE="$KIRO_DIR/agents/default.json" -PLUGIN_JSON="$PLUGIN_DIR/.claude-plugin/plugin.json" - -# Parse options -WITH_SLACK=false -WITH_CODEX=false -for arg in "$@"; do - case "$arg" in - --with-slack) WITH_SLACK=true ;; - --with-codex) WITH_CODEX=true ;; - --help|-h) - echo "Usage: bash scripts/install-kiro.sh [OPTIONS]" - echo "" - echo "Options:" - echo " --with-slack stopフックにSlack通知を追加" - echo " --with-codex Codex CLI直接実行用プロンプトを追加" - echo " -h, --help このヘルプを表示" - exit 0 - ;; - esac -done - -echo "=== NDF Plugin Installer for Kiro CLI ===" - -# Validate plugin.json exists -if [ ! -f "$PLUGIN_JSON" ]; then - echo "ERROR: $PLUGIN_JSON が見つかりません" >&2 - exit 1 -fi - -# --- Step 1: Create symlinks in .kiro/skills/ --- -mkdir -p "$SKILLS_DIR" -echo "Skills シンボリックリンクを作成中..." -SKILL_COUNT=0 -while IFS= read -r skill_path; do - skill_path="${skill_path#./}" - skill_name=$(basename "$skill_path") - src_dir="$PLUGIN_DIR/$skill_path" - - if [ ! -f "$src_dir/SKILL.md" ]; then - echo " SKIP: $skill_name (SKILL.mdなし)" - continue - fi - - # Relative symlink from .kiro/skills/ to the manifest-selected skill set. - ln -sfn "../../plugins/ndf/$skill_path" "$SKILLS_DIR/$skill_name" - echo " linked: $skill_name" - SKILL_COUNT=$((SKILL_COUNT + 1)) -done < <(grep -oP '"\.\/skills\/[^"]+' "$PLUGIN_JSON" | sed 's/"//g') - -# --- Step 2: Create prompts in .kiro/prompts/ for workflow skills --- -PROMPTS_DIR="$KIRO_DIR/prompts" -mkdir -p "$PROMPTS_DIR" -echo "ワークフロープロンプトを作成中..." - -declare -A PROMPT_DESCS=( - [pr]="commit, push, PR作成を一括実行してください。" - [pr-tests]="PRのTest Planを自動実行し、結果をPRコメントに反映してください。" - [fix]="PRのレビューコメントを確認し、修正対応を実行してください。" - [review]="PRを専門家としてレビューし、Approve/Request Changesを判定してください。" - [merged]="PRマージ後のクリーンアップを実行してください(main更新、ブランチ削除)。" - [clean]="mainマージ済みブランチをローカル/リモート一括削除してください。" -) - -if [ "$WITH_CODEX" = true ]; then - PROMPT_DESCS[codex]="Codex CLIにコード生成・レビュー・調査を委譲してください。" -fi - -for name in "${!PROMPT_DESCS[@]}"; do - cat > "$PROMPTS_DIR/$name.md" << PROMPT_EOF -${PROMPT_DESCS[$name]} - -${name}スキルの手順に従って実行してください。引数があればそのまま使用します。 -PROMPT_EOF - echo " prompt: $name" -done - -# --- Step 3: Generate agent config --- -mkdir -p "$KIRO_DIR/agents" - -if [ "$WITH_SLACK" = true ]; then echo "Slack通知: 有効"; else echo "Slack通知: 無効 (--with-slack で有効化)"; fi -if [ "$WITH_CODEX" = true ]; then echo "Codex CLI連携: 有効"; else echo "Codex CLI連携: 無効 (--with-codex で有効化)"; fi - -# Backup existing config -if [ -f "$AGENT_FILE" ]; then - cp "$AGENT_FILE" "${AGENT_FILE}.bak" - echo "既存設定をバックアップ: ${AGENT_FILE}.bak" -fi - -# Write agent JSON -python3 -c " -import json, sys -config = { - 'name': 'default', - 'description': 'NDF統合開発エージェント(Kiro CLI用)', - 'resources': [ - 'file://AGENTS.md', - 'file://README.md', - 'file://.kiro/skills/ndf-policies/SKILL.md', - 'skill://.kiro/skills/**/SKILL.md' - ], - 'hooks': { - 'agentSpawn': [{'command': 'if [ -f \"\${PWD}/CLAUDE.ndf.md\" ] || [ -f \"\$HOME/.claude/CLAUDE.ndf.md\" ]; then echo \"[NDF] CLAUDE.ndf.md が検出されました。廃止済みです。cleanup を実行して削除してください。\"; fi'}] - } -} -if sys.argv[1] == 'true': - config['hooks']['stop'] = [{'command': 'node plugins/ndf/scripts/slack-notify.js session_end', 'timeout_ms': 70000}] -json.dump(config, open(sys.argv[3], 'w'), indent=2, ensure_ascii=False) -" "$WITH_SLACK" "$WITH_CODEX" "$AGENT_FILE" - -echo "" -echo "=== インストール完了 ===" -echo " エージェント設定: $AGENT_FILE" -echo " Skills数: $SKILL_COUNT (シンボリックリンク: .kiro/skills/)" -echo "" -echo "Kiro CLIを起動して動作確認してください:" -echo " kiro-cli chat" diff --git a/scripts/runtime-smoke-test.sh b/scripts/runtime-smoke-test.sh new file mode 100755 index 0000000..c5d8efa --- /dev/null +++ b/scripts/runtime-smoke-test.sh @@ -0,0 +1,188 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +RUNTIME="all" +WITH_SECRETS="off" +KEEP_CONTAINER=false +ARTIFACT_DIR="$ROOT_DIR/tmp/runtime-smoke" + +usage() { + cat <<'USAGE' +Usage: bash scripts/runtime-smoke-test.sh [OPTIONS] + +Options: + --runtime claude|codex|kiro|all runtime to test (default: all) + --with-secrets off|auto|required secret handling mode (default: off) + --secret-file KEY=PATH inject an allowlisted file secret + --keep-container keep the container for local debugging + --artifact-dir PATH write logs/JUnit/artifacts under PATH + -h, --help show this help +USAGE +} + +SECRET_FILES=() +while [ "$#" -gt 0 ]; do + case "$1" in + --runtime) RUNTIME="${2:?}"; shift ;; + --runtime=*) RUNTIME="${1#*=}" ;; + --with-secrets) WITH_SECRETS="${2:?}"; shift ;; + --with-secrets=*) WITH_SECRETS="${1#*=}" ;; + --secret-file) SECRET_FILES+=("${2:?}"); shift ;; + --secret-file=*) SECRET_FILES+=("${1#*=}") ;; + --keep-container) KEEP_CONTAINER=true ;; + --artifact-dir) ARTIFACT_DIR="${2:?}"; shift ;; + --artifact-dir=*) ARTIFACT_DIR="${1#*=}" ;; + -h|--help) usage; exit 0 ;; + *) echo "ERROR: unknown option: $1" >&2; usage >&2; exit 2 ;; + esac + shift +done + +case "$RUNTIME" in claude|codex|kiro|all) ;; *) echo "ERROR: invalid --runtime: $RUNTIME" >&2; exit 2 ;; esac +case "$WITH_SECRETS" in off|auto|required) ;; *) echo "ERROR: invalid --with-secrets: $WITH_SECRETS" >&2; exit 2 ;; esac +if [ "$KEEP_CONTAINER" = true ] && [ "$WITH_SECRETS" != off ]; then + echo "ERROR: --keep-container cannot be used with --with-secrets=$WITH_SECRETS" >&2 + exit 2 +fi +command -v docker >/dev/null 2>&1 || { echo "ERROR: docker is required" >&2; exit 1; } + +mkdir -p "$ARTIFACT_DIR" +ARTIFACT_DIR="$(cd "$ARTIFACT_DIR" && pwd)" + +RUNTIMES=("$RUNTIME") +[ "$RUNTIME" = all ] && RUNTIMES=(claude codex kiro) + +RAW_SECRET_NAMES=( + ANTHROPIC_API_KEY + OPENAI_API_KEY + AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY + AWS_SESSION_TOKEN + BIGQUERY_PROJECT + BIGQUERY_LOCATION + BIGQUERY_DATASET + REDASH_URL + REDASH_API_KEY +) +FILE_SECRET_ENV_NAMES=(GOOGLE_APPLICATION_CREDENTIALS BIGQUERY_KEY_FILE) +ALLOWLIST="$ROOT_DIR/tests/runtime-smoke/secrets-files.allowlist" + +validate_secret_key() { + local key="$1" + [[ "$key" =~ ^[A-Za-z0-9_.-]+$ ]] || return 1 + grep -Fxq "$key" "$ALLOWLIST" +} + +copy_secret_file() { + local cid="$1" + local src="$2" + local key="$3" + local base + base="$(basename "$src")" + tar -C "$(dirname "$src")" -cf - "$base" | + docker exec -i "$cid" tar -C /tmp/runtime-secrets -xf - + docker exec "$cid" sh -c 'mv "$1" "$2" && chmod 0444 "$2"' sh "/tmp/runtime-secrets/$base" "/tmp/runtime-secrets/$key" +} + +secret_count=0 +for name in "${RAW_SECRET_NAMES[@]}"; do + [ -n "${!name:-}" ] && secret_count=$((secret_count + 1)) +done +for name in "${FILE_SECRET_ENV_NAMES[@]}"; do + [ -n "${!name:-}" ] && secret_count=$((secret_count + 1)) +done +secret_count=$((secret_count + ${#SECRET_FILES[@]})) +if [ "$WITH_SECRETS" = required ] && [ "$secret_count" -eq 0 ]; then + echo "ERROR: --with-secrets=required but no allowlisted secrets were found" >&2 + exit 1 +fi + +build_images() { + docker build -q -f "$ROOT_DIR/tests/runtime-smoke/Containerfile.base" -t ai-plugins-runtime-smoke-base "$ROOT_DIR" >/dev/null + for runtime in "${RUNTIMES[@]}"; do + docker build -q -f "$ROOT_DIR/tests/runtime-smoke/Containerfile.$runtime" -t "ai-plugins-runtime-smoke-$runtime" "$ROOT_DIR" >/dev/null + done +} + +inject_secrets() { + local cid="$1" + [ "$WITH_SECRETS" = off ] && return 0 + + { + for name in "${RAW_SECRET_NAMES[@]}"; do + [ -n "${!name:-}" ] && printf 'export %s=%q\n' "$name" "${!name}" + done + } | docker exec -i "$cid" sh -c 'cat > /tmp/runtime-secrets/raw-env && chmod 0444 /tmp/runtime-secrets/raw-env' + + for name in "${FILE_SECRET_ENV_NAMES[@]}"; do + [ -n "${!name:-}" ] || continue + local src="${!name}" + [ -f "$src" ] || { echo "ERROR: $name points to a missing file" >&2; return 1; } + local key + key="$(printf '%s' "$name" | tr '[:upper:]' '[:lower:]' | tr '_' '-')" + validate_secret_key "$key" || { echo "ERROR: file secret key is not allowlisted: $key" >&2; return 1; } + copy_secret_file "$cid" "$src" "$key" + printf 'export %s=%q\n' "$name" "/tmp/runtime-secrets/$key" | + docker exec -i "$cid" sh -c 'cat >> /tmp/runtime-secrets/raw-env' + done + + for item in "${SECRET_FILES[@]}"; do + local key="${item%%=*}" + local src="${item#*=}" + [ "$key" != "$item" ] || { echo "ERROR: --secret-file must be KEY=PATH" >&2; return 1; } + validate_secret_key "$key" || { echo "ERROR: file secret key is not allowlisted: $key" >&2; return 1; } + [ -f "$src" ] || { echo "ERROR: secret file not found: $src" >&2; return 1; } + copy_secret_file "$cid" "$src" "$key" + done +} + +run_runtime() { + local runtime="$1" + local runtime_artifacts="$ARTIFACT_DIR/$runtime" + local cid="" + rm -rf "$runtime_artifacts" + mkdir -p "$runtime_artifacts" + + cid="$(docker run -d \ + --tmpfs /tmp/runtime-secrets:rw,noexec,nosuid,nodev,size=1m \ + -e HOME=/tmp/runtime-home \ + -e RUNTIME="$runtime" \ + -e WITH_SECRETS="$WITH_SECRETS" \ + -e REPO_ROOT=/workspace/ai-plugins \ + -e PROJECT_DIR=/tmp/runtime-project \ + -e ARTIFACT_DIR=/tmp/runtime-artifacts \ + "ai-plugins-runtime-smoke-$runtime" sleep infinity)" + if [ "$KEEP_CONTAINER" = false ]; then + trap 'docker rm -f "$cid" >/dev/null 2>&1 || true' RETURN + else + echo "$cid" > "$runtime_artifacts/container-id.txt" + fi + + docker exec "$cid" mkdir -p /workspace/ai-plugins + tar \ + --exclude=.git \ + --exclude=.claude \ + --exclude=.codex \ + --exclude=.kiro \ + --exclude=tmp \ + -C "$ROOT_DIR" -cf - . | + docker exec -i "$cid" tar -C /workspace/ai-plugins -xf - + docker exec "$cid" bash -lc 'chmod -R a-w /workspace/ai-plugins' + inject_secrets "$cid" + docker exec "$cid" bash -lc 'mkdir -p "$HOME" "$PROJECT_DIR" "$ARTIFACT_DIR"; cd "$PROJECT_DIR"; "/workspace/ai-plugins/tests/runtime-smoke/adapters/${RUNTIME}.sh"' \ + >"$runtime_artifacts/smoke.log" 2>&1 + docker exec "$cid" bash -lc 'find /tmp/runtime-project -maxdepth 5 -print | sort > /tmp/runtime-artifacts/generated-tree.txt' + docker cp "$cid:/tmp/runtime-artifacts/." "$runtime_artifacts/" >/dev/null + if [ "$KEEP_CONTAINER" = false ]; then + docker rm -f "$cid" >/dev/null 2>&1 || true + fi +} + +build_images +for runtime in "${RUNTIMES[@]}"; do + echo "==> runtime smoke: $runtime" + run_runtime "$runtime" +done + +echo "runtime smoke tests passed: ${RUNTIMES[*]}" diff --git a/scripts/validate-runtime-plugins.sh b/scripts/validate-runtime-plugins.sh new file mode 100755 index 0000000..cd7e2af --- /dev/null +++ b/scripts/validate-runtime-plugins.sh @@ -0,0 +1,119 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +run() { + echo "==> $*" + "$@" +} + +run bash "$ROOT_DIR/scripts/build-runtime-plugins.sh" --check + +run python3 -m json.tool "$ROOT_DIR/.claude-plugin/marketplace.json" >/dev/null +run python3 -m json.tool "$ROOT_DIR/.agents/plugins/marketplace.json" >/dev/null + +while IFS= read -r manifest; do + run python3 -m json.tool "$manifest" >/dev/null +done < <(find "$ROOT_DIR/plugins" -path '*/.claude-plugin/plugin.json' -o -path '*/.codex-plugin/plugin.json' | sort) + +while IFS= read -r mcp_config; do + run python3 -m json.tool "$mcp_config" >/dev/null +done < <(find "$ROOT_DIR/plugins/mcp" -name .mcp.json | sort) + +run python3 - "$ROOT_DIR" <<'PY' +import json +import sys +from pathlib import Path + +root = Path(sys.argv[1]) + +def read_json(path: Path): + with path.open(encoding="utf-8") as fh: + return json.load(fh) + +errors: list[str] = [] + +claude_marketplace = read_json(root / ".claude-plugin/marketplace.json") +for plugin in claude_marketplace.get("plugins", []): + source = plugin.get("source") + if not isinstance(source, str): + errors.append(f".claude-plugin marketplace plugin {plugin.get('name')} has invalid source") + continue + plugin_dir = (root / source).resolve() + if not plugin_dir.is_dir(): + errors.append(f"Claude marketplace source missing: {source}") + continue + if not (plugin_dir / ".claude-plugin/plugin.json").is_file(): + errors.append(f"Claude plugin manifest missing under {source}") + +codex_marketplace = read_json(root / ".agents/plugins/marketplace.json") +for plugin in codex_marketplace.get("plugins", []): + source = plugin.get("source", {}) + source_path = source.get("path") if isinstance(source, dict) else None + if not isinstance(source_path, str): + errors.append(f"Codex marketplace plugin {plugin.get('name')} has invalid source.path") + continue + plugin_dir = (root / source_path).resolve() + if not plugin_dir.is_dir(): + errors.append(f"Codex marketplace source missing: {source_path}") + continue + if not (plugin_dir / ".codex-plugin/plugin.json").is_file(): + errors.append(f"Codex plugin manifest missing under {source_path}") + +for manifest in (root / "plugins/ndf-shared/manifests").glob("*-skills.txt"): + runtime = manifest.name.removesuffix("-skills.txt") + runtime_skills = root / f"plugins/ndf-{runtime}/skills" + if not runtime_skills.is_dir(): + errors.append(f"runtime skills directory missing: {runtime_skills.relative_to(root)}") + continue + for raw in manifest.read_text(encoding="utf-8").splitlines(): + skill = raw.split("#", 1)[0].strip() + if not skill: + continue + if "/" in skill or ".." in skill: + errors.append(f"invalid skill name in {manifest.relative_to(root)}: {skill}") + continue + if not (root / "plugins/ndf-shared/skills" / skill / "SKILL.md").is_file(): + errors.append(f"shared skill missing: {skill}") + if not (runtime_skills / skill / "SKILL.md").is_file(): + errors.append(f"{runtime} runtime skill missing: {skill}") + +for mcp in sorted((root / "plugins/mcp/shared").iterdir()): + if not mcp.is_dir(): + continue + for runtime in ("claude", "codex", "kiro"): + runtime_dir = root / "plugins/mcp" / runtime / mcp.name + if not runtime_dir.is_dir(): + errors.append(f"MCP runtime directory missing: plugins/mcp/{runtime}/{mcp.name}") + continue + if not (runtime_dir / ".mcp.json").is_file(): + errors.append(f"MCP config missing: plugins/mcp/{runtime}/{mcp.name}/.mcp.json") + if runtime == "kiro" and not (runtime_dir / "install.sh").is_file(): + errors.append(f"Kiro MCP installer missing: plugins/mcp/kiro/{mcp.name}/install.sh") + +if errors: + for error in errors: + print(f"ERROR: {error}", file=sys.stderr) + raise SystemExit(1) + +print("runtime plugin manifests and generated paths are valid") +PY + +if command -v claude >/dev/null 2>&1; then + run claude plugin validate "$ROOT_DIR/plugins/ndf-claude" + run claude plugin validate "$ROOT_DIR/.claude-plugin/marketplace.json" +else + echo "==> claude CLI not found; skipped claude plugin validate" +fi + +run bash "$ROOT_DIR/plugins/ndf-kiro/install.sh" --dry-run >/dev/null +run bash "$ROOT_DIR/plugins/ndf-kiro/install.sh" --dry-run --with-codex >/dev/null + +while IFS= read -r installer; do + run bash "$installer" --dry-run >/dev/null +done < <(find "$ROOT_DIR/plugins/mcp/kiro" -name install.sh | sort) + +run python3 "$ROOT_DIR/scripts/check-markdown-links.py" --root "$ROOT_DIR" + +echo "runtime plugin validation passed" diff --git a/tests/runtime-smoke/Containerfile.base b/tests/runtime-smoke/Containerfile.base new file mode 100644 index 0000000..2ed7ef7 --- /dev/null +++ b/tests/runtime-smoke/Containerfile.base @@ -0,0 +1,9 @@ +FROM node:22-bookworm-slim + +RUN apt-get update \ + && apt-get install -y --no-install-recommends bash ca-certificates curl git jq python3 findutils coreutils grep sed \ + && rm -rf /var/lib/apt/lists/* + +ENV HOME=/tmp/runtime-home +ENV PATH=/tmp/runtime-home/.local/bin:/root/.local/bin:/usr/local/bin:/usr/bin:/bin +WORKDIR /tmp/runtime-project diff --git a/tests/runtime-smoke/Containerfile.claude b/tests/runtime-smoke/Containerfile.claude new file mode 100644 index 0000000..ee68eec --- /dev/null +++ b/tests/runtime-smoke/Containerfile.claude @@ -0,0 +1,3 @@ +FROM ai-plugins-runtime-smoke-base + +RUN npm install -g @anthropic-ai/claude-code diff --git a/tests/runtime-smoke/Containerfile.codex b/tests/runtime-smoke/Containerfile.codex new file mode 100644 index 0000000..5edf2e3 --- /dev/null +++ b/tests/runtime-smoke/Containerfile.codex @@ -0,0 +1,3 @@ +FROM ai-plugins-runtime-smoke-base + +RUN curl -fsSL https://chatgpt.com/codex/install.sh | CODEX_NON_INTERACTIVE=1 sh diff --git a/tests/runtime-smoke/Containerfile.kiro b/tests/runtime-smoke/Containerfile.kiro new file mode 100644 index 0000000..7e50ade --- /dev/null +++ b/tests/runtime-smoke/Containerfile.kiro @@ -0,0 +1,3 @@ +FROM ai-plugins-runtime-smoke-base + +RUN curl -fsSL https://cli.kiro.dev/install | bash || true diff --git a/tests/runtime-smoke/README.md b/tests/runtime-smoke/README.md new file mode 100644 index 0000000..821a168 --- /dev/null +++ b/tests/runtime-smoke/README.md @@ -0,0 +1,34 @@ +# Runtime Smoke Tests + +`scripts/runtime-smoke-test.sh` runs Claude, Codex, and Kiro plugin smoke tests in disposable Docker containers. The host home directory and credential directories are not mounted. + +```bash +bash scripts/runtime-smoke-test.sh +bash scripts/runtime-smoke-test.sh --runtime claude +bash scripts/runtime-smoke-test.sh --runtime codex +bash scripts/runtime-smoke-test.sh --runtime kiro +``` + +Artifacts are written to `tmp/runtime-smoke//` by default: + +- `smoke.log` +- `version.log` +- `generated-tree.txt` +- `junit.xml` + +Secret modes: + +- `--with-secrets=off`: PR-safe unauthenticated smoke. +- `--with-secrets=auto`: inject allowlisted secrets when present and skip authenticated checks when absent. +- `--with-secrets=required`: fail when no allowlisted secret exists. + +`--keep-container` is local-debug only and is rejected with `auto` or `required` secret modes. + +File secrets are passed with allowlisted keys: + +```bash +bash scripts/runtime-smoke-test.sh --runtime claude --with-secrets=auto \ + --secret-file bigquery-key-file=/path/to/service-account.json +``` + +The authenticated GitHub workflow accepts `BIGQUERY_KEY_FILE_JSON` as a protected secret and writes it to a temporary file before passing it through `--secret-file`. diff --git a/tests/runtime-smoke/adapters/claude.sh b/tests/runtime-smoke/adapters/claude.sh new file mode 100755 index 0000000..3576fe2 --- /dev/null +++ b/tests/runtime-smoke/adapters/claude.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +set -euo pipefail + +source /workspace/ai-plugins/tests/runtime-smoke/lib/common.sh + +cd "$PROJECT_DIR" +record_version claude --version +run_step "claude plugin validate ndf" claude plugin validate "$REPO_ROOT/plugins/ndf-claude" +run_step "claude marketplace validate" claude plugin validate "$REPO_ROOT/.claude-plugin/marketplace.json" +run_step "claude marketplace add" claude plugin marketplace add "$REPO_ROOT" --scope local +run_step "claude marketplace list" claude plugin marketplace list +run_step "claude install ndf" claude plugin install ndf@ai-plugins +run_step "claude install mcp-bigquery" claude plugin install mcp-bigquery@ai-plugins +run_step "claude plugin list" claude plugin list + +"$REPO_ROOT/tests/runtime-smoke/assertions/assert-plugin-files.sh" claude +"$REPO_ROOT/tests/runtime-smoke/assertions/assert-mcp-config.sh" claude "$REPO_ROOT/plugins/mcp/claude/mcp-bigquery/.mcp.json" +"$REPO_ROOT/tests/runtime-smoke/assertions/assert-hook-fixtures.sh" claude +"$REPO_ROOT/tests/runtime-smoke/assertions/assert-authenticated-smoke.sh" claude +"$REPO_ROOT/tests/runtime-smoke/assertions/assert-no-host-contamination.sh" claude +write_junit claude diff --git a/tests/runtime-smoke/adapters/codex.sh b/tests/runtime-smoke/adapters/codex.sh new file mode 100755 index 0000000..54c303d --- /dev/null +++ b/tests/runtime-smoke/adapters/codex.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -euo pipefail + +source /workspace/ai-plugins/tests/runtime-smoke/lib/common.sh + +cd "$PROJECT_DIR" +record_version codex --version +run_step "codex marketplace add" codex plugin marketplace add "$REPO_ROOT" +run_step "codex marketplace list" codex plugin marketplace list +run_step "codex install ndf" codex plugin add ndf@ai-plugins +run_step "codex install mcp-bigquery" codex plugin add mcp-bigquery@ai-plugins +run_step "codex plugin list" codex plugin list + +"$REPO_ROOT/tests/runtime-smoke/assertions/assert-plugin-files.sh" codex +"$REPO_ROOT/tests/runtime-smoke/assertions/assert-mcp-config.sh" codex "$REPO_ROOT/plugins/mcp/codex/mcp-bigquery/.mcp.json" +"$REPO_ROOT/tests/runtime-smoke/assertions/assert-hook-fixtures.sh" codex +"$REPO_ROOT/tests/runtime-smoke/assertions/assert-authenticated-smoke.sh" codex +"$REPO_ROOT/tests/runtime-smoke/assertions/assert-no-host-contamination.sh" codex +write_junit codex diff --git a/tests/runtime-smoke/adapters/kiro.sh b/tests/runtime-smoke/adapters/kiro.sh new file mode 100755 index 0000000..47b5f2b --- /dev/null +++ b/tests/runtime-smoke/adapters/kiro.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail + +source /workspace/ai-plugins/tests/runtime-smoke/lib/common.sh + +cd "$PROJECT_DIR" +if command -v kiro-cli >/dev/null 2>&1; then + record_version kiro-cli --help +else + echo "kiro-cli is not available; using installer fallback" | tee "$ARTIFACT_DIR/version.log" +fi + +run_step "kiro install ndf" bash "$REPO_ROOT/plugins/ndf-kiro/install.sh" --project "$PROJECT_DIR" --with-slack +run_step "kiro install ndf idempotent" bash "$REPO_ROOT/plugins/ndf-kiro/install.sh" --project "$PROJECT_DIR" --with-slack +run_step "kiro install mcp-bigquery" bash "$REPO_ROOT/plugins/mcp/kiro/mcp-bigquery/install.sh" --project "$PROJECT_DIR" +run_step "kiro install mcp-bigquery idempotent" bash "$REPO_ROOT/plugins/mcp/kiro/mcp-bigquery/install.sh" --project "$PROJECT_DIR" + +"$REPO_ROOT/tests/runtime-smoke/assertions/assert-plugin-files.sh" kiro +"$REPO_ROOT/tests/runtime-smoke/assertions/assert-mcp-config.sh" kiro "$PROJECT_DIR/.mcp.json" +"$REPO_ROOT/tests/runtime-smoke/assertions/assert-hook-fixtures.sh" kiro +"$REPO_ROOT/tests/runtime-smoke/assertions/assert-authenticated-smoke.sh" kiro +"$REPO_ROOT/tests/runtime-smoke/assertions/assert-no-host-contamination.sh" kiro +write_junit kiro diff --git a/tests/runtime-smoke/assertions/assert-authenticated-smoke.sh b/tests/runtime-smoke/assertions/assert-authenticated-smoke.sh new file mode 100755 index 0000000..640005f --- /dev/null +++ b/tests/runtime-smoke/assertions/assert-authenticated-smoke.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +set -euo pipefail + +runtime="${1:?runtime is required}" +: "${WITH_SECRETS:=off}" +: "${ARTIFACT_DIR:=/tmp/runtime-artifacts}" +: "${PROJECT_DIR:=/tmp/runtime-project}" + +log="$ARTIFACT_DIR/authenticated-smoke.log" + +if [ "$WITH_SECRETS" = off ]; then + echo "authenticated smoke skipped: --with-secrets=off" > "$log" + exit 0 +fi + +auth_ran=false + +run_bigquery_secret_check() { + [ -n "${BIGQUERY_PROJECT:-}" ] || return 1 + [ -n "${BIGQUERY_LOCATION:-}" ] || return 1 + [ -n "${BIGQUERY_DATASET:-}" ] || return 1 + [ -n "${BIGQUERY_KEY_FILE:-}" ] || return 1 + case "$BIGQUERY_KEY_FILE" in + /tmp/runtime-secrets/*) ;; + *) echo "BIGQUERY_KEY_FILE is not inside /tmp/runtime-secrets" >&2; return 1 ;; + esac + test -f "$BIGQUERY_KEY_FILE" + python3 - "$BIGQUERY_KEY_FILE" >> "$log" <<'PY' +import json +import sys +from pathlib import Path + +path = Path(sys.argv[1]) +json.loads(path.read_text(encoding="utf-8")) +print("bigquery credential json parsed") +PY + auth_ran=true +} + +case "$runtime" in + claude) + if [ -n "${ANTHROPIC_API_KEY:-}" ]; then + timeout 60s claude -p "runtime smoke: reply OK only" >> "$log" 2>&1 + auth_ran=true + fi + run_bigquery_secret_check || true + ;; + codex) + if [ -n "${OPENAI_API_KEY:-}" ]; then + timeout 60s codex exec --dangerously-bypass-approvals-and-sandbox "Print OK only for runtime smoke." >> "$log" 2>&1 + auth_ran=true + fi + run_bigquery_secret_check || true + ;; + kiro) + if command -v kiro-cli >/dev/null 2>&1 && [ -n "${ANTHROPIC_API_KEY:-}${OPENAI_API_KEY:-}" ]; then + timeout 30s kiro-cli doctor >> "$log" 2>&1 || true + auth_ran=true + fi + run_bigquery_secret_check || true + ;; + *) echo "unknown runtime: $runtime" >&2; exit 2 ;; +esac + +if [ "$auth_ran" = false ]; then + if [ "$WITH_SECRETS" = required ]; then + echo "no runtime-usable authenticated smoke secret was available for $runtime" >&2 + exit 1 + fi + echo "authenticated smoke skipped: no runtime-usable secret for $runtime" > "$log" +fi diff --git a/tests/runtime-smoke/assertions/assert-hook-fixtures.sh b/tests/runtime-smoke/assertions/assert-hook-fixtures.sh new file mode 100755 index 0000000..6e18ad7 --- /dev/null +++ b/tests/runtime-smoke/assertions/assert-hook-fixtures.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +set -euo pipefail + +runtime="${1:?runtime is required}" +: "${REPO_ROOT:=/workspace/ai-plugins}" +: "${PROJECT_DIR:=/tmp/runtime-project}" +: "${HOME:=/tmp/runtime-home}" + +case "$runtime" in + claude) + PLUGIN_ROOT="$REPO_ROOT/plugins/ndf-claude" CLAUDE_PLUGIN_ROOT="$REPO_ROOT/plugins/ndf-claude" \ + bash "$REPO_ROOT/plugins/ndf-claude/scripts/ensure-retention.sh" < "$REPO_ROOT/tests/runtime-smoke/fixtures/hook-session-start.json" + PLUGIN_ROOT="$REPO_ROOT/plugins/ndf-claude" CLAUDE_PLUGIN_ROOT="$REPO_ROOT/plugins/ndf-claude" \ + node "$REPO_ROOT/plugins/ndf-claude/scripts/slack-notify.js" session_end < "$REPO_ROOT/tests/runtime-smoke/fixtures/hook-stop.json" >/dev/null + ;; + codex) + PLUGIN_ROOT="$REPO_ROOT/plugins/ndf-codex" \ + node "$REPO_ROOT/plugins/ndf-codex/scripts/codex-slack-notify.js" < "$REPO_ROOT/tests/runtime-smoke/fixtures/hook-stop.json" >/dev/null + ;; + kiro) + test -f "$PROJECT_DIR/.kiro/agents/default.json" + jq -e '.hooks.agentSpawn[0].command' "$PROJECT_DIR/.kiro/agents/default.json" >/dev/null + stop_command="$(jq -r '.hooks.stop[0].command // empty' "$PROJECT_DIR/.kiro/agents/default.json")" + test -n "$stop_command" + stop_script="$(python3 - "$stop_command" <<'PY' +import shlex +import sys + +parts = shlex.split(sys.argv[1]) +if len(parts) < 2 or parts[0] != "node": + raise SystemExit(1) +print(parts[1]) +PY +)" + test -f "$stop_script" + PLUGIN_ROOT="$REPO_ROOT/plugins/ndf-kiro" \ + node "$stop_script" session_end < "$REPO_ROOT/tests/runtime-smoke/fixtures/hook-stop.json" >/dev/null + ;; + *) echo "unknown runtime: $runtime" >&2; exit 2 ;; +esac diff --git a/tests/runtime-smoke/assertions/assert-mcp-config.sh b/tests/runtime-smoke/assertions/assert-mcp-config.sh new file mode 100755 index 0000000..bea3dec --- /dev/null +++ b/tests/runtime-smoke/assertions/assert-mcp-config.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +set -euo pipefail + +runtime="${1:?runtime is required}" +config="${2:?config path is required}" + +python3 - "$runtime" "$config" <<'PY' +import json +import os +import re +import sys +from pathlib import Path + +runtime = sys.argv[1] +path = Path(sys.argv[2]) +data = json.loads(path.read_text(encoding="utf-8")) +text = json.dumps(data, ensure_ascii=False) +if "bigquery" not in text: + raise SystemExit(f"{runtime}: bigquery MCP config not found in {path}") +for name in ("BIGQUERY_PROJECT", "BIGQUERY_LOCATION", "BIGQUERY_DATASET", "BIGQUERY_KEY_FILE"): + if name not in text: + raise SystemExit(f"{runtime}: missing env placeholder {name}") + value = os.environ.get(name) + if value and len(value) >= 8 and value in text: + raise SystemExit(f"{runtime}: secret value leaked into {path}") +if "/tmp/runtime-secrets" in text: + raise SystemExit(f"{runtime}: runtime secret path leaked into {path}") +if not re.search(r"\$\{BIGQUERY_[A-Z_]+\}", text): + raise SystemExit(f"{runtime}: expected ${...} placeholders in {path}") +PY diff --git a/tests/runtime-smoke/assertions/assert-no-host-contamination.sh b/tests/runtime-smoke/assertions/assert-no-host-contamination.sh new file mode 100755 index 0000000..0a0b630 --- /dev/null +++ b/tests/runtime-smoke/assertions/assert-no-host-contamination.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +set -euo pipefail + +runtime="${1:?runtime is required}" +: "${REPO_ROOT:=/workspace/ai-plugins}" +: "${PROJECT_DIR:=/tmp/runtime-project}" +: "${HOME:=/tmp/runtime-home}" + +case "$HOME" in + /tmp/runtime-home*) ;; + *) echo "$runtime: HOME is not isolated: $HOME" >&2; exit 1 ;; +esac +test "$PROJECT_DIR" = /tmp/runtime-project + +for path in "$REPO_ROOT/.claude" "$REPO_ROOT/.codex" "$REPO_ROOT/.kiro" "$REPO_ROOT/.mcp.json"; do + if [ -e "$path" ]; then + echo "$runtime: runtime config contaminated repo root: $path" >&2 + exit 1 + fi +done +for forbidden in /root/.claude /root/.codex /root/.kiro /root/.config /root/.aws /root/.ssh; do + if [ -e "$forbidden" ]; then + echo "$runtime: runtime touched forbidden host-like path: $forbidden" >&2 + exit 1 + fi +done +if find /tmp/runtime-artifacts -path '/tmp/runtime-artifacts/*runtime-secrets*' -print | grep -q .; then + echo "$runtime: secret path included in artifacts" >&2 + exit 1 +fi diff --git a/tests/runtime-smoke/assertions/assert-plugin-files.sh b/tests/runtime-smoke/assertions/assert-plugin-files.sh new file mode 100755 index 0000000..869fca1 --- /dev/null +++ b/tests/runtime-smoke/assertions/assert-plugin-files.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +set -euo pipefail + +runtime="${1:?runtime is required}" +: "${REPO_ROOT:=/workspace/ai-plugins}" +: "${PROJECT_DIR:=/tmp/runtime-project}" +: "${HOME:=/tmp/runtime-home}" + +case "$runtime" in + claude) + find "$HOME" -path '*/.claude-plugin/plugin.json' -print | grep -q . + find "$HOME" -path '*/skills/*/SKILL.md' -print | grep -q . + find "$HOME" -path '*/agents/*.md' -print | grep -q . + find "$HOME" -path '*/hooks/hooks.json' -print | grep -q . + ;; + codex) + find "$HOME" -path '*/.codex-plugin/plugin.json' -print | grep -q . + find "$HOME" -path '*/skills/*/SKILL.md' -print | grep -q . + find "$HOME" -path '*/hooks/hooks.json' -print | grep -q . + ;; + kiro) + test -f "$PROJECT_DIR/.kiro/agents/default.json" + find -L "$PROJECT_DIR/.kiro/skills" -path '*/SKILL.md' -print | grep -q . + test -f "$PROJECT_DIR/.kiro/prompts/pr.md" + test -L "$PROJECT_DIR/.kiro/mcp_runtime/mcp-bigquery" + ;; + *) echo "unknown runtime: $runtime" >&2; exit 2 ;; +esac diff --git a/tests/runtime-smoke/fixtures/hook-session-start.json b/tests/runtime-smoke/fixtures/hook-session-start.json new file mode 100644 index 0000000..38731f2 --- /dev/null +++ b/tests/runtime-smoke/fixtures/hook-session-start.json @@ -0,0 +1,5 @@ +{ + "hook_event_name": "SessionStart", + "session_id": "runtime-smoke-session", + "cwd": "/tmp/runtime-project" +} diff --git a/tests/runtime-smoke/fixtures/hook-stop.json b/tests/runtime-smoke/fixtures/hook-stop.json new file mode 100644 index 0000000..d857c04 --- /dev/null +++ b/tests/runtime-smoke/fixtures/hook-stop.json @@ -0,0 +1,6 @@ +{ + "hook_event_name": "Stop", + "session_id": "runtime-smoke-session", + "cwd": "/tmp/runtime-project", + "transcript_path": "/tmp/runtime-project/transcript.jsonl" +} diff --git a/tests/runtime-smoke/fixtures/mcp-env.example b/tests/runtime-smoke/fixtures/mcp-env.example new file mode 100644 index 0000000..d4bf91d --- /dev/null +++ b/tests/runtime-smoke/fixtures/mcp-env.example @@ -0,0 +1,4 @@ +BIGQUERY_PROJECT=example-project +BIGQUERY_LOCATION=US +BIGQUERY_DATASET=example_dataset +BIGQUERY_KEY_FILE=/tmp/runtime-secrets/bigquery-key-file diff --git a/tests/runtime-smoke/lib/common.sh b/tests/runtime-smoke/lib/common.sh new file mode 100755 index 0000000..3504326 --- /dev/null +++ b/tests/runtime-smoke/lib/common.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${REPO_ROOT:=/workspace/ai-plugins}" +: "${PROJECT_DIR:=/tmp/runtime-project}" +: "${ARTIFACT_DIR:=/tmp/runtime-artifacts}" +: "${HOME:=/tmp/runtime-home}" + +export PATH="$HOME/.local/bin:/root/.local/bin:$PATH" + +mkdir -p "$ARTIFACT_DIR" +cd "$REPO_ROOT" + +if [ -f /tmp/runtime-secrets/raw-env ]; then + # shellcheck disable=SC1091 + source /tmp/runtime-secrets/raw-env +fi + +run_step() { + local name="$1" + shift + echo "==> $name" + "$@" +} + +record_version() { + echo "==> $*" | tee -a "$ARTIFACT_DIR/version.log" + "$@" 2>&1 | tee -a "$ARTIFACT_DIR/version.log" +} + +write_junit() { + local runtime="$1" + cat > "$ARTIFACT_DIR/junit.xml" < + + + +XML +} diff --git a/tests/runtime-smoke/secrets-files.allowlist b/tests/runtime-smoke/secrets-files.allowlist new file mode 100644 index 0000000..72c85f4 --- /dev/null +++ b/tests/runtime-smoke/secrets-files.allowlist @@ -0,0 +1,4 @@ +google-application-credentials +bigquery-key-file +aws-credentials +aws-config