bash-review の判定ログ (~/.claude/logs/bash-review.log) をペインで表示する /bash-review-log コマンド

個人用の開発環境設定ファイル(dotfiles)のリポジトリです。Zsh、Vim、Neovim、tmux、WezTerm などの設定に加え、母艦となる Claude Code(+ Codex・Gemini CLI・GitHub Copilot CLI)の AI 開発ツールのエージェント/スキル/フック設定とセットアップスクリプトを一括管理しています。全体を Rosé Pine カラースキームで統一し、macOS / Ubuntu / WSL に対応した install.sh でシンボリックリンクを自動生成します。
English summary: Personal dotfiles unifying Zsh, Vim, Neovim, tmux, and WezTerm under the Rosé Pine theme, plus agent / skill / hook configurations for AI coding tools built around Claude Code as the hub (with Codex, Gemini CLI, and Copilot CLI alongside). A cross-platform
install.sh(macOS / Ubuntu / WSL) symlinks everything, and the hook / installer logic is covered by a pytest suite with a 90% coverage gate in CI. Deep-dive docs (in English): hook system, custom agents.
<img src="assets/architecture.svg" alt="dotfiles アーキテクチャ図 — install.sh が macOS / Ubuntu / WSL を検出し、Shell・Editors・AI Dev Tools・CI を Rosé Pine で統一管理する構成" width="100%">
AI 連携ドキュメント: Claude Code を母艦とした複数 LLM(Gemini・Codex・Copilot・Gemma)の連携、Bash 安全ゲート(bash-review)、Neovim のエディタ内 AI の構成図と解説は docs/ai-integration.md にまとめています。
.
├── .claude/ # Claude Code設定
│ ├── agents/ # カスタムサブエージェント (architect, code-reviewer 等)
│ ├── commands/ # カスタムスラッシュコマンド (tdd, verify 等)
│ ├── hooks/ # フック (auto-format, lint, bash-review 等)
│ ├── mcp-servers/ # 自作MCPサーバー (gemini-consultant)
│ ├── mods/ # mod (bash-review-log: bash-review の判定ログ表示)
│ ├── rules/ # ワークフロー / セキュリティルール
│ ├── skills/ # スキル定義 (backend-patterns 等)
│ ├── CLAUDE.md # グローバル指示
│ ├── settings.json # Claude Code設定
│ └── statusline-command.sh # ステータスライン
├── .codex/ # Codex設定
│ ├── agents/ # Codexエージェント定義 (.toml)
│ ├── hooks/ # Codexフック (+ hooks.json.template)
│ ├── skills/ # Codexスキル (.claude/skillsへの厳選リンク + .system)
│ ├── AGENTS.md # Codex向け指示
│ └── config.toml.template # Codex設定の雛形 (実体は ~/.codex/ 側。後述)
├── .config/ # アプリケーション設定
│ ├── Code/ # VS Code (settings / keybindings)
│ └── nvim/ # Neovim (lazy.nvim) 設定
├── .gemini/ # Gemini CLI設定 (GEMINI.md, settings.json)
├── .github/workflows/ # GitHub Actions CI (pytest / ruff / bandit / shellcheck / mypy / luacheck)
├── .oh-my-zsh/ # Oh My Zsh設定
│ └── custom/themes/ # Zshテーマ (px-rose-pine: pixeljae 製 agnoster ベースを vendored)
├── .vim/rc/ # Vim設定本体 (分割ロード: 00-plugins, 10-basic ...)
├── assets/ # README / docs から参照する構成図 (SVG)
├── docs/ # 補助ドキュメント (setup / configuration / testing / ai-integration)
├── .gitconfig # Git設定
├── .gitignore_global # グローバルgitignore
├── .tmux.conf # tmux設定
├── .vimrc # 薄いローダー (.vim/rc/*.vim を順次source)
├── .wezterm.lua # WezTerm設定
├── .zshrc # Zsh設定
├── INSTALL_PLATFORM.md # プラットフォーム別インストール / トラブルシューティング
├── LICENSE # MITライセンス
├── install.sh # クロスプラットフォーム対応インストールスクリプト
├── pytest.ini # pytest設定
├── scripts/ # ユーティリティスクリプト (AIツール更新, プロジェクト雛形, tmuxヘルパー, 秘密スキャナ, Codexエージェント生成)
└── tests/ # フック / スクリプトの pytest スイート (hermetic)
対応プラットフォーム: macOS、Ubuntu/Debian、Windows (WSL/Git Bash)
git clone https://github.com/sardonyx0827/dotfiles.git ~/dotfiles
cd ~/dotfiles
./install.sh
事前確認:
./install.sh --dry-run(-n)で、何も書き込まずに実行内容をプレビューできます。作成されるシンボリックリンク・バックアップ対象・生成される設定ファイル・シェル変更(chsh)が全件表示されます(パッケージ導入はネットワーク処理のため対象外=予告してスキップ)。オプション一覧は./install.sh --help(-h)。
このスクリプトは以下を自動的に行います:
~/.dotfiles_backup_<timestamp>/ へ自動退避。戻し方は元に戻すを参照)スクリプト実行中に AI 開発ツール(Claude Code / Codex / Gemini CLI / GitHub Copilot CLI)のインストールを選択できます。
# インストール完了後、ターミナルを再起動
# Neovimを開いてlazy.nvimプラグインを自動インストール
nvim
注意: プラットフォーム固有の詳細な手順やトラブルシューティングについては、INSTALL_PLATFORM.mdを参照してください。
手動でのインストール手順、install.sh が退避したファイルの戻し方(バックアップと復旧)、 よくある問題の対処は docs/setup.md にまとめています。
# インストール方法は公式ドキュメントを参照
# https://docs.anthropic.com/claude-code
npm install -g @openai/codex
npm install -g @google/gemini-cli
npm パッケージ @github/copilot として配布されています(単体の copilot コマンド)。
npm install -g @github/copilot
install.sh は Claude Code に以下の MCP サーバーをユーザースコープで登録します(冪等)。手動で行う場合は claude mcp add を使用します。
| サーバー | 用途 |
|---|---|
context7 | 最新ライブラリドキュメント取得 |
codex | Codex 連携 |
serena | コードベース解析 (LSP) |
gemini-consultant | 自作 Gemini 相談用 MCP サーバー |
# すべてのAIツールを更新 (Claude Code / Codex / Gemini CLI / Copilot CLI)
./scripts/update_ai_tools.sh
用途別に分割した詳細ドキュメントです。
| ドキュメント | 内容 |
|---|---|
| docs/setup.md | 手動インストール手順・バックアップと復旧・トラブルシューティング |
| docs/configuration.md | Zsh / Vim / Neovim / tmux / WezTerm / Git / AI 各ツールの設定、ユーティリティスクリプト、カスタマイズ |
| docs/testing.md | pytest スイート・カバレッジゲート・静的解析(ruff / bandit / shellcheck / mypy / luacheck) |
| docs/ai-integration.md | 複数 LLM 連携・Bash 安全ゲート(bash-review)・Neovim のエディタ内 AI の構成 |
| docs/claude-architecture.md | .claude/ の agents / skills / hooks / commands / rules の配線マップ |
| .claude/hooks/README.md | フックシステムの設計根拠と脅威モデル |
| .claude/agents/README.md | カスタムサブエージェントの解説 |
MIT License の下で公開しています。自由に使用・改変してください。
hooks/register.tsx 351 lines1import { atom, read, update } from "claude-code";
2import type { EngineInterface, Register } from "claude-code";
3
4import type { BashReviewLogEntry, BashReviewLogView } from "../types";
5import type { Detail } from "./log";
6import {
7 DEFAULT_VIEW,
8 FULL_MAX,
9 TYPES,
10 attachDetails,
11 commandOf,
12 countDecisions,
13 describeView,
14 displayText,
15 formatEntry,
16 labelText,
17 oneLineText,
18 parseArgs,
19 parseDetail,
20 parseLog,
21 selectEntries,
22 summarize,
23 toggleType,
24} from "./log";
25
26// bash-review (PreToolUse の settings hook) が書くログを読んで見せるだけの mod。
27// tool.call / tool.check などツールの可否に関わるイベントには一切フックしない:
28// mod は settings hook の前後で判定を差し替えられるため、触れると bash-review /
29// git-push-review / permissions.ask を素通りさせる余地が生まれる
30// (tests/test_config_wiring.py が静的に検査している)。
31
32const COMMAND = "bash-review-log";
33const PANE = "bash-review-log";
34const TITLE = "bash-review log";
35const USAGE = `使い方: /${COMMAND} [all|allow|ask|deny|error|flagged ...] [件数 1-500] [full|short]`;
36const LOG_RELATIVE = ".claude/logs/bash-review.log";
37// bash-review.py の log_dir。サマリーで 80 字に切られたコマンドの全文はここにある。
38const DETAIL_DIR = "/tmp/claude_hooks/logs/PreToolUse/Bash/bash-review";
39const DETAIL_NAME = /^bash_cmd_(\d+)_\d+\.log$/;
40// サマリーの件数に足して読む詳細ログの本数 (サマリーに残らなかった実行の分)。
41const DETAIL_SLACK = 100;
42const DETAIL_BATCH = 50;
43// 詳細ログ 1 本の上限。通常は数 KB で、これを超えるものは読まない。
44const DETAIL_MAX_BYTES = 256 * 1024;
45// ペインを描けない場所で本文 (モデルも読む) に載せる件数の上限。
46const MAX_TEXT_ENTRIES = 20;
47// 横に並べたときに望むペインの幅 (利用者が動かした幅が優先される)。
48const PANE_COLUMNS = 100;
49// ペインを開いている間、ログの更新を拾う間隔。
50const POLL_MS = 2000;
51
52const DECISION_COLOR: Record<string, string> = {
53 ALLOW: "green",
54 ASK: "yellow",
55 DENY: "red",
56};
57const PRIMARY = { variant: "primary" } as const;
58
59const entriesAtom = atom(
60 { plugin: "bash-review-log", key: "entries" } as const,
61 [] as BashReviewLogEntry[],
62);
63const viewAtom = atom(
64 { plugin: "bash-review-log", key: "view" } as const,
65 DEFAULT_VIEW as BashReviewLogView,
66);
67const errorAtom = atom(
68 { plugin: "bash-review-log", key: "error" } as const,
69 null as string | null,
70);
71
72// モジュール変数はホットリロードで初期化される。ペインの開閉は session.start
73// (リロード時にも再発火する) で $.ui.panes() から取り直す。
74let isOpen = false;
75let lastMtimeMs = -1;
76// 詳細ログは書かれた後に変わらないので、読めた結果をファイル名で覚えておく
77// (null は「読めたが Tool Input が使えない」= 伏せ字など)。
78const detailCache = new Map<string, Detail | null>();
79// 2 秒ごとの再読込と、ボタン・コマンドからの読み込みを重ねないための共有。
80let inFlight:
81 | Promise<{ entries: BashReviewLogEntry[] } | { error: string }>
82 | undefined;
83
84async function logPath($: EngineInterface): Promise<string> {
85 const home = await $.env.get("HOME");
86 if (home === undefined || home === "") throw new Error("HOME is not set");
87
88 return `${home}/${LOG_RELATIVE}`;
89}
90
91// ファイル名の time_ns (レビュー開始時刻) の順。桁数をそろえてから文字列で比べる。
92function byStartTime(a: string, b: string): number {
93 const left = (DETAIL_NAME.exec(a)?.[1] ?? "").padStart(24, "0");
94 const right = (DETAIL_NAME.exec(b)?.[1] ?? "").padStart(24, "0");
95
96 return left < right ? -1 : left > right ? 1 : 0;
97}
98
99// 新しい方から count 本の詳細ログを古い順で返す。読めないものは飛ばす。
100async function readDetails(
101 $: EngineInterface,
102 count: number,
103): Promise<Detail[]> {
104 let names: string[];
105 try {
106 // /tmp は誰でも書けるので、symlink・FIFO・巨大なファイルは読まない。
107 names = (await $.fs.list(DETAIL_DIR))
108 .filter(
109 (entry) =>
110 entry.kind === "file" &&
111 !entry.isLink &&
112 entry.size <= DETAIL_MAX_BYTES &&
113 DETAIL_NAME.test(entry.name),
114 )
115 .map((entry) => entry.name);
116 } catch {
117 // 再起動で /tmp が空になった直後など。切れたコマンドのまま見せる。
118 return [];
119 }
120 const recent = names.sort(byStartTime).slice(-count);
121 const kept = new Set(recent);
122 for (const name of detailCache.keys()) {
123 if (!kept.has(name)) detailCache.delete(name);
124 }
125 const unread = recent.filter((name) => !detailCache.has(name));
126 for (let i = 0; i < unread.length; i += DETAIL_BATCH) {
127 await Promise.all(
128 unread.slice(i, i + DETAIL_BATCH).map(async (name) => {
129 try {
130 const text = await $.fs.read(`${DETAIL_DIR}/${name}`);
131 detailCache.set(name, parseDetail(text) ?? null);
132 } catch {
133 // prune_dir() が一覧の直後に消したものなど。覚えずに次回また試す。
134 }
135 }),
136 );
137 }
138
139 return recent.flatMap((name) => {
140 const detail = detailCache.get(name);
141
142 return detail ? [detail] : [];
143 });
144}
145
146// ログを読み直して state に入れる。読み込み中に呼ばれたら、その結果を待つ。
147async function load(
148 $: EngineInterface,
149): Promise<{ entries: BashReviewLogEntry[] } | { error: string }> {
150 inFlight ??= reload($).finally(() => {
151 inFlight = undefined;
152 });
153
154 return inFlight;
155}
156
157// 読めなければ error に理由を残す。
158async function reload(
159 $: EngineInterface,
160): Promise<{ entries: BashReviewLogEntry[] } | { error: string }> {
161 let path = `~/${LOG_RELATIVE}`;
162 try {
163 path = await logPath($);
164 const { mtimeMs } = await $.fs.stat(path);
165 const parsed = parseLog(await $.fs.read(path));
166 const details = await readDetails($, parsed.length + DETAIL_SLACK);
167 const entries = attachDetails(parsed, details);
168 lastMtimeMs = mtimeMs;
169 await update($, entriesAtom, () => entries);
170 await update($, errorAtom, () => null);
171
172 return { entries };
173 } catch {
174 const error = `bash-review: ログを読めませんでした (${path})`;
175 await update($, errorAtom, () => error);
176
177 return { error };
178 }
179}
180
181async function refreshIfChanged($: EngineInterface): Promise<void> {
182 if (!isOpen) return;
183 try {
184 const { mtimeMs } = await $.fs.stat(await logPath($));
185 if (mtimeMs !== lastMtimeMs) await load($);
186 } catch {
187 // ローテーションの差し替え中などで一瞬見えないだけなら、次の周期で拾う。
188 }
189}
190
191export const register: Register = (on) => {
192 on("session.start", async ($, e, next) => {
193 // ここで投げるとセッションの開始 (next) まで止めてしまうので、握って続ける。
194 try {
195 await $.command.register({
196 name: COMMAND,
197 description:
198 "bash-review の判定ログをペインで表示 (種別・件数・全文表示を引数で指定)",
199 argumentHint: "[all|allow|ask|deny|error|flagged] [件数] [full|short]",
200 immediate: true,
201 });
202 isOpen = (await $.ui.panes()).some((pane) => pane.id === PANE);
203 } catch (error) {
204 $.ui.log(`bash-review-log: session.start failed: ${String(error)}`, {
205 to: "debug",
206 });
207 }
208 $.clock.every(POLL_MS, () => void refreshIfChanged($));
209
210 return next(e);
211 });
212
213 on("command.run", { command: COMMAND }, async ($, e) => {
214 // 型の上では string だが、別プラグインが $.command.run を args なしで呼ぶと空になる。
215 const view = parseArgs(e.args ?? "");
216 if (view === undefined) return { text: USAGE };
217 await update($, viewAtom, () => view);
218
219 const loaded = await load($);
220 if ("error" in loaded) return { text: loaded.error };
221
222 const { entries } = loaded;
223 const heading = `${summarize(entries)}(表示: ${describeView(view)})`;
224 const opened = await $.ui.open({
225 id: PANE,
226 title: TITLE,
227 columns: PANE_COLUMNS,
228 });
229 isOpen = true;
230 if (opened.isPlaced) return { text: heading };
231
232 // ペインを描けない場所では、条件に合う分を本文で返す。本文はモデルも読むので
233 // 件数を絞り、コマンドはサマリーの 80 字まで (formatEntry) にとどめる。
234 const shown = selectEntries(
235 entries,
236 view.types,
237 Math.min(view.limit, MAX_TEXT_ENTRIES),
238 );
239
240 return { text: [heading, ...shown.map(formatEntry)].join("\n") };
241 });
242
243 on("ui.close", { id: PANE }, ($, e, next) => {
244 isOpen = false;
245
246 return next(e);
247 });
248
249 on("ui.render", { component: "Pane", requestId: PANE }, async ($, e) => {
250 const { Box, Button, Text } = $.ui.resolve(e);
251 const entries = await read($, entriesAtom);
252 const view = await read($, viewAtom);
253 const error = await read($, errorAtom);
254 const counts = countDecisions(entries);
255 const matched = selectEntries(entries, view.types, entries.length);
256 const shown = matched.slice(0, view.limit);
257 const wrap = view.isFull ? "wrap" : "truncate-end";
258
259 return (
260 <Box flexDirection="column">
261 <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
262 <Text bold>{entries.length} 件</Text>
263 <Text color="green">ALLOW {counts.allow}</Text>
264 <Text color="yellow">ASK {counts.ask}</Text>
265 <Text color="red">DENY {counts.deny}</Text>
266 <Text color="magenta">ERROR {counts.error}</Text>
267 </Box>
268 <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
269 <Button
270 key="type-all"
271 hotkey="0"
272 label="すべて"
273 {...(view.types.length === 0 ? PRIMARY : {})}
274 onPress={() =>
275 update($, viewAtom, (current) => ({ ...current, types: [] }))
276 }
277 />
278 {TYPES.map((type, index) => (
279 <Button
280 key={`type-${type}`}
281 hotkey={String(index + 1)}
282 label={type}
283 {...(view.types.includes(type) ? PRIMARY : {})}
284 onPress={() =>
285 update($, viewAtom, (current) => toggleType(current, type))
286 }
287 />
288 ))}
289 <Button
290 key="full"
291 hotkey="w"
292 label={view.isFull ? "1 行で表示" : "全文を表示"}
293 onPress={() =>
294 update($, viewAtom, (current) => ({
295 ...current,
296 isFull: !current.isFull,
297 }))
298 }
299 />
300 <Button
301 key="reload"
302 hotkey="r"
303 label="再読込"
304 onPress={() => void load($)}
305 />
306 <Button
307 key="close"
308 role="dismiss"
309 label="閉じる"
310 onPress={() => void $.ui.close({ id: PANE })}
311 />
312 </Box>
313 <Text dimColor>
314 {describeView(view)} — 該当 {matched.length} 件中 {shown.length}{" "}
315 件を表示
316 </Text>
317 {error !== null && <Text color="red">{error}</Text>}
318 {error === null && shown.length === 0 && (
319 <Text dimColor>該当する判定はありません</Text>
320 )}
321 {shown.map((entry) => (
322 <Box flexDirection="column" marginTop={1}>
323 <Text wrap="truncate-end">
324 <Text dimColor>{entry.at.slice(5)} </Text>
325 <Text bold color={DECISION_COLOR[entry.decision] ?? "white"}>
326 {labelText(entry.decision)}
327 </Text>
328 <Text dimColor> {labelText(entry.stage)}</Text>
329 {entry.description === undefined
330 ? ""
331 : ` ${oneLineText(entry.description)}`}
332 </Text>
333 <Text wrap={wrap}>
334 {"$ "}
335 {view.isFull
336 ? displayText(commandOf(entry), FULL_MAX)
337 : oneLineText(commandOf(entry))}
338 </Text>
339 <Text dimColor wrap={wrap}>
340 {"↳ "}
341 {view.isFull
342 ? displayText(entry.reason, FULL_MAX)
343 : oneLineText(entry.reason)}
344 </Text>
345 </Box>
346 ))}
347 </Box>
348 );
349 });
350};
351hooks/log.ts 319 lines1import type {
2 BashReviewLogEntry,
3 BashReviewLogType,
4 BashReviewLogView,
5} from "../types";
6
7export type DecisionCounts = {
8 allow: number;
9 ask: number;
10 deny: number;
11 // レビュアー (Gemini / Codex) のエラー。判定の数え方とは重なる
12 error: number;
13 other: number;
14};
15
16// 詳細ログ (コマンドごと 1 ファイル) の Tool Input から引いたもの。
17export type Detail = { command: string; description?: string };
18
19export const TYPES: readonly BashReviewLogType[] = [
20 "ALLOW",
21 "ASK",
22 "DENY",
23 "ERROR",
24];
25// 要確認 (人の目が要る) もの: flagged が指す
26const FLAGGED: readonly BashReviewLogType[] = ["ASK", "DENY", "ERROR"];
27export const DEFAULT_LIMIT = 50;
28export const MAX_LIMIT = 500;
29export const DEFAULT_VIEW: BashReviewLogView = {
30 types: [],
31 limit: DEFAULT_LIMIT,
32 isFull: false,
33};
34
35// _bash_review_common.py の log_summary() が書く 1 行:
36// [YYYY-MM-DD HH:MM:SS] DECISION | stage | command | reason
37// decision / stage は空白詰めの固定幅。command は 80 文字で切られ、改行を含めば
38// 次の行以降へ続く (継続行)。reason に " | " は現れないので、エントリ末尾の
39// 最後の " | " が command と reason の境目になる。
40// s フラグ: コマンドに U+2028 / U+2029 / \r が混じっても . が止まらないように。
41const HEADER =
42 /^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\] (\S+)\s*\| (\S+)\s*\| (.*)$/s;
43
44// ui.render の Text は 10000 字まで、制御文字はタブと改行だけ。どちらかを破ると
45// ペイン全体が描けなくなる (エンジンが自前の表示に差し替える) ので、ログ由来の
46// 文字列は displayText() を通してから描く。bidi 制御と行区切りは表示の偽装に
47// 使えるので一緒に置き換える。
48const UNSAFE =
49 /[\u0000-\u0008\u000b-\u001f\u007f-\u009f\u200e\u200f\u202a-\u202e\u2066-\u2069\u2028\u2029]/g;
50export const COMPACT_MAX = 500;
51export const FULL_MAX = 9000;
52const LABEL_MAX = 200;
53
54const SEPARATOR = " | ";
55
56// log_summary() の command[:80] + "..."。Python の添字はコードポイント単位。
57const TRUNCATE_AT = 80;
58const ELLIPSIS = "...";
59
60const REVIEWER_ERROR = /\b(?:gemini|codex)=ERROR\b/;
61
62const TOOL_INPUT = "Tool Input: ";
63
64export function parseLog(text: string): BashReviewLogEntry[] {
65 const entries: BashReviewLogEntry[] = [];
66 let head: { at: string; decision: string; stage: string } | undefined;
67 let body = "";
68
69 const flush = () => {
70 if (head === undefined) return;
71 const cut = body.lastIndexOf(SEPARATOR);
72 entries.push({
73 ...head,
74 command: cut < 0 ? body : body.slice(0, cut),
75 reason: cut < 0 ? "" : body.slice(cut + SEPARATOR.length),
76 });
77 };
78
79 for (const line of text.replace(/\n+$/, "").split("\n")) {
80 const match = HEADER.exec(line);
81 if (match) {
82 flush();
83 const [, at = "", decision = "", stage = "", rest = ""] = match;
84 head = { at, decision, stage };
85 body = rest;
86 } else if (head !== undefined) {
87 body += `\n${line}`;
88 }
89 // 最初のヘッダより前の行は、ローテーション (末尾 500 行の保持) で頭を
90 // 失ったエントリの残りなので捨てる。
91 }
92 flush();
93
94 return entries;
95}
96
97export function isReviewerError(entry: BashReviewLogEntry): boolean {
98 return REVIEWER_ERROR.test(entry.reason);
99}
100
101export function countDecisions(
102 entries: readonly BashReviewLogEntry[],
103): DecisionCounts {
104 const counts: DecisionCounts = {
105 allow: 0,
106 ask: 0,
107 deny: 0,
108 error: 0,
109 other: 0,
110 };
111 for (const entry of entries) {
112 if (entry.decision === "ALLOW") counts.allow += 1;
113 else if (entry.decision === "ASK") counts.ask += 1;
114 else if (entry.decision === "DENY") counts.deny += 1;
115 else counts.other += 1;
116 if (isReviewerError(entry)) counts.error += 1;
117 }
118
119 return counts;
120}
121
122function isType(value: string): value is BashReviewLogType {
123 return (TYPES as readonly string[]).includes(value);
124}
125
126// 引数: 種別 (allow / ask / deny / error / flagged / all)、件数、full / short を
127// 空白区切りで順不同に。知らない語があれば undefined。
128export function parseArgs(args: string): BashReviewLogView | undefined {
129 const types = new Set<BashReviewLogType>();
130 let isAll = false;
131 let limit = DEFAULT_LIMIT;
132 let isFull = false;
133
134 for (const token of args.trim().split(/\s+/).filter(Boolean)) {
135 const word = token.toUpperCase();
136 if (isType(word)) types.add(word);
137 else if (word === "FLAGGED") FLAGGED.forEach((type) => types.add(type));
138 else if (word === "ALL") isAll = true;
139 else if (word === "FULL") isFull = true;
140 else if (word === "SHORT") isFull = false;
141 else if (/^\d+$/.test(word) && Number(word) > 0)
142 limit = Math.min(Number(word), MAX_LIMIT);
143 else return undefined;
144 }
145
146 return {
147 types: isAll ? [] : TYPES.filter((type) => types.has(type)),
148 limit,
149 isFull,
150 };
151}
152
153export function toggleType(
154 view: BashReviewLogView,
155 type: BashReviewLogType,
156): BashReviewLogView {
157 const types = view.types.includes(type)
158 ? view.types.filter((t) => t !== type)
159 : TYPES.filter((t) => t === type || view.types.includes(t));
160
161 return { ...view, types };
162}
163
164export function describeView(view: BashReviewLogView): string {
165 const types = view.types.length === 0 ? "全種別" : view.types.join("・");
166
167 return `${types} / 最大 ${view.limit} 件 / ${view.isFull ? "全文" : "1 行"}`;
168}
169
170function matchesTypes(
171 entry: BashReviewLogEntry,
172 types: readonly BashReviewLogType[],
173): boolean {
174 return (
175 types.length === 0 ||
176 types.some((type) =>
177 type === "ERROR" ? isReviewerError(entry) : entry.decision === type,
178 )
179 );
180}
181
182// ログは古い順に並ぶので、新しい順に反転してから絞り込む。
183export function selectEntries(
184 entries: readonly BashReviewLogEntry[],
185 types: readonly BashReviewLogType[],
186 limit: number,
187): BashReviewLogEntry[] {
188 return [...entries]
189 .reverse()
190 .filter((entry) => matchesTypes(entry, types))
191 .slice(0, limit);
192}
193
194export function toOneLine(command: string): string {
195 return command.replaceAll("\n", " ⏎ ");
196}
197
198// C0 は制御記号 (U+2400〜, ESC なら ␛)、DEL は ␡、それ以外は U+FFFD にし、
199// max 字 (コードポイント) を超えたら切って残りの字数を添える。
200export function displayText(text: string, max: number): string {
201 const safe = text.replace(UNSAFE, (char) => {
202 const code = char.charCodeAt(0);
203 if (code < 0x20) return String.fromCharCode(0x2400 + code);
204
205 return code === 0x7f ? "␡" : "�";
206 });
207 const chars = Array.from(safe);
208
209 return chars.length <= max
210 ? safe
211 : `${chars.slice(0, max).join("")}…(残り ${chars.length - max} 字)`;
212}
213
214// 1 行表示用: 改行を ⏎ にしてから無害化する。
215export function oneLineText(text: string): string {
216 return displayText(toOneLine(text), COMPACT_MAX);
217}
218
219export function labelText(text: string): string {
220 return displayText(text, LABEL_MAX);
221}
222
223export function commandOf(entry: BashReviewLogEntry): string {
224 return entry.fullCommand ?? entry.command;
225}
226
227export function summarize(entries: readonly BashReviewLogEntry[]): string {
228 const { allow, ask, deny, error, other } = countDecisions(entries);
229 const tail = other > 0 ? ` · その他 ${other}` : "";
230
231 return `bash-review: 直近 ${entries.length} 件 — ALLOW ${allow} · ASK ${ask} · DENY ${deny} · ERROR ${error}${tail}`;
232}
233
234// 本文 (コマンドの text。モデルも読み、トランスクリプトに残る) 用の 1 行。年を
235// 落とした日時と固定幅の decision / stage を並べる。他プロジェクトのコマンドも
236// 混じるログなので、詳細ログの全文は載せずサマリーの 80 字までにとどめる。
237export function formatEntry(entry: BashReviewLogEntry): string {
238 const decision = labelText(entry.decision).padEnd(5);
239 const stage = labelText(entry.stage).padEnd(8);
240
241 return `${entry.at.slice(5)} ${decision} ${stage} ${oneLineText(entry.command)} — ${oneLineText(entry.reason)}`;
242}
243
244// write_detail_log() が書く詳細ログから Tool Input を取り出す。秘密検出で
245// "[REDACTED - credential detected]" に伏せられたものは JSON ではないので捨てる。
246export function parseDetail(text: string): Detail | undefined {
247 const line = text.split("\n").find((l) => l.startsWith(TOOL_INPUT));
248 if (line === undefined) return undefined;
249 try {
250 const input: unknown = JSON.parse(line.slice(TOOL_INPUT.length));
251 if (typeof input !== "object" || input === null) return undefined;
252 const { command, description } = input as Record<string, unknown>;
253 if (typeof command !== "string") return undefined;
254
255 return typeof description === "string" && description !== ""
256 ? { command, description }
257 : { command };
258 } catch {
259 return undefined;
260 }
261}
262
263// サマリーで切られたコマンドか (先頭 80 字 + "..." の形)。
264function isTruncated(entry: BashReviewLogEntry): boolean {
265 return (
266 entry.command.endsWith(ELLIPSIS) &&
267 Array.from(entry.command).length === TRUNCATE_AT + ELLIPSIS.length
268 );
269}
270
271// 詳細ログとの照合キー。切られたものは「先頭 80 字」、切られていないものは
272// 「全文」で引く。ちょうど 80 字のコマンドが、それで始まる長いコマンドの
273// キーと混ざらないよう、二つの名前空間に分ける。
274function entryKey(entry: BashReviewLogEntry): string {
275 return isTruncated(entry)
276 ? `cut:${Array.from(entry.command).slice(0, TRUNCATE_AT).join("")}`
277 : `all:${entry.command}`;
278}
279
280function detailKey(detail: Detail): string {
281 const chars = Array.from(detail.command);
282
283 return chars.length > TRUNCATE_AT
284 ? `cut:${chars.slice(0, TRUNCATE_AT).join("")}`
285 : `all:${detail.command}`;
286}
287
288// details は古い順。サマリー (末尾 500 行) と詳細ログ (末尾 1000 件、/tmp なので
289// 再起動で消える) は保持範囲が違っても、どちらも「今」で終わる。そこで同じキーの
290// 中で新しい方から 1 対 1 に対応づけ、詳細ログが足りない古いエントリはそのままにする。
291// 全文は切られたエントリにだけ、description は対応がついたすべてに付ける。
292export function attachDetails(
293 entries: readonly BashReviewLogEntry[],
294 details: readonly Detail[],
295): BashReviewLogEntry[] {
296 const byKey = new Map<string, Detail[]>();
297 for (const detail of details) {
298 const key = detailKey(detail);
299 byKey.set(key, [...(byKey.get(key) ?? []), detail]);
300 }
301
302 const attached = [...entries];
303 for (let i = attached.length - 1; i >= 0; i -= 1) {
304 const entry = attached[i];
305 const detail =
306 entry === undefined ? undefined : byKey.get(entryKey(entry))?.pop();
307 if (entry === undefined || detail === undefined) continue;
308 attached[i] = {
309 ...entry,
310 ...(isTruncated(entry) ? { fullCommand: detail.command } : {}),
311 ...(detail.description === undefined
312 ? {}
313 : { description: detail.description }),
314 };
315 }
316
317 return attached;
318}
319types/index.d.ts 39 lines1// bash-review-log の $.state 契約。
2// サマリーログ 1 行 (複数行コマンドは継続行込み) を 1 エントリとして持つ。
3export type BashReviewLogEntry = {
4 // "YYYY-MM-DD HH:MM:SS"
5 at: string;
6 // ALLOW / ASK / DENY (未知の値もそのまま保持する)
7 decision: string;
8 // pre / secret / highrisk / gemini / codex / fallback
9 stage: string;
10 // サマリーログのまま (80 字を超えると先頭 80 字 + "...")
11 command: string;
12 reason: string;
13 // 詳細ログから引いた切られる前の全文と、Bash ツールの description
14 fullCommand?: string;
15 description?: string;
16};
17
18// ERROR は判定ではなく「Gemini か Codex が応答できなかった」(reason に *=ERROR)
19export type BashReviewLogType = "ALLOW" | "ASK" | "DENY" | "ERROR";
20
21export type BashReviewLogView = {
22 // 空なら全種別
23 types: BashReviewLogType[];
24 // ペインに描く最大件数
25 limit: number;
26 // true: コマンドを折り返して全文 / false: 1 行に切り詰める
27 isFull: boolean;
28};
29
30declare module "claude-code" {
31 interface PluginState {
32 "bash-review-log": {
33 entries: BashReviewLogEntry[];
34 view: BashReviewLogView;
35 error: string | null;
36 };
37 }
38}
39