mcpField
D
distill

distill

github.com/juxt開発5242.6k 回インストール5 週間前に更新
リポジトリ

distill について

distillは、既存のコードベースからAllium仕様を抽出するスキルです。対象範囲を定めてドメイン上の振る舞いを見極め、実装の詳細を除外します。大規模なコード調査はコンテキストごとに分割できます。

主な機能

  1. 01ドメイン上の振る舞いの抽出
  2. 02仕様の対象範囲の設定
  3. 03要件と実装の切り分け
  4. 04大規模コードベースの分割
  5. 05未確認の判断の記録

活用シーン

  1. 01既存コードからサービス仕様を記述
  2. 02仕様からインフラの詳細を除外
  3. 03モノレポを境界づけられたコンテキストごとに抽出

インストール

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

$npx skills add https://github.com/juxt/allium --skill distill

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

同梱ファイルreferences/worked-examples.md

詳細

蒸留ガイド

このガイドでは、既存のコードベースから Allium 仕様を抽出する方法を説明します。中心となる課題は、フォワードエリシテーションと同じで、適切な抽象度を見極めることです。エリシテーションでは、実装に関する考えが出てきたら取り除きます。蒸留では、すでに存在する実装の詳細を取り除きます。どちらにも、ドメインレベルで何が重要かを判断する同じ力が必要です。

コードは、何かが「どのように」動くかを示します。仕様は、それが「何を」行い、「なぜ」重要なのかを捉えます。求められるのは、「関係者はなぜこれを気にするのか?」「システムとして同じままで、これを別の形にできるか?」と問う力です。

対話モード

このスキルは2つのモードで動作します。以下でユーザーへの質問、確認、検証を求める指示は、いずれもモードに従います。

  • 対話型 — 会話の中でインライン実行します。ユーザーに直接質問し、回答を待ちます。
  • 非対話型 — distill サブエージェントとして実行します(たとえば Allium ループ内)。ユーザーにアクセスできないため、与えられた目標に基づいて蒸留の範囲を定め、判断が必要な点を推測してはいけません。未確認の判断(意図された動作か偶発的な動作か、アクターの識別、候補となるプロセス、範囲から除外するもの)をそれぞれ、蒸留した仕様内の open question 宣言として記録し、保留した質問を最終出力に列挙します。

蒸留作業の範囲を定める

コードを詳しく調べる前に、何を仕様化するのかを明確にします。すべてのコード行を仕様に含める必要はありません。

最初に確認する質問
  1. 「このコードベースのどの部分を仕様化しますか?」 モノレポには複数の異なるシステムが含まれていることがよくあります。1つのサービスやドメインだけを仕様化すればよい場合もあります。作業を始める前に、境界を明確にしてください。

  2. 「意図的に除外すべきコードはありますか?」

    • レガシーコード:後方互換性のために維持されているが、コアシステムの一部ではない機能
    • 付随的なコード:ドメインレベルではないサポート用インフラストラクチャ(ロギング、メトリクス、デプロイ)
    • 非推奨の経路:削除が予定されているコード
    • 実験的な機能:フィーチャーフラグの背後にあり、まだ設計上の決定に至っていないもの
  3. 「この仕様の担当者は誰ですか?」 モノレポの各部分を異なるチームが担当している場合があります。各チームの仕様は、そのチームのドメインに焦点を当てる必要があります。

「これを再構築するか?」テスト

コードの経路に出会うたびに、「このシステムをゼロから再構築するとしたら、これは要件に含まれるか?」と考えます。

  • はい:仕様に含める
  • いいえ、レガシーです:除外する
  • いいえ、インフラストラクチャです:除外する
  • いいえ、回避策です:除外する(ただし、それが解決している根本的なニーズは記録する)
範囲に関する判断を記録する

蒸留した仕様の冒頭に、含めるものと除外するものを記載します。

-- allium: 3
-- interview-scheduling.allium

-- Scope: Interview scheduling flow only
-- Includes: Candidacy, Interview, InterviewSlot, Invitation, Feedback
-- Excludes:
--   - User authentication (use auth library spec)
--   - Analytics/reporting (separate spec)
--   - Legacy V1 API (deprecated, not specified)
--   - Greenhouse sync (use greenhouse library spec)

バージョンマーカー(-- allium: N)は、すべての .allium ファイルの1行目に記述する必要があります。現在の言語バージョン番号を使用してください。

適切な抽象度を見極める

