SLOPSHOPPER

note-toc

After a write to a research repo's note/note.md, checks its generated table of contents and heading shape, and tells the model to rebuild the contents when…

newguardprocess
v0.1.0no licenseupdated 2026-10-0838kta-lab/dotfile/mods/note-toc
A shopper browsing a rack in a slop shop
README

dotfile

Personal dotfiles for zsh, WezTerm, and global Codex / Claude Code Skills.

Initialization

./init.sh

init.sh は idempotent。以下のいずれかをやったら再実行する:

  • skills/<name>/ を追加した (新 skill)
  • skills/<name>/ を削除した (broken symlink が link_skills で自動 cleanup)
  • zsh/, wezterm/, nvim/, git/, lazygit/, czg/, cz-git/, starship/ の構成を変えた

Bootstrap (new machine)

./bootstrap.sh

Then run:

gh auth login
gh auth refresh -s project
git config --global ghq.root "$HOME/src"
mkdir -p "$HOME/.config/zsh" "$HOME/.config/wezterm" "$HOME/.config/codex/skills" "$HOME/.claude/skills"
./init.sh

既にあるマシンへ 追加分だけ 取り込むときは --no-upgrade を付ける。 付けないと brew bundle は古い formula をまとめて upgrade する。

git pull && brew bundle --file=./Brewfile --no-upgrade

./bootstrap.sh also installs Miniforge3 into ~/miniforge3 when missing. ./init.sh links zsh/env.zsh, which loads conda shell support without auto-activating base.

Manual bootstrap (no script)

/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
echo "" >> "$HOME/.zprofile"
echo 'eval "$(/opt/homebrew/bin/brew shellenv zsh)"' >> "$HOME/.zprofile"
eval "$(/opt/homebrew/bin/brew shellenv zsh)"
# 公式以外の tap は Homebrew 7 から明示的な信頼が要る(~/.homebrew/trust.json, マシンごと)
brew trust olets/tap
# パッケージの一覧は Brewfile が正本(個別の brew install をここに増やさない)
brew bundle --file=./Brewfile
npm install -g git-cz czg cz-git
npx -y czg --api-key="sk-XXXX"

Clone this repo with ghq, then install:

gh auth login
gh auth refresh -s project
git config --global ghq.root "$HOME/src"
mkdir -p "$HOME/.config/zsh" "$HOME/.config/wezterm" "$HOME/.config/codex/skills" "$HOME/.claude/skills"
ghq get https://github.com/38kta-lab/dotfile
cd "$(ghq root)/github.com/38kta-lab/dotfile"
./install_miniforge.sh
./init.sh

gh auth refresh -s project is required on each machine where Codex or gh updates GitHub Projects, such as the Life project Status field.

Codex / Antigravity (Gemini) CLI

