Skip to content

Instantly share code, notes, and snippets.

@matsubo
Last active June 27, 2025 08:46
Show Gist options
  • Save matsubo/569ca861436ed6b587cd0c865ee8a24c to your computer and use it in GitHub Desktop.
Save matsubo/569ca861436ed6b587cd0c865ee8a24c to your computer and use it in GitHub Desktop.

AIコーディングエージェント 要件定義書 (v5.0)

1. プロジェクトの目的

GitHub Projectで管理されるバックログを、自律的に実装し、人間がレビュー・マージ可能な状態でPull Requestを自動作成するAIエージェントを開発する。開発サイクルを高速化し、開発者の負担を軽減することを目的とする。

2. メインワークフロー

エージェントは、以下の状態とトリガーに基づいて動作する。処理は常に**一度に1タスクずつ(直列で)**実行される。

状態遷移図:

graph TD
    subgraph 初期起動
        A[エージェント起動] --> B{バックログに<br>タスクは?};
    end

    subgraph 正常サイクル
        B -- Yes --> C[一番上のタスクを取得];
        B -- No --> D[アイドル状態で待機];

        D -- "トリガーA:<br>自身のPRが<br>mainにマージ" --> B;

        C --> E[コード実装・PR作成];
        
        subgraph レビューサイクル
            E --> F_AI[外部AIレビュー実行];
            F_AI --> G{AIレビュー結果に<br>クリティカルな問題あり?};
            G -- "Yes (3回未満)" --> H_Fix[Agentがコードを修正];
            H_Fix --> F_AI;

            G -- "No" --> I[人間にテスト依頼を通知];
            I --> J[人間による手動テスト・確認];
            J --> L{人間レビューOK?};
            L -- "No (修正依頼)" --> H_Fix;
        end

        L -- "Yes (承認)" --> K[人間がPRをマージ];
        K --> D;
    end

    subgraph 例外処理
        E -- "タイムアウト/エラー<br>(3回失敗)" --> M[人間に通知し待機状態へ];
        G -- "Yes (3回超過)" --> M;
    end
Loading

ワークフロー詳細:

  1. 初回起動: エージェント起動時、まずバックログを確認する。タスクがあれば、一番上のものを取得して処理を開始する。タスクがなければ、アイドル状態で待機する。
  2. タスク実行: コード実装、PR作成、レビュー対応(最大3回)を行う。
    • 作業ブランチの独立性: タスク着手時点のmainをベースとし、作業中にmainが更新されてもその変更は取り込まない。
  3. AIレビューサイクル:
    • Pull Requestが作成されると、外部のAIレビュー(GitHub Actions等)が実行されるのを待つ。
    • AIレビューの結果(PRコメント)を解釈し、「クリティカルな問題」の有無を判断する。
    • 問題がある場合: エージェントは指摘内容に基づきコードを修正してPushする。この修正サイクルは最大3回までとする。上限に達した場合は、人間に通知して処理を中断する。
    • 問題がない場合: 人間にテストを依頼する通知を送る。
  4. 人間によるテストとフィードバックサイクル:
    • AIレビューをパスすると、人間にテスト依頼が通知される。
    • 人間は手動でテストとコードレビューを行う。
    • 修正依頼がある場合: 人間がPRにコメント等で修正点を指摘する。エージェントはそれを解釈し、コードを修正する(H_Fixへ)。修正後、再度AIレビューからサイクルが再開される。
    • 承認された場合: 人間がPull Requestをmainブランチにマージする。このマージが次のサイクルのトリガーとなる。
  5. 待機(アイドル)状態: 以下のトリガーAのみを待つ。
    • トリガーA (継続トリガー): エージェント自身が作成したPull Requestがmainにマージされた場合。バックログを確認し、次のタスクがあれば処理を再開する。
  6. 管理外マージの無視: エージェントが関与していないPRがマージされても、それをトリガーとしない。
  7. バックログが空の場合: トリガーAが発生してもバックログが空なら、何もせず再びアイドル状態に戻り、次のトリガーAを待つ。

3. 機能要件 (FR)

