SLOPSHOPPER

archive-check

Claude Code Desktop の入力欄の上に、このセッションをアーカイブしてよいか(Done / Check / Busy)を札で出し、押すと右側の枠に理由と操作を並べる

newpanebandguardtoastprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · archive-check
│ ┃ archive-check ✕ › fix the failing auth test and add an audit log call │ ┃ Checking… │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · archive-check
Checking…
README

Archive Check for Claude Code

Claude Code Desktop(Code タブ)の入力欄の上に、そのセッションをアーカイブしてよいかを小さな札で出す mod(Claude Code のプラグイン)。札を押すと右側に枠が開き、理由と、その場でできる操作が並ぶ。

An unofficial Claude Code mod that shows, above the prompt in Claude Code Desktop, whether the session is safe to archive: Done, Check or Busy. Press the badge to open a pane on the right with the reasons and an action for each (mark a follow-up done, ask Claude to commit, push, schedule or start a suggested task, answer a waiting wrap-up question, ask Claude to tidy up the artifacts this session created). It also keeps "follow-ups" — checks that can only be done later — that Claude writes down, so you notice them before archiving.

Anthropic の公式のものではありません。

札

札(en / ja)意味何で決まるか
Busy / 作アーカイブすると作業を失う未コミットの変更 / 今のブランチ(HEAD)の、remote に無いコミット(HEAD がマージ済みの PR の head と同じなら除く)/ 未起動の提案カード(期限切れは除く)/ 動いている背景タスク(run_in_background の Bash・PowerShell と Monitor。終わりの知らせが届くか、TaskStop で止めるまで)/ 動いているエージェント
Check / 注アーカイブしてよいが、先に確かめること後続 / このセッションで作った Artifact(片付けを頼むか残すまで)/ 開いた PR の CI が終わっていない / 返事待ち(最後の wrap_up の質問・AskUserQuestion・「新しいセッションか、ここでやるか」)/ タスク一覧の残り / 開いた PR / 材料が読めなかった
Done / 済どれも無い
  • 材料(Desktop の記録・git・PR の状態・会話・設定 guide の Markdown)が読めなかったとき、判定が途中で失敗したときは Done にせず Check にして、枠に何が読めなかったかを出す。git の管理外のフォルダは「失うものが無い」として扱う
  • 1 分ごとと、ツールの呼び出し・ターンの終わり・背景タスクの終わりの知らせが届いたときに判定し直す
  • 背景タスクの終わりの知らせは、Claude が作業している最中に届いても拾う(その知らせは mod が読める会話の行 $.session.messages() には出ず、届いた時点でしか拾えない)

右側の枠

札を押すと開き、もう一度押すと閉じる。「アーカイブすると失うもの」「アーカイブする前に確かめること」に分けて並べ、行ごとに操作を付ける。

行操作
後続Mark done(mod だけで消す)/ Schedule(予定タスクにするよう Claude に頼む)
作った ArtifactAsk to tidy up(要るかを調べて要らないものを消すよう Claude に頼む)/ Keep(mod だけで枠から外す)/ 1 件なら Open
返事待ちの wrap_up の質問質問の選択肢と同じ返事を送る(commit などアプリの操作は、同じことを Claude に頼む)
未起動の提案カードAsk to start(起動を Claude に頼む)
未コミットの変更 / remote に無いコミットAsk to commit / Ask to push
開いた PROpen(GitHub の PR を開く)
  • 「頼む」ボタンは、その依頼文を自分で入力したのと同じ形でこのセッションに送る($.prompt.submit)。Claude が動いている間に押すと、そのターンが終わってから届く
  • 枠に出すのはこのセッションのものだけ。他のセッションが書き出した後続は出さない

後続

「今やる作業は無いが、時間が経たないと結果が分からない確認」(3 日後に反応を見て値下げを決める / 数日使ってから効いたか確かめる など)。mod が登録するツール mcp__archive-check__followup で Claude が書き出し(add / done / list)、いつ書き出すかの指示を mod がシステムプロンプトに 1 節足す。後続はセッションごとに mod の store に置き、CLI が止まって開き直しても残る。

作った Artifact

使い終わった claude.ai の Artifact(前後の比較・確認用のページなど)が、要るのか要らないのか分からないまま残り続けるのを、畳む前に拾うための行。 このセッションの Artifact ツールの公開(type_url から作ったものを含む)を会話の記録から拾う。前の会話の Artifact の更新(url を渡した公開)と 資産の上げ下ろしは数えず、このセッションで消したものは外す。読むのはメインの会話の新しい 4096 件までで、エージェントが公開したものは数えない。

  • 要るかどうかの判断と削除は Claude と本人に任せ、mod は消さない(消すのは Claude の Artifact ツールの delete)。Ask to tidy up は、作った Artifact の URL を並べて「artifact-sweep で調べて、要らないものを消して」と頼む。artifact-sweep は作者の手元の skill で、このリポジトリには入っていない。 無ければ、それぞれがどこから張られているかを調べ、消す前に聞くよう頼む文になっている
  • 頼んだ分(依頼が入った分)と Keep を押した分は、このセッションの枠に出さなくなる。あとで同じセッションで作った分は、また出る

設定

項目既定中身
languageenen は英語(Done / Check / Busy)、ja は日本語(済 / 注 / 作)。画面の文言・ボタンが送る依頼文・Claude への既定の指示が切り替わる
guide空Claude に渡す「後続をいつ・どう書き出すか」の指示を、この Markdown ファイルの中身に差し替える(~ はホーム)。自分の決まり(予定タスクの作り方など)を書く場所。読めないとき・中身が空のときは既定の指示を使い、枠に「読めなかった」を出す(その間、札は Check になる)
claude plugin install archive-check --marketplace tanmen/claude-tanmen-plugins --config language=ja --config guide=~/archive-check.md

入れたあとに変える(名前は archive-check@tanmen の形で渡す。変えたあとは Claude Code を起動し直すと効く):

echo '{"language":"ja"}' | claude plugin configure archive-check@tanmen --values-stdin

読むもの・書くもの

  • 読むだけ:
  • Desktop のセッションの記録(Windows は %APPDATA%\Claude\claude-code-sessions\…\<セッション>.json、macOS は ~/Library/Application Support/Claude/claude-code-sessions/…)。このセッションの分だけ。アプリの内部のファイルで、形が変わると読めなくなる
  • ~/.claude/sessions/ の各 json(使うのはこの CLI の起動時刻だけ)
  • 作業フォルダの git rev-parse / git status / git rev-list、このセッションの会話(ツールの呼び出しと結果を含む)とエージェントの一覧、設定 guide の Markdown
  • このセッションに入ってくるプロンプトのうち、prompt.submit の origin が task-notification のものの本文(背景タスクやエージェントの終わりのほか、Artifact のコメントや check-in など、Claude Code が差し込む知らせも同じ origin で届く)。拾うのは <task-notification> の中の task-id と status だけで、本文は残さない。ほかの origin のプロンプトは読まず、どれも止めも書き換えもしない
  • GitHub に問い合わせる: PR の状態を gh pr view で引く。このセッションの記録に載った開いている PR ごとに、同じ PR は 3 分に 1 回まで(判定は 1 分ごととツールの呼び出しのたび)。今のブランチに remote に無いコミットがあるときは、マージ済みの PR も引く。資格情報は gh が持ったままで、mod には渡らない
  • 書く: mod 自身の store(後続、枠に出さなくなった Artifact の id、このセッションで最後に「頼む」ボタンを押した結果。依頼文の本文は残さない)だけ。アプリの記録やリポジトリには書かない

必要なもの

  • Claude Code Desktop の Code タブ(手元で動くセッション)。ターミナルの Claude Code にも読み込まれ、札は出さないが、後続のツールとシステムプロンプトの 1 節は入り、判定(git など)も回る。ターミナルで書き出した後続は Desktop の枠には出ない(そのセッションで list を呼ぶと読める)
  • PR を見るには gh(ログイン済み)
  • Claude Code 2.1.295(Windows 版 Desktop に同梱のもの)で確かめた。背景タスクの知らせの届き方は 2.1.299 で確かめた。macOS の記録の場所は確かめていない。mod の API(function hooks)は early access で、版が上がると動かなくなることがある

usage-bars と並べる

usage-bars と一緒に入れると、札がバーの左に並ぶ(usage-bars 0.4.1 以降。どちらが先に読み込まれても同じ並びになる)。usage-bars のバーを押して詳細を開いている間だけは、読み込みの順で札がバーの横か詳細の横に付く。

外すとき

claude plugin uninstall archive-check@tanmen

開発

claude plugin validate .
claude plugin test .

