VS Code + Cline で ”Devin 風”開発を行う方法⑤ - マルチエージェントループ実行編 #1

前回は、Cline の Kanban 機能を使って、エージェントループを作成するところまで実現できました。しかし実際に動作させてみると色々な問題が出てきました。すべてを解決するにはまだ時間がかかりそうなので、一旦これまでに実現できた機能を公開いたします。

これまでに判明している問題として

  • コードベースが読めない場合がある。
    ソースコードの読み込みに失敗すると、代替手段を発見できずに処理が停止してしまう場合があるようです。
  • 推論が弱いため、かなりの手順をスクリプト化する必要がある
    手順が複雑になると処理が停止してしまうため、できるだけスクリプトで代替する必要があります。
  • 改行コードが異なるため、コードの書き換えに時間がかかる。
    Windows 上で動作させているため、Windows の改行コード (CRLF) と Linux の改行コード (LF) の違いにより、なかなか修正が完了しない。
  • スクリプトが並列的に実行される場合がある。
    スクリプトの実行に時間がかかった場合、再度同じスクリプトが実行されてしまう場合があるので、スクリプトを再入可能または排他制御するように作成する必要があります。
  • 定義ファイル (work.md / review.md など) が読めない場合がある。
    Kanban タスクの実行は Git の worktree 機能を使用するため、実際の作業フォルダと異なる場所のファイルを読みに行ってしまい、ファイルが見つからない場合があるようです。

などの問題が発生しました。

エージェントループの仕組みは LLM のタイプに依存しないので、クラウド版の LLM に変更すれば、かなりの部分が解決すると思われます。ローカル版の LLM でどこまで実現できるのか試してみたいのでこのまま実装を続けます。

現段階ではまだループを回すところまでは実現できていませんが、プランの作成からレビューの完了まで、一つのタスクを通して実行することができましたので、一旦公開させていただきます。

最終的に以下のような構造を目指しています。

現在の進捗状況は以下の通りになっています。

状況項目
Planner (タスクループ生成 & 実行)
Worker
Reviewer
レポート生成
Fix Task 自動生成
Plan 自動修正
完全ループ

今回の記事は主に、トラブルシューティング的な内容が多くなっています。

今回も記事を作成するにあたり、以下の講座で勉強させていただきました。コードを 1 行も書かずに、リアルタイムチャートと AI アシスタンス機能付きの株取引アプリ (トレーディング・ワークステーション) を構築されています。英語の講座ですが、Udemy の「トランスクリプション」という機能を使用すると講義内容がテキスト形式で右側に表示されるので、Chrome などの「翻訳機能」を使用すると日本語で表示できます。Microsoft Edge ブラウザーを使用すればビデオ内の字幕を直接日本語に変換できるようです。

Udemy

AI Coder: Complete Claude Code & Coding Agents Course

この記事で使用している実行環境は以下のようになっています。

  • VS Code (Windows 版)・・・バージョン 1.125.1
  • Node.js (Windows 版)・・・バージョン 24.15.0
  • Cline・・・バージョン 3.0.29
  • Kanban・・・バージョン 0.1.68

※本記事でご紹介している内容は、確立された手法ではなく著者の独自の解釈に基づいています。その点をご理解のうえご参照ください。

本記事で使用しているサンプルコードは記事末尾からダウンロードできます。

動作環境について

前回までは、ローカル LLM として「gpt-oss:20b」というモデルを使用していましたが、処理が複雑化するにつれて対応が難しくなってきました。そこで今回からは推論速度が多少遅くなりますが、よりパラメーター数が多い「qwen3.6:27b」というモデルに変更しました。16GB の VRAM を搭載した GPU ボードをお持ちであればかなり実用的な速度で実行可能です。

Ollama で使用する場合は、Windows や VS Code のターミナル画面で以下のように入力するとモデルがダウンロードされます。

ollama pull qwen3.6:27b
Planner の解説

Planner Task はエージェントループを作成します。この機能はほとんどがスクリプトで実行されます。

Kanban Agent タブのプロンプト入力欄に「/start <ブランチ名>」というコマンドを入力するとエージェントループが作成されて自動的に実行が開始します。このコマンドは「AGENTS.md」ファイルに以下のように定義されています。

### /start <branch>
1. Kanban ループを作成する。

