SLOPSHOPPER

issue-band

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

newbandprocess
★ 2v0.1.0no licenseupdated 2026-10-02jiikko/dotfiles/_claude/mods/issue-band
A shopper browsing a rack in a slop shop
README

dotfiles

対象は macOS のみ。Linux はサポート対象外です (2026-08-28 決定 / issue 133)。 Linux でも動きそうに見える箇所がありますが、意図的な対応ではありません (例: scripts/tmux_extract_popup.sh は pbcopy を無条件で呼び、zshlib/_fs_helpers.zsh の mount パースは macOS の出力形式のみを前提にしています)。CI も macOS runner で回します。

Installing

cd ~
git clone git@github.com:jiikko/dotfiles.git || git clone https://github.com/jiikko/dotfiles.git
cd dotfiles
./setup.sh

for Mac

設計文書・仕様・調査記録

docs/README.md が索引。触る前に読む制約 (glogx の bubbletea、テーマ色、tmux の セッション永続化)、glogx の画面の仕様、tmux 周りの仕組み、nvim の棚卸しがある。 自作ツールの使い方は src/README.md から各プロジェクトの README へ。

Git hooks

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 の冒頭にある

Testing

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)。

  • tmux のキーと表示 — prefix は C-t。ペイン・ウィンドウ操作、popup、ウィンドウ名、Claude Code の作業状態表示
  • 動画のシェル関数 — repair / av1ify (av1c) / concat
  • macOS 連携 — Karabiner-Elements / kernel-alloc-watch / Finder Quick Actions
  • Go で書いた自作ツール (glogx・pro-con など) — src/README.md から各プロジェクトの README へ
Source 3 files
hooks/register.tsx 71 lines
1import { 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}
71
hooks/band.ts 31 lines
1import 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}
31
types/index.d.ts 14 lines
1export 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