ID 要件名 詳細
FR-1 タスクの自動取得 起動時、およびトリガーA発生時に、指定されたGitHub Projectのビューから処理すべきタスクを特定し、取得する。
FR-2 コードの自動実装 Worker Agentは、設定されたAIモデル(Gemini, Claude等)を用いてコーディングを行う。<br>リポジトリ内の規約ファイル(例: GEMINI.md, CLAUDE.md)を読み込み、基本指示としてLLMに渡す。
FR-3 Pull Requestの自動作成 実装完了後、タスク情報に基づいたタイトル・本文でPull Requestを自動作成する。
FR-4 レビューに基づく自動修正 自身が作成したPRに投稿された、外部AIレビューおよび人間による全段階(テスト依頼後を含む)のレビューコメントを解釈し、コードを修正してPushする。<br>AIレビューによる修正サイクルは最大3回までとする。
FR-5 リポジトリ規約の遵守 依存関係のインストール、ビルド、テストの実行など、対象リポジトリに定められた開発・テスト手順を解釈し、それに従って処理を実行する。
FR-6 通知機能 処理の異常終了時や修正回数の上限到達時に加え、**AIレビューをパスした際の「人間へのテスト依頼」**をGitHubのコメントやSlack等で通知する。
FR-7 人間からの修正の判断・採択 人間からコードの修正案(コメント内のコードスニペット等)が提示された場合、エージェントはその修正案を採択するか、独自の解釈で再実装するか、あるいは拒否するかを自律的に判断する。

4. 非機能要件 (NFR)

ID 要件名 詳細
NFR-1 実行モデル 1つのリポジトリに対して1つのエージェントが動作する。<br>同時に処理するバックログタスクは1つに限定する(並列処理は行わない)。
NFR-2 プロセス監視 各AI処理(コード生成など)には15分のタイムアウトを設定する。<br>タイムアウトした場合、最大2回まで自動で再実行する。3回失敗した場合は異常とみなし、人間に通知する。
NFR-3 拡張性 AIモデルを切り替えられるよう、モデル呼び出し部分を抽象化して設計する。(例: Gemini, Claude)
NFR-4 セキュリティ GitHubトークンや各AIのAPIキーは、環境変数などを用いて安全に管理する。

5. スコープ

  • スコープ内:
    • 本要件定義書に記載されたすべての機能。
    • 外部のAIレビューシステムの実行をトリガーし、その結果(PRコメント)を解釈して、修正や通知のフローを制御すること。
  • スコープ外:
    • AIによるレビュー機能そのものの実装。(レビュー機能は、別途用意されたGitHub Actions等の外部ワークフローであることを前提とする。)

AIコーディングエージェント 設計書 (最終版 v2)

1. はじめに

本ドキュメントは、合意済みの要件定義に基づき、AIコーディングエージェントをNode.jsおよびTypeScriptで実装するための技術的なアーキテクチャ、コンポーネント、ワークフローを定義します。

2. アーキテクチャ概要

本システムは、指定されたサーバー上で24時間稼働する、イベント駆動型の常駐プロセスとして実装します。GitHub Webhookを利用してイベントをリアルタイムに検知し、APIを通じて各種操作を実行します。

graph TD
    subgraph "サーバー (Node.js/TypeScript)"
        Agent[Coding Agent Process @<br>/path/to/cloned/repo]
    end

    subgraph GitHub
        Repo[Target Repository]
        Project[GitHub Project]
        PR[Pull Request]
        Webhook[Webhooks]
    end

    subgraph 外部AIサービス
        Gemini[Google Gemini API]
        Claude[Anthropic Claude API]
    end

    Agent -- "1. Get Tasks" --> Project
    Agent -- "2. Push/Pull Code" --> Repo
    Agent -- "3. Create/Monitor PR" --> PR
    Webhook -- "4. Notify Events (PR Merged, Commented)" --> Agent
    Agent -- "5. Generate/Analyze Code" --> Gemini
    Agent -- "5. Generate/Analyze Code" --> Claude
Loading

3. コンポーネント設計

システムは、責務ごとに分割された以下の主要コンポーネントで構成されます。

