SLOPSHOPPER

waypoint

Project progress in one row above the Claude Code prompt: your /goal, checklists, roadmap, counters, beads epics, GitHub milestones, the agents working beside…

newpanebandguardcommandprocess
v0.1.1MITupdated 2026-10-07VibeMage/claude-mod-waypoint
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · waypoint
│ ┃ Waypoint ✕ › fix the failing auth test and add an audit log call │ ┃ app │ ┃ ⏺ Read(src/auth.ts) │ ┃ Nothing to track yet: no /goal, todos, ⎿ Read 6 lines │ ┃ checklist or roadmap found. ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ No waypoint.json: showing what was found. /… ⏺ 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 │ │ › /waypoint │ ⎿ waypoint: pane opened │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Waypoint
app Nothing to track yet: no /goal, todos, checklist or roadmap found. No waypoint.json: showing what was found. /waypoint config …
README

English | 简体中文

waypoint

Where your project stands, in one row above the Claude Code prompt. Written as a Claude Code mod.

waypoint above the Claude Code prompt

Every item is a soft capsule in Apple system colours, light or dark with your Claude Code theme. When the row is short, the least important capsules give way first. /waypoint opens a pane with the details: open checklist items, every phase, each agent and what it holds.

<img src="docs/pane.png" alt="The /waypoint pane" width="384">

Requires Claude Code 2.1.289 or newer. Mods are an early-access API that may change between releases. If waypoint stops drawing after an update, please open an issue.

Install

In Claude Code:

/plugin marketplace add VibeMage/claude-mod-waypoint
/plugin install waypoint@claude-mod-waypoint

The row appears right away. It works with no setup: it shows your /goal, Claude's todo list, the first TODO.md that has checkboxes and the phase table in ROADMAP.md. Add a config file to follow exactly what you care about.

What it can show

CapsuleWhat it isSource
◎ GoalThe session's /goal, ✓ once Claude Code's evaluator finds it met, with the todo tally beside itthe session transcript (goal_status, /goal)
▸ TodosWithout a goal: done/total and the item in progressTodoWrite and the Task tools
Checklist- [x] / - [ ] items of one section of a Markdown file, with a barany .md file
CounterA tally you keep by hand, or one a command prints as done/totalconfig
●◉○ RoadmapOne dot per phase: done, current, aheada Markdown table with a phase and a status column
EpicA beads epic's closed/total childrenbd epic status --json, at most every 5 minutes
MilestoneA GitHub milestone's closed/total issuesgh api, at most every 5 minutes, only when configured
AgentsThe Claude Code and Codex panes working on the same project: busy, waiting for approval, stuck (rate limit, model at capacity)Otty panes whose folder is the project or one of its git worktrees
LockWho holds a lock file, and on which taskconfig

Config

waypoint reads, in order:

  1. .claude/waypoint.json in the project, a single project object;
  2. ~/.claude/waypoint.json, as { "projects": [ { "paths": ["~/code/app"], ... } ] }, for projects whose repository you would rather not touch.
{
  "name": "My App",
  "checklists": [{ "file": "docs/TODO.md", "marker": "current sprint" }],
  "roadmap": { "file": "docs/ROADMAP.md", "current": "Beta" },
  "counters": [{ "label": "Screens", "done": 12, "total": 30 }],
  "beads": { "epic": "app-12" },
  "github": { "milestone": "v1.0" },
  "team": { "panes": { "p_1a2b3c_4": "Codex A" } },
  "locks": [{ "path": ".local/deploy.lock", "label": "deploy", "worktrees": true }],
  "panes": ["p_1a2b3c_3"]
}
FieldMeaning
nameThe project's name in the pane
rootThe folder relative paths resolve against (default: the repository root)
pathsGlobal file only: the folders this entry applies to
panesDraw the row only in these Otty panes (p_… ids); the pane still opens anywhere
checklists[]file, then which section: section (a heading that contains this text), or marker (the last heading that contains it, for files where each new batch is appended), else the first section with open items. label overrides the name taken from the heading. Only items at the left margin count; indented ones are sub-steps
roadmapfile and optionally current (the phase name it starts with), or false
counters[]label with done and total, or command (an argv array run in the root; its output must contain done/total)
beadstrue, false or { "epic": "<id>" }. On by default when the root has .beads/
githubtrue or { "milestone": "<title>" }. Off by default
teamfalse, or { "panes": { "<pane id>": "<name>" } } to name exactly which panes, or { "cwds": [...], "agents": ["Codex"], "stuck": ["text that means stuck"] } to widen or narrow the automatic match
locks[]path (a file, or a directory holding owner.json), label, and worktrees: true to look for it in every git worktree. The holder is read from actor, owner, holder or user, the task from task or issue

Settings (/config › waypoint): language (auto, en, zh), style (capsule or plain), refresh interval (20 s by default), and whether to show agents.

Commands

  • /waypoint: open or close the detail pane
  • /waypoint refresh: re-read everything now
  • /waypoint config: which config file is in use

What waypoint runs and reads

waypoint is a TypeScript module that Claude Code runs in its mod sandbox. It has no dependencies, downloads nothing and writes no files.

  • Reads the Markdown files and lock files your config names (or TODO.md / ROADMAP.md when there is none), its config files, the session's transcript (only the /goal records and todo tool calls), and your Claude Code theme setting.
  • Runs, every refresh: git worktree list, grep over the session transcript for /goal records, and with Otty, otty pane list and otty pane capture --lines 12 on the agent panes it shows, to tell busy, stuck and waiting apart. Every 5 minutes at most: bd epic status when beads is on, gh api for milestones when GitHub is configured (the one network request, made by your own gh). Plus any command you configure for a counter.
  • Hooks tool.call only for TodoWrite, TaskCreate and TaskUpdate, to read the todo list as Claude writes it; the calls pass through unchanged. It also hooks session.start (to register /waypoint and start the refresh timer), turn.complete (to refresh after each turn), session.end on /clear (to forget the goal and todos), and draws the band and the pane with ui.render.
  • Runs find once per session, only when the transcript is not at its usual path, to locate it under ~/.claude/projects.
  • Never types into another pane, changes a tracker, or edits a file.

See PRIVACY.md.

Develop

claude --plugin-dir .          # load it for one session; saving a file reloads it
claude plugin validate .
claude plugin test .

License

MIT