蒸留とエリシテーションには、含めるものを選ぶという根本的な課題が共通しています。以下のテストは、関係者から機能の説明を聞く場合でも、その機能を実装するコードを読む場合でも、どちらにも有効です。

「なぜ」テスト

コードの詳細を一つずつ、「関係者はなぜこれを気にするのか?」と考えます。

コードの詳細理由含める?
招待の有効期限は7日間候補者の体験に影響するはい
トークンは32バイトのURLセーフ形式セキュリティの実装いいえ
セッションはRedisに保存されるパフォーマンス上の選択いいえ
PostgreSQL JSONBを使用するデータベースの実装いいえ
スロットのステータスが「proposed」に変わる候補者に見える内容に影響するはい
招待が承諾されたときにメールを送信するコミュニケーション上の要件はい

関係者がなぜ気にするのか説明できない場合、それは実装上の詳細である可能性が高いです。

「別の方法でも実現できるか?」テスト

次のように問いかけます。「同じシステムのまま、別の方法で実装できますか?」

  • できる場合:おそらく実装の詳細なので、抽象化します
  • できない場合:おそらくドメインレベルの要素なので、含めます
詳細別の方法でも実現できるか?含めるか?
secrets.token_urlsafe(32)はい。安全なトークン生成ならどれでも可能ですいいえ
招待の有効期限が7日間いいえ。これは設計上の決定ですはい
PostgreSQLデータベースはい。どのデータベースでも可能ですいいえ
「保留中、確認済み、完了」状態いいえ。これはワークフローですはい
「テンプレートとインスタンス」テスト

これは何かのカテゴリですか、それとも特定のインスタンスですか?

インスタンス(多くの場合、実装)テンプレート(多くの場合、ドメインレベル)
Google OAuth認証プロバイダー
Slack webhook通知チャネル
SendGrid APIメール配信
timedelta(hours=3)確認期限

インスタンスそのものがドメイン上の関心事である場合もあります。下記の「具体的な詳細の問題」を参照してください。

蒸留の考え方

コードは過剰に詳細です

コードの各行は、ドメインレベルでは重要でない可能性のある判断を含んでいます。

# Code tells you:
def send_invitation(candidate_id: int, slot_ids: List[int]) -> Invitation:
    candidate = db.session.query(Candidate).get(candidate_id)
    slots = db.session.query(InterviewSlot).filter(
        InterviewSlot.id.in_(slot_ids),
        InterviewSlot.status == 'confirmed'
    ).all()

    invitation = Invitation(
        candidate_id=candidate_id,
        token=secrets.token_urlsafe(32),
        expires_at=datetime.utcnow() + timedelta(days=7),
        status='pending'
    )
    db.session.add(invitation)

    for slot in slots:
        slot.status = 'proposed'
        invitation.slots.append(slot)

    db.session.commit()

    send_email(
        to=candidate.email,
        template='interview_invitation',
        context={'invitation': invitation, 'slots': slots}
    )

    return invitation
-- Specification should say:
rule SendInvitation {
    when: SendInvitation(candidacy, slots)

    requires: slots.all(s => s.status = confirmed)

    ensures:
        for s in slots:
            s.status = proposed
    ensures: Invitation.created(
        candidacy: candidacy,
        slots: slots,
        expires_at: now + 7.days,
        status: pending
    )
    ensures: Email.created(
        to: candidacy.candidate.email,
        template: interview_invitation
    )
}

省略した内容:

  • candidate_id: int は単に candidacy になりました
  • db.session.query(...) はリレーションシップのたどり方になりました
  • secrets.token_urlsafe(32) は完全に削除しました(トークンは実装の詳細です)
  • datetime.utcnow() + timedelta(...) は now + 7.days になりました
  • db.session.add/commit は created に含意されています
  • invitation.slots.append(slot) はリレーションシップに含意されています
「プロダクトオーナーは気にするか?」と尋ねる

コードに含まれる詳細ごとに、次のように問いかけます。

コードの詳細プロダクトオーナーは気にするか?含めるか?
招待の有効期限が7日間はい、候補者の体験に影響しますはい
トークンが32バイトのURLセーフ形式いいえ、セキュリティ上の実装ですいいえ
SQLAlchemy ORMを使用いいえ、永続化の仕組みですいいえ
メールテンプレート名テンプレートが設計上の決定事項なら、気にする場合があります場合によります
スロットのステータスが「提案済み」に変わるはい、候補者に表示される内容に影響しますはい
データベースのトランザクションをコミットするいいえ、実装の詳細ですいいえ
手段と目的を区別する

