mcpField
C
clanker-discipline

clanker-discipline

github.com/gbasin開発227.5k 回インストール31 週間前に更新
リポジトリ

clanker-discipline について

エージェント向けのコードレビュー用スキルで、状態型、データモデル、状態を扱う関数を確認します。既存データからの状態導出、不正な状態の排除、純粋なロジックと副作用の分離を重視します。

主な機能

  1. 01既存データから状態を導出
  2. 02型で不正な状態を排除
  3. 03可変状態のスコープを限定
  4. 04純粋関数と副作用を分離
  5. 05検索ロジックをデータで表現

活用シーン

  1. 01アプリケーションの状態変更をレビュー
  2. 02オプション項目のあるモデルをリファクタリング
  3. 03状態を変更する関数を点検

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/gbasin/clanker-discipline --skill clanker-discipline

インストール後、Claude Code で/clanker-disciplineを入力して読み込まれたか確認します。

同梱ファイルREADME.md

詳細

Clanker Discipline

アプリケーションの状態を管理する状態型、データモデル、関数を作成またはレビューするときは、これらのルールを適用してください。エージェントはフラグやオプションフィールド、特殊ケースを追加しがちで、それらが積み重なると誰も意図していなかった状態が生まれます。コードに取り込まれる前に見つけてください。

違反を見つけたら、完全にリファクタリングしてください。目標は、最小限の差分ではなく、クリーンで保守しやすいコードです。フラグを取り除き、型を再構成し、関数を組み直してください。今、差分が大きくなっても、後で積み重なる回避策を重ねるよりよい選択です。


1. 保存せず、導出する

追加するブール値は、理論上の状態空間を倍増させます。すでにあるデータから導出できる値は保存しないでください。導出元として最適なのはイベントストリーム、つまり何が起きたかを記録したログです。

Before:キャッシュされたフラグ

エージェントは、アシスタントが自然に終了した場合にのみフッターを表示するよう求められました。そこで、4つのフラグを考案しました。

type ThreadState = {
  wasInterrupted: boolean;
  didAssistantFinish: boolean;
  didAssistantError: boolean;
  wasToolCallOnly: boolean;
};

function shouldShowFooter(state: ThreadState): boolean {
  return state.didAssistantFinish
    && !state.wasInterrupted
    && !state.didAssistantError
    && !state.wasToolCallOnly;
}

1つの問いに答えるために4つのフィールドを用意し、それらの同期を保つため、別の場所に4つの変更箇所が必要です。

After:根拠から導出する
function shouldShowFooter(events: SessionEvent[]): boolean {
  const latest = getLatestAssistantMessage(events);
  if (!latest) return false;
  return latest.completed && !latest.error && latest.finish !== 'tool-calls';
}

答えは、すでに存在するイベントから計算されるようになりました。

導出しない場合
  • ドメインに、順序付けられた遷移を持つ状態機械が本当に存在する場合。チェックアウトのステップはキャッシュされた結論ではなく、状態そのものです。
  • 非同期プロセスのタイムスタンプや、後続処理で必要な API レスポンスなど、時間的または外部由来のデータをフィールドに保持し、再導出できない場合。
  • 導出処理が、値を保存するより複雑になる場合。
導出できない場合は、カプセル化する

可変状態が必要なら、可能な限り最小のスコープに閉じ込めてください。クラスフィールドよりクロージャのほうが適しています。

// Bad: state visible to the whole class
class Writer {
  private debounceTimeout: ReturnType<typeof setTimeout> | null = null;
  queueSend(text: string) { /* can touch debounceTimeout */ }
  flushNow() { /* can touch debounceTimeout */ }
  somethingElse() { /* can also touch debounceTimeout */ }
}

// Good: state trapped in a closure
function createDebouncedAction(callback: () => void, delayMs = 300) {
  let timeout: ReturnType<typeof setTimeout> | null = null;
  return {
    trigger() {
      clearTimeout(timeout!);
      timeout = setTimeout(() => { timeout = null; callback(); }, delayMs);
    },
    clear() {
      if (timeout) { clearTimeout(timeout); timeout = null; }
    },
  };
}

