SLOPSHOPPER

idle-compact

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…

newtimer
★ 4v0.1.0no licenseupdated 2026-10-09ukwhatn/.claude/skills/idle-compact
A shopper browsing a rack in a slop shop
README

Agent User Settings(Claude Code / Codex 共用)

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・モデル

context/ の役割

@import で常駐するもの(AGENTS.md から自動ロード):

ファイル内容
tool-claude-code.mdClaude Code 固有指示(委譲判断・委譲後の実務)
workflow-rules.mdPhase 0-5 の詳細
memory-file-formats.mdメモリファイルの形式
figma-verification.mdFigma を一次ソースとする UI 検証
cloudflare-development.mdwrangler / Workers / D1 の実測知見

必要時に Read するもの(常駐させない):

ファイルRead するタイミング
herdr-delegation.md作業の委譲を検討した時点(経路・モデル・指示書・結果の回収)
code-review-checklist.mdPR 提出前・レビュー実行前
agent-cli-guide.md外部 CLI(codex / cursor)でレビューする前
worktree-guide.mdworktree の作成・片付け前
claude-customization-guide.md指示ファイル・skills・hooks の設計・監査時
tool-codex.mdCodex として動作している場合(最初の作業前)

skills/

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 CLI との共有

  • グローバル指示: ~/.codex/AGENTS.md → ~/.claude/AGENTS.md の symlink。Codex 固有の読み替え(@参照の解決・ツール対応表)は context/tool-codex.md に集約
  • skills: ~/.codex/skills/<name> → ~/.claude/skills/<name> のスキル単位 symlink。Claude Code 固有機構に依存するスキルは本文の「環境要件」節に代替手順を記載
  • PJ CLAUDE.md の直読み: ~/.codex/config.toml に project_doc_fallback_filenames = ["CLAUDE.md"] を設定
  • 記述規約: スキル・context 本文はツール中立の語彙で書き、ツール固有機能は「(Claude Code: X、Codex: Y)」の括弧書きで併記する

メモリディレクトリ

各タスクの作業ログを .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

Source 3 files
hooks/register.ts 263 lines
1import 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}
263
hooks/compact-instructions.ts 88 lines
1/**
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}
88
contract.d.ts 15 lines
1/**
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