期限切れ・期限が近い human と未決着の retro があるときだけ、プロンプトの上の帯に出す (issue 621)

対象は macOS のみ。Linux はサポート対象外です (2026-08-28 決定 / issue 133)。 Linux でも動きそうに見える箇所がありますが、意図的な対応ではありません (例: scripts/tmux_extract_popup.sh は pbcopy を無条件で呼び、zshlib/_fs_helpers.zsh の mount パースは macOS の出力形式のみを前提にしています)。CI も macOS runner で回します。
cd ~
git clone git@github.com:jiikko/dotfiles.git || git clone https://github.com/jiikko/dotfiles.git
cd dotfiles
./setup.sh
docs/README.md が索引。触る前に読む制約 (glogx の bubbletea、テーマ色、tmux の セッション永続化)、glogx の画面の仕様、tmux 周りの仕組み、nvim の棚卸しがある。 自作ツールの使い方は src/README.md から各プロジェクトの README へ。
githooks/ を setup.sh が core.hooksPath に設定する。
pre-commit: ステージした差分に、成人向けを匂わせる語や作品番号の書式がないかを civility-lint (dotfiles の外にある private repo のツール) で検査する。本体が無いマシンでは警告だけ出して通す。 誤検出を 1 行だけ通すなら、その行に civility-lint:ignore を書くpre-push: issues/ を触る push のときだけ、push する commit を展開して issue の整合検査 (番号の一意性・相対リンク・next の目印など tests/issues/ の 6 本。数秒) を回し、落ちたら止める。 今回の push が壊したものでなくても止まる。回す検査と外す検査の一覧は hook の冒頭にあるRun the regression test suite (Neovim, tmux, setup.sh, plus existing zsh tests) with:
make test runs lint and the tests all the way through and reports every failure at the end; a lint failure no longer stops the tests from running. Use make test-lint when you only want lint.
make test
You can run individual checks as well:
make test-syntax # zsh/zlogin/setup.sh syntax checks + tmux/nvim smoke
make test-nvim # verifies Neovim config loads and lazy.nvim is reachable
make test-tmux # ensures _tmux.conf can boot a tmux server (skips if tmux sockets are disallowed)
make test-setup # exercises setup.sh in a temporary HOME
make test-shellcheck # runs shellcheck on shell-compatible scripts
make test-yaml # yamllint on workflow/pre-commit config
make test-json # jq validation for JSON configs
make test-lint # aggregate lint target (shellcheck + zsh syntax + YAML + JSON + karabiner + actionlint + gitconfig + ruby syntax + the repo-wide scripts/check_*.sh gates; full list in the Makefile)
make test-src # lint + unused check + test for all Go projects under src/ (same coverage as CI's src_*.yml)
make test-runtime # aggregate runtime target (syntax + auto-discovered tests/**/test_*.sh + bats)
make test-bats # bats tests (skips if bats is not installed)
tests/zshrc/test_zshrc.sh # existing zsh tests (also run via make test)
自作ツールの使い方は docs/tools/ に置いている (索引は docs/README.md)。
C-t。ペイン・ウィンドウ操作、popup、ウィンドウ名、Claude Code の作業状態表示repair / av1ify (av1c) / concatkernel-alloc-watch / Finder Quick Actionssrc/README.md から各プロジェクトの README へhooks/register.tsx 71 lines1import { atom, read, update } from 'claude-code'
2import type { Register, EngineInterface } from 'claude-code'
3import { parseCounts, segments } from './band'
4
5// human / retro の催促を、モデル経由でなく人へ直接、プロンプトの上の帯に出す (issue 621)。
6// 数えるのは settings の SessionStart の hook と同じ script の --counts。帯が出なくても SessionStart の注入と規約の「冒頭で一言伝える」は
7// しばらく残す (mod は失敗すると黙ってスキップされるので、帯だけに頼らない。issue 618「mod が黙って止まったときの扱い」)。
8// 対話のセッションだけで数える: user の settings を読む裏の `claude -p` (ratelimit の `claude -p /usage` 等) でも mods は走る (docs/claude-mods.md)。
9
10const counts = atom({ plugin: 'issue-band', key: 'counts' } as const, null)
11
12const refresh = async ($: EngineInterface, cwd: string) => {
13 const hooks = `${$.plugin.root}/../../hooks`
14 const init = { cwd, stdin: JSON.stringify({ cwd }), timeoutMs: 10_000 }
15 try {
16 const [human, retro] = await Promise.all([
17 $.process.run([`${hooks}/human-tasks-due.sh`, '--counts'], init),
18 $.process.run([`${hooks}/retro-open.sh`, '--counts'], init),
19 ])
20 // 非 0 で stdout が空だと「issue dir の無い repo」と同じに見えて帯が黙って消えるので、理由として出す
21 const failed = [human, retro].find(r => r.exitCode !== 0)
22 if (failed) throw new Error(`rc=${failed.exitCode} ${failed.stderr.trim().slice(0, 80)}`)
23 const next = parseCounts(human.stdout, retro.stdout)
24 await update($, counts, () => next)
25 } catch (err) {
26 await update($, counts, () => ({ overdue: 0, soon: 0, broken: 0, retro: 0, error: String(err).slice(0, 120) }))
27 }
28}
29
30// desktop が起こすセッションは `-p` / SDK と同じ作りで、型定義 (SessionStartInput / session.attach) によると session.start の時点では
31// isInteractive が false・名簿 ($.session.surfaces()) が空 (desktop 実機は未観測)。起動時に決まる環境変数でも聞く。
32const isDesktopHosted = async ($: EngineInterface) => (await $.env.get('CLAUDE_CODE_ENTRYPOINT')) === 'claude-desktop'
33
34// 対話かどうかは毎回聞く (module の変数は、保存や pull で module が読み直されると消える)
35const isInteractive = async ($: EngineInterface) => (await $.session.surfaces()).length > 0 || (await isDesktopHosted($))
36
37export const register: Register = on => {
38 on('session.start', async ($, e, next) => {
39 const started = await next(e)
40 if (e.isInteractive) await refresh($, e.cwd)
41 // 環境変数は子の claude -p にも継承される (実測) ので、desktop の Bash から起こした -p も真になる。誤検出しても起動を待たせないよう裏で数える
42 else if (await isDesktopHosted($)) void refresh($, e.cwd)
43 return started
44 })
45
46 // issue を done へ移した直後に減るよう、メインのターンの終わりにも数え直す (描画の中では script を呼ばない)。
47 // サブエージェントのターン (agentId あり) では数えない。待たずに裏で走らせる (script は issue の数だけ時間がかかり、dotfiles で約 0.6 秒)
48 on('turn.complete', async ($, e, next) => {
49 const done = await next(e)
50 if (e.agentId === undefined && (await isInteractive($))) void refresh($, await $.session.cwd())
51 return done
52 })
53
54 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
55 const c = await read($, counts)
56 const parts = c === null || e.props.hasSurvey ? [] : segments(c)
57 if (parts.length === 0) return next(e)
58 const { Box, Text } = $.ui.resolve(e)
59 return (
60 <Box>
61 {parts.map((p, i) => (
62 <Text key={`seg${i}`} wrap="truncate">
63 {i > 0 ? <Text dimColor> · </Text> : null}
64 <Text color={p.color}>{p.text}</Text>
65 </Text>
66 ))}
67 </Box>
68 )
69 })
70}
71hooks/band.ts 31 lines1import type { IssueBandCounts } from '../types'
2
3// script の --counts の出力 (`key=value` を 1 行ずつ) を読む。数えるのは script (_claude/hooks/human-tasks-due.sh /
4// retro-open.sh) で、ここは写さない (issue 621)。どちらも何も出さない = issue dir の無い repo なので null
5export const parseCounts = (human: string, retro: string): IssueBandCounts | null => {
6 if (human.trim() === '' && retro.trim() === '') return null
7 const kv = new Map<string, string>()
8 for (const line of `${human}\n${retro}`.split('\n')) {
9 const i = line.indexOf('=')
10 if (i > 0) kv.set(line.slice(0, i), line.slice(i + 1))
11 }
12 const num = (k: string) => {
13 const v = Number(kv.get(k) ?? '0')
14 return Number.isInteger(v) && v >= 0 ? v : 0
15 }
16 return { overdue: num('overdue'), soon: num('soon'), broken: num('broken'), retro: num('retro'), error: kv.get('error') ?? null }
17}
18
19export type Segment = { text: string; color: 'red' | 'yellow' | 'cyan' }
20
21// 案 3 (2026-10-02 にユーザーが選んだ): 人がやる必要があるときだけ出す。余裕のある human だけなら何も出さない (空配列)
22export const segments = (c: IssueBandCounts): Segment[] => {
23 const out: Segment[] = []
24 if (c.error) out.push({ text: `issue の催促を数えられない: ${c.error}`, color: 'red' })
25 if (c.overdue > 0) out.push({ text: `期限切れの human ${c.overdue} 件`, color: 'red' })
26 if (c.soon > 0) out.push({ text: `期限が近い human ${c.soon} 件`, color: 'yellow' })
27 if (c.broken > 0) out.push({ text: `期限の読めない human ${c.broken} 件`, color: 'yellow' })
28 if (c.retro > 0) out.push({ text: `retro 未決着 ${c.retro}`, color: 'cyan' })
29 return out
30}
31types/index.d.ts 14 lines1export type IssueBandCounts = {
2 overdue: number
3 soon: number
4 broken: number
5 retro: number
6 error: string | null
7}
8
9declare module 'claude-code' {
10 interface PluginState {
11 'issue-band': { counts: IssueBandCounts | null }
12 }
13}
14