クロージャの外からタイマーに触れることはできません。

デバッグ上の利点

状態を根拠から導出すると、デバッグは「データを入れて、答えを得る」形になります。

test('footer is hidden for aborted runs', () => {
  const events = loadEvents('./fixtures/aborted-session.jsonl');
  expect(shouldShowFooter(events)).toBe(false);
});

モックもタイミングの再現も不要です。問題はイベントか純粋関数にあります。


2. 不正な状態を不可能にする

オプションフィールドが1つあるたびに、コードベースの他の部分がそのデータに触れるたびに答えなければならない問いが1つ増えます。

オプションフィールドの寄せ集めより判別可能なユニオン
// Bad: when status is 'idle', should gateway/transactionId exist? The type doesn't say.
type PaymentState = {
  status: 'idle' | 'processing' | 'settled';
  gateway?: 'stripe' | 'paypal';
  transactionId?: string;
  initiatedAt?: string;
  settledAt?: string;
};

// Good: each status carries exactly the fields it needs.
type PaymentState =
  | { status: 'idle' }
  | { status: 'processing'; gateway: 'stripe' | 'paypal'; transactionId: string; initiatedAt: string }
  | { status: 'settled'; gateway: 'stripe' | 'paypal'; transactionId: string; settledAt: string };
センチネル値より null
// Bad: 'none' is not an action. It is the absence of one.
type PendingAction = 'none' | 'confirm-address' | 'select-shipping';

// Good
type PendingAction = 'confirm-address' | 'select-shipping';
type OrderState = { pendingAction: PendingAction | null };
寄せ集めより段階的な構成
// Bad: 20+ optional fields. Every consumer does profile.firstName ?? defaults.firstName.
type UserProfile = {
  firstName?: string;
  lastName?: string;
  email?: string;
  phone?: string;
  company?: string;
  jobTitle?: string;
  billingAddress?: string;
  cardLast4?: string;
  // ... more
};

// Good: check one optional instead of eight. When identity exists, all its fields are present.
type UserProfile = {
  identity?: { firstName: string; lastName: string; email: string };
  billing?: { address: string; cardLast4: string };
};
同一のプリミティブ型にブランドを付ける
// Bad: a function accepting UserId will happily take a TeamId.
type UserId = string;
type TeamId = string;

// Good
type UserId = string & { readonly __brand: 'user' };
type TeamId = string & { readonly __brand: 'team' };
使われていないバリアントを削除する

一度も生成されないバリアントが型にあるなら、削除してください。'completed' が一度も設定されない status: 'open' | 'completed' は、実際には存在しないライフサイクルを示唆します。


3. 関数の契約を守る

純粋関数に副作用を追加しない

純粋関数にひそかに副作用が加わると、すべての呼び出し箇所が、求めていない動作を引き継ぎます。関数に副作用が必要なら、それらを別のオーケストレーターに切り出してください。

  • セマンティック関数は小さく、純粋で、自己記述的です。入力を受け取り、出力を返し、隠れた副作用はありません。
  • プラグマティック関数はオーケストレーターです。セマンティック関数を組み合わせ、煩雑なドメイン固有の処理を含みます。
Before:セマンティック関数からプラグマティック関数へ変わった例
function handleWebhook(state, eventType, payload, receivedAt): WebhookResult {
  switch (eventType) {
    case 'payment.captured': {
      const receipt = buildReceipt(payload);            // data creation
      state.order.paymentStatus = 'captured';           // mutation
      state.order.receipt = receipt;                     // mutation
      state.user.lastPurchaseAt = receivedAt;           // mutation
      state.user.lifetimeSpend += receipt.amount;        // mutation
      clearPendingAction(state);                         // side effect
      const notifications = buildPaymentNotifs(state);   // notification
      state.notifications.push(...notifications);        // mutation
      recalculateDashboard(state);                       // derivation
      return { state, output: receipt, notifications };
    }
    // ... 12 more cases, same pattern
  }
}
After:セマンティック関数を組み合わせた例
function handlePaymentCaptured(state: AppState, payload: PaymentPayload, receivedAt: string): WebhookResult {
  const receipt = buildReceipt(payload);
  const updatedOrder = applyPaymentToOrder(state.order, receipt);
  const updatedUser = applyPurchaseToUser(state.user, receipt, receivedAt);
  const notifications = buildPaymentNotifs(state, receipt);

  return {
    state: { ...state, order: updatedOrder, user: updatedUser },
    output: receipt,
    notifications,
  };
}
変更に関する契約を選ぶ

