ハーネスまだまだ改善
ハーネスって何?
Claude Code でブログを作ってから、記事投稿もコード修正もほぼエージェント任せになっている。 ここでいう「ハーネス」とは、自分が組んだエージェント構成のことだ。
- planner — 要件を読んでスプリント仕様を生成する
- generator — 仕様を読んで実装する
- evaluator — Playwright で実際に動かしてテスト・評価する
クライミングのハーネスと同じで、これがちゃんと機能していないと墜落する。
何が問題だったか
generator が吐き出す page.tsx が肥大化していた。
データフェッチ・UI・ロジックが1ファイルに混在していて、
100行を超えるコンポーネントが平気で生まれていた。
たとえばカテゴリページはこんな状態だった。
// app/categories/[slug]/page.tsx(修正前のイメージ)
export default async function CategoryPage({ params }) {
const category = await prisma.category.findUnique({ ... });
if (!category) notFound();
return (
<div className="max-w-4xl mx-auto px-6 py-12">
<h1>{category.name}</h1>
{category.articles.map((article) => ( // ← ここで any 型警告
<div key={article.id}>
<a href={`/posts/${article.slug}`}>{article.title}</a>
{/* ...50行のJSXが続く... */}
</div>
))}
</div>
);
}
問題は2つある。
-
コンポーネントが巨大
UIの変更のたびにpage.tsxを触る羽目になる。Server Component と Client Component の境界も曖昧になる。 -
TypeScript の暗黙的
any型
articles.map((article) => ...)のarticleがanyになる。 Prisma のクエリ結果は型が取れるはずなのに、もったいない。
どう直したか
① page.tsx はデータフェッチのみに絞る
// app/categories/[slug]/page.tsx(修正後)
import { notFound } from "next/navigation";
import { prisma } from "@/lib/prisma";
import CategoryView from "./CategoryView";
export default async function CategoryPage({ params }) {
const { slug } = await params;
const category = await prisma.category.findUnique({
where: { slug },
include: { articles: { where: { status: "PUBLISHED" }, ... } },
});
if (!category) notFound();
return <CategoryView category={category} />;
}
UIは CategoryView.tsx に切り出した。
page.tsx は「何を取ってくるか」だけを知っている状態になった。
② Prisma の型を正しく使う
// CategoryView.tsx
import type { Prisma } from "@prisma/client";
type CategoryWithArticles = Prisma.CategoryGetPayload<{
include: { articles: { include: { category: true } } };
}>;
export default function CategoryView({ category }: { category: CategoryWithArticles }) {
return (
<>
{category.articles.map((article) => ( // article は型推論が効く
<ArticleCard key={article.id} article={article} />
))}
</>
);
}
Prisma.XxxGetPayload<{...}> を使えば、include の構造まで含めた型が取れる。
暗黙的 any はゼロになった。
③ ESLint エラーも一緒に潰す
DarkModeToggle で useEffect 内で setState を呼んでいた。
// 修正前: useEffect 内で直接 setState
useEffect(() => {
const isDark = ...;
setDark(isDark); // ← ESLint エラー
document.documentElement.classList.toggle("dark", isDark);
}, []);
// 修正後: useState の遅延初期化で解決
const [dark, setDark] = useState(getInitialDark); // 初回のみ関数を実行
useEffect(() => {
document.documentElement.classList.toggle("dark", dark); // DOM同期のみ
}, [dark]);
useEffect の中で setState を呼ぶと再レンダーのカスケードが起きる。
useState の遅延初期化(関数を渡す形式)に移すと、マウント時に1回だけ実行されてきれいに収まる。
ハーネス自体も直す
コードを直しただけでは次のスプリントでまた同じ問題が起きる。
generator.md と evaluator.md にルールを明文化した。
generator.md に追加したこと:
page.tsxはコンポーネント呼び出しのみ(JSX直書き禁止)- 引き渡し前に Docker Node 20 内で
tsc --noEmit+ ESLint を実行する
evaluator.md に追加したこと:
- 型安全性チェックを評価基準に追加(閾値5 = 必須)
- tsc と ESLint のチェックコマンドを明記
ルールが仕様書に書いてあっても、エージェントが自分でチェックできないと意味がない。 「引き渡し前に自分でチェックして、ゼロでなければその場で直す」という手順を組み込んだ。
やってみて
エージェントに「コンポーネント分割して」と指示すると、それなりに分割してくれる。 ただし粒度の基準がなければ、1コンポーネント200行のものが出てくることもある。
ルールを言語で定義して、チェックを自動化する。 これがハーネスを締め直すということだと思っている。
クライミングのハーネスもずっと同じものを使い続けると、気づかないうちに劣化する。 コードのハーネスも同じで、定期的に見直さないとどこかで落ちる。
まだまだ改善の余地はある。次は e2e テストをコミット前フックに組み込みたい。