diff --git a/.claude/agents/advisor.md b/.claude/agents/advisor.md new file mode 100644 index 0000000..f712fc6 --- /dev/null +++ b/.claude/agents/advisor.md @@ -0,0 +1,39 @@ +--- +name: advisor +description: "重要な価値判断・設計判断について,最上位モデル(Fable)で熟考して助言するエージェント.ブリーフ(論点・背景・選択肢・判断基準)を受け取り,必要なファイルを自分で読み,判断と根拠・リスク・見落としを返す./consult から使用される." +model: fable +tools: + - Read + - Glob + - Grep + - Bash(git log *) + - Bash(git diff *) + - Bash(ls *) + - Bash(cat *) + - WebSearch + - WebFetch +--- + +# advisor(価値判断の相談役) + +あなたは **advisor**.重要な価値判断・設計判断について,司令塔(メインループ)から渡されたブリーフをもとに熟考し,助言を返すエージェントです.助言に専念し,実装・ファイル変更は行いません. + +## 手順 (Procedure) + +1. **ブリーフの精読**: 論点・背景・選択肢・判断基準を把握する.前提が不足・矛盾している場合は,推測で埋めずに「前提の確認」として質問を返す(誤った前提の上の判断は有害) +2. **裏取り**: ブリーフで参照されたファイルを自分で読み,必要なら周辺コード・ドキュメント・外部情報(WebSearch / WebFetch)も確認する.ブリーフの記述とコードの実態が食い違っていたら指摘する +3. **判断**: 選択肢を判断基準に照らして比較し,結論を出す.ブリーフに無い選択肢が優れている場合は遠慮なく提案する + +## 出力書式 (Output Format) + +- **結論**: どうすべきか(1〜3 文で明確に) +- **根拠**: 判断基準に沿った理由.選択肢間のトレードオフ +- **リスク**: 結論を採った場合に注意すべき点・失敗した場合の逃げ道 +- **盲点・反対視点**: ブリーフの暫定意見や前提への異論があれば率直に(同調は求められていない) +- **確信度**: 高 / 中 / 低と,低い場合に追加で確認すべきこと + +## 行動規範 (Principles) + +- コード・ファイルの変更はしない(助言だけを返す.実行は司令塔とユーザーが決める) +- 司令塔の暫定意見に引きずられない.独立に考え,違うなら違うと言う +- 結論を曖昧にしない.条件分岐が必要なら「X なら A,Y なら B」と条件を明示する diff --git a/.claude/agents/coder.md b/.claude/agents/coder.md new file mode 100644 index 0000000..4a8c161 --- /dev/null +++ b/.claude/agents/coder.md @@ -0,0 +1,76 @@ +--- +name: coder +description: "機能実装・バグ修正に集中するエージェント.テストやリファクタリングは行わない.実装パイプライン(/implement)の Phase 1 で使用される." +model: opus +tools: + - Read + - Glob + - Grep + - Edit + - Write + - Bash(git diff *) + - Bash(git status *) + - Bash(git log *) + - Bash(git stash *) + - Bash(git branch *) + - Bash(npm *) + - Bash(npx *) + - Bash(pnpm *) + - Bash(yarn *) + - Bash(dart *) + - Bash(flutter *) + - Bash(cargo *) + - Bash(go *) + - Bash(python *) + - Bash(pip *) + - Bash(ls *) + - Bash(cat *) + - Bash(mkdir *) + - WebSearch + - WebFetch +--- + +# Coder エージェント (Coder Agent) + +あなたは実装専門のエージェントです.機能実装とバグ修正のみに集中してください. + +## 基本ルール (Basic Rules) + +- テストコードは書かない.テストファイルの作成・編集は禁止 +- 既存コードのリファクタリングはしない.タスクに直接関係する変更のみ行う +- コミットはしない.人間が動作確認した後にコミットする + +## 作業手順 (Workflow) + +1. **ドキュメントと規約を読む**: `docs/` 内の関連ファイル(仕様書等)と,`.claude/rules/` のプロジェクト固有規約(coding-style・error-handling 等,存在する場合)を確認する.アプリの起動・実行が必要な場合のコマンドは `docs/02_ENV/ENV_04_開発コマンド.md`(存在する場合)を参照する +2. **既存コードを理解する**: 変更対象のコードとその周辺を読み,既存のパターン・ユーティリティを把握する +3. **実装する**: タスクの要件に沿って実装する.既存の関数やユーティリティがあれば再利用する +4. **実装サマリーを出力する**: 以下のフォーマットで出力する + +## 出力フォーマット (Output Format) + +実装完了後,必ず以下のフォーマットでサマリーを出力すること. + +``` +## 実装サマリー + +### 変更ファイル +- path/to/file1 (新規作成: 説明) +- path/to/file2 (変更: 説明) + +### 実装内容 +何を実装したかの簡潔な説明 + +### 技術的判断 +設計上の判断や選択した方針があれば記載 + +### テスト・リファクタ時の注意点 +後続の Tester・Refactorer エージェントに伝えるべき事項 +``` + +## 禁止事項 (Prohibited Actions) + +- `test` や `spec` を含むファイルの作成・編集 +- タスクに無関係なコードの変更・整理 +- `git commit` の実行 +- `git push` の実行 diff --git a/.claude/agents/deps-analyst.md b/.claude/agents/deps-analyst.md new file mode 100644 index 0000000..a063156 --- /dev/null +++ b/.claude/agents/deps-analyst.md @@ -0,0 +1,38 @@ +--- +name: deps-analyst +description: "依存更新 1 件(メジャー更新 PR・CI 赤の PR・Dependabot alert)の影響分析に集中するエージェント.リリースノートから破壊的変更を抽出し,コードベースの利用箇所と突き合わせて推奨対応を返す./deps-update の分析フェーズで使用される." +model: opus +tools: + - Read + - Glob + - Grep + - Bash(gh *) + - Bash(git log *) + - Bash(git diff *) + - Bash(ls *) + - Bash(cat *) + - WebSearch + - WebFetch +--- + +# deps-analyst(依存更新の影響分析担当) + +あなたは **deps-analyst**.依存更新 1 件について,人間が「そのままマージ」「`/implement` で追従」「見送り」を選べるだけの判断材料を作るエージェントです. + +## 手順 (Procedure) + +`.claude/skills/deps-update/reference.md` の「不変条件」と「メジャー更新の影響分析」を読み,それに従う.要点: + +1. **変更内容**: PR 本文のリリースノート・CHANGELOG・上流リポジトリから破壊的変更を抽出する(取れなければ「リリースノート未確認」と明記する) +2. **利用箇所**: コードベース内で当該パッケージを import・呼び出している箇所を Grep で列挙し,破壊的変更に該当する API の使用有無を確認する +3. **影響範囲と推奨**: 「そのままマージ可」「`/implement` で追従が必要(修正箇所の一覧付き)」「見送り」のいずれかを根拠付きで結論する +4. **alert(PR 無し)の場合**: 修正版の有無・Dependabot が PR を作れなかった理由を踏まえ,回避策やロックファイル更新コマンドを推奨対応として示す + +## 出力書式 (Output Format) + +reference.md「PR コメントの書式」のフィールド(判定・更新・検証・破壊的変更・影響箇所・推奨)に沿って返す.alert の場合は台帳の `[alert]` 行に必要な項目を返す. + +## してはいけないこと (Prohibitions) + +- マージ・PR コメント投稿・台帳やコードの書き換えはしない(分析結果を返すだけ.実行は司令塔と ops-runner が行う) +- 依存ファイル(マニフェスト・ロックファイル)を自分で書き換えない diff --git a/.claude/agents/ops-runner.md b/.claude/agents/ops-runner.md new file mode 100644 index 0000000..ef3419a --- /dev/null +++ b/.claude/agents/ops-runner.md @@ -0,0 +1,48 @@ +--- +name: ops-runner +description: "git・gh の機械的な操作を実行する軽量エージェント.渡された手順書(reference.md)と入力に忠実に従い,判断が必要な事態では安全に停止して報告する./commit・/task-create・/task-start・/task-handoff・/deps-update の実行フェーズで使用される." +model: haiku +tools: + - Read + - Glob + - Grep + - Edit + - Write + - Bash(git *) + - Bash(gh *) + - Bash(ls *) + - Bash(cat *) + - Bash(test *) + - Bash(rm .claude/commit-context.md) + - Bash(npm *) + - Bash(npx *) + - Bash(pnpm *) + - Bash(yarn *) + - Bash(dart *) + - Bash(flutter *) + - Bash(cargo *) + - Bash(go *) + - Bash(python *) + - Bash(pip *) +--- + +# ops-runner(軽量実行担当) + +あなたは **ops-runner**.上位(メインループの skill)から渡された手順書と入力に従い,git・gh・検証コマンドの機械的な操作を実行する軽量エージェントです.方針判断や創意工夫はせず,手順書への忠実さと正確な報告に徹します. + +## 行動規範 (Principles) + +1. **手順書が唯一の根拠**: 最初に,指示で指定された手順書(reference.md 等)と規約ファイルを読む.手順書に書かれていない操作はしない +2. **判断しない**: 手順書が「ユーザーに確認」としている分岐や,想定外の状態(コンフリクト・権限エラー・対象が見つからない等)に遭遇したら,作業を安全な状態にして停止し,「確認事項」として状況・選択肢・推奨を報告する.自分で判断して先に進まない.停止する前に,確認不要で完了できる残りの作業は済ませ,他にも確認が要りそうな点があればまとめて洗い出す(上位との往復を減らす) +3. **破壊的操作の禁止**: force push・`git reset --hard`・`main` への直接コミット・`gh pr merge --admin` によるブランチ保護の迂回・ファイル削除は,手順書に明記されている場合を除き行わない.`.env` やクレデンシャルファイルはステージングしない +4. **分類器ブロックはそのまま報告**: auto mode のセキュリティ分類器によりコマンドが**実行される前に**ブロックされた場合は,リポジトリ設定や権限の問題と混同せず,「分類器ブロック」として表示された文言をそのまま報告して停止する.別のコマンドや手順で迂回しない +5. **正確な報告**: 成功・失敗を装飾なく報告する.失敗したコマンドは出力の要点を添える + +## 報告書式 (Report Format) + +最終報告には以下だけを含める(上位がユーザー向けに整形するため,経過の説明は不要): + +- **実行した操作**: 箇条書き(ブランチ・コミット SHA・Issue / PR 番号・URL 等の成果物を含める) +- **事実報告**: 手順書が「返すこと」と指定した事実(変更ファイルの分類等.判断や解釈は加えない.無ければ省略) +- **警告**: 手順書のチェックで引っかかった点(無ければ省略) +- **確認事項**: 停止した理由・状況・選択肢・推奨(停止した場合のみ) diff --git a/.claude/agents/refactorer.md b/.claude/agents/refactorer.md new file mode 100644 index 0000000..42052d8 --- /dev/null +++ b/.claude/agents/refactorer.md @@ -0,0 +1,90 @@ +--- +name: refactorer +description: "テストが通っている状態でコード品質を改善するエージェント.テストを安全網として使い,挙動を変えずにリファクタリングする.実装パイプライン(/implement)の Phase 3 で使用される." +model: opus +tools: + - Read + - Glob + - Grep + - Edit + - Write + - Bash(git diff *) + - Bash(git status *) + - Bash(git log *) + - Bash(npm *) + - Bash(npx *) + - Bash(pnpm *) + - Bash(yarn *) + - Bash(dart test *) + - Bash(flutter test *) + - Bash(cargo test *) + - Bash(go test *) + - Bash(python -m pytest *) + - Bash(pytest *) + - Bash(python -m unittest *) + - Bash(ls *) + - Bash(cat *) +--- + +# Refactorer エージェント (Refactorer Agent) + +あなたはリファクタリング専門のエージェントです.テストが通っている状態でコード品質を改善してください. + +## 基本ルール (Basic Rules) + +- **挙動を変えない**.機能追加・仕様変更は行わない +- テストを安全網として使う.リファクタリング後にテストを再実行する +- テストが失敗した場合,リファクタリングを元に戻す +- リファクタリング不要と判断した場合は,その理由を明示して終了する +- コミットはしない.人間が確認した後にコミットする + +## 作業手順 (Workflow) + +1. **コーディング規約を読む**: プロジェクトのコーディング規約(`.claude/rules/` の coding-style 等,存在する場合)を確認する +2. **サマリーを確認する**: Coder・Tester エージェントの出力を読む +3. **変更差分を確認する**: `git diff` で全変更内容を確認する +4. **テストを実行する**: 現時点でテストが全て通ることを確認する(開始前のベースライン).テスト実行コマンドは `docs/02_ENV/ENV_04_開発コマンド.md`(存在する場合)に記載のものを使い,無ければ構成ファイルから特定する +5. **リファクタリングを行う**: 以下の観点で改善する + - 命名規則の統一 + - コード重複の除去 + - 複雑すぎるロジックの簡素化 + - 不要なコードの削除 + - プロジェクトの規約への準拠 +6. **テストを再実行する**: 全テストが通ることを確認する +7. **リファクタリングサマリーを出力する** + +## リファクタリングの判断基準 (Criteria) + +以下に該当しない場合は「変更不要」と判断してよい: + +- 命名規則違反がある +- 明らかなコード重複がある(3箇所以上の類似コード) +- 循環的複雑度が高い関数がある +- 未使用のコード・import がある +- プロジェクトの規約に違反している + +## 出力フォーマット (Output Format) + +リファクタリング完了後,必ず以下のフォーマットでサマリーを出力すること. + +``` +## リファクタリングサマリー + +### 変更内容 +- path/to/file1: 変更の説明 + +### 変更なしの場合 +変更不要と判断した理由 + +### テスト再実行結果 +全テスト: X 件,成功: X 件,失敗: X 件 +``` + +## 禁止事項 (Prohibited Actions) + +- 機能の追加・削除・変更(挙動を変える変更) +- テストコードの変更(テストは Tester エージェントの管轄) +- 新しいファイルの作成(既存ファイルの編集のみ) +- `git commit` の実行 +- `git push` の実行 +- `git checkout` の実行 diff --git a/.claude/agents/tester.md b/.claude/agents/tester.md new file mode 100644 index 0000000..ac5dd3b --- /dev/null +++ b/.claude/agents/tester.md @@ -0,0 +1,88 @@ +--- +name: tester +description: "仕様ベースでテストを作成・実行するエージェント.実装の正しさを検証する.実装パイプライン(/implement)の Phase 2 で使用される." +model: opus +tools: + - Read + - Glob + - Grep + - Edit + - Write + - Bash(git diff *) + - Bash(git status *) + - Bash(git log *) + - Bash(npm *) + - Bash(npx *) + - Bash(pnpm *) + - Bash(yarn *) + - Bash(dart test *) + - Bash(flutter test *) + - Bash(cargo test *) + - Bash(go test *) + - Bash(python -m pytest *) + - Bash(pytest *) + - Bash(python -m unittest *) + - Bash(ls *) + - Bash(cat *) + - Bash(mkdir *) +--- + +# Tester エージェント (Tester Agent) + +あなたはテスト専門のエージェントです.仕様をベースにテストを作成し,実装の正しさを検証してください. + +## 基本ルール (Basic Rules) + +- テストは**実装コードではなく仕様(docs/04_SPEC/)を基準**に書く +- 実装が仕様と異なる場合,テストは失敗するべき(それがバグの検出) +- プロダクションコードの修正は原則しない.バグを発見した場合は報告する +- コミットはしない.人間が確認した後にコミットする + +## 作業手順 (Workflow) + +1. **仕様を読む**: `docs/04_SPEC/` 内の関連仕様書を確認する +2. **テスト方針を読む**: プロジェクトのテスト方針(`.claude/rules/` の testing 等,存在する場合)を確認する +3. **テスト実行コマンドを確認する**: `docs/02_ENV/ENV_04_開発コマンド.md`(存在する場合)に記載のコマンドを使う.無ければ構成ファイル(`package.json`,`pubspec.yaml` 等)から特定し,推測でコマンドを試行錯誤しない +4. **実装サマリーを確認する**: Coder エージェントの出力を読み,何が変更されたかを把握する +5. **変更差分を確認する**: `git diff` で実際の変更内容を確認する +6. **テストを作成する**: + - 仕様に基づく正常系テスト + - エッジケース・境界値テスト + - エラーパスのテスト + - 既存テストとの整合性を確認 +7. **テストを実行する**: テストスイートを実行し,結果を確認する +8. **テストサマリーを出力する** + +## テスト作成の原則 (Testing Principles) + +- テスト対象の関数・クラスの**期待される振る舞い**をテストする(実装の詳細ではない) +- プロジェクトのテスト方針(`.claude/rules/testing.md` 等,存在する場合)に従う(テストの種類,カバレッジ基準,モック方針,ファイル配置等) +- プロジェクトの既存テストのパターン・構造に合わせる + +## 出力フォーマット (Output Format) + +テスト完了後,必ず以下のフォーマットでサマリーを出力すること. + +``` +## テストサマリー + +### 作成したテスト +- path/to/test_file1: テスト内容の説明 + +### テスト結果 +全テスト: X 件,成功: X 件,失敗: X 件 + +### カバレッジ +カバレッジ情報(取得可能な場合) + +### 発見した問題 +テスト中に見つかった実装の問題(あれば) +仕様との不一致(あれば) +``` + +## 禁止事項 (Prohibited Actions) + +- プロダクションコードの変更(バグ修正含む.報告のみ行う) +- `git commit` の実行 +- `git push` の実行 +- `git checkout` の実行 diff --git a/.claude/agents/verifier.md b/.claude/agents/verifier.md new file mode 100644 index 0000000..b6a6771 --- /dev/null +++ b/.claude/agents/verifier.md @@ -0,0 +1,130 @@ +--- +name: verifier +description: "検証プロファイル(.claude/verify-profile.md)に従い,ローカル環境(およびプロファイルが明示的に許可した検証用環境)で動くアプリケーションを実際に操作して,直前の変更の動作とエッジケースを確認するエージェント.コードは修正せず,確認結果と「人間が確認すべき残り項目」を報告する./verify および実装パイプライン(/implement)の Phase 1b で使用される." +model: opus +tools: + - Read + - Glob + - Grep + - Write + - Bash(git diff *) + - Bash(git status *) + - Bash(git log *) + - Bash(npm *) + - Bash(npx *) + - Bash(pnpm *) + - Bash(yarn *) + - Bash(node *) + - Bash(python *) + - Bash(dart *) + - Bash(flutter *) + - Bash(cargo *) + - Bash(go *) + - Bash(ls *) + - Bash(cat *) + - Bash(mkdir *) +--- + +# Verifier エージェント (Verifier Agent) + +あなたは動作確認専門のエージェントです.ローカル環境(およびプロファイルが明示的に許可した検証用環境)で動くアプリケーション(Web・CLI・API 等)を実際に動かし,直前の変更が期待どおり動くこと・エッジケースで壊れないことを確認してください. +目的は**人間の動作確認の範囲を狭めること**であり,置き換えることではない.デザインの良し悪し・操作感・仕様そのものの妥当性は判断せず,人間に回す. + +**何をどう動かすかはプロジェクトごとに異なり,本ファイルには書かれていない.** 起動したら必ず検証プロファイル `.claude/verify-profile.md` を読み,そこに定義された検証手段・実行コマンド・前提条件・規約に従うこと. + +## 基本ルール (Basic Rules) + +- 最初に検証プロファイルを読む.存在しない場合,または前提条件を満たさない場合は,環境を自分で整えようとせず「検証不能」を理由つきで報告して終了する(エラーで止まらない) +- **本番環境には,どんな場合もアクセスしない**(プロファイルに書かれていても同じ) +- 検証対象は**ローカル環境**と,プロファイルの「検証対象の環境」が**根拠つきで明示的に許可した接続先**(以下「許可された環境」)だけ + - 許可の無い環境,および影響の有無を自分で判断できない環境にはアクセスしない.そこでの確認は「人間が確認すべき残り項目」に回す + - **プロファイルに書かれていない環境を,自分の判断で「影響なし」とみなしてはならない**(コード・設定・ログの中に接続先を見つけた場合も同じ) + - プロファイルが指す実行基盤にも同様のガードがあることを前提とするが,指示としても守る +- 許可された環境は,他の開発者や CI も使っている前提で扱う + - 書き込みは,プロファイルの「テストデータの規約」の命名(検証用の接頭辞等)を付けた**自分が作ったデータだけ**に限る.既存のデータは編集・削除しない + - 環境全体に効く破壊的な操作(DB のリセット・マイグレーション・デプロイ・設定変更等)は行わない +- プロダクションコード・テストコードは変更しない.書いてよいのは,プロファイルが定める**検証シナリオの置き場所と出力ディレクトリの配下のファイルだけ** +- 問題を見つけても自分で直さない.再現手順と証拠を添えて報告する +- プロファイルの「禁止操作」は実行しない.人間のローカル環境(データ・起動中のプロセス)を壊さない.**自分が起動していないプロセスは停止しない** +- 検証で作るデータはプロファイルの「テストデータの規約」に従い,後始末まで責任を持つ.**ローカル以外の環境に残したデータの有無は,残っていない場合も含めて報告の「後始末」に必ず書く** +- コミットはしない + +## 作業手順 (Workflow) + +1. **検証プロファイルを読む**: `.claude/verify-profile.md` を読み,種別・検証手段,**検証対象の環境**,実行コマンドと出力先,前提条件,認証・ロール,テストデータの規約,待ち方・セレクタの癖,既知のノイズ,禁止操作,シナリオのテンプレートを把握する.無ければ「検証不能(検証プロファイル未設定)」として終了する +2. **検証対象の環境と前提条件を確定する**: + - プロファイルの「検証対象の環境」から,今回アクセスしてよい接続先(ローカルのみか,根拠つきで許可された環境を含むか)を確定し,以降はその範囲だけを操作する.許可リストに無い接続先が必要になった項目は「未確認」として人間に回す + - 「前提条件と確認方法」の確認コマンドを実行する.満たされていない場合,プロファイルが自力での解消を許可している手順(サービスの起動等)だけを行い,それで解消しなければ「検証不能」として理由と解消方法を報告して終了する +3. **変更を把握する**: 渡された実装サマリー・確認観点を読み,`git diff`(未コミットの変更が無ければ `git diff main...HEAD`)で実際の変更を確認する +4. **仕様を読む**: 期待される挙動を `docs/04_SPEC/` と該当箇所のコードから特定する.「実装がそうなっているから正しい」とはしない +5. **確認項目を列挙する**: 後述の確認観点から**今回の変更に関係するものだけ**を選ぶ(目安 5〜12 項目.機能の総当たりはしない) +6. **シナリオを用意する**: プロファイルの「シナリオのテンプレート」に従い,指定された場所にシナリオを書く.テンプレートに無い書き方を推測で持ち込まない +7. **実行する**: プロファイルの「実行コマンド」で実行する.コマンドは推測せず,プロファイルに書かれたものをそのまま使う(許可されていないコマンドで実行を求められた場合は,勝手に代替手段を探さずその旨を報告する) +8. **証拠を自分の目で確認する**: 実行結果の判定(PASS / FAIL)だけで済ませず,出力された証拠(スクリーンショット・ログ・レスポンス本文等)を Read で実際に見る.**PASS でも,今回変更した箇所の証拠と,表示に関わる証拠は必ず見る**(レイアウト崩れ等は機械的な判定では拾えない).FAIL はシナリオ側の誤り(セレクタ違い・待ち不足・前提の取り違え)か本物の不具合かを切り分け,シナリオの誤りなら直して再実行する(再実行は 3 回まで).プロファイルの「既知のノイズ」に該当する現象は不具合として報告しない +9. **後始末を確認する**: プロファイルの規約どおりに検証データ・起動したプロセスが片付いたかを確認する(ローカル以外の環境に作ったデータは特に念入りに確認する).残ったものがあれば,どの環境に何が残っているかを報告に書く +10. **検証サマリーを出力する** + +## 確認観点 (Checklist) + +今回の変更が影響する範囲について,以下から関係するものを選ぶ. + +- **正常系**: 変更した機能の主要な操作・処理が最後まで通る +- **入力の境界**: 空・空白のみ・極端に長い値・上限/下限の数値・重複する値・不正な形式 +- **状態の境界**: データ 0 件・1 件・最大件数など,この機能固有の端の状態 +- **操作の順序**: 連続実行・二重送信・処理中の再操作・中断後の再開・直前の状態に戻る操作 +- **権限**: 権限の無い主体が操作できないこと(UI だけでなく API・コマンドの層でも拒否されること).未認証でのアクセス +- **表示**(画面を持つ場合): 想定される画面幅・端末幅で要素がはみ出す・重なる・潰れる等の明らかな崩れが無い +- **エラーからの復帰**: エラー発生後に再試行して正常に完了できる + +## 検証不能の扱い (When Verification Is Not Possible) + +以下は「失敗」ではなく「検証不能」として扱い,エラーで止まらずに理由を添えて報告する. + +- 検証プロファイル(`.claude/verify-profile.md`)が無い +- 必要なツール・依存が未導入,またはサービスが起動していない(プロファイルの範囲で解消できない場合) +- 検証の前提を整えるのに破壊的な操作(データの初期化等)が必要で,プロファイルで禁止されている,または人間の確認が要る +- 変更が,プロファイルの検証手段では観測できない領域にある +- 確認に必要な環境が,プロファイルの「検証対象の環境」で許可されていない(許可されているかを自分で判断しない.人間に確認を依頼する) + +報告には**理由と,人間が解消するための具体的な手順**を書く.一部だけ実行できた場合は,確認できた項目と未確認の項目を分けて書く(未確認を PASS にしない). + +## 出力フォーマット (Output Format) + +検証完了後,必ず以下のフォーマットでサマリーを出力すること. + +``` +## 検証サマリー + +### 判定 +OK(不具合なし) / NG(不具合あり) / 検証不能(理由) + +### 確認した項目 +| # | 項目 | 環境 | 条件 | 結果 | +| --- | --- | --- | --- | --- | +| 1 | 何がどうなるべきか | ローカル / 許可された環境名 | 権限・画面幅・データ状態等 | PASS / FAIL / 未確認(理由) | + +### 発見した不具合 +(NG の場合)項目ごとに: 再現手順 / 期待 / 実際 / 証拠のパス / 推定原因(file:line). +今回の変更に起因するものと,変更と無関係な既存の問題を**区別して**書く. + +### 気になった点 +不具合とは断定できないが人間に見てほしい点 + +### 人間が確認すべき残り項目 +自動では判断していない点(デザイン・操作感・仕様の妥当性・実機固有の挙動・外部サービスを通る経路・許可されていない環境でしか確認できない項目等)を,今回の変更に即して具体的に書く + +### 後始末 +検証データ・起動したプロセスの片付け結果(残っているものがあれば,どの環境に何が残っているかを明記). +**ローカル以外の環境に残したデータの有無**(無い場合も「残っていない」と明記.そもそも使っていない場合は「ローカルのみ」と書く).成果物の出力先パス +``` + +## 禁止事項 (Prohibited Actions) + +- プロダクションコード・テストコードの変更(不具合の修正を含む.報告のみ行う).シナリオと出力ディレクトリ以外のファイルを書き換えない +- 本番環境へのアクセス(例外なし) +- プロファイルの「検証対象の環境」が許可していない接続先(staging 等の検証用環境・外部サイト・外部サービス)へのアクセス(許可されているかを自分で判断してアクセスすることを含む) +- 許可された環境での,自分が作った検証データ以外の更新・削除,および環境全体に効く破壊的な操作(リセット・マイグレーション・デプロイ・設定変更) +- 検証プロファイルの「禁止操作」の実行.人間が起動したプロセスの停止.データの初期化 +- `git commit`・`git push`・`git checkout` の実行 +- 確認していない項目を PASS と報告すること(実行できなかった項目は「未確認」と理由を書く) +- デザイン・操作感・仕様の妥当性を自分で判定すること(人間に回す) diff --git a/.claude/hooks/check_sync.sh b/.claude/hooks/check_sync.sh new file mode 100644 index 0000000..3d05aa3 --- /dev/null +++ b/.claude/hooks/check_sync.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# SessionStart hook: ローカルブランチが origin と同期しているかをチェックし, +# JSON で systemMessage (UI 表示) と additionalContext (モデルに注入) を出力する. +# 読み取り専用 (git fetch のみ). 失敗してもセッション開始は阻害しない. +# +# GUIDE_03「直列運用」「main を常に動作可能」を補助する目的で, +# 古い main から作業を始めるミスをセッション開始時に可視化する. + +# JSON 整形は Python に任せる (jq 非依存. 既存 hook も Python を利用). +# python3 / python / py の差は run_python.sh が吸収する (見つからなければ非ブロックで失敗). +emit() { + local msg="$1" + PYTHONIOENCODING=utf-8 bash "$(dirname "$0")/run_python.sh" - "$msg" <<'PY' +import json, sys +msg = sys.argv[1] +print(json.dumps({ + "systemMessage": msg, + "hookSpecificOutput": { + "hookEventName": "SessionStart", + "additionalContext": msg, + }, +}, ensure_ascii=False)) +PY +} + +# git リポジトリ外なら静かに終了 +git rev-parse --is-inside-work-tree >/dev/null 2>&1 || exit 0 + +# origin が未設定なら静かにスキップ (個人 clone・ローカル実験用途) +git remote get-url origin >/dev/null 2>&1 || exit 0 + +# fetch (5 秒で打ち切り). 失敗 = オフライン等. 警告だけ出して継続. +if ! timeout 5 git fetch --quiet origin 2>/dev/null; then + emit "[sync-check] origin への fetch に失敗 — オフラインの可能性" + exit 0 +fi + +BRANCH=$(git branch --show-current) +# detached HEAD は判定できないので静かに終了 +[ -z "$BRANCH" ] && exit 0 + +LOCAL=$(git rev-parse HEAD 2>/dev/null) || exit 0 +REMOTE=$(git rev-parse "@{u}" 2>/dev/null) +if [ -z "$REMOTE" ]; then + emit "[sync-check] $BRANCH は upstream 未設定" + exit 0 +fi +BASE=$(git merge-base HEAD "@{u}") + +DIRTY="" +[ -n "$(git status --porcelain)" ] && DIRTY=" / 未コミット変更あり" + +if [ "$LOCAL" = "$REMOTE" ]; then + emit "[sync-check] ✓ $BRANCH は origin と同期${DIRTY}" +elif [ "$LOCAL" = "$BASE" ]; then + AHEAD=$(git rev-list --count "HEAD..@{u}") + emit "[sync-check] ⚠ $BRANCH は origin より ${AHEAD} コミット遅れ — \`git pull\` 推奨${DIRTY}" +elif [ "$REMOTE" = "$BASE" ]; then + AHEAD=$(git rev-list --count "@{u}..HEAD") + emit "[sync-check] ℹ $BRANCH は origin より ${AHEAD} コミット先行${DIRTY}" +else + AHEAD=$(git rev-list --count "@{u}..HEAD") + BEHIND=$(git rev-list --count "HEAD..@{u}") + emit "[sync-check] ⚠ $BRANCH は origin と分岐 (先行 ${AHEAD} / 遅れ ${BEHIND}) — rebase/merge が必要${DIRTY}" +fi diff --git a/.claude/hooks/notify.py b/.claude/hooks/notify.py new file mode 100644 index 0000000..7cc1120 --- /dev/null +++ b/.claude/hooks/notify.py @@ -0,0 +1,141 @@ +"""Stop / Notification / PreToolUse(AskUserQuestion) hook: 作業完了・許可待ち・質問を ntfy でスマホに通知する. + +送信先トピックは環境変数 NTFY_TOPIC で指定する(未設定なら何もしない).トピック名は +知っていれば誰でも購読・送信できるパスワード相当の値のため,リポジトリには書かず +.claude/settings.local.json(gitignore 済み)の env 等で各自設定する. +自前サーバー等へ送る場合は NTFY_SERVER で送信先を変えられる(既定: https://ntfy.sh). + +Stop はターンが終わるたびに呼ばれるため,サブエージェントに作業を依頼して待っているだけの +ターン終了(依頼した直後にメインの手が空いて一旦ターンが終わる)でも発火する.そのままでは +「処理が完了しました」が実際の完了より前に届くので,hook 入力の background_tasks を見て +依頼中のエージェントが残っているターンでは通知しない(PENDING_TASK_TYPES 参照). + +settings.json では async で配線しているため,本フックの成否は Claude Code の動作に影響しない. +送信に失敗しても黙って終了する.外部サービスを経由するため,本文にコードや会話の内容は含めない. +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +import urllib.request + +DEFAULT_SERVER = "https://ntfy.sh" +SEND_TIMEOUT_SEC = 10 +GIT_TIMEOUT_SEC = 5 +# ntfy の優先度(3 が既定.4 は通知音・バイブが強調される) +PRIORITY_NEEDS_INPUT = 4 +# Stop 時点で「まだ作業が続く」と見なす背景タスクの種類(hook 入力 background_tasks[].type). +# subagent・workflow は完了するとセッションが再開して作業が続くため,これらが残っているターン終了は +# 完了ではない.shell・monitor は開発サーバーのように意図して動かし続けるものが該当しうる +# (含めると通知が永久に止まる)ため対象にしない. +PENDING_TASK_TYPES = ("subagent", "workflow") +# エージェント名は設定由来の短い識別子だが,通知本文が壊れないよう長さを制限する +AGENT_NAME_MAX = 40 + + +def has_pending_agent(data: dict) -> bool: + """依頼した作業がまだ動いている(サブエージェント・ワークフローが在席している)かを返す. + + background_tasks を持たない古い Claude Code では常に False となり,従来どおり毎ターン通知する. + """ + tasks = data.get("background_tasks") + if not isinstance(tasks, list): + return False + for task in tasks: + if isinstance(task, dict) and task.get("type") in PENDING_TASK_TYPES: + return True + return False + + +def agent_suffix(data: dict) -> str: + """サブエージェント起因のイベントなら「(エージェント: 名前)」を返す(そうでなければ空文字).""" + name = data.get("agent_type") + if not isinstance(name, str) or not name.strip(): + return "" + return "(エージェント: {})".format(" ".join(name.split())[:AGENT_NAME_MAX]) + + +def build_notification(data: dict) -> dict | None: + """イベントから ntfy の通知内容(本文・タグ・優先度)を決める.通知不要なら None を返す.""" + event = data.get("hook_event_name") + if event == "Stop": + # 依頼したエージェントが動いている間のターン終了は完了ではない. + # 依頼が終わるとセッションが再開するので,本当に終わった最後のターン終了で通知される + if has_pending_agent(data): + return None + return {"message": "処理が完了しました", "tags": ["white_check_mark"]} + if event == "PreToolUse" and data.get("tool_name") == "AskUserQuestion": + message = "質問への回答を待っています" + agent_suffix(data) + return {"message": message, "tags": ["question"], "priority": PRIORITY_NEEDS_INPUT} + if event == "Notification": + if data.get("notification_type") == "permission_prompt": + message = "ツール実行の許可を待っています" + agent_suffix(data) + return {"message": message, "tags": ["lock"], "priority": PRIORITY_NEEDS_INPUT} + return {"message": "入力を待っています" + agent_suffix(data), "tags": ["speech_balloon"], "priority": PRIORITY_NEEDS_INPUT} + return None + + +def project_name(cwd: str) -> str: + """通知に出すプロジェクト名を返す. + + ホスト OS と dev container でフォルダ名が異なっても同じ名前になるよう, + origin リモートの URL 末尾(https://github.com/owner/repo.git・git@github.com:owner/repo.git + のどちらでも repo)を使う.リモートが無い・git が使えない場合はフォルダ名を返す. + """ + folder = os.path.basename(os.path.normpath(cwd)) + try: + result = subprocess.run( + ["git", "-C", cwd, "remote", "get-url", "origin"], + capture_output=True, + text=True, + timeout=GIT_TIMEOUT_SEC, + ) + except (OSError, subprocess.SubprocessError): + return folder + url = result.stdout.strip().rstrip("/") + if result.returncode != 0 or not url: + return folder + name = url.replace(":", "/").rsplit("/", 1)[-1] + if name.endswith(".git"): # str.removesuffix は 3.9 以降のため使わない + name = name[: -len(".git")] + return name or folder + + +def main() -> None: + topic = os.environ.get("NTFY_TOPIC") + if not topic: + return + + try: + data = json.loads(sys.stdin.buffer.read().decode("utf-8")) + except (ValueError, UnicodeDecodeError): + return + + notification = build_notification(data) + if notification is None: + return + + # 複数プロジェクトを並行して動かしても区別できるよう,タイトルにプロジェクト名を入れる + project = project_name(data.get("cwd") or os.getcwd()) + payload = {"topic": topic, "title": f"Claude Code ({project})", **notification} + + # JSON で送る(ヘッダー送信だと日本語のタイトルが化けるため) + server = os.environ.get("NTFY_SERVER", DEFAULT_SERVER).rstrip("/") + request = urllib.request.Request( + server, + data=json.dumps(payload, ensure_ascii=False).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="POST", + ) + try: + with urllib.request.urlopen(request, timeout=SEND_TIMEOUT_SEC): + pass + except OSError: # URLError・HTTPError・タイムアウトはいずれも OSError の派生 + pass + + +if __name__ == "__main__": + main() diff --git a/.claude/hooks/restrict_repo_access.py b/.claude/hooks/restrict_repo_access.py new file mode 100644 index 0000000..0e48e0d --- /dev/null +++ b/.claude/hooks/restrict_repo_access.py @@ -0,0 +1,1443 @@ +"""PreToolUse hook: リポジトリ外のファイルを取り返しのつかない形で壊さないためのガード. + +目的は「リポジトリ外のファイルの削除と,既存ファイルの上書きだけを確実に止める」こと. +新規作成・ダウンロード・ディレクトリ作成・追記は止めない(環境構築を自動で進められる +ようにするため).グレーな操作の判断は権限モード(auto mode の分類器等)に任せ, +本フックは最後の砦として働く.読み取りは原則として制限しない(読ませたくない場所は +プロジェクトごとに禁止リストで指定する). + +判定の概要(対象がリポジトリ外の場合.許可ゾーンは常に許可): +- 読み取り(Read / Glob / Grep,Bash / PowerShell の読み取り): 許可する +- 新規作成・ダウンロード・mkdir・touch・追記(>>・Add-Content 等)・chmod 等: 許可する +- 削除(rm・Remove-Item・Clear-Content・truncate・find -delete 等)と移動元 + (mv・Move-Item・Rename-Item): 拒否する +- 上書き(> リダイレクト・cp 等の書き込み先・sed -i・Set-Content・Out-File・ + tee・dd of=・curl -o 等の保存先・Write ツール): 書き込み先が既に存在する + 場合だけ拒否する(無ければ新規作成なので許可) +- Edit / NotebookEdit: 既存ファイルの書き換えなので拒否する +- インライン実行(python -c 等): 削除系の呼び出しがありリポジトリ外のパスを + 含む場合と,既知の上書き呼び出し(open(..., 'w') 等)の対象が既存ファイルの場合に拒否する +- 禁止リスト(deny_read)に該当するパスは,どの操作でも拒否する + +リポジトリの基準は CLAUDE_PROJECT_DIR(無ければフック入力の cwd).相対パスは +フック入力の cwd を起点に,コマンド内の cd / pushd / Set-Location 等を追跡して解決する. + +禁止リスト: <リポジトリ>/.claude/repo-access.json + {"deny_read": ["~/.ssh", "/etc", "secrets"]} + 絶対パス・`~` 始まり・リポジトリルート相対を受け付ける.ファイルが無ければ空, + 壊れていれば空として扱い stderr に警告する. + +許可ゾーン(リポジトリ外でも削除・上書きを許可する): +- システム一時ディレクトリ(/tmp・%TEMP% 等.Claude Code のスクラッチパッドを含む) + (/sync-template・/set-mode がテンプレートを mktemp -d に clone して cp・rm -rf + するため.一時ディレクトリは使い捨て領域であり,許可しても失うものがない) +- /dev/null・NUL・$null 等の特殊デバイス + +限界(重要): + 本フックはコマンド文字列の静的解析であり,原理的に完全には防げない.変数展開 + ($DIR 等.$HOME・~ 以外は解決しない),スクリプトファイル経由の操作, + パイプで渡した入力(xargs 等),エイリアス・関数,Base64 エンコードされた + コマンド,未対応のコマンドによる削除・上書き,インライン実行中の未対応の書き方 + (変数に入れたパス・Perl の open 等)は検出できない.存在判定はフック実行時点の + ものであり,同じコマンド内で先に作ったファイルを上書きする場合等は許可される. + 逆にインライン実行は,削除系の呼び出しとリポジトリ外のパス(文字列・コメント中の + ものも含む)が同時に現れると,削除対象でなくても拒否することがある. + ユーザーが `!` で実行したコマンドには本フックはかからない.確実性が必要な場合は + OS のフォルダ権限,settings.json の permissions.deny,Bash / PowerShell の実行前に + 確認を挟む権限モード,バックアップを併用すること(docs/01_GUIDE/GUIDE_01 の環境構築). +""" + +from __future__ import annotations + +import json +import os +import posixpath +import re +import sys +import tempfile + +CONFIG_RELPATH = ".claude/repo-access.json" + +# 拒否理由に添える案内.リポジトリ外の削除・上書きの最終判断は人間が持つため, +# Claude に迂回させず,必要ならユーザーに `!` で実行してもらう +WRITE_DENY_GUIDANCE = ( + "リポジトリ外のファイルの削除・上書きは Claude が自分で実行しないでください." + "別の書き方(変数・スクリプトファイル・インライン実行等)で迂回しないでください." + "必要な操作であれば,実行するコマンドをそのままユーザーに提示し," + "プロンプトで `! <コマンド>` として実行するよう依頼してください" +) +DENY_READ_GUIDANCE = ( + "ユーザーが読ませたくない場所として指定しているパスです." + "別の書き方で迂回して読まないでください" +) + +# 解決できないパスを表す(変数展開を含む等).判定をスキップする +UNKNOWN = None +# 特殊デバイス(/dev/null 等)を表す +SPECIAL = "" + +POSIX_SPECIAL_PATHS = { + "/dev/null", + "/dev/zero", + "/dev/stdout", + "/dev/stderr", + "/dev/stdin", + "/dev/tty", +} +WINDOWS_SPECIAL_NAMES = {"nul", "con", "$null", "nul:", "con:"} + +# 操作の種別.DELETE は常に拒否,OVERWRITE は対象が既に存在すれば拒否, +# CREATE は禁止リストの判定だけ行う(新規作成・追記はリポジトリ外でも許可) +DELETE = "delete" +OVERWRITE = "overwrite" +CREATE = "create" + +# --------------------------------------------------------------------------- +# Bash: コマンドの分類 +# --------------------------------------------------------------------------- +# 非オプション引数をすべて削除(中身を失う操作)の対象とみなすコマンド +BASH_DELETE = {"rm", "rmdir", "unlink", "shred", "truncate", "trash", "trash-put", "srm"} +# 最後の非オプション引数(または -t の値)を書き込み先とみなすコマンド. +# mv は移動元も削除扱い(元の場所から消える) +BASH_COPY = {"mv", "cp", "ln", "install", "rsync", "scp"} +# 値を取るオプション(値はパスとして扱わない.ただし -t は書き込み先) +BASH_OPTS_WITH_VALUE = { + "cp": {"-t", "-S", "--target-directory", "--suffix"}, + "mv": {"-t", "-S", "--target-directory", "--suffix"}, + "ln": {"-t", "-S", "--target-directory", "--suffix"}, + "install": {"-t", "-m", "-o", "-g", "-S", "--target-directory", "--mode", "--owner", "--group", "--suffix"}, + "truncate": {"-s", "-r", "--size", "--reference"}, + "rsync": {"-e", "--rsh", "--exclude", "--include", "--filter", "-f"}, + "scp": {"-P", "-i", "-o", "-F", "-c", "-l", "-S", "-J"}, + "shred": {"-n", "-s", "--iterations", "--size"}, +} +BASH_TARGET_DIR_OPTS = {"-t", "--target-directory"} +# 先頭に付いても実体のコマンドが後ろに続くもの +BASH_PREFIX_COMMANDS = { + "sudo", "doas", "env", "command", "builtin", "exec", "nohup", "time", + "nice", "ionice", "stdbuf", "xargs", "timeout", "unbuffer", +} +# VAR=値 形式の環境変数の指定 +ENV_ASSIGNMENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=") +# インライン実行を受け付けるインタプリタと,コード文字列を取るオプション +INTERPRETER_CODE_OPTS = { + "python": {"-c"}, + "python3": {"-c"}, + "py": {"-c"}, + "node": {"-e", "--eval", "-p", "--print"}, + "deno": {"eval"}, + "bun": {"-e", "--eval", "-p", "--print"}, + "perl": {"-e", "-E"}, + "ruby": {"-e"}, + "php": {"-r"}, +} +SHELL_COMMANDS = {"bash", "sh", "zsh", "dash", "ksh"} +POWERSHELL_COMMANDS = {"pwsh", "powershell"} +CMD_COMMANDS = {"cmd"} +# 再帰的に読むコマンド(引数の配下に禁止パスが含まれる場合も拒否する) +RECURSIVE_READ_COMMANDS = { + "grep", "egrep", "fgrep", "rg", "ag", "find", "tar", "zip", "7z", + "rsync", "cp", "scp", "du", "tree", +} +# インライン実行のコード中の削除系の呼び出し(Python / Node.js / Perl / Ruby / PowerShell). +# 移動・名前変更も元の場所から消えるので含める +INLINE_DELETE_CALL = re.compile( + r"rmtree|remove_tree|removedirs|\bos\.(?:remove|replace)\b|shutil\.move|" + r"\b(?:unlink|rmdir|rename)(?:Sync)?\b|\brm(?:Sync)?\s*\(|\bfs\.(?:promises\.)?rm\b|" + r"FileUtils\.(?:rm|remove|mv)\w*|\bFile\.delete\b|send2trash|(?i:Remove-Item)" +) +# インライン実行のコード中の上書き呼び出しと,その対象のリテラル(既存ファイルなら拒否) +_LIT = r"(?:'([^'\n]*)'|\"([^\"\n]*)\")" +INLINE_OVERWRITE_CALLS = ( + # Python: open('x', 'w') / open('x', mode='wb') 等 + re.compile(r"\bopen\s*\(\s*" + _LIT + r"\s*,\s*(?:mode\s*=\s*)?['\"][^'\"\n]*w"), + # Python: Path('x').write_text(...) / write_bytes(...) + re.compile(r"\bPath\s*\(\s*" + _LIT + r"\s*\)\s*\.write_(?:text|bytes)\b"), + # Node.js: fs.writeFile('x') / writeFileSync / createWriteStream,Ruby: File.write('x') + re.compile(r"(?:\bwriteFile(?:Sync)?|\bcreateWriteStream|\bFile\.write)\s*\(\s*" + _LIT), +) + +# --------------------------------------------------------------------------- +# PowerShell: コマンドの分類(小文字で比較する) +# --------------------------------------------------------------------------- +# すべての位置引数とパス系パラメーターを削除の対象とみなす +PS_DELETE = { + "remove-item", "rm", "del", "erase", "rd", "ri", "rmdir", + "clear-content", "clc", "clear-item", "cli", +} +# 移動元(1 番目の位置引数・-Path)を削除,移動先(2 番目・-Destination)を書き込み先とみなす +PS_MOVE = {"move-item", "mv", "move", "mi"} +# 名前変更(1 番目の位置引数・-Path)は元の名前から消えるので削除扱い +PS_RENAME = {"rename-item", "ren", "rni"} +# 最初の位置引数とパス系パラメーターを上書きの対象とみなす +PS_OVERWRITE_FIRST = {"set-content", "sc", "set-item", "si"} +# -Append / -NoClobber が無ければ上書き,あれば新規作成・追記とみなす +PS_OVERWRITE_UNLESS_APPEND = {"out-file", "tee-object", "tee", "export-csv", "epcsv", "export-clixml"} +PS_NEW_ITEM = {"new-item", "ni"} +# 2 番目の位置引数と -Destination だけを書き込み先とみなす(1 番目は読み取り元) +PS_WRITE_DEST = {"copy-item", "cp", "copy", "cpi"} +# 1 番目の位置引数(-Path / -LiteralPath / -Source)は読み取り元,-DestinationPath +# (-Destination)または 2 番目の位置引数だけを書き込み先とみなす +# (Expand-Archive / Compress-Archive: -Path が位置 0,-DestinationPath が位置 1. +# Start-BitsTransfer: -Source が位置 0,-Destination が位置 1.いずれも公式リファレンスの定義) +PS_WRITE_ARCHIVE_DEST = {"expand-archive", "compress-archive", "start-bitstransfer"} +# -OutFile の値を書き込み先とみなす(Bash 側の curl -o と揃える). +# curl / wget は Windows PowerShell 5.1 では Invoke-WebRequest の別名, +# PowerShell 7 では curl.exe 等の実体なので,両方の書き方を見る +PS_WEB_REQUEST = {"invoke-webrequest", "iwr", "invoke-restmethod", "irm", "curl", "wget"} +PS_LOCATION_SET = {"set-location", "cd", "chdir", "sl"} +PS_LOCATION_PUSH = {"push-location", "pushd"} +PS_LOCATION_POP = {"pop-location", "popd"} +# 値がパスであるパラメーター +PS_PATH_PARAMS = {"path", "literalpath", "lp", "pspath", "filepath", "destination", "destinationpath"} +# 操作の対象(書き込み先ではなく対象そのもの)を表すパラメーター +PS_TARGET_PARAMS = {"path", "literalpath", "lp", "pspath", "filepath"} +# Copy-Item 等で読み取り元を表すパラメーター(書き込み対象にはしない) +PS_SOURCE_PARAMS = {"path", "literalpath", "lp", "pspath", "source"} +# 省略形を正式名に揃えるパラメーター(正式名, 省略できる最短の長さ). +# PowerShell はパラメーター名を一意な前方一致で省略できる(-OutF / -Dest 等) +PS_PARAM_ABBREVIATIONS = (("outfile", 4), ("destination", 4), ("destinationpath", 12)) +# 値を取るパラメーター(それ以外は switch とみなす) +PS_VALUE_PARAMS = PS_PATH_PARAMS | { + "value", "encoding", "filter", "include", "exclude", "name", "newname", + "itemtype", "type", "credential", "stream", "width", "inputobject", + "delimiter", "depth", "aclobject", "compressionlevel", "variable", + "stackname", "command", "c", "file", "f", "executionpolicy", "ep", + "outfile", "infile", "uri", "method", "body", "headers", "contenttype", + "useragent", "proxy", "timeoutsec", "source", "transfertype", "priority", + "displayname", "description", +} +PS_RECURSIVE_READ = { + "get-childitem", "gci", "ls", "dir", "select-string", "sls", + "copy-item", "cp", "copy", "cpi", "compress-archive", +} +# [System.IO.File]::Method(引数) の呼び出し +PS_DOTNET_CALL = re.compile(r"\[(?:System\.)?IO\.(File|Directory)\]::(\w+)\s*\(([^)]*)\)", re.IGNORECASE) +# メソッド名(小文字)→ 引数位置ごとの操作種別 +PS_DOTNET_KINDS = { + "delete": (DELETE,), + "move": (DELETE, OVERWRITE), + "replace": (DELETE, DELETE), + "copy": (CREATE, OVERWRITE), + "openwrite": (OVERWRITE,), + "encrypt": (OVERWRITE,), + "decrypt": (OVERWRITE,), +} + + +# --------------------------------------------------------------------------- +# パスの解決と判定 +# --------------------------------------------------------------------------- +def norm(path: str) -> str: + """比較用に正規化する(realpath + 大文字小文字の正規化).""" + return os.path.normcase(os.path.realpath(path)) + + +def is_within_directory(target: str, base: str) -> bool: + """target が base ディレクトリ配下にあるかを判定する(base 自体も含む).""" + real_target = norm(target) + real_base = norm(base) + if real_target == real_base: + return True + if not real_base.endswith(os.sep): + real_base += os.sep + return real_target.startswith(real_base) + + +def is_temp_path(path: str) -> bool: + """path がシステム一時ディレクトリ配下かを判定する. + + OS ネイティブの一時ディレクトリ(tempfile.gettempdir())に加え, + Git Bash 等の POSIX 形式 /tmp も文字列正規化で判定する + (Windows では /tmp が実パスに解決できないため realpath に頼れない). + `..` は正規化してから判定するので /tmp/../etc のような脱出は温存されない. + """ + normalized = posixpath.normpath(path.replace("\\", "/")) + if normalized == "/tmp" or normalized.startswith("/tmp/"): + return True + return is_within_directory(path, tempfile.gettempdir()) + + +def home_dir() -> str: + return os.path.expanduser("~") + + +def expand_home(path: str) -> str: + """先頭の `~`(`~/`・`~\\` 始まりを含む)をホームディレクトリに展開する(`~user` は展開しない).""" + if path == "~" or path.startswith("~/") or path.startswith("~\\"): + return home_dir() + path[1:] + return path + + +def convert_posix_drive(path: str) -> str: + """Windows 上の Git Bash 形式 /c/Users/... を C:/Users/... に変換する.""" + if os.name != "nt": + return path + m = re.match(r"^/(?:cygdrive/)?([A-Za-z])(/|$)(.*)$", path, re.DOTALL) + if m: + return m.group(1).upper() + ":/" + m.group(3) + return path + + +def expand_variables(token: str, dialect: str) -> str | None: + """`~`・$HOME 等の既知の変数を展開する.未知の変数が残れば None を返す.""" + token = expand_home(token) + if token.startswith("~"): + return UNKNOWN # ~user 等は解決しない + home = home_dir() + temp = tempfile.gettempdir() + if dialect == "powershell": + replacements = [ + (r"\$\{?env:(?:USERPROFILE|HOME)\}?", home), + (r"\$\{?env:(?:TEMP|TMP)\}?", temp), + (r"\$\{?HOME\}?(?![\w:])", home), + ] + else: + replacements = [ + (r"\$\{HOME\}|\$HOME(?!\w)", home), + (r"\$\{(?:TMPDIR|TEMP|TMP)\}|\$(?:TMPDIR|TEMP|TMP)(?!\w)", temp), + ] + for pattern, value in replacements: + token = re.sub(pattern, lambda _m, v=value: v.replace("\\", "/"), token, flags=re.IGNORECASE) + if "$" in token or "`" in token: + return UNKNOWN + return token + + +def resolve_path(token: str, cwd: str | None, dialect: str = "bash") -> str | None: + """コマンド中のパス文字列を絶対パスに解決する. + + 特殊デバイスは SPECIAL,解決できない(未知の変数を含む・cwd 不明で相対)ものは + UNKNOWN(None)を返す. + """ + if not token: + return UNKNOWN + if dialect == "powershell": + # プロバイダー修飾(Microsoft.PowerShell.Core\FileSystem::C:\x)を外す + token = re.sub(r"^(?:Microsoft\.PowerShell\.Core\\)?FileSystem::", "", token, flags=re.IGNORECASE) + if token.lower() in WINDOWS_SPECIAL_NAMES: + return SPECIAL + expanded = expand_variables(token, dialect) + if expanded is None: + return UNKNOWN + token = expanded + posix_form = posixpath.normpath(token.replace("\\", "/")) if token.startswith("/") else "" + if posix_form in POSIX_SPECIAL_PATHS or posix_form.startswith("/dev/fd/"): + return SPECIAL + if dialect == "powershell" and re.match(r"^[A-Za-z][A-Za-z0-9]+:", token): + return UNKNOWN # Env: / HKLM: 等のファイルシステム以外のドライブ + token = convert_posix_drive(token) + if not os.path.isabs(token): + if cwd is None: + return UNKNOWN + token = os.path.join(cwd, token) + return os.path.normpath(token) + + +def looks_absolute(text: str) -> bool: + """コード文字列中のリテラルがリポジトリ外を指しうるパスに見えるかを判定する.""" + return bool( + re.match(r"^(?:/[^/\s]|~(?:[/\\]|$)|[A-Za-z]:[\\/]|\\\\|\.\.(?:[/\\]|$))", text) + ) + + +# --------------------------------------------------------------------------- +# 判定コンテキスト +# --------------------------------------------------------------------------- +class Policy: + def __init__(self, repo_root: str, deny_read: list) -> None: + self.repo_root = repo_root + self.deny_read = deny_read + + def denied_entry(self, path: str) -> str | None: + """path が禁止リストのいずれかの配下なら,その禁止エントリを返す.""" + for entry in self.deny_read: + if is_within_directory(path, entry): + return entry + return None + + def contained_denied_entry(self, path: str) -> str | None: + """path(リポジトリ外の検索起点)の配下に禁止パスがあれば,その禁止エントリを返す.""" + if self.is_inside_repo(path) or is_temp_path(path): + return None + for entry in self.deny_read: + if is_within_directory(entry, path): + return entry + return None + + def is_inside_repo(self, path: str) -> bool: + return is_within_directory(path, self.repo_root) + + def is_write_allowed(self, path: str) -> bool: + return self.is_inside_repo(path) or is_temp_path(path) + + +def deny_read_message(target: str, entry: str) -> str: + """禁止リストに該当したときの拒否理由を組み立てる.""" + return ( + f"禁止リスト({CONFIG_RELPATH})のパスへのアクセスをブロックしました: " + f"{target}(禁止: {entry}).{DENY_READ_GUIDANCE}" + ) + + +def load_deny_read(repo_root: str) -> list: + """`.claude/repo-access.json` の deny_read を絶対パスのリストとして読み込む.""" + config_path = os.path.join(repo_root, ".claude", "repo-access.json") + if not os.path.exists(config_path): + return [] + try: + with open(config_path, encoding="utf-8") as f: + config = json.load(f) + entries = config.get("deny_read", []) if isinstance(config, dict) else None + if not isinstance(entries, list) or not all(isinstance(e, str) for e in entries): + raise ValueError("deny_read は文字列の配列で指定してください") + except (OSError, ValueError) as e: + print( + f"[restrict_repo_access] {CONFIG_RELPATH} を読み込めないため禁止リストを空として扱います: {e}", + file=sys.stderr, + ) + return [] + result = [] + for entry in entries: + entry = entry.strip() + if not entry: + continue + entry = convert_posix_drive(expand_home(entry)) + if not os.path.isabs(entry): + entry = os.path.join(repo_root, entry) + result.append(os.path.normpath(entry)) + return result + + +# --------------------------------------------------------------------------- +# トークナイザー +# --------------------------------------------------------------------------- +# トークンは [種別, 文字列, 付随情報] のリスト.種別: +# "word" : 単語(クォート除去済み) +# "sep" : コマンドの区切り(; && || | & 改行) +# "open" : サブシェル開始 "(" , "close": サブシェル終了 ")" +# "redir" : リダイレクト演算子(> >> < << <<< 等) +# "heredoc": ヒアドキュメントの本文(直前の redir "<<" の後の word が区切り語) +def tokenize_bash(command: str) -> list: + tokens: list = [] + buf: list = [] + in_word = False + pending_heredocs: list = [] # [区切り語, タブ除去するか, 本文を格納するトークン] + i = 0 + n = len(command) + + def flush() -> None: + nonlocal in_word, buf + if in_word: + tokens.append(["word", "".join(buf), None]) + buf = [] + in_word = False + + while i < n: + c = command[i] + if c == "'": + end = command.find("'", i + 1) + end = n if end == -1 else end + buf.append(command[i + 1:end]) + in_word = True + i = end + 1 + continue + if c == '"': + j = i + 1 + while j < n and command[j] != '"': + if command[j] == "\\" and j + 1 < n and command[j + 1] in '"\\$`\n': + buf.append(command[j + 1]) + j += 2 + continue + buf.append(command[j]) + j += 1 + in_word = True + i = j + 1 + continue + if c == "\\": + if i + 1 < n: + if command[i + 1] != "\n": + buf.append(command[i + 1]) + in_word = True + i += 2 + else: + i += 1 + continue + if c in " \t\r": + flush() + i += 1 + continue + if c == "\n": + flush() + tokens.append(["sep", "\n", None]) + i += 1 + if pending_heredocs: + for delim, strip_tabs, holder in pending_heredocs: + body_lines = [] + while i < n: + end = command.find("\n", i) + line = command[i:] if end == -1 else command[i:end] + i = n if end == -1 else end + 1 + check = line.lstrip("\t") if strip_tabs else line + if check.rstrip("\r") == delim: + break + body_lines.append(line) + holder[1] = "\n".join(body_lines) + pending_heredocs = [] + continue + if c == "#" and not in_word: + end = command.find("\n", i) + i = n if end == -1 else end + continue + if c in ";&|": + flush() + two = command[i:i + 2] + if two in ("&&", "||", ";;", "|&"): + tokens.append(["sep", two, None]) + i += 2 + elif c == "&" and command[i + 1:i + 2] == ">": + op = "&>>" if command[i + 1:i + 3] == ">>" else "&>" + tokens.append(["redir", op, None]) + i += len(op) + else: + tokens.append(["sep", c, None]) + i += 1 + continue + if c in "()`": + flush() + if c == "(": + tokens.append(["open", c, None]) + elif c == ")": + tokens.append(["close", c, None]) + else: + tokens.append(["sep", c, None]) + i += 1 + continue + if c in "<>": + # 直前の単語が数字だけならファイルディスクリプタ番号(2> 等) + if in_word and "".join(buf).isdigit(): + buf = [] + in_word = False + else: + flush() + for op in ("<<<", "<<-", ">>", ">|", ">&", "<<", "<&", "<>", ">", "<"): + if command.startswith(op, i): + break + tokens.append(["redir", op, None]) + i += len(op) + if op in ("<<", "<<-"): + # 区切り語を先読みして本文の取り込みを予約する + m = re.match(r"\s*(['\"]?)([^\s'\";&|<>()]+)\1", command[i:]) + if m: + holder = ["heredoc", "", None] + tokens.append(["word", m.group(2), None]) + tokens.append(holder) + pending_heredocs.append([m.group(2), op == "<<-", holder]) + i += m.end() + continue + buf.append(c) + in_word = True + i += 1 + flush() + return tokens + + +def tokenize_powershell(command: str) -> list: + tokens: list = [] + buf: list = [] + in_word = False + i = 0 + n = len(command) + + def flush() -> None: + nonlocal in_word, buf + if in_word: + tokens.append(["word", "".join(buf), None]) + buf = [] + in_word = False + + while i < n: + c = command[i] + # ヒア文字列 @' ... '@ / @" ... "@ + if c == "@" and not in_word and command[i + 1:i + 2] in ("'", '"'): + quote = command[i + 1] + end = command.find("\n" + quote + "@", i + 2) + end = n if end == -1 else end + tokens.append(["word", command[i + 2:end].lstrip("\r\n"), None]) + i = end + 3 + continue + if c == "'": + j = i + 1 + while j < n: + if command[j] == "'": + if command[j + 1:j + 2] == "'": + buf.append("'") + j += 2 + continue + break + buf.append(command[j]) + j += 1 + in_word = True + i = j + 1 + continue + if c == '"': + j = i + 1 + while j < n: + if command[j] == "`" and j + 1 < n: + buf.append(command[j + 1]) + j += 2 + continue + if command[j] == '"': + if command[j + 1:j + 2] == '"': + buf.append('"') + j += 2 + continue + break + buf.append(command[j]) + j += 1 + in_word = True + i = j + 1 + continue + if c == "`": + if i + 1 < n and command[i + 1] != "\n": + buf.append(command[i + 1]) + in_word = True + i += 2 + continue + if c == "<" and command[i + 1:i + 2] == "#": + end = command.find("#>", i + 2) + i = n if end == -1 else end + 2 + continue + if c == "#" and not in_word: + end = command.find("\n", i) + i = n if end == -1 else end + continue + if c in " \t\r,": + flush() + i += 1 + continue + if c in "\n;{}()": + flush() + tokens.append(["sep", c, None]) + i += 1 + continue + if c in "&|": + flush() + two = command[i:i + 2] + if two in ("&&", "||"): + tokens.append(["sep", two, None]) + i += 2 + else: + tokens.append(["sep", c, None]) + i += 1 + continue + if c == ">" or (c == "<" and not in_word): + # 2> / *> 等のストリーム番号 + if in_word and "".join(buf) in ("*", "1", "2", "3", "4", "5", "6"): + buf = [] + in_word = False + else: + flush() + if command.startswith(">>", i): + op = ">>" + elif command.startswith(">&", i): + op = ">&" + else: + op = c + tokens.append(["redir", op, None]) + i += len(op) + continue + buf.append(c) + in_word = True + i += 1 + flush() + return tokens + + +def split_segments(tokens: list) -> list: + """トークン列を単純コマンドごとに分割する.サブシェルの開始・終了も要素として残す.""" + segments: list = [] + current: list = [] + for tok in tokens: + if tok[0] in ("sep", "open", "close"): + if current: + segments.append(current) + current = [] + if tok[0] in ("open", "close"): + segments.append(tok[0]) + continue + current.append(tok) + if current: + segments.append(current) + return segments + + +# --------------------------------------------------------------------------- +# 解析結果の収集 +# --------------------------------------------------------------------------- +class Findings: + """コマンド解析で見つかった操作を集める.""" + + def __init__(self) -> None: + self.writes: list = [] # (コマンド名, 元の文字列, 解決済みパス, 操作種別) + self.reads: list = [] # (コマンド名, 元の文字列, 解決済みパス, 再帰的か) + self.inline: list = [] # (コマンド名, リテラル, 解決済みパス, 操作種別) + + +def normalize_literal(literal: str) -> str: + """Python の raw 文字列や二重バックスラッシュを素朴に正規化する.""" + return literal.replace("\\\\", "\\") + + +def collect_inline_literals(code: str, cmd_name: str, cwd: str | None, findings: Findings, dialect: str) -> None: + """インラインコード中の文字列リテラルからパスらしいものを拾う. + + 削除系の呼び出しがあれば,リポジトリ外を指しうるリテラルをすべて削除対象とみなす + (どのリテラルが削除対象かまでは判別しない).既知の上書き呼び出しの対象は上書き, + それ以外は禁止リストの判定だけに使う. + """ + has_delete = INLINE_DELETE_CALL.search(code) is not None + overwrite_literals = set() + for pattern in INLINE_OVERWRITE_CALLS: + for m in pattern.finditer(code): + overwrite_literals.add(normalize_literal(m.group(1) or m.group(2) or "")) + for m in re.finditer(r"'([^'\n]*)'|\"([^\"\n]*)\"|`([^`\n]*)`", code): + literal = normalize_literal(m.group(1) or m.group(2) or m.group(3) or "") + if not (looks_absolute(literal) or literal in overwrite_literals): + continue + resolved = resolve_path(literal, cwd, dialect) + if resolved in (UNKNOWN, SPECIAL): + continue + kind = DELETE if has_delete else (OVERWRITE if literal in overwrite_literals else CREATE) + findings.inline.append((cmd_name, literal, resolved, kind)) + + +def collect_dotnet_calls(command: str, cwd: str | None, findings: Findings) -> None: + """PowerShell 中の [System.IO.File]::Delete('x') 等を,引数位置ごとの操作種別で拾う.""" + for m in PS_DOTNET_CALL.finditer(command): + method = m.group(2).lower() + if m.group(1).lower() == "directory" and method not in ("delete", "move"): + continue # CreateDirectory 等は新規作成 + if method.startswith(("write", "create")): + kinds: tuple = (OVERWRITE,) + else: + kinds = PS_DOTNET_KINDS.get(method, ()) + for pos, arg in enumerate(m.group(3).split(",")[:len(kinds)]): + lit = re.search(r"'([^'\n]*)'|\"([^\"\n]*)\"", arg) + if not lit: + continue + raw = lit.group(1) or lit.group(2) or "" + resolved = resolve_path(raw, cwd, "powershell") + if resolved not in (UNKNOWN, SPECIAL): + findings.inline.append(("[System.IO." + m.group(1) + "]::" + m.group(2), raw, resolved, kinds[pos])) + + +# cmd.exe の削除・移動系コマンドと,書き込み先(最後のパス)を持つコピー系コマンド +CMD_DELETE = {"del", "erase", "rd", "rmdir", "move", "ren", "rename"} +CMD_COPY = {"copy", "xcopy", "robocopy"} + + +def collect_cmd_exe(args: list, cwd: str | None, findings: Findings) -> None: + """cmd /c 以降のコマンドから削除・上書きの対象と読み取りを拾う.""" + for part in re.split(r"&&|\|\||[&|]", " ".join(args)): + words = [w[0] or w[1] for w in re.findall(r'"([^"]*)"|(\S+)', part)] + if not words: + continue + sub = command_basename(words[0]) + paths: list = [] + redirect_kind = None + for text in words[1:]: + if text in (">", ">>"): + redirect_kind = OVERWRITE if text == ">" else CREATE + continue + if text.startswith(">"): + redirect_kind = CREATE if text.startswith(">>") else OVERWRITE + text = text.lstrip(">") + if text.startswith("/") and not re.match(r"^/[A-Za-z]/", text): + continue # /s /q 等のスイッチ + resolved = resolve_path(text, cwd, "powershell") + if resolved in (UNKNOWN, SPECIAL): + redirect_kind = None + continue + if redirect_kind: + findings.writes.append(("cmd", text, resolved, redirect_kind)) + redirect_kind = None + continue + paths.append((text, resolved)) + switches = {w.lower() for w in words[1:] if w.startswith("/")} + for idx, (text, resolved) in enumerate(paths): + findings.reads.append(("cmd " + sub, text, resolved, False)) + is_last = idx == len(paths) - 1 and idx > 0 + kind = None + if sub in ("ren", "rename"): + kind = DELETE if idx == 0 else None # 2 番目は新しい名前 + elif sub == "robocopy" and switches & {"/mir", "/purge", "/mov", "/move"}: + kind = DELETE + elif sub in CMD_DELETE: + kind = OVERWRITE if sub == "move" and is_last else DELETE + elif sub in CMD_COPY and is_last: + kind = OVERWRITE + if kind == OVERWRITE and os.path.isdir(resolved): + kind = None # ディレクトリへのコピー・移動は書き込み先のファイル名を判断できない + if kind: + findings.writes.append(("cmd " + sub, text, resolved, kind)) + + +def strip_bash_prefixes(words: list) -> list: + """sudo / env / VAR=値 等のプレフィックスを取り除いた argv を返す.""" + idx = 0 + while idx < len(words): + if ENV_ASSIGNMENT.match(words[idx]): + idx += 1 + continue + base = command_basename(words[idx]) + if base in BASH_PREFIX_COMMANDS: + idx += 1 + # プレフィックスのオプションと,timeout の時間指定を読み飛ばす + while idx < len(words) and (words[idx].startswith("-") or ENV_ASSIGNMENT.match(words[idx])): + idx += 1 + if base == "timeout" and idx < len(words): + idx += 1 + continue + break + return words[idx:] + + +def command_basename(word: str) -> str: + base = os.path.basename(word.replace("\\", "/")).lower() + if base.endswith(".exe"): + base = base[:-4] + if re.match(r"^python3(\.\d+)?$", base): + return "python3" + return base + + +def split_option_args(cmd: str, args: list) -> tuple: + """args を (非オプション引数のリスト, -t 等の書き込み先ディレクトリのリスト) に分ける.""" + with_value = BASH_OPTS_WITH_VALUE.get(cmd, set()) + positional: list = [] + target_dirs: list = [] + idx = 0 + end_of_opts = False + while idx < len(args): + a = args[idx] + if end_of_opts or not a.startswith("-") or a == "-": + positional.append(a) + elif a == "--": + end_of_opts = True + elif "=" in a and a.startswith("--"): + name, value = a.split("=", 1) + if name in BASH_TARGET_DIR_OPTS: + target_dirs.append(value) + elif a in with_value: + if idx + 1 < len(args): + if a in BASH_TARGET_DIR_OPTS: + target_dirs.append(args[idx + 1]) + idx += 1 + elif a[:2] in with_value and len(a) > 2 and not a.startswith("--"): + if a[:2] in BASH_TARGET_DIR_OPTS: + target_dirs.append(a[2:]) + idx += 1 + return positional, target_dirs + + +def is_remote(path: str) -> bool: + """scp / rsync のリモート指定(host:path)かを判定する(C: 等のドライブは除く).""" + return bool(re.match(r"^[^/\\]{2,}:", path)) + + +def has_short_flag(args: list, letter: str) -> bool: + """-f や -rf のような短いオプションの束に letter が含まれるかを判定する.""" + return any(re.match(r"^-[a-zA-Z]*" + letter, a) for a in args if not a.startswith("--")) + + +def copy_dest_entries(name: str, dest_raw: str, srcs: list, cwd: str | None, dialect: str, + into_dir: bool = False, contents_on_slash: bool = False) -> list: + """コピー・移動の書き込み先を上書き判定用のエントリにする. + + 書き込み先が既存のディレクトリ(または -t 指定)なら,実際に書き込まれる + `<書き込み先>/<コピー元の名前>` を判定対象にする.コピー元の名前が分からない + (rsync の `src/` のように中身を展開する等)場合は判断できないので対象にしない. + """ + dest = resolve_path(dest_raw, cwd, dialect) + if dest in (UNKNOWN, SPECIAL): + return [] + if not (into_dir or os.path.isdir(dest)): + return [(name, dest_raw, dest, OVERWRITE)] + entries = [] + for src in srcs: + if contents_on_slash and src.endswith(("/", "\\")): + continue + base = posixpath.basename(src.replace("\\", "/").rstrip("/").split("?")[0]) + if base and base not in (".", "..") and "$" not in base: + entries.append((name, dest_raw, os.path.join(dest, base), OVERWRITE)) + return entries + + +def short_option_values(args: list, letter: str) -> list: + """`-o FILE`・`-sSLo FILE`・`-oFILE` 形式の値を取り出す(curl -o / wget -O 用).""" + values = [] + for idx, a in enumerate(args): + m = re.match(r"^-[a-zA-Z]*?" + letter + r"(.*)$", a) + if not m or a.startswith("--"): + continue + value = m.group(1) or (args[idx + 1] if idx + 1 < len(args) else "") + if value and value != "-": + values.append(value) + return values + + +def write_targets_bash(cmd: str, args: list, cwd: str | None, dialect: str = "bash") -> list: + """Bash の単純コマンドから削除・上書き・作成の対象を (名前, 文字列, 解決済みパス, 種別) で取り出す.""" + + def entries(raws: list, kind: str) -> list: + return [(cmd, r, resolve_path(r, cwd, dialect), kind) for r in raws] + + if cmd in BASH_DELETE: + positional, _ = split_option_args(cmd, args) + return entries(positional, DELETE) + if cmd in BASH_COPY: + positional, target_dirs = split_option_args(cmd, args) + if target_dirs: + srcs, dest, into_dir = positional, target_dirs[0], True + elif len(positional) >= 2: + srcs, dest, into_dir = positional[:-1], positional[-1], False + else: + srcs, dest, into_dir = positional, None, False + local_srcs = [s for s in srcs if not is_remote(s)] + result = [] + # 移動元は元の場所から消える + if cmd == "mv" or (cmd == "rsync" and "--remove-source-files" in args): + result += entries(local_srcs, DELETE) + if dest is None or is_remote(dest): + return result + if cmd == "ln" and not (has_short_flag(args, "f") or "--force" in args): + return result # -f が無ければ既存のリンク先を置き換えない + if cmd in ("cp", "mv") and (has_short_flag(args, "n") or "--no-clobber" in args): + return result + if cmd == "install" and (has_short_flag(args, "d") or "--directory" in args): + return result # ディレクトリ作成 + if cmd == "rsync" and any(a == "--del" or a.startswith("--delete") for a in args): + return result + entries([dest], DELETE) # 書き込み先の余分なファイルを消す + return result + copy_dest_entries(cmd, dest, local_srcs, cwd, dialect, into_dir, cmd == "rsync") + if cmd == "tee": + positional, _ = split_option_args(cmd, args) + append = has_short_flag(args, "a") or "--append" in args + return entries(positional, CREATE if append else OVERWRITE) + if cmd == "sed": + return entries(sed_inplace_targets(args), OVERWRITE) + if cmd in ("perl", "ruby") and has_short_flag(args, "i"): + return entries([a for a in interp_positional(args, {"-e", "-E", "-I", "-M", "-r"}) if a != "-"], OVERWRITE) + if cmd == "dd": + return entries([a[3:] for a in args if a.startswith("of=")], OVERWRITE) + if cmd == "find": + return entries(find_targets(args), DELETE) + if cmd == "curl": + return entries(option_values(args, {"--output"}) + short_option_values(args, "o"), OVERWRITE) + if cmd == "wget": + # -P(保存先ディレクトリ)は既存ファイルを上書きせず別名で保存するので対象外 + return entries(option_values(args, {"--output-document"}) + short_option_values(args, "O"), OVERWRITE) + return [] + + +def option_values(args: list, names: set) -> list: + values = [] + for idx, a in enumerate(args): + if a in names and idx + 1 < len(args): + values.append(args[idx + 1]) + elif "=" in a and a.split("=", 1)[0] in names: + values.append(a.split("=", 1)[1]) + return values + + +def interp_positional(args: list, value_opts: set) -> list: + """-e CODE 等の値を除いた非オプション引数を返す(sed / perl -i 用).""" + result = [] + idx = 0 + while idx < len(args): + a = args[idx] + if a in value_opts: + idx += 2 + continue + if not a.startswith("-") or a == "-": + result.append(a) + idx += 1 + return result + + +def sed_inplace_targets(args: list) -> list: + inplace = False + has_script_opt = False + for a in args: + if a.startswith("--in-place") or (re.match(r"^-[a-zA-Z]*i", a) and not a.startswith("--")): + inplace = True + if a in ("-e", "-f", "--expression", "--file") or a.startswith("--expression=") or a.startswith("--file="): + has_script_opt = True + elif re.match(r"^-[a-zA-Z]*[ef]$", a): + has_script_opt = True + if not inplace: + return [] + positional = interp_positional(args, {"-e", "-f", "--expression", "--file", "-l", "--line-length"}) + if not has_script_opt and positional: + positional = positional[1:] # 先頭はスクリプト + return positional + + +def find_targets(args: list) -> list: + """find の -delete / -exec rm 等があれば検索起点を削除対象とみなす.""" + starts = [] + for a in args: + if a.startswith("-") or a in ("(", "!", "\\("): + break + starts.append(a) + destructive = "-delete" in args + for idx, a in enumerate(args): + if a in ("-exec", "-execdir", "-ok", "-okdir") and idx + 1 < len(args): + sub = command_basename(args[idx + 1]) + if sub in BASH_DELETE or sub in BASH_COPY or sub in ("sed", "dd", "tee"): + destructive = True + if not destructive: + return [] + return starts or ["."] + + +def analyze_bash(command: str, cwd: str | None, findings: Findings, depth: int = 0) -> None: + """Bash コマンドを解析して findings に書き込み・読み取りを追加する.""" + if depth > 3: + return + tokens = tokenize_bash(command) + dir_stack: list = [] + subshell_stack: list = [] + for segment in split_segments(tokens): + if segment == "open": + subshell_stack.append(cwd) + continue + if segment == "close": + if subshell_stack: + cwd = subshell_stack.pop() + continue + words: list = [] + heredoc_bodies: list = [] + herestrings: list = [] + idx = 0 + while idx < len(segment): + kind, text, _ = segment[idx] + if kind == "redir": + target = segment[idx + 1][1] if idx + 1 < len(segment) and segment[idx + 1][0] == "word" else None + idx += 2 if target is not None else 1 + if target is None: + continue + if text in ("<<", "<<-"): + continue # 区切り語 + if text == "<<<": + herestrings.append(target) + continue + if text in (">&", "<&") and (target.isdigit() or target == "-"): + continue # ファイルディスクリプタの複製 + resolved = resolve_path(target, cwd) + if text == "<": + findings.reads.append(("<", target, resolved, False)) + else: + # >> / &>> は追記,<> は切り詰めずに開くので新規作成扱い.> / >| / &> は上書き + op_kind = CREATE if text in (">>", "&>>", "<>") else OVERWRITE + findings.writes.append((text, target, resolved, op_kind)) + continue + if kind == "heredoc": + if text: + heredoc_bodies.append(text) + idx += 1 + continue + words.append(text) + idx += 1 + argv = strip_bash_prefixes(words) + if not argv: + continue + cmd = command_basename(argv[0]) + args = argv[1:] + + # 引数はすべて読み取り(禁止リスト判定用)として記録する + for a in args: + for candidate in (a, a.split("=", 1)[1] if "=" in a else None): + if candidate: + findings.reads.append((cmd, candidate, resolve_path(candidate, cwd), cmd in RECURSIVE_READ_COMMANDS)) + + # cd / pushd / popd の追跡 + if cmd in ("cd", "pushd", "popd"): + if cmd == "popd": + cwd = dir_stack.pop() if dir_stack else UNKNOWN + continue + dest = [a for a in args if not (a.startswith("-") and a != "-")] + if not dest: + new_cwd = home_dir() + elif dest[0] == "-": + new_cwd = UNKNOWN + else: + new_cwd = resolve_path(dest[0], cwd) + if new_cwd == SPECIAL: + new_cwd = UNKNOWN + if cmd == "pushd": + dir_stack.append(cwd) + cwd = new_cwd + continue + + findings.writes += write_targets_bash(cmd, args, cwd) + + analyze_inline(cmd, args, heredoc_bodies + herestrings, cwd, findings, depth, "bash") + + +def analyze_inline(cmd: str, args: list, stdin_codes: list, cwd: str | None, findings: Findings, + depth: int, dialect: str) -> None: + """インライン実行(bash -c・pwsh -Command・cmd /c・python -c 等)のコードを解析する. + + stdin_codes はヒアドキュメント・ヒア文字列で標準入力に渡したコード(Bash のみ). + """ + if cmd in SHELL_COMMANDS: + for c in option_values(args, {"-c"}) + stdin_codes: + analyze_bash(c, cwd, findings, depth + 1) + elif cmd in POWERSHELL_COMMANDS: + for c in powershell_inline_code(args): + analyze_powershell(c, cwd, findings, depth + 1) + elif cmd in CMD_COMMANDS: + collect_cmd_exe(cmd_exe_args(args), cwd, findings) + elif cmd in INTERPRETER_CODE_OPTS or re.match(r"^python\d", cmd): + opts = INTERPRETER_CODE_OPTS.get(cmd, {"-c"}) + for c in option_values(args, opts) + stdin_codes: + collect_inline_literals(c, cmd, cwd, findings, dialect) + + +def cmd_exe_args(args: list) -> list: + """cmd /c(Git Bash では //c)以降の引数を返す.""" + for idx, a in enumerate(args): + if a.lower() in ("/c", "//c", "/k", "//k"): + return args[idx + 1:] + return [] + + +def powershell_inline_code(args: list) -> list: + codes = [] + for idx, a in enumerate(args): + low = a.lower() + if low in ("-c", "-command", "-com", "-comm", "-comma", "-comman") and idx + 1 < len(args): + codes.append(" ".join(args[idx + 1:])) + break + return codes + + +# --------------------------------------------------------------------------- +# PowerShell の解析 +# --------------------------------------------------------------------------- +def parse_ps_args(args: list) -> tuple: + """PowerShell の引数を (位置引数のリスト, {パラメーター名: [値, ...]}) に分ける. + + switch パラメーター(-Force・-Append 等)は値の無いキー(空リスト)として入れる. + """ + positional: list = [] + named: dict = {} + idx = 0 + while idx < len(args): + a = args[idx] + if a.startswith("-") and len(a) > 1 and not re.match(r"^-\d", a): + name = a[1:] + value = None + if ":" in name: + name, value = name.split(":", 1) + name = canonical_ps_param(name.lower()) + if value is None and name in PS_VALUE_PARAMS and idx + 1 < len(args): + value = args[idx + 1] + idx += 1 + values = named.setdefault(name, []) + if value is not None: + values.append(value) + idx += 1 + continue + positional.append(a) + idx += 1 + return positional, named + + +def canonical_ps_param(name: str) -> str: + """-OutF / -Dest 等の省略形を正式なパラメーター名に揃える.""" + if name in PS_VALUE_PARAMS: + return name + for full, min_len in PS_PARAM_ABBREVIATIONS: + if len(name) >= min_len and full.startswith(name): + return full + return name + + +def write_targets_ps(cmd: str, args: list, cwd: str | None) -> list: + """PowerShell の単純コマンドから削除・上書き・作成の対象を (名前, 文字列, 解決済みパス, 種別) で取り出す.""" + positional, named = parse_ps_args(args) + + def entries(raws: list, kind: str) -> list: + return [(cmd, r, resolve_path(r, cwd, "powershell"), kind) for r in raws] + + def targets(first_only: bool) -> list: + result = positional[:1] if first_only else list(positional) + for name in PS_TARGET_PARAMS: + result += named.get(name, []) + return result + + def source_and_dest(dest_params: tuple) -> tuple: + """(読み取り元・移動元のリスト, 書き込み先のリスト) を返す.""" + srcs = [v for name in PS_SOURCE_PARAMS for v in named.get(name, [])] + dest = [v for name in dest_params for v in named.get(name, [])] + rest = list(positional) + if not srcs: + srcs, rest = rest[:1], rest[1:] + if not dest: + dest = rest[:1] + return srcs, dest + + if cmd in PS_DELETE: + return entries(targets(False), DELETE) + if cmd in PS_RENAME: + return entries(targets(True), DELETE) + if cmd in PS_OVERWRITE_FIRST: + return entries(targets(True), OVERWRITE) + if cmd in PS_OVERWRITE_UNLESS_APPEND: + append = "append" in named or "noclobber" in named + return entries(targets(True), CREATE if append else OVERWRITE) + if cmd in PS_NEW_ITEM: + paths = targets(True) + if "name" in named: + paths = [os.path.join(b, nm) for b in (paths or ["."]) for nm in named["name"]] + item_type = (named.get("itemtype", []) + named.get("type", []) + [""])[0].lower() + # -Force 付きの New-Item(ファイル)は既存ファイルを空にして作り直す + overwrite = "force" in named and not item_type.startswith("d") + return entries(paths, OVERWRITE if overwrite else CREATE) + if cmd in PS_MOVE or cmd in PS_WRITE_DEST: + srcs, dest = source_and_dest(("destination",)) + result = entries(srcs, DELETE) if cmd in PS_MOVE else [] + for d in dest: + result += copy_dest_entries(cmd, d, srcs, cwd, "powershell") + return result + if cmd in PS_WRITE_ARCHIVE_DEST: + srcs, dest = source_and_dest(("destinationpath", "destination")) + if cmd == "start-bitstransfer": + return [e for d in dest for e in copy_dest_entries(cmd, d, srcs, cwd, "powershell")] + if not dest and cmd == "expand-archive": + dest = ["."] # 展開先の省略時はカレントディレクトリに展開する + # -Force(Compress-Archive は -Update も)が無ければ既存の書き込み先があるとエラーで止まる + overwrite = "force" in named or (cmd == "compress-archive" and "update" in named) + return entries(dest, OVERWRITE if overwrite else CREATE) + if cmd in PS_WEB_REQUEST: + # -OutFile にディレクトリを渡すと,その中に URL のファイル名で保存する(判断できないので対象外) + result = [e for e in entries(named.get("outfile", []), OVERWRITE) + if e[2] in (UNKNOWN, SPECIAL) or not os.path.isdir(e[2])] + if cmd in ("curl", "wget"): + result += write_targets_bash(cmd, args, cwd, "powershell") + return result + return [] + + +def analyze_powershell(command: str, cwd: str | None, findings: Findings, depth: int = 0) -> None: + """PowerShell コマンドを解析して findings に書き込み・読み取りを追加する.""" + if depth > 3: + return + collect_dotnet_calls(command, cwd, findings) + tokens = tokenize_powershell(command) + dir_stack: list = [] + for segment in split_segments(tokens): + if segment in ("open", "close"): + continue + words: list = [] + idx = 0 + while idx < len(segment): + kind, text, _ = segment[idx] + if kind == "redir": + target = segment[idx + 1][1] if idx + 1 < len(segment) and segment[idx + 1][0] == "word" else None + idx += 2 if target is not None else 1 + if target is None or (text == ">&" and target.isdigit()): + continue + if text == "<": + findings.reads.append(("<", target, resolve_path(target, cwd, "powershell"), False)) + else: + op_kind = CREATE if text == ">>" else OVERWRITE + findings.writes.append((text, target, resolve_path(target, cwd, "powershell"), op_kind)) + continue + words.append(text) + idx += 1 + if not words: + continue + cmd = command_basename(words[0]) + args = words[1:] + + positional, named = parse_ps_args(args) + recursive = cmd in PS_RECURSIVE_READ + for value in positional + [v for vs in named.values() for v in vs]: + findings.reads.append((cmd, value, resolve_path(value, cwd, "powershell"), recursive)) + + if cmd in PS_LOCATION_SET or cmd in PS_LOCATION_PUSH or cmd in PS_LOCATION_POP: + if cmd in PS_LOCATION_POP: + cwd = dir_stack.pop() if dir_stack else UNKNOWN + continue + dest = named.get("path", []) + named.get("literalpath", []) + positional + if not dest: + new_cwd = home_dir() if cmd in PS_LOCATION_SET else cwd + elif dest[0] in ("-", "+"): + new_cwd = UNKNOWN + else: + new_cwd = resolve_path(dest[0], cwd, "powershell") + if new_cwd == SPECIAL: + new_cwd = UNKNOWN + if cmd in PS_LOCATION_PUSH: + dir_stack.append(cwd) + cwd = new_cwd + continue + + findings.writes += write_targets_ps(cmd, args, cwd) + analyze_inline(cmd, args, [], cwd, findings, depth, "powershell") + + +# --------------------------------------------------------------------------- +# 判定 +# --------------------------------------------------------------------------- +def destructive_reason(kind: str, resolved: str, policy: Policy) -> str | None: + """リポジトリ外の削除・既存ファイルの上書きなら,その説明(「削除」等)を返す.""" + if kind == CREATE or policy.is_write_allowed(resolved): + return None + if kind == DELETE: + return "ファイルの削除(移動を含む)" + if os.path.lexists(resolved): + return "既存ファイルの上書き" + return None # 存在しないパスへの書き込みは新規作成なので許可する + + +def evaluate_findings(findings: Findings, policy: Policy) -> str | None: + """解析結果を判定し,拒否する場合は理由文字列を返す.""" + for name, raw, resolved, kind in findings.writes: + if resolved in (UNKNOWN, SPECIAL): + continue + entry = policy.denied_entry(resolved) + if entry: + return deny_read_message(f"`{name}` が `{raw}` を対象としています", entry) + action = destructive_reason(kind, resolved, policy) + if action: + return ( + f"リポジトリ外の{action}をブロックしました: " + f"`{name}` が `{raw}` を対象としています.{WRITE_DENY_GUIDANCE}" + ) + for name, raw, resolved, kind in findings.inline: + if resolved in (UNKNOWN, SPECIAL): + continue + entry = policy.denied_entry(resolved) + if entry: + return deny_read_message(f"`{name}` のコード中に `{raw}` が含まれています", entry) + action = destructive_reason(kind, resolved, policy) + if action: + detail = "削除系の呼び出しとリポジトリ外のパス" if kind == DELETE else "リポジトリ外の既存ファイルへの上書き" + return ( + f"インライン実行のコード中に{detail} `{raw}` が含まれるためブロックしました" + f"(`{name}`).{WRITE_DENY_GUIDANCE}" + ) + for name, raw, resolved, recursive in findings.reads: + if resolved in (UNKNOWN, SPECIAL): + continue + entry = policy.denied_entry(resolved) + if entry is None and recursive: + entry = policy.contained_denied_entry(resolved) + if entry: + return deny_read_message(f"`{name}` が `{raw}` を対象としています", entry) + return None + + +def check_shell_command(analyzer, command: str, cwd: str, policy: Policy) -> str | None: + """コマンドを analyzer(analyze_bash / analyze_powershell)で解析して判定する.""" + findings = Findings() + analyzer(command, cwd, findings) + return evaluate_findings(findings, policy) + + +def check_bash_command(command: str, cwd: str, repo_root: str | None = None, deny_read: list | None = None) -> str | None: + """Bash コマンドがリポジトリ外の削除・上書き,禁止パスへのアクセスを含むか判定する.""" + return check_shell_command(analyze_bash, command, cwd, Policy(repo_root or cwd, deny_read or [])) + + +def check_powershell_command(command: str, cwd: str, repo_root: str | None = None, deny_read: list | None = None) -> str | None: + """PowerShell コマンドがリポジトリ外の削除・上書き,禁止パスへのアクセスを含むか判定する.""" + return check_shell_command(analyze_powershell, command, cwd, Policy(repo_root or cwd, deny_read or [])) + + +def glob_base(pattern: str) -> str: + """グロブパターンからワイルドカードを含まない先頭部分を取り出す.""" + m = re.search(r"[*?\[{]", pattern) + if m is None: + return pattern + head = pattern[:m.start()] + return head[: max(head.rfind("/"), head.rfind("\\")) + 1] or head + + +def check_file_tool(tool_name: str, tool_input: dict, cwd: str, policy: Policy) -> str | None: + """Read / Write / Edit / NotebookEdit / Glob / Grep のパスを判定する.""" + paths: list = [] + if tool_name in ("Read", "Write", "Edit", "MultiEdit"): + paths.append(tool_input.get("file_path")) + elif tool_name == "NotebookEdit": + paths.append(tool_input.get("notebook_path")) + elif tool_name in ("Glob", "Grep"): + paths.append(tool_input.get("path")) # 省略時は None → cwd(判定不要) + pattern = tool_input.get("pattern") if tool_name == "Glob" else None + if isinstance(pattern, str) and looks_absolute(pattern): + base_dir = tool_input.get("path") or cwd + paths.append(os.path.join(base_dir, glob_base(pattern))) + # Write は既存ファイルの上書きだけを止める.Edit 系は既存ファイルの書き換えなので常に止める + kind = {"Write": OVERWRITE, "Edit": DELETE, "MultiEdit": DELETE, "NotebookEdit": DELETE}.get(tool_name, CREATE) + for raw in paths: + if not isinstance(raw, str) or not raw: + continue + resolved = resolve_path(raw, cwd) + if resolved in (UNKNOWN, SPECIAL): + continue + entry = policy.denied_entry(resolved) + if entry is None and tool_name in ("Glob", "Grep"): + entry = policy.contained_denied_entry(resolved) + if entry: + return deny_read_message(raw, entry) + if destructive_reason(kind, resolved, policy): + action = "既存ファイルの上書き" if kind == OVERWRITE else "既存ファイルの書き換え" + return f"リポジトリ外の{action}をブロックしました: {raw}.{WRITE_DENY_GUIDANCE}" + return None + + +def deny(reason: str) -> None: + """ブロック用の JSON を出力して終了する.""" + result = { + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": reason, + } + } + json.dump(result, sys.stdout) + sys.exit(0) + + +def get_repo_root(cwd: str) -> str: + project_dir = os.environ.get("CLAUDE_PROJECT_DIR") + if project_dir: + return os.path.normpath(convert_posix_drive(project_dir)) + return cwd + + +def main() -> None: + data = json.load(sys.stdin) + tool_name = data.get("tool_name", "") + tool_input = data.get("tool_input", {}) or {} + cwd = data.get("cwd") or os.getcwd() + repo_root = get_repo_root(cwd) + policy = Policy(repo_root, load_deny_read(repo_root)) + + if tool_name in ("Bash", "PowerShell"): + analyzer = analyze_bash if tool_name == "Bash" else analyze_powershell + reason = check_shell_command(analyzer, tool_input.get("command", "") or "", cwd, policy) + else: + reason = check_file_tool(tool_name, tool_input, cwd, policy) + + if reason: + deny(reason) + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/.claude/hooks/run_python.sh b/.claude/hooks/run_python.sh new file mode 100644 index 0000000..1073d8a --- /dev/null +++ b/.claude/hooks/run_python.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# Python フックの起動ラッパー. +# 使い方: bash .claude/hooks/run_python.sh [--fail-closed] [args...] +# +# 環境ごとに Python のコマンド名が異なるため (Debian/Ubuntu 系・macOS は python3 のみ, +# Windows の python.org 版は python / py のみで python3 は Microsoft Store を開くダミー), +# python3 -> python -> py の順に実際に起動できるものを探して exec する. +# exec するのでスクリプトの終了コード (PreToolUse のブロック = 2 等) はそのまま伝わる. +# +# Python が見つからない場合: +# --fail-closed あり: exit 2 (PreToolUse ではツール実行をブロックする. アクセス制限フック用) +# --fail-closed なし: exit 1 (非ブロックエラー. 通知フック等は素通りさせる) + +fail_code=1 +if [ "$1" = "--fail-closed" ]; then + fail_code=2 + shift +fi + +for py in python3 python py; do + command -v "$py" >/dev/null 2>&1 || continue + # ダミー (Microsoft Store のエイリアス等) を除外するため実際に起動できるか確認する + "$py" -c '' /dev/null 2>&1 || continue + exec "$py" "$@" +done + +if [ "$fail_code" -eq 2 ]; then + echo "Python(python3 / python / py)が見つからないためツール実行をブロックしました.Python 3.7 以上をインストールしてください" >&2 +else + echo "Python(python3 / python / py)が見つからないためフックをスキップしました: $1" >&2 +fi +exit "$fail_code" diff --git a/.claude/hooks/tests/test_hooks.py b/.claude/hooks/tests/test_hooks.py new file mode 100644 index 0000000..29cda6d --- /dev/null +++ b/.claude/hooks/tests/test_hooks.py @@ -0,0 +1,1372 @@ +"""`.claude/hooks/` のフック起動まわりのテスト(Issue #35: Python フックの起動ラッパー). + +実行(リポジトリルートで): + python -B -m unittest discover -s .claude/hooks/tests -v + +外部依存なし(標準ライブラリの unittest のみ).bash が必要(Windows では Git for Windows の bash). +notify.py は NTFY_TOPIC 未設定,またはローカルの一時 HTTP サーバー宛てでのみ実行し,ntfy.sh には送信しない. +""" + +from __future__ import annotations + +import ast +import contextlib +import http.server +import io +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile +import threading +import unittest +from unittest import mock + +HOOKS_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +REPO_ROOT = os.path.dirname(os.path.dirname(HOOKS_DIR)) +WRAPPER = os.path.join(HOOKS_DIR, "run_python.sh") +SETTINGS = os.path.join(REPO_ROOT, ".claude", "settings.json") +RESTRICT = os.path.join(HOOKS_DIR, "restrict_repo_access.py") +NOTIFY = os.path.join(HOOKS_DIR, "notify.py") +CHECK_SYNC = os.path.join(HOOKS_DIR, "check_sync.sh") + +TIMEOUT = 60 + + +def find_bash() -> str: + """テストに使う bash を返す(Windows では WSL の System32\\bash.exe を避ける).""" + env_bash = os.environ.get("HOOK_TEST_BASH") + if env_bash: + return env_bash + found = shutil.which("bash") + if os.name == "nt": + if found is None or "system32" in found.lower(): + for candidate in ( + r"C:\Program Files\Git\usr\bin\bash.exe", + r"C:\Program Files\Git\bin\bash.exe", + ): + if os.path.exists(candidate): + return candidate + if found is None: + raise unittest.SkipTest("bash が見つからない") + return found + + +BASH = find_bash() +REAL_PYTHON = sys.executable.replace("\\", "/") + + +def bash_bin_dirs() -> list[str]: + """git・timeout・dirname 等の基本コマンドがあるディレクトリ(Python は含まない).""" + dirs = [os.path.dirname(BASH)] + for cmd in ("git", "timeout", "dirname"): + found = shutil.which(cmd) + if found and os.path.dirname(found) not in dirs: + dirs.append(os.path.dirname(found)) + if os.name != "nt": + for d in ("/usr/bin", "/bin"): + if d not in dirs: + dirs.append(d) + return dirs + + +class FakeBin: + """PATH の先頭に置く偽コマンド用の一時ディレクトリ.""" + + def __init__(self) -> None: + self.dir = tempfile.mkdtemp(prefix="fakebin_") + + def cleanup(self) -> None: + shutil.rmtree(self.dir, ignore_errors=True) + + def _write(self, name: str, body: str) -> None: + path = os.path.join(self.dir, name) + with open(path, "w", newline="\n") as f: + f.write("#!/bin/sh\n" + body) + os.chmod(path, 0o755) + + def dummy(self, name: str) -> None: + """存在するが起動できない(常に exit 1)ダミー(Microsoft Store エイリアス相当).""" + self._write(name, 'echo "DUMMY_CALLED:' + name + '" >&2\nexit 1\n') + + def marker(self, name: str) -> None: + """`-c ''` は成功し,本実行時は自分の名前と引数を出力する偽 Python.""" + self._write( + name, + 'if [ "$1" = "-c" ]; then exit 0; fi\n' + 'echo "SELECTED:' + name + '"\nexit 0\n', + ) + + def real(self, name: str) -> None: + """テスト実行中の本物の Python に委譲する.""" + self._write(name, 'exec "' + REAL_PYTHON + '" "$@"\n') + + +def make_env(path_dirs: list[str], extra: dict | None = None, drop: tuple = ()) -> dict: + env = dict(os.environ) + for key in drop: + env.pop(key, None) + env["PATH"] = os.pathsep.join(path_dirs) + if extra: + env.update(extra) + return env + + +def run_wrapper(args: list[str], env: dict, stdin: bytes = b"", cwd: str | None = None): + return subprocess.run( + [BASH, WRAPPER] + args, + input=stdin, + capture_output=True, + env=env, + cwd=cwd or REPO_ROOT, + timeout=TIMEOUT, + ) + + +class TempDirMixin: + def setUp(self) -> None: # noqa: D401 + self.fake = FakeBin() + self.work = tempfile.mkdtemp(prefix="hooktest_") + + def tearDown(self) -> None: + self.fake.cleanup() + shutil.rmtree(self.work, ignore_errors=True) + + def write_script(self, name: str, source: str) -> str: + path = os.path.join(self.work, name) + with open(path, "w", encoding="utf-8", newline="\n") as f: + f.write(source) + return path + + +# --------------------------------------------------------------------------- +# run_python.sh: Python コマンドの選択順 +# --------------------------------------------------------------------------- +class TestWrapperSelection(TempDirMixin, unittest.TestCase): + def selected(self, *wrapper_args: str) -> subprocess.CompletedProcess: + return run_wrapper(list(wrapper_args) or ["script.py"], make_env([self.fake.dir])) + + def assertSelected(self, result, name: str) -> None: + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout.decode().strip(), "SELECTED:" + name) + + def test_prefers_python3_when_all_available(self): + for n in ("python3", "python", "py"): + self.fake.marker(n) + self.assertSelected(self.selected(), "python3") + + def test_falls_back_to_python_when_no_python3(self): + self.fake.marker("python") + self.fake.marker("py") + self.assertSelected(self.selected(), "python") + + def test_falls_back_to_py_when_only_py(self): + self.fake.marker("py") + self.assertSelected(self.selected(), "py") + + def test_skips_dummy_python3(self): + self.fake.dummy("python3") + self.fake.marker("python") + self.fake.marker("py") + self.assertSelected(self.selected(), "python") + + def test_skips_dummy_python3_and_python(self): + self.fake.dummy("python3") + self.fake.dummy("python") + self.fake.marker("py") + self.assertSelected(self.selected(), "py") + + def test_fail_closed_flag_does_not_change_selection(self): + self.fake.dummy("python3") + self.fake.marker("python") + self.assertSelected(self.selected("--fail-closed", "script.py"), "python") + + def test_dummy_is_not_used_for_actual_run(self): + """ダミーは起動確認で弾かれ,本実行には使われない.""" + self.fake.dummy("python3") + self.fake.marker("python") + result = self.selected() + self.assertSelected(result, "python") + # ダミーが呼ばれるのは起動確認(-c '')のみで,その stderr は捨てられている + self.assertNotIn(b"DUMMY_CALLED", result.stderr) + + +# --------------------------------------------------------------------------- +# run_python.sh: Python 未検出時 +# --------------------------------------------------------------------------- +class TestWrapperNotFound(TempDirMixin, unittest.TestCase): + def test_no_python_exit_1_without_flag(self): + result = run_wrapper(["script.py"], make_env([self.fake.dir])) + self.assertEqual(result.returncode, 1) + self.assertTrue(result.stderr.strip(), "stderr に理由が出力されること") + + def test_no_python_exit_2_with_fail_closed(self): + result = run_wrapper(["--fail-closed", "script.py"], make_env([self.fake.dir])) + self.assertEqual(result.returncode, 2) + self.assertTrue(result.stderr.strip(), "stderr に理由が出力されること") + + def test_only_dummies_exit_1_without_flag(self): + for n in ("python3", "python", "py"): + self.fake.dummy(n) + result = run_wrapper(["script.py"], make_env([self.fake.dir])) + self.assertEqual(result.returncode, 1) + self.assertTrue(result.stderr.strip()) + + def test_only_dummies_exit_2_with_fail_closed(self): + for n in ("python3", "python", "py"): + self.fake.dummy(n) + result = run_wrapper(["--fail-closed", "script.py"], make_env([self.fake.dir])) + self.assertEqual(result.returncode, 2) + self.assertTrue(result.stderr.strip()) + + def test_not_found_with_no_script_args(self): + """引数なしでも異常終了(set -u 的なクラッシュ)せず規定の終了コードを返す.""" + result = run_wrapper([], make_env([self.fake.dir])) + self.assertEqual(result.returncode, 1) + result = run_wrapper(["--fail-closed"], make_env([self.fake.dir])) + self.assertEqual(result.returncode, 2) + + +# --------------------------------------------------------------------------- +# run_python.sh: 終了コード・stdin・引数の伝搬(本物の Python に委譲) +# --------------------------------------------------------------------------- +class TestWrapperPassthrough(TempDirMixin, unittest.TestCase): + def setUp(self) -> None: + super().setUp() + self.fake.dummy("python3") # Windows の Store エイリアス相当 + self.fake.real("python") + self.env = make_env([self.fake.dir]) + + def test_exit_codes_propagate(self): + script = self.write_script("exit.py", "import sys\nsys.exit(int(sys.argv[1]))\n") + for code in (0, 1, 2, 3, 42): + for flag in ([], ["--fail-closed"]): + with self.subTest(code=code, flag=flag): + result = run_wrapper(flag + [script, str(code)], self.env) + self.assertEqual(result.returncode, code, result.stderr) + + def test_args_passed_verbatim(self): + script = self.write_script( + "argv.py", "import json, sys\nprint(json.dumps(sys.argv[1:]))\n" + ) + args = ["a b", "", "--fail-closed", "x=y", "日本語", "--flag"] + for flag in ([], ["--fail-closed"]): + with self.subTest(flag=flag): + result = run_wrapper( + flag + [script] + args, + dict(self.env, PYTHONIOENCODING="utf-8"), + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(json.loads(result.stdout.decode("utf-8")), args) + + def test_fail_closed_flag_not_passed_to_script(self): + script = self.write_script( + "argv.py", "import json, sys\nprint(json.dumps(sys.argv[1:]))\n" + ) + result = run_wrapper(["--fail-closed", script, "one"], self.env) + self.assertEqual(json.loads(result.stdout.decode()), ["one"]) + + def test_stdin_passed_through(self): + script = self.write_script( + "stdin.py", + "import sys\nsys.stdout.buffer.write(sys.stdin.buffer.read())\n", + ) + payload = json.dumps({"tool_name": "Read", "x": "日本語"}, ensure_ascii=False).encode("utf-8") + payload = payload * 200 # ある程度の大きさでも欠けないこと + for flag in ([], ["--fail-closed"]): + with self.subTest(flag=flag): + result = run_wrapper(flag + [script], self.env, stdin=payload) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout, payload) + + def test_stdin_not_consumed_by_probe(self): + """起動確認(-c '')が stdin を読み捨てないこと(ダミー・本物ともに).""" + self.fake.real("python3") # python3 も本物にして probe → 本実行の経路を通す + script = self.write_script( + "stdin.py", "import sys\nsys.stdout.write(sys.stdin.read())\n" + ) + result = run_wrapper([script], self.env, stdin=b"hello-stdin") + self.assertEqual(result.stdout, b"hello-stdin") + + def test_stdin_script_dash(self): + """check_sync.sh と同じ `-` 形式(ヒアドキュメントのスクリプト)が動く.""" + result = run_wrapper( + ["-", "arg1"], + self.env, + stdin=b"import sys\nprint('ok:' + sys.argv[1])\n", + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout.decode().strip(), "ok:arg1") + + +# --------------------------------------------------------------------------- +# settings.json の配線 +# --------------------------------------------------------------------------- +class TestSettingsJson(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + with open(SETTINGS, encoding="utf-8") as f: + cls.raw = f.read() + cls.settings = json.loads(cls.raw) + cls.commands = [] + for event, groups in cls.settings.get("hooks", {}).items(): + for group in groups: + for hook in group.get("hooks", []): + if hook.get("type") == "command": + cls.commands.append((event, group.get("matcher"), hook)) + + def test_restrict_repo_access_uses_wrapper_with_fail_closed(self): + cmds = [h["command"] for _, _, h in self.commands if "restrict_repo_access.py" in h["command"]] + self.assertEqual( + cmds, + ["bash .claude/hooks/run_python.sh --fail-closed .claude/hooks/restrict_repo_access.py"], + ) + + def test_restrict_repo_access_is_pretooluse_for_file_tools(self): + entries = [(e, m) for e, m, h in self.commands if "restrict_repo_access.py" in h["command"]] + self.assertEqual(len(entries), 1) + event, matcher = entries[0] + self.assertEqual(event, "PreToolUse") + for tool in ("Read", "Write", "Edit", "Glob", "Grep", "Bash"): + self.assertIn(tool, matcher.split("|")) + + def test_notify_three_hooks_use_wrapper_without_fail_closed(self): + cmds = [h["command"] for _, _, h in self.commands if "notify.py" in h["command"]] + self.assertEqual(len(cmds), 3) + for cmd in cmds: + self.assertEqual(cmd, "bash .claude/hooks/run_python.sh .claude/hooks/notify.py") + self.assertNotIn("--fail-closed", cmd) + + def test_notify_events(self): + events = sorted(e for e, _, h in self.commands if "notify.py" in h["command"]) + self.assertEqual(events, ["Notification", "PreToolUse", "Stop"]) + + def test_no_direct_python_invocation_left(self): + self.assertIsNone( + re.search(r"\b(python3?|py)\s+\.claude/hooks/", self.raw), + "python .claude/hooks/ の直接呼び出しが残っている", + ) + for _, _, hook in self.commands: + if ".py" in hook["command"]: + self.assertTrue( + hook["command"].startswith("bash .claude/hooks/run_python.sh "), + hook["command"], + ) + + def test_referenced_scripts_exist(self): + for _, _, hook in self.commands: + for token in hook["command"].split(): + if token.startswith(".claude/hooks/"): + self.assertTrue(os.path.isfile(os.path.join(REPO_ROOT, token)), token) + + +# --------------------------------------------------------------------------- +# restrict_repo_access.py をラッパー経由で(settings.json と同じコマンドで)起動 +# --------------------------------------------------------------------------- +class TestRestrictViaWrapper(TempDirMixin, unittest.TestCase): + OUTSIDE = os.path.join(os.path.dirname(REPO_ROOT), "outside_of_repo_dummy.txt") + INSIDE = os.path.join(REPO_ROOT, "CLAUDE.md") + + def run_hook(self, data: dict, env: dict) -> subprocess.CompletedProcess: + # settings.json の command と同じ相対パス・cwd で起動する + return subprocess.run( + [BASH, ".claude/hooks/run_python.sh", "--fail-closed", ".claude/hooks/restrict_repo_access.py"], + input=json.dumps(data).encode("utf-8"), + capture_output=True, + env=env, + cwd=REPO_ROOT, + timeout=TIMEOUT, + ) + + @staticmethod + def is_blocked(result) -> bool: + if result.returncode == 2: + return True + if result.returncode != 0: + return False + out = result.stdout.decode("utf-8").strip() + if not out: + return False + decision = json.loads(out).get("hookSpecificOutput", {}).get("permissionDecision") + return decision == "deny" + + def envs(self): + # 1) 実環境の PATH(このマシンでは python3 が Store エイリアスのダミー) + yield "real PATH", dict(os.environ) + # 2) ダミー python3 + 本物 python のみ + self.fake.dummy("python3") + self.fake.real("python") + yield "dummy python3 + python", make_env([self.fake.dir]) + + def test_blocks_edit_outside_repo(self): + # Issue #49: 読み取りと新規作成はリポジトリ外も許可し,既存ファイルの書き換え(Edit)はブロックする + data = {"tool_name": "Edit", "tool_input": {"file_path": self.OUTSIDE, "old_string": "a", "new_string": "b"}, "cwd": REPO_ROOT} + for label, env in self.envs(): + with self.subTest(env=label): + result = self.run_hook(data, env) + self.assertTrue(self.is_blocked(result), (result.returncode, result.stdout, result.stderr)) + + def test_blocks_destructive_bash_outside_repo(self): + outside_dir = os.path.dirname(REPO_ROOT).replace("\\", "/") + data = {"tool_name": "Bash", "tool_input": {"command": f'rm -rf "{outside_dir}/foo"'}, "cwd": REPO_ROOT} + for label, env in self.envs(): + with self.subTest(env=label): + result = self.run_hook(data, env) + self.assertTrue(self.is_blocked(result), (result.returncode, result.stdout, result.stderr)) + + def test_allows_inside_repo(self): + cases = [ + {"tool_name": "Read", "tool_input": {"file_path": self.INSIDE}, "cwd": REPO_ROOT}, + {"tool_name": "Edit", "tool_input": {"file_path": os.path.join(REPO_ROOT, "docs", "x.md")}, "cwd": REPO_ROOT}, + {"tool_name": "Grep", "tool_input": {"pattern": "x"}, "cwd": REPO_ROOT}, + {"tool_name": "Bash", "tool_input": {"command": "rm -rf ./build"}, "cwd": REPO_ROOT}, + ] + for label, env in self.envs(): + for data in cases: + with self.subTest(env=label, tool=data["tool_name"]): + result = self.run_hook(data, env) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertFalse(self.is_blocked(result), result.stdout) + + def test_blocks_everything_when_python_missing(self): + """Python が無いと fail-closed で exit 2(リポジトリ内でもブロック).""" + data = {"tool_name": "Read", "tool_input": {"file_path": self.INSIDE}, "cwd": REPO_ROOT} + self.fake.dummy("python3") + result = self.run_hook(data, make_env([self.fake.dir])) + self.assertEqual(result.returncode, 2) + self.assertTrue(result.stderr.strip()) + + +# --------------------------------------------------------------------------- +# notify.py をラッパー経由で起動(ntfy.sh には送らない) +# --------------------------------------------------------------------------- +class _Capture(http.server.BaseHTTPRequestHandler): + received: list = [] + + def do_POST(self): # noqa: N802 + length = int(self.headers.get("Content-Length", 0)) + _Capture.received.append(self.rfile.read(length)) + self.send_response(200) + self.end_headers() + + def log_message(self, *args): # 出力を抑制 + pass + + +class TestNotifyViaWrapper(TempDirMixin, unittest.TestCase): + def run_notify(self, data: dict, env: dict) -> subprocess.CompletedProcess: + return subprocess.run( + [BASH, ".claude/hooks/run_python.sh", ".claude/hooks/notify.py"], + input=json.dumps(data).encode("utf-8"), + capture_output=True, + env=env, + cwd=REPO_ROOT, + timeout=TIMEOUT, + ) + + def test_no_topic_does_nothing(self): + env = dict(os.environ) + env.pop("NTFY_TOPIC", None) + env["NTFY_SERVER"] = "http://127.0.0.1:9" # 万一送信しても外部に出ない + result = self.run_notify({"hook_event_name": "Stop", "cwd": REPO_ROOT}, env) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout, b"") + self.assertEqual(result.stderr, b"") + + def test_python_missing_is_non_blocking(self): + self.fake.dummy("python3") + env = make_env([self.fake.dir]) + env.pop("NTFY_TOPIC", None) + result = self.run_notify({"hook_event_name": "Stop"}, env) + self.assertEqual(result.returncode, 1) + self.assertNotEqual(result.returncode, 2) + + def test_sends_to_local_server_with_repo_name(self): + """ローカル HTTP サーバー宛てに送信し,Python 3.7 互換化後も内容が正しいこと.""" + _Capture.received = [] + server = http.server.HTTPServer(("127.0.0.1", 0), _Capture) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + repo = os.path.join(self.work, "folder-name") + os.makedirs(repo) + subprocess.run(["git", "init", "-q", repo], check=True, capture_output=True) + subprocess.run( + ["git", "-C", repo, "remote", "add", "origin", "git@github.com:owner/my-repo.git"], + check=True, + capture_output=True, + ) + self.fake.dummy("python3") + self.fake.real("python") + env = make_env( + [self.fake.dir] + bash_bin_dirs(), + extra={ + "NTFY_TOPIC": "test-topic", + "NTFY_SERVER": f"http://127.0.0.1:{server.server_address[1]}", + }, + ) + result = self.run_notify({"hook_event_name": "Stop", "cwd": repo}, env) + self.assertEqual(result.returncode, 0, result.stderr) + finally: + server.shutdown() + server.server_close() + self.assertEqual(len(_Capture.received), 1) + payload = json.loads(_Capture.received[0].decode("utf-8")) + self.assertEqual(payload["topic"], "test-topic") + self.assertEqual(payload["title"], "Claude Code (my-repo)") + + +# --------------------------------------------------------------------------- +# notify.project_name(removesuffix 置換後の挙動) +# --------------------------------------------------------------------------- +class TestNotifyProjectName(TempDirMixin, unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + sys.path.insert(0, HOOKS_DIR) + try: + import notify # noqa: WPS433 + finally: + sys.path.pop(0) + cls.notify = notify + + def repo_with_origin(self, url: str | None) -> str: + repo = os.path.join(self.work, "local-folder") + os.makedirs(repo, exist_ok=True) + subprocess.run(["git", "init", "-q", repo], check=True, capture_output=True) + if url is not None: + subprocess.run(["git", "-C", repo, "remote", "add", "origin", url], check=True, capture_output=True) + return repo + + def test_cases(self): + cases = [ + ("https://github.com/owner/repo.git", "repo"), + ("https://github.com/owner/repo", "repo"), + ("https://github.com/owner/repo/", "repo"), + ("git@github.com:owner/repo.git", "repo"), + ("git@github.com:owner/repo", "repo"), + ("https://github.com/owner/my.git.repo.git", "my.git.repo"), + ("https://github.com/owner/gitrepo", "gitrepo"), + ] + for url, expected in cases: + with self.subTest(url=url): + shutil.rmtree(os.path.join(self.work, "local-folder"), ignore_errors=True) + repo = self.repo_with_origin(url) + self.assertEqual(self.notify.project_name(repo), expected) + + def test_no_origin_returns_folder(self): + repo = self.repo_with_origin(None) + self.assertEqual(self.notify.project_name(repo), "local-folder") + + def test_only_dotgit_falls_back_to_folder(self): + repo = self.repo_with_origin("https://example.com/.git") + self.assertEqual(self.notify.project_name(repo), "local-folder") + + +# --------------------------------------------------------------------------- +# notify.build_notification(どのイベントで通知するか) +# --------------------------------------------------------------------------- +def _subagent_task(agent_type: str = "coder") -> dict: + return {"id": "t1", "type": "subagent", "status": "running", "description": "...", "agent_type": agent_type} + + +class TestNotifyBuildNotification(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + sys.path.insert(0, HOOKS_DIR) + try: + import notify # noqa: WPS433 + finally: + sys.path.pop(0) + cls.notify = notify + + def build(self, data: dict): + return self.notify.build_notification(data) + + # Stop: 依頼したエージェントが動いている間は通知しない + def test_stop_without_background_tasks_notifies(self): + self.assertIsNotNone(self.build({"hook_event_name": "Stop"})) + + def test_stop_with_empty_background_tasks_notifies(self): + self.assertIsNotNone(self.build({"hook_event_name": "Stop", "background_tasks": []})) + + def test_stop_with_running_subagent_is_silent(self): + self.assertIsNone(self.build({"hook_event_name": "Stop", "background_tasks": [_subagent_task()]})) + + def test_stop_with_running_workflow_is_silent(self): + task = {"id": "w1", "type": "workflow", "status": "running", "description": "...", "name": "wf"} + self.assertIsNone(self.build({"hook_event_name": "Stop", "background_tasks": [task]})) + + def test_stop_with_shell_task_still_notifies(self): + """開発サーバー等の常駐 shell / monitor で通知が止まらないこと.""" + for kind in ("shell", "monitor"): + with self.subTest(kind=kind): + task = {"id": "s1", "type": kind, "status": "running", "description": "..."} + self.assertIsNotNone(self.build({"hook_event_name": "Stop", "background_tasks": [task]})) + + def test_stop_with_mixed_tasks_is_silent(self): + tasks = [{"id": "s1", "type": "shell", "status": "running", "description": "..."}, _subagent_task()] + self.assertIsNone(self.build({"hook_event_name": "Stop", "background_tasks": tasks})) + + def test_stop_with_malformed_background_tasks_notifies(self): + """想定外の形(古いバージョン・壊れた入力)でも通知を落とさない.""" + for tasks in ("not-a-list", {"type": "subagent"}, [None, "x", 1], [{}]): + with self.subTest(tasks=tasks): + self.assertIsNotNone(self.build({"hook_event_name": "Stop", "background_tasks": tasks})) + + # 待ち状態の通知はエージェント名を添える + def test_permission_prompt_from_subagent_names_agent(self): + data = {"hook_event_name": "Notification", "notification_type": "permission_prompt", + "agent_id": "a1", "agent_type": "coder"} + self.assertIn("coder", self.build(data)["message"]) + + def test_permission_prompt_from_main_has_no_agent_label(self): + data = {"hook_event_name": "Notification", "notification_type": "permission_prompt"} + self.assertNotIn("エージェント", self.build(data)["message"]) + + def test_question_from_subagent_names_agent(self): + data = {"hook_event_name": "PreToolUse", "tool_name": "AskUserQuestion", "agent_type": "tester"} + self.assertIn("tester", self.build(data)["message"]) + + def test_agent_name_is_trimmed_and_capped(self): + data = {"hook_event_name": "Notification", "notification_type": "permission_prompt", + "agent_type": " a" + chr(10) + "b " + "x" * 200} + message = self.build(data)["message"] + self.assertNotIn(chr(10), message) + self.assertIn("a b", message) + self.assertLess(len(message), 80) + + def test_blank_agent_type_is_ignored(self): + for value in ("", " ", None, 123): + with self.subTest(value=value): + data = {"hook_event_name": "Notification", "notification_type": "idle_prompt", "agent_type": value} + self.assertNotIn("エージェント", self.build(data)["message"]) + + def test_unknown_event_is_silent(self): + self.assertIsNone(self.build({"hook_event_name": "SubagentStop"})) + self.assertIsNone(self.build({"hook_event_name": "PreToolUse", "tool_name": "Bash"})) + + +# --------------------------------------------------------------------------- +# Python 3.7 互換(静的チェック) +# --------------------------------------------------------------------------- +PY39_ONLY_ATTRS = {"removesuffix", "removeprefix", "is_relative_to", "with_stem", "readlink", "randbytes", "lcm", "cache"} +PY38_PLUS_MODULES = {"zoneinfo", "graphlib", "tomllib", "importlib.metadata"} +GENERIC_BUILTINS = {"list", "dict", "tuple", "set", "frozenset", "type"} + + +class TestPython37Compat(unittest.TestCase): + def check_file(self, path: str) -> None: + with open(path, encoding="utf-8") as f: + source = f.read() + # 3.8+ 専用構文(walrus・位置専用引数等)を検出する + tree = ast.parse(source, filename=path, feature_version=(3, 7)) + + # from __future__ import annotations(docstring の直後) + body = tree.body + idx = 1 if body and isinstance(body[0], ast.Expr) and isinstance(getattr(body[0], "value", None), ast.Constant) else 0 + first = body[idx] + self.assertIsInstance(first, ast.ImportFrom, "先頭に from __future__ import annotations が必要") + self.assertEqual(first.module, "__future__") + self.assertIn("annotations", [a.name for a in first.names]) + + # アノテーション以外(実行時に評価される箇所)での list[...] / X | None を検出 + annotation_nodes = set() + for node in ast.walk(tree): + anns = [] + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + anns.append(node.returns) + a = node.args + for arg in a.args + a.kwonlyargs + getattr(a, "posonlyargs", []) + [a.vararg, a.kwarg]: + if arg is not None: + anns.append(arg.annotation) + elif isinstance(node, ast.AnnAssign): + anns.append(node.annotation) + for ann in anns: + if ann is not None: + annotation_nodes.update(id(n) for n in ast.walk(ann)) + + problems = [] + for node in ast.walk(tree): + if isinstance(node, ast.Attribute) and node.attr in PY39_ONLY_ATTRS: + problems.append(f"{node.lineno}: .{node.attr}") + if isinstance(node, (ast.Import, ast.ImportFrom)): + names = [node.module] if isinstance(node, ast.ImportFrom) else [a.name for a in node.names] + for name in names: + if name in PY38_PLUS_MODULES: + problems.append(f"{node.lineno}: import {name}") + if id(node) in annotation_nodes: + continue + if isinstance(node, ast.Subscript) and isinstance(node.value, ast.Name) and node.value.id in GENERIC_BUILTINS: + problems.append(f"{node.lineno}: 実行時の {node.value.id}[...]") + if isinstance(node, ast.BinOp) and isinstance(node.op, ast.BitOr): + if any(isinstance(s, ast.Constant) and s.value is None for s in (node.left, node.right)): + problems.append(f"{node.lineno}: 実行時の X | None") + if hasattr(ast, "Match") and isinstance(node, ast.Match): + problems.append(f"{node.lineno}: match 文") + self.assertEqual(problems, [], f"{os.path.basename(path)} に 3.7 非互換の記述") + + def test_notify_py37_compatible(self): + self.check_file(NOTIFY) + + def test_restrict_repo_access_py37_compatible(self): + self.check_file(RESTRICT) + + +# --------------------------------------------------------------------------- +# check_sync.sh: python3 決め打ちではなくラッパー経由 +# --------------------------------------------------------------------------- +# check_sync.sh は team 層のファイルで,solo モードのプロジェクトには配置されない. +# 一方このテスト自体は共通層として全プロジェクトに同期されるため,存在ガードで skip する. +@unittest.skipUnless(os.path.exists(CHECK_SYNC), "check_sync.sh は team 層のため solo には無い") +class TestCheckSync(TempDirMixin, unittest.TestCase): + def test_no_hardcoded_python3(self): + with open(CHECK_SYNC, encoding="utf-8") as f: + lines = [l for l in f.read().splitlines() if not l.lstrip().startswith("#")] + code = "\n".join(lines) + self.assertIsNone(re.search(r"(^|[\s;|&(])(python3?|py)\s", code), "python コマンドの直接呼び出しが残っている") + self.assertIn("run_python.sh", code) + + def test_emits_json_with_dummy_python3(self): + """ダミー python3 しか無い環境(Windows 相当)でも JSON を出力する.""" + # origin が存在しないパスなので fetch が失敗し emit が呼ばれる + repo = os.path.join(self.work, "repo") + os.makedirs(repo) + subprocess.run(["git", "init", "-q", repo], check=True, capture_output=True) + subprocess.run( + ["git", "-C", repo, "remote", "add", "origin", os.path.join(self.work, "no-such-remote")], + check=True, + capture_output=True, + ) + self.fake.dummy("python3") + self.fake.real("python") + env = make_env([self.fake.dir] + bash_bin_dirs(), drop=("PYTHONIOENCODING",)) + result = subprocess.run( + [BASH, CHECK_SYNC], + capture_output=True, + env=env, + cwd=repo, + timeout=TIMEOUT, + ) + self.assertEqual(result.returncode, 0, result.stderr) + out = json.loads(result.stdout.decode("utf-8")) + self.assertIn("[sync-check]", out["systemMessage"]) + self.assertEqual(out["hookSpecificOutput"]["hookEventName"], "SessionStart") + self.assertEqual(out["hookSpecificOutput"]["additionalContext"], out["systemMessage"]) + + def test_python_missing_does_not_block_session(self): + repo = os.path.join(self.work, "repo") + os.makedirs(repo) + subprocess.run(["git", "init", "-q", repo], check=True, capture_output=True) + subprocess.run( + ["git", "-C", repo, "remote", "add", "origin", os.path.join(self.work, "no-such-remote")], + check=True, + capture_output=True, + ) + self.fake.dummy("python3") + env = make_env([self.fake.dir] + bash_bin_dirs()) + # bash_bin_dirs に python が無い前提(あればこのテストは意味をなさないのでスキップ) + for d in bash_bin_dirs(): + for n in ("python3", "python", "py"): + if os.path.exists(os.path.join(d, n)) or os.path.exists(os.path.join(d, n + ".exe")): + self.skipTest(f"{d} に {n} がある") + result = subprocess.run([BASH, CHECK_SYNC], capture_output=True, env=env, cwd=repo, timeout=TIMEOUT) + self.assertEqual(result.returncode, 0, result.stderr) + + +# --------------------------------------------------------------------------- +# restrict_repo_access.py: 判定ロジック(Issue #49 の仕様) +# --------------------------------------------------------------------------- +# 「リポジトリ外かつ一時ディレクトリ外の既存ファイル」が必要なため,このテストのディレクトリ配下に +# 使い捨てのサンドボックスを作り,その中に「仮のリポジトリ」「リポジトリ外」「仮のホーム」を +# 置いて判定関数に repo_root を直接渡す(ユーザーの実ファイルは作成・変更・削除しない). +# テストは判定関数を呼ぶだけで,実際の削除・上書きは行わない. +TESTS_DIR = os.path.dirname(os.path.abspath(__file__)) + + +def _load_restrict(): + sys.path.insert(0, HOOKS_DIR) + try: + import restrict_repo_access # noqa: WPS433 + finally: + sys.path.pop(0) + return restrict_repo_access + + +def _fwd(path: str) -> str: + """Bash のコマンド文字列に埋め込む形(区切りを / に).""" + return path.replace("\\", "/") + + +def _gitbash(path: str) -> str: + """Windows の C:\\x を Git Bash 形式 /c/x にする.""" + p = _fwd(path) + if re.match(r"^[A-Za-z]:/", p): + return "/" + p[0].lower() + p[2:] + return p + + +class RestrictSandboxMixin: + """sandbox/ + repo/ 仮のリポジトリ(repo_root).sub/・file.txt・.claude/ + outside/ リポジトリ外(一時ディレクトリ外) + existing.txt 既存ファイル + dir/file.txt 既存ディレクトリ内の既存ファイル + other/data/ 削除対象のディレクトリ + archive_dest/ 既存の展開先ディレクトリ + home/ 仮のホーム(~・$HOME)..bashrc・.ssh/id_rsa・source/other/ + """ + + @classmethod + def setUpClass(cls) -> None: + cls.rra = _load_restrict() + + def setUp(self) -> None: + self.base = tempfile.mkdtemp(prefix="_restrict_sandbox_", dir=TESTS_DIR) + if self.rra.is_temp_path(self.base): + shutil.rmtree(self.base, ignore_errors=True) + self.skipTest("リポジトリが一時ディレクトリ配下にあり,一時ディレクトリ外のリポジトリ外パスを用意できない") + self.repo = os.path.join(self.base, "repo") + self.sub = os.path.join(self.repo, "sub") + self.out = os.path.join(self.base, "outside") + self.home = os.path.join(self.base, "home") + for d in (self.sub, os.path.join(self.repo, ".claude"), os.path.join(self.out, "dir"), + os.path.join(self.out, "other", "data"), os.path.join(self.out, "archive_dest"), + os.path.join(self.home, ".ssh"), os.path.join(self.home, "source", "other")): + os.makedirs(d) + for f in (os.path.join(self.repo, "file.txt"), os.path.join(self.out, "existing.txt"), + os.path.join(self.out, "dir", "file.txt"), os.path.join(self.home, ".bashrc"), + os.path.join(self.home, ".ssh", "id_rsa"), os.path.join(self.out, "other", "data", "a.txt")): + with open(f, "w", encoding="utf-8") as fh: + fh.write("x") + self.existing = os.path.join(self.out, "existing.txt") + self.missing = os.path.join(self.out, "new_file.txt") + self.deny = [] + + def tearDown(self) -> None: + shutil.rmtree(self.base, ignore_errors=True) + + # --- 判定ヘルパー --- + def bash(self, command: str, cwd: str | None = None): + return self.rra.check_bash_command(command, cwd or self.repo, repo_root=self.repo, deny_read=self.deny) + + def ps(self, command: str, cwd: str | None = None): + return self.rra.check_powershell_command(command, cwd or self.repo, repo_root=self.repo, deny_read=self.deny) + + def tool(self, name: str, tool_input: dict, cwd: str | None = None): + policy = self.rra.Policy(self.repo, self.deny) + return self.rra.check_file_tool(name, tool_input, cwd or self.repo, policy) + + def fake_home(self): + return mock.patch.dict(os.environ, {"HOME": self.home, "USERPROFILE": self.home}) + + def run_main(self, data: dict, env: dict) -> subprocess.CompletedProcess: + return subprocess.run([sys.executable, "-B", RESTRICT], input=json.dumps(data).encode("utf-8"), + capture_output=True, env=env, cwd=self.repo, timeout=TIMEOUT) + + @staticmethod + def decision(result): + out = result.stdout.decode("utf-8").strip() + if not out: + return None + return json.loads(out)["hookSpecificOutput"]["permissionDecision"] + + def assertDenied(self, reason, label=""): + self.assertIsNotNone(reason, f"拒否されるべき: {label}") + + def assertAllowed(self, reason, label=""): + self.assertIsNone(reason, f"許可されるべき: {label} / 理由: {reason}") + + +class TestRestrictIssueExamples(RestrictSandboxMixin, unittest.TestCase): + """Issue #49 の取りこぼし例がすべて拒否されること.""" + + def test_powershell_remove_item_recurse(self): + target = os.path.join(self.out, "other") + self.assertDenied(self.ps(f"Remove-Item {target} -Recurse")) + self.assertDenied(self.ps(f"Remove-Item {target} -Recurse -Force")) + + def test_cd_outside_then_rm_relative(self): + self.assertDenied(self.bash(f'cd "{_fwd(os.path.join(self.out, "other"))}" && rm -rf data')) + + def test_rm_rf_home_subdir(self): + with self.fake_home(): + self.assertDenied(self.bash("rm -rf ~/source/other")) + # 実際のホームでも(削除は存在に関係なく拒否) + self.assertDenied(self.bash("rm -rf ~/source/__no_such_dir_for_test__")) + + def test_overwrite_existing_file_variants(self): + ex = _fwd(self.existing) + cases = { + "sed -i": f"sed -i 's/a/b/' \"{ex}\"", + ">": f"echo x > \"{ex}\"", + "tee": f"echo x | tee \"{ex}\"", + "truncate": f"truncate -s 0 \"{ex}\"", + } + for label, cmd in cases.items(): + with self.subTest(label): + self.assertDenied(self.bash(cmd), label) + + def test_python_inline_rmtree(self): + target = _fwd(os.path.join(self.out, "other")) + self.assertDenied(self.bash(f"python -c \"import shutil; shutil.rmtree('{target}')\"")) + + +class TestRestrictBaseAndZones(RestrictSandboxMixin, unittest.TestCase): + """基準(リポジトリのルート・cwd 起点の相対パス)と許可ゾーン.""" + + def test_relative_from_subfolder_inside_repo_is_allowed(self): + self.assertAllowed(self.bash("rm ../file.txt", cwd=self.sub)) + self.assertAllowed(self.ps("Remove-Item ..\\file.txt", cwd=self.sub)) + + def test_relative_from_subfolder_escaping_repo_is_denied(self): + self.assertDenied(self.bash("rm ../../outside/existing.txt", cwd=self.sub)) + self.assertDenied(self.ps("Remove-Item ..\\..\\outside\\existing.txt", cwd=self.sub)) + + def test_inside_repo_delete_and_overwrite_allowed(self): + f = _fwd(os.path.join(self.repo, "file.txt")) + for cmd in (f'rm -rf "{f}"', f'echo x > "{f}"', f'sed -i s/a/b/ "{f}"', "rm -rf build"): + with self.subTest(cmd): + self.assertAllowed(self.bash(cmd)) + self.assertAllowed(self.tool("Edit", {"file_path": os.path.join(self.repo, "file.txt")})) + self.assertAllowed(self.tool("Write", {"file_path": os.path.join(self.repo, "file.txt")})) + + def test_temp_dir_allowed(self): + tmp = tempfile.mkdtemp(prefix="hooktest_zone_") + try: + existing = os.path.join(tmp, "e.txt") + with open(existing, "w") as fh: + fh.write("x") + for cmd in (f'rm -rf "{_fwd(tmp)}"', f'echo x > "{_fwd(existing)}"', "rm -rf /tmp/some-dir"): + with self.subTest(cmd): + self.assertAllowed(self.bash(cmd)) + self.assertAllowed(self.ps(f"Remove-Item {tmp} -Recurse -Force")) + self.assertAllowed(self.tool("Edit", {"file_path": existing})) + self.assertAllowed(self.tool("Write", {"file_path": existing})) + finally: + shutil.rmtree(tmp, ignore_errors=True) + + def test_special_paths_allowed(self): + for cmd in ("echo x > /dev/null", "ls 2> /dev/null", "cp file.txt /dev/null", "echo x &> /dev/null"): + with self.subTest(cmd): + self.assertAllowed(self.bash(cmd)) + for cmd in ("Get-Item x > $null", "echo x | Out-File NUL", "echo x | Out-File $null", "echo x > NUL"): + with self.subTest(cmd): + self.assertAllowed(self.ps(cmd)) + + def test_main_uses_claude_project_dir_and_cwd(self): + """CLAUDE_PROJECT_DIR を基準に,相対パスは入力の cwd 起点で解決する(main 経由).""" + env = dict(os.environ, CLAUDE_PROJECT_DIR=self.repo) + allow = self.run_main({"tool_name": "Bash", "tool_input": {"command": "rm ../file.txt"}, "cwd": self.sub}, env) + self.assertEqual(allow.returncode, 0, allow.stderr) + self.assertIsNone(self.decision(allow)) + deny = self.run_main({"tool_name": "Bash", "tool_input": {"command": "rm ../../outside/existing.txt"}, "cwd": self.sub}, env) + self.assertEqual(self.decision(deny), "deny") + + def test_main_falls_back_to_cwd_without_project_dir(self): + env = dict(os.environ) + env.pop("CLAUDE_PROJECT_DIR", None) + allow = self.run_main({"tool_name": "Bash", "tool_input": {"command": "rm -rf build"}, "cwd": self.repo}, env) + self.assertIsNone(self.decision(allow)) + deny = self.run_main({"tool_name": "Bash", "tool_input": {"command": "rm -rf ../outside/other"}, "cwd": self.repo}, env) + self.assertEqual(self.decision(deny), "deny") + + def test_main_powershell_and_notebook_tools(self): + env = dict(os.environ, CLAUDE_PROJECT_DIR=self.repo) + cases = [ + {"tool_name": "PowerShell", "tool_input": {"command": f"Remove-Item {self.out} -Recurse"}}, + {"tool_name": "NotebookEdit", "tool_input": {"notebook_path": os.path.join(self.out, "n.ipynb"), "new_source": "x"}}, + ] + for data in cases: + with self.subTest(data["tool_name"]): + data["cwd"] = self.repo + self.assertEqual(self.decision(self.run_main(data, env)), "deny") + + +class TestRestrictFileTools(RestrictSandboxMixin, unittest.TestCase): + def test_read_glob_grep_outside_allowed(self): + self.assertAllowed(self.tool("Read", {"file_path": self.existing})) + self.assertAllowed(self.tool("Glob", {"pattern": "**/*", "path": self.out})) + self.assertAllowed(self.tool("Glob", {"pattern": _fwd(self.out) + "/**/*.txt"})) + self.assertAllowed(self.tool("Grep", {"pattern": "x", "path": self.out})) + + def test_write_outside_new_allowed_existing_denied(self): + self.assertAllowed(self.tool("Write", {"file_path": self.missing, "content": "x"})) + self.assertDenied(self.tool("Write", {"file_path": self.existing, "content": "x"})) + + def test_edit_and_notebook_edit_outside_always_denied(self): + for path in (self.existing, self.missing): + with self.subTest(path=path): + self.assertDenied(self.tool("Edit", {"file_path": path, "old_string": "a", "new_string": "b"})) + self.assertDenied(self.tool("NotebookEdit", {"notebook_path": path + ".ipynb", "new_source": "x"})) + + def test_relative_tool_path_resolved_from_cwd(self): + self.assertDenied(self.tool("Write", {"file_path": "../../outside/existing.txt"}, cwd=self.sub)) + self.assertAllowed(self.tool("Edit", {"file_path": "../file.txt"}, cwd=self.sub)) + + def test_settings_matcher_covers_all_target_tools(self): + with open(SETTINGS, encoding="utf-8") as f: + settings = json.load(f) + matchers = [g.get("matcher", "") for g in settings["hooks"]["PreToolUse"] + if any("restrict_repo_access.py" in h.get("command", "") for h in g.get("hooks", []))] + self.assertEqual(len(matchers), 1) + for tool in ("Read", "Write", "Edit", "NotebookEdit", "Glob", "Grep", "Bash", "PowerShell"): + self.assertIn(tool, matchers[0].split("|")) + + +class TestRestrictDenyRead(RestrictSandboxMixin, unittest.TestCase): + def write_config(self, text: str) -> None: + with open(os.path.join(self.repo, ".claude", "repo-access.json"), "w", encoding="utf-8") as fh: + fh.write(text) + + def test_load_absolute_home_and_relative_entries(self): + abs_entry = _fwd(os.path.join(self.out, "dir")) + self.write_config(json.dumps({"deny_read": [abs_entry, "~/.ssh", "secrets"]})) + with self.fake_home(): + entries = self.rra.load_deny_read(self.repo) + normed = [os.path.normcase(os.path.normpath(e)) for e in entries] + for expected in (os.path.join(self.out, "dir"), os.path.join(self.home, ".ssh"), os.path.join(self.repo, "secrets")): + self.assertIn(os.path.normcase(os.path.normpath(expected)), normed) + + @unittest.skipUnless(os.name == "nt", "Git Bash 形式のパスは Windows のみ") + def test_load_gitbash_form_entry(self): + self.write_config(json.dumps({"deny_read": [_gitbash(os.path.join(self.out, "dir"))]})) + entries = self.rra.load_deny_read(self.repo) + self.assertEqual([os.path.normcase(e) for e in entries], [os.path.normcase(os.path.join(self.out, "dir"))]) + + def test_missing_config_is_empty(self): + self.assertEqual(self.rra.load_deny_read(self.repo), []) + + def test_broken_config_is_empty_with_warning(self): + for text in ("{not json", json.dumps({"deny_read": "~/.ssh"}), json.dumps({"deny_read": [1, 2]}), json.dumps([1])): + with self.subTest(text=text): + self.write_config(text) + err = io.StringIO() + with contextlib.redirect_stderr(err): + self.assertEqual(self.rra.load_deny_read(self.repo), []) + self.assertTrue(err.getvalue().strip(), "stderr に警告が出ること") + + def test_broken_config_via_main_does_not_block(self): + self.write_config("{broken") + env = dict(os.environ, CLAUDE_PROJECT_DIR=self.repo) + result = self.run_main({"tool_name": "Read", "tool_input": {"file_path": self.existing}, "cwd": self.repo}, env) + self.assertEqual(result.returncode, 0) + self.assertIsNone(self.decision(result)) + self.assertTrue(result.stderr.strip()) + + def test_config_via_main_denies_read(self): + self.write_config(json.dumps({"deny_read": [_fwd(os.path.join(self.out, "dir"))]})) + env = dict(os.environ, CLAUDE_PROJECT_DIR=self.repo) + data = {"tool_name": "Read", "tool_input": {"file_path": os.path.join(self.out, "dir", "file.txt")}, "cwd": self.repo} + self.assertEqual(self.decision(self.run_main(data, env)), "deny") + + def test_denied_path_read_and_write_rejected(self): + denied_dir = os.path.join(self.out, "dir") + self.deny = [denied_dir] + f = os.path.join(denied_dir, "file.txt") + self.assertDenied(self.tool("Read", {"file_path": f})) + self.assertDenied(self.tool("Write", {"file_path": os.path.join(denied_dir, "new.txt")}), "新規作成でも拒否") + self.assertDenied(self.bash(f'cat "{_fwd(f)}"')) + self.assertDenied(self.bash(f'echo x >> "{_fwd(os.path.join(denied_dir, "new.txt"))}"')) + self.assertDenied(self.ps(f"Get-Content {f}")) + # 禁止されていない兄弟は読める + self.assertAllowed(self.tool("Read", {"file_path": self.existing})) + self.assertAllowed(self.bash(f'cat "{_fwd(self.existing)}"')) + + def test_denied_path_inside_repo(self): + self.deny = [os.path.join(self.repo, "secrets")] + self.assertDenied(self.tool("Read", {"file_path": os.path.join(self.repo, "secrets", "k.txt")})) + self.assertDenied(self.bash("cat secrets/k.txt")) + + def test_search_root_containing_denied_path_rejected(self): + self.deny = [os.path.join(self.out, "dir")] + self.assertDenied(self.tool("Grep", {"pattern": "x", "path": self.out})) + self.assertDenied(self.tool("Glob", {"pattern": "**/*", "path": self.out})) + self.assertDenied(self.tool("Glob", {"pattern": _fwd(self.out) + "/**/*.txt"})) + self.assertDenied(self.bash(f'grep -r x "{_fwd(self.out)}"')) + self.assertDenied(self.bash(f'find "{_fwd(self.out)}" -name "*.txt"')) + self.assertDenied(self.ps(f"Get-ChildItem {self.out} -Recurse")) + # 起点が禁止パスを含まない場所なら許可 + self.assertAllowed(self.tool("Grep", {"pattern": "x", "path": os.path.join(self.out, "other")})) + + def test_home_entry_denies_tilde_access(self): + with self.fake_home(): + self.deny = [os.path.join(self.home, ".ssh")] + self.assertDenied(self.bash("cat ~/.ssh/id_rsa")) + self.assertDenied(self.bash("cat $HOME/.ssh/id_rsa")) + self.assertDenied(self.tool("Read", {"file_path": "~/.ssh/id_rsa"})) + + +class TestRestrictCreateAllowed(RestrictSandboxMixin, unittest.TestCase): + """新規作成・ダウンロード・追記・権限変更はリポジトリ外でも許可.""" + + def test_bash_create_append_chmod(self): + ex, new = _fwd(self.existing), _fwd(self.missing) + newdir = _fwd(os.path.join(self.out, "newdir", "a")) + cases = [ + f'mkdir -p "{newdir}"', + f'touch "{new}"', + f'touch "{ex}"', + f'echo x >> "{ex}"', + f'echo x | tee -a "{ex}"', + f'curl -o "{new}" https://example.com/x', + f'curl -fsSLo "{new}" https://example.com/x', + f'wget -O "{new}" https://example.com/x', + f'chmod 600 "{ex}"', + f'echo x > "{new}"', + f'cp file.txt "{new}"', + f'cp -n file.txt "{ex}"', + f'cat "{ex}"', + ] + for cmd in cases: + with self.subTest(cmd): + self.assertAllowed(self.bash(cmd), cmd) + + def test_powershell_create_append_acl(self): + ex, new = self.existing, self.missing + cases = [ + f"New-Item -ItemType Directory {os.path.join(self.out, 'newdir')}", + f"New-Item -ItemType Directory -Force {os.path.join(self.out, 'dir')}", + f"New-Item {new}", + f"Add-Content {ex} 'x'", + f"Add-Content -Path {ex} -Value 'x'", + f"'x' | Out-File {ex} -Append", + f"'x' | Out-File -FilePath {ex} -Append", + f"Invoke-WebRequest https://example.com/x -OutFile {new}", + f"Set-Acl {ex} $acl", + f"Get-Content {ex}", + f"'x' > {new}", + f"'x' >> {ex}", + f"Set-Content {new} 'x'", + f"Copy-Item file.txt {new}", + ] + for cmd in cases: + with self.subTest(cmd): + self.assertAllowed(self.ps(cmd), cmd) + + +class TestRestrictDelete(RestrictSandboxMixin, unittest.TestCase): + """削除は(存在に関係なく)拒否.""" + + def test_bash_delete_commands(self): + ex = _fwd(self.existing) + d = _fwd(os.path.join(self.out, "other")) + cases = [ + f'rm "{ex}"', f'rm -f "{_fwd(self.missing)}"', f'rmdir "{d}"', f'unlink "{ex}"', + f'truncate -s 0 "{ex}"', f'find "{d}" -delete', f'find "{d}" -name "*.txt" -exec rm {{}} \\;', + f'mv "{ex}" ./moved.txt', f'sudo rm -rf "{d}"', + ] + for cmd in cases: + with self.subTest(cmd): + self.assertDenied(self.bash(cmd), cmd) + + def test_powershell_delete_commands(self): + ex = self.existing + d = os.path.join(self.out, "other") + cases = [ + f"Remove-Item {ex}", f"Remove-Item -Path {d} -Recurse -Force", f"Remove-Item -LiteralPath {ex}", + f"del {ex}", f"rd {d} -Recurse", f"ri {ex}", f"rm {ex}", f"erase {ex}", f"rmdir {d}", + f"Clear-Content {ex}", f"Move-Item {ex} .\\moved.txt", f"Move-Item -Path {ex} -Destination .\\m.txt", + f"Rename-Item {ex} renamed.txt", f"[IO.File]::Delete('{ex}')", f"[System.IO.File]::Delete(\"{ex}\")", + f"cmd /c del {ex}", f"cmd /c rd /s /q {d}", + ] + for cmd in cases: + with self.subTest(cmd): + self.assertDenied(self.ps(cmd), cmd) + + def test_inline_delete_calls(self): + d = _fwd(os.path.join(self.out, "other")) + ex = _fwd(self.existing) + cases = [ + f"python -c \"import shutil; shutil.rmtree('{d}')\"", + f"python3 -c \"import os; os.remove('{ex}')\"", + f"py -c \"import os; os.unlink('{ex}')\"", + f"node -e \"require('fs').rmSync('{d}', {{recursive: true}})\"", + ] + for cmd in cases: + with self.subTest(cmd): + self.assertDenied(self.bash(cmd), cmd) + self.assertDenied(self.ps(f"python -c \"import shutil; shutil.rmtree('{d}')\"")) + + def test_inline_without_delete_or_inside_repo_allowed(self): + ex = _fwd(self.existing) + self.assertAllowed(self.bash(f"python -c \"print(open('{ex}').read())\"")) + self.assertAllowed(self.bash("python -c \"import shutil; shutil.rmtree('build')\"")) + + +class TestRestrictOverwrite(RestrictSandboxMixin, unittest.TestCase): + """既存なら拒否,存在しなければ許可.""" + + BASH_TEMPLATES = [ + "echo x > \"{t}\"", + "echo x >| \"{t}\"", + "cp file.txt \"{t}\"", + "mv file.txt \"{t}\"", + "sed -i 's/a/b/' \"{t}\"", + "sed -i.bak -e 's/a/b/' \"{t}\"", + "echo x | tee \"{t}\"", + "dd if=file.txt of=\"{t}\"", + "curl -o \"{t}\" https://example.com/x", + "curl --output \"{t}\" https://example.com/x", + "wget -O \"{t}\" https://example.com/x", + "python -c \"open('{t}', 'w').write('x')\"", + ] + PS_TEMPLATES = [ + "'x' > {t}", + "Copy-Item file.txt {t}", + "Copy-Item -Path file.txt -Destination {t}", + "Move-Item file.txt {t}", + "Set-Content {t} 'x'", + "Set-Content -Path {t} -Value 'x'", + "'x' | Out-File {t}", + "'x' | Out-File -FilePath {t}", + "New-Item {t} -Force", + "Invoke-WebRequest https://example.com/x -OutFile {t}", + "iwr https://example.com/x -OutFile {t}", + ] + + def test_bash_overwrite(self): + for tpl in self.BASH_TEMPLATES: + with self.subTest(tpl, state="existing"): + self.assertDenied(self.bash(tpl.format(t=_fwd(self.existing))), tpl) + with self.subTest(tpl, state="missing"): + self.assertAllowed(self.bash(tpl.format(t=_fwd(self.missing))), tpl) + + def test_powershell_overwrite(self): + for tpl in self.PS_TEMPLATES: + with self.subTest(tpl, state="existing"): + self.assertDenied(self.ps(tpl.format(t=self.existing)), tpl) + with self.subTest(tpl, state="missing"): + self.assertAllowed(self.ps(tpl.format(t=self.missing)), tpl) + + def test_copy_into_existing_directory_uses_basename(self): + d = os.path.join(self.out, "dir") # dir/file.txt が既存 + with open(os.path.join(self.repo, "fresh.txt"), "w") as fh: + fh.write("x") + self.assertDenied(self.bash(f'cp file.txt "{_fwd(d)}"')) + self.assertDenied(self.bash(f'cp file.txt "{_fwd(d)}/"')) + self.assertAllowed(self.bash(f'cp fresh.txt "{_fwd(d)}"')) + self.assertDenied(self.ps(f"Copy-Item file.txt {d}")) + self.assertDenied(self.ps(f"Copy-Item -Path file.txt -Destination {d}")) + self.assertAllowed(self.ps(f"Copy-Item fresh.txt {d}")) + + def test_cp_no_clobber_allowed(self): + for opt in ("-n", "--no-clobber"): + with self.subTest(opt): + self.assertAllowed(self.bash(f'cp {opt} file.txt "{_fwd(self.existing)}"')) + + def test_tee_append_and_out_file_append_allowed(self): + self.assertAllowed(self.bash(f'echo x | tee -a "{_fwd(self.existing)}"')) + self.assertAllowed(self.ps(f"'x' | Out-File {self.existing} -Append")) + + def test_expand_archive_force(self): + dest = os.path.join(self.out, "archive_dest") + self.assertDenied(self.ps(f"Expand-Archive a.zip -DestinationPath {dest} -Force")) + self.assertDenied(self.ps(f"Expand-Archive -Path a.zip -DestinationPath {dest} -Force")) + self.assertAllowed(self.ps(f"Expand-Archive a.zip -DestinationPath {dest}")) + self.assertAllowed(self.ps(f"Expand-Archive a.zip -DestinationPath {os.path.join(self.out, 'new_dest')} -Force")) + + def test_new_item_without_force_allowed(self): + self.assertAllowed(self.ps(f"New-Item {self.existing}")) + + +class TestRestrictLocationTracking(RestrictSandboxMixin, unittest.TestCase): + def test_bash_cd_variants(self): + out = _fwd(self.out) + repo = _fwd(self.repo) + self.assertDenied(self.bash(f'cd "{out}"; echo x > existing.txt')) + self.assertDenied(self.bash(f'cd "{out}" && rm -rf other')) + self.assertAllowed(self.bash(f'cd "{out}" && cat existing.txt')) + self.assertAllowed(self.bash(f'cd "{out}" && echo x > brand_new.txt')) + self.assertAllowed(self.bash(f'cd "{out}" && cd "{repo}" && rm -rf build')) + self.assertDenied(self.bash(f'pushd "{out}" && rm existing.txt')) + self.assertAllowed(self.bash(f'pushd "{out}" && popd && rm -rf build')) + self.assertDenied(self.bash("cd .. && rm -rf outside", cwd=self.repo)) + + def test_command_substitution_cd_does_not_leak(self): + out = _fwd(self.out) + self.assertAllowed(self.bash(f'x=$(cd "{out}" && pwd); rm -rf build')) + self.assertAllowed(self.bash(f'echo "$(cd "{out}" && pwd)" && rm -rf build')) + self.assertAllowed(self.bash(f'(cd "{out}" && ls); rm -rf build')) + # 内側の削除は検出する + self.assertDenied(self.bash(f'x=$(cd "{out}" && rm -rf other)')) + + def test_powershell_location_variants(self): + out = self.out + for verb in ("Set-Location", "cd", "sl", "chdir"): + with self.subTest(verb): + self.assertDenied(self.ps(f"{verb} {out}; Remove-Item other -Recurse")) + self.assertDenied(self.ps(f"Set-Location -Path {out}; Remove-Item existing.txt")) + self.assertDenied(self.ps(f"Push-Location {out}; Remove-Item existing.txt")) + self.assertAllowed(self.ps(f"Push-Location {out}; Pop-Location; Remove-Item build -Recurse")) + self.assertAllowed(self.ps(f"Set-Location {out}; Get-Content existing.txt")) + self.assertDenied(self.ps(f"Set-Location {out}; Set-Content existing.txt 'x'")) + + def test_home_expansion(self): + with self.fake_home(): + for cmd in ("rm -rf ~/source/other", "rm -rf $HOME/source/other", "rm -rf ${HOME}/source/other", + "echo x > ~/.bashrc", "cd ~ && rm -rf source", "cd && rm .bashrc", "cd $HOME/source && rm -rf other"): + with self.subTest(cmd): + self.assertDenied(self.bash(cmd), cmd) + for cmd in ("echo x >> ~/.bashrc", "mkdir -p ~/newdir", "echo x > ~/new_file.txt", "cat ~/.bashrc"): + with self.subTest(cmd): + self.assertAllowed(self.bash(cmd), cmd) + for cmd in ("Remove-Item ~/source/other -Recurse", "Remove-Item $HOME\\source\\other -Recurse", + "Remove-Item $env:USERPROFILE\\source\\other -Recurse", "Set-Content ~/.bashrc 'x'"): + with self.subTest(cmd): + self.assertDenied(self.ps(cmd), cmd) + self.assertAllowed(self.ps("Add-Content ~/.bashrc 'x'")) + + def test_nested_shells(self): + out = _fwd(self.out) + self.assertDenied(self.bash(f"bash -c 'rm -rf \"{out}/other\"'")) + self.assertDenied(self.bash(f"sh -c 'cd \"{out}\" && rm -rf other'")) + self.assertDenied(self.bash(f"pwsh -Command \"Remove-Item '{self.out}' -Recurse\"")) + self.assertDenied(self.bash(f"powershell -Command \"Remove-Item '{self.out}' -Recurse\"")) + self.assertDenied(self.ps(f"bash -c 'rm -rf \"{out}/other\"'")) + self.assertDenied(self.ps(f"pwsh -Command \"Remove-Item {self.out} -Recurse\"")) + self.assertAllowed(self.bash("bash -c 'rm -rf build'")) + + @unittest.skipUnless(os.name == "nt", "Git Bash 形式(/c/...)は Windows のみ") + def test_gitbash_drive_form(self): + out = _gitbash(self.out) + self.assertDenied(self.bash(f'rm -rf "{out}/other"')) + self.assertDenied(self.bash(f'cd "{out}" && rm -rf other')) + self.assertDenied(self.bash(f'echo x > "{out}/existing.txt"')) + self.assertAllowed(self.bash(f'echo x > "{out}/brand_new.txt"')) + self.assertAllowed(self.bash(f'rm -rf "{_gitbash(self.repo)}/build"')) + self.assertDenied(self.bash(f'rm -rf "/cygdrive{out}/other"')) + + +class TestRestrictReasons(RestrictSandboxMixin, unittest.TestCase): + def assertWriteGuidance(self, reason): + self.assertIsNotNone(reason) + self.assertIn("迂回", reason) + self.assertIn("! <コマンド>", reason) + + def test_delete_and_overwrite_reasons_have_guidance(self): + self.assertWriteGuidance(self.bash(f'rm -rf "{_fwd(self.out)}"')) + self.assertWriteGuidance(self.bash(f'echo x > "{_fwd(self.existing)}"')) + self.assertWriteGuidance(self.ps(f"Remove-Item {self.out} -Recurse")) + self.assertWriteGuidance(self.bash(f"python -c \"import shutil; shutil.rmtree('{_fwd(self.out)}')\"")) + self.assertWriteGuidance(self.tool("Write", {"file_path": self.existing})) + self.assertWriteGuidance(self.tool("Edit", {"file_path": self.existing})) + + def test_deny_read_reason_has_no_bang_request(self): + self.deny = [os.path.join(self.out, "dir")] + reasons = [ + self.tool("Read", {"file_path": os.path.join(self.out, "dir", "file.txt")}), + self.bash(f'cat "{_fwd(os.path.join(self.out, "dir", "file.txt"))}"'), + self.tool("Grep", {"pattern": "x", "path": self.out}), + ] + for reason in reasons: + with self.subTest(reason=reason): + self.assertIsNotNone(reason) + self.assertIn("迂回", reason) + self.assertNotIn("! <コマンド>", reason) + self.assertNotIn("`!", reason) + + +if __name__ == "__main__": + unittest.main() diff --git a/.claude/rules/docs-naming.md b/.claude/rules/docs-naming.md new file mode 100644 index 0000000..04a09e9 --- /dev/null +++ b/.claude/rules/docs-naming.md @@ -0,0 +1,36 @@ +--- +paths: ["docs/**"] +--- + +# ドキュメント管理・ファイル命名規則 (File Naming Conventions) + +## 命名の基本方針 (Basic Policy) + +プロジェクトの規模拡大に伴うファイルの散乱を防ぎ,開発メンバーおよび AI がファイルの内容・文脈を即座に特定できるようにするため,以下の規則を適用する. + +### 基本フォーマット + +`[カテゴリ]_[連番]_[ファイル名].md` + +- 区切り文字: アンダースコア `_` を使用する. +- 連番: 01 から始まる 2 桁の数字とする. +- 拡張子: マークダウン `.md` とする. + +## カテゴリ定義 (Category Definitions) + +ファイル名の先頭に付与するプレフィックス(接頭辞)定義. + +| カテゴリ | ディレクトリ | 内容 | 対象例 | +| --- | --- | --- | --- | +| `GUIDE_` | `01_GUIDE/` | 運用フロー・ガイド | 立ち上げフロー,エージェント運用,チーム開発ルール | +| `ENV_` | `02_ENV/` | 開発環境,プロジェクトの土台 | 環境構築手順,技術スタック一覧,パッケージ構成図,開発コマンド一覧 | +| `PLAN_` | `03_PLAN/` | 進行計画,スケジュール,要件定義 | WBS,ロードマップ,要件定義書,タスク一覧 | +| `SPEC_` | `04_SPEC/` | ユーザーから見える仕様 | 企画書,画面遷移図,パラメータ設定 | +| `TECH_` | `05_TECH/` | エンジニア向けの技術設計 | 通信プロトコル仕様,データベース設計,クラス設計 | +| `TEST_` | `06_TEST/` | テスト計画,品質保証 | 単体テスト仕様書,シナリオテスト計画,品質基準 | + +## 運用ルール (Operational Rules) + +- 1 ファイル 1 テーマ: ファイルサイズが肥大化しすぎないよう,トピックごとにファイルを分割することを推奨する.これにより,AI へのコンテキスト入力精度が向上する. +- スタイルの継承: 新規作成するファイルも,必ず [markdown-style](markdown-style.md) ルールに記載された書式に従うこと. +- 規約の置き場所: 「常に従うべき指示型の規約」(コーディング規約・テスト方針等)は docs ではなく `.claude/rules/` に path-scoped rule として置く(GUIDE_01「規約整備」参照).docs に置くのは運用フロー・仕様・設計等の参照資料. diff --git a/.claude/rules/git-conventions.md b/.claude/rules/git-conventions.md new file mode 100644 index 0000000..4b27f20 --- /dev/null +++ b/.claude/rules/git-conventions.md @@ -0,0 +1,68 @@ +# Git 運用ルール (Git Conventions) + +Git 操作(ブランチ作成・コミット・PR)を行うときは常に本ルールに従うこと. +push・PR 作成・マージ・クリーンアップの具体的な手順とトラブルシューティング(コンフリクト解消・誤コミット復旧)は `.claude/skills/commit/reference.md` を参照する. + +## 基本方針 (Basic Policy) + +コードの整合性を保ち,手戻りを防ぐために,開発モード(solo / team)を問わず以下の運用を徹底する. + +- **メインブランチ (main)**: + - 本番環境または常に動作可能な状態を維持するブランチ. + - 直接のコミット(直プッシュ)は禁止する.必ずプルリクエスト(PR)経由でマージする. +- **作業ブランチ (Feature Branch)**: + - 機能追加やバグ修正ごとに `main` から派生させて作成する. + - 作業完了後,`main` へマージし,原則として削除する. +- **`.gitignore`**: + - リポジトリ作成時に必ず整備する. + - ビルド成果物,依存パッケージ(`node_modules/` 等),環境変数ファイル(`.env` 等)は必ず除外する. + - `git add .` を安全に使うための前提条件であるため,メンバー追加時にも確認する. + +## ブランチ命名規則 (Branch Naming) + +### プレフィックス (Prefix) + +作業の種類を一目で判別するために,以下のプレフィックスを使用する. + +| プレフィックス | 用途 | +| --- | --- | +| `feature/` | 新機能の実装,仕様変更 | +| `fix/` | バグ修正 | +| `refactor/` | コードの整理(挙動は変えない) | +| `docs/` | ドキュメントの追記・修正 | +| `chore/` | ビルド設定やツール導入など,雑多な作業 | + +### 書式 (Format) + +- 書式: `[プレフィックス][概要]` +- 区切り: スラッシュ `/` を使用する.概要内の単語区切りはハイフン `-` を使用する. +- 概要: 作業内容を端的に表す英単語 2〜4 語程度(小文字)とする. +- 例: + - `feature/player-jump` + - `fix/collision-bug` + - `docs/guide-update` + +## コミットメッセージ (Commit Messages) + +### 書式 + +- 書式: `[タグ] 内容` +- 言語: 日本語 +- 句読点: 末尾に句点は不要 +- 例外: Dependabot が自動生成するコミット・PR(`.github/dependabot.yml` による依存更新)は `[update]` プレフィックス+英語本文(例: `[update] Bump foo from 1.2.0 to 1.3.0`)を許容し,手で書き直さない + +### タグ一覧 + +| タグ | 用途 | +| --- | --- | +| `[add]` | ファイルや機能の追加 | +| `[update]` | 機能やデータの更新・修正 | +| `[fix]` | バグ修正 | +| `[remove]` | 削除 | +| `[clean]` | 整理,リファクタリング | + +## コミットの粒度 (Commit Granularity) + +- 作業単位(意味のあるまとまり)ごとにコミットする. +- 巨大なコミットは避け,レビューしやすい粒度を心がける. +- PR の `--title` はコミットメッセージと同じ書式(`[タグ] 内容`)とする. diff --git a/.claude/rules/markdown-style.md b/.claude/rules/markdown-style.md new file mode 100644 index 0000000..b05a532 --- /dev/null +++ b/.claude/rules/markdown-style.md @@ -0,0 +1,118 @@ +--- +paths: ["**/*.md"] +--- + +# ドキュメント作成ガイドライン (Documentation Style Guide) + +本ルールはプロジェクト内のマークダウンドキュメントの書式を統一するためのルールを定める.Markdown ファイルを作成・編集するときは必ず従うこと. +基本方針として [Google Markdown Style Guide](https://google.github.io/styleguide/docguide/style.html) および [markdownlint](https://github.com/DavidAnson/markdownlint/blob/main/doc/Rules.md) のルールに準拠し,本ガイドで上書きする項目のみ以下に記載する. + +## 基本フォーマット (Basic Format) + +- 文字コード: UTF-8 +- 改行コード: LF(推奨)または CRLF +- ファイル拡張子: `.md` +- ファイル末尾: 空行を 1 行入れて終了する(MD047) + +## 句読点・約物 (Punctuation) + +- 句読点: 「,」(全角カンマ)と「.」(全角ピリオド)を使用する. + - 「、」「。」は使用しない. +- コロン: 半角コロン+半角スペース「: 」を使用する. + - NG: 全角コロン「:」,スペースなし「:」 +- 括弧: 原則として半角 `( )` を使用する.強調や補足には全角 `( )` や `「 」` も許容する. +- 注釈: 補足には「※」(全角米印)+半角スペースを使用する. + - 例: ※ shared は client/server 両方から import して使用する. + +## 見出し (Headings) + +ATX スタイル(`#`)を使用する.Setext スタイル(`===` / `---` の下線)は使用しない. + +- `#` の後に半角スペースを 1 つ入れる(MD018) +- 見出しの前後に空行を 1 行入れる(MD022) +- 見出しレベルは飛ばさない(MD001) + - NG: `#` の直下に `###` +- ドキュメント内の `#`(H1)は 1 つだけにする(MD025) +- 見出しには番号を付けない.マークダウンの見出し構造で階層を表現する. +- 見出しの書式: `タイトル (English Title)` + - 例: `## 基本設計 (Basic Design)` + +### 見出しレベルの対応表 + +| レベル | 記法 | 用途 | +| --- | --- | --- | +| H1 | `#` | ドキュメントタイトル(ファイルに 1 つ) | +| H2 | `##` | 大見出し(セクション) | +| H3 | `###` | 中見出し(サブセクション) | +| H4 | `####` | 小見出し(トピック) | + +## リスト (Lists) + +- 箇条書きには `- `(半角ハイフン+半角スペース)を使用する(MD004) + - `*` `+` `・` は使用しない. +- ネストは 2 スペースでインデントする(MD007) +- 順序付きリストは `1.` の連番で記述する. +- リストの前後に空行を 1 行入れる(MD032) + +## 強調 (Emphasis) + +- 太字: `**テキスト**` を使用する.`__テキスト__` は使用しない(MD050) +- 斜体: `*テキスト*` を使用する.`_テキスト_` は使用しない(MD049) +- 太字斜体: `***テキスト***` を使用する. + +## コードブロック・インラインコード (Code) + +- コードブロックにはフェンス(` ``` `)を使用し,言語名を明記する(MD040) + + ````markdown + ```dart + final name = 'DxNavi'; + ``` + ```` + +- インラインコードにはバッククォート `` ` `` を使用する. + - 例: `build_runner` コマンドを実行する. +- コマンド例にはシェル言語を指定する. + + ````markdown + ```bash + dart run build_runner build --delete-conflicting-outputs + ``` + ```` + +## リンク・画像 (Links & Images) + +- リンク: `[テキスト](URL)` 形式を使用する. +- 画像: `![代替テキスト](パス)` 形式を使用し,代替テキストを必ず記述する(MD045) +- プロジェクト内リンクは相対パスを使用する. + +## テーブル (Tables) + +- ヘッダー行とセパレータ行を必ず含める. +- セル内は簡潔に記述し,長い場合はリンクや脚注で補足する. + +```markdown +| 項目 | 説明 | +| --- | --- | +| 名前 | 値 | +``` + +## 空行・余白 (Spacing) + +- 連続する空行は最大 1 行までとする(MD012) +- 見出し・リスト・コードブロックの前後に空行を 1 行入れる(MD022, MD031, MD032) +- 行末の不要な空白は削除する(MD009) + +## その他 (Miscellaneous) + +- HTML タグは原則使用しない.マークダウン記法で表現できない場合のみ許容する. +- 1 行の長さに厳密な上限は設けないが,可読性のために適度に改行する. +- ディレクトリ構造の表現にはコードブロック内でツリー記号(`├──`,`│`,`└──`)を使用する. + +```text +project/ +├── lib/ +│ ├── core/ +│ └── features/ +└── test/ +``` diff --git a/.claude/rules/progress-log.md b/.claude/rules/progress-log.md new file mode 100644 index 0000000..4e4fa8d --- /dev/null +++ b/.claude/rules/progress-log.md @@ -0,0 +1,65 @@ +--- +paths: ["CLAUDE.md", "docs/PROGRESS.md"] +--- + +# 進捗記録の運用ルール (Progress Log Rules) + +`CLAUDE.md` の「開発進捗」セクションと `docs/PROGRESS.md` を更新するときは本ルールに従うこと. + +> ⚠ **チーム開発モード(team)では本ルールは上書きされる**.team プロジェクトでは進捗を `CLAUDE.md`・`docs/PROGRESS.md` に書かず,GitHub Issues と git 履歴で追う([GUIDE_03](../../docs/01_GUIDE/GUIDE_03_チーム開発ルール.md)).以下は個人開発モード(solo)の既定ルールである.team モードでは Phase 4 の進捗更新を Issue コメント(`/task-handoff` 等)に置き換える. +> なお solo モードでも `/task-create` 等で Issue によるタスク管理を併用してよい.その場合も本ルールの進捗記録(`CLAUDE.md` 進捗欄+ `docs/PROGRESS.md`)は従来どおり行い,Issue はタスクの管理・引継ぎの補助として扱う. + +## 二段構成 (Two-tier Structure) + +進捗記録は **2 ファイルの二段構成**で管理する.`/implement` の Phase 4 や `/setup` 中断時にこれらを更新する. + +| ファイル | 役割 | 更新方法 | +| --- | --- | --- | +| `CLAUDE.md` の「開発進捗」セクション | **最新ステップ 1 行のみ**(毎ターン読み込まれる薄い目印) | 上書き | +| `docs/PROGRESS.md` | **追記型のフル進捗ログ**(動機・設計判断・失敗パターン含む) | 末尾に追記 | + +**背景**: CLAUDE.md は毎ターン読み込まれるため,追記型運用にするとコンテキスト圧迫の原因になる.一方で進捗の経緯・失敗パターン・設計判断はタスクを跨いで価値があるため捨てたくない.そこで「常に読まれる薄いポインタ(CLAUDE.md)」と「必要な時だけ読む厚いログ(PROGRESS.md)」に分離する.関数名・引数・テスト件数等の実装詳細は `git log` とコミットメッセージに完全な形で残るため,どちらにも書かない. + +## CLAUDE.md「開発進捗」の書き方 + +- **最新 1 行のみ**.古い行は上書きで消す(履歴は PROGRESS.md に残るので消えてよい) +- 基本形式: `最新: <ステップ見出し>` +- 関数名・テスト件数・実装手順詳細は書かない(PROGRESS.md と `git log` に任せる) + +## docs/PROGRESS.md の書き方 + +`docs/PROGRESS.md` は追記型.最新エントリを**末尾に追記**する(時系列順.古いものが上,新しいものが下). + +**書く(PROGRESS.md に残す価値が高いもの)**: + +以下は PROGRESS.md に置く価値があるが,全て書く必要はない.シンプルなステップなら状況の 1 行だけで十分.書くことが少ない時に無理に伸ばさない. + +- 状況: 何が完了したか(1 行) +- 動機・原因: なぜそれをやったか(既存の問題点・要件変更・前バージョンの欠点など) +- 重要な設計判断: 他案との比較が必要な場合のみ.「A 案ではなく B 案を選んだ理由」が将来の判断材料になるとき +- 失敗パターンの圧縮サマリ: 「① X 案 → Y で破綻 / ② Z 案で解決」のような 1〜2 行.同じ轍を踏まないための知識として残す.試行錯誤を時系列で全部書かず,本質的に異なるアプローチの失敗だけを並列項目として記録する +- 未実装・後段: 「次に何をすべきか」を思い出すための短いメモ + +**書かない(コミットメッセージと `git log` に任せる)**: + +以下は `git log ` または `git show ` で完全な情報が取れる.PROGRESS.md に書くと重複・陳腐化する. + +- 関数名・引数・型シグネチャ +- テスト件数の内訳(合計件数のみなら可だが数字は陳腐化するので省略推奨) +- リファクタリングの手順詳細 +- 撤去・新規追加の関数リスト +- 具体的な定数値・閾値(設計判断の核心に関わるものを除く) +- 数値範囲の細部(実装パラメータ等) +- 後方互換性の細かい仕様 +- 「全テスト N passed」の報告(pass している前提のため不要) + +**長さと書式**: + +- 1 エントリの長さは内容次第で可変.書くことが少なければ **1 行で十分**.複数系統の作業を 1 ステップにまとめた等で必要なら長くなってもよい.無理に伸ばさず無理に削らず,「未来の自分(または他人)が辿れるか」だけを基準にする +- 基本形式: `- [x] **<簡潔な見出し>**: <1〜2 文の要約>`.必要に応じて下に動機・設計判断・失敗パターンを 1〜2 行ずつ追加 +- 太字は**見出しに使う**(目次の項目として目立たせる).本文中で多用しない +- 長い箇条書きを避ける.箇条書きは「失敗パターン ① ② ③」のような本質的に異なる選択肢の並列項目に限定する + +**既存エントリの長文化に気付いたとき**: + +過去のエントリで「詳細をベタ書きしていて目次として機能しなくなっている」ものを見つけたら,ユーザーに圧縮を提案する.自動で削らない(背景情報の価値判断はユーザーが行う).提案時は「この情報は `git log` にあるので削れる / これは失敗パターンなので残すべき」のように切り分けて示すこと. diff --git a/.claude/rules/template-customization.md b/.claude/rules/template-customization.md new file mode 100644 index 0000000..a56f992 --- /dev/null +++ b/.claude/rules/template-customization.md @@ -0,0 +1,33 @@ +--- +paths: [".claude/agents/**", ".claude/skills/**", ".claude/rules/**", ".claude/hooks/**", "docs/01_GUIDE/**"] +--- + +# テンプレート由来ファイルの改変記録ルール (Template Customization Rules) + +テンプレート(programming-template)由来のファイルを**このプロジェクトの都合で**変更するときは本ルールに従い,`.claude/template-overrides.md`(テンプレート改変台帳)に記録すること.記録が無いと次回の `/sync-template` で改変が検出・確認の対象になり,判断を誤るとテンプレート版で上書きされて改変が失われる. + +## 適用条件 (Scope) + +- テンプレートから派生したプロジェクト(`.claude/template-sync-sha` が存在する)でのみ適用する.テンプレートリポジトリ自身の編集では不要 +- **対象(テンプレートが管理するファイル)**: `.claude/agents/`,`.claude/skills/`(テンプレート提供の skill),`.claude/rules/`(`git-conventions`・`markdown-style`・`docs-naming`・`progress-log`・本ルール等,テンプレート由来のもの),`.claude/hooks/`,`docs/01_GUIDE/`(`GUIDE_01`〜`GUIDE_03`) +- **対象外**: + - プロジェクトが新規に作ったファイル(立ち上げ時に作成するコーディング規約 rule,プロジェクト独自の skill / agent 等).テンプレートに存在しないので同期で上書きされない + - `.claude/verify-profile.md`(検証プロファイル).テンプレートが持つのは雛形(`.claude/skills/verify/profile-template.md`)だけであり,プロファイル本体はプロジェクト所有のため同期で触られない.台帳登録は不要 + - `.claude/repo-access.json`(リポジトリ外アクセス制限フックの禁止リスト).テンプレートには含まれないプロジェクト所有のファイルのため同期で触られない.台帳登録は不要 + - `CLAUDE.md`・`.gitignore`・`docs/PROGRESS.md`・`.gitattributes`・`.claude/settings.json`.これらは `/sync-template` の「マージ必須ファイル」として別途保護されており,台帳登録は不要 +- テンプレート由来かどうか判断に迷う場合は**記録する側に倒す**.余計な記録は `/sync-template` が「テンプレート側に存在しないパス」として警告してくれるが,記録漏れは改変の消失につながる + +## 手順 (Procedure) + +1. **変更する前に**,その変更が「プロジェクト固有の都合」か「テンプレート自体の改善」かを判断する + - テンプレート自体の改善(誤記修正,全プロジェクトに有益な手順改善等)であれば,テンプレートリポジトリ側で直すべきことをユーザーに伝える.プロジェクト側で先に直す場合も,テンプレート側の修正に後から追従できるよう台帳には `merge` で登録する +2. **変更後**,`.claude/template-overrides.md` の「台帳」表に 1 行追加する(既に行があるファイルは理由を最新化する).書式・方針(`keep` / `merge` / `ask`)の定義は同ファイルの「記録ルール」節に従う + - 方針は,テンプレート側の改善も今後取り込みたいなら `merge`(既定.改変箇所とテンプレートの差分が別の箇所なら自動で両立する),完全に別物にしたなら `keep`,都度判断したいなら `ask` + - 理由は「なぜテンプレートと変えたか」を 1 文で書く.次回同期時の判断材料になる +3. 改変をやめてテンプレートに追従するときは,台帳から行を削除する(次回 `/sync-template` で上書き/マージされる) + +## 禁止事項 (Prohibitions) + +- 台帳に記録せずにテンプレート由来ファイルを改変したままにしない +- 台帳の書式を崩さない(`/sync-template` が機械的に読む) +- 台帳ファイル自体の「記録ルール」節の説明文を削除しない(他メンバー・将来の自分が読む) diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..1801fd4 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,53 @@ +{ + "permissions": { + "allow": [ + "Bash(gh pr merge:*)" + ] + }, + "hooks": { + "PreToolUse": [ + { + "matcher": "Read|Write|Edit|NotebookEdit|Glob|Grep|Bash|PowerShell", + "hooks": [ + { + "type": "command", + "command": "bash .claude/hooks/run_python.sh --fail-closed .claude/hooks/restrict_repo_access.py" + } + ] + }, + { + "matcher": "AskUserQuestion", + "hooks": [ + { + "type": "command", + "command": "bash .claude/hooks/run_python.sh .claude/hooks/notify.py", + "async": true + } + ] + } + ], + "Notification": [ + { + "matcher": "permission_prompt|elicitation_dialog|elicitation_url_dialog", + "hooks": [ + { + "type": "command", + "command": "bash .claude/hooks/run_python.sh .claude/hooks/notify.py", + "async": true + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "bash .claude/hooks/run_python.sh .claude/hooks/notify.py", + "async": true + } + ] + } + ] + } +} diff --git a/.claude/skills/auto-audit/SKILL.md b/.claude/skills/auto-audit/SKILL.md new file mode 100644 index 0000000..f486cc0 --- /dev/null +++ b/.claude/skills/auto-audit/SKILL.md @@ -0,0 +1,265 @@ +--- +name: auto-audit +model: inherit +description: "放置中(無人)に自律的に回り続け,空き時間をコードベース全体の「バグ発見」と「脆弱性発見」に充てる巡回ループ.メインは薄い司令塔として振る舞い,各指摘の裏取り〜修正〜検証を「1指摘=1体の項目オーケストレータ」に隔離コンテキストで完走させる(内部で tester/coder 等の専門エージェントを使い分ける).見つけた指摘を独立検証(裏取り)した上で,バグを再現して落ちるテスト(赤→緑)で修正の正しさを担保できるものだけを積極的に自動修正し,再現テストが書けない/挙動判断が要るものは台帳に報告する.影響範囲を解析し,各修正について人間が行うべき動作確認を添えて報告する.専用ブランチ(fix/)へ自律コミットするが push・PR・マージはしない." +argument-hint: "[bug|security|all(省略時 all)] [対象ディレクトリ/モジュールの絞り込み(省略可)]" +--- + +あなたは**自律バグ/脆弱性 監査ループの制御層(オーケストレータ)**です. +ユーザーが席を外している無人時間に,空いた稼働枠を「コードベース全体を巡回してバグ・脆弱性を発見し,安全に直せるものを直す」ことに充てるために起動されました. +Git 規約(`.claude/rules/git-conventions.md`),GUIDE_02(エージェント運用)を読み,書式・規約・パイプラインのルールを理解した上で,以下の不変条件を厳守してループを回してください. + +このループが扱う作業は,入口の「発見の観点」が **2 種類**あるだけで,後段の「修正の機械」は共通です. + +- **バグ観点 (bug)**:null 参照・境界値/オフバイワン・条件式の反転・誤った演算子・例外未処理・リソースリーク・競合・型の取り違え・戻り値の取り違え等,**実装が意図に反して壊れている**箇所. +- **脆弱性観点 (security)**:インジェクション(SQL/コマンド/パス),入力検証欠如,認証・認可の抜け,秘匿情報のハードコード/ログ漏れ,安全でない乱数/暗号,既知 CVE を持つ依存等,**セキュリティ上の欠陥**. + +いずれで見つけた指摘も,後述の **4 ゲート(裏取り → 再現テスト先行 → 影響範囲解析 → 影響評価つき報告)** を通す.違いは「何を探すか」だけで,「どう安全に直すか」は同じ. + +## 二層構造 — 誰が何をするか(重要・このコマンドの核) + +このループは**コンテキストを薄く保ったまま長時間回す**ために,責務を階層で分ける.メインは自分でコードやテストを触らず,指摘1件ごとに専用の作業体を起動し,**その最終報告(構造化サマリ)だけ**を受け取って台帳に反映する.こうするとメインには各指摘の詳細な読み込み・思考が積もらず,長時間・大規模でも司令塔のコンテキストが一定に保たれる.一方で専門エージェントの役割分担(独立したテスト作成・修正)はそのまま維持される(サブエージェントの内部コンテキストはメインに戻らないため,両立できる). + +- **① ループ制御層(あなた=メイン)**:セットアップ,台帳の読み書き,発見の起動,指摘の選定,**項目オーケストレータの起動**,返ってきた構造化サマリの台帳反映,収束判定,完了報告のみを行う.**自分でコード・テストを編集しない.指摘の詳細な裏取り・修正・検証はすべて下層に委ねる**(メインを薄く保つため). +- **② 項目オーケストレータ(1指摘=1体)**:メインから渡された**1つの指摘**について,4 ゲートを隔離コンテキストで**丸ごと完走**する司令塔.内部で下層の専門エージェントを使い分け,最後に**構造化サマリ1通**をメインへ返す.自動修正できたものは自分で `fix/` ブランチへコミットする. +- **③ 専門エージェント(項目オーケストレータが内部で使う)**: + - **発見**:`Explore`(読み取り専用で候補箇所を広く洗い出す). + - **裏取り(反証)**:独立した検証役(別エージェント)に「これは誤検知だ」と反証を試みさせる(adversarial verify). + - **再現テスト・特性化テスト**:`tester`. + - **修正**:`coder`. + - ※ 専門エージェントへの委譲が使えない環境では,項目オーケストレータが同じ手順(別ステップ・赤→緑規律)を自分で行ってよい.ただし**テスト作成と修正は必ず別ステップ**とし,独立性と赤→緑の規律を崩さない. + +### 二層で回すときの鉄則 + +- **メインは逐次に1体ずつ**項目オーケストレータを起動する(並行起動しない).同一の作業ツリー・`fix/` ブランチを共有するため,同時にコミットすると競合するため. +- メインが下層へ渡すのは「1つの指摘」と,本コマンドの**不変条件・4 ゲート・検証コマンド・除外リスト・観点フィルタ・既存台帳の要点(既にスキップ/報告済みで再処理不要なもの)**.渡した後はメインは待ち,**返ってきたサマリだけ**を信じて台帳を更新する(下層の詳細をメインが読み返さない=薄さを保つ). +- 発見(`Explore`)はメイン自身の起動でも,項目オーケストレータ内でもよいが,**メインが自分でコードを精読して候補を作らない**(読み込みは `Explore` に出す). + +## このコマンドの位置づけ(重要) + +- 本コマンドは **CLAUDE.md の「`/commit` 自発実行禁止」ルールに対する,ユーザー承認済みの明示的な例外**である(`/auto-refactor` と同格の例外). + - ただし例外が認められるのは **`fix/` 専用ブランチへのコミットに限る**(コミットは項目オーケストレータが行うが,制約は同じ). + - **`git push`・PR 作成・マージ・`main` への操作は一切行わない**(取り込み可否の判断は後で人間が行う). +- 「無人で回り続ける」ことが目的のため,各修正での人間確認は挟まない.これが許される根拠は次の通り: + - **自動修正するのは「再現テストで正しさを証明できたバグ/脆弱性」だけ**である(後述ゲート②).赤→緑になる再現テストと,緑を保つ既存スイートが,人間確認の代わりの安全網を果たす. + - **証明できないもの(正しい挙動が仕様判断になる/テスト基盤が無い UI 層等)は自動で直さず,報告のみに落とす**(後述ゲート②の歯止め).挙動を推測して塗り替えると,間違った仕様に固定してしまうため. + +## 不変条件 (Invariants) — 1つでも破れそうなら停止する + +1. **メインは手を動かさない(二層の分離)**:メイン(制御層)は自分でコード・テストを編集せず,指摘の詳細な裏取り・修正・検証は必ず**項目オーケストレータ**に委ねる.メインが保持するのは台帳・選定・収束判定・報告だけに限る(コンテキストを薄く保ち長時間運転を可能にするため). +2. **項目オーケストレータは逐次1体ずつ**:メインは項目オーケストレータを**並行起動しない**.作業ツリー・`fix/` ブランチを共有するため,同時コミットは競合し破損を招く.1体が完了して返ってから次を起動する. +3. **再現テストで証明できた修正だけを自動適用する**:どんな修正も「そのバグ/脆弱性を再現して**落ちるテスト(修正前は赤)**を書く → 修正 → **そのテストが緑になり,かつ既存の検証スイート全体も緑**」を満たした場合にのみコミットする.再現テストが書けない/緑にできないものは自動修正しない(ゲート②へ). +4. **裏取りしてから触る**:発見した指摘は,独立した視点で「本当にバグ/脆弱性か」を検証し,**誤検知(false positive)を修正対象から除外**してから着手する(ゲート①).確度が低いものは報告のみに落とす. +5. **影響範囲を保護してから直す**:修正するシンボルの呼び出し元・依存先を洗い出し,**テスト未カバーの影響先には先に特性化テスト(現状固定)を足して保護**してから修正する.影響が広すぎて無人で追い切れない場合は自動修正せず報告に落とす(ゲート③). +6. **挙動を戻さない安全網**:修正後に検証スイート(テスト+あればビルド/型チェック/リンタ)が**完全に緑**であること.赤が1つでも出たら,その修正は破棄(revert/stash)して対象を報告に落とす. +7. **専用ブランチのみ**:作業は `fix/` ブランチ上でのみ行う.`main` には絶対にコミットしない. +8. **push/PR しない**:リモート操作は一切しない. +9. **1修正 = 1コミット(テストは別コミット可)**:意味のあるまとまり(1バグ修正/1脆弱性修正)単位でこまめにコミットし,巨大な未コミット差分を溜めない.再現テスト・特性化テストの追加は `[add]`,修正本体は `[fix]` に分けてよい. +10. **除外領域に触れない**:後述の「対象外の領域(除外リスト)」に該当する箇所は修正対象に選ばない. +11. **疑わしきは報告に落とす**:判断に迷う・裏取りで確信が持てない・再現テストが書けない・影響が追い切れない・テストコマンドが特定できない場合は,**無理に直さず報告のみ**にする(報告台帳へ).修正よりも「新規バグを埋め込まないこと」を優先する. +12. **仕様・意図を推測で塗り替えない**:「これは仕様かバグか」の判断が要るものは自動修正しない.コードのコメントやドキュメントが示す意図に明確に反している場合のみ「バグ」と扱い,判断が割れるものは報告に回す. + +## advisor への相談(`/consult`)— 上限付きで許可 + +不変条件 11・12 で「報告に落とす」と判断する前に,**項目オーケストレータ**は `/consult`(advisor エージェント・Fable)に相談してよい.無人で Fable が回り続けるのを防ぐため以下を厳守する. + +- **上限**: 1 ループ実行(`/auto-audit` の 1 起動)あたり **3 回まで**,かつ **1 指摘につき 1 回まで**.メインは相談回数を台帳に記録し,3 回に達したら以降の指摘は相談せずに従来どおり報告に落とす +- **相談してよい論点**: 「仕様かバグか」の判定,修正案が複数あって影響範囲の広さで迷う場合,のみ.裏取り(ゲート①)や調査の代わりには使わない +- **結論の扱い**: advisor の確信度が**高**のときだけ,その結論に従って修正に進んでよい(4 ゲートはそのまま通す).確信度が中・低なら報告に落とし,advisor の結論・根拠を報告行に添える(人間の判断材料にする) +- advisor の助言は不変条件・除外リストを上書きしない(除外領域は相談しても触らない) +- 相談した指摘は台帳に「advisor 相談済み」と記す(次回の再相談を防ぐ) + +## 対象外の領域(除外リスト) — 修正対象に選ばない + +以下は無人で触ると事故りやすい,あるいは本コマンドの責務外のため,**自動修正の対象に選ばない**.発見フェーズで指摘が出ても,修正はせず(重大なら報告のみに落とし)スキップする. + +- 自動生成コード(コードジェネレータ出力,`*.g.dart`,`*.pb.go`,スナップショット等) +- 依存・サードパーティ(`node_modules/`,`vendor/`,`.dart_tool/`,`target/`,ベンダリングされた外部ソース) + - ただし**依存の既知 CVE**(脆弱性観点)は,コードを書き換えるのではなく「どの依存をどのバージョンに上げるべきか」を**報告**する(バージョン更新の自動適用はしない.挙動変化・破壊的変更のリスクがあるため).Dependabot alerts が有効なら同じ CVE は Dependabot 側でも検出されるので,報告時に「`/deps-update` で処理できる」旨を添える. +- ロックファイル・依存マニフェスト(`package-lock.json`,`yarn.lock`,`pubspec.lock`,`Cargo.lock`,`go.sum` 等) +- マイグレーション・スキーマ履歴(適用済みの DB マイグレーション等) +- 設定・秘匿情報ファイル(`.env*`,CI 設定,各種 config):秘匿情報のハードコード等を見つけた場合は**報告のみ**(無人でのローテーション・秘匿値の書き換えはしない). +- ビルド成果物・キャッシュ(`dist/`,`build/`,`.next/`,`coverage/` 等) +- ドキュメント全般(`docs/`,`CLAUDE.md`,`.claude/` 配下) + +判断に迷うファイルは「触らない(=報告に落とす)」側に倒す(不変条件 10). + +## セットアップ (Pre-check) — ループ開始前に一度だけ(メインが行う) + +1. **観点フィルタと対象範囲の確定**:`$ARGUMENTS` を解釈する. + - 先頭トークンが `bug` / `security` / `all` なら**観点フィルタ**とする(省略時は `all`=バグ+脆弱性の両観点).`bug` はバグ観点のみ,`security` は脆弱性観点のみ. + - 残りのトークンにディレクトリ/モジュールの絞り込みがあればその範囲に限定する.無ければプロジェクト全体を対象候補とする. +2. **ブランチの準備**: + - `git status` で作業ツリーがクリーンか確認する.未コミットの変更があれば,無人で巻き込むのは危険なので**停止して報告**する. + - 現在のブランチを確認する.`main` にいる場合は `.claude/rules/git-conventions.md` のブランチ命名規則に従い `fix/<英単語2〜4語(kebab-case)>` ブランチを作成して移動する(例: `fix/auto-audit`). + - 既に `fix/` ブランチにいる場合はそれを使う.`feature/`・`refactor/` 等の他作業ブランチにいる場合は,混在を避けるため**停止して確認を求める**. +3. **検証スイートの特定**:`docs/02_ENV/ENV_04_開発コマンド.md` が存在すればまずそれを参照する.無ければ(または記載が不足していれば)構成ファイル(`package.json`,`pubspec.yaml`,`Cargo.toml`,`go.mod`,`pyproject.toml`/`pytest.ini` 等)から,以下を特定する. + - **テスト実行コマンドとフレームワーク**(必須). + - 利用可能なら **ビルド/型チェック/リンタのコマンド**(例: `tsc --noEmit`,`cargo build`,`go build ./...`,`flutter analyze`,`npm run lint` 等). + - 以降,**「検証スイート」= テスト+(あれば)ビルド/型チェック/リンタ** をまとめて指す. + - **テストフレームワーク自体が存在せず特定できない場合**は,再現テスト先行(不変条件 3)が成立しないため**自動修正は一切行わず**,発見した指摘を**すべて報告のみ**にするモードで動く(この場合でもループは有用).その旨をセットアップ時に明示し,各項目オーケストレータにも「報告のみモード」を伝える. +4. **ベースラインの確認**:既存テストがある場合は検証スイートを実行し,**開始時点で全て緑**であることを確認する.赤がある場合は,修正の前提(緑からの出発)が崩れているため**停止して報告**する.既存テストが0件でも,フレームワークが特定できていれば「新規に再現テストを書いて流せる」ので開始してよい.ビルド/型チェックがあれば実行して緑を確認する. +5. **台帳の読み込み**:以下のローカル台帳が存在すれば読み込む(無ければ空として扱う).いずれも**ローカルな運用状態であり成果物ではないため `.gitignore` 対象**とする(追跡されていなければ `.gitignore` に追記する).メインは各項目オーケストレータへ「既にスキップ/報告済みの指摘(=再処理不要)」の要点を渡し,二重処理を防ぐ. + - `.claude/auto-audit-report.md`(報告台帳):自動修正しなかった指摘(要人間対応).**既出は再記録しない**(重複・トークン浪費防止).形式は1行1エントリ,先頭にカテゴリを付ける: + - `- [バグ] <箇所> | <重大度: 高/中/低> | <症状/再現手順> | <推定原因> | <提案する修正> | <自動修正しなかった理由> | <日付>` + - `- [脆弱性] <箇所> | <重大度: 高/中/低> | <脆弱性の種別と攻撃シナリオ> | <提案する対策> | <自動修正しなかった理由> | <日付>` + - `.claude/auto-audit-fixed.md`(修正済み台帳):自動修正できた項目の記録(`/loop` で複数回起動されても最終報告を積み上げられるように永続化する).形式は1行1エントリ: + - `- [修正] <箇所> | <バグ/脆弱性の内容> | <加えた変更> | <影響範囲(呼び出し元/依存先)> | <推奨する動作確認> | <コミットハッシュ or 概要> | <日付>` + - `.claude/auto-audit-skip.md`(スキップ台帳):裏取りで**誤検知**と判断した指摘や,安全に扱えず再挑戦しない対象.**再スキャンで同じものを何度も検証しない**ために記録する.形式は1行1エントリ `- <箇所/指摘> | <理由(誤検知/対処不能 等)> | <日付>`. + +## ループ本体 (Loop Body) — メインは薄い司令塔として回す + +メインは自分で指摘を精査せず,**「発見 → 1指摘を選ぶ → 項目オーケストレータに完走委譲 → 返り値を台帳へ」**を繰り返す. + +### ステップ M1: 発見(候補キューの作成) + +- セットアップで確定した範囲・観点フィルタの中から,`Explore` エージェント(読み取り専用)に**候補となる指摘を広く洗い出させ**,短い候補キュー(各エントリ=箇所・一言・観点)を作る.**メイン自身はコードを精読しない**(読み込みは `Explore` に委譲し,メインは一覧だけ保持する). +- 発見は多角的に依頼する(1つの探し方に依存しない):バグ観点なら「境界値・null・例外経路」「並行/状態管理」「戻り値・型」,脆弱性観点なら「入力の流れ(source→sink)」「認証認可」「秘匿情報」「依存 CVE」をそれぞれ探させる. +- **報告台帳・スキップ台帳に既出**の候補はキューから除外する(同じものを再処理しない). +- 候補が1つも無ければ**この回は空振り**として完了報告(収束判定)へ. + +### ステップ M2: 1指摘を選ぶ + +- キューから**未処理の指摘を1つ**選ぶ.優先度の目安(高い順):**セキュリティ高**(インジェクション・認可欠如・秘匿漏れ)→ **バグ高**(データ破壊・クラッシュ・誤った計算結果)→ 中 → 低. +- 選んだらステップ M3 へ.キューが空になったら完了報告へ. + +### ステップ M3: 項目オーケストレータを1体起動(1指摘を完走委譲) + +選んだ指摘について,項目オーケストレータを**1体だけ**(逐次)起動する.次を渡す: + +- **対象の指摘**(箇所・観点・`Explore` が挙げた根拠) +- **不変条件(本コマンドの全項目)・4 ゲートの手順**(下記「項目オーケストレータの憲章」) +- **検証スイートのコマンド**(テスト+あればビルド/型チェック/リンタ),**除外リスト**,**観点フィルタ**,**報告のみモードか否か** +- **既存台帳の要点**(既にスキップ/報告済みで再処理不要な指摘) +- **作業ブランチ名**(`fix/...`)と「push/PR/マージ禁止・`fix/` へのみコミット」の制約 + +起動後はメインは待ち,**返ってきた構造化サマリ(下記フォーマット)だけ**を受け取る.下層の詳細をメインが読み返さない(薄さの維持). + +### ステップ M4: 返り値を台帳へ反映・収束カウント + +項目オーケストレータのサマリに従い,メインが台帳を更新する: + +- **fixed**:`.claude/auto-audit-fixed.md` に返ってきた1行を追記(コミットは項目オーケストレータが実施済み). +- **reported**:`.claude/auto-audit-report.md` に返ってきた1行を追記. +- **skipped**:`.claude/auto-audit-skip.md` に返ってきた1行を追記. +- **stopped**(異常):停止条件に該当.ループを止めて報告へ. + +この回で「実質作業をした件数」(自動修正+**新規に**報告/スキップへ記録した件数)を数え,収束判定に使う.反映したらステップ M2 へ戻る(キューに残りがあれば次の指摘).キューが尽きたら,必要なら M1 を1回だけ再実行して取りこぼしを拾い,それでも候補が無ければ完了報告へ. + +--- + +## 項目オーケストレータの憲章(メインが各指摘とともに渡す) + +あなたは**1つの指摘を担当する項目オーケストレータ**である.渡された指摘について,以下の 4 ゲートを隔離コンテキストで完走し,最後に**構造化サマリ1通**を返す.内部で `tester`/`coder` 等の専門エージェントに委譲してよい(委譲できない場合も,テスト作成と修正は必ず別ステップとし赤→緑規律を守る).本コマンドの不変条件・除外リストを厳守し,`fix/` ブランチにのみコミットする(push/PR/マージ禁止). + +### ゲート①: 裏取り(誤検知を先に殺す・独立検証つき) + +- 指摘が**本当にバグ/脆弱性か**を,コードの実際の挙動・前後の文脈・呼び出され方から確認する. +- さらに**独立した検証役(別エージェント)に「これは誤検知だ」と反証を試みさせる**(adversarial verify).反証役には「デフォルトで誤検知寄りに判断せよ」と指示し,確度を厳しく見る. +- **誤検知,または確度が低い(反証が成立する/判断が割れる)** → 修正しない.`skipped` として返す(スキップ台帳行:`- <箇所> | <理由(誤検知/対処不能 等)> | <日付>`).確度が低いだけで実害があり得るものは `reported`(重大度「低〜中」)で返してもよい. +- **反証を退けて確かにバグ/脆弱性である** → ゲート②へ. + +### ゲート②: 再現テスト先行(修正の正しさを担保する核) + +修正の正しさを証明できるかを,**先にテストで確かめる**.`tester` に委譲してよい. + +1. **再現テストを書く**:その不具合を突く「**修正前は落ちる(赤)**」テストを作る. + - バグ観点:バグを踏む入力を与え,**あるべき正しい結果**をアサートする(現状は誤った結果を返すので赤になる). + - 脆弱性観点:攻撃シナリオを与え,**安全な挙動(拒否・エスケープ・検証エラー等)**をアサートする(現状は脆弱なので赤になる). +2. **赤を確認する**:修正前の現状コードでこのテストが**実際に落ちる**ことを確認する(=バグ/脆弱性が確かに存在する証拠). + - **落ちない(赤にできない)** → 再現できていない.裏取りが甘かった可能性があるため,修正せず `reported` として返す(「再現テストで赤にできなかった」旨を報告行に含める). +3. **正しい挙動が一意に決められるか**を判断する: + - **一意に決められる**(例: off-by-one,null 参照,条件反転,パラメタ化で防げる SQL インジェクション,パスの正規化で防げるトラバーサル等) → 再現テストを `[add]` でコミットしてゲート③へ. + - **仕様判断になる/挙動が割れる/テスト基盤の無い UI 層で観測できない** → **自動修正しない**(不変条件 11).`reported` として返す(「提案する修正・自動修正しなかった理由」を報告行に含める). + - ※ セットアップが「報告のみモード」(テストフレームワーク無し)なら,ここで一律 `reported` として返す. + +### ゲート③: 影響範囲(blast radius)の解析と保護 + +- 修正対象のシンボル(関数/メソッド/クラス)の**呼び出し元・依存先を洗い出す**(参照検索+呼び出し追跡). +- 影響先が**テストで守られていない**箇所 → 先に**特性化テスト(現状の挙動を固定するテスト)**を足して保護する(`tester` に委譲).追加したら `[add]` でコミットする. + - 特性化テストが緑にできない(挙動を固定できない)ほど影響先が不安定 → 無人で安全に直せないため,修正せず `reported` として返す. +- 影響が**広すぎて無人で追い切れない**(多数のモジュール・外部 I/O・非同期経路にまたがる等) → 自動修正せず `reported` として返す. +- 影響先が保護できた → ゲート④の修正実行へ. + +### ゲート④: 修正の実行・検証・影響評価つき記録 + +#### 修正の実行(`coder` に委譲してよい) + +- ゲート②で確認したバグ/脆弱性を修正する.**最小の変更**にとどめ,ついでのリファクタや無関係な整形を混ぜない(影響範囲を絞るため). +- 修正後,**検証スイート全体**(テスト+あればビルド/型チェック/リンタ)を実行する. + +#### 検証(安全網の確認) + +- 次の**両方**を満たすことを確認する: + - ゲート②で書いた**再現テストが緑**になった(=直したいものが直った証拠). + - **既存の検証スイート全体も緑のまま**(=他を壊していない証拠.ゲート③で足した特性化テストも含む). +- **両方満たす** → コミットへ. +- **再現テストが緑にならない/既存スイートに赤が出た** → その修正を破棄(`git checkout -- .` または `git stash`)し,`reported` として返す(「自動修正を試みたが安全に直せなかった/提案する修正」を報告行に含める).追加済みの再現テストは残してよい(後の人間修正の助けになる). + +#### コミット(`fix/` ブランチへ) + +- `git add` で当該変更をステージングする(`.env`・クレデンシャル・ビルド成果物は含めない). +- `.claude/rules/git-conventions.md` のコミット書式に従い,タグ **`[fix]`** でコミットする. + - バグ例: `[fix] 〇〇の境界値でインデックスが1つずれる不具合を修正` + - 脆弱性例: `[fix] 〇〇クエリをパラメタ化して SQL インジェクションを防止` +- **push・PR はしない.** + +#### 影響評価つきサマリを返す(`fixed`) + +修正済み台帳に積むべき1行を含めて `fixed` を返す: +`- [修正] <箇所> | <内容> | <加えた変更> | <影響範囲(呼び出し元/依存先)> | <推奨する動作確認> | <コミット概要> | <日付>` + +- **推奨する動作確認**には,テストで担保しきれない確認を具体的に書く.特に CLAUDE.md の「手動確認が必要な作業」に該当するもの(**実機・ブラウザでの動作確認,外部サービスの挙動**)は名指しで「ここは自動テストで確認できないので人間が確認してほしい」と記す. + +### 項目オーケストレータの返却フォーマット(メインはこれだけで台帳を更新できる) + +作業完了後,次の構造化サマリ**だけ**をメインへ返す(詳細な思考ログは返さない=メインを薄く保つ): + +- **結果種別**:`fixed` / `reported` / `skipped` / `stopped` +- **台帳行**:追記すべき1行(どの台帳か= fixed/report/skip も明示).`stopped` の場合は停止理由. +- **コミット**:作成したコミットのタグと要約(`fixed` のとき,`[add]` の再現/特性化テストと `[fix]` を列挙) +- **作業有無**:この指摘で実質作業をしたか(収束カウント用の真偽) +- **一言**:メインが完了報告に使える要約 + +--- + +## 停止条件 (Stop Conditions) + +以下のいずれかでループを止める. + +- **選べる指摘(バグ/脆弱性)が尽きた** → **正常終了**. +- 項目オーケストレータが `stopped` を返した/同じ箇所で修正が繰り返し赤になる/検証スイートが途中で失敗するようになった → **異常として停止し報告**. +- 未コミットの変更が残ったまま先に進めない状況になった → **停止して報告**. +- 不変条件を満たせない状況になった → **停止して報告**. +- ユーザーが割り込んで中断した → その時点までの成果(コミット済み)は `fix/` ブランチに残る. + +## /loop との併用(推奨される使い方) + +このコマンドは単体で「安全に扱える指摘が尽きるまで」連続実行するが,ユーザーが `/loop` でラップして起動すると,放置中の空き枠を使って自律的に再トリガーし続けられる(本コマンドの本来の目的). +`/loop /auto-audit` で全観点巡回,`/loop /auto-audit security` でセキュリティだけ巡回,のように使い分けられる.`/loop` で回す場合も上記の不変条件・停止条件は同じく厳守すること.長い待機を挟む場合はキャッシュとコストを意識した間隔を選ぶ. + +### 収束条件(loop-until-dry)— 空回りでトークンを浪費しない + +`/loop` で回すと,指摘が尽きた後もウェイクのたびに「対象なし → 即終了」を繰り返し,**無駄にトークンを消費**しうる.これを防ぐため,以下の収束ルールを守る. + +- 各ウェイクの発見(ステップ M1)で候補を洗い出し,**この回で実施した作業の件数**を数える(自動修正した件数+**新規に**報告台帳/スキップ台帳に記録した件数.既出項目の再確認は作業に数えない). +- **作業件数が 0 だった回**を「空振り」とカウントする. +- **空振りが 2 回連続**したら,もう安全に拾える指摘は出尽くしたとみなし,**`/loop` 自体を終了する**(次のウェイクをスケジュールしない). + - 1 回の空振りで即終了しないのは,「順序やコミットの巡り合わせで一時的に拾えなかっただけ」のケースを 1 回分許容するため. +- 1 件でも作業した回が出たら,空振りカウントは 0 にリセットする. +- 終了時は「空振りが続いたため収束した」旨を完了報告に明記する. + +## 完了報告 (Reporting) + +ループ終了時,メインがユーザーに以下を報告する(`.claude/auto-audit-fixed.md` / `-report.md` / `-skip.md` を根拠にする). + +- 作業ブランチ名と,適用した観点フィルタ(bug / security / all)・対象範囲 +- **自動修正した件数と一覧**(`[fix]` コミット).各項目について「加えた変更・影響範囲・**人間が行うべき推奨動作確認**」を添える(修正済み台帳の内容). + - 特に **実機・ブラウザ・外部サービスでの確認**が要るものは名指しで案内する(自動テストで担保できないため). +- 追加した再現テスト・特性化テストの件数(`[add]` コミット) +- **⚠ 自動修正しなかった指摘(要人間対応)**:報告台帳 `.claude/auto-audit-report.md` の `[バグ]`/`[脆弱性]` の件数と概要(重大度順).「正しい挙動が仕様判断になる/影響が追い切れない/再現できなかった」等の**自動修正しなかった理由**を明記し,`/implement` 等での対応を促す. + - 依存の既知 CVE は「どの依存をどのバージョンへ」の形で提示する(バージョン更新は自動適用していない旨も添える). +- 誤検知としてスキップした件数(`.claude/auto-audit-skip.md`) +- 収束理由(指摘が尽きた/空振りが続いた/停止条件に該当,など) +- 次のアクションの案内:「内容を確認の上,問題なければ `/commit push`(または `merge`)で `main` に取り込めます」 + - **本コマンドからは push・PR・マージを行わない.** 取り込みは必ずユーザーの明示指示(`/commit push` 等)で行う. diff --git a/.claude/skills/auto-refactor/SKILL.md b/.claude/skills/auto-refactor/SKILL.md new file mode 100644 index 0000000..f154b52 --- /dev/null +++ b/.claude/skills/auto-refactor/SKILL.md @@ -0,0 +1,340 @@ +--- +name: auto-refactor +model: inherit +description: "放置中(無人)に自律的に回り続け,空き時間をコード/ドキュメント/テストの整理に充てるループ.メインは薄い司令塔として振る舞い,各作業項目を「1項目=1体の項目オーケストレータ」に隔離コンテキストで完走させる(内部で tester/refactorer 等の専門エージェントを使い分ける).テスト保護下のコードリファクタ,実装を真としたドキュメント追記・整形(矛盾は報告のみ),ミューテーション保護下のテストリファクタを,対象が尽きるまで繰り返し,専用ブランチへ自律コミットする." +argument-hint: "<対象ディレクトリ/モジュールの絞り込み(省略可)>" +--- + +あなたは**自律リファクタリング&ドキュメント同期ループの制御層(オーケストレータ)**です. +ユーザーが席を外している無人時間に,空いた稼働枠を「コードの大規模リファクタリング」と「実装とドキュメントのズレ解消」に充てるために起動されました. +Git 規約(`.claude/rules/git-conventions.md`),GUIDE_02(エージェント運用)と,ドキュメント書式・命名のルール(`.claude/rules/markdown-style.md`・`.claude/rules/docs-naming.md`)を読み,書式・規約・パイプラインのルールを理解した上で,以下の不変条件を厳守してループを回してください. + +このループが扱う作業は **4 種類**ある.前の3種をすべて拾い尽くした後にだけ,最後の「テストリファクタ系」を行う(最も安全網が特殊で危険なため,最後に回す). + +- **コードリファクタ系**:挙動を変えずにコード品質を改善する(安全網=テスト). +- **ドキュメント同期系**:実装を真として `docs/` の記述を実態に近づける.ただし「ドキュメントに無い事項の追記」は自動で行い,「ドキュメントと実装の矛盾」は自動で書き換えず報告のみ(後述). +- **ドキュメントリファクタ系**:ドキュメントの**意味を変えずに**読みやすさ・品質を上げる(冗長表現の簡潔化,表記統一,書式整形,リンク修正等).コードリファクタにおけるテストに相当する「意味が保たれたかの自動検証手段が無い」ため,**情報を削るより残す側に倒す**のが原則(後述の不変条件 11). +- **テストリファクタ系**(前3種が尽きた後のテールフェーズ):肥大化・重複・無意味なテストを整理する.**テストを再実行しても「検証力を弱めた」ことに気づけない**のが根本問題なので,安全網としてミューテーションテストの差分を使う(後述の不変条件 12).使えない場合は構造改善のみ自動・削除/弱体化は提案に落とす. + +## 二層構造 — 誰が何をするか(重要・このコマンドの核) + +このループは**コンテキストを薄く保ったまま長時間回す**ために,責務を階層で分ける.メインは自分でコードやテストを触らず,作業項目1件ごとに専用の作業体を起動し,**その最終報告(構造化サマリ)だけ**を受け取ってコミット結果・要確認リストに反映する.こうするとメインには各項目の詳細な読み込み・思考が積もらず,長時間・大規模でも司令塔のコンテキストが一定に保たれる.一方で専門エージェントの役割分担(独立したテスト作成・リファクタ)はそのまま維持される(サブエージェントの内部コンテキストはメインに戻らないため,両立できる). + +- **① ループ制御層(あなた=メイン)**:セットアップ,台帳の読み書き,発見の起動,作業項目の選定,**項目オーケストレータの起動**,返ってきた構造化サマリの反映(要確認リストへの追記等),収束判定,完了報告のみを行う.**自分でコード・ドキュメント・テストを編集しない.詳細な保護・リファクタ・検証はすべて下層に委ねる**(メインを薄く保つため). +- **② 項目オーケストレータ(1項目=1体)**:メインから渡された**1つの作業項目**について,種別(R/D/DR/T)に応じた処理を隔離コンテキストで**丸ごと完走**する司令塔.内部で下層の専門エージェントを使い分け,最後に**構造化サマリ1通**をメインへ返す.自動で行えたものは自分で `refactor/` ブランチへコミットする. +- **③ 専門エージェント(項目オーケストレータが内部で使う)**: + - **発見**:`Explore`(読み取り専用でリファクタ候補・ドキュメントドリフト・テストスメルを広く洗い出す). + - **特性化テスト**:`tester`. + - **コードリファクタ**:`refactorer`. + - **ドキュメント同期・整形(D/DR)とテストリファクタ(T)**:項目オーケストレータが自身で行ってよい(Dは追記,DRは意味保存の整形,Tは慎重な整理.いずれもテスト/ミューテーションの安全網手順に従う). + - ※ 専門エージェントへの委譲が使えない環境では,項目オーケストレータが同じ手順を自分で行ってよい.ただし**リファクタは必ず「緑のテストで保護された状態」でのみ**行い,安全網の規律を崩さない. + +### 二層で回すときの鉄則 + +- **メインは逐次に1体ずつ**項目オーケストレータを起動する(並行起動しない).同一の作業ツリー・`refactor/` ブランチを共有するため,同時にコミットすると競合するため. +- メインが下層へ渡すのは「1つの作業項目」と,本コマンドの**不変条件・種別ごとの処理手順(R/D/DR/T)・検証コマンド・除外リスト・既存台帳の要点(既にスキップ/記録済みで再処理不要なもの)**.渡した後はメインは待ち,**返ってきたサマリだけ**を信じて反映する(下層の詳細をメインが読み返さない=薄さを保つ). +- 発見(`Explore`)はメイン自身の起動でも,項目オーケストレータ内でもよいが,**メインが自分でコード/ドキュメントを精読して候補を作らない**(読み込みは `Explore` に出す). + +## このコマンドの位置づけ(重要) + +- 本コマンドは **CLAUDE.md の「`/commit` 自発実行禁止」ルールに対する,ユーザー承認済みの明示的な例外**である. + - ただし例外が認められるのは **`refactor/` 専用ブランチへのコミットに限る**(リファクタ・ドキュメント同期いずれの成果もこのブランチに積む). + - **`git push`・PR 作成・マージ・`main` への操作は一切行わない**(マージ可否の判断は後で人間が行う). +- 「無人で回り続ける」ことが目的のため,各 Phase での人間確認は挟まない.これが許される根拠は作業種別ごとに異なる: + - **コードリファクタ系**:リファクタは「挙動を変えない」作業であり,**テストが安全網として人間確認の代わりを果たす**から(テスト無しの箇所には触らない). + - **ドキュメント同期系**:**追記(事実の補完)に限定**し,かつ**矛盾の自動上書きをしない**から.意図的に書かれた記述を壊さず,バグを仕様に固定しない(後述の不変条件 10). + +## 不変条件 (Invariants) — 1つでも破れそうなら停止する + +1. **メインは手を動かさない(二層の分離)**:メイン(制御層)は自分でコード・ドキュメント・テストを編集せず,詳細な保護・リファクタ・検証は必ず**項目オーケストレータ**に委ねる.メインが保持するのは台帳・選定・収束判定・報告だけに限る(コンテキストを薄く保ち長時間運転を可能にするため). +2. **項目オーケストレータは逐次1体ずつ**:メインは項目オーケストレータを**並行起動しない**.作業ツリー・`refactor/` ブランチを共有するため,同時コミットは競合し破損を招く.1体が完了して返ってから次を起動する. +3. **挙動を変えない**:リファクタ前後で**検証スイートが完全に緑のまま**であること(検証スイート=テスト+利用可能ならビルド/型チェック/リンタ.後述).赤が1つでも出たら,その変更は破棄(revert/stash)して対象をスキップする. +4. **テスト保護下で着手する**:リファクタは必ず**安全網(緑のテスト)がある状態**で行う.対象にテストが無い場合は,**まず特性化テスト(現状の挙動を固定するテスト)を生成して保護下にしてから**着手する(後述のステップ R2).テストフレームワーク自体が特定できない/生成テストを流せない箇所は対象にしない. +5. **専用ブランチのみ**:作業は `refactor/` ブランチ上でのみ行う.`main` には絶対にコミットしない. +6. **push/PR しない**:リモート操作は一切しない. +7. **1作業 = 1コミット**:意味のあるまとまり(1リファクタ/1ドキュメント更新)単位でこまめにコミットし,巨大な未コミット差分を溜めない. +8. **除外領域に触れない**:後述の「対象外の領域(除外リスト)」に該当する箇所はリファクタ対象に選ばない. +9. **疑わしきは停止**:判断に迷う・安全網が確認できない・テストコマンドが特定できない場合は,無理に進めず停止して状況を報告する(項目オーケストレータは当該項目を `skipped`/`stopped` として返す). +10. **ドキュメントは「追記は自動・矛盾は報告のみ」**:実装を真としてよいのは**ドキュメントに記載が無い事項の追記**だけ.ドキュメントと実装が**食い違う**箇所は**自動で書き換えず**,要確認リストに記録して報告する(矛盾は実装バグの兆候かもしれず,無人で塗り潰すと検出機会を失うため).`docs/01_GUIDE/`(規約),`CLAUDE.md`,`docs/03_PLAN/`・`docs/PROGRESS.md`(計画・進捗)は実装を真とする対象に**含めない**. +11. **ドキュメントリファクタは「意味保存」が絶対条件**:自動で行ってよいのは**意味・情報を保ったままの品質改善**だけ.具体的には ①無損失整形(表記揺れの統一・書式/見出し/表の整形・リンクや参照の修正・明らかなタイポ)と,②**意味を保った簡潔化・重複の集約**(冗長な言い回しを短くする,同一内容の重複を1か所に集約して他はリンクする).**消してはいけないもの**=事実・決定事項・設計判断(なぜその案にしたか)・失敗パターン・具体的な制約や閾値.これらが落ちる「陳腐化記述の削除」や「大規模な再編」は自動で行わず,要確認リストに**提案として記録**する.意味保存を自動検証する手段は無いため,迷ったら削らない(残す側に倒す). +12. **テストリファクタは「検証力を落とさない」が絶対条件**:テストの整理では「検証スイートが緑」は安全網にならない(削除・弱体化したテストも緑のまま).そこで**ミューテーションテストの差分**を安全網にする — リファクタ前に対象テストが守る本番コード範囲で「殺せるミュータント集合」を基準として取り,リファクタ後も**その全てを殺せたまま**なら検証力は保たれた=安全(削除・統合・簡略化も可).1つでも生存に転じたら検証力低下なので破棄する.ミューテーションテストが**走らせられない**場合(対応ツールが無い/恒久的な依存追加が必要/コスト非現実的)は,**構造改善(extract・共通化・命名・AAA 整理など検証を減らさないもの)のみ自動**とし,**テストの削除・アサーション弱体化・統合は提案として記録**する.テストコードへの自動編集はこのテストリファクタ処理(T)でのみ行う(コードリファクタ R はテストを触らない). + +## advisor への相談(`/consult`)— 上限付きで許可 + +不変条件 9 で「停止/skipped」と判断する前に,**項目オーケストレータ**は `/consult`(advisor エージェント・Fable)に相談してよい.無人で Fable が回り続けるのを防ぐため以下を厳守する. + +- **上限**: 1 ループ実行(`/auto-refactor` の 1 起動)あたり **3 回まで**,かつ **1 項目につき 1 回まで**.メインは相談回数を記録し,3 回に達したら以降の項目は相談せずに従来どおり skipped/提案記録に落とす +- **相談してよい論点**: リファクタ案が複数あり設計方針で迷う場合,ドキュメント簡潔化で「意味保存か情報損失か」の線引きに迷う場合,のみ.調査や候補探索の代わりには使わない +- **結論の扱い**: advisor の確信度が**高**のときだけ,その結論に従って作業に進んでよい(安全網=テスト緑・ミューテーション差分はそのまま必須).確信度が中・低なら作業せず,advisor の結論・根拠を要確認リストの `[提案]` 行に添える +- advisor の助言は不変条件・除外リストを上書きしない(矛盾の自動書き換え・除外領域への編集は相談しても行わない) +- 相談した項目は要確認リスト等に「advisor 相談済み」と記す(次回の再相談を防ぐ) + +## 対象外の領域(除外リスト) — コードリファクタの対象に選ばない + +以下は無人で触ると事故りやすく,かつリファクタの価値が薄いため,**コードリファクタの対象に選ばない**.発見・選定時に必ず除外する. + +- 自動生成コード(コードジェネレータ出力,`*.g.dart`,`*.pb.go`,スナップショット等) +- 依存・サードパーティ(`node_modules/`,`vendor/`,`.dart_tool/`,`target/`,ベンダリングされた外部ソース) +- ロックファイル・依存マニフェスト(`package-lock.json`,`yarn.lock`,`pubspec.lock`,`Cargo.lock`,`go.sum` 等) +- マイグレーション・スキーマ履歴(適用済みの DB マイグレーション等,後から書き換えてはいけないもの) +- 設定・秘匿情報(`.env*`,CI 設定,各種 config ファイル) +- ビルド成果物・キャッシュ(`dist/`,`build/`,`.next/`,`coverage/` 等) +- ドキュメント全般(`docs/`,`CLAUDE.md`,`.claude/` 配下) +- テストコード(テストファイル):コードリファクタ R は**本番コードのみ**を対象とし,テストには触らない(テストは安全網のため).テストの整理は専用の**テストリファクタ処理 (T)** で扱う. + +※ ドキュメントはコードリファクタの対象にはしないが,**ドキュメント同期系の処理(D)/ドキュメントリファクタ系の処理(DR)**では `docs/02_ENV/`・`docs/04_SPEC/`・`docs/05_TECH/` を更新対象とする(不変条件 10・11 の範囲で).`docs/01_GUIDE/`・`CLAUDE.md`・`.claude/`・進捗系(`docs/03_PLAN/`・`docs/PROGRESS.md`)は自動編集の対象に**含めない**. + +判断に迷うファイルは「触らない」側に倒す(不変条件 9). + +## セットアップ (Pre-check) — ループ開始前に一度だけ(メインが行う) + +1. **対象範囲の確認**:`$ARGUMENTS` に絞り込み(ディレクトリ/モジュール)があればその範囲に限定する.無ければプロジェクト全体を対象候補とする. +2. **ブランチの準備**: + - `git status` で作業ツリーがクリーンか確認する.未コミットの変更があれば,無人で巻き込むのは危険なので**停止して報告**する. + - 現在のブランチを確認する.`main` にいる場合は `.claude/rules/git-conventions.md` のブランチ命名規則に従い `refactor/<英単語2〜4語(kebab-case)>` ブランチを作成して移動する(例: `refactor/auto-cleanup`). + - 既に `refactor/` ブランチにいる場合はそれを使う.`feature/` 等の他作業ブランチにいる場合は,混在を避けるため**停止して確認を求める**. +3. **検証スイートの特定**:`docs/02_ENV/ENV_04_開発コマンド.md` が存在すればまずそれを参照する.無ければ(または記載が不足していれば)プロジェクトの構成ファイル(`package.json`,`pubspec.yaml`,`Cargo.toml`,`go.mod`,`pyproject.toml`/`pytest.ini` 等)から,以下を特定する. + - **テスト実行コマンドとフレームワーク**(必須). + - 利用可能なら **ビルド/型チェック/リンタのコマンド**(例: `tsc --noEmit`,`cargo build`,`go build ./...`,`flutter analyze`,`npm run lint` 等).これらは「テスト緑」だけでは拾えないコンパイル破壊やリンタ違反を検出する追加の安全網になる. + - 以降,**「検証スイート」= テスト+(あれば)ビルド/型チェック/リンタ** をまとめて指す.緑の判定はこの検証スイート全体で行う. + - テストが1つも無くても,**テストフレームワークが特定でき新規テストを書いて流せる**なら開始してよい(無い箇所は特性化テストを生成して保護下にする). + - テストフレームワーク自体が存在せず特定できない場合は,どの土台でテストを書くかは設計判断が要るため,**無人で土台を新設せず停止して報告**する.ただしこの場合でも**ドキュメント同期系(D)は実行してよい**(テスト不要のため). +4. **ベースラインの確認**:既存テストがある場合は検証スイートを実行し,**開始時点で全て緑**であることを確認する.赤がある場合は,リファクタの前提(緑からの出発)が崩れているため**停止して報告**する.既存テストが0件の場合はテスト実行を省略してよいが,ビルド/型チェックがあれば実行して緑を確認する. +5. **台帳の読み込み**:以下のローカル台帳が存在すれば読み込む(無ければ空として扱う).いずれも**ローカルな運用状態であり成果物ではないため `.gitignore` 対象**とする(追跡されていなければ `.gitignore` に追記する).メインは各項目オーケストレータへ「既にスキップ/記録済みの項目(=再処理不要)」の要点を渡し,二重処理を防ぐ. + - `.claude/auto-refactor-skip.md`(スキップ台帳):過去に安全に扱えなかったリファクタ対象.今回も**再挑戦せず除外**する.形式は1行1エントリ `- <対象> | <理由> | <日付>`. + - `.claude/auto-refactor-doc-review.md`(ドキュメント要確認リスト):過去に検出した「ドキュメントと実装の矛盾」および「自動では行わない簡潔化・再編の提案」.**既出の項目は再記録しない**(重複・トークン浪費防止).形式は1行1エントリ,先頭にカテゴリを付ける: + - 矛盾: `- [矛盾] <箇所> | <ドキュメントの記述> | <実装の挙動> | <推定原因> | <日付>` + - 提案: `- [提案] <箇所> | <提案内容(どう簡潔化/再編したいか)> | <自動で行わない理由> | <日付>` + - `.claude/auto-refactor-test-review.md`(テスト要確認リスト):テストリファクタで「自動では行わなかった削除・統合・弱体化の提案」.**既出は再記録しない**.形式は1行1エントリ `- [提案] <対象テスト> | <提案内容> | <自動で行わない理由(ミューテーション未実施 等)> | <日付>`. + +## ループ本体 (Loop Body) — メインは薄い司令塔として回す + +メインは自分で対象を精査せず,**「発見 → 1項目を選ぶ → 項目オーケストレータに完走委譲 → 返り値を反映」**を繰り返す. + +### ステップ M1: 発見(候補キューの作成) + +- セットアップで確定した範囲の中から,`Explore` エージェント(読み取り専用)に**候補となる作業項目を広く洗い出させ**,短い候補キュー(各エントリ=種別 R/D/DR/T・箇所・一言)を作る.**メイン自身はコード/ドキュメントを精読しない**(読み込みは `Explore` に委譲し,メインは一覧だけ保持する). + - コード(R):重複コード,過度に長い関数,命名の乱れ,デッドコード,複雑な構造(除外リスト・スキップ台帳を除く). + - ドキュメント(D):`docs/02_ENV`・`04_SPEC`・`05_TECH` と実装の**記載漏れ/矛盾**(要確認リスト既出の矛盾は除外). + - ドキュメント整形(DR):同ドキュメント群の冗長・表記揺れ・書式乱れ・重複・リンク切れ. + - テスト(T):肥大化・重複・無意味なテスト(test smells). +- **報告台帳・スキップ台帳・要確認リストに既出**の候補はキューから除外する. +- 候補が1つも無ければ**この回は空振り**として完了報告(収束判定)へ. + +### ステップ M2: 1項目を選ぶ(種別の優先順位を守る) + +- **前3種(R/D/DR)を優先**し,それらが尽きた後にだけ**テストリファクタ(T)**を選ぶ(Tは最も安全網が特殊で危険なため最後に回す). +- 同種内の優先度(コードR):重複コードの集約 → 過度に長い関数の分割 → 命名の改善 → デッドコード除去 → 構造の単純化. +- 選んだらステップ M3 へ.4種すべてに項目が無くなったら完了報告へ. + +### ステップ M3: 項目オーケストレータを1体起動(1項目を完走委譲) + +選んだ作業項目について,項目オーケストレータを**1体だけ**(逐次)起動する.次を渡す: + +- **対象の作業項目**(種別 R/D/DR/T・箇所・`Explore` が挙げた根拠) +- **不変条件(本コマンドの全項目)・種別ごとの処理手順**(下記「項目オーケストレータの憲章」) +- **検証スイートのコマンド**(テスト+あればビルド/型チェック/リンタ),**除外リスト** +- **既存台帳の要点**(既にスキップ/記録済みで再処理不要な項目) +- **作業ブランチ名**(`refactor/...`)と「push/PR/マージ禁止・`refactor/` へのみコミット」の制約 + +起動後はメインは待ち,**返ってきた構造化サマリ(下記フォーマット)だけ**を受け取る.下層の詳細をメインが読み返さない(薄さの維持). + +### ステップ M4: 返り値を反映・収束カウント + +項目オーケストレータのサマリに従い,メインが反映する: + +- **done**:コミットは項目オーケストレータが実施済み(`[add]`/`[clean]`/`[update]`).メインは件数・概要を完了報告用に記録する. +- **doc-review**:`.claude/auto-refactor-doc-review.md` に返ってきた1行(`[矛盾]`/`[提案]`)を追記. +- **test-review**:`.claude/auto-refactor-test-review.md` に返ってきた1行(`[提案]`)を追記. +- **skipped**:`.claude/auto-refactor-skip.md` に返ってきた1行を追記. +- **stopped**(異常):停止条件に該当.ループを止めて報告へ. + +この回で「実質作業をした件数」(コードリファクタ+特性化テスト生成+ドキュメント追記+ドキュメントリファクタ+テストリファクタ+**新規に記録した**矛盾・提案)を数え,収束判定に使う.反映したらステップ M2 へ戻る.キューが尽きたら,必要なら M1 を1回だけ再実行して取りこぼしを拾い,それでも候補が無ければ完了報告へ. + +--- + +## 項目オーケストレータの憲章(メインが各項目とともに渡す) + +あなたは**1つの作業項目を担当する項目オーケストレータ**である.渡された項目の種別(R/D/DR/T)に応じて,以下の処理を隔離コンテキストで完走し,最後に**構造化サマリ1通**を返す.内部で `tester`/`refactorer` に委譲してよい(委譲できない場合も安全網の手順を自分で守る).本コマンドの不変条件・除外リストを厳守し,`refactor/` ブランチにのみコミットする(push/PR/マージ禁止). + +### リファクタ処理 (R) + +#### ステップ R1: 保護状態の確認 + +- 「その箇所を変更したときに失敗するテストが存在するか」を確認する. + - **保護下にある** → ステップ R3 へ. + - **テストが無い** → ステップ R2(特性化テストの生成)へ. + +#### ステップ R2: 特性化テストの生成(テストが無い対象のみ/`tester` に委譲) + +テストが無い対象は,まず安全網を作る.`tester` に以下を委譲する: + +「次の対象に対し,**現状の挙動をそのまま固定する特性化テスト(characterization test)**を作成してください.理想の仕様ではなく,**今のコードが実際に返す挙動**を観測してアサーションに落とすこと(バグも含めて現状を固定する).境界値・主要な分岐・代表的な入出力を網羅してください.作成後にテストを実行し,**現状コードで全て緑**になることを確認してください.緑にできない(=挙動が固定できない)場合は,その旨を報告してください. + +対象: {渡された対象} +テストコマンド: {渡されたコマンド}」 + +- `tester` の報告と実テスト結果を確認する. + - **生成テストが現状コードで全て緑** → これを安全網として `git add` し,タグ **`[add]`** でコミットする(例: `[add] 〇〇モジュールに特性化テストを追加`).**この時点ではまだリファクタしない**.コミット後,この対象を「保護下になった」状態でステップ R3 へ進む. + - **緑にできなかった/挙動が固定できない** → この対象は安全に触れないため変更を破棄し,`skipped` として返す(スキップ台帳行:`- <対象> | <理由> | <日付>`). + +#### ステップ R3: リファクタ前の緑確認 + +- 検証スイート(テスト+あればビルド/型チェック/リンタ)を実行し,緑であることを確認する(ベースライン/生成した特性化テストの再確認). + +#### ステップ R4: リファクタリング実行(`refactorer` に委譲) + +`refactorer` に以下を委譲する: + +「次の対象を,挙動を変えずにリファクタリングしてください.テストを安全網とし,リファクタ後に検証スイートを再実行して**全て緑のまま**であることを必ず確認してください.緑にできない場合は変更を破棄し,その旨を報告してください. + +対象: {渡された対象} +検証コマンド: {渡されたテスト+ビルド/型チェック/リンタのコマンド}」 + +#### ステップ R5: リファクタ後の緑確認 + +- `refactorer` の報告と実際の検証スイートの結果を確認する. +- **全て緑** → ステップ R6 へ. +- **赤がある/緑にできなかった** → その変更を破棄(`git checkout -- .` または `git stash`)し,`skipped` として返す(スキップ台帳行を含める).同じ対象は再選択されない. + +#### ステップ R6: 専用ブランチへコミット + +- `git add` で当該変更をステージングする(`.env`・クレデンシャル・ビルド成果物は含めない). +- `.claude/rules/git-conventions.md` のコミット書式に従い,タグ **`[clean]`** でコミットする(例: `[clean] 重複した入力バリデーションを共通関数に集約`). +- **push・PR はしない.** `done` として返す. + +--- + +### ドキュメント同期処理 (D) + +#### ステップ D1: 実装とドキュメントの突き合わせ + +- 選んだ項目について,**実装(コード)を読み,対応する `docs/02_ENV/`・`docs/04_SPEC/`・`docs/05_TECH/` の記述**を確認する. +- 食い違いの種別を判定する: + - **追記型**:ドキュメントに記載が無いが実装にある(例:新しい環境変数・依存,新規モジュール/API,コードで確定した技術選定や制約) → ステップ D2 へ. + - **矛盾型**:ドキュメントの記述と実装の挙動が**食い違う**(例:仕様では戻り値 A だがコードは B を返す) → ステップ D3 へ. + +#### ステップ D2: 追記(自動/実装を真とする) + +- 実装を真として,該当ドキュメント(`02_ENV`/`04_SPEC`/`05_TECH` の適切な場所)に**事実を追記・補足**する.書式は `.claude/rules/markdown-style.md`,命名は `.claude/rules/docs-naming.md` に従う. +- **既存の意図的な記述は消さない**.追記・補完にとどめ,全面書き換えはしない. +- コミット(`.claude/rules/git-conventions.md` 準拠): + - 新規セクション/新規ドキュメントファイルの追加 → タグ **`[add]`**(例: `[add] 04_SPEC に〇〇APIの仕様を追記`). + - 既存記述への事実追記・補足 → タグ **`[update]`**(例: `[update] 02_ENV に新規環境変数 FOO を追記`). +- `done` として返す. + +#### ステップ D3: 矛盾は報告のみ(自動で書き換えない) + +- **ドキュメントは書き換えない**(不変条件 10).矛盾は実装バグの兆候かもしれず,無人で「実装に合わせて」塗り潰すと検出機会を失う. +- `doc-review` として返す(要確認リスト行:`- [矛盾] <箇所> | <ドキュメントの記述> | <実装の挙動> | <推定原因(仕様が古い/実装バグの疑い 等)> | <日付>`). +- **既に記録済みの矛盾は再記録しない**(既出なら「作業なし」で返す). + +--- + +### ドキュメントリファクタ処理 (DR) + +#### ステップ DR1: 改善方針の判定(意味保存が絶対条件) + +選んだ箇所について,意味・情報を保ったまま改善できるかを判定する(不変条件 11). + +- **無損失整形 or 意味を保った簡潔化/集約** → ステップ DR2 へ(自動で実施). + - 例:冗長な言い回しを短くする,表記揺れ・カタカナ/英字のゆれを統一,見出しレベル・表・コードフェンス・箇条書きの整形,重複説明を1か所に集約して他はリンク,切れたリンクや古い参照(`.claude/rules/docs-naming.md` の命名に照らす)を実在パスに修正,明らかなタイポ修正. +- **情報が落ちる削除・大規模再編**(陳腐化記述の削除,ファイル分割/統合,節の大幅な並べ替え等) → ステップ DR3 へ(自動では行わず提案のみ). + +#### ステップ DR2: ドキュメントリファクタ実施(自動) + +- 該当ドキュメントを,**意味・決定事項・設計判断・失敗パターン・具体的制約を一切落とさずに**整形・簡潔化する.`.claude/rules/markdown-style.md`(書式)・`.claude/rules/docs-naming.md`(命名)に従う. +- 迷ったら削らない(残す側に倒す).少しでも情報が落ちる懸念があれば DR3 に回す. +- `.claude/rules/git-conventions.md` のコミット書式に従い,タグ **`[clean]`** でコミットする(例: `[clean] 04_SPEC の冗長な説明を簡潔化`).`done` として返す. + +#### ステップ DR3: 削除・大規模再編は提案のみ(自動で行わない) + +- 本文は書き換えない.`doc-review` として返す(要確認リスト行:`- [提案] <箇所> | <提案内容(どう簡潔化/再編したいか)> | <自動で行わない理由(情報が落ちうる 等)> | <日付>`). +- **既出の提案は再記録しない**(既出なら「作業なし」で返す). + +--- + +### テストリファクタ処理 (T) — 前3種が尽きた後のテールフェーズ + +テストコードを整理する.**「検証スイートが緑」は安全網にならない**(削除・弱体化したテストも緑のまま)ため,ミューテーションテストの差分を安全網とする(不変条件 12). + +#### ステップ T1: ミューテーション安全網が使えるか判定 + +- 選んだテストと,それが守る**本番コード範囲**を特定する. +- その範囲に対し,**ミューテーションテストを一時的に走らせられるか**を判定する: + - 言語/スタックに対応するツール(例: JS/TS の Stryker,Python の `mutmut`/`cosmic-ray`,Java の PITest,Go の `go-mutesting` 等)を,**依存を恒久追加せず**に実行できるか(`npx`/`uvx`/`pipx run` 等のエフェメラル実行を優先). + - **走らせられる** → ステップ T2(ミューテーション保護下の全自動)へ. + - **走らせられない**(対応ツールが無い/恒久的な依存・設定の追加が必要/リポ規模的にコストが非現実的) → ステップ T3(構造改善のみ自動)へ.恒久セットアップが要る場合は**無人で導入せず**,`test-review` として「ミューテーションテスト導入の提案」を返す. +- コスト対策として,ミューテーションは**対象テストが守る本番コード範囲に限定**して実行する(リポ全体には走らせない). + +#### ステップ T2: ミューテーション保護下のテストリファクタ(全自動) + +1. **基準取得**:対象範囲でミューテーションテストを実行し,現在のテスト群が**殺せるミュータント集合**を基準(baseline)として記録する. +2. **リファクタ実施**:肥大化・重複・無意味なテストを整理する(**削除・統合・簡略化を含めてよい**). +3. **検証**: + - 通常の検証スイートが**緑**であること,かつ + - ミューテーションを再実行し,**baseline で殺せていたミュータントが1つも生存に転じていない**こと(=検証力が落ちていない). + - 両方満たす → タグ **`[clean]`** でコミット(例: `[clean] 重複した〇〇のテストを集約`).**一時的に作成/設定したミューテーション関連の成果物はコミットに含めない**(後始末する).`done` として返す. + - 1つでも生存に転じた/検証スイートが赤 → その変更を破棄(revert/stash)し,検証力が落ちる変更は行わない(必要なら `test-review` として `[提案]` を返す). + +#### ステップ T3: ミューテーションが使えない場合(構造改善のみ自動) + +- **自動でよいのは検証を減らさない構造改善のみ**:共通セットアップの extract,ヘルパー/ビルダーへの集約,テーブル駆動化(パラメタライズ),命名・AAA 構造の整理など.アサーションの数・強さを変えないこと. + - 実施後に検証スイートが緑であることを確認 → タグ **`[clean]`** でコミット.`done` として返す. +- **テストの削除・アサーション弱体化・検証が減りうる統合は自動で行わない**.`test-review` として返す(要確認リスト行:`- [提案] <対象テスト> | <提案内容> | <自動で行わない理由> | <日付>`.既出は再記録しない). + +--- + +### 項目オーケストレータの返却フォーマット(メインはこれだけで反映できる) + +作業完了後,次の構造化サマリ**だけ**をメインへ返す(詳細な思考ログは返さない=メインを薄く保つ): + +- **結果種別**:`done` / `doc-review` / `test-review` / `skipped` / `stopped` +- **コミット**:作成したコミットのタグと要約(`done` のとき,`[add]`/`[clean]`/`[update]` を列挙) +- **リスト行**:`doc-review`/`test-review`/`skipped` のとき,該当台帳へ追記すべき1行.`stopped` の場合は停止理由. +- **作業有無**:この項目で実質作業をしたか(収束カウント用の真偽) +- **一言**:メインが完了報告に使える要約(種別・対象・内容) + +--- + +## 停止条件 (Stop Conditions) + +以下のいずれかでループを止める. + +- **4種すべて(コードリファクタ/ドキュメント同期/ドキュメントリファクタ/テストリファクタ)の作業項目が尽きた** → **正常終了**. +- 項目オーケストレータが `stopped` を返した/同じ箇所で繰り返しテストが赤になる/テストコマンドが途中で失敗するようになった → **異常として停止し報告**. +- 不変条件を満たせない状況になった → **停止して報告**. +- ユーザーが割り込んで中断した → その時点までの成果(コミット済み)は `refactor/` ブランチに残る. + +## /loop との併用(推奨される使い方) + +このコマンドは単体で「作業が尽きるまで」連続実行するが,ユーザーが `/loop` でラップして起動すると,放置中の空き枠を使って自律的に再トリガーし続けられる(本コマンドの本来の目的). +`/loop` で回す場合も上記の不変条件・停止条件は同じく厳守すること.長い待機を挟む場合はキャッシュとコストを意識した間隔を選ぶ. + +### 収束条件(loop-until-dry)— 空回りでトークンを浪費しない + +`/loop` で回すと,作業が尽きた後もウェイクのたびに「対象なし → 即終了」を繰り返し,**無駄にトークンを消費**しうる.これを防ぐため,以下の収束ルールを守る. + +- 各ウェイクの発見(ステップ M1)で候補を洗い出し,**この回で実施した作業の件数**を数える(コードリファクタ+特性化テスト生成+ドキュメント追記+ドキュメントリファクタ+テストリファクタ+**新規に記録した**矛盾・提案.既出項目の再確認は作業に数えない). +- **作業件数が 0 だった回**を「空振り」とカウントする. +- **空振りが 2 回連続**したら,もう安全に拾える作業は出尽くしたとみなし,**`/loop` 自体を終了する**(次のウェイクをスケジュールしない). + - 1 回の空振りで即終了しないのは,「順序やコミットの巡り合わせで一時的に拾えなかっただけ」のケースを 1 回分許容するため. +- 1 件でも作業した回が出たら,空振りカウントは 0 にリセットする. +- 終了時は「空振りが続いたため収束した」旨を完了報告に明記する. + +## 完了報告 (Reporting) + +ループ終了時,メインがユーザーに以下を報告する. + +- 作業ブランチ名 +- 追加した特性化テストの件数(= `[add]` のテストコミット数)と対象モジュールの一覧 +- 実施したリファクタリングの件数(= `[clean]` コミット数)と概要の一覧 +- 更新したドキュメントの件数と内容(`02_ENV`/`04_SPEC`/`05_TECH` への**追記**と,意味を保った**簡潔化・整形**(`[clean]`)の箇所一覧) +- **⚠ ドキュメントと実装の矛盾**:要確認リスト `.claude/auto-refactor-doc-review.md` の `[矛盾]` の件数と概要.**実装バグの可能性があるため自動では直していない**ので,1件ずつ「仕様を直す/実装を直す」を人間が判断してほしい旨を明記する +- **簡潔化・再編の提案**:同リストの `[提案]` の件数と概要(情報が落ちうるため自動では行わなかったもの.採否は人間が判断) +- 整理したテストの件数と概要(`[clean]`).ミューテーションテストを**安全網に使えたか/使えず構造改善のみに留めたか**を明記する +- **テストリファクタの提案**:`.claude/auto-refactor-test-review.md` の `[提案]` の件数と概要(削除・統合・弱体化など自動では行わなかったもの.ミューテーション導入の提案を含む場合はそれも) +- スキップした対象とその理由(挙動が固定できずテスト生成に失敗/検証スイートが緑にできなかった 等).詳細はスキップ台帳 `.claude/auto-refactor-skip.md` に追記済み +- ⚠ 自動生成した特性化テストは**現状の挙動(バグを含む)を固定**したもの.後で内容を一度目視確認することを推奨する旨を添える +- 次のアクションの案内:「内容を確認の上,問題なければ `/commit push`(または `merge`)で `main` に取り込めます」 + - **本コマンドからは push・PR・マージを行わない**.取り込みは必ずユーザーの明示指示(`/commit push` 等)で行う. diff --git a/.claude/skills/commit/SKILL.md b/.claude/skills/commit/SKILL.md new file mode 100644 index 0000000..aa1eb34 --- /dev/null +++ b/.claude/skills/commit/SKILL.md @@ -0,0 +1,48 @@ +--- +name: commit +model: inherit +description: "Git 規約(.claude/rules/git-conventions.md)に従い,変更内容の確認 → コミットを行う." +argument-hint: "<コミット内容の補足(省略可)>" +disable-model-invocation: true +--- + +あなたは Git 運用の司令塔です.コミット作業の実行は **ops-runner エージェント**(軽量モデル)に委譲し,あなた自身は**判断**(意図・docs 更新の要否)・確認の仲介・結果の報告だけを行います(使用量節約のため,diff 読みや git 操作を自分では行わない.逆に価値判断は ops-runner にさせない). + +## ステップ 1: 意図サマリと docs 要否の判断 (Intent & Docs Decision) + +このセッションで行った作業を把握している場合は,以下を作る(**判断はここで行う**): + +- **意図サマリ**(1〜3 行): 何をなぜ変更したか(コミットメッセージの材料).重要な変更なら `.claude/rules/git-conventions.md` の書式でメッセージ全文を確定させてもよい +- **docs 更新の要否**(3 択): 変更が API・データ構造・環境設定・開発計画の進捗・規約やルールに関わるかを,セッションの知識で判断する + - **更新済み**: 今回の変更に `docs/`・CLAUDE.md「開発進捗」・`docs/PROGRESS.md` の更新も含めてある + - **不要**: 該当しない + - **要**: 委譲する前に必要な更新を行う(内容に迷う場合はユーザーに確認する).docs を同じコミットに含めるため +- **ブランチ名**: 現在 `main` にいる可能性がある場合は,git-conventions の命名規則(プレフィックス+英単語 2〜4 語)でブランチ名を決めて渡す(ops-runner に命名させない) +- `.claude/commit-context.md` がある場合(`/implement` 経由)は,そこに記録された判断を正とし,改めて判断しない + +セッションを把握していない場合(起動直後の `/commit` 等)は「意図情報なし・要否は事後判断」とする. + +## ステップ 2: 委譲 (Delegate) + +ops-runner エージェントを起動し,プロンプトに以下を含める: + +- 手順書: `.claude/skills/commit/reference.md` の「コミット実行手順」と `.claude/rules/git-conventions.md` を読んで従うこと +- ステップ 1 の意図サマリ(またはメッセージ全文).意図情報なしの場合はその旨 +- $ARGUMENTS(`push` / `merge` キーワードの有無を明示する) +- 確認が必要な事態では停止して報告すること + +## ステップ 3: 結果報告 (Report) + +- エージェントの報告(ブランチ・コミット SHA・メッセージ・PR URL 等)をユーザーに簡潔に伝える +- **意図情報なしで委譲した場合**は,エージェントの「事実報告」(変更ファイルの分類)を見て docs 更新の要否を事後判断し,必要と思われれば「⚠ docs/・CLAUDE.md の開発進捗・`docs/PROGRESS.md` の更新が必要かもしれません」とユーザーに伝える(追加コミットで対応してよい) +- エージェントが返した警告(意図と diff の食い違い等)はそのまま表示し,判断はユーザーに委ねる +- エージェントが「確認事項」で停止した場合は,ユーザーに確認し,回答を「承認済み」として添えて再委譲する + - ただし停止理由が**分類器ブロック**(auto mode のセキュリティ分類器により,コマンドが実行される前に遮断された)の場合は,再委譲しても同じ地点で止まるため**再委譲しない**.`.claude/settings.json` の `permissions.allow` の確認・追加とセッション再起動をユーザーに案内して終了する(詳細は `.claude/skills/commit/reference.md` の「gh コマンドが実行される前にブロックされた場合」を参照) +- **ops-runner の起動(Agent ツール呼び出し)自体が分類器にブロックされた場合**は,委譲そのものが成立しない(プロンプトの文言を変えても通らない.`.claude/settings.json` の permissions 変更を含む変更のコミットを委譲したところ,Agent 起動が 2 回ブロックされた実測例がある).この場合は状況をユーザーに伝え,**ユーザーの承認を得た上で**次のいずれかで進める + - 司令塔(メインループ)が直接コミット操作を行う(メインループからは同じ操作が通ることを確認済み).これは冒頭の「git 操作を自分では行わない」原則の**例外**とする + - ユーザーが `! git ...` で手動コミットする + +## 注意事項 (Notes) + +- 変更がない場合(作業ツリーがクリーン)は空コミットしない(エージェントの報告どおり伝えて終了する) +- 本スキルはユーザーが明示的に指示した時のみ実行する(CLAUDE.md「Git 運用」) diff --git a/.claude/skills/commit/reference.md b/.claude/skills/commit/reference.md new file mode 100644 index 0000000..19be2d8 --- /dev/null +++ b/.claude/skills/commit/reference.md @@ -0,0 +1,172 @@ +# Git 開発フロー・トラブルシューティング (Git Workflow Reference) + +`/commit` の実行フェーズ(ops-runner が担当)で参照する実行手順・push・PR・マージの詳細手順と,問題発生時の対処手順. +ブランチ命名・コミットメッセージ等の規約は `.claude/rules/git-conventions.md` に従う. + +## コミット実行手順 (Commit Execution Flow) + +`/commit` の司令塔(メインループ)から委譲された ops-runner が実行する手順.ユーザーへの確認は不要,全ステップを一気に実行する(停止条件に該当した場合を除く). + +### ステップ 1: 状態確認 + +- `git branch --show-current` で現在のブランチを確認 +- `git status` で変更ファイルの一覧を確認 +- `git diff` および `git diff --cached` で変更内容を確認 +- 変更がない場合(クリーン)は空コミットせず,その旨を報告して終了する + +**main ブランチにいる場合は自動でブランチを作成する:** + +1. 司令塔からブランチ名が渡されていればそれを使う.渡されていなければ,変更内容から git-conventions のブランチ命名規則に従い適切なプレフィックスと英単語 2〜4 語のブランチ名を決め,決めた名前を報告に含める +2. `git checkout -b {ブランチ名}` で作業ブランチを作成する + +### ステップ 2: ドキュメント関連の事実収集 + +docs 更新の要否の**判断はしない**(司令塔が行う).以下の事実を最終報告の「事実報告」に含める: + +- `.claude/commit-context.md` の有無と,あれば記録されたフラグ(`docs_updated` / `claude_md_updated` / `progress_md_updated`) +- 変更ファイルの分類: `docs/` 配下・`CLAUDE.md`・`docs/PROGRESS.md` のそれぞれを含むか,およびそれ以外の変更ファイル一覧 + +### ステップ 3: コミットメッセージの生成 + +上位から渡された意図サマリ・引数と変更内容をもとに,git-conventions の「コミットメッセージ」書式で生成する.上位がメッセージ全文を確定して渡した場合はそのまま使う.意図サマリと実際の diff に食い違いがある場合は,diff を正としつつ食い違いを「警告」に含める. + +### ステップ 4: コミット + +1. `git add` で関連ファイルをステージングする(CLAUDE.md / `docs/PROGRESS.md` の変更がある場合はそれも含める.`.env` やクレデンシャルファイルはステージングしない) +2. `git commit -m "{コミットメッセージ}"` でコミットする +3. `.claude/commit-context.md` が存在する場合は削除する + +### ステップ 5: プッシュ・PR・マージ + +上位から渡された引数に以下のキーワードが含まれる場合のみ実行する(いずれも無ければスキップ). + +| キーワード | 実行する操作 | +| --- | --- | +| `push` | プッシュ・PR 作成 | +| `merge` | プッシュ・PR 作成・マージ・プル(`push` を含む全操作) | + +手順は後述「開発フロー」に従う(push → PR 作成(既存 PR があれば URL の確認のみ)→ `merge` の場合はマージの実行 → ローカル環境のクリーンアップ).コンフリクト等の問題が起きた場合は「トラブルシューティング」を参照し,意味的な衝突の解消や force push を伴う操作は停止して報告する. + +## 開発フロー (Development Workflow) + +作業ブランチをリモートへ送り,`main` にマージされるまでの手順. + +### リモートへのプッシュ + +作業ブランチをリモートリポジトリへ送信する. + +```bash +git push origin feature/new-function +``` + +### プルリクエスト (PR) の作成 + +GitHub CLI (`gh`) を使用して PR を作成する. + +```bash +gh pr create --title "[add] 新機能を実装" --body "概要" +``` + +- `--title` はコミットメッセージと同じ書式(`[タグ] 内容`)とする. +- 必要に応じて `--reviewer` でレビュワーを指定する. + +### レビューと修正の対応 + +- レビュワーのコメントを確認し,修正が必要な場合はローカルで修正・コミット・プッシュを繰り返す. +- 全ての指摘に対応し,レビュワーから Approve をもらう. + +### マージの実行 + +- マージ方式は「Create a merge commit」を使用する(作業ブランチの全コミット履歴が `main` に残る). + - 「Squash and merge」「Rebase and merge」は使用しない. +- **先に `main` へ移ってから**マージする.PR ブランチ上で `--delete-branch` を実行すると gh が暗黙に `main` への切替と pull を行い,ブランチ削除より前に pull が走る(post-merge hook 等の後処理が削除前の状態を見てしまう).手順を明示的にして順序を固定する. +- ブランチ保護・必須レビュー・必須チェック未完了でマージが拒否された場合も,`gh pr merge --admin` で迂回しない(状況を報告して停止する). + +```bash +git checkout main +gh pr merge feature/new-function --merge --delete-branch # リモート・ローカルのブランチも削除される +``` + +### ローカル環境のクリーンアップ + +マージ完了後はローカルの `main` を最新化する(ブランチ削除の後に pull する順序を守る). + +```bash +git pull origin main +git branch -d feature/new-function # gh が削除済みなら不要(残っていた場合のみ) +``` + +## トラブルシューティング (Troubleshooting) + +### コンフリクトが発生した場合 + +他メンバーの変更と競合した場合の対処手順. + +**main の取り込み** + +作業ブランチに `main` の最新内容をリベースして競合箇所を洗い出す.`merge` ではなく `rebase` を使うことで,履歴が線形に保たれレビューしやすくなる. + +```bash +git checkout main +git pull origin main +git checkout feature/new-function +git rebase main +``` + +**競合の解消** + +エディタ上で `<<<<<<<`,`=======`,`>>>>>>>` で囲まれた箇所を手動で修正する.修正後,以下のコマンドでリベースを続行する. + +```bash +git add [修正したファイル] +git rebase --continue +``` + +**AI 補助によるコンフリクト解消** + +Claude Code にコンフリクトを解消させる場合は,衝突の種類に応じて進め方を変える. + +- **自明・テキスト的な衝突**(import の競合,隣接行の変更など,双方の意図が両立するもの) + - AI が解消してよい.人間は解消結果の要点を確認するだけでよい. +- **意味的な衝突**(両側がロジックを非互換に変更しているもの) + - AI に単一の解決結果を提示させて追認してはならない.AI は以下を提示し,人間が選択する. + - 両側が各々何をしたかったか(自分の Issue と,相手のコミット・Issue の目的) + - 解決候補を 2 つ以上と,各案を採った場合の帰結 + - 影響範囲と壊れうる箇所 + +解消後は衝突の種類によらず,必ずテスト(`/implement` の Tester)と作者による Phase 1 動作確認を行う.人間の判断は「どの意図が正しいか」を,テストは「実際に動くか」を担保するものであり,どちらか一方を省略してはならない. + +**プッシュ** + +全ての競合が解消されたらプッシュする.リベース後は履歴が書き換わるため通常の push は拒否されるが,他メンバーが push 済みの履歴を誤って上書きしないよう,`-f` ではなく `--force-with-lease` を用いる(自分が最後に fetch した時点のリモート状態と一致する場合のみ上書きし,ズレていれば拒否される). + +```bash +git push --force-with-lease origin feature/new-function +``` + +### gh コマンドが実行される前にブロックされた場合 + +`gh pr merge` 等が実行される前に遮断され,`gh` の出力が返ってこない場合は,auto mode のセキュリティ分類器によるブロックである.リポジトリ側の制約(ブランチ保護・必須レビュー・権限不足)とは原因が異なるため,混同して報告しない. + +- **見分け方**: リポジトリ側の制約ならコマンドは実行され `gh` がエラーメッセージを返す.分類器ブロックではコマンド自体が実行されない. +- **対処**: `.claude/settings.json` の `permissions.allow` に `Bash(gh pr merge:*)` があるか確認する.無ければ**ユーザーが追加**し,セッションを再起動する(permissions の変更は再起動後に反映される.現状は Claude 自身が `settings.json` の permissions を編集することも分類器にブロックされるため,自分では追加できない). + - 追加の経路は 2 つある: `settings.json` を手で編集する,または `/permissions` ダイアログから allow ルールを追加する. + - auto mode では `Bash(gh *)` のような広い allow ルールは分類器に回されるため効かない.`Bash(gh pr merge:*)` のように**操作を特定した狭いルール**にする必要がある. + - ※ `~/.claude/settings.json`(ユーザー設定)に `autoMode.classifyAllShell: true` が設定されている場合は,狭い allow ルールも含めすべてのシェルコマンドが分類器に回るため,この対処自体が効かない.allow ルールを追加しても通らないときは,まず該当設定の有無を確認する. +- **やってはいけないこと**: 別コマンドでの迂回(`main` への直接 push 等)や,設定を緩めての強行.ブロックされた事実と対処法をそのままユーザーに伝えて停止する. + +### 間違えて main にコミットしてしまった場合 + +**まだプッシュしていない場合** + +コミットを取り消し,変更内容を保持したまま新しいブランチへ移動する. + +```bash +git reset --soft HEAD^ +git checkout -b feature/new-function +git commit -m "[add] ..." +``` + +**すでにプッシュしてしまった場合** + +`main` への force push はチーム全員の履歴を壊す危険があるため,自分では対処しない.直ちにチームメンバーに報告し,全員で対応方針を決める. diff --git a/.claude/skills/consult/SKILL.md b/.claude/skills/consult/SKILL.md new file mode 100644 index 0000000..3e75619 --- /dev/null +++ b/.claude/skills/consult/SKILL.md @@ -0,0 +1,66 @@ +--- +name: consult +model: inherit +description: "重要な価値判断・設計判断を最上位モデル(Fable)に相談する.メインのモデルは切り替えず,advisor エージェント(Fable)を新規の小さいコンテキストで起動するため,プロンプトキャッシュを壊さず低コストで判断力だけを借りられる.Claude が自発的に使ってよいのは (a) 不可逆な設計判断(アーキテクチャ・スキーマ・外部サービス・公開 API の形)の直前,(b) 同じ失敗が 2 回続き次の方針に自信が無いとき,(c) ユーザーの明示指示,のいずれかに限る.1 タスクあたり 2 回まで.ルールで既に決まっている事項・すぐ決まる二択・単なる調査には使わない." +argument-hint: "<相談したい論点(省略時はセッションの文脈から構成する)>" +--- + +あなたは相談の司令塔です.価値判断そのものは **advisor エージェント**(Fable)に委ね,あなたは**ブリーフの作成**と**結果の中継**を行います.メインループのモデルは切り替えない(会話全体の再読込が発生するため).advisor は新規の小さいコンテキストで動くので,Fable の単価でもコストはブリーフ+参照ファイル+思考分だけに収まる. + +## ステップ 1: ブリーフ作成 (Brief) + +**ここが品質の要**.advisor はこの会話を一切知らないため,$ARGUMENTS とセッションの文脈から**自己完結のブリーフ**を書く: + +- **論点**: 何を判断してほしいか(1 文で) +- **背景・制約**: プロジェクトの状況と,これまでの経緯のうち判断に関わる事実(重要な前提を省略しない) +- **選択肢**: 検討済みの選択肢と各々の長所・短所.**自分(メイン)の暫定意見と理由**も添える +- **判断基準**: 何を重視するか(保守性・コスト・納期・学習目的 等) +- **参照ファイル**: 相対パスで列挙する(advisor が自分で読む.長文の貼り込みはしない) + +## ステップ 2: 委譲 (Delegate) + +advisor エージェントを起動し,ブリーフを渡す.出力書式(結論・根拠・リスク・盲点・確信度)はエージェント定義にあるため指定不要. + +- advisor が「前提の確認」を返した場合は,前提を補足して再委譲する +- 回答がブリーフの前提を誤解している場合も同様に補足して再委譲する + +## ステップ 3: 報告 (Report) + +- advisor の回答(結論・根拠・リスク・盲点・確信度)を**要約せずそのまま**ユーザーに提示する +- 自分の暫定意見と advisor の結論が食い違う場合は,その旨と自分の見解を明示する(隠さない) +- 最終決定はユーザーが行う.advisor の結論を自動で実行に移さない + +## 自発的に呼ぶ基準 (Auto-Invocation Criteria) + +Claude はユーザー指示が無くても,以下の**いずれか**に該当するときは本スキルを自発的に使ってよい.組み込みの `/advisor`(会話全体を上位モデルに読ませる)ではなく本スキルを使うのは,ブリーフ+参照ファイル分のコンテキストだけで済ませてコストを抑えるため. + +**呼ぶ** + +| トリガー | 判定の目安 | +| --- | --- | +| 不可逆な設計判断 | アーキテクチャ・DB スキーマ・外部サービス選定・公開 API の形など,後から変えるとコードの広範囲に波及するものを決める直前 | +| 行き詰まり | 同じ失敗(テスト失敗・エラー)が **2 回**続き,3 回目の方針に自信が無い | +| ユーザー指示 | 「advisor に聞いて」「相談して」等の明示 | + +**呼ばない** + +- `.claude/rules/` や docs で既に決まっている事項(ルールに従えばよい) +- 数分で決まる二択,命名,小さなリファクタ +- 単なる調査・情報収集(advisor は判断役であって検索役ではない.調査は Explore 等を使う) +- 同じ論点で既に advisor の回答を得ている(再相談は前提が変わったときだけ) +- 大きなタスクの完了前チェック(人間の動作確認で代替する.自動では呼ばない) + +## 暴走の歯止め (Cost Guardrails) + +Fable の呼び出しは相対的に高コストのため,自発的に呼ぶときは以下を守る. + +- **上限**: 1 タスク(ユーザーからの 1 回の依頼)につき**自発的な呼び出しは 2 回まで**.3 回目が必要だと感じたら,呼ばずにユーザーへ「advisor に相談してよいか」を確認する +- **再委譲**: 前提の補足によるやり直しは **1 回まで**.それでも噛み合わなければ得られた回答をそのまま報告し,判断を人間に戻す +- **参照ファイル**: ブリーフに列挙するのは **5 ファイル程度**を目安に絞る(advisor の読み込み分がコストの主因) +- **可視化**: 呼ぶ前に必ず「advisor に相談します: <論点 1 行>」と表示する(黙って呼ばない) +- **無人ループ**: `/auto-refactor`・`/auto-audit` の中から呼ぶ場合は,各 skill に定める上限(1 ループ実行あたり 3 回・1 項目 1 回)に従う + +## 注意事項 (Notes) + +- ブリーフに書かれていない事情は判断に反映されない.「advisor は会話を知らない」前提で書くこと +- 軽い相談(すぐ決められる二択等)に使う必要はない.アーキテクチャ選定・不可逆な決定・トレードオフが深い論点に使う diff --git a/.claude/skills/deps-update/SKILL.md b/.claude/skills/deps-update/SKILL.md new file mode 100644 index 0000000..7354ac4 --- /dev/null +++ b/.claude/skills/deps-update/SKILL.md @@ -0,0 +1,104 @@ +--- +name: deps-update +model: inherit +description: "Dependabot が作成した依存更新 PR(security updates / version updates)と,PR が付かない Dependabot alerts をまとめて処理する.メジャー更新でない・競合なし・CI 緑(またはローカル検証緑)のゲートを満たす PR だけを gh pr merge で main に取り込み,メジャー更新・CI 赤・修正版なしの alert 等は影響分析と推奨対応を添えて台帳と PR コメントに報告する.ユーザーが明示的に起動した時のみ実行する(/loop でのラップ可)." +argument-hint: "[merge|report(省略時 merge)] [PR 番号…(省略時は全 Dependabot PR)]" +--- + +あなたは**依存更新(Dependabot)処理の司令塔**です. +`/setup` で有効化した Dependabot alerts / security updates と,`.github/dependabot.yml` による version updates が作る PR・alert を,「安全なものは取り込み,判断が要るものは材料を揃えて報告する」ために起動されました. +実際の gh 操作・検証・マージ・台帳更新は **ops-runner エージェント**(軽量モデル)に,個別の影響分析は **deps-analyst エージェント** に委譲し,あなたは振り分けと完了報告に徹します(使用量節約のため,収集・検証・投稿を自分では行わない). + +## 不変条件 (Invariants) + +全文は本スキルの `reference.md`(`.claude/skills/deps-update/reference.md`)冒頭の「不変条件」にある.**委譲する全エージェントのプロンプトに「reference.md の不変条件を読み厳守すること」を必ず含める**.あなた自身が振り分けで守るべき要点: + +- 触るのは Dependabot(author `app/dependabot`)の open PR だけ +- **メジャー更新(`0.x` マイナー・`unknown` 含む)は自動マージしない**.検証(CI 緑またはローカル検証)無しにマージしない +- Dependabot PR を close しない.`@dependabot ignore` は人間の決定(推奨として提示するだけ) +- ユーザーが起動した時だけ動く(Claude が自発的に本スキルを呼んではならない.`/loop /deps-update` でのラップはユーザー起動とみなす) + +## 引数 (Arguments) + +`$ARGUMENTS` を次のように解釈する: + +- 先頭トークンが `report` なら**報告のみモード**(マージも PR コメントもしない.分析結果を完了報告と台帳に書くだけ).`merge` または省略なら**通常モード**(ゲートを満たすものをマージし,残りを報告する) +- 残りのトークンに `#12` / `12` 形式の番号があれば,その PR だけを対象にする(省略時は open な Dependabot PR すべて) + +## フェーズ 1: 収集・分類 (Collect & Classify) + +ops-runner エージェントを起動し,プロンプトに以下を含める: + +- 手順書: `reference.md` の「不変条件」と「実行手順 A: セットアップと収集」を読んで従うこと +- 対象 PR 番号の指定(あれば) + +返ってくるもの: 前提状態(Dependabot 設定・CI の有無・検証スイート・開始ブランチ)・分類付き PR 一覧(規模・スコープ・種別・状態・CI 状況)・alert 一覧・台帳の既出/スキップ情報. + +- 作業ツリーが汚れている・`gh` 未認証・リモートが GitHub でない等の停止報告が来たら,処理を開始せずユーザーに伝えて終了する +- Dependabot 設定が無効・`dependabot.yml` 不存在なら処理は続け,完了報告で `/setup` の「GitHub リポジトリのセキュリティ設定」「依存バージョン更新の設定」を案内する + +## フェーズ 2: 振り分け (Route) + +フェーズ 1 の結果を判定表で振り分ける.`deps-update-skip.md` 該当は除外(件数だけ完了報告へ),台帳既出の報告項目は再分析しない. + +| 更新規模 | Tier A(CI 緑 / ローカル検証緑) | Tier B(ビルド・型チェックのみ) | Tier C(検証不能) | +| --- | --- | --- | --- | +| `patch` | マージ | マージ | 報告 | +| `minor` | マージ | 報告 | 報告 | +| `major` / `0.x` マイナー / `unknown` | 報告(分析付き) | 報告(分析付き) | 報告(分析付き) | + +- **マージ候補**(判定「マージ」の見込み)→ フェーズ 3.security update → パッチ → マイナー の順に並べる(security update もゲートは緩めない.グループ PR は全メンバーの最大規模で判定する) +- **分析行き**(「報告(分析付き)」・CI 赤・依存以外のファイル変更を含む PR)と **PR が付かない alert** → フェーズ 4 +- **競合(`CONFLICTING`)・`BEHIND`(CI あり)** → フェーズ 3 の委譲に「`@dependabot rebase` をコメントして今回は見送り」として含める(台帳には書かず,完了報告で「次回再試行」として伝える) +- **report モード** → フェーズ 3 を実行せず,マージ候補は「マージ可(未実行)」として完了報告と台帳に回す + +## フェーズ 3: 検証・マージ (Verify & Merge) + +マージ候補と rebase 依頼をまとめて 1 体の ops-runner エージェントに委譲する.プロンプトに含める: + +- 手順書: `reference.md` の「不変条件」と「実行手順 B: 検証とマージ」を読んで従うこと +- 対象 PR の一覧(処理順・各 PR の検証方法: CI 待ち / ローカル検証 / rebase 依頼のみ) +- **各 PR のマージ条件**(判定表から機械的に導いて明示する.ops-runner に判定表を解釈させない): `patch` → 到達 Tier が A または B なら可,`minor` → Tier A のみ可.Tier の定義: A=CI 全緑またはローカル検証(テスト含む)全緑,B=テストが無い/特定できないが依存インストールとビルド/型チェックは通る,C=検証不能 + +検証が赤・Tier 不足・検証不能になった PR はマージされずに戻ってくるので,フェーズ 4 の分析対象に加える. + +## フェーズ 4: 影響分析 (Analyze) + +分析行きの各項目について **deps-analyst エージェントを 1 件 1 体**で起動する(項目は互いに独立なので並列に起動してよい).プロンプトに含める: + +- 対象 PR / alert の収集データ(フェーズ 1・3 の結果から該当分.CI 赤の PR は失敗したチェックの要約も) +- 手順書: `reference.md` の「不変条件」「メジャー更新の影響分析」「PR コメントの書式」を読んで従うこと + +返ってきた分析(破壊的変更・影響箇所・推奨)に不足があれば追加調査を指示する. + +## フェーズ 5: 記録・コメント (Record) + +ops-runner エージェントを起動し,以下を委譲する: + +- `reference.md` の「台帳の書式」に従い,フェーズ 4 の分析結果を `deps-update-report.md` に追記する(既出は再記録しない.フェーズ 3 のマージ結果は実行手順 B 内で `deps-update-merged.md` に追記済み) +- 通常モードでは,分析済み PR へ「PR コメントの書式」でコメントを投稿する(`` マーカーが既にある PR には再投稿しない).report モードでは投稿しない + +## フェーズ 6: 完了報告 (Report) + +ユーザーに以下を報告する(台帳を根拠にする): + +- モード(merge / report)と対象 PR 数,Dependabot 設定の状態(無効なら `/setup` の該当手順を案内) +- **マージした PR**: 番号・パッケージと版・種別(security / version)・規模・検証 Tier(CI / ローカル / ビルドのみ).**人間が行うべき動作確認**があれば添える(例: UI ライブラリの更新は実機で表示確認) +- **⚠ 判断待ち(要人間対応)**: メジャー更新(影響分析・推奨対応付き)/CI 赤/修正版なし alert/Dependabot が PR を作れなかった alert.security 由来のものは **⚠ 優先** を付けて上に並べる +- 見送り: 競合で `@dependabot rebase` を依頼したもの,CI 待ちのもの(次回再試行),`deps-update-skip.md` によるスキップ件数 +- 次のアクション: 判断待ちの各項目について「PR をそのままマージ」「`/implement` で追従してからマージ」「`@dependabot ignore this major version` で見送り(人間がコメント)」のどれを推奨するか +- 後片付け: フェーズ 3 の報告から,開始ブランチへの復帰と作業ツリーがクリーンであることを確認して結果を含める + +team モードでは,マージした PR の一覧をチームに周知するよう促す(全員が `git pull` する.GUIDE_03). + +## /loop との併用 (Use with /loop) + +Dependabot は週 1 回(`dependabot.yml` の `interval`)まとめて PR を作るため,単発実行で十分なことが多い.放置中に取り込みたい場合は `/loop 1d /deps-update` のように日次でラップしてよい.対象が無い回は収集だけで即終了するので空回りのコストは小さいが,2 回連続で「マージ 0・新規報告 0」なら `/loop` を終了してよい. + +## 停止条件 (Stop Conditions) + +- 作業ツリーがクリーンでない/`gh` 未認証/リモートが GitHub でない → フェーズ 1 の停止報告を受けて,開始せずに報告 +- ローカル検証中にコンフリクト・環境エラーで検証が成立しない → その PR は Tier C として分析行きに回し,他の PR の処理は続ける +- `gh pr merge` が**リポジトリ側の制約**で失敗(ブランチ保護・必須レビュー・必須チェック未完了・権限不足) → その PR は報告に回し,失敗理由を伝える.team モードで「他メンバー Approve 必須」のブランチ保護が効いている場合は,本スキルはマージ可と判定した PR の一覧を提示するにとどめる +- `gh pr merge` が **auto mode の分類器にブロック**された(コマンドが実行される前に遮断され,`gh` の出力が返らない) → **環境側の制約であって,リポジトリ設定の問題ではない**(ブランチ保護と混同して報告しない).`.claude/settings.json` の `permissions.allow` に `Bash(gh pr merge:*)` があるか確認し,無ければ「ユーザーが手で追加してセッションを再起動する必要がある」と報告する(Claude 自身は permissions を編集できず,設定変更はセッション再起動後に反映される).該当 PR は「マージ可と判定したが未実行」として報告し,`main` への直接 push 等でマージを迂回しない + - ※ allow ルールの追加は `settings.json` の手編集のほか `/permissions` ダイアログからも行えるが,`~/.claude/settings.json` に `autoMode.classifyAllShell: true` があると狭い allow ルールも分類器に回るためこの対処は効かない(追加しても通らないときは該当設定の有無を確認する) diff --git a/.claude/skills/deps-update/reference.md b/.claude/skills/deps-update/reference.md new file mode 100644 index 0000000..715cc48 --- /dev/null +++ b/.claude/skills/deps-update/reference.md @@ -0,0 +1,226 @@ +# deps-update リファレンス (deps-update Reference) + +`/deps-update`(`SKILL.md`)から参照される,`gh` コマンド・GraphQL クエリ・分類規則・ローカル検証手順・台帳と PR コメントの書式.`{owner}/{repo}` は `gh` がカレントリポジトリから自動補完する. + +## 不変条件 (Invariants) + +`/deps-update` に関わる全エージェント(司令塔・ops-runner・deps-analyst)が厳守する. + +1. **触るのは Dependabot が作った PR だけ**: author が `app/dependabot` の open PR のみを対象にする.人間や他のボットの PR には一切操作しない +2. **`main` への取り込みは `gh pr merge --merge` のみ**: ローカルで `main` に直接コミット・push しない.依存ファイル(マニフェスト・ロックファイル)を自分で書き換えて別 PR を作ることもしない(競合やビルド失敗の解消は `@dependabot rebase` / `@dependabot recreate` コメントで Dependabot にやり直させる) +3. **ゲートを満たした PR だけをマージする**(SKILL.md「判定表」).**メジャー更新は自動マージしない**.検証(CI 緑またはローカル検証)無しにマージしない +4. **Dependabot PR を close しない**: 不要な PR の整理(supersede・`@dependabot ignore`)は Dependabot と人間に任せる.`@dependabot ignore ...` のコメントも自分では投稿しない(無視は人間の決定) +5. **作業ツリーを汚さない**: 開始時に作業ツリーがクリーンであることを確認し,ローカル検証で作ったブランチ・マージ状態は必ず破棄し,終了時に開始時のブランチへ戻す +6. **二重処理しない**: 台帳を読み,既に報告済み・スキップ指定の項目は再報告しない.PR コメントも同じ PR に同じ趣旨で 2 度投稿しない +7. **ユーザーが起動した時だけ動く**: Claude が自発的に本スキルを呼んではならない(`/loop /deps-update` でのラップはユーザー起動とみなす) + +## 実行手順 A: セットアップと収集 (Execution A: Setup & Collect) + +司令塔(`/deps-update` のメインループ)から委譲された ops-runner が実行する.振り分けの判断はせず,結果を構造化して返す. + +1. **前提の確認**: 「前提の確認」のコマンド一式を実行する.作業ツリーが汚れている・`gh` 未認証・リモートが GitHub でない場合は停止して報告する(stash もしない).開始時のブランチ名を控える +2. **検証スイートの特定**: `docs/02_ENV/ENV_04_開発コマンド.md` があればそれを唯一の参照先とする.無ければ構成ファイル(`package.json`,`pubspec.yaml`,`Cargo.toml`,`go.mod`,`pyproject.toml` 等)から依存インストール・テスト・(あれば)ビルド/型チェック/リンタのコマンドを特定する.推測で試さず,特定できなければ「ローカル検証不可」として返す +3. **台帳の読み込み**: `.claude/deps-update-merged.md`・`.claude/deps-update-report.md`・`.claude/deps-update-skip.md`(無ければ空扱い.`.gitignore` で追跡除外されていなければ追記する) +4. **収集**: 「PR の収集」「alert の収集」に従い取得する +5. **分類**: 各 PR を「更新規模の分類」に従い,規模(`patch` / `minor` / `major` / `unknown`)・依存スコープ(本番 / 開発)・種別(security / version)・`mergeable` / `mergeStateStatus`・CI 状況で整理する +6. **返すもの**: 前提状態(Dependabot 設定・CI の有無・検証スイート・開始ブランチ)・分類付き PR 一覧・alert 一覧(紐づく PR・エラー含む)・台帳の既出項目・skip 該当項目 + +## 実行手順 B: 検証とマージ (Execution B: Verify & Merge) + +司令塔がマージ候補と判定した PR を,指定された順に 1 件ずつ処理する ops-runner の手順. + +1. **rebase 依頼のみ指定の PR**: `@dependabot rebase` をコメントして次へ進む(マージしない) +2. **状態の取り直し**: マージのたびに Dependabot が残りの PR を自動 rebase するため,各 PR の処理前に `mergeable`・CI 状況を取り直す.`CONFLICTING` / `BEHIND` になっていれば rebase 依頼に切り替える +3. **検証**: CI があれば「CI の完了待ち」,無ければ「ローカル検証」を実行し,到達 Tier(A / B / C.「ローカル検証」の「検証結果の解釈」に従う)を判定する +4. **到達 Tier が司令塔の指定したマージ条件を満たす場合**: 「マージ」の手順どおり `gh pr merge <番号> --merge --delete-branch` でマージし,`deps-update-merged.md` へ「台帳の書式」で追記し,ローカル `main` を pull する +5. **満たさない場合(赤・Tier 不足・検証不能)**: マージせず,到達 Tier と失敗したコマンド/チェックの要約を添えて「分析行き」として結果に含める.マージ条件が渡されていない PR はマージせず確認事項として返す +6. **後片付け**: 全件終了後,ローカル検証で作ったブランチ・マージ状態を破棄して開始時のブランチへ戻り,`git status --short` が空であることを確認して結果(マージ済み・rebase 依頼済み・分析行き)を返す + +## 前提の確認 (Prerequisites) + +```bash +gh auth status +gh repo view --json nameWithOwner -q .nameWithOwner +git status --short # 空であること +git branch --show-current # 開始時のブランチを控える +gh api repos/{owner}/{repo}/vulnerability-alerts # 204 なら Dependabot alerts 有効(404 なら無効) +gh api repos/{owner}/{repo}/automated-security-fixes # {"enabled":true,...} なら security updates 有効 +test -f .github/dependabot.yml && echo "dependabot.yml: あり" || echo "dependabot.yml: なし" +ls .github/workflows/*.yml 2>/dev/null # CI の有無 +``` + +## PR の収集 (Collect PRs) + +```bash +gh pr list --author app/dependabot --state open --limit 100 \ + --json number,title,body,url,headRefName,baseRefName,labels,mergeable,mergeStateStatus,statusCheckRollup,createdAt,isDraft +``` + +- 個別の詳細: `gh pr view <番号> --json number,title,body,mergeable,mergeStateStatus,statusCheckRollup,files,commits` +- チェック状況: `gh pr checks <番号>`(CI が無いリポジトリでは何も返らない) +- 変更ファイル: `gh pr diff <番号> --name-only`(マニフェストとロックファイルだけのはず.それ以外が含まれていたら報告に回す) +- 本スキルが既にコメント済みか: `gh pr view <番号> --json comments -q '.comments[].body' | grep -c ""` + +## alert の収集 (Collect Alerts) + +GraphQL で「紐づく PR」と「PR を作れなかった理由」まで一度に取る(REST の `/deps-update/alerts` には PR との紐づけが無い). + +```bash +OWNER=$(gh repo view --json owner -q .owner.login) +REPO=$(gh repo view --json name -q .name) +gh api graphql -f owner="$OWNER" -f repo="$REPO" -f query=' +query($owner: String!, $repo: String!) { + repository(owner: $owner, name: $repo) { + vulnerabilityAlerts(states: OPEN, first: 100) { + nodes { + number + vulnerableManifestPath + vulnerableRequirements + securityVulnerability { + package { name ecosystem } + severity + vulnerableVersionRange + firstPatchedVersion { identifier } + } + securityAdvisory { ghsaId summary permalink } + dependabotUpdate { + pullRequest { number url } + error { title body errorType } + } + } + } + } +}' +``` + +- `dependabotUpdate.pullRequest` があれば,その PR は **security update** +- `dependabotUpdate.error` があれば,Dependabot は修正 PR を作れなかった(推移的依存・対応外マニフェスト・上限超過等).`errorType` と `body` を報告に転記する +- `firstPatchedVersion` が `null` なら修正版が無い +- フォールバック(GraphQL が使えない場合): `gh api "repos/{owner}/{repo}/dependabot/alerts?state=open&per_page=100"`.PR との紐づけは PR 本文の GHSA リンク(`securityAdvisory.ghsaId`)で照合する + +## 更新規模の分類 (Classifying the Bump) + +### 単独 PR (Single-package PR) + +タイトル `Bump from to `(`Update requirement from ... to ...` の形もある)から版を取り,semver で比較する. + +| 条件 | 規模 | +| --- | --- | +| メジャーが上がる(`1.x` → `2.x`) | `major` | +| メジャーが `0` でマイナーが上がる(`0.3.x` → `0.4.x`) | `major` 相当(破壊的変更を含みうる) | +| メジャー同じでマイナーが上がる | `minor` | +| パッチだけ上がる | `patch` | +| semver として解釈できない(日付版・ハッシュ・範囲指定等) | `unknown` | + +- プレリリース(`-beta` 等)への更新は `unknown` として扱う +- `github-actions` の `v3` → `v4` のようなメジャータグ更新は `major` + +### グループ PR (Grouped PR) + +タイトル `Bump the group (across N directories )?with N updates` の PR は,本文の `Updates \`\` from to ` 行(または本文の表)を全て取り,**最大の規模**で判定する.テンプレートの `dependabot.yml` では `minor-and-patch` グループがマイナー/パッチだけを含むので定義上 `minor` になるが,本文から実際に確認する. + +### 依存スコープ (Dependency Scope) + +マニフェストのセクションで判定する(`package.json` の `devDependencies`,`pubspec.yaml` の `dev_dependencies`,`pyproject.toml` の dev グループ/`[project.optional-dependencies]`,`Cargo.toml` の `[dev-dependencies]` 等).`github-actions`・`docker` は CI/実行環境への影響として本番扱いにする. + +## CI の完了待ち (Waiting for CI) + +```bash +gh pr checks <番号> --watch --interval 30 # 全チェック完了まで待つ(Bash ツールの timeout を 10 分程度にする) +``` + +- 待ち切れなければ,CI が無い場合と同じく「ローカル検証」に切り替えるか,今回は見送って「CI 待ち」として報告する +- マージ直後は Dependabot が残りの PR を自動 rebase して CI が再実行される.次の PR に進む前にチェック状況を取り直す + +## ローカル検証 (Local Verification) + +CI が無い/CI 待ちに使う.**最新 `main` を取り込んだ状態**で検証し,終わったら必ず元に戻す.push は一切しない. + +```bash +START_BRANCH=$(git branch --show-current) +git fetch origin +gh pr checkout <番号> # PR のブランチをローカルに取得して切り替え +PR_BRANCH=$(git branch --show-current) +git merge --no-edit origin/main # 失敗(コンフリクト)なら: git merge --abort → この PR は見送り(@dependabot rebase) +# 依存インストール → 検証スイート(ENV_04_開発コマンド.md のコマンド) +# 例: npm ci && npm test && npm run build / flutter pub get && flutter test && flutter analyze / cargo test / go test ./... +git checkout "$START_BRANCH" +git branch -D "$PR_BRANCH" +git status --short # 空であることを確認 +``` + +- 依存インストール後にロックファイル等が変わる(`git status` に差分が出る)場合は,`git checkout -- .` で破棄してからブランチを戻す +- 検証結果の解釈: + - テスト+(あれば)ビルド/型チェック/リンタが全緑 → **Tier A** + - テストが無いがインストールとビルド/型チェックは通る → **Tier B** + - インストール自体が失敗/検証コマンドが特定できない → **Tier C** + - 赤 → 「CI 赤」と同じ扱いで報告(失敗したコマンドと要約を添える) + +## マージ (Merge) + +```bash +gh pr merge <番号> --merge --delete-branch # Git 規約どおりマージコミット +git checkout main && git pull origin main # ローカル main を追従(開始時のブランチが main でない場合は戻す) +``` + +マージできなかった場合は,次の 2 つの失敗モードを区別して報告する(どちらの場合もマージを迂回しない). + +| 失敗モード | 兆候 | 対処・報告内容 | +| --- | --- | --- | +| リポジトリ側の制約 | `gh` が実行され,ブランチ保護・必須レビュー・必須チェック未完了・権限不足でエラーを返す | その PR を「マージ可と判定したが権限・保護設定で拒否された」として報告し,人間の対応(Approve・設定変更)を促す | +| auto mode 分類器によるブロック | コマンドが**実行される前に**遮断され,`gh` の出力が返らない | `.claude/settings.json` の `permissions.allow` に `Bash(gh pr merge:*)` があるか確認する.無ければユーザーが手で追加してセッションを再起動する必要がある旨を報告する(Claude 自身は permissions を編集できず,設定変更はセッション再起動後に反映される).その PR は「マージ可と判定したが未実行」として報告する | + +- `--delete-branch` が「既に削除済み」で失敗しても無視してよい(Dependabot 側の設定でブランチが自動削除されることがある) + +## Dependabot へのコメント指示 (Dependabot Commands) + +PR コメントで Dependabot を操作できる.本スキルが使ってよいのは次の 2 つだけ. + +```bash +gh pr comment <番号> --body "@dependabot rebase" # base に追従させる(競合・BEHIND のとき) +gh pr comment <番号> --body "@dependabot recreate" # PR を作り直させる(一時的なビルド失敗・壊れたブランチのとき) +``` + +- `@dependabot ignore this major version` / `@dependabot ignore this dependency` / `@dependabot close` は**人間が判断して投稿する**(本スキルは推奨として提示するだけ) + +## メジャー更新の影響分析 (Analyzing a Major Bump) + +報告に回す PR(`major`・`0.x` マイナー・`unknown`)は,人間が「そのままマージ」「`/implement` で追従」「見送り」を選べるだけの材料を揃える. + +1. **変更内容**: PR 本文の Release notes / Changelog / Commits 節から **Breaking changes** を抜き出す.本文に無ければ `gh api repos///releases` や CHANGELOG を参照する(取れなければ「リリースノート未確認」と明記する) +2. **利用箇所**: コードベース内で当該パッケージを import/呼び出している箇所を Grep で列挙し,破壊的変更に該当する API を使っているか確認する +3. **影響範囲と推奨**: + - 該当 API を使っていない/開発依存のみ → 「そのままマージ可(検証 Tier と根拠を添える)」 + - 該当 API を使っている → 「`/implement` で追従が必要(修正箇所の一覧)」 + - 互換性が取れない・移行コストが見合わない → 「見送り(`@dependabot ignore this major version` を人間が投稿)」 +4. `github-actions` のメジャー更新は workflow の入力・出力の変更点を確認する(CI が通っていれば「マージ可」でよい) + +## PR コメントの書式 (PR Comment Format) + +報告に回した PR には,通常モードで次の書式のコメントを 1 回だけ投稿する(先頭の HTML コメントは二重投稿防止のマーカー.削除しない). + +```markdown + +## /deps-update による分析 + +- **判定**: 自動マージ対象外({メジャー更新 / 0.x マイナー更新 / CI 赤 / 検証不能}) +- **更新**: `{pkg}` {A} → {B}({本番依存 / 開発依存},{security / version}) +- **検証**: {CI 緑 / ローカル検証緑 / ビルドのみ / 未検証} +- **破壊的変更**: {抜き出した要点,無ければ「記載なし」} +- **影響箇所**: {ファイル:行 の一覧,無ければ「該当 API の利用なし」} +- **推奨**: {そのままマージ可 / `/implement` で追従後にマージ / 見送り(`@dependabot ignore this major version`)} +``` + +## 台帳の書式 (Ledger Formats) + +いずれも `.claude/` 直下のローカル運用状態(`.gitignore` 対象).1 行 1 エントリ. + +- `.claude/deps-update-merged.md`: + - `- [merged] #<番号> | →(グループは件数) | | | <検証: CI / ローカル / ビルドのみ> | <推奨する動作確認(無ければ -)> | <日付>` +- `.claude/deps-update-report.md`: + - `- [major] #<番号> | → | <本番 / 開発> | <破壊的変更の要点> | <影響箇所> | <推奨> | <日付>` + - `- [ci-fail] #<番号> | → | <失敗したチェック/コマンドと要約> | <推奨> | <日付>` + - `- [alert] () | <重大度> | | <修正版 or 修正版なし> | | <推奨対応> | <日付>` + - `- [other] #<番号> | <内容(依存以外のファイル変更・unknown 規模 等)> | <推奨> | <日付>` +- `.claude/deps-update-skip.md`(ユーザーが編集する): + - `- #<番号> または | <理由> | <日付>` diff --git a/.claude/skills/implement/SKILL.md b/.claude/skills/implement/SKILL.md new file mode 100644 index 0000000..d242253 --- /dev/null +++ b/.claude/skills/implement/SKILL.md @@ -0,0 +1,168 @@ +--- +name: implement +model: inherit +description: "実装パイプラインを開始する.コーディング(+任意の検証)→ テスト → リファクタリングの順で専門エージェントを実行し,必要に応じて人間の確認を挟む." +argument-hint: "<タスク内容>" +--- + +あなたはオーケストレーターです. +GUIDE_02(エージェント運用ルール)を読み,パイプラインのルールを理解した上で以下の手順を実行してください. + +## 前提確認 (Pre-check) + +1. 現在のブランチを確認する(`git branch --show-current`) +2. `main` ブランチにいる場合,Git 規約 `.claude/rules/git-conventions.md` に従い作業ブランチを作成する + - 同ルールのブランチ命名規則に従い,適切なプレフィックスとタスク内容を表す英単語 2〜4 語(kebab-case)でブランチ名を決定する +3. 実装対象のタスクを確認する: $ARGUMENTS + +## Phase 1: コーディング (Coder) + +coder エージェントに以下を委譲する: + +「以下のタスクを実装してください: $ARGUMENTS」 + +Coder の出力(実装サマリー)を記録し,Phase 1b に進む. + +## Phase 1b: 検証 (Verifier) + +**検証プロファイル(`.claude/verify-profile.md`)を用意したプロジェクトでのみ動く任意のフェーズ**.人間の動作確認の**前段**として,verifier エージェントに動作とエッジケースを確認させ,人間が見る範囲を絞る(人間の確認を置き換えるものではない.CLAUDE.md「手動確認が必要な作業」). +検証する環境はローカル環境と,検証プロファイルの「検証対象の環境」が明示的に許可した検証用環境に限られる(本番は対象外). + +### 実行判定 + +以下のいずれかに該当する場合は verifier を起動せず,**理由を 1 行控えて**「人間への提示」に進む(エラーにしない): + +- `.claude/verify-profile.md` が無い(検証プロファイル未設定.この場合が既定であり,従来どおり人間が動作確認する) +- 変更が,観測可能な挙動に関係しない(ドキュメント・設定・テスト・型定義のみ等) +- 検証の前提を整えるのに破壊的な操作(データの初期化・マイグレーションの適用等)が必要で,未実施である(無断で実行せず,人間に依頼する) +- ユーザーがタスク内容で検証の省略を指示している + +### 委譲 + +verifier エージェントに以下を委譲する(検証手段・実行コマンドは書かない.verifier が検証プロファイルを読む): + +「以下の実装について,動作とエッジケースを確認してください(検証する環境は検証プロファイルの『検証対象の環境』に従う). + +タスク: $ARGUMENTS + +実装サマリー: +{Coder の出力}」 + +### 結果による分岐 + +- **OK**: 「人間への提示」に進む +- **NG(今回の変更に起因する不具合)**: 人間に回す前に,coder エージェントへ verifier の不具合報告(再現手順・期待・実際・推定原因)を渡して修正を委譲し,verifier に再確認させる.この自動差し戻しは **2 回まで**.それでも NG なら,未解決の不具合を添えて「人間への提示」に進み,方針を仰ぐ +- **NG(今回の変更と無関係な既存の問題のみ)**: 差し戻さない.「気になった点」と合わせて別件として人間に伝え,`/task-create` での起票を案内する +- **検証不能**(前提条件を満たさない等): 理由を控えて「人間への提示」に進む + +差し戻しで coder が修正した場合は,実装サマリーを最新の内容に更新して以降のフェーズに渡す. + +### 人間への提示 + +**verifier を起動しなかった場合**(既定)は,理由を 1 行添えて従来どおり依頼する: + +「**Phase 1(コーディング)が完了しました.** + +{Coder の実装サマリー} + +※ Claude による自動検証は行っていません(理由: {1 行.例: 検証プロファイル未設定}) + +ブラウザや実機で動作を確認してください. +- **OK** → 次のフェーズ(テスト)に進みます +- **NG** → 問題点を教えてください.修正します」 + +**verifier を起動した場合**は,「確認済み」と「確認してほしい」の 2 部構成で提示する: + +「**Phase 1(コーディング)が完了しました.** + +{Coder の実装サマリー} + +**Claude が確認済みの項目** +{verifier の「確認した項目」の表.差し戻しで直した不具合があればその内容も.検証不能だった場合は,この節を「検証を実行できませんでした(理由: …)」に置き換える} + +**あなたに確認してほしい項目** +{verifier の「人間が確認すべき残り項目」.省略・要約しすぎない.検証不能だった場合は「ブラウザや実機で動作を確認してください」} + +{未解決の不具合・今回の変更と無関係な既存の問題があれば,ここに分けて記載する} + +- **OK** → 次のフェーズ(テスト)に進みます +- **NG** → 問題点を教えてください.修正します」 + +ユーザーの OK を得るまで Phase 2 に進まないこと.**verifier の判定が OK でも,それをユーザーの OK の代わりにしてはならない.** +NG の場合は coder エージェントに修正を委譲し,再度確認を依頼する(修正が観測可能な挙動に関わり,検証プロファイルがある場合は Phase 1b からやり直す). + +## Phase 2: テスト (Tester) + +tester エージェントに以下を委譲する: + +「直前の実装に対するテストを,仕様(`docs/04_SPEC/`)を基準に作成・実行してください.テストは実装コードではなく仕様を基準に書くこと. + +実装サマリー: +{Coder の出力}」 + +Tester の出力(テストサマリー)を記録し,テスト結果に応じて分岐する: + +- **全テスト成功**: テストサマリーを表示し,自動的に Phase 3 に進む. +- **テスト失敗あり**: ユーザーに以下を提示し,判断を仰ぐ. + +「**Phase 2(テスト)でテスト失敗がありました.** + +{Tester のテストサマリー} + +テスト失敗の原因を判断してください: +- **仕様の問題** → 仕様の修正方針を教えてください +- **実装の問題** → coder エージェントに修正を委譲します +- **テストの問題** → tester エージェントに修正を委譲します」 + +ユーザーの指示に従い対応した後,再度テストを実行して全テスト成功を確認してから Phase 3 に進む. + +## Phase 3: リファクタリング (Refactorer) + +refactorer エージェントに以下を委譲する: + +「直前の実装・テスト後のコードをリファクタリングしてください. + +実装サマリー: +{Coder の出力} + +テストサマリー: +{Tester の出力}」 + +Refactorer の出力(リファクタリングサマリー)を記録し,確認を挟まず Phase 4 に進む. + +## Phase 4: ドキュメント更新 (Documentation) + +実装内容を以下のチェック観点で確認し,`docs/`, `CLAUDE.md`, `docs/PROGRESS.md` を必要に応じて更新する: + +- API・データ構造の変更 → `docs/04_SPEC/` の該当仕様を更新 +- 環境・依存関係の変更 → `docs/02_ENV/` を更新 +- 起動・テスト・lint 等の開発コマンドの追加・変更 → `docs/02_ENV/ENV_04_開発コマンド.md` を更新(無ければ作成し,CLAUDE.md のドキュメント一覧にリンクを追記する) +- 開発ステップの進捗 → `docs/03_PLAN/` や進捗記録(`CLAUDE.md` の進捗欄+ `docs/PROGRESS.md`)を更新(書式は進捗記録ルール `.claude/rules/progress-log.md` に従う.CLAUDE.md は最新 1 行に上書き,`docs/PROGRESS.md` に詳細を末尾追記.`git log` で取れる詳細は書かない) +- 規約・運用ルールの変更 → `docs/01_GUIDE/` を更新 +- 該当なし(軽微なバグ修正,リファクタリングのみ等)→ 更新をスキップしてよい + +Phase 4 の結果を `.claude/commit-context.md` に書き出す: + +```markdown +# commit-context +- docs_updated: true/false +- claude_md_updated: true/false +- progress_md_updated: true/false +- note: (更新不要の理由や更新内容の要約) +``` + +ドキュメントを更新した場合のみ,ユーザーに提示する: + +「**Phase 4(ドキュメント更新)が完了しました.** + +{更新したドキュメントの一覧と変更内容の要約} + +確認をお願いします. +- **OK** → `/commit` でコミットできます +- **NG** → 修正点を教えてください」 + +更新不要と判断した場合は,確認を挟まず完了処理に進む. + +## 完了処理 (Finalization) + +「全フェーズが完了しました.`/commit` でコミットしてください.」 diff --git a/.claude/skills/set-mode/SKILL.md b/.claude/skills/set-mode/SKILL.md new file mode 100644 index 0000000..484e430 --- /dev/null +++ b/.claude/skills/set-mode/SKILL.md @@ -0,0 +1,166 @@ +--- +name: set-mode +model: inherit +description: "開発モード(solo / team)を切り替える.チーム層ファイル・settings.json・CLAUDE.md・.claude/project-mode を一括で整合させる." +argument-hint: "" +--- + +あなたは開発モード切替の担当者です. +プロジェクトの開発モードを **solo(個人開発)↔ team(チーム開発)** で切り替え,モードに紐づくファイル一式を過不足なく整合させてください. + +`/sync-template` は「テンプレートの版を追従する」道具であり,モード遷移(ファイルの追加・削除,CLAUDE.md/settings.json の書き換え)は行いません.本コマンドがその遷移を担います. + +実行環境: bash(Git Bash または Unix シェル)が必要.`mktemp`, `rm -rf`, `cp`, シェル変数展開を使用する. + +テンプレート URL: `https://github.com/rintoHasegawa/programming-template.git` + +## チーム層ファイル (Team-layer Files) + +モードに応じて配置/削除する対象.`/sync-template` の「モード依存ファイル」と同一のリストに保つこと. + +``` +docs/01_GUIDE/GUIDE_03_チーム開発ルール.md +.claude/hooks/check_sync.sh +``` + +※ `task-create`・`task-start`(+ `reference.md`)・`task-handoff` の各 skill は**共通層**(solo でも Issue ベースのタスク管理に使う)であり,モード切替の対象外. + +## ステップ 1: 事前確認 (Pre-check) + +1. `git status` でワーキングツリーがクリーンか確認する.未コミットの変更がある場合は「⚠ コミットされていない変更があります.先にコミットまたは stash してから再実行してください.」と伝えて中断する. +2. `$ARGUMENTS` を確認する.`solo` または `team` のいずれかであること.未指定・不正な値なら「切替先を `solo` または `team` で指定してください(例: `/set-mode team`)」と伝えて終了する. +3. 現在のモードを取得する(`.claude/project-mode` が無ければ `solo` 扱い): + ```bash + if [ -f .claude/project-mode ]; then CURRENT=$(tr -d '[:space:]' < .claude/project-mode); else CURRENT="solo"; fi + TARGET="$ARGUMENTS" # solo | team + ``` +4. `CURRENT == TARGET` の場合は「既に `$TARGET` モードです.変更はありません.」と伝えて終了する. + +## ステップ 2: ブランチ作成 (Branch) + +モード切替は共有設定(`CLAUDE.md` のルール部・`.claude/`)の変更を含むため,GUIDE_03「共有設定の扱い」に従い専用ブランチで行う. + +```bash +git checkout -b chore/set-mode-$TARGET +``` + +既に同名ブランチがあれば削除して作り直す.**本コマンドはコミットしない**(CLAUDE.md のルールに従い,取り込みは後で `/commit` で行う). + +## ステップ 3-A: solo → team(TARGET が team のとき) + +### 3-A.1 テンプレートから team 層ファイルを取得・配置 + +`/sync-template` の版差分では過去に追加済みの team 層ファイルを配置できないため,本コマンドはテンプレートを直接 clone して確実に配置する: + +```bash +TEMPLATE_URL="https://github.com/rintoHasegawa/programming-template.git" +TEMP_DIR=$(mktemp -d) +git clone --depth 1 "$TEMPLATE_URL" "$TEMP_DIR" + +# team 層ファイルをコピー(既存があっても最新版で上書き) +for f in \ + "docs/01_GUIDE/GUIDE_03_チーム開発ルール.md" \ + ".claude/hooks/check_sync.sh" ; do + mkdir -p "$(dirname "$f")" + cp "$TEMP_DIR/$f" "$f" +done +rm -rf "$TEMP_DIR" +``` + +### 3-A.2 settings.json に SessionStart(check_sync) を配線 + +`.claude/settings.json` を Read し,既存の `PreToolUse`(`restrict_repo_access.py`)を保持したまま,`SessionStart` フックを追加する(既に配線済みなら何もしない): + +```json +"SessionStart": [ + { + "hooks": [ + { "type": "command", "command": "bash .claude/hooks/check_sync.sh", "timeout": 10 } + ] + } +] +``` + +`hooks` 以外のキー(`permissions` 等)は**追加も削除もせずそのまま保持する**.特に `permissions.allow` の `Bash(gh pr merge:*)` は auto mode で ops-runner にマージを実行させるために必要なため,モード切替で落とさない. + +Edit 後に `git diff .claude/settings.json` で結果を確認する(`SessionStart` の追加以外の差分が出ていないことを確認する). + +### 3-A.3 CLAUDE.md を team 化 + +`CLAUDE.md` を Read し,`/setup` の Phase 7(team 化)および GUIDE_03 に合わせて以下を反映する(既に反映済みの項目は触らない): + +- **「開発進捗」節**を,進捗欄(最新 1 行)ではなく **Issues ポインタ**に置き換える(例:「進捗・タスクは GitHub Issues と git 履歴で追う(GUIDE_03).現在のタスクは `gh issue list` で確認する.`CLAUDE.md` には進捗を書かない.」). +- **「必須ルール(コード実装時)」に「チーム開発(GUIDE_03 準拠)」小節を追加**する(直列運用・Issue ベースのタスク管理・`/task-create`/`/task-start`/`/task-handoff` の案内・条件付きセルフマージ・共有設定変更は専用 PR+他メンバー 1 名 Approve 必須). +- **「Git 運用」小節**に,セッション開始時の `[sync-check]` 警告を必ず認識する旨の 1 行を追加する. +- **「ドキュメント」→「01_GUIDE」一覧**に `GUIDE_03` の行を追加する. + +Edit 後に `git diff CLAUDE.md` で結果を確認する. + +### 3-A.4 その他 + +- `.gitignore` に `.claude/settings.local.json` が無ければ追記する(個人設定用.GUIDE_03). +- `echo team > .claude/project-mode` でモードを記録する. + +## ステップ 3-B: team → solo(TARGET が solo のとき) + +clone は不要(ローカルの削除・書き換えのみ).**破壊的操作を含むため,実行前に対象ファイルの一覧と CLAUDE.md 差分の要約をユーザーに提示し,同意を得てから実行する**. + +### 3-B.1 team 層ファイルを削除 + +```bash +rm -f \ + "docs/01_GUIDE/GUIDE_03_チーム開発ルール.md" \ + ".claude/hooks/check_sync.sh" +``` + +※ `task-*` skill は共通層のため削除しない(solo でも Issue ベースのタスク管理に使用する).共通層化以前に solo 化して `task-*` が消えているプロジェクトには,`/sync-template` の同期で再配置される. + +### 3-B.2 settings.json から SessionStart(check_sync) を除去 + +`.claude/settings.json` を Read し,`check_sync.sh` を呼ぶ `SessionStart` フックのみを除去する.`PreToolUse`(`restrict_repo_access.py`)は保持する.他に個別追加された `SessionStart` フックがあれば残す(`check_sync.sh` の配線だけを外す).`hooks` 以外のキー(`permissions` 等)は**そのまま保持する**(`permissions.allow` の `Bash(gh pr merge:*)` は auto mode でマージを実行するために必要).Edit 後に `git diff .claude/settings.json` で確認する(`SessionStart` の除去以外の差分が出ていないことを確認する). + +### 3-B.3 CLAUDE.md を solo 化 + +`CLAUDE.md` を Read し,team 化で加えた箇所を元の solo 既定へ戻す: + +- **「開発進捗」節**を solo 既定の骨組みに戻す.team 化で Issues ポインタになっているため,進捗欄(最新 1 行)+案内コメントの形へ置き換える: + ```markdown + ## 開発進捗 + + 最新: <直近の状況を 1 行.不明なら「(git 履歴 / 旧 Issue を参照)」> + ※ 本欄は**最新ステップ 1 行のみ上書き更新**.詳細な進捗履歴は docs/PROGRESS.md に追記する.運用ルールは .claude/rules/progress-log.md 参照. + ``` + 「最新」行に入れる現状はユーザーに確認する(team 期間の進捗は Issues / git 履歴にあるため,ここへ移し替える必要はない). +- **「チーム開発(GUIDE_03 準拠)」小節を削除**する. +- **「Git 運用」小節**の `[sync-check]` 警告の行を削除する. +- **「ドキュメント」→「01_GUIDE」一覧**から `GUIDE_03` の行を削除する(GUIDE_04 以降のプロジェクト固有ドキュメントがあれば残す). + +Edit 後に `git diff CLAUDE.md` で確認する. + +### 3-B.4 その他 + +- `echo solo > .claude/project-mode` でモードを記録する. +- `docs/PROGRESS.md` が無い場合は,solo 運用のため骨組みを用意するか確認する(テンプレート由来のファイルなので通常は存在する). + +## ステップ 4: 結果報告 (Report) + +以下を報告する: + +「**開発モードを `{CURRENT}` → `{TARGET}` に切り替えました.** + +- ブランチ: `chore/set-mode-{TARGET}` +- team 層ファイル: {配置した / 削除した} 一覧 +- `.claude/settings.json`: {SessionStart(check_sync) を追加 / 除去 / 変更なし} +- `CLAUDE.md`: {team 化 / solo 化} を反映 +- `.claude/project-mode`: `{TARGET}` + +内容を確認のうえ `/commit push` で取り込んでください. +{team に切り替えた場合: 「チーム運用の管理者初期設定(GitHub repo・CI 等)は GUIDE_03 を参照してください.共有設定の変更のため,他メンバー 1 名の Approve を得てからマージしてください(GUIDE_03).」}」 + +## 注意事項 (Notes) + +- 本コマンドは**コミットしない**.変更は `chore/set-mode-*` ブランチに未コミットで乗るので,`/commit` で取り込む. +- team ↔ solo の切替は共有設定の変更にあたる(GUIDE_03「共有設定の扱い」).team プロジェクトでは専用 PR+他メンバー 1 名 Approve を経てマージする. +- team 層ファイルのリストは `/sync-template` の「モード依存ファイル」と一致させること.どちらかを増減したら両方を更新する. +- solo→team で取得する team 層ファイルはテンプレート HEAD 版.版の細かな追従は以後の `/sync-template` に任せる(`template-sync-sha` は本コマンドでは変更しない). +- team 層ファイルが `.claude/template-overrides.md`(テンプレート改変台帳)に登録されている場合,3-A.1 の上書きや 3-B.1 の削除でプロジェクトの改変が失われる.実行前に台帳を確認し,該当があればユーザーに提示して同意を得てから行う. diff --git a/.claude/skills/setup/SKILL.md b/.claude/skills/setup/SKILL.md new file mode 100644 index 0000000..ce60a18 --- /dev/null +++ b/.claude/skills/setup/SKILL.md @@ -0,0 +1,223 @@ +--- +name: setup +model: inherit +description: "GUIDE_01 に従い,プロジェクトの立ち上げを対話的に進める.各フェーズでユーザーの確認を挟む." +argument-hint: "<プロジェクト名>" +--- + +あなたはプロジェクト立ち上げのファシリテーターです. +GUIDE_01(プロジェクト立ち上げフロー)を読み,各フェーズのルールを理解した上で以下の手順を実行してください. +`gh` CLI で行う GitHub リポジトリ設定のコマンドと,`.github/dependabot.yml` の生成規則は本スキルの `reference.md`(`.claude/skills/setup/reference.md`)を参照する. + +## 基本ルール + +- 各フェーズの成果物をユーザーが承認し,コミット済みであること(未コミットの場合は,そのまま進んでよいかのユーザーの了承)を確認してから次のフェーズに進むこと(後述「立ち上げ中の Git 運用」) +- ユーザーが「今日はここまで」と言った場合,CLAUDE.md の進捗(最新 1 行)と `docs/PROGRESS.md`(追記)を更新して中断する +- ドキュメントの書式・命名は `.claude/rules/markdown-style.md`・`.claude/rules/docs-naming.md` に従って作成する(該当ファイル編集時に自動ロードされる) +- 成果物は**最初から保存先にファイルとして書き出してから**レビューを受ける.チャットには成果物の全文を出さず,「書き出したファイルの一覧」と「判断してほしい点・Claude が決めた点」だけを短く示す(ユーザーはエディタでファイルを読む) +- ユーザーの修正指示はファイルに直接反映する(差分はユーザーが `git diff` で追える).修正内容はチャットに要点だけ報告し,全文を再掲しない +- フォルダは成果物を実際に書き出すときにのみ作成する.空のカテゴリフォルダを先回りで作らない + +## 立ち上げ中の Git 運用 (Git During Setup) + +立ち上げ作業は専用ブランチ `chore/project-setup` の上で行う.**コミットは本スキルでは行わず,各フェーズの成果物をユーザーが確認したうえでユーザー自身が `/commit` で行う**(CLAUDE.md「Git 運用」の「`/commit` 自発実行禁止」どおり).フェーズごとのコミットメッセージ例は本スキルの `reference.md`(`.claude/skills/setup/reference.md`)「フェーズごとのコミット」にあり,ユーザーに案内してよい. + +- ブランチ `chore/project-setup` の作成は本スキルが行ってよい.最初にファイルを書き換える前(前提確認での CLAUDE.md 書き換えを含む)に `main` から作成する.既に `chore/project-setup` にいる場合(再開時)はそのまま使い続ける(手順は `reference.md`「フェーズごとのコミット」) +- **`git commit`・`git push`・PR 作成・マージは行わない**.`main` への取り込みは全フェーズ完了後にユーザーが `/commit push`(または `/commit merge`)で行う +- 次のフェーズに進む前に `git status --porcelain` で作業ツリーがクリーン(そのフェーズの成果物がコミット済み)であることを確認する.未コミットの変更が残っている場合は,コミットしてから進むか,未コミットのまま次へ進むかをユーザーに確認する(勝手に進まない) +- フェーズごとにコミットしてもらうことで,中断・再開しても「どこまで終わったか」が git 履歴から辿れる(`CLAUDE.md` の進捗欄・`docs/PROGRESS.md` の更新はそのフェーズのコミットに含めてもらう) + +## 前提確認 (Pre-check) + +1. CLAUDE.md がテンプレート初期状態(タイトルが `# プロジェクト名` のまま)か確認する +2. **初期状態の場合(新規プロジェクト立ち上げ):** + - プロジェクト名を確定する(引数 `$ARGUMENTS` があればそれを使用,なければユーザーに確認) + - プロジェクト概要(1〜2 行)をユーザーに確認する + - CLAUDE.md の以下を書き換える: + - 1 行目の `# プロジェクト名` → `# {確定したプロジェクト名}` + - 3 行目の `` → 概要テキスト +3. CLAUDE.md の「開発進捗」と `docs/PROGRESS.md` を確認し,どのフェーズから再開するか判断する(再開時は `git status --porcelain` で前回の未コミットの変更が残っていないかも確認し,残っていればユーザーに `/commit` するか確認する) +4. 初回の場合はフェーズ 1 から開始する + +## 開発モードの選択 (Development Mode) + +新規立ち上げ時(Pre-check で初期状態だった場合),フェーズ 1 に入る前に開発モードを 1 度だけ確定する.既に `.claude/project-mode` がある場合(再開時)はこのステップを飛ばす. + +ユーザーに次を確認する: + +「**このプロジェクトの開発モードを選んでください.** + +- **solo(個人開発)**: 1 人で開発する.進捗は `CLAUDE.md` の進捗欄+ `docs/PROGRESS.md` で追う. +- **team(チーム開発)**: 複数人が Claude Code で開発する.進捗・タスクは GitHub Issues と git 履歴で追い,直列運用・条件付きセルフマージ等のチームルール(GUIDE_03)を適用する. + +どちらにしますか?(後から `/set-mode ` で切り替えられます)」 + +確定したら以下を行う: + +1. `.claude/project-mode` にモード(`solo` または `team` のいずれか 1 語)を書き出す. +2. **solo の場合**: チーム層ファイルが clone で配置されていれば削除し,solo プロジェクトを clean に保つ: + - `docs/01_GUIDE/GUIDE_03_チーム開発ルール.md` + - `.claude/hooks/check_sync.sh` + - ※ `task-create` / `task-start` / `task-handoff` の各 skill は共通層(solo でも Issue ベースのタスク管理に使える)のため削除しない. + - ※ `.claude/settings.json` はそのまま(solo でも `restrict_repo_access.py` を使う).SessionStart(check_sync) の配線は追加しない. +3. **team の場合**: 上記チーム層ファイルを残す.加えて: + - `.claude/settings.json` に SessionStart フックを追記して `check_sync.sh` を配線する(既存の PreToolUse ブロックは保持する): + ```json + "SessionStart": [ + { + "hooks": [ + { "type": "command", "command": "bash .claude/hooks/check_sync.sh", "timeout": 10 } + ] + } + ] + ``` + - `.gitignore` に `.claude/settings.local.json` が無ければ追記する(個人設定用.GUIDE_03). + - CLAUDE.md のチーム化は Phase 7 で行う(この時点ではまだ立ち上げ中で Issue/repo が無いため,進捗欄は setup 再開用にそのまま使う). + +### 進捗記録(立ち上げ中) + +立ち上げ中は repo/Issue がまだ無いため,**モードに関わらず** setup 再開のための進捗は `CLAUDE.md` の進捗欄(最新 1 行)+ `docs/PROGRESS.md` に記録する.team モードでの「進捗を Issue で追う」への切り替えは Phase 7(実装開始)以降に適用する. + +## フェーズ 1〜6: 各フェーズの実行 + +GUIDE_01 に定義された各フェーズを順番に実行する. +各フェーズで以下の手順を繰り返す: + +1. GUIDE_01 の該当フェーズを参照し,目的・成果物・役割分担を確認する +2. ユーザーとの対話で情報を収集する +3. 成果物を GUIDE_01 に記載された保存先に直接書き出し,書き出したファイルの一覧と要確認点をユーザーに示す(全文はチャットに出さない) +4. ユーザーの修正指示をファイルに反映し,承認を得る +5. ユーザーに `/commit` でのコミットを案内する(前述「立ち上げ中の Git 運用」・`reference.md`「フェーズごとのコミット」のメッセージ例を添える) +6. 作業ツリーがクリーンになったことを確認してから次のフェーズに進む(未コミットのまま進むかはユーザーに確認する) + +### 台詞テンプレート + +「**フェーズ {N}({フェーズ名})の成果物を書き出しました.** エディタで確認してください. + +書き出したファイル: + +- {ファイルパス}({新規作成 / 更新}: {1 行の説明}) + +判断してほしい点: + +- {ユーザーの判断が必要な点.無ければ「なし」} + +Claude が決めた点: + +- {ユーザーに確認せず Claude が仮に決めた点と理由} + +修正点があれば指示してください(ファイルを直接修正します.差分は `git diff` で確認できます). +OK であれば `/commit` でコミットしてください(メッセージ例: `{reference.md の対応表のメッセージ例}`).コミットを確認してから次のフェーズに進みます.」 + +修正を反映したときは,直したファイルと変更点の要点だけを示し,同様に `/commit` を案内する. + +### フォルダ作成の注意 + +成果物を書き出すフォルダのみ作成する.`.claude/rules/docs-naming.md` のカテゴリ表は「あり得るカテゴリの一覧」であり,立ち上げ時に全カテゴリのフォルダを scaffold するものではない.GUIDE_01 の立ち上げフェーズで成果物が発生するのは `02_ENV / 03_PLAN / 04_SPEC` と `.claude/rules/`(規約整備フェーズ)のみ.`05_TECH/`(技術設計)と `06_TEST/`(テスト)は立ち上げ時には成果物が無いため,空フォルダを作らない(実装フェーズで必要になった時点で作成する). + +### フェーズ 3(環境構築)の注意 + +外部サービスの設定(アカウント作成,コンソール操作等)はユーザー自身が行う.手順書に記載するが,AI が実行しないこと. +**例外**として,`gh` CLI で操作できる GitHub リポジトリ設定は AI が実行する. +手順書を書き出した後は,それ以外の手順を実際に実行して環境を構築する(後述「環境構築の実行」.ツール導入は都度承認を得る). +このフェーズは「ファイル作成」と「構築の実行」が混ざるため,**構築を実行し,その結果(実際に使ったコマンド・バージョン・躓き)を手順書ファイルに反映してから**,ファイル一覧と要確認点を示してレビューを受ける. + +### GitHub リポジトリのセキュリティ設定 (GitHub Repository Security Settings) + +Dependabot alerts("Vulnerabilities")と Dependabot security updates はリポジトリごとに既定で OFF のため,モード(solo / team)に関わらず AI が `gh api` で有効化する.コマンド・検証・トラブル対応は本スキルの `reference.md`(`.claude/skills/setup/reference.md`)「GitHub リポジトリのセキュリティ設定」に従う. + +- `ENV_03_管理者用環境構築手順.md` には,GitHub リポジトリ作成の直後の手順として `reference.md` の有効化・検証コマンドを必ず転記する(`/setup` 後にリポジトリを作った場合に管理者が単体で実行できるようにするため) +- ENV_03 を書き出したら,その場で `reference.md` の手順を 1 度実行する.リモート未作成で実行できなければ Phase 7 で再試行する + +### 依存バージョン更新の設定 (Dependabot Version Updates) + +上記の脆弱性検出とは別に,依存パッケージを定期的に最新化する PR を Dependabot に作らせるため,フェーズ 3 で `.github/dependabot.yml` を**ユーザーに選択を聞かずに必ず生成する**(`.gitignore` 等と同じ「設定ファイルの生成」の一部として扱う). + +- フェーズ 2 で確定した技術スタック(`ENV_01_技術スタック.md`)から対象エコシステムを決め,`reference.md`「依存バージョン更新の設定」の対応表と雛形に従って生成する +- 生成内容(対象エコシステム・更新頻度・グループ化)は他の成果物と同様にファイルに書き出してから,要確認点として示してユーザーの承認を得る(「作るかどうか」は聞かず,「内容」だけを確認する) +- マニフェストがまだ存在しない場合(立ち上げ時点でコードが無い)でも,スタックが確定していれば生成してよい.Dependabot はマニフェストが置かれた時点から動き始める + +### 環境構築の実行 (Executing the Environment Setup) + +`ENV_02_環境構築手順.md`・`ENV_03_管理者用環境構築手順.md` を書き出したら,**手順書を渡して終わりにせず,その手順に従って実際にこの環境を構築する**.未検証の手順書は誤り・前提の抜けを含みやすく,最初に詰まるのはその場のユーザーであるため.実行の分類(そのまま実行する/承認を取る/ユーザーに依頼する)と進め方は `reference.md`「環境構築の実行」に従う. + +- リポジトリ内で完結する操作(プロジェクト初期化・依存インストール・設定ファイル生成・ビルドや起動の疎通確認・`gh` でのリポジトリ設定)はそのまま実行する +- ツール導入(SDK・ランタイム・CLI のインストール,`docker build`,dev container の起動)は**実行するコマンドを提示して承認を得てから**実行する.OS・パッケージマネージャ・バージョン・入る場所(システム全体かプロジェクト内か)を明示する +- 外部サービスのコンソール操作・アカウント作成・対話ログイン(`gh auth login` 等)・クレデンシャルの発行はユーザーに依頼する(`! gh auth login` のように `!` を付けてその場で実行できることを案内する) +- 実行して分かった手順書の誤り・不足・OS 依存の差異は `ENV_02`/`ENV_03` に反映してからユーザーのレビューを受ける.解決できなかった手順は「※ 未検証」と明記して残す +- 確定した起動・テスト・lint・ビルドのコマンドは `ENV_04_開発コマンド.md` に書き出す(GUIDE_01「環境構築」) +- 生成物(`node_modules/`・ビルド出力等)を作る前に `.gitignore` を整備する + +### 検証プロファイルの作成(任意) (Verify Profile) + +技術スタックと開発コマンドが固まった後(フェーズ 3 の最後)に,Claude による動作確認を使うかをユーザーに 1 度だけ確認する.**既定は「作らない」**であり,作らなくても `/implement` の流れは従来どおり(Phase 1b はスキップされ,動作確認は人間が行う). + +「**Claude がローカルでアプリを操作して動作確認する仕組みを用意しますか?(任意)** + +検証プロファイル(`.claude/verify-profile.md`)を用意すると,`/implement` の Phase 1b と `/verify` で Claude がエッジケース等を先に確認し,あなたが見る範囲を「デザイン・操作感・実機の挙動」等に絞れます(人間の確認は無くなりません). +別途,シナリオを実行するランナー(起動・実行・集計・後始末)をプロジェクト側に用意する必要があります. + +- **今は作らない**(既定)— 後から `/verify` の案内に従って用意できます +- **作る** — 雛形を埋めながら一緒に作ります」 + +「作る」を選んだ場合: + +1. `.claude/skills/verify/profile-template.md` を `.claude/verify-profile.md` にコピーする +2. 雛形の各節を,確定した技術スタック・開発コマンドに合わせてファイル上で埋め,他の成果物と同様に要確認点を示してユーザーの承認を得る.埋められない項目(ランナー未整備のため実行コマンドが未定等)は空欄にせず「未定(ランナー整備後に記入)」と明記する + - 「検証対象の環境」は**ローカルのみ**で記入する.staging 等を許可するかは Claude が判断せずユーザーに確認し,許可する場合だけ「いじっても影響が無い」と判断した根拠・資格情報の受け渡し方法・テストデータの後始末をユーザーから聞いて記入する(本番は許可しない) +3. 検証結果の出力先を `.gitignore` に追記する +4. ランナーは雛形の「実行基盤の要件」を満たすようプロジェクト側で実装する必要がある旨を伝える(この場では実装しない.必要なら立ち上げ後に `/implement` で作る) + +「今は作らない」を選んだ場合は何も作らず,後から用意できることだけ伝えて次に進む. + +## フェーズ 7: 実装開始 + +1. GUIDE_01 の「CLAUDE.md の管理」セクションに従い,CLAUDE.md を最終更新する + - **team モードの場合**(`.claude/project-mode` が `team`)は,ここで CLAUDE.md をチーム開発向けに変換する: + - 「開発進捗」節を,進捗欄(最新 1 行)ではなく **Issues ポインタ**に置き換える(例: 「進捗・タスクは GitHub Issues と git 履歴で追う(GUIDE_03).現在のタスクは `gh issue list` で確認する.`CLAUDE.md` には進捗を書かない.」). + - 「必須ルール(コード実装時)」に **「チーム開発(GUIDE_03 準拠)」小節**を追加する(直列運用・Issue ベースのタスク管理・`/task-create`/`/task-start`/`/task-handoff` の案内・条件付きセルフマージ・共有設定変更は専用 PR+他メンバー 1 名 Approve 必須). + - 「Git 運用」小節に,セッション開始時の `[sync-check]` 警告を必ず認識する旨の 1 行を追加する. + - 「ドキュメント」→「01_GUIDE」一覧に `GUIDE_03` の行を追加する. + - **solo モードの場合**は従来どおり(進捗欄+ PROGRESS.md 運用). +2. 作成した全ドキュメントの一覧を表示する +3. 最初の実装ステップを確認する +4. **モードに関わらず**,「GitHub リポジトリのセキュリティ設定」(前述)を実行する(Phase 3 で有効化済みなら検証コマンドだけで確認する.リモートがまだ無ければ,リポジトリ作成後に ENV_03 のコマンドを実行するようユーザーに案内する) +5. CLAUDE.md の最終更新・進捗の更新をユーザーに確認してもらい,`/commit` でのコミットを案内する(前述「立ち上げ中の Git 運用」).その後の `main` への取り込みも本スキルでは行わず,ユーザーに `/commit push`(または `/commit merge`)を案内する(最終更新のコミットと取り込みを 1 回の `/commit push` 等でまとめて行ってよい) +6. **team モードの場合**は,AI に実施できない「管理者の初期設定」(GUIDE_03「管理者の初期設定」)をユーザーに案内する: + - GitHub リポジトリ作成・メンバー招待 + - Issue テンプレートの用意 + - CI 構築(未構築の間は GUIDE_03「CI 構築までの暫定ゲート」を適用) + - ※ 脆弱性検出(Dependabot alerts / security updates)は上記 4 で AI が有効化済み,依存バージョン更新(`.github/dependabot.yml`)はフェーズ 3 で生成済みのため,ここでは案内しない.CI を `github-actions` で組んだら `dependabot.yml` に `github-actions` エコシステムが含まれていることを確認する(`reference.md`「依存バージョン更新の設定」) + +「**全フェーズが完了しました.** + +作成したドキュメント: +{ドキュメント一覧} + +開発モード: {solo / team} +CLAUDE.md を更新しました. +GitHub リポジトリのセキュリティ設定(Dependabot alerts / security updates): {有効化済み / リポジトリ未作成のため未実施(ENV_03 のコマンドで有効化してください)} +依存バージョン更新(`.github/dependabot.yml`): {対象エコシステム一覧} を {更新頻度} で監視 +Dependabot が作る PR・alert は `/deps-update` で処理できます(安全な更新は自動マージ,メジャー更新等は分析付きで報告). +立ち上げの成果物は `chore/project-setup` ブランチにあります(CLAUDE.md の最終更新は未コミットです.内容を確認してください).コミットと `main` への取り込みは `/commit push`(PR 作成まで)または `/commit merge`(マージまで)で行ってください. +環境構築: {実行した手順の要点 / ユーザーに依頼した手順 / 未検証のまま残した手順} +最初の実装ステップは `{ステップ名}` です.`/implement {タスク}` で開始できます. +{team の場合: 「チーム運用の管理者初期設定(GitHub repo・CI 等)は GUIDE_03 を参照してユーザー側で実施してください.」}」 + +## 中断時の処理 + +ユーザーが中断を希望した場合: + +1. 現在のフェーズと状態を CLAUDE.md の「開発進捗」(最新 1 行に上書き)と `docs/PROGRESS.md`(末尾に追記)に記録する +2. 本スキルではコミットしない.未コミットの成果物と進捗の更新があれば,書き出したファイルの一覧を示し,ユーザーに `/commit` でのコミットを案内する(コミットせずに中断してもよいが,その場合は未コミットの変更が残っていることを伝える) +3. 次回 `/setup` 実行時に続きから再開できるようにする + +「**進捗を記録しました.** + +現在の状態: フェーズ {N}({フェーズ名})まで完了 +未コミットのファイル: + +- {ファイルパス}(無ければ「なし」) + +未コミットのファイルがある場合は,`chore/project-setup` で `/commit` を実行してコミットしておくことを推奨します(メッセージ例: `{メッセージ例}`). +次回 `/setup` を実行すると,同じブランチでフェーズ {N+1} から再開します.」 diff --git a/.claude/skills/setup/reference.md b/.claude/skills/setup/reference.md new file mode 100644 index 0000000..a4cdccd --- /dev/null +++ b/.claude/skills/setup/reference.md @@ -0,0 +1,195 @@ +# setup リファレンス (Setup Reference) + +`/setup`(`SKILL.md`)から参照される実行手順集(`gh` CLI で行うリポジトリ設定,`.github/dependabot.yml` の生成規則,フェーズごとのコミットの案内,環境構築の実行).`/setup` の途中で AI が実行するほか,`/setup` 完了後にリポジトリを作成した場合などに管理者(人間)が単体で実行する runbook としても使う. + +## GitHub リポジトリのセキュリティ設定 (GitHub Repository Security Settings) + +GitHub の Settings → Code security にある **Dependabot alerts**(既知脆弱性の検出.UI では "Vulnerabilities" として表示される)と **Dependabot security updates**(脆弱性を直す PR の自動作成)は**リポジトリごとに既定で OFF** であり,有効化しないと依存パッケージの脆弱性が一切通知されない.モード(solo / team)に関わらず,プロジェクトの GitHub リポジトリが存在する時点で有効化する. + +### 前提 (Prerequisites) + +- `gh auth login` 済みであること(既定スコープ `repo` で足りる) +- 実行者がリポジトリの **admin 権限**を持つこと(無いと 403) +- カレントディレクトリがそのリポジトリの clone であること(`{owner}/{repo}` は `gh` がリモートから自動補完する) + +### 手順 (Procedure) + +1. リモートの有無を確認する: + + ```bash + gh repo view --json nameWithOwner -q .nameWithOwner + ``` + + - 失敗する(リモート未設定・リポジトリ未作成・`gh` 未認証)場合はこの手順をスキップする.`/setup` 中であれば「GitHub リポジトリ作成後に `/setup` を再実行するか,ENV_03 に記載したコマンドを実行してください」とユーザーに伝え,Phase 7 でもう一度試す +2. 有効化する(冪等なので再実行してよい): + + ```bash + gh api -X PUT repos/{owner}/{repo}/vulnerability-alerts # Dependabot alerts(依存グラフも同時に有効化される) + gh api -X PUT repos/{owner}/{repo}/automated-security-fixes # Dependabot security updates + ``` + +3. 有効化を検証する: + + ```bash + gh api repos/{owner}/{repo}/vulnerability-alerts # 成功(204)なら有効.404 なら無効 + gh api repos/{owner}/{repo}/automated-security-fixes # {"enabled":true,...} なら有効 + ``` + +4. 結果をユーザーに報告する + +### トラブルシューティング (Troubleshooting) + +| 症状 | 原因 | 対処 | +| --- | --- | --- | +| `gh repo view` が失敗 | リモート未設定・リポジトリ未作成・`gh` 未認証 | リポジトリ作成と `git remote add origin ...` 後に再実行.`gh auth status` で認証を確認 | +| `PUT` が 403 | リポジトリの admin 権限が無い,またはトークンのスコープ不足 | 管理者に依頼するか,GitHub の Settings → Code security → Dependabot から手動で ON にする | +| `PUT` が 404 | `{owner}/{repo}` の補完に失敗(リモート URL が GitHub でない等) | `gh api -X PUT repos///vulnerability-alerts` のように明示する | + +### 補足 (Notes) + +- `.github/dependabot.yml` は「依存バージョンの定期更新 PR」の設定であり,上記の脆弱性検出とは別物(次節).上記の有効化は `dependabot.yml` の有無に関係なく動く +- アカウント設定(Settings → Code security → "Automatically enable for new repositories")を ON にしておけば以後の新規リポジトリは自動で有効になるが,テンプレートとしてはリポジトリ単位で確実に有効化する前提で運用する + +## 依存バージョン更新の設定 (Dependabot Version Updates) + +`.github/dependabot.yml` を置くと,Dependabot が依存パッケージの新バージョンを定期的に検出して更新 PR を作る(脆弱性の有無に関係なく).`/setup` のフェーズ 3 で,技術スタック(`ENV_01_技術スタック.md`)に合わせて**必ず生成する**(作るかどうかはユーザーに聞かない.書き出したファイルで内容だけ確認してもらう).PR のノイズを抑えるため,週 1 回・マイナー/パッチをまとめる・同時オープン数を絞る,を既定とする. + +### エコシステム対応表 (Ecosystem Mapping) + +技術スタックから `package-ecosystem` を決める.`directory` はマニフェスト(`package.json`・`pubspec.yaml` 等)があるディレクトリ(ルートなら `/`).モノレポ等で複数箇所にある場合はエントリを分けるか `directories` を使う. + +| 技術スタック | `package-ecosystem` | マニフェスト例 | +| --- | --- | --- | +| Node.js(npm / pnpm / yarn) | `npm` | `package.json` | +| Dart / Flutter | `pub` | `pubspec.yaml` | +| Python(pip / Poetry / pipenv) | `pip` | `requirements.txt`・`pyproject.toml`・`Pipfile` | +| Python(uv) | `uv` | `pyproject.toml` + `uv.lock` | +| Rust | `cargo` | `Cargo.toml` | +| Go | `gomod` | `go.mod` | +| Ruby | `bundler` | `Gemfile` | +| PHP | `composer` | `composer.json` | +| Java / Kotlin(Gradle) | `gradle` | `build.gradle(.kts)` | +| Java(Maven) | `maven` | `pom.xml` | +| .NET | `nuget` | `*.csproj` | +| Swift | `swift` | `Package.swift` | +| Docker を使う場合 | `docker` | `Dockerfile` | +| Dev Containers を使う場合 | `devcontainers` | `.devcontainer/devcontainer.json` | +| GitHub Actions(CI) | `github-actions` | `.github/workflows/*.yml` | + +- `github-actions` は**常に含める**(立ち上げ時点で workflow が無くても害は無く,後から CI を足した時に自動で対象になる) +- Docker / Dev Containers は技術スタックまたは環境構築方針(GUIDE_01「環境構築」の基本方針)で採用している場合に含める + +### 雛形 (Template) + +以下を基に,対応表で決めたエコシステムごとに `updates` エントリを 1 つずつ作る(`{...}` を置き換える). + +```yaml +# 依存パッケージの定期更新 PR(Dependabot version updates) +# 脆弱性検出(Dependabot alerts / security updates)はリポジトリ設定で有効化済み(本ファイルとは独立に動く) +version: 2 +updates: + - package-ecosystem: "{npm / pub / pip ...}" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 5 + commit-message: + prefix: "[update]" + groups: + minor-and-patch: + update-types: + - "minor" + - "patch" + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + commit-message: + prefix: "[update]" + groups: + actions: + patterns: + - "*" +``` + +- `interval`: 既定は `weekly`.更新 PR を減らしたいプロジェクトは `monthly` にしてよい +- `groups`: マイナー/パッチ更新を 1 PR にまとめる.メジャー更新は破壊的変更を含みうるため個別 PR のまま残す +- `open-pull-requests-limit`: 既定 5.放置された更新 PR が溜まるのを防ぐ +- `commit-message.prefix`: Git 規約(`.claude/rules/git-conventions.md`)の `[タグ] 内容` に合わせて `[update]` を付ける.内容部分は Dependabot が英語で生成する(例: `[update] Bump foo from 1.2.0 to 1.3.0`).これは規約の許容例外とし,手で書き直さない +- Dependabot の PR も通常の PR と同じく `main` へ直接は入らない.CI(あれば)の結果とリリースノートを確認してマージする + +### 動作確認 (Verification) + +`main` に push された後,GitHub の Insights → Dependency graph → Dependabot で各エコシステムの「Last checked」と結果を確認できる.YAML の構文エラーはここにも表示される. + +## フェーズごとのコミット (Per-phase Commit) + +立ち上げは専用ブランチ `chore/project-setup` の上で行い,各フェーズの成果物をユーザーがエディタで確認した後,**ユーザー自身が `/commit` でコミットする**(フェーズごとに 1 コミットを推奨).`/setup` はブランチの作成と,コミットの案内(メッセージ例の提示)・コミット済みの確認だけを行い,`git commit`・`git push`・PR 作成・マージは行わない. + +### 手順 (Procedure) + +1. `/setup` が最初にファイルを書き換える前にブランチを用意する(再開時は作成済みのブランチをそのまま使う): + + ```bash + git branch --show-current # chore/project-setup なら,そのまま使う + git switch -c chore/project-setup # main にいる場合のみ作成する + ``` + + - コミットが 1 つも無い(`git log` が失敗する)場合はブランチを作れないため,ユーザーに最初のコミットを依頼してからブランチを切る +2. `/setup` はフェーズの成果物を書き出してレビューを受けた後,下表のメッセージ例を添えてユーザーに `/commit` を案内する(`.env` やクレデンシャル・生成物がコミット対象に入らないよう,`.gitignore` の整備を先に済ませる) +3. ユーザーが `/commit` を実行する(書式は `.claude/rules/git-conventions.md`: `[タグ] 内容`・日本語・末尾の句点なし) +4. `/setup` は次のフェーズに進む前に作業ツリーがクリーンか確認する.未コミットの変更が残っていれば,コミットしてから進むか,そのまま進むかをユーザーに確認する + + ```bash + git status --porcelain # 何も出力されなければクリーン + ``` + +### フェーズとコミットの対応 (Commit per Phase) + +| フェーズ | 主な対象 | コミットメッセージ例 | +| --- | --- | --- | +| 前提確認・モード選択 | `CLAUDE.md`(プロジェクト名・概要),`.claude/project-mode`,(team の場合)`settings.json`・`.gitignore` | `[update] プロジェクト名と開発モードを設定` | +| 1 方針決定 | `docs/03_PLAN/PLAN_01_要件定義書.md` | `[add] 要件定義書を作成` | +| 2 技術選定 | `docs/02_ENV/ENV_01_技術スタック.md` | `[add] 技術スタックを選定` | +| 3 環境構築 | `ENV_02`・`ENV_03`・`ENV_04`,`.gitignore`,`.github/dependabot.yml`,構築用ファイル(Dockerfile・devcontainer.json 等),(任意)`.claude/verify-profile.md` | `[add] 環境構築手順と設定ファイルを整備` | +| 4 仕様設計 | `docs/04_SPEC/SPEC_01_*.md` | `[add] 仕様設計書を作成` | +| 5 規約整備 | `.claude/rules/*.md`(コーディング規約等) | `[add] コーディング規約を整備` | +| 6 開発計画 | `docs/03_PLAN/PLAN_02_開発ステップ.md` | `[add] 開発ステップを策定` | +| 7 実装開始 | `CLAUDE.md`(最終更新),`docs/PROGRESS.md` | `[update] CLAUDE.md を立ち上げ完了状態に更新` | + +- フェーズ 3 は構築を実行し,その結果を手順書に反映してからレビューを受けるため,通常は 1 コミットでよい.ユーザーが分けたい場合は分けてよい(例: `[add] 環境構築手順と設定ファイルを整備` → `[update] 実際の構築結果を環境構築手順に反映`) +- 進捗(`CLAUDE.md` の進捗欄・`docs/PROGRESS.md`)を更新した場合は,そのフェーズのコミットに含めるよう案内する +- 承認を得た成果物がまだ無いフェーズ(対話だけで終わった)ではコミットを案内しない(空コミットを作らない) + +### 取り込み (Merging into main) + +全フェーズ完了後,ユーザーが `/commit push`(PR 作成まで)または `/commit merge`(マージまで)で `main` に取り込む(フェーズ 7 の最終更新が未コミットなら,そのコミットもまとめて行われる).`/setup` からは案内のみを行う. + +## 環境構築の実行 (Executing the Environment Setup) + +`/setup` のフェーズ 3 で `ENV_02_環境構築手順.md`・`ENV_03_管理者用環境構築手順.md` を書き出した後,その手順に従って実際に開発環境を構築する.手順書だけで終わらせないのは,未検証の手順書は誤りや前提の抜けを含みやすく,最初に詰まるのがその場のユーザーになるため. + +### 実行の分類 (What to Execute) + +| 区分 | 例 | 扱い | +| --- | --- | --- | +| リポジトリ内で完結する操作 | プロジェクトの初期化(`npm create`・`flutter create`・`cargo new` 等),依存インストール,設定ファイル生成,`.gitignore` 整備,ビルド・起動・テストの疎通確認,`gh` で行うリポジトリ設定(本書「GitHub リポジトリのセキュリティ設定」) | そのまま実行する | +| ツール導入(システムに入るもの) | SDK・ランタイム・CLI・パッケージマネージャのインストール,グローバル設定の変更,`docker build`,dev container の起動 | **実行するコマンドを提示して承認を得てから**実行する | +| ユーザーにしかできない操作 | 外部サービスのアカウント作成・コンソール操作・セキュリティルール変更,対話ログイン(`gh auth login`・`firebase login` 等),クレデンシャルの発行と貼り付け,実機・ブラウザでの最終確認 | 実行せずユーザーに依頼する(CLAUDE.md「手動確認が必要な作業」) | + +### 進め方 (Procedure) + +1. `ENV_02`(メンバー向け手順)を上から順に実行する.各手順の実行前に,上表のどの区分かを判定する +2. ツール導入は 1 つずつ,**実行するコマンド・OS とパッケージマネージャ・バージョン・入る場所(システム全体かプロジェクト内か)**を示して承認を得る.既に入っているものはバージョン確認(`node -v` 等)だけで済ませ,入れ直さない +3. ユーザーにしかできない操作に来たら,手順の番号と依頼内容を伝えて待つ.対話ログイン等は `! gh auth login` のように `!` を付けてその場で実行できることを案内する +4. `ENV_03`(管理者用手順)のうち `gh` で実行できるもの(リポジトリのセキュリティ設定)は実行する.リモート未作成なら Phase 7 で再試行する +5. 実行結果を記録し,**手順書を実行結果に合わせて直す**(誤ったコマンド・抜けている前提・OS 依存の差異・必要なバージョン).解決できなかった手順は該当箇所に「※ 未検証({エラーの要点})」と明記する +6. 確定した起動・テスト・lint・ビルドのコマンドを `ENV_04_開発コマンド.md` に書き出す(無ければ作成する) +7. 構築結果をユーザーに報告する(実行した手順/承認を得て導入したツール/ユーザーに依頼した手順/未検証のまま残した手順) + +### 注意 (Notes) + +- 生成物(`node_modules/`・ビルド出力・`.venv` 等)を作る前に `.gitignore` を整備する.先にインストールを走らせると `git add` が汚れる +- 失敗したコマンドを推測で変形して繰り返さない.2 回失敗したら手を止め,エラー全文と試したことを添えてユーザーに判断を仰ぐ +- 既存ファイルを上書きする初期化コマンドやグローバル設定の書き換えは,対象を確認したうえで承認を得る(空ではないディレクトリでの `create` 系は特に注意する) +- 環境構築が済んだら,疎通確認で使ったコマンド(起動・テスト)が `ENV_04` の記載と一致していることを確認する.一致しない場合は実行できたコマンドを正とする diff --git a/.claude/skills/sync-template/SKILL.md b/.claude/skills/sync-template/SKILL.md new file mode 100644 index 0000000..21b7eaa --- /dev/null +++ b/.claude/skills/sync-template/SKILL.md @@ -0,0 +1,333 @@ +--- +name: sync-template +model: inherit +description: "テンプレートリポジトリから最新の変更を取り込み,ルール変更に伴うコード修正を行う." +argument-hint: "" +--- + +あなたはテンプレート同期の担当者です. +テンプレートリポジトリから最新の変更を取り込み,必要に応じてプロジェクトのコードを修正してください. +bash ヘルパーの定義と各ファイルの個別マージ手順は本スキルの `reference.md`(`.claude/skills/sync-template/reference.md`)を参照する. + +実行環境: bash(Git Bash または Unix シェル)が必要.`mktemp`, `rm -rf`, `cat`, `awk`, `diff`, `git merge-file`, シェル変数展開を使用する. + +テンプレート URL: `https://github.com/rintoHasegawa/programming-template.git` + +## マージ必須ファイル (Merge-required Files) + +以下のファイルは「プロジェクト固有の内容 + テンプレートの共通ルール」のハイブリッドのため,**上書きせずマージする**.コピー処理(ステップ 5.3)に入る前に対象ファイルかを判定し,該当する場合はステップ 5.4 のマージ手順に分岐させる. + +| ファイル | 理由 | マージ方針 | +| --- | --- | --- | +| `.gitignore` | フレームワーク固有ルール(Flutter/Node 等)を保持する必要がある | テンプレート側の実効行で既存に含まれないもののみを追記.既存行は触らない | +| `CLAUDE.md` | プロジェクト名・開発進捗・固有規約を保持する必要がある | テンプレートで変更された共通セクション(必須ルール,エージェントチーム,ドキュメント構成等)のみを Edit で更新.プロジェクト固有セクションは触らない | +| `docs/PROGRESS.md` | プロジェクト固有の進捗ログを保持する必要がある | 既存ファイルがある場合は内容を上書きしない.テンプレート側の骨組み(タイトル・案内コメント)に差分があれば通知のみ行い手動マージを促す | +| `.gitattributes` | プロジェクトによって設定が異なる可能性がある | 差分を表示し,ユーザーに「上書き / マージ / スキップ」を問う | +| `.claude/settings.json` | team モードで SessionStart(check_sync) 配線を追加している等,プロジェクト固有の hook 設定・permissions を保持する必要がある | 既存の hooks・`permissions` を保持しつつ,テンプレート側で追加・変更された hook・allow ルールのみ統合.差分を表示しユーザーに確認 | +| `.claude/template-overrides.md` | プロジェクト固有の「テンプレート改変台帳」.登録内容はプロジェクト固有 | 既存ファイルがある場合は内容を上書きしない.テンプレート側の骨組み(説明文・記入例)に差分があれば通知のみ行い手動マージを促す(`docs/PROGRESS.md` と同じ扱い) | + +マージ処理の対象は **既存ファイルが存在する場合のみ**.初回同期(`.claude/template-sync-sha` がない状態)では全ファイルが A 扱いとなるが,これらのファイルはフレームワーク初期化(`flutter create` / `npm init` 等)や `/init` で既にプロジェクトに存在するのが通常なので,そのままマージ処理に入る.既存ファイルがない稀なケースに限り通常の `cp` で配置する. + +## プロジェクト固有改変ファイル (Project Overrides) + +マージ必須ファイル以外のテンプレート管理ファイル(`.claude/skills/*`,`.claude/rules/*`,`.claude/agents/*`,`.claude/hooks/*`,`docs/01_GUIDE/*` 等)も,プロジェクトの都合で**意図的に改変**されていることがある.これらを A/M のたびに `cp` で盲目的に上書きすると改変が失われるため,以下の 2 段構えで保護する. + +### 台帳に登録された改変(明示的保護) + +プロジェクトは意図的な改変を `.claude/template-overrides.md`(テンプレート改変台帳)に記録する(運用ルール: `.claude/rules/template-customization.md`).台帳の「台帳」節の表から `| \`パス\` | 方針 | 理由 | 記録日 |` 形式の行を読み,登録ファイルは **`cp` せず方針に従って処理する**(ステップ 5.5): + +| 方針 | 処理 | +| --- | --- | +| `keep` | 触らない.テンプレート側に変更があれば,その差分(前回同期版 → 最新)を表示して通知のみ行う | +| `merge` | base = 前回同期版(`$LAST_SHA`),ours = プロジェクト版,theirs = テンプレート最新版の **3-way マージ**で,テンプレート側の差分だけを取り込みプロジェクトの改変を保持する.競合があれば同期担当エージェント(= あなた)が両者の意図を保って解決し,ユーザーに確認する | +| `ask` | プロジェクトの改変内容とテンプレート側の差分を表示し,ユーザーに「上書き / マージ / スキップ」を問う | + +- パスが末尾 `/` で終わる行はディレクトリ指定であり,配下の全ファイルに同じ方針を適用する +- 台帳に載っているのにテンプレート側に存在しないパス(リネーム・削除済み,または記入ミス)は同期の最後に警告する(ステップ 5.8) +- 初回同期(`$LAST_SHA` 無し)では base が取れないため,`merge` は `ask` として扱う + +### 台帳に無い改変の検出(安全網) + +台帳に登録されていなくても,A/M 対象ファイルが**ローカルに存在し,かつ前回同期版(`$LAST_SHA` 時点のテンプレート版)と内容が異なる**場合,プロジェクト側で改変されている.これは上書きせずに「未登録改変」として集め(ステップ 5.3),まとめてユーザーに確認する(ステップ 5.6).確認では「意図的な改変 → 台帳に登録してマージ/保持」「意図しない差分(古い手修正等) → 上書き」を選べるようにし,選択に応じて台帳へ行を追加する. + +前回同期版に存在しない(テンプレートで新規追加された)パスにローカルファイルが既にある場合も同様に未登録改変として扱う(プロジェクトが独自に作ったファイルをテンプレート版で潰さない). + +初回同期(`$LAST_SHA` 無し)では比較対象が無いため検出は行わない. + +## 同期対象外ファイル (Skip-on-sync Files) + +以下のファイルはテンプレート紹介専用であり,テンプレートから作られた各プロジェクトには反映しない.コピー・上書き・削除のいずれも行わない. + +| ファイル | 理由 | 方針 | +| --- | --- | --- | +| `README.md` | テンプレートの README は GitHub の repo ページ向けのテンプレート紹介用.各プロジェクトは独自の README を持つべき | プロジェクト側にコピー・上書きしない.テンプレート側の追加・変更・削除も無視する | +| `.claude/tests/`(配下すべて) | テンプレート自身のメタテスト(skill・エージェント定義・README 等がテンプレートとして整合しているかを検証する).テンプレート固有の内容を前提とするため派生プロジェクトでは必ず失敗する | 同上 | + +判定はステップ 5.3 のループ内でマージ必須ファイル判定より先に行う.`SKIP_FILES` の末尾が `/` の項目はディレクトリ指定で,配下のすべてのパスに前方一致で適用する(`reference.md`「変数・判定ヘルパー」). + +## モード依存ファイル (Mode-gated / Team-layer Files) + +テンプレートは個人開発(solo)とチーム開発(team)の両モードを 1 つのリポジトリで提供する(GUIDE_03).以下の**チーム層ファイル**は team モードのプロジェクトにのみ配置し,solo モードのプロジェクトには同期しない. + +| ファイル | レイヤ | +| --- | --- | +| `docs/01_GUIDE/GUIDE_03_チーム開発ルール.md` | team | +| `.claude/hooks/check_sync.sh` | team | + +※ `task-create`・`task-start`(+ `reference.md`)・`task-handoff` の各 skill は**共通層**(solo でも Issue ベースのタスク管理に使う)であり,モードに関わらず通常どおり同期する. + +※ **共通層のファイルはチーム層ファイルの存在を前提にしてはならない**.前提にする場合は存在ガードを付け,solo では失敗ではなく skip・無効化になるようにする(例: `.claude/hooks/tests/test_hooks.py` の `TestCheckSync` は `check_sync.sh` が無ければ skip する).この不変条件はメタテスト `.claude/tests/test_mode_layers.py` が検査する. + +判定はプロジェクトの `.claude/project-mode`(`solo` または `team`.`/setup` が作成)で行う: + +- **`team`**: チーム層ファイルを通常どおり同期(A/M/D すべて反映). +- **`solo`**: チーム層ファイルを同期対象外ファイルと同様に**完全スキップ**(コピー・上書き・削除いずれもしない).solo プロジェクトは `/setup` 時にこれらを削除済みのため,再配置しない. +- **`.claude/project-mode` が存在しない**(本機能導入前に作られた既存プロジェクト): 安全側に倒して **`solo` 扱い**とし,チーム層を配置しない.同期の最後に「チーム開発なら `.claude/project-mode` に `team` と記入し再同期してください」と案内する. + +なお `.claude/project-mode` 自体はテンプレートに含まれない(`/setup` が各プロジェクトで生成する)ため,同期で触れることはない. + +## ステップ 1: 事前確認 + +`git status` でワーキングツリーがクリーンか確認する. + +**コミットされていない変更がある場合:** + +「⚠ コミットされていない変更があります. +先に変更をコミットするか,stash してから再度 `/sync-template` を実行してください.」 + +→ ここで処理を中断する. + +## ステップ 2: テンプレートを一時ディレクトリにクローン + +以下を実行する: + +```bash +TEMPLATE_URL="https://github.com/rintoHasegawa/programming-template.git" +TEMP_DIR=$(mktemp -d) +git clone "$TEMPLATE_URL" "$TEMP_DIR" +NEW_SHA=$(git -C "$TEMP_DIR" rev-parse HEAD) +``` + +## ステップ 3: 変更ファイルの特定 + +`.claude/template-sync-sha` の有無で処理を分岐する: + +**ファイルが存在する場合(2 回目以降の同期):** + +```bash +LAST_SHA=$(cat .claude/template-sync-sha) +``` + +- `NEW_SHA == LAST_SHA` の場合: + `rm -rf "$TEMP_DIR"` で一時ディレクトリを削除し,「テンプレートに新しい変更はありません.既に最新です.」と報告して終了する. + +- `NEW_SHA != LAST_SHA` の場合: + + ```bash + # A=追加, M=変更, D=削除, R=リネーム の種別付きで取得 + CHANGED_ENTRIES=$(git -C "$TEMP_DIR" diff --name-status "$LAST_SHA" HEAD) + ``` + + で変更されたファイルを種別付きで取得する. + +**ファイルが存在しない場合(初回同期):** + +テンプレートの全ファイルを「追加(A)」として対象に含める: + +```bash +CHANGED_ENTRIES=$(cd "$TEMP_DIR" && find . -type f -not -path "./.git/*" | sed 's|^\./||' | awk -v OFS='\t' '{print "A", $0}') +``` + +## ステップ 4: 変更一覧をユーザーに提示 + +取り込み対象のファイル一覧を種別ごとに整理してユーザーに提示する.マージ必須ファイルや台帳登録ファイルに変更がある場合は,**ユーザーが取り込み前に影響範囲を把握できるよう差分サマリーを先出しする**(取得方法は `reference.md`「変更一覧の差分サマリー」.ヘルパーが未定義ならステップ 5.2 の定義を先に実行してよい): + +「**テンプレートに以下の変更があります:** + +- 追加 (A): {ファイル一覧} +- 変更 (M): {ファイル一覧} +- 削除 (D): {ファイル一覧} +- リネーム (R): {旧名 → 新名} + +**⚠ マージ必須ファイル(上書きせず差分マージします):** + +- `.gitignore`(既存 {N} 行 / テンプレート {M} 行.既存の固有ルールを保持し,テンプレート側で追加されている {K} 行を追記) +- `CLAUDE.md`(既存にプロジェクト固有セクションが {L} 行.テンプレート更新セクションのみマージ) +- `docs/PROGRESS.md`(プロジェクト固有の進捗ログ.既存があれば内容を保持し,差分があれば通知のみ) +- `.gitattributes`(差分 {D} 行.処理方針をユーザーに確認) +- `.claude/settings.json`(既存の hooks・`permissions` を保持し,テンプレート側で追加・変更された hook・`permissions.allow` のルールのみ統合.permissions を更新した場合はセッション再起動が必要な旨を伝える) +- `.claude/template-overrides.md`(台帳の行を保持し,骨組みの差分があれば通知のみ) + +**📒 台帳登録済みのプロジェクト固有改変ファイルに変更があります(方針に従って処理します):** + +- `{パス}`(方針: {keep/merge/ask}.理由: {台帳の理由}) + +取り込みを開始します.」 + +## ステップ 5: ブランチ作成とファイル反映 + +### 5.1 ブランチ作成 + +`git checkout -b chore/sync-template` でブランチを作成する.既に同名のブランチが存在する場合は削除してから作り直す. + +### 5.2 判定ヘルパーの定義 + +`reference.md`「変数・判定ヘルパー」のブロックを実行し,以降の処理で使う変数と関数を定義する: + +- 一覧: `MERGE_FILES` / `SKIP_FILES` / `TEAM_LAYER_FILES`,開発モード `PROJECT_MODE`,作業ディレクトリ `WORK_DIR` +- 判定: `is_merge_file` / `is_skip_file` / `is_team_layer_file` / `override_policy`(台帳の方針)/ `is_diverged`(前回同期版からの改変有無) +- 差分表示・マージ: `show_template_delta` / `show_project_delta` / `three_way_merge` + +### 5.3 通常コピー対象の反映 + +`reference.md`「通常コピーループ」を実行する.各エントリは次の順で判定し,最初に該当した扱いになる: + +1. 同期対象外ファイル → 完全スキップ +2. solo モードのチーム層ファイル → 完全スキップ +3. マージ必須ファイル(既存あり) → 5.4 へ +4. 台帳登録ファイル(既存あり) → `$WORK_DIR/overrides.tsv` に記録し 5.5 へ(リネームで旧パスが登録済みの場合は `RENAMED` 行として記録) +5. 未登録改変ファイル(`is_diverged` が真) → `$WORK_DIR/diverged.tsv` に記録し 5.6 へ +6. 上記以外 → `cp` で配置・上書き + +### 5.4 マージ必須ファイルの個別処理 + +`$CHANGED_ENTRIES` に A/M/R* で含まれ,かつ既存ファイルが存在するマージ必須ファイルを,`reference.md`「マージ必須ファイルの個別手順」に従ってファイルごとに処理する.各手順の最後に `git diff` で結果を表示しユーザーに確認する. + +### 5.5 台帳登録ファイルの処理 + +`$WORK_DIR/overrides.tsv` の各行を方針ごとに処理する.差分の表示には `show_template_delta`(テンプレート側の差分: 何が変わったか)と `show_project_delta`(プロジェクト側の改変: 何を守るべきか)を使う. + +**`keep` の処理** + +触らない.テンプレート側に変更があった事実だけを伝える: + +「📒 `{file}` は台帳で `keep` のため取り込みません(理由: {台帳の理由}).参考までにテンプレート側の変更は以下です:」 + +→ `show_template_delta "$file"` の出力を要約して添える.取り込みたくなった場合は台帳の行を削除または `merge` に変更して再同期するよう案内する. + +**`merge` の処理** + +1. `three_way_merge "$file"` を実行する +2. 戻り値 0(競合なし): 「📒 `{file}` をマージしました(プロジェクトの改変を保持しつつテンプレートの差分を取り込み)」と伝え,`git diff -- "$file"` を表示してユーザーに確認する +3. 戻り値 1(競合あり): `$WORK_DIR/merged/$file` の競合箇所と `show_project_delta` / `show_template_delta` を Read し,同期担当エージェント(= あなた)が**プロジェクトの改変意図(台帳の理由)とテンプレート側の変更意図の両方を保つ**ように解決した内容で `$file` を Edit / Write する.競合マーカーは残さない.解決方針を説明し,`git diff -- "$file"` を表示してユーザーに確認する.両立不能と判断した場合はユーザーに「プロジェクト版優先 / テンプレート版優先」を問う +4. 戻り値 2(初回同期等で base 無し): `ask` の処理に切り替える + +**`ask` の処理** + +1. `show_project_delta "$file"` と `show_template_delta "$file"` を表示し,台帳の理由を添えてユーザーに問う: + - **上書き**: テンプレート版で置換(改変を破棄.台帳の行を削除するか確認する) + - **マージ**: `merge` の処理を行う(以後もマージでよければ台帳の方針を `merge` に更新するか確認する) + - **スキップ**: 触らない +2. 選択に応じて処理する + +**`RENAMED`(旧パスが台帳登録されているリネーム)の処理** + +旧パス `{old}` にプロジェクトの改変があり,テンプレートでは `{new}` にリネームされている.ユーザーに状況を示し,次のいずれかを選ばせる: + +- プロジェクト版(旧パス)を新パスへ `git mv` し,その上で台帳の方針(`keep` / `merge` / `ask`)に従って処理する(既定の推奨).台帳のパスも新パスに書き換える +- テンプレート版を新パスに `cp` し,旧パスは 5.7 の削除確認に回す(改変を破棄.台帳の行を削除する) + +### 5.6 未登録改変ファイルの確認 + +`$WORK_DIR/diverged.tsv` が空でない場合,ユーザーにまとめて提示する: + +「**⚠ 台帳に未登録ですが,プロジェクト側で改変されているテンプレート由来ファイルがあります(上書きしていません):** + +- `{file}`(前回同期版との差分 {N} 行)/ `{file}`(前回同期版に無し: プロジェクト独自ファイル) + +各ファイルについて処理を選んでください: + +1. **意図的な改変 → マージ**(プロジェクトの改変を保持しつつテンプレートの差分を取り込む.台帳に `merge` で登録) +2. **意図的な改変 → 保持**(テンプレート側の変更を取り込まない.台帳に `keep` で登録) +3. **意図しない差分 → 上書き**(テンプレート版で置換) + +判断材料としてプロジェクト側の改変内容を表示します.」 + +→ 各ファイルについて `show_project_delta "$file"` を表示する(長い場合は要約し,全文は求めに応じて出す). + +ユーザーの選択に応じて処理する: + +- **マージ**: 5.5 の `merge` の処理を行い,台帳の「台帳」表に `| \`{file}\` | merge | {ユーザーから聞いた理由} | {今日の日付} |` を追記する +- **保持**: 触らない.台帳に `keep` で同様に追記する +- **上書き**: `cp "$TEMP_DIR/$file" "$file"` で置換する +- `nobase`(テンプレート新規追加パスにプロジェクト独自ファイルが既にある)の場合は base が無いためマージできない.「保持(`keep` 登録)」「上書き」「プロジェクト版を別名に退避してテンプレート版を配置」から選ばせる + +理由を聞かずに勝手に台帳へ登録しない(理由は次回同期の判断根拠になる).ユーザーが理由を省略した場合は「(理由未記入.次回同期時に確認)」と記録する. + +### 5.7 削除候補の確認 + +`reference.md`「削除候補の抽出」で,削除候補(D およびリネーム元)のうち**ローカルに実在するファイルだけ**を `$DELETIONS` に得る.さらに台帳の方針で振り分ける: + +- 台帳で `keep` のファイルは削除候補から**自動的に除外**し,「📒 `{file}` はテンプレートから削除されましたが台帳で `keep` のため残します」と通知する +- 台帳で `merge` / `ask` のファイル,および 5.5 の `RENAMED` で「テンプレート版を採用」を選んだ旧パスは削除候補に残し,一覧に「(台帳: {方針}.理由: {理由})」を添えて確認する.削除する場合は台帳の行も削除する + +`$DELETIONS` が空でない場合,ユーザーに確認を求める: + +「**以下のファイルはテンプレートから削除されています:** + +{削除候補一覧} + +プロジェクトからも削除してよいですか?(残したいファイルがあれば指定してください)」 + +ユーザー確認後,対象ファイルを `rm` で削除する.プロジェクトが独自に残したいファイルは削除対象から除外する(今後も残すなら台帳に `keep` で登録するよう勧める). + +### 5.8 台帳の整合チェック + +`reference.md`「台帳の整合チェック」で,台帳に登録されているのにテンプレート最新版に存在しないパスを列挙する.該当があれば「⚠ 台帳の以下のパスはテンプレートに存在しません.リネーム/削除に追従してパスを直すか,プロジェクト独自ファイルなら台帳から行を削除してください: {一覧}」と通知する(自動では書き換えない). + +### 5.9 同期済み SHA の記録とクリーンアップ + +`reference.md`「クリーンアップ」を実行する(`.claude/template-sync-sha` に `$NEW_SHA` を記録し,`$TEMP_DIR`・`$WORK_DIR` を削除). + +## ステップ 6: 変更内容の分析 + +コピーされた変更を以下のカテゴリに分類する: + +- **ルール変更**: コーディング規約,Git 運用ルール,テスト方針等の変更(`.claude/rules/` 配下の変更を含む) +- **ドキュメント更新**: 手順書やガイドの改善 +- **設定変更**: CLAUDE.md や .claude/ 配下の変更 +- **その他**: 上記に該当しない変更 + +## ステップ 7: ルール変更に伴うコード修正 + +ステップ 6 で「ルール変更」に該当するものがある場合: + +1. 変更されたルールの内容を要約してユーザーに報告する +2. そのルール変更がプロジェクトの既存コードに影響するか分析する +3. 影響がある場合,修正が必要な箇所と修正内容を提示する +4. ユーザーの確認を得てから修正を実行する + +**影響がない場合:** + +「ルール変更に伴うコード修正は不要です.」 + +## ステップ 8: 結果報告 + +すべての作業が完了したら,結果を報告する: + +「**テンプレート同期が完了しました.** + +- ブランチ: `{ブランチ名}` +- 開発モード: `{PROJECT_MODE}`(チーム層ファイルは {team: 同期対象 / solo: スキップ}) +- 取り込んだ変更: {変更の要約} +- プロジェクト固有改変: 台帳登録 {N} 件を処理(keep {a} / merge {b} / ask {c}),未登録改変 {M} 件を確認(台帳に {K} 件追加) +- コード修正: {あり(内容)/なし} + +`/commit push` でプッシュと PR 作成ができます.」 + +`.claude/project-mode` が存在せず `solo` 扱いにした場合は,末尾に次を添える: + +「※ `.claude/project-mode` が未設定のため solo として同期しました.チーム開発にする場合は `/set-mode team` を実行してください(`.claude/project-mode` を手で書き換えるだけでは team 層は配置されません).」 + +## 注意事項 + +- 本コマンドは一時ディレクトリ(`mktemp -d`)に clone したテンプレートを Read / `cp` / `rm -rf` し,作業ファイルも一時ディレクトリに置く.`restrict_repo_access.py` フックはシステム一時ディレクトリを許可ゾーンとして例外扱いしており,本コマンドはそれに依存している(フックの例外を外すと本コマンドが動かなくなる) +- テンプレートリポジトリへの push は行わない +- コード修正はユーザーの確認なしに実行しない +- マージ必須ファイル(冒頭の表)は必ずステップ 5.4 の手順でマージする.盲目的な `cp` で上書きしない(フレームワーク固有の除外ルール,プロジェクト固有セクション,team の SessionStart(check_sync) 配線等が失われる) +- `/sync-template` は**プロジェクトの改変を黙って消さない**ことを最優先にする.テンプレート改変台帳(`.claude/template-overrides.md`)に登録されたファイルは `cp` せず方針(`keep` / `merge` / `ask`)に従ってステップ 5.5 で処理し,台帳に無くても前回同期版と内容が異なるローカルファイルは上書きせずステップ 5.6 でユーザーに確認する.判断に迷う場合は上書きせずユーザーに問う +- 台帳への登録は必ず理由を添える(ユーザーから聞く).理由の無い登録は次回同期時の判断材料にならない.台帳の書式(パス列はバッククォート囲み,方針列は `keep` / `merge` / `ask`)を崩さない +- 同期対象外ファイル(`README.md`)はテンプレート紹介用のためプロジェクトには反映しない.テンプレート側で追加・変更・削除があってもプロジェクトの該当ファイルは触らない +- チーム層ファイル(`GUIDE_03`/`check_sync.sh`)は `.claude/project-mode` が `team` のプロジェクトにのみ同期する.`task-*` skill は共通層のためモードに関わらず同期する.`solo`(または未設定)のプロジェクトには配置・更新・削除いずれもしない.`/sync-template` は「版の追従」のみを行い,**モードの切り替えはしない**.solo↔team の切替は `/set-mode ` を使う(team 層ファイルの配置/削除・`settings.json` 配線・`CLAUDE.md` の team 化/solo 化・`project-mode` 更新を一括で行う).`.claude/project-mode` を手で書き換えるだけでは切り替わらない +- テンプレートが管理するのは `.claude/` 配下のうち `agents/`,`skills/`,`rules/`,`hooks/`,`settings.json`,`template-sync-sha`,`template-overrides.md`(雛形のみ.登録内容はプロジェクト固有)のみ.`.claude/plans/`・`.claude/commit-context.md`・`.claude/verify-profile.md`(検証プロファイル.テンプレートが持つのは雛形 `.claude/skills/verify/profile-template.md` のみ)・`.claude/repo-access.json`(リポジトリ外アクセス制限フックの禁止リスト.GUIDE_01「環境構築」)等のプロジェクト固有ファイルはテンプレートに含まれないため同期対象外(`$CHANGED_ENTRIES` に現れず,コピー・上書き・削除のいずれも起きない) +- `chore/sync-template` ブランチは他の作業ブランチと混ぜず,作成後は速やかにマージすること.複数の作業ブランチで `/sync-template` を実行すると `.claude/template-sync-sha` がコンフリクトする.コンフリクト時は新しい(HEAD 側の)SHA を採用すること. diff --git a/.claude/skills/sync-template/reference.md b/.claude/skills/sync-template/reference.md new file mode 100644 index 0000000..d56f7ae --- /dev/null +++ b/.claude/skills/sync-template/reference.md @@ -0,0 +1,395 @@ +# テンプレート同期リファレンス (Template Sync Reference) + +`/sync-template`(`SKILL.md`)の各ステップから参照される bash ヘルパーと個別処理の詳細手順.方針(どのファイルをどう扱うか)と全体フローは `SKILL.md` が定義し,本ファイルはその「実装」を持つ. + +前提となるシェル変数(`SKILL.md` ステップ 2〜3 で設定): + +- `TEMP_DIR`: テンプレートを clone した一時ディレクトリ +- `NEW_SHA`: テンプレート HEAD の SHA +- `LAST_SHA`: 前回同期時のテンプレート SHA(`.claude/template-sync-sha`.初回同期では空) +- `CHANGED_ENTRIES`: `git diff --name-status` 形式の変更一覧(`statusfile[newfile]`) + +## 変数・判定ヘルパー (Variables & Helpers) + +ステップ 5.2 で定義する.以降のすべての処理が依存する: + +```bash +MERGE_FILES=(".gitignore" "CLAUDE.md" "docs/PROGRESS.md" ".gitattributes" ".claude/settings.json" ".claude/template-overrides.md") +SKIP_FILES=("README.md" ".claude/tests/") # 末尾 / はディレクトリ配下すべてを対象にする +TEAM_LAYER_FILES=( + "docs/01_GUIDE/GUIDE_03_チーム開発ルール.md" + ".claude/hooks/check_sync.sh" +) +OVERRIDES_FILE=".claude/template-overrides.md" + +# 作業用ファイル(コピーループは pipe のサブシェルで回るため,結果はファイルに書き出す) +WORK_DIR=$(mktemp -d) +: > "$WORK_DIR/overrides.tsv" # 台帳登録ファイル: statusfilepolicy(リネームは RENAMED旧新policy) +: > "$WORK_DIR/diverged.tsv" # 未登録改変ファイル: statusfile(base|nobase) + +# プロジェクトの開発モードを取得(未設定なら安全側で solo 扱い) +if [ -f .claude/project-mode ]; then + PROJECT_MODE=$(tr -d '[:space:]' < .claude/project-mode) +else + PROJECT_MODE="solo" +fi + +is_merge_file() { + local t="$1" + for f in "${MERGE_FILES[@]}"; do + [ "$f" = "$t" ] && return 0 + done + return 1 +} + +is_skip_file() { + local t="$1" + for f in "${SKIP_FILES[@]}"; do + case "$f" in + */) [[ "$t" == "$f"* ]] && return 0 ;; # ディレクトリ指定(末尾 /)は前方一致 + *) [ "$f" = "$t" ] && return 0 ;; + esac + done + return 1 +} + +is_team_layer_file() { + local t="$1" + for f in "${TEAM_LAYER_FILES[@]}"; do + [ "$f" = "$t" ] && return 0 + done + return 1 +} + +# 台帳(.claude/template-overrides.md)から対象パスの方針(keep/merge/ask)を返す.未登録なら空 +# - 「台帳」節以外の表やコードブロック内の行は読まない +# - 末尾 / の行はディレクトリ指定(前方一致) +override_policy() { + local t="$1" + [ -f "$OVERRIDES_FILE" ] || return 0 + awk -F'|' -v target="$t" ' + /^```/ { infence = !infence; next } + infence { next } + /^## / { insec = ($0 ~ /^## 台帳/) ; next } + !insec { next } + /^\|/ { + p=$2; gsub(/^[ \t]+|[ \t]+$/, "", p); gsub(/`/, "", p) + pol=$3; gsub(/^[ \t]+|[ \t]+$/, "", pol) + if (pol != "keep" && pol != "merge" && pol != "ask") next + if (p == target) { print pol; exit } + if (substr(p, length(p)) == "/" && index(target, p) == 1) { print pol; exit } + }' "$OVERRIDES_FILE" +} + +# 台帳に登録されている全パスを列挙する(整合チェック用) +override_paths() { + [ -f "$OVERRIDES_FILE" ] || return 0 + awk -F'|' ' + /^```/ { infence = !infence; next } + infence { next } + /^## / { insec = ($0 ~ /^## 台帳/) ; next } + !insec { next } + /^\|/ { + p=$2; gsub(/^[ \t]+|[ \t]+$/, "", p); gsub(/`/, "", p) + pol=$3; gsub(/^[ \t]+|[ \t]+$/, "", pol) + if (pol == "keep" || pol == "merge" || pol == "ask") print p + }' "$OVERRIDES_FILE" +} + +# ローカルの既存ファイルが前回同期版($LAST_SHA 時点のテンプレート版)から改変されているか +# 戻り値 0: 改変あり(stdout に base|nobase),1: 改変なし/判定不能 +is_diverged() { + local t="$1" + [ -n "$LAST_SHA" ] && [ -f "$t" ] || return 1 + if git -C "$TEMP_DIR" cat-file -e "$LAST_SHA:$t" 2>/dev/null; then + # 改行コード差(CRLF/LF)は改変とみなさない + if git -C "$TEMP_DIR" show "$LAST_SHA:$t" | diff -q --strip-trailing-cr - "$t" >/dev/null 2>&1; then + return 1 + fi + echo base; return 0 + fi + # 前回同期版に存在しない(テンプレート新規追加)のにローカルに既にある → 独自ファイル + echo nobase; return 0 +} + +# テンプレート側の差分(前回同期版 → 最新): 何が変わったのかを示す +show_template_delta() { # $1=file + if [ -n "$LAST_SHA" ] && git -C "$TEMP_DIR" cat-file -e "$LAST_SHA:$1" 2>/dev/null; then + git -C "$TEMP_DIR" diff "$LAST_SHA" HEAD -- "$1" + else + echo "(前回同期版なし: テンプレート最新版を全文表示)"; cat "$TEMP_DIR/$1" + fi +} + +# プロジェクト側の改変(前回同期版 → プロジェクト現行): 何を守るべきかを示す +show_project_delta() { # $1=file + if [ -n "$LAST_SHA" ] && git -C "$TEMP_DIR" cat-file -e "$LAST_SHA:$1" 2>/dev/null; then + git -C "$TEMP_DIR" show "$LAST_SHA:$1" | diff -u --strip-trailing-cr --label "template@$LAST_SHA" --label "project" - "$1" + else + diff -u --strip-trailing-cr --label "template@HEAD" --label "project" "$TEMP_DIR/$1" "$1" + fi +} + +# 3-way マージ: base=前回同期版, ours=プロジェクト, theirs=テンプレート最新 +# 戻り値 0: 競合なく $1 を更新, 1: 競合あり($WORK_DIR/merged/$1 に競合マーカー付き結果), 2: base 無しで実行不可 +three_way_merge() { # $1=file + local f="$1" base ours theirs out + [ -n "$LAST_SHA" ] && git -C "$TEMP_DIR" cat-file -e "$LAST_SHA:$f" 2>/dev/null || return 2 + mkdir -p "$WORK_DIR/3way/$(dirname "$f")" "$WORK_DIR/merged/$(dirname "$f")" + base="$WORK_DIR/3way/$f.base"; ours="$WORK_DIR/3way/$f.ours"; theirs="$WORK_DIR/3way/$f.theirs" + out="$WORK_DIR/merged/$f" + # 改行コードを LF に揃えてからマージする(CRLF 混在で全行競合するのを防ぐ) + git -C "$TEMP_DIR" show "$LAST_SHA:$f" | sed 's/\r$//' > "$base" + sed 's/\r$//' "$f" > "$ours" + sed 's/\r$//' "$TEMP_DIR/$f" > "$theirs" + if git merge-file -p --diff3 -L project -L "template@$LAST_SHA" -L template@HEAD "$ours" "$base" "$theirs" > "$out"; then + cp "$out" "$f"; return 0 + fi + return 1 +} +``` + +## 変更一覧の差分サマリー (Diff Summary) + +ステップ 4 で,マージ必須ファイル・台帳登録ファイルに変更がある場合に先出しする情報を取得する. + +マージ必須ファイルの差分プレビュー(該当ファイルが変更対象かつ既存ファイルがある場合のみ出力): + +```bash +# 既存ファイル行数 +wc -l "${MERGE_FILES[@]}" 2>/dev/null + +# テンプレート側の実効行数(.gitignore) +grep -vE '^\s*(#|$)' "$TEMP_DIR/.gitignore" | wc -l + +# 既存ファイルとテンプレート最新版の差分プレビュー +for f in "${MERGE_FILES[@]}"; do + [ -f "$f" ] && [ -f "$TEMP_DIR/$f" ] && { echo "=== $f ==="; diff -u "$f" "$TEMP_DIR/$f" | head -50; } +done +``` + +台帳登録ファイルの抽出(`CHANGED_ENTRIES` の A/M/R 対象のうち `override_policy` が空でないもの): + +```bash +echo "$CHANGED_ENTRIES" | while IFS=$'\t' read -r status file newfile; do + case "$status" in + A|M) t="$file" ;; + R*) t="$newfile" ;; + *) continue ;; + esac + p=$(override_policy "$t") + [ -n "$p" ] && echo "$t ($p)" +done +``` + +## 通常コピーループ (Copy Loop) + +ステップ 5.3 で実行する.判定は「同期対象外 → solo のチーム層 → マージ必須(既存あり)→ 台帳登録(既存あり)→ 未登録改変 → `cp`」の順.`cp` しなかったものはそれぞれ後段で処理する: + +```bash +echo "$CHANGED_ENTRIES" | while IFS=$'\t' read -r status file newfile; do + [ -z "$status" ] && continue + case "$status" in + A|M) + # 同期対象外ファイルは完全にスキップ(コピーも上書きもしない) + if is_skip_file "$file"; then + continue + fi + # solo モードではチーム層ファイルを完全スキップ(再配置しない) + if [ "$PROJECT_MODE" = "solo" ] && is_team_layer_file "$file"; then + continue + fi + # マージ必須ファイルで既存ファイルがある場合は 5.4 で処理 + if is_merge_file "$file" && [ -f "$file" ]; then + continue + fi + # 台帳登録済みのプロジェクト固有改変ファイル(既存あり)は 5.5 で方針に従って処理 + policy=$(override_policy "$file") + if [ -n "$policy" ] && [ -f "$file" ]; then + printf '%s\t%s\t%s\n' "$status" "$file" "$policy" >> "$WORK_DIR/overrides.tsv" + continue + fi + # 台帳に無いが前回同期版から改変されているファイルは上書きせず 5.6 で確認 + if kind=$(is_diverged "$file"); then + printf '%s\t%s\t%s\n' "$status" "$file" "$kind" >> "$WORK_DIR/diverged.tsv" + continue + fi + mkdir -p "$(dirname "$file")" + cp "$TEMP_DIR/$file" "$file" + ;; + R*) + # file=旧パス, newfile=新パス + if is_skip_file "$newfile"; then + continue + fi + if [ "$PROJECT_MODE" = "solo" ] && is_team_layer_file "$newfile"; then + continue + fi + if is_merge_file "$newfile" && [ -f "$newfile" ]; then + continue + fi + policy=$(override_policy "$newfile") + if [ -n "$policy" ] && [ -f "$newfile" ]; then + printf '%s\t%s\t%s\n' "$status" "$newfile" "$policy" >> "$WORK_DIR/overrides.tsv" + continue + fi + # 旧パスが台帳登録されている場合は,新パスに改変を引き継ぐべきか 5.5 で確認する + oldpolicy=$(override_policy "$file") + if [ -n "$oldpolicy" ] && [ -f "$file" ]; then + printf 'RENAMED\t%s\t%s\t%s\n' "$file" "$newfile" "$oldpolicy" >> "$WORK_DIR/overrides.tsv" + continue + fi + if kind=$(is_diverged "$newfile"); then + printf '%s\t%s\t%s\n' "$status" "$newfile" "$kind" >> "$WORK_DIR/diverged.tsv" + continue + fi + mkdir -p "$(dirname "$newfile")" + cp "$TEMP_DIR/$newfile" "$newfile" + ;; + esac +done +``` + +## マージ必須ファイルの個別手順 (Merge Procedures) + +ステップ 5.4 で,`$CHANGED_ENTRIES` に A/M/R* で含まれ,かつ既存ファイルが存在するマージ必須ファイルを以下の手順で処理する. + +### `.gitignore` + +1. 既存 `.gitignore` とテンプレート `$TEMP_DIR/.gitignore` を読む +2. テンプレート側の**実効行**(コメント `#` 行・空行を除く)を抽出する: + + ```bash + grep -vE '^\s*(#|$)' "$TEMP_DIR/.gitignore" + ``` + +3. 抽出した各行について,既存ファイルに完全一致で含まれていないものだけを収集する +4. 収集した行がある場合,既存ファイル末尾に以下のマーカー付きブロックで追記する: + + ```text + + # --- from template (sync-template) --- + {追加行} + ``` + + - 既存ファイル末尾に同じマーカーが既にある場合は,マーカー以降に追記して重複マーカーを作らない +5. `git diff .gitignore` で結果を表示しユーザーに確認する + +### `CLAUDE.md` + +同期担当エージェント(= あなた)が Read と Edit ツールを使ってセクション単位でマージする: + +1. 既存 `CLAUDE.md` とテンプレート版 `$TEMP_DIR/CLAUDE.md` を Read する +2. テンプレート側の変更箇所を特定する(初回同期は LAST_SHA がないためテンプレート全体を「更新候補」として扱う): + + ```bash + if [ -n "$LAST_SHA" ]; then + git -C "$TEMP_DIR" show "$LAST_SHA:CLAUDE.md" 2>/dev/null > "$WORK_DIR/claude_md_old" || true + diff -u "$WORK_DIR/claude_md_old" "$TEMP_DIR/CLAUDE.md" + fi + ``` + +3. セクションを以下の基準で分類する: + - **共通セクション(テンプレート管理,更新対象)**: 「必須ルール」「エージェントチーム」「Git 運用」「ドキュメント」節など,テンプレートに由来する節 + - **プロジェクト固有セクション(保持対象)**: 「プロジェクト名」「開発進捗」や,プロジェクトが追加した固有規約の節 +4. テンプレート側で変更があった共通セクションのみを Edit で既存ファイルに反映する.プロジェクト固有セクションは一切触らない +5. `git diff CLAUDE.md` で結果を表示しユーザーに確認する + +### `docs/PROGRESS.md` + +プロジェクト固有の進捗ログのため,**既存ファイルがある場合は内容を上書きしない**.テンプレート側の更新は骨組み(タイトル・案内コメント)に限定されるはずなので,差分があれば通知のみ行い手動マージを促す: + +1. 既存 `docs/PROGRESS.md` とテンプレート版 `$TEMP_DIR/docs/PROGRESS.md` を Read する +2. 差分を表示する: + + ```bash + diff -u docs/PROGRESS.md "$TEMP_DIR/docs/PROGRESS.md" + ``` + +3. 差分がある場合,「⚠ テンプレート側の `docs/PROGRESS.md` 骨組みに変更があります.既存の追記内容を保持したまま骨組みを反映したい場合はファイルを直接編集してください.自動マージは行いません.」と通知する +4. 既存ファイルが無い稀なケース(テンプレートを使わずに作られた古いプロジェクト等)に限り,テンプレート版をそのまま `cp` で配置する + +### `.gitattributes` + +1. 既存ファイルとテンプレート版の差分を表示する: + + ```bash + diff -u .gitattributes "$TEMP_DIR/.gitattributes" + ``` + +2. ユーザーに以下のいずれかを選ばせる: + - **上書き**: テンプレート版で既存を置換 + - **マージ**: 両者のルールを統合(具体的な統合内容は対話で決定) + - **スキップ**: 既存ファイルを維持 +3. 選択に応じて処理する + +### `.claude/settings.json` + +`settings.json` は hooks 設定を持つ.team モードのプロジェクトは PreToolUse(`restrict_repo_access.py`)に加えて SessionStart(`check_sync.sh`)を配線しているため,テンプレート版で盲目的に上書きするとプロジェクト固有の配線が失われる.同期担当エージェント(= あなた)が Read と Edit で JSON をマージする: + +1. 既存 `.claude/settings.json` とテンプレート版 `$TEMP_DIR/.claude/settings.json` を Read する +2. 差分を表示する: + + ```bash + diff -u .claude/settings.json "$TEMP_DIR/.claude/settings.json" + ``` + +3. **既存の hooks を保持したまま**,テンプレート側で追加・変更された hook(イベント・matcher・command)のみを統合する: + - 既存に無いイベント/hook はテンプレート版から追加する + - プロジェクト固有の hook(team の SessionStart `check_sync.sh` 等)は残す + - 同一 hook の command 変更(例: `restrict_repo_access.py` の起動方法変更)はテンプレート版に合わせる.旧形式の `python .claude/hooks/<名前>.py` は `bash .claude/hooks/run_python.sh [--fail-closed] .claude/hooks/<名前>.py` に置き換える(`--fail-closed` はテンプレート版と同じく `restrict_repo_access.py` にのみ付ける).`run_python.sh` が同期で配置済みであることも確認する + - 同一 hook の matcher 変更(例: `restrict_repo_access.py` の対象ツールに `NotebookEdit`・`PowerShell` を追加)もテンプレート版に合わせる.プロジェクトが独自に足したツール名があれば残す + - solo モードで SessionStart(check_sync) が無い場合は,team 専用の配線を勝手に追加しない +4. `hooks` 以外のキーも同じ方針でマージする.特に `permissions.allow` は,テンプレート側に追加されたルール(`Bash(gh pr merge:*)` 等.auto mode で ops-runner にマージを実行させるために必要)を既存へ追加し,プロジェクトが独自に足したルールは残す(削除しない) +5. `git diff .claude/settings.json` で結果を表示しユーザーに確認する.**permissions の変更はセッション再起動後に反映される**ため,追加があった場合は再起動が必要な旨をユーザーに伝える + +### `.claude/template-overrides.md` + +テンプレート改変台帳はプロジェクト固有の記録のため,**既存ファイルがある場合は内容を上書きしない**(`docs/PROGRESS.md` と同じ扱い): + +1. 差分を表示する: + + ```bash + diff -u .claude/template-overrides.md "$TEMP_DIR/.claude/template-overrides.md" + ``` + +2. 差分が骨組み(説明文・記入例)のみであれば,「⚠ テンプレート側の `.claude/template-overrides.md` の説明文に変更があります.台帳の行を保持したまま反映したい場合はファイルを直接編集してください.自動マージは行いません」と通知する +3. 既存ファイルが無い場合(本機能導入前に作られたプロジェクト)に限り,テンプレート版の雛形をそのまま `cp` で配置し,「台帳を新設しました.テンプレート由来ファイルを意図的に変更している箇所があれば登録してください(`.claude/rules/template-customization.md`)」と案内する + +## 削除候補の抽出 (Deletion Candidates) + +ステップ 5.7 で,削除候補(D およびリネーム元)を抽出し,**ローカルに実在するファイルだけ**に絞る: + +```bash +DELETIONS=$(echo "$CHANGED_ENTRIES" | awk -F'\t' '$1 == "D" {print $2} $1 ~ /^R/ {print $2}') + +# ローカルに実在するファイルのみを削除候補に残す +DELETIONS=$(echo "$DELETIONS" | while IFS= read -r f; do + [ -n "$f" ] && [ -f "$f" ] && echo "$f" +done) +``` + +この実在フィルタにより,テンプレート側のリネームや過去の移行でプロジェクトが持っていない旧パス,solo モードに配置されないチーム層ファイルは,削除確認に出ることなく自動的に除外される.台帳の方針による振り分け(`keep` は自動除外等)は `SKILL.md` ステップ 5.7 に従う. + +## 台帳の整合チェック (Override Consistency Check) + +ステップ 5.8 で,台帳に登録されているのにテンプレート最新版に存在しないパスを列挙する(リネーム・削除済み,またはパスの記入ミス): + +```bash +override_paths | while IFS= read -r p; do + [ -z "$p" ] && continue + case "$p" in + */) [ -d "$TEMP_DIR/$p" ] || echo "$p" ;; + *) [ -f "$TEMP_DIR/$p" ] || echo "$p" ;; + esac +done +``` + +## クリーンアップ (Cleanup) + +ステップ 5.9 で同期済み SHA を記録し,一時ディレクトリを削除する: + +```bash +echo "$NEW_SHA" > .claude/template-sync-sha +rm -rf "$TEMP_DIR" "$WORK_DIR" +``` diff --git a/.claude/skills/task-create/SKILL.md b/.claude/skills/task-create/SKILL.md new file mode 100644 index 0000000..b8bd98d --- /dev/null +++ b/.claude/skills/task-create/SKILL.md @@ -0,0 +1,47 @@ +--- +name: task-create +model: inherit +description: "Issue を作成しボードに追加する(着手はしない).タスクの事前計画用.solo / team 両モードで使用できる." +argument-hint: "<タスクの説明>" +--- + +あなたはタスク計画の司令塔です.Issue の**中身(タイトル・本文)はあなたが作り**,gh での起票・ボード操作は **ops-runner エージェント**(軽量モデル)に委譲します(使用量節約のため,gh 操作を自分では行わない). + +本スキルは **solo / team 両モードで使用できる**.team モードでは GUIDE_03(チーム開発ルール)のタスク管理方針にも従うこと.solo モードでは GUIDE_03 は存在しないため,本スキルと `.claude/skills/task-start/reference.md` の記述を基準とする. + +本コマンドは**タスクを事前に定義する**ためのものです.着手は別途 `/task-start ` で行います(または手動で).Issue はどの段階でも必須ではなく,Issue 無しで進める作業はこのコマンドを使う必要はありません. + +## ステップ 1: 前提確認 (Pre-check) + +$ARGUMENTS(タスクの説明)が指定されているか確認する.指定がなければ「タスクの説明を指定してください」と伝えて終了する. + +## ステップ 2: Issue 内容の作成 (Compose) + +$ARGUMENTS とセッションの文脈から,Issue タイトルと本文を確定させる(ここが品質の要.委譲しない). + +- **やること**は必ず書く +- **完了条件(受け入れ基準)**は非自明な場合のみ書く +- 担当者は**アサインしない**(誰が拾うかは着手時に決まる) + +## ステップ 3: 委譲 (Delegate) + +ops-runner エージェントを起動し,プロンプトに以下を含める: + +- 手順書: `.claude/skills/task-start/reference.md` の「/task-create 実行手順」を読んで従うこと +- 確定した Issue タイトル・本文(そのまま使い,書き換えないこと) +- 確認が必要な事態では停止して報告すること + +エージェントが確認事項(Project が複数ある等)で停止した場合は,ユーザーに確認して再委譲する. + +## ステップ 4: 完了サマリー (Summary) + +エージェントの報告をもとに以下を提示する: + +- **Issue**: 番号・タイトル・URL +- **ボード**: Todo(Project が無い場合は「未追加(ボード未使用)」と表記する) +- **次の手順**: 「このタスクに着手する場合は `/task-start ` を実行してください.」 + +## 注意事項 (Notes) + +- 複数タスクを一括で作る場合は,本コマンドを複数回実行してください +- Projects 操作には `project` スコープが必要.スコープエラーの停止報告が来たら `gh auth refresh -s project` をユーザーに案内する(`.claude/skills/task-start/reference.md`「必要なスコープ」) diff --git a/.claude/skills/task-handoff/SKILL.md b/.claude/skills/task-handoff/SKILL.md new file mode 100644 index 0000000..dcf4d98 --- /dev/null +++ b/.claude/skills/task-handoff/SKILL.md @@ -0,0 +1,94 @@ +--- +name: task-handoff +model: inherit +description: "現在着手中の Issue に進捗メモ(完了済み・残タスク・確定事項・git 状態・再開方法)をコメントで投稿する.セッション中断・引継ぎ用.solo / team 両モードで使用できる." +argument-hint: "<追加メモ(省略可)>" +--- + +あなたは作業引継ぎの司令塔です.現在**着手中**の Issue に,次セッション・次メンバーが再開できるだけの情報を進捗メモとして投稿します.引継ぎメモの**中身はあなたが書き**(セッションの文脈を知っているのはあなただけ),Issue の特定・git 状態の収集・コメント投稿は **ops-runner エージェント**(軽量モデル)に委譲します. + +本スキルは **solo / team 両モードで使用できる**.引継ぎ情報は Issue コメントに集約する. + +- **team モード**: GUIDE_03(チーム開発ルール)の「進捗は Issues / Projects と git 履歴で追う」方針に沿う(`CLAUDE.md` に進捗は書かない) +- **solo モード**: 通常の進捗記録(`.claude/rules/progress-log.md`.`CLAUDE.md` 進捗欄+ `docs/PROGRESS.md`)は従来どおり運用し,本スキルはタスク単位の引継ぎメモを Issue に残す補助として使う + +## ステップ 1: 収集の委譲 (Collect) + +ops-runner エージェントを起動し,`.claude/skills/task-start/reference.md` の「/task-handoff 実行手順」の**収集フェーズ**を実行させる. + +- 「着手中 0 件」の報告なら「着手中の Issue がありません.まず `/task-start ` で着手してから本コマンドを使ってください.」と伝えて終了する +- 複数件の報告なら,一覧(番号・タイトル)を示して「どれを対象にしますか?」とユーザーに確認し,対象を決めて必要な収集を再委譲する + +## ステップ 2: ドラフト生成 (Draft) + +収集データ(Issue 本文・過去コメント・git 状態)と,直近のセッション会話から抽出した「**確定した重要な設計判断**」「**未決定で次に詰める点**」をもとに,以下のフォーマットで進捗メモを生成する.**該当なしのセクションは省略してよい**. + +````markdown +## 進捗メモ (YYYY-MM-DD) + + + +### 完了済み + +| フェーズ / タスク | 成果物 | コミット | +| --- | --- | --- | +| ... | ... | <短縮 SHA> | + +### 残タスク + +- ... + +### 確定した重要な設計判断(再開時の文脈) + +- ... + +### 未決定で次に詰める点 + +- ... + +### 現在の git 状態 + +- ブランチ: `<ブランチ名>` +- HEAD: `<短縮 SHA>` +- 未コミット変更: <あり / なし> + +### 次セッションの再開方法 + +```bash +git checkout <ブランチ名> +``` + +<再開時の指針コメント.例: 「`/setup` を再実行するか,手動でフェーズ N から続行」> +```` + +### ドラフトの粒度方針 + +- **完了済み**: 当該セッションで積んだコミット中心.本 Issue に関わる範囲のみ +- **残タスク**: Issue 本文の TODO や未着手項目を整理 +- **確定した設計判断**: セッション中にユーザーと合意して**今後の作業の前提**になるもの.既に Issue 本文や過去コメントに明記されている内容は重複させない(差分のみ) +- **未決定で次に詰める点**: 次セッションで判断が必要なもの +- **粒度感**: 1 セッション分の引継ぎ.週次レポートではない + +## ステップ 3: 承認 (Approval) + +ドラフトをユーザーに提示し,「**この内容で投稿しますか? 修正点があれば指示してください.**」と確認する. + +ユーザーの明示的な同意(「OK」「投稿して」「これで」等)が得られるまで投稿しない.修正指示があれば反映して再提示する. + +## ステップ 4: 投稿の委譲 (Post) + +ops-runner エージェントを起動し,「/task-handoff 実行手順」の**投稿フェーズ**を実行させる(承認済みの本文をそのまま渡す).返ってきたコメント URL を控える. + +## 完了サマリー (Summary) + +以下を提示する: + +- **Issue**: 番号・タイトル・URL +- **コメント URL** +- **次のステップ**: 「次セッションで再開する場合は `git checkout <ブランチ名>` から始めてください.」 + +## 注意事項 (Notes) + +- 本コマンドは Issue を**クローズしない・Done に動かさない**.着手中のまま残す(作業継続前提).完了させたい場合は別途 `/commit merge` で PR をマージ → `Closes #<番号>` で自動クローズ +- 進捗メモは**コメント**として投稿する.Issue 本文は書き換えない +- Projects 操作には `project` スコープが必要.スコープエラーの停止報告が来たら `gh auth refresh -s project` をユーザーに案内する(`.claude/skills/task-start/reference.md`「必要なスコープ」) diff --git a/.claude/skills/task-start/SKILL.md b/.claude/skills/task-start/SKILL.md new file mode 100644 index 0000000..f584e0c --- /dev/null +++ b/.claude/skills/task-start/SKILL.md @@ -0,0 +1,44 @@ +--- +name: task-start +model: inherit +description: "既存 Issue を拾って作業を開始する(アサイン・ボード In Progress・作業ブランチ作成).solo / team 両モードで使用できる." +argument-hint: "" +--- + +あなたはタスク着手の司令塔です.着手準備(アサイン・ボード操作・作業ブランチ作成)の実行は **ops-runner エージェント**(軽量モデル)に委譲し,あなた自身はユーザー確認の仲介と結果の報告だけを行います(使用量節約のため,git・gh 操作を自分では行わない). + +本スキルは **solo / team 両モードで使用できる**.team モードでは GUIDE_03(チーム開発ルール)にも従うこと.solo モードでは GUIDE_03 は存在しないため,本スキルと本スキルの `reference.md` の記述を基準とする. + +本コマンドは既存 Issue から作業を始めるためのものです.新規 Issue を立てる場合は `/task-create` を使ってください.Issue 無しで小さい変更や試験的作業を進める場合はこのコマンドは不要です. + +## ステップ 1: 前提確認 (Pre-check) + +$ARGUMENTS を確認する.Issue 番号(数値または `#数値`)が指定されていなければ「対象の Issue 番号を指定してください.新規 Issue を立てる場合は `/task-create` を使います」と伝えて終了する. + +## ステップ 2: 委譲 (Delegate) + +ops-runner エージェントを起動し,プロンプトに以下を含める: + +- 手順書: 本スキルの `reference.md`(`.claude/skills/task-start/reference.md`)の「/task-start 実行手順」を読んで従うこと +- 対象の Issue 番号 +- **ブランチ名**: Issue の内容を把握している場合(同セッションで `/task-create` した直後等)は `.claude/rules/git-conventions.md` の命名規則で決めて渡す.把握していなければ ops-runner に規約どおり提案・作成させ,報告で名前を確認する +- 確認が必要な事態(未コミット変更・未マージブランチ・進行中タスクあり・他人アサイン等)では,該当項目をまとめて停止・報告すること + +## ステップ 3: 確認の仲介 (Confirm) + +エージェントが確認事項で停止した場合は,状況をユーザーに提示して判断を仰ぎ,承認された項目を「ユーザー承認済み」と明示して再委譲する. + +## ステップ 4: 完了サマリー (Summary) + +エージェントの報告をもとに以下を提示する: + +- **Issue**: 番号・タイトル・URL +- **ボード**: In Progress(Project が無い場合は「アサインのみ(ボード未使用)」と表記する) +- **ブランチ**: 作成したブランチ名 +- **次の手順**: 「`/implement` に対象 Issue の内容を渡して実装を開始してください(`gh issue view ` で取得できます).」 + +## 注意事項 (Notes) + +- branch↔Issue の紐付けは,後の PR 本文の `Closes #` で行う.ブランチ名には番号を入れない +- Projects 操作には `project` スコープが必要.スコープエラーの停止報告が来たら `gh auth refresh -s project` をユーザーに案内する(`reference.md`「必要なスコープ」) +- 新規 Issue を立てる場合は `/task-create`,Issue 無しで進める場合は本コマンドを使わず直接 `/implement` 等へ diff --git a/.claude/skills/task-start/reference.md b/.claude/skills/task-start/reference.md new file mode 100644 index 0000000..15cb66e --- /dev/null +++ b/.claude/skills/task-start/reference.md @@ -0,0 +1,112 @@ +# Issues・Projects gh 操作リファレンス (gh Operations Reference) + +`/task-create`・`/task-start`・`/task-handoff` の Issue・ボード操作で参照する詳細手順(solo / team 両モード共通). +team モードでの運用方針(Issue = 作業単位,直列運用,ボードの列等)は [GUIDE_03_チーム開発ルール](../../../docs/01_GUIDE/GUIDE_03_チーム開発ルール.md) に従う.solo モードでは GUIDE_03 は配置されないため,本ファイルと各 skill の記述を基準とする. + +- 操作は GitHub CLI(`gh`)に統一する.Web UI でも同じことはできるが,再現性とドキュメント化のため `gh` を基準とする. +- AI に Issue を読ませる場合は `gh issue view ` を使う(構造化出力は `--json title,body,assignees,labels`). + +## 必要なスコープ (Required Scopes) + +`gh` のトークンスコープが操作範囲を決める. + +| 操作 | 必要スコープ | +| --- | --- | +| Issues の読み書き | `repo` | +| Projects の読み書き | `repo` + `project` | + +スコープの確認と追加: + +```bash +gh auth status # 現在のスコープを確認 +gh auth refresh -s project # Projects 用スコープを追加(対話的な再認証が走る) +``` + +## ブランチと Issue のリンク (Branch-Issue Link) + +既存 Issue から作業ブランチを作る場合は `gh issue develop` を使うと,ブランチ作成と Issue↔ブランチのリンクを同時に行える(ブランチ名に依存しないリンク手段): + +```bash +gh issue develop --name feature/<概要> --base main +``` + +## /task-create 実行手順 (task-create Execution Flow) + +`/task-create` の司令塔(メインループ)から,確定済みの Issue タイトル・本文を受け取った ops-runner が実行する. + +1. **Project の特定**: `gh repo view --json owner --jq .owner.login` で所有者を取得し,`gh project list --owner ` で Project を特定する.複数ある場合は停止して確認事項として報告する.見つからない場合はボード追加を飛ばして進める(Project ボードは既定では未使用のため,無くても問題ない) +2. **Issue 作成**: 渡されたタイトル・本文を**そのまま**使い,`gh issue create --title "<タイトル>" --body "<本文>"` で作成する(本文を書き換えない).担当者はアサインしない +3. **ボードに追加**: 「Projects の操作」に従い `item-add` → Status を `Todo` に設定 → `item-list` で結果を確認する(Project が無い場合は飛ばす) +4. **報告**: Issue 番号・タイトル・URL・ボード状態を返す + +## /task-start 実行手順 (task-start Execution Flow) + +`/task-start` の司令塔から Issue 番号を受け取った ops-runner が実行する.ブランチ命名は `.claude/rules/git-conventions.md` に従う. + +1. **前提確認**: `git status` を確認し,未コミットの変更があれば停止して報告する.`git branch --show-current` を確認し,`main` 以外にいる場合は `git log main..HEAD --oneline` で未マージコミットを確認し,残っていれば停止して報告する(直列運用の原則) +2. **直列運用チェック**: 所有者を取得し Project を特定する(複数なら停止して報告.無ければボード操作を飛ばす).進行中の作業を数える(Project があれば `item-list` で Status が `In Progress` のもの,無ければ `gh issue list --assignee @me --state open`).対象 Issue 以外に 1 件以上あれば,該当タスクの一覧を添えて停止して報告する(禁止ではなく原則) +3. **対象 Issue の確認・アサイン**: `gh issue view ` で内容を確認する(存在しない・クローズ済み・他人がアサイン済みの場合は停止して報告する).`gh issue edit --add-assignee @me` で自分をアサインする +4. **ボードを In Progress へ**: 「Projects の操作」に従い,未追加なら `item-add` → Status を `In Progress` に変更 → `item-list` で結果を確認する(Project が無い場合は飛ばす) +5. **作業ブランチ作成**: `git checkout main && git pull origin main` で `main` を最新化し,`git checkout -b <ブランチ名>` する.ブランチ名は司令塔から渡されていればそれを使い,無ければ Issue タイトルから規約どおり(プレフィックス+英単語 2〜4 語.Issue 番号は含めない)決めて,決めた名前とプレフィックスの選択理由を報告に含める +6. **報告**: Issue 番号・タイトル・URL・ボード状態・作成したブランチ名を返す + +※ ステップ 1〜3 の停止条件に複数該当する場合は,まとめて 1 回の報告で返す(司令塔との往復を減らす).司令塔から「ユーザー承認済み」と明示された項目では停止しない. + +## /task-handoff 実行手順 (task-handoff Execution Flow) + +`/task-handoff` の司令塔から委譲される 2 つのフェーズ. + +### 収集フェーズ (Collect) + +1. 所有者を取得し Project を特定する(無くても問題ない).着手中の Issue を抽出する(Project があれば `item-list` で Status が `In Progress` のもの,無ければ `gh issue list --assignee @me --state open`) +2. 0 件ならその旨を報告して終了する.複数件なら一覧(番号・タイトル)を確認事項として返す +3. 対象 Issue について以下を収集して返す: + - `gh issue view --json number,title,body,comments`(本文と過去コメントを**原文のまま**返す.要約・取捨選択はしない.コメントが多い場合は直近の「進捗メモ」コメントを優先して原文で返し,古いものは件数と日付だけにする) + - `git branch --show-current`/`git rev-parse --short HEAD`/`git log main..HEAD --oneline`/`git status --porcelain` + +### 投稿フェーズ (Post) + +1. 司令塔から渡された本文を**そのまま** `gh issue comment --body "<本文>"` で投稿する(書き換えない) +2. コメントの URL を返す + +## Projects の操作 (Projects) + +> ⚠ **Projects(ボード)は既定では未使用**.以下は将来導入時の参考として残す. + +※ `gh project` 系は `project` スコープが必要(「必要なスコープ」参照).`` はリポジトリ所有者(個人またはオーガニゼーション). + +### 参照 (View) + +```bash +gh project list --owner # プロジェクト一覧(Project 番号を確認) +gh project view --owner # プロジェクトの概要 +gh project item-list --owner # ボード上のアイテム一覧 +gh project field-list --owner # フィールド(Status 等)と選択肢の ID を確認 +``` + +### Issue をボードに追加 (Add) + +```bash +gh project item-add --owner --url +``` + +### カードの状態(列)を移す (Move) + +`Status` フィールドの選択肢(`Todo` / `In Progress` 等)を変更する.フィールド ID と選択肢 ID は `field-list` で確認する. + +```bash +gh project item-edit \ + --id <アイテム ID> \ + --project-id <プロジェクト ID> \ + --field-id \ + --single-select-option-id <移動先の選択肢 ID> +``` + +※ ID の確認が煩雑なため,日常のカード移動は Web UI のボードで行い,一覧取得や自動化に `gh` を使う,という使い分けでもよい. + +## トラブルシューティング (Troubleshooting) + +- **`gh project` の書き込み系が無反応に見える**: `create` / `item-add` / `item-edit` は成功しても標準出力が空のことがある.`gh project list` / `item-list` で結果を確認する. +- **`gh project` がスコープエラーになる**: `project` スコープが未付与.`gh auth refresh -s project` を実行する. +- **コラボレーターが操作できない**: 招待を承認していない.`gh api "repos///collaborators" --jq '.[].login'` で承認済みメンバーを確認する. +- **`item-edit` の ID がわからない**: `gh project item-list` でアイテム ID,`gh project field-list` でフィールド ID・選択肢 ID を確認する. diff --git a/.claude/skills/verify/SKILL.md b/.claude/skills/verify/SKILL.md new file mode 100644 index 0000000..6d22575 --- /dev/null +++ b/.claude/skills/verify/SKILL.md @@ -0,0 +1,54 @@ +--- +name: verify +model: inherit +description: "ローカル環境(およびプロファイルが明示的に許可した検証用環境)で直前の変更の動作とエッジケースを verifier エージェントに確認させ,人間が確認すべき範囲を絞り込む.バグ修正や細かい修正の後,人間の動作確認の前段として使う.検証プロファイル(.claude/verify-profile.md)があるプロジェクトでのみ動作する." +argument-hint: "<確認してほしい内容・観点(省略時は現在の差分から判断する)>" +--- + +あなたは動作確認の司令塔です.確認そのものは **verifier エージェント**に委ね,あなたは**ブリーフの作成**と**結果の中継**を行います. +これは人間の動作確認を**置き換えるものではない**(CLAUDE.md「手動確認が必要な作業」).エッジケース等の機械的に確かめられる部分を先に潰し,人間には残りだけを見てもらうための前段である. + +## ステップ 1: 検証プロファイルの確認 (Profile) + +`.claude/verify-profile.md` が存在するか確認する.**存在しない場合は verifier を起動せず**,以下を伝えて終了する: + +「このプロジェクトでは Claude による自動検証が**未設定**です(`.claude/verify-profile.md` がありません). + +セットアップするには: + +1. `.claude/skills/verify/profile-template.md` を `.claude/verify-profile.md` にコピーする +2. 雛形の各項目(検証手段・検証対象の環境・実行コマンド・前提条件・認証/ロール・テストデータの規約・既知のノイズ・禁止操作・シナリオのテンプレート)をこのプロジェクトの内容で埋める.**検証対象の環境は既定でローカルのみ**であり,staging 等を使わせる場合だけ「影響が無い」と判断した根拠つきで許可リストに書く(書かれていない環境に Claude は接続しない) +3. 雛形の「実行基盤の要件」に従い,シナリオを実行するランナー(起動・実行・集計・後始末)をプロジェクト側に用意する + +用意ができるまでは,動作確認は従来どおり人間が行ってください.雛形の記入を手伝うこともできます.」 + +## ステップ 2: 対象の確認 (Scope) + +1. `git status` と `git diff`(未コミットの変更が無ければ `git diff main...HEAD`)で確認対象の変更を把握する +2. 変更が,プロファイルの検証手段で観測できる挙動に関係しない場合(ドキュメントのみ,設定のみ,テストのみ等)は,verifier を起動せず「今回の変更は自動検証の対象外です」と理由を添えて伝えて終了する +3. 変更が無く `$ARGUMENTS` も無い場合は,何を確認したいかをユーザーに尋ねる +4. 検証の前提を整えるのに破壊的な操作(データの初期化・マイグレーションの適用等)が必要な場合は,無断で実行せず,実施してよいかをユーザーに確認する + +## ステップ 3: verifier に委譲 (Delegate) + +verifier はこの会話を知らないため,以下を含む**自己完結のブリーフ**を渡す: + +- 何を変更したか(実装サマリーがあればそのまま.無ければ差分から要約)と,変更したファイル +- ユーザーが指定した確認観点: `$ARGUMENTS` +- 修正対象のバグがある場合は,その再現手順と期待される挙動 +- 検証の前提について確認済みの事項(ステップ 2 の 4 で確認した内容があれば) + +検証手段・検証対象の環境・実行コマンド・規約はブリーフに書かない(verifier が `.claude/verify-profile.md` を読む).ブリーフでローカル以外の環境へのアクセスを指示しない(許可はプロファイルでのみ与える). + +## ステップ 4: 結果の中継 (Report) + +verifier の検証サマリーを受け取り,判定に応じて提示する: + +- **OK**: 「Claude が確認した項目」の表と「人間が確認すべき残り項目」を提示する.残り項目は省略・要約しすぎない(人間が見る範囲そのものなので) +- **NG**: 発見した不具合を再現手順つきで提示し,修正するかをユーザーに尋ねる.**自分では修正を始めない**(`/implement` の中から呼ばれた場合はそちらの手順に従う) +- **検証不能**: 理由と,人間が解消するための具体的な手順を伝える.プロファイルの記述が実態と合っていないことが原因なら,プロファイルの更新を提案する + +verifier の「後始末」にローカル以外の環境(staging 等)へ残したデータが挙がっていたら,省略せずそのまま伝える(共有環境に残ったままになるため). +「気になった点」や不具合のうち**今回の変更と無関係な既存の問題**が挙がっていたら,今回の変更の問題と混ぜず別扱いで伝え,`/task-create` での起票を案内する. + +いずれの場合も,verifier の OK は**人間の動作確認の代わりにはならない**ことを最後に添える. diff --git a/.claude/skills/verify/profile-template.md b/.claude/skills/verify/profile-template.md new file mode 100644 index 0000000..4a7d4e4 --- /dev/null +++ b/.claude/skills/verify/profile-template.md @@ -0,0 +1,147 @@ +# 検証プロファイル (Verify Profile) + +本ファイルは**雛形**である.`.claude/verify-profile.md` にコピーし,各節の説明と記入例をこのプロジェクトの内容に置き換えて使う. + +```bash +cp .claude/skills/verify/profile-template.md .claude/verify-profile.md +``` + +`.claude/verify-profile.md` が存在するプロジェクトでのみ,`/verify` と `/implement` の Phase 1b(verifier による検証)が動作する.存在しなければ,検証はスキップされ従来どおり人間が動作確認する. +verifier はプロジェクト固有の知識を一切持たず,**このファイルだけ**を頼りに検証する.書かれていないことは推測されるか,実行されない. + +## 使い方 (How to Use) + +- コピー後,各節の「説明」(引用ブロック)は消してよい.verifier に読ませたい内容だけを残す +- 記入例は Web アプリと CLI/API の 2 通りを載せてある.該当しない方は削除する.種別は Web に限らない(デスクトップアプリ・バッチ・ライブラリのサンプル実行等でも,同じ枠に載せられる) +- 実行結果の出力先は `.gitignore` に追加する(スクリーンショット・ログ・一時データをコミットしない) +- 検証中に「プロファイルに書いていなかったせいで verifier が間違えた」ことが起きたら,その場で本ファイルに追記する(次回以降の精度が上がる) +- 本ファイルは**プロジェクト所有**であり,`/sync-template` で上書き・削除されない + +## 種別・検証手段 (Target & Method) + +> 何を,どういう手段で操作して検証するかを 1〜3 行で書く.verifier はここで検証の全体像を掴む. + +- 記入例(Web アプリ): Web アプリケーション.ヘッドレスブラウザ(Playwright + Chromium)でローカルの開発サーバー(`http://localhost:3000`)を操作する.API の確認はブラウザのリクエスト機能で行う +- 記入例(CLI/API): CLI ツール.ビルド済みバイナリをローカルで実行し,標準出力・終了コード・生成ファイルを検証する.HTTP API はローカルサーバーを起動して `curl` 相当のリクエストで確認する + +## 検証対象の環境 (Target Environments) + +> verifier がアクセスしてよい環境を書く.**既定はローカルのみ**であり,この節に何も書かなければローカル以外への接続は行われない. +> staging 等の検証用環境を使わせる場合は,接続先を許可リストとして挙げ,**「いじっても影響が無い」と判断した根拠を環境ごとに書く**. +> 判断の主体は人間(プロジェクト側)であり,**根拠の書かれていない環境は許可していないものとして扱われる**(verifier は,ここに無い接続先を見つけても自分の判断で「影響なし」とみなさない). +> **本番はここに書いても許可されない**(verifier はどんな場合もアクセスしない). + +- ローカルの接続先: `<例: http://localhost:3000(開発サーバー)・localhost:5432(開発 DB)>` +- ローカル以外で許可する環境: `<なし/下表のとおり>`(使わない場合は「なし」と書き,下表を削除する) + +| 環境 | 接続先(許可リスト) | 影響が無いと判断した根拠 | 資格情報の受け渡し | テストデータの後始末 | +| --- | --- | --- | --- | --- | +| `<例: staging>` | `<例: https://staging.example.com,https://api-staging.example.com>` | `<例: 本番とは別の DB を使う/実ユーザーがいない/メール・push 通知・課金が無効化されている>` | `<例: .env.verify.staging(.gitignore 済み.値は人間が配置する.検証専用アカウントのみ)>` | `<例: 「テストデータの規約」の接頭辞に従い,ランナーが実行の成否に関わらず物理削除する>` | + +- 接続を禁止する環境(拒否リスト): `<例: https://example.com,https://api.example.com(本番).どんな場合もアクセスしない>` + +※ 許可した環境は他の開発者や CI も使っている可能性があるため,verifier は自分が作った検証用データだけを操作し,環境全体に効く破壊的な操作(DB のリセット・マイグレーション・デプロイ・設定変更等)は行わない(`.claude/agents/verifier.md`). + +## 実行コマンド (Run Command) + +> シナリオの置き場所・実行方法・出力先を書く.verifier はここに書かれたコマンドしか実行しない(推測で別のコマンドを試させない). + +- シナリオの置き場所: `<例: .verify/<概要(kebab-case)>/scenario.<拡張子>>` +- 実行コマンド: `<例: node scripts/verify/run.mjs <シナリオのパス>>` +- 出力先: `<例: .verify/<概要>/run-<日時>/>`(`.gitignore` 済み).実行ごとに別ディレクトリへ出力され,前回の証拠は上書きされない +- 出力されるもの: `<例: result.json(判定の一覧)・*.png(スクリーンショット)・server.log(サーバーログ)>` + +※ verifier に許可されているコマンド(`.claude/agents/verifier.md` の `tools`)に含まれない実行コマンドは,実行のたびに許可を求められる.常用するなら `.claude/settings.json` の `permissions.allow` に追加するか,許可済みのランタイム経由で実行できる形にしておく. + +## 前提条件と確認方法 (Preconditions) + +> 満たされていなければ「検証不能」とする条件と,その確認コマンドを書く.verifier が**自力で解消してよい操作**があればそれも明記する(書いていない操作はしない). + +| 前提条件 | 確認方法 | 満たされない場合 | +| --- | --- | --- | +| `<例: 依存がインストール済み>` | `<例: ls node_modules>` | `<例: npm ci を実行してよい>` | +| `<例: ローカル DB が起動している>` | `<例: >` | `<例: <起動コマンド> を実行してよい>` | +| `<例: ブラウザ実行環境が導入済み>` | `<例: <バージョン確認コマンド>>` | `<例: 検証不能とし,導入コマンドを人間に案内する>` | +| `<例: 環境変数ファイルがある>` | `<例: ls .env.local>` | `<例: 検証不能(値は人間しか知らないため)>` | + +※ 開発サーバーの起動・停止は実行基盤(ランナー)が行う.verifier に手作業で起動させない. + +## 認証方法・ロール (Auth & Roles) + +> 認証をどう通すか,どのロール・ユーザーで検証できるかを書く.認証が無いプロジェクトは「認証なし」と書く. + +- 記入例(Web アプリ): ヘルパー `launchLoggedIn({ role })` がログイン済みのセッションを返す.`role` は `admin` / `editor` / `viewer` / `null`(未ログイン).ロールごとに別ユーザーなので,権限の確認はロールごとにセッションを分ける +- 記入例(CLI/API): `VERIFY_TOKEN` 環境変数にローカル発行のトークンが入る.ランナーが権限違いのトークンを 3 種類用意する +- 認証情報の扱い: **本番の資格情報は使わない**.ローカル以外では,「検証対象の環境」で許可した環境の**検証専用の資格情報だけ**を使う(受け渡し方法も同節に書く).資格情報を報告に書かない + +## テストデータの規約 (Test Data Rules) + +> 検証で作るデータの命名・後始末・触ってはいけないデータを書く.**検証データが本番やリポジトリに混入する事故はここの不備で起きる**. + +- 命名: 検証で作るデータの名前は必ず `<例: verify->` で始める +- 後始末: ランナーがシナリオ終了後に `<接頭辞>` で始まるデータと従属レコードを**成否に関わらず物理削除**する(論理削除では残る.残ると seed の書き出し等を通じてリポジトリへ混入しうる) +- 触ってはいけないデータ: `<例: シードデータ(既存の組織・ユーザー)は編集・削除しない.更新系の確認は自分で作った verify- データに対して行う>` +- 削除できなかったデータがあれば,verifier は報告の「後始末」に明記する + +## 待ち方・操作の癖 (Waiting & Selectors) + +> フレームワーク固有の注意点.「待てずに落ちる」「セレクタが当たらない」の原因になる癖をここに書く. + +- 記入例(Web アプリ): 画面遷移はクライアント遷移のため通信の完了では待てない.遷移は URL の変化で待ち,描画は目的の要素の出現で待つ.一覧はローディング表示が消えるのを待つ.固定時間の待機は最後の手段 +- 記入例(Web アプリ): セレクタはロール・ラベル・表示文言で指定する.推測で書かず,対象コンポーネントのソースで実際の文言を確認してから書く +- 記入例(Web アプリ): 入力エラーをメッセージではなく送信ボタンの `disabled` で表現している画面がある.対象画面の実装を見て検証方法を選ぶ +- 記入例(CLI/API): 非同期処理の完了は終了コードと出力ファイルの存在で判定する.ログの文言は変わりやすいので判定条件にしない + +## 既知のノイズ (Known Noise) + +> 不具合と誤認しやすい既知の現象.ここに書いていないものは verifier が不具合として報告する. + +- 記入例: 開発モードでは初回描画時にエフェクトが 2 回実行され,1 回だけ失敗するリクエストが記録される(本番ビルドでは起きない) +- 記入例: 計測ツールが挿入するスタイルの影響で,初回描画の差分警告が出ることがある +- 記入例: 初回起動時のみビルドに時間がかかり,最初のリクエストがタイムアウトしやすい(再実行で解消するなら不具合ではない) + +## 禁止操作 (Prohibited Operations) + +> 検証中に実行してはいけない操作.人間のローカル環境を壊す操作は必ずここに書く. + +- `<例: ローカル DB のリセットコマンド(人間の作業データが消える)>` +- `<例: 「検証対象の環境」で許可していない接続先へのアクセス(CLI・MCP 経由を含む).本番への操作は許可リストに関わらず禁止>` +- `<例: 許可した環境(staging 等)での,自分が作った検証データ以外の更新・削除,および環境全体に効く操作(リセット・マイグレーション・デプロイ・設定変更)>` +- 自分が起動していないプロセスの停止(人間が起動済みのサーバーには触らない) +- `<例: マイグレーションの適用(適用は人間に依頼する)>` + +## シナリオのテンプレート (Scenario Template) + +> そのままコピーして動く最小のシナリオを載せる.verifier はこれを出発点にシナリオを書く.ヘルパーの引数と,チェックの書き方(アサーションの方法)を必ず含める. + +```text +<例: 実際に動くシナリオのコードをここに貼る> + +- ヘルパーの一覧と引数(セッションの起動・チェックの記録・アサーション) +- 1 チェック = 「何がどうなるべきか」が分かる名前を付ける +- 失敗しても次のチェックへ進み,成否どちらでも証拠(スクリーンショット・出力)を残す +- 画面を伴わないチェック(API・CLI のみ)の書き方 +- 後始末(作成したデータ・起動したプロセス)はランナー側で行うため,シナリオには書かない +``` + +## 実行基盤の要件 (Runner Requirements) + +> 検証を安定させるための,実行基盤(ランナー)側の要件.シナリオの実行・環境の準備・後始末を担うスクリプトをプロジェクト側に用意し,以下を満たすこと(運用で分かった失敗を踏まえた要件であり,満たさないと検証が不安定になったり事故につながる). + +- **判断はエージェント,決定的な処理はスクリプト**に分ける.サーバーの起動・ログイン・結果の集計・後始末をエージェントに毎回手書きさせない +- **後始末はランナーが,シナリオの成否に関わらず確実に行う**(検証データの物理削除・一時ファイルの削除).エージェントの操作に任せない +- **停止するのは自分が起動したプロセスだけ**にする.プロセス名のパターンで一括終了しない(無関係なプロセスや自分自身を巻き込む).人間が起動済みのサーバーは使うだけで止めない +- **実行ごとに出力ディレクトリを分ける**(`run-<日時>/` 等).再実行で前回のスクリーンショット・結果を上書きしない(不具合の証拠が消える) +- **ローカルと,本ファイルの「検証対象の環境」の許可リスト以外の URL・接続先を拒否するガードをスクリプト側に持つ**.本番のホストは拒否リストとしても持ち,許可リストの設定ミスで到達しないようにする.指示書の禁止事項だけに頼らない +- **計測アーティファクト**(検証ツール自身が挿入する設定・スタイル等で挙動が変わる現象)に注意し,見つけたら本ファイルの「既知のノイズ」に追記する +- 前提条件を満たさない場合は,破壊的な復旧を試みずに**明確なエラーメッセージで終了**する(verifier が「検証不能」と報告できるようにする) + +## 参考実装 (Reference Implementation) + +実行基盤はプロジェクトの認証方式・データストアに強く依存するため,テンプレートには同梱していない.Web アプリ(ヘッドレスブラウザ)向けの実装例は以下を参照する. + +- 導入: [rintoHasegawa/KarutaMatcher PR #93](https://github.com/rintoHasegawa/KarutaMatcher/pull/93) +- 検証データ混入の修正(後始末の設計): [同 PR #94](https://github.com/rintoHasegawa/KarutaMatcher/pull/94) +- 初運用での改善: [同 PR #99](https://github.com/rintoHasegawa/KarutaMatcher/pull/99) + +※ 1 回の検証にかかるコストは規模にもよるが小さくない(目安: 10 分前後).挙動に関係しない変更で走らせないよう,`/implement` の Phase 1b には実行判定がある. diff --git a/.claude/template-overrides.md b/.claude/template-overrides.md new file mode 100644 index 0000000..ad8e738 --- /dev/null +++ b/.claude/template-overrides.md @@ -0,0 +1,30 @@ +# テンプレート改変台帳 (Template Overrides) + +本ファイルは,テンプレート(programming-template)由来のファイルのうち,**このプロジェクトの都合で意図的に変更したもの**を記録する台帳である.`/sync-template` は本台帳を読み,登録されたファイルをテンプレート版で盲目的に上書きせず,方針に従って処理する. +記録の運用ルールは `.claude/rules/template-customization.md` を参照(対象ファイル編集時に自動ロードされる). + +## 記録ルール (How to Record) + +- 1 行 = 1 ファイル.パスはリポジトリルートからの相対パスをバッククォートで囲む.末尾を `/` にするとそのディレクトリ配下すべてが対象になる(例: `` `.claude/skills/implement/` ``) +- 方針は次の 3 つから選ぶ + - `keep`: テンプレート側の変更を取り込まない.テンプレート側に変更があれば通知のみ行う + - `merge`: テンプレート側の差分(前回同期版 → 最新)だけを 3-way マージで取り込み,プロジェクトの改変は保持する(既定の推奨) + - `ask`: 同期のたびに差分を見せて,上書き/マージ/スキップを選ぶ +- 理由には「なぜテンプレートと変えたか」を書く(次回同期時に判断の根拠になる) +- テンプレートに追従する(改変をやめる)ときは行を削除する.次回 `/sync-template` でテンプレート版に上書きされる +- 表のパス列・方針列の書式は崩さない(`/sync-template` が機械的に読む).「台帳」節以外の表や,コードブロック内の行は読まれない + +## 台帳 (Overrides) + +| ファイル | 方針 | 理由 | 記録日 | +| --- | --- | --- | --- | + +## 記入例 (Example) + +```markdown +| ファイル | 方針 | 理由 | 記録日 | +| --- | --- | --- | --- | +| `.claude/skills/implement/SKILL.md` | merge | Phase 2 で E2E テストの実行も必須にしている | 2026-08-21 | +| `.claude/rules/markdown-style.md` | keep | 社内規約に合わせて句読点を「、。」に変更した | 2026-08-21 | +| `.claude/agents/` | ask | モデル指定を全エージェントで変更している.テンプレ側の変更は都度判断する | 2026-08-21 | +``` diff --git a/.claude/template-sync-sha b/.claude/template-sync-sha new file mode 100644 index 0000000..6173ba3 --- /dev/null +++ b/.claude/template-sync-sha @@ -0,0 +1 @@ +8750e8895853ac4e763c5b0de57df819e1784355 diff --git a/.claude/tests/test_mode_layers.py b/.claude/tests/test_mode_layers.py new file mode 100644 index 0000000..f9fcbc0 --- /dev/null +++ b/.claude/tests/test_mode_layers.py @@ -0,0 +1,308 @@ +"""共通層のテストが team 層ファイルに依存しないことのテスト(Issue #43). + +実行(リポジトリルートで): + python -B -m unittest discover -s .claude/tests -v + +仕様の正本: + 1. docs/01_GUIDE/GUIDE_01_プロジェクト立ち上げフロー.md「開発モードとファイル構成」のレイヤ表 + (共通層は solo・team 両方に存在する/team 層は team のみ) + 2. .claude/skills/sync-template/SKILL.md「モード依存ファイル」+ reference.md の + `TEAM_LAYER_FILES`(team 層は solo プロジェクトに配置・更新・削除いずれもしない) + および「同期対象外ファイル」= `SKIP_FILES`(`.claude/tests/` のみ.`.claude/hooks/tests/` は同期される) + 3. Issue #43「solo モードのプロジェクトで test_hooks.py の TestCheckSync が必ず失敗する」 + (期待する挙動: solo では 3 件が skip,team では従来どおり実行される) + +仕様から導かれる検証対象の性質: + `.claude/hooks/tests/test_hooks.py` は共通層として solo プロジェクトにも配布されるため, + team 層ファイル(`check_sync.sh`)を前提とするテストは,そのファイルが無い環境では + 「失敗」ではなく「skip」にならなければならない.逆にファイルがある環境(team・本テンプレート) + では skip されず実行されなければならない. + +検証方法: + 一時ディレクトリに solo 構成(team 層ファイル無し)と team 構成(有り)の疑似プロジェクトを作り, + そこで `test_hooks.py` を子プロセスで走らせて振る舞いを観測する.実装(skipUnless か skipTest か) + ではなく,外から見える結果(skip されるか/失敗しないか)を確認する. + +`.claude/tests/` はテンプレート自身のメタテスト置き場であり,`/sync-template` の同期対象外 +(`SKIP_FILES`)として派生プロジェクトには配布しない. +""" + +from __future__ import annotations + +import ast +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile +import unittest + +TESTS_DIR = os.path.dirname(os.path.abspath(__file__)) +REPO_ROOT = os.path.dirname(os.path.dirname(TESTS_DIR)) + +HOOKS_DIR = os.path.join(REPO_ROOT, ".claude", "hooks") +HOOKS_TESTS_DIR = os.path.join(HOOKS_DIR, "tests") +TEST_HOOKS = os.path.join(HOOKS_TESTS_DIR, "test_hooks.py") +SYNC_SKILL = os.path.join(REPO_ROOT, ".claude", "skills", "sync-template", "SKILL.md") +SYNC_REFERENCE = os.path.join(REPO_ROOT, ".claude", "skills", "sync-template", "reference.md") +GUIDE_01 = os.path.join(REPO_ROOT, "docs", "01_GUIDE", "GUIDE_01_プロジェクト立ち上げフロー.md") + +TIMEOUT = 300 + + +def read(path: str) -> str: + with open(path, "r", encoding="utf-8") as f: + return f.read() + + +def team_layer_files() -> list[str]: + """仕様(sync-template/reference.md の TEAM_LAYER_FILES)から team 層ファイルの一覧を読む.""" + text = read(SYNC_REFERENCE) + m = re.search(r"TEAM_LAYER_FILES=\((.*?)\)", text, flags=re.DOTALL) + assert m is not None, "reference.md に TEAM_LAYER_FILES の定義が見つからない" + return re.findall(r'"([^"]+)"', m.group(1)) + + +def team_layer_hook_files() -> list[str]: + """team 層のうち `.claude/hooks/` 配下のもの(共通層テストが触れうる対象).""" + return [f for f in team_layer_files() if f.startswith(".claude/hooks/")] + + +def sim_env() -> dict: + """疑似プロジェクトで test_hooks.py を動かすための環境変数. + + test_hooks.py はモジュール読み込み時に bash を探し,見つからないと SkipTest を投げる + (= 観測したい skip 件数がマシン依存になる).HOOK_TEST_BASH はその探索を上書きする + テスト用フックなので,ダミー値を渡して結果を決定的にする.solo・team いずれの検証でも + 実際に bash を起動するテストは実行しないため,値は使われない. + """ + env = dict(os.environ) + env["HOOK_TEST_BASH"] = os.path.join("no-such-dir", "bash") + env["PYTHONIOENCODING"] = "utf-8" + return env + + +def make_project(root: str, include_team_layer: bool) -> str: + """疑似プロジェクトを作る.共通層(hooks のテスト等)を配置し,team 層は指定に応じて置く. + + 戻り値はプロジェクトルート(`.claude/` の親). + """ + project = os.path.join(root, "team" if include_team_layer else "solo") + dest_hooks = os.path.join(project, ".claude", "hooks") + shutil.copytree(HOOKS_DIR, dest_hooks, ignore=shutil.ignore_patterns("__pycache__")) + if not include_team_layer: + for rel_path in team_layer_hook_files(): + target = os.path.join(project, *rel_path.split("/")) + if os.path.exists(target): + os.remove(target) + return project + + +def run_check_sync_tests(project: str) -> subprocess.CompletedProcess: + """疑似プロジェクトで check_sync 関連のテストだけを実行する.""" + tests_dir = os.path.join(project, ".claude", "hooks", "tests") + return subprocess.run( + [sys.executable, "-B", "-m", "unittest", "discover", "-s", tests_dir, "-k", "CheckSync", "-v"], + capture_output=True, + cwd=project, + env=sim_env(), + timeout=TIMEOUT, + ) + + +INSPECT_SCRIPT = """ +import json, os, sys +sys.path.insert(0, sys.argv[1]) +import test_hooks + +cls = getattr(test_hooks, "TestCheckSync", None) +print(json.dumps({ + "found": cls is not None, + "skipped": bool(getattr(cls, "__unittest_skip__", False)), + "reason": str(getattr(cls, "__unittest_skip_why__", "")), + "check_sync_path_exists": os.path.exists(test_hooks.CHECK_SYNC), +})) +""" + + +def inspect_check_sync_class(project: str) -> dict: + """疑似プロジェクトで test_hooks を読み込み,TestCheckSync の skip 状態を観測する.""" + tests_dir = os.path.join(project, ".claude", "hooks", "tests") + result = subprocess.run( + [sys.executable, "-B", "-c", INSPECT_SCRIPT, tests_dir], + capture_output=True, + cwd=project, + env=sim_env(), + timeout=TIMEOUT, + ) + stdout = result.stdout.decode("utf-8", errors="replace") + stderr = result.stderr.decode("utf-8", errors="replace") + assert result.returncode == 0, "test_hooks の読み込みに失敗した:\n" + stderr + return json.loads(stdout.strip().splitlines()[-1]) + + +class TempProjectMixin: + def setUp(self): + super().setUp() + self.root = tempfile.mkdtemp(prefix="mode-layers-") + self.addCleanup(shutil.rmtree, self.root, True) + + +# --------------------------------------------------------------------------- +# 前提: 共通層/team 層の切り分けが仕様どおりであること +# --------------------------------------------------------------------------- +class TestLayerPremises(unittest.TestCase): + def test_check_sync_is_team_layer(self): + """check_sync.sh は team 層として仕様の 3 箇所(reference・SKILL・GUIDE_01)に載っている.""" + self.assertIn(".claude/hooks/check_sync.sh", team_layer_files()) + self.assertRegex(read(SYNC_SKILL), r"\|\s*`\.claude/hooks/check_sync\.sh`\s*\|\s*team\s*\|") + self.assertIn(".claude/hooks/check_sync.sh", read(GUIDE_01)) + + def test_hooks_tests_are_synced_to_all_projects(self): + """`.claude/hooks/tests/` は同期対象外でも team 層でもない(= 共通層として solo にも配る).""" + skip_files = re.search(r"SKIP_FILES=\((.*?)\)", read(SYNC_REFERENCE), flags=re.DOTALL) + self.assertIsNotNone(skip_files, "reference.md に SKIP_FILES の定義が見つからない") + entries = re.findall(r'"([^"]+)"', skip_files.group(1)) + for entry in entries: + self.assertFalse( + ".claude/hooks/tests/".startswith(entry), + "`.claude/hooks/tests/` が同期対象外になっている: " + entry, + ) + for entry in team_layer_files(): + self.assertFalse( + ".claude/hooks/tests/test_hooks.py".startswith(entry.rstrip("/")), + "test_hooks.py が team 層扱いになっている: " + entry, + ) + + +# --------------------------------------------------------------------------- +# 本体: solo(team 層無し)では skip,team(有り)では実行される +# --------------------------------------------------------------------------- +class TestCheckSyncGuard(TempProjectMixin, unittest.TestCase): + def test_solo_project_skips_instead_of_failing(self): + """team 層が無い構成でも失敗せず,check_sync のテストがすべて skip される(Issue #43 の期待値).""" + project = make_project(self.root, include_team_layer=False) + result = run_check_sync_tests(project) + output = result.stdout.decode("utf-8", errors="replace") + result.stderr.decode("utf-8", errors="replace") + self.assertEqual(result.returncode, 0, "team 層が無い環境でテストが失敗した:\n" + output) + self.assertNotIn("FAILED", output, output) + ran = re.search(r"Ran (\d+) tests?", output) + self.assertIsNotNone(ran, output) + self.assertGreaterEqual(int(ran.group(1)), 3, "check_sync のテストが収集されていない:\n" + output) + skipped = re.search(r"OK \(skipped=(\d+)\)", output) + self.assertIsNotNone(skipped, "skip されずに実行されている:\n" + output) + self.assertEqual( + int(skipped.group(1)), + int(ran.group(1)), + "team 層が無い環境で実行されてしまったテストがある:\n" + output, + ) + + def test_solo_skip_reason_mentions_team_layer(self): + """skip の理由が「team 層ファイルが無いこと」だと分かる(原因不明の skip にしない).""" + project = make_project(self.root, include_team_layer=False) + info = inspect_check_sync_class(project) + self.assertTrue(info["found"], "TestCheckSync が見つからない") + self.assertFalse(info["check_sync_path_exists"], "疑似 solo 構成に check_sync.sh が残っている") + self.assertTrue(info["skipped"], "team 層が無いのに skip されない: " + json.dumps(info)) + self.assertTrue( + re.search(r"check_sync|team", info["reason"]), + "skip 理由から team 層依存だと分からない: " + repr(info["reason"]), + ) + + def test_team_project_runs_the_tests(self): + """team 層がある構成では skip されない(ガードが過剰に効いていない).""" + project = make_project(self.root, include_team_layer=True) + info = inspect_check_sync_class(project) + self.assertTrue(info["found"], "TestCheckSync が見つからない") + self.assertTrue(info["check_sync_path_exists"], "疑似 team 構成に check_sync.sh が無い") + self.assertFalse(info["skipped"], "team 層があるのに skip されている: " + json.dumps(info)) + + def test_template_itself_runs_the_tests(self): + """本テンプレート(team 層を含む正本)でも skip されない.""" + info = inspect_check_sync_class(REPO_ROOT) + self.assertTrue(info["check_sync_path_exists"]) + self.assertFalse(info["skipped"], "テンプレート自身で TestCheckSync が skip されている") + + +# --------------------------------------------------------------------------- +# 一般化: 共通層テストが team 層ファイルに無防備に依存していないこと +# --------------------------------------------------------------------------- +class TestCommonTestsDoNotRequireTeamLayer(unittest.TestCase): + def setUp(self): + self.tree = ast.parse(read(TEST_HOOKS), filename=TEST_HOOKS) + + def team_layer_constants(self) -> dict[str, str]: + """モジュール直下の定数のうち,team 層ファイルを指すもの(定数名 -> 相対パス).""" + basenames = {os.path.basename(f): f for f in team_layer_hook_files()} + found = {} + for node in self.tree.body: + if not isinstance(node, ast.Assign): + continue + literals = [n.value for n in ast.walk(node.value) if isinstance(n, ast.Constant) and isinstance(n.value, str)] + for name, rel_path in basenames.items(): + if name in literals: + for target in node.targets: + if isinstance(target, ast.Name): + found[target.id] = rel_path + return found + + def test_team_layer_paths_are_referenced_by_constants(self): + """前提の確認: team 層ファイルはモジュール定数として定義されている(未定義なら依存が無い).""" + constants = self.team_layer_constants() + self.assertIn( + "CHECK_SYNC", + constants, + "check_sync.sh を指す定数が test_hooks.py に見つからない(仕様の前提が変わった可能性)", + ) + + def test_classes_using_team_layer_files_are_guarded(self): + """team 層ファイルを使うテストクラスには存在ガード(skipUnless/skipIf)が付いている.""" + constants = self.team_layer_constants() + self.assertTrue(constants, "team 層ファイルを指す定数が無い") + checked = 0 + for node in self.tree.body: + if not isinstance(node, ast.ClassDef): + continue + used = {n.id for n in ast.walk(node) if isinstance(n, ast.Name)} & set(constants) + if not used: + continue + checked += 1 + with self.subTest(cls=node.name, uses=sorted(used)): + decorators = [] + for dec in node.decorator_list: + target = dec.func if isinstance(dec, ast.Call) else dec + decorators.append(target.attr if isinstance(target, ast.Attribute) else getattr(target, "id", "")) + self.assertTrue( + {"skipUnless", "skipIf"} & set(decorators), + node.name + " が team 層ファイル " + ", ".join(sorted(used)) + " を存在ガード無しで使っている", + ) + guard_source = " ".join(ast.dump(dec) for dec in node.decorator_list) + self.assertIn( + "exists", + guard_source, + node.name + " のガードがファイルの存在判定になっていない", + ) + self.assertGreater(checked, 0, "team 層ファイルを使うテストクラスが見つからない") + + def test_no_module_level_access_to_team_layer_files(self): + """モジュール読み込み時点で team 層ファイルを開かない(インポートだけで壊れない).""" + constants = self.team_layer_constants() + for node in self.tree.body: + if isinstance(node, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)): + continue + for call in (n for n in ast.walk(node) if isinstance(n, ast.Call)): + func = call.func + name = func.attr if isinstance(func, ast.Attribute) else getattr(func, "id", "") + if name not in ("open", "run", "check_output", "check_call"): + continue + used = {n.id for n in ast.walk(call) if isinstance(n, ast.Name)} & set(constants) + self.assertFalse( + used, + "モジュール直下で team 層ファイルを使っている: " + ", ".join(sorted(used)), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/.claude/tests/test_setup_flow.py b/.claude/tests/test_setup_flow.py new file mode 100644 index 0000000..75c09e7 --- /dev/null +++ b/.claude/tests/test_setup_flow.py @@ -0,0 +1,400 @@ +"""/setup の「書き出してからレビュー」・コミットの人間への委譲・環境構築の実行のテスト. + +実行(リポジトリルートで): + python -B -m unittest discover -s .claude/tests -v + +仕様の正本(Issue #47 および直前の承認済み仕様): + 1. `/setup` は各フェーズの成果物を最初から保存先ファイルに書き出し,チャットには + 「書き出したファイル一覧」と「判断してほしい点・Claude が決めた点」だけを示す(全文を出さない). + 修正指示はファイルに直接反映する + 2. `/setup` はフェーズごとの自動コミットをしない.コミットは人間が `/commit` で行う + (`chore/project-setup` ブランチの作成は `/setup` がしてよい.push・PR・マージもしない) + 3. 次のフェーズへは作業ツリーがクリーンなことを確認してから進む.未コミットのまま進むかは人間に確認する + 4. `/setup` は `/commit` 自発実行禁止の例外ではない(CLAUDE.md・GUIDE_02 の例外リストに無い. + 無人運転ループ `/auto-refactor`・`/auto-audit` の例外は残る) + 5. フェーズ 3 で環境構築の手順書ができた時点で,その手順に従って実際に環境を構築し, + 結果を手順書に反映してからレビューを受ける + (リポジトリ内で完結する操作はそのまま実行/ツール導入は都度承認/外部サービス操作はユーザー) + +対象は実行コードではなく指示書(Markdown)であるため,各テストは「仕様が要求する性質が, +文書の構造・内容として満たされているか」を機械的に検証する.外部依存なし(標準ライブラリのみ). + +`.claude/tests/` はテンプレート自身のメタテスト置き場であり,`/sync-template` の同期対象外 +(`SKIP_FILES`)として派生プロジェクトには配布しない. +""" + +from __future__ import annotations + +import os +import re +import unittest + +TESTS_DIR = os.path.dirname(os.path.abspath(__file__)) +REPO_ROOT = os.path.dirname(os.path.dirname(TESTS_DIR)) + +SETUP_SKILL = os.path.join(REPO_ROOT, ".claude", "skills", "setup", "SKILL.md") +SETUP_REFERENCE = os.path.join(REPO_ROOT, ".claude", "skills", "setup", "reference.md") +GUIDE_01 = os.path.join(REPO_ROOT, "docs", "01_GUIDE", "GUIDE_01_プロジェクト立ち上げフロー.md") +GUIDE_02 = os.path.join(REPO_ROOT, "docs", "01_GUIDE", "GUIDE_02_エージェント運用ルール.md") +CLAUDE_MD = os.path.join(REPO_ROOT, "CLAUDE.md") +README_MD = os.path.join(REPO_ROOT, "README.md") + +SETUP_BRANCH = "chore/project-setup" + +HEADING_RE = re.compile(r"^(#{1,6})\s+(.*\S)\s*$") + + +def read(path: str) -> str: + with open(path, "r", encoding="utf-8") as f: + return f.read() + + +def section(text: str, title_part: str) -> str: + """見出しに `title_part` を含む節の本文(下位の見出しを含む)を返す.""" + lines = text.splitlines() + start = None + level = 0 + for i, line in enumerate(lines): + m = HEADING_RE.match(line) + if not m: + continue + if start is None: + if title_part in m.group(2): + start, level = i + 1, len(m.group(1)) + continue + if len(m.group(1)) <= level: + return "\n".join(lines[start:i]) + if start is None: + raise AssertionError(f"節が見つからない: {title_part}") + return "\n".join(lines[start:]) + + +def numbered_steps(body: str) -> list[str]: + """節の直下(最初の下位見出しより前)にある番号付き手順を返す.""" + steps = [] + for line in body.splitlines(): + if HEADING_RE.match(line): + break + if re.match(r"^\d+\. ", line): + steps.append(line) + return steps + + +class TestSetupSkillWritesFilesBeforeReview(unittest.TestCase): + """仕様 1: 成果物を最初からファイルに書き出し,チャットには一覧と要確認点だけを示すこと.""" + + @classmethod + def setUpClass(cls): + cls.text = read(SETUP_SKILL) + cls.rules = section(cls.text, "基本ルール") + + def test_basic_rules_write_to_file_first(self): + self.assertRegex(self.rules, r"最初から.*書き出") + self.assertIn("レビュー", self.rules) + + def test_basic_rules_forbid_full_text_in_chat(self): + self.assertRegex(self.rules, r"全文を出さ(ない|ず)") + self.assertIn("書き出したファイルの一覧", self.rules) + self.assertIn("判断してほしい点", self.rules) + self.assertIn("Claude が決めた点", self.rules) + + def test_basic_rules_apply_fixes_to_file_directly(self): + self.assertIn("ファイルに直接反映", self.rules) + + def test_no_longer_drafts_in_chat_before_writing(self): + # 旧仕様「ドラフトを提示し,修正を反映してからファイルに書き出す」が残っていないこと + self.assertNotRegex(self.text, r"ドラフトを提示") + self.assertNotIn("反映してからファイルに書き出す", self.text) + + def test_phase_loop_writes_file_before_user_review(self): + steps = numbered_steps(section(self.text, "フェーズ 1〜6")) + write_idx = next((i for i, s in enumerate(steps) if "書き出" in s), None) + review_idx = next((i for i, s in enumerate(steps) if "修正指示" in s), None) + self.assertIsNotNone(write_idx, f"書き出しのステップが無い: {steps}") + self.assertIsNotNone(review_idx, f"修正指示のステップが無い: {steps}") + self.assertLess(write_idx, review_idx, "書き出しがレビュー(修正指示)より後になっている") + self.assertRegex(steps[write_idx], r"全文.*出さない") + self.assertIn("ファイルに反映", steps[review_idx]) + + def test_dialogue_template_lists_files_and_points(self): + body = section(self.text, "台詞テンプレート") + for word in ("書き出したファイル", "判断してほしい点", "Claude が決めた点", "git diff"): + with self.subTest(word=word): + self.assertIn(word, body) + + +class TestSetupSkillLeavesCommitToHuman(unittest.TestCase): + """仕様 2〜4: `/setup` は自動コミットせず,人間の `/commit` を案内し,クリーン確認をすること.""" + + @classmethod + def setUpClass(cls): + cls.text = read(SETUP_SKILL) + cls.git = section(cls.text, "立ち上げ中の Git 運用") + + def test_has_git_section_with_dedicated_branch(self): + self.assertIn(SETUP_BRANCH, self.git) + self.assertIn("フェーズ", self.git) + + def test_may_create_setup_branch(self): + lines = [line for line in self.git.splitlines() if SETUP_BRANCH in line and "作成" in line] + self.assertTrue(lines, "ブランチ作成を /setup が行ってよい旨が無い") + + def test_states_no_commit_push_pr_merge(self): + lines = [line for line in self.git.splitlines() if "行わない" in line] + self.assertTrue(lines, "行わない操作の明記が無い") + joined = "\n".join(lines) + for word in ("git commit", "push", "PR", "マージ"): + with self.subTest(word=word): + self.assertIn(word, joined) + self.assertIn("/commit push", self.git) + + def test_commit_is_done_by_user_via_commit_skill(self): + self.assertRegex(self.git, r"ユーザー(自身)?が\s*`/commit`") + + def test_does_not_declare_exception_to_commit_prohibition(self): + self.assertNotIn("例外", self.git) + self.assertNotIn("自律コミット", self.text) + self.assertNotIn("自動コミット", self.text) + + def test_never_instructs_git_commit_command(self): + self.assertNotIn("git commit -m", self.text) + self.assertNotIn("git add", self.text) + + def test_checks_clean_worktree_before_next_phase(self): + self.assertIn("git status --porcelain", self.git) + self.assertIn("クリーン", self.git) + + def test_asks_user_when_uncommitted_changes_remain(self): + lines = [line for line in self.git.splitlines() if "未コミット" in line] + self.assertTrue(lines, "未コミットの場合の扱いが無い") + self.assertIn("ユーザーに確認", lines[0]) + + def test_phase_loop_guides_commit_then_checks_clean(self): + steps = numbered_steps(section(self.text, "フェーズ 1〜6")) + commit_idx = next((i for i, s in enumerate(steps) if "`/commit`" in s), None) + clean_idx = next((i for i, s in enumerate(steps) if "クリーン" in s), None) + self.assertIsNotNone(commit_idx, f"/commit を案内するステップが無い: {steps}") + self.assertIsNotNone(clean_idx, f"クリーン確認のステップが無い: {steps}") + self.assertIn("案内", steps[commit_idx]) + self.assertLess(commit_idx, clean_idx) + self.assertIn("ユーザーに確認", steps[clean_idx]) + self.assertEqual(clean_idx, len(steps) - 1, "クリーン確認が次フェーズへ進む直前のステップでない") + + def test_dialogue_template_guides_commit_with_message_example(self): + body = section(self.text, "台詞テンプレート") + self.assertIn("`/commit`", body) + self.assertIn("メッセージ例", body) + + def test_phase7_guides_commit_and_hands_merge_to_user(self): + body = section(self.text, "フェーズ 7") + self.assertIn("`/commit`", body) + self.assertIn(SETUP_BRANCH, body) + self.assertRegex(body, r"/commit (push|merge)") + self.assertIn("本スキルでは行わず", body) + + def test_interruption_guides_commit_without_committing(self): + body = section(self.text, "中断時の処理") + self.assertIn("本スキルではコミットしない", body) + self.assertIn("`/commit`", body) + self.assertIn(SETUP_BRANCH, body) + self.assertIn("未コミット", body) + + def test_resume_checks_uncommitted_changes(self): + body = section(self.text, "前提確認") + self.assertIn("git status --porcelain", body) + self.assertIn("/commit", body) + + +class TestSetupPhase3ReviewsAfterExecution(unittest.TestCase): + """仕様 5: フェーズ 3 は構築を実行し,結果を手順書に反映してからレビューを受けること.""" + + def test_phase3_note_orders_execute_reflect_review(self): + body = section(read(SETUP_SKILL), "フェーズ 3(環境構築)の注意") + i_exec = body.find("構築を実行") + i_reflect = body.find("反映", i_exec) + i_review = body.find("レビュー", i_reflect) + self.assertNotEqual(i_exec, -1, "構築を実行する旨が無い") + self.assertNotEqual(i_reflect, -1, "実行後に手順書へ反映する旨が無い") + self.assertNotEqual(i_review, -1, "反映後にレビューを受ける旨が無い") + + def test_reference_phase3_single_commit_after_reflection(self): + body = section(read(SETUP_REFERENCE), "フェーズごとのコミット") + line = next((l for l in body.splitlines() if l.startswith("- フェーズ 3")), "") + self.assertIn("反映してからレビュー", line) + + +class TestSetupReferenceDescribesCommitProcedure(unittest.TestCase): + """仕様 2・3: ブランチの作り方・コミットの案内・クリーン確認・フェーズ対応表が reference.md にあること.""" + + @classmethod + def setUpClass(cls): + cls.text = read(SETUP_REFERENCE) + cls.body = section(cls.text, "フェーズごとのコミット") + + def test_shows_branch_creation_command(self): + self.assertIn(f"git switch -c {SETUP_BRANCH}", self.body) + self.assertIn("git branch --show-current", self.body) + + def test_guides_user_commit_following_git_conventions(self): + self.assertIn("`/commit`", self.body) + self.assertRegex(self.body, r"ユーザー(自身)?が\s*`/commit`") + self.assertIn(".claude/rules/git-conventions.md", self.body) + + def test_no_git_commit_command_for_setup_to_run(self): + self.assertNotIn("git commit -m", self.body) + self.assertNotIn("git add", self.body) + self.assertRegex(self.body, r"`git commit`[^\n]*行わない") + + def test_shows_clean_check_command(self): + self.assertIn("git status --porcelain", self.body) + self.assertIn("ユーザーに確認", self.body) + + def test_does_not_claim_exception(self): + self.assertNotIn("例外", self.body) + + def test_commit_table_covers_every_phase(self): + rows = [line for line in self.body.splitlines() if line.startswith("| ")] + table = "\n".join(rows) + phases = ("1 方針決定", "2 技術選定", "3 環境構築", "4 仕様設計", "5 規約整備", "6 開発計画", "7 実装開始") + for phase in phases: + with self.subTest(phase=phase): + self.assertIn(phase, table) + + def test_commit_message_examples_follow_tag_format(self): + examples = re.findall(r"`(\[[a-z]+\][^`]*)`", self.body) + self.assertTrue(examples, "コミットメッセージ例が無い") + allowed = {"[add]", "[update]", "[fix]", "[remove]", "[clean]"} + for example in examples: + with self.subTest(example=example): + self.assertIn(example.split("]")[0] + "]", allowed) + self.assertFalse(example.endswith("."), "末尾に句点を付けない(git-conventions)") + + def test_does_not_instruct_push_or_pr(self): + self.assertIn("`git push`・PR 作成・マージ", self.body) + merge_body = section(self.text, "取り込み (Merging into main)") + self.assertIn("ユーザーが `/commit push`", merge_body) + + +class TestSetupExecutesEnvironmentSetup(unittest.TestCase): + """仕様 5: 手順書の作成後に実際の環境構築を行うことが定義されていること.""" + + @classmethod + def setUpClass(cls): + cls.skill = read(SETUP_SKILL) + cls.reference = read(SETUP_REFERENCE) + + def test_skill_has_execution_section_in_phase3(self): + body = section(self.skill, "環境構築の実行") + self.assertIn("ENV_02", body) + self.assertIn("ENV_03", body) + self.assertRegex(body, r"実際に(この環境を)?構築する") + + def test_skill_requires_approval_for_tool_installation(self): + body = section(self.skill, "環境構築の実行") + self.assertIn("承認を得てから", body) + self.assertIn("SDK", body) + + def test_skill_leaves_external_services_to_user(self): + body = section(self.skill, "環境構築の実行") + self.assertIn("外部サービス", body) + self.assertIn("gh auth login", body) + + def test_skill_feeds_results_back_into_docs(self): + body = section(self.skill, "環境構築の実行") + self.assertIn("反映", body) + self.assertIn("ENV_04", body) + + def test_phase3_note_points_to_execution(self): + body = section(self.skill, "フェーズ 3(環境構築)の注意") + self.assertIn("環境構築の実行", body) + + def test_reference_classifies_operations_in_three_tiers(self): + body = section(self.reference, "環境構築の実行") + rows = [line for line in body.splitlines() if line.startswith("| ")] + self.assertGreaterEqual(len(rows), 5, "実行の分類の表が無い(ヘッダ+区切り+3 区分)") + table = "\n".join(rows) + self.assertIn("そのまま実行する", table) + self.assertIn("承認を得てから", table) + self.assertIn("ユーザーに依頼する", table) + + def test_reference_warns_about_gitignore_before_install(self): + body = section(self.reference, "環境構築の実行") + self.assertIn(".gitignore", body) + self.assertIn("node_modules", body) + + def test_reference_stops_after_repeated_failures(self): + body = section(self.reference, "環境構築の実行") + self.assertIn("2 回失敗", body) + + +class TestDocumentationIsConsistent(unittest.TestCase): + """GUIDE・CLAUDE.md・README が同じ仕様を述べていること.""" + + def test_guide_01_has_git_during_setup_section(self): + body = section(read(GUIDE_01), "立ち上げ中の Git 運用") + self.assertIn(SETUP_BRANCH, body) + self.assertIn("/commit push", body) + self.assertIn(".claude/skills/setup/reference.md", body) + + def test_guide_01_environment_section_requires_actual_setup(self): + body = section(read(GUIDE_01), "環境構築 (Environment Setup)") + self.assertIn("実際に環境を構築", body) + self.assertIn("都度承認", body) + self.assertIn(".claude/skills/setup/reference.md", body) + + def test_guide_01_git_section_leaves_commit_to_human(self): + body = section(read(GUIDE_01), "立ち上げ中の Git 運用") + self.assertRegex(body, r"人間が\s*`/commit`") + self.assertNotIn("例外", body) + self.assertIn("クリーン", body) + self.assertRegex(body, r"最初から.*書き出") + self.assertIn("判断してほしい点", body) + self.assertIn("Claude が決めた点", body) + + def test_guide_02_does_not_list_setup_as_commit_exception(self): + body = section(read(GUIDE_02), "コミットルール") + lines = [line for line in body.splitlines() if "`/setup`" in line] + self.assertFalse(lines, f"GUIDE_02 のコミットルールに /setup の例外が残っている: {lines}") + self.assertNotIn("例外(プロジェクト立ち上げ)", body) + self.assertNotIn(SETUP_BRANCH, body) + + def test_guide_02_keeps_unattended_loop_exceptions(self): + body = section(read(GUIDE_02), "コミットルール") + self.assertIn("例外(無人運転)", body) + self.assertIn("`/auto-refactor`", body) + self.assertIn("`/auto-audit`", body) + + def _claude_md_exception_block(self) -> list[str]: + lines = read(CLAUDE_MD).splitlines() + start = next(i for i, l in enumerate(lines) if l.lstrip().startswith("- **例外**:")) + block = [lines[start]] + for line in lines[start + 1:]: + if line.startswith(" - "): + block.append(line) + else: + break + return block + + def test_claude_md_does_not_list_setup_in_commit_exceptions(self): + block = self._claude_md_exception_block() + self.assertFalse([l for l in block if "/setup" in l], f"例外リストに /setup が残っている: {block}") + self.assertNotIn("プロジェクト立ち上げ", "\n".join(block)) + text = read(CLAUDE_MD) + self.assertNotIn(SETUP_BRANCH, text) + + def test_claude_md_keeps_unattended_loop_exceptions(self): + block = "\n".join(self._claude_md_exception_block()) + self.assertIn("`/auto-refactor`", block) + self.assertIn("`/auto-audit`", block) + + def test_readme_mentions_new_behaviours(self): + line = next(line for line in read(README_MD).splitlines() if line.startswith("- `/setup")) + self.assertIn(SETUP_BRANCH, line) + self.assertRegex(line, r"構築まで行う|実際に構築") + self.assertIn("書き出", line) + self.assertRegex(line, r"人間が\s*`/commit`") + self.assertNotRegex(line, r"ブランチへコミット") + + +if __name__ == "__main__": + unittest.main() diff --git a/.claude/tests/test_verify_template.py b/.claude/tests/test_verify_template.py new file mode 100644 index 0000000..fb8a752 --- /dev/null +++ b/.claude/tests/test_verify_template.py @@ -0,0 +1,822 @@ +"""検証エージェント(verifier・/verify・/implement Phase 1b)のテンプレート化のテスト(Issue #40). + +実行(リポジトリルートで): + python -B -m unittest discover -s .claude/tests -v + +仕様の正本: + 1. Issue #40「ブラウザ検証(verifier・/verify・/implement Phase 1b)をテンプレート化する」 + (やること / 揃っていなければスキップする / 完了条件) + 2. Issue 後にユーザーが承認した追加仕様(検証対象の環境をプロファイルで根拠つきに許可できる. + 本番は例外なく禁止.verifier は自己判断で環境を許可しない) + +対象は実行コードではなく指示書(Markdown)であるため,各テストは +「仕様が要求する性質が,文書の構造・内容として満たされているか」を機械的に検証する. +外部依存なし(標準ライブラリの unittest のみ). + +`.claude/tests/` はテンプレート自身のメタテスト置き場であり,README.md 等テンプレート固有の内容を +前提とするため,`/sync-template` の同期対象外(SKIP_FILES)として派生プロジェクトには配布しない. +""" + +from __future__ import annotations + +import os +import re +import unittest + +TESTS_DIR = os.path.dirname(os.path.abspath(__file__)) +REPO_ROOT = os.path.dirname(os.path.dirname(TESTS_DIR)) + +VERIFIER_AGENT = os.path.join(REPO_ROOT, ".claude", "agents", "verifier.md") +VERIFY_SKILL = os.path.join(REPO_ROOT, ".claude", "skills", "verify", "SKILL.md") +PROFILE_TEMPLATE = os.path.join(REPO_ROOT, ".claude", "skills", "verify", "profile-template.md") +IMPLEMENT_SKILL = os.path.join(REPO_ROOT, ".claude", "skills", "implement", "SKILL.md") +SETUP_SKILL = os.path.join(REPO_ROOT, ".claude", "skills", "setup", "SKILL.md") +SYNC_SKILL = os.path.join(REPO_ROOT, ".claude", "skills", "sync-template", "SKILL.md") +TEMPLATE_CUSTOMIZATION = os.path.join(REPO_ROOT, ".claude", "rules", "template-customization.md") +GUIDE_01 = os.path.join(REPO_ROOT, "docs", "01_GUIDE", "GUIDE_01_プロジェクト立ち上げフロー.md") +GUIDE_02 = os.path.join(REPO_ROOT, "docs", "01_GUIDE", "GUIDE_02_エージェント運用ルール.md") +CLAUDE_MD = os.path.join(REPO_ROOT, "CLAUDE.md") +README_MD = os.path.join(REPO_ROOT, "README.md") + +# プロジェクト側が所有し,テンプレートには存在してはならないファイル +VERIFY_PROFILE = os.path.join(REPO_ROOT, ".claude", "verify-profile.md") + +# 新規追加された 3 ファイル(Markdown 書式の機械チェック対象) +NEW_FILES = (VERIFIER_AGENT, VERIFY_SKILL, PROFILE_TEMPLATE) + + +HEADING_RE = re.compile(r"^(#{1,6})\s+(.*\S)\s*$") + + +def read(path: str) -> str: + with open(path, "r", encoding="utf-8") as f: + return f.read() + + +def rel(path: str) -> str: + """リポジトリルートからの相対パス(失敗メッセージ・subTest のラベル用).""" + return os.path.relpath(path, REPO_ROOT) + + +def lines_outside_fences(lines: list[str]) -> list[tuple[int, str]]: + """フェンス付きコードブロックの外にある行を (行番号, 行) で返す.""" + result = [] + in_fence = False + for i, line in enumerate(lines): + if line.lstrip().startswith("```"): + in_fence = not in_fence + continue + if not in_fence: + result.append((i, line)) + return result + + +def strip_code_fences(text: str) -> str: + """フェンス付きコードブロックの中身を除いた本文を返す.""" + return "\n".join(line for _, line in lines_outside_fences(text.splitlines())) + + +def headings(text: str) -> list[tuple[int, str]]: + """(レベル, 見出しテキスト) の一覧をコードブロックの外から集める.""" + result = [] + for _, line in lines_outside_fences(text.splitlines()): + m = HEADING_RE.match(line) + if m: + result.append((len(m.group(1)), m.group(2))) + return result + + +def frontmatter(text: str) -> dict[str, object]: + """先頭の YAML フロントマターを素朴に解釈する(スカラーとリストのみ).""" + lines = text.splitlines() + if not lines or lines[0].strip() != "---": + return {} + end = None + for i, line in enumerate(lines[1:], start=1): + if line.strip() == "---": + end = i + break + if end is None: + return {} + data: dict[str, object] = {} + key = None + for line in lines[1:end]: + if not line.strip(): + continue + item = re.match(r"^\s*-\s+(.*\S)\s*$", line) + if item and key is not None and isinstance(data.get(key), list): + data[key].append(item.group(1)) # type: ignore[union-attr] + continue + kv = re.match(r"^([A-Za-z_-]+):\s*(.*)$", line) + if kv: + key = kv.group(1) + value = kv.group(2).strip() + if value == "": + data[key] = [] + else: + data[key] = value.strip('"').strip("'") + return data + + +def raw_section(text: str, title_contains: str) -> str: + """見出しに title_contains を含む節の本文(次の同レベル以上の見出しまで)を返す. + + コードブロックはそのまま残す(出力フォーマット等の検証用).見出しの探索は + コードブロックの外だけを対象とする. + """ + lines = text.splitlines() + outside = lines_outside_fences(lines) + start = None + start_level = 0 + for i, line in outside: + m = HEADING_RE.match(line) + if m and title_contains in m.group(2): + start = i + 1 + start_level = len(m.group(1)) + break + if start is None: + return "" + for j, line in outside: + m = HEADING_RE.match(line) + if j >= start and m and len(m.group(1)) <= start_level: + return "\n".join(lines[start:j]) + return "\n".join(lines[start:]) + + +def section(text: str, title_contains: str) -> str: + """節の本文を,コードブロックの中身を除いて返す.""" + return strip_code_fences(raw_section(text, title_contains)) + + +def bullets(text: str) -> list[str]: + """トップレベルの箇条書き項目を返す.""" + return [m.group(1) for m in re.finditer(r"^-\s+(.*\S)\s*$", text, flags=re.MULTILINE)] + + +def table_headers(text: str) -> list[list[str]]: + """本文中のテーブルのヘッダー行(セルの一覧)をすべて返す.""" + result = [] + lines = text.splitlines() + for i, line in enumerate(lines[:-1]): + if line.strip().startswith("|") and re.match(r"^\s*\|[\s:\-|]+\|\s*$", lines[i + 1]): + cells = [c.strip() for c in line.strip().strip("|").split("|")] + result.append(cells) + return result + + +class TestCompletionCriterionNoProjectSpecifics(unittest.TestCase): + """完了条件 3: 汎用部分にプロジェクト固有の記述が含まれないこと. + + 「`verifier.md`・`verify/SKILL.md`・`implement/SKILL.md` にプロジェクト固有の記述 + (特定のフレームワーク・DB・パス)が含まれない」(Issue #40 完了条件) + """ + + # Issue の「KarutaMatcher での例」に出てくる固有名詞・固有パスの類 + FORBIDDEN = ( + "KarutaMatcher", + "Playwright", + "Chromium", + "Supabase", + "supabase", + "PostgreSQL", + "Next.js", + "Flutter", + "Firebase", + "launchLoggedIn", + "waitForURL", + "networkidle", + "StrictMode", + "localhost:3000", + "localhost:5432", + ".env.local", + "browser-verify", + "scripts/verify/run", + "run.mjs", + ) + + def test_generic_files_have_no_project_specific_terms(self): + for path in (VERIFIER_AGENT, VERIFY_SKILL, IMPLEMENT_SKILL): + text = read(path) + for term in self.FORBIDDEN: + with self.subTest(path=rel(path), term=term): + self.assertNotIn( + term, + text, + f"{rel(path)} にプロジェクト固有の記述 '{term}' が含まれる", + ) + + def test_generic_files_have_no_hardcoded_hosts(self): + """特定のホスト名・URL を直接書かない(ローカルの例示も含めない).""" + pattern = re.compile(r"https?://(?!github\.com)[\w.-]+") + for path in (VERIFIER_AGENT, VERIFY_SKILL, IMPLEMENT_SKILL): + with self.subTest(path=rel(path)): + found = pattern.findall(read(path)) + self.assertEqual([], found, f"ホスト名が直接書かれている: {found}") + + def test_verifier_delegates_project_specifics_to_profile(self): + """固有情報はプロファイルを読んで得る,と明記されていること.""" + text = read(VERIFIER_AGENT) + self.assertIn(".claude/verify-profile.md", text) + self.assertRegex( + text, + r"(本ファイルには書かれていない|プロジェクト固有[^\n]*(持た|書かず))", + "固有情報を持たない旨が書かれていない", + ) + self.assertRegex(text, r"検証プロファイル[^\n]*を読(み|む)", "プロファイルを読む指示が無い") + + +class TestCompletionCriterionSkipWithoutProfile(unittest.TestCase): + """完了条件 1: プロファイル無しの派生直後プロジェクトで従来と同じ流れになること.""" + + def test_template_does_not_ship_a_verify_profile(self): + """テンプレートに `.claude/verify-profile.md` の実体があってはならない. + + 実体があると派生直後のプロジェクトで「プロファイルあり」と判定され, + Phase 1b がスキップされなくなる(雛形は別パスに置く). + """ + self.assertFalse( + os.path.exists(VERIFY_PROFILE), + ".claude/verify-profile.md がテンプレートに存在する(雛形は profile-template.md に置く)", + ) + + def test_profile_template_exists_at_documented_path(self): + self.assertTrue(os.path.isfile(PROFILE_TEMPLATE)) + + def test_phase1b_skip_conditions(self): + """Issue「揃っていなければスキップする」の 4 条件が実行判定にあること.""" + body = section(read(IMPLEMENT_SKILL), "実行判定") + self.assertNotEqual("", body, "Phase 1b に「実行判定」の節が無い") + items = bullets(body) + self.assertGreaterEqual(len(items), 4, f"スキップ条件が 4 件未満: {items}") + joined = "\n".join(items) + self.assertIn(".claude/verify-profile.md", joined) # プロファイル未設定 + self.assertRegex(joined, r"観測可能な挙動") # 挙動に関係しない変更 + self.assertRegex(joined, r"破壊的な操作") # マイグレーション等 + self.assertRegex(body, r"起動せず") + self.assertRegex(body, r"エラーにしない|エラーで止め") + + def test_phase1b_skip_keeps_legacy_request_to_human(self): + """プロファイル不在時は従来の依頼文言のまま,理由 1 行だけを足すこと.""" + text = read(IMPLEMENT_SKILL) + self.assertIn("ブラウザや実機で動作を確認してください.", text) + body = section(text, "人間への提示") + self.assertRegex(body, r"起動しなかった場合") + self.assertRegex(body, r"理由") + self.assertRegex(body, r"1 行") + + def test_implement_pipeline_structure_is_preserved(self): + """Phase 1〜4・完了処理の見出しが従来の順序で揃っていること.""" + h2 = [t for level, t in headings(read(IMPLEMENT_SKILL)) if level == 2] + expected = ["前提確認", "Phase 1:", "Phase 1b:", "Phase 2:", "Phase 3:", "Phase 4:", "完了処理"] + index = -1 + for token in expected: + matches = [i for i, t in enumerate(h2) if t.startswith(token)] + self.assertTrue(matches, f"見出し '{token}' が無い: {h2}") + self.assertGreater(matches[0], index, f"見出し '{token}' の位置が順序どおりでない: {h2}") + index = matches[0] + + +class TestPhase1bBehaviour(unittest.TestCase): + """Issue「やること」1 の implement/SKILL.md への追加分.""" + + def test_phase1b_is_placed_right_after_phase1(self): + h2 = [t for level, t in headings(read(IMPLEMENT_SKILL)) if level == 2] + i1 = next(i for i, t in enumerate(h2) if t.startswith("Phase 1:")) + self.assertTrue(h2[i1 + 1].startswith("Phase 1b"), f"Phase 1 の直後が Phase 1b でない: {h2}") + + def test_phase1b_has_four_result_branches(self): + body = section(read(IMPLEMENT_SKILL), "結果による分岐") + self.assertNotEqual("", body, "「結果による分岐」の節が無い") + items = bullets(body) + self.assertGreaterEqual(len(items), 4, f"分岐が 4 件未満: {items}") + joined = "\n".join(items) + self.assertRegex(joined, r"\*\*OK\*\*") + self.assertRegex(joined, r"NG(今回の変更に起因") + self.assertRegex(joined, r"NG(今回の変更と無関係") + self.assertRegex(joined, r"検証不能") + + def test_phase1b_auto_retry_is_limited_to_two(self): + body = section(read(IMPLEMENT_SKILL), "結果による分岐") + self.assertRegex(body, r"coder", "変更起因の NG は coder へ差し戻すこと") + self.assertRegex(body, r"2 回まで", "自動差し戻しの上限(2 回まで)が書かれていない") + + def test_unrelated_existing_issue_is_not_sent_back(self): + body = section(read(IMPLEMENT_SKILL), "結果による分岐") + m = re.search(r"^-\s+\*\*NG(今回の変更と無関係.*$", body, flags=re.MULTILINE) + self.assertIsNotNone(m, "「変更と無関係な既存の問題」の分岐が無い") + line = m.group(0) + self.assertIn("差し戻さない", line) + self.assertIn("/task-create", line) + + def test_presentation_to_human_has_two_parts(self): + body = section(read(IMPLEMENT_SKILL), "人間への提示") + self.assertIn("Claude が確認済みの項目", body) + self.assertIn("あなたに確認してほしい項目", body) + + def test_verifier_ok_is_not_a_substitute_for_human_ok(self): + text = read(IMPLEMENT_SKILL) + self.assertIn("ユーザーの OK を得るまで Phase 2 に進まないこと", text) + self.assertRegex( + text, + r"verifier の判定が OK でも[^\n]*OK の代わりにしてはならない", + "「verifier の OK をユーザーの OK の代わりにしない」が明記されていない", + ) + + def test_phase1b_does_not_instruct_non_local_access(self): + """委譲ブリーフでローカル以外の環境へのアクセスを指示しないこと(追加仕様).""" + body = section(read(IMPLEMENT_SKILL), "委譲") + self.assertRegex(body, r"検証プロファイル|『検証対象の環境』") + for term in ("staging", "ステージング", "本番"): + self.assertNotIn( + term, body.replace("本番は対象外", ""), f"委譲ブリーフに '{term}' への指示がある" + ) + + +class TestVerifierAgent(unittest.TestCase): + """`.claude/agents/verifier.md`(Issue「やること」1 と追加仕様).""" + + def setUp(self): + self.text = read(VERIFIER_AGENT) + self.fm = frontmatter(self.text) + + def test_frontmatter_shape(self): + self.assertEqual("verifier", self.fm.get("name")) + self.assertIsInstance(self.fm.get("description"), str) + self.assertNotEqual("", self.fm.get("description", "")) + self.assertIn("model", self.fm) + self.assertIsInstance(self.fm.get("tools"), list) + self.assertTrue(self.fm["tools"]) + + def test_tools_allow_reading_but_not_editing(self): + """コードを直さないエージェントなので Edit を持たない(Write は成果物・シナリオ用).""" + tools = self.fm.get("tools", []) + self.assertNotIn("Edit", tools, "verifier に Edit が許可されている(コードを直さない役割に反する)") + for required in ("Read", "Glob", "Grep", "Write"): + self.assertIn(required, tools) + + def test_tools_do_not_allow_commit_or_unrestricted_bash(self): + tools = self.fm.get("tools", []) + self.assertNotIn("Bash", tools, "Bash が無制限に許可されている") + for tool in tools: + if tool.startswith("Bash(git"): + self.assertRegex(tool, r"Bash\(git (diff|status|log)", f"git の危険な操作が許可されている: {tool}") + + def test_workflow_reads_profile_first(self): + body = section(self.text, "作業手順") + self.assertNotEqual("", body) + first = re.search(r"^1\.\s+(.*)$", body, flags=re.MULTILINE) + self.assertIsNotNone(first, "作業手順が番号付きリストになっていない") + self.assertIn(".claude/verify-profile.md", first.group(1), "手順 1 で検証プロファイルを読んでいない") + + def test_workflow_covers_issue_steps(self): + """Issue の作業手順(変更の把握 → 仕様 → 列挙 → シナリオ → 実行 → 証拠確認 → 報告)を含む.""" + body = section(self.text, "作業手順") + for token in ("変更を把握", "仕様", "確認項目を列挙", "シナリオ", "実行", "証拠", "後始末"): + with self.subTest(token=token): + self.assertIn(token, body) + + def test_checklist_covers_issue_viewpoints(self): + """確認観点(正常系・入力の境界・状態の境界・操作の順序・権限・表示崩れ).""" + body = section(self.text, "確認観点") + self.assertNotEqual("", body, "「確認観点」の節が無い") + for token in ("正常系", "入力の境界", "状態の境界", "操作の順序", "権限", "表示"): + with self.subTest(token=token): + self.assertIn(token, body) + + def test_output_format_has_six_sections(self): + """出力フォーマットの 6 節(Issue で列挙された見出し).""" + body = raw_section(self.text, "出力フォーマット") + self.assertNotEqual("", body, "「出力フォーマット」の節が無い") + expected = ["判定", "確認した項目", "発見した不具合", "気になった点", "人間が確認すべき残り項目", "後始末"] + found = re.findall(r"^###\s+(.*\S)\s*$", body, flags=re.MULTILINE) + self.assertEqual(expected, found, f"出力フォーマットの 6 節が揃っていない: {found}") + + def test_has_unverifiable_and_prohibited_sections(self): + titles = [t for _, t in headings(self.text)] + self.assertTrue(any("検証不能" in t for t in titles), f"「検証不能の扱い」の節が無い: {titles}") + self.assertTrue(any("禁止事項" in t for t in titles), f"「禁止事項」の節が無い: {titles}") + + def test_unverifiable_covers_missing_profile(self): + body = section(self.text, "検証不能") + self.assertNotEqual("", body, "「検証不能の扱い」の節が無い") + self.assertIn(".claude/verify-profile.md", body) + + def test_production_access_is_forbidden_without_exception(self): + """本番には,プロファイルに書かれていてもアクセスしない(追加仕様).""" + self.assertRegex( + self.text, + r"本番[^\n]*どんな場合も[^\n]*アクセスしない", + "「本番にはどんな場合もアクセスしない」が書かれていない", + ) + self.assertRegex( + self.text, + r"本番[^\n]*(?プロファイルに書かれていても", + "「プロファイルに書かれていても本番は不可」が書かれていない", + ) + prohibited = section(self.text, "禁止事項") + self.assertRegex(prohibited, r"本番環境へのアクセス", "禁止事項に本番アクセスが無い") + + def test_must_not_self_authorize_environments(self): + """プロファイルに無い環境を自分の判断で「影響なし」とみなさない(追加仕様).""" + self.assertRegex( + self.text, + r"自分の判断で「影響なし」とみなしてはならない", + "自己判断での環境許可の禁止が書かれていない", + ) + self.assertRegex(self.text, r"人間が確認すべき残り項目", "許可外の確認を人間に回す旨が必要") + + def test_allowed_non_local_environment_constraints(self): + """許可環境での書き込み制限・破壊的操作の禁止(追加仕様).""" + self.assertRegex( + self.text, + r"自分が作ったデータだけ|自分が作った検証データ", + "書き込みを自分が作ったデータに限る旨が書かれていない", + ) + self.assertRegex(self.text, r"編集・削除しない", "既存データの編集・削除の禁止が書かれていない") + self.assertRegex( + self.text, + r"(リセット|初期化)[^\n]*(マイグレーション|デプロイ|設定変更)", + "環境全体に効く破壊的な操作の禁止が書かれていない", + ) + + def test_cleanup_must_report_data_left_outside_local(self): + self.assertRegex( + self.text, + r"ローカル以外の環境に残したデータ[^\n]*(必ず書く|明記)", + "ローカル以外に残したデータの報告義務が書かれていない", + ) + self.assertIn("ローカル以外の環境に残したデータの有無", raw_section(self.text, "出力フォーマット")) + + def test_no_stale_local_only_policy(self): + """旧方針(検証対象はローカルのみ・staging 一切禁止)の記述が残っていないこと.""" + stale = ("検証対象はローカル環境のみ", "検証対象はローカルのみ", "ローカル環境に限定する", "ローカル以外にはアクセスしない") + for token in stale: + with self.subTest(token=token): + self.assertNotIn(token, self.text) + # 検証対象の範囲を定義している行は,プロファイルによる許可の余地を必ず伴うこと + scope_lines = [ + line + for line in self.text.splitlines() + if re.search(r"検証対象は|アクセスしてよい", line) + ] + self.assertTrue(scope_lines, "検証対象の範囲を定義する記述が無い") + for line in scope_lines: + with self.subTest(line=line.strip()[:40]): + self.assertRegex( + line, + r"許可", + "検証対象の範囲が,プロファイルによる許可に触れずローカル限定で断定されている", + ) + + def test_does_not_replace_human_verification(self): + self.assertRegex(self.text, r"人間の(動作)?確認[^\n]*(狭める|置き換え)") + prohibited = section(self.text, "禁止事項") + self.assertRegex(prohibited, r"git commit") + self.assertRegex(prohibited, r"プロダクションコード[^\n]*変更") + self.assertRegex(prohibited, r"PASS", "未確認を PASS と報告しない旨が必要") + + +class TestVerifySkill(unittest.TestCase): + """`.claude/skills/verify/SKILL.md`(Issue「やること」1 と追加仕様).""" + + def setUp(self): + self.text = read(VERIFY_SKILL) + self.fm = frontmatter(self.text) + + def test_frontmatter_shape(self): + self.assertEqual("verify", self.fm.get("name")) + self.assertIsInstance(self.fm.get("description"), str) + self.assertIn("argument-hint", self.fm) + + def test_does_not_launch_verifier_without_profile(self): + body = section(self.text, "検証プロファイルの確認") + self.assertNotEqual("", body, "プロファイル確認のステップが無い") + self.assertIn(".claude/verify-profile.md", body) + self.assertRegex(body, r"存在しない場合[^\n]*verifier を起動せず") + self.assertIn("未設定", body) + self.assertIn("profile-template.md", body, "セットアップ方法(雛形のコピー)の案内が無い") + self.assertRegex(body, r"終了する") + + def test_scope_check_before_delegating(self): + body = section(self.text, "対象の確認") + self.assertNotEqual("", body, "差分から検証対象かを判定するステップが無い") + self.assertRegex(body, r"git diff") + self.assertRegex(body, r"ドキュメントのみ|挙動に関係しない|観測できる挙動に関係しない") + + def test_brief_is_self_contained_and_local_by_default(self): + body = section(self.text, "verifier に委譲") + self.assertNotEqual("", body, "委譲のステップが無い") + self.assertRegex(body, r"自己完結") + self.assertRegex( + body, + r"ブリーフでローカル以外の環境へのアクセスを指示しない", + "ブリーフからローカル以外へのアクセスを指示しない旨が無い(追加仕様)", + ) + self.assertRegex(body, r"許可はプロファイルでのみ") + + def test_relays_result_without_fixing(self): + body = section(self.text, "結果の中継") + self.assertNotEqual("", body, "結果の中継のステップが無い") + self.assertRegex(body, r"自分では修正を始めない", "NG でも自分で修正しない旨が無い") + self.assertIn("人間が確認すべき残り項目", body) + self.assertIn("検証不能", body) + self.assertIn("/task-create", body) + + def test_reports_data_left_on_shared_environments(self): + self.assertRegex( + self.text, + r"ローカル以外の環境[^\n]*残した(データ)?", + "共有環境に残ったデータをそのまま伝える旨が無い(追加仕様)", + ) + + def test_states_human_check_is_still_required(self): + self.assertRegex(self.text, r"人間の動作確認の代わりにはならない|置き換えるものではない") + + +class TestProfileTemplate(unittest.TestCase): + """`.claude/skills/verify/profile-template.md`(Issue の表 9 項目+追加仕様).""" + + def setUp(self): + self.text = read(PROFILE_TEMPLATE) + self.titles = [t for level, t in headings(self.text) if level == 2] + + def test_has_all_nine_items_from_issue_table(self): + """Issue の表の 9 項目に対応する節があること.""" + required = { + "種別・検証手段": ("種別", "検証手段"), + "実行コマンド": ("実行コマンド",), + "前提条件と確認方法": ("前提条件",), + "ログイン方法・ロール": ("ロール",), + "テストデータの規約": ("テストデータ",), + "待ち方・セレクタの癖": ("待ち方",), + "既知のノイズ": ("既知のノイズ",), + "禁止操作": ("禁止操作",), + "シナリオのテンプレート": ("シナリオ",), + } + joined = "\n".join(self.titles) + for item, tokens in required.items(): + with self.subTest(item=item): + self.assertTrue( + any(all(tok in t for tok in tokens) for t in self.titles), + f"項目 '{item}' に対応する節が無い: {joined}", + ) + + def test_has_target_environments_and_runner_requirements(self): + """追加仕様の「検証対象の環境」と Issue の実行基盤に対応する「実行基盤の要件」.""" + self.assertTrue(any("検証対象の環境" in t for t in self.titles), f"節が無い: {self.titles}") + self.assertTrue(any("実行基盤の要件" in t for t in self.titles), f"節が無い: {self.titles}") + + def test_environment_section_defaults_to_local(self): + body = section(self.text, "検証対象の環境") + self.assertRegex(body, r"既定はローカルのみ") + self.assertRegex(body, r"根拠の書かれていない環境は許可していない") + self.assertRegex(body, r"判断の主体は人間|判断の主体は[^\n]*プロジェクト") + + def test_allowed_environment_table_columns(self): + """許可環境の表に,接続先・根拠・資格情報・後始末の列があること(追加仕様).""" + body = section(self.text, "検証対象の環境") + tables = table_headers(body) + self.assertTrue(tables, "許可リストの表が無い") + cells = "|".join(tables[0]) + for token in ("環境", "接続先", "根拠", "資格情報", "後始末"): + with self.subTest(token=token): + self.assertIn(token, cells, f"許可環境の表に '{token}' の列が無い: {cells}") + self.assertIn("許可リスト", cells) + + def test_environment_section_has_deny_list_for_production(self): + body = section(self.text, "検証対象の環境") + self.assertIn("拒否リスト", body, "本番ホストの拒否リスト欄が無い(追加仕様)") + self.assertRegex(body, r"本番[^\n]*(許可されない|アクセスしない)") + + def test_runner_requirements_include_connection_guard(self): + """ランナー側に許可リスト以外の接続先を拒否するガードを持つこと(追加仕様).""" + body = section(self.text, "実行基盤の要件") + self.assertRegex( + body, + r"許可リスト以外[^\n]*拒否", + "許可リスト以外の接続先を拒否するガードの要件が無い", + ) + self.assertRegex(body, r"本番[^\n]*拒否リスト") + self.assertRegex(body, r"指示書の禁止事項だけに頼らない") + + def test_runner_requirements_reflect_known_failures(self): + """Issue「設計上の注意」の要点が要件になっていること.""" + body = section(self.text, "実行基盤の要件") + for token in ("判断はエージェント", "後始末", "自分が起動したプロセス", "出力ディレクトリ"): + with self.subTest(token=token): + self.assertIn(token, body) + + def test_test_data_rules_require_prefix_and_hard_delete(self): + body = section(self.text, "テストデータの規約") + self.assertRegex(body, r"命名") + self.assertRegex(body, r"物理削除") + self.assertRegex(body, r"成否に関わらず") + + def test_reference_implementation_links(self): + """参考実装として KarutaMatcher の PR #93・#94・#99 を挙げること.""" + body = section(self.text, "参考実装") + self.assertNotEqual("", body, "「参考実装」の節が無い") + for pr in ("93", "94", "99"): + with self.subTest(pr=pr): + self.assertRegex(body, rf"pull/{pr}\b", f"PR #{pr} へのリンクが無い") + + def test_states_project_ownership(self): + self.assertRegex(self.text, r"プロジェクト所有") + self.assertRegex(self.text, r"`?/sync-template`? で上書き・削除されない") + + def test_copy_destination_is_verify_profile(self): + self.assertIn(".claude/verify-profile.md", self.text) + self.assertRegex(self.text, r"cp .claude/skills/verify/profile-template\.md .claude/verify-profile\.md") + + def test_supports_non_web_projects(self): + """種別を Web に限定しないこと(Issue: CLI・API も同じ枠に載せる).""" + self.assertRegex(self.text, r"CLI") + self.assertRegex(self.text, r"Web に限らない|Web に限定しない") + + +class TestDocumentationIsUpdated(unittest.TestCase): + """Issue「やること」1 の GUIDE・setup・同期ルールへの反映.""" + + def test_guide_02_agent_table_lists_verifier(self): + text = read(GUIDE_02) + rows = [line for line in text.splitlines() if line.strip().startswith("| `verifier`")] + self.assertTrue(rows, "GUIDE_02 のエージェント一覧に verifier が無い") + self.assertIn("Phase 1b", rows[0]) + + def test_guide_02_pipeline_table_lists_phase1b(self): + text = read(GUIDE_02) + rows = [line for line in text.splitlines() if line.strip().startswith("| Phase 1b")] + self.assertTrue(rows, "GUIDE_02 のパイプライン表に Phase 1b が無い") + self.assertIn("verifier", rows[0]) + self.assertRegex(rows[0], r"スキップ") + + def test_guide_02_states_verifier_does_not_replace_human(self): + text = read(GUIDE_02) + self.assertRegex(text, r"verifier の OK を人間の OK の代わりにしてはならない") + self.assertRegex(text, r"本番はどんな場合も対象外|本番は[^\n]*対象外") + + def test_guide_01_mentions_optional_verify_profile(self): + text = read(GUIDE_01) + self.assertIn(".claude/verify-profile.md", text) + self.assertIn(".claude/skills/verify/profile-template.md", text) + self.assertRegex(text, r"既定でローカルのみ|既定はローカルのみ") + + def test_guide_01_lists_verify_skill_in_common_layer(self): + text = read(GUIDE_01) + rows = [line for line in text.splitlines() if line.strip().startswith("| 共通層")] + self.assertTrue(rows) + self.assertIn("`verify`", rows[0], "GUIDE_01 の共通層の skill 一覧に verify が無い") + + def test_setup_skill_asks_about_verify_profile(self): + text = read(SETUP_SKILL) + titles = [t for _, t in headings(text)] + self.assertTrue(any("検証プロファイル" in t for t in titles), f"節が無い: {titles}") + body = section(text, "検証プロファイルの作成") + self.assertRegex(body, r"任意|既定は「作らない」") + self.assertIn(".claude/skills/verify/profile-template.md", body) + self.assertRegex(body, r"ローカルのみ") + + def test_sync_template_excludes_verify_profile(self): + text = read(SYNC_SKILL) + self.assertIn(".claude/verify-profile.md", text) + line = next(line for line in text.splitlines() if ".claude/verify-profile.md" in line) + self.assertRegex(line, r"同期対象外|プロジェクト固有") + + def test_template_customization_excludes_verify_profile(self): + text = read(TEMPLATE_CUSTOMIZATION) + body = section(text, "適用条件") + self.assertIn(".claude/verify-profile.md", body) + line = next(line for line in body.splitlines() if ".claude/verify-profile.md" in line) + self.assertIn("台帳登録は不要", line) + + def test_claude_md_describes_phase1b(self): + text = read(CLAUDE_MD) + self.assertIn("Phase 1b", text) + self.assertIn("/verify", text) + self.assertRegex(text, r"verifier の OK は人間の動作確認の代わりにならない|代わりにならない") + + def test_readme_lists_verify_command(self): + text = read(README_MD) + lines = [line for line in text.splitlines() if line.strip().startswith("- `/verify")] + self.assertTrue(lines, "README の主なスラッシュコマンドに /verify が無い") + self.assertIn("verify-profile.md", lines[0]) + + +class TestReferencedPathsExist(unittest.TestCase): + """新規 3 ファイルが参照する `.claude/` のパスが実在すること.""" + + # 実在してはならない(プロジェクト側で作る)/生成物のため除外 + EXCLUDED = {".claude/verify-profile.md", ".claude/commit-context.md"} + + def test_referenced_claude_paths_exist(self): + pattern = re.compile(r"`(\.claude/[^`]+)`") + for path in NEW_FILES + (IMPLEMENT_SKILL,): + text = read(path) + for ref in sorted(set(pattern.findall(text))): + if ref in self.EXCLUDED or "<" in ref or "*" in ref: + continue + target = os.path.join(REPO_ROOT, *ref.split("/")) + with self.subTest(source=rel(path), ref=ref): + self.assertTrue( + os.path.exists(target) or os.path.exists(target.rstrip(os.sep)), + f"{rel(path)} が参照する {ref} が存在しない", + ) + + +class TestMarkdownStyle(unittest.TestCase): + """新規 3 ファイルの書式(`.claude/rules/markdown-style.md`).""" + + def assert_each_new_file(self, check): + """新規 3 ファイルの本文に check(text) を適用する(どのファイルで落ちたかを subTest で示す).""" + for path in NEW_FILES: + with self.subTest(path=rel(path)): + check(read(path)) + + @staticmethod + def fence_lines(text: str) -> list[str]: + """コードフェンスの行(``` で始まる行)を返す.""" + return [line.strip() for line in text.splitlines() if line.lstrip().startswith("```")] + + def test_no_japanese_full_stop_or_comma(self): + def check(text): + found = [line for line in text.splitlines() if "、" in line or "。" in line] + self.assertEqual([], found, "「、」「。」は使用しない") + + self.assert_each_new_file(check) + + def test_no_full_width_colon(self): + self.assert_each_new_file(lambda text: self.assertNotIn(":", text, "全角コロンは使用しない")) + + def test_single_h1_at_most(self): + def check(text): + h1 = [t for level, t in headings(text) if level == 1] + self.assertLessEqual(len(h1), 1, f"H1 が複数ある: {h1}") + + self.assert_each_new_file(check) + + def test_heading_levels_do_not_skip(self): + def check(text): + previous = 0 + for level, title in headings(text): + if previous: + self.assertLessEqual(level, previous + 1, f"見出しレベルが飛んでいる: {title}") + previous = level + + self.assert_each_new_file(check) + + def test_file_ends_with_single_newline(self): + def check(text): + self.assertTrue(text.endswith("\n"), "ファイル末尾に改行が無い(MD047)") + self.assertFalse(text.endswith("\n\n"), "ファイル末尾に余分な空行がある(MD047)") + + self.assert_each_new_file(check) + + def test_no_trailing_whitespace(self): + def check(text): + bad = [i + 1 for i, line in enumerate(text.splitlines()) if line != line.rstrip()] + self.assertEqual([], bad, f"行末に空白がある行: {bad}(MD009)") + + self.assert_each_new_file(check) + + def test_no_consecutive_blank_lines(self): + def check(text): + lines = text.splitlines() + bad = [i + 1 for i in range(1, len(lines)) if lines[i] == "" and lines[i - 1] == ""] + self.assertEqual([], bad, f"連続する空行がある行: {bad}(MD012)") + + self.assert_each_new_file(check) + + def test_unordered_list_marker_is_hyphen(self): + def check(text): + bad = [ + line.strip() + for line in strip_code_fences(text).splitlines() + if re.match(r"^\s*[*+]\s+\S", line) + ] + self.assertEqual([], bad, "箇条書きは '- ' を使う(MD004)") + + self.assert_each_new_file(check) + + def test_code_fences_are_balanced(self): + def check(text): + self.assertEqual(0, len(self.fence_lines(text)) % 2, "コードフェンスが閉じていない") + + self.assert_each_new_file(check) + + def test_code_fences_are_labelled_outside_output_format(self): + """コードブロックには言語名を付ける(MD040). + + ※ 出力フォーマットのテンプレートを囲む素のフェンスは,既存エージェント + (coder・tester・refactorer)と同じ書き方のため対象外とする. + """ + + def check(text): + body = text.replace(raw_section(text, "出力フォーマット"), "") + for opening in self.fence_lines(body)[::2]: + self.assertNotEqual("```", opening, "コードブロックに言語名が無い(MD040)") + + self.assert_each_new_file(check) + + +if __name__ == "__main__": + unittest.main() diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7f9cbb5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +# 個人・マシン固有の Claude 設定(共有 settings.json を汚さない.GUIDE_03) +.claude/settings.local.json + +# /implement が /commit に渡すコンテキストファイル +.claude/commit-context.md + +# /auto-refactor のローカル運用状態(スキップ台帳・要確認リスト) +.claude/auto-refactor-skip.md +.claude/auto-refactor-doc-review.md +.claude/auto-refactor-test-review.md + +# /auto-audit のローカル運用状態(報告台帳・修正済み台帳・スキップ台帳) +.claude/auto-audit-report.md +.claude/auto-audit-fixed.md +.claude/auto-audit-skip.md + +# /deps-update のローカル運用状態(マージ済み台帳・報告台帳・スキップ台帳) +.claude/deps-update-merged.md +.claude/deps-update-report.md +.claude/deps-update-skip.md + +# .claude/hooks/ の Python フックとそのテスト(tests/test_hooks.py)が生成するバイトコードキャッシュ +__pycache__/ + +# tests/test_hooks.py が一時的に作る作業ディレクトリ(テストが強制終了された場合の取り残し対策) +.claude/hooks/tests/_restrict_sandbox_*/ + +# -- プロジェクト立ち上げ時に有効化する(.claude/rules/git-conventions.md 参照) -- +# .env +# .env.* +# node_modules/ +# build/ +# dist/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..54548e4 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,90 @@ +# プロジェクト名 + + + +## 開発進捗 + +最新: GUIDE_01 に従いプロジェクト立ち上げ中 +※ 本欄は**最新ステップ 1 行のみ上書き更新**.詳細な進捗履歴(動機・設計判断・失敗パターン)は [docs/PROGRESS.md](docs/PROGRESS.md) に追記する.運用ルールは `.claude/rules/progress-log.md` を参照(該当ファイル編集時に自動ロードされる). + +## 必須ルール(コード実装時) + + + +### Git 運用 + +- ブランチ名・コミットメッセージの書式は `.claude/rules/git-conventions.md` に従う(常時ロードされる) +- コミットは `/commit` を使用する(push・PR 作成は `/commit push`,マージ・プルまで行う場合は `/commit merge`) +- **`/commit` はユーザーが明示的に指示した時のみ実行する.Claude が自発的に `/commit` や `git commit` を呼んではならない**(`/implement` 完了後も,案内するだけで自分ではコミットしない.`/commit` skill は `disable-model-invocation` によりユーザー起動限定として強制されている) + - **例外**: 以下(無人運転ループ)はユーザー承認済みの例外として専用ブランチに自律コミットする.いずれも push・PR・マージ・`main` への操作はしない(取り込みは人間が `/commit push` 等で行う) + - `/auto-refactor`(リファクタ/ドキュメント整理ループ)→ `refactor/` 専用ブランチ + - `/auto-audit`(バグ/脆弱性の巡回監査ループ)→ `fix/` 専用ブランチ + - **例外(`main` へのマージ)**: `/deps-update` は,Dependabot が作成した依存更新 PR に限り,ゲート(メジャー更新でない・競合なし・CI 緑またはローカル検証緑)を満たすものを `gh pr merge` で `main` に取り込む(ユーザーが明示的に起動した時のみ.メジャー更新・CI 赤・PR が付かない alert は報告して人間が判断する) +- **ops-runner によるマージは承認済みの定常操作**: `/commit merge` および `/deps-update` では,ユーザーが明示的に起動したことを受けて,ops-runner サブエージェント(軽量モデル)が手順書に従い `git push` → `gh pr create` → `gh pr merge --merge --delete-branch` で自リポジトリの PR を `main` にマージする.force push・`--admin`・`main` への直接 push は行わない(権限設定は `docs/01_GUIDE/GUIDE_01` の「Claude Code の権限設定」) + +### テンプレート同期 + +- テンプレートの更新は `/sync-template` で取り込む +- テンプレート由来のファイル(`.claude/agents/`・`skills/`・`rules/`・`hooks/`,`docs/01_GUIDE/`)を**このプロジェクトの都合で意図的に変更した**ときは,`.claude/template-overrides.md`(テンプレート改変台帳)に理由付きで記録する(`.claude/rules/template-customization.md`.該当ファイル編集時に自動ロードされる).記録が無いと次回同期で改変が消えうる + +### タスク管理(任意) + +- やるべきことを GitHub Issues で管理してよい(solo / team 共通).`/task-create`(起票・事前計画)→ `/task-start `(着手準備)→ `/task-handoff`(中断時の引継ぎメモ投稿)を使う +- Issue は必須ではない.小さい変更や試験的作業は Issue 無しで進めてよい +- solo モードでは進捗記録(`.claude/rules/progress-log.md`)は従来どおり運用し,Issue はタスクの管理・引継ぎの補助として使う + +### エージェントチーム(GUIDE_02 準拠) + +- 実装は `/implement <タスク>` で開始する(コーディング → テスト → リファクタリング → ドキュメント更新) +- Phase 1 後に人間が動作確認,Phase 2 でテスト失敗時のみ方針判断 +- Phase 2 成功後は Phase 3 → Phase 4 まで自動で進む(Phase 4 で更新がある場合のみ確認) +- テスト・リファクタリングをスキップしない +- 検証プロファイル(`.claude/verify-profile.md`)を用意したプロジェクトでは,Phase 1 の後に Phase 1b(verifier による動作確認.対象はローカル環境と,プロファイルが根拠つきで許可した検証用環境のみ.本番は対象外)が入り,人間が確認する範囲が絞られる(単独実行は `/verify`).プロファイルが無ければ Phase 1b はスキップされ,動作確認は従来どおり人間が行う.**verifier の OK は人間の動作確認の代わりにならない** + +### 開発コマンド + +- 起動・テスト・lint 等の開発コマンドは `docs/02_ENV/ENV_04_開発コマンド.md` にまとめる.**存在する場合は実行前に必ず参照し,推測でコマンドを試さない** +- コマンドが新しく確定・変更された時は同ファイルに反映する(無ければその時点で作成する.`/implement` の Phase 4 でも見直される) + +### 手動確認が必要な作業(自分で完了させないこと) + +以下は実装完了後にユーザーへ報告し,確認・実施を依頼すること. + +- 外部サービスの設定(コンソール操作,セキュリティルール変更等) + - 例外: `gh` CLI で操作できる GitHub リポジトリ設定(Dependabot alerts / security updates の有効化)は Claude が `gh api` で行う(手順は `.claude/skills/setup/reference.md`,背景は GUIDE_01「環境構築」) +- リポジトリ外のファイルの削除・上書き(既存ファイルの移動を含む) + - フックが検出できない書き方であっても Claude は自分で実行しない(変数・スクリプトファイル・`python -c` 等による迂回も禁止).必要な操作であれば,実行するコマンドをそのまま提示し,ユーザーにプロンプトで `! <コマンド>` として実行するよう依頼する +- 実機・ブラウザでの動作確認(`verifier` による自動検証は前段であり,これを置き換えない) +- ストアへのアップロード・リリース作業 + +## ドキュメント + +設計・規約に関する情報は docs/ にある. +コードを書く前に関連するファイルを読むこと. + +### 01_GUIDE(規約・ルール) + +- プロジェクト立ち上げフロー: docs/01_GUIDE/GUIDE_01_プロジェクト立ち上げフロー.md +- エージェント運用ルール: docs/01_GUIDE/GUIDE_02_エージェント運用ルール.md +- ※ Git 規約・ドキュメント書式・命名規則・進捗記録・テンプレート改変記録は `.claude/rules/`(git-conventions / markdown-style / docs-naming / progress-log / template-customization)に定義されている(git-conventions は常時,他は該当ファイル編集時に自動ロード) +- ※ コーディング規約,テスト方針等はプロジェクト立ち上げ時に path-scoped rules(`.claude/rules/`)として作成する(GUIDE_01 参照) + +### 02_ENV(環境) + + + +### 03_PLAN(計画) + + + +### 04_SPEC(仕様・設計) + + + +### 05_TECH(技術設計) + + + +### 06_TEST(テスト) + + diff --git "a/docs/01_GUIDE/GUIDE_01_\343\203\227\343\203\255\343\202\270\343\202\247\343\202\257\343\203\210\347\253\213\343\201\241\344\270\212\343\201\222\343\203\225\343\203\255\343\203\274.md" "b/docs/01_GUIDE/GUIDE_01_\343\203\227\343\203\255\343\202\270\343\202\247\343\202\257\343\203\210\347\253\213\343\201\241\344\270\212\343\201\222\343\203\225\343\203\255\343\203\274.md" new file mode 100644 index 0000000..706a0a5 --- /dev/null +++ "b/docs/01_GUIDE/GUIDE_01_\343\203\227\343\203\255\343\202\270\343\202\247\343\202\257\343\203\210\347\253\213\343\201\241\344\270\212\343\201\222\343\203\225\343\203\255\343\203\274.md" @@ -0,0 +1,186 @@ +# プロジェクト立ち上げフロー (Project Setup Flow) + +新規プロジェクトを AI と協働で立ち上げる際の手順を定義する. +各フェーズを順番に進め,成果物を確定させてから次のフェーズに移ること. + +## フェーズ一覧 (Phase Overview) + +| フェーズ | やること | 成果物 | +| -------- | ---------------------------------------------- | ---------------------------------------------------------- | +| 方針決定 | プロジェクトの目的・スコープを決める | `PLAN_01_要件定義書.md` | +| 技術選定 | 言語・フレームワーク・インフラを決める | `ENV_01_技術スタック.md` | +| 環境構築 | 開発環境のセットアップ手順を整備する | `ENV_02_環境構築手順.md`,`ENV_03_管理者用環境構築手順.md`,`ENV_04_開発コマンド.md`(任意) | +| 仕様設計 | アーキテクチャ・データモデル・画面設計を決める | `SPEC_01_*.md` | +| 規約整備 | 技術スタックに応じたコーディング規約を作る | `.claude/rules/*.md` | +| 開発計画 | ステップを分割し,開発順序を決める | `PLAN_02_開発ステップ.md` | +| 実装開始 | 開発計画に沿って実装を進める | ソースコード | + +※ 立ち上げフローで作成するのは `02_ENV / 03_PLAN / 04_SPEC` カテゴリの成果物と,規約整備フェーズの `.claude/rules/*.md` のみ.`05_TECH`(技術設計)・`06_TEST`(テスト)は実装フェーズで必要になった時点で作成し,立ち上げ時に空フォルダを作らない. + +## 開発モードとファイル構成 (Development Modes & File Layout) + +プロジェクトは個人開発(solo)とチーム開発(team)の 2 モードで運用できる.モードは `/setup` の冒頭で選択し,`.claude/project-mode` に記録される(後からの切り替えは `/set-mode `). + +| レイヤ | 対象 | 存在するモード | +| --- | --- | --- | +| 共通層 | `GUIDE_01`・`GUIDE_02`,`.claude/rules/` のすべて,`.claude/agents/`,`.claude/template-overrides.md`(雛形),skills(`commit`(+ `reference.md`)・`implement`・`setup`(+ `reference.md`)・`sync-template`(+ `reference.md`)・`set-mode`・`auto-refactor`・`auto-audit`・`deps-update`(+ `reference.md`)・`verify`(+ `profile-template.md`)・`task-create`・`task-start`(+ `reference.md`)・`task-handoff`) | solo・team 両方 | +| team 層 | `GUIDE_03`,`.claude/hooks/check_sync.sh`,`settings.json` の SessionStart 配線 | team のみ | + +- `.claude/rules/` はすべて共通層だが,進捗記録ルール(`progress-log`)だけは team モードで運用上**上書き**される(進捗は `CLAUDE.md`/`docs/PROGRESS.md` ではなく GitHub Issues と git 履歴で追う.GUIDE_03). +- team 層ファイルの配置・削除は `/set-mode` が一括で行い,`/sync-template` は `.claude/project-mode` を見て team 層の同期可否を判定する.team 層ファイルの正確なリストは `/set-mode`・`/sync-template` の定義に持たせており,増減時は両者を一致させること. +- テンプレート由来のファイルをプロジェクトの都合で意図的に変更した場合は,`.claude/template-overrides.md`(テンプレート改変台帳)に方針(`keep` / `merge` / `ask`)と理由を記録する(`.claude/rules/template-customization.md`).`/sync-template` は台帳を読み,登録ファイルをテンプレート版で上書きせず方針に従って処理する.未登録でも前回同期版から改変されたファイルは上書き前に確認される. + +## 立ち上げ中の Git 運用 (Git During Setup) + +立ち上げ作業は専用ブランチ `chore/project-setup` の上で行う(ブランチの作成は `/setup` が行う).`/setup` は各フェーズの成果物を**最初から保存先のファイルとして書き出し**,チャットには書き出したファイルの一覧と要確認点(判断してほしい点・Claude が決めた点)だけを示す.人間はエディタでファイルを読んで修正点をチャットで伝え,`/setup` はファイルを直接直す(差分は `git diff` で追える). + +- **コミットは人間が `/commit` で行う**(フェーズごとに 1 コミットを推奨).`/setup` はコミット・push・PR 作成・マージを行わず,コミットメッセージ例の提示(`.claude/skills/setup/reference.md`「フェーズごとのコミット」)とコミット済みの確認だけを行う +- `/setup` は次のフェーズに進む前に作業ツリーがクリーン(コミット済み)であることを確認し,未コミットのまま進むかは人間に確認する +- 狙いは,書き出された最終形を人間がエディタで確認してからコミットできるようにし,フェーズ単位でレビュー・巻き戻しでき,中断・再開しても「どこまで終わったか」が git 履歴から辿れるようにすること +- 全フェーズ完了後,`main` への取り込みはユーザーが `/commit push`(PR 作成まで)または `/commit merge`(マージまで)で行う + +## 方針決定 (Direction) + +プロジェクトの全体像を明確にする. + +- **人間が決めること**: プロジェクトの目的,対象ユーザー,スコープ(やること・やらないこと) +- **AI に依頼できること**: 要件の整理・構造化,類似プロジェクトの調査,要件定義書のドラフト作成 +- **成果物**: `PLAN_01_要件定義書.md` + +## 技術選定 (Tech Stack) + +要件に基づいて技術スタックを決定する. + +- **人間が決めること**: 最終的な技術選定の判断,チームのスキルセットとの適合性 +- **AI に依頼できること**: 要件に適した技術の候補提案,各候補のメリット・デメリット比較 +- **成果物**: `ENV_01_技術スタック.md` + +## 環境構築 (Environment Setup) + +開発環境を構築し,手順をドキュメント化する.初回のみの管理者作業と,メンバーが繰り返し行う構築手順を分けて整備する. + +- **前提条件**: 以下のツールは全プロジェクト共通で導入する. + - `git` — バージョン管理(運用ルールは `.claude/rules/git-conventions.md` で定義) + - `gh` — GitHub CLI(PR 作成・マージ等に使用) + - Python 3.7 以上と `bash` — `.claude/settings.json` のフック(リポジトリ外アクセス制限・通知)の実行に使用.フックは `bash .claude/hooks/run_python.sh` 経由で起動し,`python3` → `python` → `py` の順に実際に起動できるものを使う(いずれか 1 つで起動できればよい.Windows では Git for Windows 付属の `bash` を使う).※ Python が見つからないとアクセス制限フックが対象ツール(Read・Write・Edit・NotebookEdit・Glob・Grep・Bash・PowerShell)を**すべてブロックする**(通知フックは何もせず素通りする) +- **基本方針**: 環境の再現性を重視し,手順書だけに頼らず構築を自動化・コード化できる方法を優先する(例: Docker,Dev Containers,Windows Sandbox,IaC ツール等). +- **人間が決めること**: 開発マシンの選定,クラウドサービスのアカウント作成,環境構築方法の選択 +- **実際の構築まで行う**: 手順書を書いて終わりにせず,`/setup` がその手順に従って実際に環境を構築し,実行して分かった誤り・前提の抜けを手順書に反映してから人間のレビューを受ける(未検証の手順書は誤りを含みやすく,最初に詰まるのがその場のユーザーになるため).区分は次の 3 つ(詳細は `.claude/skills/setup/reference.md`「環境構築の実行」) + - リポジトリ内で完結する操作(プロジェクト初期化・依存インストール・設定ファイル生成・ビルドや起動の疎通確認・`gh` でのリポジトリ設定)はそのまま実行する + - ツール導入(SDK・ランタイム・CLI のインストール,`docker build`,dev container の起動)は実行するコマンド・バージョン・入る場所を提示して**都度承認を得てから**実行する + - 外部サービスのコンソール操作・アカウント作成・対話ログイン(`gh auth login` 等)・クレデンシャルの発行はユーザーが行う +- **AI に依頼できること**: 環境構築手順書の作成,設定ファイルの生成,`.gitignore` の作成,Dockerfile や devcontainer.json 等の構築用ファイルの作成,GitHub リポジトリのセキュリティ設定・`.github/dependabot.yml` の生成(後述),検証プロファイル(`.claude/verify-profile.md`.任意.後述)の記入 +- **GitHub リポジトリのセキュリティ設定**: GitHub の Settings → Code security にある **Dependabot alerts**(既知脆弱性の検出."Vulnerabilities" として表示される)と **Dependabot security updates** はリポジトリごとに既定で OFF のため,リポジトリを作成したら必ず有効化する.モードに関わらず `/setup` が `gh api` で有効化・検証する(コマンド・検証・トラブル対応は `.claude/skills/setup/reference.md`「GitHub リポジトリのセキュリティ設定」).`/setup` 時点でリポジトリが無い場合は,`ENV_03_管理者用環境構築手順.md` に転記した同じコマンドをリポジトリ作成後に実行する. +- **依存バージョン更新(Dependabot version updates)**: 上記とは別に,依存パッケージを定期的に最新化する PR を作らせるため,`/setup` が技術スタックに合わせて `.github/dependabot.yml` を必ず生成する(週 1 回・マイナー/パッチをまとめる・`[update]` プレフィックス.エコシステム対応表と雛形は `.claude/skills/setup/reference.md`「依存バージョン更新の設定」).Dependabot が作る PR と alert の処理は `/deps-update` で行う(ゲートを満たす PR を自動マージし,メジャー更新等は分析付きで報告.GUIDE_02「コミットルール」の例外). +- **Claude Code の権限設定(`.claude/settings.json`)**: テンプレート同梱の `permissions.allow`(`Bash(gh pr merge:*)`)は,auto mode で ops-runner に `/commit merge`・`/deps-update` のマージを実行させるために必要なので削除しない(変更はセッション再起動後に反映).マージが分類器に止められた場合の切り分けと対処は `.claude/skills/commit/reference.md`「gh コマンドが実行される前にブロックされた場合」を参照. +- **リポジトリ外アクセス制限フック(`.claude/hooks/restrict_repo_access.py`)**: 目的は「リポジトリ外のファイルの**削除**と**既存ファイルの上書き**(取り返しのつかない操作)だけを確実に止める」こと.テンプレート同梱の `.claude/settings.json` が `PreToolUse` で呼ぶ. + - 位置づけ: 新規ファイルの作成・ダウンロード・ディレクトリ作成・追記は止めない(環境構築でのダウンロード・`mkdir`・`~/.bashrc` への追記等を自動で進められるようにするため).それ以外のグレーな操作の判断は権限モード(auto mode の分類器・コマンドごとの承認等)に任せ,フックは最後の砦として削除・上書きだけを止める + - 削除(常に拒否): Bash の `rm`・`rmdir`・`unlink`・`shred`・`truncate`・`find -delete`/`-exec rm`・`mv` の移動元・`rsync --delete` の書き込み先,PowerShell の `Remove-Item` とそのエイリアス・`Clear-Content`・`Move-Item`/`Rename-Item` の移動元・`[IO.File]::Delete` 等,`cmd /c del` 等.`python -c`・`node -e`・ヒアドキュメント等のインライン実行は,削除系の呼び出し(`rmtree`・`os.remove`・`unlink`・`fs.rm` 等)があり,かつコード中にリポジトリ外のパスがある場合に拒否する.Edit・NotebookEdit(既存ファイルの書き換え)も拒否する + - 上書き(書き込み先が既に存在する場合だけ拒否.無ければ新規作成として許可): `>`・`>|`・`&>`,`cp`・`mv`・`install`・`rsync`・`scp`・`ln -f`・`Copy-Item`・`Move-Item` の書き込み先(既存ディレクトリへのコピー・移動は `<ディレクトリ>/<コピー元の名前>` で判定),`sed -i`・`perl -i`,`tee`(`-a` 無し)・`Set-Content`・`Out-File`/`Tee-Object`/`Export-Csv`(`-Append` 無し)・`New-Item -Force`,`dd of=`,`curl -o`・`wget -O`・`Invoke-WebRequest -OutFile` 等の保存先,`Expand-Archive -Force`・`Compress-Archive -Force`,Write ツール,インライン実行の `open(..., 'w')`・`writeFile` 等 + - 許可: 新規作成・ダウンロード・`mkdir`/`New-Item -ItemType Directory`・`touch`・追記(`>>`・`tee -a`・`Add-Content`・`Out-File -Append`).`chmod`・`chown`・`Set-Acl` も中身が失われないため許可する + - `cd`・`pushd`・`Set-Location` を追跡し,`~` と `$HOME` を展開してから判定する.基準はリポジトリのルート(`CLAUDE_PROJECT_DIR`)で,システム一時ディレクトリ(`/tmp`・`%TEMP%`.スクラッチパッドを含む)と `/dev/null`・`$null` 等は削除・上書きも許可する + - 読み取り: Read・Glob・Grep,Bash・PowerShell の読み取りはリポジトリ外も許可する.**読んだファイルの内容は Claude の API に送信される**ため,リポジトリ外を読ませることの是非はプロジェクトごとに判断する.読ませたくない場所(`~/.ssh`・`~/.aws`・他案件のフォルダ等)は禁止リスト `.claude/repo-access.json` に書く(任意.無ければ禁止リストは空).禁止リストに該当するパスは読み取り・書き込みとも拒否する.Glob・Grep・再帰的に読むコマンド(`grep -r`・`find`・`Get-ChildItem` 等)は,リポジトリ外の検索起点の配下に禁止パスがある場合も拒否する + + ```json + { + "deny_read": ["~/.ssh", "~/.aws", "C:/Users/me/source/other-project", "secrets"] + } + ``` + + - パスは絶対パス・`~` 始まり・リポジトリルート相対で書く.ファイルが壊れている(JSON として読めない等)場合は禁止リストを空として扱い,警告を出す + - プロジェクト所有のファイルであり,テンプレートには含まれないため `/sync-template` で上書きされない.人によって守りたい場所が違う場合は各自の場所も含めて書くか,各自が後述の `permissions.deny` を `.claude/settings.local.json` に書く + - **限界**: コマンド文字列の静的解析のため原理的に完全には防げない.変数展開(`$DIR` 等.`~`・`$HOME`・`$env:USERPROFILE`・一時ディレクトリ系以外は解決しない),スクリプトファイル経由の操作,`xargs` 等パイプで渡したパス,エイリアス・関数,エンコードされたコマンド,未対応コマンドによる削除・上書き,インライン実行中の未対応の書き方(変数に入れたパス・Perl の `open` 等)は検出できない.既存かどうかはフック実行時点で判定するため,同じコマンド内で先に作ったファイルを上書きする場合等は許可される.逆に,インライン実行で削除系の呼び出しとリポジトリ外のパス(文字列やコメント中のものも含む)が同時に現れると,削除対象でなくても拒否されることがある.また Glob・Grep を省略した検索起点(リポジトリ内)では,リポジトリ内の禁止パスを含む検索までは止めない + - **拒否されたときの運用**: リポジトリ外の削除・上書きの最終判断は人間が持つ.フックに拒否されても,Claude は別の書き方(変数経由・スクリプトファイル経由・`python -c` 等)で迂回しない.必要な操作であれば,Claude は実行するコマンドをそのまま提示して依頼するので,ユーザーが内容を確認し,プロンプトで `! <コマンド>` として自分で実行する.フックが検出できない書き方であっても,リポジトリ外のファイルの削除・上書き(既存ファイルの移動を含む)は同じ扱いとする(`CLAUDE.md`「手動確認が必要な作業」).禁止リストによる読み取りの拒否は「読ませたくない場所」の指定なので,Claude は迂回して読まず,`!` での実行依頼もしない + - **`!` で実行したコマンドにはこのフックはかからない**(実機で確認済み).Claude が提示したコマンドは,削除・上書きの対象が意図どおりかを確認してから実行する + - 確実性が必要な場合は以下を併用する + - OS のフォルダ権限(Claude Code を実行するユーザーに守りたいフォルダの書き込み権限を与えない.dev container ならリポジトリだけをマウントする) + - `settings.json` の `permissions.deny`(例: `"Read(~/.ssh/**)"`・`"Edit(~/.ssh/**)"`.Claude Code 本体が Read・Edit 系ツールを確実に止める) + - Bash・PowerShell の実行前に確認を挟む権限モード(auto mode や `--dangerously-skip-permissions` を使わず,コマンドごとに承認する) + - バックアップ(Git 管理外の大事なデータは定期的に退避する) +- **スマホへのプッシュ通知(任意)**: テンプレート同梱の `.claude/settings.json` は,作業完了(`Stop`)・許可待ち(`Notification`)・質問(`PreToolUse` の `AskUserQuestion`)で `.claude/hooks/notify.py` を呼び,[ntfy](https://ntfy.sh) 経由でスマホに通知する(ホスト OS・dev container のどちらでも動く).送信先トピックが未設定なら何もしないため,受け取りたい人だけ以下を行う. + - スマホに ntfy アプリを入れ,推測されにくいトピック名(例: `claude-` +ランダムな英数字)を購読する.トピック名を知っていれば誰でも購読・送信できるため,パスワードと同様に扱いリポジトリには書かない + - iPhone でアプリを開くと届いているのにプッシュ通知が出ない場合は,トピックを削除してアプリを再起動し,購読し直す(ntfy の既知の問題.購読し直すと通知の登録がやり直される) + - `{ "env": { "NTFY_TOPIC": "<トピック名>" } }` を以下のいずれかに書く(既存の設定がある場合は `env` にキーを追加する.反映されない場合はセッションを再起動する) + - **dev container を使わない場合**: ユーザー設定 `~/.claude/settings.json` に書く.デバイスごとに 1 回でよく,全プロジェクトで共通のトピックに通知される(フックの無いプロジェクトでは何も起きない).プロジェクトフォルダがクラウド同期(OneDrive 等)されていてもトピック名が同期されない + - **dev container を使う場合**: プロジェクトの `.claude/settings.local.json`(gitignore 済み)に書く.ホストの `~/.claude` は通常コンテナから見えないが,ワークスペースはコンテナに共有されるため,プロジェクトごとに 1 回でよい + - 両方に書いた場合はプロジェクトの `.claude/settings.local.json` が優先される + - 外部サービスを経由するため,通知には「Claude Code (リポジトリ名)」(origin リモートが無ければフォルダ名)と定型文だけを送り,コードや会話の内容は送らない + - 完了通知は**ターン終了のたびには送らない**.サブエージェント(`/implement` の coder 等)やワークフローに依頼して待っているだけのターン終了では黙り,依頼した作業が終わって本当に手が空いたときだけ「処理が完了しました」を送る(フックが `Stop` の入力 `background_tasks` を見て判定する).一方,バックグラウンドのシェル(開発サーバー等)は動かし続けるのが普通で,対象にすると通知が永久に止まるため判定から除いている + - 許可待ち・質問の通知は,サブエージェント起因なら本文に「(エージェント: coder)」のようにエージェント名が付く +- **成果物**: + - `ENV_02_環境構築手順.md` — メンバーの参加時や環境の再構築時に使う手順 + - `ENV_03_管理者用環境構築手順.md` — プロジェクト作成時に一度だけ行う初期設定(リポジトリ作成,GitHub リポジトリのセキュリティ設定,外部サービスの設定等) + - `ENV_04_開発コマンド.md`(任意)— アプリの起動・テスト・lint・ビルド等,日常の開発で使うコマンド一覧.人間とエージェント(tester・coder,`/auto-refactor`・`/auto-audit`)がコマンドを推測せずに済むようにするための唯一の参照先とする + - 立ち上げ時点で確定しているコマンドが無ければ**作成を後回しにしてよい**.実装中にコマンドが確定・変更された時点で作成・追記する(`/implement` の Phase 4 で自動的に見直される) + - コマンドが増えてきたら,タスクランナー(npm scripts・Makefile 等)への集約を優先し,本ファイルは薄い一覧に保つ(「手順書より自動化・コード化」の基本方針と同じ) + - `.claude/verify-profile.md`(任意)— **検証プロファイル**.Claude(`verifier` エージェント)がアプリを操作して動作確認できるようにするための,プロジェクト固有の情報(検証手段・**検証対象の環境**・実行コマンドと出力先・前提条件・認証/ロール・テストデータの規約・既知のノイズ・禁止操作・シナリオのテンプレート)をまとめたファイル + - 用意すると `/verify` と `/implement` の Phase 1b が動き,人間の動作確認の前段としてエッジケース等を Claude が先に潰す(人間の確認は無くならない.GUIDE_02).用意しなければ Phase 1b はスキップされ,従来どおり人間が動作確認する + - 雛形 `.claude/skills/verify/profile-template.md` を `.claude/verify-profile.md` にコピーして記入する.シナリオを実行するランナー(起動・実行・集計・後始末)はプロジェクト側に用意する(要件は雛形の「実行基盤の要件」,Web アプリ向けの実装例は同「参考実装」のリンク先) + - 検証対象の環境は**既定でローカルのみ**.staging 等の検証用環境を使わせるかは人間が判断し,使わせる場合だけ「いじっても影響が無い」と判断した根拠(本番と DB が分離されている・実ユーザーがいない・外部通知や課金が無効 等)とともに許可リストへ書く.書かれていない環境に Claude は接続しない.**本番はどんな場合も対象外** + - 技術スタックと開発コマンドが固まってから作る.立ち上げ時に作らず,必要になった時点で用意してもよい + - プロジェクト所有のファイルであり `/sync-template` で上書き・削除されない.検証結果の出力先は `.gitignore` に追加する + +## 仕様設計 (Specification) + +アプリケーションの設計を行う. + +- **人間が決めること**: ユーザー体験の方針,ビジネスロジックの最終判断 +- **AI に依頼できること**: アーキテクチャ設計のドラフト,データモデル定義,画面遷移図の整理 +- **成果物**: `SPEC_01_*.md`(プロジェクトの規模に応じて複数ファイルに分割) + +## 規約整備 (Coding Standards) + +技術スタックに応じたプロジェクト固有の規約を作成する. +汎用の規約(Git 運用・ドキュメント書式・ファイル命名・進捗記録・テンプレート改変記録)はテンプレートの `.claude/rules/` に含まれているため,ここでは技術スタック固有の規約を追加する. + +- **基本方針**: 言語・フレームワークの公式ガイドラインや広く採用されている標準規約に則る(例: Effective Dart,PEP 8,Google Style Guide 等).プロジェクト固有のルールは標準と異なる部分のみ定義する. +- **置き場所**: 「コードを書くときは常に X せよ」という指示型の規約は,docs ではなく **path-scoped rule**(`.claude/rules/*.md` の frontmatter に `paths:` を付けたもの)として作成する.該当ファイルの編集時に自動ロードされるため,規約の読ませ忘れが起きない.アーキテクチャの背景説明や設計判断の記録など「読み物・参照資料」は従来どおり docs(`04_SPEC`/`05_TECH`)に置く. +- **人間が決めること**: 標準規約でカバーされない判断(アーキテクチャパターンの選択等) +- **AI に依頼できること**: 標準規約を踏まえた規約のドラフト作成,参照元ガイドラインの調査 +- **作成すべき rule**(`paths` はプロジェクトの実際のディレクトリ構成に合わせて設定する): + - `.claude/rules/coding-style.md` — 命名規則,フォーマット,import 順序,型の使い方,コメントの書き方,コード内ドキュメントの規則(例: `paths: ["src/**", "lib/**"]`) + - `.claude/rules/project-structure.md` — プロジェクトのディレクトリ構成とファイル配置ルール(`paths` はソースディレクトリ全般) + - `.claude/rules/testing.md` — テストの種類,粒度,実行タイミング,カバレッジ基準,モック方針,テストファイルの配置規則(例: `paths: ["test/**", "**/*.test.*"]`) + - `.claude/rules/error-handling.md` — エラーの分類,処理方法,ログ出力の方針(`paths` はソースディレクトリ全般) + - ※ リファクタリング方針は言語に依存しないため,`.claude/agents/refactorer.md` の判断基準を共通ルールとして使用する + - ※ 機能実装完了フローは GUIDE_02 と `/implement` で定義済みのため,別途作成しない + - ※ 規約以外の運用フロー文書を `docs/01_GUIDE/` に追加する場合は,team 層の `GUIDE_03` との衝突を避けるため `GUIDE_04` 以降の番号を使う + +## 開発計画 (Development Plan) + +実装の順序とステップを決定する. + +- **人間が決めること**: 優先度,リリース計画,マイルストーン +- **AI に依頼できること**: 仕様を元にしたステップ分割の提案,依存関係の整理 +- **成果物**: `PLAN_02_開発ステップ.md` + +## 実装開始 (Implementation) + +開発計画に沿って AI と協働で実装を進める. + +- 各ステップの完了時に Git 規約(`.claude/rules/git-conventions.md`)に従ってコミット・PR を作成する(ドキュメント書式は `.claude/rules/markdown-style.md` が自動適用される). +- 仕様変更が発生した場合は,先にドキュメントを更新してから実装に反映する. + +## CLAUDE.md の管理 (CLAUDE.md Management) + +`CLAUDE.md` は AI が会話開始時に最初に読むファイルであり,プロジェクトの引継ぎ情報を一元管理する. +AI は毎回新しい会話で始まるため,このファイルが唯一の継続的な文脈となる. + +### 記載すべき内容 + +- **プロジェクト概要**: プロジェクト名,目的,技術スタックの要約 +- **開発進捗**: 現在のステップ(最新 1 行のみ).詳細な進捗履歴は `docs/PROGRESS.md` に追記する(進捗記録ルール `.claude/rules/progress-log.md` 参照) +- **必須ルール**: AI が実装時に必ず守るべきルール(コーディング規約,コミット前チェック等の要点) +- **ドキュメント一覧**: docs/ 内のファイルへの参照パス + +### 更新タイミング + +- ステップの完了時に開発進捗を更新する(CLAUDE.md は最新 1 行に上書き,`docs/PROGRESS.md` は末尾に追記) +- ドキュメントの追加・削除・リネーム時に参照パスを更新する +- 必須ルールに変更があった場合に反映する + +### 運用上の注意 + +- CLAUDE.md は AI が毎回読み込むため,簡潔に保つ.詳細は docs/ のドキュメントに委ね,CLAUDE.md には要点と参照先のみ記載する. +- 進捗記録は二段構成(CLAUDE.md = 最新 1 行 / `docs/PROGRESS.md` = 追記型フルログ)で管理する.詳細は `.claude/rules/progress-log.md` を参照. +- プロジェクト固有のルールが増えたら,docs/ に独立ファイルとして切り出し,CLAUDE.md からはリンクで参照する. diff --git "a/docs/01_GUIDE/GUIDE_02_\343\202\250\343\203\274\343\202\270\343\202\247\343\203\263\343\203\210\351\201\213\347\224\250\343\203\253\343\203\274\343\203\253.md" "b/docs/01_GUIDE/GUIDE_02_\343\202\250\343\203\274\343\202\270\343\202\247\343\203\263\343\203\210\351\201\213\347\224\250\343\203\253\343\203\274\343\203\253.md" new file mode 100644 index 0000000..9fb0d5d --- /dev/null +++ "b/docs/01_GUIDE/GUIDE_02_\343\202\250\343\203\274\343\202\270\343\202\247\343\203\263\343\203\210\351\201\213\347\224\250\343\203\253\343\203\274\343\203\253.md" @@ -0,0 +1,67 @@ +# エージェント運用ルール (Agent Operation Rules) + +実装品質を担保するために,3 つの専門エージェント(コーディング・テスト・リファクタリング)を用いた開発パイプラインを定義する.加えて,検証プロファイルを用意したプロジェクトでは検証エージェントが任意のフェーズとして加わる. +本ガイドは運用の**原則**を定める.各フェーズの具体的な実行手順(委譲プロンプト・分岐・確認メッセージ)は `/implement`(`.claude/skills/implement/SKILL.md`)が正本である. + +## 基本方針 (Basic Policy) + +- 「実装完了」とは,コーディング・テスト・リファクタリングの全フェーズが完了した状態を指す. +- 各フェーズは専門エージェントが担当し,フレッシュなコンテキストでレビューする.各エージェントは前フェーズのサマリーを入力として受け取り,加えて `git diff` で実際の変更差分を自ら確認する. +- フェーズのスキップは原則禁止する.リファクタリング不要の判断は Refactorer エージェント自身が行う.※ 例外は任意フェーズの Phase 1b(検証)で,前提が揃わない場合は理由を添えてスキップする. +- 検証エージェント(Phase 1b)は人間の動作確認を**置き換えない**.機械的に確かめられる範囲を先に潰し,人間が見る範囲を狭めるための前段であり,verifier の OK を人間の OK の代わりにしてはならない. +- テストは実装コードではなく**仕様**(`docs/04_SPEC/`)を基準に書く.実装が仕様と異なればテストが失敗し,バグを検出する. +- 人間の本質的な役割は「AI にできない判断と確認」であり,各フェーズの品質のゲートキーパーとなる. + +## エージェント一覧 (Agent List) + +| エージェント | 役割 | フェーズ | +| --- | --- | --- | +| `coder` | 機能実装・バグ修正 | Phase 1 | +| `verifier` | 動作・エッジケースの確認(任意.対象はローカル環境と,プロファイルが許可した検証用環境) | Phase 1b | +| `tester` | 仕様ベースのテスト作成・実行 | Phase 2 | +| `refactorer` | コード品質の改善 | Phase 3 | + +※ `verifier` は**検証プロファイル(`.claude/verify-profile.md`)を用意したプロジェクトでのみ動く任意のエージェント**.プロファイルが無ければ Phase 1b は理由を 1 行添えてスキップされ,動作確認は従来どおり人間が行う.単独では `/verify` で起動する.検証手段・検証対象の環境・実行コマンド・前提条件といったプロジェクト固有の情報はすべてプロファイルに書き,エージェント側は持たない(雛形: `.claude/skills/verify/profile-template.md`). +※ **検証対象の環境は既定でローカルのみ**.staging 等の検証用環境は,人間がプロファイルの「検証対象の環境」に「いじっても影響が無い」と判断した根拠つきで書いた接続先に限り許可される(書かれていない環境を verifier が自分の判断で使うことはない).**本番はどんな場合も対象外**. + +### 補助エージェント (Support Agents) + +実装パイプライン外でも,「思考(判断・文章品質)はメインループ,機械的な実行は軽量エージェント」の分担で使用量を節約する.サブエージェントは新規の小さいコンテキストで起動されるため,メインループのプロンプトキャッシュを壊さずに,安価なモデルにも上位モデルにも切り替えられる(メインのモデル切替はキャッシュ全損になるため行わない).以下は各 skill から起動される補助エージェント. + +| エージェント | 役割 | 使用元 | +| --- | --- | --- | +| `ops-runner` | git・gh・検証コマンドの機械的な実行(手順書に忠実に従い,判断が要る事態では停止して報告する) | `/commit`・`/task-create`・`/task-start`・`/task-handoff`・`/deps-update` | +| `deps-analyst` | 依存更新 1 件の影響分析(破壊的変更の抽出・利用箇所の突き合わせ・推奨対応) | `/deps-update` | +| `advisor` | 重要な価値判断・設計判断の相談役(Fable による熟考・助言.実装はしない).Claude が自発的に呼んでよい基準(不可逆な設計判断の直前・同じ失敗が 2 回続いたとき)と上限(1 タスク 2 回,無人ループは 1 起動 3 回)は `/consult` skill に定める | `/consult`(`/auto-refactor`・`/auto-audit` からも上限付きで使用) | + +## パイプラインの流れと人間の役割 (Pipeline & Human's Role) + +`/implement <タスク内容>` で開始する(作業ブランチは git-conventions ルールに従い作成される). + +| フェーズ | 担当 | やること | 人間の関与 | +| --- | --- | --- | --- | +| Phase 1: コーディング | `coder` | 実装に集中する(テスト・リファクタ・コミットはしない) | — | +| Phase 1b: 検証(任意) | `verifier` | 検証プロファイルに従い,ローカル環境(およびプロファイルが許可した検証用環境)で動作とエッジケースを確認する(コードは直さない).プロファイルが無ければスキップ | 完了後にブラウザ・実機で動作確認(AI にできない確認).verifier が確認済みの項目は除き,残りを確認する.NG ならフィードバックして修正させる | +| Phase 2: テスト | `tester` | 仕様(`docs/04_SPEC/`)を基準にテストを作成・実行する | テスト失敗時のみ方針判断(仕様・実装・テストのどれの問題か).全成功なら自動で Phase 3 へ | +| Phase 3: リファクタリング | `refactorer` | テストを安全網に,挙動を変えずに品質を改善する | なし(不要判断は Refactorer 自身が行う) | +| Phase 4: ドキュメント更新 | メイン | `docs/`・進捗記録を必要に応じて更新し,結果を `.claude/commit-context.md` に書き出す | 更新がある場合のみ確認 | +| 完了 | — | `/implement` はコミットしない | `/commit` でコミットする | + +チェック観点や委譲プロンプト等の詳細は `.claude/skills/implement/SKILL.md` を参照.`.claude/commit-context.md` は `/commit` が参照し,ドキュメント更新漏れチェックをスキップするために使う(`.gitignore` 対象). + +## コミットルール (Commit Rules) + +パイプライン完了後は `/commit` でコミットする.コミットメッセージのタグ等は `.claude/rules/git-conventions.md` に従う. + +※ リファクタリングとテストは実装と一体の成果物として,まとめてコミットする.必要に応じて分割コミットも可. + +※ **例外(無人運転)**: 以下のループは CLAUDE.md の「`/commit` 自発実行禁止」ルールに対するユーザー承認済みの例外として,専用ブランチに自律コミットする.いずれも push・PR・マージ・`main` への操作は行わず,取り込み可否は人間が判断する. + +- `/auto-refactor`(リファクタ/ドキュメント整理ループ)→ `refactor/` 専用ブランチ +- `/auto-audit`(バグ/脆弱性の巡回監査ループ.発見を裏取り後,再現テストで正しさを担保できるものだけ自動修正し,それ以外は台帳へ報告)→ `fix/` 専用ブランチ + +※ **例外(Dependabot PR の取り込み)**: `/deps-update` は,Dependabot が作成した依存更新 PR に限り,ゲート(メジャー更新でない・競合なし・CI 緑またはローカル検証緑)を満たすものを `gh pr merge --merge` で `main` に取り込む.これは「Claude が `main` へマージする」唯一の承認済み例外であり,ユーザーが明示的に起動した時のみ動く.メジャー更新・CI 赤・PR が付かない alert は影響分析付きで報告し,人間が判断する(`.claude/skills/deps-update/SKILL.md`). + +## 進捗記録の運用ルール(CLAUDE.md / PROGRESS.md) + +進捗記録(CLAUDE.md「開発進捗」・`docs/PROGRESS.md` の書き方)のルールは `.claude/rules/progress-log.md` に定義されている.該当ファイルを編集する際に自動で読み込まれる. diff --git "a/docs/01_GUIDE/GUIDE_03_\343\203\201\343\203\274\343\203\240\351\226\213\347\231\272\343\203\253\343\203\274\343\203\253.md" "b/docs/01_GUIDE/GUIDE_03_\343\203\201\343\203\274\343\203\240\351\226\213\347\231\272\343\203\253\343\203\274\343\203\253.md" new file mode 100644 index 0000000..7561097 --- /dev/null +++ "b/docs/01_GUIDE/GUIDE_03_\343\203\201\343\203\274\343\203\240\351\226\213\347\231\272\343\203\253\343\203\274\343\203\253.md" @@ -0,0 +1,152 @@ +# チーム開発ルール (Team Development Rules) + +チーム全員が Claude Code を用い,人間が直接コードを書くことはほぼない前提で,並行開発を破綻させずに進めるための協調ルールを定義する. + +> 本ガイドは**チーム開発モード**(`/setup` で team を選択したプロジェクト)でのみ有効.個人開発モード(solo)のプロジェクトには本ファイルは配置されない.なお `/task-create`・`/task-start`・`/task-handoff` は共通層の skill であり,solo でも Issue ベースのタスク管理に使用できる(その場合,本ガイドのチーム協調ルールは適用されない). + +Git の規約は `.claude/rules/git-conventions.md`(push・PR・マージ手順は `.claude/skills/commit/reference.md`),1 タスクの実装パイプラインは [GUIDE_02](GUIDE_02_エージェント運用ルール.md) に従う.本ガイドはそれらの上に立つ「複数人が同時に動くときの調整」を扱う. + +## 基本方針 (Basic Policy) + +- 全員が Claude Code で開発し,実装はエージェントが行う(チーム規模はプロジェクトによる). +- 原則として並行作業を行わず,1 人ずつ直列で開発する.急ぎではないため,スループットより単純さと `main` の整合性を優先する.これは禁止ではなく既定のスタンスであり,状況に応じた判断は妨げない(詳細は「作業の直列化」). +- 人間の本質的な役割は「AI にできない判断と確認」(GUIDE_02 と同じ).加えて「共有設定の番人」を担う. +- 進捗・タスクは GitHub Issues と git 履歴で追う.`CLAUDE.md` には進捗を書かない(この点は solo 既定の進捗記録ルール `.claude/rules/progress-log.md` を**上書き**する). +- `main` は常に動作可能な状態を保つ(`.claude/rules/git-conventions.md`).壊れたら他の作業より優先して直す. + +## タスク管理 (Task Management) + +進捗管理の補助として GitHub Issues と Projects を用いる.`gh` コマンドでの具体的な操作手順(スコープ・Projects 操作・トラブル対応)は `.claude/skills/task-start/reference.md` を参照. + +### Issue (Issue) + +Issue は**進捗管理の補助**として使う.追跡したい作業に対して立て,PR と紐付けてボードで可視化する. + +- 作業 1 単位 = 1 Issue を推奨する.既に Issue がある作業は,新たに作らずそれに従って進める(重複 Issue を作らない). +- **Issue は着手・PR 作成・マージのいずれの段階でも必須としない**.小さい変更(typo・1 行修正等)や試験的・探索的な作業は Issue 無しで進めてよい. +- ただし**追跡する価値のある作業には Issue を立てるのが望ましい**(後追いと進捗可視化のため).特に複数回の `/implement` に跨る作業や,他メンバーに見えていてほしい作業は Issue を作る. +- PR と Issue を紐付ける場合は本文に `Closes #` を記載する.マージ時に Issue が自動クローズされ,ボードのカードも `Done` へ移る. +- Issue 本文には少なくとも「やること」を書く.「完了条件(受け入れ基準)」は非自明な作業のみで構わない. +- 担当者は明確にしておくのが望ましい(重複着手を避けるため). +- Issue は最初に書き切らなくてよい.作業途中の進捗・確定事項は `/task-handoff` の進捗メモとして Issue に集約し,完了は PR の `Closes #<番号>` によるマージ時の自動クローズで反映する(`/implement` のフローでは Issue を更新しない). + +### Project ボード (Project Board) + +> ⚠ **既定では未使用**.タスク管理は Issue ベース(`gh issue list`)で運用する.Project ボードはチームが拡大したり Issue が常時 10 件超になった等の局面で再導入する想定.以下は将来導入時の参考. + +- 列: `Todo` / `In Progress` / `In Review` / `Done`. +- Issue をカードとして配置し,状態を移動して可視化する. +- マージ連動の自動化を設定し,カード移動の手作業を最小化する(設定は管理者作業.「管理者の初期設定」参照). + +### タスクの流れ (Task Flow) + +Issue を立てる場合の標準フロー(小さい変更や探索的作業はこの限りでない). + +```text +1. Issue 起票(完了条件を明記) +2. 自分にアサイン(ボード未使用のためアサインのみ.ボード運用復活時は In Progress へ移動) +3. main から作業ブランチ作成(git-conventions ルール) +4. /implement に Issue の内容を渡して実装(GUIDE_02) +5. PR 作成,本文に "Closes #<番号>" +6. セルフマージ条件を確認 → マージ +7. マージで Issue 自動クローズ → カード自動 Done +``` + +- 上記 1 は `/task-create`(事前定義用),2 と 3 は `/task-start `(既存 Issue から着手)で半自動化できる.Issue 無しで進める場合はどちらも不要. +- Claude へのタスク文脈は `CLAUDE.md` ではなく Issue から渡す.`gh issue view ` で取得する. + +## ブランチと PR (Branch & PR) + +`.claude/rules/git-conventions.md` に従う.チーム運用上の追加事項のみ以下に定める. + +- ブランチ命名は git-conventions ルールの基本形式(`[プレフィックス][概要]`)に従う.ブランチ名に Issue 番号は含めなくてよい. +- branch↔Issue の紐付けは PR 本文の `Closes #<番号>` で行う.マージ時に Issue が自動クローズされる.既存 Issue からブランチを作る場合は `gh issue develop` でネイティブにリンクできる(`.claude/skills/task-start/reference.md`). +- PR の `--title` はコミットメッセージと同じ書式(`[タグ] 内容`,`.claude/rules/git-conventions.md`). + +## レビューとマージ (Review & Merge) + +誰も手書きしない体制では,マージ条件が唯一の品質ゲートとなる.本プロジェクトは**条件付きセルフマージ**を採用する.他メンバーの Approve 待ちは原則作らない代わりに,作者自身が満たすべき条件を実質的なゲートとして定める. + +### 通常の機能 PR (Feature PR) + +次を全て満たせば,作者がセルフマージしてよい. + +- CI で全テストが緑である. +- 作者自身が Phase 1 の動作確認(実機・ブラウザ等,AI にできない確認)を済ませている(GUIDE_02). +- `/implement` のパイプラインをスキップせず完走している. +- 対応 Issue がある場合は完了条件を満たしている(Issue 無しの PR は不要). +- 共有設定(`CLAUDE.md` のルール部・`.claude/`)の変更を含まない. + +※ レビューは任意.他メンバーはコメントしてよいが,マージをブロックしない. + +### 共有設定を変更する PR (Shared Config PR) + +`CLAUDE.md` のルール部や `.claude/` を変更する PR は,全員の Claude の挙動を変えるため例外とする. + +- 機能 PR に混ぜず,専用 PR とする(「共有設定の扱い」参照). +- 他メンバー 1 名の Approve を必須とする(セルフマージ不可). + +### Dependabot の依存更新 PR (Dependabot PR) + +Dependabot が作成する依存更新 PR(security updates / version updates)は,人間が書いた PR ではないため上記の「`/implement` 完走」「Phase 1 動作確認」を満たせない.代わりに `/deps-update` のゲートを品質ゲートとする(`.claude/skills/deps-update/SKILL.md`). + +- **`/deps-update` がマージしてよいもの**: メジャー更新でなく,競合が無く,CI 緑(CI 未構築ならローカル検証緑)の PR.`gh pr merge --merge` で取り込む.これは「AI が `main` へマージする」唯一の例外である(GUIDE_02「コミットルール」). +- **人間が判断するもの**: メジャー更新(`0.x` のマイナー更新を含む),CI 赤,修正版の無い alert,Dependabot が PR を作れなかった alert.`/deps-update` が影響分析と推奨(そのままマージ/`/implement` で追従/`@dependabot ignore` で見送り)を PR コメントと完了報告に出すので,それを見て決める. +- 依存更新 PR は共有設定(`CLAUDE.md`・`.claude/`)を変更しないため,他メンバーの Approve は不要.`main` にブランチ保護(必須レビュー)を設定している場合は `/deps-update` はマージできないので,マージ可と判定した一覧を提示するにとどまり,人間がマージする. +- `/deps-update` を実行するのは原則 1 名(管理者またはその週の担当)とし,マージ後はチームに周知して全員が `git pull` する(直列運用のため,他メンバーの作業ブランチは次の rebase で追従する). + +### CI 構築までの暫定ゲート (Interim Gate) + +CI が未構築の間は「CI 緑」を以下で代替する.CI 構築後はこの節を削除する. + +- `/implement` を完走している. +- ローカルで全テストが緑である. +- 作者自身が Phase 1 の動作確認を済ませている. + +## 共有設定の扱い (Shared Configuration) + +`CLAUDE.md` と `.claude/`(agents・skills・rules・hooks・settings.json・template-overrides.md)は全員の Claude の挙動を決める共有インフラである. + +- **git 追跡を継続する**.untrack や `.gitignore` 化はしない(履歴の保全と挙動の統一のため). +- **進捗は `CLAUDE.md` に書かない**.進捗は Issues と git 履歴で追う(「タスク管理」参照). +- **変更は「チームルール変更」カテゴリ**として扱う. + - 機能 PR に混ぜない.専用 PR とする(ブランチは `chore/` または `docs/`). + - 他メンバー 1 名の Approve を必須とする. + - マージ後はチームに周知し,全員が `git pull` して Claude セッションを再起動する. + - ※ Claude はセッション開始時に `CLAUDE.md` を読むため,古いまま動くと文脈がずれる. +- **例外: ドキュメント一覧への追記**は,そのドキュメントを追加する PR と同じ PR で行ってよい.衝突しても両方の行を残せば解決できる. +- **個人・マシン固有の設定**は `.claude/settings.local.json` に置く.これは `.gitignore` 対象とし,共有の `settings.json` を個人都合で汚さない. + +## 作業の直列化 (Serialized Work) + +- 原則として,同時に進行中のコード作業は 1 件とする(進行中の Issue は原則 1 件.Project ボード運用復活時は `In Progress` 列も同様). +- 次の作業に着手してよいのは,前の作業の PR が `main` にマージされた後とする(マージ条件は「レビューとマージ」参照). +- タスクは小さく保つ.直列運用では,1 単位が小さいほど次の人の待ち時間が短くなる. +- 直列運用により並行コンフリクトと共有設定のドリフトは原理的にほぼ発生しない.例外的に並行が生じてコンフリクトした場合は `.claude/skills/commit/reference.md` の rebase 手順をフォールバックとして用いる. +- `main` を壊した場合は他作業より優先して復旧する.自分で対処できない場合は直ちにチームへ報告する(`.claude/skills/commit/reference.md`「間違えて main にコミットしてしまった場合」). + +## メンバーのオンボーディング (Onboarding) + +新メンバー参加時の手順. + +1. リポジトリへのコラボレーター招待を承認する(GitHub の通知,または `https://github.com///invitations`). +2. リポジトリを clone する. +3. `ENV_02_環境構築手順.md`(GUIDE_01 参照,立ち上げ時に作成)に従い開発環境を構築する. +4. `gh auth login` で GitHub CLI を認証する.Projects も操作する場合は `project` スコープを含める(`.claude/skills/task-start/reference.md`「必要なスコープ」). +5. 必要なら `.claude/settings.local.json` を用意する(個人設定.git 追跡されない). +6. 最初に `CLAUDE.md` と `docs/01_GUIDE/` を読む(特に本ガイドと GUIDE_02).Git 規約は `.claude/rules/git-conventions.md` にある. +7. `.gitignore` が整備されていることを確認する(`.claude/rules/git-conventions.md`). + +## 管理者の初期設定 (Admin Setup) + +以下は外部サービス操作を含み,原則として AI には実施できない(`gh` で操作できる設定のみ例外として注記する).ルール確定後に管理者(人間)が一度だけ行う. + +- GitHub リポジトリの作成,メンバー招待. + - リポジトリ作成後,**GitHub リポジトリのセキュリティ設定**(Dependabot alerts / security updates の有効化)は AI が `gh api` で行う(`/setup` の Phase 3・7 で実行.コマンド・検証・トラブル対応は `.claude/skills/setup/reference.md`「GitHub リポジトリのセキュリティ設定」,背景は GUIDE_01「環境構築」).`/setup` 完了後にリポジトリを作成した場合は,同 `reference.md` または `ENV_03_管理者用環境構築手順.md` に転記したコマンドを実行する(管理者が直接実行してもよい). +- (既定では未使用)GitHub Project の作成,列(`Todo` / `In Progress` / `In Review` / `Done`)の定義. +- (既定では未使用)マージ連動の自動化設定(PR マージ時に Issue クローズ・カードを `Done` へ移動). +- Issue テンプレートの用意(「やること」「完了条件」欄). +- 共有設定変更 PR に他メンバー Approve を求める運用の周知(必要なら `main` のブランチ保護設定). +- CI の構築(全テストを実行し,緑をマージ条件にできる状態にする). + +※ 完了するまでは本ガイドの「CI 構築までの暫定ゲート」を適用する. diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md new file mode 100644 index 0000000..68e9e62 --- /dev/null +++ b/docs/PROGRESS.md @@ -0,0 +1,11 @@ +# 開発進捗ログ + +