実行形式:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File ./.kanban/scripts/loop-builder.ps1 -baseRef <branch>
```
2. ループが作成できたら推論を止めて exit 0 で終了する。

Planner Task は以下の責務を受け持ちます。

  • ループの状態を管理する「status.json」ファイルの作成と更新を行います。
  • 「tasks.md」ファイルから未完了のタスクを選択して、「plan.md」ファイルを作成します。
  • plan.md ファイルの内容を元に実装プラン (work.md) ファイルを作成します。
  • plan.md ファイルの内容を元にレビュープラン (review.md) ファイルを作成します。
  • 「Worker Task」、「Reviewer Task」を作成して、連続して実行できるようにリンクを作成します。
  • 「Worker Task」を実行します。

これらの処理は「.kanban\scripts\loop-builder.ps1」スクリプトファイルに記述されています。

また前回実装したブートストラップ用のタスクは動作が安定しないため、今回は直接ループを作成するスクリプトを呼ぶように変更しました。

ループ作成用のスクリプトは以下のようになっています。

loop-builder.ps1 クリックすると展開します。
#
# Kanban 開発ループを構築します。
#
using module .\kanban\status\status-manager.psm1

param(
    [string]$baseRef

)

$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$ProjectRoot = Resolve-Path (Join-Path $ScriptDir "..\..")

Import-Module (Join-Path $PSScriptRoot "kanban\status\status-manager.psm1")
Import-Module (Join-Path $PSScriptRoot "common\logger.psm1")
Import-Module (Join-Path $PSScriptRoot "common\json.psm1")
Import-Module (Join-Path $PSScriptRoot "common\git.psm1")
Import-Module (Join-Path $PSScriptRoot "common\task.psm1")
Import-Module (Join-Path $PSScriptRoot "common\lock.psm1")

$CreateBranch = Join-Path $PSScriptRoot "kanban\git\create-branch.ps1"
$PopStash = Join-Path $PSScriptRoot "kanban\git\pop-stash.ps1"
$CreatePlan = Join-Path $PSScriptRoot "kanban\task\create-plan.ps1"
$CreateWork = Join-Path $PSScriptRoot "kanban\task\create-work.ps1"
$CreateReview = Join-Path $PSScriptRoot "kanban\task\create-review.ps1"
$Detach = Join-Path $PSScriptRoot "kanban\git\detach.ps1"
$GetTaskId = Join-Path $PSScriptRoot "kanban\task\get-task-id.ps1"
$StartKanbanTask = Join-Path $PSScriptRoot "kanban\task\start-kanban-task.ps1"

Write-Header
$Command = $MyInvocation.MyCommand.Name
$Arguments = "-baseRef $baseRef"
Write-Log -m "Command: $Command $Arguments"

if ([string]::IsNullOrEmpty($baseRef)) {
    Write-Log -m "ブランチ名がありません。'loop-builder.ps1 -baseRef refactor/login' という形式で実行してください。" -t "ERROR"
    exit 1
}

Push-Location $ProjectRoot

try {
    # 排他制御用にロックファイル作成
    $lockFile = "loop-builder.lock"
    $lockResult = Lock -f $lockFile -i 1

    if (-not $lockResult) {
        Write-Log -m "loop-builder is already running. Skip." -t "ERROR"
        exit 1
    }

    # ブランチ切り替え
    $currentBranch = Get-CurrentBranch -i 1

    & $CreateBranch -b $baseRef -c $true -i 1

    # 現在のループの状態をチェック
    $result = Get-LoopStatus -i 1

    if ($result.Status -and $result.Status -ne [LoopStatus]::IDLE) {
        exit $result.ExitCode
    }

    Set-LoopStatus -n LOOP_BUILDING -i 1

    # タスクを取得
    $taskId = & $GetTaskId -t "memory-bank\kanban\tasks.md" -i 1

    if ([string]::IsNullOrEmpty($taskId)) {
        Write-Log -m "実行できるタスクがありません。" -i $IndentLevel
        exit 0
    }

    # status.json ファイル作成
    Set-Status -p "baseRef" -v $baseRef -i 1
    Set-Status -p "currentTask" -v $taskId -i 1

    # plan.md ファイル作成
    & $CreatePlan -t $taskId -i 1

    # work.md ファイル作成
    & $CreateWork -t $taskId -i 1

    # review.md ファイル作成
    & $CreateReview -t $taskId -i 1

    # Worker タスク作成
    $WorkerTaskId = New-KanbanTask `
        -title "Worker Task" `
        -prompt ".clinerules\AGENTS.md を読む。/workflow worker を実行する。" `
        -baseref $baseRef `
        -autoreview $true `
        -i 1

    # Update-KanbanTask -t $WorkerTaskId -i 1 --cline-model "qwen3.6:27b"

    Set-Status -p "workerTaskId" -v $WorkerTaskId -i 1

    # reviewer タスク作成
    $ReviewerTaskId = New-KanbanTask `
        -title "Reviewer Task" `
        -prompt ".clinerules\AGENTS.md を読む。/workflow reviewer を実行する。" `
        -baseref $baseRef `
        -autoreview $true `
        -i 1

    Set-Status -p "reviewerTaskId" -v $ReviewerTaskId -i 1
    
    # リンクを作成
    New-KanbanLinkJson `
        -SourceTaskId $WorkerTaskId `
        -TargetTaskId $ReviewerTaskId `
        -i 1

    Set-LoopStatus LOOP_STARTING -i 1

    # 変更をコミット
    Invoke-Commit -m "chore: update status.json" -i 1

    # タスクステータス更新
    Set-TaskStatus -t $taskId -s "PROCESSING" -i 1

    # 現在のブランチをデタッチ
    & $Detach -i 1

    # ブランチ復元
    Switch-Branch $currentBranch -i 1

    Start-Sleep -Seconds 1
    
    # タスク実行
    $StartText = & $StartKanbanTask -t $WorkerTaskId -i 1
    
    Write-Output $StartText

    Write-Log -m "LOOP_BUILDER_DONE"

    exit 0
}
catch {
    Write-Log -m $_.Exception.Message -i 1 -t "ERROR"
    exit 1
}
finally {
    & $PopStash -i 1
    # ロック解除
    UnLock -f $lockFile -i 1
    Write-Footer
    Pop-Location
}
Worker の解説

Worker Task は以下の責務を受け持ちます。

  • 実装プラン (work.md) ファイルを読み込んでコードを修正します。
  • 実装が完了したら Lint (文法) チェックを行い、エラーがある場合は再度実装を行います。
  • 実装が完了してエラーチェックも終了したら、実装結果のレポートファイル (worker-result.md) を作成します。
  • すべての実装が完了したら、Kanban のリンク機能により「Reviewer Task」を起動します。

これらの処理は「worker-workflow.md」ファイルに記述されています。

ワーカータスク用の md ファイルは以下のようになっています。

worker-workflow.md クリックすると展開します。
# Worker ワークフロー

**重要** 各手順に従い順番に実行してください。

あなたは Worker Agent です。

以下のルールを必ず読んでください。

- `./.clinerules/kanban/00-common.md`
- `./.clinerules/kanban/20-worker.md`

## 実行手順

### 1. 前準備

- `.\.kanban\scripts\kanban\task\initialize-task.ps1` スクリプトを実行してください。

実行例:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File .\.kanban\scripts\kanban\task\initialize-task.ps1 WORKER
```
- `INITIALIZE_TASK_DONE` が出力されるまで次に進まないこと。


### 2. コード修正

**重要** ファイルの読み込みは `/read-file <relative_filepath>` を優先的に使用する。
**重要** ファイルの修正は `/replace-file <relative_filepath> <old_string> <new_string>` を優先的に使用する。
**重要** スクリプトコマンドの実行が失敗したら代替手段を試みる。

以下のファイルを読み込んでコード修正を行ってください。

- `./memory-bank/kanban/plan.md`
- `./memory-bank/kanban/work.md`

コード修正は説明だけで終わらせず、必ず実ファイルを変更してください。

### 3. 修正チェック

コードが修正できたら以下のスクリプトで必ずチェックしてください。

- `./.kanban/scripts/kanban/task/worker-check.ps1`

実行例:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File .\.kanban\scripts\kanban\task\worker-check.ps1
```