コンポーネント名 主な責務
1. Master Controller ・エージェント全体のメインプロセス。<br>・状態(アイドル、作業中)を管理するステートマシンを保持。<br>・Webhookからのイベントを受け取り、適切なコンポーネントに処理を委譲する。
2. Task Executor ・単一のタスクを遂行するワーカー。<br>・Git操作、ローカルでのテスト実行、LLM Clientの呼び出しなど、実装のコアロジックを担当。
3. GitHub Client ・GitHub APIとのすべての通信を担当するラッパー。<br>・リポジトリの操作、Projectからのタスク取得、PRの作成・編集、コメントの取得・投稿など。
4. LLM Client ・各種LLM(Gemini, Claude)との通信を抽象化するラッパー。<br>・モデルに応じたAPIキーとプロンプト(GEMINI.md等)を使用して、コード生成や分析を依頼する。
5. State Manager ・エージェントの現在の状態(例: current_task_id, pr_url)を永続化する。<br>・シンプルなJSONファイルやSQLiteを使用し、エージェントの再起動後も状態を復元できるようにする。
6. Process Monitor ・Task Executorが実行する時間のかかる処理(LLM呼び出し、テスト実行)を監視する。<br>・15分のタイムアウトと最大2回のリトライ処理を管理する。
7. Notifier ・人間に通知を送る責務を持つ。<br>・GitHubのPRへのコメント投稿や、設定されていればSlackへの通知を行う。

4. 主要ワークフロー詳細

4.1. 起動シーケンス
  1. npm start 等のコマンドでエージェントを起動。
  2. 環境変数 (.envファイル等) からConfig情報(APIキー、Project URL等)を読み込む。
  3. State Managerを介して前回の状態を読み込む。作業途中のタスクがあれば復旧を試みる。
  4. 状態が「アイドル」であれば、GitHub Clientを使ってバックログを一度だけ確認し、タスクがあれば処理を開始する。なければWebhookイベントの待機ループに入る。
4.2. タスク実行プロセス
  1. 準備 (更新):
    • エージェントが起動されたカレントディレクトリをワーキングディレクトリとして使用します。
    • まず git checkout maingit fetch origingit reset --hard origin/main を実行してローカルのmainブランチをリモートの最新状態に同期します。
    • 次に、タスクに基づいた名前で新しいブランチを作成します(git checkout -b feature/task-123)。
  2. プロンプト構築: タスク内容、規約ファイル (GEMINI.md等)、関連する既存コードを基に、LLM Clientに渡す詳細なプロンプトを生成する。
  3. コード生成: LLM Clientを介してAIモデルのAPIを呼び出す。応答として、変更対象のファイルパスとその変更内容(コードやdiff形式)を構造化された形式(JSON等)で受け取る。
  4. コード適用と検証: 受け取った変更をワーキングディレクトリ内のファイルに適用し、リポジトリで定められたテストコマンド(例: npm test)を child_process で実行する。テスト失敗時は、エラー内容を加えて自己修正を試みる。
  5. PR作成: テスト成功後、変更をCommit/Pushし、GitHub ClientでPull Requestを作成。PR情報をState Managerに保存する。
4.3. レビューと修正のループ
  1. イベント待機: 内蔵のWebサーバーで、管理下のPRに対するコメント等のWebhookイベントを待つ。
  2. コメント解釈: 新しいコメントをLLM Clientに渡し、「修正が必要か」「クリティカルか」等を判断させる。
  3. 修正実行: 修正が必要な場合、Task Executorを再度起動し、コメント内容を新たな指示として自己修正サイクルを実行する。
  4. ループ管理: 修正回数をカウントし、上限を超えた場合はプロセスを中断し、Notifierで人間に通知する。

5. 技術スタックと前提条件

  • ランタイム: Node.js
  • 言語: TypeScript
  • Webフレームワーク: Express.js または Fastify (Webhookのエンドポイント実装のため)
  • GitHub APIクライアント: Octokit.js (GitHub公式推奨ライブラリ)
  • LLM APIクライアント:
    • @google/generative-ai (Gemini API用)
    • @anthropic-ai/sdk (Claude API用)
  • プロセス実行: Node.jsの child_process モジュール (Gitコマンドやテスト実行のため)
  • インフラ: エージェントを24時間稼働させるためのサーバー (VM, Dockerコンテナ等)
  • 前提条件:
    • エージェントは、対象のGitリポジトリがクローンされたディレクトリのルートで実行される必要があります。
    • エージェント用のGitHub AppまたはPATの発行と権限付与。
    • エージェントのサーバー(またはngrok等のトンネル)のエンドポイントに対するWebhook設定。

