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.

Personal dotfiles for zsh, WezTerm, and global Codex / Claude Code Skills.
./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.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.
/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 CLI
npm install -g @openai/codex
mkdir -p ~/.config/codex
mv ~/.codex/* ~/.config/codex/
codex sign-in
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。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
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
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.
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:
work/<hostname> rebased onto main to avoid long-lived divergence.main.wezterm/keybinds.lua and linked to ~/.config/wezterm/keybinds.lua.skills/ and linked to both ~/.config/codex/skills/ and ~/.claude/skills/.~/.config/.czrc is not committed; see czrc/.czrc.example for a template.Keyboard
日本語 - ローマ字入力英字オフの時「英字」を入力Trackpad
MaxオンPointer
1つ大きくする#D05654#464758iCloud
オンDesktop
スタックを使用表示オプションを表示10種類名前36x36下から4番目Dock
左オンDefault browser
Chromehooks/register.tsx 1796 lines1import 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 })
1200hooks/hubs.ts 206 lines1// 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}
206hooks/notes.ts 92 lines1// 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}
92hooks/trend.ts 75 lines1// 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}
75hooks/alert.ts 120 lines1// 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