関数が入力を変更するなら、void を返してください。値を返すなら、先に複製してください。入力を変更したうえで同じ参照を返すのは避けてください。呼び出し側は、返された値と元の値のどちらを使えばよいのか判断できません。

// Bad: mutates AND returns the same object
function withPendingAction(state: AppState, action: string): AppState {
  state.pendingAction = action;
  return state;
}

// Good: mutate, return void
function applyPendingAction(state: AppState, action: string): void {
  state.pendingAction = action;
}

// Also good: clone, return new
function withPendingAction(state: AppState, action: string): AppState {
  return { ...state, pendingAction: action };
}

4. 手続きよりデータ

長い if 文の連鎖が、どの分岐でも似た形を返すなら、そのロジックはコードで表現されたルックアップテーブルです。データに変換してください。

Before:if 文の連鎖
function getStepInfo(step: string): StepInfo | null {
  if (step === 'verify-email') {
    return { tone: 'action', title: 'Verify your email', detail: 'Check your inbox' };
  }
  if (step === 'add-payment') {
    return { tone: 'action', title: 'Add payment method', detail: 'Enter card details' };
  }
  if (step === 'review-order') {
    return { tone: 'confirm', title: 'Review your order', detail: 'Check totals' };
  }
  // ... 10 more branches
  return null;
}
After:宣言的なテーブル
const STEP_INFO: Array<{
  match: (step: string) => boolean;
  info: StepInfo;
}> = [
  { match: (s) => s === 'verify-email', info: { tone: 'action', title: 'Verify your email', detail: 'Check your inbox' } },
  { match: (s) => s === 'add-payment',  info: { tone: 'action', title: 'Add payment method', detail: 'Enter card details' } },
  { match: (s) => s === 'review-order', info: { tone: 'confirm', title: 'Review your order', detail: 'Check totals' } },
  // data, not code
];

function getStepInfo(step: string): StepInfo | null {
  return STEP_INFO.find(({ match }) => match(step))?.info ?? null;
}

読みやすく、拡張やテストも容易です。エージェントが新しいステップを追加するときは、制御フローに分岐を追加するのではなく、データエントリーを追加します。

変換しない場合

分岐ごとに制御フローが異なる場合は、単に返り値が異なるだけでなければ、コードのままにしてください。テーブルは入力を出力に対応付けるものであり、「X を呼び出してから、条件に応じて Y を呼び出す」といった処理は表現できません。

チェックリスト

コード(自分のコードまたはエージェントのコード)をレビューするときは、次の点を確認してください。

  • 新しいフィールドは既存の状態から導出できますか?導出してください。
  • 可変状態が必要最小限のスコープを超えて見えていませんか?クロージャー内に閉じ込めてください。
  • 不可能であるべきフィールドの組み合わせを許すモデルはありませんか?判別可能なユニオンを使ってください。
  • null で代用できる番兵値('none'、'unknown'、-1)はありませんか?null を使ってください。
  • 異なるドメイン概念に対して同一の型エイリアスがありませんか?ブランド型にするか、削除してください。
  • 入力を変更すると同時に、その入力を返す関数はありませんか?どちらか一方の契約にしてください。
  • セマンティック関数に副作用が増えていませんか?切り出してください。
  • すべての分岐が似た形を返す if 連鎖はありませんか?テーブルにしてください。
  • 構築されることのないデッドな型バリアントはありませんか?削除してください。

コメント