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..d61d686 --- /dev/null +++ b/.claude/hooks/tests/test_hooks.py @@ -0,0 +1,772 @@ +"""`.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 決め打ちではなくラッパー経由 +# --------------------------------------------------------------------------- +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..f779753 100644 --- a/.claude/skills/setup/SKILL.md +++ b/.claude/skills/setup/SKILL.md @@ -110,6 +110,28 @@ - 生成内容(対象エコシステム・更新頻度・グループ化)は他の成果物と同様にドラフトとしてユーザーに提示し,承認を得てから書き出す(「作るかどうか」は聞かず,「内容」だけを確認する) - マニフェストがまだ存在しない場合(立ち上げ時点でコードが無い)でも,スタックが確定していれば生成してよい.Dependabot はマニフェストが置かれた時点から動き始める +### 検証プロファイルの作成(任意) (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 を最終更新する diff --git a/.claude/skills/sync-template/SKILL.md b/.claude/skills/sync-template/SKILL.md index 55d1311..74358ee 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) @@ -326,5 +327,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..02d7b25 100644 --- a/.claude/template-sync-sha +++ b/.claude/template-sync-sha @@ -1 +1 @@ -0ac68c7d78975e6688d602cd5b7e3fcf9f13b4bf \ No newline at end of file +60ab68c40d9f96d071fb9ecfecc9f2adb925a411 diff --git a/.gitignore b/.gitignore index 7109241..7b7507d 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,4 @@ .claude/deps-update-merged.md .claude/deps-update-report.md .claude/deps-update-skip.md +__pycache__/ diff --git a/CLAUDE.md b/CLAUDE.md index b046917..cd7b24a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ ## 開発進捗 -最新: Dependabot 追従作業のうち #29(OpenCvSharp を専用グループに分離)・#30(NUnit テストを制約モデルへ移行)・#32(ENV_04 の `vswhere` 修正)をマージ.検証過程で見つけた「クリーンビルドでテスト 41 件が落ちる」既存不具合も #39 で修正済み.#31(ArUco API 移行)は実機確認待ちの PR #36 として保留中 +最新: テンプレートを `60ab68c` に同期(検証エージェント `verifier`/Phase 1b・フック起動ラッパー・ntfy 通知フック).本プロジェクトは実機依存のため検証プロファイルは作らず Phase 1b はスキップ運用.#31(ArUco API 移行)は実機確認待ちの PR #36 として引き続き保留中 ※ 本欄は**最新ステップ 1 行のみ上書き更新**.詳細な進捗履歴(動機・設計判断・失敗パターン)は [docs/PROGRESS.md](docs/PROGRESS.md) に追記する.運用ルールは `.claude/rules/progress-log.md` を参照(該当ファイル編集時に自動ロードされる). ## 必須ルール(コード実装時) @@ -48,6 +48,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 は人間の動作確認の代わりにならない** ### 開発コマンド @@ -60,7 +61,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..27b39e9 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,7 +23,7 @@ | レイヤ | 対象 | 存在するモード | | --- | --- | --- | -| 共通層 | `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). @@ -53,18 +53,35 @@ - **前提条件**: 以下のツールは全プロジェクト共通で導入する. - `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` の生成(後述) +- **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..9fb0d5d 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` に書き出す | 更新がある場合のみ確認 | diff --git "a/docs/02_ENV/ENV_02_\347\222\260\345\242\203\346\247\213\347\257\211\346\211\213\351\240\206.md" "b/docs/02_ENV/ENV_02_\347\222\260\345\242\203\346\247\213\347\257\211\346\211\213\351\240\206.md" index 0976cbc..a31ad7f 100644 --- "a/docs/02_ENV/ENV_02_\347\222\260\345\242\203\346\247\213\347\257\211\346\211\213\351\240\206.md" +++ "b/docs/02_ENV/ENV_02_\347\222\260\345\242\203\346\247\213\347\257\211\346\211\213\351\240\206.md" @@ -65,6 +65,13 @@ - Lumenera 製カメラ(Lw110)の使用に必要. - `lumenera.api.dll` はリポジトリ内に同梱されている. +### Python と bash(Claude Code のフック用) + +- `.claude/settings.json` のフック(リポジトリ外アクセス制限・通知)の実行に必要.アプリのビルド・実行には不要なため,Claude Code を使わない場合は省略できる. +- **Python 3.7 以上**: フックは `bash .claude/hooks/run_python.sh` 経由で起動し,`python3` → `python` → `py` の順に実際に起動できるものを使う(いずれか 1 つで起動できればよい). +- **bash**: Git for Windows に付属するものを使う.Visual Studio と併せて `git` を入れていれば追加インストールは不要. +- ※ Python が見つからないとアクセス制限フックが Read・Write・Edit・Glob・Grep・Bash を**すべてブロックする**(通知フックは何もせず素通りする). + ## リポジトリのセットアップ (Repository Setup) 1. リポジトリのクローン diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index f31f2ae..ecce916 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -134,3 +134,10 @@ - 設計判断(推移的依存をどのバージョンで足すか): 同じ「不足している推移的依存を追加する」対処でも,**要求元のバージョンによって正解が変わる**.#39(OpenCvSharp 4.10 のまま)は本体と同一の現行版を足すのが正しい.4.10 が要求するアセンブリバージョンと NuGet パッケージが供給する版が厳密一致し,バインディングリダイレクトが不要になるため.一方 #31(4.13 へ更新)は依存を 4.6.x 系に上げる必要がある.4.13 の要求版はどの lib にも存在せずリダイレクトを自動生成できないので,手書きのダウングレードリダイレクトを保守し続けることになるため.「競合を避けたいから新版に先合わせする」は後者の罠を前者に持ち込むので採らない. - 原因(#31 側): OpenCvSharp は 4.13 で `lib/net48` を廃止し `lib/netstandard2.0` のみになった.net48 プロジェクトが netstandard2.0 アセンブリを消費する形になり,`System.Memory` 等への推移的依存が新たに発生する.`packages.config` は推移的依存を自動解決しないため,本体・テストの両方に明示的な参照追加が要る. - 後段: #31 は**実機・実チャートでの確認待ち**(PR #36).OpenCvSharp のネイティブ側が変わるため,合成 fixture の自動テストが緑でも検出性能・色変換の数値差は実機でしか担保できない.CI 導入(#40)が入れば `/deps-update` のマージゲートがローカル検証から CI 緑に変わり,判断待ちで残っている #25・#26・#28 の処理も進めやすくなる.テストプロジェクトの PackageReference 移行(推移的依存が自動解決される根本対策)は #31 マージ後・CI 導入後に検討する. + +- [x] **テンプレートを最新版に同期(検証エージェント・フック起動ラッパー・通知フックの導入)** (2026-09-18): `/sync-template` で programming-template を `0ac68c7` → `60ab68c` に追従.検証エージェント `verifier`(`/verify`・`/implement` Phase 1b),Python フックの起動ラッパー `run_python.sh`,ntfy 通知フック `notify.py` とそのテストが入った.テンプレート由来ファイルのローカル改変は今回も 0 件で,台帳は空のまま. + - 設計判断(検証プロファイルを作らない): Phase 1b は `.claude/verify-profile.md` がある時だけ動く任意フェーズだが,本プロジェクトでは作らない.verifier が操作するのは WinForms UI と実機カメラであり,これは `.claude/rules/testing.md` が「自動テスト対象外=ハードウェア結合層」と定義した領域そのもので,プロファイルを書いても担保できる範囲が増えないため.動作確認は従来どおり実機で人間が行う(Phase 1b は理由付きでスキップされる). + - 失敗パターン: `/sync-template` は**自分自身の定義も同期対象**のため,旧ルールで走らせた処理を新ルールで見直す必要がある.今回は同期後の skill で `.claude/tests/`(テンプレート自身のメタテスト.派生プロジェクトでは必ず失敗する)が同期対象外に追加されており,旧ルールで一度コピーしてから取り消した.skill 自体に差分がある回は,コピー後に新しい除外規則を読み直すこと. + - 上流に差し戻した不具合: 同期で入った `test_hooks.py` は 52 件中 3 件が必ず落ちる.`TestCheckSync` が team 層の `check_sync.sh` の存在をガードしておらず,solo プロジェクト全てで失敗する(programming-template#43 として起票).プロジェクト固有の都合ではなくテンプレート側の欠陥なので,ローカル改変(台帳登録)ではなく上流修正に倒した. + - フックの前提ツール(Python 3.7 以上と bash)を ENV_02 に追記した.Python が見つからないとアクセス制限フックが Read・Write・Edit・Glob・Grep・Bash を**全てブロックする**ため,環境再構築時に踏むと原因が分かりにくい. + - 後段: #31(ArUco API 移行.PR #36)の実機確認待ちは変わらず継続.