dotfiles のガード群 (git 操作のスキル強制・ブランチロック・コメント規約・文脈注入)。機能ごとに hooks/ 配下の 1 ファイル

個人用のdotfiles設定リポジトリです。 パッケージ管理と Homebrew 管理は別の nix-config リポジトリで行い、このリポジトリは主にシェルやアプリ設定を管理します。
git clone https://github.com/yourusername/dotfiles.git ~/dotfiles
cd ~/dotfiles
cd ~/nix-config
darwin-rebuild switch --flake .#<nix-configで定義したホスト名>
./install.sh
既存の設定ファイルは~/backupディレクトリにバックアップされます。
install.sh: dotfilesのシンボリックリンク作成スクリプトMANUAL_APPS.md: 手動インストールが必要なアプリケーション一覧.config/: 各種アプリケーションの設定ファイル.zshrc: Zshシェルの設定.gitconfig: Git設定.tmux.conf: tmux設定.claude/: Claude Code の設定.codex/: Codex の設定hooks/index.ts 69 lines1import type { Register } from "claude-code";
2import { branchTrackDeny } from "./branch-track-guard";
3import type { ReadFile, Run } from "./caps";
4import { commentStyleNote } from "./comment-style-guard";
5import { feedbackMemoryNote } from "./feedback-memory-router";
6import { ghStackHelpNote } from "./gh-stack-help";
7import { gitSkillDeny } from "./git-skill-enforcer";
8import { resetRestackNote } from "./reset-restack-hint";
9import { shipBranchLockDeny } from "./ship-branch-lock";
10import { taskContractNote } from "./task-contract-trigger";
11
12// エンジンは同一イベントの重複登録を拒否し、検証器は $ を import 越しに追えない。
13// そのためイベント登録と $ の呼び出しは本ファイルに閉じ、機能本体は各ファイルの純関数に置く
14export const register: Register = (on) => {
15 on("prompt.submit", async ($, e, next) => {
16 const run: Run = (argv, init) => $.process.run(argv, init);
17 const notes = [
18 taskContractNote(e.text),
19 resetRestackNote(e.text),
20 await ghStackHelpNote(run, e.text),
21 ].filter((n): n is string => n !== undefined);
22 if (notes.length === 0) return next(e);
23 return next({ ...e, context: [...(e.context ?? []), ...notes] });
24 });
25
26 on("tool.call", { tool: "Bash" }, async ($, e, next) => {
27 const run: Run = (argv, init) => $.process.run(argv, init);
28 const home = await $.env.get("HOME");
29 const deny =
30 gitSkillDeny(e.command) ??
31 branchTrackDeny(e.command) ??
32 (await shipBranchLockDeny(run, home, e.command));
33 if (deny !== undefined) return { deny };
34 return next(e);
35 });
36
37 on("tool.call", { tool: "Edit" }, async ($, e, next) => {
38 const r = await next(e);
39 if (r.deny !== undefined || r.isError) return r;
40 const run: Run = (argv, init) => $.process.run(argv, init);
41 const readFile: ReadFile = (path) => $.fs.read(path);
42 const notes = [
43 await commentStyleNote(
44 run,
45 e.file_path,
46 e.new_string,
47 e.old_string,
48 false,
49 ),
50 await feedbackMemoryNote(readFile, e.file_path),
51 ].filter((n): n is string => n !== undefined);
52 if (notes.length === 0) return r;
53 return { ...r, context: [...(r.context ?? []), ...notes] };
54 });
55
56 on("tool.call", { tool: "Write" }, async ($, e, next) => {
57 const r = await next(e);
58 if (r.deny !== undefined || r.isError) return r;
59 const run: Run = (argv, init) => $.process.run(argv, init);
60 const readFile: ReadFile = (path) => $.fs.read(path);
61 const notes = [
62 await commentStyleNote(run, e.file_path, e.content, "", true),
63 await feedbackMemoryNote(readFile, e.file_path),
64 ].filter((n): n is string => n !== undefined);
65 if (notes.length === 0) return r;
66 return { ...r, context: [...(r.context ?? []), ...notes] };
67 });
68};
69hooks/branch-track-guard.ts 17 lines1// origin/main 起点のブランチ作成は upstream が main に向き、git push が main を狙う事故につながる
2const CREATE_FROM_MAIN_RE =
3 /\bgit(\s+-C\s+\S+)?\s+(switch\s+(-c|-C|--create)|checkout\s+(-b|-B))\s+\S+\s+origin\/(main|master)\b/;
4const NO_TRACK_RE = /\s--no-track\b/;
5const SET_UPSTREAM_MAIN_RE =
6 /\bgit(\s+-C\s+\S+)?\s+branch\s+(-u|--set-upstream-to)[= ]origin\/(main|master)\b/;
7
8export function branchTrackDeny(cmd: string): string | undefined {
9 if (CREATE_FROM_MAIN_RE.test(cmd) && !NO_TRACK_RE.test(cmd)) {
10 return "ブランチ作成をブロック: origin/main 起点で作ると upstream が origin/main になり、git push が main を狙う事故につながる。--no-track を付けて作成し、upstream は初回 push 時に 'git push -u origin HEAD' で同名リモートブランチへ紐づけること。";
11 }
12 if (SET_UPSTREAM_MAIN_RE.test(cmd)) {
13 return "upstream 変更をブロック: フィーチャーブランチの upstream を origin/main に向けてはならない。'git push -u origin HEAD' で同名リモートブランチへ紐づけること。";
14 }
15 return undefined;
16}
17hooks/caps.ts 6 lines1import type { EngineInterface } from "claude-code";
2
3// 検証器は $ を import 越しに追えないため、$ の呼び出しは index.ts に閉じ、機能ファイルへはこの型の関数を渡す
4export type Run = EngineInterface["process"]["run"];
5export type ReadFile = (path: string) => Promise<string>;
6hooks/comment-style-guard.ts 103 lines1import type { Run } from "./caps";
2
3const MARKERS: readonly [RegExp, string][] = [
4 [
5 /\.(sh|bash|zsh|py|rb|pl|nix|toml|yml|yaml|conf|tf|fish|mk)$|(^|\/)Makefile$/,
6 "#",
7 ],
8 [
9 /\.(c|h|cpp|hpp|cc|cs|m|mm|swift|js|jsx|ts|tsx|go|rs|java|kt|kts|scala|dart|zig)$/,
10 "//",
11 ],
12 [/\.(lua|hs|sql|elm)$/, "--"],
13 [/\.vim$|vimrc$/, '"'],
14 [/\.(el|lisp|clj)$/, ";"],
15];
16
17const MSG = (path: string, found: string) =>
18 `CLAUDE.md の Comments ルール違反の可能性: ${path} に複数行コメントブロックが追加された。
19以下のワークフローを今回の Edit 1 回で完結させる (段階的な削減はしない):
20
211. コメントが WHY (非自明な理由: workaround・制約・ドメイン知識) / Why not (採らなかった選択肢とその理由) を述べているか確認する
222. 述べていない → 削除する
23 - what コメント (次の行が何をするかの言い換え) — What の説明はテストコードの責務
24 - Usage・列挙
25 - 変更過程の説明 (fixed / changed 等)
263. 述べている → 1 行に削減する
27 - 要約不能な複雑な制約/ドメイン知識のみ、そのまま残して再編集しない
28
29事実として正しい既存コメントは保持する。
30
31## 検出ブロック
32${found}`;
33
34// 2行以上連続するフルラインコメントブロックを抽出 (shebang・@ディレクティブ行は除外)
35function extractBlocks(text: string, m: string, dm: string): string[] {
36 const blocks: string[] = [];
37 let buf: string[] = [];
38 const flush = () => {
39 if (buf.length >= 2) blocks.push(buf.join("\n"));
40 buf = [];
41 };
42 for (const line of text.split("\n")) {
43 const l = line.replace(/^[ \t]+/, "");
44 if (
45 l.startsWith(m) &&
46 !l.startsWith("#!") &&
47 (dm === "" || !l.startsWith(dm))
48 ) {
49 let r = l.slice(m.length);
50 r = r.replace(/^[-!/*#";]*[ \t]*/, "");
51 // awk の next 相当: @行は flush せず読み飛ばす (既存ブロックの非連続化と対で old 側の抽出結果比較が必要)
52 if (r.startsWith("@")) continue;
53 buf.push(line);
54 } else flush();
55 }
56 flush();
57 return blocks;
58}
59
60export async function commentStyleNote(
61 run: Run,
62 path: string,
63 newText: string,
64 oldText: string,
65 isWrite: boolean,
66): Promise<string | undefined> {
67 const marker = MARKERS.find(([re]) => re.test(path))?.[1];
68 if (!marker) return undefined;
69 // doc コメント (C#/Rust の ///) はブロック検知から除外
70 const dm = marker === "//" ? "///" : "";
71
72 // Write (old_string なし) は git HEAD の内容を既存扱いにする
73 let old = oldText;
74 if (isWrite) {
75 const dir = path.replace(/\/[^/]+$/, "");
76 const rel = await run([
77 "git",
78 "-C",
79 dir,
80 "ls-files",
81 "--full-name",
82 "--",
83 path,
84 ]);
85 const relPath = rel.stdout.trim().split("\n")[0];
86 if (rel.exitCode === 0 && relPath) {
87 const show = await run(["git", "-C", dir, "show", `HEAD:${relPath}`]);
88 if (show.exitCode === 0) old = show.stdout;
89 }
90 }
91
92 const blocks = extractBlocks(newText, marker, dm);
93 if (blocks.length === 0) return undefined;
94 // @行を挟む既存ブロックは抽出結果が非連続になり raw 比較で一致しないため、old 側の抽出結果とも比較する
95 const oldBlocks = extractBlocks(old, marker, dm).join("\n");
96
97 const found = blocks
98 .filter((b) => !old.includes(b) && !oldBlocks.includes(b))
99 .map((b) => `${b}\n----`)
100 .join("\n");
101 return found ? MSG(path, found) : undefined;
102}
103hooks/feedback-memory-router.ts 22 lines1import type { ReadFile } from "./caps";
2
3const MSG = `feedback メモリが保存された。この指導の適用スコープを判定し、正しい階層へ配置し直せ:
41. 全プロジェクト普遍 → 決定論化できるなら hook 化をユーザーに提案。できなければ ~/.claude/CLAUDE.md へ昇格し、メモリは削除
52. リポジトリ普遍 → repo の CLAUDE.md / .claude/skills へ (git 同期で全 worktree に届き、乖離が構造的に消える)。メモリは削除
63. このリポジトリの一時的・作業固有の知識 → メモリのままでよい。ただし陳腐化条件 (何が完了したら消すか) を本文に明記せよ`;
7
8export async function feedbackMemoryNote(
9 readFile: ReadFile,
10 path: string,
11): Promise<string | undefined> {
12 if (!/\/\.claude\/projects\/[^/]+\/memory\/[^/]+\.md$/.test(path))
13 return undefined;
14 try {
15 const text = await readFile(path);
16 if (/^\s*type:\s*feedback/m.test(text)) return MSG;
17 } catch {
18 return undefined;
19 }
20 return undefined;
21}
22hooks/gh-stack-help.ts 18 lines1import type { Run } from "./caps";
2
3const GH_STACK_RE = /stack|スタック|積み(上げ)?PR|多段 ?PR/i;
4
5// モジュール環境はセッション中維持されるため、help 注入の一度きり判定に使える
6let injected = false;
7
8export async function ghStackHelpNote(
9 run: Run,
10 text: string,
11): Promise<string | undefined> {
12 if (injected || !GH_STACK_RE.test(text)) return undefined;
13 const r = await run(["gh", "stack", "--help"]);
14 if (r.exitCode !== 0) return undefined;
15 injected = true;
16 return `stacked PR 関連の依頼を検知した。この環境には gh の stack 拡張が導入済みである。stacked PR の作成・同期・再構成には手動の git 操作ではなく gh stack サブコマンドを優先して使うこと。以下は gh stack --help の出力:\n\n${r.stdout}`;
17}
18hooks/git-skill-enforcer.ts 10 lines1// 各スキル (commit / pr-create / pr-review-fix / branch-create) は実コマンドに CLAUDE_GIT_SKILL=<skill> を前置する規約
2const TARGET_RE = /\b(git\s+commit|git\s+push|gh\s+pr\s+create)\b/;
3const MARKER_RE =
4 /CLAUDE_GIT_SKILL=(commit|pr-create|pr-review-fix|branch-create)\b/;
5
6export const gitSkillDeny = (command: string): string | undefined =>
7 TARGET_RE.test(command) && !MARKER_RE.test(command)
8 ? "git commit / git push / gh pr create の直接実行はブロックされました。commit / pr-create / pr-review-fix スキル経由で (CLAUDE_GIT_SKILL=<skill名> を前置して) 実行してください。"
9 : undefined;
10hooks/reset-restack-hint.ts 9 lines1// reset 言及または積み直し系の語彙のいずれかで発火
2const RESET_RESTACK_RE =
3 /reset|リセット|積み直|積みなお|コミットし直|コミットしなお|切り直|作り直|やり直/i;
4const MSG =
5 "reset はユーザーが既に完了しており、Claude が破壊的操作 (git reset 等) を実行する必要は無い。「権限上できない」等と拒否せず、まず git status / git log / git reflog で現状を確認し、作業ツリーの内容からコミットを積み直せ。";
6
7export const resetRestackNote = (text: string): string | undefined =>
8 RESET_RESTACK_RE.test(text) ? MSG : undefined;
9hooks/ship-branch-lock.ts 144 lines1import type { Run } from "./caps";
2
3const BRANCH_OP_RE =
4 /\bgit(?:\s+-C\s+(\S+))?\s+(switch|checkout|worktree\s+add)\b/;
5// "git checkout -- <path>" のファイル復元はブランチ移動ではないため許可
6const CHECKOUT_PATHS_RE = /\bgit(\s+-C\s+\S+)?\s+checkout(\s+\S+)*\s+--(\s|$)/;
7// ユーザー明示承認の escape (open PR ロックと unstack のみ。ship ロックは絶対)
8const USER_ALLOW_RE = /(^|[^A-Za-z0-9_])SHIP_ALLOW_BRANCH_SWITCH=1/;
9// unstack 以外の gh stack サブコマンドは stacked PR の正常運用なので見ない
10const GH_STACK_UNSTACK_RE = /\bgh(\s+-R\s+\S+)?\s+stack\s+unstack\b/;
11// 新規ブランチ・worktree の作成はスタック内判定の対象にしない (段を足すのは gh stack add)
12const CREATES_RE =
13 /(^|\s)(-c|-b|-B|--create|--orphan)(\s|$)|\bworktree\s+add\b/;
14const NON_TARGET_WORDS = new Set([
15 "git",
16 "gh",
17 "switch",
18 "checkout",
19 "worktree",
20 "add",
21 "&&",
22 ";",
23]);
24
25export async function shipBranchLockDeny(
26 run: Run,
27 home: string | undefined,
28 cmd: string,
29): Promise<string | undefined> {
30 const escaped = USER_ALLOW_RE.test(cmd);
31 if (GH_STACK_UNSTACK_RE.test(cmd)) {
32 return escaped
33 ? undefined
34 : "ブロック: gh stack unstack はスタックをローカルと GitHub の両方から削除する。作業フロー中は実行しないこと。必要ならユーザーに報告し、明示承認があった場合のみ 'SHIP_ALLOW_BRANCH_SWITCH=1 <cmd>' で再実行すること。";
35 }
36
37 const op = BRANCH_OP_RE.exec(cmd);
38 if (!op || CHECKOUT_PATHS_RE.test(cmd)) return undefined;
39
40 const creates = CREATES_RE.test(cmd);
41 // スタックのブランチ名と照合するだけなので余分なトークンが混ざっても無害
42 const targets = creates
43 ? []
44 : cmd
45 .split(/\s+/)
46 .filter((t) => t && !t.startsWith("-") && !NON_TARGET_WORDS.has(t));
47
48 // -C 先 (サブモジュール等) のロックはそのリポジトリの ship 状態・PR で判定する
49 const target = op[1]?.replace(/^(["'])(.*)\1$/, "$2");
50 const runIn: Run = (argv, init) =>
51 run(argv, target ? { ...init, cwd: target } : init);
52
53 const inRepo =
54 (await runIn(["git", "rev-parse", "--is-inside-work-tree"])).exitCode === 0;
55 if (!inRepo) return undefined;
56
57 // (1) ship フローのロック。exit 3 のみがロック検知 (guard 自体の失敗で誤ブロックしない)
58 if (home) {
59 const guard = await runIn([
60 "bash",
61 `${home}/.claude/skills/ship/scripts/ship.sh`,
62 "guard",
63 ...targets,
64 ]);
65 if (guard.exitCode === 3) {
66 return `ブランチ操作をブロック: ${(guard.stdout + guard.stderr).trim()}`;
67 }
68 }
69
70 if (escaped) return undefined;
71
72 // (2) ship 未 init でも open PR があれば禁止
73 const out = async (argv: readonly string[]) => {
74 const r = await runIn(argv);
75 return r.exitCode === 0 ? r.stdout.trim() : "";
76 };
77 const defaultBranch = (
78 await out(["git", "symbolic-ref", "--short", "refs/remotes/origin/HEAD"])
79 ).replace(/^origin\//, "");
80 const current = await out(["git", "rev-parse", "--abbrev-ref", "HEAD"]);
81 if (!current || !defaultBranch || current === defaultBranch) return undefined;
82
83 const prState = await out([
84 "gh",
85 "pr",
86 "view",
87 "--json",
88 "state",
89 "--jq",
90 ".state",
91 ]);
92 if (prState !== "OPEN") return undefined;
93
94 const dirty = (await out(["git", "status", "--porcelain"])) !== "";
95
96 // 同一スタック内の段への移動は stacked PR の正常運用なので未 push でも許可 (未コミットの変更は別の段へ引き連れるため不可)
97 if (!dirty && targets.length > 0 && home) {
98 const inStack = new Set(
99 (
100 await out([
101 "bash",
102 `${home}/.claude/skills/ship/scripts/ship.sh`,
103 "stack-branches",
104 ])
105 )
106 .split("\n")
107 .filter(Boolean),
108 );
109 if (targets.some((t) => inStack.has(t))) return undefined;
110 }
111
112 // clean かつ push 済みなら失う作業が無いため許可 (worktree で連続タスクを受ける運用で毎回の承認待ちになる)
113 if (!dirty) {
114 let remoteRef = await out([
115 "git",
116 "rev-parse",
117 "--abbrev-ref",
118 "--symbolic-full-name",
119 "@{u}",
120 ]);
121 if (!remoteRef) {
122 const chk = await runIn([
123 "git",
124 "rev-parse",
125 "--verify",
126 "-q",
127 `origin/${current}`,
128 ]);
129 if (chk.exitCode === 0) remoteRef = `origin/${current}`;
130 }
131 if (remoteRef) {
132 const count = await runIn([
133 "git",
134 "rev-list",
135 "--count",
136 `${remoteRef}..HEAD`,
137 ]);
138 if (count.exitCode === 0 && count.stdout.trim() === "0") return undefined;
139 }
140 }
141
142 return `ブランチ操作をブロック: 現在ブランチ '${current}' には OPEN な PR があり、未コミットまたは未 push の変更が残っています。commit / push で提出を完了させてから移動すること (同一スタック内の段への移動なら clean であれば未 push でも通ります)。それでも移動が必要ならユーザーに報告し指示を待ち、明示承認があった場合のみ 'SHIP_ALLOW_BRANCH_SWITCH=1 <cmd>' で再実行すること。`;
143}
144hooks/task-contract-trigger.ts 9 lines1// 開放的依頼の語彙 (誤検知しても context 注入のみで無害)
2const OPEN_ENDED_RE =
3 /いい感じに|よしなに|自律的に|任せる|お任せ|PDCA|自由に(改善|修正|作|やっ)|なんとかして|うまく(やっ|し)といて|良さそうに|それっぽく/;
4const MSG =
5 "開放的な依頼を検知した。作業を開始する前に task-contract スキルを発動し、受け入れ基準・可動範囲・検証手段・イテレーション上限を確認せよ (依頼文で全項目が明示済みの場合のみ省略可)。";
6
7export const taskContractNote = (text: string): string | undefined =>
8 OPEN_ENDED_RE.test(text) ? MSG : undefined;
9