手段: コードが何かを実現する方法です。
目的: システムが必要とする結果です。

手段(コード)目的(仕様)
requests.post('https://slack.com/api/...')Notification.created(channel: slack)
candidate.oauth_token = google.exchange(code)Candidate authenticated
redis.setex(f'session:{id}', 86400, data)Session.created(expires: 24.hours)
for slot in slots: slot.status = 'cancelled'for s in slots: s.status = cancelled

具体的な詳細の問題

最も判断が難しいのは、具体的な詳細がドメインの一部なのか、単なる実装なのかという点です。

Google OAuthの例

次のコードが見つかったとします。

OAUTH_PROVIDERS = {
    'google': GoogleOAuthProvider(client_id=..., client_secret=...),
}

def authenticate(provider: str, code: str) -> User:
    return OAUTH_PROVIDERS[provider].authenticate(code)

質問: 「Google OAuth」はドメインレベルですか、それとも実装ですか?

次の場合は実装です。

  • Googleは、選択された認証方式にすぎません
  • 任意のOAuthプロバイダーに置き換えられます
  • ユーザーはどのプロバイダーかを見ず、気にも留めません
  • コードが汎用的に書かれています(プロバイダーはパラメーターです)

次の場合はドメインレベルです。

  • ユーザーが(Microsoftなどではなく)Googleを明示的に選択します
  • 「Googleでサインイン」が機能として提供されています
  • Google固有のスコープや権限が使用されています
  • 複数のプロバイダーが機能としてサポートされています

判断方法: UIとユーザーフローを確認します。ユーザーに選択肢として「Googleでサインイン」と表示されるなら、ドメインレベルです。「サインイン」とだけ表示され、その裏側でGoogleが使われているだけなら、実装です。

データベース選択の例

PostgreSQL固有のコードが見つかったとします。

from sqlalchemy.dialects.postgresql import JSONB, ARRAY

class Candidate(Base):
    skills = Column(ARRAY(String))
    metadata = Column(JSONB)

ほとんどの場合、実装です。 仕様には次のように記述します。

entity Candidate {
    skills: Set<String>
    metadata: String?              -- or model specific fields
}

特定のデータベースがドメインレベルの要素になることは、ほとんどありません。例外は、システムがPostgreSQLとの互換性やPostgreSQL固有の機能をユーザーに明示的に約束している場合です。

サードパーティ連携の例

Greenhouse ATS との連携を見つけたとします。

class GreenhouseSync:
    def import_candidate(self, greenhouse_id: str) -> Candidate:
        data = self.client.get_candidate(greenhouse_id)
        return Candidate(
            name=data['name'],
            email=data['email'],
            greenhouse_id=greenhouse_id,
            source='greenhouse'
        )

次のどちらかです。

実装レベルの場合:

  • 候補者の出所がたまたま Greenhouse であるだけ
  • Lever、Workable などに置き換えられる
  • この連携は「候補者をインポートする」という機能の実装詳細である

仕様:

external entity Candidate {
    name: String
    email: String
    source: CandidateSource
}

プロダクトレベルの場合:

  • 「Greenhouse 連携」がセールスポイントである
  • ユーザーが Greenhouse との接続を設定する
  • フィードバックを同期して戻すなど、Greenhouse 固有の機能が公開されている

仕様:

external entity Candidate {
    name: String
    email: String
    greenhouse_id: String?  -- explicitly modeled
}

rule SyncFromGreenhouse {
    when: GreenhouseWebhookReceived(candidate_data)
    ensures: Candidate.created(
        ...
        greenhouse_id: candidate_data.id
    )
}
「複数の実装」ヒューリスティック

コードベースにバリエーションがあるか確認します。

  • OAuth プロバイダーが 1 つだけなら、おそらく実装レベルです
  • OAuth プロバイダーが複数あるなら、おそらくドメインレベルです
  • 通知チャネルが 1 つだけなら、おそらく実装レベルです
  • Slack、メール、SMS があるなら、おそらくドメインレベルです

複数の実装が存在することは、そのバリエーション自体がドメイン上の関心事であることを示唆します。

抽出プロセス

抽出では多くのコードを読み取りますが、生成する仕様は簡潔です。コストのかかる失敗は、読み取ったソースを 1 つのコンテキストウィンドウにすべて蓄積し、毎ターン読み直すことです。作業セットを小さく保ちましょう。読み取り負荷の高い手順はサブエージェントに委任し、抽出した結果だけを保持します。

