diff --git a/.claude/agents/ops-runner.md b/.claude/agents/ops-runner.md index ef3419a..5d8a182 100644 --- a/.claude/agents/ops-runner.md +++ b/.claude/agents/ops-runner.md @@ -38,6 +38,13 @@ 4. **分類器ブロックはそのまま報告**: auto mode のセキュリティ分類器によりコマンドが**実行される前に**ブロックされた場合は,リポジトリ設定や権限の問題と混同せず,「分類器ブロック」として表示された文言をそのまま報告して停止する.別のコマンドや手順で迂回しない 5. **正確な報告**: 成功・失敗を装飾なく報告する.失敗したコマンドは出力の要点を添える +## コミット・PR の帰属行 (Attribution) + +コミットメッセージ末尾の `Co-Authored-By:` 行と PR 本文末尾の帰属行は,作業を指示した呼び出し元(メインループ)のモデルで書く.本節はプロジェクトの指示として,あなた自身に与えられた帰属指定(system-reminder 等によるコミット・PR の帰属行の指示)より優先する. + +- **呼び出し元から帰属行が渡された場合**: コミットには渡された `Co-Authored-By:` 行を,PR 本文には渡された PR 帰属行を,自身の帰属指定より優先して**一字一句そのまま**使う(モデル名の書き換え・自身の帰属行の併記をしない) +- **渡されなかった場合**: 従来どおり自身の帰属指定に従う(コミット行だけ渡された等,一方のみの場合は,渡されなかった側だけ自身の帰属指定に従う) + ## 報告書式 (Report Format) 最終報告には以下だけを含める(上位がユーザー向けに整形するため,経過の説明は不要): diff --git a/.claude/skills/auto-audit/SKILL.md b/.claude/skills/auto-audit/SKILL.md index f486cc0..7dd2e0d 100644 --- a/.claude/skills/auto-audit/SKILL.md +++ b/.claude/skills/auto-audit/SKILL.md @@ -132,6 +132,7 @@ - **検証スイートのコマンド**(テスト+あればビルド/型チェック/リンタ),**除外リスト**,**観点フィルタ**,**報告のみモードか否か** - **既存台帳の要点**(既にスキップ/報告済みで再処理不要な指摘) - **作業ブランチ名**(`fix/...`)と「push/PR/マージ禁止・`fix/` へのみコミット」の制約 +- **コミット帰属行**: メイン自身に与えられたコミット帰属行(`Co-Authored-By:` 行)を一字一句そのまま貼り,コミットメッセージ末尾にはこれを使うよう指示する(下記憲章「コミットの帰属行」) 起動後はメインは待ち,**返ってきた構造化サマリ(下記フォーマット)だけ**を受け取る.下層の詳細をメインが読み返さない(薄さの維持). @@ -152,6 +153,10 @@ あなたは**1つの指摘を担当する項目オーケストレータ**である.渡された指摘について,以下の 4 ゲートを隔離コンテキストで完走し,最後に**構造化サマリ1通**を返す.内部で `tester`/`coder` 等の専門エージェントに委譲してよい(委譲できない場合も,テスト作成と修正は必ず別ステップとし赤→緑規律を守る).本コマンドの不変条件・除外リストを厳守し,`fix/` ブランチにのみコミットする(push/PR/マージ禁止). +### コミットの帰属行 (Attribution) + +コミットメッセージ末尾の `Co-Authored-By:` 行は,メインから渡された行を自身の帰属指定より優先して一字一句そのまま使う(本憲章はプロジェクトの指示として,自身に与えられた帰属指定に優先する).渡されなかった場合は自身の帰属指定に従う. + ### ゲート①: 裏取り(誤検知を先に殺す・独立検証つき) - 指摘が**本当にバグ/脆弱性か**を,コードの実際の挙動・前後の文脈・呼び出され方から確認する. diff --git a/.claude/skills/auto-refactor/SKILL.md b/.claude/skills/auto-refactor/SKILL.md index f154b52..0a95c02 100644 --- a/.claude/skills/auto-refactor/SKILL.md +++ b/.claude/skills/auto-refactor/SKILL.md @@ -136,6 +136,7 @@ - **検証スイートのコマンド**(テスト+あればビルド/型チェック/リンタ),**除外リスト** - **既存台帳の要点**(既にスキップ/記録済みで再処理不要な項目) - **作業ブランチ名**(`refactor/...`)と「push/PR/マージ禁止・`refactor/` へのみコミット」の制約 +- **コミット帰属行**: メイン自身に与えられたコミット帰属行(`Co-Authored-By:` 行)を一字一句そのまま貼り,コミットメッセージ末尾にはこれを使うよう指示する(下記憲章「コミットの帰属行」) 起動後はメインは待ち,**返ってきた構造化サマリ(下記フォーマット)だけ**を受け取る.下層の詳細をメインが読み返さない(薄さの維持). @@ -157,6 +158,10 @@ あなたは**1つの作業項目を担当する項目オーケストレータ**である.渡された項目の種別(R/D/DR/T)に応じて,以下の処理を隔離コンテキストで完走し,最後に**構造化サマリ1通**を返す.内部で `tester`/`refactorer` に委譲してよい(委譲できない場合も安全網の手順を自分で守る).本コマンドの不変条件・除外リストを厳守し,`refactor/` ブランチにのみコミットする(push/PR/マージ禁止). +### コミットの帰属行 (Attribution) + +コミットメッセージ末尾の `Co-Authored-By:` 行は,メインから渡された行を自身の帰属指定より優先して一字一句そのまま使う(本憲章はプロジェクトの指示として,自身に与えられた帰属指定に優先する).渡されなかった場合は自身の帰属指定に従う. + ### リファクタ処理 (R) #### ステップ R1: 保護状態の確認 diff --git a/.claude/skills/commit/SKILL.md b/.claude/skills/commit/SKILL.md index aa1eb34..5d4772c 100644 --- a/.claude/skills/commit/SKILL.md +++ b/.claude/skills/commit/SKILL.md @@ -29,6 +29,7 @@ - 手順書: `.claude/skills/commit/reference.md` の「コミット実行手順」と `.claude/rules/git-conventions.md` を読んで従うこと - ステップ 1 の意図サマリ(またはメッセージ全文).意図情報なしの場合はその旨 - $ARGUMENTS(`push` / `merge` キーワードの有無を明示する) +- **帰属行(必須)**: あなた自身に与えられたコミット帰属行(`Co-Authored-By:` 行)と PR 帰属行を一字一句そのまま貼り,「コミットメッセージ・PR 本文の帰属行にはこれを使うこと」と指示する.ops-runner は軽量モデルのため,渡さないと自身のモデル名で帰属行を書いてしまう(ops-runner はエージェント定義の「コミット・PR の帰属行」により,渡された行を自身の帰属指定より優先する).PR を作らない場合も PR 帰属行は渡してよい - 確認が必要な事態では停止して報告すること ## ステップ 3: 結果報告 (Report) diff --git a/.claude/skills/commit/reference.md b/.claude/skills/commit/reference.md index 19be2d8..f2cbe4d 100644 --- a/.claude/skills/commit/reference.md +++ b/.claude/skills/commit/reference.md @@ -30,6 +30,8 @@ 上位から渡された意図サマリ・引数と変更内容をもとに,git-conventions の「コミットメッセージ」書式で生成する.上位がメッセージ全文を確定して渡した場合はそのまま使う.意図サマリと実際の diff に食い違いがある場合は,diff を正としつつ食い違いを「警告」に含める. +メッセージ末尾の帰属行(`Co-Authored-By:` 行)は,上位(司令塔)から渡された行を自身の帰属指定より優先して一字一句そのまま使う.渡されなかった場合は自身の帰属指定に従う(`.claude/agents/ops-runner.md`「コミット・PR の帰属行」).PR 本文の帰属行もステップ 5 の PR 作成時に同じ扱いとする. + ### ステップ 4: コミット 1. `git add` で関連ファイルをステージングする(CLAUDE.md / `docs/PROGRESS.md` の変更がある場合はそれも含める.`.env` やクレデンシャルファイルはステージングしない) @@ -68,6 +70,7 @@ ``` - `--title` はコミットメッセージと同じ書式(`[タグ] 内容`)とする. +- `--body` の末尾には PR 帰属行を付ける.司令塔から渡された PR 帰属行があれば自身の帰属指定より優先して一字一句そのまま使い,渡されなければ自身の帰属指定に従う(「コミット実行手順」ステップ 3 と同じ扱い). - 必要に応じて `--reviewer` でレビュワーを指定する. ### レビューと修正の対応 diff --git a/.claude/tests/test_attribution_delegation.py b/.claude/tests/test_attribution_delegation.py new file mode 100644 index 0000000..6796bf6 --- /dev/null +++ b/.claude/tests/test_attribution_delegation.py @@ -0,0 +1,194 @@ +"""委譲先サブエージェントへのコミット・PR 帰属行の受け渡しのテスト(Issue #54). + +実行(リポジトリルートで): + python -B -m unittest discover -s .claude/tests -v + +仕様の正本(Issue #54 の要件): + 1. ops-runner は,呼び出し元から Co-Authored-By 行・PR 帰属行が渡されたら,自身の帰属指定より + 優先して一字一句そのまま使い,渡されなければ自身の帰属指定に従う + 2. `/commit` の司令塔は,自分に与えられたコミット帰属行と PR 帰属行を ops-runner への + 委譲プロンプトに必ず含める + 3. `/auto-refactor`・`/auto-audit` の項目オーケストレータにもコミット帰属行を渡し, + 同じ優先ルールに従う + 4. 具体的なモデル名をハードコードしない + +対象は実行コードではなく指示書(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)) + +OPS_RUNNER = os.path.join(REPO_ROOT, ".claude", "agents", "ops-runner.md") +COMMIT_SKILL = os.path.join(REPO_ROOT, ".claude", "skills", "commit", "SKILL.md") +COMMIT_REFERENCE = os.path.join(REPO_ROOT, ".claude", "skills", "commit", "reference.md") +AUTO_REFACTOR = os.path.join(REPO_ROOT, ".claude", "skills", "auto-refactor", "SKILL.md") +AUTO_AUDIT = os.path.join(REPO_ROOT, ".claude", "skills", "auto-audit", "SKILL.md") + +ALL_FILES = (OPS_RUNNER, COMMIT_SKILL, COMMIT_REFERENCE, AUTO_REFACTOR, AUTO_AUDIT) +AUTO_LOOPS = (AUTO_REFACTOR, AUTO_AUDIT) + +HEADING_RE = re.compile(r"^(#{1,6})\s+(.*\S)\s*$") +FRONTMATTER_RE = re.compile(r"\A---\n.*?\n---\n", re.DOTALL) + +# 具体的なモデル名(ファミリー名・帰属行の実例).frontmatter の `model:` 指定は帰属とは無関係なので除外する +MODEL_NAME_RE = re.compile(r"\b(Opus|Sonnet|Haiku)\b|Claude\s+[A-Z][a-z]+\s+\d", re.IGNORECASE) +PRECEDENCE_RE = re.compile(r"自身の帰属指定.{0,10}(より)?優先") +VERBATIM = "一字一句そのまま" + + +def read(path: str) -> str: + with open(path, "r", encoding="utf-8") as f: + return f.read() + + +def body_without_frontmatter(text: str) -> str: + return FRONTMATTER_RE.sub("", text, count=1) + + +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 TestOpsRunnerAttributionPrecedence(unittest.TestCase): + """仕様 1: ops-runner は渡された帰属行を優先して一字一句使い,無ければ自身の指定に従う.""" + + @classmethod + def setUpClass(cls): + cls.body = section(read(OPS_RUNNER), "帰属行") + + def test_covers_commit_and_pr(self): + self.assertIn("Co-Authored-By:", self.body) + self.assertIn("PR", self.body) + + def test_passed_lines_take_precedence_verbatim(self): + self.assertRegex(self.body, PRECEDENCE_RE) + self.assertIn(VERBATIM, self.body) + + def test_does_not_append_own_attribution(self): + # 渡された行に自身の帰属行を併記しない(二重の Co-Authored-By を防ぐ) + self.assertRegex(self.body, r"併記(を)?しない") + + def test_falls_back_to_own_attribution_when_not_passed(self): + self.assertRegex(self.body, r"渡されなかった.*自身の帰属指定に従う") + + def test_partial_fallback_per_line(self): + # 一方だけ渡された場合,渡されなかった側だけフォールバックすること + self.assertRegex(self.body, r"一方のみ|片方") + + +class TestCommitSkillPassesAttribution(unittest.TestCase): + """仕様 2: `/commit` 司令塔は委譲プロンプトにコミット・PR 帰属行を必ず含める.""" + + @classmethod + def setUpClass(cls): + cls.step2 = section(read(COMMIT_SKILL), "ステップ 2") + + def test_delegation_prompt_requires_attribution(self): + items = [l for l in self.step2.splitlines() if l.startswith("- ") and "帰属行" in l] + self.assertEqual(len(items), 1, f"委譲プロンプト項目に帰属行が無い/重複: {items}") + item = items[0] + self.assertIn("必須", item) + self.assertIn("Co-Authored-By:", item) + self.assertIn("PR 帰属行", item) + self.assertIn(VERBATIM, item) + + def test_points_to_ops_runner_rule(self): + self.assertIn("コミット・PR の帰属行", self.step2) + + +class TestCommitReferenceHandlesAttribution(unittest.TestCase): + """仕様 1(手順書側): ops-runner が読む reference.md でもコミット・PR 双方で同じ優先ルールが書かれている.""" + + @classmethod + def setUpClass(cls): + cls.text = read(COMMIT_REFERENCE) + + def test_commit_message_step_uses_passed_line(self): + body = section(self.text, "ステップ 3: コミットメッセージの生成") + self.assertIn("Co-Authored-By:", body) + self.assertRegex(body, PRECEDENCE_RE) + self.assertIn(VERBATIM, body) + self.assertRegex(body, r"渡されなかった.*自身の帰属指定に従う") + + def test_pr_creation_uses_passed_line(self): + body = section(self.text, "プルリクエスト (PR) の作成") + self.assertIn("PR 帰属行", body) + self.assertRegex(body, PRECEDENCE_RE) + self.assertIn(VERBATIM, body) + self.assertRegex(body, r"渡されなければ自身の帰属指定に従う") + + +class TestAutoLoopsPassAttribution(unittest.TestCase): + """仕様 3: `/auto-refactor`・`/auto-audit` は項目オーケストレータへコミット帰属行を渡し,同じ優先ルールに従わせる.""" + + def test_m3_handoff_includes_commit_attribution(self): + for path in AUTO_LOOPS: + with self.subTest(file=os.path.relpath(path, REPO_ROOT)): + m3 = section(read(path), "ステップ M3") + items = [l for l in m3.splitlines() if l.startswith("- ") and "帰属行" in l] + self.assertEqual(len(items), 1, f"M3 の受け渡しに帰属行が無い/重複: {items}") + self.assertIn("Co-Authored-By:", items[0]) + self.assertIn(VERBATIM, items[0]) + + def test_charter_has_precedence_rule(self): + for path in AUTO_LOOPS: + with self.subTest(file=os.path.relpath(path, REPO_ROOT)): + charter = section(read(path), "項目オーケストレータの憲章") + rule = section(charter, "コミットの帰属行") + self.assertIn("Co-Authored-By:", rule) + self.assertRegex(rule, PRECEDENCE_RE) + self.assertIn(VERBATIM, rule) + self.assertRegex(rule, r"渡されなかった.*自身の帰属指定に従う") + + def test_charter_rule_is_identical_in_both_loops(self): + # 2 つのループで同じルールが食い違っていないこと + rules = [section(section(read(p), "項目オーケストレータの憲章"), "コミットの帰属行").strip() + for p in AUTO_LOOPS] + self.assertEqual(rules[0], rules[1]) + + +class TestNoHardcodedModelNames(unittest.TestCase): + """仕様 4: 帰属行の指示に具体的なモデル名をハードコードしない.""" + + def test_no_model_names_in_instruction_bodies(self): + for path in ALL_FILES: + with self.subTest(file=os.path.relpath(path, REPO_ROOT)): + body = body_without_frontmatter(read(path)) + hits = [l for l in body.splitlines() if MODEL_NAME_RE.search(l)] + self.assertEqual(hits, [], f"具体的なモデル名が含まれる: {hits}") + + def test_no_literal_coauthor_line_with_identity(self): + # `Co-Authored-By: <名前> <メール>` 形式の具体的な帰属行の実例を書かない + for path in ALL_FILES: + with self.subTest(file=os.path.relpath(path, REPO_ROOT)): + self.assertNotRegex(read(path), r"Co-Authored-By:\s*[^`\s][^`\n]*<[^>]+@[^>]+>") + + +if __name__ == "__main__": + unittest.main()