Agent Plugins、Claude Code、Codex向けの品質支援プラグイン。コードと文書を扱う作業、計画やバグ調査の指針を示し、フックで入力と操作を確かめる。

[![CI][ci-badge]][ci-url]
[ci-badge]: https://github.com/ak110/dotfiles/actions/workflows/ci.yaml/badge.svg [ci-url]: https://github.com/ak110/dotfiles/actions/workflows/ci.yaml
chezmoiで管理する個人用dotfilesである。
~/.*)の一括デプロイpytools)の同梱pinactによるGitHub Actionsのコミットハッシュ固定)agent-toolkitは、Agent Plugins、Claude Code、Codexで共有できるコーディングエージェント向けツールキットである。 単体導入にはClaude Code CLI、Codex CLI、uvを使用する。 dotfiles配布では、chezmoi apply後の処理がこれらの導入と更新を担う。 統合導入はagent-toolkit導入ガイド、 Codex固有の詳細はCodex利用ガイドを参照する。
Windows PowerShellでは、環境によって公式インストーラーが使用するGet-FileHashを解決できず、Codex CLIの導入に失敗する。
sudo apt install git
curl -fsSL https://astral.sh/uv/install.sh | sh
winget install --id=Git.Git -e --source=winget
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
curl -fsSL https://raw.githubusercontent.com/ak110/dotfiles/master/install.sh | bash
powershell -NoProfile -ExecutionPolicy Bypass -Command "irm https://raw.githubusercontent.com/ak110/dotfiles/master/install.ps1 | iex"
winget install twpayne.chezmoi && chezmoi init ak110 --source=%USERPROFILE%\dotfiles --apply && setx PATH "%PATH%;%USERPROFILE%\bin;%USERPROFILE%\.local\bin"
update-dotfilesはmiseが見つからない場合にmise本体を自動で導入する。 ネットワーク制限などで自動導入に失敗した場合は、次の手順で導入してからupdate-dotfilesを再実行する。
Linuxの場合は以下を実行する。
curl -fsSL https://mise.run | MISE_INSTALL_PATH="$HOME/.local/bin/mise" sh
Windows(PowerShell)の場合は以下を実行する。
winget install jdx.mise
mise経由でインストールされる一部ツール(jq・actionlint・pinactなど)では aquaがGitHub artifact attestations検証のためにapi.github.comへアクセスする。 未認証では1時間あたり60リクエストのIPベース制限があり、update-dotfiles実行時に API rate limit exceededで失敗することがあり、OSに依存せず発生し得る。
回避には個人アクセストークンを発行し、ユーザー環境変数GITHUB_TOKENへ設定する。
Generate new tokenをクリックするRepository accessはPublic Repositories (read-only)を選択する(追加スコープ設定は不要)Generate tokenをクリックしてトークンを発行するLinuxの場合は~/.envに追記する(~/.bashrcはchezmoiの管理対象で上書きされるため使わない)。
export GITHUB_TOKEN=<コピーしたトークン>
Windows(PowerShell)の場合は以下を実行する。
[Environment]::SetEnvironmentVariable("GITHUB_TOKEN", "<コピーしたトークン>", "User")
update-dotfilesを再実行するupdate-dotfilesはAntigravity CLI(agy)も導入する。 日本語の技術文書の推敲に使い、agents_serverからはモデルを明示指定したときだけ選べる。
初回はブラウザでの認証を要する。agyを対話で起動して認証を済ませるまで、agents_serverからは使えない。
agy
指定できるモデルスラッグは次のコマンドで確認する。
agy models
statuslineをClaude Codeと同じ体裁で表示する場合は、Antigravity CLIのstatuslineコマンドへ claude-statusline agy-statuslineを設定する。
update-dotfiles
更新が失敗したときや、直近の更新で何が実行されたかを確認したいときは、直近1回の実行の保存ログを表示する。
update-dotfiles logs
hooks/register.ts 33 lines1import type { Register } from "claude-code";
2
3import { COMPACT_CONVERSATION_TOOL, register as registerCompactConversation } from "./compact_conversation.ts";
4import { register as registerPeriodicRecheck } from "./periodic_recheck.ts";
5import { register as registerSendToUser, SEND_TO_USER_TOOL } from "./send_to_user.tsx";
6import { exitStatePath, register as registerSessionExit } from "./session_exit.ts";
7
8// hooks.jsonの`modules`は1つのプラグインにつき1つのhooks moduleだけを受け付け、照合条件の無い同じイベントのhookは
9// 1回しか登録できない。また`$`は別ファイルの関数へ渡せないため、各機能の`session.start`の処理をここへまとめる。
10export const register: Register = (on) => {
11 on("session.start", async ($, e, next) => {
12 // ツールは最初の発話より前に一覧へ載るよう、`next`より前に登録する。
13 await $.tool.register(SEND_TO_USER_TOOL);
14 if (!(await $.env.get("AGENT_TOOLKIT_OWNER_SESSION"))) await $.tool.register(COMPACT_CONVERSATION_TOOL);
15 const result = await next(e);
16 // `atk agents-exit-session`の終了要求を受け付ける準備ができたことを示す印を書き、前の要求を消費済みにする。
17 const sessionId = await $.session.id();
18 const configured = await $.env.get("CLAUDE_CONFIG_DIR");
19 const home = (await $.env.get("HOME")) || (await $.env.get("USERPROFILE"));
20 const marker = exitStatePath(sessionId, configured, home, "marker");
21 const request = exitStatePath(sessionId, configured, home, "request");
22 if (marker && request) {
23 await $.fs.write(request, "consumed");
24 await $.fs.write(marker, "ready");
25 }
26 return result;
27 });
28 registerSessionExit(on);
29 registerCompactConversation(on);
30 registerSendToUser(on);
31 registerPeriodicRecheck(on);
32};
33hooks/compact_conversation.ts 49 lines1import type { On } from "claude-code";
2
3const TOOL_NAME = "compact_conversation";
4
5// 実ホストの版・入力・結果と再検証はdocs/development/audit-records.mdの
6// 「agent-toolkit/hooks/compact_conversation.ts:会話圧縮の予約:2026年10月9日」に記録する。
7
8export const COMPACT_CONVERSATION_TOOL = {
9 name: TOOL_NAME,
10 description:
11 "Claude Codeメインの会話圧縮を予約する。instructionsは任意の圧縮指示。受付は完了ではない。予約後は現在のターンを終え、ホストの圧縮結果を待つ。未完了の予約は重ねず、完了または失敗後は再予約できる。",
12 inputSchema: {
13 type: "object",
14 properties: { instructions: { type: "string", description: "圧縮後に保持する情報と再開のための指示。" } },
15 additionalProperties: false,
16 },
17 isDeferred: false,
18};
19
20export function register(on: On): void {
21 // modはセッションごとに読み込まれる。予約中だけ状態を保持し、永続予約や解除入口は持たない。
22 let pending = false;
23 on("tool.check", { tool: /__compact_conversation$/ }, async ($, e, next) => {
24 if (e.tool !== `mcp__${$.plugin.name}__${TOOL_NAME}`) return next(e);
25 return e.agentId === undefined && !(await $.env.get("AGENT_TOOLKIT_OWNER_SESSION")) ? { decision: "allow" } : { decision: "deny", reason: "メインの会話で使うツール。" };
26 });
27 on("tool.call", { tool: /__compact_conversation$/ }, async ($, e, next) => {
28 if (e.tool !== `mcp__${$.plugin.name}__${TOOL_NAME}`) return next(e);
29 if (e.agentId !== undefined || (await $.env.get("AGENT_TOOLKIT_OWNER_SESSION"))) return { deny: "メインの会話で圧縮を予約する。" };
30 const instructions = "instructions" in e ? e.instructions : undefined;
31 if (instructions !== undefined && typeof instructions !== "string") {
32 return { deny: "instructionsには文字列を指定するか、省略する。圧縮は予約していない。" };
33 }
34 if (pending) return { result: "未完了の圧縮予約があるため追加していない。現在のターンを終えてホストの結果を待つ。" };
35 pending = true;
36 // command.runはhook内で拒否されるため、hookが応答した後のタイマーから呼ぶ。
37 $.clock.after(0, async () => {
38 try {
39 await $.command.run({ command: "compact", ...(instructions === undefined ? {} : { args: instructions }) });
40 pending = false;
41 } catch (error) {
42 pending = false;
43 await $.prompt.submit({ text: `会話圧縮の予約実行に失敗した。予約状態は解除済みで再予約できる。原因: ${String(error)}` });
44 }
45 });
46 return { result: "会話圧縮を予約した。まだ完了していない。現在のターンを終えてホストの圧縮結果を待つ。" };
47 });
48}
49hooks/periodic_recheck.ts 138 lines1import type { EngineInterface, On } from "claude-code";
2
3// `agents_server`の`start`の処理の中で、メインの定期再確認のtaskを`CronCreate`で装着し、結果をメインの会話へ届ける。
4// モデルが起動前に`atk wait-schedule`と`CronCreate`を呼ぶ手順は装着漏れを残し、起動までの呼び出しも増やすため、
5// 装着をモデルの遵守に依存させない。装着できない場合は、モデルが規範の手順で装着するよう案内する。
6// cron式とpromptの共通本文は`atk wait-schedule --format json`の出力から得る。本文の定義元はPython側の1か所であり、
7// モデルが自ら装着する手順も同じ出力を使う。
8// `Agent`ツールのサブエージェント(`agentId`を持つ呼出主体)へは装着も通知もしない。`CronList`はメインと共有され、
9// サブエージェントが作成したtaskのpromptは親の会話へ届くため、サブエージェントの待機を再確認できない。
10// サブエージェントは完了通知で待機を解く。
11// 設計と不採用とした代替は`docs/development/design-hooks.md`「`start`の処理の中での定期再確認の装着」にある。
12
13// Claude Codeがプラグインのstdio MCPサーバーのツールへ付ける名前(`mcp__plugin_<プラグイン名>_<サーバー名>__<ツール名>`)。
14// matcherの無い`tool.call`のhookは`send_to_user.tsx`が登録済みで同じイベントへ2件目を登録できないため、ツール名をmatcherに指定する。
15// `hooks.json`のPreToolUseの`matcher`も同じ綴りを使う。
16const START_TOOL = "mcp__plugin_agent-toolkit_agents_server__start";
17const RUNTIME_REFERENCE = "`agent-toolkit:delegation`の`references/claude-code-runtime.md`「待機中の定期再確認と背景転換」";
18// 定期再確認のpromptの1行目に置く標識。`agent-toolkit/agent_toolkit/_hooks/user_prompt_submit.py`の`PERIODIC_RECHECK_MARKER`と
19// 同じリテラルとし、一致は`agent-toolkit/skills/delegation/references/runtime_contract_invariant_test.py`が確かめる。
20export const PERIODIC_RECHECK_MARKER = '<atk-auto source="periodic-recheck" kind="periodic-recheck">';
21const CRON_EXPRESSION = /^\S+( \S+){4}$/;
22// `atk wait-schedule`は`claude auth status`を最大5秒待つ。`uv run`の環境同期を含めても収まる上限を取る。
23const WAIT_SCHEDULE_TIMEOUT_MS = 60_000;
24
25// 装着の結果は保持するtask IDを伝えるだけで対処を要さないため`notice`、
26// 装着できなかった事実はモデルが自ら装着して原因を除けるため`warn`とする。
27function notice(kind: "notice" | "warn", body: string): string {
28 return `<atk-auto source="periodic-recheck" kind="${kind}">\n${body}\n</atk-auto>`;
29}
30
31function notMounted(reason: string): string {
32 return notice(
33 "warn",
34 `agent-toolkitのmodは定期再確認を装着できなかった(理由: ${reason})。` +
35 `待機でターンを終える場合は、${RUNTIME_REFERENCE}に従って自ら装着する。`,
36 );
37}
38
39function isMarked(prompt: string): boolean {
40 return (prompt.split("\n", 1)[0] ?? "").trim() === PERIODIC_RECHECK_MARKER;
41}
42
43// メインが既に定期再確認のtaskを持つかを判定する。
44// 装着はメインに限るため、標識付きのtaskは全てメインのものとみなす。
45// モデルが規範の手順で作成したtaskとmodの再読込前に作成したtaskも数え、重複作成を避ける。
46function holdsTask(markedIds: string[]): boolean {
47 return markedIds.length > 0;
48}
49
50type Schedule = { cron: string; prompt: string };
51
52// `atk wait-schedule --format json`の標準出力から、cron式と標識で始まるpromptを取り出す。形が合わなければ`undefined`を返す。
53function parseSchedule(stdout: string): Schedule | undefined {
54 let parsed: unknown;
55 try {
56 parsed = JSON.parse(stdout);
57 } catch {
58 return undefined;
59 }
60 if (typeof parsed !== "object" || parsed === null) return undefined;
61 const { cron, prompt } = parsed as { cron?: unknown; prompt?: unknown };
62 if (typeof cron !== "string" || typeof prompt !== "string") return undefined;
63 if (!CRON_EXPRESSION.test(cron.trim()) || !isMarked(prompt)) return undefined;
64 return { cron: cron.trim(), prompt };
65}
66
67// 装着の結果をメインへ届ける本文を返す。サブエージェントの呼び出しと、既にtaskを持つ場合は`undefined`を返し、何も届けない。
68async function mount($: EngineInterface, agentId: string | undefined): Promise<string | undefined> {
69 if (agentId !== undefined) return undefined;
70 if ((await $.tool.check({ tool: "CronList", input: {} })).decision === "deny") {
71 return notMounted("`CronList`の許可の判定が`deny`");
72 }
73 const listed = await $.tool.call({ tool: "CronList" });
74 if (listed.deny !== undefined) return notMounted(`\`CronList\`が拒否された: ${listed.deny}`);
75 if (listed.isError === true) return notMounted(`\`CronList\`が失敗した: ${listed.text ?? "本文なし"}`);
76 const markedIds = listed.result.jobs.filter((job) => isMarked(job.prompt)).map((job) => job.id);
77 if (holdsTask(markedIds)) return undefined;
78
79 const root = $.plugin.root;
80 const schedule = await $.process.run(
81 [
82 "uv",
83 "run",
84 "--project",
85 root,
86 "--locked",
87 "--no-default-groups",
88 `${root}/agent_toolkit/atk.py`,
89 "wait-schedule",
90 "--request-bucket",
91 "main",
92 "--format",
93 "json",
94 ],
95 { timeoutMs: WAIT_SCHEDULE_TIMEOUT_MS },
96 );
97 const parsed = schedule.exitCode === 0 ? parseSchedule(schedule.stdout) : undefined;
98 if (parsed === undefined) {
99 return notMounted(
100 `\`atk wait-schedule --request-bucket main --format json\`がcron式と標識付きのpromptを返さなかった(終了コード${schedule.exitCode}、` +
101 `標準エラー: ${schedule.stderr.trim() || "なし"})`,
102 );
103 }
104 const { cron } = parsed;
105
106 const input = { cron, prompt: parsed.prompt, recurring: true };
107 if ((await $.tool.check({ tool: "CronCreate", input })).decision === "deny") {
108 return notMounted("`CronCreate`の許可の判定が`deny`");
109 }
110 const created = await $.tool.call({ tool: "CronCreate", ...input });
111 if (created.deny !== undefined) return notMounted(`\`CronCreate\`が拒否された: ${created.deny}`);
112 if (created.isError === true) return notMounted(`\`CronCreate\`が失敗した: ${created.text ?? "本文なし"}`);
113 return notice(
114 "notice",
115 `agent-toolkitのmodが定期再確認のtaskを作成した(task ID: ${created.result.id}、cron: ${cron}、` +
116 `request bucket: main)。このtask IDを保持し、再利用、resumeとcompaction後の確認、` +
117 `待機する全対象の終端後の\`CronDelete\`は${RUNTIME_REFERENCE}に従う。`,
118 );
119}
120
121export function register(on: On): void {
122 // `start`の結果は変えず、装着の結果を同じ呼び出しの後にモデルが読む文脈として加える。
123 // 起動が拒否または失敗した呼び出しでは待機対象が生じないため装着しない。
124 on("tool.call", { tool: START_TOOL }, async ($, e, next) => {
125 const result = await next(e);
126 if (result.deny !== undefined || result.isError === true) return result;
127 let note: string | undefined;
128 try {
129 note = await mount($, e.agentId);
130 } catch (error) {
131 // `$.tool.call`は呼び出し先のツールが公開されていない場合にrejectする。
132 note = notMounted(`装着の処理が例外で終わった: ${String(error)}`);
133 }
134 if (note === undefined) return result;
135 return { ...result, context: [...(result.context ?? []), note] };
136 }).catch(($, e, next) => next(e));
137}
138hooks/send_to_user.tsx 71 lines1import type { On } from "claude-code";
2
3// Claude Codeは、同じ応答でツール呼び出しより前に置いた地の文の一部を、APIが返す要約(progress update)へ置き換えて表示する。
4// ツールの入力は要約されないため、ユーザーへ届ける本文をこのツールの`message`で運び、
5// 呼び出しの行を`message`の`Markdown`で描く。ツールの登録と表示を同じmoduleに置き、表示できる環境にだけツールが現れるようにする。
6// 設計と不採用とした代替は`docs/development/design-hooks.md`「send_to_userツール」にある。
7
8const TOOL_NAME = "send_to_user";
9
10const DESCRIPTION = [
11 "ユーザーへ届ける本文を、途中・末尾ともにユーザーの画面へそのまま表示する。",
12 "質問への回答、確認結果、判明した事実や原因、成果物、進捗、作業完了報告と次の工程の予告に使う。",
13 "ユーザーの判断を求める確認は、このツールではなく既存の質問手段を使う。推論は送らない。",
14].join("");
15
16const INPUT_SCHEMA = {
17 type: "object",
18 properties: {
19 message: { type: "string", description: "ユーザーの画面へそのまま表示するMarkdownの本文。" },
20 },
21 required: ["message"],
22 additionalProperties: false,
23};
24
25function fullName(plugin: string): string {
26 return `mcp__${plugin}__${TOOL_NAME}`;
27}
28
29function messageOf(input: unknown): string | undefined {
30 if (input && typeof input === "object" && "message" in input) {
31 const message = (input as { message: unknown }).message;
32 if (typeof message === "string") return message;
33 }
34 return undefined;
35}
36
37// `register.ts`の`session.start`が`$.tool.register`へ渡すツールの定義。
38export const SEND_TO_USER_TOOL = { name: TOOL_NAME, description: DESCRIPTION, inputSchema: INPUT_SCHEMA, isDeferred: false };
39
40export function register(on: On): void {
41 // 報告のたびに権限確認の画面が出ると作業が止まるため、このツールの呼び出しを許可する。
42 on("tool.check", ($, e, next) => (e.tool === fullName($.plugin.name) ? { decision: "allow" } : next(e))).catch(
43 ($, e, next) => next(e),
44 );
45
46 // 判定が失敗した場合も他のツールの呼び出しを止めないよう、後続へ渡す。
47 on("tool.call", ($, e, next) => {
48 if (e.tool !== fullName($.plugin.name)) return next(e);
49 if (messageOf(e) === undefined) {
50 return { deny: "文字列のmessageが無いため、画面へ本文を表示していない。本文をmessageへ入れて呼び直す。" };
51 }
52 return { result: "ユーザーの画面へ表示した。" };
53 }).catch(($, e, next) => next(e));
54
55 // 呼び出しの行を`message`の全文の`Markdown`で描く。他のツールの行は描き替えない。
56 on("ui.render", { component: "ToolUse" }, ($, e, next) => {
57 if (e.props.tool !== fullName($.plugin.name)) return next(e);
58 const message = messageOf(e.props.input);
59 if (message === undefined) return next(e);
60 const { Markdown } = $.ui.resolve(e);
61 return <Markdown text={message} />;
62 });
63
64 // 成功結果はモデルへ返し、画面には本文の行だけを残す。エラーは後続の描画で示す。
65 on("ui.render", { component: "ToolResult" }, ($, e, next) => {
66 if (e.props.tool !== fullName($.plugin.name) || e.props.isErrored) return next(e);
67 const { Box } = $.ui.resolve(e);
68 return <Box />;
69 });
70}
71hooks/session_exit.ts 77 lines1import type { EngineInterface, On } from "claude-code";
2
3const SESSION_ID = /^[A-Za-z0-9_-]+$/;
4
5function isAbsolute(path: string): boolean {
6 return path.startsWith("/") || /^[A-Za-z]:[\\/]/.test(path);
7}
8
9// 終了要求の状態ファイルのパスを、セッションIDと設定ディレクトリの環境変数の値から求める。
10// hooks moduleは`$`を別ファイルの関数へ渡せないため、`$`を受け取らない形で`register.ts`の`session.start`と共有する。
11export function exitStatePath(
12 sessionId: string,
13 configured: string | undefined,
14 home: string | undefined,
15 kind: "marker" | "request",
16): string | undefined {
17 if (!SESSION_ID.test(sessionId)) return undefined;
18 let root: string | undefined;
19 if (configured && isAbsolute(configured)) root = configured;
20 else if (home && isAbsolute(home)) root = `${home}/.claude`;
21 if (!root) return undefined;
22 return `${root}/agent-toolkit-function-hooks/${kind}-${sessionId}.txt`;
23}
24
25async function statePath($: EngineInterface, sessionId: string, kind: "marker" | "request"): Promise<string | undefined> {
26 const configured = await $.env.get("CLAUDE_CONFIG_DIR");
27 const home = (await $.env.get("HOME")) || (await $.env.get("USERPROFILE"));
28 return exitStatePath(sessionId, configured, home, kind);
29}
30
31function isMissing(error: unknown): boolean {
32 if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") return true;
33 return String(error).includes("ENOENT");
34}
35
36// `/exit`は終了とともに停止するセッション限りの作業が残ると確認画面を表示し、無人のセッションはそこで止まる。
37// セッション限りのcron taskは終了で消えるため、確認画面で「Exit and stop tasks」を選ぶのと同じ結果になるよう
38// `/exit`の直前に削除する。durableなtaskは終了後も残す指定のため削除しない。
39// 許可が`allow`でない環境で`$.tool.call`を呼ぶと権限確認ダイアログで同じく止まるため、先に`$.tool.check`で判定する。
40// 一覧の取得と削除に失敗しても`/exit`は実行する(結果は削除しない場合の確認画面と同じ)。
41// 一覧の拒否(`deny`)とエラー(`isError`)は`result`に一覧を持たないため、成功した一覧だけを走査する。
42async function deleteSessionCrons($: EngineInterface): Promise<void> {
43 try {
44 if ((await $.tool.check({ tool: "CronList", input: {} })).decision !== "allow") return;
45 const listed = await $.tool.call({ tool: "CronList" });
46 if (listed.deny !== undefined || listed.isError === true) return;
47 for (const job of listed.result.jobs) {
48 if (job.durable === true) continue;
49 if ((await $.tool.check({ tool: "CronDelete", input: { id: job.id } })).decision !== "allow") continue;
50 await $.tool.call({ tool: "CronDelete", id: job.id });
51 }
52 } catch {
53 // 削除を諦めて終了要求の処理を続ける。
54 }
55}
56
57export function register(on: On): void {
58 on("turn.complete", async ($, e, next) => {
59 const result = await next(e);
60 if (e.agentId !== undefined) return result;
61 const request = await statePath($, await $.session.id(), "request");
62 if (!request) return result;
63 let content: string;
64 try {
65 content = await $.fs.read(request);
66 } catch (error) {
67 if (isMissing(error)) return result;
68 throw error;
69 }
70 if (content !== "requested") return result;
71 await $.fs.write(request, "consumed");
72 await deleteSessionCrons($);
73 await $.command.run({ command: "exit" });
74 return result;
75 });
76}
77