SLOPSHOPPER

daily-quest

/quest で、日替わりのお題 3 つ (ターン・編集・テスト・コミットなど) の進みと連続日数をペインで見る。ごほうびは dev-rpg の経験値と turn-gacha のおまけの 1 回。記録は毎日控えを取り、控えから戻せる

newpaneguardcommandtoasttimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · daily-quest
│ ┃ きょうのお題 ✕ › fix the failing auth test and add an au╭─────────────────────────╮ │ ┃ きょうのお題 10/9 連続 0 日 通算 1 個 │ daily-quest │ │ ┃ まだ コミットを 2 回する 0/2 ごほうび 経験値 ⏺ Read(src/auth.ts) │ お題「新しいファイルを 1 個作る」をこなした │ │ ┃ +35 / ガチャ 1 回 ⎿ Read 6 lines ╰─────────────────────────╯ │ ┃ まだ コマンドを 10 回実行する 3/10 ごほうび ⏺ Update(src/auth.ts) │ ┃ 経験値 +35 / ガチャ 1 回 ⎿ Added 2 lines, removed 1 line │ ┃ 達成 新しいファイルを 1 個作る 1/1 ごほうび ⏺ Bash(bun test) │ ┃ 経験値 +20 / ガチャ 1 回 ⎿ 3 pass, 1 fail │ ┃ この 7 日で全部こなした日 0 日 │ ┃ ごほうびは dev-rpg と turn-gacha ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ が受け取ります │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ [ 控えから戻す ] │ › /quest │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · きょうのお題
きょうのお題 10/9 連続 0 日 通算 1 個 まだ コミットを 2 回する 0/2 ごほうび 経験値 +35 / ガチャ 1 回 まだ コマンドを 10 回実行する 3/10 ごほうび 経験値 +35 / ガチャ 1 回 達成 新しいファイルを 1 個作る 1/1 ごほうび 経験値 +20 / ガチャ 1 回 この 7 日で全部こなした日 0 日 ごほうびは dev-rpg と turn-gacha が受け取ります [ 控えから戻す ]
README

claude-code-mods

English

Claude Code の関数フック mod を集めたリポジトリです。 ペイン・入力欄の上の帯・枠・トーストを使い、VS Code 拡張でもターミナルでも見た目が崩れないように作っています。 VS Code では SVG のグラフやアニメ(SMIL / CSS)も描けます。

Anthropic とは関係のない、個人が作った非公式の mod です。

入っている mod

mod呼び方何をするか
mod-hub/mod画面のある mod(ペインや帯を出す物)をアイコン付きで並べる。名前・説明で絞る、固定して上に置く、カテゴリー別(開発・状況・道具・遊び)・名前順・最近開いた順。見出しに季節の小さな飾り
cc-dash/dashセッションの計器盤。経過・ターン・ツール・エラーのタイル、ターンの所要時間の棒グラフ、ツールのタイムライン、使用量のリング型ゲージ。「自分の並び」で出す枠と順を自分で決められる
work-bar入力欄の上に自動で出る作業中は経過時間・今のツール・流れる光の帯。待ち中は前のターンのまとめと定型文のボタン
aqua-pet/aquarium作業に反応する水槽。魚は 1 匹ずつターンで 稚魚 → 若魚 → 成魚 → 大物 と育ち、節目で新しい魚(全 10 種、後ほど珍しい)が来る。エラーで赤く震える。えさ・名前・掃除・魚の一覧のボタン。日本時間の時刻で水の色と光が(夕は橙で沈む日、夜は月と星)、季節で飾り(花びら・泡・落ち葉・雪)が変わる。turn-gacha で集めたカードでかざり(水草・石・流木・貝・つぼ・城・宝箱・沈没船)が手に入り、底の 5 つの置き場に置ける(城と沈没船の周りを魚が回る)。記録は毎日控えを取り、ペインから戻せる
project-board/projectsフォルダに並んだ引き継ぎメモ(*.md)の鮮度グラフと一覧・詳細
git-pane/git今のフォルダの git の様子。ブランチ・変更行数の差分バー・直近コミット。git には書き込まない
token-meter/tokensターンごとのトークンの積み上げ棒とキャッシュの当たり率
focus-timer/focus-timerポモドーロ。減っていくリングと今日の本数
session-recap/session-recapセッションのふりかえり。ターンの帯のタイムラインと、押した時だけ作る要約
edit-log/editsこのセッションで Claude が Edit / Write などの編集ツールで変えたファイルの時系列。押すと git の差分(HEAD と比べる)と「レビューして」
todo-board/todos今のフォルダの TODO / FIXME / HACK / XXX の件数・推移・一覧。git の外はフォルダを歩いて読む。種類のボタンで絞り、押すと「片付けて」
test-runner/testrun設定したテストのコマンドをボタンで走らせる。信号と直近 20 回の結果、失敗したら「直して」。Claude が走らせたテストも拾い、落ちている間は入力欄の上に 1 行出す。落ちたままコミットしようとしたらトーストで知らせる
prompt-book/promptsよく使う依頼文を保存して、ボタンで入力欄へ。タグ・使った回数・並び替え
live-commentary/jikkyoClaude の動きをスポーツ実況風にステータス行へ。口調は熱血・冷静・関西
turn-gacha/gachaターンが終わるたびにガチャ。N / R / SR / SSR のカード 60 種と図鑑、集まり具合の実績 6 個。出たカードは dev-rpg で装備にできる。daily-quest のお題をこなすと、おまけで引ける。記録は毎日控えを取り、「控えから戻す」で戻せる
dev-rpg/rpg作業すると経験値がたまる RPG。レベル・称号・実績 22 個。ターンを終えるとたまる「魔物の気配」で魔物 8 種・ボス 3 体とボタンで戦う。ボスは時々湧き、湧いたボスは気配を使わずに挑めて経験値 1.5 倍。戦いで減った HP は 15 分休むか新しいセッションで戻る。レベル・実績・勝利・ボスで入る SP を能力(力・体力・知恵・敏捷)に振り、スキル 10 種を覚える。turn-gacha のカードを武器・防具・お守りに装備して能力が上がる。daily-quest のお題をこなすと経験値が入る。ターンで潜るダンジョン(8 ターンで 1 階、10 階ごとに番人、5 階ごとに休み場)で一番深い階を目指し、勝った分は「帰る」で持ち帰る。記録は毎日控えを取れる
code-omikuji/omikuji今日のコード運勢。筒を振ると棒の番号が回って止まり、和紙のおみくじが開く。文は 238 通りと、その季節の日だけ出る一言
agent-watch/agent-watch動いているサブエージェントの帯のタイムライン(経過・ツール回数・状態)。ボタンでその会話へ切り替え、メインへ戻る
canvas-pane/canvasClaude が呼べるツール show を足す。「売上をグラフにしてペインに出して」と頼むと、棒・折れ線・円・表・Markdown・SVG をペインに描く。直近 20 枚をめくれる
rule-switch/rules「日本語で答える」「commit しない」などの約束ごとをボタンでオン/オフし、システムプロンプトに入れる。自分の約束も足せる
wait-games/games長いターンの間に遊ぶ 2048・マインスイーパー・15 パズル・ライツアウト・数独(4x4 / 9x9)・神経衰弱。ゲームごとに勝ち数・最短手数・最速を残す。ターンが終わったら知らせる。記録は毎日控えを取り、「控えから戻す」で戻せる
spec-board/specsSpecStatus の確認待ち・途中の仕様書と次のおすすめ。押すと詳細(読むだけ)
ci-pane/ci今のブランチの PR(状態・レビュー・チェックの丸)と GitHub Actions の結果。gh を使う。何かを起こす操作は無い
port-watch/ports待ち受け中の TCP ポートと、セッション中に増えた物の印。URL を写すボタンと、開発用の実行環境だけ 2 回押しで止めるボタン
pre-check/precheckコミット前の確かめ。変更の要約・秘密情報らしい文字列(足した行だけ、伏せて表示)・衝突の印・1MB 超のファイル・テスト(ボタンで)
face-band/face で出す / 隠す待ち・考え中・ツール実行中・答え終わり・エラーで変わる Claude の顔。日本時間の時間帯と季節で小物が変わる(夜は眠そうにナイトキャップ、夏は麦わら帽子、冬はマフラーなど)。入れただけでは出ない(最初はオフ)。work-bar と並べて出る
done-chime/chime長いターン(既定 30 秒以上)が終わったら音を鳴らす。音の種類・しきい値・音量・試し鳴らし
turn-haiku/haikuターンが終わるたびに、やった事を五七五の 1 句にして縦書きの短冊に。季節の季語の候補を添えて詠む。過去の句の一覧
session-timeline/timelineこのセッションのターンとツール呼び出しを横の帯の年表で。ツールの種類で色分け、失敗は赤い印、サブエージェントは細い帯、1 分以上のターンは黄の縁
file-map/filemapこのセッションで読んだ・書いたファイルを、フォルダごとの四角(ツリーマップ)で。大きいほど多く触った所、橙は書いた物、青は読んだだけ
toast-inbox/inboxほかの mod が出したトーストを 100 件まで残す受け箱(設定でステータス行も)。未読の数・mod ごとの件数・mod で絞る・全部既読・空にする。通知の行を押すと、それを出した mod のペインを開く
usage-history/history日をまたいだ使い方の記録。日ごとのターン数か出力トークンの棒(7 日 / 30 日)、合計と 1 日平均、曜日と時間帯のヒートマップ。直近 7 日の数をほかの mod(week-card)にも見せる。記録は毎日控えを取れる
fail-log/errorsツールが失敗した記録を 200 件まで残す。似た失敗をまとめた「よく落ちる物」の棒、最近の一覧、今日・通算の数。サブエージェントの分は紫で分ける
daily-quest/quest日替わりのお題 3 つ(ターン・ファイルを直す・テスト・コミットなど 8 種から)。進みの棒・ごほうび・連続日数・直近 7 日。ごほうびは dev-rpg が経験値、turn-gacha がおまけの抽選で受け取る。記録は毎日控えを取り、「控えから戻す」で戻せる
trophy-shelf/trophiesdev-rpg・turn-gacha・aqua-pet・wait-games・daily-quest の記録を 1 枚の棚にまとめ、トロフィーを初めて取った日付つきで集める。ダンジョンの深さのトロフィー 3 つ(番人越え・地下三十階・百階の主)も
mod-check/mod-checkmod がちゃんと動いているかを 1 回で確かめる。入っている・コマンドあり・予備名・見つからないの数と、mod ごとの判定(本来の名前か予備名か、本来の名前を使っている相手)、画面・テーマ・Claude Code の版をペインに出し、同じ結果を 1 行 1 mod の貼れる文で返す。隣の mod の版がセッション中に変わったら、帯とトーストで開き直しを頼む。「点検表」タブで実画面の見え方に よい・だめ・まだ を付け、結果を貼る文にする
compact-cue入力欄の上に自動で出る・/cueコンテキストの使用量が多くなった時、テストが通った・コミットした・引き継ぎメモを書いた直後なら「今が /compact のよい時」、編集の途中なら「区切りまで待って」と帯とトーストで知らせる。ペインは使用量のリングと作業の様子。知らせるだけで compact はしない
ship-guard常時・/guardClaude が git push・gh release・gh repo create・npm publish を打つ時、直前に AskUserQuestion で公開してよいと答えていなければ、その場で「実行しますか?」と聞き、断られたら止める。ペインは記録とオン/オフ。git push の前は、送るコミットの作者が noreply か、足した行に個人のメール・ユーザー名入りのパスが無いかも確かめ、引っかかったら確認済みでも聞き直す
rule-watch入力欄の上に自動で出る・/rulewatchClaude の返事が約束ごとから外れた時に帯で知らせる。英語で答えた・AskUserQuestion を使わずに「〜しますか?」で終わった・コミットの後に引き継ぎメモや開発ログを書いていない・開発ログを書いたのに「📝」の 1 行が無い・締めの言葉なのに開発ログが無い、の 6 つ。ペインは数と記録、「Claude に伝える」で入力欄に一言入れる
enc-guard常時・/enc文字コード・改行の罠を Claude が踏んだ時に、直し方を Claude に伝える(設定で止めるにもできる)。-Encoding の無い Set-Content / Add-Content、日本語入りで BOM の無い .ps1、ASCII 以外の字が入った .bat / .cmd、Windows で encoding= / newline= の無い Python の open
release-draft/release前のタグから今までのコミットと、版が上がった plugin.json / package.json を集めて、CHANGELOG の下書き(Added / Changed / Fixed)と次の版の候補を出す。「これで CHANGELOG を書いて」で Claude に頼む。git は読むだけ
reply-frame常時返答をオレンジ、自分の発言を青の角丸の枠で囲む
danger-frame常時危ないシェルのコマンドの行を赤い枠で囲む
long-turn-toast常時長くかかったターン(既定 120 秒超)をトーストで知らせる
usage-pane/usage-pane使用量の JSON を 3 本のバーで表示
devlog-pane/devlogフォルダにある今日の開発ログ(YYYY-MM-DD.md)を表示
run-guard常時・/runguardClaude が確かめのために窓のあるアプリを起動する・音を鳴らす・claude -p を隔離なしで走らせるのを、実行の前に止めて直し方(-WindowStyle Hidden・--headless・一時の設定フォルダ)を返す。組ごとにオン/オフでき、記録も残る
week-card/week1 週間の使い方と、お題・ガチャの図鑑・冒険の書(レベルとダンジョン)・水槽の増え方を 1 枚のカードにまとめ、週報に貼る文(Markdown)を入力欄に入れる