- `WORKER_COMPLETED` が出力されなかったり、エラーが発生した場合は再度「1. コード修正」を行ってください。

### 4. 変更をコミット

コード修正が完了したら、以下のスクリプトを実行して変更を正しいブランチにコミットしてください。

```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File .\.kanban\scripts\kanban\task\auto-commit.ps1
```

- `AUTO_COMMIT_DONE` が出力されるまで次に進まないこと。
- このスクリプトはデタッチされた worktree の変更を正しいブランチにコミットします。
- コミット後は再度 `read-worktree-file.ps1` 経由でファイルを読み込む必要はありません。コード修正は完了しています。


### 5. 終了処理

- WORKER_COMPLETED が出力されたら、以下のスクリプトを実行します。
- `.\.kanban\scripts\kanban\task\finalize-task.ps1 WORKER` を実行する。

実行例:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File .\.kanban\scripts\kanban\task\finalize-task.ps1 WORKER
```
- FINALIZE_TASK_DONE が出力されたらタスクを `done` レーンに移動します。
Reviewer の解説

Reviewer Task は以下の責務を受け持ちます。

  • レビュープラン (review.md) ファイルや関連ファイルを読み込んでレビューを実施します。
  • レビューが OK の場合はコードのフォーマットと Lint を実行したあと、レビュー結果のレポートを作成します。
  • レビュー結果が NG の場合は、「plan.md」ファイルを修正して再度「Planner Task」を作成して実行します。(今回はまだ実装されていません。)
  • 次のタスクがある場合は、新しいエージェントループを作成します。(今回はまだ実装されていません。)

これらの処理は「reviewer-workflow.md」ファイルに記述されています。

reviewer-workflow.md クリックすると展開します。
# Reviewer ワークフロー

あなたは Reviewer Agent です。

以下のルールを必ず読んでください。

- `./.clinerules\kanban\00-common.md`
- `./.clinerules\kanban\30-reviewer.md`

**重要** 手順に従いすべて自動的に実行してください。

## 1. 前準備

- `.\.kanban\scripts\kanban\task\initialize-task.ps1` スクリプトを実行してください。

実行例:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File .\.kanban\scripts\kanban\task\initialize-task.ps1 REVIEWER
```
- `INITIALIZE_TASK_DONE` が出力されるまで次に進まないこと。


## 2. レビューの実施

### レビュー前処理

以下のファイルを必ず読むこと。

1. `memory-bank\kanban\plan.md`
2. `memory-bank\kanban\review.md`
3. `memory-bank\kanban\worker-result.md`

### 差分確認

以下の方法で変更内容を確認すること。

```powershell
git diff HEAD~1 HEAD
```

または

```powershell
git show --stat
```

実際に変更されたファイルを確認すること。

### レビュー手順

#### 1. Plan確認

plan.md の内容を確認する。

以下を確認すること。

* 実装内容が plan.md と一致しているか
* 完了条件を満たしているか
* 禁止事項に違反していないか

#### 2. WorkerResult確認

worker-result.md の内容を確認する。

以下を確認すること。

* ChangedFiles が実際の変更内容と一致しているか
* ImplementationSummary が差分と一致しているか
* ValidationResults が妥当か
* KnownIssues が適切に記録されているか

#### 3. ReviewCheckList確認

review.md の内容を確認する。

ReviewCheckList を満たしているか確認すること。

#### 4. 差分レビュー

実際の変更差分を確認する。

以下を確認すること。

* 不要な変更が含まれていないか
* 関係ないファイルを変更していないか
* plan.md の範囲を超えた変更がないか
* syntax error の原因となる変更がないか

### NG の場合

以下を実施すること。

1. 問題内容を整理する。
2. review.md にレビュー結果を追記する。
3. tasks.md の現在タスクを FAILED に変更する。

実行形式:

```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File .\.kanban\scripts\kanban\status\update-task-status.ps1 FAILED
```

最後に以下を出力すること。

```text
REVIEW_REJECTED
```

### OK の場合

以下を実施すること。

1. review.md にレビュー結果を追記する。
2. `memory-bank\kanban\worker-result.md` の `ChangedFiles` に記載されている PHP ファイルを確認する。

例:
```md
### ChangedFiles (変更したファイル)

- app/src/adapter/Controllers/UserController.php
```
3. ChangedFiles に記載されている各 PHP ファイルに対して format-code.ps1 を実行する。
実行形式:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File .\.kanban\scripts\kanban\lint\format-code.ps1 <対象ファイル>
```

例:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File .\.kanban\scripts\kanban\lint\format-code.ps1 app\src\adapter\Controllers\UserController.php
```
4. format-code.ps1 の実行後、同じファイルに対して php-lint.ps1 を実行する。
実行形式:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File .\.kanban\scripts\kanban\lint\php-lint.ps1 <対象ファイル>
```

例:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File .\.kanban\scripts\kanban\lint\php-lint.ps1 app\src\adapter\Controllers\UserController.php
```
5. format-code.ps1 または php-lint.ps1 が失敗した場合は、DONE にしてはいけない。
問題内容を review.md に追記し、現在タスクを FAILED に変更する。

実行形式:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File .\.kanban\scripts\kanban\status\update-task-status.ps1 FAILED
```
6. すべての確認が成功した場合のみ、tasks.md の現在タスクを DONE に変更する。
実行形式:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File .\.kanban\scripts\kanban\status\update-task-status.ps1 DONE
```

最後に以下を出力すること。

```text
REVIEW_APPROVED
```

### レビュー結果記録

review.md に以下を追記すること。

#### ReviewResult

* Approved または Rejected
* 確認した差分
* 指摘事項
* 修正が必要な内容
* Reviewer コメント

既存の review.md の内容を削除してはいけない。
追記のみ行うこと。


## 3. 終了処理

- REVIEW_APPROVED が出力されたら、以下のスクリプトを実行して終了します。