作業中の mod は claude --plugin-dir . で読み込める。型チェックは usage-bars の README の「開発」と同じ(一度 --plugin-dir で起動すると .claude-plugin/types/ が置かれ、tsc -p . が通る)。

License

MIT

Source 5 files
hooks/register.tsx 643 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { BackgroundTask, FollowUp, Reason, ReasonAction, Report, Verdict } from '../types'
5import {
6  ARTIFACT_PREFIX,
7  artifactReasons,
8  askReasons,
9  cardReasons,
10  endedInNotification,
11  endedTaskIds,
12  expandHome,
13  FOLLOWUP_PREFIX,
14  followupReasons,
15  isFollowUp,
16  parsePrStatus,
17  pendingAsks,
18  prHref,
19  publishedArtifacts,
20  recordPrs,
21  replayPlan,
22  sortReasons,
23  verdictOf,
24} from './logic'
25import type { MessageLite, PrStatus, RecordPr, SessionRecord } from './logic'
26import { langOf, MESSAGES } from './messages'
27import type { Messages } from './messages'
28
29const report = atom({ plugin: 'archive-check', key: 'report' } as const, null)
30const tasks = atom({ plugin: 'archive-check', key: 'tasks' } as const, [])
31const ended = atom({ plugin: 'archive-check', key: 'ended' } as const, [])
32
33const PANE = 'archive-check'
34// hook の matcher は、このファイルの中の文字列でないと validate が読めない(logic.ts の同名の値と同じ)
35const FOLLOWUP_TOOL = 'mcp__archive-check__followup'
36
37// 設定(register が読み込むたびに入れ直す)
38let m: Messages = MESSAGES.en
39let guideSetting = ''
40
41// SVG は画像として描かれ、アプリのテーマに追従しない。usage-bars と同じ、Desktop のダークテーマに合わせた色
42const TRACK = '#2C2C2A'
43const TONES: Record<Verdict, { fill: string; text: string }> = {
44  ok: { fill: '#1D9E75', text: '#5DCAA5' },
45  caution: { fill: '#BA7517', text: '#EF9F27' },
46  blocked: { fill: '#E24B4A', text: '#F09595' },
47}
48const FONT = "'Segoe UI', 'Yu Gothic UI', 'Hiragino Sans', system-ui, sans-serif"
49const HEIGHT = 19
50
51// PR の状態(gh)はこれより新しければ読み直さない
52const PR_CACHE_MS = 3 * 60_000
53// 枠のボタンの依頼が入ったかを待つ長さ(hook の持ち時間 10 秒より短く)
54const SUBMIT_WAIT_MS = 8_000
55
56function badgeSvg(verdict: Verdict): string {
57  const tone = TONES[verdict]
58  return [
59    `<svg xmlns="http://www.w3.org/2000/svg" width="${m.badgeWidth}" height="${HEIGHT}">`,
60    `<rect width="100%" height="${HEIGHT}" rx="6" fill="${TRACK}"/>`,
61    `<rect width="100%" height="${HEIGHT}" rx="6" fill="${tone.fill}" fill-opacity="0.22"/>`,
62    `<text x="50%" y="14" text-anchor="middle" font-family="${FONT}" font-size="12" font-weight="600" fill="${tone.text}">${m.badge[verdict]}</text>`,
63    `</svg>`,
64  ].join('')
65}
66
67function unread(what: string): Reason {
68  return { level: 'caution', kind: 'unread', text: m.unread(what) }
69}
70
71function asString(value: unknown): string {
72  return typeof value === 'string' ? value.trim() : ''
73}
74
75// ---- 置き場(Windows は %APPDATA%、macOS は ~/Library/Application Support)
76
77type Places = { home: string; sep: string; recordRoot: string | null }
78
79async function places($: EngineInterface): Promise<Places> {
80  const appData = await $.env.get('APPDATA')
81  const home = (await $.env.get('USERPROFILE')) || (await $.env.get('HOME')) || ''
82  if (appData) return { home, sep: '\\', recordRoot: `${appData}\\Claude\\claude-code-sessions` }
83  return { home, sep: '/', recordRoot: home ? `${home}/Library/Application Support/Claude/claude-code-sessions` : null }
84}
85
86// ---- Claude への指示(設定 guide の Markdown があればそれ、無ければ既定の文)
87
88async function readGuide($: EngineInterface): Promise<string | null> {
89  if (guideSetting === '') return null
90  const { home } = await places($)
91  try {
92    const text = await $.fs.read(expandHome(guideSetting, home))
93    return text.trim() === '' ? null : text
94  } catch {
95    return null
96  }
97}
98
99// ---- 後続($.store にセッションごとに置く。CLI が止まって開き直しても残る。全セッション共通)
100
101async function loadFollowupsOf($: EngineInterface, cliId: string): Promise<FollowUp[]> {
102  const value = await $.store.get(`${FOLLOWUP_PREFIX}${cliId}`)
103  return Array.isArray(value) ? value.filter(isFollowUp) : []
104}
105
106async function saveFollowupsOf($: EngineInterface, cliId: string, list: FollowUp[]): Promise<void> {
107  const key = `${FOLLOWUP_PREFIX}${cliId}`
108  if (list.length === 0) await $.store.delete(key)
109  else await $.store.set(key, list)
110}
111
112async function removeFollowupOf($: EngineInterface, cliId: string, id: string): Promise<FollowUp | null> {
113  const list = await loadFollowupsOf($, cliId)
114  const found = list.find(f => f.id === id) ?? null
115  if (found !== null) await saveFollowupsOf($, cliId, list.filter(f => f.id !== id))
116  return found
117}
118
119// ---- このセッションで公開した Artifact のうち、もう枠に出さないもの(「残す」か「片付けを頼む」を押した)。id の並びを置く
120async function loadHandledArtifacts($: EngineInterface, cliId: string): Promise<string[]> {
121  const value = await $.store.get(`${ARTIFACT_PREFIX}${cliId}`)
122  return Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string') : []
123}
124
125async function markArtifactsHandled($: EngineInterface, cliId: string, ids: readonly string[]): Promise<void> {
126  const prev = await loadHandledArtifacts($, cliId)
127  await $.store.set(`${ARTIFACT_PREFIX}${cliId}`, [...new Set([...prev, ...ids])])
128}
129
130// 枠のボタンから Claude に頼む。届いたかを $.store の lastSubmit:<CLI のセッション id> に残す(道具の list で読める)。
131// 依頼文の本文は残さない(store は全セッション共通)。onSubmitted は依頼が入ったときだけ呼ぶ(Claude が動いている間に押すと、あとで呼ばれる)
132async function submitPrompt($: EngineInterface, action: ReasonAction, onSubmitted?: () => Promise<void>): Promise<void> {
133  const at = await $.clock.now()
134  const key = `lastSubmit:${await $.session.id()}`
135  const record = (result: string, detail: string) =>
136    $.store.set(key, { at, label: action.label, result, ...(detail ? { detail: detail.slice(0, 300) } : {}) }).catch(() => undefined)
137  try {
138    const submitting = $.prompt.submit({ text: action.prompt, asUser: true })
139    void submitting.then(
140      async done => {
141        if (done.drop !== undefined) return record('dropped', String(done.drop))
142        await record('submitted', '')
143        if (onSubmitted) await onSubmitted().catch(() => undefined)
144      },
145      err => record('error', String(err)),
146    )
147    const settled = await Promise.race([
148      submitting.then(done => ('drop' in done && done.drop !== undefined ? 'dropped' : 'submitted')),
149      $.clock.sleep(SUBMIT_WAIT_MS).then(() => 'waiting'),
150    ])
151    if (settled === 'waiting') await record('waiting', '')
152    $.ui.toast(settled === 'dropped' ? m.notSent(action.label) : m.asked(action.label))
153  } catch (err) {
154    await record('error', String(err))
155    $.ui.toast(m.notSent(action.label, String(err).slice(0, 120)))
156  }
157}
158
159async function handleFollowupTool($: EngineInterface, input: Record<string, unknown>): Promise<{ result: string }> {
160  const action = asString(input.action)
161  const cliId = await $.session.id()
162  const list = await loadFollowupsOf($, cliId)
163  if (action === 'add') {
164    const what = asString(input.what)
165    if (what === '') return { result: JSON.stringify({ error: 'what is empty' }) }
166    const now = await $.clock.now()
167    const dueAt = asString(input.due_at)
168    const followup: FollowUp = {
169      id: `f${now.toString(36)}${Math.floor(Math.random() * 1296).toString(36)}`,
170      what,
171      when: asString(input.when),
172      how: asString(input.how),
173      ...(dueAt ? { dueAt } : {}),
174      createdAt: now,
175    }
176    list.push(followup)
177    await saveFollowupsOf($, cliId, list)
178    evaluateSoon($, 0)
179    return { result: JSON.stringify({ added: followup, followups: list }) }
180  }
181  if (action === 'done') {
182    const id = asString(input.id)
183    const removed = await removeFollowupOf($, cliId, id)
184    evaluateSoon($, 0)
185    return { result: JSON.stringify(removed === null ? { error: `no follow-up with id ${id}`, followups: list } : { removed, followups: list.filter(f => f.id !== id) }) }
186  }
187  return { result: JSON.stringify({ followups: list, lastSubmit: await $.store.get(`lastSubmit:${cliId}`) }) }
188}
189
190// ---- この CLI のプロセスで起きた背景タスク
191
192async function trackTask($: EngineInterface, e: Record<string, unknown>, result: unknown): Promise<void> {
193  const tool = String(e.tool)
194  const record = (typeof result === 'object' && result !== null ? (result as { result?: unknown }).result : undefined) as
195    | Record<string, unknown>
196    | undefined
197  let task: BackgroundTask | null = null
198  const now = await $.clock.now()
199  if ((tool === 'Bash' || tool === 'PowerShell') && typeof record?.backgroundTaskId === 'string') {
200    const label = asString(e.description) || asString(e.command).slice(0, 80)
201    task = { id: record.backgroundTaskId, kind: 'shell', label, startedAt: now }
202  } else if (tool === 'Monitor' && typeof record?.taskId === 'string') {
203    task = { id: record.taskId, kind: 'monitor', label: asString(e.description), startedAt: now }
204  }
205  if (task !== null) {
206    const added = task
207    await update($, tasks, prev => [...(prev ?? []).filter(t => t.id !== added.id), added].slice(-50))
208  }
209}
210
211// 背景タスクの知らせ(<task-notification>)から、終わったタスクの id を残す。
212// ターン中に届いた知らせは会話の行にならず(添付 queued_command)、$.session.messages() からは読めない
213async function recordEnded($: EngineInterface, text: string): Promise<void> {
214  const ids = endedInNotification(text)
215  if (ids.length === 0) return
216  await update($, ended, prev => [...new Set([...(prev ?? []), ...ids])].slice(-200))
217  evaluateSoon($, 0)
218}
219
220// ---- Desktop の記録(アプリの状態ファイル。読むだけ)
221
222let recordPath: string | null = null
223
224async function findRecordPath($: EngineInterface): Promise<string | null> {
225  if (recordPath !== null) return recordPath
226  const hostId = await $.env.get('CLAUDE_CODE_HOST_SESSION_ID')
227  const { sep, recordRoot } = await places($)
228  if (!hostId || recordRoot === null) return null
229  for (const account of await $.fs.list(recordRoot)) {
230    if (account.kind !== 'dir') continue
231    for (const profile of await $.fs.list(`${recordRoot}${sep}${account.name}`)) {
232      if (profile.kind !== 'dir') continue
233      const path = `${recordRoot}${sep}${account.name}${sep}${profile.name}${sep}${hostId}.json`
234      if (await $.fs.exists(path)) {
235        recordPath = path
236        return path
237      }
238    }
239  }
240  return null
241}
242
243async function readRecord($: EngineInterface): Promise<SessionRecord | null> {
244  try {
245    const path = await findRecordPath($)
246    if (path === null) return null
247    const body: unknown = JSON.parse(await $.fs.read(path))
248    return typeof body === 'object' && body !== null ? (body as SessionRecord) : null
249  } catch {
250    return null
251  }
252}
253
254// この CLI が起きた時刻。~/.claude/sessions/<pid>.json の startedAt(背景タスクの記録から、前のプロセスの分を外す)
255let processStartedAt: number | null = null
256
257async function readProcessStartedAt($: EngineInterface, cliId: string): Promise<number | null> {
258  if (processStartedAt !== null) return processStartedAt
259  const { home, sep } = await places($)
260  if (home === '') return null
261  const dir = `${home}${sep}.claude${sep}sessions`
262  for (const entry of await $.fs.list(dir)) {
263    if (entry.kind !== 'file' || !entry.name.endsWith('.json')) continue
264    try {
265      const body = JSON.parse(await $.fs.read(`${dir}${sep}${entry.name}`)) as { sessionId?: unknown; startedAt?: unknown }
266      if (body.sessionId === cliId && typeof body.startedAt === 'number') {
267        processStartedAt = body.startedAt
268        return processStartedAt
269      }
270    } catch {
271      // 書きかけの記録は飛ばす
272    }
273  }
274  return null
275}
276
277// ---- PR(gh)
278
279const prCache = new Map<string, { at: number; status: PrStatus | null }>()
280
281async function prStatus($: EngineInterface, pr: RecordPr, now: number): Promise<PrStatus | null> {
282  const key = `${pr.repo}#${pr.number}`
283  const cached = prCache.get(key)
284  if (cached !== undefined && now - cached.at < PR_CACHE_MS) return cached.status
285  let status: PrStatus | null = null
286  try {
287    const run = await $.process.run(
288      ['gh', 'pr', 'view', String(pr.number), '--repo', pr.repo, '--json', 'state,headRefOid,statusCheckRollup'],
289      { timeoutMs: 20_000 },
290    )
291    status = run.exitCode === 0 ? parsePrStatus(run.stdout) : null
292  } catch {
293    status = null
294  }
295  prCache.set(key, { at: now, status })
296  return status
297}
298
299// ---- 作業ツリー(未コミット・remote に無いコミット)
300
301async function workTreeReasons(
302  $: EngineInterface,
303  cwd: string,
304  mergedHeads: () => Promise<string[] | null>,
305): Promise<Reason[]> {
306  // 失敗(時間切れ・起動できない)は null。どれも「読めなかった」にして、Done にしない
307  const git = (args: string[]) =>
308    $.process.run(['git', '-C', cwd, ...args], { env: { LC_ALL: 'C', LANG: 'C' } }).catch(() => null)
309
310  const top = await git(['rev-parse', '--show-toplevel'])
311  if (top === null) return [unread(m.unreadWhat.git)]
312  if (top.exitCode !== 0) {
313    // git の管理外のフォルダなら、失う変更もコミットも無い。それ以外の失敗(所有者の不一致など)は読めなかった扱い
314    return /not a git repository/i.test(top.stderr) ? [] : [unread(m.unreadWhat.git)]
315  }
316
317  const reasons: Reason[] = []
318  const status = await git(['status', '--porcelain'])
319  if (status === null || status.exitCode !== 0) {
320    reasons.push(unread(m.unreadWhat.dirty))
321  } else {
322    const dirty = status.stdout.split('\n').filter(line => line.trim() !== '').length
323    if (dirty > 0) {
324      reasons.push({ level: 'blocked', kind: 'dirty', text: m.dirty(dirty), actions: [{ label: m.commitLabel, prompt: m.commitPrompt }] })
325    }
326  }
327
328  const count = await git(['rev-list', '--count', 'HEAD', '--not', '--remotes'])
329  const unpushed = count !== null && count.exitCode === 0 ? Number.parseInt(count.stdout.trim(), 10) : Number.NaN
330  if (Number.isNaN(unpushed)) {
331    reasons.push(unread(m.unreadWhat.unpushed))
332  } else if (unpushed > 0) {
333    // squash マージ済みで、HEAD がマージ済みの PR の head と同じなら失うものは無い
334    const head = (await git(['rev-parse', 'HEAD']))?.stdout.trim() ?? ''
335    const heads = await mergedHeads()
336    if (heads === null) reasons.push(unread(m.unreadWhat.mergedPrs))
337    if (!(heads ?? []).includes(head)) {
338      reasons.push({ level: 'blocked', kind: 'unpushed', text: m.unpushed(unpushed), actions: [{ label: m.pushLabel, prompt: m.pushPrompt }] })
339    }
340  }
341  return reasons
342}
343
344// ---- 判定
345
346async function evaluate($: EngineInterface): Promise<Report> {
347  const now = await $.clock.now()
348  const cwd = await $.session.cwd()
349  const cliId = await $.session.id()
350  const reasons: Reason[] = []
351
352  const record = await readRecord($)
353  if (record === null) reasons.push(unread(m.unreadWhat.record))
354  const prs = record === null ? [] : recordPrs(record)
355  if (record !== null) reasons.push(...cardReasons(record, m, now))
356
357  // 開いている PR と CI。状態は gh で引き直し、読めなければ記録の値で判断する
358  let isPrUnread = false
359  for (const pr of prs.filter(p => p.state === 'OPEN')) {
360    const status = await prStatus($, pr, now)
361    if (status === null) isPrUnread = true
362    const state = status?.state || pr.state
363    if (state !== 'OPEN') continue
364    const name = `${pr.repo}#${pr.number}`
365    const href = prHref(pr.repo, pr.number)
366    reasons.push({ level: 'caution', kind: 'pr', text: m.openPr(name), href })
367    if (status?.isCiPending) reasons.push({ level: 'caution', kind: 'ci', text: m.ciPending(name), href })
368  }
369
370  const mergedHeads = async (): Promise<string[] | null> => {
371    const heads: string[] = []
372    for (const pr of prs.filter(p => p.state === 'MERGED')) {
373      const status = await prStatus($, pr, now)
374      if (status === null) return null
375      heads.push(status.headRefOid)
376    }
377    return heads
378  }
379  reasons.push(...(await workTreeReasons($, cwd, mergedHeads)))
380  if (isPrUnread) reasons.push(unread(m.unreadWhat.prState))
381
382  let messages: MessageLite[] = []
383  try {
384    messages = await $.session.messages()
385  } catch {
386    reasons.push(unread(m.unreadWhat.messages))
387  }
388
389  // 背景タスク: この CLI のプロセスで起きて、まだ終わりの知らせが来ていないもの。
390  // 終わりは届いた知らせ(ターン中のものを含む)・会話の行の知らせ・TaskStop から読む
391  const endedIds = endedTaskIds(messages)
392  for (const id of (await read($, ended)) ?? []) endedIds.add(id)
393  const startedAt = await readProcessStartedAt($, cliId).catch(() => null)
394  for (const task of (await read($, tasks)) ?? []) {
395    if (endedIds.has(task.id) || (startedAt !== null && task.startedAt < startedAt)) continue
396    reasons.push({ level: 'blocked', kind: 'task', text: m.backgroundTask(task.label || task.id) })
397  }
398  try {
399    for (const agent of await $.agent.list()) {
400      if (agent.status === 'running') {
401        reasons.push({ level: 'blocked', kind: 'task', text: m.agent(agent.description || agent.type) })
402      }
403    }
404  } catch {
405    reasons.push(unread(m.unreadWhat.agents))
406  }
407
408  reasons.push(...followupReasons(await loadFollowupsOf($, cliId), m))
409  reasons.push(...artifactReasons(publishedArtifacts(messages), await loadHandledArtifacts($, cliId), m))
410  reasons.push(...askReasons(pendingAsks(messages, m)))
411  const plan = replayPlan(messages)
412  if (plan.left > 0) reasons.push({ level: 'caution', kind: 'plan', text: m.planLeft(plan.left, plan.next) })
413  if (guideSetting !== '' && (await readGuide($)) === null) reasons.push(unread(m.unreadWhat.guide))
414
415  const sorted = sortReasons(reasons)
416  return { verdict: verdictOf(sorted), reasons: sorted, checkedAt: now }
417}
418
419let isEvaluating = false
420let isQueued = false
421let isScheduled = false
422
423async function runEvaluate($: EngineInterface): Promise<void> {
424  if (isEvaluating) {
425    isQueued = true
426    return
427  }
428  isEvaluating = true
429  try {
430    // 判定ごと落ちたら、前の札を残さず「読めなかった」の Check にする
431    const next = await evaluate($).catch(async (): Promise<Report> => ({
432      verdict: 'caution',
433      reasons: [unread(m.unreadWhat.evaluate)],
434      checkedAt: await $.clock.now(),
435    }))
436    const prev = await read($, report)
437    const same =
438      prev !== null && prev.verdict === next.verdict && JSON.stringify(prev.reasons) === JSON.stringify(next.reasons)
439    if (!same) await update($, report, () => next)
440  } finally {
441    isEvaluating = false
442    if (isQueued) {
443      isQueued = false
444      evaluateSoon($, 1_000)
445    }
446  }
447}
448
449function evaluateSoon($: EngineInterface, delayMs: number): void {
450  if (isScheduled) return
451  isScheduled = true
452  void $.clock.after(delayMs, () => {
453    isScheduled = false
454    void runEvaluate($).catch(() => undefined)
455  })
456}
457
458// 帯を押すたびに枠を開く / 閉じる
459async function togglePane($: EngineInterface): Promise<void> {
460  const pane = (await $.ui.panes()).find(p => p.id === PANE)
461  if (pane !== undefined && pane.isShown) {
462    await $.ui.close({ id: PANE })
463    return
464  }
465  evaluateSoon($, 0)
466  const opened = await $.ui.open({ id: PANE, title: m.paneTitle })
467  if (!opened.isPlaced) $.ui.toast(m.paneNotPlaced(opened.reason))
468}
469
470export const register: Register = (on, options) => {
471  m = MESSAGES[langOf(options.language)]
472  guideSetting = typeof options.guide === 'string' ? options.guide.trim() : ''
473
474  on('session.start', async ($, e, next) => {
475    await $.tool
476      .register({
477        name: 'followup',
478        description: m.toolDescription,
479        isDeferred: false,
480        inputSchema: {
481          type: 'object',
482          properties: {
483            action: { type: 'string', enum: ['add', 'done', 'list'] },
484            what: { type: 'string', description: m.toolFields.what },
485            when: { type: 'string', description: m.toolFields.when },
486            how: { type: 'string', description: m.toolFields.how },
487            due_at: { type: 'string', description: m.toolFields.dueAt },
488            id: { type: 'string', description: m.toolFields.id },
489          },
490          required: ['action'],
491        },
492      })
493      .catch(() => undefined)
494    evaluateSoon($, 0)
495    $.clock.every(60_000, () => evaluateSoon($, 0))
496    return next(e)
497  })
498
499  on('tool.call', { tool: FOLLOWUP_TOOL }, ($, e) => handleFollowupTool($, e as unknown as Record<string, unknown>))
500
501  on('tool.call', async ($, e, next) => {
502    const result = await next(e)
503    void trackTask($, e as unknown as Record<string, unknown>, result).catch(() => undefined)
504    evaluateSoon($, 2_000)
505    return result
506  })
507
508  on('turn.complete', ($, e, next) => {
509    evaluateSoon($, 500)
510    return next(e)
511  })
512
513  // 背景タスクの知らせ。idle のときに取り出されたものも、走っているターンに届いたもの(turnId あり)もここを通る。
514  // 知らせは止めも書き換えもしない(失敗しても素通しする)
515  on('prompt.submit', ($, e, next) => {
516    if (e.origin.kind === 'task-notification') void recordEnded($, e.text).catch(() => undefined)
517    return next(e)
518  }).catch(($, e, next) => next(e))
519
520  // 後続を書き出す基準を Claude に渡す。設定 guide の Markdown があればそれ、無ければ既定の文
521  on('prompt.compose', async ($, e, next) => {
522    const composed = await next(e)
523    if (!e.tools.includes(FOLLOWUP_TOOL)) return composed
524    const text = (await readGuide($)) ?? m.guide(FOLLOWUP_TOOL)
525    return { sections: [...composed.sections, { id: 'archive-check:followups', text, scope: 'session' as const }] }
526  })
527
528  // 帯に重ねた hit.ts が、押されたことを知らせてくる
529  on('ui.message', async ($, e, next) => {
530    const data = e.data as { source?: unknown } | null
531    if (typeof data !== 'object' || data === null || data.source !== 'archive-check') return next(e)
532    await togglePane($)
533    return next(e)
534  })
535
536  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
537    // ターミナルには描かない(Desktop の帯だけ)
538    if (e.surface !== 'desktop' || e.props.hasSurvey) return next(e)
539    const below = await next(e)
540    const current = await read($, report)
541    if (current === null) return below
542
543    const { Box, Client, Svg } = $.ui.resolve(e)
544
545    // 札。下の mod(usage-bars など)の木があれば、その左に並べる
546    const badge = (
547      <Box position="relative" flexShrink={0}>
548        <Svg source={badgeSvg(current.verdict)} alt={m.alt[current.verdict]} width={m.badgeWidth} height={HEIGHT} />
549        <Box position="absolute" top={0} left={0} right={0} bottom={0}>
550          <Client key="hit-archive-check" module="./hit.ts" props={{}} width="100%" height="100%" />
551        </Box>
552      </Box>
553    )
554    const hasBelow = (below as { type?: unknown }).type !== 'engine'
555    if (!hasBelow) return badge
556    return (
557      <Box flexDirection="row" gap={1}>
558        {badge}
559        <Box flexDirection="column" flexGrow={1} flexShrink={1}>
560          {below}
561        </Box>
562      </Box>
563    )
564  })
565
566  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
567    const { Box, Button, Link, Text } = $.ui.resolve(e)
568    const current = await read($, report)
569    const cliId = await $.session.id()
570
571    if (current === null) return <Text dimColor>{m.checking}</Text>
572
573    const tag = (r: Reason) => (r.kind === 'followup' || r.kind === 'ci' ? m.tagFollowup : r.kind === 'ask' ? m.tagAsk : null)
574    const row = (r: Reason, i: number) => (
575      <Box flexDirection="row" justifyContent="space-between" gap={2}>
576        <Box flexDirection="column" flexShrink={1}>
577          <Box flexDirection="row" gap={1}>
578            {tag(r) ? <Text dimColor>{tag(r)}</Text> : null}
579            <Text>{r.text}</Text>
580          </Box>
581          {r.note ? <Text dimColor>{r.note}</Text> : null}
582        </Box>
583        <Box flexDirection="row" gap={1} flexShrink={0}>
584          {r.href ? <Link href={r.href} label={m.open} /> : null}
585          {r.followupId ? (
586            <Button
587              key={`done:${r.followupId}`}
588              label={m.markDone}
589              onPress={async () => {
590                await removeFollowupOf($, cliId, r.followupId ?? '')
591                await runEvaluate($)
592              }}
593            />
594          ) : null}
595          {(r.actions ?? []).map((a, j) => (
596            <Button
597              key={`act:${i}:${j}`}
598              label={a.label}
599              onPress={() =>
600                // Artifact の片付けを頼んだら、その分は枠から外す(消すかどうかは Claude と本人が決める)
601                submitPrompt($, a, r.artifactIds ? () => markArtifactsHandled($, cliId, r.artifactIds ?? []).then(() => runEvaluate($)) : undefined)
602              }
603            />
604          ))}
605          {r.artifactIds ? (
606            <Button
607              key="keep:artifacts"
608              label={m.keepLabel}
609              onPress={async () => {
610                await markArtifactsHandled($, cliId, r.artifactIds ?? [])
611                await runEvaluate($)
612              }}
613            />
614          ) : null}
615        </Box>
616      </Box>
617    )
618
619    const blocked = current.reasons.filter(r => r.level === 'blocked')
620    const caution = current.reasons.filter(r => r.level === 'caution')
621    const section = (title: string, color: 'error' | 'warning', list: Reason[], offset: number) =>
622      list.length === 0 ? null : (
623        <Box flexDirection="column">
624          <Text bold color={color}>
625            {title}
626          </Text>
627          {list.map((r, i) => row(r, offset + i))}
628        </Box>
629      )
630
631    return (
632      <Box flexDirection="column" gap={1}>
633        <Text bold color={current.verdict === 'ok' ? 'success' : current.verdict === 'caution' ? 'warning' : 'error'}>
634          {m.verdict[current.verdict]}
635        </Text>
636        {current.reasons.length === 0 ? <Text dimColor>{m.nothingLeft}</Text> : null}
637        {section(m.sectionBlocked, 'error', blocked, 0)}
638        {section(m.sectionCaution, 'warning', caution, blocked.length)}
639      </Box>
640    )
641  })
642}
643
hooks/logic.ts 306 lines
1// $ を使わない判定。register.tsx が集めた材料から理由を組み立てる(テストはここを直接呼ぶ)
2import type { FollowUp, Reason, ReasonAction, Verdict } from '../types'
3import type { Messages } from './messages'
4
5export type ToolUseLite = { tool: string; input: Record<string, unknown>; text?: string; result?: unknown; isError?: true }
6export type MessageLite = { role: 'user' | 'assistant'; text: string; toolUses: readonly ToolUseLite[] }
7
8export const FOLLOWUP_TOOL = 'mcp__archive-check__followup'
9
10const UPDATE_PLAN = 'mcp__ccd_turn__update_plan'
11const WRAP_UP = 'mcp__ccd_turn__wrap_up'
12const START_SESSION = 'mcp__ccd_session__start_session'
13
14type Step = { content: string; status: string }
15
16// タスク一覧(update_plan)を頭から再生して、残りの手順を数える。wrap_up は進行中の手順を済ませる
17export function replayPlan(messages: readonly MessageLite[]): { left: number; next: string | null } {
18  let steps: Step[] = []
19  for (const m of messages) {
20    for (const u of m.toolUses) {
21      if (u.tool === UPDATE_PLAN) {
22        const input = u.input
23        if (Array.isArray(input.items)) {
24          steps = input.items.map(item => {
25            const i = (typeof item === 'object' && item !== null ? item : {}) as { content?: unknown; status?: unknown }
26            return { content: String(i.content ?? ''), status: String(i.status ?? 'pending') }
27          })
28        }
29        if (Array.isArray(input.done)) {
30          for (const n of input.done) {
31            const step = typeof n === 'number' ? steps[n - 1] : undefined
32            if (step) step.status = 'completed'
33          }
34        }
35        if (input.advance === true) {
36          const current = steps.find(s => s.status === 'in_progress')
37          if (current) current.status = 'completed'
38          const pending = steps.find(s => s.status === 'pending')
39          if (pending) pending.status = 'in_progress'
40        }
41        const start = typeof input.start === 'number' ? steps[input.start - 1] : undefined
42        if (start) start.status = 'in_progress'
43      } else if (u.tool === WRAP_UP) {
44        for (const s of steps) if (s.status === 'in_progress') s.status = 'completed'
45      }
46    }
47  }
48  const left = steps.filter(s => s.status !== 'completed')
49  return { left: left.length, next: left[0]?.content ?? null }
50}
51
52// wrap_up のボタンのうち、アプリの操作(commit など)は mod から押せないので、同じことを Claude に頼む
53function wrapActions(input: Record<string, unknown>, m: Messages): ReasonAction[] {
54  const actions: ReasonAction[] = []
55  for (const raw of Array.isArray(input.actions) ? input.actions : []) {
56    const a = (typeof raw === 'object' && raw !== null ? raw : {}) as { label?: unknown; message?: unknown; action?: unknown }
57    if (typeof a.action === 'string') {
58      const app = m.appActions[a.action]
59      if (app) actions.push(app)
60      continue
61    }
62    const label = typeof a.label === 'string' ? a.label.trim() : ''
63    const message = typeof a.message === 'string' && a.message.trim() !== '' ? a.message.trim() : label
64    if (label !== '') actions.push({ label, prompt: message })
65  }
66  return actions
67}
68
69export type PendingAsk = { text: string; actions: ReasonAction[] }
70
71// 本人の返事を待っているもの: 最後の wrap_up の ask(そのあと本人も Claude も何も書いていない)・
72// 答えを待っている AskUserQuestion・「新しいセッションか、ここでやるか」の offer
73export function pendingAsks(messages: readonly MessageLite[], m: Messages): PendingAsk[] {
74  const asks: PendingAsk[] = []
75  let lastWrap: { index: number; ask: string; actions: ReasonAction[] } | null = null
76  messages.forEach((msg, index) => {
77    for (const u of msg.toolUses) {
78      if (u.tool === WRAP_UP) {
79        lastWrap = { index, ask: typeof u.input.ask === 'string' ? u.input.ask.trim() : '', actions: wrapActions(u.input, m) }
80      } else if (u.tool === 'AskUserQuestion' && u.text === undefined) {
81        asks.push({ text: m.askQuestion, actions: [] })
82      } else if (u.tool === START_SESSION && u.input.initiation === 'offer' && u.text === undefined) {
83        asks.push({ text: m.askOffer(typeof u.input.title === 'string' ? u.input.title : ''), actions: [] })
84      }
85    }
86  })
87  const wrap = lastWrap as { index: number; ask: string; actions: ReasonAction[] } | null
88  if (wrap !== null && wrap.ask !== '') {
89    const isAnswered = messages
90      .slice(wrap.index + 1)
91      .some(msg => msg.role === 'assistant' || msg.text.trim() !== '')
92    if (!isAnswered) asks.unshift({ text: wrap.ask, actions: wrap.actions })
93  }
94  return asks
95}
96
97export function askReasons(asks: readonly PendingAsk[]): Reason[] {
98  return asks.map(a => ({
99    level: 'caution',
100    kind: 'ask',
101    text: a.text,
102    ...(a.actions.length > 0 ? { actions: a.actions } : {}),
103  }))
104}
105
106// 終わった背景タスクの id: <task-notification> の status と、TaskStop で止めたもの
107const RUNNING_STATUSES = new Set(['running', 'started', 'pending', 'event'])
108
109// 知らせの本文から、終わったと書かれた背景タスクの id を拾う
110export function endedInNotification(text: string): string[] {
111  if (!text.includes('<task-notification>')) return []
112  const ids: string[] = []
113  for (const block of text.matchAll(/<task-notification>([\s\S]*?)<\/task-notification>/g)) {
114    const body = block[1] ?? ''
115    const id = /<task-id>([^<]+)<\/task-id>/.exec(body)?.[1]?.trim()
116    const status = /<status>([^<]+)<\/status>/.exec(body)?.[1]?.trim().toLowerCase()
117    if (id && status && !RUNNING_STATUSES.has(status)) ids.push(id)
118  }
119  return ids
120}
121
122// 会話の行に残るのは、idle のときに届いた知らせだけ(ターン中に届いたものは添付になり、行に出ない)
123export function endedTaskIds(messages: readonly MessageLite[]): Set<string> {
124  const ended = new Set<string>()
125  for (const m of messages) {
126    if (m.role === 'user') for (const id of endedInNotification(m.text)) ended.add(id)
127    for (const u of m.toolUses) {
128      if (u.tool === 'TaskStop') {
129        const id = u.input.task_id ?? u.input.shell_id
130        if (typeof id === 'string') ended.add(id)
131      }
132    }
133  }
134  return ended
135}
136
137// Desktop の記録(<host session id>.json)のうち、判定に使う欄
138export type SessionRecord = {
139  backgroundTaskSuggestions?: { id?: unknown; title?: unknown; expiresAt?: unknown }[]
140  prs?: { prNumber?: unknown; repo?: unknown; state?: unknown }[]
141}
142
143export type RecordPr = { number: number; repo: string; state: string }
144
145export function recordPrs(record: SessionRecord): RecordPr[] {
146  const prs: RecordPr[] = []
147  for (const pr of record.prs ?? []) {
148    if (typeof pr.prNumber === 'number' && typeof pr.repo === 'string') {
149      prs.push({ number: pr.prNumber, repo: pr.repo, state: typeof pr.state === 'string' ? pr.state : '' })
150    }
151  }
152  return prs
153}
154
155export function prHref(repo: string, number: number): string {
156  return `https://github.com/${repo}/pull/${number}`
157}
158
159// 未起動の提案カード。期限(expiresAt)を過ぎたカードはもう起動できないので数えない
160export function cardReasons(record: SessionRecord, m: Messages, now: number): Reason[] {
161  const live = (record.backgroundTaskSuggestions ?? []).filter(card => typeof card.expiresAt !== 'number' || card.expiresAt > now)
162  return live.map(card => {
163    const id = typeof card.id === 'string' ? card.id : ''
164    const title = typeof card.title === 'string' ? card.title : id
165    return {
166      level: 'blocked',
167      kind: 'card',
168      text: m.card(title),
169      actions: [{ label: m.launchLabel, prompt: m.launchPrompt(title, id) }],
170    }
171  })
172}
173
174// gh pr view --json state,headRefOid,statusCheckRollup の応答
175export type PrStatus = { state: string; headRefOid: string; isCiPending: boolean }
176
177export function parsePrStatus(stdout: string): PrStatus | null {
178  let body: unknown
179  try {
180    body = JSON.parse(stdout)
181  } catch {
182    return null
183  }
184  if (typeof body !== 'object' || body === null) return null
185  const v = body as { state?: unknown; headRefOid?: unknown; statusCheckRollup?: unknown }
186  const checks = Array.isArray(v.statusCheckRollup) ? v.statusCheckRollup : []
187  const isCiPending = checks.some(c => {
188    const check = (typeof c === 'object' && c !== null ? c : {}) as { status?: unknown; state?: unknown; __typename?: unknown }
189    if (check.__typename === 'StatusContext' || (check.status === undefined && check.state !== undefined)) {
190      return check.state === 'PENDING' || check.state === 'EXPECTED'
191    }
192    return typeof check.status === 'string' && check.status !== 'COMPLETED'
193  })
194  return {
195    state: typeof v.state === 'string' ? v.state : '',
196    headRefOid: typeof v.headRefOid === 'string' ? v.headRefOid : '',
197    isCiPending,
198  }
199}
200
201export function followupReasons(followups: readonly FollowUp[], m: Messages): Reason[] {
202  return followups.map(f => ({
203    level: 'caution',
204    kind: 'followup',
205    text: m.followup(f.what, f.when),
206    ...(f.how ? { note: f.how } : {}),
207    followupId: f.id,
208    actions: [{ label: m.scheduleLabel, prompt: m.schedulePrompt(f, FOLLOWUP_TOOL) }],
209  }))
210}
211
212// このセッションで公開した Artifact。Artifact ツールの公開(action 無し / publish)の結果から拾う
213export type PublishedArtifact = { id: string; url: string; title: string }
214
215const ARTIFACT_URL = /https:\/\/claude\.ai\/(?:code\/)?artifact\/([A-Za-z0-9-]+)/
216
217function artifactIdOf(url: string): string | null {
218  return ARTIFACT_URL.exec(url)?.[1] ?? null
219}
220
221export function publishedArtifacts(messages: readonly MessageLite[]): PublishedArtifact[] {
222  const found = new Map<string, PublishedArtifact>()
223  const deleted = new Set<string>()
224  for (const msg of messages) {
225    for (const u of msg.toolUses) {
226      if (u.tool !== 'Artifact' || u.isError === true) continue
227      const action = u.input.action ?? 'publish'
228      if (action === 'delete') {
229        // 資産(asset)だけを消した呼び出しは Artifact そのものを消さない
230        const id = typeof u.input.url === 'string' && u.input.path === undefined ? artifactIdOf(u.input.url) : null
231        if (id !== null && u.text !== undefined) deleted.add(id)
232        continue
233      }
234      // 資産の上げ下ろしと、前の会話の Artifact の更新(url を渡した公開)は、このセッションで作ったものではない
235      if (action !== 'publish' || u.input.asset === true || u.input.url !== undefined) continue
236      // 結果の記録(url・title)を先に見て、無ければ本文の「Published … at https://claude.ai/artifact/…」から拾う
237      const result = (typeof u.result === 'object' && u.result !== null ? u.result : {}) as { url?: unknown; title?: unknown }
238      const url = typeof result.url === 'string' && artifactIdOf(result.url) !== null ? result.url : ARTIFACT_URL.exec(u.text ?? '')?.[0]
239      const id = url === undefined ? null : artifactIdOf(url)
240      if (url === undefined || id === null) continue
241      const title = typeof result.title === 'string' ? result.title : typeof u.input.title === 'string' ? u.input.title : ''
242      found.set(id, { id, url, title: title || found.get(id)?.title || '' })
243    }
244  }
245  return [...found.values()].filter(a => !deleted.has(a.id))
246}
247
248export function artifactReasons(artifacts: readonly PublishedArtifact[], handled: readonly string[], m: Messages): Reason[] {
249  const left = artifacts.filter(a => !handled.includes(a.id))
250  if (left.length === 0) return []
251  return [
252    {
253      level: 'caution',
254      kind: 'artifact',
255      text: m.artifacts(left.length),
256      note: m.artifactTitles(left.map(a => a.title || a.id)),
257      ...(left.length === 1 ? { href: left[0]?.url } : {}),
258      artifactIds: left.map(a => a.id),
259      actions: [{ label: m.sweepLabel, prompt: m.sweepPrompt(left) }],
260    },
261  ]
262}
263
264export function isFollowUp(value: unknown): value is FollowUp {
265  if (typeof value !== 'object' || value === null) return false
266  const v = value as Partial<FollowUp>
267  return typeof v.id === 'string' && typeof v.what === 'string'
268}
269
270export const FOLLOWUP_PREFIX = 'followups:'
271export const ARTIFACT_PREFIX = 'artifacts:'
272
273// 設定のパスの頭の ~ をホームに置き換える
274export function expandHome(path: string, home: string): string {
275  if (path === '~') return home
276  if (path.startsWith('~/') || path.startsWith('~\\')) return `${home}${path.slice(1)}`
277  return path
278}
279
280export function verdictOf(reasons: readonly Reason[]): Verdict {
281  if (reasons.some(r => r.level === 'blocked')) return 'blocked'
282  return reasons.length > 0 ? 'caution' : 'ok'
283}
284
285// 作を先に、注は後続・承認待ち・その他の順に並べる
286const ORDER: Record<Reason['kind'], number> = {
287  dirty: 0,
288  unpushed: 1,
289  card: 2,
290  task: 3,
291  followup: 4,
292  artifact: 5,
293  ci: 6,
294  ask: 7,
295  plan: 8,
296  pr: 9,
297  unread: 10,
298}
299
300export function sortReasons(reasons: readonly Reason[]): Reason[] {
301  return [...reasons].sort((a, b) => {
302    if (a.level !== b.level) return a.level === 'blocked' ? -1 : 1
303    return ORDER[a.kind] - ORDER[b.kind]
304  })
305}
306
hooks/messages.ts 271 lines
1// 画面の文言・ボタンが Claude に送る依頼・Claude への既定の指示。設定 language で英語(既定)と日本語を切り替える
2import type { Verdict } from '../types'
3
4export type Lang = 'en' | 'ja'
5
6export type Messages = {
7  // 帯の札の文字と幅(px)、枠の見出し、帯の alt
8  badge: Record<Verdict, string>
9  badgeWidth: number
10  verdict: Record<Verdict, string>
11  alt: Record<Verdict, string>
12  // 枠
13  paneTitle: string
14  checking: string
15  nothingLeft: string
16  sectionBlocked: string
17  sectionCaution: string
18  tagFollowup: string
19  tagAsk: string
20  open: string
21  markDone: string
22  // 理由
23  unread: (what: string) => string
24  unreadWhat: {
25    record: string
26    git: string
27    dirty: string
28    unpushed: string
29    mergedPrs: string
30    prState: string
31    messages: string
32    agents: string
33    guide: string
34    evaluate: string
35  }
36  dirty: (n: number) => string
37  unpushed: (n: number) => string
38  card: (title: string) => string
39  openPr: (pr: string) => string
40  ciPending: (pr: string) => string
41  backgroundTask: (label: string) => string
42  agent: (label: string) => string
43  planLeft: (n: number, next: string | null) => string
44  askQuestion: string
45  askOffer: (title: string) => string
46  followup: (what: string, when: string) => string
47  artifacts: (n: number) => string
48  artifactTitles: (titles: string[]) => string
49  // ボタンと、押すと Claude に送る依頼
50  launchLabel: string
51  launchPrompt: (title: string, id: string) => string
52  commitLabel: string
53  commitPrompt: string
54  pushLabel: string
55  pushPrompt: string
56  scheduleLabel: string
57  schedulePrompt: (f: { id: string; what: string; when: string; how: string; dueAt?: string }, tool: string) => string
58  sweepLabel: string
59  sweepPrompt: (artifacts: { url: string; title: string }[]) => string
60  keepLabel: string
61  appActions: Record<string, { label: string; prompt: string }>
62  // 知らせ
63  asked: (label: string) => string
64  notSent: (label: string, detail?: string) => string
65  paneNotPlaced: (reason: string) => string
66  // Claude へ
67  toolDescription: string
68  toolFields: { what: string; when: string; how: string; dueAt: string; id: string }
69  guide: (tool: string) => string
70}
71
72const en: Messages = {
73  badge: { ok: 'Done', caution: 'Check', blocked: 'Busy' },
74  badgeWidth: 46,
75  verdict: { ok: 'Done', caution: 'Check first', blocked: 'Busy' },
76  alt: {
77    ok: 'Done (safe to archive)',
78    caution: 'Check first (look before archiving)',
79    blocked: 'Busy (archiving loses work)',
80  },
81  paneTitle: 'Archive',
82  checking: 'Checking…',
83  nothingLeft: 'Nothing left',
84  sectionBlocked: 'Lost if you archive',
85  sectionCaution: 'Check before archiving',
86  tagFollowup: 'Follow-up',
87  tagAsk: 'Waiting on you',
88  open: 'Open',
89  markDone: 'Mark done',
90  unread: what => `Couldn't read: ${what}`,
91  unreadWhat: {
92    record: 'Desktop session record',
93    git: 'git',
94    dirty: 'uncommitted changes',
95    unpushed: 'commits not on a remote',
96    mergedPrs: 'merged PRs',
97    prState: 'PR state',
98    messages: 'conversation',
99    agents: 'agents',
100    guide: 'guide file',
101    evaluate: 'what the check needs',
102  },
103  dirty: n => `${n} uncommitted ${n === 1 ? 'change' : 'changes'}`,
104  unpushed: n => `${n} ${n === 1 ? 'commit' : 'commits'} not on a remote`,
105  card: title => `Unstarted suggested task “${title}”`,
106  openPr: pr => `Open PR ${pr}`,
107  ciPending: pr => `CI not finished on PR ${pr}`,
108  backgroundTask: label => `Running background task “${label}”`,
109  agent: label => `Running agent “${label}”`,
110  planLeft: (n, next) => `${n} task list ${n === 1 ? 'step' : 'steps'} left${next ? ` (next: ${next})` : ''}`,
111  askQuestion: 'A question to answer',
112  askOffer: title => `New session or here: “${title}”`,
113  followup: (what, when) => (when ? `${what} (${when})` : what),
114  artifacts: n => `${n} ${n === 1 ? 'artifact' : 'artifacts'} created`,
115  artifactTitles: titles => titles.map(t => `“${t}”`).join(', '),
116  launchLabel: 'Ask to start',
117  launchPrompt: (title, id) => `Start the suggested task “${title}”${id ? ` (${id})` : ''} (from the archive-check pane).`,
118  commitLabel: 'Ask to commit',
119  commitPrompt: "Commit this session's uncommitted changes (from the archive-check pane).",
120  pushLabel: 'Ask to push',
121  pushPrompt: 'Push the commits that are not on a remote (from the archive-check pane).',
122  scheduleLabel: 'Schedule',
123  schedulePrompt: (f, tool) =>
124    [
125      `Turn the follow-up “${f.what}” into a scheduled task (from the archive-check pane).`,
126      `- When: ${f.when || '(not given)'}${f.dueAt ? ` (${f.dueAt})` : ''}`,
127      `- How to check: ${f.how || '(not given)'}`,
128      'The scheduled task runs in a new session, so write the context it needs into its prompt. If the time is unclear, ask before creating it.',
129      `Once it exists, remove the follow-up with ${tool} (action: done, id: ${f.id}).`,
130    ].join('\n'),
131  sweepLabel: 'Ask to tidy up',
132  sweepPrompt: artifacts =>
133    [
134      `Check the ${artifacts.length} ${artifacts.length === 1 ? 'artifact' : 'artifacts'} created in this session with artifact-sweep and delete the ones no longer needed (from the archive-check pane).`,
135      ...artifacts.map(a => `- ${a.title ? `“${a.title}” ` : ''}${a.url}`),
136      "If you don't have artifact-sweep, check where each one is linked from (docs, memory, PR descriptions) and ask me before deleting any.",
137    ].join('\n'),
138  keepLabel: 'Keep',
139  appActions: {
140    commit: { label: 'Commit', prompt: 'Commit' },
141    create_pr: { label: 'Create PR', prompt: 'Create the PR' },
142    merge: { label: 'Merge', prompt: 'Merge the PR' },
143  },
144  asked: label => `Asked: ${label}`,
145  notSent: (label, detail) => `Couldn't send “${label}”${detail ? `: ${detail}` : ''}`,
146  paneNotPlaced: reason => `Couldn't open the archive pane: ${reason}`,
147  toolDescription: [
148    "Add, remove or list this session's follow-ups. A follow-up is a check with nothing to do now that can only be done later",
149    ' (e.g. look at the replies in 3 days and decide on a price cut / try it for a few days and see whether it helped / resume once a wait is over).',
150    ' Follow-ups show above the prompt in Claude Code Desktop, so the person sees them before archiving the session.',
151    ' action: add takes what, when (a time, or what has to happen first) and how (where to look, what decides it); due_at (ISO 8601 with offset) when the time is fixed.',
152    ' action: done takes the id and removes it (checked, no longer needed, or moved to a scheduled task). action: list lists them.',
153  ].join(''),
154  toolFields: {
155    what: 'What to check',
156    when: 'When, or what has to happen first',
157    how: 'How to check (where to look, what decides it)',
158    dueAt: 'ISO 8601 with offset, when the time is fixed',
159    id: 'The id of the follow-up to remove (action: done)',
160  },
161  guide: tool =>
162    [
163      '# Follow-ups (archive-check)',
164      `When this session is left with a check that has nothing to do now and can only be done later (e.g. look at the replies in a few days and decide on a price cut / use it for a while and see whether it helped / resume once a wait is over), add it with ${tool} (action: add) before ending the turn: what, when (or what has to happen first), and how to check.`,
165      "Don't add what only waits for the person's answer, the rest of this turn's work, or CI on an open PR (those are counted already). Remove follow-ups that are checked or no longer needed with action: done.",
166    ].join('\n'),
167}
168
169const ja: Messages = {
170  badge: { ok: '済', caution: '注', blocked: '作' },
171  badgeWidth: 24,
172  verdict: { ok: '作業済み', caution: '注意あり', blocked: '作業中' },
173  alt: {
174    ok: '作業済み(アーカイブしてよい)',
175    caution: '注意あり(アーカイブする前に確かめる)',
176    blocked: '作業中(アーカイブすると作業を失う)',
177  },
178  paneTitle: 'アーカイブ',
179  checking: '確かめています…',
180  nothingLeft: '残っているものはありません',
181  sectionBlocked: 'アーカイブすると失うもの',
182  sectionCaution: 'アーカイブする前に確かめること',
183  tagFollowup: '後続',
184  tagAsk: '承認待ち',
185  open: '開く',
186  markDone: '済みにする',
187  unread: what => `読めなかった: ${what}`,
188  unreadWhat: {
189    record: 'Desktop の記録',
190    git: 'git',
191    dirty: '未コミットの変更',
192    unpushed: 'remote に無いコミット',
193    mergedPrs: 'マージ済みの PR',
194    prState: 'PR の状態',
195    messages: '会話の記録',
196    agents: 'エージェント',
197    guide: '指示の Markdown',
198    evaluate: '判定の材料',
199  },
200  dirty: n => `未コミットの変更 ${n} 件`,
201  unpushed: n => `remote に無いコミット ${n} 本`,
202  card: title => `未起動の提案カード「${title}」`,
203  openPr: pr => `開いている PR ${pr}`,
204  ciPending: pr => `PR ${pr} の CI が終わっていない`,
205  backgroundTask: label => `動いている背景タスク「${label}」`,
206  agent: label => `動いているエージェント「${label}」`,
207  planLeft: (n, next) => `タスク一覧の残り ${n} 手順${next ? `(次: ${next})` : ''}`,
208  askQuestion: '質問への回答',
209  askOffer: title => `新しいセッションか、ここでやるか「${title}」`,
210  followup: (what, when) => (when ? `${what}(${when})` : what),
211  artifacts: n => `作った Artifact ${n} 件`,
212  artifactTitles: titles => titles.map(t => `「${t}」`).join(''),
213  launchLabel: '起動を頼む',
214  launchPrompt: (title, id) => `提案カード「${title}」${id ? `(${id})` : ''}を起動して(archive-check の枠から)。`,
215  commitLabel: 'コミットを頼む',
216  commitPrompt: 'このセッションの未コミットの変更をコミットして(archive-check の枠から)。',
217  pushLabel: 'push を頼む',
218  pushPrompt: 'remote に無いコミットを push して(archive-check の枠から)。',
219  scheduleLabel: '予定に移す',
220  schedulePrompt: (f, tool) =>
221    [
222      `後続「${f.what}」を予定タスクに移して(archive-check の枠から)。`,
223      `- いつ: ${f.when || '(未記入)'}${f.dueAt ? `(${f.dueAt})` : ''}`,
224      `- どう確かめる: ${f.how || '(未記入)'}`,
225      '予定は新しいセッションで動くので、prompt にはこのセッションの前提を書き込む。日時がはっきりしなければ作る前に聞く。',
226      `作れたら ${tool}(action: done, id: ${f.id})で後続から消す。`,
227    ].join('\n'),
228  sweepLabel: '片付けを頼む',
229  sweepPrompt: artifacts =>
230    [
231      `このセッションで作った Artifact ${artifacts.length} 件を artifact-sweep で調べて、要らないものを消して(archive-check の枠から)。`,
232      ...artifacts.map(a => `- ${a.title ? `「${a.title}」 ` : ''}${a.url}`),
233      'artifact-sweep が無ければ、それぞれがどこから張られているか(docs・memory・PR 本文)を調べ、消す前に聞いて。',
234    ].join('\n'),
235  keepLabel: '残す',
236  appActions: {
237    commit: { label: 'コミット', prompt: 'コミットして' },
238    create_pr: { label: 'PR を作る', prompt: 'PR を作って' },
239    merge: { label: 'マージ', prompt: 'PR をマージして' },
240  },
241  asked: label => `「${label}」を頼みました`,
242  notSent: (label, detail) => `「${label}」を送れませんでした${detail ? `: ${detail}` : ''}`,
243  paneNotPlaced: reason => `アーカイブの枠を開けませんでした: ${reason}`,
244  toolDescription: [
245    'このセッションの「後続」を書き出す・消す・一覧する。後続は、今やる作業は無いが時間が経たないと結果が分からない確認',
246    '(例: 3 日後に出品の反応を見て値下げを決める / 数日使ってから効いたか確かめる / 待ち時間が明けてから再開する)。',
247    '書き出した後続は Desktop の入力欄の上の帯に出て、本人がアーカイブする前に気づける。',
248    'action: add は what(何を)・when(いつ、または何が起きたら)・how(どう確かめる)を渡す。日時が決まっていれば due_at(ISO 8601、時差付き)も。',
249    'action: done は id を渡して消す(確かめ終えた・不要になった・予定タスクに移した)。action: list は一覧を返す。',
250  ].join(''),
251  toolFields: {
252    what: '何を確かめるか',
253    when: 'いつ、または何が起きたら確かめるか',
254    how: 'どう確かめるか(見る場所・判断の基準)',
255    dueAt: '日時が決まっていれば ISO 8601(時差付き)',
256    id: 'done で消す後続の id',
257  },
258  guide: tool =>
259    [
260      '# 後続(archive-check)',
261      `今やる作業は無いが時間が経たないと結果が分からない確認がこのセッションに残ったら(例: 数日後に出品の反応を見て値下げを決める / しばらく使ってから効いたか確かめる / 待ち時間が明けてから再開する)、ターンを終える前に ${tool} の add で、何を・いつ(または何が起きたら)・どう確かめるかを書き出す。`,
262      '本人の返事を待つだけのもの・このターンの続きの作業・開いた PR の CI 待ち(自動で数える)は後続にしない。確かめ終えた・不要になった後続は done で消す。',
263    ].join('\n'),
264}
265
266export const MESSAGES: Record<Lang, Messages> = { en, ja }
267
268export function langOf(value: unknown): Lang {
269  return value === 'ja' ? 'ja' : 'en'
270}
271
hooks/hit.ts 16 lines
1import type { ClientModule } from 'claude-code'
2
3// 状態の帯の上に透明に重ねる領域。何も描かず、押されたことだけを hooks に知らせる。
4// Svg は押せる要素で包めない(Button の中身は文字だけ)ので、押せる面を別に重ねている
5const Hit: ClientModule<Record<string, never>, true> = (_props, surface) => {
6  if (surface.state === undefined) {
7    surface.onPointer(event => {
8      if (event.type === 'up' && event.button === 'left') surface.post({ source: 'archive-check' })
9    })
10    surface.setState(true)
11  }
12  return surface.elements.Box({})
13}
14
15export default Hit
16
types/index.d.ts 70 lines
1// Busy(作) = 畳むと作業を失う(blocked) / Check(注) = 畳んでよいが確認を忘れずに(caution) / Done(済) = どれも無い(ok)
2export type Verdict = 'ok' | 'caution' | 'blocked'
3
4export type ReasonKind =
5  | 'dirty'
6  | 'unpushed'
7  | 'card'
8  | 'task'
9  | 'followup'
10  | 'artifact'
11  | 'ci'
12  | 'ask'
13  | 'plan'
14  | 'pr'
15  | 'unread'
16
17// 枠のボタンで Claude に頼むこと。押すと prompt をこのセッションに送る
18export type ReasonAction = {
19  label: string
20  prompt: string
21}
22
23export type Reason = {
24  level: 'blocked' | 'caution'
25  kind: ReasonKind
26  text: string
27  // 補足(後続の確かめ方など)。枠で薄い字にする
28  note?: string
29  // 後続なら、その id(枠の「済みにする」「予定に移す」が使う)
30  followupId?: string
31  // このセッションで公開した Artifact の行なら、その id(枠の「残す」と、片付けを頼んだあとに行を外すのに使う)
32  artifactIds?: string[]
33  // 枠のボタン(Claude に頼む)
34  actions?: ReasonAction[]
35  // 枠のリンク(PR など)
36  href?: string
37}
38
39// 時間が経たないと分からない確認。Claude が mcp__archive-check__followup で書き出す
40export type FollowUp = {
41  id: string
42  what: string
43  when: string
44  how: string
45  // 決まっていれば ISO 8601(時差付き)
46  dueAt?: string
47  createdAt: number
48}
49
50// この CLI のプロセスで起きた背景タスク(Bash / PowerShell の背景実行・Monitor)
51export type BackgroundTask = {
52  id: string
53  kind: 'shell' | 'monitor'
54  label: string
55  startedAt: number
56}
57
58export type Report = {
59  verdict: Verdict
60  reasons: Reason[]
61  checkedAt: number
62}
63
64declare module 'claude-code' {
65  interface PluginState {
66    // ended: 知らせ(prompt.submit)で終わりが分かった背景タスクの id
67    'archive-check': { report: Report | null; tasks: BackgroundTask[]; ended: string[] }
68  }
69}
70