Compacts the main conversation once it has sat idle for idleMinutes, while the prompt cache is still warm, and hands every compaction of the main conversation…

user-level 設定ファイル集。プロジェクト横断で使うワークフロー・スキル・グローバル指示を定義し、Claude Code と OpenAI Codex CLI の両方から利用する。
git clone <this-repo> ~/.claude
~/.claude/
├── AGENTS.md # グローバル指示の実体(両ツール共通)
├── CLAUDE.md # AGENTS.md への互換 symlink
├── CLAUDE.local.md # マシン固有設定(git管理外)
├── output-styles/ # 応答形式の真実源(Claude Code: system prompt 末尾に追記される)
├── context/ # エージェント向け詳細ガイド
├── skills/ # 自動トリガースキル(Agent Skills 形式・両ツール共用)
├── hooks/ # SessionStart 等のフックスクリプト
├── templates/project/ # プロジェクト初期化テンプレート
├── vendor/ # 取り込んだ外部 skill の台帳と、取り込み時の改変パッチ
├── .github/workflows/ # 取り込んだ外部 skill の上流更新を週次で検出する
└── settings.json # 権限・環境変数・hooks・モデル
@import で常駐するもの(AGENTS.md から自動ロード):
| ファイル | 内容 |
|---|---|
tool-claude-code.md | Claude Code 固有指示(委譲判断・委譲後の実務) |
workflow-rules.md | Phase 0-5 の詳細 |
memory-file-formats.md | メモリファイルの形式 |
figma-verification.md | Figma を一次ソースとする UI 検証 |
cloudflare-development.md | wrangler / Workers / D1 の実測知見 |
必要時に Read するもの(常駐させない):
| ファイル | Read するタイミング |
|---|---|
herdr-delegation.md | 作業の委譲を検討した時点(経路・モデル・指示書・結果の回収) |
code-review-checklist.md | PR 提出前・レビュー実行前 |
agent-cli-guide.md | 外部 CLI(codex / cursor)でレビューする前 |
worktree-guide.md | worktree の作成・片付け前 |
claude-customization-guide.md | 指示ファイル・skills・hooks の設計・監査時 |
tool-codex.md | Codex として動作している場合(最初の作業前) |
29 スキル。一覧と発動条件は各 SKILL.md の frontmatter を参照(Claude Code では /help で確認できる)。
主要なもの: commit / create-draft-pr(コミット・PR)、writing-code(実装原則)、systematic-debugging(根本原因調査)、self-review / pr-review / codebase-review(レビュー)、design-feature(要件定義)、update-inst / instructions-audit(本リポジトリ自体の保守)、designing-ui / writing-ui-text(画面の型の選択と文言)。
skills/yomiyasu/ と skills/shadcn/ は外部リポジトリから取り込んだもの。出典・改変・更新手順は NOTICE.md、台帳は vendor/manifest.json。上流の更新は bin/vendor-check.py が検出し、GitHub Actions が週次で実行して issue を立てる。
skills/idle-compact/ はスキルではなく、function hooks で書いた Claude Code のプラグインで、idle-compact@skills-dir として自動で読み込まれる。役割は 2 つある。メイン会話を最後の応答から idleMinutes(既定 50 分)放置すると、プロンプトキャッシュが切れる前に compact する。また、どの compact にも AGENTS.md の「Compact Instructions」節を渡す。有効化には settings.json の env にある CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 が要る。オプションは pluginConfigs["idle-compact@skills-dir"].options で変える。テストは CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude plugin test skills/idle-compact、型チェックはセッションで /plugin-types skills/idle-compact/types を実行してから tsc -p skills/idle-compact/tsconfig.json で行う(types/ は git 管理外)。
Phase 0-5(準備 → 調査 → 計画 → 実装 → 品質確認 → 完了報告)。適用条件と各 Phase の内容は AGENTS.md「作業フロー」および context/workflow-rules.md を参照。
~/.codex/AGENTS.md → ~/.claude/AGENTS.md の symlink。Codex 固有の読み替え(@参照の解決・ツール対応表)は context/tool-codex.md に集約~/.codex/skills/<name> → ~/.claude/skills/<name> のスキル単位 symlink。Claude Code 固有機構に依存するスキルは本文の「環境要件」節に代替手順を記載~/.codex/config.toml に project_doc_fallback_filenames = ["CLAUDE.md"] を設定各タスクの作業ログを .local/memory/YYMMDD_<context_name>/ に保存する(global gitignore で除外済み)。形式は context/memory-file-formats.md を参照。
.local/
├── memory/YYMMDD_<context_name>/ # 05_log.md(必須)、30_plan.md 等
└── issues/ # codebase-review が生成
新規プロジェクトでは 組み込みの /init を実行するか、templates/project/CLAUDE.md をコピーする。
MIT
hooks/register.ts 263 lines1import type { EngineInterface, On, PluginOptions, Timer } from 'claude-code'
2
3import { compactInstructionsOf, joinedInstructions } from './compact-instructions.js'
4
5const MINUTE_MS = 60_000
6
7const lastAnswer = { plugin: 'idle-compact', key: 'lastAnswer' } as const
8
9type Idle = {
10 timer: Timer | undefined
11}
12
13type Settings = {
14 idleMs: number
15 cutoffMs: number
16 minMessageTokens: number
17 instructionsPath: string
18}
19
20/**
21 * Registers the idle compaction of the main conversation and the Compact
22 * Instructions every compaction of it is handed.
23 *
24 * One timer per answer: a turn start cancels it, the next answer re-arms it,
25 * so an idle stretch compacts at most once. The answer's time lives in
26 * `$.state`, so a reload of this module re-arms the timer for what is left.
27 *
28 * @param on the engine's registrar
29 * @param options `idleMinutes`, `cutoffMinutes`, `minMessageTokens`,
30 * `instructionsPath` as the manifest declares them
31 */
32export function register(on: On, options: PluginOptions): void {
33 const settings = settingsOf(options)
34 const idle: Idle = { timer: undefined }
35
36 on('session.start', async ($, e, next) => {
37 const result = await next(e)
38 const { value: answeredAt } = await $.state.get(lastAnswer)
39
40 if (typeof answeredAt === 'number') {
41 const idleFor = (await $.clock.now()) - answeredAt
42 armIdle($, idle, settings, Math.max(0, settings.idleMs - idleFor), answeredAt)
43 }
44
45 return result
46 })
47
48 on('turn.start', async ($, e, next) => {
49 idle.timer?.cancel()
50 idle.timer = undefined
51 await $.state.set(lastAnswer, null)
52
53 return next(e)
54 })
55
56 on('turn.complete', async ($, e, next) => {
57 const result = await next(e)
58
59 // A turn with no response (an API error, an interrupt before one) left
60 // the cache where the last response did.
61 if (e.agentId !== undefined || e.usage === undefined) {
62 return result
63 }
64
65 const answeredAt = await $.clock.now()
66 await $.state.set(lastAnswer, answeredAt)
67 armIdle($, idle, settings, settings.idleMs, answeredAt)
68
69 return result
70 })
71
72 on('session.compact', async ($, e, next) => {
73 if (e.agentId !== undefined) {
74 return next(e)
75 }
76
77 const section = await sectionOf($, settings.instructionsPath)
78 const result = await next(
79 section === undefined
80 ? e
81 : { ...e, instructions: joinedInstructions(section, e.instructions) },
82 )
83
84 // A precompute replaces nothing, so the idle stretch still stands.
85 if (e.trigger !== 'precompute' && result.messages !== undefined) {
86 idle.timer?.cancel()
87 idle.timer = undefined
88 await $.state.set(lastAnswer, null)
89 }
90
91 return result
92 })
93}
94
95/**
96 * Replaces the pending idle timer with one that tries the stretch of the
97 * answer at `answeredAt` after `delayMs`.
98 */
99function armIdle(
100 $: EngineInterface,
101 idle: Idle,
102 settings: Settings,
103 delayMs: number,
104 answeredAt: number,
105): void {
106 idle.timer?.cancel()
107 idle.timer = $.clock.after(delayMs, () => {
108 idle.timer = undefined
109 void compactIfIdle($, settings, answeredAt).catch((error: unknown) => {
110 $.ui.log(`idle compaction failed: ${String(error)}`)
111 })
112 })
113}
114
115/**
116 * Compacts the main conversation when it is still idle: no turn running
117 * since `answeredAt`, the timer not fired past the cutoff, the conversation
118 * over the minimum. Says why when it does not.
119 */
120async function compactIfIdle(
121 $: EngineInterface,
122 settings: Settings,
123 answeredAt: number,
124): Promise<void> {
125 const held = await $.state.get(lastAnswer)
126
127 if (held.value !== answeredAt) {
128 if (held.value === null) {
129 $.ui.log('idle compaction skipped: a turn is running')
130 }
131
132 return
133 }
134
135 // Claimed before the usage read and the compaction: a reload while they run
136 // finds null and re-arms nothing.
137 const claim = await $.state.set(lastAnswer, null, { ifVersion: held.version })
138
139 if (!claim.isSet) {
140 return
141 }
142
143 const idleFor = (await $.clock.now()) - answeredAt
144 const idleMinutes = Math.round(idleFor / MINUTE_MS)
145
146 if (idleFor > settings.cutoffMs) {
147 $.ui.log(
148 `idle compaction skipped: ${idleMinutes} minutes since the last answer, past cutoffMinutes (${settings.cutoffMs / MINUTE_MS})`,
149 )
150
151 return
152 }
153
154 const messageTokens = await messageTokensOf($)
155
156 if (messageTokens === undefined) {
157 $.ui.log('idle compaction skipped: the context breakdown has no Messages row')
158
159 return
160 }
161
162 if (messageTokens < settings.minMessageTokens) {
163 $.ui.log(
164 `idle compaction skipped: ${messageTokens} message tokens, under minMessageTokens (${settings.minMessageTokens})`,
165 )
166
167 return
168 }
169
170 // Our own call skips our session.compact hook, so the section rides here.
171 const section = await sectionOf($, settings.instructionsPath)
172 const result = await $.session.compact(
173 section === undefined ? undefined : { instructions: section },
174 )
175
176 if (result.skip !== undefined) {
177 $.ui.log(`compaction skipped: ${result.skip}`)
178 }
179
180 if (result.messages !== undefined) {
181 const sizes =
182 result.tokensBefore !== undefined && result.tokensAfter !== undefined
183 ? ` (${result.tokensBefore} → ${result.tokensAfter} tokens)`
184 : ''
185 $.ui.log(`compacted after ${idleMinutes} idle minutes${sizes}`)
186 }
187}
188
189/**
190 * The options as numbers and a path; a missing or non-positive value takes
191 * the manifest's default.
192 */
193function settingsOf(options: PluginOptions): Settings {
194 const atLeast = (min: number) => (value: unknown, fallback: number): number =>
195 typeof value === 'number' && Number.isFinite(value) && value >= min
196 ? value
197 : fallback
198 const positive = atLeast(Number.MIN_VALUE)
199 const path = options.instructionsPath
200
201 return {
202 idleMs: positive(options.idleMinutes, 50) * MINUTE_MS,
203 cutoffMs: positive(options.cutoffMinutes, 56) * MINUTE_MS,
204 minMessageTokens: atLeast(0)(options.minMessageTokens, 30_000),
205 instructionsPath: typeof path === 'string' ? path.trim() : '',
206 }
207}
208
209/**
210 * The conversation's tokens as /context's Messages row estimates them: the
211 * part a compaction shrinks, without the system prompt, tools and memory
212 * files it leaves in place.
213 */
214async function messageTokensOf($: EngineInterface): Promise<number | undefined> {
215 const { context } = await $.session.usage({ breakdown: 'summary' })
216
217 // The row name is the only mark: no ContextCategoryKind tells messages apart.
218 return context.breakdown?.categories.find(
219 row => row.kind === 'used' && row.name === 'Messages',
220 )?.tokens
221}
222
223/**
224 * The Compact Instructions section of the configured file, or of the
225 * person's AGENTS.md; undefined when the file or the section is missing.
226 */
227async function sectionOf(
228 $: EngineInterface,
229 configured: string,
230): Promise<string | undefined> {
231 const path = configured !== '' ? configured : await defaultPathOf($)
232
233 if (path === undefined) {
234 return undefined
235 }
236
237 const text = await $.fs.read(path).catch(() => undefined)
238
239 if (typeof text !== 'string') {
240 $.ui.log(`compact instructions skipped: ${path} could not be read`)
241
242 return undefined
243 }
244
245 const section = compactInstructionsOf(text)
246
247 if (section === undefined) {
248 $.ui.log(`compact instructions skipped: ${path} has no Compact Instructions section`)
249 }
250
251 return section
252}
253
254async function defaultPathOf($: EngineInterface): Promise<string | undefined> {
255 const [configDir, home] = await Promise.all([
256 $.env.get('CLAUDE_CONFIG_DIR'),
257 $.env.get('HOME'),
258 ])
259 const dir = configDir ?? (home === undefined ? undefined : `${home}/.claude`)
260
261 return dir === undefined ? undefined : `${dir}/AGENTS.md`
262}
263hooks/compact-instructions.ts 88 lines1/**
2 * The heading the section is found under, any level, any case, indented up
3 * to three spaces as CommonMark allows.
4 */
5const HEADING = /^ {0,3}(#{1,6})[ \t]+compact instructions(?:[ \t]+#+)?[ \t]*$/i
6
7const ANY_HEADING = /^ {0,3}(#{1,6})(?:[ \t]|$)/
8
9const FENCE = /^ {0,3}(`{3,}|~{3,})/
10
11/**
12 * The body of the first "Compact Instructions" section of a Markdown text:
13 * the lines under its heading up to the next heading of the same or a higher
14 * level, trimmed.
15 *
16 * Headings inside fenced code blocks are not headings.
17 *
18 * @param markdown the file's text
19 * @returns the section's body, or undefined when there is none or it is empty
20 */
21export function compactInstructionsOf(markdown: string): string | undefined {
22 const lines = markdown.split(/\r?\n/)
23 let level: number | undefined
24 let fence: string | undefined
25 const body: string[] = []
26
27 for (const line of lines) {
28 const isFenced = fence !== undefined
29 fence = fenceAfter(fence, line)
30
31 if (level === undefined) {
32 const found = isFenced || fence !== undefined ? null : HEADING.exec(line)
33 level = found?.[1]?.length
34
35 continue
36 }
37
38 const heading = isFenced || fence !== undefined ? null : ANY_HEADING.exec(line)
39
40 if (heading?.[1] !== undefined && heading[1].length <= level) {
41 break
42 }
43
44 body.push(line)
45 }
46
47 const text = body.join('\n').trim()
48
49 return text === '' ? undefined : text
50}
51
52/**
53 * The fence open after `line`: a fence closes only on a bare run of its own
54 * character at least as long as the one that opened it.
55 */
56function fenceAfter(open: string | undefined, line: string): string | undefined {
57 const run = FENCE.exec(line)?.[1]
58
59 if (open === undefined) {
60 return run
61 }
62
63 const closes =
64 run !== undefined &&
65 run[0] === open[0] &&
66 run.length >= open.length &&
67 line.trim() === run
68
69 return closes ? undefined : open
70}
71
72/**
73 * What a compaction is told: the section first, then what the person typed
74 * after /compact, when anything.
75 *
76 * @param section the Compact Instructions section
77 * @param typed the instructions the compaction already carries
78 * @returns the joined instructions
79 */
80export function joinedInstructions(
81 section: string,
82 typed: string | undefined,
83): string {
84 const extra = typed?.trim()
85
86 return extra === undefined || extra === '' ? section : `${section}\n\n${extra}`
87}
88contract.d.ts 15 lines1/**
2 * When the main conversation last answered, in milliseconds since the epoch,
3 * while that idle stretch is still to be tried; null while a turn runs and
4 * once the stretch was tried.
5 */
6export type IdleCompactLastAnswer = number | null
7
8declare module 'claude-code' {
9 interface PluginState {
10 'idle-compact': {
11 lastAnswer: IdleCompactLastAnswer
12 }
13 }
14}
15