reply-frame は takosasi-dev/reply-frame でも公開しています。ここに入っているのは、自分の発言の枠を足した新しい版です。

画面

VS Code のペインに描かれる SVG の部分です(架空のデータ。ボタンなどは省いています)。node tools/shots.mts で作り直せます(--light で明るいテーマ)。

<table> <tr> <td align="center" valign="top"><b>mod-hub</b><br><img src="docs/images/mod-hub.png" width="250" alt="mod-hub"></td> <td align="center" valign="top"><b>cc-dash</b><br><img src="docs/images/cc-dash.png" width="250" alt="cc-dash"></td> <td align="center" valign="top"><b>work-bar</b><br><img src="docs/images/work-bar.png" width="250" alt="work-bar"></td> </tr> <tr> <td align="center" valign="top"><b>face-band</b><br><img src="docs/images/face-band.png" width="250" alt="face-band"></td> <td align="center" valign="top"><b>aqua-pet</b><br><img src="docs/images/aqua-pet.png" width="250" alt="aqua-pet"></td> <td align="center" valign="top"><b>project-board</b><br><img src="docs/images/project-board.png" width="250" alt="project-board"></td> </tr> <tr> <td align="center" valign="top"><b>git-pane</b><br><img src="docs/images/git-pane.png" width="250" alt="git-pane"></td> <td align="center" valign="top"><b>token-meter</b><br><img src="docs/images/token-meter.png" width="250" alt="token-meter"></td> <td align="center" valign="top"><b>focus-timer</b><br><img src="docs/images/focus-timer.png" width="250" alt="focus-timer"></td> </tr> <tr> <td align="center" valign="top"><b>session-recap</b><br><img src="docs/images/session-recap.png" width="250" alt="session-recap"></td> <td align="center" valign="top"><b>edit-log</b><br><img src="docs/images/edit-log.png" width="250" alt="edit-log"></td> <td align="center" valign="top"><b>todo-board</b><br><img src="docs/images/todo-board.png" width="250" alt="todo-board"></td> </tr> <tr> <td align="center" valign="top"><b>test-runner</b><br><img src="docs/images/test-runner.png" width="250" alt="test-runner"></td> <td align="center" valign="top"><b>pre-check</b><br><img src="docs/images/pre-check.png" width="250" alt="pre-check"></td> <td align="center" valign="top"><b>ci-pane</b><br><img src="docs/images/ci-pane.png" width="250" alt="ci-pane"></td> </tr> <tr> <td align="center" valign="top"><b>spec-board</b><br><img src="docs/images/spec-board.png" width="250" alt="spec-board"></td> <td align="center" valign="top"><b>port-watch</b><br><img src="docs/images/port-watch.png" width="250" alt="port-watch"></td> <td align="center" valign="top"><b>prompt-book</b><br><img src="docs/images/prompt-book.png" width="250" alt="prompt-book"></td> </tr> <tr> <td align="center" valign="top"><b>agent-watch</b><br><img src="docs/images/agent-watch.png" width="250" alt="agent-watch"></td> <td align="center" valign="top"><b>canvas-pane</b><br><img src="docs/images/canvas-pane.png" width="250" alt="canvas-pane"></td> <td align="center" valign="top"><b>rule-switch</b><br><img src="docs/images/rule-switch.png" width="250" alt="rule-switch"></td> </tr> <tr> <td align="center" valign="top"><b>live-commentary</b><br><img src="docs/images/live-commentary.png" width="250" alt="live-commentary"></td> <td align="center" valign="top"><b>turn-gacha</b><br><img src="docs/images/turn-gacha.png" width="250" alt="turn-gacha"></td> <td align="center" valign="top"><b>dev-rpg</b><br><img src="docs/images/dev-rpg.png" width="250" alt="dev-rpg"></td> </tr> <tr> <td align="center" valign="top"><b>code-omikuji</b><br><img src="docs/images/code-omikuji.png" width="250" alt="code-omikuji"></td> <td align="center" valign="top"><b>turn-haiku</b><br><img src="docs/images/turn-haiku.png" width="250" alt="turn-haiku"></td> <td align="center" valign="top"><b>wait-games</b><br><img src="docs/images/wait-games.png" width="250" alt="wait-games"></td> </tr> <tr> <td align="center" valign="top"><b>session-timeline</b><br><img src="docs/images/session-timeline.png" width="250" alt="session-timeline"></td> <td align="center" valign="top"><b>file-map</b><br><img src="docs/images/file-map.png" width="250" alt="file-map"></td> <td align="center" valign="top"><b>usage-history</b><br><img src="docs/images/usage-history.png" width="250" alt="usage-history"></td> </tr> <tr> <td align="center" valign="top"><b>toast-inbox</b><br><img src="docs/images/toast-inbox.png" width="250" alt="toast-inbox"></td> <td align="center" valign="top"><b>done-chime</b><br><img src="docs/images/done-chime.png" width="250" alt="done-chime"></td> <td align="center" valign="top"><b>fail-log</b><br><img src="docs/images/fail-log.png" width="250" alt="fail-log"></td> </tr> <tr> <td align="center" valign="top"><b>daily-quest</b><br><img src="docs/images/daily-quest.png" width="250" alt="daily-quest"></td> <td align="center" valign="top"><b>trophy-shelf</b><br><img src="docs/images/trophy-shelf.png" width="250" alt="trophy-shelf"></td> <td align="center" valign="top"><b>mod-check</b><br><img src="docs/images/mod-check.png" width="250" alt="mod-check"></td> </tr> <tr> <td align="center" valign="top"><b>compact-cue</b><br><img src="docs/images/compact-cue.png" width="250" alt="compact-cue"></td> <td align="center" valign="top"><b>ship-guard</b><br><img src="docs/images/ship-guard.png" width="250" alt="ship-guard"></td> <td align="center" valign="top"><b>rule-watch</b><br><img src="docs/images/rule-watch.png" width="250" alt="rule-watch"></td> </tr> <tr> <td align="center" valign="top"><b>enc-guard</b><br><img src="docs/images/enc-guard.png" width="250" alt="enc-guard"></td> <td align="center" valign="top"><b>release-draft</b><br><img src="docs/images/release-draft.png" width="250" alt="release-draft"></td> <td align="center" valign="top"><b>run-guard</b><br><img src="docs/images/run-guard.png" width="250" alt="run-guard"></td> </tr> <tr> <td align="center" valign="top"><b>week-card</b><br><img src="docs/images/week-card.png" width="250" alt="week-card"></td> </tr> </table>

動く絵

ペインの絵は開いた時に 1 回動きます(数字が増える・棒が伸びる・おみくじが開く)。node tools/gif.mts <mod> で作り直せます。

<table> <tr> <td align="center" valign="top"><b>mod-hub</b><br><img src="docs/images/mod-hub.gif" width="250" alt="mod-hub(動く絵)"></td> <td align="center" valign="top"><b>cc-dash</b><br><img src="docs/images/cc-dash.gif" width="250" alt="cc-dash(動く絵)"></td> </tr> <tr> <td align="center" valign="top"><b>usage-history</b><br><img src="docs/images/usage-history.gif" width="250" alt="usage-history(動く絵)"></td> <td align="center" valign="top"><b>code-omikuji</b><br><img src="docs/images/code-omikuji.gif" width="250" alt="code-omikuji(動く絵)"></td> </tr> </table>

明るいテーマ

Claude Code の /config で theme を明るい物(light で始まる物)にすると、SVG も明るい色で描きます(VS Code の配色は mod から読めないので、Claude Code の theme に合わせます)。

<table> <tr> <td align="center" valign="top"><b>mod-hub</b><br><img src="docs/images/mod-hub-light.png" width="250" alt="mod-hub(明るいテーマ)"></td> <td align="center" valign="top"><b>git-pane</b><br><img src="docs/images/git-pane-light.png" width="250" alt="git-pane(明るいテーマ)"></td> <td align="center" valign="top"><b>usage-history</b><br><img src="docs/images/usage-history-light.png" width="250" alt="usage-history(明るいテーマ)"></td> </tr> </table>