# Codex CLI
npm install -g @openai/codex
mkdir -p ~/.config/codex
mv ~/.codex/* ~/.config/codex/
codex sign-in

Antigravity CLI (agy) — Gemini CLI の後継

Google は 2026-06-18 に個人 Google アカウント向けの Gemini CLI ログインを サーバ側で打ち切った(This client is no longer supported for Gemini Code Assist for individuals エラー)。後継の Antigravity CLI (agy) に移行する。

# 旧 gemini CLI が残っていれば削除
npm uninstall -g @google/gemini-cli 2>/dev/null; rm -rf ~/.gemini

# Antigravity CLI をインストール (Go 製 single binary、~/.local/bin/agy)
curl -fsSL https://antigravity.google/cli/install.sh | bash

# 初回起動でブラウザ認証 (要 Google アカウント。Pro 系モデルはサブスク必要)
agy
  • ~/.local/bin は zsh/env.zsh で既に PATH 追加済み → 追加設定不要。
  • 使い方: 対話 agy / 非対話 agy -p "プロンプト" / モデル指定 agy --model <id> -p ... / 一覧 agy models / 継続 agy -c。
  • 認証は初回ブラウザ OAuth のみ、以降は OS keyring から自動サインイン。

Miniforge / Conda

Miniforge3 is installed under:

~/miniforge3

Install or verify it:

./install_miniforge.sh

Use a pinned Miniforge release when needed:

MINIFORGE_VERSION=25.11.0-0 ./install_miniforge.sh

The installer supports Apple Silicon and Intel macOS by selecting the matching installer from the official conda-forge/miniforge GitHub releases:

https://github.com/conda-forge/miniforge/releases

base should not auto-activate:

conda config --set auto_activate_base false

For per-repo environments, prefer environment.yml in that repo:

conda env create -f environment.yml
conda activate <env-name>

If an environment already exists:

conda env update -f environment.yml --prune

Google Calendar Credentials

For Codex-assisted Calendar reads in the life repo, place the OAuth desktop client JSON at:

~/.config/life/google-calendar-credentials.json

Copy this file between personal Macs using a private secure channel. Do not commit it to git, and do not create a public/shared link.

On a new Mac:

mkdir -p "$HOME/.config/life"
mv "$HOME/Downloads/google-calendar-credentials.json" "$HOME/.config/life/google-calendar-credentials.json"
chmod 600 "$HOME/.config/life/google-calendar-credentials.json"

If the downloaded file has a client_secret_*.json name:

mkdir -p "$HOME/.config/life"
mv "$HOME/Downloads"/client_secret_*.json "$HOME/.config/life/google-calendar-credentials.json"
chmod 600 "$HOME/.config/life/google-calendar-credentials.json"

Do not copy this token between Macs:

~/.config/life/google-calendar-read-token.json

Generate that token separately on each Mac by running the Calendar reader from the life repo after activating its conda environment:

conda activate life
python scripts/google_calendar_read.py --format json

Skills (Codex / Claude Code)

Global user Skills are managed in this repo under:

skills/

./init.sh links each directory under skills/ into both:

~/.config/codex/skills/
~/.claude/skills/

System Skills under ~/.config/codex/skills/.system/ are not managed here. Claude Code's auto memory under ~/.claude/projects/.../memory/ is also not managed here.

PR Workflow (squash)

Use PRs with squash merge to keep main clean and reduce cross-machine conflicts. Rule of thumb: update main, but do not work directly on it.

Branch naming (one branch per machine):

  • work/<hostname> (example: work/kta38-mini-lab)

Get <hostname> with:

hostname -s

Initial setup (first time on a machine):

HOST="$(hostname -s)"
git switch main
git pull --rebase
git switch -c "work/$HOST"
git push -u origin "work/$HOST"

Flow (manual):

# start work (every time)
git switch main
git pull --rebase
git switch work/<hostname>
git rebase main

# work + commit
git add -A
git commit -m "feat: ..."
git push -u origin work/<hostname>

Create a PR from work/<hostname> to main:

gh pr create --base main --head work/<hostname> --fill

Then Squash and merge it on GitHub, or with gh:

gh pr merge <PR_NUMBER> --squash

Merge commit (no squash):

gh pr merge <PR_NUMBER> --merge

Aliases (see zsh/alias.zsh):

winit   # initial setup: create/push work/<hostname> branch
wmain   # update main only (before switching)
wstart  # start work: update main -> switch work/<hostname> -> rebase
wrebase # rebase current work branch onto main
prc     # gh pr create --base main --head work/<hostname> --fill
prs     # gh pr merge --squash
prm     # gh pr merge --merge

Then on other machines:

git switch main
git pull --rebase

Notes:

  • Keep work/<hostname> rebased onto main to avoid long-lived divergence.
  • Squash keeps history clean; use merge commits only when you need full commit history preserved.
  • Avoid pushing directly to main.

Notes

  • WezTerm keybinds are managed at wezterm/keybinds.lua and linked to ~/.config/wezterm/keybinds.lua.
  • Global Skills are managed under skills/ and linked to both ~/.config/codex/skills/ and ~/.claude/skills/.
  • ~/.config/.czrc is not committed; see czrc/.czrc.example for a template.

Mac Setup Checklist

Keyboard

  • Input source: 日本語 - ローマ字入力
  • Input mode: 英字
  • Caps Lock: オフの時「英字」を入力

Trackpad

  • Tracking speed: Max
  • Tap to click: オン

Pointer

  • Size: 1つ大きくする
  • Fill: #D05654
  • Outline: #464758

iCloud

  • Desktop and Documents sync: オン

Desktop

  • スタックを使用
  • 表示オプションを表示
  • テキストサイズ: 10
  • 並べ替え: 種類
  • 表示順序: 名前
  • アイコン: 36x36
  • グリッド間隔: 下から4番目

Dock

  • Position: 左
  • Automatically show/hide: オン
  • Size/zoom: いい感じに

Default browser

  • Chrome

Mac app

  • Zoom: Download is here
  • Microsoft: Word, Excel, Powerpoint
  • Magnet: Download is here
  • Gmail, Google calender
  • Google drive for mac: Download is here
  • ChimeraX: Download is here
Source 1 files
hooks/register.ts 84 lines
1import type { Register } from 'claude-code'
2
3// note.md's table of contents is generated from its headings by
4// scripts/note_toc.py. After every write to a note.md, check that the
5// contents still match and that every analysis heading has its three lines,
6// and tell the model when not. The mod never rewrites the file itself: the
7// model may still be editing it, and a write from outside would make its
8// next Edit fail.
9const NOTE = /(^|\/)note\/note\.md$/
10
11export type Verdict = { stale: boolean; problems: string[] }
12
13export function verdict(exitCode: number, stderr: string): Verdict {
14  const problems = stderr
15    .split('\n')
16    .map(l => l.trim())
17    .filter(l => l.startsWith('note_toc: ') && !l.includes('書き込まない'))
18    .map(l => l.slice('note_toc: '.length))
19  return { stale: exitCode === 1, problems }
20}
21
22export function message(path: string, command: string, v: Verdict): string | undefined {
23  if (!v.stale && v.problems.length === 0) return undefined
24  const parts: string[] = []
25  if (v.problems.length > 0) {
26    parts.push(`note-toc: ${path} の見出しが型に合っていません(目次を作れません): ${v.problems.join(' / ')}。` +
27      '型は「## <解析番号> — 題(YYYY-MM-DD|日付未記載)」と直下の「- 問い: / - 結論: / - 状態:」の 3 行、1 つの番号に ## は 1 つ。')
28  }
29  if (v.stale) {
30    parts.push(`note-toc: ${path} の冒頭の目次が古くなっています。note の編集が終わったら \`${command}\` を実行して作り直してください(目次は手で書かない)。`)
31  }
32  return parts.join(' ')
33}
34
35// Rules.md「研究 PJ の解析番号の作法」5: the 結論 field holds the user's words
36// only, marked (user YYYY-MM-DD); anything else stays 未記載. Only the text this
37// call wrote is looked at, so conclusions written before the rule stay quiet.
38export function unmarkedConclusions(text: string): string[] {
39  return text
40    .split('\n')
41    .map(l => l.trim())
42    .filter(l => /^- 結論:/.test(l))
43    .map(l => l.replace(/^- 結論:\s*/, ''))
44    .filter(c => c !== '' && !c.startsWith('未記載') && !/(user[\s ]/.test(c) && !/\(user\s/.test(c))
45}
46
47export function conclusionMessage(path: string, found: string[]): string | undefined {
48  if (found.length === 0) return undefined
49  const shown = found.slice(0, 3).map(c => `「${c.slice(0, 60)}」`).join(' ')
50  return `note-toc: ${path} の「結論」に user の印の無い文があります: ${shown}${found.length > 3 ? ` ほか ${found.length - 3} 件` : ''}。` +
51    '結果の解釈は user が持つ(Rules.md「研究 PJ の解析番号の作法」5)。user が言った結論なら末尾に「(user YYYY-MM-DD)」を付け、そうでなければ「未記載」に戻して、観察(数値・差・件数)は本文の「結果」に書く。'
52}
53
54async function check($: any, python: string, repo: string, path: string): Promise<string | undefined> {
55  const script = `${repo}/scripts/note_toc.py`
56  const r = await $.process.run([python, script, path, '--check'], { timeoutMs: 30000 })
57  return message(path, `${python} ${script} ${path} --write`, verdict(r.exitCode, r.stderr))
58}
59
60// One hook for both tools; each is registered with a literal matcher, because
61// a session reads the matcher from the source (a matcher built from a loop
62// variable passed the tests but never ran in a session).
63async function afterWrite($: any, path: string, written: string, ran: any, python: string, repo: string): Promise<any> {
64  if (!NOTE.test(path) || ran.deny !== undefined || ran.isError) return ran
65  const unmarked = conclusionMessage(path, unmarkedConclusions(written))
66  if (unmarked) ran = { ...ran, context: [...(ran.context ?? []), unmarked] }
67  // Say so instead of staying silent: an unset path means the check never runs.
68  if (!repo) return { ...ran, context: [...(ran.context ?? []), 'note-toc: life_repo が未設定のため、note.md の目次と見出しの型を確かめられませんでした(settings.json の pluginConfigs)。'] }
69  try {
70    const note = await check($, python, repo, path)
71    return note ? { ...ran, context: [...(ran.context ?? []), note] } : ran
72  } catch (err) {
73    return { ...ran, context: [...(ran.context ?? []), `note-toc: note.md の確認に失敗しました(${String(err).slice(0, 300)})。`] }
74  }
75}
76
77export const register: Register = (on, options) => {
78  const python = String(options.python || 'python3')
79  const repo = String(options.life_repo ?? '')
80
81  on('tool.call', { tool: 'Edit' }, async ($, e, next) => afterWrite($, e.file_path, String(e.new_string ?? ''), await next(e), python, repo))
82  on('tool.call', { tool: 'Write' }, async ($, e, next) => afterWrite($, e.file_path, String(e.content ?? ''), await next(e), python, repo))
83}
84