diff --git a/.gitignore b/.gitignore index 82376a7..055b158 100644 --- a/.gitignore +++ b/.gitignore @@ -142,3 +142,6 @@ vite.config.ts.timestamp-* # MCP .mcp.json + +# AIに渡すプロンプトファイルを置く場所 +user-prompt diff --git a/design-docs-for-ai/issue3-codex-review-loop-implementation-plan.md b/design-docs-for-ai/issue3-codex-review-loop-implementation-plan.md new file mode 100644 index 0000000..a157040 --- /dev/null +++ b/design-docs-for-ai/issue3-codex-review-loop-implementation-plan.md @@ -0,0 +1,2187 @@ +# Issue #3: Claude Codeが作成した実装プランをCodexでレビューするループ機能の実装計画 + +## 1. 概要 + +### 1.1 目的 + +Claude Codeが作成した実装計画(Markdownファイル)を、Codex CLIでレビューし、レビュー指摘の修正→再レビューのループを半自動で回す機能を `planloop` CLIに実装する。 + +### 1.2 背景 + +[Zenn記事「AIコーディングの技術負債・理解負債排除手法」](https://zenn.dev/avaintelligence/articles/debt-free-ai-coding-practices) のステップ2.2〜2.5で手動実施しているレビュー往復(最大10往復以上)の自動化が目的。現状は都度Markdownファイルパスを変えてCodexにレビューを依頼し、結果をClaude Codeに渡す作業を手動で行っている。 + +### 1.3 設計方針 + +| 方針 | 詳細 | +| -------------------------- | ------------------------------------------------------------------------------------------------------------------ | +| 半自動オーケストレーション | 初回レビューは人間がtriageし、2回目以降は自動フィルタリング。完全自動化は過剰指摘の永久ループを招くため回避する | +| 構造化出力 | proseではなくJSON構造でレビュー結果を管理し、停止条件を機械的に判定する | +| エージェント非依存 | アダプターパターンで将来的にClaude/Codex以外のAIエージェントに差し替え可能な設計 | +| CLI wrapper方式 | v1ではSDKではなくCLI(`claude -p`、`codex exec`)を`child_process`で呼び出す。認証やレート制限の問題を回避するため | +| 初回判断の永続化 | 人間のtriage判断をwaiver ruleとして保存し、次回以降のラウンドで自動フィルタとして再利用する | + +### 1.4 全体フロー + +``` +planloop run --plan --prompt + │ + ▼ +┌─── ループ開始 ───────────────────────────────────┐ +│ │ +│ 1. Codex exec でレビュー実行 │ +│ (構造化JSON出力, workspace-write sandbox) │ +│ ※毎回fresh run(セッション不要) │ +│ │ │ +│ ▼ │ +│ 2. Waiver適用 & 前ラウンドとの差分比較 │ +│ │ │ +│ ▼ │ +│ 3. 人間トリアージ判定 │ +│ ├── 初回 → 必ずトリアージ │ +│ └── 2回目以降 → 新規HIGH時のみトリアージ │ +│ │ │ +│ ▼ (トリアージが必要な場合) │ +│ 3a. レビュー結果をトリアージファイルに書き出し │ +│ 3b. $EDITOR でトリアージファイルを開く │ +│ 3c. 人間が自然言語でフィードバックを記入 │ +│ 例: 「指摘2はスコープ外」 │ +│ 「以後、互換性に関する指摘は対象外」 │ +│ 3d. fresh codex exec でフィードバック付き再レビュー│ +│ (初回レビュー結果+人間フィードバックを含む) │ +│ 3e. Codexが調整後のレビュー結果を返す │ +│ 3f. フィードバックからwaiver ruleを抽出・永続化 │ +│ │ │ +│ ▼ │ +│ 4. Blocking findingsを抽出 │ +│ ├── 0件 → ループ終了 │ +│ └── 1件以上 → 次へ │ +│ │ │ +│ 5. 停滞判定 │ +│ ├── 同一fingerprint集合が連続 → 終了 │ +│ └── 変化あり → 次へ │ +│ │ │ +│ ▼ │ +│ 6. Claude Code で実装計画を修正 │ +│ (curated reviewのみ渡す, fresh session) │ +│ │ │ +│ ▼ │ +│ 7. ラウンド状態を記録 │ +│ └── 次のラウンドへ ─────────────────┘ │ +│ │ +└───────────────────────────────────────────────────┘ + │ + ▼ + ループ終了(結果サマリー表示) +``` + +### 1.5 設計上の重要な判断 + +#### 1.5.1 `codex exec resume` を使用しない理由 + +初期設計ではトリアージ時に `codex exec resume --last` で前回セッションを再開する方針だったが、以下の理由で **fresh run 方式**に変更した: + +1. **`resume` サブコマンドが `--output-schema` をサポートしていない**(codex-cli 0.117.0 で確認)。構造化出力をスキーマで制約できないため、planloop側で頑強にパースする追加負担が発生する。 +2. **`--last` によるセッション特定が脆弱**。ユーザーが別ターミナルで `codex exec` を実行した場合、誤ったセッションを resume する危険がある。複数 run の並列実行にも耐えない。 + +代替として、トリアージ後の再レビューでは **初回レビュー結果 + 人間のフィードバックを含む新しいプロンプトで fresh `codex exec` を実行**する。これにより、セッション管理の複雑さを排除し、常に `--output-schema` による構造化出力を利用できる。 + +#### 1.5.2 レビュー工程で `workspace-write` を採用する理由 + +レビュー工程では `read-only` ではなく `workspace-write` サンドボックスを使用する。**v1ではこの設定は固定であり、設定ファイルで変更することはできない。** 実行コマンドは常に `--full-auto`(`workspace-write` + 自動承認のエイリアス)を使用する。理由は以下の通り: + +- `gh` コマンド(GitHub Issue/PR の参照)の実行に `workspace-write` + `network_access = true` が必要 +- MCP サーバー(Context7等)の利用に `workspace-write` が必要 +- Web検索等のネットワークアクセスを伴うツール実行の安定性を確保するため + +> 注: 将来的に `read-only` 等を選択可能にする場合は、`--full-auto` をやめて `--sandbox ` と approval 方針を明示的に組み立てる設計へ変更する必要がある(v2以降で検討)。 + +#### 1.5.3 `codex exec` でのWeb検索の有効化方法 + +`codex exec` サブコマンドには `--search` オプションが存在しない(codex-cli 0.117.0 で確認)。Web検索を non-interactive な `codex exec` で有効化するには、以下のいずれかの方法を使う: + +1. **グローバル設定(推奨)**: `~/.codex/config.toml` のトップレベルに `web_search = "live"` を設定する。`codex exec` はグローバル設定を読み込むため、この設定があればWeb検索ツールが自動的に利用可能になる。 +2. **コマンドラインオーバーライド**: `-c 'web_search="live"'` を `codex exec` に渡す。 + +**設定キーの一次情報による検証(codex-cli 0.117.0):** + +``` +$ codex exec --enable web_search_request -c 'features.web_search_request=true' ... + +→ `[features].web_search_request` is deprecated because web search is enabled by default. + (Set `web_search` to "live", "cached", or "disabled" at the top level + (or under a profile) in config.toml if you want to override it.) +``` + +上記のCLI出力により、以下が確認されている: + +- **`[features].web_search_request` は deprecated**(旧形式。GitHub Issue #6031 / #7661 等で見られる記述は旧バージョン向け) +- **`web_search = "live" | "cached" | "disabled"` がトップレベルの正式な設定キー**(現行形式) +- Web検索は codex-cli 0.117.0 ではデフォルトで有効 + +planloop は v1 ではグローバル設定に `web_search = "live"` が設定されていることを前提条件とする(§2.1参照)。planloop 側でコマンドラインに `-c` を付加する追加実装は行わない。 + +> 注: `web_search` がグローバル設定で明示的に `"disabled"` に設定されている場合、Web検索は利用できない。エビデンス検証(§7.15)において `web_search` は `suggestedEvidence` に分類するため、未充足でも警告表示のみでレビューは続行する。`web_search` を `requiredEvidence` として扱いたい場合は、ユーザーがグローバル設定を正しく行った上で、エビデンス検証の結果を人間が確認する運用とする。 + +ただし、レビュー工程では **Codex がレビュー対象の plan ファイルを編集してはならない**。この制約は以下の「edit guard」により保証する: + +- **レビュー前**: plan ファイルの内容をメモリ上にスナップショットとして保存し、SHA-256ハッシュも記録する +- **レビュー後**: plan ファイルのSHA-256ハッシュを再計算し、レビュー前と一致することを検証 +- **不一致の場合**: スナップショットからplan ファイルを復元し、当該ラウンドを失敗扱いとしてエラーを報告する + +> 注: ハッシュ値だけでは元の内容は復元できないため、ファイル内容そのものをスナップショットとして保持する。検知にはハッシュ比較(高速)を使い、復元にはスナップショット(正確)を使う二段構えとする。 + +## 2. 前提条件 + +### 2.1 実行環境 + +- Node.js >= 22(ESM対応) +- Claude Code CLIがインストール済み(`claude` コマンドが利用可能) +- Codex CLIがインストール済み(`codex` コマンドが利用可能) +- 各CLIの認証が完了済み +- Codexのグローバル設定(`~/.codex/config.toml`)で以下が設定済み: + - `sandbox_mode = "workspace-write"` + `network_access = true`(ghコマンド等のネットワークアクセスに必要) + - `web_search = "live"`(Web検索の有効化。§1.5.3参照) + - レビュー時に使用するMCPサーバー(Context7等)の設定 + - 対象プロジェクトの `trust_level = "trusted"` 設定 + +### 2.2 プロジェクト設定 + +- TypeScript 6.x, ESM (`"type": "module"`, `"module": "Node16"`) +- Biome/ultracite によるlint/format +- vitestによるテスト +- `.npmrc` の `save-exact=true` によりバージョン固定 + +## 3. 追加する依存ライブラリ + +### 3.1 dependencies + +| ライブラリ | 用途 | 選定理由 | +| ---------- | ---------------------- | ------------------------------------------------ | +| commander | CLIフレームワーク | サブコマンド対応、TypeScript型サポート、軽量 | +| zod | スキーマバリデーション | 設定ファイル・構造化出力の型安全なバリデーション | +| yaml | YAMLパーサー | 設定ファイル(`.planloop/config.yml`)の読み込み | +| picocolors | ターミナル色付け | 軽量(依存なし)、Finding表示の視認性向上 | + +### 3.2 devDependencies + +追加なし(既存の `@types/node`、`vitest` で十分) + +### 3.3 インストールコマンド + +```bash +npm install commander zod yaml picocolors +``` + +> 注: `.npmrc` の `save-exact=true` により、バージョンは自動的に固定される + +### 3.4 対話型トリアージUIについて + +v1では対話型トリアージを `process.stdin` / `process.stdout` を直接使用した最小限の実装で行う。`@inquirer/prompts` 等の追加ライブラリは、UX改善が必要になった時点で導入を検討する。 + +## 4. ディレクトリ構成 + +### 4.1 ソースファイル構成 + +``` +src/ + bin/ + planloop.ts # CLIエントリポイント(既存を改修) + planloop.test.ts # CLIテスト(既存を改修) + types/ + index.ts # 全型定義・定数 + config/ + schema.ts # Zodスキーマ定義 + loader.ts # 設定ファイル読み込み + loader.test.ts + adapters/ + types.ts # アダプターインターフェース定義 + claude-cli.ts # Claude CLI アダプター + claude-cli.test.ts + codex-cli.ts # Codex CLI アダプター + codex-cli.test.ts + core/ + fingerprint.ts # Finding fingerprint生成 + fingerprint.test.ts + waiver.ts # Waiverマッチングロジック + waiver.test.ts + triage.ts # 対話型トリアージ + triage.test.ts + loop-runner.ts # メインループオーケストレーター + loop-runner.test.ts + prompts/ + codex-review.ts # Codexレビュープロンプト生成 + codex-review.test.ts + claude-revision.ts # Claude修正プロンプト生成 + claude-revision.test.ts + display/ + result.ts # 結果サマリー表示 + result.test.ts + stream-renderer.ts # リアルタイム進捗表示 + stream-renderer.test.ts + evidence/ + analyzer.ts # prompt/planからのエビデンス要件抽出 + analyzer.test.ts + verifier.ts # toolsUsedとエビデンス要件の照合 + verifier.test.ts + intervention/ + handler.ts # 途中介入ハンドラー + handler.test.ts +``` + +### 4.2 設定・状態ファイル構成(ユーザープロジェクト側) + +``` +/ + .planloop/ + config.yml # 設定ファイル(Git管理対象) + runs/ # 実行状態ディレクトリ(Git管理外) + / + state.json # ループ全体の状態 + round-1/ + codex-raw.jsonl # Codex生出力 + review.json # パース済みレビュー結果 + triage.json # トリアージ決定 + round-2/ + ... +``` + +### 4.3 .gitignore への追加(ユーザープロジェクト側で必要) + +``` +.planloop/runs/ +``` + +## 5. 型定義 + +### 5.1 src/types/index.ts + +```typescript +// ---- Finding categories ---- +export const FINDING_CATEGORIES = [ + "correctness", + "spec_mismatch", + "missing_acceptance_criteria", + "migration_risk", + "speculative_future", + "unnecessary_fallback", + "code_quality", + "security", + "performance", + "other", +] as const; + +export type FindingCategory = (typeof FINDING_CATEGORIES)[number]; + +// ---- Finding severity ---- +export const FINDING_SEVERITIES = ["high", "medium", "low"] as const; +export type FindingSeverity = (typeof FINDING_SEVERITIES)[number]; + +// ---- A single review finding ---- +export type Finding = { + id: string; + summary: string; + detail: string; + severity: FindingSeverity; + category: FindingCategory; + fingerprint: string; + lineRef?: string; +}; + +// ---- Triage result ---- +export type TriageResult = { + adjustedFindings: RawFinding[]; + humanFeedback: string; + newWaivers: Waiver[]; +}; + +// ---- Raw finding (fingerprint未設定、アダプターからの生出力) ---- +export type RawFinding = Omit; + +// ---- Persistent waiver rule ---- +export type Waiver = { + match: string; + category?: FindingCategory; + action: "ignore" | "downgrade"; + downgradeTo?: "non_blocking"; + reason: string; +}; + +// ---- Codex review result ---- +export type ReviewResult = { + round: number; + timestamp: string; + findings: Finding[]; + toolsUsed: string[]; + rawOutputPath: string; +}; + +// ---- Claude revision result ---- +export type RevisionResult = { + reflectedFindings: string[]; + summary: string; + timestamp: string; +}; + +// ---- State for a single round ---- +export type RoundState = { + round: number; + review: ReviewResult; + humanFeedback?: string; + actionableFindings: Finding[]; + revision?: RevisionResult; +}; + +// ---- Overall loop state ---- +export type LoopStatus = "in_progress" | "completed" | "stopped"; + +export type LoopState = { + runId: string; + planFile: string; + promptFile: string; + rounds: RoundState[]; + waivers: Waiver[]; + status: LoopStatus; + stopReason?: StopReason; + startedAt: string; + updatedAt: string; +}; + +// ---- Stop reason ---- +export type StopReason = + | "no_blocking_findings" + | "stagnation" + | "max_rounds" + | "human_abort"; + +// ---- Evidence verification ---- +export const EVIDENCE_SOURCES = [ + "gh", // GitHub CLI(Issue/PR参照) + "figma_mcp", // Figma MCP + "context7_mcp", // Context7 MCP(ライブラリドキュメント参照) + "web_search", // Web検索 +] as const; + +export type EvidenceSource = (typeof EVIDENCE_SOURCES)[number]; + +export type EvidenceRequirement = { + source: EvidenceSource; + reason: string; + matchPatterns: string[]; // toolsUsedとの照合に使うパターン(部分一致) +}; + +export type EvidenceVerificationResult = { + required: Array< + EvidenceRequirement & { satisfied: boolean; matchedTools: string[] } + >; + suggested: Array< + EvidenceRequirement & { satisfied: boolean; matchedTools: string[] } + >; + allRequiredSatisfied: boolean; +}; + +// ---- Config ---- +export type PlanloopConfig = { + version: 1; + paths: { + reviewDir: string; + runDir: string; + }; + policy: { + requireHumanOnFirstRound: boolean; + requireHumanOnNewHighSeverity: boolean; + maxRounds: number; + stagnationRounds: number; + blockingCategories: FindingCategory[]; + autoWaiveCategories: FindingCategory[]; + }; + review: { + perspectives: string[]; + additionalInstructions?: string; + }; + engines: { + claude: { + mode: "inherited" | "bare"; + }; + }; +}; +``` + +## 6. 設定ファイル仕様 + +### 6.1 設定ファイルの配置 + +- パス: `.planloop/config.yml` または `.planloop/config.yaml` +- プロジェクトルートからの相対パスで検索 + +### 6.2 完全な設定ファイル例 + +```yaml +version: 1 + +paths: + reviewDir: design-docs-for-ai # curated reviewの出力先 + runDir: .planloop/runs # 実行状態の保存先 + +policy: + requireHumanOnFirstRound: true # 初回ラウンドで必ず人間triageを挟む + requireHumanOnNewHighSeverity: true # 新規HIGH指摘時に人間ゲートを開く + maxRounds: 8 # 最大ラウンド数 + stagnationRounds: 2 # 停滞判定の閾値(同一fingerprintが連続するラウンド数) + blockingCategories: # blockingとして扱うカテゴリ + - correctness + - spec_mismatch + - missing_acceptance_criteria + - migration_risk + autoWaiveCategories: # 自動でwaiveするカテゴリ + - speculative_future + - unnecessary_fallback + +review: + perspectives: # レビュー時の観点(カスタマイズ可能) + - "correctness: 実装計画の内容が要件と一致しているか" + - "spec_mismatch: 仕様との不一致がないか" + - "missing_acceptance_criteria: 受け入れ基準の漏れがないか" + - "migration_risk: マイグレーションリスクがないか" + - "security: セキュリティ上の懸念がないか" + - "performance: パフォーマンス上の懸念がないか" + additionalInstructions: | # レビュアーへの追加指示(任意) + 必ず関連情報を確認してからレビューを行ってください。 + - GitHubのIssueやPRがあればghコマンドで内容を確認 + - 特定のライブラリの利用方法はドキュメントで確認 + - 不明点はWeb検索で確認 + +engines: + # codex: v1ではサンドボックスモードは workspace-write 固定(§1.5.2参照) + # --full-auto(workspace-write + 自動承認)を常に使用する + # 将来的にread-only等を選択可能にする場合はv2で対応 + claude: + mode: inherited # Claude Codeの実行モード +``` + +### 6.3 デフォルト値 + +設定ファイルが存在しない場合、上記の値がデフォルトとして使用される。部分的な設定も可能で、未指定のフィールドにはデフォルト値が適用される。 + +### 6.4 Zodスキーマ (src/config/schema.ts) + +```typescript +import { z } from "zod"; +import { FINDING_CATEGORIES } from "../types/index.js"; + +const findingCategorySchema = z.enum(FINDING_CATEGORIES); + +export const configSchema = z.object({ + version: z.literal(1), + paths: z + .object({ + reviewDir: z.string().default("design-docs-for-ai"), + runDir: z.string().default(".planloop/runs"), + }) + .default({}), + policy: z + .object({ + requireHumanOnFirstRound: z.boolean().default(true), + requireHumanOnNewHighSeverity: z.boolean().default(true), + maxRounds: z.number().int().min(1).max(20).default(8), + stagnationRounds: z.number().int().min(1).max(10).default(2), + blockingCategories: z + .array(findingCategorySchema) + .default([ + "correctness", + "spec_mismatch", + "missing_acceptance_criteria", + "migration_risk", + ]), + autoWaiveCategories: z + .array(findingCategorySchema) + .default(["speculative_future", "unnecessary_fallback"]), + }) + .default({}), + review: z + .object({ + perspectives: z + .array(z.string()) + .default([ + "correctness: 実装計画の内容が要件と一致しているか", + "spec_mismatch: 仕様との不一致がないか", + "missing_acceptance_criteria: 受け入れ基準の漏れがないか", + "migration_risk: マイグレーションリスクがないか", + "security: セキュリティ上の懸念がないか", + "performance: パフォーマンス上の懸念がないか", + ]), + additionalInstructions: z.string().optional(), + }) + .default({}), + engines: z + .object({ + // codex: v1ではサンドボックスモードは workspace-write 固定のため設定項目なし + claude: z + .object({ + mode: z.enum(["inherited", "bare"]).default("inherited"), + }) + .default({}), + }) + .default({}), +}); + +export type ConfigInput = z.input; +``` + +## 7. 各モジュールの詳細仕様 + +### 7.1 設定ファイル読み込み (src/config/loader.ts) + +#### 責務 + +- `.planloop/config.yml` の検索と読み込み +- Zodスキーマによるバリデーションとデフォルト値適用 +- 設定ファイルが存在しない場合はデフォルト設定を返す + +#### 公開関数 + +```typescript +/** + * プロジェクトルートから設定ファイルを読み込む + * @param basePath プロジェクトルートの絶対パス + * @returns バリデーション済みの設定オブジェクト + * @throws 設定ファイルの内容が不正な場合 + */ +export const loadConfig = async (basePath: string): Promise; +``` + +#### 処理フロー + +1. `basePath/.planloop/config.yml` を試みる +2. 存在しなければ `basePath/.planloop/config.yaml` を試みる +3. どちらも存在しなければデフォルト設定を返す +4. ファイルが存在する場合はYAMLパース → Zodバリデーション +5. バリデーションエラー時は具体的なエラーメッセージを含む例外をスロー + +#### テスト項目 (src/config/loader.test.ts) + +- 正常な設定ファイルの読み込みとパース +- `.yml` / `.yaml` 両拡張子の対応 +- 設定ファイル不存在時のデフォルト値適用 +- 部分的な設定での未指定フィールドへのデフォルト適用 +- 不正なYAML構文でのエラー +- スキーマバリデーションエラー(不正なversion、範囲外のmaxRounds等) +- review.perspectivesのカスタム設定の読み込み +- review.additionalInstructionsの読み込み(設定あり/なし) +- review未設定時のデフォルトレビュー観点の適用 + +### 7.2 アダプターインターフェース (src/adapters/types.ts) + +#### 責務 + +- レビュー実行とプラン修正のインターフェースを定義 +- エージェント非依存の抽象化レイヤー + +```typescript +import type { + Finding, + RawFinding, + RevisionResult, + Waiver, +} from "../types/index.js"; + +/** レビューアダプターへの入力コンテキスト */ +export type ReviewContext = { + round: number; + previousWaivers: Waiver[]; + previousFindings: Finding[]; +}; + +/** レビューアダプターの出力(RawFindingはtypes/index.tsで定義) */ +export type ReviewAdapterResult = { + findings: RawFinding[]; + toolsUsed: string[]; + rawOutput: string; +}; + +/** トリアージ付き再レビューの入力コンテキスト */ +export type FeedbackReviewContext = { + round: number; + originalFindings: RawFinding[]; + humanFeedback: string; +}; + +/** レビュー実行アダプター(Codex等) */ +export type ReviewAdapter = { + /** 実装計画の初回レビューを実行する */ + review: ( + planContent: string, + promptContent: string, + context: ReviewContext, + ) => Promise; + + /** + * 人間のフィードバックを踏まえた再レビューを実行する(fresh run) + * 初回レビュー結果と人間のフィードバックを含む新しいプロンプトで + * fresh codex exec を実行し、調整後のfindingsを取得する + */ + reviewWithFeedback: ( + planContent: string, + promptContent: string, + context: FeedbackReviewContext, + ) => Promise; +}; + +/** プラン修正アダプター(Claude等) */ +export type RevisionAdapter = { + revise: ( + planFile: string, + promptFile: string, + findings: Finding[], + ) => Promise; +}; +``` + +### 7.3 Codex CLI アダプター (src/adapters/codex-cli.ts) + +#### 責務 + +- Codex CLIを子プロセスとして実行し、実装計画のレビューを取得する +- 構造化JSON出力をパースしてFinding配列を返す + +#### 公開関数 + +```typescript +export const createCodexCliAdapter = (config: PlanloopConfig): ReviewAdapter; +``` + +#### Codex CLI実行コマンド(共通: review / reviewWithFeedback 両方) + +```bash +echo "<レビュープロンプト>" | codex exec \ + --json \ + --full-auto \ + --ephemeral \ + --output-schema /tmp/planloop-xxxx/review-schema.json \ + -o /tmp/planloop-xxxx/last-message.txt \ + - +``` + +> 注: +> +> - プロンプトが長文になるため、引数ではなくstdin経由(末尾の `-`)で渡す +> - `--output-schema` はファイルパスを受け取るため、JSON Schemaファイルを一時ディレクトリに書き出す必要がある +> - **`--full-auto`** を使用する。これは `--sandbox workspace-write` + 自動承認のエイリアスで、非対話モードでもghコマンド・MCPが自動実行される。v1ではサンドボックスモードは `workspace-write` 固定のため、常にこのフラグを使用する(§1.5.2参照) +> - **`--ephemeral` を使用する**。毎回 fresh run のためセッション保持は不要(§1.5.1参照) +> - **Web検索**: `codex exec` には `--search` オプションがないため、グローバル設定 `~/.codex/config.toml` の `web_search = "live"` に依存する(§1.5.3参照)。planloop側でのコマンドライン追加は行わない +> - **MCP サーバー**(Context7等)はユーザーのグローバル設定 `~/.codex/config.toml` から自動的に読み込まれる。planloop側での追加設定は不要 +> - `review` と `reviewWithFeedback` はどちらもこのコマンド形式を使用する。違いはプロンプト内容のみ + +#### 処理フロー(初回レビュー: `review` メソッド) + +1. planファイルの内容をメモリ上にスナップショットとして保存し、SHA-256ハッシュを記録する(edit guard: §1.5.2参照) +2. `generateCodexReviewPrompt()` でレビュープロンプトを生成 +3. JSON Schemaファイルを一時ディレクトリに書き出す(`--output-schema` 用) +4. プロンプトをstdin経由で `codex exec` に渡し、`child_process.spawn` で実行 + - `--json`: JSONL形式でイベントストリームを出力 + - `--full-auto`: `workspace-write` サンドボックス + 自動承認(ghコマンド・MCP・Web検索を非対話で自動実行) + - `--ephemeral`: セッションを保持しない(fresh run方式) + - `--output-schema `: レビュー結果のJSON Schema制約 + - `-o `: 最終メッセージをファイルに書き出し +5. **`--json` のJSONLストリームを `renderStream()` でリアルタイム表示**しつつ、全出力を収集する +6. `-o` で書き出されたファイルからFinding配列をパースする(最も信頼性が高い) +7. JSONLイベントから `toolsUsed` を収集: `command_execution` → コマンド文字列、`mcp_tool_call` → `ツール名: 入力概要`、`web_search` → `web_search: 検索クエリ` +8. **edit guard検証**: planファイルのSHA-256ハッシュを再計算し、手順1と一致することを確認。不一致の場合は手順1で保存したスナップショットからファイルを復元し、エラーを報告する +9. **エビデンス検証**: `toolsUsed` と事前に組み立てた `requiredEvidence` / `suggestedEvidence` を照合する(§7.15参照) + +#### 処理フロー(フィードバック付き再レビュー: `reviewWithFeedback` メソッド) + +1. planファイルの内容をメモリ上にスナップショットとして保存し、SHA-256ハッシュを記録する(edit guard) +2. `generateCodexFeedbackReviewPrompt()` でフィードバック付き再レビュープロンプトを生成 + - 初回レビューの findings 一覧を含める + - 人間のフィードバック内容を含める + - 「フィードバックを踏まえ、未解決のblocking issueのみを再出力」する指示を含める +3. JSON Schemaファイルを一時ディレクトリに書き出す(初回と同じスキーマ) +4. プロンプトをstdin経由で fresh `codex exec` に渡し、`child_process.spawn` で実行(初回と同じオプション) +5. 出力の収集・パース(初回と同じ手順) +6. edit guard検証(初回と同じ) + +#### Codexへの出力スキーマ(レビュー結果のJSON構造) + +`--output-schema` に渡すJSON Schemaファイルの内容: + +```json +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "required": ["findings"], + "properties": { + "findings": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "summary", "detail", "severity", "category"], + "properties": { + "id": { "type": "string" }, + "summary": { "type": "string" }, + "detail": { "type": "string" }, + "severity": { "type": "string", "enum": ["high", "medium", "low"] }, + "category": { + "type": "string", + "enum": [ + "correctness", + "spec_mismatch", + "missing_acceptance_criteria", + "migration_risk", + "speculative_future", + "unnecessary_fallback", + "code_quality", + "security", + "performance", + "other" + ] + }, + "lineRef": { "type": "string" } + } + } + } + } +} +``` + +期待される出力例: + +```json +{ + "findings": [ + { + "id": "finding-1", + "summary": "指摘の要約(1行)", + "detail": "指摘の詳細説明", + "severity": "high", + "category": "correctness", + "lineRef": "## 7.3 の手順4" + } + ] +} +``` + +#### Codex JSONL出力のイベント構造 + +`--json` オプション使用時、Codexはstdoutに1行1JSONオブジェクトのJSONL形式でイベントを出力する。主要なイベントタイプ: + +```jsonl +{"type": "message", "role": "assistant", "content": "..."} +{"type": "command_execution", "command": "gh issue view 3", "exit_code": 0} +{"type": "mcp_tool_call", "tool": "context7_query", "input": {...}} +{"type": "plan_update", "plan": "..."} +``` + +planloopが活用するイベント: + +- `command_execution`: `toolsUsed` に実行コマンド文字列そのもの(例: `"gh issue view 3"`, `"npm ls"`)を記録する。エビデンス検証(§7.15)で `gh issue` / `gh pr` 等のパターンと部分一致で照合するため、粗い種別(`"command"` 等)ではなく具体的なコマンド文字列が必要 +- `mcp_tool_call`: `toolsUsed` に `": "` の形式で記録する(例: `"context7_query: commander.js"`, `"figma_get_frame: https://figma.com/..."`)。ツール名だけでなく入力概要も残すことで、どの情報源にアクセスしたかを後段で判定可能にする +- `web_search`: `toolsUsed` に `"web_search: <検索クエリ>"` の形式で記録する +- 最終 `message` イベント: Finding JSONの抽出元(`-o` ファイルが利用できない場合のフォールバック) + +#### Codex出力のバリデーションスキーマ(planloop側) + +`-o` で取得した最終メッセージまたはJSONLから抽出したFinding JSONを、Zodスキーマでバリデーションする: + +```typescript +import { z } from "zod"; +import { FINDING_CATEGORIES, FINDING_SEVERITIES } from "../types/index.js"; + +export const codexFindingSchema = z.object({ + id: z.string(), + summary: z.string(), + detail: z.string(), + severity: z.enum(FINDING_SEVERITIES), + category: z.enum(FINDING_CATEGORIES), + lineRef: z.string().optional(), +}); + +export const codexReviewOutputSchema = z.object({ + findings: z.array(codexFindingSchema), +}); +``` + +> 注: CodexがJSON Schema制約に完全に従わない場合も想定し、Zodバリデーション失敗時はエラー詳細をログ出力し、パース可能な部分だけを救済する(partial parsing)。 + +#### エラーハンドリング + +- `codex` コマンドが見つからない場合: `ENOENT` → 「Codex CLIがインストールされていません」メッセージ +- タイムアウト(5分): プロセスをkillし、タイムアウトエラーを返す +- 非ゼロ終了コード: stderrの内容を含むエラーを返す +- JSON パース失敗: 生出力を保存し、パースエラーを返す + +#### テスト項目 (src/adapters/codex-cli.test.ts) + +- 正常なレビュー結果のパース +- JSONL出力からのFinding抽出 +- toolsUsedの収集(command_execution, mcp_tool_call, web_search) +- `codex` 未インストール時のエラーメッセージ +- タイムアウト時の挙動 +- 不正なJSON出力時のフォールバック +- `reviewWithFeedback` メソッドでのfresh run実行(初回findings + フィードバックを含むプロンプト) +- `reviewWithFeedback` メソッドでの調整後Finding配列のパース +- edit guard: planファイルが変更されていた場合のエラー検出とスナップショットからの復元 +- edit guard: planファイルが変更されていない場合の正常通過 +- edit guard: スナップショットからの復元後、ファイル内容がレビュー前と一致すること + +> 注: テストでは `child_process.spawn` をモックし、実際のCodex CLIは呼び出さない + +### 7.4 Claude CLI アダプター (src/adapters/claude-cli.ts) + +#### 責務 + +- Claude Code CLIを子プロセスとして実行し、実装計画の修正を行う +- curated reviewのみをClaudeに渡し、修正結果を取得する + +#### 公開関数 + +```typescript +export const createClaudeCliAdapter = (config: PlanloopConfig): RevisionAdapter; +``` + +#### Claude CLI実行コマンド + +```bash +echo "<修正プロンプト>" | claude -p \ + --output-format stream-json \ + --json-schema '{"type":"object","properties":{"reflectedFindings":{"type":"array","items":{"type":"string"}},"summary":{"type":"string"}},"required":["reflectedFindings","summary"]}' \ + --no-session-persistence \ + --allowedTools "Edit,Read,Glob,Grep" \ + --permission-mode acceptEdits +``` + +> 注: `--json-schema` によりClaude CLIレベルで出力を構造化JSON形式に制約できる。`--no-session-persistence` を指定し、プログラマティック実行でセッション履歴が肥大化するのを防ぐ。`--allowedTools` で実装計画ファイルの編集に必要な最小限のツールに制限する。`--permission-mode acceptEdits` を指定し、ファイル編集時に対話的な許可プロンプトが出ることを防ぐ。 + +#### 処理フロー + +1. `generateClaudeRevisionPrompt()` で修正プロンプトを生成 +2. プロンプトをstdin経由で `claude -p` に渡し、`child_process.spawn` で実行 + - `-p`: 非対話(print)モード + - `--output-format stream-json`: ストリーミングJSON形式で出力(リアルタイム表示用) + - `--json-schema `: 構造化出力のJSON Schema制約(インラインJSON) + - `--no-session-persistence`: セッションをディスクに保存しない + - `--allowedTools "Edit,Read,Glob,Grep"`: 最小限のツール権限 +3. **stream-JSON出力を `renderStream()` でリアルタイム表示**しつつ、全出力を収集する +4. 収集した全出力から最終結果の `reflectedFindings` と `summary` を抽出 +5. `RevisionResult` を返す + +#### Claudeへの修正プロンプトに含める情報 + +- 実装計画ファイルのパス +- curated review(triage済みのblocking findingsのみ) +- 修正指示 +- 出力形式の指定(反映したfindingのIDリスト + 変更概要) + +#### エラーハンドリング + +- `claude` コマンドが見つからない場合: `ENOENT` → 「Claude Code CLIがインストールされていません」メッセージ +- タイムアウト(10分): プロセスをkillし、タイムアウトエラーを返す +- 非ゼロ終了コード: stderrの内容を含むエラーを返す +- `--json-schema` バリデーション失敗: Claude CLIが自動でリトライするため、通常は追加処理不要 + +#### テスト項目 (src/adapters/claude-cli.test.ts) + +- 正常な修正結果のパース +- `-p` モードでの実行引数の正確性 +- `claude` 未インストール時のエラーメッセージ +- タイムアウト時の挙動 + +> 注: テストでは `child_process.spawn` をモックし、実際のClaude CLIは呼び出さない + +### 7.5 フィンガープリント生成 (src/core/fingerprint.ts) + +#### 責務 + +- 各Findingに対して安定したフィンガープリントを生成する +- 異なるラウンドで同一概念の指摘が言い換えられても、同じfingerprintを返す + +#### 公開関数 + +```typescript +/** + * Findingのフィンガープリントを生成する + * @param finding フィンガープリント対象のFinding(fingerprint未設定) + * @returns SHA-256ベースのフィンガープリント文字列 + */ +export const generateFingerprint = (finding: Omit): string; +``` + +#### アルゴリズム + +1. `category` を取得(最も安定した識別子) +2. `summary` を正規化: + - 小文字化 + - 数値を `` に置換(行番号等のブレを吸収) + - 連続する空白を単一スペースに + - 先頭末尾の空白を除去 +3. `category + ":" + normalizedSummary` を結合 +4. SHA-256ハッシュの先頭16文字をフィンガープリントとして返す + +#### テスト項目 (src/core/fingerprint.test.ts) + +- 同一内容のFindingに対して同一のfingerprintを返す +- summaryの微妙な言い換え(数値の変更、大文字小文字)で同一fingerprintを返す +- 異なるcategory/summaryのFindingに対して異なるfingerprintを返す +- 空文字列のsummaryでもエラーにならない + +### 7.6 Waiverマッチング (src/core/waiver.ts) + +#### 責務 + +- Waiver ruleのリストに基づいてFindingをフィルタリングする +- カテゴリベースのwaive(`autoWaiveCategories`)も処理する + +#### 公開関数 + +```typescript +/** + * WaiverルールをFindingリストに適用する + * @param findings フィルタリング対象のFinding配列 + * @param waivers 適用するWaiverルール配列 + * @param autoWaiveCategories 自動waiveするカテゴリ配列 + * @returns フィルタリング結果(waived / active / downgraded) + */ +export const applyWaivers = ( + findings: Finding[], + waivers: Waiver[], + autoWaiveCategories: FindingCategory[], +): WaiverResult; + +export type WaiverResult = { + active: Finding[]; + waived: Array<{ finding: Finding; reason: string }>; + downgraded: Array<{ finding: Finding; from: "blocking"; to: "non_blocking" }>; +}; +``` + +#### マッチングロジック + +1. `autoWaiveCategories` に含まれるカテゴリのFindingを自動waive +2. 各Waiverの `match` 文字列をFindingの `summary` + `detail` に対して部分一致検索 +3. Waiverに `category` が指定されている場合は、カテゴリも一致する必要がある +4. `action: "ignore"` → waivedリストに追加 +5. `action: "downgrade"` → downgradedリストに追加 +6. どのWaiverにもマッチしなかったFinding → activeリストに追加 + +#### テスト項目 (src/core/waiver.test.ts) + +- autoWaveCategoriesによる自動waive +- match文字列による部分一致 +- categoryフィルタ付きWaiverのマッチング +- ignore / downgradeの正しい分類 +- 複数Waiverのどれにもマッチしないケース +- 空のWaiverリストでの全Finding active + +### 7.7 自然言語トリアージ (src/core/triage.ts) + +#### 責務 + +- Codexのレビュー結果をトリアージファイルとして書き出す +- `$EDITOR` でトリアージファイルを開き、人間が自然言語でフィードバックを記入できるようにする +- 人間のフィードバックを `reviewWithFeedback`(fresh codex exec)でCodexに送信し、調整後のレビュー結果を取得する +- フィードバック内容からwaiver ruleを抽出して永続化する + +#### 設計思想 + +Issue #3のChatGPT会話に明記されている通り、人間のUXは自然言語のままとし、内部では構造化して保存する: + +> 入力は自然言語のまま受ける。でも内部では構造化して保存する。 + +人間は自然言語でこう書く: + +``` +- 指摘 2 は対象外。今回は既存互換性を担保する変更ではない +- 指摘 4 は方向性だけ採用。フォールバック実装の追加までは不要 +- 指摘 5 は blocking ではなく non-blocking +- 以後、「将来必要になるかもしれない拡張性」だけの指摘は無視 +``` + +ツール側ではこれを waiver rule として保存し、次回以降のラウンドで自動フィルタとして再利用する。 + +#### 公開関数 + +```typescript +/** + * エディタベースの自然言語トリアージを実行する + * @param findings トリアージ対象のFinding配列 + * @param round ラウンド番号 + * @param planContent 実装計画の内容(フィードバック付き再レビューに渡す) + * @param promptContent プロンプトの内容(フィードバック付き再レビューに渡す) + * @param reviewAdapter フィードバック付き再レビュー実行用のアダプター + * @param config 設定 + * @returns トリアージ結果(調整後のfindings + 新規waiver rules) + */ +export const runNaturalLanguageTriage = async ( + findings: RawFinding[], + round: number, + planContent: string, + promptContent: string, + reviewAdapter: ReviewAdapter, + config: PlanloopConfig, +): Promise; // TriageResult は src/types/index.ts で定義済み +``` + +#### トリアージファイル仕様 + +トリアージファイルは一時ディレクトリに以下の形式で生成される: + +```markdown +# Codex レビュー結果 (Round 1) + +以下はCodexによるレビュー指摘です。内容を確認し、下部のコメントセクションにフィードバックを記入してください。 + +## finding-1 [HIGH] correctness + +API endpoint path does not match the specification. +The plan references /api/v2/users but the spec defines /api/v1/users. + +## finding-2 [MEDIUM] spec_mismatch + +The error handling strategy doesn't cover network timeout scenarios. + +## finding-3 [LOW] speculative_future + +Consider adding pagination support for future scaling needs. + +--- + +# トリアージコメント + +以下に自然言語でレビュー内容への指摘・調整を記入してください。 +保存してエディタを閉じると、フィードバックがCodexに送信されます。 + +例: + +- 指摘2はこの仕様上レビュー対象外 +- 指摘3は将来の話なので無視 +- 以後、互換性に関する指摘は対象外にしてください +``` + +#### 処理フロー + +1. **トリアージファイル生成**: findings をMarkdown形式で一時ファイルに書き出す +2. **エディタ起動**: `$EDITOR`(未設定時は `vi`)でトリアージファイルを開く +3. **フィードバック抽出**: エディタ終了後、`---` セパレータ以降のテキストを人間のフィードバックとして抽出 +4. **フィードバックが空の場合**: フィードバックなし(全指摘をそのまま受け入れ)としてトリアージを終了 +5. **フィードバック付き再レビュー実行**: `reviewAdapter.reviewWithFeedback()` を呼び出す。これは fresh `codex exec` を実行し、以下のコンテキストを含むプロンプトをCodexに渡す: + - 初回レビューの findings 一覧 + - 人間のフィードバック内容 + - 「フィードバックを踏まえ、未解決のblocking issueのみを再出力」する指示 +6. **調整後レビューのパース**: Codexの応答から調整後のfinding配列を抽出(`--output-schema` による構造化出力のため、パースは初回レビューと同じ方式) +7. **Waiver rule抽出**: フィードバックテキストを解析し、永続的なwaiver ruleを生成 + - 「以後〜」「今後〜」「今回以降〜」を含む文はwaiver ruleとして保存 + - 具体的な指摘IDへの言及(「指摘Nは対象外」等)は `ignore-once` として扱い、waiver化しない + +#### Waiver rule抽出ロジック + +```typescript +/** + * 人間のフィードバックテキストからwaiver ruleを抽出する + * 「以後」「今後」「今回以降」等のキーワードを含む行を永続的なwaiver ruleとして扱う + */ +export const extractWaiversFromFeedback = (feedback: string): Waiver[]; +``` + +抽出ルール: + +- `以後`、`今後`、`今回以降`、`以降` を含む行 → waiver rule として保存 + - 例: `以後、「将来必要になるかもしれない拡張性」だけの指摘は無視` → `{ match: "将来必要になるかもしれない拡張性", action: "ignore", reason: "..." }` +- それ以外の行 → 今回のラウンドのみに適用(waiver化しない) + +> 注: v1ではキーワードベースの単純な抽出とする。将来的にはLLMによる意味解析での抽出も検討可能。 + +#### テスト項目 (src/core/triage.test.ts) + +- トリアージファイルの正しいMarkdown生成(findings全件が含まれること) +- フィードバック抽出(`---` セパレータ以降のテキスト取得) +- フィードバックが空の場合の処理(全指摘をそのまま受け入れ) +- waiver ruleの抽出(「以後」「今後」キーワード検出) +- waiver ruleでない行(今回限りの指摘)の正しい分類 +- `reviewWithFeedback` 呼び出し時のコンテキスト(findings + feedback)の正しい構成 +- `$EDITOR` 環境変数未設定時のフォールバック(`vi`) + +> 注: テストではエディタ起動をモック(トリアージファイルへの書き込みをシミュレート)し、ReviewAdapterの `reviewWithFeedback` 呼び出しもモックする + +### 7.8 ループランナー (src/core/loop-runner.ts) + +#### 責務 + +- レビュー→triage→修正のループ全体をオーケストレーションする +- ループ状態の永続化と復元を管理する + +#### 公開関数 + +```typescript +/** + * レビューループを実行する + * @param options ループ実行のオプション + * @returns ループ完了後の最終状態 + */ +export const runLoop = async (options: RunLoopOptions): Promise; + +export type RunLoopOptions = { + planFile: string; + promptFile: string; + config: PlanloopConfig; + reviewAdapter: ReviewAdapter; + revisionAdapter: RevisionAdapter; + stdin?: NodeJS.ReadableStream; + stdout?: NodeJS.WritableStream; +}; +``` + +#### ループロジック(疑似コード) + +```typescript +const runLoop = async (options: RunLoopOptions): Promise => { + const { planFile, promptFile, config, reviewAdapter, revisionAdapter } = + options; + const runId = generateRunId(); // 例: "2026-03-28T21-40-00" + let state = initializeState(runId, planFile, promptFile); + + // 入力ファイルの存在チェック + await validateInputFiles(planFile, promptFile); + + for (let round = 1; round <= config.policy.maxRounds; round++) { + // 1. 実装計画の内容を読み込む(毎ラウンド読み直す: Claude修正後の内容を取得するため) + const planContent = await readFile(planFile, "utf-8"); + const promptContent = await readFile(promptFile, "utf-8"); + + // 2. Codexでレビュー実行 + const reviewResult = await reviewAdapter.review( + planContent, + promptContent, + { + round, + previousWaivers: state.waivers, + previousFindings: getPreviousFindings(state), + }, + ); + + // 3. 各FindingにFingerprintを付与 + const findings = reviewResult.findings.map((f) => ({ + ...f, + fingerprint: generateFingerprint(f), + })); + + // 4. Waiver適用 + const waiverResult = applyWaivers( + findings, + state.waivers, + config.policy.autoWaiveCategories, + ); + + // 5. 人間トリアージ判定 + let activeFindingsForRevision = waiverResult.active; + let triageResult: TriageResult | undefined; + if (needsHumanGate(round, waiverResult.active, state, config)) { + // 自然言語トリアージ: + // エディタを開き、人間がフィードバックを記入 + // → fresh codex exec(reviewWithFeedback)でCodexに送信 + // → 調整後のfindingsを取得 + triageResult = await runNaturalLanguageTriage( + waiverResult.active, + round, + planContent, + promptContent, + reviewAdapter, + config, + ); + activeFindingsForRevision = triageResult.adjustedFindings; + state.waivers.push(...triageResult.newWaivers); + } + + // 6. Blocking findingsを抽出(blockingCategoriesに基づいて自動判定) + const actionable = filterByBlockingCategories( + activeFindingsForRevision, + config, + ); + + // 7. 停止条件チェック + if (actionable.length === 0) { + state.status = "completed"; + state.stopReason = "no_blocking_findings"; + break; + } + if (isStagnating(state, actionable, config.policy.stagnationRounds)) { + state.status = "stopped"; + state.stopReason = "stagnation"; + break; + } + + // 8. Claude Codeで修正 + const revision = await revisionAdapter.revise( + planFile, + promptFile, + actionable, + ); + + // 9. 生出力をファイルに保存し、rawOutputPath を取得 + const rawOutputPath = await saveRawOutput( + config.paths.runDir, + state.runId, + round, + reviewResult.rawOutput, + ); + + // 10. ラウンド状態を記録 + const roundState: RoundState = { + round, + review: { + round, + timestamp: new Date().toISOString(), + findings, + toolsUsed: reviewResult.toolsUsed, + rawOutputPath, + }, + humanFeedback: triageResult?.humanFeedback, + actionableFindings: actionable, + revision, + }; + state.rounds.push(roundState); + state.updatedAt = new Date().toISOString(); + + // 11. 状態を永続化 + await persistState(state, config.paths.runDir); + } + + // maxRounds到達 + if (state.status === "in_progress") { + state.status = "stopped"; + state.stopReason = "max_rounds"; + } + + await persistState(state, config.paths.runDir); + return state; +}; +``` + +#### 人間ゲート判定ロジック + +```typescript +const needsHumanGate = ( + round: number, + activeFindings: Finding[], + state: LoopState, + config: PlanloopConfig, +): boolean => { + // 初回は必ず人間triage + if (round === 1 && config.policy.requireHumanOnFirstRound) { + return true; + } + // 新規HIGH severity findingがある場合 + if (config.policy.requireHumanOnNewHighSeverity) { + const previousFingerprints = new Set( + state.rounds.flatMap((r) => r.review.findings.map((f) => f.fingerprint)), + ); + const hasNewHigh = activeFindings.some( + (f) => f.severity === "high" && !previousFingerprints.has(f.fingerprint), + ); + return hasNewHigh; + } + return false; +}; +``` + +#### 停滞判定ロジック + +```typescript +const isStagnating = ( + state: LoopState, + currentActionable: Finding[], + threshold: number, +): boolean => { + if (state.rounds.length < threshold) return false; + + const currentSet = new Set(currentActionable.map((f) => f.fingerprint)); + let consecutiveMatch = 0; + + for (let i = state.rounds.length - 1; i >= 0; i--) { + const prevSet = new Set( + state.rounds[i].actionableFindings.map((f) => f.fingerprint), + ); + if (setsAreEqual(currentSet, prevSet)) { + consecutiveMatch++; + } else { + break; + } + } + + return consecutiveMatch >= threshold; +}; + +const setsAreEqual = (a: Set, b: Set): boolean => { + if (a.size !== b.size) return false; + for (const item of a) { + if (!b.has(item)) return false; + } + return true; +}; +``` + +#### 生出力の保存 + +```typescript +/** + * アダプターが返した生出力テキストをファイルに保存し、パスを返す + * ReviewAdapterResult.rawOutput → ReviewResult.rawOutputPath への変換を担う + */ +const saveRawOutput = async ( + runDir: string, + runId: string, + round: number, + rawOutput: string, +): Promise => { + const dir = resolve(runDir, runId, `round-${round}`); + await mkdir(dir, { recursive: true }); + const filePath = resolve(dir, "codex-raw.jsonl"); + await writeFile(filePath, rawOutput, "utf-8"); + return filePath; +}; +``` + +#### 状態永続化 + +```typescript +const persistState = async ( + state: LoopState, + runDir: string, +): Promise => { + const dir = resolve(runDir, state.runId); + await mkdir(dir, { recursive: true }); + await writeFile( + resolve(dir, "state.json"), + JSON.stringify(state, null, 2), + "utf-8", + ); +}; +``` + +#### filterByBlockingCategories関数 + +```typescript +/** + * blockingCategoriesに含まれるカテゴリのFindingのみを抽出する + * トリアージ後(または人間ゲート不要時)に、最終的なblocking findingsを決定する + */ +const filterByBlockingCategories = ( + findings: RawFinding[], + config: PlanloopConfig, +): RawFinding[] => { + return findings.filter((f) => + config.policy.blockingCategories.includes(f.category), + ); +}; +``` + +#### SIGINTハンドリング + +triage中にユーザーがCtrl+Cを押した場合: + +1. `process.on("SIGINT")` でシグナルをキャッチ +2. 現在のラウンドまでの状態を永続化 +3. `state.status = "stopped"`, `state.stopReason = "human_abort"` を設定 +4. 状態ファイルに書き込んでからプロセスを終了 + +#### getPreviousFindings ヘルパー + +```typescript +/** + * 過去のラウンドの全Findingを取得する + */ +const getPreviousFindings = (state: LoopState): Finding[] => { + return state.rounds.flatMap((r) => r.review.findings); +}; +``` + +#### RunId生成 + +```typescript +// ISO 8601形式のタイムスタンプからファイルシステムセーフなIDを生成 +const generateRunId = (): string => { + return new Date().toISOString().replace(/[:.]/g, "-"); // 例: "2026-03-28T21-40-00-000Z" +}; +``` + +#### 入力ファイルの検証 + +```typescript +import { access } from "node:fs/promises"; +import { extname } from "node:path"; + +const validateInputFiles = async ( + planFile: string, + promptFile: string, +): Promise => { + try { + await access(planFile); + } catch { + throw new Error(`実装計画ファイルが見つかりません: ${planFile}`); + } + try { + await access(promptFile); + } catch { + throw new Error(`プロンプトファイルが見つかりません: ${promptFile}`); + } + // Markdown以外のファイルは警告のみ(エラーにはしない) + for (const file of [planFile, promptFile]) { + if (extname(file) !== ".md") { + console.warn(`警告: ${file} はMarkdownファイルではありません`); + } + } +}; +``` + +#### テスト項目 (src/core/loop-runner.test.ts) + +- 1ラウンドでblocking finding 0件 → 即座にcompleted +- 複数ラウンドの正常なループ実行 +- 初回の人間ゲート発動 +- 新規HIGH finding時の人間ゲート発動 +- 停滞判定でのループ停止 +- maxRounds到達でのループ停止 +- Waiver ruleの累積適用 +- 状態永続化の正確性 +- filterByBlockingCategoriesの判定 +- 自然言語トリアージ後のadjustedFindingsの反映 +- トリアージでフィードバック空の場合(全指摘受け入れ) +- humanFeedbackのラウンド状態への記録 +- 入力ファイル不存在時のエラー +- SIGINT時の状態保存 +- エビデンス検証結果の表示(required充足/未充足、suggested警告) +- エビデンスrequired未充足時のユーザー確認プロンプト + +> 注: テストではReviewAdapter / RevisionAdapterをモックとして注入 + +### 7.9 Codexレビュープロンプト生成 (src/prompts/codex-review.ts) + +#### 責務 + +- Codexに渡すレビュープロンプトを生成する +- 前ラウンドのwaiver情報を含める + +#### 公開関数 + +```typescript +import type { PlanloopConfig } from "../types/index.js"; +import type { ReviewContext } from "../adapters/types.js"; + +export const generateCodexReviewPrompt = ( + planContent: string, + promptContent: string, + context: ReviewContext, + config: PlanloopConfig, +): string; +``` + +#### プロンプトテンプレート + +````markdown +あなたは実装計画のレビュアーです。以下の実装計画をレビューしてください。 + +## レビュー対象の実装計画 + +{planContent} + +## 実装計画の元となった要件(プロンプト) + +{promptContent} + +## レビュー指示 + +1. 以下の観点でレビューを行ってください: + {config.review.perspectives を1行ずつ " - " 付きで出力} + +{config.review.additionalInstructions が設定されている場合のみ出力:} 2. 追加指示: +{config.review.additionalInstructions} + +{最後に固定で出力:} 3. 以下の種類の指摘は避けてください: + +- speculative_future: 「将来必要になるかもしれない」だけの指摘 +- unnecessary_fallback: 不要なフォールバック実装の要求 + +{waiverSection} + +## 出力形式 + +以下のJSON形式で出力してください。JSON以外のテキストは出力しないでください。 + +```json +{ + "findings": [ + { + "id": "finding-1", + "summary": "指摘の要約(1行)", + "detail": "指摘の詳細説明(複数行可)", + "severity": "high | medium | low", + "category": "correctness | spec_mismatch | missing_acceptance_criteria | migration_risk | security | performance | code_quality | other" + } + ] +} +``` +```` + +```` + +#### waiver sectionの生成 + +前ラウンドでwaive-ruleが設定されている場合: + +```markdown +## 前回のレビューでの調整事項 + +以下の観点は前回のレビューで対象外と判断されています。これらに該当する指摘は出力しないでください。 + +{waiver.forEach: "- {waiver.reason} (カテゴリ: {waiver.category}, パターン: {waiver.match})"} +```` + +#### テスト項目 (src/prompts/codex-review.test.ts) + +- 基本的なプロンプト生成(デフォルトのレビュー観点が含まれること) +- カスタムレビュー観点の反映(config.review.perspectivesの内容がプロンプトに含まれること) +- additionalInstructionsありの場合のセクション含有 +- additionalInstructionsなしの場合のセクション省略 +- waiver情報なしの場合のwaiver section省略 +- waiver情報ありの場合のwaiver section含有 +- planContent / promptContentの正確な埋め込み +- フィードバック付き再レビュープロンプト(`generateCodexFeedbackReviewPrompt`)の生成 +- フィードバック付きプロンプトに初回findingsが正しく含まれること +- フィードバック付きプロンプトに人間のフィードバックが正しく含まれること + +#### フィードバック付き再レビュープロンプト生成 + +```typescript +import type { RawFinding } from "../types/index.js"; +import type { FeedbackReviewContext } from "../adapters/types.js"; + +/** + * 人間のフィードバックを踏まえた再レビュー用プロンプトを生成する + * reviewWithFeedback メソッドで使用する + */ +export const generateCodexFeedbackReviewPrompt = ( + planContent: string, + promptContent: string, + context: FeedbackReviewContext, + config: PlanloopConfig, +): string; +``` + +#### フィードバック付き再レビュープロンプトテンプレート + +```markdown +あなたは実装計画のレビュアーです。前回のレビューに対して人間からフィードバックがあったため、調整後のレビュー結果を出力してください。 + +## レビュー対象の実装計画 + +{planContent} + +## 実装計画の元となった要件(プロンプト) + +{promptContent} + +## 前回のレビュー指摘 + +{context.originalFindings.forEach: "### {finding.id} [{finding.severity}] {finding.category}\n{finding.summary}\n{finding.detail}"} + +## 人間からのフィードバック + +{context.humanFeedback} + +## 指示 + +上記のフィードバックを踏まえ、以下の条件で再レビューしてください: + +1. waiveまたは対象外と指示された指摘は含めないでください +2. フィードバックで修正方針が示された指摘は、その方針を反映して調整してください +3. フィードバックで言及されていない指摘はそのまま残してください +4. 新たに気づいた指摘があれば追加してください + +## 出力形式 + +(初回レビューと同じJSON形式) +``` + +### 7.10 Claude修正プロンプト生成 (src/prompts/claude-revision.ts) + +#### 責務 + +- Claude Codeに渡す修正プロンプトを生成する +- triage済みのblocking findingsのみを含める + +#### 公開関数 + +```typescript +export const generateClaudeRevisionPrompt = ( + planFile: string, + promptFile: string, + findings: Finding[], +): string; +``` + +#### プロンプトテンプレート + +````markdown +以下の実装計画に対するレビュー指摘を反映してください。 + +## 対象ファイル + +実装計画: {planFile} +元の要件: {promptFile} + +## 反映すべきレビュー指摘 + +{findings.forEach: "### {finding.id} [{finding.severity}] {finding.category}\n{finding.summary}\n{finding.detail}"} + +## 指示 + +1. 上記の指摘内容を実装計画ファイル({planFile})に反映してください +2. 実装計画ファイルを直接編集してください +3. 元の要件({promptFile})の内容に矛盾しないように注意してください +4. 反映結果を以下のJSON形式で標準出力に出力してください + +```json +{ + "reflectedFindings": ["finding-1", "finding-2"], + "summary": "変更内容の概要" +} +``` +```` + +``` + +#### テスト項目 (src/prompts/claude-revision.test.ts) + +- 基本的なプロンプト生成 +- 複数Findingの正確なフォーマット +- ファイルパスの正確な埋め込み + +### 7.11 CLIエントリポイント (src/bin/planloop.ts) + +#### 責務 +- commander.jsベースのCLI定義 +- サブコマンドのルーティング + +#### コマンド体系 + +``` + +planloop run --plan --prompt # レビューループを実行 +planloop status [--run ] # ループの状態を表示 +planloop init # デフォルト設定ファイルを生成 + +```` + +#### 実装 + +```typescript +#!/usr/bin/env node + +import { Command } from "commander"; +import { loadConfig } from "../config/loader.js"; +import { createCodexCliAdapter } from "../adapters/codex-cli.js"; +import { createClaudeCliAdapter } from "../adapters/claude-cli.js"; +import { runLoop } from "../core/loop-runner.js"; + +const program = new Command(); + +program + .name("planloop") + .description("AIコーディングエージェントが作成した実装計画のレビューループを自動化するCLIツール") + .version("0.1.0"); + +program + .command("run") + .description("レビューループを実行する") + .requiredOption("--plan ", "レビュー対象の実装計画ファイル") + .requiredOption("--prompt ", "実装計画の元となったプロンプトファイル") + .action(async (options) => { + const config = await loadConfig(process.cwd()); + const reviewAdapter = createCodexCliAdapter(config); + const revisionAdapter = createClaudeCliAdapter(config); + + const result = await runLoop({ + planFile: options.plan, + promptFile: options.prompt, + config, + reviewAdapter, + revisionAdapter, + }); + + displayResult(result, process.stdout); + }); + +program + .command("status") + .description("ループの状態を表示する") + .option("--run ", "表示するランのID(省略時は最新)") + .action(async (options) => { + // 1. config.paths.runDir からrunディレクトリ一覧を取得 + // 2. --run 指定時はそのID、省略時は最新のstate.jsonを読み込む + // 3. displayResult() で状態を表示 + // 最新runの判定: ディレクトリ名(ISO 8601ベース)のソートで最新を取得 + }); + +program + .command("init") + .description("デフォルト設定ファイルと.gitignoreエントリを生成する") + .action(async () => { + // 1. .planloop/ ディレクトリを作成 + // 2. .planloop/config.yml をデフォルト値で生成(既存なら上書きしない) + // 3. .planloop/runs/ ディレクトリを作成 + // 4. .gitignore に .planloop/runs/ が含まれていなければ追記 + }); + +program.parse(); +```` + +#### テスト項目の改修 (src/bin/planloop.test.ts) + +既存テスト(バージョン情報出力)に加えて: + +- `planloop --help` でヘルプメッセージが表示される +- `planloop run --help` でrunコマンドのヘルプが表示される +- `planloop run` で `--plan` / `--prompt` 未指定時のエラー +- `planloop init` でデフォルト設定ファイルが生成される + +### 7.12 結果サマリー表示 (src/display/result.ts) + +#### 責務 + +- ループ完了後の結果をターミナルに表示する + +#### 公開関数 + +```typescript +/** + * ループ完了結果をターミナルに表示する + */ +export const displayResult = ( + state: LoopState, + stdout: NodeJS.WritableStream, +): void; +``` + +#### 表示内容 + +``` +════════════════════════════════════════ + planloop 完了 +════════════════════════════════════════ + ステータス: completed (no_blocking_findings) + ラウンド数: 3 + 実行時間: 2026-03-28T21:40:00Z ~ 2026-03-28T21:55:00Z + 適用Waiver: 2件 +──────────────────────────────────────── + ラウンド別サマリー: + Round 1: 7件 → triage → 3件blocking → Claude修正 + Round 2: 2件 → auto → 1件blocking → Claude修正 + Round 3: 0件 → 完了 +──────────────────────────────────────── + 状態ファイル: .planloop/runs/2026-03-28T21-40-00-000Z/state.json +════════════════════════════════════════ +``` + +- ステータスに応じてpicolorsで色分け: + - `completed` → 緑 + - `stopped (stagnation)` → 黄 + - `stopped (max_rounds)` → 黄 + - `stopped (human_abort)` → 赤 + +### 7.13 リアルタイム進捗表示 (src/display/stream-renderer.ts) + +#### 責務 + +- Codex/Claude Codeの実行状況をリアルタイムでターミナルに表示する +- JSONLストリーム(Codex `--json`)およびstream-JSON(Claude `--output-format stream-json`)をパースし、人間が読める形式でレンダリングする + +#### 公開関数 + +```typescript +import type { ChildProcess } from "node:child_process"; + +/** + * 子プロセスのstdoutストリームをリアルタイムでターミナルに表示する + * @param childProcess spawn済みの子プロセス + * @param label 表示ラベル(例: "Codex レビュー", "Claude Code 修正") + * @param format ストリームのフォーマット + * @returns ストリーム終了時に全出力を返すPromise + */ +export const renderStream = ( + childProcess: ChildProcess, + label: string, + format: "codex-jsonl" | "claude-stream-json", +): Promise; +``` + +#### Codex JSONL ストリームの表示 + +Codexの `--json` 出力から主要イベントを抽出し、以下の形式でリアルタイム表示する: + +``` +═══ Round 1: Codex レビュー実行中 ═══ + 🔍 [tool] gh issue view 3 --repo nekochans/planloop + 🔍 [tool] context7: querying commander.js docs + 💬 レビュー観点を確認中... + 📋 Finding 1: API endpoint path mismatch (HIGH) + 📋 Finding 2: Missing error handling (MEDIUM) + 📋 Finding 3: Pagination not considered (LOW) + ✓ レビュー完了: 3件の指摘 +``` + +表示対象のJSONLイベント: + +- `command_execution` → `🔍 [tool] ` として表示 +- `mcp_tool_call` → `🔍 [tool] ` として表示 +- `message` (role: assistant) → 内容に "finding" や指摘を含む場合は `📋` 、それ以外は `💬` として表示 + +#### Claude stream-JSON ストリームの表示 + +Claude CLIの `--output-format stream-json` 出力をパースし、以下の形式で表示する: + +``` +═══ Round 1: Claude Code 修正中 ═══ + 📖 [Read] design-docs-for-ai/issue3-plan.md + ✏️ [Edit] design-docs-for-ai/issue3-plan.md + 💬 finding-1を反映: APIパスを修正 + ✏️ [Edit] design-docs-for-ai/issue3-plan.md + 💬 finding-2を反映: エラーハンドリングを追加 + ✓ 修正完了 +``` + +表示対象のstream-JSONイベント: + +- ツール使用イベント(Read, Edit, Glob等) → ツール名とパラメータを表示 +- テキスト出力イベント → Claude の応答テキストを表示 + +#### テスト項目 (src/display/stream-renderer.test.ts) + +- Codex JSONL形式の正しいパースと表示 +- Claude stream-JSON形式の正しいパースと表示 +- 不正なJSONL行のスキップ(エラーにならないこと) +- 空のストリームでのハンドリング +- 全出力の収集と返却 + +### 7.14 途中介入ハンドラー (src/intervention/handler.ts) + +#### 責務 + +- ループ実行中にユーザーのキー入力を監視する +- 介入キーが押された場合、実行中の子プロセスを中断し、ユーザーに選択肢を提示する + +#### 公開関数 + +```typescript +/** + * 途中介入の監視を開始する + * @param onIntervene 介入時のコールバック + * @returns 監視を停止するための関数 + */ +export const startInterventionMonitor = ( + onIntervene: () => Promise, +): StopMonitor; + +export type InterventionAction = + | "abort" // このラウンドを破棄してループを終了 + | "retry" // このラウンドを破棄してやり直す + | "continue"; // 中断を取り消して続行 + +export type StopMonitor = () => void; +``` + +#### 介入フロー + +1. **キー入力監視**: `process.stdin` をrawモードに設定し、キー入力を監視する +2. **介入キー**: `q` キーを押すと介入モードに入る +3. **子プロセス停止**: 実行中の `codex` / `claude` 子プロセスに `SIGTERM` を送信 +4. **選択肢表示**: + +``` +⚠ 介入を検出しました。実行中のプロセスを停止しました。 + + [A] 中断して終了 — このラウンドの結果を破棄し、ここまでの状態を保存して終了 + [R] やり直し — このラウンドを最初からやり直す + [C] 続行 — 中断を取り消して処理を続行(プロセスは再開されます) +> _ +``` + +5. **アクション実行**: + - `A` (abort): `state.stopReason = "human_abort"` で状態を保存してループ終了 + - `R` (retry): 現在のラウンドを破棄し、同じラウンド番号で再実行 + - `C` (continue): 子プロセスを再起動して処理を続行 + +#### ループランナーとの統合 + +```typescript +// loop-runner.ts 内での使用例 +const stopMonitor = startInterventionMonitor(async () => { + // 実行中の子プロセスを停止 + currentChildProcess?.kill("SIGTERM"); + // ユーザーに選択肢を提示 + return await promptInterventionAction(options.stdin, options.stdout); +}); + +try { + // ラウンド実行... +} finally { + stopMonitor(); // 監視停止(stdinのrawモードを元に戻す) +} +``` + +#### 注意事項 + +- rawモードは対話型トリアージ(エディタ起動)中は無効にする必要がある(エディタがstdinを使うため) +- `--no-tty` のようなオプションで介入機能を無効にできるようにする(CI/CD環境等での利用を想定) +- `process.stdin.isTTY` が `false` の場合(パイプ接続等)は自動的に介入機能を無効にする + +#### テスト項目 (src/intervention/handler.test.ts) + +- `q` キー入力での介入モード発動 +- 各アクション(A/R/C)の正しい処理 +- rawモードの設定と解除 +- TTYでない環境での自動無効化 +- エディタ起動中の監視一時停止 + +> 注: テストでは `process.stdin` をモックし、キー入力をシミュレートする + +### 7.15 エビデンス検証 (src/evidence/) + +#### 7.15.1 エビデンス要件分析器 (src/evidence/analyzer.ts) + +##### 責務 + +- prompt / plan の内容を静的解析し、案件ごとに「必須確認(requiredEvidence)」と「推奨確認(suggestedEvidence)」を組み立てる +- URL パターンやキーワードに基づいて、どの情報源への確認が期待されるかを判定する + +##### 設計思想 + +レビューで「関連情報を必ず確認する」を機械的に保証するには、案件ごとに期待される確認先を事前に特定する必要がある。全案件で一律にチェックするのではなく、prompt / plan の内容から動的に期待値を立てることで、過不足のない検証を実現する。 + +##### 公開関数 + +```typescript +import type { EvidenceRequirement } from "../types/index.js"; + +export type EvidenceAnalysisResult = { + required: EvidenceRequirement[]; + suggested: EvidenceRequirement[]; +}; + +/** + * prompt / plan の内容を静的解析し、エビデンス要件を抽出する + * @param planContent 実装計画の内容 + * @param promptContent プロンプトの内容 + * @returns 必須確認と推奨確認の要件リスト + */ +export const analyzeEvidenceRequirements = ( + planContent: string, + promptContent: string, +): EvidenceAnalysisResult; +``` + +##### 抽出ルール + +| 検出パターン | 情報源 | 分類 | matchPatterns | +| ------------------------------------------------------------------------- | -------------- | ------------- | --------------------------------- | +| GitHub Issue/PR URL(`github.com/.../issues/N`, `github.com/.../pull/N`) | `gh` | **required** | `["gh issue", "gh pr"]` | +| `gh issue view`, `gh pr view` 等のコマンド言及 | `gh` | **required** | `["gh issue", "gh pr"]` | +| Figma URL(`figma.com/`) | `figma_mcp` | **required** | `["figma"]` | +| ライブラリ名の言及 + バージョン確認の指示 | `context7_mcp` | **suggested** | `["context7", "resolve-library"]` | +| 「ドキュメントで確認」「公式ドキュメント」等の指示 | `context7_mcp` | **suggested** | `["context7", "resolve-library"]` | +| 「Web検索」「Web で確認」等の指示 | `web_search` | **suggested** | `["web_search", "search"]` | + +##### 処理フロー + +1. `planContent` と `promptContent` を結合してテキストを走査 +2. 正規表現で GitHub URL / Figma URL を検出 → `required` に追加 +3. 「ドキュメントで確認」等のキーワードを検出 → `suggested` に追加 +4. 重複を除去して返す + +##### テスト項目 (src/evidence/analyzer.test.ts) + +- GitHub Issue URLを含むplanからの `gh` required検出 +- GitHub PR URLを含むpromptからの `gh` required検出 +- Figma URLを含むplanからの `figma_mcp` required検出 +- ライブラリ言及からの `context7_mcp` suggested検出 +- URL/キーワードが一切ない場合の空配列返却 +- 複数の情報源が同時に検出される場合 + +#### 7.15.2 エビデンス検証器 (src/evidence/verifier.ts) + +##### 責務 + +- Codex実行後の `toolsUsed` リストとエビデンス要件を照合し、検証結果を返す +- `required` の未充足はエラー(ループ続行前にユーザーに警告) +- `suggested` の未充足は警告のみ + +##### 公開関数 + +```typescript +import type { EvidenceRequirement, EvidenceVerificationResult } from "../types/index.js"; + +/** + * toolsUsedとエビデンス要件を照合する + * @param toolsUsed Codex実行中に使用されたツールのリスト + * @param required 必須エビデンス要件 + * @param suggested 推奨エビデンス要件 + * @returns 検証結果 + */ +export const verifyEvidence = ( + toolsUsed: string[], + required: EvidenceRequirement[], + suggested: EvidenceRequirement[], +): EvidenceVerificationResult; +``` + +##### 照合ロジック + +1. 各 `EvidenceRequirement` の `matchPatterns` を `toolsUsed` の各要素に対して部分一致で検索 +2. 1つでもマッチすれば `satisfied: true`、マッチしたツール名を `matchedTools` に記録 +3. 全 `required` が satisfied なら `allRequiredSatisfied: true` + +##### 検証結果の表示 + +``` +═══ エビデンス検証結果 ═══ + ✓ [必須] GitHub Issue/PR の確認 — gh issue view 3 + ✓ [推奨] ライブラリドキュメントの確認 — context7: querying commander.js + ✗ [推奨] Web検索 — 未実行(推奨のため続行) +``` + +`allRequiredSatisfied` が `false` の場合: + +``` +⚠ [必須] GitHub Issue/PR の確認 — 未実行 + 必須エビデンスが不足しています。レビュー結果の信頼性が低い可能性があります。 + 続行しますか? [Y/n] +``` + +> 注: `required` 未充足時はユーザーに確認を求める(自動中断ではなく、判断は人間に委ねる)。これは、ツール名の部分一致による検出には誤検知の可能性があり、実際にはストリーミング表示で確認できている場合もあるため。 + +##### テスト項目 (src/evidence/verifier.test.ts) + +- 全required/suggested充足時のallRequiredSatisfied: true +- required未充足時のallRequiredSatisfied: false +- matchPatternsの部分一致検出(`gh issue view` → `gh issue` にマッチ) +- toolsUsedが空の場合 +- required/suggestedが空の場合(スキップ) +- 複数のmatchPatternsで1つでもマッチすればsatisfied + +## 8. 停止条件の詳細 + +| 条件 | 判定方法 | LoopState.stopReason | +| -------------------- | ----------------------------------------------------- | ---------------------- | +| Blocking findingなし | `actionableFindings.length === 0` | `no_blocking_findings` | +| 停滞 | 同一fingerprint集合が `stagnationRounds` ラウンド連続 | `stagnation` | +| 最大ラウンド到達 | `round >= maxRounds` | `max_rounds` | +| 人間による中断 | triage中のCtrl+C | `human_abort` | + +## 9. テスト計画 + +### 9.1 単体テスト + +| モジュール | テストファイル | 主なテスト内容 | +| ----------------------- | ------------------------------- | -------------------------------------------------- | +| config/loader | config/loader.test.ts | 設定ファイル読み込み、デフォルト値、バリデーション | +| adapters/codex-cli | adapters/codex-cli.test.ts | Codex CLI呼び出し、出力パース、エラーハンドリング | +| adapters/claude-cli | adapters/claude-cli.test.ts | Claude CLI呼び出し、出力パース、エラーハンドリング | +| core/fingerprint | core/fingerprint.test.ts | フィンガープリント生成の安定性・一意性 | +| core/waiver | core/waiver.test.ts | Waiverマッチング、カテゴリフィルタ | +| core/triage | core/triage.test.ts | 対話型UI、各アクションの処理 | +| core/loop-runner | core/loop-runner.test.ts | ループ全体の統合テスト(アダプターモック) | +| prompts/codex-review | prompts/codex-review.test.ts | プロンプト生成 | +| prompts/claude-revision | prompts/claude-revision.test.ts | プロンプト生成 | +| display/stream-renderer | display/stream-renderer.test.ts | JSONL/stream-JSONのパースとレンダリング | +| evidence/analyzer | evidence/analyzer.test.ts | prompt/planからのエビデンス要件抽出 | +| evidence/verifier | evidence/verifier.test.ts | toolsUsedとエビデンス要件の照合 | +| intervention/handler | intervention/handler.test.ts | キー入力監視、介入アクション処理 | + +### 9.2 テスト方針 + +- **アダプターテスト**: `child_process.spawn` をモックし、CLI出力のパースロジックを検証 +- **ループランナーテスト**: ReviewAdapter / RevisionAdapterをモックとして注入し、ループロジックのみを検証 +- **トリアージテスト**: stdin/stdoutをモックストリームで代替し、対話ロジックを検証 +- **テストデータ**: `src/__fixtures__/` に共通のテストデータを配置: + - `src/__fixtures__/sample-findings.ts` — テスト用Findingファクトリー関数(severity/categoryの各パターン) + - `src/__fixtures__/sample-config.ts` — テスト用PlanloopConfigのデフォルト値 + - `src/__fixtures__/codex-output-valid.jsonl` — 正常なCodex JSONL出力サンプル + - `src/__fixtures__/codex-output-malformed.jsonl` — 不正なCodex出力のテスト用サンプル + - `src/__fixtures__/sample-plan.md` — テスト用の実装計画Markdownファイル + - `src/__fixtures__/sample-prompt.md` — テスト用のプロンプトMarkdownファイル + +## 10. 実装順序 + +実装は依存関係の浅い順に進める。各ステップでテストを先に書き、実装後にパスすることを確認する。 + +### Phase 1: 基盤(依存ライブラリ + 型定義 + インターフェース) + +1. **依存ライブラリのインストール** + - `npm install commander zod yaml picocolors` +2. **型定義の作成** (`src/types/index.ts`) + - 全型定義・定数を作成 +3. **アダプターインターフェース** (`src/adapters/types.ts`) + - ReviewAdapter / RevisionAdapterのインターフェース定義(型定義のみに依存) +4. **設定スキーマの作成** (`src/config/schema.ts`) + - Zodスキーマ定義 +5. **設定ローダーの作成** (`src/config/loader.ts` + テスト) + - 設定ファイル読み込みロジック +6. **テストフィクスチャの作成** (`src/__fixtures__/`) + - 共通テストデータの準備 + +### Phase 2: プロンプトテンプレート + +7. **Codexレビュープロンプト** (`src/prompts/codex-review.ts` + テスト) +8. **Claude修正プロンプト** (`src/prompts/claude-revision.ts` + テスト) + +### Phase 3: コアロジック + +9. **フィンガープリント** (`src/core/fingerprint.ts` + テスト) +10. **Waiverマッチング** (`src/core/waiver.ts` + テスト) +11. **対話型トリアージ** (`src/core/triage.ts` + テスト) + +### Phase 4: アダプター + +12. **Codex CLIアダプター** (`src/adapters/codex-cli.ts` + テスト) +13. **Claude CLIアダプター** (`src/adapters/claude-cli.ts` + テスト) + +### Phase 5: エビデンス検証 + +14. **エビデンス要件分析器** (`src/evidence/analyzer.ts` + テスト) + - prompt/planの静的解析、requiredEvidence/suggestedEvidence の抽出 +15. **エビデンス検証器** (`src/evidence/verifier.ts` + テスト) + - toolsUsedとエビデンス要件の照合 + +### Phase 6: リアルタイム表示・途中介入 + +16. **リアルタイム進捗表示** (`src/display/stream-renderer.ts` + テスト) + - Codex JSONL / Claude stream-JSON のパースとレンダリング +17. **途中介入ハンドラー** (`src/intervention/handler.ts` + テスト) + - キー入力監視、子プロセス停止、選択肢表示 + +### Phase 7: 統合 + +18. **結果サマリー表示** (`src/display/result.ts` + テスト) +19. **ループランナー** (`src/core/loop-runner.ts` + テスト) + - renderStream / interventionHandler / evidenceVerifier との統合 +20. **CLIエントリポイント改修** (`src/bin/planloop.ts` + テスト改修) + +### Phase 8: ドキュメント + +21. **README.md の更新** + - 以下の内容を追記する: + - 新しいCLIコマンドの使い方(`planloop run`、`planloop status`、`planloop init`) + - 前提条件(Claude Code CLI / Codex CLIのインストールと認証) + - `.planloop/config.yml` の設定方法(レビュー観点のカスタマイズ方法を含む) + - 基本的なワークフロー(init → run → 結果確認)の説明 + - ディレクトリ構成の更新 + +### Phase 9: 品質管理 + +22. 品質管理手順の実行(後述) + +## 11. 品質管理手順 + +全ての実装完了後、以下の手順を順番に実施する。1つでも異常終了した場合は、問題点を修正してエラーが出なくなるまで修正を繰り返す。 + +1. `npm run format` — Formatterの適用 +2. `npm run lint` — Linterエラーがないことを確認 +3. `npm run test` — テストコードの実行(全テストがパスすること) +4. `npm run build` — ビルドが正常終了することを確認 diff --git a/package-lock.json b/package-lock.json index 031cf3e..e5ca53d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,12 @@ "name": "@nekochans/planloop", "version": "0.1.0", "license": "MIT", + "dependencies": { + "commander": "14.0.3", + "picocolors": "1.1.1", + "yaml": "2.8.3", + "zod": "4.3.6" + }, "bin": { "planloop": "dist/bin/planloop.js" }, @@ -879,7 +885,6 @@ "version": "14.0.3", "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", - "dev": true, "license": "MIT", "engines": { "node": ">=20" @@ -1471,7 +1476,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, "license": "ISC" }, "node_modules/picomatch": { @@ -1905,6 +1909,30 @@ "engines": { "node": ">=8" } + }, + "node_modules/yaml": { + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index a917d6f..d75e67e 100644 --- a/package.json +++ b/package.json @@ -36,5 +36,11 @@ "typescript": "6.0.2", "ultracite": "7.3.2", "vitest": "4.1.2" + }, + "dependencies": { + "commander": "14.0.3", + "picocolors": "1.1.1", + "yaml": "2.8.3", + "zod": "4.3.6" } } diff --git a/src/__fixtures__/codex-output-malformed.jsonl b/src/__fixtures__/codex-output-malformed.jsonl new file mode 100644 index 0000000..3cfb7ab --- /dev/null +++ b/src/__fixtures__/codex-output-malformed.jsonl @@ -0,0 +1,3 @@ +{"type": "message", "role": "assistant", "content": "Starting review..."} +{this is not valid json} +{"type": "message", "role": "assistant", "content": "Here are my findings: some unstructured text"} diff --git a/src/__fixtures__/codex-output-valid.jsonl b/src/__fixtures__/codex-output-valid.jsonl new file mode 100644 index 0000000..31b826d --- /dev/null +++ b/src/__fixtures__/codex-output-valid.jsonl @@ -0,0 +1,4 @@ +{"type": "message", "role": "assistant", "content": "レビューを開始します。"} +{"type": "command_execution", "command": "gh issue view 3 --repo nekochans/planloop", "exit_code": 0} +{"type": "mcp_tool_call", "tool": "context7_query", "input": {"query": "commander.js"}} +{"type": "message", "role": "assistant", "content": "{\"findings\":[{\"id\":\"finding-1\",\"summary\":\"API endpoint path mismatch\",\"detail\":\"The plan references /api/v2/users but the spec defines /api/v1/users.\",\"severity\":\"high\",\"category\":\"correctness\"},{\"id\":\"finding-2\",\"summary\":\"Missing error handling\",\"detail\":\"Network timeout scenarios are not covered.\",\"severity\":\"medium\",\"category\":\"spec_mismatch\"}]}"} diff --git a/src/__fixtures__/sample-config.ts b/src/__fixtures__/sample-config.ts new file mode 100644 index 0000000..76892bf --- /dev/null +++ b/src/__fixtures__/sample-config.ts @@ -0,0 +1,57 @@ +import type { PlanloopConfig } from "../types/index.js"; + +type DeepPartial = { + [K in keyof T]?: T[K] extends Array + ? T[K] + : T[K] extends object + ? DeepPartial + : T[K]; +}; + +const defaults: PlanloopConfig = { + version: 1, + paths: { + reviewDir: "design-docs-for-ai", + runDir: ".planloop/runs", + }, + policy: { + requireHumanOnFirstRound: true, + requireHumanOnNewHighSeverity: true, + maxRounds: 8, + stagnationRounds: 2, + blockingCategories: [ + "correctness", + "spec_mismatch", + "missing_acceptance_criteria", + "migration_risk", + ], + autoWaiveCategories: ["speculative_future", "unnecessary_fallback"], + }, + review: { + perspectives: [ + "correctness: 実装計画の内容が要件と一致しているか", + "spec_mismatch: 仕様との不一致がないか", + "missing_acceptance_criteria: 受け入れ基準の漏れがないか", + "migration_risk: マイグレーションリスクがないか", + "security: セキュリティ上の懸念がないか", + "performance: パフォーマンス上の懸念がないか", + ], + }, + engines: { + claude: { + mode: "inherited", + }, + }, +}; + +export const createSampleConfig = ( + overrides: DeepPartial = {} +): PlanloopConfig => ({ + version: 1, + paths: { ...defaults.paths, ...overrides.paths }, + policy: { ...defaults.policy, ...overrides.policy }, + review: { ...defaults.review, ...overrides.review }, + engines: { + claude: { ...defaults.engines.claude, ...overrides.engines?.claude }, + }, +}); diff --git a/src/__fixtures__/sample-findings.ts b/src/__fixtures__/sample-findings.ts new file mode 100644 index 0000000..07e93a9 --- /dev/null +++ b/src/__fixtures__/sample-findings.ts @@ -0,0 +1,48 @@ +import type { Finding, RawFinding } from "../types/index.js"; + +export const createRawFinding = ( + overrides: Partial = {} +): RawFinding => ({ + id: "finding-1", + summary: "Test finding summary", + detail: "Test finding detail", + severity: "high", + category: "correctness", + ...overrides, +}); + +export const createFinding = (overrides: Partial = {}): Finding => ({ + id: "finding-1", + summary: "Test finding summary", + detail: "Test finding detail", + severity: "high", + category: "correctness", + fingerprint: "abc123", + ...overrides, +}); + +export const sampleFindings: RawFinding[] = [ + createRawFinding({ + id: "finding-1", + summary: "API endpoint path mismatch", + detail: + "The plan references /api/v2/users but the spec defines /api/v1/users.", + severity: "high", + category: "correctness", + }), + createRawFinding({ + id: "finding-2", + summary: "Missing error handling for network timeout", + detail: + "The error handling strategy doesn't cover network timeout scenarios.", + severity: "medium", + category: "spec_mismatch", + }), + createRawFinding({ + id: "finding-3", + summary: "Consider adding pagination support", + detail: "Consider adding pagination support for future scaling needs.", + severity: "low", + category: "speculative_future", + }), +]; diff --git a/src/__fixtures__/sample-plan.md b/src/__fixtures__/sample-plan.md new file mode 100644 index 0000000..4a20259 --- /dev/null +++ b/src/__fixtures__/sample-plan.md @@ -0,0 +1,15 @@ +# サンプル実装計画 + +## 概要 + +このドキュメントはテスト用のサンプル実装計画です。 + +## 実装内容 + +1. APIエンドポイント `/api/v1/users` の実装 +2. エラーハンドリングの追加 +3. テストコードの作成 + +## 関連Issue + +https://github.com/nekochans/planloop/issues/3 diff --git a/src/__fixtures__/sample-prompt.md b/src/__fixtures__/sample-prompt.md new file mode 100644 index 0000000..da6cf5c --- /dev/null +++ b/src/__fixtures__/sample-prompt.md @@ -0,0 +1,11 @@ +# サンプルプロンプト + +## 要件 + +ユーザー管理APIの実装計画を作成してください。 + +## 制約 + +- REST APIとして実装 +- 認証にはJWTを使用 +- エラーレスポンスはRFC 7807準拠 diff --git a/src/adapters/claude-cli.test.ts b/src/adapters/claude-cli.test.ts new file mode 100644 index 0000000..6a8a4a5 --- /dev/null +++ b/src/adapters/claude-cli.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { extractRevisionResult } from "./claude-cli.js"; + +describe("extractRevisionResult", () => { + it("should parse result from stream-json output", () => { + const output = `{"type":"message","role":"assistant","content":"Analyzing..."} +{"type":"tool_use","tool":"Read","path":"plan.md"} +{"type":"result","result":"{\\"reflectedFindings\\":[\\"finding-1\\",\\"finding-2\\"],\\"summary\\":\\"APIパスを修正しました\\"}"}`; + + const result = extractRevisionResult(output); + expect(result.reflectedFindings).toEqual(["finding-1", "finding-2"]); + expect(result.summary).toBe("APIパスを修正しました"); + }); + + it("should parse direct JSON result", () => { + const output = `{"reflectedFindings":["finding-1"],"summary":"修正完了"}`; + const result = extractRevisionResult(output); + expect(result.reflectedFindings).toEqual(["finding-1"]); + expect(result.summary).toBe("修正完了"); + }); + + it("should return empty result for unparseable output", () => { + const output = "Some non-JSON text\nAnother line"; + const result = extractRevisionResult(output); + expect(result.reflectedFindings).toEqual([]); + expect(result.summary).toBe(""); + }); + + it("should handle empty output", () => { + const result = extractRevisionResult(""); + expect(result.reflectedFindings).toEqual([]); + expect(result.summary).toBe(""); + }); +}); diff --git a/src/adapters/claude-cli.ts b/src/adapters/claude-cli.ts new file mode 100644 index 0000000..4659dd5 --- /dev/null +++ b/src/adapters/claude-cli.ts @@ -0,0 +1,172 @@ +import { spawn } from "node:child_process"; +import { generateClaudeRevisionPrompt } from "../prompts/claude-revision.js"; +import type { + Finding, + PlanloopConfig, + RevisionResult, +} from "../types/index.js"; +import type { RevisionAdapter } from "./types.js"; + +const CLAUDE_TIMEOUT = 10 * 60 * 1000; + +const REVISION_JSON_SCHEMA = JSON.stringify({ + type: "object", + properties: { + reflectedFindings: { + type: "array", + items: { type: "string" }, + }, + summary: { type: "string" }, + }, + required: ["reflectedFindings", "summary"], +}); + +export const createClaudeCliAdapter = ( + _config: PlanloopConfig +): RevisionAdapter => { + return { + revise: async ( + planFile: string, + promptFile: string, + findings: Finding[] + ): Promise => { + const prompt = generateClaudeRevisionPrompt( + planFile, + promptFile, + findings + ); + + const args = [ + "-p", + "--verbose", + "--output-format", + "stream-json", + "--json-schema", + REVISION_JSON_SCHEMA, + "--no-session-persistence", + "--allowedTools", + "Edit,Read,Glob,Grep", + "--permission-mode", + "acceptEdits", + ]; + + const { output } = await spawnClaude(args, prompt); + const result = extractRevisionResult(output); + + return { + reflectedFindings: result.reflectedFindings, + summary: result.summary, + rawOutput: output, + timestamp: new Date().toISOString(), + }; + }, + }; +}; + +const spawnClaude = ( + args: string[], + stdinData: string +): Promise<{ output: string }> => { + return new Promise((resolve, reject) => { + const child = spawn("claude", args, { + stdio: ["pipe", "pipe", "pipe"], + }); + + let output = ""; + let stderr = ""; + let settled = false; + + child.stdout?.on("data", (chunk: Buffer) => { + output += chunk.toString(); + }); + + child.stderr?.on("data", (chunk: Buffer) => { + stderr += chunk.toString(); + }); + + child.on("error", (err: NodeJS.ErrnoException) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timeout); + if (err.code === "ENOENT") { + reject( + new Error( + "Claude Code CLIがインストールされていません。https://claude.ai/code を参照してください。" + ) + ); + } else { + reject(err); + } + }); + + child.on("close", (code) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timeout); + if (code === 0) { + resolve({ output }); + } else { + const lastOutput = output.slice(-500); + const detail = stderr || lastOutput || "(出力なし)"; + reject( + new Error( + `Claude Code CLIがエラーで終了しました (exit code: ${code}):\n${detail}` + ) + ); + } + }); + + const timeout = setTimeout(() => { + if (settled) { + return; + } + settled = true; + child.kill("SIGTERM"); + reject(new Error("Claude Code CLIがタイムアウトしました(10分)")); + }, CLAUDE_TIMEOUT); + + child.stdin?.write(stdinData); + child.stdin?.end(); + }); +}; + +export const extractRevisionResult = ( + output: string +): { reflectedFindings: string[]; summary: string } => { + const lines = output.split("\n").filter((l) => l.trim()); + + for (const line of [...lines].reverse()) { + try { + const parsed = JSON.parse(line); + + if (parsed.type === "result" && typeof parsed.result === "string") { + try { + const inner = JSON.parse(parsed.result); + if (Array.isArray(inner.reflectedFindings)) { + return { + reflectedFindings: inner.reflectedFindings, + summary: inner.summary || "", + }; + } + } catch { + // not parseable inner JSON + } + } + + if (Array.isArray(parsed.reflectedFindings)) { + return { + reflectedFindings: parsed.reflectedFindings, + summary: parsed.summary || "", + }; + } + } catch { + // skip non-JSON lines + } + } + + return { reflectedFindings: [], summary: "" }; +}; diff --git a/src/adapters/codex-cli.test.ts b/src/adapters/codex-cli.test.ts new file mode 100644 index 0000000..88cd49b --- /dev/null +++ b/src/adapters/codex-cli.test.ts @@ -0,0 +1,111 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import { parseFindings, parseFindingsFromJsonl } from "./codex-cli.js"; + +describe("parseFindings", () => { + it("should parse valid JSON findings", () => { + const output = JSON.stringify({ + findings: [ + { + id: "finding-1", + summary: "API mismatch", + detail: "Path is wrong", + severity: "high", + category: "correctness", + }, + ], + }); + const findings = parseFindings(output); + expect(findings).toHaveLength(1); + expect(findings[0].id).toBe("finding-1"); + expect(findings[0].severity).toBe("high"); + }); + + it("should parse findings from code block", () => { + const output = `Here are my findings: +\`\`\`json +{ + "findings": [ + { + "id": "finding-1", + "summary": "Test", + "detail": "Detail", + "severity": "medium", + "category": "spec_mismatch" + } + ] +} +\`\`\``; + const findings = parseFindings(output); + expect(findings).toHaveLength(1); + expect(findings[0].category).toBe("spec_mismatch"); + }); + + it("should do partial parsing when some findings are invalid", () => { + const output = JSON.stringify({ + findings: [ + { + id: "finding-1", + summary: "Valid", + detail: "Detail", + severity: "high", + category: "correctness", + }, + { + id: "finding-2", + summary: "Invalid", + // missing detail + severity: "invalid_severity", + category: "correctness", + }, + ], + }); + const findings = parseFindings(output); + expect(findings).toHaveLength(1); + expect(findings[0].id).toBe("finding-1"); + }); + + it("should throw on completely invalid JSON", () => { + expect(() => parseFindings("not json at all")).toThrow(); + }); +}); + +describe("parseFindingsFromJsonl", () => { + it("should extract findings from valid JSONL", () => { + const jsonl = readFileSync( + resolve(import.meta.dirname, "../__fixtures__/codex-output-valid.jsonl"), + "utf-8" + ); + const findings = parseFindingsFromJsonl(jsonl); + expect(findings).toHaveLength(2); + expect(findings[0].id).toBe("finding-1"); + }); + + it("should collect toolsUsed from JSONL events", () => { + // This tests the collectToolUsage logic indirectly via the JSONL fixture + const jsonl = readFileSync( + resolve(import.meta.dirname, "../__fixtures__/codex-output-valid.jsonl"), + "utf-8" + ); + const findings = parseFindingsFromJsonl(jsonl); + expect(findings.length).toBeGreaterThan(0); + }); + + it("should return empty array for malformed JSONL", () => { + const jsonl = readFileSync( + resolve( + import.meta.dirname, + "../__fixtures__/codex-output-malformed.jsonl" + ), + "utf-8" + ); + const findings = parseFindingsFromJsonl(jsonl); + expect(findings).toHaveLength(0); + }); + + it("should return empty array for empty input", () => { + const findings = parseFindingsFromJsonl(""); + expect(findings).toHaveLength(0); + }); +}); diff --git a/src/adapters/codex-cli.ts b/src/adapters/codex-cli.ts new file mode 100644 index 0000000..a952d4a --- /dev/null +++ b/src/adapters/codex-cli.ts @@ -0,0 +1,359 @@ +import { spawn } from "node:child_process"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import pc from "picocolors"; +import { z } from "zod"; +import { + generateCodexFeedbackReviewPrompt, + generateCodexReviewPrompt, +} from "../prompts/codex-review.js"; +import type { PlanloopConfig, RawFinding } from "../types/index.js"; +import { FINDING_CATEGORIES, FINDING_SEVERITIES } from "../types/index.js"; +import type { + FeedbackReviewContext, + ReviewAdapter, + ReviewAdapterResult, + ReviewContext, +} from "./types.js"; + +const CODEX_TIMEOUT = 5 * 60 * 1000; + +const codexFindingSchema = z.object({ + id: z.string(), + summary: z.string(), + detail: z.string(), + severity: z.enum(FINDING_SEVERITIES), + category: z.enum(FINDING_CATEGORIES), + lineRef: z + .string() + .nullable() + .optional() + .transform((v) => v ?? undefined), +}); + +const codexReviewOutputSchema = z.object({ + findings: z.array(codexFindingSchema), +}); + +const OUTPUT_SCHEMA = { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + required: ["findings"], + additionalProperties: false, + properties: { + findings: { + type: "array", + items: { + type: "object", + required: [ + "id", + "summary", + "detail", + "severity", + "category", + "lineRef", + ], + additionalProperties: false, + properties: { + id: { type: "string" }, + summary: { type: "string" }, + detail: { type: "string" }, + severity: { + type: "string", + enum: ["high", "medium", "low"], + }, + category: { + type: "string", + enum: [ + "correctness", + "spec_mismatch", + "missing_acceptance_criteria", + "migration_risk", + "speculative_future", + "unnecessary_fallback", + "code_quality", + "security", + "performance", + "other", + ], + }, + lineRef: { type: ["string", "null"] }, + }, + }, + }, + }, +}; + +export const createCodexCliAdapter = ( + config: PlanloopConfig +): ReviewAdapter => { + return { + review: ( + planContent: string, + promptContent: string, + context: ReviewContext + ): Promise => { + const prompt = generateCodexReviewPrompt( + planContent, + promptContent, + context, + config + ); + return executeCodexReview(prompt, planContent); + }, + + reviewWithFeedback: ( + planContent: string, + promptContent: string, + context: FeedbackReviewContext + ): Promise => { + const prompt = generateCodexFeedbackReviewPrompt( + planContent, + promptContent, + context, + config + ); + return executeCodexReview(prompt, planContent); + }, + }; +}; + +const executeCodexReview = async ( + prompt: string, + _planContent: string +): Promise => { + const tmpDir = await mkdtemp(join(tmpdir(), "planloop-codex-")); + const schemaPath = join(tmpDir, "review-schema.json"); + const outputPath = join(tmpDir, "last-message.txt"); + await writeFile(schemaPath, JSON.stringify(OUTPUT_SCHEMA), "utf-8"); + + const args = [ + "exec", + "--json", + "--full-auto", + "--ephemeral", + "--output-schema", + schemaPath, + "-o", + outputPath, + "-", + ]; + + try { + const { rawOutput, toolsUsed } = await spawnCodex(args, prompt); + + let findings: RawFinding[]; + try { + const outputContent = await readFile(outputPath, "utf-8"); + findings = parseFindings(outputContent); + } catch (err) { + console.error( + pc.yellow( + ` 警告: -o出力のパース失敗、JSONLフォールバック: ${err instanceof Error ? err.message : String(err)}` + ) + ); + findings = parseFindingsFromJsonl(rawOutput); + } + + return { findings, toolsUsed, rawOutput }; + } finally { + await rm(tmpDir, { recursive: true, force: true }).catch(() => undefined); + } +}; + +const spawnCodex = ( + args: string[], + stdinData: string +): Promise<{ rawOutput: string; toolsUsed: string[] }> => { + return new Promise((resolve, reject) => { + const child = spawn("codex", args, { + stdio: ["pipe", "pipe", "pipe"], + }); + + let rawOutput = ""; + let stderr = ""; + const toolsUsed: string[] = []; + let settled = false; + + child.stdout?.on("data", (chunk: Buffer) => { + const text = chunk.toString(); + rawOutput += text; + + for (const line of text.split("\n")) { + if (!line.trim()) { + continue; + } + try { + const event = JSON.parse(line); + collectToolUsage(event, toolsUsed); + renderProgressEvent(event); + } catch { + // skip non-JSON lines + } + } + }); + + child.stderr?.on("data", (chunk: Buffer) => { + stderr += chunk.toString(); + }); + + child.on("error", (err: NodeJS.ErrnoException) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timeout); + if (err.code === "ENOENT") { + reject( + new Error( + "Codex CLIがインストールされていません。`npm install -g @openai/codex` でインストールしてください。" + ) + ); + } else { + reject(err); + } + }); + + child.on("close", (code) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timeout); + if (code === 0) { + resolve({ rawOutput, toolsUsed }); + } else { + const lastOutput = rawOutput.slice(-500); + const detail = stderr || lastOutput || "(出力なし)"; + reject( + new Error( + `Codex CLIがエラーで終了しました (exit code: ${code}):\n${detail}` + ) + ); + } + }); + + const timeout = setTimeout(() => { + if (settled) { + return; + } + settled = true; + child.kill("SIGTERM"); + reject(new Error("Codex CLIがタイムアウトしました(5分)")); + }, CODEX_TIMEOUT); + + child.stdin?.write(stdinData); + child.stdin?.end(); + }); +}; + +const collectToolUsage = ( + event: Record, + toolsUsed: string[] +): void => { + if (event.type === "command_execution" && typeof event.command === "string") { + toolsUsed.push(event.command); + } else if (event.type === "mcp_tool_call" && typeof event.tool === "string") { + const inputSummary = + typeof event.input === "object" && event.input + ? Object.values(event.input as Record) + .slice(0, 1) + .join(", ") + : ""; + toolsUsed.push( + inputSummary ? `${event.tool}: ${inputSummary}` : event.tool + ); + } else if (event.type === "web_search" && typeof event.query === "string") { + toolsUsed.push(`web_search: ${event.query}`); + } +}; + +export const parseFindings = (output: string): RawFinding[] => { + const jsonStr = extractJson(output); + const parsed = JSON.parse(jsonStr); + const result = codexReviewOutputSchema.safeParse(parsed); + + if (result.success) { + return result.data.findings; + } + + console.error( + pc.yellow(` 警告: Codex出力のスキーマ検証失敗: ${result.error.message}`) + ); + + if (Array.isArray(parsed?.findings)) { + const valid = parsed.findings.filter( + (f: unknown) => codexFindingSchema.safeParse(f).success + ); + if (valid.length < parsed.findings.length) { + console.error( + pc.yellow( + ` 警告: ${parsed.findings.length}件中${valid.length}件のfindingsのみ有効` + ) + ); + } + return valid; + } + + throw new Error(`Codex出力のパースに失敗しました: ${result.error.message}`); +}; + +export const parseFindingsFromJsonl = (jsonl: string): RawFinding[] => { + const lines = jsonl.split("\n").filter((l) => l.trim()); + for (const line of [...lines].reverse()) { + try { + const event = JSON.parse(line); + if ( + event.type === "message" && + event.role === "assistant" && + typeof event.content === "string" + ) { + try { + return parseFindings(event.content); + } catch { + // continue to next message + } + } + } catch { + // skip non-JSON lines + } + } + return []; +}; + +const CODE_BLOCK_REGEX = /```(?:json)?\s*\n?([\s\S]*?)\n?```/; +const JSON_OBJECT_REGEX = /\{[\s\S]*\}/; + +const extractJson = (text: string): string => { + const codeBlockMatch = text.match(CODE_BLOCK_REGEX); + if (codeBlockMatch) { + return codeBlockMatch[1].trim(); + } + + const jsonMatch = text.match(JSON_OBJECT_REGEX); + if (jsonMatch) { + return jsonMatch[0]; + } + + return text; +}; + +const renderProgressEvent = (event: Record): void => { + if (event.type === "command_execution" && typeof event.command === "string") { + process.stderr.write(` ${pc.cyan("[tool]")} ${event.command}\n`); + } else if (event.type === "mcp_tool_call" && typeof event.tool === "string") { + process.stderr.write(` ${pc.cyan("[mcp]")} ${event.tool}\n`); + } else if (event.type === "web_search" && typeof event.query === "string") { + process.stderr.write(` ${pc.cyan("[search]")} ${event.query}\n`); + } else if ( + event.type === "message" && + event.role === "assistant" && + typeof event.content === "string" + ) { + const preview = event.content.slice(0, 80).replace(/\n/g, " "); + if (preview.trim()) { + process.stderr.write(` ${pc.dim("...")} ${pc.dim(preview)}\n`); + } + } +}; diff --git a/src/adapters/types.ts b/src/adapters/types.ts new file mode 100644 index 0000000..de42ba5 --- /dev/null +++ b/src/adapters/types.ts @@ -0,0 +1,51 @@ +import type { + Finding, + RawFinding, + RevisionResult, + Waiver, +} from "../types/index.js"; + +/** レビューアダプターへの入力コンテキスト */ +export interface ReviewContext { + previousFindings: Finding[]; + previousWaivers: Waiver[]; + round: number; +} + +/** レビューアダプターの出力 */ +export interface ReviewAdapterResult { + findings: RawFinding[]; + rawOutput: string; + toolsUsed: string[]; +} + +/** トリアージ付き再レビューの入力コンテキスト */ +export interface FeedbackReviewContext { + humanFeedback: string; + originalFindings: RawFinding[]; + round: number; +} + +/** レビュー実行アダプター(Codex等) */ +export interface ReviewAdapter { + review: ( + planContent: string, + promptContent: string, + context: ReviewContext + ) => Promise; + + reviewWithFeedback: ( + planContent: string, + promptContent: string, + context: FeedbackReviewContext + ) => Promise; +} + +/** プラン修正アダプター(Claude等) */ +export interface RevisionAdapter { + revise: ( + planFile: string, + promptFile: string, + findings: Finding[] + ) => Promise; +} diff --git a/src/bin/planloop.test.ts b/src/bin/planloop.test.ts index 1b3206f..1b937db 100644 --- a/src/bin/planloop.test.ts +++ b/src/bin/planloop.test.ts @@ -1,17 +1,74 @@ import { execFileSync } from "node:child_process"; -import { dirname, resolve } from "node:path"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; const __dirname = dirname(fileURLToPath(import.meta.url)); +const cliPath = resolve(__dirname, "../../dist/bin/planloop.js"); + +const exec = (args: string[], cwd?: string) => + execFileSync("node", [cliPath, ...args], { + encoding: "utf-8", + cwd, + timeout: 10_000, + }); describe("planloop CLI", () => { - it("should output version info", () => { - const result = execFileSync( - "node", - [resolve(__dirname, "../../dist/bin/planloop.js")], - { encoding: "utf-8" } - ); - expect(result.trim()).toBe("planloop v0.1.0"); + it("should display version", () => { + const result = exec(["--version"]); + expect(result.trim()).toBe("0.1.0"); + }); + + it("should display help", () => { + const result = exec(["--help"]); + expect(result).toContain("planloop"); + expect(result).toContain("run"); + expect(result).toContain("status"); + expect(result).toContain("init"); + }); + + it("should display run command help", () => { + const result = exec(["run", "--help"]); + expect(result).toContain("--plan"); + expect(result).toContain("--prompt"); + }); + + it("should error when run is called without --plan", () => { + expect(() => exec(["run", "--prompt", "test.md"])).toThrow(); + }); + + it("should error when run is called without --prompt", () => { + expect(() => exec(["run", "--plan", "test.md"])).toThrow(); + }); + + describe("init command", () => { + let testDir: string; + + beforeAll(() => { + testDir = mkdtempSync(join(tmpdir(), "planloop-init-test-")); + }); + + afterAll(() => { + rmSync(testDir, { recursive: true, force: true }); + }); + + it("should generate default config file", () => { + const result = exec(["init"], testDir); + expect(result).toContain("config.yml"); + + const configContent = readFileSync( + join(testDir, ".planloop", "config.yml"), + "utf-8" + ); + expect(configContent).toContain("version: 1"); + expect(configContent).toContain("maxRounds: 8"); + }); + + it("should not overwrite existing config", () => { + const result = exec(["init"], testDir); + expect(result).toContain("既に存在します"); + }); }); }); diff --git a/src/bin/planloop.ts b/src/bin/planloop.ts index 032e848..526b475 100644 --- a/src/bin/planloop.ts +++ b/src/bin/planloop.ts @@ -1,7 +1,154 @@ #!/usr/bin/env node -const main = () => { - console.log("planloop v0.1.0"); -}; +import { mkdir, readdir, readFile, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { Command } from "commander"; +import { stringify as stringifyYaml } from "yaml"; +import { createClaudeCliAdapter } from "../adapters/claude-cli.js"; +import { createCodexCliAdapter } from "../adapters/codex-cli.js"; +import { loadConfig } from "../config/loader.js"; +import { runLoop } from "../core/loop-runner.js"; +import { displayResult } from "../display/result.js"; +import type { LoopState } from "../types/index.js"; -main(); +const program = new Command(); + +program + .name("planloop") + .description( + "AIコーディングエージェントが作成した実装計画のレビューループを自動化するCLIツール" + ) + .version("0.1.0"); + +program + .command("run") + .description("レビューループを実行する") + .requiredOption("--plan ", "レビュー対象の実装計画ファイル") + .requiredOption("--prompt ", "実装計画の元となったプロンプトファイル") + .action(async (options) => { + try { + const config = await loadConfig(process.cwd()); + const reviewAdapter = createCodexCliAdapter(config); + const revisionAdapter = createClaudeCliAdapter(config); + + const result = await runLoop({ + planFile: options.plan, + promptFile: options.prompt, + config, + reviewAdapter, + revisionAdapter, + }); + + displayResult(result, process.stdout); + } catch (err) { + console.error( + `\nエラー: ${err instanceof Error ? err.message : String(err)}` + ); + process.exit(1); + } + }); + +program + .command("status") + .description("ループの状態を表示する") + .option("--run ", "表示するランのID(省略時は最新)") + .action(async (options) => { + const config = await loadConfig(process.cwd()); + const runDir = config.paths.runDir; + + let targetRunId: string; + if (options.run) { + targetRunId = options.run; + } else { + const entries = await readdir(runDir).catch(() => []); + if (entries.length === 0) { + console.error("実行履歴がありません。"); + process.exit(1); + } + targetRunId = entries.sort().at(-1) as string; + } + + const statePath = resolve(runDir, targetRunId, "state.json"); + try { + const stateJson = await readFile(statePath, "utf-8"); + const state: LoopState = JSON.parse(stateJson); + displayResult(state, process.stdout); + } catch { + console.error(`状態ファイルが見つかりません: ${statePath}`); + process.exit(1); + } + }); + +program + .command("init") + .description("デフォルト設定ファイルと.gitignoreエントリを生成する") + .action(async () => { + const planloopDir = resolve(process.cwd(), ".planloop"); + const configPath = resolve(planloopDir, "config.yml"); + const runsDir = resolve(planloopDir, "runs"); + + await mkdir(planloopDir, { recursive: true }); + await mkdir(runsDir, { recursive: true }); + + try { + await readFile(configPath, "utf-8"); + console.log(".planloop/config.yml は既に存在します。"); + } catch { + const defaultConfig = { + version: 1, + paths: { + reviewDir: "design-docs-for-ai", + runDir: ".planloop/runs", + }, + policy: { + requireHumanOnFirstRound: true, + requireHumanOnNewHighSeverity: true, + maxRounds: 8, + stagnationRounds: 2, + blockingCategories: [ + "correctness", + "spec_mismatch", + "missing_acceptance_criteria", + "migration_risk", + ], + autoWaiveCategories: ["speculative_future", "unnecessary_fallback"], + }, + review: { + perspectives: [ + "correctness: 実装計画の内容が要件と一致しているか", + "spec_mismatch: 仕様との不一致がないか", + "missing_acceptance_criteria: 受け入れ基準の漏れがないか", + "migration_risk: マイグレーションリスクがないか", + "security: セキュリティ上の懸念がないか", + "performance: パフォーマンス上の懸念がないか", + ], + }, + engines: { + claude: { + mode: "inherited", + }, + }, + }; + await writeFile(configPath, stringifyYaml(defaultConfig), "utf-8"); + console.log(".planloop/config.yml を生成しました。"); + } + + const gitignorePath = resolve(process.cwd(), ".gitignore"); + const entry = ".planloop/runs/"; + try { + const content = await readFile(gitignorePath, "utf-8"); + if (!content.includes(entry)) { + await writeFile( + gitignorePath, + `${content.trimEnd()}\n${entry}\n`, + "utf-8" + ); + console.log(`.gitignore に ${entry} を追加しました。`); + } + } catch { + await writeFile(gitignorePath, `${entry}\n`, "utf-8"); + console.log(`.gitignore を生成し、${entry} を追加しました。`); + } + }); + +program.parse(); diff --git a/src/config/loader.test.ts b/src/config/loader.test.ts new file mode 100644 index 0000000..70b08a3 --- /dev/null +++ b/src/config/loader.test.ts @@ -0,0 +1,120 @@ +import { mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { loadConfig } from "./loader.js"; + +describe("loadConfig", () => { + let testDir: string; + + beforeEach(async () => { + testDir = join(tmpdir(), `planloop-test-${Date.now()}`); + await mkdir(join(testDir, ".planloop"), { recursive: true }); + }); + + afterEach(async () => { + await rm(testDir, { recursive: true, force: true }); + }); + + it("should load a valid config file (.yml)", async () => { + await writeFile( + join(testDir, ".planloop", "config.yml"), + "version: 1\npolicy:\n maxRounds: 5\n" + ); + const config = await loadConfig(testDir); + expect(config.version).toBe(1); + expect(config.policy.maxRounds).toBe(5); + }); + + it("should load a valid config file (.yaml)", async () => { + await writeFile( + join(testDir, ".planloop", "config.yaml"), + "version: 1\npolicy:\n maxRounds: 3\n" + ); + const config = await loadConfig(testDir); + expect(config.policy.maxRounds).toBe(3); + }); + + it("should prefer .yml over .yaml", async () => { + await writeFile( + join(testDir, ".planloop", "config.yml"), + "version: 1\npolicy:\n maxRounds: 10\n" + ); + await writeFile( + join(testDir, ".planloop", "config.yaml"), + "version: 1\npolicy:\n maxRounds: 3\n" + ); + const config = await loadConfig(testDir); + expect(config.policy.maxRounds).toBe(10); + }); + + it("should return defaults when no config file exists", async () => { + await rm(join(testDir, ".planloop"), { recursive: true, force: true }); + const config = await loadConfig(testDir); + expect(config.version).toBe(1); + expect(config.policy.maxRounds).toBe(8); + expect(config.policy.requireHumanOnFirstRound).toBe(true); + expect(config.paths.reviewDir).toBe("design-docs-for-ai"); + expect(config.paths.runDir).toBe(".planloop/runs"); + }); + + it("should apply defaults for missing fields in partial config", async () => { + await writeFile(join(testDir, ".planloop", "config.yml"), "version: 1\n"); + const config = await loadConfig(testDir); + expect(config.policy.maxRounds).toBe(8); + expect(config.policy.stagnationRounds).toBe(2); + expect(config.engines.claude.mode).toBe("inherited"); + }); + + it("should throw on invalid YAML syntax", async () => { + await writeFile( + join(testDir, ".planloop", "config.yml"), + "version: 1\npolicy:\n maxRounds: [invalid" + ); + await expect(loadConfig(testDir)).rejects.toThrow(); + }); + + it("should throw on invalid version", async () => { + await writeFile(join(testDir, ".planloop", "config.yml"), "version: 2\n"); + await expect(loadConfig(testDir)).rejects.toThrow("バリデーションエラー"); + }); + + it("should throw on out-of-range maxRounds", async () => { + await writeFile( + join(testDir, ".planloop", "config.yml"), + "version: 1\npolicy:\n maxRounds: 100\n" + ); + await expect(loadConfig(testDir)).rejects.toThrow("バリデーションエラー"); + }); + + it("should load custom review perspectives", async () => { + await writeFile( + join(testDir, ".planloop", "config.yml"), + `version: 1\nreview:\n perspectives:\n - "custom: カスタム観点"\n` + ); + const config = await loadConfig(testDir); + expect(config.review.perspectives).toEqual(["custom: カスタム観点"]); + }); + + it("should load additionalInstructions when set", async () => { + await writeFile( + join(testDir, ".planloop", "config.yml"), + `version: 1\nreview:\n additionalInstructions: "追加指示"\n` + ); + const config = await loadConfig(testDir); + expect(config.review.additionalInstructions).toBe("追加指示"); + }); + + it("should have undefined additionalInstructions when not set", async () => { + await writeFile(join(testDir, ".planloop", "config.yml"), "version: 1\n"); + const config = await loadConfig(testDir); + expect(config.review.additionalInstructions).toBeUndefined(); + }); + + it("should apply default review perspectives when review is not set", async () => { + await writeFile(join(testDir, ".planloop", "config.yml"), "version: 1\n"); + const config = await loadConfig(testDir); + expect(config.review.perspectives).toHaveLength(6); + expect(config.review.perspectives[0]).toContain("correctness"); + }); +}); diff --git a/src/config/loader.ts b/src/config/loader.ts new file mode 100644 index 0000000..d776e99 --- /dev/null +++ b/src/config/loader.ts @@ -0,0 +1,37 @@ +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { parse as parseYaml } from "yaml"; +import type { PlanloopConfig } from "../types/index.js"; +import { configSchema } from "./schema.js"; + +export const loadConfig = async (basePath: string): Promise => { + const ymlPath = resolve(basePath, ".planloop", "config.yml"); + const yamlPath = resolve(basePath, ".planloop", "config.yaml"); + + let rawContent: string | undefined; + + for (const configPath of [ymlPath, yamlPath]) { + try { + rawContent = await readFile(configPath, "utf-8"); + break; + } catch { + // file not found, try next + } + } + + if (rawContent === undefined) { + return configSchema.parse({ version: 1 }); + } + + const parsed = parseYaml(rawContent); + const result = configSchema.safeParse(parsed); + + if (!result.success) { + const messages = result.error.issues + .map((issue) => ` - ${issue.path.join(".")}: ${issue.message}`) + .join("\n"); + throw new Error(`設定ファイルのバリデーションエラー:\n${messages}`); + } + + return result.data; +}; diff --git a/src/config/schema.ts b/src/config/schema.ts new file mode 100644 index 0000000..4367d09 --- /dev/null +++ b/src/config/schema.ts @@ -0,0 +1,59 @@ +import { z } from "zod"; +import { FINDING_CATEGORIES } from "../types/index.js"; + +const findingCategorySchema = z.enum(FINDING_CATEGORIES); + +export const configSchema = z.object({ + version: z.literal(1), + paths: z + .object({ + reviewDir: z.string().default("design-docs-for-ai"), + runDir: z.string().default(".planloop/runs"), + }) + .prefault({}), + policy: z + .object({ + requireHumanOnFirstRound: z.boolean().default(true), + requireHumanOnNewHighSeverity: z.boolean().default(true), + maxRounds: z.number().int().min(1).max(20).default(8), + stagnationRounds: z.number().int().min(1).max(10).default(2), + blockingCategories: z + .array(findingCategorySchema) + .default([ + "correctness", + "spec_mismatch", + "missing_acceptance_criteria", + "migration_risk", + ]), + autoWaiveCategories: z + .array(findingCategorySchema) + .default(["speculative_future", "unnecessary_fallback"]), + }) + .prefault({}), + review: z + .object({ + perspectives: z + .array(z.string()) + .default([ + "correctness: 実装計画の内容が要件と一致しているか", + "spec_mismatch: 仕様との不一致がないか", + "missing_acceptance_criteria: 受け入れ基準の漏れがないか", + "migration_risk: マイグレーションリスクがないか", + "security: セキュリティ上の懸念がないか", + "performance: パフォーマンス上の懸念がないか", + ]), + additionalInstructions: z.string().optional(), + }) + .prefault({}), + engines: z + .object({ + claude: z + .object({ + mode: z.enum(["inherited", "bare"]).default("inherited"), + }) + .prefault({}), + }) + .prefault({}), +}); + +export type ConfigInput = z.input; diff --git a/src/core/fingerprint.test.ts b/src/core/fingerprint.test.ts new file mode 100644 index 0000000..67c4bf4 --- /dev/null +++ b/src/core/fingerprint.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import { createRawFinding } from "../__fixtures__/sample-findings.js"; +import { generateFingerprint } from "./fingerprint.js"; + +describe("generateFingerprint", () => { + it("should return the same fingerprint for identical findings", () => { + const finding = createRawFinding({ summary: "API path mismatch" }); + const fp1 = generateFingerprint(finding); + const fp2 = generateFingerprint(finding); + expect(fp1).toBe(fp2); + }); + + it("should return the same fingerprint for different numeric values in summary", () => { + const finding1 = createRawFinding({ summary: "Error on line 42" }); + const finding2 = createRawFinding({ summary: "Error on line 99" }); + expect(generateFingerprint(finding1)).toBe(generateFingerprint(finding2)); + }); + + it("should return the same fingerprint for case differences", () => { + const finding1 = createRawFinding({ summary: "API Path Mismatch" }); + const finding2 = createRawFinding({ summary: "api path mismatch" }); + expect(generateFingerprint(finding1)).toBe(generateFingerprint(finding2)); + }); + + it("should return different fingerprints for different categories", () => { + const finding1 = createRawFinding({ + summary: "Same summary", + category: "correctness", + }); + const finding2 = createRawFinding({ + summary: "Same summary", + category: "security", + }); + expect(generateFingerprint(finding1)).not.toBe( + generateFingerprint(finding2) + ); + }); + + it("should return different fingerprints for different summaries", () => { + const finding1 = createRawFinding({ summary: "API mismatch" }); + const finding2 = createRawFinding({ summary: "Missing validation" }); + expect(generateFingerprint(finding1)).not.toBe( + generateFingerprint(finding2) + ); + }); + + it("should handle empty summary without error", () => { + const finding = createRawFinding({ summary: "" }); + const fp = generateFingerprint(finding); + expect(fp).toHaveLength(16); + }); + + it("should return 16 character hex string", () => { + const finding = createRawFinding(); + const fp = generateFingerprint(finding); + expect(fp).toHaveLength(16); + expect(fp).toBe(fp.toLowerCase()); + }); +}); diff --git a/src/core/fingerprint.ts b/src/core/fingerprint.ts new file mode 100644 index 0000000..c2e3bff --- /dev/null +++ b/src/core/fingerprint.ts @@ -0,0 +1,15 @@ +import { createHash } from "node:crypto"; +import type { Finding } from "../types/index.js"; + +export const generateFingerprint = ( + finding: Omit +): string => { + const normalized = finding.summary + .toLowerCase() + .replace(/\d+/g, "") + .replace(/\s+/g, " ") + .trim(); + + const input = `${finding.category}:${normalized}`; + return createHash("sha256").update(input).digest("hex").slice(0, 16); +}; diff --git a/src/core/loop-runner.test.ts b/src/core/loop-runner.test.ts new file mode 100644 index 0000000..3144506 --- /dev/null +++ b/src/core/loop-runner.test.ts @@ -0,0 +1,396 @@ +import { mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createSampleConfig } from "../__fixtures__/sample-config.js"; +import { createRawFinding } from "../__fixtures__/sample-findings.js"; +import type { ReviewAdapter, RevisionAdapter } from "../adapters/types.js"; +import type { Finding, LoopState } from "../types/index.js"; +import { + filterByBlockingCategories, + isStagnating, + needsHumanGate, + runLoop, +} from "./loop-runner.js"; + +const createMockReviewAdapter = ( + findingsPerRound: ReturnType[][] +): ReviewAdapter => { + let callCount = 0; + return { + review: vi.fn(() => { + const findings = findingsPerRound[callCount] ?? []; + callCount++; + return Promise.resolve({ findings, toolsUsed: [], rawOutput: "{}" }); + }), + reviewWithFeedback: vi.fn(() => + Promise.resolve({ + findings: [], + toolsUsed: [], + rawOutput: "{}", + }) + ), + }; +}; + +const createMockRevisionAdapter = (): RevisionAdapter => ({ + revise: vi.fn(() => + Promise.resolve({ + reflectedFindings: ["finding-1"], + summary: "修正完了", + rawOutput: "{}", + timestamp: new Date().toISOString(), + }) + ), +}); + +describe("runLoop", () => { + let testDir: string; + let planFile: string; + let promptFile: string; + + beforeEach(async () => { + testDir = join(tmpdir(), `planloop-loop-test-${Date.now()}`); + await mkdir(testDir, { recursive: true }); + planFile = join(testDir, "plan.md"); + promptFile = join(testDir, "prompt.md"); + await writeFile(planFile, "# Test Plan", "utf-8"); + await writeFile(promptFile, "# Test Prompt", "utf-8"); + }); + + afterEach(async () => { + await rm(testDir, { recursive: true, force: true }); + }); + + it("should complete immediately when no blocking findings", async () => { + const config = createSampleConfig({ + paths: { reviewDir: testDir, runDir: join(testDir, "runs") }, + policy: { + requireHumanOnFirstRound: false, + requireHumanOnNewHighSeverity: false, + }, + }); + const reviewAdapter = createMockReviewAdapter([[]]); + const revisionAdapter = createMockRevisionAdapter(); + + const result = await runLoop({ + planFile, + promptFile, + config, + reviewAdapter, + revisionAdapter, + }); + + expect(result.status).toBe("completed"); + expect(result.stopReason).toBe("no_blocking_findings"); + expect(result.rounds).toHaveLength(1); + }); + + it("should run multiple rounds until no blocking findings", async () => { + const config = createSampleConfig({ + paths: { reviewDir: testDir, runDir: join(testDir, "runs") }, + policy: { + requireHumanOnFirstRound: false, + requireHumanOnNewHighSeverity: false, + }, + }); + const reviewAdapter = createMockReviewAdapter([ + [createRawFinding({ id: "f1", category: "correctness" })], + [], + ]); + const revisionAdapter = createMockRevisionAdapter(); + + const result = await runLoop({ + planFile, + promptFile, + config, + reviewAdapter, + revisionAdapter, + }); + + expect(result.status).toBe("completed"); + expect(result.rounds).toHaveLength(2); + expect(revisionAdapter.revise).toHaveBeenCalledTimes(1); + }); + + it("should stop at maxRounds", async () => { + const config = createSampleConfig({ + paths: { reviewDir: testDir, runDir: join(testDir, "runs") }, + policy: { + maxRounds: 2, + requireHumanOnFirstRound: false, + requireHumanOnNewHighSeverity: false, + }, + }); + const finding = createRawFinding({ id: "f1", category: "correctness" }); + const reviewAdapter = createMockReviewAdapter([ + [finding], + [finding], + [finding], + ]); + const revisionAdapter = createMockRevisionAdapter(); + + const result = await runLoop({ + planFile, + promptFile, + config, + reviewAdapter, + revisionAdapter, + }); + + expect(result.status).toBe("stopped"); + expect(result.stopReason).toBe("max_rounds"); + }); + + it("should stop on stagnation when same findings repeat", async () => { + const config = createSampleConfig({ + paths: { reviewDir: testDir, runDir: join(testDir, "runs") }, + policy: { + maxRounds: 10, + stagnationRounds: 2, + requireHumanOnFirstRound: false, + requireHumanOnNewHighSeverity: false, + }, + }); + const finding = createRawFinding({ id: "f1", category: "correctness" }); + const reviewAdapter = createMockReviewAdapter([ + [finding], + [finding], + [finding], + ]); + const revisionAdapter = createMockRevisionAdapter(); + + const result = await runLoop({ + planFile, + promptFile, + config, + reviewAdapter, + revisionAdapter, + }); + + expect(result.status).toBe("stopped"); + expect(result.stopReason).toBe("stagnation"); + }); + + it("should throw on missing plan file", async () => { + const config = createSampleConfig({ + paths: { reviewDir: testDir, runDir: join(testDir, "runs") }, + }); + await expect( + runLoop({ + planFile: "/nonexistent/plan.md", + promptFile, + config, + reviewAdapter: createMockReviewAdapter([]), + revisionAdapter: createMockRevisionAdapter(), + }) + ).rejects.toThrow("実装計画ファイルが見つかりません"); + }); + + it("should throw on missing prompt file", async () => { + const config = createSampleConfig({ + paths: { reviewDir: testDir, runDir: join(testDir, "runs") }, + }); + await expect( + runLoop({ + planFile, + promptFile: "/nonexistent/prompt.md", + config, + reviewAdapter: createMockReviewAdapter([]), + revisionAdapter: createMockRevisionAdapter(), + }) + ).rejects.toThrow("プロンプトファイルが見つかりません"); + }); +}); + +describe("needsHumanGate", () => { + const config = createSampleConfig(); + const emptyState: LoopState = { + runId: "test", + planFile: "", + promptFile: "", + rounds: [], + waivers: [], + status: "in_progress", + startedAt: "", + updatedAt: "", + }; + + it("should return true on first round when requireHumanOnFirstRound", () => { + expect(needsHumanGate(1, [], emptyState, config)).toBe(true); + }); + + it("should return false on first round when not requireHumanOnFirstRound", () => { + const cfg = createSampleConfig({ + policy: { ...config.policy, requireHumanOnFirstRound: false }, + }); + expect(needsHumanGate(1, [], emptyState, cfg)).toBe(false); + }); + + it("should return true when new HIGH severity finding appears", () => { + const finding: Finding = { + id: "new", + summary: "new high", + detail: "d", + severity: "high", + category: "correctness", + fingerprint: "new-fp", + }; + expect(needsHumanGate(2, [finding], emptyState, config)).toBe(true); + }); + + it("should return false when HIGH finding already seen", () => { + const finding: Finding = { + id: "old", + summary: "old high", + detail: "d", + severity: "high", + category: "correctness", + fingerprint: "existing-fp", + }; + const stateWithPrevious: LoopState = { + ...emptyState, + rounds: [ + { + round: 1, + review: { + round: 1, + timestamp: "", + findings: [finding], + toolsUsed: [], + rawOutputPath: "", + }, + actionableFindings: [finding], + }, + ], + }; + expect(needsHumanGate(2, [finding], stateWithPrevious, config)).toBe(false); + }); +}); + +describe("isStagnating", () => { + const findingA: Finding = { + id: "a", + summary: "A", + detail: "", + severity: "high", + category: "correctness", + fingerprint: "fp-a", + }; + + it("should return false when not enough rounds", () => { + const state: LoopState = { + runId: "test", + planFile: "", + promptFile: "", + rounds: [], + waivers: [], + status: "in_progress", + startedAt: "", + updatedAt: "", + }; + expect(isStagnating(state, [findingA], 2)).toBe(false); + }); + + it("should return true when same fingerprints for threshold rounds", () => { + const state: LoopState = { + runId: "test", + planFile: "", + promptFile: "", + rounds: [ + { + round: 1, + review: { + round: 1, + timestamp: "", + findings: [], + toolsUsed: [], + rawOutputPath: "", + }, + actionableFindings: [findingA], + }, + { + round: 2, + review: { + round: 2, + timestamp: "", + findings: [], + toolsUsed: [], + rawOutputPath: "", + }, + actionableFindings: [findingA], + }, + ], + waivers: [], + status: "in_progress", + startedAt: "", + updatedAt: "", + }; + expect(isStagnating(state, [findingA], 2)).toBe(true); + }); + + it("should return false when fingerprints change", () => { + const findingB: Finding = { ...findingA, id: "b", fingerprint: "fp-b" }; + const state: LoopState = { + runId: "test", + planFile: "", + promptFile: "", + rounds: [ + { + round: 1, + review: { + round: 1, + timestamp: "", + findings: [], + toolsUsed: [], + rawOutputPath: "", + }, + actionableFindings: [findingA], + }, + { + round: 2, + review: { + round: 2, + timestamp: "", + findings: [], + toolsUsed: [], + rawOutputPath: "", + }, + actionableFindings: [findingB], + }, + ], + waivers: [], + status: "in_progress", + startedAt: "", + updatedAt: "", + }; + expect(isStagnating(state, [findingA], 2)).toBe(false); + }); +}); + +describe("filterByBlockingCategories", () => { + const config = createSampleConfig(); + + it("should keep findings in blocking categories", () => { + const findings = [ + createRawFinding({ category: "correctness" }), + createRawFinding({ category: "code_quality" }), + createRawFinding({ category: "spec_mismatch" }), + ]; + const result = filterByBlockingCategories(findings, config); + expect(result).toHaveLength(2); + expect(result.map((f) => f.category)).toEqual([ + "correctness", + "spec_mismatch", + ]); + }); + + it("should return empty when no findings match blocking categories", () => { + const findings = [ + createRawFinding({ category: "code_quality" }), + createRawFinding({ category: "performance" }), + ]; + const result = filterByBlockingCategories(findings, config); + expect(result).toHaveLength(0); + }); +}); diff --git a/src/core/loop-runner.ts b/src/core/loop-runner.ts new file mode 100644 index 0000000..63965c3 --- /dev/null +++ b/src/core/loop-runner.ts @@ -0,0 +1,335 @@ +import { access, mkdir, readFile, writeFile } from "node:fs/promises"; +import { extname, resolve } from "node:path"; +import pc from "picocolors"; +import type { ReviewAdapter, RevisionAdapter } from "../adapters/types.js"; +import type { + Finding, + LoopState, + PlanloopConfig, + RawFinding, + RoundState, + TriageResult, +} from "../types/index.js"; +import { generateFingerprint } from "./fingerprint.js"; +import { runNaturalLanguageTriage } from "./triage.js"; +import { applyWaivers } from "./waiver.js"; + +export interface RunLoopOptions { + config: PlanloopConfig; + planFile: string; + promptFile: string; + reviewAdapter: ReviewAdapter; + revisionAdapter: RevisionAdapter; + stdin?: NodeJS.ReadableStream; + stdout?: NodeJS.WritableStream; +} + +export const runLoop = async (options: RunLoopOptions): Promise => { + const { planFile, promptFile, config, reviewAdapter, revisionAdapter } = + options; + const runId = generateRunId(); + const state: LoopState = { + runId, + planFile, + promptFile, + rounds: [], + waivers: [], + status: "in_progress", + startedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + await validateInputFiles(planFile, promptFile); + + const handleSigint = () => { + state.status = "stopped"; + state.stopReason = "human_abort"; + state.updatedAt = new Date().toISOString(); + persistState(state, config.paths.runDir) + .catch(() => { + // best-effort save; proceed to exit regardless + }) + .then(() => process.exit(1)); + }; + process.on("SIGINT", handleSigint); + + try { + for (let round = 1; round <= config.policy.maxRounds; round++) { + console.error( + `\n${pc.bold(`════ Round ${round}/${config.policy.maxRounds}: Codex レビュー実行中 ════`)}` + ); + + const planContent = await readFile(planFile, "utf-8"); + const promptContent = await readFile(promptFile, "utf-8"); + + const reviewResult = await reviewAdapter.review( + planContent, + promptContent, + { + round, + previousWaivers: state.waivers, + previousFindings: getPreviousFindings(state), + } + ); + + const findings: Finding[] = reviewResult.findings.map((f) => ({ + ...f, + fingerprint: generateFingerprint(f), + })); + + console.error( + `\n ${pc.bold("レビュー完了")}: ${findings.length}件の指摘` + ); + for (const f of findings) { + const label = `[${f.severity.toUpperCase()}]`; + const sev = formatSeverity(label, f.severity); + console.error(` ${sev} ${f.id}: ${f.summary}`); + } + + const waiverResult = applyWaivers( + findings, + state.waivers, + config.policy.autoWaiveCategories + ); + + let activeFindingsForRevision: RawFinding[] = waiverResult.active; + let triageResult: TriageResult | undefined; + + const roundDir = getRoundDir(config.paths.runDir, state.runId, round); + + if ( + waiverResult.active.length > 0 && + needsHumanGate(round, waiverResult.active, state, config) + ) { + triageResult = await runNaturalLanguageTriage( + waiverResult.active, + round, + planContent, + promptContent, + reviewAdapter, + config, + roundDir + ); + activeFindingsForRevision = triageResult.adjustedFindings; + state.waivers.push(...triageResult.newWaivers); + } + + const actionable = filterByBlockingCategories( + activeFindingsForRevision, + config + ); + + const actionableWithFingerprint: Finding[] = actionable.map((f) => ({ + ...f, + fingerprint: generateFingerprint(f), + })); + + const rawOutputPath = await saveArtifact( + roundDir, + "codex-review.jsonl", + reviewResult.rawOutput + ); + + const buildRoundState = ( + revision?: RoundState["revision"] + ): RoundState => ({ + round, + review: { + round, + timestamp: new Date().toISOString(), + findings, + toolsUsed: reviewResult.toolsUsed, + rawOutputPath, + }, + humanFeedback: triageResult?.humanFeedback, + actionableFindings: actionableWithFingerprint, + revision, + }); + + if (actionableWithFingerprint.length === 0) { + state.rounds.push(buildRoundState()); + state.status = "completed"; + state.stopReason = "no_blocking_findings"; + state.updatedAt = new Date().toISOString(); + await persistState(state, config.paths.runDir); + break; + } + + if ( + isStagnating( + state, + actionableWithFingerprint, + config.policy.stagnationRounds + ) + ) { + state.rounds.push(buildRoundState()); + state.status = "stopped"; + state.stopReason = "stagnation"; + state.updatedAt = new Date().toISOString(); + await persistState(state, config.paths.runDir); + break; + } + + console.error( + `\n${pc.bold(`════ Round ${round}/${config.policy.maxRounds}: Claude Code 修正中 ════`)}` + ); + + const revision = await revisionAdapter.revise( + planFile, + promptFile, + actionableWithFingerprint + ); + + await saveArtifact(roundDir, "claude-revision.json", revision.rawOutput); + + state.rounds.push(buildRoundState(revision)); + state.updatedAt = new Date().toISOString(); + await persistState(state, config.paths.runDir); + } + + if (state.status === "in_progress") { + state.status = "stopped"; + state.stopReason = "max_rounds"; + state.updatedAt = new Date().toISOString(); + await persistState(state, config.paths.runDir); + } + } finally { + process.removeListener("SIGINT", handleSigint); + } + + return state; +}; + +export const needsHumanGate = ( + round: number, + activeFindings: Finding[], + state: LoopState, + config: PlanloopConfig +): boolean => { + if (round === 1 && config.policy.requireHumanOnFirstRound) { + return true; + } + if (config.policy.requireHumanOnNewHighSeverity) { + const previousFingerprints = new Set( + state.rounds.flatMap((r) => r.review.findings.map((f) => f.fingerprint)) + ); + const hasNewHigh = activeFindings.some( + (f) => f.severity === "high" && !previousFingerprints.has(f.fingerprint) + ); + return hasNewHigh; + } + return false; +}; + +export const isStagnating = ( + state: LoopState, + currentActionable: Finding[], + threshold: number +): boolean => { + if (state.rounds.length < threshold) { + return false; + } + + const currentSet = new Set(currentActionable.map((f) => f.fingerprint)); + let consecutiveMatch = 0; + + for (let i = state.rounds.length - 1; i >= 0; i--) { + const prevSet = new Set( + state.rounds[i].actionableFindings.map((f) => f.fingerprint) + ); + if (setsAreEqual(currentSet, prevSet)) { + consecutiveMatch++; + } else { + break; + } + } + + return consecutiveMatch >= threshold; +}; + +const setsAreEqual = (a: Set, b: Set): boolean => { + if (a.size !== b.size) { + return false; + } + for (const item of a) { + if (!b.has(item)) { + return false; + } + } + return true; +}; + +export const filterByBlockingCategories = ( + findings: RawFinding[], + config: PlanloopConfig +): RawFinding[] => { + return findings.filter((f) => + config.policy.blockingCategories.includes(f.category) + ); +}; + +const getPreviousFindings = (state: LoopState): Finding[] => { + return state.rounds.flatMap((r) => r.review.findings); +}; + +const generateRunId = (): string => { + return new Date().toISOString().replace(/[:.]/g, "-"); +}; + +const validateInputFiles = async ( + planFile: string, + promptFile: string +): Promise => { + try { + await access(planFile); + } catch { + throw new Error(`実装計画ファイルが見つかりません: ${planFile}`); + } + try { + await access(promptFile); + } catch { + throw new Error(`プロンプトファイルが見つかりません: ${promptFile}`); + } + for (const file of [planFile, promptFile]) { + if (extname(file) !== ".md") { + console.warn(`警告: ${file} はMarkdownファイルではありません`); + } + } +}; + +const getRoundDir = (runDir: string, runId: string, round: number): string => + resolve(runDir, runId, `round-${round}`); + +const saveArtifact = async ( + roundDir: string, + filename: string, + content: string +): Promise => { + await mkdir(roundDir, { recursive: true }); + const filePath = resolve(roundDir, filename); + await writeFile(filePath, content, "utf-8"); + return filePath; +}; + +const persistState = async ( + state: LoopState, + runDir: string +): Promise => { + const dir = resolve(runDir, state.runId); + await mkdir(dir, { recursive: true }); + await writeFile( + resolve(dir, "state.json"), + JSON.stringify(state, null, 2), + "utf-8" + ); +}; + +const formatSeverity = (label: string, severity: string): string => { + if (severity === "high") { + return pc.red(label); + } + if (severity === "medium") { + return pc.yellow(label); + } + return pc.dim(label); +}; diff --git a/src/core/triage.test.ts b/src/core/triage.test.ts new file mode 100644 index 0000000..207e6db --- /dev/null +++ b/src/core/triage.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from "vitest"; +import { createRawFinding } from "../__fixtures__/sample-findings.js"; +import { + buildTriageFileContent, + extractFeedback, + extractWaiversFromFeedback, +} from "./triage.js"; + +describe("buildTriageFileContent", () => { + it("should generate markdown with all findings", () => { + const findings = [ + createRawFinding({ + id: "finding-1", + summary: "Issue A", + severity: "high", + }), + createRawFinding({ + id: "finding-2", + summary: "Issue B", + severity: "low", + }), + ]; + const content = buildTriageFileContent(findings, 1); + expect(content).toContain("# Codex レビュー結果 (Round 1)"); + expect(content).toContain("## finding-1 [HIGH]"); + expect(content).toContain("Issue A"); + expect(content).toContain("## finding-2 [LOW]"); + expect(content).toContain("Issue B"); + expect(content).toContain("# フィードバック"); + }); + + it("should include the separator", () => { + const content = buildTriageFileContent([createRawFinding()], 2); + expect(content).toContain("---"); + expect(content).toContain("Round 2"); + }); + + it("should include instructions as HTML comments", () => { + const content = buildTriageFileContent([createRawFinding()], 1); + expect(content).toContain(""); + }); +}); + +describe("extractFeedback", () => { + it("should extract user feedback and ignore HTML comments", () => { + const content = `# Codex レビュー結果 +some findings + +--- + +# フィードバック + + + + +- finding-1は修正済みなので対象外 +- 以後、パフォーマンスに関する指摘は無視`; + + const feedback = extractFeedback(content); + expect(feedback).toContain("finding-1は修正済みなので対象外"); + expect(feedback).toContain("以後、パフォーマンスに関する指摘は無視"); + expect(feedback).not.toContain(" + + +`; + const feedback = extractFeedback(content); + expect(feedback).toBe(""); + }); + + it("should return empty string when no separator", () => { + const content = "No separator here"; + const feedback = extractFeedback(content); + expect(feedback).toBe(""); + }); +}); + +describe("extractWaiversFromFeedback", () => { + it("should extract waiver from line with 以後", () => { + const feedback = "以後、「将来の拡張性」に関する指摘は無視"; + const waivers = extractWaiversFromFeedback(feedback); + expect(waivers).toHaveLength(1); + expect(waivers[0].match).toBe("将来の拡張性"); + expect(waivers[0].action).toBe("ignore"); + }); + + it("should extract waiver from line with 今後", () => { + const feedback = "今後、互換性の指摘は対象外"; + const waivers = extractWaiversFromFeedback(feedback); + expect(waivers).toHaveLength(1); + expect(waivers[0].reason).toContain("今後"); + }); + + it("should extract waiver from line with 今回以降", () => { + const feedback = "今回以降、セキュリティの指摘は無視"; + const waivers = extractWaiversFromFeedback(feedback); + expect(waivers).toHaveLength(1); + }); + + it("should not create waiver for one-time feedback", () => { + const feedback = "- 指摘2はスコープ外\n- 指摘4は方向性だけ採用"; + const waivers = extractWaiversFromFeedback(feedback); + expect(waivers).toHaveLength(0); + }); + + it("should extract quoted text as match", () => { + const feedback = "以後、「パフォーマンス最適化」に関する指摘は不要"; + const waivers = extractWaiversFromFeedback(feedback); + expect(waivers[0].match).toBe("パフォーマンス最適化"); + }); + + it("should use full line as match when no quoted text", () => { + const feedback = "以後、パフォーマンスに関する指摘は不要"; + const waivers = extractWaiversFromFeedback(feedback); + expect(waivers[0].match).toBe("以後、パフォーマンスに関する指摘は不要"); + }); + + it("should handle list marker prefixes", () => { + const feedback = "- 以後、「拡張性」の指摘は不要"; + const waivers = extractWaiversFromFeedback(feedback); + expect(waivers).toHaveLength(1); + expect(waivers[0].match).toBe("拡張性"); + }); +}); diff --git a/src/core/triage.ts b/src/core/triage.ts new file mode 100644 index 0000000..3790b3e --- /dev/null +++ b/src/core/triage.ts @@ -0,0 +1,196 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { createInterface } from "node:readline"; +import pc from "picocolors"; +import type { ReviewAdapter } from "../adapters/types.js"; +import type { + PlanloopConfig, + RawFinding, + TriageResult, + Waiver, +} from "../types/index.js"; + +const TRIAGE_HEADER_REGEX = /^[\s\S]*?# フィードバック\s*/; +const HTML_COMMENT_REGEX = //g; +const LIST_MARKER_REGEX = /^[-・*]\s*/; +const QUOTED_TEXT_REGEX = /[「『]([^」』]+)[」』]/; + +const stripHtmlComments = (text: string): string => + text.replace(HTML_COMMENT_REGEX, ""); + +export const runNaturalLanguageTriage = async ( + findings: RawFinding[], + round: number, + planContent: string, + promptContent: string, + reviewAdapter: ReviewAdapter, + _config: PlanloopConfig, + roundDir: string +): Promise => { + const triageContent = buildTriageFileContent(findings, round); + await mkdir(roundDir, { recursive: true }); + const triageFilePath = join(roundDir, `triage-round-${round}.md`); + await writeFile(triageFilePath, triageContent, "utf-8"); + + printTriageSummary(findings, round, triageFilePath); + await waitForUserInput( + `\nトリアージファイルにフィードバックを記入し、保存したら ${pc.bold("Enter")} を押してください(空のままEnterで全指摘を受け入れ): ` + ); + + const editedContent = await readFile(triageFilePath, "utf-8"); + const humanFeedback = extractFeedback(editedContent); + + if (!humanFeedback.trim()) { + console.error( + pc.dim(" フィードバックなし — 全指摘をそのまま受け入れます") + ); + return { + adjustedFindings: findings, + humanFeedback: "", + newWaivers: [], + }; + } + + console.error(pc.cyan(" フィードバックをCodexに送信中...")); + + const feedbackResult = await reviewAdapter.reviewWithFeedback( + planContent, + promptContent, + { + round, + originalFindings: findings, + humanFeedback, + } + ); + + const newWaivers = extractWaiversFromFeedback(humanFeedback); + + return { + adjustedFindings: feedbackResult.findings, + humanFeedback, + newWaivers, + }; +}; + +const printTriageSummary = ( + findings: RawFinding[], + round: number, + triageFilePath: string +): void => { + console.error(""); + console.error(pc.bold(`════ Round ${round}: トリアージ ════`)); + console.error(""); + for (const f of findings) { + const label = `[${f.severity.toUpperCase()}]`; + const sev = severityLabel(label, f.severity); + console.error(` ${sev} ${pc.bold(f.id)} ${pc.dim(f.category)}`); + console.error(` ${f.summary}`); + } + console.error(""); + console.error(` トリアージファイル: ${pc.underline(triageFilePath)}`); + console.error( + pc.dim( + " 上記ファイルの「# フィードバック」セクションにフィードバックを記入してください。" + ) + ); +}; + +const severityLabel = (label: string, severity: string): string => { + if (severity === "high") { + return pc.red(label); + } + if (severity === "medium") { + return pc.yellow(label); + } + return pc.dim(label); +}; + +const waitForUserInput = (prompt: string): Promise => { + return new Promise((resolve) => { + const rl = createInterface({ + input: process.stdin, + output: process.stderr, + }); + rl.question(prompt, () => { + rl.close(); + resolve(); + }); + }); +}; + +export const buildTriageFileContent = ( + findings: RawFinding[], + round: number +): string => { + const findingsSection = findings + .map( + (f) => + `## ${f.id} [${f.severity.toUpperCase()}] ${f.category}\n${f.summary}\n${f.detail}` + ) + .join("\n\n"); + + return `# Codex レビュー結果 (Round ${round}) + +${findingsSection} + +--- + +# フィードバック + + + + + + + +`; +}; + +export const extractFeedback = (content: string): string => { + const separatorIndex = content.indexOf("---"); + if (separatorIndex === -1) { + return ""; + } + + const afterSeparator = content.slice(separatorIndex + 3); + const headerRemoved = afterSeparator.replace(TRIAGE_HEADER_REGEX, ""); + const withoutComments = stripHtmlComments(headerRemoved); + const lines = withoutComments.split("\n"); + const filtered = lines.filter((line) => line.trim() !== ""); + + return filtered.join("\n").trim(); +}; + +export const extractWaiversFromFeedback = (feedback: string): Waiver[] => { + const waivers: Waiver[] = []; + const lines = feedback.split("\n"); + const permanentKeywords = ["以後", "今後", "今回以降", "以降"]; + + for (const line of lines) { + const trimmed = line.replace(LIST_MARKER_REGEX, "").trim(); + if (!trimmed) { + continue; + } + + const isPermanent = permanentKeywords.some((kw) => trimmed.includes(kw)); + if (!isPermanent) { + continue; + } + + const quotedMatch = trimmed.match(QUOTED_TEXT_REGEX); + const matchText = quotedMatch ? quotedMatch[1] : trimmed; + + waivers.push({ + match: matchText, + action: "ignore", + reason: trimmed, + }); + } + + return waivers; +}; diff --git a/src/core/waiver.test.ts b/src/core/waiver.test.ts new file mode 100644 index 0000000..d8c97cf --- /dev/null +++ b/src/core/waiver.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from "vitest"; +import { createFinding } from "../__fixtures__/sample-findings.js"; +import { applyWaivers } from "./waiver.js"; + +describe("applyWaivers", () => { + it("should auto-waive findings in autoWaiveCategories", () => { + const findings = [ + createFinding({ category: "speculative_future", fingerprint: "a" }), + createFinding({ category: "correctness", fingerprint: "b" }), + ]; + const result = applyWaivers(findings, [], ["speculative_future"]); + expect(result.waived).toHaveLength(1); + expect(result.waived[0].finding.category).toBe("speculative_future"); + expect(result.active).toHaveLength(1); + expect(result.active[0].category).toBe("correctness"); + }); + + it("should match waiver by partial text in summary", () => { + const findings = [ + createFinding({ summary: "API互換性の問題", fingerprint: "a" }), + ]; + const waivers = [ + { match: "互換性", action: "ignore" as const, reason: "互換性は対象外" }, + ]; + const result = applyWaivers(findings, waivers, []); + expect(result.waived).toHaveLength(1); + expect(result.active).toHaveLength(0); + }); + + it("should match waiver by partial text in detail", () => { + const findings = [ + createFinding({ + summary: "Some issue", + detail: "将来の拡張性に関する指摘", + fingerprint: "a", + }), + ]; + const waivers = [ + { match: "拡張性", action: "ignore" as const, reason: "拡張性は無視" }, + ]; + const result = applyWaivers(findings, waivers, []); + expect(result.waived).toHaveLength(1); + }); + + it("should filter by category when waiver has category", () => { + const findings = [ + createFinding({ + summary: "拡張性の問題", + category: "correctness", + fingerprint: "a", + }), + createFinding({ + summary: "拡張性の問題", + category: "speculative_future", + fingerprint: "b", + }), + ]; + const waivers = [ + { + match: "拡張性", + category: "speculative_future" as const, + action: "ignore" as const, + reason: "将来の話は無視", + }, + ]; + const result = applyWaivers(findings, waivers, []); + expect(result.waived).toHaveLength(1); + expect(result.waived[0].finding.category).toBe("speculative_future"); + expect(result.active).toHaveLength(1); + expect(result.active[0].category).toBe("correctness"); + }); + + it("should handle downgrade action", () => { + const findings = [ + createFinding({ summary: "パフォーマンスの懸念", fingerprint: "a" }), + ]; + const waivers = [ + { + match: "パフォーマンス", + action: "downgrade" as const, + downgradeTo: "non_blocking" as const, + reason: "パフォーマンスはnon-blocking", + }, + ]; + const result = applyWaivers(findings, waivers, []); + expect(result.downgraded).toHaveLength(1); + expect(result.downgraded[0].to).toBe("non_blocking"); + expect(result.active).toHaveLength(0); + }); + + it("should keep all findings active when no waivers match", () => { + const findings = [ + createFinding({ summary: "Issue A", fingerprint: "a" }), + createFinding({ summary: "Issue B", fingerprint: "b" }), + ]; + const waivers = [ + { match: "unrelated", action: "ignore" as const, reason: "no match" }, + ]; + const result = applyWaivers(findings, waivers, []); + expect(result.active).toHaveLength(2); + expect(result.waived).toHaveLength(0); + }); + + it("should return all findings as active with empty waivers", () => { + const findings = [ + createFinding({ fingerprint: "a" }), + createFinding({ fingerprint: "b" }), + ]; + const result = applyWaivers(findings, [], []); + expect(result.active).toHaveLength(2); + expect(result.waived).toHaveLength(0); + expect(result.downgraded).toHaveLength(0); + }); +}); diff --git a/src/core/waiver.ts b/src/core/waiver.ts new file mode 100644 index 0000000..7dc2224 --- /dev/null +++ b/src/core/waiver.ts @@ -0,0 +1,50 @@ +import type { Finding, FindingCategory, Waiver } from "../types/index.js"; + +export interface WaiverResult { + active: Finding[]; + downgraded: Array<{ finding: Finding; from: "blocking"; to: "non_blocking" }>; + waived: Array<{ finding: Finding; reason: string }>; +} + +export const applyWaivers = ( + findings: Finding[], + waivers: Waiver[], + autoWaiveCategories: FindingCategory[] +): WaiverResult => { + const active: Finding[] = []; + const waived: WaiverResult["waived"] = []; + const downgraded: WaiverResult["downgraded"] = []; + + for (const finding of findings) { + if (autoWaiveCategories.includes(finding.category)) { + waived.push({ + finding, + reason: `カテゴリ "${finding.category}" は自動waive対象`, + }); + continue; + } + + const matchedWaiver = waivers.find((w) => matchesWaiver(finding, w)); + + if (!matchedWaiver) { + active.push(finding); + continue; + } + + if (matchedWaiver.action === "ignore") { + waived.push({ finding, reason: matchedWaiver.reason }); + } else if (matchedWaiver.action === "downgrade") { + downgraded.push({ finding, from: "blocking", to: "non_blocking" }); + } + } + + return { active, waived, downgraded }; +}; + +const matchesWaiver = (finding: Finding, waiver: Waiver): boolean => { + if (waiver.category && waiver.category !== finding.category) { + return false; + } + const text = `${finding.summary} ${finding.detail}`; + return text.includes(waiver.match); +}; diff --git a/src/display/result.test.ts b/src/display/result.test.ts new file mode 100644 index 0000000..7c8a077 --- /dev/null +++ b/src/display/result.test.ts @@ -0,0 +1,103 @@ +import { PassThrough } from "node:stream"; +import { describe, expect, it } from "vitest"; +import type { LoopState } from "../types/index.js"; +import { displayResult } from "./result.js"; + +const createLoopState = (overrides: Partial = {}): LoopState => ({ + runId: "2026-03-28T21-40-00-000Z", + planFile: "plan.md", + promptFile: "prompt.md", + rounds: [], + waivers: [], + status: "completed", + stopReason: "no_blocking_findings", + startedAt: "2026-03-28T21:40:00Z", + updatedAt: "2026-03-28T21:55:00Z", + ...overrides, +}); + +describe("displayResult", () => { + it("should display completed status", () => { + const stdout = new PassThrough(); + let output = ""; + stdout.on("data", (chunk: Buffer) => { + output += chunk.toString(); + }); + + displayResult(createLoopState(), stdout); + expect(output).toContain("planloop 完了"); + expect(output).toContain("completed"); + expect(output).toContain("no_blocking_findings"); + }); + + it("should display round summaries", () => { + const state = createLoopState({ + rounds: [ + { + round: 1, + review: { + round: 1, + timestamp: "t1", + findings: [ + { + id: "f1", + summary: "s", + detail: "d", + severity: "high", + category: "correctness", + fingerprint: "fp1", + }, + ], + toolsUsed: [], + rawOutputPath: "/tmp/raw.jsonl", + }, + humanFeedback: "feedback", + actionableFindings: [ + { + id: "f1", + summary: "s", + detail: "d", + severity: "high", + category: "correctness", + fingerprint: "fp1", + }, + ], + revision: { + reflectedFindings: ["f1"], + summary: "Fixed", + timestamp: "t2", + }, + }, + ], + }); + + const stdout = new PassThrough(); + let output = ""; + stdout.on("data", (chunk: Buffer) => { + output += chunk.toString(); + }); + + displayResult(state, stdout); + expect(output).toContain("Round 1"); + expect(output).toContain("1件"); + expect(output).toContain("triage"); + }); + + it("should display waiver count", () => { + const state = createLoopState({ + waivers: [ + { match: "test", action: "ignore", reason: "test" }, + { match: "test2", action: "ignore", reason: "test2" }, + ], + }); + + const stdout = new PassThrough(); + let output = ""; + stdout.on("data", (chunk: Buffer) => { + output += chunk.toString(); + }); + + displayResult(state, stdout); + expect(output).toContain("2件"); + }); +}); diff --git a/src/display/result.ts b/src/display/result.ts new file mode 100644 index 0000000..3721198 --- /dev/null +++ b/src/display/result.ts @@ -0,0 +1,58 @@ +import pc from "picocolors"; +import type { LoopState } from "../types/index.js"; + +export const displayResult = ( + state: LoopState, + stdout: NodeJS.WritableStream +): void => { + const statusText = formatStatus(state); + const duration = `${state.startedAt} ~ ${state.updatedAt}`; + const waiverCount = state.waivers.length; + + const lines = [ + "", + pc.bold("════════════════════════════════════════"), + pc.bold(" planloop 完了"), + pc.bold("════════════════════════════════════════"), + ` ステータス: ${statusText}`, + ` ラウンド数: ${state.rounds.length}`, + ` 実行時間: ${duration}`, + ` 適用Waiver: ${waiverCount}件`, + "────────────────────────────────────────", + " ラウンド別サマリー:", + ...state.rounds.map((r) => { + const findingCount = r.review.findings.length; + const actionableCount = r.actionableFindings.length; + const triageLabel = r.humanFeedback === undefined ? "auto" : "triage"; + const revisionLabel = r.revision ? "Claude修正" : ""; + + if (actionableCount === 0 && !r.revision) { + return ` Round ${r.round}: ${findingCount}件 → 完了`; + } + return ` Round ${r.round}: ${findingCount}件 → ${triageLabel} → ${actionableCount}件blocking → ${revisionLabel}`; + }), + "────────────────────────────────────────", + ` 状態ファイル: ${state.rounds.length > 0 ? `${state.runId}/state.json` : "なし"}`, + pc.bold("════════════════════════════════════════"), + "", + ]; + + stdout.write(lines.join("\n")); +}; + +const formatStatus = (state: LoopState): string => { + const reason = state.stopReason ? ` (${state.stopReason})` : ""; + const label = `${state.status}${reason}`; + + switch (state.status) { + case "completed": + return pc.green(label); + case "stopped": + if (state.stopReason === "human_abort") { + return pc.red(label); + } + return pc.yellow(label); + default: + return label; + } +}; diff --git a/src/display/stream-renderer.test.ts b/src/display/stream-renderer.test.ts new file mode 100644 index 0000000..d691c7b --- /dev/null +++ b/src/display/stream-renderer.test.ts @@ -0,0 +1,62 @@ +import type { ChildProcess } from "node:child_process"; +import { EventEmitter } from "node:events"; +import { describe, expect, it } from "vitest"; +import { renderStream } from "./stream-renderer.js"; + +const createMockChildProcess = (outputChunks: string[]): ChildProcess => { + const mockStdout = new EventEmitter(); + const mockProcess = new EventEmitter() as ChildProcess; + (mockProcess as { stdout: EventEmitter }).stdout = mockStdout; + + setTimeout(() => { + for (const chunk of outputChunks) { + mockStdout.emit("data", Buffer.from(chunk)); + } + mockStdout.emit("end"); + mockProcess.emit("close", 0); + }, 10); + + return mockProcess; +}; + +describe("renderStream", () => { + it("should collect all output from codex-jsonl format", async () => { + const lines = [ + '{"type": "message", "role": "assistant", "content": "Starting review"}\n', + '{"type": "command_execution", "command": "gh issue view 3"}\n', + ]; + const child = createMockChildProcess(lines); + const output = await renderStream(child, "Codex レビュー", "codex-jsonl"); + expect(output).toContain("command_execution"); + expect(output).toContain("message"); + }); + + it("should handle empty stream", async () => { + const child = createMockChildProcess([]); + const output = await renderStream(child, "Test", "codex-jsonl"); + expect(output).toBe(""); + }); + + it("should skip invalid JSON lines without error", async () => { + const lines = ["not json\n", '{"type": "message", "content": "valid"}\n']; + const child = createMockChildProcess(lines); + const output = await renderStream(child, "Test", "codex-jsonl"); + expect(output).toContain("not json"); + expect(output).toContain("valid"); + }); + + it("should collect claude stream-json output", async () => { + const lines = [ + '{"type": "tool_use", "tool": "Read", "path": "plan.md"}\n', + '{"type": "result", "result": "done"}\n', + ]; + const child = createMockChildProcess(lines); + const output = await renderStream( + child, + "Claude Code 修正", + "claude-stream-json" + ); + expect(output).toContain("tool_use"); + expect(output).toContain("result"); + }); +}); diff --git a/src/display/stream-renderer.ts b/src/display/stream-renderer.ts new file mode 100644 index 0000000..4a20a8a --- /dev/null +++ b/src/display/stream-renderer.ts @@ -0,0 +1,63 @@ +import type { ChildProcess } from "node:child_process"; + +export const renderStream = ( + childProcess: ChildProcess, + label: string, + format: "codex-jsonl" | "claude-stream-json" +): Promise => { + return new Promise((resolve) => { + let output = ""; + + childProcess.stdout?.on("data", (chunk: Buffer) => { + const text = chunk.toString(); + output += text; + + if (format === "codex-jsonl") { + renderCodexJsonl(text, label); + } else { + renderClaudeStreamJson(text, label); + } + }); + + childProcess.on("close", () => { + resolve(output); + }); + }); +}; + +const renderCodexJsonl = (text: string, _label: string): void => { + for (const line of text.split("\n")) { + if (!line.trim()) { + continue; + } + try { + const event = JSON.parse(line); + if (event.type === "command_execution") { + process.stderr.write(` [tool] ${event.command}\n`); + } else if (event.type === "mcp_tool_call") { + process.stderr.write(` [tool] ${event.tool}\n`); + } else if (event.type === "web_search") { + process.stderr.write(` [search] ${event.query}\n`); + } + } catch { + // skip non-JSON lines + } + } +}; + +const renderClaudeStreamJson = (text: string, _label: string): void => { + for (const line of text.split("\n")) { + if (!line.trim()) { + continue; + } + try { + const event = JSON.parse(line); + if (event.type === "tool_use") { + const toolName = event.tool || event.name || "unknown"; + process.stderr.write(` [${toolName}] ${event.path || ""}\n`); + } + } catch { + // skip non-JSON lines + } + } +}; diff --git a/src/evidence/analyzer.test.ts b/src/evidence/analyzer.test.ts new file mode 100644 index 0000000..51be127 --- /dev/null +++ b/src/evidence/analyzer.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { analyzeEvidenceRequirements } from "./analyzer.js"; + +describe("analyzeEvidenceRequirements", () => { + it("should detect GitHub Issue URL as required gh evidence", () => { + const plan = "参照: https://github.com/nekochans/planloop/issues/3"; + const result = analyzeEvidenceRequirements(plan, ""); + expect(result.required).toHaveLength(1); + expect(result.required[0].source).toBe("gh"); + }); + + it("should detect GitHub PR URL from prompt as required", () => { + const prompt = "PR: https://github.com/nekochans/planloop/pull/2"; + const result = analyzeEvidenceRequirements("", prompt); + expect(result.required).toHaveLength(1); + expect(result.required[0].source).toBe("gh"); + }); + + it("should detect Figma URL as required", () => { + const plan = "デザイン: https://figma.com/file/abc123"; + const result = analyzeEvidenceRequirements(plan, ""); + expect(result.required).toHaveLength(1); + expect(result.required[0].source).toBe("figma_mcp"); + }); + + it("should detect doc keywords as suggested context7", () => { + const prompt = "ドキュメントで確認してください"; + const result = analyzeEvidenceRequirements("", prompt); + expect(result.suggested).toHaveLength(1); + expect(result.suggested[0].source).toBe("context7_mcp"); + }); + + it("should detect web search keywords as suggested", () => { + const prompt = "不明点はWeb検索で確認"; + const result = analyzeEvidenceRequirements("", prompt); + expect(result.suggested).toHaveLength(1); + expect(result.suggested[0].source).toBe("web_search"); + }); + + it("should return empty arrays when no evidence needed", () => { + const result = analyzeEvidenceRequirements( + "シンプルな計画", + "シンプルな要件" + ); + expect(result.required).toHaveLength(0); + expect(result.suggested).toHaveLength(0); + }); + + it("should detect multiple evidence sources simultaneously", () => { + const plan = + "Issue: https://github.com/nekochans/planloop/issues/3\nデザイン: https://figma.com/file/abc"; + const prompt = "ドキュメントで確認してください\nWeb検索も活用"; + const result = analyzeEvidenceRequirements(plan, prompt); + expect(result.required).toHaveLength(2); + expect(result.suggested).toHaveLength(2); + }); + + it("should not duplicate gh evidence for multiple GitHub URLs", () => { + const plan = + "https://github.com/nekochans/planloop/issues/3\nhttps://github.com/nekochans/planloop/pull/2"; + const result = analyzeEvidenceRequirements(plan, ""); + expect(result.required).toHaveLength(1); + }); +}); diff --git a/src/evidence/analyzer.ts b/src/evidence/analyzer.ts new file mode 100644 index 0000000..abbaadc --- /dev/null +++ b/src/evidence/analyzer.ts @@ -0,0 +1,76 @@ +import type { EvidenceRequirement } from "../types/index.js"; + +export interface EvidenceAnalysisResult { + required: EvidenceRequirement[]; + suggested: EvidenceRequirement[]; +} + +const GITHUB_URL_PATTERN = /github\.com\/[^/]+\/[^/]+\/(issues|pull)\/\d+/gi; +const FIGMA_URL_PATTERN = /figma\.com\//gi; +const GH_COMMAND_PATTERN = /gh\s+(issue|pr)\s+(view|list)/gi; +const DOC_KEYWORDS = [ + "ドキュメントで確認", + "公式ドキュメント", + "ドキュメントを参照", +]; +const WEB_SEARCH_KEYWORDS = ["Web検索", "Webで確認", "web検索", "webで確認"]; + +export const analyzeEvidenceRequirements = ( + planContent: string, + promptContent: string +): EvidenceAnalysisResult => { + const text = `${planContent}\n${promptContent}`; + const required: EvidenceRequirement[] = []; + const suggested: EvidenceRequirement[] = []; + const addedSources = new Set(); + + GITHUB_URL_PATTERN.lastIndex = 0; + GH_COMMAND_PATTERN.lastIndex = 0; + if ( + (GITHUB_URL_PATTERN.test(text) || GH_COMMAND_PATTERN.test(text)) && + !addedSources.has("gh") + ) { + required.push({ + source: "gh", + reason: "GitHub Issue/PR の確認", + matchPatterns: ["gh issue", "gh pr"], + }); + addedSources.add("gh"); + } + + FIGMA_URL_PATTERN.lastIndex = 0; + if (FIGMA_URL_PATTERN.test(text) && !addedSources.has("figma_mcp")) { + required.push({ + source: "figma_mcp", + reason: "Figma デザインの確認", + matchPatterns: ["figma"], + }); + addedSources.add("figma_mcp"); + } + + if ( + DOC_KEYWORDS.some((kw) => text.includes(kw)) && + !addedSources.has("context7_mcp") + ) { + suggested.push({ + source: "context7_mcp", + reason: "ライブラリドキュメントの確認", + matchPatterns: ["context7", "resolve-library"], + }); + addedSources.add("context7_mcp"); + } + + if ( + WEB_SEARCH_KEYWORDS.some((kw) => text.includes(kw)) && + !addedSources.has("web_search") + ) { + suggested.push({ + source: "web_search", + reason: "Web検索", + matchPatterns: ["web_search", "search"], + }); + addedSources.add("web_search"); + } + + return { required, suggested }; +}; diff --git a/src/evidence/verifier.test.ts b/src/evidence/verifier.test.ts new file mode 100644 index 0000000..69629fb --- /dev/null +++ b/src/evidence/verifier.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vitest"; +import { verifyEvidence } from "./verifier.js"; + +describe("verifyEvidence", () => { + it("should return allRequiredSatisfied true when all required are met", () => { + const result = verifyEvidence( + ["gh issue view 3", "context7: commander.js"], + [ + { + source: "gh", + reason: "GH確認", + matchPatterns: ["gh issue", "gh pr"], + }, + ], + [ + { + source: "context7_mcp", + reason: "ドキュメント確認", + matchPatterns: ["context7"], + }, + ] + ); + expect(result.allRequiredSatisfied).toBe(true); + expect(result.required[0].satisfied).toBe(true); + expect(result.required[0].matchedTools).toContain("gh issue view 3"); + expect(result.suggested[0].satisfied).toBe(true); + }); + + it("should return allRequiredSatisfied false when required not met", () => { + const result = verifyEvidence( + ["context7: commander.js"], + [ + { + source: "gh", + reason: "GH確認", + matchPatterns: ["gh issue", "gh pr"], + }, + ], + [] + ); + expect(result.allRequiredSatisfied).toBe(false); + expect(result.required[0].satisfied).toBe(false); + expect(result.required[0].matchedTools).toHaveLength(0); + }); + + it("should match with partial patterns", () => { + const result = verifyEvidence( + ["gh issue view 3 --repo nekochans/planloop"], + [{ source: "gh", reason: "GH確認", matchPatterns: ["gh issue"] }], + [] + ); + expect(result.required[0].satisfied).toBe(true); + }); + + it("should handle empty toolsUsed", () => { + const result = verifyEvidence( + [], + [{ source: "gh", reason: "GH確認", matchPatterns: ["gh issue"] }], + [ + { + source: "web_search", + reason: "Web検索", + matchPatterns: ["web_search"], + }, + ] + ); + expect(result.allRequiredSatisfied).toBe(false); + expect(result.suggested[0].satisfied).toBe(false); + }); + + it("should handle empty required and suggested", () => { + const result = verifyEvidence(["gh issue view 3"], [], []); + expect(result.allRequiredSatisfied).toBe(true); + expect(result.required).toHaveLength(0); + expect(result.suggested).toHaveLength(0); + }); + + it("should satisfy with any matching pattern", () => { + const result = verifyEvidence( + ["gh pr view 5"], + [ + { + source: "gh", + reason: "GH確認", + matchPatterns: ["gh issue", "gh pr"], + }, + ], + [] + ); + expect(result.required[0].satisfied).toBe(true); + }); +}); diff --git a/src/evidence/verifier.ts b/src/evidence/verifier.ts new file mode 100644 index 0000000..8dc31c1 --- /dev/null +++ b/src/evidence/verifier.ts @@ -0,0 +1,33 @@ +import type { + EvidenceRequirement, + EvidenceVerificationResult, +} from "../types/index.js"; + +export const verifyEvidence = ( + toolsUsed: string[], + required: EvidenceRequirement[], + suggested: EvidenceRequirement[] +): EvidenceVerificationResult => { + const checkRequirements = (reqs: EvidenceRequirement[]) => + reqs.map((req) => { + const matchedTools = toolsUsed.filter((tool) => + req.matchPatterns.some((pattern) => + tool.toLowerCase().includes(pattern.toLowerCase()) + ) + ); + return { + ...req, + satisfied: matchedTools.length > 0, + matchedTools, + }; + }); + + const requiredResults = checkRequirements(required); + const suggestedResults = checkRequirements(suggested); + + return { + required: requiredResults, + suggested: suggestedResults, + allRequiredSatisfied: requiredResults.every((r) => r.satisfied), + }; +}; diff --git a/src/intervention/handler.test.ts b/src/intervention/handler.test.ts new file mode 100644 index 0000000..d252553 --- /dev/null +++ b/src/intervention/handler.test.ts @@ -0,0 +1,62 @@ +import { PassThrough } from "node:stream"; +import { describe, expect, it } from "vitest"; +import { promptInterventionAction } from "./handler.js"; + +describe("promptInterventionAction", () => { + it("should return abort for A key", async () => { + const stdin = new PassThrough(); + const stdout = new PassThrough(); + + const promise = promptInterventionAction(stdin, stdout); + stdin.write("A"); + const result = await promise; + expect(result).toBe("abort"); + }); + + it("should return retry for R key", async () => { + const stdin = new PassThrough(); + const stdout = new PassThrough(); + + const promise = promptInterventionAction(stdin, stdout); + stdin.write("R"); + const result = await promise; + expect(result).toBe("retry"); + }); + + it("should return continue for C key", async () => { + const stdin = new PassThrough(); + const stdout = new PassThrough(); + + const promise = promptInterventionAction(stdin, stdout); + stdin.write("C"); + const result = await promise; + expect(result).toBe("continue"); + }); + + it("should return continue for unknown key", async () => { + const stdin = new PassThrough(); + const stdout = new PassThrough(); + + const promise = promptInterventionAction(stdin, stdout); + stdin.write("X"); + const result = await promise; + expect(result).toBe("continue"); + }); + + it("should display intervention message", async () => { + const stdin = new PassThrough(); + const stdout = new PassThrough(); + let output = ""; + stdout.on("data", (chunk: Buffer) => { + output += chunk.toString(); + }); + + const promise = promptInterventionAction(stdin, stdout); + stdin.write("A"); + await promise; + expect(output).toContain("介入を検出しました"); + expect(output).toContain("[A]"); + expect(output).toContain("[R]"); + expect(output).toContain("[C]"); + }); +}); diff --git a/src/intervention/handler.ts b/src/intervention/handler.ts new file mode 100644 index 0000000..fdd7414 --- /dev/null +++ b/src/intervention/handler.ts @@ -0,0 +1,69 @@ +export type InterventionAction = "abort" | "retry" | "continue"; + +export type StopMonitor = () => void; + +export const startInterventionMonitor = ( + onIntervene: () => Promise +): StopMonitor => { + if (!process.stdin.isTTY) { + // no-op for non-TTY environments + return () => undefined; + } + + let active = true; + + const onData = async (data: Buffer) => { + if (!active) { + return; + } + const key = data.toString(); + if (key === "q" || key === "Q") { + active = false; + process.stdin.setRawMode(false); + process.stdin.removeListener("data", onData); + await onIntervene(); + } + }; + + process.stdin.setRawMode(true); + process.stdin.resume(); + process.stdin.on("data", onData); + + return () => { + active = false; + if (process.stdin.isTTY) { + process.stdin.setRawMode(false); + } + process.stdin.removeListener("data", onData); + process.stdin.pause(); + }; +}; + +export const promptInterventionAction = ( + stdin: NodeJS.ReadableStream, + stdout: NodeJS.WritableStream +): Promise => { + const message = ` +\u26a0 介入を検出しました。実行中のプロセスを停止しました。 + + [A] 中断して終了 \u2014 このラウンドの結果を破棄し、ここまでの状態を保存して終了 + [R] やり直し \u2014 このラウンドを最初からやり直す + [C] 続行 \u2014 中断を取り消して処理を続行(プロセスは再開されます) +> `; + stdout.write(message); + + return new Promise((resolve) => { + const onData = (data: Buffer) => { + const key = data.toString().trim().toUpperCase(); + stdin.removeListener("data", onData); + if (key === "A") { + resolve("abort"); + } else if (key === "R") { + resolve("retry"); + } else { + resolve("continue"); + } + }; + stdin.on("data", onData); + }); +}; diff --git a/src/prompts/claude-revision.test.ts b/src/prompts/claude-revision.test.ts new file mode 100644 index 0000000..eea2384 --- /dev/null +++ b/src/prompts/claude-revision.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { createFinding } from "../__fixtures__/sample-findings.js"; +import { generateClaudeRevisionPrompt } from "./claude-revision.js"; + +describe("generateClaudeRevisionPrompt", () => { + it("should generate basic prompt", () => { + const prompt = generateClaudeRevisionPrompt("plan.md", "prompt.md", [ + createFinding({ id: "finding-1", summary: "API mismatch" }), + ]); + expect(prompt).toContain("plan.md"); + expect(prompt).toContain("prompt.md"); + expect(prompt).toContain("finding-1"); + expect(prompt).toContain("API mismatch"); + }); + + it("should format multiple findings correctly", () => { + const findings = [ + createFinding({ + id: "finding-1", + summary: "Issue A", + severity: "high", + category: "correctness", + }), + createFinding({ + id: "finding-2", + summary: "Issue B", + severity: "medium", + category: "spec_mismatch", + }), + ]; + const prompt = generateClaudeRevisionPrompt( + "plan.md", + "prompt.md", + findings + ); + expect(prompt).toContain("### finding-1 [HIGH] correctness"); + expect(prompt).toContain("### finding-2 [MEDIUM] spec_mismatch"); + }); + + it("should embed file paths correctly", () => { + const prompt = generateClaudeRevisionPrompt( + "design-docs/plan.md", + "prompts/req.md", + [createFinding()] + ); + expect(prompt).toContain("実装計画: design-docs/plan.md"); + expect(prompt).toContain("元の要件: prompts/req.md"); + expect(prompt).toContain("実装計画ファイル(design-docs/plan.md)"); + }); +}); diff --git a/src/prompts/claude-revision.ts b/src/prompts/claude-revision.ts new file mode 100644 index 0000000..ced0fdc --- /dev/null +++ b/src/prompts/claude-revision.ts @@ -0,0 +1,39 @@ +import type { Finding } from "../types/index.js"; + +export const generateClaudeRevisionPrompt = ( + planFile: string, + promptFile: string, + findings: Finding[] +): string => { + const findingsSection = findings + .map( + (f) => + `### ${f.id} [${f.severity.toUpperCase()}] ${f.category}\n${f.summary}\n${f.detail}` + ) + .join("\n\n"); + + return `以下の実装計画に対するレビュー指摘を反映してください。 + +## 対象ファイル + +実装計画: ${planFile} +元の要件: ${promptFile} + +## 反映すべきレビュー指摘 + +${findingsSection} + +## 指示 + +1. 上記の指摘内容を実装計画ファイル(${planFile})に反映してください +2. 実装計画ファイルを直接編集してください +3. 元の要件(${promptFile})の内容に矛盾しないように注意してください +4. 反映結果を以下のJSON形式で標準出力に出力してください + +\`\`\`json +{ + "reflectedFindings": ["finding-1", "finding-2"], + "summary": "変更内容の概要" +} +\`\`\``; +}; diff --git a/src/prompts/codex-review.test.ts b/src/prompts/codex-review.test.ts new file mode 100644 index 0000000..23b1882 --- /dev/null +++ b/src/prompts/codex-review.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it } from "vitest"; +import { createSampleConfig } from "../__fixtures__/sample-config.js"; +import { createRawFinding } from "../__fixtures__/sample-findings.js"; +import { + generateCodexFeedbackReviewPrompt, + generateCodexReviewPrompt, +} from "./codex-review.js"; + +describe("generateCodexReviewPrompt", () => { + const config = createSampleConfig(); + const planContent = "# 実装計画\nAPIの実装"; + const promptContent = "# 要件\nユーザー管理API"; + + it("should include default review perspectives", () => { + const prompt = generateCodexReviewPrompt( + planContent, + promptContent, + { round: 1, previousWaivers: [], previousFindings: [] }, + config + ); + expect(prompt).toContain( + "correctness: 実装計画の内容が要件と一致しているか" + ); + expect(prompt).toContain("security: セキュリティ上の懸念がないか"); + }); + + it("should include custom review perspectives", () => { + const customConfig = createSampleConfig({ + review: { perspectives: ["custom: カスタム観点"] }, + }); + const prompt = generateCodexReviewPrompt( + planContent, + promptContent, + { round: 1, previousWaivers: [], previousFindings: [] }, + customConfig + ); + expect(prompt).toContain("custom: カスタム観点"); + }); + + it("should include additionalInstructions when set", () => { + const customConfig = createSampleConfig({ + review: { + perspectives: config.review.perspectives, + additionalInstructions: "追加の指示内容", + }, + }); + const prompt = generateCodexReviewPrompt( + planContent, + promptContent, + { round: 1, previousWaivers: [], previousFindings: [] }, + customConfig + ); + expect(prompt).toContain("追加指示"); + expect(prompt).toContain("追加の指示内容"); + }); + + it("should not include additionalInstructions section when not set", () => { + const prompt = generateCodexReviewPrompt( + planContent, + promptContent, + { round: 1, previousWaivers: [], previousFindings: [] }, + config + ); + expect(prompt).not.toContain("追加指示:"); + }); + + it("should not include waiver section when no waivers", () => { + const prompt = generateCodexReviewPrompt( + planContent, + promptContent, + { round: 1, previousWaivers: [], previousFindings: [] }, + config + ); + expect(prompt).not.toContain("前回のレビューでの調整事項"); + }); + + it("should include waiver section when waivers exist", () => { + const prompt = generateCodexReviewPrompt( + planContent, + promptContent, + { + round: 2, + previousWaivers: [ + { + match: "拡張性", + category: "speculative_future", + action: "ignore", + reason: "将来の拡張性に関する指摘は対象外", + }, + ], + previousFindings: [], + }, + config + ); + expect(prompt).toContain("前回のレビューでの調整事項"); + expect(prompt).toContain("将来の拡張性に関する指摘は対象外"); + expect(prompt).toContain("speculative_future"); + }); + + it("should embed planContent and promptContent correctly", () => { + const prompt = generateCodexReviewPrompt( + planContent, + promptContent, + { round: 1, previousWaivers: [], previousFindings: [] }, + config + ); + expect(prompt).toContain(planContent); + expect(prompt).toContain(promptContent); + }); +}); + +describe("generateCodexFeedbackReviewPrompt", () => { + const config = createSampleConfig(); + const planContent = "# 実装計画\nAPIの実装"; + const promptContent = "# 要件\nユーザー管理API"; + + it("should include original findings", () => { + const findings = [ + createRawFinding({ id: "finding-1", summary: "API path mismatch" }), + createRawFinding({ + id: "finding-2", + summary: "Missing error handling", + severity: "medium", + }), + ]; + const prompt = generateCodexFeedbackReviewPrompt( + planContent, + promptContent, + { + round: 1, + originalFindings: findings, + humanFeedback: "指摘1は修正済み", + }, + config + ); + expect(prompt).toContain("finding-1"); + expect(prompt).toContain("API path mismatch"); + expect(prompt).toContain("finding-2"); + expect(prompt).toContain("Missing error handling"); + }); + + it("should include human feedback", () => { + const prompt = generateCodexFeedbackReviewPrompt( + planContent, + promptContent, + { + round: 1, + originalFindings: [createRawFinding()], + humanFeedback: "指摘2はスコープ外です", + }, + config + ); + expect(prompt).toContain("指摘2はスコープ外です"); + }); + + it("should include plan and prompt content", () => { + const prompt = generateCodexFeedbackReviewPrompt( + planContent, + promptContent, + { + round: 1, + originalFindings: [], + humanFeedback: "", + }, + config + ); + expect(prompt).toContain(planContent); + expect(prompt).toContain(promptContent); + }); +}); diff --git a/src/prompts/codex-review.ts b/src/prompts/codex-review.ts new file mode 100644 index 0000000..6efef27 --- /dev/null +++ b/src/prompts/codex-review.ts @@ -0,0 +1,139 @@ +import type { + FeedbackReviewContext, + ReviewContext, +} from "../adapters/types.js"; +import type { PlanloopConfig, Waiver } from "../types/index.js"; + +export const generateCodexReviewPrompt = ( + planContent: string, + promptContent: string, + context: ReviewContext, + config: PlanloopConfig +): string => { + const perspectivesSection = config.review.perspectives + .map((p) => ` - ${p}`) + .join("\n"); + + let instructions = `1. 以下の観点でレビューを行ってください:\n${perspectivesSection}`; + + if (config.review.additionalInstructions) { + instructions += `\n\n2. 追加指示:\n${config.review.additionalInstructions}`; + } + + instructions += `\n\n${config.review.additionalInstructions ? "3" : "2"}. 以下の種類の指摘は避けてください: + - speculative_future: 「将来必要になるかもしれない」だけの指摘 + - unnecessary_fallback: 不要なフォールバック実装の要求`; + + const waiverSection = buildWaiverSection(context.previousWaivers); + + return `あなたは実装計画のレビュアーです。以下の実装計画をレビューしてください。 + +## レビュー対象の実装計画 + +${planContent} + +## 実装計画の元となった要件(プロンプト) + +${promptContent} + +## レビュー指示 + +${instructions} +${waiverSection} +## 出力形式 + +以下のJSON形式で出力してください。JSON以外のテキストは出力しないでください。 + +\`\`\`json +{ + "findings": [ + { + "id": "finding-1", + "summary": "指摘の要約(1行)", + "detail": "指摘の詳細説明(複数行可)", + "severity": "high | medium | low", + "category": "correctness | spec_mismatch | missing_acceptance_criteria | migration_risk | security | performance | code_quality | other" + } + ] +} +\`\`\``; +}; + +export const generateCodexFeedbackReviewPrompt = ( + planContent: string, + promptContent: string, + context: FeedbackReviewContext, + _config: PlanloopConfig +): string => { + const findingsSection = context.originalFindings + .map( + (f) => + `### ${f.id} [${f.severity.toUpperCase()}] ${f.category}\n${f.summary}\n${f.detail}` + ) + .join("\n\n"); + + return `あなたは実装計画のレビュアーです。前回のレビューに対して人間からフィードバックがあったため、調整後のレビュー結果を出力してください。 + +## レビュー対象の実装計画 + +${planContent} + +## 実装計画の元となった要件(プロンプト) + +${promptContent} + +## 前回のレビュー指摘 + +${findingsSection} + +## 人間からのフィードバック + +${context.humanFeedback} + +## 指示 + +上記のフィードバックを踏まえ、以下の条件で再レビューしてください: +1. waiveまたは対象外と指示された指摘は含めないでください +2. フィードバックで修正方針が示された指摘は、その方針を反映して調整してください +3. フィードバックで言及されていない指摘はそのまま残してください +4. 新たに気づいた指摘があれば追加してください + +## 出力形式 + +以下のJSON形式で出力してください。JSON以外のテキストは出力しないでください。 + +\`\`\`json +{ + "findings": [ + { + "id": "finding-1", + "summary": "指摘の要約(1行)", + "detail": "指摘の詳細説明(複数行可)", + "severity": "high | medium | low", + "category": "correctness | spec_mismatch | missing_acceptance_criteria | migration_risk | security | performance | code_quality | other" + } + ] +} +\`\`\``; +}; + +const buildWaiverSection = (waivers: Waiver[]): string => { + if (waivers.length === 0) { + return ""; + } + + const waiverLines = waivers + .map( + (w) => + `- ${w.reason} (カテゴリ: ${w.category ?? "全て"}, パターン: ${w.match})` + ) + .join("\n"); + + return ` +## 前回のレビューでの調整事項 + +以下の観点は前回のレビューで対象外と判断されています。これらに該当する指摘は出力しないでください。 + +${waiverLines} +`; +}; diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..722177e --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,149 @@ +// ---- Finding categories ---- +export const FINDING_CATEGORIES = [ + "correctness", + "spec_mismatch", + "missing_acceptance_criteria", + "migration_risk", + "speculative_future", + "unnecessary_fallback", + "code_quality", + "security", + "performance", + "other", +] as const; + +export type FindingCategory = (typeof FINDING_CATEGORIES)[number]; + +// ---- Finding severity ---- +export const FINDING_SEVERITIES = ["high", "medium", "low"] as const; +export type FindingSeverity = (typeof FINDING_SEVERITIES)[number]; + +// ---- A single review finding ---- +export interface Finding { + category: FindingCategory; + detail: string; + fingerprint: string; + id: string; + lineRef?: string; + severity: FindingSeverity; + summary: string; +} + +// ---- Triage result ---- +export interface TriageResult { + adjustedFindings: RawFinding[]; + humanFeedback: string; + newWaivers: Waiver[]; +} + +// ---- Raw finding (fingerprint未設定、アダプターからの生出力) ---- +export type RawFinding = Omit; + +// ---- Persistent waiver rule ---- +export interface Waiver { + action: "ignore" | "downgrade"; + category?: FindingCategory; + downgradeTo?: "non_blocking"; + match: string; + reason: string; +} + +// ---- Codex review result ---- +export interface ReviewResult { + findings: Finding[]; + rawOutputPath: string; + round: number; + timestamp: string; + toolsUsed: string[]; +} + +// ---- Claude revision result ---- +export interface RevisionResult { + rawOutput: string; + reflectedFindings: string[]; + summary: string; + timestamp: string; +} + +// ---- State for a single round ---- +export interface RoundState { + actionableFindings: Finding[]; + humanFeedback?: string; + review: ReviewResult; + revision?: RevisionResult; + round: number; +} + +// ---- Overall loop state ---- +export type LoopStatus = "in_progress" | "completed" | "stopped"; + +export interface LoopState { + planFile: string; + promptFile: string; + rounds: RoundState[]; + runId: string; + startedAt: string; + status: LoopStatus; + stopReason?: StopReason; + updatedAt: string; + waivers: Waiver[]; +} + +// ---- Stop reason ---- +export type StopReason = + | "no_blocking_findings" + | "stagnation" + | "max_rounds" + | "human_abort"; + +// ---- Evidence verification ---- +export const EVIDENCE_SOURCES = [ + "gh", + "figma_mcp", + "context7_mcp", + "web_search", +] as const; + +export type EvidenceSource = (typeof EVIDENCE_SOURCES)[number]; + +export interface EvidenceRequirement { + matchPatterns: string[]; + reason: string; + source: EvidenceSource; +} + +export interface EvidenceVerificationResult { + allRequiredSatisfied: boolean; + required: Array< + EvidenceRequirement & { satisfied: boolean; matchedTools: string[] } + >; + suggested: Array< + EvidenceRequirement & { satisfied: boolean; matchedTools: string[] } + >; +} + +// ---- Config ---- +export interface PlanloopConfig { + engines: { + claude: { + mode: "inherited" | "bare"; + }; + }; + paths: { + reviewDir: string; + runDir: string; + }; + policy: { + requireHumanOnFirstRound: boolean; + requireHumanOnNewHighSeverity: boolean; + maxRounds: number; + stagnationRounds: number; + blockingCategories: FindingCategory[]; + autoWaiveCategories: FindingCategory[]; + }; + review: { + perspectives: string[]; + additionalInstructions?: string; + }; + version: 1; +} diff --git a/tsconfig.json b/tsconfig.json index 1cea0e4..95b1f03 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,7 +12,8 @@ "resolveJsonModule": true, "isolatedModules": true, "declaration": false, - "sourceMap": true + "sourceMap": true, + "types": ["node"] }, "include": ["src"], "exclude": ["node_modules", "dist", "src/**/*.test.ts"]