distill について
distillは、既存のコードベースからAllium仕様を抽出するスキルです。対象範囲を定めてドメイン上の振る舞いを見極め、実装の詳細を除外します。大規模なコード調査はコンテキストごとに分割できます。
主な機能
- 01ドメイン上の振る舞いの抽出
- 02仕様の対象範囲の設定
- 03要件と実装の切り分け
- 04大規模コードベースの分割
- 05未確認の判断の記録
活用シーン
- 01既存コードからサービス仕様を記述
- 02仕様からインフラの詳細を除外
- 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つのサービスやドメインだけを仕様化すればよい場合もあります。作業を始める前に、境界を明確にしてください。
-
「意図的に除外すべきコードはありますか?」
- レガシーコード:後方互換性のために維持されているが、コアシステムの一部ではない機能
- 付随的なコード:ドメインレベルではないサポート用インフラストラクチャ(ロギング、メトリクス、デプロイ)
- 非推奨の経路:削除が予定されているコード
- 実験的な機能:フィーチャーフラグの背後にあり、まだ設計上の決定に至っていないもの
-
「この仕様の担当者は誰ですか?」 モノレポの各部分を異なるチームが担当している場合があります。各チームの仕様は、そのチームのドメインに焦点を当てる必要があります。
「これを再構築するか?」テスト
コードの経路に出会うたびに、「このシステムをゼロから再構築するとしたら、これは要件に含まれるか?」と考えます。
- はい:仕様に含める
- いいえ、レガシーです:除外する
- いいえ、インフラストラクチャです:除外する
- いいえ、回避策です:除外する(ただし、それが解決している根本的なニーズは記録する)
範囲に関する判断を記録する
蒸留した仕様の冒頭に、含めるものと除外するものを記載します。
-- 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 つ起動します。各サブエージェントは担当範囲だけを読み、抽出した断片を返します。断片には、エンティティ案(状態と遷移エッジ)、ルール案(トリガー/前提条件/保証)、外部境界、アクター、設定を含め、それぞれに
file:lineの根拠を添えます。サブエージェントが返すのは仕様の断片と根拠だけであり、生のソースは返しません。各サブエージェントには、対象パス、マップで定義した共有エンティティ語彙(コンテキスト間で名称を統一するため)、およびステップ 2~5 の抽出ガイダンスを渡します。説明文ではなく、簡潔な断片を返すよう依頼します。 - 統合。 オーケストレーターであるあなたが保持するのは、マップと返却された断片だけであり、ソースは保持しません。断片を 1 つの仕様にまとめます。横断的なエンティティ(
Email、Notification、AuditLog)の重複を除き、用語を統一し(1 つの概念につき 1 つの名称。課題のリファレンスを参照)、コンテキスト間の参照を解決します。 - 統合した仕様を抽象化し、検証します(ステップ 6~7)。
これが重要な理由:生のソースがコンテキストに蓄積されないため、ターンごとに再処理する必要がありません。各サブエージェントの担当範囲は、断片を返した時点で破棄されます。関連する行はすべて読み取りますが、一度にすべてを読む必要も、繰り返し読む必要もありません。結果として、トークンを大幅に抑えながら、同じ仕様を得られます。
本当に小規模なコードベース(数個のファイル)では、並行処理のオーバーヘッドに見合う価値はありません。直接読み込み、ステップ 1~7 をその場で適用してください。
ステップ 1:全体像を把握する
コードベースを深く読み込まずにスキャンし、境界づけられたコンテキストと共有語彙を特定して、並行処理の計画を立てます。次の項目を特定してください。
- エントリーポイント。 API ルート、CLI コマンド、メッセージハンドラー、スケジュール済みジョブ。
- ドメインモデル。 通常は
models/、entities/、domain/にあります。 - ビジネスロジック。 サービス、ユースケース、ハンドラー。
- 外部連携。 どのサードパーティと通信するか。
- 境界づけられたコンテキスト。 上記を、まとまりのある範囲(モジュール、パッケージ、機能領域別)にグループ化します。これらが並行処理の単位になります。複数の範囲に登場するエンティティを記録します。これらは、すべてのサブエージェントが一貫して使うべき共有語彙です。
大まかなマップを作成します。
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': raise | requires: x.status = pending |
if x.expires_at < now: raise | requires: x.expires_at > now |
if item not in collection: raise | requires: 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:ステークホルダーと検証する
抽出した仕様は仮説です。検証してください。
- 元の開発者に仕様を見せます。「システムの動作はこれで合っていますか?」
- ステークホルダーに見せます。「システムの動作はこれであるべきですか?」
- 不足がないか確認します。 コードにはバグや未実装の機能があることが多く、仕様によってそれらが明らかになる場合があります。
よくある指摘:
- 「ああ、その再試行ロジックはその場しのぎだったので、削除すべきです」
- 「実際には 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 が nullreminded:reminded_at が設定され、feedback_id が nullsubmitted: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
...
}
課題:デッドコードと過去の偶発的な実装
コードベースには、構築されたものの使われなかった機能、すでに修正されたバグへの回避策、実行されることのないコードパスが蓄積していきます。
これらを仕様に含めないでください。判断に迷う場合は、次の点を確認してください。
- そのコードが実際に到達可能かどうかを確認する
- 意図的なものか、開発者に尋ねる
- 背景を把握するために、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"
}