6. リスクと課題

  • プロンプトエンジニアリング: エージェントの性能は、GEMINI.md等の指示書と動的プロンプトの品質に依存する。
  • LLMの出力安定性: LLMの応答が不安定な場合を想定した、出力の検証とエラーハンドリングが必須となる。
  • 状態管理: ファイルベースの状態管理はシンプルだが、クラッシュ時の堅牢性に欠ける。高信頼性が求められる場合は、より堅牢なデータベースの導入を検討する。
  • コンテキスト長: 大規模な修正ではLLMのコンテキスト長が課題になるため、関連コードを的確に抽出するロジックが重要となる。
@matsubo
Copy link
Author

matsubo commented Jun 27, 2025

Canvasモードで出てきた実装。使えないなぁ。

// =================================================================
// package.json - プロジェクトの依存関係とスクリプト // UPDATED
// =================================================================
{
  "name": "ai-coding-agent",
  "version": "1.0.0",
  "description": "An autonomous AI agent for coding tasks.",
  "main": "dist/index.js",
  "scripts": {
    "build": "tsc",
    "start": "node dist/index.js",
    "dev": "nodemon src/index.ts"
  },
  "keywords": [
    "ai",
    "agent",
    "typescript",
    "github"
  ],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "@octokit/rest": "^20.1.0",
    "@slack/webhook": "^7.0.2",
    "dotenv": "^16.4.5",
    "express": "^4.19.2",
    "simple-git": "^3.24.0"
  },
  "devDependencies": {
    "@types/express": "^4.17.21",
    "@types/node": "^20.12.12",
    "nodemon": "^3.1.0",
    "ts-node": "^10.9.2",
    "typescript": "^5.4.5"
  }
}

// =================================================================
// tsconfig.json - TypeScriptコンパイラ設定
// =================================================================
{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "rootDir": "./src",
    "outDir": "./dist",
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "skipLibCheck": true
  },
  "include": ["src/**/*"]
}


// =================================================================
// .env.example - 環境変数のサンプル // UPDATED
// =================================================================
# GitHub Settings
GITHUB_TOKEN="ghp_..."
GITHUB_PROJECT_URL="https://github.com/orgs/your-org/projects/123"
GITHUB_WEBHOOK_SECRET="your_strong_secret"

# Slack Notification Settings (New!)
SLACK_WEBHOOK_URL=""

# Server Settings
PORT=3000

# Agent Logic Settings
CRITICAL_COMMENT_KEYWORD="[Critical]"


// =================================================================
// src/index.ts - アプリケーションのエントリーポイント
// =================================================================
import dotenv from 'dotenv';
import { MasterController } from './MasterController';
import { createServer } from './server';
import { logger } from './utils/logger';

// .envファイルから環境変数を読み込む
dotenv.config();

async function main() {
  logger.info("🤖 AIコーディングエージェントを起動します...");

  const port = process.env.PORT || 3000;

  try {
    const controller = new MasterController();
    await controller.initialize();

    const app = createServer(controller);

    app.listen(port, () => {
      logger.info(`👂 Webhookサーバーがポート ${port} で待機中です`);
    });
    
    // 初期状態がアイドルなら、一度だけタスクの有無を確認して開始する
    if (controller.getCurrentStatus() === 'idle') {
      logger.info("初回起動チェック: バックログのタスクを確認します...");
      await controller.startNextTask();
    }

  } catch (error) {
    logger.error("💥 起動中に致命的なエラーが発生しました:", error);
    process.exit(1);
  }
}

main();


// =================================================================
// src/server.ts - Webhook受信用Expressサーバー
// =================================================================
import express from 'express';
import crypto from 'crypto';
import { MasterController } from './MasterController';
import { logger } from './utils/logger';