動作環境

  • Claude Code 2.1.300 以降(関数フックの mod を読み込める版)
  • VS Code で描くには、VS Code 拡張 2.1.300 以降が要ります。2.1.288 までの拡張は mod の描画に対応していません
  • ターミナル・デスクトップアプリでも動きます。ターミナルでは SVG の代わりに文字だけの表示になります

入れ方

好きな mod だけ入れられます。冒険の書(dev-rpg)とガチャ(turn-gacha)の 2 本だけが欲しい時は、単体版の claude-code-dev-rpg からも入れられます(中身は同じなので、どちらか片方から入れてください)。ターミナルの Claude Code の入力欄で:

/plugin install cc-dash --marketplace takosasi-dev/claude-code-mods

y でマーケットプレイスを足し、スコープを選ぶと入ります。シェルからは:

claude plugin install cc-dash --marketplace takosasi-dev/claude-code-mods

デスクトップアプリでは、入力欄の横の + → Plugins → Add marketplace に takosasi-dev/claude-code-mods を入れ、Add plugin で選びます。

mod-hub に並ぶのは、mod-hub より後に読み込まれる mod だけです。mod-hub を最初に入れてください。カテゴリーとアイコンはこのリポジトリの mod だけに付いていて、ほかの mod は「その他」に入ります。toast-inbox と mod-check も、ほかの mod より後から読み込まれる物を見るので、mod-hub の次に入れるのがおすすめです。

コマンドの名前がほかのプラグインや組み込みと被った時は、/edit-log・/todo-board・/test-runner・/prompt-book・/live-commentary・/turn-gacha・/dev-rpg・/code-omikuji・/gitpane・/token-meter・/mod-hub・/canvas-pane・/rule-switch・/wait-games・/spec-board・/ci-pane・/port-watch・/pre-check・/face-band・/done-chime・/turn-haiku・/session-timeline・/file-map・/toast-inbox・/usage-history・/fail-log・/compact-cue・/ship-guard・/rule-watch・/enc-guard・/release-draft・/daily-quest・/trophy-shelf・/modcheck・/run-guard・/week-card のように mod の名前のコマンドになります。

まとめて入れる

claude plugin install は 1 回に 1 本だけ受け取ります。まとめて入れる時は、下の行のかたまりをシェル(bash・zsh・PowerShell のどれでも)にそのまま貼ってください。最初の 1 本でマーケットプレイスが足され、2 本目からはそれを使います。スコープは既定で user(全プロジェクト)で、-s project を付けると今のプロジェクトだけに入ります。mod-hub・toast-inbox・mod-check を先に入れてから、ほしいカテゴリーを開いて貼ります。

最初に(mod-hub・toast-inbox・mod-check)

claude plugin install mod-hub --marketplace takosasi-dev/claude-code-mods
claude plugin install toast-inbox --marketplace takosasi-dev/claude-code-mods
claude plugin install mod-check --marketplace takosasi-dev/claude-code-mods
claude plugin install project-board --marketplace takosasi-dev/claude-code-mods
claude plugin install git-pane --marketplace takosasi-dev/claude-code-mods
claude plugin install edit-log --marketplace takosasi-dev/claude-code-mods
claude plugin install todo-board --marketplace takosasi-dev/claude-code-mods
claude plugin install test-runner --marketplace takosasi-dev/claude-code-mods
claude plugin install spec-board --marketplace takosasi-dev/claude-code-mods
claude plugin install ci-pane --marketplace takosasi-dev/claude-code-mods
claude plugin install port-watch --marketplace takosasi-dev/claude-code-mods
claude plugin install pre-check --marketplace takosasi-dev/claude-code-mods
claude plugin install fail-log --marketplace takosasi-dev/claude-code-mods
claude plugin install ship-guard --marketplace takosasi-dev/claude-code-mods
claude plugin install rule-watch --marketplace takosasi-dev/claude-code-mods
claude plugin install enc-guard --marketplace takosasi-dev/claude-code-mods
claude plugin install release-draft --marketplace takosasi-dev/claude-code-mods
claude plugin install run-guard --marketplace takosasi-dev/claude-code-mods
claude plugin install cc-dash --marketplace takosasi-dev/claude-code-mods
claude plugin install work-bar --marketplace takosasi-dev/claude-code-mods
claude plugin install token-meter --marketplace takosasi-dev/claude-code-mods
claude plugin install session-recap --marketplace takosasi-dev/claude-code-mods
claude plugin install agent-watch --marketplace takosasi-dev/claude-code-mods
claude plugin install session-timeline --marketplace takosasi-dev/claude-code-mods
claude plugin install file-map --marketplace takosasi-dev/claude-code-mods
claude plugin install usage-history --marketplace takosasi-dev/claude-code-mods
claude plugin install compact-cue --marketplace takosasi-dev/claude-code-mods
claude plugin install usage-pane --marketplace takosasi-dev/claude-code-mods
claude plugin install devlog-pane --marketplace takosasi-dev/claude-code-mods
claude plugin install focus-timer --marketplace takosasi-dev/claude-code-mods
claude plugin install prompt-book --marketplace takosasi-dev/claude-code-mods
claude plugin install canvas-pane --marketplace takosasi-dev/claude-code-mods
claude plugin install rule-switch --marketplace takosasi-dev/claude-code-mods
claude plugin install done-chime --marketplace takosasi-dev/claude-code-mods
claude plugin install aqua-pet --marketplace takosasi-dev/claude-code-mods
claude plugin install live-commentary --marketplace takosasi-dev/claude-code-mods
claude plugin install turn-gacha --marketplace takosasi-dev/claude-code-mods
claude plugin install dev-rpg --marketplace takosasi-dev/claude-code-mods
claude plugin install code-omikuji --marketplace takosasi-dev/claude-code-mods
claude plugin install wait-games --marketplace takosasi-dev/claude-code-mods
claude plugin install face-band --marketplace takosasi-dev/claude-code-mods
claude plugin install turn-haiku --marketplace takosasi-dev/claude-code-mods
claude plugin install daily-quest --marketplace takosasi-dev/claude-code-mods
claude plugin install trophy-shelf --marketplace takosasi-dev/claude-code-mods
claude plugin install week-card --marketplace takosasi-dev/claude-code-mods
claude plugin install reply-frame --marketplace takosasi-dev/claude-code-mods
claude plugin install danger-frame --marketplace takosasi-dev/claude-code-mods
claude plugin install long-turn-toast --marketplace takosasi-dev/claude-code-mods

全部(48 本)をまとめて(bash / zsh):

for m in mod-hub toast-inbox mod-check cc-dash work-bar aqua-pet project-board git-pane token-meter focus-timer session-recap edit-log todo-board test-runner prompt-book live-commentary turn-gacha dev-rpg code-omikuji agent-watch canvas-pane rule-switch wait-games spec-board ci-pane port-watch pre-check face-band done-chime turn-haiku session-timeline file-map usage-history compact-cue fail-log ship-guard rule-watch enc-guard release-draft daily-quest trophy-shelf reply-frame danger-frame long-turn-toast usage-pane devlog-pane run-guard week-card; do claude plugin install "$m" --marketplace takosasi-dev/claude-code-mods; done

PowerShell:

foreach ($m in 'mod-hub','toast-inbox','mod-check','cc-dash','work-bar','aqua-pet','project-board','git-pane','token-meter','focus-timer','session-recap','edit-log','todo-board','test-runner','prompt-book','live-commentary','turn-gacha','dev-rpg','code-omikuji','agent-watch','canvas-pane','rule-switch','wait-games','spec-board','ci-pane','port-watch','pre-check','face-band','done-chime','turn-haiku','session-timeline','file-map','usage-history','compact-cue','fail-log','ship-guard','rule-watch','enc-guard','release-draft','daily-quest','trophy-shelf','reply-frame','danger-frame','long-turn-toast','usage-pane','devlog-pane','run-guard','week-card') { claude plugin install $m --marketplace takosasi-dev/claude-code-mods }

この一覧は node tools/plugin_dirs.mts --readme で作り直せます(--en で英語版)。

clone したフォルダから読ませる

リポジトリを clone して、そのフォルダから読ませることもできます。

  • フォルダをマーケットプレイスとして足す: 上の行の takosasi-dev/claude-code-mods を clone 先のパス(例 D:\work\claude-code-mods)に変えて入れます。mod はそのフォルダから直接読まれるので、git pull の後に /reload-plugins で新しい版になります
  • CLAUDE_CODE_PLUGIN_DIRS で読ませる: 入れずに、セッションを始める時に毎回フォルダから読みます。値は clone 先で node tools/plugin_dirs.mts を走らせると出ます(別の場所を指すなら node tools/plugin_dirs.mts D:\work\claude-code-mods)。mod-hub・toast-inbox・mod-check を先頭に、全 mod のフォルダを Windows は ;、ほかは : でつないだ 1 行です。これを環境変数か、~/.claude/settings.json の env の CLAUDE_CODE_PLUGIN_DIRS に入れます(道具は settings.json を書き換えません)。要らない mod はその行から消してください
D:\work\claude-code-mods\mods\mod-hub;D:\work\claude-code-mods\mods\toast-inbox;D:\work\claude-code-mods\mods\cc-dash;...