オーケストレーションモデル

数個を超えるファイルを扱う場合、コードベース全体を自分で読み込まないでください。代わりに、次の手順を実行します。

  1. マッピング。 コードベースを境界づけられたコンテキストに分類します。これは簡単なスキャン(ステップ 1)であり、深く読み込む必要はありません。
  2. 並行処理。 境界づけられたコンテキストごとにサブエージェントを 1 つ起動します。各サブエージェントは担当範囲だけを読み、抽出した断片を返します。断片には、エンティティ案(状態と遷移エッジ)、ルール案(トリガー/前提条件/保証)、外部境界、アクター、設定を含め、それぞれに file:line の根拠を添えます。サブエージェントが返すのは仕様の断片と根拠だけであり、生のソースは返しません。各サブエージェントには、対象パス、マップで定義した共有エンティティ語彙(コンテキスト間で名称を統一するため)、およびステップ 2~5 の抽出ガイダンスを渡します。説明文ではなく、簡潔な断片を返すよう依頼します。
  3. 統合。 オーケストレーターであるあなたが保持するのは、マップと返却された断片だけであり、ソースは保持しません。断片を 1 つの仕様にまとめます。横断的なエンティティ(Email、Notification、AuditLog)の重複を除き、用語を統一し(1 つの概念につき 1 つの名称。課題のリファレンスを参照)、コンテキスト間の参照を解決します。
  4. 統合した仕様を抽象化し、検証します(ステップ 6~7)。

これが重要な理由:生のソースがコンテキストに蓄積されないため、ターンごとに再処理する必要がありません。各サブエージェントの担当範囲は、断片を返した時点で破棄されます。関連する行はすべて読み取りますが、一度にすべてを読む必要も、繰り返し読む必要もありません。結果として、トークンを大幅に抑えながら、同じ仕様を得られます。

本当に小規模なコードベース(数個のファイル)では、並行処理のオーバーヘッドに見合う価値はありません。直接読み込み、ステップ 1~7 をその場で適用してください。

ステップ 1:全体像を把握する

コードベースを深く読み込まずにスキャンし、境界づけられたコンテキストと共有語彙を特定して、並行処理の計画を立てます。次の項目を特定してください。

  1. エントリーポイント。 API ルート、CLI コマンド、メッセージハンドラー、スケジュール済みジョブ。
  2. ドメインモデル。 通常は models/、entities/、domain/ にあります。
  3. ビジネスロジック。 サービス、ユースケース、ハンドラー。
  4. 外部連携。 どのサードパーティと通信するか。
  5. 境界づけられたコンテキスト。 上記を、まとまりのある範囲(モジュール、パッケージ、機能領域別)にグループ化します。これらが並行処理の単位になります。複数の範囲に登場するエンティティを記録します。これらは、すべてのサブエージェントが一貫して使うべき共有語彙です。

大まかなマップを作成します。

