1. はじめに
AgentReview は、コーディングエージェントが書いた変更を、別のモデル(Claude Code または Grok Build)に読み取り専用の snapshot 上でレビューさせ、その finding を書いた側のエージェントが一つずつ検証してから直す、という私自身のワークフローを CLI にしたものです。
One agent writes. Another reviews. The first verifies.
v0.1 から v0.4 まで Python で作ってきました。標準ライブラリだけで動き、pip install も要らない。
設計としては自分でも気に入っています。
それでも、このたび TypeScript に移植して agent-review-ts として別リポジトリに切り出しました。理由は機能ではなく、インストールです。
2. なぜ移植したか
「依存ゼロ」でも入れにくい
Python 版のインストール手順は、突き詰めるとこうでした。
git clone https://github.com/SilentMalachite/AgentReview.git \
"$HOME/.agents/skills/agent-review"
これだけです。外部パッケージへの依存はゼロ。にもかかわらず、実際に人に使ってもらおうとすると引っかかる。原因は「前提」の一行目にありました。
- Python 3.11 以上
tomllib や ExceptionGroup を使っているわけではなく、型ヒントの書き方や contextvars まわりの都合で 3.11 を要求していたのですが、これが重い。macOS に同梱される Python は 3.9 ですし、Ubuntu 22.04 は 3.10 です。「まず Python を入れてください」と言った時点で、レビューという本題に入る前に一つ関門ができてしまいます。
一方で、AgentReview が reviewer として呼び出す Claude Code も Codex も Grok Build も、npm で配布される Node.js 製の CLI です。つまり AgentReview を使う人の手元には Node.js が必ずある。Python 3.11 がある保証はない。
依存ゼロを誇っていた Python 版は、実は「利用者が持っていない可能性のあるランタイム」に依存していたわけです。
プラグインとして配れない
もう一つ、こちらのほうが致命的でした。
Claude Code にも Codex にもプラグイン機構があり、マーケットプレイスから 2 コマンドで導入できます。ところが、その配布の流儀は次のようになっています。
| エージェント | プラグインの取得方法 |
|---|---|
| Claude Code | git、あるいは SHA-256 を固定したアーカイブ |
| Codex | マーケットプレイスのリポジトリを clone(ビルド手順は走らない) |
どちらも「ソース、もしくはビルド済み成果物を置くだけ」という前提です。Python パッケージがここに乗るには、インタープリタの解決を利用者側に押しつけるしかありません。skill ディレクトリに clone するだけの「手動導入」しか提供できなかったのは、このためです。
これは面倒です。
Node.js なら事情が違います。ランタイムはすでにある。tsc の出力と prebuild 済みバイナリを zip に固めれば、Claude Code のアーカイブ配布にそのまま乗る。Codex の clone にも、後述する一手間で乗せられる。
移植の動機はこれで全部です。
TypeScript のほうが書きやすいからでも、速いからでもありません。
3. 移植の方針 ― できるだけ1:1 で写す
設計文書の冒頭に、こう書きました。
移植は 1:1 を基本とする。Python 版のモジュール構成、例外階層、エラー文言、exit code、成果物のバイト列をそのまま写し、Node.js が同じ形を許さない箇所だけを非同期化する。Python 版のテスト 604 件が事実上の仕様であり、ファイル単位で 1:1 に移植する。
「TypeScript らしい再設計」は明示的に非目標にしました。API の形を変えた瞬間に、Python 版と成果物を相互に読み書きできるという互換性契約が崩れ、テストの移植もできなくなるからです。
具体的には次を固定しています。
- サブコマンド(
quick/milestone/deep/verify/finalize)とフラグ名、exit code、reason=slug の語彙 - 成果物 JSON のバイト列。Python の
json.dumps(ensure_ascii=True, indent=2, sort_keys=True)と同じ出力を出すasciiStringifyを自前で実装 - reviewer に渡す brief と prompt のバイト列。ここが違うと reviewer の挙動が変わる
-
SKILL.mdの起動契約${AGENT_REVIEW_SKILL_DIR}/scripts/agent-review。skill として差し替え可能にする
JSON.parse も使っていません。重複キーを黙って上書きするので、Python 版の厳格ローダと同じ判定(重複キー、NaN、先頭 BOM、深いネストを拒否)を再帰下降パーサで書き直しました。
4. 一番の難所 ― Node.js に openat がない
Python 版の安全性モデルの中核は、snapshot と成果物ストアの TOCTOU 対策です。os.open(..., dir_fd=...) による rooted traversal、O_NOFOLLOW、atomic rename、ディレクトリに対する flock。symlink を植えられても、レビュー対象リポジトリの外に一切書かない。
ところが Node.js の fs には openat 系も flock もありません。
候補は三つありました。
-
realpath+lstatで純 TypeScript 実装する ― symlink の競合状態に対する保証が Python 版より弱くなり、README の安全性の記述を書き換える必要が出る。却下 -
fs-extなどの既存パッケージ ―flockはあるがopenat系がなく、NAN ベースでインストール時ビルドを要求する。npm と GitHub を探した範囲で、openatを提供する N-API パッケージは見つからなかった。却下 - 最小限の N-API ネイティブアドオンを自分で書く
3 を選びました。native/posixfs.cc に、Node.js 標準に無いものだけを 8 関数(openat、mkdirat、unlinkat、fstatat、readlinkat、renameat、fchmodat、flock)用意し、TypeScript 側の posix.ts で型を付けています。O_CLOEXEC が fs.constants に無いので、アドオン側で常に FD_CLOEXEC を立てる、といった細部もここに集約しました。
「node-gyp を利用者に要求しない」
ネイティブアドオンを入れると、今度は「インストールにコンパイラが要る」という新しい壁が生まれます。それでは Python 3.11 の壁を別の壁に置き換えただけです。
そこで prebuildify で darwin-arm64 / darwin-x64 / linux-x64 / linux-arm64 のバイナリを CI で生成し、tarball に同梱しています。対応プラットフォームではインストール時にコンパイルは走りません。
ただ、prebuild だけでは musl ベースの Linux(Alpine など)で詰みます。glibc 向けバイナリが dlopen の時点で失敗し、フォールバックがない。これは CI の prebuild ワークフローで Linux だけ --tag-libc を付けて .glibc. タグを入れることで解決しました。タグ付きなら musl 環境では「一致なし」と判定され、install スクリプト(node-gyp-build)が同梱の native/ ソースと binding.gyp からビルドする経路に落ちます。node-addon-api がランタイム依存に入っているのは、このフォールバックビルドがヘッダーを参照するためだけです。
macOS 側はあえてタグなしにしています。prebuildify の libc 検出は Alpine とそれ以外しか区別しないので、darwin にも glibc と付いてしまい、意味をなさないからです。
5. 同期と非同期の境界
Python 版は fs 層が全部同期です。これを全面 Promise 化すると、競合状態の再検討がすべてのモジュールに広がってしまう。なので「非同期の島」を作る方針にしました。
| 領域 | 方式 |
|---|---|
| models、contract、prompt、json | 同期(純粋関数) |
| artifact、snapshot の fs 層 | 同期(fs.*Sync + アドオン) |
git の単発呼び出し |
spawnSync |
git cat-file --batch |
spawn + Promise ベースの framed reader |
| reviewer 起動 |
spawn({detached: true}) + process.kill(-pid)
|
runReview、cli |
async |
Node.js に同期のパイプ読みが無いので、長寿命ストリームである cat-file --batch だけはどうしても非同期になります。そこから上流の materializeSnapshot は Python 版のジェネレータではなく acquire() / release() の明示ペアにし、workflow は __enter__ / __exit__ の呼び出し順序をそのまま写しました。
割り込みの扱い
Python 版は KeyboardInterrupt を BaseException として他の例外より優先し、割り込み後は成果物を永続化しません。JavaScript にこの区別はないので、明示的に持たせました。
- cli が SIGINT / SIGTERM を捕捉して
AbortControllerを abort する - reviewer 起動は
AbortSignalを受け取り、abort 時にプロセスグループを停止してReviewerInterruptedErrorを投げる -
workflowは各段階の直前とstore.createの直前にsignal.abortedを検査する - どちらの割り込み例外も、Python 版の
KeyboardInterruptと同じ exit code 4 に写す
ContextVar は AsyncLocalStorage に置き換えています。
6. 相互運用テスト ― 両方を同じテストで走らせる
移植で一つだけ新規に書いたテストファイルが tests/interop.test.ts です。AGENT_REVIEW_PY_REPO に Python 版の checkout を指定すると、両実装を同じテストの中で動かして、互いの成果物を読み書きできること、同じ ReviewRun から出るバイト列が一致することを確認します。
「バイト一致」を本当に検証するために、いくつか決め事があります。
- Python ↔ TypeScript のバイト転送はすべて base64 over stdout。シェルや端末のエンコーディング層が途中で書き換える余地をなくす
- 比較は
Buffer同士。文字列に decode した時点で、捕まえたい差異が消える - 両実装は同じ中立な JSON spec から
ReviewRunを組み立てる。TypeScript が直列化したバイト列を Python に食わせても、parse と re-emit の安定性しか証明できない
環境変数が未設定なら suite ごと SKIPPED、設定されているのに使えなければ FAILURE。「空の pass」を絶対に出さない設計にしました。将来 Python 版がリリースを重ねたときに、このファイルが黙って no-op に腐るのを防ぐためです。
7. 三つの導入経路
移植の目的はここでした。
v0.4.0 の agent-review-ts では、同じ skill を三通りで入れられます。
| エージェント | 方法 | コンパイラ | clone |
|---|---|---|---|
| Claude Code | plugin | 不要 | 不要 |
| Codex | plugin | 不要 | 不要 |
| その他 | 手動 | 対応プラットフォームでは不要 | 必要 |
Claude Code
/plugin marketplace add SilentMalachite/agent-review-ts
/plugin install agent-review@agent-review
.claude-plugin/marketplace.json の source は archive で、GitHub Releases の zip を URL と SHA-256 で固定しています。ビルド済みなので clone もコンパイルも起きません。
Codex
codex plugin marketplace add SilentMalachite/agent-review-ts
codex plugin add agent-review@agent-review
Codex はマーケットプレイスのリポジトリを clone するだけで、ビルド手順を走らせません。dist/ と prebuilds/ は git 管理外なので、clone 直後の plugin には実行可能なエントリポイントがない。
これを埋めるのが scripts/ensure-runtime です。最初のレビュー時に一度だけ動き、次の順で実行可能な AgentReview を解決します。
-
$AGENT_REVIEW_SKILL_DIRが実行可能ならそれを使う(手動導入した人の環境を尊重) - スクリプト自身のリポジトリがビルド済みならそれ(Claude Code のアーカイブ経路はここで止まる)
- キャッシュ(
$XDG_CACHE_HOMEまたは~/.cache)に展開済みならそれ - Claude Code plugin と同じリリースアーカイブをダウンロードし、固定ダイジェストと照合してキャッシュに展開
ダイジェストが一致しなければ、検証できないアーカイブは実行せずに拒否します。2 回目以降はオフラインで動き、レビュー対象リポジトリには一切書きません。
npm test が「VERSION が package.json と一致し、ARCHIVE_URL がそのバージョンを指している」ことを assert しているので、バージョンを上げたのにダイジェストだけ前のアーカイブを指し続ける、という事故は起きません。
手動導入
Python 版と同じく skill ディレクトリに clone しますが、npm ci && npm run build が一回だけ必要です。
8. 残した違いは明記しておく
1:1 を目指しても、言語の都合で写せないところは残ります。
CHANGELOG に "Known differences" として全部列挙しました。たとえば、
-
api_error_statusの解析で、JavaScript のnumberは401と401.0を区別できない - exit code 9(Markdown summary をエンコードできない場合)は定義してあるが、Node の
process.stdout.write()は非 ASCII で例外を投げないので、実際のランチャーからは到達しない - 成果物 basename のマイクロ秒フィールドは、ミリ秒精度由来なので常に
000で終わる - reviewer 子プロセスの出力はストリームごと 16 MiB が上限。Python の
communicateは無制限
いずれも、このプロジェクトが定義する schema、CLI、workflow のどこからも到達できないことを確認した上で残しています。「同等です」と一言で済ませるより、差異を列挙したほうが、移植を信用してもらえると考えました。
9. 数字で見る
| Python 版 v0.4.0 | agent-review-ts v0.4.0 | |
|---|---|---|
| 実装 | 約 7,200 行 | 約 9,600 行(N-API アドオン含む) |
| テスト | 604 件 | 700 件余り(相互運用スイートを追加) |
| ランタイム依存 | なし |
commander、node-gyp-build、node-addon-api
|
| 必要なもの | Python 3.11+ | Node.js 22+ |
| プラグイン配布 | なし | Claude Code / Codex |
行数が増えているのは、Python が持っていて Node.js が持っていないもの(json の ASCII 直列化、厳格パーサ、openat 系、KeyboardInterrupt の優先順位)を全部自前で埋めたからです。
10. AI との協働について
移植作業そのものは Claude Code で行いました。
設計文書と実装計画を先に書いてから、モジュール単位で進めています。
git log を見返すと、reviewer の移植から plugin 化までがほぼ 2 日に収まっています。
当初は Codex で進めていました。
v0.2 以降の AgentReview は Claude Code / Codex / Cursor Agents の三者で分担し、どれかが止まっても引き継げる体制にしていたからです。
ところが今回は、Codex 側がセキュリティ上の理由で個人認証を求めてきました。
そこから先に進めませんでした。
三者体制にしておいた意味が、皮肉にもここで出たことになります。
この速さは「1:1 で写す」と決めて、604 件のテストを仕様として固定したから出たものです。
設計の自由度を残したままエージェントに任せると、TypeScript らしく直したくなる誘惑にエージェント自身が負けて、互換性契約が少しずつ崩れていきます。
非目標を先に書いておくことが、この手の移植では一番効きました。
11. おわりに
Python 版はそのまま残します。agent-review-ts は独立したリポジトリとして、Python 版のコミット 4c4d714(v0.4.0)を基準に並行して維持していきます。
「動くのに入れられない」道具は、実質的にないことと同じです。
移植して変わったのは機能ではなく、初めて使う人が本題にたどり着くまでの距離でした。
- Python 版:
- TypeScript 版: