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/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 index e8ea139..eca9cd6 100644 --- a/.claude/hooks/restrict_repo_access.py +++ b/.claude/hooks/restrict_repo_access.py @@ -12,6 +12,8 @@ 事故から守る)に照らして許可ゾーンにしても失うものがない. """ +from __future__ import annotations + import json import os import posixpath 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..ebec9f6 --- /dev/null +++ b/.claude/hooks/tests/test_hooks.py @@ -0,0 +1,775 @@ +"""`.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 http.server +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile +import threading +import unittest + +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_read_outside_repo(self): + data = {"tool_name": "Read", "tool_input": {"file_path": self.OUTSIDE}, "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) + + +if __name__ == "__main__": + unittest.main() diff --git a/.claude/rules/template-customization.md b/.claude/rules/template-customization.md index 8e62ef2..58f1c59 100644 --- a/.claude/rules/template-customization.md +++ b/.claude/rules/template-customization.md @@ -12,6 +12,7 @@ - **対象(テンプレートが管理するファイル)**: `.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.md`・`.gitignore`・`docs/PROGRESS.md`・`.gitattributes`・`.claude/settings.json`.これらは `/sync-template` の「マージ必須ファイル」として別途保護されており,台帳登録は不要 - テンプレート由来かどうか判断に迷う場合は**記録する側に倒す**.余計な記録は `/sync-template` が「テンプレート側に存在しないパス」として警告してくれるが,記録漏れは改変の消失につながる diff --git a/.claude/settings.json b/.claude/settings.json index b06d6ff..9c8a859 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -11,7 +11,40 @@ "hooks": [ { "type": "command", - "command": "python .claude/hooks/restrict_repo_access.py" + "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/implement/SKILL.md b/.claude/skills/implement/SKILL.md index ffc6932..d242253 100644 --- a/.claude/skills/implement/SKILL.md +++ b/.claude/skills/implement/SKILL.md @@ -1,7 +1,7 @@ --- name: implement model: inherit -description: "実装パイプラインを開始する.コーディング → テスト → リファクタリングの順で 3 つのエージェントを実行し,必要に応じて人間の確認を挟む." +description: "実装パイプラインを開始する.コーディング(+任意の検証)→ テスト → リファクタリングの順で専門エージェントを実行し,必要に応じて人間の確認を挟む." argument-hint: "<タスク内容>" --- @@ -21,18 +21,75 @@ 「以下のタスクを実装してください: $ARGUMENTS」 -Coder の出力(実装サマリー)を記録し,ユーザーに提示する: +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** → 問題点を教えてください.修正します」 -ユーザーの OK を得るまで Phase 2 に進まないこと. -NG の場合は coder エージェントに修正を委譲し,再度確認を依頼する. +**verifier を起動した場合**は,「確認済み」と「確認してほしい」の 2 部構成で提示する: + +「**Phase 1(コーディング)が完了しました.** + +{Coder の実装サマリー} + +**Claude が確認済みの項目** +{verifier の「確認した項目」の表.差し戻しで直した不具合があればその内容も.検証不能だった場合は,この節を「検証を実行できませんでした(理由: …)」に置き換える} + +**あなたに確認してほしい項目** +{verifier の「人間が確認すべき残り項目」.省略・要約しすぎない.検証不能だった場合は「ブラウザや実機で動作を確認してください」} + +{未解決の不具合・今回の変更と無関係な既存の問題があれば,ここに分けて記載する} + +- **OK** → 次のフェーズ(テスト)に進みます +- **NG** → 問題点を教えてください.修正します」 + +ユーザーの OK を得るまで Phase 2 に進まないこと.**verifier の判定が OK でも,それをユーザーの OK の代わりにしてはならない.** +NG の場合は coder エージェントに修正を委譲し,再度確認を依頼する(修正が観測可能な挙動に関わり,検証プロファイルがある場合は Phase 1b からやり直す). ## Phase 2: テスト (Tester) diff --git a/.claude/skills/setup/SKILL.md b/.claude/skills/setup/SKILL.md index 19f0839..59b16ee 100644 --- a/.claude/skills/setup/SKILL.md +++ b/.claude/skills/setup/SKILL.md @@ -17,6 +17,15 @@ - 成果物のドラフトを提示し,ユーザーの修正指示を反映してからファイルに書き出す - フォルダは成果物を実際に書き出すときにのみ作成する.空のカテゴリフォルダを先回りで作らない +## 立ち上げ中の Git 運用 (Git During Setup) + +立ち上げ作業は専用ブランチ `chore/project-setup` の上で行い,**各フェーズの成果物を書き出してユーザーが承認したら,その場でコミットする**(フェーズごとに 1 コミット).手順・コミットメッセージの対応表は本スキルの `reference.md`(`.claude/skills/setup/reference.md`)「フェーズごとのコミット」に従う. + +- 本スキルは CLAUDE.md「Git 運用」の「`/commit` 自発実行禁止」に対する**ユーザー承認済みの例外**である(`/auto-refactor`・`/auto-audit` と同格).例外が認められるのは `chore/project-setup` ブランチへのコミットに限る +- **`git push`・PR 作成・マージ・`main` への直接コミットは一切行わない**.`main` への取り込みは全フェーズ完了後にユーザーが `/commit push`(または `/commit merge`)で行う +- ブランチは最初のコミットを作る直前に `main` から作成する.既に `chore/project-setup` にいる場合(再開時)はそのまま使い続ける +- コミット履歴がそのままフェーズの進捗になるため,中断・再開しても「どこまで終わったか」が git から辿れる(`CLAUDE.md` の進捗欄・`docs/PROGRESS.md` の更新はそのフェーズのコミットに含める) + ## 前提確認 (Pre-check) 1. CLAUDE.md がテンプレート初期状態(タイトルが `# プロジェクト名` のまま)か確認する @@ -77,6 +86,7 @@ 2. ユーザーとの対話で情報を収集する 3. 成果物のドラフトを作成し,ユーザーに提示する 4. 承認を得てから GUIDE_01 に記載された保存先に書き出す +5. 書き出した成果物をコミットする(前述「立ち上げ中の Git 運用」・`reference.md`「フェーズごとのコミット」.push・PR は行わない) ### 台詞テンプレート @@ -84,7 +94,7 @@ {ドラフト内容} -修正点があれば指示してください.OKであれば {保存先} に保存して次のフェーズに進みます.」 +修正点があれば指示してください.OKであれば {保存先} に保存し,`chore/project-setup` にコミットして次のフェーズに進みます.」 ### フォルダ作成の注意 @@ -94,6 +104,7 @@ 外部サービスの設定(アカウント作成,コンソール操作等)はユーザー自身が行う.手順書に記載するが,AI が実行しないこと. **例外**として,`gh` CLI で操作できる GitHub リポジトリ設定は AI が実行する. +手順書を書き出した後は,それ以外の手順を実際に実行して環境を構築する(後述「環境構築の実行」.ツール導入は都度承認を得る). ### GitHub リポジトリのセキュリティ設定 (GitHub Repository Security Settings) @@ -110,6 +121,39 @@ - 生成内容(対象エコシステム・更新頻度・グループ化)は他の成果物と同様にドラフトとしてユーザーに提示し,承認を得てから書き出す(「作るかどうか」は聞かず,「内容」だけを確認する) - マニフェストがまだ存在しない場合(立ち上げ時点でコードが無い)でも,スタックが確定していれば生成してよい.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 を最終更新する @@ -122,7 +166,8 @@ 2. 作成した全ドキュメントの一覧を表示する 3. 最初の実装ステップを確認する 4. **モードに関わらず**,「GitHub リポジトリのセキュリティ設定」(前述)を実行する(Phase 3 で有効化済みなら検証コマンドだけで確認する.リモートがまだ無ければ,リポジトリ作成後に ENV_03 のコマンドを実行するようユーザーに案内する) -5. **team モードの場合**は,AI に実施できない「管理者の初期設定」(GUIDE_03「管理者の初期設定」)をユーザーに案内する: +5. CLAUDE.md の最終更新・進捗の更新を `chore/project-setup` にコミットする(前述「立ち上げ中の Git 運用」).`main` への取り込みは行わず,ユーザーに `/commit push`(または `/commit merge`)を案内する +6. **team モードの場合**は,AI に実施できない「管理者の初期設定」(GUIDE_03「管理者の初期設定」)をユーザーに案内する: - GitHub リポジトリ作成・メンバー招待 - Issue テンプレートの用意 - CI 構築(未構築の間は GUIDE_03「CI 構築までの暫定ゲート」を適用) @@ -138,6 +183,8 @@ GitHub リポジトリのセキュリティ設定(Dependabot alerts / security updates): {有効化済み / リポジトリ未作成のため未実施(ENV_03 のコマンドで有効化してください)} 依存バージョン更新(`.github/dependabot.yml`): {対象エコシステム一覧} を {更新頻度} で監視 Dependabot が作る PR・alert は `/deps-update` で処理できます(安全な更新は自動マージ,メジャー更新等は分析付きで報告). +立ち上げの成果物は `chore/project-setup` に {N} コミットで積んであります.`main` への取り込みは `/commit push`(PR 作成まで)または `/commit merge`(マージまで)で行ってください. +環境構築: {実行した手順の要点 / ユーザーに依頼した手順 / 未検証のまま残した手順} 最初の実装ステップは `{ステップ名}` です.`/implement {タスク}` で開始できます. {team の場合: 「チーム運用の管理者初期設定(GitHub repo・CI 等)は GUIDE_03 を参照してユーザー側で実施してください.」}」 @@ -146,9 +193,11 @@ ユーザーが中断を希望した場合: 1. 現在のフェーズと状態を CLAUDE.md の「開発進捗」(最新 1 行に上書き)と `docs/PROGRESS.md`(末尾に追記)に記録する -2. 次回 `/setup` 実行時に続きから再開できるようにする +2. ここまでの成果物と進捗の更新を `chore/project-setup` にコミットする(未コミットの差分を残さない.push・PR は行わない) +3. 次回 `/setup` 実行時に続きから再開できるようにする 「**進捗を保存しました.** 現在の状態: フェーズ {N}({フェーズ名})まで完了 -次回 `/setup` を実行すると,フェーズ {N+1} から再開します.」 +`chore/project-setup` にコミット済みです(未コミットの変更はありません). +次回 `/setup` を実行すると,同じブランチでフェーズ {N+1} から再開します.」 diff --git a/.claude/skills/setup/reference.md b/.claude/skills/setup/reference.md index e1f9466..116934f 100644 --- a/.claude/skills/setup/reference.md +++ b/.claude/skills/setup/reference.md @@ -1,6 +1,6 @@ # setup リファレンス (Setup Reference) -`/setup`(`SKILL.md`)から参照される,GitHub リポジトリまわりの設定手順集(`gh` CLI で行うリポジトリ設定と,`.github/dependabot.yml` の生成規則).`/setup` の途中で AI が実行するほか,`/setup` 完了後にリポジトリを作成した場合などに管理者(人間)が単体で実行する runbook としても使う. +`/setup`(`SKILL.md`)から参照される実行手順集(`gh` CLI で行うリポジトリ設定,`.github/dependabot.yml` の生成規則,フェーズごとのコミット,環境構築の実行).`/setup` の途中で AI が実行するほか,`/setup` 完了後にリポジトリを作成した場合などに管理者(人間)が単体で実行する runbook としても使う. ## GitHub リポジトリのセキュリティ設定 (GitHub Repository Security Settings) @@ -121,3 +121,77 @@ ### 動作確認 (Verification) `main` に push された後,GitHub の Insights → Dependency graph → Dependabot で各エコシステムの「Last checked」と結果を確認できる.YAML の構文エラーはここにも表示される. + +## フェーズごとのコミット (Per-phase Commit) + +`/setup` は立ち上げ専用ブランチ `chore/project-setup` の上で,各フェーズの成果物を書き出してユーザーが承認したタイミングごとにコミットする.`git push`・PR 作成・マージ・`main` への直接コミットは行わない(CLAUDE.md「Git 運用」の例外が認められるのはこのブランチへのコミットに限る). + +### 手順 (Procedure) + +1. 最初のコミットの直前にブランチを用意する(2 回目以降のフェーズは作成済みのブランチをそのまま使う): + + ```bash + git branch --show-current # chore/project-setup なら,そのまま使う + git switch -c chore/project-setup # main にいる場合のみ作成する + ``` + + - コミットが 1 つも無い(`git log` が失敗する)場合は,先に最初のコミットを作ってからブランチを切る(コミットが無いとブランチを作れないため) +2. そのフェーズで書き出したファイルだけをステージングする(`.env` やクレデンシャル・生成物はステージングしない.`.gitignore` の整備を先に済ませる) +3. `.claude/rules/git-conventions.md` の書式(`[タグ] 内容`・日本語・末尾の句点なし)でコミットする + + ```bash + git add docs/03_PLAN/PLAN_01_要件定義書.md + git commit -m "[add] 要件定義書を作成" + ``` + +4. コミットした旨(ブランチ・メッセージ)を次フェーズの案内と一緒に 1 行で報告する + +### フェーズとコミットの対応 (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 は「手順書・設定ファイルの作成」と「実際の構築(+手順書の修正)」で差分の性質が違うため,2 コミット以上に分けてよい(例: `[add] 環境構築手順と設定ファイルを整備` → `[update] 実際の構築結果を環境構築手順に反映`) +- 進捗(`CLAUDE.md` の進捗欄・`docs/PROGRESS.md`)を更新した場合は,そのフェーズのコミットに含める +- 承認を得た成果物がまだ無いフェーズ(対話だけで終わった)ではコミットしない(空コミットを作らない) + +### 取り込み (Merging into main) + +全フェーズ完了後,ユーザーが `/commit push`(PR 作成まで)または `/commit merge`(マージまで)で `main` に取り込む.`/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 index 55d1311..d2a3d0d 100644 --- a/.claude/skills/sync-template/SKILL.md +++ b/.claude/skills/sync-template/SKILL.md @@ -61,8 +61,9 @@ | ファイル | 理由 | 方針 | | --- | --- | --- | | `README.md` | テンプレートの README は GitHub の repo ページ向けのテンプレート紹介用.各プロジェクトは独自の README を持つべき | プロジェクト側にコピー・上書きしない.テンプレート側の追加・変更・削除も無視する | +| `.claude/tests/`(配下すべて) | テンプレート自身のメタテスト(skill・エージェント定義・README 等がテンプレートとして整合しているかを検証する).テンプレート固有の内容を前提とするため派生プロジェクトでは必ず失敗する | 同上 | -判定はステップ 5.3 のループ内でマージ必須ファイル判定より先に行う. +判定はステップ 5.3 のループ内でマージ必須ファイル判定より先に行う.`SKIP_FILES` の末尾が `/` の項目はディレクトリ指定で,配下のすべてのパスに前方一致で適用する(`reference.md`「変数・判定ヘルパー」). ## モード依存ファイル (Mode-gated / Team-layer Files) @@ -75,6 +76,8 @@ ※ `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 すべて反映). @@ -326,5 +329,5 @@ - 台帳への登録は必ず理由を添える(ユーザーから聞く).理由の無い登録は次回同期時の判断材料にならない.台帳の書式(パス列はバッククォート囲み,方針列は `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/` 配下のうち `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` のみ)等のプロジェクト固有ファイルはテンプレートに含まれないため同期対象外(`$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 index 355b38c..0ee45b4 100644 --- a/.claude/skills/sync-template/reference.md +++ b/.claude/skills/sync-template/reference.md @@ -15,7 +15,7 @@ ```bash MERGE_FILES=(".gitignore" "CLAUDE.md" "docs/PROGRESS.md" ".gitattributes" ".claude/settings.json" ".claude/template-overrides.md") -SKIP_FILES=("README.md") +SKIP_FILES=("README.md" ".claude/tests/") # 末尾 / はディレクトリ配下すべてを対象にする TEAM_LAYER_FILES=( "docs/01_GUIDE/GUIDE_03_チーム開発ルール.md" ".claude/hooks/check_sync.sh" @@ -45,7 +45,10 @@ is_skip_file() { local t="$1" for f in "${SKIP_FILES[@]}"; do - [ "$f" = "$t" ] && return 0 + case "$f" in + */) [[ "$t" == "$f"* ]] && return 0 ;; # ディレクトリ指定(末尾 /)は前方一致 + *) [ "$f" = "$t" ] && return 0 ;; + esac done return 1 } @@ -334,7 +337,7 @@ 3. **既存の hooks を保持したまま**,テンプレート側で追加・変更された hook(イベント・matcher・command)のみを統合する: - 既存に無いイベント/hook はテンプレート版から追加する - プロジェクト固有の hook(team の SessionStart `check_sync.sh` 等)は残す - - 同一 hook の command 変更(例: `restrict_repo_access.py` の起動方法変更)はテンプレート版に合わせる + - 同一 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` が同期で配置済みであることも確認する - solo モードで SessionStart(check_sync) が無い場合は,team 専用の配線を勝手に追加しない 4. `hooks` 以外のキーも同じ方針でマージする.特に `permissions.allow` は,テンプレート側に追加されたルール(`Bash(gh pr merge:*)` 等.auto mode で ops-runner にマージを実行させるために必要)を既存へ追加し,プロジェクトが独自に足したルールは残す(削除しない) 5. `git diff .claude/settings.json` で結果を表示しユーザーに確認する.**permissions の変更はセッション再起動後に反映される**ため,追加があった場合は再起動が必要な旨をユーザーに伝える 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-sync-sha b/.claude/template-sync-sha index 6f6497b..33da67c 100644 --- a/.claude/template-sync-sha +++ b/.claude/template-sync-sha @@ -1 +1 @@ -0ac68c7d78975e6688d602cd5b7e3fcf9f13b4bf \ No newline at end of file +00dd94e0234076eb545a662e737594413a336066 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..b27e197 --- /dev/null +++ b/.claude/tests/test_setup_flow.py @@ -0,0 +1,234 @@ +"""/setup のフェーズごとのコミットと環境構築の実行のテスト. + +実行(リポジトリルートで): + python -B -m unittest discover -s .claude/tests -v + +仕様の正本(ユーザーが承認した追加仕様): + 1. `/setup` は各フェーズが終わるたびに,専用ブランチ `chore/project-setup` へコミットする + (push・PR 作成・マージ・`main` への直接コミットはしない.取り込みは完了後に人間が `/commit` で行う) + 2. フェーズ 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:]) + + +class TestSetupSkillCommitsEachPhase(unittest.TestCase): + """仕様 1: フェーズごとのコミットが `/setup` の手順に組み込まれていること.""" + + @classmethod + def setUpClass(cls): + cls.text = read(SETUP_SKILL) + + def test_has_git_section_with_dedicated_branch(self): + body = section(self.text, "立ち上げ中の Git 運用") + self.assertIn(SETUP_BRANCH, body) + self.assertIn("フェーズ", body) + + def test_states_no_push_pr_merge(self): + body = section(self.text, "立ち上げ中の Git 運用") + for word in ("push", "PR", "マージ"): + with self.subTest(word=word): + self.assertIn(word, body) + self.assertIn("/commit push", body) + + def test_declares_exception_to_commit_prohibition(self): + body = section(self.text, "立ち上げ中の Git 運用") + self.assertIn("例外", body) + self.assertIn("`/commit`", body) + + def test_phase_loop_includes_commit_step(self): + body = section(self.text, "フェーズ 1〜6") + steps = [line for line in body.splitlines() if re.match(r"^\d+\. ", line)] + self.assertTrue(steps, "フェーズの手順が箇条書きで見つからない") + self.assertTrue( + any("コミット" in s for s in steps), + f"フェーズの手順にコミットのステップが無い: {steps}", + ) + + def test_phase7_commits_and_hands_merge_to_user(self): + body = section(self.text, "フェーズ 7") + self.assertIn("コミット", body) + self.assertIn(SETUP_BRANCH, body) + self.assertRegex(body, r"/commit (push|merge)") + + def test_interruption_commits_before_stopping(self): + body = section(self.text, "中断時の処理") + self.assertIn("コミット", body) + self.assertIn(SETUP_BRANCH, body) + + +class TestSetupReferenceDescribesCommitProcedure(unittest.TestCase): + """仕様 1: 実行手順(ブランチの作り方・フェーズ対応表)が 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_shows_commit_command_following_git_conventions(self): + self.assertIn("git commit -m", self.body) + self.assertIn(".claude/rules/git-conventions.md", 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): + """仕様 2: 手順書の作成後に実際の環境構築を行うことが定義されていること.""" + + @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 が同じ 2 つの仕様を述べていること.""" + + 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_02_lists_setup_as_commit_exception(self): + body = section(read(GUIDE_02), "コミットルール") + lines = [line for line in body.splitlines() if "`/setup`" in line] + self.assertTrue(lines, "GUIDE_02 のコミットルールに /setup の例外が無い") + self.assertIn(SETUP_BRANCH, lines[0]) + + def test_claude_md_lists_setup_in_commit_exceptions(self): + text = read(CLAUDE_MD) + lines = [line for line in text.splitlines() if "`/setup`" in line and SETUP_BRANCH in line] + self.assertTrue(lines, "CLAUDE.md の自律コミット例外に /setup が無い") + + def test_readme_mentions_both_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"構築まで行う|実際に構築") + + +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/CLAUDE.md b/CLAUDE.md index a9d43e8..79ca5e9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -71,7 +71,8 @@ - ブランチ名・コミットメッセージの書式は `.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` 等で行う) + - **例外**: 以下(プロジェクト立ち上げと無人運転ループ)はユーザー承認済みの例外として専用ブランチに自律コミットする.いずれも push・PR・マージ・`main` への操作はしない(取り込みは人間が `/commit push` 等で行う) + - `/setup`(プロジェクト立ち上げ)→ `chore/project-setup` 専用ブランチ(フェーズごとに 1 コミット) - `/auto-refactor`(リファクタ/ドキュメント整理ループ)→ `refactor/` 専用ブランチ - `/auto-audit`(バグ/脆弱性の巡回監査ループ)→ `fix/` 専用ブランチ - **例外(`main` へのマージ)**: `/deps-update` は,Dependabot が作成した依存更新 PR に限り,ゲート(メジャー更新でない・競合なし・CI 緑またはローカル検証緑)を満たすものを `gh pr merge` で `main` に取り込む(ユーザーが明示的に起動した時のみ.メジャー更新・CI 赤・PR が付かない alert は報告して人間が判断する) @@ -94,6 +95,7 @@ - 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 は人間の動作確認の代わりにならない** ### 開発コマンド @@ -110,7 +112,7 @@ - 外部サービスの設定(コンソール操作,セキュリティルール変更等) - 例外: `gh` CLI で操作できる GitHub リポジトリ設定(Dependabot alerts / security updates の有効化)は Claude が `gh api` で行う(手順は `.claude/skills/setup/reference.md`,背景は GUIDE_01「環境構築」) -- 実機・ブラウザでの動作確認 +- 実機・ブラウザでの動作確認(`verifier` による自動検証は前段であり,これを置き換えない) - ストアへのアップロード・リリース作業 ## ドキュメント 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" index 7ddca5d..574cdde 100644 --- "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" @@ -23,13 +23,21 @@ | レイヤ | 対象 | 存在するモード | | --- | --- | --- | -| 共通層 | `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`)・`task-create`・`task-start`(+ `reference.md`)・`task-handoff`) | solo・team 両方 | +| 共通層 | `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` の上で行い,**各フェーズの成果物が確定(ユーザーが承認)した時点でコミットする**(フェーズごとに 1 コミット).`/setup` が Git 規約(`.claude/rules/git-conventions.md`)の書式でコミットまで行い,`git push`・PR 作成・マージ・`main` への直接コミットは行わない.手順とフェーズごとのコミットメッセージ例は `.claude/skills/setup/reference.md`「フェーズごとのコミット」にある. + +- 狙いは,立ち上げの成果物をフェーズ単位でレビュー・巻き戻しできる形にし,中断・再開しても「どこまで終わったか」が git 履歴から辿れるようにすること +- 全フェーズ完了後,`main` への取り込みはユーザーが `/commit push`(PR 作成まで)または `/commit merge`(マージまで)で行う +- これは CLAUDE.md「Git 運用」の「`/commit` 自発実行禁止」に対するユーザー承認済みの例外(`/auto-refactor`・`/auto-audit` と同格)であり,`chore/project-setup` ブランチへのコミットに限る + ## 方針決定 (Direction) プロジェクトの全体像を明確にする. @@ -53,18 +61,39 @@ - **前提条件**: 以下のツールは全プロジェクト共通で導入する. - `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・Glob・Grep・Bash を**すべてブロックする**(通知フックは何もせず素通りする) - **基本方針**: 環境の再現性を重視し,手順書だけに頼らず構築を自動化・コード化できる方法を優先する(例: Docker,Dev Containers,Windows Sandbox,IaC ツール等). - **人間が決めること**: 開発マシンの選定,クラウドサービスのアカウント作成,環境構築方法の選択 -- **AI に依頼できること**: 環境構築手順書の作成,設定ファイルの生成,`.gitignore` の作成,Dockerfile や devcontainer.json 等の構築用ファイルの作成,GitHub リポジトリのセキュリティ設定・`.github/dependabot.yml` の生成(後述) +- **実際の構築まで行う**: 手順書を書いて終わりにせず,`/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/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) 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" index 429b73d..04fef34 100644 --- "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" @@ -1,13 +1,14 @@ # エージェント運用ルール (Agent Operation Rules) -実装品質を担保するために,3 つの専門エージェントを用いた開発パイプラインを定義する. +実装品質を担保するために,3 つの専門エージェント(コーディング・テスト・リファクタリング)を用いた開発パイプラインを定義する.加えて,検証プロファイルを用意したプロジェクトでは検証エージェントが任意のフェーズとして加わる. 本ガイドは運用の**原則**を定める.各フェーズの具体的な実行手順(委譲プロンプト・分岐・確認メッセージ)は `/implement`(`.claude/skills/implement/SKILL.md`)が正本である. ## 基本方針 (Basic Policy) - 「実装完了」とは,コーディング・テスト・リファクタリングの全フェーズが完了した状態を指す. - 各フェーズは専門エージェントが担当し,フレッシュなコンテキストでレビューする.各エージェントは前フェーズのサマリーを入力として受け取り,加えて `git diff` で実際の変更差分を自ら確認する. -- フェーズのスキップは原則禁止する.リファクタリング不要の判断は Refactorer エージェント自身が行う. +- フェーズのスキップは原則禁止する.リファクタリング不要の判断は Refactorer エージェント自身が行う.※ 例外は任意フェーズの Phase 1b(検証)で,前提が揃わない場合は理由を添えてスキップする. +- 検証エージェント(Phase 1b)は人間の動作確認を**置き換えない**.機械的に確かめられる範囲を先に潰し,人間が見る範囲を狭めるための前段であり,verifier の OK を人間の OK の代わりにしてはならない. - テストは実装コードではなく**仕様**(`docs/04_SPEC/`)を基準に書く.実装が仕様と異なればテストが失敗し,バグを検出する. - 人間の本質的な役割は「AI にできない判断と確認」であり,各フェーズの品質のゲートキーパーとなる. @@ -16,9 +17,13 @@ | エージェント | 役割 | フェーズ | | --- | --- | --- | | `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 から起動される補助エージェント. @@ -35,7 +40,8 @@ | フェーズ | 担当 | やること | 人間の関与 | | --- | --- | --- | --- | -| Phase 1: コーディング | `coder` | 実装に集中する(テスト・リファクタ・コミットはしない) | 完了後にブラウザ・実機で動作確認(AI にできない確認).NG ならフィードバックして修正させる | +| 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` に書き出す | 更新がある場合のみ確認 | @@ -49,6 +55,8 @@ ※ リファクタリングとテストは実装と一体の成果物として,まとめてコミットする.必要に応じて分割コミットも可. +※ **例外(プロジェクト立ち上げ)**: `/setup` は立ち上げ中,各フェーズの成果物が確定した時点で `chore/project-setup` ブランチに自律コミットする(GUIDE_01「立ち上げ中の Git 運用」).push・PR・マージは行わず,`main` への取り込みは完了後に人間が `/commit push` 等で行う. + ※ **例外(無人運転)**: 以下のループは CLAUDE.md の「`/commit` 自発実行禁止」ルールに対するユーザー承認済みの例外として,専用ブランチに自律コミットする.いずれも push・PR・マージ・`main` への操作は行わず,取り込み可否は人間が判断する. - `/auto-refactor`(リファクタ/ドキュメント整理ループ)→ `refactor/` 専用ブランチ