Entry points:
  - API: /api/candidates/*, /api/interviews/*, /api/invitations/*
  - Webhooks: /webhooks/greenhouse, /webhooks/calendar
  - Jobs: send_reminders, expire_invitations, sync_calendars

Models:
  - Candidate, Interview, InterviewSlot, Invitation, Feedback

Services:
  - SchedulingService, NotificationService, CalendarService

Integrations:
  - Google Calendar, Slack, Greenhouse, SendGrid

Bounded contexts (fan-out units):
  - scheduling: Interview, InterviewSlot (SchedulingService, /api/interviews)
  - invitations: Invitation, Feedback (/api/invitations, expire job)
  - intake: Candidate (Greenhouse webhook) — external
Shared entities: Candidate, Interview (appear across contexts)

ステップ 2~5 は、並行処理で各サブエージェントが担当範囲に適用する抽出ガイダンスです(小規模なコードベースでは、あなたが直接適用します)。対象パスと共有語彙とともに各サブエージェントに渡し、断片を集めてオーケストレーションモデルに従って統合します。

ステップ 2:エンティティの状態を抽出する

列挙型のフィールドやステータス列を確認します。

class Invitation(Base):
    status = Column(Enum('pending', 'accepted', 'declined', 'expired'))

次のようになります。

entity Invitation {
    status: pending | accepted | declined | expired
}

列挙型の定義、ステータス列や状態列、STATUS_PENDING = 'pending' のような定数、状態マシンライブラリ(例:transitions、django-fsm)を探します。

ステップ 2.5:候補となるプロセスを特定する

エンティティとその状態を抽出した後、エンドツーエンドのプロセスを示唆する状態マシンを探します。コードベース全体で各ステータス値が設定される箇所を追跡します(status = 'interviewing' が設定されるのはどこでしょうか?)。候補となるプロセスをユーザーに提示し、確認してもらいます。「applied → screening → interviewing → deciding → hired/rejected の状態を持つエンティティが見つかりました。このシステムで対応するプロセスでしょうか?」

エンティティ間のデータフローも追跡します。エンティティ A のルールでエンティティ B のフィールドが必要な場合、その連鎖を追います。エンティティ B のフィールドはどこで設定され、何がその処理をトリガーするのでしょうか。連鎖を提示します。「採用の判断には background_check_status = clear が必要です。この値は /api/webhooks/background-check の webhook ハンドラーで設定されています。この連鎖で正しいでしょうか?」

抽出したルールから遷移グラフを生成します。このグラフはコードから導出したビューです。グラフに抜けがある場合(終端状態ではないのに、そこからの遷移がない状態など)、潜在的な問題として指摘します。

ステップ 3:遷移を抽出する

ステータスが変更される箇所を探します。

def accept_invitation(invitation_id: int, slot_id: int):
    invitation = get_invitation(invitation_id)

    if invitation.status != 'pending':
        raise InvalidStateError()
    if invitation.expires_at < datetime.utcnow():
        raise ExpiredError()

    slot = get_slot(slot_id)
    if slot not in invitation.slots:
        raise InvalidSlotError()

    invitation.status = 'accepted'
    slot.status = 'booked'

    # Release other slots
    for other_slot in invitation.slots:
        if other_slot.id != slot_id:
            other_slot.status = 'available'

    # Create the interview
    interview = Interview(
        candidate_id=invitation.candidate_id,
        slot_id=slot_id,
        status='scheduled'
    )

    notify_interviewers(interview)
    send_confirmation_email(invitation.candidate, interview)

抽出結果:

rule CandidateAcceptsInvitation {
    when: CandidateAccepts(invitation, slot)

    requires: invitation.status = pending
    requires: invitation.expires_at > now
    requires: slot in invitation.slots

    ensures: invitation.status = accepted
    ensures: slot.status = booked
    ensures:
        for s in invitation.slots:
            if s != slot: s.status = available
    ensures: Interview.created(
        candidacy: invitation.candidacy,
        slot: slot,
        status: scheduled
    )
    ensures: Notification.created(to: slot.interviewers, ...)
    ensures: Email.created(to: invitation.candidate.email, ...)
}

主な抽出パターン:

コードパターン仕様パターン
if x.status != 'pending': raiserequires: x.status = pending
if x.expires_at < now: raiserequires: x.expires_at > now
if item not in collection: raiserequires: item in collection
x.status = 'accepted'ensures: x.status = accepted
Model.create(...)ensures: Model.created(...)
send_email(...)ensures: Email.created(...)
notify(...)ensures: Notification.created(...)

コード内で見つかったアサーション、チェック、検証(例:assert balance >= 0、クラスレベルのバリデーター)は、ルールの事前条件ではなく、式を伴う不変条件に対応する場合があります。それらがシステム全体の性質を表すのか、ルール固有のガードを表すのかを検討してください。

ステップ 4:時間的なトリガーを見つける

スケジュールされたジョブや時間に基づくロジックを探します。

# In celery tasks or cron jobs
@app.task
def expire_invitations():
    expired = Invitation.query.filter(
        Invitation.status == 'pending',
        Invitation.expires_at < datetime.utcnow()
    ).all()

    for invitation in expired:
        invitation.status = 'expired'
        for slot in invitation.slots:
            slot.status = 'available'
        notify_candidate_expired(invitation)

@app.task
def send_reminders():
    upcoming = Interview.query.filter(
        Interview.status == 'scheduled',
        Interview.slot.time.between(
            datetime.utcnow() + timedelta(hours=1),
            datetime.utcnow() + timedelta(hours=2)
        )
    ).all()

    for interview in upcoming:
        send_reminder_notification(interview)

抽出結果:

rule InvitationExpires {
    when: invitation: Invitation.expires_at <= now
    requires: invitation.status = pending

    ensures: invitation.status = expired
    ensures:
        for s in invitation.slots:
            s.status = available
    ensures: CandidateInformed(candidate: invitation.candidate, about: invitation_expired)
}

rule InterviewReminder {
    when: interview: Interview.slot.time - 1.hour <= now
    requires: interview.status = scheduled

    ensures: Notification.created(to: interview.interviewers, template: reminder)
}
ステップ 5:外部との境界を特定する

サードパーティ API 呼び出し、webhook ハンドラー、インポート/エクスポート関数、および読み込みのみ(または書き込みのみ)のデータを探します。

これらは多くの場合、外部エンティティを示しています。

# Candidate data comes from Greenhouse, we don't create it
def import_from_greenhouse(webhook_data):
    candidate = Candidate.query.filter_by(
        greenhouse_id=webhook_data['id']
    ).first()

    if not candidate:
        candidate = Candidate(greenhouse_id=webhook_data['id'])

    candidate.name = webhook_data['name']
    candidate.email = webhook_data['email']

次のことを示唆しています。

external entity Candidate {
    name: String
    email: String
}

サービス境界をまたいで同じインターフェースパターンが繰り返し見られる場合(例:複数の利用側が同じシリアライズ契約を想定している場合)、義務ブロックをインラインで重複させるのではなく、再利用のための contract 宣言が適しています。

ステップ 5.5:認証パターンからアクターを特定する

API エンドポイントから対象範囲を抽出した後、認証と認可のパターンを調べてアクターを特定します。認証コンテキストの違いは、異なるアクターを示唆します。

  • API キー認証 → システムアクター(外部サービス)
  • ロールベースのアクセス(user.role == 'admin')→ ロールごとに異なるアクター
  • スコープ付きアクセス(user.org_id == resource.org_id)→ within スコープを持つアクター
  • 認証されていないエンドポイント → 公開向けアクターまたはシステム webhook

ユーザーに確認します。「このエンドポイントでは管理者ロールの認証が必要です。『管理者』は独立したアクターですか。それとも、権限を昇格した通常のユーザーと同一人物ですか?」

ステップ 6:組み立てて抽象化する

作業を分担していた場合は、まず返された断片を 1 つの仕様に組み立てます。すべてのエンティティ、ルール、境界、設定値を集め、横断的なエンティティの重複を取り除き、用語を調整して、各概念に名前が 1 つだけ付くようにします(2 つの文脈で異なる名前が付いていた概念は、ここで統一する必要があります。下記の「用語の重複に関する課題」を参照してください)。次に、組み立てた仕様を見直し、実装の詳細を取り除きます。

変換前(具体的すぎる例):

entity Invitation {
    candidate_id: Integer
    token: String(32)
    created_at: DateTime
    expires_at: DateTime
    status: pending | accepted | declined | expired
}

変換後(ドメインレベル):

entity Invitation {
    candidacy: Candidacy
    created_at: Timestamp
    expires_at: Timestamp
    status: pending | accepted | declined | expired

    is_expired: expires_at <= now
}

変更点:

  • candidate_id: Integer は candidacy: Candidacy に変更しました(外部キーではなく、関係を表します)。
  • token: String(32) は削除しました(実装の詳細です)。
  • DateTime は Timestamp に変更しました(ドメイン型です)。
  • 明確さを高めるため、派生値 is_expired を追加しました。

他の設定値から導出される設定値(例:extended_timeout = base_timeout * 2)は、独立したリテラル値としてではなく、修飾付きの参照または式形式のデフォルト値を設定ブロック内で使用してください。

ステップ 7:ステークホルダーと検証する

抽出した仕様は仮説です。検証してください。

  1. 元の開発者に仕様を見せます。「システムの動作はこれで合っていますか?」
  2. ステークホルダーに見せます。「システムの動作はこれであるべきですか?」
  3. 不足がないか確認します。 コードにはバグや未実装の機能があることが多く、仕様によってそれらが明らかになる場合があります。

よくある指摘:

  • 「ああ、その再試行ロジックはその場しのぎだったので、削除すべきです」
  • 「実際には X を実現したかったのですが、結局実装しませんでした」
  • 「この 2 つのコードパスは同じであるべきですが、そうなっていません」

さらに確認を進める前に、仕様の評価を読み、抽出した仕様の成熟度を見極めてください。これにより、仕様がプロセスレベルの分析に進める状態か、まだ構造上の作業が必要かを判断できます。

Allium CLI が利用できる場合は、抽出した仕様に対して allium check を実行して構造上の問題を検出し、続いて allium analyse を実行してプロセスレベルの不足を特定してください。analyse の結果は、検証のための質問につながります。「抽出した仕様には background_check.status = clear を必須とするルールがありますが、バックグラウンドチェックの結果を取得する箇所がありません。まだ確認していないコードベースの一部で処理されているのでしょうか?」結果をドメインに関する質問へ変換する方法については、結果への対応を参照してください。

ライブラリ仕様の候補を見極める

抽出作業中は、アプリケーション固有のロジックではなく、汎用的な統合パターンを実装するコードに注意してください。これらはライブラリ仕様に属します。判断の枠組み(確認すべき質問、対処方法、よくある抽出例)の全容については、ライブラリ仕様の機会を見極めるを参照してください。

コード内の兆候

ライブラリ仕様の可能性を示す、次のようなパターンを探してください。

サードパーティ統合モジュール:

class StripeWebhookHandler:
    def handle_invoice_paid(self, event):
        ...

class GoogleOAuthProvider:
    def exchange_code(self, code):
        ...

設定駆動型の統合:

OAUTH_CONFIG = {
    'google': {'client_id': ..., 'scopes': ...},
    'microsoft': {'client_id': ..., 'scopes': ...},
}

特定のプロバイダーを使った汎用パターン: OAuth フロー、決済処理、メール配信、カレンダー同期、ATS 統合、ファイルストレージ。

注意信号:仕様に統合ロジックが含まれている

仕様を次のように書いていることに気づいたら、いったん止めて見直してください。

-- TOO DETAILED - this is Stripe's domain, not yours
rule ProcessStripeWebhook {
    when: WebhookReceived(payload, signature)
    requires: verify_stripe_signature(payload, signature)
    let event = parse_stripe_event(payload)
    if event.type = "invoice.paid":
        ...
}

代わりに、次のようにします。

-- Application responds to payment events (integration handled elsewhere)
rule PaymentReceived {
    when: stripe/InvoicePaid(invoice)
    ...
}

ライブラリ仕様の統合例については、patterns.md のパターン 8を参照してください。

抽出でよくある課題

課題:用語の重複

同じ概念に対して2つの用語が使われている場合(複数の仕様間、1つの仕様内、または仕様とコードの間)、阻害要因として扱ってください。

-- BAD: Acknowledges duplication without resolving it
-- Order vs Purchase
-- checkout.allium uses "Purchase" - these are equivalent concepts.

これは解決策ではありません。コードベースの異なる部分が異なる仕様に基づいて構築されると、両方の用語が実装に残り、モデルの重複、冗長な結合テーブル、双方向を指す外部キーが発生します。

対処方法:

  • 用語を1つ選びます。決定する前に、関連する仕様を相互参照してください。
  • すべての参照を更新します。コメントや「関連項目」メモに古い用語を残さないでください。
  • 名前の変更は、仕様自体ではなく変更履歴に記載します。

コード内の警告サイン:

  • 同じ概念を表す2つのモデル(Order と Purchase)
  • 両方の結合テーブル(order_items、purchase_items)
  • 「Xと同等」や「Yと同じ」といったコメント

抽出する仕様では、用語を1つ選んでください。もう一方は、削除すべき技術的負債として記録します。

課題:暗黙的な状態機械

コードには、モデル化されていない暗黙的な状態がよくあります。

# No explicit status field, but there's a state machine hiding here
class FeedbackRequest:
    interview_id = Column(Integer)
    interviewer_id = Column(Integer)
    requested_at = Column(DateTime)
    reminded_at = Column(DateTime, nullable=True)
    feedback_id = Column(Integer, nullable=True)  # FK to Feedback if submitted

暗黙的な状態は次のとおりです。

  • pending:requested_at が設定され、feedback_id が null、reminded_at が null
  • reminded:reminded_at が設定され、feedback_id が null
  • submitted:feedback_id が設定されている

明示的な状態として抽出します。

entity FeedbackRequest {
    interview: Interview
    interviewer: Interviewer
    requested_at: Timestamp
    reminded_at: Timestamp?
    status: pending | reminded | submitted
}
課題:分散したロジック

同じ概念上のルールが、複数の場所に分散している場合があります。

# In API handler
def accept_invitation(request):
    if invitation.status != 'pending':
        return error(400, "Already responded")
    ...

# In model
class Invitation:
    def can_accept(self):
        return self.expires_at > datetime.utcnow()

# In service
def process_acceptance(invitation, slot):
    if slot not in invitation.slots:
        raise InvalidSlot()
    ...

1つのルールに統合します。

rule CandidateAccepts {
    when: CandidateAccepts(invitation, slot)

    requires: invitation.status = pending
    requires: invitation.expires_at > now
    requires: slot in invitation.slots
    ...
}
課題:デッドコードと過去の偶発的な実装

コードベースには、構築されたものの使われなかった機能、すでに修正されたバグへの回避策、実行されることのないコードパスが蓄積していきます。

これらを仕様に含めないでください。判断に迷う場合は、次の点を確認してください。

  1. そのコードが実際に到達可能かどうかを確認する
  2. 意図的なものか、開発者に尋ねる
  3. 背景を把握するために、git の履歴を確認する
課題:エラー処理の不足

コードでは、エラーが黙って無視されたり、エラー処理が不完全だったりする場合があります。

def send_notification(user, message):
    try:
        slack.send(user.slack_id, message)
    except SlackError:
        pass  # Silently ignore failures

仕様には、バグではなく意図された動作を記載します。

ensures: Notification.created(to: user, channel: slack)

現在の実装が障害を適切に処理しているかどうかは、システムがどう動作すべきかとは別の問題です。

課題:過剰に設計された抽象化

エンタープライズのコードベースには、意図をわかりにくくする抽象化レイヤーがよくあります。

public interface NotificationStrategy {
    void notify(NotificationContext context);
}

public class SlackNotificationStrategy implements NotificationStrategy {
    @Override
    public void notify(NotificationContext context) {
        // Actual Slack call buried 5 levels deep
    }
}

実際の動作を明らかにしてください。仕様にストラテジーパターン、依存性注入、抽象ファクトリーは必要ありません。「ensures: Notification.created(channel: slack, ...)」だけで十分です。

チェックリスト:十分に抽象化できていますか?

抽象化した仕様を確定する前に、次の点を確認してください。

  • データベースのカラム型(Integer、VARCHAR など)が含まれていない
  • ORM やクエリーの構文が含まれていない
  • HTTP ステータスコードや API パスが含まれていない
  • フレームワーク固有の概念(ミドルウェア、デコレーターなど)が含まれていない
  • プログラミング言語の型(int、str、List など)が含まれていない
  • コード内の変数名が含まれていない(ドメイン用語を使う)
  • インフラストラクチャ(Redis、Kafka、S3 など)が含まれていない
  • 外部キーが関係性に置き換えられている
  • トークンやシークレット(ID の実装)が削除されている
  • タイムスタンプに timedelta や秒数ではなく、ドメインの Duration が使われている

これらが残っている場合は、「ステークホルダーはこれを要件書に含めるだろうか?」と自問してください。

チェックリスト:用語の一貫性

  • 仕様全体を通して、各概念の名称が1つに統一されている
  • 「別名」や「同等」といったコメントがない
  • 用語が競合する関連仕様を相互参照した
  • コード内の重複モデルが、削除すべき技術的負債として記録されている

抽象化の後

抽出した仕様は出発点です。抽象化の過程で、体系的な調査が必要な不足事項(不明確な要件、複雑なエンティティの関係、明記されていないビジネスルール)が明らかになった場合は、elicit スキルを使って補完してください。要件の変化に応じた対象を絞った変更には、tend スキルを使います。仕様と実装の継続的な整合性を確認するには、weed スキルを使います。

型付き結果(ループへの引き渡し)

Allium ループ内の distill サブエージェントとして実行する場合は、distill-result.schema.json に準拠した単一の JSON オブジェクトのみを返してください。内容は、spec_path、保留中の open_questions、仕様の対象範囲を一行で示す summary です。すべてのフィールドを出力し、リストが空の場合は [] を使用してください。読み取ったソースは呼び出し元のコンテキストには含まれず、これらのフィールドだけが返されます。対話形式で実行する場合は、通常どおり調査結果を文章で提示してください。型付きレコードは会話ではなく、機械への引き渡しに使用します。

{
  "phase": "distill",
  "spec_path": "giftcard.allium",
  "open_questions": ["Is forcing an over-redeemed balance to zero intended or accidental?"],
  "summary": "GiftCard redemption and status lifecycle"
}

参考資料

  • 言語リファレンス:Allium の構文全般
  • 仕様の評価:仕様の成熟度を評価し、適切な分析レベルを選ぶ方法
  • 調査結果への対応:チェッカーの調査結果をドメインに関する質問へと変換する方法
  • 実例:Python、TypeScript、Java を使った、コードから仕様を作成する一連の例

コメント