SLOPSHOPPER

ops-rules

Rules for the session started with LIFE_ROLE=ops: it decides and does not measure (analysis commands need the person's OK).

newguardprompt
v0.1.0no licenseupdated 2026-10-0838kta-lab/dotfile/mods/ops-rules
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 244 lines
1import type { Register } from 'claude-code'
2
3// The ops session decides what to measure; the project sessions measure.
4// When ops runs an analysis itself, it has no control beside it and its
5// numbers leak into decisions unchecked. So in the ops session an analysis
6// command is held until the person allows it.
7const ANALYSIS_TOOLS = new Set([
8  // alignment and similarity search
9  'nucmer', 'promer', 'show-coords', 'delta-filter', 'dnadiff', 'mummer',
10  'blastn', 'blastp', 'blastx', 'tblastn', 'tblastx', 'makeblastdb', 'diamond', 'mmseqs',
11  'minimap2', 'bwa', 'bowtie2', 'hisat2',
12  // reads and variants
13  'samtools', 'bcftools', 'bedtools', 'mosdepth',
14  // assembly
15  'hifiasm', 'flye', 'canu', 'spades.py', 'metaMDBG', 'unicycler',
16  // annotation and profiles
17  'prodigal', 'prokka', 'bakta', 'dfast', 'exec_annotation', 'hmmsearch', 'hmmscan',
18  // phylogeny and alignment
19  'gtdbtk', 'iqtree', 'iqtree2', 'raxml-ng', 'FastTree', 'mafft', 'muscle', 'trimal',
20  // job submission
21  'sbatch', 'srun', 'vsub',
22])
23
24// Words that only wrap the command that follows them.
25const WRAPPERS = new Set(['sudo', 'time', 'nohup', 'exec', 'xargs', 'env', 'command', 'nice'])
26const ASKS_ONLY = /^(--?version|-v|--?help|-h)$/
27
28function strip(token: string): string {
29  return token.replace(/^[('"`$]+|[)'"`;]+$/g, '')
30}
31
32// The tools a shell command runs as commands (not names it only mentions).
33// Splits on the shell's own separators, quoted or not, so the command inside
34// `ssh host '...'` is split the same way.
35export function analysisTools(command: string): string[] {
36  const found: string[] = []
37  for (const segment of command.split(/\|\||&&|[;|\n]|\$\(|`/)) {
38    const tokens = segment.trim().split(/\s+/).map(strip).filter(Boolean)
39    let i = 0
40    while (i < tokens.length) {
41      const t = tokens[i]
42      if (WRAPPERS.has(t) || /^[A-Za-z_][A-Za-z0-9_]*=/.test(t)) { i++; continue }
43      if (t === 'ssh') { i++; while (i < tokens.length && tokens[i].startsWith('-')) i += 2; i++; continue }
44      if (t === 'conda' && tokens[i + 1] === 'run') { i += 2; while (i < tokens.length && tokens[i].startsWith('-')) i += 2; continue }
45      if (t === 'singularity' || t === 'apptainer') {
46        while (i < tokens.length && !tokens[i].endsWith('.sif')) i++
47        i++
48        continue
49      }
50      break
51    }
52    if (i >= tokens.length) continue
53    const name = tokens[i].split('/').pop() ?? ''
54    if (!ANALYSIS_TOOLS.has(name)) continue
55    const args = tokens.slice(i + 1)
56    if (args.length > 0 && args.every(a => ASKS_ONLY.test(a))) continue
57    found.push(name)
58  }
59  return [...new Set(found)]
60}
61
62// tasks.md: a line turned ✅ means a hub's Next Actions are now stale.
63// Rules.md says to fix the hub in the same turn; this tells the model so
64// right after the edit lands, which is when it can still act on it.
65const TASKS_PATH = /(^|\/)ideas\/task-review\/tasks\.md$/
66const NO_HUB = new Set(['事務'])
67
68// ✅ lines in `newText` beyond those already in `oldText`, counted as a
69// multiset so a second identical line still counts.
70export function newlyDone(oldText: string, newText: string): string[] {
71  const before = new Map<string, number>()
72  for (const l of oldText.split('\n').map(x => x.trim())) before.set(l, (before.get(l) ?? 0) + 1)
73  const out: string[] = []
74  for (const l of newText.split('\n').map(x => x.trim())) {
75    if (!l.startsWith('- ✅')) continue
76    const n = before.get(l) ?? 0
77    if (n > 0) before.set(l, n - 1)
78    else out.push(l)
79  }
80  return out
81}
82
83async function readText($: any, path: string): Promise<string | undefined> {
84  try {
85    return String(await $.fs.read(path))
86  } catch {
87    return undefined
88  }
89}
90
91function projects(lines: readonly string[]): string[] {
92  const tags = lines.flatMap(l => {
93    const m = l.match(/^- ✅ \[([^\]]+)\]/)
94    return m ? m[1].split('/') : []
95  })
96  return [...new Set(tags)].filter(t => !NO_HUB.has(t))
97}
98
99// Every line turned ✅ is also logged, with the minute, to a month file next
100// to tasks.md (done/YYYY-MM.md), so what was done when survives the morning
101// clean-up that deletes ✅ lines from tasks.md.
102function two(n: number): string {
103  return String(n).padStart(2, '0')
104}
105
106export function logLines(now: number, done: readonly string[]): { month: string; lines: string[] } {
107  const d = new Date(now)
108  const month = `${d.getFullYear()}-${two(d.getMonth() + 1)}`
109  const stamp = `${month}-${two(d.getDate())} ${two(d.getHours())}:${two(d.getMinutes())}`
110  return { month, lines: done.map(l => `- ${stamp} ${l.replace(/^- /, '')}`) }
111}
112
113async function logDone($: any, tasksPath: string, done: readonly string[]): Promise<void> {
114  const { month, lines } = logLines(await $.clock.now(), done)
115  const dir = tasksPath.replace(/[^/]*$/, '')
116  const path = `${dir}done/${month}.md`
117  let old = ''
118  try {
119    old = String(await $.fs.read(path))
120  } catch {
121    old = `# done ${month}\n\ntasks.md で ✅ にした行(ops-rules mod が ✅ の瞬間に書く)。\n\n`
122  }
123  await $.fs.write(path, old.replace(/\n*$/, '\n') + lines.join('\n') + '\n')
124}
125
126// ---- replies in Japanese ----------------------------------------------------
127// The person reads in Japanese. A rule in the system prompt asks for it every
128// turn; a turn whose answer still came out in English is followed, once, by an
129// automatic request to say the same thing again in Japanese.
130const JAPANESE: { id: string; text: string; scope: 'session' } = {
131  id: 'ops-rules:japanese',
132  text:
133    'user への返答は、作業の途中の一言も最終報告も、すべて日本語で書く。英語にするのは user が頼んだときだけ。' +
134    'コマンドの出力・コード・ファイル名が英語でも、説明の地の文は日本語で書く。長い作業の後の最終報告ほど英語になりやすいので、書き始める前に言語を確かめる。',
135  scope: 'session',
136}
137const RESTATE =
138  '(ops-rules 自動)直前の返答が英語でした。同じ内容を日本語で言い直してください。作業はやり直さず、ツールも使わないでください。'
139
140// Prose only: code blocks, inline code, URLs and paths say nothing about the
141// language of the reply. English when its words clearly outnumber the
142// Japanese characters; a short reply is never judged.
143export function looksEnglish(answer: string): boolean {
144  const prose = answer
145    .replace(/```[\s\S]*?```/g, ' ')
146    .replace(/`[^`\n]*`/g, ' ')
147    .replace(/https?:\/\/\S+/g, ' ')
148    .replace(/[\w.~-]*\/[\w.~/-]+/g, ' ')
149  const ja = (prose.match(/[\u3040-\u30ff\u3400-\u9fff]/g) ?? []).length
150  const words = (prose.match(/[A-Za-z]{2,}/g) ?? []).length
151  return words >= 25 && ja < words * 0.5
152}
153
154let restating = false
155
156const ALLOW = 'Allow once'
157const DENY = 'Deny'
158
159// The alert pane reads a mail's body with `alert_feed.py --body` to show it on
160// the terminal only. Run from the model's Bash, the body would land in the
161// model's context and go out with the next request, sensitive classes (人事・
162// 成績・査読) included. So the model may not run it, in any session. The pane's
163// own $.process.run is not a tool call and is not held here.
164export function readsMailBody(command: string): boolean {
165  return /alert_feed(\.py)?\b[^|;&\n]*--body\b/.test(command)
166}
167
168const MAIL_BODY_DENY =
169  'ops-rules: alert_feed.py --body is for the alert pane only (it shows a mail body on the terminal, sensitive ones included). ' +
170  'Do not run it, or read the body another way. To read a mail the person chose, use `alert_feed.py --show <id>`, which refuses sensitive classes.'
171
172export const register: Register = on => {
173  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
174    if (readsMailBody(e.command)) return { deny: MAIL_BODY_DENY }
175    const tools = analysisTools(e.command)
176    if (tools.length === 0) return next(e)
177    if ((await $.env.get('LIFE_ROLE')) !== 'ops') return next(e)
178    const what = tools.join(', ')
179    let answer: string
180    try {
181      answer = await $.ui.ask(
182        `ops-rules: the ops session is about to run an analysis (${what}). ops decides and does not measure. Allow it?`,
183        { header: 'ops', options: [DENY, ALLOW] },
184      )
185    } catch {
186      answer = DENY
187    }
188    if (answer === ALLOW) return next(e)
189    return {
190      deny:
191        `ops-rules: the person declined this analysis in the ops session (${what}). ` +
192        'Do not run it another way and do not hand it to another session on your own. ' +
193        'Tell the person what you wanted to measure and wait for their instruction.',
194    }
195  }).catch(($, e, next) => (next.called ? next(e) : { deny: 'ops-rules: its check failed, so the call was refused.' }))
196
197  on('prompt.compose', async ($, e, next) => {
198    const r = await next(e)
199    return { ...r, sections: [...r.sections.filter(x => x.id !== JAPANESE.id), JAPANESE] }
200  })
201
202  on('turn.complete', async ($, e, next) => {
203    const r = await next(e)
204    if (e.agentId !== undefined || e.reason !== 'answer') return r
205    if (restating) {
206      restating = false
207      return r
208    }
209    if (!looksEnglish(e.answer)) return r
210    restating = true
211    $.prompt.submit({ text: RESTATE }).catch(() => {
212      restating = false
213    })
214    return r
215  })
216
217  on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
218    if (!TASKS_PATH.test(e.file_path)) return next(e)
219    // The edit's own strings say whether a ✅ was added, but an edit of part
220    // of a line carries only that part. The whole lines come from the file,
221    // read before and after; the strings stand in when it cannot be read.
222    const hint = newlyDone(e.old_string, e.new_string)
223    if (hint.length === 0) return next(e)
224    const before = await readText($, e.file_path)
225    const ran = await next(e)
226    if (ran.deny !== undefined || ran.isError) return ran
227    const after = before === undefined ? undefined : await readText($, e.file_path)
228    const whole = before !== undefined && after !== undefined ? newlyDone(before, after) : []
229    const done = whole.length > 0 ? whole : hint
230    try {
231      await logDone($, e.file_path, done)
232    } catch (err) {
233      $.ui.log(`ops-rules: could not log done lines: ${String(err)}`)
234    }
235    const pjs = projects(done)
236    if (pjs.length === 0) return ran
237    const hubs = pjs.map(p => `projects/active/${p}*.md`).join(', ')
238    const note =
239      `ops-rules: tasks.md で ✅ にした行があります(${done.join(' / ')})。` +
240      `Rules.md「記録の 3 層と tasks.md」により、このターンのうちに該当 hub(${hubs})の Next Actions から済んだ項目を外し、必要なら Current State を上書きしてください。`
241    return { ...ran, context: [...(ran.context ?? []), note] }
242  })
243}
244