export function createServer(controller: MasterController) {
  const app = express();

  // GitHubのWebhookペイロードを正しく受け取るための設定
  app.use(express.json({
    verify: (req, res, buf) => {
      const signature = req.headers['x-hub-signature-256'];
      if (signature) {
        const hmac = crypto.createHmac('sha256', process.env.GITHUB_WEBHOOK_SECRET!);
        const digest = `sha256=${hmac.update(buf).digest('hex')}`;
        if (!crypto.timingSafeEqual(Buffer.from(digest), Buffer.from(signature as string))) {
          throw new Error('Webhookのシグネチャが一致しません');
        }
      }
      (req as any).rawBody = buf;
    }
  }));

  app.post('/webhook', (req, res) => {
    const event = req.headers['x-github-event'] as string;
    const payload = req.body;

    logger.info(`受信したWebhookイベント: ${event}`);
    
    // イベント処理は非同期で行い、GitHubには即座にレスポンスを返す
    controller.handleWebhookEvent(event, payload).catch(err => {
        logger.error(`Webhookイベント処理中にエラー: ${err}`);
    });

    res.status(202).send('Accepted');
  });
  
  app.get('/status', (req, res) => {
    res.status(200).json({ status: controller.getCurrentStatus() });
  });

  return app;
}

// =================================================================
// src/MasterController.ts - エージェントの頭脳。状態管理と指示。
// =================================================================
import { StateManager, AgentState } from './utils/StateManager';
import { GitHubClient } from './clients/GitHubClient';
import { TaskExecutor } from './TaskExecutor';
import { logger } from './utils/logger';
import { Notifier } from './utils/Notifier';

export class MasterController {
  private stateManager: StateManager;
  private githubClient: GitHubClient;
  private notifier: Notifier;

  constructor() {
    this.stateManager = new StateManager();
    this.githubClient = new GitHubClient();
    this.notifier = new Notifier();
  }

  async initialize() {
    await this.stateManager.load();
    const state = this.stateManager.getState();

    // もしエージェントが作業中にクラッシュした場合の復旧処理
    if (state.status === 'working') {
      logger.warn(`エージェントはタスク ${state.currentTask?.issueNodeId} の作業中に停止しました。状態をアイドルにリセットします。`);
      await this.notifier.notify(`🤖 作業中に停止しました。状態をリセットし、アイドル状態に戻ります。\nタスク: ${state.currentTask?.issueNodeId || 'N/A'}`);
      state.status = 'idle';
      state.currentTask = null;
      await this.stateManager.save();
    }
  }

  getCurrentStatus(): AgentState['status'] {
    return this.stateManager.getState().status;
  }

  async handleWebhookEvent(event: string, payload: any): Promise<void> {
    const state = this.stateManager.getState();
    
    // --- トリガーA: 自身のPRがマージされた場合 ---
    if (
      event === 'pull_request' &&
      payload.action === 'closed' &&
      payload.pull_request.merged === true &&
      state.currentTask?.prUrl === payload.pull_request.html_url
    ) {
      logger.info(`🎉 タスク ${state.currentTask.issueNodeId} のPRがマージされました。次のタスクを探します。`);
      await this.notifier.notify(`✅ PRがマージされました: <${payload.pull_request.html_url}|${payload.pull_request.title}>`);
      state.status = 'idle';
      state.currentTask = null;
      await this.stateManager.save();
      await this.startNextTask();
      return;
    }
    
    // --- レビューコメントへの対応 ---
    if (
      (event === 'issue_comment' && payload.action === 'created') ||
      (event === 'pull_request_review_comment' && payload.action === 'created')
    ) {
        if(state.status === 'working' && state.currentTask?.prUrl === (payload.pull_request?.html_url || payload.issue?.html_url)) {
            logger.info(`新しいレビューコメントを受信しました。内容を確認します。`);
            // TODO: コメントを解釈してTaskExecutorに修正を指示するロジックを実装
        }
    }
  }
  
