SLOPSHOPPER

ops-dash

Every session with LIFE_ROLE publishes its status; the ops session's /dash pane shows plan usage, every session, a one-week calendar and the shared task list.

newpanebandguardcommandtoast
v0.2.0no licenseupdated 2026-10-0838kta-lab/dotfile/mods/ops-dash
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ops-dash
│ ┃ fig ✕ › fix the failing auth test and add an audit log call │ ┃ fig r: 更新 │ ┃ 仮の図はまだありません(/Users/dev/.local/sh ⏺ Read(src/auth.ts) │ ┃ are/life/preview/ に png ⎿ Read 6 lines │ ┃ を置く。figstyle.preview(fig, "名前")) ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · fig
fig r: 更新 仮の図はまだありません(/Users/dev/.local/share/life/preview / に png を置く。figstyle.preview(fig, "名前"))
Pane · alert
alert r: 更新 ─ Gmail 未処理 0 件(未読 0)・一斉配信 0 件 取得 — ── 読み込んでいます…(最初の取得は 15 秒ほど) ─ Slack ──────────────────────────────────────────────── 準備中(チャンネルの整理と workspace の追加の後に作る)
Pane · notes
notes hub に nas_code / nas_data がある PJ(目次は選んでから読む) 見つかりません
Pane · hubs
hubs 0 件 r: 更新 hub の場所が未設定(pluginConfigs の life_repo) slug status 更新 次の Milestone 次の手順
Pane · peers
c: calendar r: trend k: tasks ─ system usage ───────────────────────────────────────── まだ計測なし ─ sessions ───────────────────────────────────────────── まだ状態を書いたセッションなし ─ calendar ─────────────────────────────────────────── 9木 10金 11土 12日 13月 14火 15水 終 08 · · · · · · · 09 · · · · · · · 10 · · · · · · · 11 · · · · · · · 12 · · · · · · · 13 · · · · · · · 14 · · · · · · · 15 · · · · · · · 16 · · · · · · · 17 · · · · · · · 18 · · · · · · · 19 · · · · · · · 20 · · · · · · · この先の予定なし ─ trend ────────────────────────────────────────────── ─ tasks ──────────────────────────────────────────────── タスク未設定(pluginConfigs の life_repo)
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 5 files
hooks/register.tsx 1796 lines
1import type { Register } from 'claude-code'
2
3import { consultText, parseHub, sectionMarkdown, sortHubs, TABS } from './hubs'
4import type { Hub } from './hubs'
5import { bytesToBase64, entryLines, frontValue, imageRows, isBase64, pngList, tocRows } from './notes'
6import type { TocRow } from './notes'
7import { latestTrendFile, parseTrend, starText, topItems } from './trend'
8import type { Trend, TrendItem } from './trend'
9import { bodyArgv, calendarArgv, calendarTitle, detailPrompt, doneArgv, mailLine, newIds, parseBody, parseFeed, parseWhen, shortDate, splitMail } from './alert'
10import type { MailBody, MailFeed, MailItem } from './alert'
11
12// Each session started with LIFE_ROLE writes one record about itself to the
13// store every session on this machine shares. The ops session reads them all.
14// Sessions without a role (cron runs, ad-hoc sessions) write nothing.
15
16export type SessionRecord = {
17  name: string
18  role: string
19  pj?: string
20  busy: boolean
21  turnStartedAt?: number
22  lastTurnEndAt?: number
23  contextPercent?: number
24  costUsd?: number
25  lastReportAt?: number
26  // A question waiting for the person (a guard's dialog, the model's
27  // AskUserQuestion): its first words and since when. Cleared when answered.
28  waiting?: string
29  waitingSince?: number
30  updatedAt: number
31}
32
33export type Dispatch = { at: number; line: string }
34export type Limits = { at: number; windows: { kind: string; percentUsed: number; resetsAt?: string }[] }
35
36const PREFIX = 'ops-dash:session:'
37const DISPATCH = 'ops-dash:dispatch:'
38const LIMITS = 'ops-dash:limits'
39const PANE = 'peers'
40// `/peers` is the built-in alias of /list-agents, so the command is /dash.
41const COMMAND = 'dash'
42
43type Me = { name: string; role: string; pj?: string }
44
45async function whoAmI($: any): Promise<Me | undefined> {
46  const role = await $.env.get('LIFE_ROLE')
47  if (!role) return undefined
48  const pj = await $.env.get('LIFE_PJ')
49  const name = (await $.env.get('LIFE_NAME')) ?? (role === 'ops' ? 'ops' : pj ? `pj-${pj}` : role)
50  return { name, role, pj: pj || undefined }
51}
52
53// Recording status must never break the session: a failed write is dropped.
54async function write($: any, me: Me, change: Partial<SessionRecord>): Promise<void> {
55  try {
56    await writeOnce($, me, change)
57  } catch {
58    // the next event writes again
59  }
60}
61
62async function writeOnce($: any, me: Me, change: Partial<SessionRecord>): Promise<void> {
63  const key = PREFIX + me.name
64  const old = ((await $.store.get(key)) as SessionRecord | undefined) ?? { name: me.name, role: me.role, busy: false, updatedAt: 0 }
65  const now = await $.clock.now()
66  await $.store.set(key, { ...old, name: me.name, role: me.role, pj: me.pj, ...change, updatedAt: now })
67}
68
69export function hhmm(ms?: number): string {
70  if (!ms) return '—'
71  const d = new Date(ms)
72  return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
73}
74
75// "47分前" / "2時間05分前"
76export function ago(now: number, ms?: number): string {
77  if (!ms) return ''
78  const m = Math.max(0, Math.round((now - ms) / 60000))
79  return m < 60 ? `${m}分前` : `${Math.floor(m / 60)}時間${String(m % 60).padStart(2, '0')}分前`
80}
81
82export function elapsed(now: number, ms?: number): string {
83  if (!ms) return ''
84  const m = Math.max(0, Math.round((now - ms) / 60000))
85  return m < 60 ? `${m}分` : `${Math.floor(m / 60)}時間${m % 60}分`
86}
87
88// Terminal cells a string takes: CJK and full-width forms take two.
89export function cells(text: string): number {
90  let n = 0
91  for (const ch of text) n += /[\u1100-\u115f\u2e80-\ua4cf\uac00-\ud7a3\uf900-\ufaff\ufe30-\ufe4f\uff00-\uff60\uffe0-\uffe6]/.test(ch) ? 2 : 1
92  return n
93}
94
95export function pad(text: string, width: number): string {
96  return text + ' '.repeat(Math.max(0, width - cells(text)))
97}
98
99export function bar(percent: number, width = 16): string {
100  const filled = Math.max(0, Math.min(width, Math.round((percent / 100) * width)))
101  return '█'.repeat(filled) + '░'.repeat(width - filled)
102}
103
104export function tone(percent: number): string {
105  return percent >= 80 ? 'red' : percent >= 50 ? 'yellow' : 'green'
106}
107
108// "16:10 リセット" today, "明日 09:00 リセット" tomorrow, else "10/14 09:00 リセット".
109export function resetLabel(now: number, iso?: string): string {
110  if (!iso) return ''
111  const t = Date.parse(iso)
112  if (Number.isNaN(t)) return ''
113  const a = new Date(now), b = new Date(t)
114  const day = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime()
115  const diff = Math.round((day(b) - day(a)) / 86400000)
116  const when = diff === 0 ? hhmm(t) : diff === 1 ? `明日 ${hhmm(t)}` : `${b.getMonth() + 1}/${b.getDate()} ${hhmm(t)}`
117  return `${when} リセット`
118}
119
120const WINDOW_LABEL: Record<string, string> = { five_hour: '5時間枠', seven_day: '週間枠' }
121
122export type LimitRow = { label: string; percent: number; reset: string }
123export function limitRows(l: Limits | undefined, now: number): LimitRow[] {
124  return (l?.windows ?? []).map(w => ({ label: WINDOW_LABEL[w.kind] ?? w.kind, percent: w.percentUsed, reset: resetLabel(now, w.resetsAt) }))
125}
126
127export type Card = {
128  name: string
129  pj?: string
130  busy: boolean
131  state: string
132  context?: number
133  meta: string
134}
135
136export function cardsOf(records: SessionRecord[], dispatches: Record<string, Dispatch>, now: number, width: number): Card[] {
137  const sorted = [...records].sort((a, b) => (a.role === 'ops' ? -1 : b.role === 'ops' ? 1 : a.name.localeCompare(b.name)))
138  return sorted.map(r => {
139    const parts: string[] = []
140    if (r.lastTurnEndAt) parts.push(`ターン ${hhmm(r.lastTurnEndAt)}(${ago(now, r.lastTurnEndAt)})`)
141    if (r.role !== 'ops' && r.lastReportAt) parts.push(`報告 ${hhmm(r.lastReportAt)}`)
142    const d = dispatches[r.name]
143    if (d) parts.push(`依頼 ${hhmm(d.at)} ${d.line}`)
144    let meta = parts.length ? parts.join(' · ') : 'まだターンなし'
145    const room = width - 3
146    if (cells(meta) > room) {
147      let cut = ''
148      for (const ch of meta) {
149        if (cells(cut + ch) > room - 1) break
150        cut += ch
151      }
152      meta = cut + '…'
153    }
154    return {
155      name: r.name,
156      pj: r.pj,
157      busy: r.busy,
158      state: r.busy ? `作業中 ${elapsed(now, r.turnStartedAt)}` : '待機',
159      context: r.contextPercent,
160      meta,
161    }
162  })
163}
164
165
166// ---- calendar: one week, fixed height ----------------------------------
167
168export type CalEvent = {
169  title: string
170  start: string
171  end: string
172  calendarId: string
173  location?: string
174  description?: string
175  meetingUrl?: string
176  htmlLink?: string
177}
178
179export const HOUR_FIRST = 8
180export const HOUR_LAST = 20 // the last row is 20:00–21:00
181const WEEKDAY = ['日', '月', '火', '水', '木', '金', '土']
182
183export function isAllDay(ev: CalEvent): boolean {
184  return !ev.start.includes('T')
185}
186
187// "[07_G] note と hub の整理 [status:focus]" → "07_G"; untagged → the title's start
188export function shortTitle(title: string): string {
189  const tag = title.match(/^\[([^\]]+)\]/)
190  if (tag && !tag[1].startsWith('status:')) return tag[1]
191  return title.replace(/\[status:[^\]]+\]/g, '').replace(/【[^】]*】/g, '').trim()
192}
193
194export function eventColor(ev: CalEvent): string {
195  const t = ev.title
196  if (/\[status:focus\]/.test(t)) return 'green'
197  if (/\[status:meeting\]/.test(t) || /【MTG】/.test(t)) return 'blue'
198  if (/\[status:experiment\]/.test(t)) return 'magenta'
199  if (/\[status:break\]/.test(t)) return 'gray'
200  if (!ev.calendarId.startsWith('c_')) return 'blue'
201  return 'cyan'
202}
203
204function dayStart(ms: number): number {
205  const d = new Date(ms)
206  return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime()
207}
208
209// all-day events: "2026-10-14" … end exclusive
210function allDayCovers(ev: CalEvent, day: number): boolean {
211  const [ys, ms, ds] = ev.start.slice(0, 10).split('-').map(Number)
212  const [ye, me, de] = ev.end.slice(0, 10).split('-').map(Number)
213  const s = new Date(ys, ms - 1, ds).getTime()
214  const e = new Date(ye, me - 1, de).getTime()
215  return day >= s && day < e
216}
217
218export type Cell = { text: string; color?: string }
219export type Week = { days: { label: string; isToday: boolean }[]; allDay: Cell[]; hours: { label: string; isNow: boolean; cells: Cell[] }[] }
220
221// The grid is always 1 header + 1 all-day row + 12 hour rows, whatever the events.
222export function weekGrid(events: CalEvent[], now: number, dayWidth: number): Week {
223  const today = dayStart(now)
224  const days = Array.from({ length: 7 }, (_, i) => {
225    const d = new Date(today + i * 86400000 + 3600000) // +1h guards DST-free JST anyway
226    const start = dayStart(d.getTime())
227    return { start, label: `${d.getDate()}${WEEKDAY[d.getDay()]}`, isToday: i === 0 }
228  })
229  const fit = (t: string) => {
230    let out = ''
231    for (const ch of t) {
232      if (cells(out + ch) > dayWidth - 1) break
233      out += ch
234    }
235    return pad(out, dayWidth - 1) + ' '
236  }
237  const blank = { text: ' '.repeat(dayWidth) }
238
239  const allDay = days.map(d => {
240    const ev = events.find(e => isAllDay(e) && allDayCovers(e, d.start))
241    if (!ev) return blank
242    const first = allDayCovers(ev, d.start - 86400000) && d.start !== today ? '' : shortTitle(ev.title)
243    return { text: fit(first), color: eventColor(ev) }
244  })
245
246  const nowHour = new Date(now).getHours()
247  const hours = []
248  for (let h = HOUR_FIRST; h <= HOUR_LAST; h++) {
249    const cellsRow = days.map(d => {
250      const from = d.start + h * 3600000
251      const to = from + 3600000
252      // An event that starts within this hour wins over one still running
253      // into it (13:00–14:30 then 14:30–17:30: the 14:00 row is the second,
254      // with its name); with none starting, the running one's band goes on.
255      const overlapping = events.filter(e => !isAllDay(e) && Date.parse(e.start) < to && Date.parse(e.end) > from)
256      const starting = overlapping.filter(e => Date.parse(e.start) >= from).sort((a, b) => Date.parse(a.start) - Date.parse(b.start))
257      const ev = starting[0] ?? overlapping[0]
258      if (!ev) return blank
259      const startsHere = Date.parse(ev.start) >= from || h === HOUR_FIRST
260      return { text: fit(startsHere ? shortTitle(ev.title) : ''), color: eventColor(ev) }
261    })
262    hours.push({ label: String(h).padStart(2, '0'), isNow: h === nowHour, cells: cellsRow })
263  }
264  return { days: days.map(d => ({ label: d.label, isToday: d.isToday })), allDay, hours }
265}
266
267// ---- tasks: ideas/task-review/tasks.md ---------------------------------
268// A line is "- ⬜ [PJ] what — @owner — MM/DD(deadline note)". Only lines
269// under a "## " heading count, so the file's own preamble is never a task.
270
271export type Task = {
272  done: boolean
273  tag: string
274  title: string // short: the first part up to its first "(" or ":"
275  full: string // the whole line after the tag, as written
276  owner: string
277  who: string // the owner part as written ("@user・@ops")
278  created?: string
279  note?: string // the parenthetical that ends the line
280  deadline?: { month: number; day: number }
281}
282export type TaskSection = { title: string; tasks: Task[] }
283
284// ---- the next events, and one event's detail ----------------------------
285
286// Timed events not yet over, soonest first; all-day events covering today
287// come first. At most `n`.
288export function upcoming(events: CalEvent[], now: number, n: number): CalEvent[] {
289  const today = dayStart(now)
290  const allDay = events.filter(e => isAllDay(e) && allDayCovers(e, today))
291  const timed = events
292    .filter(e => !isAllDay(e) && Date.parse(e.end) > now)
293    .sort((a, b) => Date.parse(a.start) - Date.parse(b.start))
294  return [...allDay, ...timed].slice(0, n)
295}
296
297// "今日 14:30–16:30" / "明日 10:00–12:00" / "10/14(水) 終日"
298export function whenLabel(ev: CalEvent, now: number): string {
299  const today = dayStart(now)
300  const dayOf = (iso: string) => (isAllDay({ ...ev, start: iso }) ? (() => { const [y, m, d] = iso.slice(0, 10).split('-').map(Number); return new Date(y, m - 1, d).getTime() })() : dayStart(Date.parse(iso)))
301  const start = dayOf(ev.start)
302  const diff = Math.round((start - today) / 86400000)
303  const d = new Date(start)
304  const day = diff === 0 ? '今日' : diff === 1 ? '明日' : `${d.getMonth() + 1}/${d.getDate()}(${WEEKDAY[d.getDay()]})`
305  if (isAllDay(ev)) return `${day} 終日`
306  return `${day} ${hhmm(Date.parse(ev.start))}–${hhmm(Date.parse(ev.end))}`
307}
308
309// The hub an event belongs to, by the "[PJ]" tag at the start of its title.
310// A hub's code is "22_R" in "22_R_sample-genome", "Z90" in "Z90-slides"; a tag
311// naming two ("21_Q/22_R") goes to the first.
312export function hubOfTag(tag: string, slugs: readonly string[]): string | undefined {
313  const first = tag.split('/')[0].trim()
314  if (!first || first.startsWith('status:')) return undefined
315  return slugs.find(s => s.match(/^(\d{2}_[A-Z]|[A-Z]\d{2})/)?.[1] === first)
316}
317
318export function hubOfEvent(ev: CalEvent, slugs: readonly string[]): string | undefined {
319  const tag = ev.title.match(/^\[([^\]]+)\]/)?.[1]
320  return tag ? hubOfTag(tag, slugs) : undefined
321}
322
323// "学会 A の参加登録(ポスターのみ)" → "学会 A の参加登録". Only at "(": a colon
324// often leaves just a label ("解析 8"). A cut leaving under 4 characters keeps the whole.
325export function shortTaskTitle(title: string): string {
326  const m = title.match(/^(.+?)(/)
327  return m && cells(m[1].trim()) >= 4 ? m[1].trim() : title
328}
329
330function plain(text: string): string {
331  return text.replace(/\*\*(.+?)\*\*/g, '$1').replace(/`([^`]+)`/g, '$1').trim()
332}
333
334export function parseTask(done: boolean, body: string): Task {
335  let rest = plain(body)
336  let tag = ''
337  const t = rest.match(/^\[([^\]]+)\]\s*/)
338  if (t) {
339    tag = t[1]
340    rest = rest.slice(t[0].length)
341  }
342  // the deadline note is the parenthetical that ends the line, after the parts
343  let deadline: Task['deadline']
344  const tail = rest.match(/(([^()]*))$/)
345  // " — " separates the parts; tasks.md also writes ")— " with no space before it
346  const parts = rest.split(/\s?— /)
347  const full = parts[0].trim()
348  let owner = ''
349  let ownerText = ''
350  let created: string | undefined
351  for (const part of parts.slice(1)) {
352    const c = part.trim().match(/^(\d{1,2}\/\d{1,2})/)
353    if (c && created === undefined) created = c[1]
354    const at = part.match(/@([A-Za-z0-9_\-・@]+)/)
355    if (at) {
356      ownerText = part.trim()
357      const names = part.match(/@[A-Za-z0-9_\-]+/g) ?? []
358      const others = names.map(n => n.slice(1)).filter(n => n !== 'user')
359      owner = others.map(n => n.replace(/^claude-/, '')).join('・')
360      if (/未依頼/.test(part)) owner += owner ? ' 未依頼' : ''
361    }
362  }
363  if (tail && parts.length > 1) {
364    const d = tail[1].match(/(\d{1,2})\/(\d{1,2})/)
365    if (d) deadline = { month: Number(d[1]), day: Number(d[2]) }
366  }
367  return { done, tag, title: shortTaskTitle(full), full, owner, who: ownerText, created, note: tail && parts.length > 1 ? tail[1] : undefined, deadline }
368}
369
370export function parseTasks(md: string): TaskSection[] {
371  const out: TaskSection[] = []
372  for (const raw of md.split('\n')) {
373    const h = raw.match(/^## (.+)$/)
374    if (h) {
375      out.push({ title: h[1].trim(), tasks: [] })
376      continue
377    }
378    const t = raw.match(/^- (⬜|✅) (.+)$/)
379    if (t && out.length > 0) out[out.length - 1].tasks.push(parseTask(t[1] === '✅', t[2]))
380  }
381  return out
382}
383
384// days from today to the deadline (this year, or next year if it is long past)
385export function daysLeft(now: number, d: { month: number; day: number }): number {
386  const today = new Date(dayStart(now))
387  let target = new Date(today.getFullYear(), d.month - 1, d.day)
388  if (target.getTime() < today.getTime() - 180 * 86400000) target = new Date(today.getFullYear() + 1, d.month - 1, d.day)
389  return Math.round((target.getTime() - today.getTime()) / 86400000)
390}
391
392export function tagColor(tag: string): string {
393  if (/^\d\d_[A-Z]/.test(tag)) return 'cyan'
394  if (/^[A-Z]\d\d/.test(tag)) return 'magenta'
395  return 'gray'
396}
397
398export function shortTag(tag: string, width: number): string {
399  const first = tag.split('/')[0]
400  const t = tag.includes('/') ? `${first}+` : first
401  return pad(cells(t) > width ? clip(t, width) : t, width)
402}
403
404export function clip(text: string, width: number): string {
405  if (cells(text) <= width) return text
406  let out = ''
407  for (const ch of text) {
408    if (cells(out + ch) > width - 1) break
409    out += ch
410  }
411  return out + '…'
412}
413
414async function readAll($: any): Promise<{ records: SessionRecord[]; dispatches: Record<string, Dispatch>; limits?: Limits }> {
415  const keys: string[] = await $.store.keys()
416  const records: SessionRecord[] = []
417  const dispatches: Record<string, Dispatch> = {}
418  for (const k of keys) {
419    if (k.startsWith(PREFIX)) records.push((await $.store.get(k)) as SessionRecord)
420    else if (k.startsWith(DISPATCH)) dispatches[k.slice(DISPATCH.length)] = (await $.store.get(k)) as Dispatch
421  }
422  return { records, dispatches, limits: (await $.store.get(LIMITS)) as Limits | undefined }
423}
424
425// ---- alert pane: Gmail (and later Slack), headers only, every 5 minutes ----
426
427const ALERT = 'alert'
428const MAIL_ROWS = 12
429let mailFeed: MailFeed | undefined
430let mailNote = ''
431let mailSeen: Set<string> | undefined
432const mailDone = new Set<string>()
433let mailSel = new Set<string>()
434let mailBulkOpen = false
435let mailPage = 0
436let mailMode: { kind: 'list' } | { kind: 'ask' } | { kind: 'cal-title'; item: MailItem } | { kind: 'cal-when'; item: MailItem; title: string } = { kind: 'list' }
437let mailBusy = false
438// One mail opened from the list: its body, fetched when opened, not kept.
439let mailView: { kind: 'list' } | { kind: 'body'; item: MailItem; body?: MailBody; full: boolean } = { kind: 'list' }
440const MAIL_BODY_LINES = 40
441
442async function mailOpen($: any, python: string, repo: string, item: MailItem): Promise<void> {
443  mailView = { kind: 'body', item, full: false }
444  $.ui.invalidate('ui.render')
445  try {
446    const r = await $.process.run(bodyArgv(python, repo, item.id), { cwd: repo, timeoutMs: 60000 })
447    const body = parseBody(String(r.stdout ?? ''), item.id)
448    if (mailView.kind === 'body' && mailView.item.id === item.id) mailView = { ...mailView, body }
449  } catch (err) {
450    if (mailView.kind === 'body' && mailView.item.id === item.id) mailView = { ...mailView, body: { id: item.id, error: String(err).slice(0, 80) } }
451  }
452  $.ui.invalidate('ui.render')
453}
454
455// Headers only (scripts/gmail/alert_feed.py); nothing here reaches the model.
456// `toast`: say "Gmail 新着 n 件" for ids not seen at the last look (never on
457// the first look of the session).
458async function fetchMail($: any, python: string, repo: string, toast: boolean): Promise<void> {
459  if (!python || !repo || mailBusy) return
460  mailBusy = true
461  try {
462    const r = await $.process.run([python, `${repo}/scripts/gmail/alert_feed.py`], { cwd: repo, timeoutMs: 120000 })
463    const feed = parseFeed(String(r.stdout ?? ''))
464    if (feed.error) {
465      mailNote = `Gmail を取れなかった(${feed.error.slice(0, 80)})`
466      return
467    }
468    const fresh = newIds(mailSeen, feed.items)
469    if (toast && fresh.length > 0) $.ui.toast(`Gmail 新着 ${fresh.length} 件`, { timeoutMs: 10000 })
470    mailSeen = new Set(feed.items.map(x => x.id))
471    mailFeed = feed
472    for (const id of [...mailSel]) if (!mailSeen.has(id)) mailSel.delete(id)
473    for (const id of [...mailDone]) if (!mailSeen.has(id)) mailDone.delete(id)
474    // A fetch clears only its own earlier failure, not what an action said.
475    if (mailNote.startsWith('Gmail を取れなかった')) mailNote = ''
476  } catch (err) {
477    mailNote = `Gmail を取れなかった(${String(err).slice(0, 60)})`
478  } finally {
479    mailBusy = false
480    $.ui.invalidate('ui.render')
481  }
482}
483
484function mailSelected(): MailItem[] {
485  return (mailFeed?.items ?? []).filter(x => mailSel.has(x.id))
486}
487
488async function mailMarkDone($: any, python: string, repo: string): Promise<void> {
489  const items = mailSelected()
490  if (items.length === 0) return
491  mailNote = `Done のラベルを付けています(${items.length} 件)…`
492  $.ui.invalidate('ui.render')
493  try {
494    const r = await $.process.run(doneArgv(python, repo, items.map(x => x.id)), { cwd: repo, timeoutMs: 60000 })
495    if (r.exitCode === 0) {
496      for (const x of items) mailDone.add(x.id)
497      mailSel = new Set()
498      mailNote = `Done にした: ${items.length} 件`
499    } else {
500      mailNote = `Done にできなかった(exit ${r.exitCode}: ${String(r.stderr ?? '').trim().slice(-80)})`
501    }
502  } catch (err) {
503    mailNote = `Done にできなかった(${String(err).slice(0, 60)})`
504  }
505  $.ui.invalidate('ui.render')
506}
507
508async function mailAsk($: any, ask: string): Promise<void> {
509  const text = detailPrompt(mailSelected(), ask)
510  if (!text) {
511    mailNote = '人事などの区分を含むので、モデルには渡しません'
512  } else {
513    const r = await $.prompt.fill({ text })
514    mailNote = r.isFilled ? 'プロンプトに下書きを入れた(直して送ると、選んだメールだけを読みます)' : `プロンプトに入れられなかった(${r.reason ?? '理由不明'})`
515    if (r.isFilled) mailSel = new Set()
516  }
517  mailMode = { kind: 'list' }
518  $.ui.invalidate('ui.render')
519}
520
521async function mailCalendar($: any, python: string, repo: string, title: string, whenText: string): Promise<void> {
522  const when = parseWhen(whenText)
523  if ('error' in when) {
524    mailNote = when.error
525    $.ui.invalidate('ui.render')
526    return
527  }
528  mailNote = 'カレンダーに入れています…'
529  mailMode = { kind: 'list' }
530  $.ui.invalidate('ui.render')
531  try {
532    const r = await $.process.run(calendarArgv(python, repo, title, when), { cwd: repo, timeoutMs: 60000 })
533    mailNote = r.exitCode === 0 ? `カレンダーに入れた: ${when.start.replace('T', ' ').slice(0, 16)}  ${title}` : `カレンダーに入れられなかった(exit ${r.exitCode})`
534    if (r.exitCode === 0) {
535      mailSel = new Set()
536      await fetchCalendar($, python, repo)
537    }
538  } catch (err) {
539    mailNote = `カレンダーに入れられなかった(${String(err).slice(0, 60)})`
540  }
541  $.ui.invalidate('ui.render')
542}
543
544// ---- toasts for the ops session: a peer finished or is waiting, an event soon ----
545
546export type Snap = { busy: boolean; waiting?: string; turnStartedAt?: number }
547
548// What changed since the last look, as lines to toast, and the new snapshot.
549// A name not seen before is only remembered: nothing is said on the first look.
550export function peerNotices(prev: Map<string, Snap> | undefined, records: SessionRecord[], now: number): { lines: string[]; snap: Map<string, Snap> } {
551  const snap = new Map<string, Snap>()
552  const lines: string[] = []
553  for (const r of records) {
554    if (!r || r.role === 'ops') continue
555    snap.set(r.name, { busy: r.busy, waiting: r.waiting, turnStartedAt: r.turnStartedAt })
556    const old = prev?.get(r.name)
557    if (!old) continue
558    if (!old.waiting && r.waiting) lines.push(`${r.name} が確認待ち: ${r.waiting.slice(0, 60)}`)
559    else if (old.busy && !r.busy && !r.waiting) lines.push(`${r.name} が終わりました${old.turnStartedAt ? `(作業 ${elapsed(now, old.turnStartedAt)})` : ''}`)
560  }
561  return { lines, snap }
562}
563
564export const SOON_MS = 10 * 60000
565
566// Timed events starting within the next 10 minutes, each told once.
567export function eventNotices(events: CalEvent[], now: number, told: Set<string>): string[] {
568  const out: string[] = []
569  for (const ev of events) {
570    if (isAllDay(ev)) continue
571    const start = Date.parse(ev.start)
572    if (!(start > now && start - now <= SOON_MS)) continue
573    const id = `${ev.start}|${ev.title}`
574    if (told.has(id)) continue
575    told.add(id)
576    const mins = Math.max(1, Math.round((start - now) / 60000))
577    const title = ev.title.replace(/\[status:[^\]]+\]/g, '').trim()
578    out.push(`${hhmm(start)} ${title}(あと ${mins} 分)${ev.meetingUrl ? '  会議 URL あり(dash の予定から)' : ''}`)
579  }
580  return out
581}
582
583let noticeSnap: Map<string, Snap> | undefined
584const toldEvents = new Set<string>()
585
586async function checkNotices($: any): Promise<void> {
587  try {
588    const now = await $.clock.now()
589    const { records } = await readAll($)
590    const { lines, snap } = peerNotices(noticeSnap, records, now)
591    noticeSnap = snap
592    for (const l of [...lines, ...eventNotices(calendar, now, toldEvents)]) $.ui.toast(l, { timeoutMs: 10000 })
593  } catch {
594    // a missed look is caught by the next one
595  }
596}
597
598// The ops session's calendar, fetched every 10 minutes.
599let calendar: CalEvent[] = []
600let calendarNote = ''
601
602async function fetchCalendar($: any, python: string, repo: string): Promise<void> {
603  if (!python || !repo) {
604    calendarNote = 'カレンダー未設定(pluginConfigs の python / life_repo)'
605    return
606  }
607  const now = await $.clock.now()
608  const d0 = new Date(dayStart(now))
609  const d7 = new Date(dayStart(now) + 7 * 86400000 + 3600000)
610  const iso = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
611  try {
612    const r = await $.process.run(
613      [python, `${repo}/scripts/google_calendar_read.py`, '--start', iso(d0), '--end', iso(d7), '--format', 'json', '--details', '--max-results', '200'],
614      { cwd: repo, timeoutMs: 60000 },
615    )
616    if (r.exitCode !== 0) {
617      calendarNote = `カレンダー取得に失敗(exit ${r.exitCode})`
618      return
619    }
620    const list = JSON.parse(r.stdout) as {
621      title: string; start: string; end: string; calendar_id: string
622      location?: string; description?: string; meeting_url?: string; html_link?: string
623    }[]
624    calendar = list.map(e => ({
625      title: e.title, start: e.start, end: e.end, calendarId: e.calendar_id,
626      location: e.location || undefined, description: e.description || undefined,
627      meetingUrl: e.meeting_url || undefined, htmlLink: e.html_link || undefined,
628    }))
629    calendarNote = `更新 ${hhmm(now)}`
630  } catch (err) {
631    calendarNote = `カレンダー取得に失敗(${String(err).slice(0, 40)})`
632  }
633}
634
635// ---- hubs pane: read project hubs, a list then one hub by section ----------
636
637const HUBS = 'hubs'
638let hubs: Hub[] = []
639let hubsLoadedAt = 0
640let hubsNote = ''
641let view: { kind: 'list' } | { kind: 'hub'; slug: string; tab: number } = { kind: 'list' }
642let back: { slug: string; tab: number }[] = []
643let consultNote = ''
644
645// One event's detail, drawn in place of the dash (b goes back). Not a pane of
646// its own: a pane opened from a press in another pane never gets the keys.
647let detail: CalEvent | undefined
648let taskDetail: { task: Task; section: string } | undefined
649
650// The daily trend under the calendar: the newest ideas/daily/md/*-trend.md,
651// read again every 10 minutes. A fixed 5 rows on the dash; `t` lists all.
652const TREND_ROWS = 5
653let trend: Trend | undefined
654let trendNote = ''
655let trendLoadedAt = 0
656let trendItem: TrendItem | undefined
657let trendList: { minStars: number } | undefined
658let trendFiles: string[] = [] // every *-trend.md, newest first
659let trendPast = false // the list of days is open
660let trendShown: Trend | undefined // a past day chosen from that list
661let queued = new Set<string>() // URLs already in the reading queue
662let queueNote = ''
663
664// The reading list: ideas/to-read.md in the life repo, one paper a line.
665// A URL already there is not added again.
666export function toReadLine(it: TrendItem, day: string, today: string): string {
667  const clean = (t: string) => t.replace(/\s+/g, ' ').replace(/[\[\]]/g, '').trim()
668  return `- ⬜ ${today} ${'★'.repeat(it.stars) || '☆'} [${clean(it.ja || it.title)}](${it.url}) — ${clean(it.title)}(${it.source}・trend ${day})`
669}
670
671export function toReadUrls(md: string): Set<string> {
672  return new Set([...md.matchAll(/\]\((https?:\/\/[^)\s]+)\)/g)].map(m => m[1]))
673}
674
675const TO_READ_HEAD = '# 読みたい\n\ndash の trend から「読みたい」で足した論文・記事(ops-dash が末尾に追記)。読んだら ✅、やめたら行を消す。\n\n'
676
677async function refreshQueued($: any, repo: string): Promise<void> {
678  if (!repo) return
679  try {
680    queued = toReadUrls(String(await $.fs.read(`${repo}/ideas/to-read.md`)))
681  } catch {
682    queued = new Set()
683  }
684}
685
686async function addToQueue($: any, repo: string, it: TrendItem, day: string): Promise<void> {
687  const path = `${repo}/ideas/to-read.md`
688  try {
689    let md = ''
690    try {
691      md = String(await $.fs.read(path))
692    } catch {
693      md = TO_READ_HEAD
694    }
695    if (toReadUrls(md).has(it.url)) {
696      queued.add(it.url)
697      queueNote = 'もう読みたいリストに入っています'
698    } else {
699      const now = new Date(await $.clock.now())
700      const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
701      await $.fs.write(path, md.replace(/\n*$/, '\n') + toReadLine(it, day, today) + '\n')
702      queued.add(it.url)
703      queueNote = '読みたいリスト(ideas/to-read.md)に入れました'
704    }
705  } catch (err) {
706    queueNote = `入れられませんでした(${String(err).slice(0, 50)})`
707  }
708  $.ui.invalidate('ui.render')
709}
710
711async function openPastTrend($: any, repo: string, file: string): Promise<void> {
712  try {
713    trendShown = parseTrend(String(await $.fs.read(`${repo}/ideas/daily/md/${file}`)))
714    trendPast = false
715    trendList = { minStars: 0 }
716  } catch (err) {
717    trendNote = `trend を読めない(${String(err).slice(0, 40)})`
718  }
719  $.ui.invalidate('ui.render')
720}
721
722async function loadTrend($: any, repo: string, now: number): Promise<void> {
723  if (!repo || now - trendLoadedAt < 600000) return
724  trendLoadedAt = now
725  try {
726    const dir = `${repo}/ideas/daily/md`
727    const names = ((await $.fs.list(dir)) as any[]).filter(f => f.kind === 'file').map(f => String(f.name))
728    trendFiles = names.filter(n => /^\d{4}-\d{2}-\d{2}-trend\.md$/.test(n)).sort().reverse()
729    const file = latestTrendFile(names)
730    if (!file) {
731      trend = undefined
732      trendNote = 'trend の md がまだ無い'
733      return
734    }
735    trend = parseTrend(String(await $.fs.read(`${dir}/${file}`)))
736    trendNote = ''
737  } catch (err) {
738    trendNote = `trend を読めない(${String(err).slice(0, 40)})`
739  }
740}
741let copied = ''
742const NEXT_EVENTS = 5
743
744async function loadHubs($: any, repo: string): Promise<void> {
745  if (!repo) {
746    hubsNote = 'hub の場所が未設定(pluginConfigs の life_repo)'
747    return
748  }
749  const dir = `${repo}/projects/active`
750  try {
751    const now = await $.clock.now()
752    const entries = await $.fs.list(dir)
753    const files = entries.filter((f: any) => f.kind === 'file' && f.name.endsWith('.md') && f.name !== 'README.md')
754    const out: Hub[] = []
755    for (const f of files) {
756      out.push(parseHub(f.name.replace(/\.md$/, ''), String(await $.fs.read(`${dir}/${f.name}`)), now))
757    }
758    hubs = sortHubs(out)
759    hubsLoadedAt = now
760    hubsNote = ''
761  } catch (err) {
762    hubsNote = `hub を読めない(${String(err).slice(0, 60)})`
763  }
764}
765
766function openHub(slug: string, tab = 0): void {
767  consultNote = ''
768  if (view.kind === 'hub') back.push({ slug: view.slug, tab: view.tab })
769  view = { kind: 'hub', slug, tab }
770}
771
772function goBack(): void {
773  const prev = back.pop()
774  view = prev ? { kind: 'hub', slug: prev.slug, tab: prev.tab } : { kind: 'list' }
775}
776
777function openTask($: any, task: Task, section: string): void {
778  taskDetail = { task, section }
779  detail = undefined
780  $.ui.invalidate('ui.render')
781}
782
783function closeTask($: any): void {
784  taskDetail = undefined
785  $.ui.invalidate('ui.render')
786}
787
788function openEvent($: any, ev: CalEvent): void {
789  taskDetail = undefined
790  detail = ev
791  copied = ''
792  $.ui.invalidate('ui.render')
793}
794
795function closeEvent($: any): void {
796  detail = undefined
797  copied = ''
798  $.ui.invalidate('ui.render')
799}
800
801async function copyText($: any, label: string, text: string, press: any): Promise<void> {
802  const r = await $.ui.copy({ text, surface: press?.surface })
803  copied = r.isCopied ? `${label}をコピーしました` : `コピーできませんでした(${r.reason ?? '理由不明'})`
804  $.ui.invalidate('ui.render')
805}
806
807async function goToHub($: any, slug: string): Promise<void> {
808  openHub(slug)
809  taskDetail = undefined
810  closeEvent($)
811  await $.ui.open({ id: HUBS, title: 'hubs', columns: 84, focus: true })
812}
813
814// ---- notes pane: a project's note → its contents → a number's pictures ----
815
816const NOTES = 'notes'
817type NotePj = { slug: string; title: string; code: string; data: string }
818let notePjs: NotePj[] = []
819let notesView:
820  | { kind: 'list' }
821  | { kind: 'toc'; pj: NotePj; rows: TocRow[] }
822  | { kind: 'pngs'; pj: NotePj; rows: TocRow[]; row: TocRow; files: string[]; deep?: boolean; body?: string[]; full?: boolean }
823  | { kind: 'image'; pj: NotePj; rows: TocRow[]; row: TocRow; files: string[]; file: string; src?: string; w?: number; h?: number; body?: string[] } = { kind: 'list' }
824const BODY_LINES = 20
825let notesNote = ''
826const THUMB = '/tmp/ops-dash-notes-view.png'
827const MAX_B64 = 2700000 // Image takes at most 2 MiB of picture
828
829// The list comes from the hubs alone (nas_code and nas_data): nothing is read
830// from the NAS until a project is chosen.
831async function notesLoad($: any, repo: string): Promise<void> {
832  if (hubs.length === 0) await loadHubs($, repo)
833  const out: NotePj[] = []
834  for (const h of hubs) {
835    const code = frontValue(h.text, 'nas_code')
836    const data = frontValue(h.text, 'nas_data')
837    if (code && data) out.push({ slug: h.slug, title: h.title, code, data })
838  }
839  notePjs = out
840}
841
842// Only the head of note.md, up to the end of its contents (a note can be
843// 10,000+ lines on a slow NAS): awk stops reading there.
844async function notesOpenToc($: any, pj: NotePj): Promise<void> {
845  notesView = { kind: 'toc', pj, rows: [] }
846  notesNote = `${pj.code}/note/note.md の目次を読んでいます…`
847  $.ui.invalidate('ui.render')
848  try {
849    const r = await $.process.run(['awk', '{ print } /<!-- TOC:END -->/ { exit }', `${pj.code}/note/note.md`], { timeoutMs: 20000 })
850    const rows = tocRows(String(r.stdout ?? ''))
851    notesView = { kind: 'toc', pj, rows }
852    notesNote = rows.length > 0 ? '' : r.exitCode === 0 ? 'この note には目次がありません(scripts/note_toc.py で作る)' : `note を読めない(exit ${r.exitCode})`
853  } catch (err) {
854    notesNote = `note を読めない(${String(err).slice(0, 60)})`
855  }
856  $.ui.invalidate('ui.render')
857}
858
859// First the quick look: <number>/out/*.png only, no walking
860// down the tree (the NAS is slow to walk). `deep` walks it, on request.
861// The entry itself, read alongside: awk prints from the number's heading to
862// the next "## " heading and stops there, not reading the rest of the note.
863async function notesReadEntry($: any, pj: NotePj, id: string): Promise<string[]> {
864  try {
865    const prog = '$0 ~ "^## " id " — " { p = 1 } p && /^## / && $0 !~ "^## " id " — " { exit } p { print }'
866    const r = await $.process.run(['awk', '-v', `id=${id}`, prog, `${pj.code}/note/note.md`], { timeoutMs: 20000 })
867    return entryLines(String(r.stdout ?? ''), id)
868  } catch {
869    return []
870  }
871}
872
873async function notesOpenPngs($: any, pj: NotePj, rows: TocRow[], row: TocRow, deep = false, body?: string[]): Promise<void> {
874  const dir = `${pj.data}/${row.id}`
875  notesView = { kind: 'pngs', pj, rows, row, files: [], deep, body }
876  const bodyRead = body ? Promise.resolve(body) : notesReadEntry($, pj, row.id)
877  notesNote = deep ? `${dir} の下の階層も探しています…` : `${dir}/out を見ています…`
878  $.ui.invalidate('ui.render')
879  try {
880    const argv = deep
881      ? ['find', dir, '-maxdepth', '6', '-type', 'f', '-iname', '*.png']
882      : ['/bin/sh', '-c', 'for f in "$1"/out/*.png "$1"/out/*.PNG; do [ -f "$f" ] && echo "$f"; done; exit 0', 'sh', dir]
883    const [r, lines] = await Promise.all([$.process.run(argv, { timeoutMs: deep ? 60000 : 15000 }), bodyRead])
884    const files = pngList(String(r.stdout ?? ''), dir)
885    notesView = { kind: 'pngs', pj, rows, row, files, deep, body: lines }
886    const where = deep ? '下の階層まで探して' : 'out/ に'
887    notesNote = files.length === 0 ? (r.exitCode === 0 ? `${where} png はありません` : `探せなかった(exit ${r.exitCode})`) : `${where} ${files.length} 件${files.length >= 200 ? '(先頭 200 件)' : ''}`
888  } catch (err) {
889    notesNote = `探せなかった(${String(err).slice(0, 60)})`
890  }
891  $.ui.invalidate('ui.render')
892}
893
894async function notesOpenImage($: any, v: { pj: NotePj; rows: TocRow[]; row: TocRow; files: string[] }, file: string): Promise<void> {
895  const path = `${v.pj.data}/${v.row.id}/${file}`
896  notesView = { ...v, kind: 'image', file }
897  notesNote = '読み込んでいます…'
898  $.ui.invalidate('ui.render')
899  try {
900    const dims = await $.process.run(['sips', '-g', 'pixelWidth', '-g', 'pixelHeight', path], { timeoutMs: 20000 })
901    const w = Number(String(dims.stdout).match(/pixelWidth:\s*(\d+)/)?.[1] ?? 0)
902    const h = Number(String(dims.stdout).match(/pixelHeight:\s*(\d+)/)?.[1] ?? 0)
903    let src = bytesToBase64(await $.fs.read(path, { as: 'bytes' }))
904    if (src.length > MAX_B64) {
905      await $.process.run(['sips', '-Z', '1600', path, '--out', THUMB], { timeoutMs: 30000 })
906      src = bytesToBase64(await $.fs.read(THUMB, { as: 'bytes' }))
907    }
908    if (!isBase64(src) || src.length > MAX_B64) {
909      notesNote = '画像を読めなかった(大きすぎるか、png でない)'
910      notesView = { ...v, kind: 'image', file }
911    } else {
912      notesView = { ...v, kind: 'image', file, src, w, h }
913      notesNote = w && h ? `${w} × ${h} px` : ''
914    }
915  } catch (err) {
916    notesNote = `画像を読めなかった(${String(err).slice(0, 60)})`
917  }
918  $.ui.invalidate('ui.render')
919}
920
921// ---- fig pane: a draft figure any session drops in a folder, and the palette ----
922
923// Any session puts a PNG in ~/.local/share/life/preview/ (figstyle.preview does
924// it in one line); /fig shows the newest at once, the rest as a list, and the
925// shared palette's sample always first.
926const FIG = 'fig'
927type FigEntry = { label: string; path: string }
928let figList: FigEntry[] = []
929let figView: { kind: 'list' } | { kind: 'image'; entry: FigEntry; src?: string; w?: number; h?: number } = { kind: 'list' }
930let figNote = ''
931const FIG_MAX = 15
932
933async function loadPicture($: any, path: string): Promise<{ src?: string; w?: number; h?: number; note: string }> {
934  try {
935    const dims = await $.process.run(['sips', '-g', 'pixelWidth', '-g', 'pixelHeight', path], { timeoutMs: 20000 })
936    const w = Number(String(dims.stdout).match(/pixelWidth:\s*(\d+)/)?.[1] ?? 0)
937    const h = Number(String(dims.stdout).match(/pixelHeight:\s*(\d+)/)?.[1] ?? 0)
938    let src = bytesToBase64(await $.fs.read(path, { as: 'bytes' }))
939    if (src.length > MAX_B64) {
940      await $.process.run(['sips', '-Z', '1600', path, '--out', THUMB], { timeoutMs: 30000 })
941      src = bytesToBase64(await $.fs.read(THUMB, { as: 'bytes' }))
942    }
943    if (!isBase64(src) || src.length > MAX_B64) return { note: '画像を読めなかった(大きすぎるか、png でない)' }
944    return { src, w, h, note: w && h ? `${w} × ${h} px` : '' }
945  } catch (err) {
946    return { note: `画像を読めなかった(${String(err).slice(0, 60)})` }
947  }
948}
949
950async function figLoad($: any, repo: string): Promise<void> {
951  const home = String((await $.env.get('HOME')) ?? '')
952  const dir = `${home}/.local/share/life/preview`
953  const out: FigEntry[] = []
954  if (repo) out.push({ label: '共通 12 色の見本(figstyle.GLOBAL)', path: `${repo}/assets/figstyle/palette.png` })
955  try {
956    const r = await $.process.run(['/bin/sh', '-c', 'ls -t "$1"/*.png 2>/dev/null | head -n "$2"; exit 0', 'sh', dir, String(FIG_MAX)], { timeoutMs: 10000 })
957    for (const line of String(r.stdout ?? '').split('\n').map((l: string) => l.trim()).filter(Boolean)) {
958      out.push({ label: line.slice(dir.length + 1), path: line })
959    }
960    figNote = out.length > 1 ? '' : `仮の図はまだありません(${dir}/ に png を置く。figstyle.preview(fig, "名前"))`
961  } catch (err) {
962    figNote = `仮の図の場所を読めない(${String(err).slice(0, 60)})`
963  }
964  figList = out
965}
966
967async function figOpen($: any, entry: FigEntry): Promise<void> {
968  figView = { kind: 'image', entry }
969  figNote = '読み込んでいます…'
970  $.ui.invalidate('ui.render')
971  const p = await loadPicture($, entry.path)
972  figView = { kind: 'image', entry, src: p.src, w: p.w, h: p.h }
973  figNote = p.note
974  $.ui.invalidate('ui.render')
975}
976
977function notesBack($: any): void {
978  const v = notesView
979  if (v.kind === 'image') notesView = { kind: 'pngs', pj: v.pj, rows: v.rows, row: v.row, files: v.files, deep: true, body: v.body }
980  else if (v.kind === 'pngs') notesView = { kind: 'toc', pj: v.pj, rows: v.rows }
981  else notesView = { kind: 'list' }
982  notesNote = ''
983  $.ui.invalidate('ui.render')
984}
985
986export function hubLine(h: Hub, width: number): string {
987  const age = h.daysSinceUpdate === undefined ? '—' : h.daysSinceUpdate === 0 ? '今日' : `${h.daysSinceUpdate}日前`
988  const ms = h.next ? `★${h.next.date} ${h.next.daysLeft}日` : '—'
989  const next = h.openNext > 0 ? `次${h.openNext}` : ''
990  const head = `${pad(h.slug.split('_').slice(0, 2).join('_').replace(/-.*/, ''), 6)} ${pad(h.status, 8)} ${pad(age, 7)} ${pad(ms, 13)} ${pad(next, 4)} `
991  return clip(head + h.title.replace(/\s*\([^)]*\)\s*$/, ''), width)
992}
993
994// Who this session is, read once per load. Every hook asks, so a skipped
995// session.start (or a reload) never leaves the session silent.
996let self: Me | undefined
997let resolved = false
998
999async function who($: any): Promise<Me | undefined> {
1000  if (!resolved) {
1001    self = await whoAmI($)
1002    resolved = true
1003  }
1004  return self
1005}
1006
1007export const register: Register = (on, options) => {
1008  resolved = false
1009  const python = String(options.python ?? '')
1010  const repo = String(options.life_repo ?? '')
1011
1012  on('session.start', async ($, e, next) => {
1013    const me = await who($)
1014    if (me) {
1015      await write($, me, { busy: false })
1016      if (me.role === 'ops') {
1017        // The pane shows context and plan usage; no status line (and clear one
1018        // an earlier version of this mod left).
1019        $.ui.status(undefined)
1020        try {
1021          await $.command.register({ name: 'notes', description: 'Open the research notes: a project, its note contents by number, the pictures under that number, one picture' })
1022          await $.command.register({ name: 'fig', description: 'Show draft figures any session drops in ~/.local/share/life/preview/ (newest first), and the shared palette' })
1023          await $.command.register({ name: 'hubs', description: 'Open the project hubs: a list, then one hub by section, with links to related hubs' })
1024          await $.command.register({ name: 'alert', description: 'Open the alerts: new Gmail (headers only) to mark Done, ask about, or put on the calendar; Slack later' })
1025          await $.command.register({ name: COMMAND, description: 'Open the ops dashboard: every session with a role (busy or idle, context, last turn, last report, last dispatch) and the plan usage' })
1026          $.clock.every(30000, () => $.ui.invalidate('ui.render'))
1027          void checkNotices($)
1028          $.clock.every(30000, () => checkNotices($))
1029          void fetchMail($, python, repo, false)
1030          $.clock.every(300000, () => fetchMail($, python, repo, true))
1031          void fetchCalendar($, python, repo).then(() => $.ui.invalidate('ui.render'))
1032          // Open the three panes at start, without taking the keyboard. Opened
1033          // unasked, a pane is drawn from 144 columns (110 once the person has
1034          // opened it); narrower, it waits until the terminal widens or its
1035          // command is run. dash first: the pane opened first is the one in front
1036          // (10/08, seen on the terminal: hubs opened first stayed in front), and
1037          // its tab comes first. notes reads only the hubs here, nothing on the NAS.
1038          void loadHubs($, repo)
1039            .then(() => $.ui.open({ id: PANE, title: 'ops-dash', columns: 58 }))
1040            .then(() => $.ui.open({ id: HUBS, title: 'hubs', columns: 84 }))
1041            .then(() => notesLoad($, repo))
1042            .then(() => $.ui.open({ id: NOTES, title: 'notes', columns: 84 }))
1043            // Which tab ends up in front is not the order of opening on every
1044            // terminal (10/08: with three, notes was in front). Only a focused
1045            // open raises a tab, so dash is opened again with focus when another
1046            // is in front; Esc hands the keyboard back to the prompt.
1047            .then(() => $.ui.panes())
1048            .then((panes: readonly { id: string; isShown: boolean }[]) =>
1049              panes.some(p => p.id === PANE && p.isShown) ? undefined : $.ui.open({ id: PANE, title: 'ops-dash', columns: 58, focus: true }))
1050            .catch((err: unknown) => $.ui.log(`ops-dash: could not open the panes at start: ${String(err)}`, { to: 'debug' }))
1051          $.clock.every(600000, () => fetchCalendar($, python, repo).then(() => $.ui.invalidate('ui.render')))
1052        } catch (err) {
1053          $.ui.log(`ops-dash: could not register /${COMMAND}: ${String(err)}`)
1054        }
1055      }
1056    }
1057    return next(e)
1058  })
1059
1060  // Every question put to the person in this session passes here as a
1061  // tool.call of AskUserQuestion, a guard's $.ui.ask included. While it is
1062  // open the session is waiting for the person, not working.
1063  on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
1064    const me = await who($)
1065    if (!me) return next(e)
1066    const q = String(e.questions?.[0]?.question ?? '').replace(/\s+/g, ' ').trim()
1067    await write($, me, { waiting: q.slice(0, 120) || '確認', waitingSince: await $.clock.now() })
1068    try {
1069      return await next(e)
1070    } finally {
1071      await write($, me, { waiting: undefined, waitingSince: undefined })
1072    }
1073  })
1074
1075  on('turn.start', async ($, e, next) => {
1076    const me = await who($)
1077    // No status line: clear any an earlier version of this mod left.
1078    if (me?.role === 'ops') $.ui.status(undefined)
1079    if (me) await write($, me, { busy: true, turnStartedAt: await $.clock.now() })
1080    return next(e)
1081  })
1082
1083  on('turn.complete', async ($, e, next) => {
1084    const ran = await next(e)
1085    const me = await who($)
1086    if (me) await write($, me, { busy: false, lastTurnEndAt: await $.clock.now() })
1087    return ran
1088  })
1089
1090  on('session.measure', async ($, e, next) => {
1091    const me = await who($)
1092    if (me) {
1093      await write($, me, { contextPercent: e.context.percent, costUsd: e.cost?.usd })
1094      if (e.rateLimits.length > 0) {
1095        await $.store.set(LIMITS, {
1096          at: await $.clock.now(),
1097          windows: e.rateLimits.map(w => ({ kind: w.kind, percentUsed: w.percentUsed, resetsAt: w.resetsAt })),
1098        })
1099      }
1100      $.ui.invalidate('ui.render')
1101    }
1102    return next(e)
1103  })
1104
1105  // ops: remember who was sent what. Others: remember when they last reported to ops.
1106  on('session.send', async ($, e, next) => {
1107    const ran = await next(e)
1108    const me = await who($)
1109    if (me && ran.isDelivered !== false) {
1110      const now = await $.clock.now()
1111      if (me.role === 'ops') {
1112        const line = e.text.split('\n').find(l => l.trim())?.trim().slice(0, 40) ?? ''
1113        try {
1114          await $.store.set(DISPATCH + e.to.replace(/ \[[0-9a-f]+\]$/, ''), { at: now, line })
1115        } catch {
1116          // a lost dispatch record only blanks one cell
1117        }
1118      } else if (/^ops\b/.test(e.to)) {
1119        await write($, me, { lastReportAt: now })
1120      }
1121    }
1122    return ran
1123  })
1124
1125  on('command.run', { command: 'fig' }, async $ => {
1126    await figLoad($, repo)
1127    const newest = figList.find(x => !x.label.startsWith('共通'))
1128    figView = { kind: 'list' }
1129    const opened = await $.ui.open({ id: FIG, title: 'fig', columns: 84, focus: true })
1130    if (newest) void figOpen($, newest)
1131    if (opened.isPlaced) return { text: 'fig opened.' }
1132    return { text: `fig: the pane is waiting and not drawn yet (${opened.reason ?? 'no reason given'}).` }
1133  })
1134
1135  on('ui.render', { component: 'Pane', requestId: FIG }, async ($, e) => {
1136    const { Box, Text, Button, Image } = $.ui.resolve(e)
1137    const width = Math.max(40, e.props.bodyColumns ?? 84)
1138    // A reload of this mod empties its memory while the pane stays open:
1139    // read again here rather than asking for /fig again.
1140    if (figList.length === 0) await figLoad($, repo)
1141    const v = figView
1142    const note = figNote !== '' ? <Text dimColor>{figNote}</Text> : null
1143    const reload = async () => {
1144      await figLoad($, repo)
1145      if (v.kind === 'image') await figOpen($, v.entry)
1146      else $.ui.invalidate('ui.render')
1147    }
1148    if (v.kind === 'list') {
1149      return (
1150        <Box flexDirection="column">
1151          <Box flexDirection="row" columnGap={2}>
1152            <Text bold>fig</Text>
1153            <Button key="reload" label="更新" hotkey="r" plain onPress={reload} />
1154          </Box>
1155          {note}
1156          <Box flexDirection="column" marginTop={1}>
1157            {figList.map(f => (
1158              <Button key={`fig-${f.path}`} label={clip(f.label, width - 2)} plain onPress={() => figOpen($, f)} />
1159            ))}
1160          </Box>
1161        </Box>
1162      )
1163    }
1164    const cols = Math.min(255, Math.max(20, width - 2))
1165    return (
1166      <Box flexDirection="column">
1167        <Box flexDirection="row" columnGap={2}>
1168          <Button key="back" label="← 一覧" hotkey="b" plain onPress={() => { figView = { kind: 'list' }; figNote = ''; $.ui.invalidate('ui.render') }} />
1169          <Text bold>{clip(v.entry.label, width - 24)}</Text>
1170          <Button key="reload" label="更新" hotkey="r" plain onPress={reload} />
1171        </Box>
1172        {note}
1173        {v.src && <Image key="fig-image" source={{ png: v.src }} columns={cols} rows={imageRows(v.w ?? 0, v.h ?? 0, cols)} alt="(この端末では画像を描けない。CLAUDE_CODE_FORCE_TERMINAL_IMAGES=1 で起動する)" />}
1174      </Box>
1175    )
1176  })
1177
1178  // A row above the prompt: open dash / hubs / notes without typing a command
1179  // (a typed /dash stays in the chat). The ops session only; a survey wins.
1180  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
1181    const me = await who($)
1182    if (me?.role !== 'ops' || e.props.hasSurvey) return next(e)
1183    const { Box, Text, Button } = $.ui.resolve(e)
1184    const go = async (id: string, title: string, columns: number) => {
1185      if (id === NOTES) void notesLoad($, repo).then(() => $.ui.invalidate('ui.render'))
1186      if (id === HUBS) await loadHubs($, repo)
1187      const r = await $.ui.open({ id, title, columns, focus: true })
1188      if (!r.isPlaced) $.ui.toast(`${title}: 端末が狭くて開けない(${r.reason ?? ''})`)
1189    }
1190    return (
1191      <Box flexDirection="row" columnGap={2}>
1192        <Text dimColor>pane</Text>
1193        <Button key="band-dash" label="ops-dash" hotkey="d" plain onPress={() => go(PANE, 'ops-dash', 58)} />
1194        <Button key="band-hubs" label="hubs" hotkey="h" plain onPress={() => go(HUBS, 'hubs', 84)} />
1195        <Button key="band-notes" label="notes" hotkey="n" plain onPress={() => go(NOTES, 'notes', 84)} />
1196        <Button key="band-alert" label={`alert${mailFeed ? ` ${splitMail(mailFeed.items, mailDone).personal.filter(x => x.unread).length}` : ''}`} hotkey="a" plain onPress={() => go(ALERT, 'alert', 84)} />
1197      </Box>
1198    )
1199  })
1200
hooks/hubs.ts 206 lines
1// Reading project hubs (projects/active/<slug>.md) for the hubs pane.
2// Pure functions: no engine calls, so the tests can feed them text.
3
4export type Milestone = { date: string; label: string; daysLeft: number }
5
6export type Hub = {
7  slug: string
8  title: string
9  summary: string
10  status: string
11  updated: string
12  daysSinceUpdate?: number
13  next?: Milestone
14  openNext: number
15  related: string[]
16  sections: { name: string; body: string }[]
17  text: string
18}
19
20const DAY = 86400000
21
22function dayOf(ms: number): number {
23  const d = new Date(ms)
24  return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime()
25}
26
27function parseDate(s: string): number | undefined {
28  const m = s.match(/(\d{4})-(\d{2})-(\d{2})/)
29  return m ? new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])).getTime() : undefined
30}
31
32function frontmatter(text: string): Record<string, string> {
33  const out: Record<string, string> = {}
34  const m = text.match(/^---\n([\s\S]*?)\n---/)
35  if (!m) return out
36  for (const line of m[1].split('\n')) {
37    const kv = line.match(/^([A-Za-z_]+):\s*(.*)$/)
38    if (kv) out[kv[1]] = kv[2].replace(/^"(.*)"$/, '$1').trim()
39  }
40  return out
41}
42
43// "## " sections, ignoring "## " lines inside code fences.
44export function sections(text: string): { name: string; body: string }[] {
45  const out: { name: string; body: string }[] = []
46  let fence = false
47  let current: { name: string; lines: string[] } | undefined
48  for (const line of text.split('\n')) {
49    if (/^\s*(```|~~~)/.test(line)) fence = !fence
50    if (!fence && line.startsWith('## ')) {
51      if (current) out.push({ name: current.name, body: current.lines.join('\n').trim() })
52      current = { name: line.slice(3).trim(), lines: [] }
53      continue
54    }
55    if (current) current.lines.push(line)
56  }
57  if (current) out.push({ name: current.name, body: current.lines.join('\n').trim() })
58  return out
59}
60
61const plainCell = (s: string) => s.replace(/\*\*/g, '').replace(/[★⭐]/g, '').trim()
62
63// The nearest milestone on or after today, from the Milestones table.
64// Struck-through rows (~~...~~) do not count.
65export function nextMilestone(body: string, now: number): Milestone | undefined {
66  const today = dayOf(now)
67  let best: Milestone | undefined
68  for (const row of body.split('\n')) {
69    if (!row.startsWith('|') || /^\|\s*-/.test(row) || row.includes('~~')) continue
70    const cells = row.split('|').slice(1, -1)
71    if (cells.length < 2) continue
72    const t = parseDate(cells[0])
73    if (t === undefined || t < today) continue
74    const daysLeft = Math.round((t - today) / DAY)
75    if (!best || daysLeft < best.daysLeft) {
76      best = { date: cells[0].match(/\d{4}-(\d{2})-(\d{2})/)![0].slice(5).replace('-', '/').replace(/^0/, ''), label: plainCell(cells[1]), daysLeft }
77    }
78  }
79  return best
80}
81
82export function openActions(body: string): number {
83  return body.split('\n').filter(l => /^\s*(?:-|\d+\.)\s*(?:⬜|\[ \])/.test(l)).length
84}
85
86export function parseHub(slug: string, text: string, now: number): Hub {
87  const fm = frontmatter(text)
88  const secs = sections(text)
89  const find = (pred: (n: string) => boolean) => secs.find(s => pred(s.name))
90  const milestones = find(n => n.startsWith('Milestones'))
91  const nextActions = find(n => n.startsWith('Next Actions'))
92  const updatedAt = parseDate(fm.updated ?? '')
93  const related = (fm.related ?? '')
94    .replace(/^\[|\]$/g, '')
95    .split(',')
96    .map(s => s.trim().split('/').pop()!.replace(/\.md$/, ''))
97    .filter(Boolean)
98  return {
99    slug,
100    title: (text.match(/^# (.+)$/m)?.[1] ?? slug).trim(),
101    summary: fm.summary ?? '',
102    status: fm.status ?? '',
103    updated: fm.updated ?? '',
104    daysSinceUpdate: updatedAt === undefined ? undefined : Math.round((dayOf(now) - updatedAt) / DAY),
105    next: milestones ? nextMilestone(milestones.body, now) : undefined,
106    openNext: nextActions ? openActions(nextActions.body) : 0,
107    related,
108    sections: secs,
109    text,
110  }
111}
112
113const STATUS_ORDER = ['active', 'pending', 'blocked', 'standing', 'done', 'archived']
114
115export function sortHubs(hubs: Hub[]): Hub[] {
116  const rank = (s: string) => (STATUS_ORDER.indexOf(s) === -1 ? 99 : STATUS_ORDER.indexOf(s))
117  return [...hubs].sort((a, b) => rank(a.status) - rank(b.status) || (a.daysSinceUpdate ?? 9999) - (b.daysSinceUpdate ?? 9999) || a.slug.localeCompare(b.slug))
118}
119
120// The tabs of the detail view, and how each finds its section.
121export const TABS: { key: string; label: string; pick: (h: Hub) => { name: string; body: string } | undefined }[] = [
122  {
123    key: '1',
124    label: 'Current State',
125    // older hubs append dated "Current State" sections; the last one is the newest
126    pick: h => [...h.sections].reverse().find(s => s.name.includes('Current State')),
127  },
128  { key: '2', label: 'Milestones', pick: h => h.sections.find(s => s.name.startsWith('Milestones')) },
129  { key: '3', label: 'Next Actions', pick: h => h.sections.find(s => s.name.startsWith('Next Actions')) },
130  { key: '4', label: '会議', pick: h => h.sections.find(s => s.name.startsWith('Meetings')) },
131  { key: '5', label: '置き場', pick: h => h.sections.find(s => s.name.startsWith('Repositories')) },
132  { key: '6', label: '全文', pick: h => ({ name: '全文', body: h.text }) },
133]
134
135// Tables wrap badly in a pane, so every table is drawn as a list: the first
136// cell bold, the second beside it, the rest as "column: value" lines below.
137// Code fences are left as they are.
138const cellsOf = (row: string) => row.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map(c => c.trim())
139const isRule = (row: string) => /^\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$/.test(row.trim())
140
141export function tablesToLists(md: string): string {
142  const lines = md.split('\n')
143  const out: string[] = []
144  let fence = false
145  for (let i = 0; i < lines.length; i++) {
146    const line = lines[i]
147    if (/^\s*(```|~~~)/.test(line)) fence = !fence
148    const startsTable = !fence && line.trim().startsWith('|') && i + 1 < lines.length && isRule(lines[i + 1])
149    if (!startsTable) {
150      out.push(line)
151      continue
152    }
153    const head = cellsOf(line)
154    i += 2
155    while (i < lines.length && lines[i].trim().startsWith('|')) {
156      const c = cellsOf(lines[i])
157      const first = c[0] ? `**${c[0].replace(/\*\*/g, '')}**` : ''
158      out.push(`- ${[first, c[1]].filter(Boolean).join(' — ')}`)
159      for (let k = 2; k < c.length; k++) {
160        if (c[k] && c[k] !== '—' && c[k] !== '-') out.push(`  - ${head[k] ? `${head[k]}: ` : ''}${c[k]}`)
161      }
162      i++
163    }
164    i--
165    out.push('')
166  }
167  return out.join('\n')
168}
169
170export const MAX_CHARS = 90000
171
172export function sectionMarkdown(h: Hub, tab: number): string {
173  const t = TABS[tab]
174  const s = t.pick(h)
175  if (!s) return `_この hub には「${t.label}」の節がありません。_`
176  const body = tablesToLists(s.name === '全文' ? s.body : `## ${s.name}\n\n${s.body}`)
177  if (body.length <= MAX_CHARS) return body
178  return body.slice(0, MAX_CHARS) + `\n\n_(${body.length.toLocaleString()} 字のうち先頭 ${MAX_CHARS.toLocaleString()} 字まで。続きは節ごとに見てください)_`
179}
180
181// The draft put in the ops prompt by "次の一手を相談": where the hub is, its
182// current state and its first open next actions, then the ask. The person
183// reads it, edits it if they like, and sends it; nothing is sent from here.
184export function consultText(h: Hub, path: string, maxState = 1500): string {
185  const state = TABS[0].pick(h)
186  let body = (state?.body ?? '').trim()
187  if (body.length > maxState) body = body.slice(0, maxState) + '\n…(続きは hub)'
188  const next = h.sections.find(s => s.name.startsWith('Next Actions'))
189  const open = (next?.body ?? '')
190    .split('\n')
191    .filter(l => /^\s*(?:-|\d+\.)\s*(?:⬜|\[ \])/.test(l))
192    .slice(0, 3)
193    .map(l => l.trim())
194  return [
195    `hub「${h.slug}」(${h.title})の次の一手を一緒に考えたい。全文は ${path}`,
196    '',
197    `## ${state?.name ?? 'Current State'}`,
198    body || '(書かれていない)',
199    '',
200    '## Next Actions(まだ済んでいない上から 3 つ)',
201    ...(open.length ? open : ['(無い)']),
202    '',
203    'これを踏まえて、次にやるべきことを 1〜3 個、理由つきで提案して。締切と、誰がやるか(user / ops / peer)も添えて。',
204  ].join('\n')
205}
206
hooks/notes.ts 92 lines
1// Reading a research repo's note/note.md table of contents (written by the
2// life repo's scripts/note_toc.py) and choosing pictures under a number's
3// data folder, for the notes pane. Pure functions: the tests feed them text.
4
5export type TocRow = { id: string; date: string; title: string; conclusion: string; state: string }
6
7const cellsOf = (row: string) => row.trim().replace(/^\|/, '').replace(/\|$/, '').split(/(?<!\\)\|/).map(c => c.trim().replace(/\\\|/g, '|'))
8
9// The rows of the table between <!-- TOC:START --> and <!-- TOC:END -->.
10export function tocRows(md: string): TocRow[] {
11  const start = md.indexOf('<!-- TOC:START -->')
12  const end = md.indexOf('<!-- TOC:END -->')
13  if (start < 0 || end < start) return []
14  const out: TocRow[] = []
15  for (const line of md.slice(start, end).split('\n')) {
16    if (!/^\|\s*KM_[A-Z]\d{4}\s*\|/.test(line)) continue
17    const c = cellsOf(line)
18    if (c.length < 5) continue
19    out.push({ id: c[0], date: c[1], title: c[2], conclusion: c[3], state: c[4] })
20  }
21  return out
22}
23
24// A hub's frontmatter value, the inline comment after "  #" dropped.
25export function frontValue(md: string, key: string): string | undefined {
26  const fm = md.match(/^---\n([\s\S]*?)\n---/)?.[1] ?? ''
27  const line = fm.split('\n').find(l => l.startsWith(`${key}:`))
28  if (!line) return undefined
29  return line.slice(key.length + 1).replace(/\s+#.*$/, '').trim() || undefined
30}
31
32// `find` output → picture paths relative to the number's folder, sorted, at most `max`.
33export function pngList(findOut: string, base: string, max = 200): string[] {
34  const root = base.replace(/\/+$/, '') + '/'
35  return findOut
36    .split('\n')
37    .map(l => l.trim())
38    .filter(l => l.toLowerCase().endsWith('.png') && l.startsWith(root))
39    .map(l => l.slice(root.length))
40    .sort((a, b) => a.localeCompare(b))
41    .slice(0, max)
42}
43
44// Terminal rows for a picture `columns` wide: a cell is about twice as tall as wide.
45export function imageRows(width: number, height: number, columns: number, maxRows = 40): number {
46  if (!width || !height) return Math.min(maxRows, Math.round(columns / 2))
47  return Math.max(4, Math.min(maxRows, Math.round((columns * height) / width / 2)))
48}
49
50const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
51
52// Standard padded base64: what Image's `png` must be.
53export function base64(u8: Uint8Array): string {
54  let out = ''
55  for (let i = 0; i < u8.length; i += 3) {
56    const a = u8[i], b = i + 1 < u8.length ? u8[i + 1] : 0, c = i + 2 < u8.length ? u8[i + 2] : 0
57    const n = (a << 16) | (b << 8) | c
58    out += B64[(n >> 18) & 63] + B64[(n >> 12) & 63]
59    out += i + 1 < u8.length ? B64[(n >> 6) & 63] : '='
60    out += i + 2 < u8.length ? B64[n & 63] : '='
61  }
62  return out
63}
64
65// What $.fs.read(path, { as: 'bytes' }) gave, as base64: a session hands
66// { base64 }, a test hands bytes (or an object of them).
67export function bytesToBase64(r: any): string {
68  if (typeof r?.base64 === 'string') return r.base64
69  const raw = r?.bytes ?? r
70  const u8 = raw instanceof Uint8Array ? raw : Array.isArray(raw) ? Uint8Array.from(raw) : raw && typeof raw === 'object' ? Uint8Array.from(Object.values(raw) as number[]) : new Uint8Array(0)
71  return base64(u8)
72}
73
74export const isBase64 = (s: string) => s.length > 0 && s.length % 4 === 0 && /^[A-Za-z0-9+/]+={0,2}$/.test(s)
75
76// One number's entry in note.md: the lines under its "## <id> — …" heading,
77// up to the next "## " heading. `awk` hands over just that span (heading
78// included); the whole note works too. No such heading: nothing.
79export function entryLines(md: string, id: string): string[] {
80  const lines = md.split('\n')
81  const at = lines.findIndex(l => l.startsWith(`## ${id} — `))
82  if (at < 0) return []
83  const out: string[] = []
84  for (let i = at + 1; i < lines.length; i++) {
85    if (lines[i].startsWith('## ')) break
86    out.push(lines[i])
87  }
88  while (out.length && !out[out.length - 1].trim()) out.pop()
89  while (out.length && !out[0].trim()) out.shift()
90  return out
91}
92
hooks/trend.ts 75 lines
1// Reading the daily search-trend digest (ideas/daily/md/YYYY-MM-DD-trend.md):
2// tables of papers and news, each row a linked original title, its Japanese
3// rendering and an interest rating in stars. Pure functions: the tests feed
4// them text.
5
6export type TrendItem = {
7  source: string // the "### " heading the table sits under
8  short: string // a few letters for the one-line view
9  title: string
10  url: string
11  ja: string
12  stars: number
13  category: string
14  order: number // position in the file, for stable sorting
15}
16
17export type Trend = { date: string; items: TrendItem[] }
18
19const SHORT: [RegExp, string][] = [
20  [/pubmed/i, 'PM'],
21  [/biorxiv|europe\s*pmc/i, 'bR'],
22  [/nature/i, 'Nat'],
23  [/science|aaas/i, 'Sci'],
24  [/ナゾロジー/, 'ナゾ'],
25]
26
27export function shortSource(source: string): string {
28  return SHORT.find(([r]) => r.test(source))?.[1] ?? source.slice(0, 4)
29}
30
31const cellsOf = (row: string) =>
32  row.trim().replace(/^\|/, '').replace(/\|$/, '').split(/(?<!\\)\|/).map(c => c.trim().replace(/\\\|/g, '|'))
33
34export function parseTrend(md: string): Trend {
35  const date = md.match(/^# .*?(\d{4}-\d{2}-\d{2})/m)?.[1] ?? ''
36  const items: TrendItem[] = []
37  let source = ''
38  for (const line of md.split('\n')) {
39    const h = line.match(/^###\s+(.+)$/)
40    if (h) {
41      source = h[1].trim()
42      continue
43    }
44    if (!source || !line.trim().startsWith('|') || /^\|\s*-/.test(line.trim())) continue
45    const c = cellsOf(line)
46    const link = c[0]?.match(/^\[(.+)\]\((\S+)\)$/)
47    if (!link) continue // the header row and anything else without a linked title
48    items.push({
49      source,
50      short: shortSource(source),
51      title: link[1],
52      url: link[2],
53      ja: c[1] ?? '',
54      stars: (c[2]?.match(/★/g) ?? []).length,
55      category: c[3] ?? '',
56      order: items.length,
57    })
58  }
59  return { date, items }
60}
61
62// Highest rated first; equal ratings keep the file's order.
63export function topItems(items: readonly TrendItem[], n: number): TrendItem[] {
64  return [...items].sort((a, b) => b.stars - a.stars || a.order - b.order).slice(0, n)
65}
66
67export function starText(n: number): string {
68  return '★'.repeat(n) + '☆'.repeat(Math.max(0, 5 - n))
69}
70
71// The newest "YYYY-MM-DD-trend.md" among file names.
72export function latestTrendFile(names: readonly string[]): string | undefined {
73  return names.filter(n => /^\d{4}-\d{2}-\d{2}-trend\.md$/.test(n)).sort().pop()
74}
75
hooks/alert.ts 120 lines
1// The alert pane's pure parts: the Gmail feed's items, how a row reads, and the
2// commands a selection turns into. No engine calls, so tests can feed them.
3//
4// What reaches the model: nothing, until the person presses 詳しく and sends
5// the draft it puts in the prompt. A message in a sensitive class (人事・成績・
6// 査読 …, decided on the machine in ~/.config/life/alert.json) never gets 詳しく:
7// it can be marked Done or put on the calendar, both run here without the model.
8
9export type MailItem = {
10  id: string
11  thread_id?: string
12  from: string
13  from_name: string
14  subject: string
15  date: string
16  labels: string[]
17  unread: boolean
18  bulk: boolean
19  sensitive?: string
20}
21
22export type MailFeed = { fetched_at: string; items: MailItem[]; error?: string; fetched_new?: number }
23
24export const DONE_LABEL = '9. Done/Triage'
25
26export function parseFeed(stdout: string): MailFeed {
27  try {
28    const d = JSON.parse(stdout)
29    return { fetched_at: String(d.fetched_at ?? ''), items: Array.isArray(d.items) ? d.items : [], error: d.error, fetched_new: d.fetched_new }
30  } catch {
31    return { fetched_at: '', items: [], error: '取得の結果を読めなかった' }
32  }
33}
34
35// Ids in `items` not in `seen`: what came since the last look.
36export function newIds(seen: ReadonlySet<string> | undefined, items: readonly MailItem[]): string[] {
37  if (!seen) return []
38  return items.filter(x => !seen.has(x.id)).map(x => x.id)
39}
40
41// "10/08 14:32" in the machine's zone.
42export function shortDate(iso: string): string {
43  const t = Date.parse(iso)
44  if (Number.isNaN(t)) return '     '
45  const d = new Date(t)
46  const two = (n: number) => String(n).padStart(2, '0')
47  return `${d.getMonth() + 1}/${two(d.getDate())} ${two(d.getHours())}:${two(d.getMinutes())}`
48}
49
50// Personal mail first, newest first; bulk mail (a list-unsubscribe header) after.
51export function splitMail(items: readonly MailItem[], done: ReadonlySet<string>): { personal: MailItem[]; bulk: MailItem[] } {
52  const live = items.filter(x => !done.has(x.id)).sort((a, b) => Date.parse(b.date) - Date.parse(a.date))
53  return { personal: live.filter(x => !x.bulk), bulk: live.filter(x => x.bulk) }
54}
55
56// A row's text; the ☐ / ☑ that selects it is a button of its own before it
57// (pressing the row opens the mail).
58export function mailLine(item: MailItem): string {
59  const dot = item.unread ? '●' : ' '
60  const tag = item.sensitive ? `[${item.sensitive}]` : ''
61  return `${dot} ${shortDate(item.date)}  ${item.from_name}  ${tag}${item.subject}`
62}
63
64// One mail with its body, from `alert_feed.py --body` (the pane's own call; the
65// model is kept from it by ops-rules). Shown on the terminal, never stored.
66export type MailBody = { id: string; from?: string; date?: string; subject?: string; sensitive?: string | null; body?: string; error?: string }
67
68export function bodyArgv(python: string, repo: string, id: string): string[] {
69  return [python, `${repo}/scripts/gmail/alert_feed.py`, '--body', id]
70}
71
72export function parseBody(stdout: string, id: string): MailBody {
73  try {
74    const d = JSON.parse(stdout)
75    return { id, ...d }
76  } catch {
77    return { id, error: '本文を読めなかった' }
78  }
79}
80
81export function doneArgv(python: string, repo: string, ids: readonly string[]): string[] {
82  return [python, `${repo}/scripts/gmail_label.py`, ...ids.flatMap(id => ['--message-id', id]), '--add-label', DONE_LABEL]
83}
84
85// "2026-10-15 17:00" or "2026-10-15" (then 09:00); a 30-minute block that
86// ends at the deadline's time would hide it, so the block starts there.
87export function parseWhen(text: string): { start: string; end: string } | { error: string } {
88  const m = text.trim().match(/^(\d{4})-(\d{1,2})-(\d{1,2})(?:[ T](\d{1,2}):(\d{2}))?$/)
89  if (!m) return { error: '日時は 2026-10-15 17:00 か 2026-10-15 の形で' }
90  const [y, mo, d, h, mi] = [m[1], m[2], m[3], m[4] ?? '9', m[5] ?? '00'].map(Number)
91  const start = new Date(y, mo - 1, d, h, mi)
92  if (start.getMonth() !== mo - 1 || start.getDate() !== d || h > 23 || mi > 59) return { error: 'その日時はありません' }
93  const end = new Date(start.getTime() + 30 * 60000)
94  const iso = (t: Date) => `${t.getFullYear()}-${String(t.getMonth() + 1).padStart(2, '0')}-${String(t.getDate()).padStart(2, '0')}T${String(t.getHours()).padStart(2, '0')}:${String(t.getMinutes()).padStart(2, '0')}:00`
95  return { start: iso(start), end: iso(end) }
96}
97
98export function calendarArgv(python: string, repo: string, title: string, when: { start: string; end: string }): string[] {
99  return [python, `${repo}/scripts/google_calendar_create.py`, '--title', title, '--start', when.start, '--end', when.end, '--execute']
100}
101
102// A sensitive message's default title says only its class, never its subject
103// (a subject can carry the matter itself).
104export function calendarTitle(item: MailItem): string {
105  return item.sensitive ? `[${item.sensitive}]締切` : `〆 ${item.subject}`
106}
107
108// The draft 詳しく puts in the prompt: the ids to read, and what to do. Only
109// the person's send hands it to the model; sensitive mail is refused here too.
110export function detailPrompt(items: readonly MailItem[], ask: string): string | undefined {
111  if (items.length === 0 || items.some(x => x.sensitive)) return undefined
112  const lines = items.map(x => `- id ${x.id} — ${shortDate(x.date)} ${x.from_name}「${x.subject}」`)
113  return [
114    'Gmail の次のメールを読んで、頼んだことをしてください。本文は life repo で `python scripts/gmail/alert_feed.py --show <id>` で 1 通ずつ取る(読むだけ。返信・削除はしない)。',
115    ...lines,
116    '',
117    `頼むこと: ${ask.trim() || '要点と、私がすべきことを短く'}`,
118  ].join('\n')
119}
120