import type { Register, EngineInterface, Timer } from 'claude-code' import { bar, buildProgress, changedPaths, defaultTab, issueOfBranch, joinPath, manualTasks, parseDecisions, parseFindings, parseLedger, parsePlan, parseWorktrees, stageOf, statusNote, TABS, worktreeOf, wrapFocus, } from './parse.ts' import type { Decision, Doc, Findings, Ledger, Plan, Progress, Stage, Tab, TaskView, Worktree } from './parse.ts' const PANE = 'feature' // /feature may be taken by a built-in or another plugin; fall back to the next free name const NAMES = ['feature', 'progress', 'feature-pane'] const PLANS_DIR = 'docs/superpowers/plans' const SDD_DIR = '.superpowers/sdd' // SDD runs in a background subagent, so its edits do not always pass through this session's tool calls const POLL_MS = 15_000 type Ruling = Decision & { marker?: string; path: string } /** One thing to show: a plan in a worktree, or a worktree with no plan (a bounded branch). */ type Target = { key: string worktree: Worktree issue?: number changes: string[] bounded: boolean commits: string[] pushed: boolean planPath?: string plan?: Plan ledger?: Ledger ledgerPath?: string progress?: Progress findings?: Findings rulings: Ruling[] docs: Doc[] stage: Stage activityMs: number } // Module state: rebuilt from git and disk on every open, refresh and edit, so a reload loses nothing let commandName = '' let targets: Target[] = [] let selected: string | undefined let hideDone = false // Раскрытые задачи по каждому плану (ключ Target); плана нет в карте — раскрыта следующая задача const expanded = new Map>() // Выбранная вкладка по каждому Target; нет в карте — по этапу const tabs = new Map() // Раскрытые рулинги (номер решения) и находки (маркер) по каждому Target const openRulings = new Map>() const openFindings = new Map>() let isOpen = false let loaded = false let error = '' let loading: Promise | undefined let poll: Timer | undefined // Строки списка в порядке отрисовки и последняя строка с фокусом: стрелки ходят только по ним let rows: string[] = [] let lastRow: string | undefined // Команда просмотрщика: glow, если он есть, иначе less; ищется один раз let viewerCmd: string | undefined async function git($: EngineInterface, cwd: string, args: string[]): Promise { try { const r = await $.process.run(['git', ...args], { cwd, timeoutMs: 10_000 }) return r.exitCode === 0 ? r.stdout : undefined } catch { return undefined } } async function readText($: EngineInterface, path: string): Promise { try { return await $.fs.read(path) } catch { // Missing, or mid-write: skip it this round return undefined } } async function mtime($: EngineInterface, path: string): Promise { try { return (await $.fs.stat(path)).mtimeMs } catch { return 0 } } async function dirs($: EngineInterface, path: string): Promise { try { return (await $.fs.list(path)).filter(e => e.kind === 'dir').map(e => e.name) } catch { return [] } } async function exists($: EngineInterface, path: string): Promise { try { return await $.fs.exists(path) } catch { return false } } async function viewer($: EngineInterface): Promise { if (viewerCmd) return viewerCmd let glow: string | undefined try { const r = await $.process.run(['/usr/bin/which', 'glow'], { timeoutMs: 5_000 }) if (r.exitCode === 0) glow = r.stdout.trim() || undefined } catch { // which itself missing: try the usual places } for (const p of ['/opt/homebrew/bin/glow', '/usr/local/bin/glow']) { if (!glow && (await exists($, p))) glow = p } viewerCmd = glow ? `${glow} -t` : '/usr/bin/less' return viewerCmd } /** * Открыть документ в терминальном просмотрщике рядом с сессией. Свой терминал у мода * не интерактивный (вывод уходит в пайп), поэтому — соседняя панель iTerm или окно Terminal. */ async function openDoc($: EngineInterface, path: string): Promise { const term = await $.env.get('TERM_PROGRAM') const cmd = await viewer($) const script = term === 'iTerm.app' ? 'tell application "iTerm2" to tell current session of current window to split vertically with default profile command ((item 1 of argv) & " " & quoted form of (item 2 of argv))' : term === 'Apple_Terminal' ? 'tell application "Terminal"\nactivate\ndo script ((item 1 of argv) & " " & quoted form of (item 2 of argv))\nend tell' : undefined try { const r = script ? await $.process.run(['osascript', '-e', 'on run argv', '-e', script, '-e', 'end run', cmd, path], { timeoutMs: 10_000, }) : await $.process.run(['open', path], { timeoutMs: 10_000 }) if (r.exitCode !== 0) $.ui.toast(`Не открылось: ${r.stderr.trim() || 'код ' + r.exitCode}`) } catch (err) { $.ui.toast(`Не открылось: ${err instanceof Error ? err.message : String(err)}`) } } const CHANGE_DOCS: [file: string, label: string][] = [ ['proposal', 'Proposal — зачем фича и что меняется'], ['design', 'Design — устройство и решения без обсуждения'], ] /** Документы change: proposal, design, спеки capability. README — только указатель, его нет. */ async function changeDocs($: EngineInterface, wt: string, name: string, several: boolean): Promise { const dir = joinPath(wt, `openspec/changes/${name}`) // Несколько change в одной ветке — подписи различает имя change const of = several ? ` (${name})` : '' const docs: Doc[] = [] for (const [file, label] of CHANGE_DOCS) { if (await exists($, `${dir}/${file}.md`)) docs.push({ label: label + of, path: `${dir}/${file}.md` }) } for (const cap of await dirs($, `${dir}/specs`)) { if (await exists($, `${dir}/specs/${cap}/spec.md`)) { docs.push({ label: `Спека ${cap} — требования и сценарии${of}`, path: `${dir}/specs/${cap}/spec.md` }) } } return docs } async function loadWorktree($: EngineInterface, wt: Worktree, base: string | undefined): Promise { const onBase = !wt.branch || wt.branch === base const changes = (await dirs($, joinPath(wt.path, 'openspec/changes'))).filter(n => n !== 'archive') const bounded = await exists($, joinPath(wt.path, '.bounded')) const log = !onBase && base ? await git($, wt.path, ['log', '--format=%h %s', '-n', '8', `${base}..HEAD`]) : '' const commits = (log ?? '').split(/\r?\n/).filter(Boolean) // На сервере и ничего не осталось непушнутым const ahead = onBase ? undefined : await git($, wt.path, ['rev-list', '--count', '@{u}..HEAD']) const pushed = ahead !== undefined && ahead.trim() === '0' const changeDocsList: Doc[] = [] const decisions: { d: Decision; path: string }[] = [] for (const name of changes) { changeDocsList.push(...(await changeDocs($, wt.path, name, changes.length > 1))) const designPath = joinPath(wt.path, `openspec/changes/${name}/design.md`) const design = await readText($, designPath) if (design) decisions.push(...parseDecisions(design).map(d => ({ d, path: designPath }))) } // Plans SDD runs on, else the plans this branch adds or changes const runs: { planRel: string; ledger?: Ledger; ledgerText?: string; ledgerPath?: string; ledgerMs: number }[] = [] for (const name of await dirs($, joinPath(wt.path, SDD_DIR))) { const ws = joinPath(wt.path, `${SDD_DIR}/${name}`) const text = await readText($, ws + '/progress.md') const ledger = text === undefined ? undefined : parseLedger(text) const marker = (await readText($, ws + '/plan-path'))?.trim() const planRel = marker || ledger?.plan || `${PLANS_DIR}/${name}.md` runs.push({ planRel, ledger, ledgerText: text, ledgerPath: text === undefined ? undefined : ws + '/progress.md', ledgerMs: await mtime($, ws + '/progress.md'), }) } if (runs.length === 0) { const diff = !onBase && base ? await git($, wt.path, ['diff', '--name-only', `${base}...HEAD`, '--', PLANS_DIR]) : '' const status = await git($, wt.path, ['status', '--porcelain', '--untracked-files=all', '--', PLANS_DIR]) for (const rel of changedPaths(diff ?? '', status ?? '')) { if (rel.endsWith('.md')) runs.push({ planRel: rel, ledgerMs: 0 }) } } const common = { worktree: wt, changes, bounded, commits, pushed } const rulingsFor = (ledger?: Ledger): Ruling[] => decisions // Решения, выбранные человеком, смотреть не нужно .filter(x => !x.d.human) .map(x => ({ ...x.d, marker: ledger?.rulingOf.get(x.d.n), path: x.path })) if (runs.length === 0) { // The main checkout with nothing in flight is not worth a row if (onBase && changes.length === 0 && !bounded) return [] const stage = stageOf({ change: changes.length > 0, bounded, manual: [], pushed, commits: commits.length }) return [ { key: wt.path, issue: issueOfBranch(wt.branch), ...common, rulings: rulingsFor(), docs: changeDocsList, stage, activityMs: 0, }, ] } const out: Target[] = [] for (const run of runs) { const planPath = joinPath(wt.path, run.planRel) const text = await readText($, planPath) const plan = text === undefined ? undefined : parsePlan(run.planRel, text) const progress = plan ? buildProgress(plan, run.ledger) : undefined const docs = [...changeDocsList] if (plan) docs.push({ label: `План — ${plan.title || run.planRel}`, path: planPath }) if (run.ledgerPath) docs.push({ label: 'SDD ledger — ход исполнения и находки', path: run.ledgerPath }) out.push({ key: wt.path + '::' + run.planRel, issue: plan?.issue ?? issueOfBranch(wt.branch), ...common, planPath: run.planRel, plan, ledger: run.ledger, ledgerPath: run.ledgerPath, progress, findings: run.ledgerText === undefined ? undefined : parseFindings(run.ledgerText), rulings: rulingsFor(run.ledger), docs, stage: stageOf({ change: changes.length > 0, bounded, planPath: run.planRel, progress, ledger: run.ledger, manual: manualTasks(plan, run.ledger), pushed, commits: commits.length, }), activityMs: Math.max(run.ledgerMs, await mtime($, planPath)), }) } return out } /** Default: the session's own worktree, its most active plan; else the most active plan anywhere. */ function defaultKey(worktrees: Worktree[], root: string): string | undefined { const own = worktreeOf(worktrees, root) const byActivity = [...targets].sort((a, b) => b.activityMs - a.activityMs) const mine = byActivity.filter(t => t.worktree.path === own?.path) return (mine[0] ?? byActivity.find(t => t.progress) ?? byActivity[0])?.key } async function load($: EngineInterface): Promise { const root = await $.session.root() const listing = await git($, root, ['worktree', 'list', '--porcelain']) const worktrees = listing ? parseWorktrees(listing) : [{ path: root }] const base = (await git($, root, ['rev-parse', '--verify', '--quiet', 'refs/heads/main'])) !== undefined ? 'main' : (await git($, root, ['rev-parse', '--verify', '--quiet', 'refs/heads/master'])) !== undefined ? 'master' : undefined targets = (await Promise.all(worktrees.map(w => loadWorktree($, w, base)))).flat() loaded = true error = targets.length === 0 ? 'Нет веток в работе: ни планов, ни SDD-ledger, ни OpenSpec change' : '' if (!selected || !targets.some(t => t.key === selected)) selected = defaultKey(worktrees, root) } async function refresh($: EngineInterface): Promise { if (!loading) { loading = load($).finally(() => { loading = undefined }) } await loading $.ui.invalidate('ui.render') } type RowStyle = { dimColor?: boolean; bold?: boolean; color?: 'suggestion' | 'success' | 'warning' } function expandedFor(key: string, p: Progress): Set { let set = expanded.get(key) if (!set) { set = new Set(p.next ? [p.next.n] : []) expanded.set(key, set) } return set } /** Выбор человека запоминается по каждой ветке; иначе — вкладка этапа. */ function tabFor(t: Target): Tab { return ( tabs.get(t.key) ?? defaultTab({ progress: t.progress, ledger: t.ledger, docs: t.docs.length, rulings: t.rulings.length, boundedNoPlan: t.bounded && !t.planPath, pushed: t.pushed, }) ) } function startPolling($: EngineInterface): void { if (poll) return poll = $.clock.every(POLL_MS, () => { if (isOpen) void refresh($) }) } function stopPolling(): void { poll?.cancel() poll = undefined } function touchesProgress(path: unknown): boolean { return ( typeof path === 'string' && /(\.superpowers\/sdd\/|docs\/superpowers\/plans\/|openspec\/changes\/|\/\.bounded$)/.test(path) ) } function branchName(wt: Worktree): string { return wt.branch ?? (wt.path.split('/').pop() || wt.path) + ' (detached)' } function targetLabel(t: Target, plansInWorktree: number): string { const plan = plansInWorktree > 1 && t.planPath ? ' ' + (t.planPath.split('/').pop() ?? '') : '' const state = t.progress ? `${t.progress.done}/${t.progress.total}` : t.planPath ? 'план не найден' : t.bounded ? 'bounded' : 'без плана' return `${branchName(t.worktree)}${plan} ${state}` } function pick(wanted: string): Target | undefined { const w = wanted.replace(/^#/, '') return ( targets.find(t => t.worktree.branch === w) ?? targets.find(t => String(t.issue) === w) ?? targets.find(t => (t.worktree.branch ?? '').includes(w) || (t.planPath ?? '').includes(w)) ) } function setOf(map: Map>, key: string): Set { let set = map.get(key) if (!set) { set = new Set() map.set(key, set) } return set } export const register: Register = on => { on('session.start', async ($, e, next) => { for (const name of NAMES) { try { const { command } = await $.command.register({ name, description: 'Фича по веткам: этап и чей ход, задачи плана, рулинги, отложенное, документы', argumentHint: '[ветка | issue | план]', immediate: true, }) commandName = command break } catch { // Name taken, try the next one } } return next(e) }) on('command.run', async ($, e, next) => { if (!commandName || e.command !== commandName) return next(e) const wanted = (e.args ?? '').trim() await load($) if (wanted) selected = pick(wanted)?.key ?? selected isOpen = true startPolling($) await $.ui.open({ id: PANE, title: 'Фича', focus: true, closeOnEscape: true }) return {} }) on('ui.close', async ($, e, next) => { if (e.id === PANE) { isOpen = false stopPolling() } return next(e) }) // Стрелки и Tab ходят по кругу только по строкам списка; кнопки и ветки — по своим клавишам on('ui.focus', { requestId: PANE }, async ($, e, next) => { const el = e.element if (el && rows.includes(el)) { lastRow = el return next(e) } const target = e.origin.kind === 'person' ? wrapFocus(rows, lastRow) : undefined if (!target) return next(e) lastRow = target return next({ ...e, element: target }) }) // Live refresh while plans, ledgers and changes are edited in this session on('tool.call', async ($, e, next) => { const result = await next(e) if (!isOpen) return result const touched = touchesProgress((e as { file_path?: unknown }).file_path) || e.tool === 'Agent' || (e.tool === 'Bash' && /\bgit\b|openspec|superpowers|\.bounded/.test(String((e as { command?: unknown }).command ?? ''))) if (touched) await refresh($) return result }) // And once more at the end of every turn, to catch anything else on('turn.complete', async ($, e, next) => { const result = await next(e) if (isOpen) await refresh($) return result }) on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => { // Only an open pane is drawn: after a reload this is how the module learns it is open isOpen = true startPolling($) if (!loaded) void refresh($) const els = $.ui.resolve(e) const { Box, Text, Button } = els const columns = Math.max(30, e.props.bodyColumns ?? 60) const refreshButton = ( , { dimColor: true }, ), ) }) } // ── 2. Задачи if (tab === 'tasks' && p) { const pct = p.total ? Math.round((p.done / p.total) * 100) : 0 const wide = Math.max(4, Math.min(30, columns - 24)) // Счётчики шагов — только если план кто-то отмечает: SDD шаги не отмечает const bySteps = p.source === 'steps' && p.anyTicked const open = expandedFor(t.key, p) const toggle = (n: number) => { if (open.has(n)) open.delete(n) else open.add(n) redraw() } body.push( {t.plan?.title || t.planPath} , {bar(p.done, p.total, wide)} {`${p.done}/${p.total}`} {`задач · ${pct}%`} , {p.next ? `Дальше: Task ${p.next.n} — ${p.next.title}` : 'Все задачи выполнены'} , , ) const shown = hideDone ? p.tasks.filter(x => x.status !== 'done') : p.tasks const hidden = p.tasks.length - shown.length if (hidden > 0) body.push({`✓ скрыто готовых: ${hidden}`}) shown.forEach((task: TaskView, index) => { const isNext = task === p.next const isOpenTask = open.has(task.n) // Статус: ✓ готово, ● в работе или следующая, ○ не начата. Раскрытие — отдельно: + / − const mark = task.status === 'done' ? '✓' : isNext || task.status === 'active' ? '●' : '○' const steps = bySteps ? (task.stepsTotal ? `${task.stepsDone}/${task.stepsTotal}` : '').padStart(5) + ' ' : '' const style: RowStyle = { dimColor: task.status === 'done', bold: isNext, color: isNext ? 'suggestion' : task.status === 'done' ? 'success' : undefined, } const prefix = `${task.steps.length ? (isOpenTask ? '−' : '+') : ' '} ${mark} ${steps}${String(task.n).padStart(2)}. ` focusRows.push('task-' + task.n) body.push( hang( 't' + task.n, prefix, , style, ), ) const indent = ' '.repeat(prefix.length) if (task.note) { body.push(hang('n' + task.n, indent, {task.note}, { dimColor: true })) } if (!isOpenTask) return task.steps.forEach((s, i) => { // SDD отмечает готовность задачи в ledger, не шаги: у готовой задачи готовы и шаги const done = s.done || (p.source === 'ledger' && task.status === 'done') focusRows.push(`step-${task.n}-${i}`) body.push( hang( `s${task.n}-${i}`, indent + ' '.repeat(s.depth) + (done ? '✓ ' : '· '), // Enter на шаге сворачивает задачу и возвращает на неё фокус , { dimColor: done }, ), ) }) }) } // Без плана на вкладке задач — что уже в ветке if (tab === 'tasks' && !p) { if (t.planPath) body.push({'План не найден: ' + t.planPath}) else empty(t.bounded ? 'Bounded-задача, плана нет' : 'Плана нет') body.push( {t.commits.length ? `Коммиты в ветке (${t.commits.length}${t.commits.length === 8 ? '+' : ''}):` : 'Коммитов поверх main нет'} , ) t.commits.forEach((c, i) => body.push( {' ' + c} , ), ) } // ── 3. Рулинги: решения без обсуждения из design.md, кроме выбранных человеком if (tab === 'rulings') { if (!t.rulings.length) empty('Решений без обсуждения нет') const open = setOf(openRulings, t.key) t.rulings.forEach((r, i) => { const isOpenRuling = open.has(r.n) const more = Boolean(r.why || r.cost) const prefix = `${more ? (isOpenRuling ? '−' : '+') : ' '} ${(r.marker ?? '').padEnd(4)} #${String(r.n).padEnd(3)}` focusRows.push('rul-' + r.n) body.push( hang( 'r' + r.n, prefix, // Enter раскрывает обоснование и цену ошибки прямо в списке , { color: r.marker ? 'suggestion' : undefined, dimColor: !r.marker }, ), ) if (!isOpenRuling) return const indent = ' '.repeat(prefix.length) if (r.why) body.push(hang('rw' + r.n, indent, {'Почему: ' + r.why})) if (r.cost) body.push(hang('rc' + r.n, indent, {'Цена ошибки: ' + r.cost})) }) } // ── 4. Отложено: открытые находки первыми, затем deferred if (tab === 'deferred') { if (!f) empty('SDD-ledger нет: находок пока нет') else if (!deferred.length && !openItems.length) empty('Отложенного нет') else body.push( {f.final ? 'по таблице ## Findings в ledger' : 'предварительно, по строкам ledger: таблицы ## Findings ещё нет'} , ) const open = setOf(openFindings, t.key) ;[...openItems, ...deferred].forEach((item, i) => { const isOpenItem = item.status === 'open' // Пояснение к статусу — то, что сверх самого слова: `deferred (решит Task 7, шаг 3)` const note = statusNote(item.detail) const isOpenNote = open.has(item.marker) const prefix = `${note ? (isOpenNote ? '−' : '+') : ' '} ${isOpenItem ? '!' : '·'} ${item.marker.padEnd(7)}` focusRows.push('find-' + item.marker) body.push( hang( 'f' + item.marker, prefix, // Enter раскрывает пояснение к статусу, если оно есть , { color: isOpenItem ? 'warning' : undefined, bold: isOpenItem }, ), ) if (isOpenNote) { body.push(hang('fn' + item.marker, ' '.repeat(prefix.length), {note})) } }) } rows = focusRows // Полоса вкладок: цифры 1–4 работают в любой раскладке; в подписи — счётчик вкладки const counts: Record = { docs: String(t.docs.length), tasks: p ? `${p.done}/${p.total}` : t.commits.length ? `${t.commits.length} комм.` : '', rulings: String(t.rulings.length), deferred: String(deferred.length) + (openItems.length ? ` !${openItems.length}` : ''), } const titles: Record = { docs: 'Документы', tasks: 'Задачи', rulings: 'Рулинги', deferred: 'Отложено' } const tabBar = ( // Вкладки растянуты по ширине панели {TABS.map((id, i) => (