  async startNextTask(): Promise<void> {
    if (this.getCurrentStatus() !== 'idle') {
      logger.warn('エージェントは現在作業中のため、新しいタスクを開始できません。');
      return;
    }

    const task = await this.githubClient.getTopTaskFromProject();
    if (!task) {
      logger.info('現在対応可能なタスクはありません。アイドル状態で待機します。');
      return;
    }

    logger.info(`新しいタスクが見つかりました: [${task.title}](${task.url})`);
    await this.notifier.notify(`🚀 新しいタスクを開始します: <${task.url}|${task.title}>`);
    
    const state = this.stateManager.getState();
    state.status = 'working';
    state.currentTask = {
      issueNodeId: task.id,
      title: task.title,
      body: task.body,
      prUrl: null,
      reviewCycleCount: 0,
    };
    await this.stateManager.save();
    
    try {
      const executor = new TaskExecutor(task, this.githubClient);
      const prUrl = await executor.execute();
      
      state.currentTask.prUrl = prUrl;
      await this.stateManager.save();
      logger.info(`PRの作成が完了しました: ${prUrl}`);
      await this.notifier.notify(`📝 PRを作成しました。レビューをお願いします。\n<${prUrl}|${task.title}>`);
      // ここでAIレビューを待つ状態に移行
      
    } catch (error) {
      logger.error(`タスク[${task.title}]の実行に失敗しました。`, error);
      await this.notifier.notify(`❌ タスクの実行に失敗しました: <${task.url}|${task.title}>\n\`\`\`${error}\`\`\``);
      state.status = 'idle';
      state.currentTask = null;
      await this.stateManager.save();
    }
  }
}

// =================================================================
// src/TaskExecutor.ts - 単一タスクの実行担当 // UPDATED
// =================================================================
import simpleGit, { SimpleGit } from 'simple-git';
import { exec } from 'child_process';
import util from 'util';
import fs from 'fs/promises';
import path from 'path';
import { GitHubClient, Task } from './clients/GitHubClient';
import { LLMClient, ClaudeClient } from './clients/LLMClient';
import { logger } from './utils/logger';

const execAsync = util.promisify(exec);

export class TaskExecutor {
  private task: Task;
  private git: SimpleGit;
  private githubClient: GitHubClient;
  private llmClient: LLMClient;

  constructor(task: Task, githubClient: GitHubClient) {
    this.task = task;
    this.githubClient = githubClient;
    this.git = simpleGit(); // カレントディレクトリで初期化

    // LLMクライアントとしてClaudeClientを直接使用する
    this.llmClient = new ClaudeClient();
  }

  async execute(): Promise<string> {
    const branchName = `agent/task-${this.task.number}`;
    
    // 1. 準備
    logger.info('Gitリポジトリを最新の状態に更新します...');
    await this.git.checkout('main');
    await this.git.fetch('origin');
    await this.git.reset(['--hard', 'origin/main']);
    await this.git.checkout(['-b', branchName]);
    logger.info(`新しいブランチを作成しました: ${branchName}`);

    // 2. コード生成とテストのループ
    let success = false;
    let lastError: Error | null = null;
    for (let i = 0; i < 3; i++) { // 最大3回自己修正を試みる
        logger.info(`コード生成とテストの試行 ${i + 1}/3...`);
        try {
            const prompt = this.buildPrompt(lastError?.message);
            const modifications = await this.llmClient.generateCode(prompt);
            
            // ファイル変更を適用
            logger.info(`${modifications.length}件のファイル変更を適用します...`);
            for (const mod of modifications) {
                const filePath = path.join(process.cwd(), mod.filePath);
                await fs.mkdir(path.dirname(filePath), { recursive: true });
                await fs.writeFile(filePath, mod.content, 'utf-8');
                logger.info(`  - ${mod.filePath} を更新しました。`);
            }
            
            logger.info('コード変更を適用し、テストを実行します...');
            const { stdout, stderr } = await execAsync('npm test');
            if (stderr) {
                logger.warn(`テスト実行時にstderrに出力がありました: ${stderr}`);
            }
            logger.info(`テスト実行 stdout: ${stdout}`);

            logger.info('テストに成功しました!');
            success = true;
            break; 
        } catch(error: any) {
            lastError = error;
            logger.warn(`試行 ${i + 1}: 処理中にエラーが発生しました。自己修正を試みます。`, error.message);
        }
    }

    if (!success) {
        throw new Error(`複数回試行しましたが、テストをパスできませんでした。最後のエラー: ${lastError?.message}`);
    }

    // 3. PR作成
    logger.info('変更をコミットし、Pull Requestを作成します...');
    await this.git.add('.');
    await this.git.commit(`feat: ${this.task.title}`);
    await this.git.push('origin', branchName, ['--force']);
    
    const pr = await this.githubClient.createPullRequest(
        branchName,
        this.task.title,
        `Resolves #${this.task.number}\n\n${this.task.body}`
    );
    
    return pr.html_url;
  }
  