- `.\.kanban\scripts\kanban\task\finalize-task.ps1 WORKER` を実行する。

実行例:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File .\.kanban\scripts\kanban\task\finalize-task.ps1 REVIEWER
```

- FINALIZE_TASK_DONE が出力されたらタスクを `done` レーンに移動します。
コードベースが読めない場合の対応

コードベース (ソースファイル) の読み込みで以下のようなエラーが発生して止まる場合がありました。

search_codebase
[object Object]
Error
{"error":"Tool call search_codebase was rejected before 
execution: Invalid input for tool 
search_codebase: Type 
validation failed: Value: {\"queries\":[{\"filePaths\":null,\"query\":\"\\\\.\\\\blogin\\\\s*\\\\(\"}]}.\nError 
message: [\n  {\n    \"expected\": \"string\",\n    \"code\": \"invalid_type\",\n    \"path\": [\n      \"queries\",\n      0\n    ],\n    \"message\": \"Invalid input: expected string, received object\"\n  }\n]"
}

改行文字が多くて読みにくいですが、以下のようなことを言っています。「queries[0] には 文字列 が必要なのに、実際には オブジェクトが渡されている。」

Invalid input: expected string, received object
path: ["queries", 0]

表示内容を見ると、現在は以下のような内容が渡されているようです。

{
  "queries": [
    {
      "filePaths": null,
      "query": "\\.\\blogin\\s*\\("
    }
  ]
}

しかし期待されているのは、おそらく以下のような形式と思われます。

{
  "queries": [
    "\\.\\blogin\\s*\\("
  ]
}

また、検索ワードも少し怪しいです。PowerShell での呼び出しを考慮しているのか変なエスケープがされています。「login(」を検索するのであれば以下の形式になっていれば十分です。

{
  "queries": [
    "login("
  ]
}

このエラーを修正するために「.clinerules\AGENTS.md」ファイルに以下のルールを追加しました。

## search_codebase - MUST FOLLOW

search_codebase は queries にオブジェクト配列ではなく、文字列配列を渡す。
正: { "queries": ["login("] }
誤: { "queries": [{ "query": "login(", "filePaths": null }] }
推論を補うためのスクリプトの追加

本末転倒ですが、コードを書かずに実装を行うループを作成するために相当な数のスクリプトが必要になりました。ローカル LLM にはできるだけ本業のコード修正などに集中できる環境を整えます。各ファイルのご説明は割愛いたしますが、現在のファイルは以下のようになっています。

クリックすると展開します。
.kanban/
├── locks/                               # ロックファイル格納ディレクトリ(並列実行防止用)
├── logs/
│  ├── invoke-git.log                    # git 操作の実行ログ
│  ├── kanban-task.log                   # Kanban タスク操作のログ
│  ├── kanban.log                        # Kanban フレームワーク全体の実行ログ
│  └── kanban.py.log                     # Kanban フレームワークの実行ログ (Python)
├── python/
│  ├── __init__.py                       # Python パッケージ定義ファイル
│  ├── append-file.py                    # ファイル末尾に文字列を追加するユーティリティ
│  ├── logger.py                         # ロギング機能を提供するモジュール
│  ├── replace_text.py                   # ファイル内の文字列を置換するユーティリティ(/replace-file 用)
│  ├── stdin_replace_text.py             # stdin から入力を受け取る文字列置換ユーティリティ
│  └── test-data/                        # Python スクリプトのテストデータ格納ディレクトリ
├── scripts/
│  ├── loop-builder.ps1                  # Kanban ループを構築するエントリースクリプト(/start コマンド用)
│  ├── start-kanban.ps1                  # Kanban ワークフローを開始するスクリプト
│  ├── common/                           # 共通モジュール
│  │  ├── encode-utf8.ps1                # UTF-8 エンコード変換ユーティリティ
│  │  ├── git.psm1                       # git 操作の共通関数ライブラリ
│  │  ├── invoke-git.psm1                # git コマンド実行ラッパーモジュール
│  │  ├── invoke-task.psm1               # タスク実行 Invoke モジュール
│  │  ├── json.psm1                      # JSON 解析・操作ユーティリティモジュール
│  │  ├── lock.psm1                      # ファイルロック機構モジュール
│  │  ├── logger.psm1                    # ロガー共通モジュール
│  │  └── task.psm1                      # タスク管理共通モジュール
│  ├── kanban/                           # Kanban 関連スクリプト
│  │  ├── git/                           # git 操作系スクリプト
│  │  │  ├── commit-all.ps1              # 変更ファイルをすべてコミットする(/commit コマンド用)
│  │  │  ├── create-branch.ps1           # ブランチを作成する
│  │  │  ├── detach.ps1                  # worktree の detached 状態を操作する
│  │  │  ├── has-changed.ps1             # ファイルが変更されているか確認する
│  │  │  ├── is-branch-exist.ps1         # ブランチの存在確認
│  │  │  ├── merge-to-branch.ps1         # 指定ブランチにマージする
│  │  │  ├── pop-stash.ps1               # stash を適用する
│  │  │  ├── stash.ps1                   # 変更を stash する
│  │  │  └── switch-current-branch.ps1   # 現在のブランチを切り替える
│  │  ├── lint/                          # リント・フォーマット系スクリプト
│  │  │  ├── format-code.ps1             # コードフォーマット実行スクリプト
│  │  │  └── php-lint.ps1                # PHP リントチェック実行スクリプト
│  │  ├── status/                        # タスクステータス管理系スクリプト
│  │  │  ├── status-manager.psm1         # ステータス管理モジュール
│  │  │  └── update-task-status.ps1      # タスクステータスを更新する
│  │  ├── task/                          # Kanban タスク操作系スクリプト
│  │  │  ├── auto-commit.ps1             # 自動コミット処理
│  │  │  ├── create-plan.ps1             # プランテンプレートを生成する
│  │  │  ├── create-review.ps1           # レビューテンプレートを生成する
│  │  │  ├── create-work.ps1             # ワークテンプレートを生成する
│  │  │  ├── finalize-task.ps1           # タスクを最終確定する
│  │  │  ├── get-task-id.ps1             # 現在のタスク ID を取得する
│  │  │  ├── initialize-task.ps1         # タスクを初期化する
│  │  │  ├── kanban-task.ps1             # Kanban CLI の PowerShell ラッパー(/read-file, /replace-file 等他のコマンドが経由)
│  │  │  ├── reviewer-check.ps1          # Reviewer のチェック用スクリプト
│  │  │  ├── start-kanban-task.ps1       # Kanban タスクの開始処理
│  │  │  ├── worker-check.ps1            # Worker のチェック用スクリプト(/workflow worker 用)
│  │  │  └── worker-workflow.ps1         # Worker ワークフロー実行スクリプト
│  │  └── util/                          # ユーティリティ系スクリプト
│  │     ├── read-worktree-file.ps1      # worktree のファイルを読み取る(/read-file コマンド用)
│  │     └── write-worktree-file.ps1     # worktree のファイルに書き込む
│  └── tests/                            # テスト系スクリプト
│     ├── coverage-tests.ps1             # カバレッジテスト実行スクリプト
│     ├── tests.ps1                      # テストスイート実行スクリプト
│     └── kanban/                        # Kanban 関連テスト用サブディレクトリ
│        └── status/                     # ステータス関連テスト用ディレクトリ
│           └── status-manager.tests.ps1 # ステータス関連テストファイル
└── templates/                           # テンプレートファイル格納ディレクトリ
   ├── plan-template.md                  # プランドキュメントのテンプレート
   ├── review-template.md                # レビュードキュメントのテンプレート
   ├── work-template.md                  # ワークドキュメントのテンプレート
   └── worker-result-template.md         # Worker 結果ドキュメントのテンプレート

ファイル数は多いですが、ローカル LLM に接続した VS Code 上の Cline を使用してかなりの部分を自動で作成してもらいました。

各ディレクトリの役割は以下のようになっています。

ディレクトリ役割
locks/ 並列実行防止用のロックファイル格納先
logs/git 操作・タスク操作の実行ログ出力先
python/ファイル操作(置換・追加)用の Python スクリプト群
scripts/common/PowerShell 共通モジュール(git ラッパー・JSON 処理・ロック・ロガーなど)
scripts/kanban/git/git 操作関連のスクリプト集
scripts/kanban/lint/コード品質チェック用スクリプト
scripts/kanban/status/タスクステータス管理スクリプト
scripts/kanban/task/Kanban タスクのライフサイクル管理(生成・初期化・確定など)
scripts/kanban/util/worktree 上のファイル読み書き用ユーティリティ
scripts/tests/スクリプトのテスト用スクリプト
templates/plan/review/work などのドキュメントテンプレート
改行コードの違いについて

Kanban のタスクが使用している改行コード (Linux系 : LF) と実際の Windows ファイルの改行コード (CRLF) が異なるために、実際は書き換えに成功しているのに何度も書き換えを行う場合があるようです。

最終的に書き換えに成功する場合もありますが、この作業を効率化するためにスクリプトに移譲します。

当初は他のスクリプトと同様に、以下のような Json 構造を定義して AI エージェントから PowerShell のスクリプトを呼び出せるようにしましたが、PowerShell では引数の渡し方で問題が起きるようです。

{
  "command": "powershell",
  "args": [
    "-NoProfile",
    "-ExecutionPolicy",
    "Bypass",
    "-File",
    ".kanban\scripts\replace.ps1",
    "--targetFile",
    "app/src/adapter/Controllers/UserController.php",
    "--oldString",
    "$this->login($output);",
    "--newString",
    "$this->classic_login($output->username());"
  ]
}

PowerShell の場合は引数内にある「$」マークで始まる文字列が変数として評価されて、以下のように変換された文字列がスクリプトへ渡されるようです。

"$this->login($output);", # という文字列が
↓
"->login();",             # のように変換される

PowerShell の場合はバッククォート「`」でエスケープ (無効化) する事ができますが、「&」や「(」などもエスケープする必要があり、書き換え用の文字列の渡し方が非常に複雑になってしまいます。この操作はそのような制限を受けない Python のスクリプトで実装します。

Python スクリプトの呼び出しは PowerShell と同様に以下のような Json 構造を AI エージェントに教えると呼び出してくれます。(スクリプトを実行するには こちら の記事でご紹介しているような Python の実行環境がインストールされている必要があります。)

{
  "command": "python",
  "args": [
    ".\.kanban\python\replace_text.py",
    "--file",
    "app/src/adapter/Controllers/UserController.php",
    "--old",
    "$this->login($output);",
    "--new",
    "$this->classic_login($output->username());"
  ]
}

呼び出しの問題は解決しましたが、実際に動作させると以下のように一回で複数行にわたる置換処理が行われる場合もあるようです。

====================
Time: 2026-06-19 18:33:33
Command: replace_text.py --file app/src/adapter/Controllers/UserController.php --old     /**
     * UserRegisterOutput を元に簡易ログインを行う
     */
    private function login(UserRegisterOutput $data): void
    {
        // 既存 UserSchema を再構築(最低限の項目だけセット)
        $schema = new UserSchema;
        $schema->username = $data->username(); --new     /**
     * username を元に Classic MVC の Session にログインする
     */
    private function classic_login(string $username): void
    {
        // 既存 UserSchema を再構築(最低限の項目だけセット)
        $schema = new UserSchema;
        $schema->username = $username;
--------------------

複数行の置き換えの場合は AI エージェントの改行コード (LF) と実際のファイルの改行コード (CRLF) の違いにより、置き換えたい文字列の検索が失敗します。

そこで少々トリッキーですが、AI エージェントから受信した各文字列と置き換えを行うためにメモリーに読み込んだファイル内容の改行文字を LF に統一してから置換処理を行います。その後メモリー内の改行コードを CRLF に変換してファイルに書き戻します。

念のために AI エージェントには文字列の比較は改行コードを含めないように指示しておきます。

### /replace-file <relative_filepath> <old_string> <new_string>
指定されたファイルの該当部分を書き換える。

実行形式:
```python
{
  "command": "python",
  "args": [
    ".\.kanban\python\replace_text.py",
    "--file",
    "<relative_filepath>",
    "--old",
    "<old_string>",
    "--new",
    "<new_string>"
  ]
}
```
以下の制限があります。
- 改行は \n を使用する。
- \n でも CR と LF が追加されるため、文字列の比較は改行を含んではいけない。
- スクリプトパスの区切り記号を2重にしてはいけない。
誤:
`.\\.kanban\\python\\replace_text.py`
正:
`.\.kanban\python\replace_text.py`

以上を踏まえて Python スクリプトを作成します。(スクリプトの作成も VS Code + Cline + ローカル LLM (qwen3.6:27b) で行いました。(少し手直ししています。))

replace_text.py クリックすると展開します。
import argparse
import subprocess
import sys
from pathlib import Path

from logger import write_header, write_footer, write_command_log, write_log


def get_worktree_root() -> Path:
    result = subprocess.run(
        ["git", "rev-parse", "--show-toplevel"],
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        text=True,
        encoding="utf-8",
        errors="replace",
    )
    if result.returncode != 0:
        raise RuntimeError("Git worktree が見つかりません。")
    return Path(result.stdout.strip()).resolve()


def normalize_relative_path(file_path: str) -> Path:
    path = Path(file_path.replace("\\", "/").lstrip("./"))
    if path.is_absolute():
        raise ValueError("絶対パスは使用できません。")
    if ".." in path.parts:
        raise ValueError(".. を含むパスは使用できません。")
    return path


def read_file(path: Path) -> tuple[str, str]:
    raw = path.read_bytes()
    if raw.startswith(b"\xef\xbb\xbf"):
        return raw.decode("utf-8-sig"), "utf-8-sig"
    try:
        return raw.decode("utf-8"), "utf-8"
    except UnicodeDecodeError:
        return raw.decode("cp932"), "cp932"


def write_file(path: Path, content: str, encoding: str) -> None:
    path.write_text(content, encoding=encoding, newline="")


def to_lf(text: str) -> str:
    return text.replace("\r\n", "\n").replace("\r", "\n")


def to_crlf(text: str) -> str:
    return to_lf(text).replace("\n", "\r\n")


def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument("--file", required=True, help="置換対象ファイル")
    parser.add_argument("--old", required=True, help="置換前文字列")
    parser.add_argument("--new", required=True, help="置換後文字列")
    parser.add_argument("--count", type=int, default=-1, help="置換回数(既定: 全置換)")
    parser.add_argument("--allow-no-change", action="store_true", default=True)
    args = parser.parse_args()

    write_header()
    write_command_log()

    try:
        target = (get_worktree_root() / normalize_relative_path(args.file)).resolve()

        if not str(target).startswith(str(get_worktree_root())):
            raise ValueError("worktree 外のファイルは変更できません。")
        if not target.exists():
            raise FileNotFoundError(f"ファイルが見つかりません: {target}")

        content, encoding = read_file(target)
        content_lf, old_lf, new_lf = to_lf(content), to_lf(args.old), to_lf(args.new)

        count = content_lf.count(old_lf)
        if count == 0:
            msg = f"REPLACE_TEXT_NOT_FOUND: {args.old}"
            print(msg)
            write_log(msg)
            if not args.allow_no_change:
                raise RuntimeError(msg)
            print("REPLACE_TEXT_DONE")
            write_log("REPLACE_TEXT_DONE")
            return 0

        new_lf = content_lf.replace(old_lf, new_lf, args.count if args.count > 0 else count)
        write_file(target, to_crlf(new_lf), encoding)

        print(f"REPLACE_TEXT_DONE: {args.file}")
        print(f"REPLACE_COUNT: {count}")
        write_log(f"REPLACE_TEXT_DONE: {args.file}")
        write_log(f"REPLACE_COUNT: {count}")
        return 0

    except Exception as e:
        msg = f"ERROR: {e}"
        print(msg, file=sys.stderr)
        write_log(msg, log_type="ERROR")
        return 1

    finally:
        write_footer()


if __name__ == "__main__":
    sys.exit(main())
排他制御について

ループ作成用のスクリプトなどで処理時間がかかると、動作がハングアップしたとみなされて再度同じスクリプトが実行される場合があります。それ以外にもファイル読み込みなどの処理はシーケンシャルに実行されずに、マルチタスク的にほぼ同時に実行される場合もあるようです。

特にタスクループを作成するスクリプトが何度も呼ばれてしまうと、タスクループがいくつも作成されてしまうので排他制御を行う必要があります。

今回はロックファイルという仕組みを利用して排他制御を行いました。ロックファイルとは、異なるプロセスが同じ名称のファイルを作成した場合、後から作成を行ったプロセスの方では例外が発生するという仕組みを利用した排他制御になります。

以下のように使用すると、後から実行されたスクリプトは処理に入る前に終了します。

try {
    # 排他制御用にロックファイル作成
    $lockFile = "loop-builder.lock"
    $lockResult = Lock -f $lockFile -i 1

    if (-not $lockResult) {
        Write-Log -m "loop-builder is already running. Skip." -t "ERROR"
        exit 1
    }

    # ループ作成などの処理

    exit 0
}
catch {
    Write-Error $_.Exception.Message
    exit 1
}
finally {
    # ロック解除
    UnLock -f $lockFile -i 1
}

ロックファイルのスクリプトは以下のようになっています。

lock.psm1 クリックすると展開します。
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$ProjectRoot = Resolve-Path (Join-Path $ScriptDir "..\..\..")

Import-Module (Join-Path $PSScriptRoot "..\common\logger.psm1")

function Lock {
    param(
        [string]$filePath,
        [int]$IndentLevel = 0
    )

    Push-Location $ProjectRoot

    Write-Header -i $IndentLevel
    $Command = $MyInvocation.MyCommand.Name
    $Arguments = "-filePath $filePath"
    Write-Log -m "Command: $Command $Arguments" -i $IndentLevel

    if ([string]::IsNullOrEmpty($filePath)) {
        Write-Log -m "Filename is required." -i $IndentLevel -t "ERROR"
        return $false
    }

    try {
        $LockDir = Join-Path $ProjectRoot ".kanban\locks"
        $LockPath = Join-Path $LockDir $filePath

        # ロックファイルを作成して排他制御します
        if (-not (Test-Path $LockDir)) {
            New-Item -ItemType Directory -Path $LockDir -Force | Out-Null
        }

        New-Item -Path $LockPath -ItemType File -ErrorAction Stop

        return $true
    }
    catch {
        Write-Log -m "File '$filePath' is already locked." -i $IndentLevel -t "ERROR"
        return $false
    }
    finally {
        Write-Footer -i $IndentLevel
        Pop-Location
    }
}

function UnLock {
    param(
        [string]$filePath,
        [int]$IndentLevel = 0
    )

    Push-Location $ProjectRoot

    Write-Header -i $IndentLevel
    $Command = $MyInvocation.MyCommand.Name
    $Arguments = "-filePath $filePath"
    Write-Log -m "Command: $Command $Arguments" -i $IndentLevel

    if ([string]::IsNullOrEmpty($filePath)) {
        Write-Log -m "Filename is required." -i $IndentLevel -t "ERROR"
        return $false
    }

    try {
        $LockDir = Join-Path $ProjectRoot ".kanban\locks"
        $LockPath = Join-Path $LockDir $filePath

        if (Test-Path $LockPath) {
            Remove-Item $LockPath -Force
            Write-Log -m "Unlock '$filePath'" -i $IndentLevel
        }
        else {
            Write-Log -m "'$filePath' not found." -i $IndentLevel
        }
        return $true
    }
    catch {
        Write-Log -m $_.Exception.Message -i $IndentLevel -t "ERROR"
        return $false
    }
    finally {
        Write-Footer -i $IndentLevel
        Pop-Location
    }
}

Export-ModuleMember -Function Lock, UnLock

この方式の欠点として、ロックファイルを削除する前にスクリプトが終了してしまうとずっとロックされた状態になってしまいます。

このような問題が発生する場合は、「セマフォ」や「ミューテックス」などの OS レベルの排他制御を検討する必要があるかもしれません。

定義ファイルが読めない場合への対応

Kanban のタスクは Git の worktree という仕組みを利用しているので、作業用のフォルダ (例では worktree-A / worktree-B) は以下の場所に作成されます。

PC
├─ YelpCampCleanArch/
.  └─ .git/ # リポジトリ本体
.
%USERPROFILE%
   └─ .cline/
      └─ worktrees/
         ├─ worktree-A/
         │  └─ YelpCampCleanArch/
         │     └─ .git  # リポジトリ本体の場所が書かれたテキストファイル
         └─ worktree-B/
            └─ YelpCampCleanArch/
               └─ .git  # リポジトリ本体の場所が書かれたテキストファイル

そのためステータス情報 (status.json) ファイルなどに以下のような項目が保存されていた場合に、気を利かせて本来の作業位置 (worktree-A など) ではない場所のファイルを読みに行ってしまうことがあります。

"workspacePath": "C:\MAMP\htdocs\cline\YelpCampCleanArch"

誤った位置からの読み込みを防ぐため、以下のようなスクリプトを追加します。

色々記述してありますが、20 行目の「Invoke-Git "rev-parse" "--show-toplevel"」という部分で「現在の worktree のパス」を取得しています。後はこのパスと引数のファイル名から正しいファイルを読み込んで内容を返しています。

param(
    [string]$FilePath,
    [int]$IndentLevel = 0
)

$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$ProjectRoot = Resolve-Path (Join-Path $ScriptDir "..\..\..\..")

Import-Module (Join-Path $PSScriptRoot "..\..\common\Invoke-Git.psm1")
Import-Module (Join-Path $PSScriptRoot "..\..\common\logger.psm1")

Write-Header -i $IndentLevel
$Command = $MyInvocation.MyCommand.Name
$Arguments = "-FilePath $FilePath -IndentLevel $IndentLevel"
Write-Log -m "Command: $Command $Arguments" -i $IndentLevel

Push-Location $ProjectRoot

try {
    $worktreePath = (Invoke-Git "rev-parse" "--show-toplevel").Trim()

    if ([string]::IsNullOrWhiteSpace($worktreePath)) {
        Write-Log "WORKTREE_NOT_FOUND: $worktreePath" -i $IndentLevel -t "ERROR"
        exit 1
    }

    $normalizedFilePath = $FilePath -replace '/', '\'
    $normalizedFilePath = $normalizedFilePath -replace '^\.\\', ''

    $targetPath = Join-Path $worktreePath $normalizedFilePath

    if (-not (Test-Path $targetPath)) {
        Write-Log "FILE_NOT_FOUND: $targetPath" -i $IndentLevel
        exit 1
    }

    $content = Get-Content -Path $targetPath -Raw -Encoding UTF8

    Write-Log -m "READ_WORKTREE_FILE_DONE" -i $IndentLevel

    Write-Output $content
    
    exit 0
}
finally {
    Pop-Location
}

AI エージェントには以下のように指示しています。

### /read-file <relative_filepath>
指定されたファイル内容を取得する。

実行形式:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File ./.kanban/scripts/kanban/util/read-worktree-file.ps1 -FilePath <relative_filepath>
```
その他

その他、PowerShell スクリプト開発に役立ちそうな機能をご紹介します。

・PowerShell スクリプトのテスト

スクリプトファイルを修正したあと手動でデバッグ実行してテストを行っていましたが、数が増えてくると影響範囲も広がりテストが大変になってきましたので、PowerShell のテストモジュールを導入しました。

VS Code のターミナル画面などから、以下のコマンドを実行すると「Pester」と呼ばれる PowerShell の Unit Test パッケージがインストールされます。

Install-Module Pester -Scope CurrentUser -Force -SkipPublisherCheck

以下のコマンドでインストールされているバージョンが確認できます。

Get-InstalledModule Pester

「Pester 3.4.0」などのバージョンが表示される場合は、以下のコマンドで新しいバージョンに切り替えることができます。

Import-Module Pester -RequiredVersion 5.7.1

このようなスクリプトをテストしたい場合は

function Add-Numbers {
    param(
        [int]$A,
        [int]$B
    )

    return ($A + $B)
}

以下のようなスクリプトでテストできます。

. "$PSScriptRoot\target.ps1"

Describe "Add-Numbers" {

    It "returns correct sum" {
        Add-Numbers 1 2 | Should -Be 3
    }
}

以下のコマンドを入力するとテストが実行されます。

Invoke-Pester

特にスクリプトのパスなどを指定しなくてもテストを自動で検索してくれるようです。

PS C:\MAMP\htdocs\cline\YelpCampCleanArch> Invoke-Pester 
Discovery found 8 tests in 239ms.                                                                          
Running tests.                                                                                             
[+] C:\MAMP\htdocs\cline\YelpCampCleanArch\.kanban\scripts\tests\kanban\status\status-manager.tests.ps1 1.28s (622ms|444ms)                                                                                          
Tests completed in 1.3s                                                                                    
Tests Passed: 8, Failed: 0, Skipped: 0, Inconclusive: 0, NotRun: 0                                         
PS C:\MAMP\htdocs\cline\YelpCampCleanArch>  

モックなども使用できるようです。

status-manager.tests.ps1 クリックすると展開します。
# Tests for status-manager.psm1
using module ..\..\..\kanban\status\status-manager.psm1

Describe 'Get-LoopStatus' {
    BeforeEach {
        Mock Write-Log {} -ModuleName status-manager
        Mock Write-Header {} -ModuleName status-manager
        Mock Write-Footer {} -ModuleName status-manager
    }

    Context 'Valid loopStatus' {
        BeforeEach {
            Mock Get-Property { 'IDLE' } -ModuleName status-manager
        }
        It 'returns Success=true, Status=IDLE, ExitCode=0' {
            $result = Get-LoopStatus
            $result.Success | Should -BeTrue
            $result.Status | Should -Be ([LoopStatus]::IDLE)
            $result.ExitCode | Should -Be 0
        }
    }

    Context 'Invalid loopStatus' {
        BeforeEach {
            Mock Get-Property { 'UNKNOWN' } -ModuleName status-manager
        }
        It 'returns Success=false, Status=$null, ExitCode=1' {
            $result = Get-LoopStatus
            $result.Success | Should -BeFalse
            $result.Status | Should -Be $null
            $result.ExitCode | Should -Be 1
        }
    }

    Context 'Missing loopStatus' {
        BeforeEach {
            Mock Get-Property { '' } -ModuleName status-manager
        }
        It 'returns Success=false, ExitCode=1' {
            $result = Get-LoopStatus
            $result.Success | Should -BeFalse
            $result.ExitCode | Should -Be 0
        }
    }

    Context 'Throw exception' {
        BeforeEach {
            Mock Get-Property { Throw "exception" } -ModuleName status-manager
        }
        It 'returns Success=false, ExitCode=1' {
            $result = Get-LoopStatus
            $result.Success | Should -BeFalse
            $result.ExitCode | Should -Be 1
        }
    }
}

Describe 'Set-LoopStatus' {
    BeforeEach {
        Mock Write-Log {} -ModuleName status-manager
        Mock Write-Header {} -ModuleName status-manager
        Mock Write-Footer {} -ModuleName status-manager
    }

    Context 'Basic functionality' {
        It 'returns true when Set-Property succeeds' {
            Mock Set-Property { return $true } -ModuleName status-manager
            $result = Set-LoopStatus -newStatus ([LoopStatus]::IDLE)
            $result | Should -BeTrue
        }
    }
}

Describe 'Get-Status' {
    BeforeEach {
        Mock Write-Log {} -ModuleName status-manager
        Mock Write-Header {} -ModuleName status-manager
        Mock Write-Footer {} -ModuleName status-manager
    }

    Context 'Valid property' {
        It 'returns mocked value' {
            Mock Get-Property { return 'value' } -ModuleName status-manager
            $result = Get-Status -property 'TestProp'
            $result | Should -Be 'value'
        }
    }
}

Describe 'Set-Status' {
    BeforeEach {
        Mock Write-Log {} -ModuleName status-manager
        Mock Write-Header {} -ModuleName status-manager
        Mock Write-Footer {} -ModuleName status-manager
    }

    Context 'Sets property' {
        It 'calls Set-Property with correct args' {
            $prop = 'TestProp'
            $value = 'new value'
            Mock Set-Property { } -ModuleName status-manager
            Set-Status -property $prop -value $value
        }
    }
}

Describe 'Invoke-Commit' {
    BeforeEach {
        Mock Write-Log {} -ModuleName status-manager
        Mock Write-Header {} -ModuleName status-manager
        Mock Write-Footer {} -ModuleName status-manager
    }

    Context 'Commits message' {
        It 'calls Commit with provided message' {
            $msg = "Test commit"
            Mock Commit { } -ModuleName status-manager
            Invoke-Commit -m $msg
        }
    }
}
まとめ

今回は 1 タスクについて、プラン作成からレビュー実行までの処理を実装しました。

ループを作成するには今後さらにレビュータスクに以下の機能を実装する必要があります。

  • レビュー失敗時にプランを修正する機能
  • 新しいプランで再度タスクループを生成する機能
  • タスク完了時に次のタスクのループを作成して実行する機能

など

また、以下のような問題も依然として残っています。

  • 推論が途中で止まることがある。
  • Kanban の自動コミット機能と併用すると、Kanban が落ちることがある。
  • 開発用のブランチを VS Code で使用している場合はループが開始しない。
  • 設定ファイル作成ごとにコミットしているため履歴が長くなりがち

など、スクリプトや md ファイルをさらに修正する必要があります。

このようにさまざまな課題がありますが、コードの実装機能に関していえば小さいローカル LLM でもかなりのポテンシャルを持っているような感触はあります。

開発中のため、まだまだ不安定ですが今回ご紹介したコードは こちら からダウンロードできます。よろしかったらご覧ください。エージェントループを実行したい場合は、VS Code のターミナル画面で以下のコマンドを実行してください。(PHP の Lint やフォーマッターを実行するのに必要です。)

cd app
composer install

次回はいよいよマルチエージェントループを回せるところまで実装したいと思います。