気を付けること

  • rule-switch の約束ごとは、サブエージェントのシステムプロンプトにも入ります。また、組織が Claude Code を管理している環境では、利用者が入れたプラグインのシステムプロンプトのフックは動きません
  • rule-switch を切り替えると、プロンプトのキャッシュが作り直されます
  • canvas-pane のツールに渡された生の SVG は、スクリプト・イベント属性・外部の参照を含む物を断ります(ペインでは画像として描かれ、スクリプトは動きません)
  • turn-haiku は、答えて終わったターンごと(ツールを使ったか 20 秒以上かかった時)に Haiku のモデルを 1 回呼び、トークンを使います。/haiku の「自動で詠む」で止められます
  • port-watch の「止める」は子プロセスごと止めます(Windows は taskkill /T /F)。node で動く MCP サーバーも開発用に見えるので、止める前に名前とポートを確かめてください。macOS / Linux(lsof)の実機では未確認です
  • pre-check の秘密情報の検出は手がかり程度です。見つからなくても安全とは限りません
  • done-chime は Windows では PowerShell の SoundPlayer で鳴らします。それ以外は Claude Code の音の再生を使い、Linux のターミナルでは鳴らないことがあります
  • spec-board の一覧は SpecStatus が GitHub の API を使うため、回数の上限に当たることがあります。読み直しは 5 分ごとです
  • ci-pane と git を読む mod は、所有者の違うフォルダ(別ドライブなど)では git が止まります。git config --global --add safe.directory <フォルダ> を足してください
  • aqua-pet の背景は 15 分ごとに描き直します。描き直すと泳ぎや泡のアニメが頭から始まります
  • aqua-pet 0.2 の記録は、開いた時に今の匹数の魚を作って引き継ぎます。0.2 と 0.3 のセッションを同時に開いていると、魚の来た日が作り直されることがあります
  • dev-rpg の装備は turn-gacha 0.2.0 以降と一緒に入れた時だけ使えます。ガチャの図鑑から消えたカードは外れた扱いになりますが、記録は残ります
  • toast-inbox が拾うのは、ほかの mod が $.ui.toast / $.ui.status を呼んだ時だけです。「空にする」はほかのセッションの記録も消します
  • usage-history は入れた後のターンから数えます。日付・曜日・時刻は日本時間です
  • file-map は失敗した呼び出しを数えません。Grep と Glob は path を渡した時だけ数えます
  • 明るいテーマでも、水槽・俳句の短冊・おみくじの紙・実況のスタジアム・RPG の窓は絵の色のままです
  • mod の版を上げたら、開いているほかのセッションも開き直してください。0.7.0 から記録は知らない欄を残して書くので、新しい版の記録は消えなくなりましたが、0.6 以前の mod を読んだまま動いているセッションは、新しい版で増えた欄(魚の種類・装備・SP の割り振りなど)を消して書き戻します
  • fail-log の記録は、同時に開いた全セッションで共有します。「今日」は日本時間の日付で、残している 200 件から数えます
  • todo-board は git の中では git grep で読みます(.gitignore の物は読みません)。git の外では作業フォルダを歩いて読み(2000 ファイル・5000 件・8 秒で止め、1 MiB を超えるファイルと . で始まるフォルダ・node_modules・dist などは読みません)、この時はファイルを変えても自動では読み直さないので「再読込」を押してください
  • daily-quest のお題は日本時間の日付だけで決まります。数えるのはメインのターンだけで、「テスト」はコマンドの名前の形(npm test・pytest など)で見ます。ごほうびは dev-rpg と turn-gacha がターンの終わりに 1 回だけ受け取ります
  • trophy-shelf はほかの mod の記録を読むだけです。入っていない mod の棚は「入っていません」と出ます。一度取ったトロフィーは、その mod を外しても残ります
  • cc-dash の「自分の並び」は記録に残るので、全セッションで共有されます
  • mod-check が版と「読み込みを拒まれた」を出せるのは、mod-check より後に読み込まれた mod だけです。「state まだ」は壊れているという意味ではありません(開
Source 5 files
hooks/register.tsx 242 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { QuestCounts, QuestDay, QuestSave } from '../types'
5import { addCounts, applyPending, countTool, countTurn, dayOf, EMPTY, fromSaved, isEmpty, jstDate, paneLines, paneSvg, streakOf, subCounts, withUnknown } from './quest'
6import { backup, backupDir, backupOf, closeRestore, resetRestore, restoreButton, restoreCard, setBackupDir } from './backup'
7import { followTheme, mayWrite, readTheme, rememberVersion } from './style'
8
9const PANE = 'daily-quest'
10const TITLE = 'きょうのお題'
11const DESCRIPTION = '日替わりのお題 3 つの進みと連続日数を見る'
12const STORE_KEY = 'quest'
13
14const TODAY = atom({ plugin: 'daily-quest', key: 'today' } as const, { date: '', quests: [] } as QuestDay)
15const STREAK = atom({ plugin: 'daily-quest', key: 'streak' } as const, 0)
16const TOTAL = atom({ plugin: 'daily-quest', key: 'totalDone' } as const, 0)
17const SAVED = atom({ plugin: 'daily-quest', key: 'saved' } as const, EMPTY as QuestSave)
18const PENDING = atom({ plugin: 'daily-quest', key: 'pending' } as const, { date: '', counts: {} } as { date: string; counts: QuestCounts })
19const ERRORS = atom({ plugin: 'daily-quest', key: 'turnErrors' } as const, 0)
20const NOTE = atom({ plugin: 'daily-quest', key: 'note' } as const, '')
21
22type $ = EngineInterface
23
24const BROKEN = '記録を読めなかったので、進みを残せません(上書きしないよう止めています)'
25
26// ponytail: 主の名前を持てたかはモジュール変数。リロードでも session.start が来て決め直す
27let ownsMain = false
28
29// store に書く物は 1 本ずつ流す。重なると同じ数を 2 回足してしまう
30let chain: Promise<unknown> = Promise.resolve()
31const serial = (f: () => Promise<unknown>) => (chain = chain.then(f, f))
32
33/** store の値 (と、知らない欄を残して書くための生の値)。キーが無ければ空、読めない時 (例外・形が違う) は undefined */
34async function readStore($: $): Promise<{ saved: QuestSave; raw: unknown } | undefined> {
35  try {
36    const raw = await $.store.get(STORE_KEY)
37    if (raw === undefined) return { saved: EMPTY, raw }
38    return typeof raw === 'object' && raw !== null && !Array.isArray(raw) ? { saved: fromSaved(raw), raw } : undefined
39  } catch {
40    return undefined
41  }
42}
43
44/** 保存した数とまだ流していない数から、今日のお題・連続日数・通算を置き直す (他の mod はこれを読む) */
45async function refresh($: $) {
46  const date = jstDate(await $.clock.now())
47  const saved = await read($, SAVED)
48  const pending = await read($, PENDING)
49  const counts = addCounts(saved.days[date] ?? {}, pending.date === date ? pending.counts : {})
50  await update($, TODAY, () => dayOf(date, counts))
51  await update($, STREAK, () => streakOf(saved.fullDays, date))
52  await update($, TOTAL, () => saved.totalDone)
53}
54
55async function load($: $) {
56  const got = await readStore($)
57  if (got) await update($, SAVED, () => got.saved)
58  else await update($, NOTE, () => BROKEN)
59  await refresh($)
60  // その日はじめてなら、読んだ生の値 (知らない欄ごと) を控える
61  if (backupDir) await backup($, STORE_KEY, got?.raw, await $.clock.now())
62}
63
64/** day の控えに戻し、メモリの中 (他の mod が読む今日のお題・連続日数・通算) も読み直す。古い版のままなら戻さない */
65const restoreDay = ($: $, day: string) =>
66  serial(async () => {
67    if (!(await mayWrite($, await $.clock.now()))) return
68    const value = await backupOf($, STORE_KEY, day)
69    if (typeof value !== 'object' || value === null || Array.isArray(value)) {
70      $.ui.toast('控えを読めなかったので、戻していません', { timeoutMs: 8000 })
71      return
72    }
73    const isSaved = await $.store.set(STORE_KEY, value).then(
74      () => true,
75      () => false,
76    )
77    if (!isSaved) {
78      $.ui.toast('控えの記録を書けませんでした', { timeoutMs: 8000 })
79      return
80    }
81    await update($, NOTE, () => '')
82    await load($)
83    closeRestore($)
84    $.ui.toast(`${day} の記録に戻しました`)
85  })
86
87/**
88 * まだ流していない数を store に足す。書く直前に読み直す (別のセッションの分を消さない)。
89 * 新しくこなしたお題はここでトーストを 1 回出す
90 */
91const flush = ($: $) =>
92  serial(async () => {
93    const p = await read($, PENDING)
94    if (p.date === '' || isEmpty(p.counts)) return
95    const got = await readStore($)
96    if (!got) {
97      await update($, NOTE, () => BROKEN)
98      return
99    }
100    const store = got.saved
101    const now = await $.clock.now()
102    // 日をまたいで動き続けるセッションも、その日はじめて書く前に控える
103    if (backupDir) await backup($, STORE_KEY, got.raw, now)
104    // 新しい版が入ったのに古い版のまま動いている時は書かない。進みは流さずに残す (トーストで開き直しを頼む)
105    if (!(await mayWrite($, now))) return
106    const r = applyPending(store, p.date, p.counts)
107    // この版の知らない欄 (新しい版が足した物) は読んだまま残す
108    const isSaved = await $.store.set(STORE_KEY, withUnknown(got.raw, r.next)).then(
109      () => true,
110      () => false,
111    )
112    if (!isSaved) {
113      // 流せなかった数は残して、次のターンの終わりにもう一度
114      await update($, SAVED, () => store)
115      await update($, NOTE, () => '進みを保存できませんでした。次のターンの終わりにもう一度書きます')
116      await refresh($)
117      return
118    }
119    // 書いた分だけ引く (その間に増えた分は残す)
120    await update($, PENDING, cur => (cur.date === p.date ? { date: cur.date, counts: subCounts(cur.counts, p.counts) } : cur))
121    await update($, SAVED, () => r.next)
122    await update($, NOTE, () => '')
123    await refresh($)
124    for (const q of r.newly) $.ui.toast(`お題「${q.title}」をこなした`)
125    if (r.isFull) $.ui.toast(`きょうのお題を全部こなした。連続 ${await read($, STREAK)} 日`)
126  })
127
128/** 数を足す。日付が変わっていたら、前の日の分を先に流す */
129async function bump($: $, counts: QuestCounts) {
130  if (isEmpty(counts)) return
131  const date = jstDate(await $.clock.now())
132  const p = await read($, PENDING)
133  if (p.date !== date && p.date !== '' && !isEmpty(p.counts)) await flush($)
134  await update($, PENDING, cur => (cur.date === date ? { date, counts: addCounts(cur.counts, counts) } : { date, counts }))
135  await refresh($)
136}
137
138const str = (v: unknown) => (typeof v === 'string' ? v : '')
139
140const openPane = async ($: $) => {
141  await refresh($)
142  await $.ui.open({ id: PANE, title: TITLE })
143  return { text: 'きょうのお題を開きました。' }
144}
145
146export const register: Register = (on, options) => {
147  setBackupDir(options)
148  followTheme(on)
149
150  on('session.start', async ($, e, next) => {
151    const started = await next(e)
152    await readTheme($)
153    await rememberVersion($).catch(() => {})
154    resetRestore()
155    // /quest が拒まれたら /daily-quest にする
156    const got = await $.command.register({ name: 'quest', description: DESCRIPTION }).catch(() => undefined)
157    ownsMain = got?.command === 'quest'
158    if (!ownsMain) await $.command.register({ name: 'daily-quest', description: DESCRIPTION }).catch(() => {})
159    await load($)
160    // 日付が変わったら、お題を置き直す
161    $.clock.every(60_000, async () => {
162      if ((await read($, TODAY)).date !== jstDate(await $.clock.now())) await refresh($)
163    })
164    return started
165  })
166
167  // /quest を持てなかった時は、その名前の持ち主に回す
168  on('command.run', { command: 'quest' }, ($, e, next) => (ownsMain ? openPane($) : next(e))).catch(($, e, next) => next(e))
169  // /quest を持てた時は /daily-quest を登録していないので、その名前の持ち主に回す
170  on('command.run', { command: 'daily-quest' }, ($, e, next) => (ownsMain ? next(e) : openPane($))).catch(($, e, next) => next(e))
171
172  on('session.end', async ($, e, next) => {
173    await flush($)
174    return next(e)
175  }).catch(($, e, next) => next(e))
176
177  // メインのターンだけ数える (サブエージェントの分は agentId 付き)
178  on('turn.start', async ($, e, next) => {
179    if (e.agentId === undefined) await update($, ERRORS, () => 0)
180    return next(e)
181  }).catch(($, e, next) => next(e))
182
183  // 見るだけ。断られた呼び出し (deny) は数えない。Write は前にファイルが無かった時だけ「新しいファイル」
184  on('tool.call', async ($, e, next) => {
185    if (e.agentId !== undefined) return next(e)
186    const tool = String(e.tool)
187    const input = e as unknown as Record<string, unknown>
188    const path = str(input.file_path)
189    // 確かめられない時はあった扱い (こなしていないお題を達成にしない)
190    const isNew = tool === 'Write' && path !== '' ? !(await $.fs.exists(path).catch(() => true)) : false
191    const ran = await next(e)
192    if (ran.deny !== undefined) return ran
193    const isError = ran.isError === true
194    if (isError) await update($, ERRORS, n => n + 1)
195    await bump($, countTool(tool, str(input.command), isError, isNew))
196    return ran
197  }).catch(($, e, next) => next(e))
198
199  // 進みは next の前に store へ流す (dev-rpg と turn-gacha は next の後にごほうびを見るので、同じターンで届く)
200  on('turn.complete', async ($, e, next) => {
201    if (e.agentId === undefined) {
202      if (e.reason === 'answer') await bump($, countTurn(await read($, ERRORS)))
203      await flush($)
204    }
205    return next(e)
206  }).catch(($, e, next) => next(e))
207
208  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
209    const t = $.ui.resolve(e)
210    const { Box, Text } = t
211    // terminal の表にも Svg の名前があるので、画面の種類でも分ける
212    const Svg = e.surface !== 'terminal' && 'Svg' in t ? t.Svg : undefined
213    const day = await read($, TODAY)
214    const streak = await read($, STREAK)
215    const total = await read($, TOTAL)
216    const { fullDays } = await read($, SAVED)
217    const note = await read($, NOTE)
218    const lines = paneLines(day, streak, total, fullDays)
219
220    return (
221      <Box flexDirection="column" gap={1}>
222        {Svg ? (
223          <Svg key="quest" source={paneSvg(day, streak, total, fullDays)} alt={lines.map(l => l.text).join('\n')} />
224        ) : (
225          <Box key="lines" flexDirection="column">
226            {lines.map((l, i) => (
227              <Text key={`line-${i}`} color={l.color} dimColor={l.isDim} bold={l.isBold}>
228                {l.text}
229              </Text>
230            ))}
231          </Box>
232        )}
233        {note !== '' && <Text color="yellow">{note}</Text>}
234        <Box key="actions" flexDirection="row" flexWrap="wrap" gap={1}>
235          {restoreButton($, t, STORE_KEY)}
236        </Box>
237        {restoreCard($, t, day => void restoreDay($, day))}
238      </Box>
239    )
240  })
241}
242
hooks/quest.ts 290 lines
1// お題の表・日付から決まる選び方・数え方・保存の形・SVG と terminal 用の行。どれも純関数
2import type { Quest, QuestCounts, QuestDay, QuestKind, QuestReward, QuestSave } from '../types'
3import { C, countUp, esc, frame, growBar, header, HEADER_H, panel } from './style'
4
5// ---------------------------------------------------------------- 日付 (JST)
6
7/** JST の日付 `YYYY-MM-DD` (Date のタイムゾーンに頼らない) */
8export const jstDate = (ms: number) => new Date(ms + 9 * 3600_000).toISOString().slice(0, 10)
9
10/** date で終わる直近 n 日 (古い順) */
11export function lastDays(date: string, n = 7) {
12  const t = Date.parse(`${date}T00:00:00Z`)
13  return Array.from({ length: n }, (_, i) => new Date(t - (n - 1 - i) * 86_400_000).toISOString().slice(0, 10))
14}
15
16const prevDay = (date: string) => lastDays(date, 2)[0]!
17
18/** 10/21 の形 */
19export const md = (date: string) => `${Number(date.slice(5, 7))}/${Number(date.slice(8, 10))}`
20
21// ---------------------------------------------------------------- お題の表
22
23type Kind = { kind: QuestKind; title: (n: number) => string; goals: readonly [number, number, number] }
24
25/** お題の種類。ふつうに作業していればこなせる物だけ。goals は易しい順 */
26export const KINDS: readonly Kind[] = [
27  { kind: 'turns', title: n => `ターンを ${n} 回終える`, goals: [5, 10, 15] },
28  { kind: 'edits', title: n => `ファイルを ${n} 回直す`, goals: [5, 10, 20] },
29  { kind: 'tests', title: n => `テストを ${n} 回走らせる`, goals: [1, 2, 3] },
30  { kind: 'commits', title: n => `コミットを ${n} 回する`, goals: [1, 2, 3] },
31  { kind: 'clean', title: n => `失敗なしのターンを ${n} 回`, goals: [3, 5, 8] },
32  { kind: 'newFiles', title: n => `新しいファイルを ${n} 個作る`, goals: [1, 2, 3] },
33  { kind: 'reads', title: n => `ファイルを ${n} 回調べる`, goals: [10, 20, 30] },
34  { kind: 'shells', title: n => `コマンドを ${n} 回実行する`, goals: [5, 10, 15] },
35]
36
37/** ごほうびは難しさ (goals の何番目か) で決まる。経験値は dev-rpg の 1 レベル (60 から) の半分くらいまで */
38export const REWARDS: readonly [QuestReward, QuestReward, QuestReward] = [
39  { exp: 20, pulls: 1 },
40  { exp: 35, pulls: 1 },
41  { exp: 50, pulls: 2 },
42]
43
44export const PER_DAY = 3
45
46// 乱数 (FNV-1a で種を作り、mulberry32 で回す)
47function hash(s: string) {
48  let h = 0x811c9dc5
49  for (let i = 0; i < s.length; i++) {
50    h ^= s.charCodeAt(i)
51    h = Math.imul(h, 0x01000193)
52  }
53  return h >>> 0
54}
55
56function rng(seed: number) {
57  let a = seed
58  return () => {
59    a = (a + 0x6d2b79f5) | 0
60    let t = Math.imul(a ^ (a >>> 15), 1 | a)
61    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
62    return ((t ^ (t >>> 14)) >>> 0) / 4294967296
63  }
64}
65
66export type QuestPick = { kind: QuestKind; id: string; title: string; goal: number; reward: QuestReward }
67
68/** その日のお題 3 つ。日付だけから決まる (同じ日なら何度呼んでも同じ) */
69export function questsFor(date: string): QuestPick[] {
70  const r = rng(hash(`daily-quest|${date}`))
71  const pool = [...KINDS]
72  const out: QuestPick[] = []
73  for (let i = 0; i < PER_DAY && pool.length > 0; i++) {
74    const k = pool.splice(Math.floor(r() * pool.length), 1)[0]!
75    const tier = Math.min(2, Math.floor(r() * 3))
76    const goal = k.goals[tier]!
77    out.push({ kind: k.kind, id: `${k.kind}-${goal}`, title: k.title(goal), goal, reward: { ...REWARDS[tier]! } })
78  }
79  return out
80}
81
82/** その日の数からお題の進みを作る (進みは goal で止める) */
83export function dayOf(date: string, counts: QuestCounts): QuestDay {
84  return {
85    date,
86    quests: questsFor(date).map(({ id, title, goal, reward, kind }): Quest => {
87      const progress = Math.min(goal, counts[kind] ?? 0)
88      return { id, title, goal, progress, done: progress >= goal, reward }
89    }),
90  }
91}
92
93// ---------------------------------------------------------------- 数え方
94
95const KIND_IDS = KINDS.map(k => k.kind)
96const EDITORS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
97const READERS = new Set(['Read', 'Grep', 'Glob'])
98const SHELLS = new Set(['Bash', 'PowerShell'])
99
100/** テストを走らせるコマンドか (中身は見ない。名前の形だけ) */
101const TEST_RES = [
102  /\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?test\b/,
103  /\b(?:cargo|go|dotnet|deno|bun|mix|swift|flutter|zig)\s+test\b/,
104  /\b(?:pytest|jest|vitest|mocha|phpunit|rspec|ctest|tox)\b/,
105  /\bpython[0-9.]*\s+-m\s+(?:pytest|unittest)\b/,
106  /\bnode\s+--test\b/,
107  /\bplugin\s+test\b/,
108  /\b(?:gradlew?|mvn)\b[^|;&\n]*\btest\b/,
109  /\bInvoke-Pester\b/i,
110]
111export const isTestCommand = (cmd: string) => TEST_RES.some(re => re.test(cmd))
112/** git commit (git と commit の間にオプションがあってもよい) */
113export const isCommitCommand = (cmd: string) => /\bgit\b[^|;&\n]*\scommit\b/.test(cmd)
114
115/**
116 * ツール 1 回で増える数。テストは落ちても「走らせた」に数える。
117 * isNew は Write の前にファイルが無かった時だけ true
118 */
119export function countTool(tool: string, command: string, isError: boolean, isNew: boolean): QuestCounts {
120  const out: QuestCounts = {}
121  const isShell = SHELLS.has(tool)
122  if (isShell && isTestCommand(command)) out.tests = 1
123  if (isError) return out
124  if (EDITORS.has(tool)) out.edits = 1
125  if (tool === 'Write' && isNew) out.newFiles = 1
126  if (READERS.has(tool)) out.reads = 1
127  if (isShell) {
128    out.shells = 1
129    if (isCommitCommand(command)) out.commits = 1
130  }
131  return out
132}
133
134/** 答えて終わったメインのターン 1 回で増える数 */
135export const countTurn = (errors: number): QuestCounts => (errors === 0 ? { turns: 1, clean: 1 } : { turns: 1 })
136
137export const addCounts = (a: QuestCounts, b: QuestCounts): QuestCounts => {
138  const out: QuestCounts = { ...a }
139  for (const k of KIND_IDS) if (b[k]) out[k] = (out[k] ?? 0) + b[k]!
140  return out
141}
142
143/** a から b を引く (0 になった種類は消す) */
144export const subCounts = (a: QuestCounts, b: QuestCounts): QuestCounts => {
145  const out: QuestCounts = {}
146  for (const k of KIND_IDS) {
147    const n = (a[k] ?? 0) - (b[k] ?? 0)
148    if (n > 0) out[k] = n
149  }
150  return out
151}
152
153export const isEmpty = (c: QuestCounts) => KIND_IDS.every(k => !c[k])
154
155// ---------------------------------------------------------------- 保存の形
156
157export const EMPTY: QuestSave = { days: {}, fullDays: [], totalDone: 0 }
158const KEEP_DAYS = 7
159const KEEP_FULL = 400
160const DATE_RE = /^\d{4}-\d{2}-\d{2}$/
161const num = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? Math.floor(v) : 0)
162
163/** store から読んだ値を形にそろえる。知らない種類や壊れた値は捨てる */
164export function fromSaved(raw: unknown): QuestSave {
165  if (typeof raw !== 'object' || raw === null) return EMPTY
166  const o = raw as Record<string, unknown>
167  const days: QuestSave['days'] = {}
168  if (typeof o.days === 'object' && o.days !== null)
169    for (const [date, v] of Object.entries(o.days as Record<string, unknown>)) {
170      if (!DATE_RE.test(date) || typeof v !== 'object' || v === null) continue
171      const c: QuestCounts = {}
172      for (const k of KIND_IDS) if (num((v as Record<string, unknown>)[k]) > 0) c[k] = num((v as Record<string, unknown>)[k])
173      days[date] = c
174    }
175  const fullDays = Array.isArray(o.fullDays) ? [...new Set(o.fullDays.filter((d): d is string => typeof d === 'string' && DATE_RE.test(d)))].sort().slice(-KEEP_FULL) : []
176  return { days, fullDays, totalDone: num(o.totalDone) }
177}
178
179const isObj = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
180
181/** raw のうち known に無い欄 (この版が知らない、新しい版が書いた物)。物でなければ空 */
182export function unknownOf(raw: unknown, known: Iterable<string>): Record<string, unknown> {
183  if (!isObj(raw)) return {}
184  const k = new Set(known)
185  return Object.fromEntries(Object.entries(raw).filter(([key]) => !k.has(key)))
186}
187
188/**
189 * store に書く形。書く直前に読んだ raw のうち、この版の知らない欄 (新しい版の欄・日付でないキー・知らないお題の種類の数) を残して mine を重ねる。
190 * 古い版のまま動いているセッションが、新しい版の記録を消さないように。刈り込んだ古い日は戻さない
191 */
192export function withUnknown(raw: unknown, mine: QuestSave): Record<string, unknown> {
193  const o = isObj(raw) ? raw : {}
194  const was = isObj(o.days) ? o.days : {}
195  const days = unknownOf(was, Object.keys(was).filter(d => DATE_RE.test(d)))
196  for (const [d, c] of Object.entries(mine.days)) days[d] = { ...unknownOf(was[d], KIND_IDS), ...c }
197  return { ...unknownOf(o, Object.keys(EMPTY)), ...mine, days }
198}
199
200/**
201 * 今の store に、まだ流していない数 (その日の分) を足す。
202 * 新しくこなしたお題と、その日のお題を全部こなしたところかを返す
203 */
204export function applyPending(save: QuestSave, date: string, pending: QuestCounts) {
205  const before = dayOf(date, save.days[date] ?? {})
206  const counts = addCounts(save.days[date] ?? {}, pending)
207  const after = dayOf(date, counts)
208  const newly = after.quests.filter((q, i) => q.done && !before.quests[i]?.done)
209  const isFull = after.quests.every(q => q.done) && !before.quests.every(q => q.done)
210  const keep = new Set(lastDays(date, KEEP_DAYS))
211  const days: QuestSave['days'] = {}
212  for (const [d, c] of Object.entries({ ...save.days, [date]: counts })) if (keep.has(d) || d > date) days[d] = c
213  const fullDays = isFull ? [...new Set([...save.fullDays, date])].sort().slice(-KEEP_FULL) : save.fullDays
214  return { next: { days, fullDays, totalDone: save.totalDone + newly.length } as QuestSave, newly, isFull }
215}
216
217/** 全部こなした日が続いている日数。今日がまだなら昨日から数える (今日の途中で 0 にしない) */
218export function streakOf(fullDays: readonly string[], today: string) {
219  const set = new Set(fullDays)
220  let d = set.has(today) ? today : prevDay(today)
221  let n = 0
222  while (set.has(d)) {
223    n++
224    d = prevDay(d)
225  }
226  return n
227}
228
229// ---------------------------------------------------------------- 描画
230
231export const rewardText = (r: QuestReward) => `経験値 +${r.exp} / ガチャ ${r.pulls} 回`
232export const HINT = 'ごほうびは dev-rpg と turn-gacha が受け取ります'
233
234/** ペインの絵: お題 3 つの進みの棒・ごほうび・連続日数と直近 7 日 */
235export function paneSvg(day: QuestDay, streak: number, totalDone: number, fullDays: readonly string[]): string {
236  let b = header('きょうのお題', day.date === '' ? '' : `${md(day.date)} 連続 ${streak} 日`, C.yellow)
237  let y = HEADER_H + 10
238  if (day.quests.length === 0) {
239    b += `<text x="190" y="${y + 24}" font-size="13" fill="${C.weak}" text-anchor="middle">お題を読み込んでいます</text>`
240    y += 40
241  }
242  day.quests.forEach((q, i) => {
243    const frac = q.goal > 0 ? Math.min(1, q.progress / q.goal) : 0
244    b += panel(8, y, 364, 74)
245    b += `<rect x="8" y="${y + 12}" width="4" height="50" rx="2" fill="${q.done ? C.green : C.line}"/>`
246    b += `<text x="24" y="${y + 25}" font-size="14" font-weight="bold" fill="${C.text}">${esc(q.title)}</text>`
247    b += `<text x="358" y="${y + 25}" font-size="13" font-weight="bold" fill="${q.done ? C.green : C.dim}" text-anchor="end">${q.done ? '達成' : `${q.progress} / ${q.goal}`}</text>`
248    b += `<rect x="24" y="${y + 35}" width="334" height="10" rx="5" fill="${C.bg}" stroke="${C.line}"/>`
249    if (frac > 0) b += growBar(24, y + 35, 334 * frac, 10, q.done ? C.green : C.blue, i * 0.15)
250    b += `<text x="24" y="${y + 63}" font-size="12" fill="${C.weak}">ごほうび ${rewardText(q.reward)}</text>`
251    y += 82
252  })
253
254  // 連続日数と通算、直近 7 日 (全部こなした日を緑に)
255  b += panel(8, y, 364, 112)
256  b += `<text x="24" y="${y + 22}" font-size="12" fill="${C.weak}">連続</text>`
257  b += countUp(streak, `x="24" y="${y + 52}" font-size="26" font-weight="bold" fill="${streak > 0 ? C.yellow : C.dim}"`, n => `${n} 日`)
258  b += `<text x="200" y="${y + 22}" font-size="12" fill="${C.weak}">通算でこなしたお題</text>`
259  b += countUp(totalDone, `x="200" y="${y + 52}" font-size="26" font-weight="bold" fill="${C.text}"`, n => `${n} 個`)
260  const full = new Set(fullDays)
261  lastDays(day.date === '' ? '1970-01-01' : day.date, 7).forEach((d, i) => {
262    const x = 24 + i * 48
263    const isToday = d === day.date
264    b +=
265      `<rect x="${x}" y="${y + 66}" width="40" height="16" rx="4" fill="${full.has(d) ? C.green : C.bg}" stroke="${isToday ? C.yellow : C.line}"${isToday ? ' stroke-width="2"' : ''}/>` +
266      `<text x="${x + 20}" y="${y + 100}" font-size="12" fill="${isToday ? C.text : C.weak}" text-anchor="middle">${md(d)}</text>`
267  })
268  y += 120
269  b += `<text x="190" y="${y + 10}" font-size="12" fill="${C.weak}" text-anchor="middle">${HINT}</text>`
270  return frame(y + 24, b)
271}
272
273export type Line = { text: string; color?: string; isDim?: boolean; isBold?: boolean }
274
275/** Svg の alt と terminal で使う行 */
276export function paneLines(day: QuestDay, streak: number, totalDone: number, fullDays: readonly string[]): Line[] {
277  const week = day.date === '' ? 0 : lastDays(day.date, 7).filter(d => fullDays.includes(d)).length
278  return [
279    { text: `きょうのお題 ${day.date === '' ? '' : md(day.date)} 連続 ${streak} 日 通算 ${totalDone} 個`, isBold: true },
280    ...day.quests.map(
281      (q): Line => ({
282        text: `${q.done ? '達成' : 'まだ'} ${q.title} ${q.progress}/${q.goal} ごほうび ${rewardText(q.reward)}`,
283        color: q.done ? 'green' : undefined,
284      }),
285    ),
286    { text: `この 7 日で全部こなした日 ${week} 日`, isDim: true },
287    { text: HINT, isDim: true },
288  ]
289}
290
hooks/backup.tsx 102 lines
1// 記録の控え (設定 backupDir) と、ペインの「控えから戻す」欄 (docs/mod-guide.md の 9 節)。
2// turn-gacha・daily-quest・wait-games が同じ物を持つ。戻す欄の開け閉めは $.state に置かず、
3// モジュールの値で持って描き直しを頼む (state の key を増やさない)
4import type { ElementConstructor, BoxProps, ButtonProps, EngineInterface, PluginOptions, TextProps } from 'claude-code'
5
6import { C, backupDaily, jstDay, listBackups } from './style'
7
8type $ = EngineInterface
9
10const isAbsolute = (p: string) => /^([a-zA-Z]:[\\/]|[\\/])/.test(p)
11
12/** 記録の控えのフォルダ (設定 backupDir)。絶対パスだけ。空なら控えない */
13export let backupDir = ''
14export const setBackupDir = (options: PluginOptions) => {
15  const dir = String(options.backupDir ?? '').trim()
16  backupDir = isAbsolute(dir) ? dir : ''
17}
18
19/** key ごとに、今日もう控えを確かめた日 (1 日 1 回だけファイルを読みに行く) */
20const backedDay = new Map<string, string>()
21export const isBackedToday = (key: string, now: number) => !backupDir || backedDay.get(key) === jstDay(now)
22
23/** その日はじめての時だけ、store の生の値 (知らない欄ごと) を控える */
24export async function backup($: $, key: string, raw: unknown, now: number) {
25  if (isBackedToday(key, now) || raw === undefined) return
26  backedDay.set(key, jstDay(now))
27  // 書けない時も、その日はもう試さない (ターンのたびに読み書きしない)
28  await backupDaily($, backupDir, key, raw, now).catch(() => {})
29}
30
31/** day の key の控えの値。無い・読めない時は undefined */
32export async function backupOf($: $, key: string, day: string) {
33  return (await listBackups($, backupDir, key).catch(() => [])).find(b => b.day === day)?.value
34}
35
36// ---- 戻す欄 ----
37
38/** null は閉じている。days は控えのある日 (新しい順)、pick は選んだ日 ('' はまだ) */
39let view: { days: string[]; pick: string } | null = null
40export const isRestoring = () => view !== null
41
42function show($: $, v: typeof view) {
43  view = v
44  $.ui.invalidate('ui.render')
45}
46
47/** セッションの始まりに閉じる (リロードの前の欄を残さない) */
48export const resetRestore = () => {
49  view = null
50  backedDay.clear()
51}
52
53export function closeRestore($: $) {
54  show($, null)
55}
56
57/** 控えのある日を並べて開く。key は日を並べる元の記録 */
58export async function openRestore($: $, key: string) {
59  const days = (await listBackups($, backupDir, key).catch(() => [])).map(b => b.day)
60  show($, { days, pick: '' })
61}
62
63type Parts = { Box: ElementConstructor<BoxProps>; Text: ElementConstructor<TextProps>; Button: ElementConstructor<ButtonProps> }
64
65/** 「控えから戻す」のボタン (脇の操作)。開いている時は「戻すのをやめる」 */
66export function restoreButton($: $, { Button }: Parts, key: string) {
67  return <Button key="restore" dimColor label={view ? '戻すのをやめる' : '控えから戻す'} onPress={() => void (view ? closeRestore($) : openRestore($, key))} />
68}
69
70/** 戻す欄: 日を選ぶ → 確かめる → 戻す。onRestore は選んだ日を受け取って書く */
71export function restoreCard($: $, { Box, Text, Button }: Parts, onRestore: (day: string) => void) {
72  const v = view
73  if (!v) return null
74  return (
75    <Box key="restore-card" flexDirection="column" borderStyle="round" borderColor={C.yellow} paddingX={1} paddingY={1} gap={1}>
76      <Text bold color={C.yellow}>
77        控えから戻す
78      </Text>
79      {!backupDir ? (
80        <Text dimColor>設定の backupDir を入れると控えが取れます</Text>
81      ) : v.days.length === 0 ? (
82        <Text dimColor>まだ控えがありません (1 日 1 回取ります)</Text>
83      ) : (
84        <Box flexDirection="row" flexWrap="wrap" gap={1}>
85          {v.days.map(day => (
86            <Button key={`day-${day}`} dimColor={v.pick !== day} label={day} onPress={() => show($, view ? { ...view, pick: day } : null)} />
87          ))}
88        </Box>
89      )}
90      {v.pick ? (
91        <Box flexDirection="column" gap={1}>
92          <Text>{`${v.pick}: この日の記録に戻しますか。今の記録は上書きされます`}</Text>
93          <Box flexDirection="row" gap={1}>
94            <Button key="restore-ok" variant="primary" label="戻す" onPress={() => onRestore(v.pick)} />
95            <Button key="restore-no" dimColor label="やめる" onPress={() => show($, view ? { ...view, pick: '' } : null)} />
96          </Box>
97        </Box>
98      ) : null}
99    </Box>
100  )
101}
102
hooks/style.ts 315 lines
1// 全 mod で共通の見た目(色・見出し・動き)と小物。
2// 正本は shared/style.ts。各 mod の hooks/style.ts はその写しなので、直すのは正本だけにして
3// `node tools/copy_shared.mts` で写す(`--check` で写しのずれを見つける)。
4import type { EngineInterface, On } from 'claude-code'
5
6export type Palette = {
7  bg: string
8  face: string
9  line: string
10  text: string
11  dim: string
12  weak: string
13  blue: string
14  green: string
15  yellow: string
16  red: string
17  purple: string
18  orange: string
19}
20
21/** GitHub Dark 系(docs/mod-guide.md の 7 節) */
22export const DARK: Palette = {
23  bg: '#0d1117',
24  face: '#161b22',
25  line: '#30363d',
26  text: '#c9d1d9',
27  dim: '#8b949e',
28  weak: '#8b949e',
29  blue: '#58a6ff',
30  green: '#3fb950',
31  yellow: '#d29922',
32  red: '#f85149',
33  purple: '#bc8cff',
34  orange: '#f78166',
35}
36
37/** GitHub Light 系 */
38export const LIGHT: Palette = {
39  bg: '#ffffff',
40  face: '#f6f8fa',
41  line: '#d0d7de',
42  text: '#1f2328',
43  dim: '#656d76',
44  weak: '#656d76',
45  blue: '#0969da',
46  green: '#1a7f37',
47  yellow: '#9a6700',
48  red: '#cf222e',
49  purple: '#8250df',
50  orange: '#bc4c00',
51}
52
53// ponytail: 今の色はモジュールの変数 1 個(この mod の中だけ)。SVG を作る関数は C.bg などを呼ぶたびに読むので、
54// 引数を増やさずにテーマが変わる。描く途中で切り替わることは無い(JS は 1 本で走る)
55/** 今の色。setTheme で中身が入れ替わる(同じ物を指し続けるので、import した C のままでよい) */
56export const C: Palette = { ...DARK }
57let isLightNow = false
58
59export const isLight = () => isLightNow
60
61/** /config の theme の値が明るい系か('light'・'light-daltonized'・'light-ansi' など) */
62export const isLightTheme = (value: unknown) => typeof value === 'string' && value.startsWith('light')
63
64export function setTheme(light: boolean) {
65  isLightNow = light
66  Object.assign(C, light ? LIGHT : DARK)
67}
68
69/**
70 * 今の /config の theme を読んで色を合わせる。mod 自身の session.start の中で呼ぶ
71 * (同じイベントに matcher 無しのフックを 2 つ置くと読み込みを拒まれるので、ここでは on しない)。
72 * VS Code の配色は mod から読めないので、Claude Code の theme が light 系の時だけ明るくなる
73 */
74export async function readTheme($: EngineInterface) {
75  const rows = await $.config.list().catch(() => [])
76  setTheme(isLightTheme(rows.find(r => r.key === 'theme')?.value))
77}
78
79/** /config で theme が変わったら色を替えて描き直す。register の中で 1 回呼ぶ */
80export function followTheme(on: On) {
81  on('config.set', { key: 'theme' }, async ($, e, next) => {
82    const r = await next(e)
83    if (r.deny === undefined) {
84      setTheme(isLightTheme(r.value))
85      $.ui.invalidate('ui.render')
86    }
87    return r
88  })
89}
90
91// ---- 小物 ----
92
93/** 外から来る文字列の制御文字を落とす。エンジンは制御文字を含む Text の子や alt を拒み、ペインが閉じる */
94export const clean = (s: string) => s.replace(/[\t\n\r]+/g, ' ').replace(/[\x00-\x1f\x7f-\x9f]/g, '').trim()
95
96/** SVG の文字に入れる前のエスケープ(制御文字も落とす) */
97export const esc = (s: string) =>
98  clean(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
99
100// ---- 時間帯と季節 (日本時間 JST = UTC+9 固定。動いている環境のタイムゾーンには頼らない) ----
101
102export type DayPart = '朝' | '昼' | '夕' | '夜'
103export type Season = '春' | '夏' | '秋' | '冬'
104
105const jstDate = (now: number) => new Date(now + 9 * 3_600_000)
106
107/** 時間帯。朝 5-10 時 / 昼 10-16 時 / 夕 16-19 時 / 夜 19-5 時 */
108export const dayPart = (now: number): DayPart => {
109  const h = jstDate(now).getUTCHours()
110  return h >= 5 && h < 10 ? '朝' : h >= 10 && h < 16 ? '昼' : h >= 16 && h < 19 ? '夕' : '夜'
111}
112
113/** 季節。春 3-5 月 / 夏 6-8 月 / 秋 9-11 月 / 冬 12-2 月 */
114export const season = (now: number): Season => {
115  const m = jstDate(now).getUTCMonth() + 1
116  return m >= 3 && m <= 5 ? '春' : m >= 6 && m <= 8 ? '夏' : m >= 9 && m <= 11 ? '秋' : '冬'
117}
118
119/** n 文字を超えたら … で切る */
120export const short = (s: string, n: number) => (s.length > n ? `${s.slice(0, Math.max(0, n - 1))}…` : s)
121
122// ---- 記録を守る(docs/mod-guide.md の 9 節) ----
123
124/** JST の YYYY-MM-DD */
125export const jstDay = (now: number) => jstDate(now).toISOString().slice(0, 10)
126
127export type Backup = { day: string; value: unknown }
128
129// ponytail: $.fs に消す手が無いので、曜日ごとの 7 つの枠を上書きして 7 日分だけ残す。もっと残すなら枠を日付にして process で消す
130const backupPath = (dir: string, plugin: string, key: string, wd: number) => `${dir.replace(/[\\/]+$/, '')}/${plugin}/${key}-${wd}.json`
131
132/**
133 * $.store の key の値を、その日はじめて呼ばれた時だけ dir に控える(曜日ごとの枠で 7 日分)。
134 * dir が空なら何もしない(公開の既定は空。入れた人が backupDir を設定する)。書いたら true
135 */
136export async function backupDaily($: EngineInterface, dir: string, key: string, value: unknown, now: number): Promise<boolean> {
137  if (!dir.trim() || value === undefined) return false
138  const day = jstDay(now)
139  const path = backupPath(dir, $.plugin.name, key, jstDate(now).getUTCDay())
140  // 壊れた枠(途中まで・null)は無いのと同じに扱って上書きする
141  const old = await $.fs.read(path).then(t => parseBackup(t)?.day ?? '', () => '')
142  if (old === day) return false
143  await $.fs.write(path, JSON.stringify({ day, value } satisfies Backup))
144  return true
145}
146
147/** 控えの 1 枠を読む。壊れていれば undefined */
148export function parseBackup(text: string): Backup | undefined {
149  try {
150    const b = JSON.parse(text) as Partial<Backup> | null
151    return b && typeof b.day === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(b.day) && b.value !== undefined ? (b as Backup) : undefined
152  } catch {
153    return undefined
154  }
155}
156
157/** 控えの一覧。新しい日から順。読めない枠・壊れた枠は飛ばす */
158export async function listBackups($: EngineInterface, dir: string, key: string): Promise<Backup[]> {
159  if (!dir.trim()) return []
160  const all = await Promise.all(
161    [0, 1, 2, 3, 4, 5, 6].map(wd =>
162      $.fs.read(backupPath(dir, $.plugin.name, key, wd)).then(parseBackup, () => undefined),
163    ),
164  )
165  return all.filter((b): b is Backup => b !== undefined).sort((a, b) => b.day.localeCompare(a.day))
166}
167
168let loadedPrint: string | undefined
169let staleAt = 0
170let isStaleNow = false
171let toldStale = false
172
173const normPath = (p: string) => p.replace(/[\\/]+/g, '/').replace(/\/$/, '').toLowerCase()
174
175/** FNV-1a 32bit。中身が変わったかを見るだけ */
176function fnv(s: string) {
177  let h = 0x811c9dc5
178  for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 0x01000193)
179  return (h >>> 0).toString(16)
180}
181
182/**
183 * 今ディスクにある、この mod の指紋。plugin.json と hooks の .ts / .tsx の中身から作る
184 * (版の数字を上げずに中身だけ変えた時も変わる)。plugin.json が読めなければ undefined
185 */
186async function diskPrint($: EngineInterface): Promise<string | undefined> {
187  const root = $.plugin.root
188  let manifest: string | undefined
189  for (const p of [`${root}/.claude-plugin/plugin.json`, `${root}/plugin.json`]) {
190    manifest = await $.fs.read(p).then(t => t, () => undefined)
191    if (manifest !== undefined) break
192  }
193  if (manifest === undefined) return undefined
194  const names = await $.fs.list(`${root}/hooks`).then(
195    es => es.filter(e => e.kind === 'file' && /\.tsx?$/.test(e.name)).map(e => e.name).sort(),
196    () => [] as string[],
197  )
198  const parts = await Promise.all(names.map(n => $.fs.read(`${root}/hooks/${n}`).then(t => `${n}\n${t}`, () => n)))
199  return fnv([manifest, ...parts].join('\0'))
200}
201
202/**
203 * マーケットプレイスから入れた形(<plugins>/cache/<market>/<name>/<版>)なら、installed_plugins.json に
204 * 今入っている場所が自分と違うか。違えば新しい版が入っている。分からなければ false
205 */
206async function replacedInCache($: EngineInterface): Promise<boolean> {
207  const root = normPath($.plugin.root)
208  const at = root.lastIndexOf('/plugins/cache/')
209  if (at < 0) return false
210  const raw = await $.fs.read(`${$.plugin.root.slice(0, at + '/plugins'.length)}/installed_plugins.json`).then(t => t, () => '')
211  try {
212    const plugins = (JSON.parse(raw) as { plugins?: Record<string, { installPath?: unknown }[]> }).plugins ?? {}
213    const mine = Object.entries(plugins)
214      .filter(([k]) => k.startsWith(`${$.plugin.name}@`))
215      .flatMap(([, v]) => (Array.isArray(v) ? v : []))
216      .map(e => (typeof e.installPath === 'string' ? normPath(e.installPath) : ''))
217    return mine.length > 0 && !mine.includes(root)
218  } catch {
219    return false
220  }
221}
222
223/** 読み込んだ時の指紋を覚える。mod の session.start で 1 回呼ぶ */
224export async function rememberVersion($: EngineInterface) {
225  loadedPrint ??= await diskPrint($)
226}
227
228/**
229 * 新しい版が入ったのに、このセッションはまだ古い版で動いているか。
230 * フォルダから読ませている時は中身の指紋が読み込んだ時と違えば、マーケットプレイスから入れた時は
231 * 入っている場所が自分でなくなれば true。読み直すのは 60 秒に 1 回まで。分からない時は false
232 */
233export async function isStale($: EngineInterface, now: number): Promise<boolean> {
234  if (isStaleNow || now - staleAt < 60_000) return isStaleNow
235  staleAt = now
236  if (await replacedInCache($)) return (isStaleNow = true)
237  if (loadedPrint === undefined) return false
238  const p = await diskPrint($)
239  isStaleNow = p !== undefined && p !== loadedPrint
240  return isStaleNow
241}
242
243/**
244 * $.store に書いてよいか。古い版のままなら書かず、1 回だけトーストで開き直しを頼む
245 * (古い版は新しい版の欄を知らないので、書くと消してしまう)
246 */
247export async function mayWrite($: EngineInterface, now: number): Promise<boolean> {
248  if (!(await isStale($, now))) return true
249  if (!toldStale) {
250    toldStale = true
251    $.ui.toast(`${$.plugin.name}: 新しい版が入りました。VS Code を開き直すまで記録は書きません`)
252  }
253  return false
254}
255
256// ---- SVG の部品 ----
257
258export const W = 380
259export const HEADER_H = 44
260
261/** SVG 全体の枠。地を塗って body を入れる。幅は W にそろえる */
262export const frame = (h: number, body: string, w = W) =>
263  `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}" font-family="sans-serif">` +
264  `<rect width="${w}" height="${h}" rx="10" fill="${C.bg}"/>${body}</svg>`
265
266/** 面(カード)。角丸 8、線 1 */
267export const panel = (x: number, y: number, w: number, h: number, fill = C.face) =>
268  `<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="8" fill="${fill}" stroke="${C.line}"/>`
269
270/**
271 * 共通の見出し帯(高さ HEADER_H)。左に色の縦線、左に題、右に添え書き。
272 * 下の細い線が左から伸びる
273 */
274export function header(title: string, sub = '', accent = C.blue, y = 0, w = W) {
275  const pad = 14
276  return [
277    `<rect x="0" y="${y}" width="${w}" height="${HEADER_H}" rx="10" fill="${C.face}"/>`,
278    `<rect x="0" y="${y + 8}" width="4" height="${HEADER_H - 16}" rx="2" fill="${accent}"/>`,
279    `<text x="${pad}" y="${y + 28}" font-size="16" font-weight="bold" fill="${C.text}">${esc(title)}</text>`,
280    sub === '' ? '' : `<text x="${w - pad}" y="${y + 28}" font-size="12" fill="${C.dim}" text-anchor="end">${esc(sub)}</text>`,
281    growBar(pad, y + HEADER_H - 3, w - pad * 2, 2, accent, 0, 0.35),
282  ].join('')
283}
284
285/** 横に伸びる棒。幅 0 から w まで伸びて止まる(shots.mts は終わりの値で撮る) */
286export const growBar = (x: number, y: number, w: number, h: number, fill: string, delay = 0, opacity = 1) =>
287  `<rect x="${x}" y="${y}" width="${Math.max(0, w)}" height="${h}" rx="${Math.min(3, h / 2)}" fill="${fill}"${opacity === 1 ? '' : ` opacity="${opacity}"`}>` +
288  `<animate attributeName="width" from="0" to="${Math.max(0, w)}" begin="${delay}s" dur="0.6s" fill="freeze"/></rect>`
289
290/** 下から伸びる縦の棒(下端 bottom・高さ h) */
291export const growColumn = (x: number, bottom: number, w: number, h: number, fill: string, delay = 0) =>
292  `<rect x="${x}" y="${bottom - h}" width="${w}" height="${Math.max(0, h)}" rx="${Math.min(3, w / 2)}" fill="${fill}">` +
293  `<animate attributeName="y" from="${bottom}" to="${bottom - h}" begin="${delay}s" dur="0.6s" fill="freeze"/>` +
294  `<animate attributeName="height" from="0" to="${Math.max(0, h)}" begin="${delay}s" dur="0.6s" fill="freeze"/></rect>`
295
296/** ふわっと出る。中身は SVG の断片 */
297export const fadeIn = (body: string, delay = 0) =>
298  `<g opacity="1"><animate attributeName="opacity" from="0" to="1" begin="${delay}s" dur="0.4s" fill="freeze"/>${body}</g>`
299
300/**
301 * 0 から value まで数字が増えて止まる。コマ送りの文字を重ね、最後のコマだけが残る。
302 * attrs は <text> に付ける属性(x・y・font-size・fill など)。format で桁区切りや単位を付ける
303 */
304export function countUp(value: number, attrs: string, format: (n: number) => string = n => String(n), delay = 0) {
305  const STEPS = 8
306  const d = 0.08
307  const last = `<text ${attrs} opacity="0">${esc(format(value))}<animate attributeName="opacity" from="0" to="1" begin="${(delay + (STEPS - 1) * d).toFixed(2)}s" dur="0.01s" fill="freeze"/></text>`
308  if (!Number.isFinite(value) || value === 0) return `<text ${attrs}>${esc(format(value))}</text>`
309  const frames = Array.from({ length: STEPS - 1 }, (_, k) => {
310    const n = Number.isInteger(value) ? Math.round((value * k) / (STEPS - 1)) : (value * k) / (STEPS - 1)
311    return `<text ${attrs} opacity="0">${esc(format(n))}<animate attributeName="opacity" values="1;1" begin="${(delay + k * d).toFixed(2)}s" dur="${d}s"/></text>`
312  })
313  return frames.join('') + last
314}
315
types/index.d.ts 58 lines
1export interface QuestReward {
2  /** dev-rpg が受け取る経験値 */
3  exp: number
4  /** turn-gacha が受け取る、おまけで引ける回数 */
5  pulls: number
6}
7export interface Quest {
8  /** その日の中で一意。例 'turns-5' */
9  id: string
10  title: string
11  goal: number
12  progress: number
13  done: boolean
14  reward: QuestReward
15}
16export interface QuestDay {
17  /** JST の YYYY-MM-DD */
18  date: string
19  quests: Quest[]
20}
21
22/** お題の種類 (数える物) */
23export type QuestKind = 'turns' | 'edits' | 'tests' | 'commits' | 'clean' | 'newFiles' | 'reads' | 'shells'
24
25/** 種類ごとの数。無い種類は 0 */
26export type QuestCounts = Partial<Record<QuestKind, number>>
27
28/** $.store に残す物 */
29export interface QuestSave {
30  /** JST の日付 → その日の数。直近 7 日だけ */
31  days: Record<string, QuestCounts>
32  /** 3 つ全部こなした日 (古い順) */
33  fullDays: string[]
34  /** 通算でこなしたお題の数 */
35  totalDone: number
36}
37
38declare module 'claude-code' {
39  interface PluginState {
40    'daily-quest': {
41      today: QuestDay
42      /** 3 つ全部こなした日が続いている日数 */
43      streak: number
44      /** 通算でこなしたお題の数 */
45      totalDone: number
46      // ここから下は daily-quest の中だけで使う (読む側は上の 3 つだけを当てにする)
47      /** 最後に store から読んだ・書いた物 */
48      saved: QuestSave
49      /** まだ store に流していない数 (ターンの終わりに流す) */
50      pending: { date: string; counts: QuestCounts }
51      /** 今のメインのターンで失敗したツールの数 */
52      turnErrors: number
53      /** 保存の失敗などの一言。空なら出さない */
54      note: string
55    }
56  }
57}
58