Source 3 files
hooks/register.tsx 925 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { WpAgent, WpCount, WpGoal, WpLock, WpPhase, WpSnapshot, WpTodo } from '../types'
5import {
6  APPROVAL_PATTERNS,
7  STUCK_PATTERNS,
8  bar,
9  clipWidth,
10  currentPhase,
11  displayWidth,
12  fitSegments,
13  hasCjk,
14  isUnder,
15  latestGoal,
16  normalizePane,
17  paneState,
18  phaseShort,
19  pickSection,
20  replayTodo,
21  roadmapPhases,
22} from './parse'
23
24const PANE = 'waypoint'
25
26// Sources: the project's files, Otty, lock files and trackers, all read-only
27
28export type ChecklistSource = { file: string; section?: string; marker?: string; label?: string }
29export type CounterSource = { label: string; done?: number; total?: number; command?: string[] }
30export type LockSource = { path: string; label?: string; worktrees?: boolean }
31
32export type ProjectConfig = {
33  name?: string
34  root?: string
35  paths?: string[]
36  panes?: string[]
37  checklists?: ChecklistSource[]
38  roadmap?: { file?: string; current?: string } | false
39  counters?: CounterSource[]
40  beads?: { epic?: string } | boolean
41  github?: { milestone?: string } | boolean
42  team?: { panes?: Record<string, string>; cwds?: string[]; agents?: string[]; stuck?: string[] } | false
43  locks?: LockSource[]
44}
45
46type Resolved = { cfg: ProjectConfig; root: string; path?: string; problems: string[] }
47
48const SLOW_MS = 5 * 60_000
49const WORKTREE_MS = 60_000
50
51// Results of the slow sources (bd, gh, git worktree), kept between refreshes in the module
52const cache = new Map<string, { at: number; value: unknown }>()
53
54async function cached<T>(key: string, ttl: number, now: number, load: () => Promise<T>): Promise<T> {
55  const hit = cache.get(key)
56  if (hit && now - hit.at < ttl) return hit.value as T
57  const value = await load()
58  cache.set(key, { at: now, value })
59
60  return value
61}
62
63function expand(path: string, home: string): string {
64  return path === '~' ? home : path.startsWith('~/') ? `${home}${path.slice(1)}` : path
65}
66
67function join(root: string, path: string): string {
68  return path.startsWith('/') ? path : `${root.replace(/\/+$/, '')}/${path}`
69}
70
71async function readJson($: EngineInterface, path: string): Promise<unknown> {
72  return JSON.parse(await $.fs.read(path))
73}
74
75async function run($: EngineInterface, argv: string[], cwd: string, timeoutMs = 8000): Promise<string | null> {
76  try {
77    const { exitCode, stdout } = await $.process.run(argv, { cwd, timeoutMs })
78    return exitCode === 0 ? stdout : null
79  } catch {
80    return null
81  }
82}
83
84// The project's own `.claude/waypoint.json`, else the entry of `~/.claude/waypoint.json` whose paths hold the session
85async function resolveConfig($: EngineInterface, homeDir: string): Promise<Resolved> {
86  const problems: string[] = []
87  const cwd = await $.session.cwd()
88  const root = await $.session.root().catch(() => cwd)
89
90  const own = `${root}/.claude/waypoint.json`
91  if (await $.fs.exists(own).catch(() => false)) {
92    try {
93      const cfg = (await readJson($, own)) as ProjectConfig
94      return { cfg, root: cfg.root ? expand(cfg.root, homeDir) : root, path: own, problems }
95    } catch (err) {
96      problems.push(`${own}: ${String(err)}`)
97    }
98  }
99
100  const configDir = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${homeDir}/.claude`
101  const global = `${configDir}/waypoint.json`
102  if (await $.fs.exists(global).catch(() => false)) {
103    try {
104      const file = (await readJson($, global)) as { projects?: ProjectConfig[] }
105      for (const cfg of file.projects ?? []) {
106        const paths = (cfg.paths ?? (cfg.root ? [cfg.root] : [])).map(p => expand(p, homeDir))
107        if (paths.some(p => isUnder(cwd, p) || isUnder(root, p))) {
108          return { cfg, root: cfg.root ? expand(cfg.root, homeDir) : (paths[0] ?? root), path: global, problems }
109        }
110      }
111    } catch (err) {
112      problems.push(`${global}: ${String(err)}`)
113    }
114  }
115
116  return { cfg: {}, root, problems }
117}
118
119async function checklistCounts($: EngineInterface, cfg: ProjectConfig, root: string, problems: string[]): Promise<WpCount[]> {
120  let sources = cfg.checklists
121  if (!sources) {
122    // No config: the first conventional file that holds checkboxes
123    for (const file of ['TODO.md', 'docs/TODO.md', 'ROADMAP.md', 'docs/ROADMAP.md', 'PLAN.md', 'docs/PLAN.md']) {
124      const text = await $.fs.read(join(root, file)).catch(() => null)
125      if (text && /^[-*+] \[[ xX]\]/m.test(text)) {
126        sources = [{ file }]
127        break
128      }
129    }
130  }
131  const out: WpCount[] = []
132  for (const src of sources ?? []) {
133    const text = await $.fs.read(join(root, src.file)).catch(() => null)
134    if (text === null) {
135      problems.push(`${src.file}: not found`)
136      continue
137    }
138    const name = src.file.split('/').pop()!.replace(/\.md$/i, '')
139    const section = pickSection(text, src, name)
140    if (!section) {
141      if (src.section) problems.push(`${src.file}: no section "${src.section}"`)
142      continue
143    }
144    out.push({
145      kind: 'checklist',
146      label: src.label ?? section.label,
147      done: section.done,
148      total: section.total,
149      detail: src.file,
150      items: section.items,
151    })
152  }
153
154  return out
155}
156
157async function roadmap($: EngineInterface, cfg: ProjectConfig, root: string): Promise<{ phases: WpPhase[]; current: number }> {
158  if (cfg.roadmap === false) return { phases: [], current: -1 }
159  const files = cfg.roadmap?.file ? [cfg.roadmap.file] : ['ROADMAP.md', 'docs/ROADMAP.md']
160  for (const file of files) {
161    const text = await $.fs.read(join(root, file)).catch(() => null)
162    if (text === null) continue
163    const phases = roadmapPhases(text)
164    if (phases.length > 0) return { phases, current: currentPhase(phases, cfg.roadmap?.current) }
165  }
166
167  return { phases: [], current: -1 }
168}
169
170async function counters($: EngineInterface, cfg: ProjectConfig, root: string, problems: string[]): Promise<WpCount[]> {
171  const out: WpCount[] = []
172  for (const c of cfg.counters ?? []) {
173    if (c.command) {
174      const stdout = await run($, c.command, root)
175      const m = stdout?.match(/(\d+)\s*\/\s*(\d+)/)
176      if (!m) {
177        problems.push(`${c.label}: the command printed no "done/total"`)
178        continue
179      }
180      out.push({ kind: 'counter', label: c.label, done: Number(m[1]), total: Number(m[2]) })
181    } else if (typeof c.done === 'number' && typeof c.total === 'number') {
182      out.push({ kind: 'counter', label: c.label, done: c.done, total: c.total })
183    }
184  }
185
186  return out
187}
188
189type BeadsEpic = {
190  epic: { id: string; title: string; status: string; updated_at?: string }
191  total_children: number
192  closed_children: number
193}
194
195async function beads($: EngineInterface, cfg: ProjectConfig, root: string, now: number): Promise<WpCount[]> {
196  if (cfg.beads === false) return []
197  if (cfg.beads === undefined && !(await $.fs.exists(`${root}/.beads`).catch(() => false))) return []
198  const epics = await cached(`bd:${root}`, SLOW_MS, now, async () => {
199    const stdout = await run($, ['bd', 'epic', 'status', '--json'], root, 15_000)
200    try {
201      return stdout ? (JSON.parse(stdout) as BeadsEpic[]) : []
202    } catch {
203      return []
204    }
205  })
206  const pinned = typeof cfg.beads === 'object' ? cfg.beads.epic : undefined
207  const open = epics.filter(e => (pinned ? e.epic.id === pinned : e.epic.status !== 'closed'))
208
209  return open.slice(0, pinned ? 1 : 2).map(e => ({
210    kind: 'beads',
211    label: e.epic.title,
212    done: e.closed_children,
213    total: e.total_children,
214    detail: `beads ${e.epic.id}`,
215  }))
216}
217
218type Milestone = { title: string; open_issues: number; closed_issues: number; due_on?: string | null }
219
220async function github($: EngineInterface, cfg: ProjectConfig, root: string, now: number): Promise<WpCount[]> {
221  if (!cfg.github) return []
222  const list = await cached(`gh:${root}`, SLOW_MS, now, async () => {
223    const stdout = await run($, ['gh', 'api', 'repos/{owner}/{repo}/milestones?state=open'], root, 15_000)
224    try {
225      return stdout ? (JSON.parse(stdout) as Milestone[]) : []
226    } catch {
227      return []
228    }
229  })
230  const wanted = typeof cfg.github === 'object' ? cfg.github.milestone : undefined
231  const pick = wanted ? list.find(m => m.title === wanted) : list[0]
232  if (!pick) return []
233
234  return [
235    {
236      kind: 'github',
237      label: pick.title,
238      done: pick.closed_issues,
239      total: pick.open_issues + pick.closed_issues,
240      detail: 'GitHub milestone',
241    },
242  ]
243}
244
245async function worktrees($: EngineInterface, root: string, now: number): Promise<string[]> {
246  return cached(`wt:${root}`, WORKTREE_MS, now, async () => {
247    const stdout = await run($, ['git', 'worktree', 'list', '--porcelain'], root)
248    const list = (stdout ?? '')
249      .split('\n')
250      .filter(l => l.startsWith('worktree '))
251      .map(l => l.slice(9))
252
253    return list.length > 0 ? list : [root]
254  })
255}
256
257type OttyPane = { id: string; agent?: string; agent_state?: string; cwd?: string; process?: string }
258
259// `✳ Fix the build` → `Fix the build`: Otty's title keeps the agent's spinner glyph in front
260function paneName(pane: OttyPane): string {
261  const title = (pane.process ?? '').replace(/^[^\p{L}\p{N}]+/u, '').trim()
262
263  return title || pane.agent || pane.id
264}
265
266async function teamPanes($: EngineInterface, cfg: ProjectConfig, root: string, self: string | undefined, homeDir: string, dirs: string[]): Promise<WpAgent[] | null> {
267  if (cfg.team === false || !self) return null
268  const stdout = await run($, ['otty', 'pane', 'list', '--json'], root, 4000)
269  if (!stdout) return null
270  let panes: OttyPane[] = []
271  try {
272    const parsed = JSON.parse(stdout) as { data?: OttyPane[] | { panes?: OttyPane[] } }
273    panes = Array.isArray(parsed.data) ? parsed.data : (parsed.data?.panes ?? [])
274  } catch {
275    return null
276  }
277  const named = cfg.team?.panes
278  const agents = cfg.team?.agents ?? ['Claude Code', 'Codex']
279  const places = [...dirs, ...(cfg.team?.cwds ?? []).map(d => expand(d, homeDir))]
280  const members = panes.filter(p =>
281    named
282      ? p.id in named
283      : p.id !== self && agents.includes(p.agent ?? '') && places.some(d => isUnder(p.cwd ?? '', d)),
284  )
285  const stuck = cfg.team?.stuck ?? STUCK_PATTERNS
286
287  return Promise.all(
288    members.map(async p => {
289      const screen = (await run($, ['otty', 'pane', 'capture', '--pane', p.id, '--lines', '12', '--trim'], root, 3000)) ?? ''
290      return {
291        pane: p.id,
292        name: named?.[p.id] ?? paneName(p),
293        agent: p.agent ?? '',
294        state: paneState(p.agent_state ?? '', screen, stuck, APPROVAL_PATTERNS, p.agent ?? ''),
295        cwd: p.cwd ?? '',
296      }
297    }),
298  )
299}
300
301async function locks($: EngineInterface, cfg: ProjectConfig, root: string, homeDir: string, dirs: string[]): Promise<WpLock[]> {
302  const out: WpLock[] = []
303  const seen = new Set<string>()
304  for (const src of cfg.locks ?? []) {
305    const path = expand(src.path, homeDir)
306    const candidates = src.worktrees && !path.startsWith('/') ? dirs.map(d => join(d, path)) : [join(root, path)]
307    for (const candidate of candidates) {
308      const stat = await $.fs.stat(candidate, { resolve: true }).catch(() => undefined)
309      if (!stat) continue
310      const real = stat.realPath ?? candidate
311      if (seen.has(real)) continue
312      seen.add(real)
313      // A lock directory names its holder in owner.json; a lock file is JSON itself or held by being there
314      const file = stat.kind === 'dir' ? `${candidate}/owner.json` : candidate
315      let info: Record<string, unknown> = {}
316      try {
317        info = (await readJson($, file)) as Record<string, unknown>
318      } catch {
319        // Held, holder unnamed
320      }
321      const str = (...keys: string[]) => {
322        for (const k of keys) if (typeof info[k] === 'string' && info[k]) return info[k] as string
323        return undefined
324      }
325      out.push({
326        label: src.label ?? path.split('/').filter(Boolean).pop() ?? 'lock',
327        actor: str('actor', 'owner', 'holder', 'user', 'agent') ?? '?',
328        task: str('task', 'issue', 'ticket'),
329        since: str('utc', 'since', 'time', 'created_at', 'acquired_at'),
330        path: candidate,
331      })
332    }
333  }
334
335  return out
336}
337
338// The last /goal the transcript holds: the evaluator's goal_status record, or the command itself
339async function readGoal($: EngineInterface, transcript: string | undefined): Promise<WpGoal | null | undefined> {
340  if (!transcript) return undefined
341  const stdout = await run($, ['grep', '-E', '"goal_status"|<command-name>/goal<', transcript], '/', 4000)
342  if (stdout === null) return null
343  return latestGoal(stdout.split('\n'))
344}
345
346// The session's transcript file: `<config>/projects/<cwd with every non-alphanumeric as ->/<session id>.jsonl`
347async function transcriptPath($: EngineInterface, homeDir: string): Promise<string | undefined> {
348  const configDir = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${homeDir}/.claude`
349  const id = await $.session.id()
350  const cwd = await $.session.cwd()
351  const guess = `${configDir}/projects/${cwd.replace(/[^a-zA-Z0-9]/g, '-')}/${id}.jsonl`
352  if (await $.fs.exists(guess).catch(() => false)) return guess
353  const found = await run($, ['find', `${configDir}/projects`, '-maxdepth', '2', '-name', `${id}.jsonl`], '/', 5000)
354
355  return found?.split('\n').find(Boolean)
356}
357
358async function collect($: EngineInterface, homeDir: string, self: string | undefined): Promise<WpSnapshot> {
359  const now = await $.clock.now()
360  const { cfg, root, path, problems } = await resolveConfig($, homeDir)
361  const dirs = await worktrees($, root, now)
362  const [checklists, road, counts, epics, milestones, members, held] = await Promise.all([
363    checklistCounts($, cfg, root, problems),
364    roadmap($, cfg, root),
365    counters($, cfg, root, problems),
366    beads($, cfg, root, now),
367    github($, cfg, root, now),
368    teamPanes($, cfg, root, self, homeDir, dirs),
369    locks($, cfg, root, homeDir, dirs),
370  ])
371
372  return {
373    project: cfg.name ?? root.split('/').filter(Boolean).pop() ?? root,
374    isShown: !cfg.panes || (self !== undefined && cfg.panes.map(normalizePane).includes(self)),
375    counts: [...checklists, ...counts, ...epics, ...milestones],
376    phases: road.phases,
377    current: road.current,
378    team: members,
379    locks: held,
380    hasLocks: (cfg.locks ?? []).length > 0,
381    problems,
382    configPath: path,
383    at: now,
384  }
385}
386
387const goal = atom({ plugin: 'waypoint', key: 'goal' } as const, null)
388const todos = atom({ plugin: 'waypoint', key: 'todos' } as const, [])
389const snapshot = atom({ plugin: 'waypoint', key: 'snapshot' } as const, null)
390
391type Palette = {
392  text: string
393  subtext: string
394  overlay: string
395  fill: string
396  track: string
397  blue: string
398  green: string
399  orange: string
400  red: string
401  indigo: string
402}
403
404// Apple's system colours (HIG), the accents at their increased-contrast values so they read on a fill
405const CUPERTINO: Record<'light' | 'dark', Palette> = {
406  light: {
407    text: '#1d1d1f',
408    subtext: '#6e6e73',
409    overlay: '#8e8e93',
410    fill: '#ececf0',
411    track: '#c7c7cc',
412    blue: '#0071e3',
413    green: '#248a3d',
414    orange: '#c93400',
415    red: '#d70015',
416    indigo: '#5e5ce6',
417  },
418  dark: {
419    text: '#f5f5f7',
420    subtext: '#98989f',
421    overlay: '#7c7c80',
422    fill: '#2c2c2e',
423    track: '#48484a',
424    blue: '#0a84ff',
425    green: '#30d158',
426    orange: '#ff9f0a',
427    red: '#ff453a',
428    indigo: '#7d7aff',
429  },
430}
431
432const WORDS = {
433  en: {
434    goal: 'Goal',
435    met: 'met',
436    todos: 'todos',
437    team: 'agents',
438    busy: 'busy',
439    idle: 'idle',
440    approval: 'approval',
441    stuck: 'stuck',
442    unknown: '?',
443    lock: 'lock',
444    free: 'free',
445    roadmap: 'Roadmap',
446    checklists: 'Progress',
447    locks: 'Locks',
448    problems: 'Notes',
449    noConfig: 'No waypoint.json: showing what was found. /waypoint config shows how to add one.',
450    nothing: 'Nothing to track yet: no /goal, todos, checklist or roadmap found.',
451  },
452  zh: {
453    goal: '目标',
454    met: '已达成',
455    todos: '任务',
456    team: '团队',
457    busy: '忙',
458    idle: '闲',
459    approval: '待批',
460    stuck: '卡',
461    unknown: '?',
462    lock: '锁',
463    free: '无',
464    roadmap: '路线图',
465    checklists: '进度',
466    locks: '锁',
467    problems: '提示',
468    noConfig: '没有 waypoint.json,只显示自动找到的内容。/waypoint config 查看配置方法。',
469    nothing: '还没有可跟踪的内容:没有 /goal、任务清单、勾选清单或路线图。',
470  },
471} as const
472
473type Lang = keyof typeof WORDS
474type Words = (typeof WORDS)[Lang]
475
476type Config = { language: 'auto' | Lang; refreshSeconds: number; style: 'capsule' | 'plain'; showTeam: boolean }
477
478export function readConfig(options: Record<string, unknown>): Config {
479  const n = Number(options.refreshSeconds)
480
481  return {
482    language: options.language === 'en' || options.language === 'zh' ? options.language : 'auto',
483    refreshSeconds: Number.isFinite(n) ? Math.min(600, Math.max(5, Math.round(n))) : 20,
484    style: options.style === 'plain' ? 'plain' : 'capsule',
485    showTeam: options.showTeam !== false,
486  }
487}
488
489async function palette($: EngineInterface): Promise<Palette> {
490  try {
491    return String((await $.settings.read()).theme ?? 'dark').includes('light') ? CUPERTINO.light : CUPERTINO.dark
492  } catch {
493    return CUPERTINO.dark
494  }
495}
496
497function language(cfg: Config, s: WpSnapshot | null, g: WpGoal | null, list: WpTodo[], lang: string): Lang {
498  if (cfg.language !== 'auto') return cfg.language
499  if (lang.toLowerCase().startsWith('zh')) return 'zh'
500  const seen = [g?.condition ?? '', ...list.map(t => t.text), ...(s?.counts ?? []).map(c => c.label), ...(s?.phases ?? []).map(p => p.name)]
501
502  return seen.some(hasCjk) ? 'zh' : 'en'
503}
504
505type Part = { text: string; color: string; bold?: boolean }
506type Segment = { key: string; priority: number; width: number; parts: Part[] }
507
508function segment(key: string, priority: number, parts: Part[]): Segment {
509  return { key, priority, parts, width: parts.reduce((w, p) => w + displayWidth(p.text), 0) + 2 }
510}
511
512// A checklist, epic or milestone gets a five-cell bar; a counter, usually a long tally, its figures alone
513function countParts(c: WpCount, p: Palette, labelMax: number, withBar = c.kind !== 'counter'): Part[] {
514  const isDone = c.total > 0 && c.done >= c.total
515  const b = withBar ? bar(c.done, c.total) : { filled: '', empty: '' }
516
517  return [
518    { text: isDone ? '✓ ' : '', color: p.green },
519    { text: clipWidth(c.label, labelMax), color: p.text },
520    { text: withBar ? ' ' : '', color: p.text },
521    { text: b.filled, color: isDone ? p.green : p.blue },
522    { text: b.empty, color: p.track },
523    { text: ` ${c.done}/${c.total}`, color: p.subtext },
524  ].filter(part => part.text !== '')
525}
526
527function teamTally(team: WpAgent[]) {
528  const by = (s: WpAgent['state']) => team.filter(a => a.state === s).length
529
530  return { busy: by('busy'), stuck: by('stuck'), approval: by('approval') }
531}
532
533// The band's segments, most important first in priority; the fit drops the lowest when the row is short
534export function bandSegments(s: WpSnapshot | null, g: WpGoal | null, list: WpTodo[], p: Palette, w: Words, showTeam: boolean): Segment[] {
535  const out: Segment[] = []
536  const done = list.filter(t => t.status === 'completed').length
537  const tally = list.length > 0 ? `${done}/${list.length}` : ''
538
539  if (g) {
540    out.push(
541      segment('goal', 100, [
542        { text: g.isMet ? '✓ ' : '◎ ', color: g.isMet ? p.green : p.indigo },
543        { text: clipWidth(g.condition, 26), color: p.text, bold: true },
544        ...(g.isMet ? [{ text: ` ${w.met}`, color: p.green }] : []),
545        ...(tally && !g.isMet ? [{ text: ` ${tally}`, color: p.subtext }] : []),
546      ]),
547    )
548  } else if (list.length > 0 && done < list.length) {
549    const current = list.find(t => t.status === 'in_progress')
550    out.push(
551      segment('todos', 95, [
552        { text: '▸ ', color: p.blue },
553        { text: tally, color: p.text, bold: true },
554        { text: ` ${clipWidth(current?.active || current?.text || w.todos, 22)}`, color: p.subtext },
555      ]),
556    )
557  }
558
559  for (const [i, c] of (s?.counts ?? []).entries()) {
560    const base = c.kind === 'checklist' ? 90 : c.kind === 'counter' ? 70 : 60
561    out.push(segment(`count-${i}`, base - i, countParts(c, p, 14)))
562  }
563
564  if (s && s.phases.length > 0) {
565    const dots = s.phases.map((ph, i) => ({
566      text: i === s.current ? '◉' : ph.status === 'done' ? '●' : '○',
567      color: i === s.current ? p.blue : ph.status === 'done' ? p.green : p.track,
568    }))
569    const now = s.phases[s.current]
570    out.push(segment('roadmap', 80, [...dots, ...(now ? [{ text: ` ${phaseShort(now.name)}`, color: p.text }] : [])]))
571  }
572
573  if (showTeam && s?.team && s.team.length > 0) {
574    const t = teamTally(s.team)
575    out.push(
576      segment('team', 50, [
577        { text: `${w.team} ${s.team.length}`, color: p.text },
578        ...(t.busy ? [{ text: ` ${w.busy}${t.busy}`, color: p.green }] : []),
579        ...(t.approval ? [{ text: ` ${w.approval}${t.approval}`, color: p.orange }] : []),
580        ...(t.stuck ? [{ text: ` ${w.stuck}${t.stuck}`, color: p.red, bold: true }] : []),
581      ]),
582    )
583  }
584
585  if (s && s.hasLocks && s.locks.length === 0) {
586    out.push(segment('locks', 30, [{ text: `${w.lock} ${w.free}`, color: p.overlay }]))
587  } else if (s && s.locks.length > 0) {
588    const first = s.locks[0]!
589    out.push(
590      segment('locks', 40, [
591        { text: `${w.lock} `, color: p.subtext },
592        { text: clipWidth(first.actor, 12), color: p.orange },
593        ...(s.locks.length > 1 ? [{ text: ` +${s.locks.length - 1}`, color: p.subtext }] : []),
594      ]),
595    )
596  }
597
598  return out
599}
600
601const STATE_ORDER: WpAgent['state'][] = ['stuck', 'approval', 'busy', 'idle', 'unknown']
602
603// `codex-M` and `Codex M` name the same holder
604function sameName(a: string, b: string): boolean {
605  const norm = (s: string) => s.toLowerCase().replace(/[^\p{L}\p{N}]/gu, '')
606  return norm(a) !== '' && norm(a) === norm(b)
607}
608
609function ago(at: number, since: string | undefined): string {
610  const t = since ? Date.parse(since) : NaN
611  if (!Number.isFinite(t)) return ''
612  const m = Math.max(0, Math.round((at - t) / 60_000))
613
614  return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m`
615}
616
617// Kept by the module, so a reload starts them over: who this session is, and its transcript file
618let isRefreshing = false
619let transcript: string | undefined
620let homeDir = ''
621let self: string | undefined
622let lang = ''
623
624async function refresh($: EngineInterface) {
625  if (isRefreshing) return
626  isRefreshing = true
627  try {
628    const s = await collect($, homeDir, self)
629    await update($, snapshot, () => s)
630    transcript ??= await transcriptPath($, homeDir)
631    const g = await readGoal($, transcript)
632    if (g !== undefined) await update($, goal, () => g)
633  } finally {
634    isRefreshing = false
635  }
636}
637
638// The todo list as the transcript left it, so a reload or a resume keeps it
639async function backfillTodos($: EngineInterface) {
640  let list: WpTodo[] = []
641  for (const m of await $.session.messages()) {
642    for (const call of m.toolUses ?? []) list = replayTodo(list, String(call.tool), call.input, call.result)
643  }
644  await update($, todos, old => (old.length === 0 ? list : old))
645}
646
647export const register: Register = (on, options) => {
648  const cfg = readConfig(options as Record<string, unknown>)
649
650  on('session.start', async ($, e, next) => {
651    const started = await next(e)
652    await $.command.register({
653      name: 'waypoint',
654      description: 'Project progress: open or close the detail pane; `refresh` to re-read now, `config` for the config file',
655      argumentHint: '[refresh|config]',
656    })
657    homeDir = (await $.env.get('HOME')) ?? ''
658    const pane = await $.env.get('OTTY_PANE_ID')
659    self = pane ? normalizePane(pane) : undefined
660    lang = (await $.env.get('LANG')) ?? ''
661    void backfillTodos($).catch(() => undefined)
662    void refresh($).catch(() => undefined)
663    $.clock.every(cfg.refreshSeconds * 1000, () => void refresh($).catch(() => undefined))
664
665    return started
666  })
667
668  on('turn.complete', async ($, e, next) => {
669    const ran = await next(e)
670    void refresh($).catch(() => undefined)
671
672    return ran
673  })
674
675  on('session.end', { reason: 'clear' }, async ($, e, next) => {
676    await update($, todos, () => [])
677    await update($, goal, () => null)
678
679    return next(e)
680  })
681
682  on('tool.call', { tool: ['TodoWrite', 'TaskCreate', 'TaskUpdate'] }, async ($, e, next) => {
683    const ran = await next(e)
684    if (ran.deny === undefined && !ran.isError) {
685      await update($, todos, list => replayTodo(list, String(e.tool), e as unknown as Record<string, unknown>, ran.result))
686    }
687
688    return ran
689    // A failed list update never holds up the tool itself
690  }).catch(($, e, next) => next(e))
691
692  on('command.run', { command: 'waypoint' }, async ($, e) => {
693    const arg = (e.args ?? '').trim().toLowerCase()
694    if (arg === 'refresh') {
695      await refresh($)
696      return { text: 'refreshed' }
697    }
698    if (arg === 'config') {
699      const s = await read($, snapshot)
700      return {
701        text: [
702          s?.configPath ? `waypoint reads ${s.configPath}` : 'waypoint found no config and shows what it detects.',
703          'Put one in the project as .claude/waypoint.json, or list projects in ~/.claude/waypoint.json as { "projects": [ { "paths": [...], ... } ] }.',
704          'See the README for every field: checklists, roadmap, counters, beads, github, team, locks, panes.',
705        ].join('\n'),
706      }
707    }
708    if (arg) return { text: 'Usage: /waypoint [refresh|config]' }
709    const open = (await $.ui.panes()).some(p => p.id === PANE)
710    if (open) {
711      await $.ui.close({ id: PANE })
712      return { text: 'pane closed' }
713    }
714    await $.ui.open({ id: PANE, title: 'Waypoint', rows: 36 })
715    void refresh($).catch(() => undefined)
716
717    return { text: 'pane opened' }
718  })
719
720  // One row above the prompt: capsules for the goal, each count, the roadmap, the team and the locks
721  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
722    if (e.props.hasSurvey) return next(e)
723    const s = await read($, snapshot)
724    if (s && !s.isShown) return next(e)
725    const g = await read($, goal)
726    const list = await read($, todos)
727    const p = await palette($)
728    const w = WORDS[language(cfg, s, g, list, lang)]
729    const all = bandSegments(s, g, list, p, w, cfg.showTeam)
730    if (all.length === 0) return next(e)
731
732    const { Box, Text } = $.ui.resolve(e)
733    const columns = e.props.bodyColumns ?? e.viewport?.columns ?? 100
734    const isCapsule = cfg.style === 'capsule'
735    const kept = fitSegments(all, columns, isCapsule ? 1 : 3)
736
737    return (
738      <Box>
739        <Text wrap="truncate-end">
740          {kept.map((seg, i) => (
741            <Text>
742              {i > 0 && <Text color={p.track}>{isCapsule ? ' ' : ' · '}</Text>}
743              <Text backgroundColor={isCapsule ? p.fill : undefined}>
744                {isCapsule ? ' ' : ''}
745                {seg.parts.map(part => (
746                  <Text color={part.color} bold={part.bold}>
747                    {part.text}
748                  </Text>
749                ))}
750                {isCapsule ? ' ' : ''}
751              </Text>
752            </Text>
753          ))}
754        </Text>
755      </Box>
756    )
757  })
758
759  // The detail pane: everything the band abbreviates
760  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
761    const { Box, Text } = $.ui.resolve(e)
762    const s = await read($, snapshot)
763    const g = await read($, goal)
764    const list = await read($, todos)
765    const p = await palette($)
766    const w = WORDS[language(cfg, s, g, list, lang)]
767    const width = Math.max(20, (e.props.bodyColumns ?? e.viewport?.columns ?? 60) - 2)
768    const title = (text: string) => (
769      <Text color={p.subtext} bold>
770        {text}
771      </Text>
772    )
773    const stateColor: Record<WpAgent['state'], string> = {
774      busy: p.green,
775      idle: p.overlay,
776      approval: p.orange,
777      stuck: p.red,
778      unknown: p.overlay,
779    }
780
781    const done = list.filter(t => t.status === 'completed').length
782    const goalBlock =
783      g || list.length > 0 ? (
784        <Box flexDirection="column">
785          {title(w.goal)}
786          {g && (
787            <Text>
788              <Text color={g.isMet ? p.green : p.indigo}>{g.isMet ? '✓ ' : '◎ '}</Text>
789              <Text color={p.text} bold>
790                {g.condition}
791              </Text>
792            </Text>
793          )}
794          {g?.reason && !g.isMet && <Text color={p.overlay}>{g.reason}</Text>}
795          {list.length > 0 && (
796            <Text color={p.subtext}>
797              {w.todos} {done}/{list.length}
798            </Text>
799          )}
800          {list.slice(0, 12).map(t => (
801            <Text wrap="truncate-end">
802              <Text color={t.status === 'completed' ? p.green : t.status === 'in_progress' ? p.blue : p.track}>
803                {t.status === 'completed' ? '  ✓ ' : t.status === 'in_progress' ? '  ▸ ' : '  ○ '}
804              </Text>
805              <Text color={t.status === 'completed' ? p.overlay : p.text}>{t.status === 'in_progress' ? t.active || t.text : t.text}</Text>
806            </Text>
807          ))}
808        </Box>
809      ) : null
810
811    const countBlock =
812      s && s.counts.length > 0 ? (
813        <Box flexDirection="column">
814          {title(w.checklists)}
815          {s.counts.map(c => (
816            <Box flexDirection="column">
817              <Text wrap="truncate-end">
818                {countParts(c, p, Math.min(40, width - 16), true).map(part => (
819                  <Text color={part.color} bold={part.bold}>
820                    {part.text}
821                  </Text>
822                ))}
823                {c.detail && <Text color={p.overlay}> {clipWidth(c.detail, Math.max(10, width - 30))}</Text>}
824              </Text>
825              {(c.items ?? [])
826                .filter(it => !it.isDone)
827                .slice(0, 8)
828                .map(it => (
829                  <Text wrap="truncate-end">
830                    <Text color={p.track}>{'  ○ '}</Text>
831                    <Text color={p.text}>{clipWidth(it.text, width - 4)}</Text>
832                  </Text>
833                ))}
834            </Box>
835          ))}
836        </Box>
837      ) : null
838
839    const roadBlock =
840      s && s.phases.length > 0 ? (
841        <Box flexDirection="column">
842          {title(w.roadmap)}
843          {s.phases.map((ph, i) => (
844            <Text wrap="truncate-end">
845              <Text color={i === s.current ? p.blue : ph.status === 'done' ? p.green : p.track}>
846                {i === s.current ? '◉ ' : ph.status === 'done' ? '● ' : '○ '}
847              </Text>
848              <Text color={ph.status === 'done' ? p.subtext : p.text} bold={i === s.current}>
849                {ph.name}
850              </Text>
851            </Text>
852          ))}
853        </Box>
854      ) : null
855
856    const teamBlock =
857      cfg.showTeam && s?.team && s.team.length > 0 ? (
858        <Box flexDirection="column">
859          {title(`${w.team} ${s.team.length}`)}
860          {[...s.team]
861            .sort((x, y) => STATE_ORDER.indexOf(x.state) - STATE_ORDER.indexOf(y.state))
862            .map(a => {
863              const held = s.locks.find(l => sameName(l.actor, a.name))
864              const where = a.name.toLowerCase().includes(a.agent.toLowerCase()) ? '' : `${a.agent} · `
865              return (
866                <Text wrap="truncate-end">
867                  <Text color={stateColor[a.state]}>● </Text>
868                  <Text color={p.text}>{a.name}</Text>
869                  <Text color={stateColor[a.state]}> {w[a.state]}</Text>
870                  {held && <Text color={p.orange}> {w.lock}{held.task ? ` ${held.task}` : ''}</Text>}
871                  <Text color={p.overlay}>
872                    {' '}
873                    {where}
874                    {a.cwd.split('/').pop()}
875                  </Text>
876                </Text>
877              )
878            })}
879        </Box>
880      ) : null
881
882    const lockBlock =
883      s && s.locks.length > 0 ? (
884        <Box flexDirection="column">
885          {title(w.locks)}
886          {s.locks.map(l => (
887            <Text wrap="truncate-end">
888              <Text color={p.orange}>● </Text>
889              <Text color={p.text}>{l.actor}</Text>
890              {l.task && <Text color={p.subtext}> {l.task}</Text>}
891              {l.since && <Text color={p.overlay}> {ago(s.at, l.since)}</Text>}
892              <Text color={p.overlay}> {l.label}</Text>
893            </Text>
894          ))}
895        </Box>
896      ) : null
897
898    const notes = [...(s && !s.configPath ? [w.noConfig] : []), ...(s?.problems ?? [])]
899    const blocks = [goalBlock, countBlock, teamBlock, lockBlock, roadBlock].filter(b => b !== null)
900
901    return (
902      <Box flexDirection="column" rowGap={1}>
903        {s && (
904          <Text>
905            <Text color={p.text} bold>
906              {s.project}
907            </Text>
908          </Text>
909        )}
910        {blocks.length === 0 && <Text color={p.subtext}>{w.nothing}</Text>}
911        {blocks}
912        {notes.length > 0 && (
913          <Box flexDirection="column">
914            {notes.map(n => (
915              <Text color={p.overlay} wrap="truncate-end">
916                {n}
917              </Text>
918            ))}
919          </Box>
920        )}
921      </Box>
922    )
923  })
924}
925
hooks/parse.ts 345 lines
1// Pure parsers and layout helpers: no `$`, so the tests run them directly
2
3import type { WpAgentState, WpGoal, WpPhase, WpTodo } from '../types'
4
5export type Heading = { level: number; text: string; line: number }
6
7export type Section = { heading: string; label: string; done: number; total: number; items: { text: string; isDone: boolean }[] }
8
9const HEADING = /^(#{1,6})\s+(.*?)\s*#*\s*$/
10// A checkbox at the left margin; indented ones are sub-steps of the item above
11const CHECKBOX = /^[-*+] \[( |x|X)\]\s+(.*)$/
12
13function stripMarkdown(text: string): string {
14  return text
15    .replace(/\*\*|__|`/g, '')
16    .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
17    .trim()
18}
19
20// `一·三、第三批(2026-10-07 起,goal 范围)` → `第三批`; `2. Beta (in progress)` → `Beta`
21export function sectionLabel(heading: string): string {
22  const plain = stripMarkdown(heading)
23    .replace(/[((][^))]*[))]/g, '')
24    .replace(/^[\d一二三四五六七八九十百·.、::\s-]+(?=\S)/, '')
25    .trim()
26
27  return plain || stripMarkdown(heading)
28}
29
30export function headings(markdown: string): Heading[] {
31  const out: Heading[] = []
32  let isFenced = false
33  markdown.split('\n').forEach((line, i) => {
34    if (/^\s*(```|~~~)/.test(line)) isFenced = !isFenced
35    if (isFenced) return
36    const m = line.match(HEADING)
37    if (m) out.push({ level: m[1]!.length, text: m[2]!, line: i })
38  })
39
40  return out
41}
42
43// Every heading's own checklist: the top-level checkboxes between it and the next heading, so a title that
44// encloses the whole file never swallows its batches
45export function sections(markdown: string): Section[] {
46  const lines = markdown.split('\n')
47  const hs = headings(markdown)
48  const out: Section[] = []
49  hs.forEach((head, i) => {
50    const end = hs[i + 1]?.line ?? lines.length
51    const items: Section['items'] = []
52    let isFenced = false
53    for (const line of lines.slice(head.line + 1, end)) {
54      if (/^\s*(```|~~~)/.test(line)) isFenced = !isFenced
55      if (isFenced) continue
56      const m = line.match(CHECKBOX)
57      if (m) items.push({ text: stripMarkdown(m[2]!), isDone: m[1] !== ' ' })
58    }
59    out.push({
60      heading: stripMarkdown(head.text),
61      label: sectionLabel(head.text),
62      done: items.filter(it => it.isDone).length,
63      total: items.length,
64      items,
65    })
66  })
67
68  return out
69}
70
71// The whole file's top-level checkboxes, for a list with no headings
72export function wholeFile(markdown: string, label: string): Section {
73  const items = markdown
74    .split('\n')
75    .map(line => line.match(CHECKBOX))
76    .filter((m): m is RegExpMatchArray => m !== null)
77    .map(m => ({ text: stripMarkdown(m[2]!), isDone: m[1] !== ' ' }))
78
79  return { heading: label, label, done: items.filter(it => it.isDone).length, total: items.length, items }
80}
81
82export type SectionRule = { section?: string; marker?: string }
83
84/**
85 * Picks the checklist to follow: a heading containing `section`; else the LAST heading containing `marker`
86 * (a batch file where each new batch is appended and marked); else the first heading whose own items are not
87 * all done; else the whole file.
88 */
89export function pickSection(markdown: string, rule: SectionRule, fallbackLabel: string): Section | null {
90  const all = sections(markdown).filter(s => s.total > 0)
91  if (rule.section) return all.find(s => s.heading.includes(rule.section!)) ?? null
92  if (rule.marker) {
93    const marked = all.filter(s => s.heading.includes(rule.marker!))
94    if (marked.length > 0) return marked[marked.length - 1]!
95  }
96  const open = all.find(s => s.done < s.total)
97  if (open) return open
98  const whole = wholeFile(markdown, fallbackLabel)
99
100  return whole.total > 0 ? whole : null
101}
102
103const PHASE_HEAD = /阶段|里程碑|版本|phase|stage|milestone|version|release/i
104const STATUS_HEAD = /状态|进度|status|state|progress/i
105
106function cells(row: string): string[] {
107  return row
108    .trim()
109    .replace(/^\||\|$/g, '')
110    .split('|')
111    .map(c => stripMarkdown(c))
112}
113
114export function phaseStatus(status: string): WpPhase['status'] {
115  const s = status.trim()
116  if (/未开始|not started|planned|^todo$|^[—–-]?$/i.test(s)) return 'todo'
117  if (/未完成|incomplete/i.test(s)) return 'active'
118  if (/完成|已发布|已上线|done|complete|shipped|released|✅|✓|✔/i.test(s)) return 'done'
119  if (/进行|研究|开发中|in progress|doing|wip|active|current|ongoing|underway|🚧/i.test(s)) return 'active'
120
121  return 'todo'
122}
123
124// The first Markdown table with a phase column and a status column, one phase per row
125export function roadmapPhases(markdown: string): WpPhase[] {
126  const lines = markdown.split('\n')
127  for (let i = 0; i + 1 < lines.length; i++) {
128    const head = lines[i]!
129    if (!head.trim().startsWith('|') || !/^\s*\|?\s*:?-{2,}/.test(lines[i + 1]!)) continue
130    const names = cells(head)
131    const phaseCol = names.findIndex(n => PHASE_HEAD.test(n))
132    const statusCol = names.findIndex(n => STATUS_HEAD.test(n))
133    if (phaseCol < 0 || statusCol < 0) continue
134    const phases: WpPhase[] = []
135    for (const row of lines.slice(i + 2)) {
136      if (!row.trim().startsWith('|')) break
137      const c = cells(row)
138      const name = c[phaseCol] ?? ''
139      if (!name) continue
140      phases.push({ name, status: phaseStatus(c[statusCol] ?? '') })
141    }
142    if (phases.length > 0) return phases
143  }
144
145  return []
146}
147
148// The phase named by `current` (a prefix of its name), else the first under way, else the first not done
149export function currentPhase(phases: WpPhase[], current?: string): number {
150  if (current) {
151    const named = phases.findIndex(p => p.name.startsWith(current) || p.name.includes(current))
152    if (named >= 0) return named
153  }
154  const active = phases.findIndex(p => p.status === 'active')
155  if (active >= 0) return active
156
157  return phases.findIndex(p => p.status !== 'done')
158}
159
160// `0.2 自有化与品牌` → `0.2`; a phase with no version-like lead keeps its first word
161export function phaseShort(name: string): string {
162  const version = name.match(/^v?\d+(?:\.\d+)*/i)
163  if (version) return version[0]
164  const word = name.split(/[\s::((]/)[0] ?? name
165
166  return word.length > 12 ? `${word.slice(0, 11)}…` : word
167}
168
169// Columns a string takes in a terminal: East Asian wide and emoji count two
170export function displayWidth(text: string): number {
171  let width = 0
172  for (const ch of text) {
173    const c = ch.codePointAt(0)!
174    if (c === 0x200d || (c >= 0xfe00 && c <= 0xfe0f) || (c >= 0x300 && c <= 0x36f)) continue
175    const isWide =
176      (c >= 0x1100 && c <= 0x115f) ||
177      (c >= 0x2e80 && c <= 0x303e) ||
178      (c >= 0x3041 && c <= 0x33ff) ||
179      (c >= 0x3400 && c <= 0x4dbf) ||
180      (c >= 0x4e00 && c <= 0x9fff) ||
181      (c >= 0xa000 && c <= 0xa4cf) ||
182      (c >= 0xac00 && c <= 0xd7a3) ||
183      (c >= 0xf900 && c <= 0xfaff) ||
184      (c >= 0xfe30 && c <= 0xfe4f) ||
185      (c >= 0xff00 && c <= 0xff60) ||
186      (c >= 0xffe0 && c <= 0xffe6) ||
187      (c >= 0x1f300 && c <= 0x1faff) ||
188      (c >= 0x20000 && c <= 0x3fffd)
189    width += isWide ? 2 : 1
190  }
191
192  return width
193}
194
195// Cuts to `max` columns, an ellipsis in the last one
196export function clipWidth(text: string, max: number): string {
197  const line = text.replace(/\s+/g, ' ').trim()
198  if (displayWidth(line) <= max) return line
199  let out = ''
200  for (const ch of line) {
201    if (displayWidth(out + ch) > max - 1) break
202    out += ch
203  }
204
205  return `${out}…`
206}
207
208export type Fit = { key: string; width: number; priority: number }
209
210/**
211 * Keeps the segments that fit in `columns`, `gap` columns between each, dropping the lowest priority first
212 * (a higher number is dropped later); the order of what stays is the order given.
213 */
214export function fitSegments<T extends Fit>(segments: T[], columns: number, gap: number): T[] {
215  const kept = [...segments]
216  const total = () => kept.reduce((sum, s) => sum + s.width, 0) + Math.max(0, kept.length - 1) * gap
217  while (kept.length > 1 && total() > columns) {
218    let lowest = 0
219    kept.forEach((s, i) => {
220      if (s.priority < kept[lowest]!.priority) lowest = i
221    })
222    kept.splice(lowest, 1)
223  }
224
225  return kept
226}
227
228// A five-cell bar: ▰ per fifth done
229export function bar(done: number, total: number, cells = 5): { filled: string; empty: string } {
230  const n = total > 0 ? Math.min(cells, Math.round((done / total) * cells)) : 0
231
232  return { filled: '▰'.repeat(n), empty: '▱'.repeat(cells - n) }
233}
234
235// The goal a /goal command or ProposeGoal set, from the transcript's text; `null` once cleared
236export function goalFromText(text: string): string | null | undefined {
237  const command = text.match(/<command-name>\/goal<\/command-name>[\s\S]*?<command-args>([\s\S]*?)<\/command-args>/)
238  if (command) {
239    const args = command[1]!.trim()
240    if (args === '' || /^clear$/i.test(args)) return null
241    return args
242  }
243  const hook = text.match(/Stop hook is now active with condition: "([\s\S]*?)"\.\s/)
244  if (hook) return hook[1]!
245
246  return undefined
247}
248
249// Applies one TodoWrite, TaskCreate or TaskUpdate call to the todo list
250export function replayTodo(list: WpTodo[], tool: string, input: Record<string, unknown>, result: unknown): WpTodo[] {
251  const str = (v: unknown) => (typeof v === 'string' ? v : undefined)
252  if (tool === 'TodoWrite' && Array.isArray(input.todos)) {
253    return input.todos.map((t: Record<string, unknown>, i: number) => ({
254      id: String(i),
255      text: str(t.content) ?? '',
256      active: str(t.activeForm) ?? str(t.content) ?? '',
257      status: (t.status as WpTodo['status']) ?? 'pending',
258    }))
259  }
260  if (tool === 'TaskCreate') {
261    const id = str((result as { task?: { id?: unknown } } | undefined)?.task?.id)
262    const text = str(input.subject) ?? ''
263    return id ? [...list, { id, text, active: str(input.activeForm) ?? text, status: 'pending' }] : list
264  }
265  if (tool === 'TaskUpdate') {
266    const id = str(input.taskId)
267    const status = str(input.status)
268    if (status === 'deleted') return list.filter(t => t.id !== id)
269    return list.map(t =>
270      t.id === id
271        ? {
272            ...t,
273            text: str(input.subject) ?? t.text,
274            active: str(input.activeForm) ?? t.active,
275            status: (status as WpTodo['status'] | undefined) ?? t.status,
276          }
277        : t,
278    )
279  }
280
281  return list
282}
283
284export const STUCK_PATTERNS = ['at capacity', 'rate limit', 'usage limit', 'overloaded', 'quota exceeded', 'hit your limit']
285// Claude Code's permission prompt and Codex's approval prompt
286export const APPROVAL_PATTERNS = ['Do you want to proceed', 'Do you want to make this edit', 'Would you like to']
287
288// An agent pane's state from Otty's own and the last lines on its screen
289export function paneState(ottyState: string, screen: string, stuck: string[], approval: string[], agent = ''): WpAgentState {
290  const tail = screen.toLowerCase()
291  if (stuck.some(p => tail.includes(p.toLowerCase()))) return 'stuck'
292  if (approval.some(p => tail.includes(p.toLowerCase()))) return 'approval'
293  if (ottyState === 'processing') return 'busy'
294  // Claude Code reports `awaiting` on a permission prompt; Codex reports it at its idle prompt too
295  if (ottyState === 'awaiting' && agent === 'Claude Code') return 'approval'
296  if (ottyState === 'idle' || ottyState === 'awaiting') return 'idle'
297
298  return 'unknown'
299}
300
301// Whether `path` is `dir` or lies under it
302export function isUnder(path: string, dir: string): boolean {
303  const d = dir.replace(/\/+$/, '')
304
305  return path === d || path.startsWith(`${d}/`)
306}
307
308export function hasCjk(text: string): boolean {
309  return /[㐀-鿿가-힣぀-ヿ]/.test(text)
310}
311
312export function normalizePane(id: string): string {
313  return id.startsWith('p_') ? id : `p_${id}`
314}
315
316// The goal as the newest transcript line about it leaves it: a goal_status record, `/goal <condition>`, or a
317// clear (null). Lines are the transcript's JSONL rows that mention a goal, oldest first
318export function latestGoal(lines: string[]): WpGoal | null {
319  // Newest first; a line that merely quotes these strings (a tool result) is passed over
320  for (const line of [...lines].reverse()) {
321    let row: {
322      type?: string
323      content?: unknown
324      attachment?: { type?: string; condition?: string; met?: boolean; reason?: string }
325      message?: { content?: unknown }
326    }
327    try {
328      row = JSON.parse(line)
329    } catch {
330      continue
331    }
332    if (row.attachment?.type === 'goal_status' && row.attachment.condition) {
333      return { condition: row.attachment.condition, isMet: row.attachment.met === true, reason: row.attachment.reason }
334    }
335    // The command as typed is a user row; `/goal clear` is kept as a local-command notice, its text at the top
336    const raw = row.type === 'user' ? row.message?.content : row.type === 'system' ? row.content : undefined
337    const content = typeof raw === 'string' ? raw : ''
338    if (!content.trimStart().startsWith('<command-name>/goal</command-name>')) continue
339    const goal = goalFromText(content)
340    return goal ? { condition: goal, isMet: false } : null
341  }
342
343  return null
344}
345
types/index.d.ts 43 lines
1export type WpTodo = { id: string; text: string; active: string; status: 'pending' | 'in_progress' | 'completed' }
2
3// The session's /goal: its condition, and whether the evaluator found it met
4export type WpGoal = { condition: string; isMet: boolean; reason?: string }
5
6// One count of done out of total: a checklist section, a counter, an epic, a milestone
7export type WpCount = {
8  kind: 'checklist' | 'counter' | 'beads' | 'github'
9  label: string
10  done: number
11  total: number
12  detail?: string
13  items?: { text: string; isDone: boolean }[]
14}
15
16export type WpPhase = { name: string; status: 'done' | 'active' | 'todo' }
17
18export type WpAgentState = 'busy' | 'idle' | 'approval' | 'stuck' | 'unknown'
19
20export type WpAgent = { pane: string; name: string; agent: string; state: WpAgentState; cwd: string }
21
22export type WpLock = { label: string; actor: string; task?: string; since?: string; path: string }
23
24export type WpSnapshot = {
25  project: string
26  isShown: boolean
27  counts: WpCount[]
28  phases: WpPhase[]
29  current: number
30  team: WpAgent[] | null
31  locks: WpLock[]
32  hasLocks: boolean
33  problems: string[]
34  configPath?: string
35  at: number
36}
37
38declare module 'claude-code' {
39  interface PluginState {
40    waypoint: { goal: WpGoal | null; todos: WpTodo[]; snapshot: WpSnapshot | null }
41  }
42}
43