  private buildPrompt(errorContext?: string): string {
      const errorPrompt = errorContext 
        ? `
---
PREVIOUS ATTEMPT FAILED:
The previous attempt to apply changes and run tests failed with the following error. Please analyze this error and correct your code.
Error:
\`\`\`
${errorContext}
\`\`\`
---
` 
        : '';
      return `
        Task Title: ${this.task.title}
        Task Body: ${this.task.body}
        ${errorPrompt}
        Please analyze the task and provide the necessary code modifications to implement it.
      `;
  }
}


// =================================================================
// src/clients/GitHubClient.ts - GitHub APIラッパー
// =================================================================
import { Octokit } from '@octokit/rest';
import { logger } from '../utils/logger';

export interface Task {
  id: string;
  number: number;
  title: string;
  body: string;
  url: string;
}

export class GitHubClient {
  private octokit: Octokit;
  private owner: string;
  private repo: string;
  private projectNumber: number;

  constructor() {
    this.octokit = new Octokit({ auth: process.env.GITHUB_TOKEN });
    const projectUrl = process.env.GITHUB_PROJECT_URL!;
    // URLからowner, repo, projectNumberを抽出
    const urlParts = new URL(projectUrl).pathname.split('/');
    this.owner = urlParts[2];
    this.repo = ''; // Project v2 can be org-level
    this.projectNumber = parseInt(urlParts[4], 10);
  }

  async getTopTaskFromProject(): Promise<Task | null> {
    // GitHub Projects v2 APIはGraphQLのみ。
    // ここでは簡略化のためにダミーデータを返します。
    // 実際のプロジェクトではGraphQLクエリを実装する必要があります。
    logger.warn('getTopTaskFromProjectは現在ダミーデータを返します。GraphQLの実装が必要です。');
    return { id: 'dummy-node-id', number: 1, title: 'Dummy Task', body: 'Implement dummy feature', url: 'https://github.com' };
    // return null; 
  }

  async createPullRequest(head: string, title: string, body: string) {
    // TODO: orgs/owner/projects/project_number の場合 repo が空になるので、
    // 実際の運用では Issue からリポジトリ名を取得する必要がある
    const repoName = 'ai-coding-agent'; // ダミーのリポジトリ名
    const { data } = await this.octokit.pulls.create({
      owner: this.owner,
      repo: this.repo || repoName,
      title,
      body,
      head,
      base: 'main'
    });
    return data;
  }
  // 他のGitHub APIメソッド...
}

// =================================================================
// src/clients/LLMClient.ts - LLM APIの抽象化レイヤー // UPDATED
// =================================================================
import fs from 'fs/promises';
import { exec } from 'child_process';
import util from 'util';
import { logger } from '../utils/logger';

const execAsync = util.promisify(exec);

export interface CodeModification {
    filePath: string;
    content: string;
}

export abstract class LLMClient {
  abstract generateCode(prompt: string): Promise<CodeModification[]>;

  protected async getInstructions(filePath: string): Promise<string> {
    try {
        return await fs.readFile(filePath, 'utf-8');
    } catch (error) {
        logger.warn(`${filePath} が見つかりません。`);
        return '';
    }
  }
}

export class ClaudeClient extends LLMClient {
    async generateCode(prompt: string): Promise<CodeModification[]> {
        // Claude CLI用の指示書を読み込む
        const instructions = await this.getInstructions('CLAUDE.md');
        const jsonInstruction = `
Your entire output MUST be a single, valid JSON object that can be parsed by JSON.parse(). Do not include any other text, explanations, or markdown formatting like \`\`\`json.
The JSON object must be an array of objects, where each object has the following structure:
{
  "filePath": "path/to/file/relative/to/repo/root.ts",
  "content": "the full new content of the file"
}

Example of a valid response:
[
  {
    "filePath": "src/components/Button.ts",
    "content": "export const Button = () => { /* new button code */ };"
  }
]
`;
        const fullPrompt = `${instructions}\n\n${jsonInstruction}\n\n---\n\n${prompt}`;
        
        // シェルコマンドで安全に実行するためプロンプトをエスケープ
        const escapedPrompt = JSON.stringify(fullPrompt);
        
        // claude CLIコマンドを構築
        const command = `claude -p --output-format json --model opus ${escapedPrompt}`;

        logger.info('Executing claude command...');
        
        try {
            // コマンド実行(バッファサイズを増やす)
            const { stdout, stderr } = await execAsync(command, { maxBuffer: 1024 * 1024 * 10 }); 
            if (stderr) {
                logger.warn(`claude command stderr: ${stderr}`);
            }

            const parsedOutput = JSON.parse(stdout);
            
            // 出力形式を検証
            if (!Array.isArray(parsedOutput) || (parsedOutput.length > 0 && !parsedOutput.every(item => typeof item.filePath === 'string' && typeof item.content === 'string'))) {
                throw new Error('LLM output did not match the expected CodeModification[] structure.');
            }

            logger.info('claude command executed successfully and output parsed.');
            return parsedOutput as CodeModification[];
        } catch (error: any) {
            logger.error('Failed to execute or parse claude command:', error);
            // エラーの内容をより具体的にラップしてスローする
            throw new Error(`claude command failed: ${error.message || error}`);
        }
    }
}


// =================================================================
// src/utils/StateManager.ts - 状態の永続化
// =================================================================
import fs from 'fs/promises';
import path from 'path';
import { logger } from './logger';

export interface AgentState {
    status: 'idle' | 'working';
    currentTask: {
        issueNodeId: string;
        title: string;
        body: string;
        prUrl: string | null;
        reviewCycleCount: number;
    } | null;
}

const STATE_FILE_PATH = path.join(process.cwd(), 'agent-state.json');

export class StateManager {
    private state: AgentState;

    constructor() {
        this.state = { status: 'idle', currentTask: null };
    }

    async load(): Promise<void> {
        try {
            const data = await fs.readFile(STATE_FILE_PATH, 'utf-8');
            this.state = JSON.parse(data);
            logger.info('エージェントの状態をロードしました。');
        } catch (error) {
            logger.warn('状態ファイルが見つかりません。新しい状態で開始します。');
            await this.save();
        }
    }

    async save(): Promise<void> {
        await fs.writeFile(STATE_FILE_PATH, JSON.stringify(this.state, null, 2));
    }

    getState(): AgentState {
        return this.state;
    }
}


// =================================================================
// src/utils/logger.ts - シンプルなロガー
// =================================================================
export const logger = {
  info: (message: string, ...args: any[]) => {
    console.log(`[INFO] ${new Date().toISOString()} - ${message}`, ...args);
  },
  warn: (message: string, ...args: any[]) => {
    console.warn(`[WARN] ${new Date().toISOString()} - ${message}`, ...args);
  },
  error: (message: string, ...args: any[]) => {
    console.error(`[ERROR] ${new Date().toISOString()} - ${message}`, ...args);
  },
};

// =================================================================
// src/utils/Notifier.ts - 通知用ユーティリティ
// =================================================================
import { IncomingWebhook } from '@slack/webhook';
import { logger } from './logger';

export class Notifier {
    private webhook: IncomingWebhook | null = null;

    constructor() {
        const webhookUrl = process.env.SLACK_WEBHOOK_URL;
        if(webhookUrl) {
            this.webhook = new IncomingWebhook(webhookUrl);
            logger.info('Slack通知が有効になりました。');
        } else {
            logger.warn('SLACK_WEBHOOK_URLが設定されていません。通知はコンソールにのみ出力されます。');
        }
    }

    async notify(message: string): Promise<void> {
        // 常にコンソールにはログを出力
        logger.info(`[NOTIFICATION] ${message}`);
        
        // Slack Webhookが設定されていれば通知を送信
        if (this.webhook) {
            try {
                await this.webhook.send({
                    text: message,
                });
            } catch (error) {
                logger.error('Slackへの通知送信に失敗しました:', error);
            }
        }
    }
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment