feat(plugins): мод /feature — панель фичи, перенос в обвязку (#7)

Мод Claude Code жил во временной папке сессии obsidian-voice-note и в
новых сессиях не загружался. Перенесён как есть в plugins/feature/,
плагин переименован plan-progress → feature (команда /feature, запасные
/progress и /feature-pane). Поведение не менялось.

- README мода: что показывает, контракт с форматами обвязки (ledger,
  ## Findings, Decisions в design.md, планы, .bounded), разработка.
- Сгенерированные движком типы (.claude-plugin/types/) — в .gitignore.
- Строки о моде в CLAUDE.md и README обвязки; install.sh его пока не ставит.

Проверено: claude plugin test plugins/feature — 22/22, claude plugin
validate — без ошибок, tsc — чисто, bash tests/run.sh — 157/157.

Refs #7

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-15 11:37:42 +04:00
parent 2c599a1324
commit 069c5643b1
10 changed files with 2093 additions and 0 deletions

2
.gitignore vendored
View File

@ -1,3 +1,5 @@
.superpowers/
.bounded
.DS_Store
# Типы движка Claude Code: пишет сам движок при загрузке мода, у каждой версии свои
plugins/*/.claude-plugin/types/

View File

@ -15,6 +15,7 @@
- `install.sh`, `adopt-project.sh`, `new-project.sh` — установка и бутстрап. `install.sh` копирует `global/`, `hooks/`, `project-template/`, `adopt-project.sh`, `new-project.sh` и `skills/harness-doctor` в `~/.claude/{CLAUDE.md,hooks,harness,skills}`. `adopt-project.sh` работает на ветке `chore/harness-init` и вливает её ff в текущую ветку; это единственная разрешённая запись на main вне PR.
- `global/CLAUDE.md` — глобальные правила процесса, ставятся в `~/.claude/CLAUDE.md`. `project-template/` — `CLAUDE.md`, `CLAUDE.harness-section.md` (секция «OpenSpec и superpowers», по ней `harness-check` узнаёт подключённый проект), `ARCHITECTURE.md`, `gitignore`.
- `skills/harness-doctor/` — skill диагностики: `scripts/doctor.sh` снимает состояние машины и проекта и прогоняет самотест хуков; `SKILL.md` — чеклист прочтения отчёта.
- `plugins/feature/` — мод Claude Code `/feature`: панель фичи (этап, задачи по SDD-ledger, рулинги, отложенное, документы). TypeScript на API модов, только чтение артефактов обвязки; контракт с их форматами — в `plugins/feature/README.md`. Тесты — `claude plugin test plugins/feature`. `install.sh` его пока не ставит.
- `roles/` — общий слой ролей (формат: четыре раздела, см. спеку ролей), `skills/role/` — skill `/role`, `agents/briefing.md` — агент выжимки, `link-roles.sh` — симлинки workspace → репозитории направлений. Ставятся `install.sh` в `~/.claude/{roles,skills/role,agents,harness}`.
- `tests/` — тесты пакета на bash: `run.sh` + `test-*.sh`, работают с источниками, `install.sh` ставят во временный `CLAUDE_CONFIG_DIR`.
- `caveman/config.json` — выключенный по умолчанию caveman. `examples/` — реальный `CLAUDE.md` проекта-пилота.

View File

@ -36,6 +36,7 @@ link-roles.sh симлинки ролей/агентов из worksp
project-template/workspace/ CLAUDE.md workspace с таблицей репозиториев, roles/dev.md
project-template/role-repo/ репозиторий направления: CLAUDE.md, ROLE.md, notes/README.md
tests/run.sh тесты пакета (bash): bash tests/run.sh
plugins/feature/ мод Claude Code /feature: панель фичи — этап, задачи, рулинги, отложенное, документы (пока не ставится install.sh)
docs/HARNESS.md как это работает: слои, цикл фичи, SDD, дисциплина закрытия
docs/PILOT-2026-09-27.md результаты пилота и находки
```

View File

@ -0,0 +1,5 @@
{
"name": "feature",
"version": "0.1.0",
"description": "Фича по веткам: этап и чей ход, задачи плана, рулинги, отложенное, документы в glow"
}

47
plugins/feature/README.md Normal file
View File

@ -0,0 +1,47 @@
# feature — панель фичи для Claude Code
Мод Claude Code: команда `/feature [ветка | issue | план]` открывает панель по веткам в работе — где фича и чей ход, что сделано и что требует внимания человека. Только чтение: мод ничего не пишет в репозитории.
```
‹ feat/10-obsidian-wrapper 6/7 ›
○ ● ○ ○ ○ ○ ○
#10 · openspec: obsidian-wrapper · по SDD-ledger
Этап: ручная проверка (Task 7) ход за тобой
1: ○ Документы 5 2: ● Задачи 6/7 3: ○ Рулинги 10 4: ○ Отложено 14
```
## Что показывает
- **Этап и чей ход** — по тому, какие артефакты есть: brainstorming → ревью спеки → ревью плана → исполнение → финальное ревью → ручная проверка → ветка на сервере (PR).
- **1 Документы** — proposal, design, спеки capability, план, SDD-ledger. Enter открывает файл в `glow -t` в соседней панели iTerm (Terminal.app — новое окно, иначе `open`; без glow — `less`).
- **2 Задачи** — задачи плана со статусами по ledger (SDD шаги плана не отмечает), шаги раскрываются по Enter.
- **3 Рулинги** — решения из `## Decisions made without discussion`, кроме принятых человеком, с маркером `R-n`; Enter раскрывает обоснование и цену ошибки.
- **4 Отложено** — находки `deferred` и находки без статуса (`!`); Enter раскрывает пояснение к статусу.
Вкладка по умолчанию — по этапу: до старта исполнения документы, при исполнении задачи, на ревью PR рулинги. Клавиши: `1`–`4` вкладки, `9`/`0` ветки, `e`/`h` раскрыть все и скрыть готовые задачи, `r` обновить. Цифры работают в любой раскладке.
## Что читает (контракт с обвязкой)
| Источник | Что берётся |
|---|---|
| `git worktree list` в корне сессии | ветки в работе; ветка на `main` без работы не показывается |
| `.superpowers/sdd/<plan>/progress.md`, `plan-path` | статусы задач (`Task N: complete`, `fix round`), `R-n (design #m)`, `Task N is manual`, строки `Final…` |
| таблица `## Findings` в ledger (`\| Маркер \| Суть \| Статус \|`) | отложенное; без таблицы — предварительный подсчёт по строкам ledger |
| `docs/superpowers/plans/*.md` | задачи (`### Task N: …`) и шаги (`- [ ]`), `Issue: #N` |
| `openspec/changes/<id>/` | proposal, design, `specs/*/spec.md`; `## Decisions made without discussion` в design.md |
| `.bounded`, `feat/<issue>-…`, `@{u}` | bounded-задача, номер issue, ветка на сервере |
Меняется формат одного из этих источников — меняется и мод, в том же PR.
## Разработка
- Тесты: `claude plugin test plugins/feature`; проверка манифеста и вызовов: `claude plugin validate plugins/feature`.
- Типы: движок пишет их в `.claude-plugin/types/` при загрузке мода (в git не попадают); после этого `npx -p typescript tsc -p plugins/feature --noEmit`.
- Запуск из worktree с перезагрузкой при правках: `claude --plugin-dir <worktree>/plugins/feature`.
- Логика разбора — чистые функции в `hooks/parse.ts` (тестируются напрямую), отрисовка и ввод-вывод — `hooks/register.tsx`.
## Ограничения
- Корень сессии должен быть git-репозиторием: workspace из нескольких репозиториев (`.wt/<фича>/<репо>`) пока не поддерживается.
- Фон панели в полноэкранном режиме рисует Claude Code ключом темы `composerSidebarBackground`; поменять его можно только своей темой (`~/.claude/themes/<имя>.json`).

View File

@ -0,0 +1 @@
{ "modules": ["./register.tsx"] }

View File

@ -0,0 +1,463 @@
// Pure parsing of superpowers plans, SDD ledgers and git output — no mods API here, so it is unit-testable.
export type PlanStep = { text: string; done: boolean; depth: number }
export type PlanTask = { n: number; title: string; stepsDone: number; stepsTotal: number; steps: PlanStep[] }
export type Plan = { path: string; title: string; spec?: string; issue?: number; tasks: PlanTask[] }
export type LedgerTask = { complete: boolean; lines: string[] }
export type Ledger = {
plan?: string
tasks: Map<number, LedgerTask>
deferred: number
rulings: number
parked: number
// Номер решения в design.md → маркер рулинга: `R-3 (design #17)`
rulingOf: Map<number, string>
// Задачи, которые контроллер отдал человеку: `Task 7 is manual`
manual: number[]
// Финальное ревью ветки начато (строки `Final…`)
finalReview: boolean
}
export type FindingStatus = 'deferred' | 'fixed' | 'rejected' | 'open'
export type Finding = { marker: string; text: string; status: FindingStatus; detail: string }
// final — из таблицы `## Findings` контроллера; иначе предварительно, по последнему упоминанию в ledger
export type Findings = { items: Finding[]; final: boolean }
// why — обоснование, cost — цена ошибки: показываются, когда человек раскрывает рулинг
export type Decision = { n: number; date: string; stage?: string; text: string; why: string; cost: string; human: boolean }
export type Who = 'you' | 'agent'
export type Stage = { label: string; who: Who }
export type Doc = { label: string; path: string }
export type Status = 'done' | 'active' | 'todo'
export type TaskView = PlanTask & { status: Status; note?: string }
export type Progress = {
tasks: TaskView[]
done: number
total: number
next?: TaskView
source: 'ledger' | 'steps'
// Хотя бы один шаг плана отмечен: только тогда счётчики шагов что-то значат
anyTicked: boolean
}
export type Worktree = { path: string; branch?: string }
const FENCE = /^\s*(```|~~~)/
const HEADING = /^(#{1,6})\s+(.+?)\s*#*\s*$/
const TASK = /^Task\s+(\d+)\s*[:.—–-]?\s*(.*)$/i
const STEP = /^(\s*)[-*+]\s+\[([ xX])\]\s+(.*\S)\s*$/
const MINOR = /^minor\s*\(deferred\)/i
/** `**Step 1: Добавить порты в `ports.ts`:** и т.д.` → `1. Добавить порты в ports.ts` */
export function stepText(raw: string): string {
const bold = /^\*\*(.+?)\*\*/.exec(raw)
return (bold ? (bold[1] ?? '') : raw)
.replace(/`/g, '')
.replace(/\*\*/g, '')
.trim()
.replace(/\s*[:.]$/, '')
.replace(/^Step\s+(\d+)\s*[:.]\s*/i, '$1. ')
}
export function parsePlan(path: string, md: string): Plan {
const plan: Plan = { path, title: '', tasks: [] }
let task: PlanTask | undefined
let taskLevel = 0
let inFence = false
for (const line of md.split(/\r?\n/)) {
// Plans quote code with checkboxes in it; those are not steps
if (FENCE.test(line)) {
inFence = !inFence
continue
}
if (inFence) continue
const h = HEADING.exec(line)
if (h) {
const level = (h[1] ?? '').length
const text = (h[2] ?? '').replace(/\*\*/g, '')
if (level === 1 && !plan.title) plan.title = text
const t = TASK.exec(text)
if (t) {
task = { n: Number(t[1]), title: (t[2] ?? '').replace(/`/g, '').trim(), stepsDone: 0, stepsTotal: 0, steps: [] }
taskLevel = level
plan.tasks.push(task)
} else if (task && level <= taskLevel) {
// "## Self-Review" and the like close the last task
task = undefined
}
continue
}
if (!plan.spec) {
const spec = /\*\*Spec:?\*\*:?\s*`([^`]+)`/.exec(line)
if (spec) plan.spec = spec[1]
}
if (plan.issue === undefined) {
const issue = /\bIssue:?\**\s*#(\d+)/.exec(line)
if (issue) plan.issue = Number(issue[1])
}
const s = STEP.exec(line)
if (s && task) {
const done = s[2] !== ' '
const indent = (s[1] ?? '').replace(/\t/g, ' ').length
task.steps.push({ text: stepText(s[3] ?? ''), done, depth: Math.floor(indent / 2) })
task.stepsTotal++
if (done) task.stepsDone++
}
}
return plan
}
const LEDGER_TITLE = /^#\s*SDD ledger\s*[—–-]+\s*plan:\s*(.+?)\s*$/
const LEDGER_LINE = /^\s*(?:[-*+]\s+)?(?:\*\*)?Task\s+(\d+)(?:\*\*)?\s*:\s*(?:\*\*)?\s*(.+?)\s*$/
export function parseLedger(md: string): Ledger {
const ledger: Ledger = {
tasks: new Map(),
deferred: 0,
rulings: 0,
parked: 0,
rulingOf: new Map(),
manual: [],
finalReview: false,
}
for (const line of md.split(/\r?\n/)) {
const title = LEDGER_TITLE.exec(line)
if (title && !ledger.plan) {
ledger.plan = (title[1] ?? '').replace(/^`|`$/g, '')
continue
}
if (/Ruling:/.test(line)) ledger.rulings++
for (const r of line.matchAll(/\b(R-\d+)\s*\(design\s*#(\d+)\)/g)) ledger.rulingOf.set(Number(r[2]), r[1] ?? '')
for (const m of line.matchAll(/\bTask\s+(\d+)\s+is\s+manual\b/gi)) ledger.manual.push(Number(m[1]))
if (/^\s*(?:[-*+]\s+)?(?:\*\*)?Final\b/.test(line)) ledger.finalReview = true
const m = LEDGER_LINE.exec(line)
if (!m) continue
const n = Number(m[1])
const text = m[2] ?? ''
const entry = ledger.tasks.get(n) ?? { complete: false, lines: [] }
entry.lines.push(text)
if (/^complete\b/i.test(text)) entry.complete = true
if (MINOR.test(text)) ledger.deferred++
if (/^parked\b/i.test(text)) ledger.parked++
ledger.tasks.set(n, entry)
}
return ledger
}
/** The ledger line worth showing beside an unfinished task: its last fix round, else its last line. */
function noteOf(entry: LedgerTask | undefined): string | undefined {
if (!entry || entry.complete) return undefined
const work = entry.lines.filter(l => !MINOR.test(l))
const round = [...work].reverse().find(l => /^fix round/i.test(l))
const line = round ?? work[work.length - 1]
if (!line) return undefined
// "fix round 2/5 (1 addressed, 1 open — …; commits …)" → "fix round 2/5 (1 addressed, 1 open)"
const short = line.replace(/\s*[—;].*$/, '')
return short.includes('(') && !short.includes(')') ? short + ')' : short
}
/**
* Task statuses. With a ledger, SDD is the source: a task is done only on its
* `complete` line, since SDD never ticks plan steps. Without one, the steps are.
*/
export function buildProgress(plan: Plan, ledger?: Ledger): Progress {
const tasks: TaskView[] = plan.tasks.map(t => {
if (ledger) {
const entry = ledger.tasks.get(t.n)
const worked = entry?.lines.some(l => !MINOR.test(l)) ?? false
const status: Status = entry?.complete ? 'done' : worked ? 'active' : 'todo'
return { ...t, status, note: noteOf(entry) }
}
const status: Status =
t.stepsTotal > 0 && t.stepsDone === t.stepsTotal ? 'done' : t.stepsDone > 0 ? 'active' : 'todo'
return { ...t, status }
})
const next = tasks.find(t => t.status === 'active') ?? tasks.find(t => t.status === 'todo')
return {
tasks,
done: tasks.filter(t => t.status === 'done').length,
total: tasks.length,
next,
source: ledger ? 'ledger' : 'steps',
anyTicked: plan.tasks.some(t => t.stepsDone > 0),
}
}
// T4.1, T5.I-1, C-1, I-2, M-1, O-3, H-1. R-n — рулинги, не находки. `I-1` внутри `T5.I-1` не отдельный маркер
const MARKER = /(?<![\w.-])(T\d+\.(?:[CIM]-)?\d+|[CIMOH]-\d+)(?![\w-])/g
const FINDINGS_HEADING = /^#{2,3}\s+(findings|таблица находок)\b/i
function statusOf(text: string): FindingStatus | undefined {
const s = text.toLowerCase()
if (/\bfixed\b|\baddressed\b/.test(s)) return 'fixed'
if (/\brejected\b/.test(s)) return 'rejected'
if (/\bdeferred\b/.test(s)) return 'deferred'
return undefined
}
function cells(line: string): string[] | undefined {
if (!/^\s*\|/.test(line)) return undefined
return line.trim().replace(/^\||\|$/g, '').split('|').map(c => c.trim())
}
/** Таблица `## Findings` (или «Таблица находок»): колонки ищутся по заголовкам, порядок любой. */
function findingsTable(md: string): Finding[] | undefined {
const lines = md.split(/\r?\n/)
const start = lines.findIndex(l => FINDINGS_HEADING.test(l))
if (start < 0) return undefined
let col: { marker: number; status: number; text: number } | undefined
const items: Finding[] = []
for (const line of lines.slice(start + 1)) {
if (/^#{1,3}\s/.test(line)) break
const row = cells(line)
if (!row) {
if (col && items.length && line.trim()) break
continue
}
if (row.every(c => /^:?-+:?$/.test(c))) continue
if (!col) {
const at = (re: RegExp) => row.findIndex(c => re.test(c))
const marker = at(/маркер|marker/i)
const status = at(/статус|status/i)
if (marker < 0 || status < 0) return undefined
const text = row.findIndex((_, i) => i !== marker && i !== status)
col = { marker, status, text }
continue
}
const marker = (row[col.marker] ?? '').replace(/[`*]/g, '')
if (!marker) continue
const detail = row[col.status] ?? ''
items.push({
marker,
text: (row[col.text] ?? '').replace(/`/g, ''),
status: statusOf(detail.split(/[\s:(]/)[0] ?? '') ?? statusOf(detail) ?? 'open',
detail,
})
}
return col ? items : undefined
}
/**
* Находки фичи. Есть таблица `## Findings` — она и есть итог. Нет — предварительно по ledger:
* статус маркера берётся из его куска строки (до `;`), иначе из начала строки (`minor (deferred):`),
* и последний явный статус побеждает; упоминание без статуса прежний статус не трогает.
*/
export function parseFindings(md: string): Findings {
const table = findingsTable(md)
if (table) return { items: table, final: true }
const byMarker = new Map<string, Finding>()
for (const line of md.split(/\r?\n/)) {
if (cells(line)) continue
const first = line.search(MARKER)
if (first < 0) continue
// Один маркер в строке — статус из всей строки; несколько — из начала строки до первого маркера
const single = [...line.matchAll(MARKER)].length === 1
const lineStatus = statusOf(single ? line : line.slice(0, first))
for (const segment of line.split(';')) {
for (const m of segment.matchAll(MARKER)) {
const marker = m[1] ?? ''
const status = statusOf(segment) ?? lineStatus
const prev = byMarker.get(marker)
if (!prev) {
const after = segment.slice((m.index ?? 0) + marker.length).replace(/^[\s:—–-]+/, '')
byMarker.set(marker, {
marker,
text: after.replace(/`/g, '').trim(),
status: status ?? 'open',
detail: '',
})
} else if (status) {
prev.status = status
}
}
}
}
return { items: [...byMarker.values()], final: false }
}
/** Пояснение к статусу находки — то, что сверх самого слова: `deferred (решит Task 7, шаг 3)` → `решит Task 7, шаг 3`. */
export function statusNote(detail: string): string {
return detail
.replace(/^\s*(deferred|fixed|rejected|open)\b[\s:—–-]*/i, '')
.replace(/^\((.*)\)$/, '$1')
.trim()
}
/** Разметка прочь, пометка «принято человеком» — тоже: она уже в флаге human. */
function clean(text: string): string {
return text
.replace(/\*+/g, '')
.replace(/`/g, '')
.replace(/\s*\([^)]*человеком[^)]*\)\s*\.?/gi, '')
.trim()
}
/** Раздел `## Decisions made without discussion` в design.md: `N. дата (этап) — решение. *Обоснование.*` */
export function parseDecisions(md: string): Decision[] {
const out: Decision[] = []
let inside = false
for (const line of md.split(/\r?\n/)) {
if (/^##\s/.test(line)) {
inside = /^##\s+Decisions made without discussion/i.test(line)
continue
}
if (!inside) continue
const m = /^\s*(\d+)\.\s+(\d{4}-\d{2}-\d{2})\s*(?:\(([^)]*)\))?\s*[—–-]+\s*(.*)$/.exec(line)
if (!m) continue
const body = m[4] ?? ''
// Само решение — до курсива с обоснованием; дальше обоснование, с первого `**` — цена ошибки.
// Два вида записи: `*Обоснование.* **Цена.**` и `*Обоснование:* текст **Цена ошибки:** текст`
const italic = body.search(/\s\*(?!\*)/)
const decision = italic < 0 ? body : body.slice(0, italic)
const rest = italic < 0 ? '' : body.slice(italic)
const costAt = rest.indexOf('**')
out.push({
n: Number(m[1]),
date: m[2] ?? '',
stage: m[3],
text: clean(decision),
why: clean(costAt < 0 ? rest : rest.slice(0, costAt)).replace(/^Обоснование\s*:\s*/i, ''),
cost: costAt < 0 ? '' : clean(rest.slice(costAt)).replace(/^Цена ошибки\s*:\s*/i, ''),
human: /принято человеком|подтверждено человеком/i.test(body),
})
}
return out
}
/** Задачи, которые делает человек: из ledger (`Task 7 is manual`) или по названию задачи плана. */
export function manualTasks(plan: Plan | undefined, ledger: Ledger | undefined): number[] {
const byTitle = (plan?.tasks ?? []).filter(t => /ручн|manual|human/i.test(t.title)).map(t => t.n)
return [...new Set([...(ledger?.manual ?? []), ...byTitle])].sort((a, b) => a - b)
}
export type StageInput = {
change: boolean
bounded: boolean
planPath?: string
progress?: Progress
ledger?: Ledger
manual: number[]
// Ветка на сервере и без непушнутых коммитов
pushed: boolean
commits: number
}
/** Где фича и чей ход: по тому, какие артефакты уже есть. */
export function stageOf(s: StageInput): Stage {
const pr: Stage = { label: 'ветка запушена: ревью PR, merge по твоей команде', who: 'you' }
if (s.bounded && !s.planPath) {
if (s.pushed && s.commits) return pr
return { label: 'bounded: реализация и PR', who: 'agent' }
}
if (!s.planPath) {
if (s.change) return { label: 'ревью спеки', who: 'you' }
return { label: 'brainstorming', who: 'you' }
}
const p = s.progress
if (!p) return { label: 'план не найден', who: 'you' }
if (!s.ledger && !p.anyTicked) return { label: 'ревью плана и выбор режима исполнения', who: 'you' }
const auto = p.tasks.filter(t => !s.manual.includes(t.n) && t.status !== 'done')
if (auto.length) return { label: `исполнение: Task ${auto[0]!.n}`, who: 'agent' }
if (s.ledger && !s.ledger.finalReview) return { label: 'финальное ревью ветки', who: 'agent' }
// Ручную задачу в ledger никто не закрывает: после push её результат уже в PR
if (s.pushed) return pr
const manual = p.tasks.filter(t => s.manual.includes(t.n) && t.status !== 'done')
if (manual.length) return { label: `ручная проверка (Task ${manual.map(t => t.n).join(', ')})`, who: 'you' }
return { label: 'готово: push и PR', who: 'agent' }
}
// Вкладки в порядке, в котором они нужны по ходу фичи: спека и план → исполнение → рулинги на ревью → отложенное при закрытии
export const TABS = ['docs', 'tasks', 'rulings', 'deferred'] as const
export type Tab = (typeof TABS)[number]
export type TabInput = {
progress?: Progress
ledger?: Ledger
docs: number
rulings: number
// Bounded-ветка без плана: смотреть там нечего, кроме коммитов
boundedNoPlan: boolean
pushed: boolean
}
/**
* Вкладка этапа: до старта исполнения — документы, во время исполнения и ручной проверки — задачи,
* когда ветка на сервере (ревью PR) — рулинги.
*/
export function defaultTab(s: TabInput): Tab {
const beforeRun = !s.progress || (!s.ledger && !s.progress.anyTicked)
if (beforeRun) return s.docs && !s.boundedNoPlan ? 'docs' : 'tasks'
if (s.pushed && s.rulings) return 'rulings'
return 'tasks'
}
export function parseWorktrees(porcelain: string): Worktree[] {
const out: Worktree[] = []
let cur: Worktree | undefined
for (const line of porcelain.split(/\r?\n/)) {
if (line.startsWith('worktree ')) {
cur = { path: line.slice('worktree '.length) }
out.push(cur)
} else if (cur && line.startsWith('branch ')) {
cur.branch = line.slice('branch '.length).replace(/^refs\/heads\//, '')
} else if (cur && line === 'bare') {
out.pop()
cur = undefined
}
}
return out
}
/** `feat/7-wrapper-followups` → 7 */
export function issueOfBranch(branch?: string): number | undefined {
const m = /(?:^|\/)(\d+)-/.exec(branch ?? '')
return m ? Number(m[1]) : undefined
}
/** Paths from `git diff --name-only` and `git status --porcelain`, deleted ones dropped, deduplicated. */
export function changedPaths(diffNames: string, statusPorcelain: string): string[] {
const fromStatus = statusPorcelain
.split(/\r?\n/)
.filter(l => l.length > 3 && !l.startsWith(' D') && !l.startsWith('D '))
.map(l => l.slice(3).replace(/^.* -> /, '').replace(/^"|"$/g, ''))
const all = [...diffNames.split(/\r?\n/), ...fromStatus].map(s => s.trim()).filter(Boolean)
return [...new Set(all)]
}
export function joinPath(base: string, rel: string): string {
return rel.startsWith('/') ? rel : base.replace(/\/+$/, '') + '/' + rel.replace(/^\.\//, '')
}
/** The worktree that holds `cwd`: the longest path that is a prefix of it. */
export function worktreeOf(worktrees: Worktree[], cwd: string): Worktree | undefined {
return worktrees
.filter(w => cwd === w.path || cwd.startsWith(w.path.replace(/\/+$/, '') + '/'))
.sort((a, b) => b.path.length - a.path.length)[0]
}
export function bar(done: number, total: number, width: number): string {
if (total === 0 || width <= 0) return ''
const full = Math.round((done / total) * width)
return '█'.repeat(full) + '░'.repeat(width - full)
}
/**
* Куда поставить фокус, когда человек уводит его со строк списка (на кнопку,
* на ветку, на служебный элемент): за верх — на последнюю строку, за низ — на первую.
*/
export function wrapFocus(rows: readonly string[], lastRow: string | undefined): string | undefined {
if (rows.length === 0) return undefined
const first = rows[0] as string
const last = rows[rows.length - 1] as string
if (lastRow === first) return last
if (lastRow === last) return first
return lastRow !== undefined && rows.includes(lastRow) ? lastRow : first
}

View File

@ -0,0 +1,893 @@
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<string, Set<number>>()
// Выбранная вкладка по каждому Target; нет в карте — по этапу
const tabs = new Map<string, Tab>()
// Раскрытые рулинги (номер решения) и находки (маркер) по каждому Target
const openRulings = new Map<string, Set<number>>()
const openFindings = new Map<string, Set<string>>()
let isOpen = false
let loaded = false
let error = ''
let loading: Promise<void> | 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<string | undefined> {
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<string | undefined> {
try {
return await $.fs.read(path)
} catch {
// Missing, or mid-write: skip it this round
return undefined
}
}
async function mtime($: EngineInterface, path: string): Promise<number> {
try {
return (await $.fs.stat(path)).mtimeMs
} catch {
return 0
}
}
async function dirs($: EngineInterface, path: string): Promise<string[]> {
try {
return (await $.fs.list(path)).filter(e => e.kind === 'dir').map(e => e.name)
} catch {
return []
}
}
async function exists($: EngineInterface, path: string): Promise<boolean> {
try {
return await $.fs.exists(path)
} catch {
return false
}
}
async function viewer($: EngineInterface): Promise<string> {
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<void> {
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<Doc[]> {
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<Target[]> {
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<void> {
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<void> {
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<number> {
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<T>(map: Map<string, Set<T>>, key: string): Set<T> {
let set = map.get(key)
if (!set) {
set = new Set<T>()
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 = (
<Button key="refresh" label="обновить" hotkey="r" plain dimColor onPress={() => void refresh($)} />
)
if (!loaded || targets.length === 0) {
rows = []
return (
<Box flexDirection="column">
<Text dimColor>{loaded ? error : 'Загрузка…'}</Text>
{refreshButton}
</Box>
)
}
const t = targets.find(x => x.key === selected) ?? (targets[0] as Target)
const redraw = () => $.ui.invalidate('ui.render')
const choose = (key: string) => {
selected = key
redraw()
}
const plansIn = (wt: Worktree) => targets.filter(x => x.worktree.path === wt.path).length
// 9 / 0 — по кругу по всем веткам; цифры работают в любой раскладке
const step = (by: number) => {
const i = targets.findIndex(x => x.key === t.key)
const next = targets[(i + by + targets.length) % targets.length]
if (next) choose(next.key)
}
const branchButtons =
targets.length > 1
? [
// Порядок как на клавиатуре и в карусели: 9 — влево, 0 — вправо
<Button key="prev-branch" label="‹ ветка" hotkey="9" plain dimColor onPress={() => step(-1)} />,
<Button key="next-branch" label="ветка ›" hotkey="0" plain dimColor onPress={() => step(1)} />,
]
: []
// Ветки — карусель: 9 / 0 внизу листают по кругу. Не Select: он забирает стрелки у списка
const index = targets.findIndex(x => x.key === t.key)
const picker =
targets.length > 1 ? (
<Box flexDirection="row" columnGap={1}>
<Text dimColor>‹</Text>
<Text bold wrap="truncate-end">
{targetLabel(t, plansIn(t.worktree))}
</Text>
<Text dimColor>›</Text>
</Box>
) : (
<Text bold wrap="truncate-end">
{branchName(t.worktree)}
</Text>
)
const meta = [
t.issue !== undefined ? '#' + t.issue : undefined,
t.changes.length ? 'openspec: ' + t.changes.join(', ') : undefined,
t.bounded ? 'bounded' : undefined,
t.progress
? t.progress.source === 'ledger'
? 'по SDD-ledger'
: t.progress.anyTicked
? 'по шагам плана'
: 'SDD не запущен'
: undefined,
].filter(Boolean)
const yours = t.stage.who === 'you'
// Шапка по центру панели
const head = (
<Box flexDirection="column" alignItems="center">
{picker}
{/* Точки под каруселью: по одной на ветку, текущая — закрашена */}
{targets.length > 1 && (
<Box flexDirection="row" columnGap={1}>
{targets.map((x, i) => (
<Text key={'dot-' + i} dimColor={i !== index}>
{i === index ? '●' : '○'}
</Text>
))}
</Box>
)}
{meta.length > 0 && (
<Text dimColor wrap="truncate-end">
{meta.join(' · ')}
</Text>
)}
<Box flexDirection="row" columnGap={2}>
<Text bold wrap="truncate-end">{`Этап: ${t.stage.label}`}</Text>
<Text color={yours ? 'warning' : undefined} dimColor={!yours} bold={yours}>
{yours ? 'ход за тобой' : 'работает агент'}
</Text>
</Box>
</Box>
)
// Висячий отступ: префикс — колонка постоянной ширины, текст переносится в своей колонке
const hang = (key: string, prefix: string, body: JSX.Element, prefixProps: RowStyle = {}) => (
<Box key={key} flexDirection="row">
<Box flexShrink={0}>
<Text {...prefixProps}>{prefix}</Text>
</Box>
<Box flexGrow={1} flexShrink={1}>
{body}
</Box>
</Box>
)
const tab = tabFor(t)
const setTab = (next: Tab) => {
tabs.set(t.key, next)
redraw()
}
const focusRows: string[] = []
const body: JSX.Element[] = []
const empty = (text: string) => body.push(<Text key="empty" dimColor>{text}</Text>)
const p = t.progress
const f = t.findings
const deferred = f?.items.filter(i => i.status === 'deferred') ?? []
const openItems = f?.items.filter(i => i.status === 'open') ?? []
// ── 1. Документы: Enter открывает файл в просмотрщике рядом с сессией
if (tab === 'docs') {
if (!t.docs.length) empty('Документов нет: ни OpenSpec change, ни плана')
// Маркированный список; перенос длинной подписи — под текст, не под маркер
t.docs.forEach((d, i) => {
focusRows.push('doc-' + d.path)
body.push(
hang(
'd' + d.path,
'• ',
<Button key={'doc-' + d.path} plain autoFocus={i === 0 || undefined} onPress={() => void openDoc($, d.path)}>
<Text wrap="wrap">{d.label}</Text>
</Button>,
{ 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(
<Text key="plan" bold wrap="truncate-end">
{t.plan?.title || t.planPath}
</Text>,
<Box key="bar" flexDirection="row" columnGap={1}>
<Text color={pct === 100 ? 'success' : 'suggestion'}>{bar(p.done, p.total, wide)}</Text>
<Text bold>{`${p.done}/${p.total}`}</Text>
<Text dimColor>{`задач · ${pct}%`}</Text>
</Box>,
<Text key="next" wrap="truncate-end" color={p.next ? 'suggestion' : 'success'}>
{p.next ? `Дальше: Task ${p.next.n} — ${p.next.title}` : 'Все задачи выполнены'}
</Text>,
<Text key="gap"> </Text>,
)
const shown = hideDone ? p.tasks.filter(x => x.status !== 'done') : p.tasks
const hidden = p.tasks.length - shown.length
if (hidden > 0) body.push(<Text key="hidden" dimColor>{`✓ скрыто готовых: ${hidden}`}</Text>)
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,
<Button
key={'task-' + task.n}
plain
autoFocus={isNext || (!p.next && index === 0) || undefined}
onPress={() => toggle(task.n)}
>
<Text {...style} wrap="wrap">
{task.title}
</Text>
</Button>,
style,
),
)
const indent = ' '.repeat(prefix.length)
if (task.note) {
body.push(hang('n' + task.n, indent, <Text dimColor wrap="wrap">{task.note}</Text>, { 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 на шаге сворачивает задачу и возвращает на неё фокус
<Button
key={`step-${task.n}-${i}`}
plain
onPress={() => {
open.delete(task.n)
redraw()
void $.ui.focus({ requestId: PANE, key: 'task-' + task.n }).catch(() => {})
}}
>
<Text dimColor={done} wrap="wrap">
{s.text}
</Text>
</Button>,
{ dimColor: done },
),
)
})
})
}
// Без плана на вкладке задач — что уже в ветке
if (tab === 'tasks' && !p) {
if (t.planPath) body.push(<Text key="noplan" color="warning">{'План не найден: ' + t.planPath}</Text>)
else empty(t.bounded ? 'Bounded-задача, плана нет' : 'Плана нет')
body.push(
<Text key="commits-h" dimColor>
{t.commits.length
? `Коммиты в ветке (${t.commits.length}${t.commits.length === 8 ? '+' : ''}):`
: 'Коммитов поверх main нет'}
</Text>,
)
t.commits.forEach((c, i) =>
body.push(
<Text key={'c' + i} wrap="truncate-end">
{' ' + c}
</Text>,
),
)
}
// ── 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 раскрывает обоснование и цену ошибки прямо в списке
<Button
key={'rul-' + r.n}
plain
autoFocus={i === 0 || undefined}
onPress={() => {
if (isOpenRuling) open.delete(r.n)
else if (more) open.add(r.n)
redraw()
}}
>
<Text wrap="wrap">{r.stage ? `${r.text} (${r.stage})` : r.text}</Text>
</Button>,
{ 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, <Text dimColor wrap="wrap">{'Почему: ' + r.why}</Text>))
if (r.cost) body.push(hang('rc' + r.n, indent, <Text dimColor wrap="wrap">{'Цена ошибки: ' + r.cost}</Text>))
})
}
// ── 4. Отложено: открытые находки первыми, затем deferred
if (tab === 'deferred') {
if (!f) empty('SDD-ledger нет: находок пока нет')
else if (!deferred.length && !openItems.length) empty('Отложенного нет')
else
body.push(
<Text key="hint" dimColor>
{f.final ? 'по таблице ## Findings в ledger' : 'предварительно, по строкам ledger: таблицы ## Findings ещё нет'}
</Text>,
)
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 раскрывает пояснение к статусу, если оно есть
<Button
key={'find-' + item.marker}
plain
autoFocus={i === 0 || undefined}
onPress={() => {
if (isOpenNote) open.delete(item.marker)
else if (note) open.add(item.marker)
redraw()
}}
>
<Text wrap="wrap">{item.text || item.detail || '—'}</Text>
</Button>,
{ color: isOpenItem ? 'warning' : undefined, bold: isOpenItem },
),
)
if (isOpenNote) {
body.push(hang('fn' + item.marker, ' '.repeat(prefix.length), <Text dimColor wrap="wrap">{note}</Text>))
}
})
}
rows = focusRows
// Полоса вкладок: цифры 1–4 работают в любой раскладке; в подписи — счётчик вкладки
const counts: Record<Tab, string> = {
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<Tab, string> = { docs: 'Документы', tasks: 'Задачи', rulings: 'Рулинги', deferred: 'Отложено' }
const tabBar = (
// Вкладки растянуты по ширине панели
<Box key="tabs" width="100%" flexDirection="row" flexWrap="wrap" justifyContent="space-evenly" columnGap={2} marginTop={1}>
{TABS.map((id, i) => (
<Button
key={'tab-' + id}
label={`${tab === id ? '●' : '○'} ${titles[id]} ${counts[id]}`.trimEnd()}
hotkey={String(i + 1)}
plain
dimColor={tab !== id}
onPress={() => setTab(id)}
/>
))}
</Box>
)
const extra: JSX.Element[] = []
if (tab === 'tasks' && p) {
const open = expandedFor(t.key, p)
extra.push(
<Button
key="expand-all"
label={open.size === p.tasks.length ? 'свернуть' : 'раскрыть'}
hotkey="e"
plain
dimColor
onPress={() => {
if (open.size === p.tasks.length) open.clear()
else for (const task of p.tasks) open.add(task.n)
redraw()
}}
/>,
<Button
key="toggle-done"
label={hideDone ? 'показать ✓' : 'скрыть ✓'}
hotkey="h"
plain
dimColor
onPress={() => {
hideDone = !hideDone
redraw()
}}
/>,
)
}
return (
<Box flexDirection="column">
{head}
{tabBar}
<Text> </Text>
{/* Содержимое вкладки — с отступом от краёв */}
<Box flexDirection="column" paddingX={2}>
{body}
</Box>
<Text> </Text>
{/* Все клавиши — одной приглушённой строкой внизу */}
<Box key="keys" flexDirection="row" flexWrap="wrap" columnGap={2}>
<Text dimColor>{tab === 'docs' ? '↑↓ Enter: открыть' : '↑↓ Enter: раскрыть'}</Text>
{branchButtons}
{extra}
{refreshButton}
</Box>
</Box>
)
})
}

View File

@ -0,0 +1,673 @@
import { describe, expect, test } from 'claude-code/testing'
import type { On } from 'claude-code'
import {
bar,
buildProgress,
changedPaths,
defaultTab,
issueOfBranch,
manualTasks,
parseDecisions,
parseFindings,
parseLedger,
parsePlan,
parseWorktrees,
stageOf,
statusNote,
stepText,
worktreeOf,
wrapFocus,
} from '../hooks/parse.ts'
const PLAN = `# OpenAI Transcriber Implementation Plan
**Spec:** \`openspec/changes/openai-transcriber/\` — proposal.md. Issue: #5. Ветка: \`feat/5-openai-transcriber\`.
## Global Constraints
- [ ] not a step: before any task
### Task 1: HTTP-порт и сборка multipart
- [x] **Step 1: Порт**
- [x] **Step 2: Фейк**
\`\`\`ts
// - [ ] a checkbox inside code is not a step
\`\`\`
### Task 2: Настройки
- [x] **Step 1: Тест**
- [ ] **Step 2: Реализация**
- [ ] nested sub-step
### Task 3: Ручная проверка
- [ ] **Step 1: Mac**
## Self-Review
- [ ] not a step either
`
const LEDGER = `# SDD ledger — plan: docs/superpowers/plans/2026-10-01-openai.md
Task 1: complete (commits a1b2c3d..d4e5f6a, review clean)
Task 2: minor (deferred): rename helper
Task 2: fix round 1/5 (1 addressed, 1 open — timeout leak; commits d4e5f6a..b7c8d9e)
Task 2: Ruling: keep plan text — spec wins
- Task 3: parked — flaky check — Ruling: env issue
`
describe('plan', () => {
test('tasks, steps, spec and issue; code fences and other sections ignored', async () => {
const plan = parsePlan('p.md', PLAN)
expect(plan.title).toBe('OpenAI Transcriber Implementation Plan')
expect(plan.spec).toBe('openspec/changes/openai-transcriber/')
expect(plan.issue).toBe(5)
expect(plan.tasks.map(t => [t.n, t.title, t.stepsDone, t.stepsTotal])).toEqual([
[1, 'HTTP-порт и сборка multipart', 2, 2],
[2, 'Настройки', 1, 3],
[3, 'Ручная проверка', 0, 1],
])
})
test('task titles drop backticks', async () => {
const plan = parsePlan('p.md', '### Task 3: `RecordingSession` — пороги\n- [ ] s\n')
expect(plan.tasks[0]?.title).toBe('RecordingSession — пороги')
})
test('step texts are short and readable', async () => {
const plan = parsePlan('p.md', PLAN)
expect(plan.tasks[1]?.steps).toEqual([
{ text: '1. Тест', done: true, depth: 0 },
{ text: '2. Реализация', done: false, depth: 0 },
{ text: 'nested sub-step', done: false, depth: 1 },
])
expect(stepText('**Step 1: Добавить порты в конец `src/core/ports.ts`:**')).toBe(
'1. Добавить порты в конец src/core/ports.ts',
)
expect(stepText('**Step 2: Добавить фейки в `tests/fakes.ts`.** В импорт типов добавить')).toBe(
'2. Добавить фейки в tests/fakes.ts',
)
expect(stepText('Записать результаты в описание PR')).toBe('Записать результаты в описание PR')
})
test('anyTicked is false for an untouched plan', async () => {
expect(buildProgress(parsePlan('p.md', PLAN)).anyTicked).toBe(true)
expect(buildProgress(parsePlan('p.md', PLAN.replace(/- \[x\]/g, '- [ ]'))).anyTicked).toBe(false)
})
test('without a ledger the ticked steps decide', async () => {
const p = buildProgress(parsePlan('p.md', PLAN))
expect(p.source).toBe('steps')
expect(p.tasks.map(t => t.status)).toEqual(['done', 'active', 'todo'])
expect(p.done).toBe(1)
expect(p.next?.n).toBe(2)
})
})
describe('ledger', () => {
test('completion, fix rounds, minors, rulings, parked', async () => {
const l = parseLedger(LEDGER)
expect(l.plan).toBe('docs/superpowers/plans/2026-10-01-openai.md')
expect(l.tasks.get(1)?.complete).toBe(true)
expect(l.tasks.get(2)?.complete).toBe(false)
expect(l.deferred).toBe(1)
expect(l.rulings).toBe(2)
expect(l.parked).toBe(1)
})
test('with a ledger SDD decides, whatever the steps say', async () => {
const plan = parsePlan('p.md', PLAN.replace(/- \[ \]/g, '- [x]'))
const p = buildProgress(plan, parseLedger(LEDGER))
expect(p.source).toBe('ledger')
expect(p.tasks.map(t => t.status)).toEqual(['done', 'active', 'active'])
expect(p.tasks[1]?.note).toBe('fix round 1/5 (1 addressed, 1 open)')
expect(p.next?.n).toBe(2)
})
test('a task with only a deferred minor has not started', async () => {
const plan = parsePlan('p.md', '### Task 1: A\n- [ ] s\n### Task 2: B\n- [ ] s\n')
const p = buildProgress(plan, parseLedger('# SDD ledger — plan: p.md\nTask 2: minor (deferred): x\n'))
expect(p.tasks.map(t => t.status)).toEqual(['todo', 'todo'])
expect(p.next?.n).toBe(1)
})
})
describe('focus', () => {
test('leaving the list wraps around instead of reaching buttons', async () => {
const rows = ['task-1', 'step-1-0', 'step-1-1', 'task-2']
expect(wrapFocus(rows, 'task-1')).toBe('task-2')
expect(wrapFocus(rows, 'task-2')).toBe('task-1')
expect(wrapFocus(rows, undefined)).toBe('task-1')
expect(wrapFocus(rows, 'step-9-9')).toBe('task-1')
expect(wrapFocus(['task-1'], 'task-1')).toBe('task-1')
expect(wrapFocus([], 'task-1')).toBeUndefined()
})
})
describe('git', () => {
test('worktrees, issue from branch, changed plans, own worktree', async () => {
const wts = parseWorktrees(
'worktree /r\nHEAD 1\nbranch refs/heads/main\n\nworktree /r/.claude/worktrees/x\nHEAD 2\nbranch refs/heads/feat/7-wrapper\n\nworktree /r/d\nHEAD 3\ndetached\n',
)
expect(wts).toEqual([
{ path: '/r', branch: 'main' },
{ path: '/r/.claude/worktrees/x', branch: 'feat/7-wrapper' },
{ path: '/r/d' },
])
expect(issueOfBranch('feat/7-wrapper')).toBe(7)
expect(issueOfBranch('main')).toBeUndefined()
expect(worktreeOf(wts, '/r/.claude/worktrees/x/src')?.branch).toBe('feat/7-wrapper')
expect(worktreeOf(wts, '/r/src')?.branch).toBe('main')
expect(changedPaths('docs/a.md\n', '?? docs/b.md\n M docs/a.md\n D docs/c.md\n')).toEqual([
'docs/a.md',
'docs/b.md',
])
expect(bar(1, 4, 8)).toBe('██░░░░░░')
})
})
const ROOT = '/repo'
const WT = '/repo/.claude/worktrees/feat-5'
const PLAN_REL = 'docs/superpowers/plans/2026-10-01-openai.md'
const FILES: Record<string, string> = {
[`${WT}/.superpowers/sdd/2026-10-01-openai/progress.md`]: LEDGER,
[`${WT}/.superpowers/sdd/2026-10-01-openai/plan-path`]: PLAN_REL + '\n',
[`${WT}/${PLAN_REL}`]: PLAN,
}
const DIRS: Record<string, string[]> = {
[`${WT}/.superpowers/sdd`]: ['2026-10-01-openai'],
[`${WT}/openspec/changes`]: ['archive', 'openai-transcriber'],
[`${ROOT}/openspec/changes`]: ['archive'],
[`${ROOT}/.claude/worktrees/feat-7/openspec/changes`]: ['archive'],
}
const PANE = {
plugin: 'feature',
component: 'Pane',
requestId: 'feature',
viewport: { columns: 120, rows: 40 },
props: {
title: 'План',
isFocused: true,
bodyColumns: 80,
placement: 'inline',
scroll: { offset: 0, bodyRows: 30 },
view: {},
},
} as const
const RUN = { origin: { kind: 'composer' }, presentation: { isFullscreen: false, columns: 120 } } as const
function mockRepo(on: On) {
on('session.start', () => ({ cwd: WT }))
on('session.root', () => ({ value: WT }))
on('command.register', ($, e) => ({ value: { command: e.name } }))
on('process.run', ($, e) => {
const args = e.argv.slice(1).join(' ')
const cwd = e.init?.cwd
const result = (exitCode: number, stdout: string) => ({
value: { exitCode, stdout, stderr: '', isStdoutTruncated: false, isStderrTruncated: false },
})
const out = (stdout: string) => result(0, stdout)
if (args === 'worktree list --porcelain') {
return out(
`worktree ${ROOT}\nbranch refs/heads/main\n\nworktree ${WT}\nbranch refs/heads/feat/5-openai\n\nworktree ${ROOT}/.claude/worktrees/feat-7\nbranch refs/heads/feat/7-wrapper\n`,
)
}
if (args.startsWith('rev-parse')) return args.endsWith('refs/heads/main') ? out('abc\n') : result(1, '')
if (args.startsWith('log') && cwd?.endsWith('feat-7')) return out('b15ae97 fix: crlf\n543af13 fix: settings\n')
return out('')
})
on('fs.list', ($, e) => ({
value: (DIRS[e.path] ?? []).map(name => ({ name, kind: 'dir' as const, size: 0, mtimeMs: 0, isLink: false })),
}))
on('fs.exists', ($, e) => ({ value: e.path in FILES || e.path in DIRS || e.path.endsWith('feat-7/.bounded') }))
on('fs.read', ($, e) => (e.path in FILES ? { value: FILES[e.path] as string } : { deny: 'ENOENT' }))
on('fs.stat', () => ({ value: { kind: 'file' as const, size: 1, mtimeMs: 5, isLink: false } }))
on('ui.open', () => ({ value: { isPlaced: true } }))
}
test('/feature opens the SDD plan of the session worktree, then a bounded branch', async ($, on) => {
mockRepo(on)
await $.session.start({ surface: 'terminal', isInteractive: true, cwd: WT })
expect(await $.command.run({ command: 'feature', args: '', ...RUN })).toEqual({})
for (const surface of ['terminal', 'desktop'] as const) {
const ui = await $.ui.mount({ ...PANE, surface })
expect(await ui.find({ type: 'Text', text: /#5 · openspec: openai-transcriber · по SDD-ledger/ })).toBeDefined()
expect(await ui.find({ type: 'Text', text: '1/3' })).toBeDefined()
expect(await ui.find({ type: 'Text', text: /Дальше: Task 2 — Настройки/ })).toBeDefined()
expect(await ui.find({ type: 'Text', text: 'Этап: исполнение: Task 2' })).toBeDefined()
expect(await ui.find({ type: 'Text', text: 'работает агент' })).toBeDefined()
expect(await ui.find({ type: 'Text', text: /fix round 1\/5/ })).toBeDefined()
// The next task is open by default; a task row toggles its steps
expect(await ui.find({ type: 'Text', text: '1. Тест' })).toBeDefined()
expect(await ui.find({ type: 'Text', text: /✓ $/ })).toBeDefined()
expect(await ui.find({ type: 'Text', text: '2. Реализация' })).toBeDefined()
await ui.press({ key: 'task-2' })
expect(await ui.find({ type: 'Text', text: '2. Реализация' })).toBeUndefined()
await ui.press({ key: 'task-1' })
// Task 1 is complete in the ledger, so its steps read as done though the plan has no ticks there
expect(await ui.find({ type: 'Text', text: '2. Фейк' })).toBeDefined()
await ui.press({ key: 'task-1' })
await ui.press({ key: 'task-2' })
// h hides finished tasks
await ui.press({ key: 'toggle-done' })
expect(await ui.find({ type: 'Text', text: /HTTP-порт/ })).toBeUndefined()
expect(await ui.find({ type: 'Text', text: /скрыто готовых: 1/ })).toBeDefined()
await ui.press({ key: 'toggle-done' })
// The bounded branch: no plan, its commits instead
// The top line is a carousel; 9 / 0 at the bottom step through every branch, wrapping around
expect((await ui.findAll({ type: 'Text', text: /^[●○]$/ })).map(d => d.text).join(' ')).toBe('● ○')
expect((await ui.find({ key: 'prev-branch' }))?.props.hotkey).toBe('9')
expect((await ui.find({ key: 'next-branch' }))?.props.hotkey).toBe('0')
await ui.press({ key: 'next-branch' })
expect((await ui.findAll({ type: 'Text', text: /^[●○]$/ })).map(d => d.text).join(' ')).toBe('○ ●')
expect(await ui.find({ type: 'Text', text: /Этап: bounded/ })).toBeDefined()
expect(await ui.find({ type: 'Text', text: /b15ae97 fix: crlf/ })).toBeDefined()
expect(await ui.find({ type: 'Text', text: /#7 · bounded/ })).toBeDefined()
await ui.press({ key: 'next-branch' })
expect(await ui.find({ type: 'Text', text: /HTTP-порт/ })).toBeDefined()
await ui.press({ key: 'prev-branch' })
expect(await ui.find({ type: 'Text', text: /Этап: bounded/ })).toBeDefined()
expect(await ui.find({ type: 'Text', text: /↑↓ Enter/ })).toBeDefined()
await ui.press({ key: 'prev-branch' })
expect((await ui.findAll({ type: 'Text', text: /^[●○]$/ })).map(d => d.text).join(' ')).toBe('● ○')
await ui.unmount()
}
})
test('/feature 7 picks the branch by issue number', async ($, on) => {
mockRepo(on)
await $.session.start({ surface: 'terminal', isInteractive: true, cwd: WT })
await $.command.run({ command: 'feature', args: '#7', ...RUN })
const ui = await $.ui.mount({ ...PANE, surface: 'terminal' })
expect(await ui.find({ type: 'Text', text: /Этап: bounded/ })).toBeDefined()
await ui.unmount()
})
test('falls back to /progress when /feature is taken', async ($, on) => {
const registered: string[] = []
on('session.start', () => ({ cwd: ROOT }))
on('command.register', ($, e) => {
if (e.name === 'feature') return { deny: '"/feature" refused: taken' }
registered.push(e.name)
return { value: { command: e.name } }
})
on('command.run', () => ({ text: 'someone else' }))
await $.session.start({ surface: 'terminal', isInteractive: true, cwd: ROOT })
expect(registered).toEqual(['progress'])
expect(await $.command.run({ command: 'feature', args: '', ...RUN })).toEqual({ text: 'someone else' })
})
test('a plan SDD has not started shows no step counters and opens steps on demand', async ($, on) => {
const WT2 = '/repo/.claude/worktrees/feat-11'
const REL = 'docs/superpowers/plans/2026-10-15-wrapper.md'
const fresh = PLAN.replace(/- \[x\]/g, '- [ ]')
on('session.start', () => ({ cwd: WT2 }))
on('session.root', () => ({ value: WT2 }))
on('command.register', ($, e) => ({ value: { command: e.name } }))
on('process.run', ($, e) => {
const args = e.argv.slice(1).join(' ')
const out = (stdout: string) => ({
value: { exitCode: 0, stdout, stderr: '', isStdoutTruncated: false, isStderrTruncated: false },
})
if (args === 'worktree list --porcelain') return out(`worktree ${WT2}\nbranch refs/heads/feat/11-wrapper\n`)
if (args.startsWith('rev-parse')) return out('abc\n')
if (args.startsWith('diff')) return out(REL + '\n')
return out('')
})
on('fs.list', () => ({ value: [] }))
on('fs.exists', () => ({ value: false }))
on('fs.read', ($, e) => (e.path === `${WT2}/${REL}` ? { value: fresh } : { deny: 'ENOENT' }))
on('fs.stat', () => ({ value: { kind: 'file' as const, size: 1, mtimeMs: 5, isLink: false } }))
on('ui.open', () => ({ value: { isPlaced: true } }))
await $.session.start({ surface: 'terminal', isInteractive: true, cwd: WT2 })
await $.command.run({ command: 'feature', args: '', ...RUN })
const ui = await $.ui.mount({ ...PANE, surface: 'terminal' })
expect(await ui.find({ type: 'Text', text: /#5 · SDD не запущен/ })).toBeDefined()
// До старта исполнения открыта вкладка документов, план в ней; задачи — на вкладке работы
expect(await ui.find({ type: 'Text', text: 'Этап: ревью плана и выбор режима исполнения' })).toBeDefined()
expect(await ui.find({ type: 'Text', text: 'План — OpenAI Transcriber Implementation Plan' })).toBeDefined()
// Путей в списке документов нет
expect(await ui.find({ type: 'Text', text: REL })).toBeUndefined()
await ui.press({ key: 'tab-tasks' })
expect(await ui.find({ type: 'Text', text: /0\/2/ })).toBeUndefined()
// Prefix and title are separate columns: the title wraps under itself, not under the prefix
expect(await ui.find({ type: 'Text', text: '+ ○ 3. ' })).toBeDefined()
// The next task is marked ●, open (−) and holds the focus ring first
expect(await ui.find({ type: 'Text', text: '− ● 1. ' })).toBeDefined()
expect(await ui.find({ type: 'Text', text: 'Ручная проверка' })).toBeDefined()
// Task 1 is next, so it is open
expect(await ui.find({ type: 'Text', text: '1. Порт' })).toBeDefined()
// A step is a focus stop too; Enter on it folds its task
await ui.press({ key: 'step-1-0' })
expect(await ui.find({ type: 'Text', text: '1. Порт' })).toBeUndefined()
expect(await ui.find({ type: 'Text', text: '+ ● 1. ' })).toBeDefined()
// e opens every task
await ui.press({ key: 'expand-all' })
expect(await ui.find({ type: 'Text', text: '1. Mac' })).toBeDefined()
await ui.press({ key: 'expand-all' })
expect(await ui.find({ type: 'Text', text: '1. Порт' })).toBeUndefined()
await ui.unmount()
})
// Строки из настоящего ledger фичи #10, сокращены
const LEDGER_10 = `# SDD ledger — plan: docs/superpowers/plans/2026-10-15-obsidian-wrapper.md
Scope: Tasks 1-6; Task 7 is manual (human). Base: 60fed47.
O-1: Task 1 implementer used a Haiku trailer; amended HEAD (fixed, now a1317e3).
Task 1: minor (deferred): T1.1 no test for start() after stop() (restart)
Task 1: complete (commits 60fed47..a1317e3, review clean)
Task 5: review: spec compliant; 2 Important: T5.I-1 'error' event drops tail chunk; T5.I-2 mic leak on start failure
Task 5: Ruling: R-1 (design #15) — fix both plan-mandated defects
Task 5: minor (deferred): T5.2 handlers.end() throw; T5.3 AudioContext suspended on iOS; T5.4 track mute unhandled
Task 5: fix round 1/5 dispatched (T5.I-1, T5.I-2, T5.1), resume implementer
Task 5: fix round 1/5 (3 addressed, 0 open — T5.I-1, T5.I-2, T5.1; commits f8b0df3..7f03ea5)
Final review (opus): no Critical. I-1 Task 7 checklist lacks level bar. I-2 stale metadataCache
Final: Ruling: R-3 (design #17) — fix I-2: poke on metadataCache 'changed'
Final: fix wave fixed I-1, I-2, T5.3 (06aa7bb); scoped re-review: all addressed
Final: M-1 deferred; M-2 deferred; T2.1 deferred (follow-up)
`
const DESIGN = `# Design
## Decisions
### Порты
1. not a decision — another section
## Decisions made without discussion
Формат: дата — решение. *Обоснование.* **Цена ошибки.**
1. 2026-10-15 — Окно записи с таймером и \`Стоп\`. (Принято человеком.)
2. 2026-10-15 — Очередь раз в 30 секунд. *Повторы стоят 30 с.* **Батарея.**
17. 2026-10-15 (исполнение) — проход очереди по \`metadataCache.on('changed')\`. *list() читает кэш.*
## Risks
3. 2026-10-15 — not here either
`
describe('feature', () => {
test('ledger: rulings by design number, manual tasks, final review', async () => {
const l = parseLedger(LEDGER_10)
expect([...l.rulingOf]).toEqual([[15, 'R-1'], [17, 'R-3']])
expect(l.manual).toEqual([7])
expect(l.finalReview).toBe(true)
expect(parseLedger(LEDGER).finalReview).toBe(false)
})
test('provisional findings: last explicit status per marker wins', async () => {
const f = parseFindings(LEDGER_10)
expect(f.final).toBe(false)
const status = Object.fromEntries(f.items.map(i => [i.marker, i.status]))
expect(status).toEqual({
'O-1': 'fixed',
'T1.1': 'deferred',
'T5.I-1': 'fixed',
'T5.I-2': 'fixed',
'T5.2': 'deferred',
'T5.3': 'fixed',
'T5.4': 'deferred',
'T5.1': 'fixed',
'I-1': 'fixed',
'I-2': 'fixed',
'M-1': 'deferred',
'M-2': 'deferred',
'T2.1': 'deferred',
})
expect(f.items.find(i => i.marker === 'T1.1')?.text).toBe('no test for start() after stop() (restart)')
})
test('the controller table, when present, is the final word', async () => {
const md =
LEDGER_10 +
`
## Findings
| Маркер | Суть | Статус |
|---|---|---|
| T1.1 | нет теста на \`start()\` после stop() | deferred |
| T1.2 | FakeClock.jump не используется | rejected: нужен тестам Task 2 |
| T5.3 | AudioContext suspended | fixed 06aa7bb (resume()) |
`
const f = parseFindings(md)
expect(f.final).toBe(true)
expect(f.items.map(i => [i.marker, i.status])).toEqual([
['T1.1', 'deferred'],
['T1.2', 'rejected'],
['T5.3', 'fixed'],
])
expect(f.items[0]?.text).toBe('нет теста на start() после stop()')
expect(f.items[1]?.detail).toBe('rejected: нужен тестам Task 2')
expect(statusNote('deferred (решит Task 7, шаг 3)')).toBe('решит Task 7, шаг 3')
expect(statusNote('rejected: нужен тестам Task 2')).toBe('нужен тестам Task 2')
expect(statusNote('deferred')).toBe('')
expect(statusNote('')).toBe('')
// Колонки по заголовкам, в любом порядке
const swapped = parseFindings('## Findings\n| Status | Marker | Text |\n|-|-|-|\n| deferred | M-1 | jitter |\n')
expect(swapped.items).toEqual([{ marker: 'M-1', text: 'jitter', status: 'deferred', detail: 'deferred' }])
})
test('decisions: only the section, text without rationale, human-accepted flagged', async () => {
const d = parseDecisions(DESIGN)
expect(d.map(x => x.n)).toEqual([1, 2, 17])
expect(d[0]).toEqual({
n: 1,
date: '2026-10-15',
stage: undefined,
text: 'Окно записи с таймером и Стоп.',
why: '',
cost: '',
human: true,
})
expect([d[1]?.why, d[1]?.cost]).toEqual(['Повторы стоят 30 с.', 'Батарея.'])
// Второй вид записи: подписи «Обоснование:» и «Цена ошибки:» внутри разметки; пометка о человеке отрезана
const labelled = parseDecisions(
'## Decisions made without discussion\n15. 2026-10-15 (план) — Ждать stop. *Обоснование:* спека (T5.I-1). **Цена ошибки:** запоздает. (Подтверждено человеком.)\n',
)
expect(labelled[0]).toMatchObject({ text: 'Ждать stop.', why: 'спека (T5.I-1).', cost: 'запоздает.', human: true })
expect(d[1]?.text).toBe('Очередь раз в 30 секунд.')
expect(d[1]?.human).toBe(false)
expect(d[2]?.stage).toBe('исполнение')
expect(d[2]?.text).toBe("проход очереди по metadataCache.on('changed').")
})
test('stage and whose move it is', async () => {
const plan = parsePlan('p.md', '### Task 1: A\n- [ ] s\n### Task 2: Ручная проверка\n- [ ] s\n')
const base = { change: true, bounded: false, manual: [2], pushed: false, commits: 3 }
expect(stageOf({ ...base, change: false })).toEqual({ label: 'brainstorming', who: 'you' })
expect(stageOf(base)).toEqual({ label: 'ревью спеки', who: 'you' })
expect(stageOf({ ...base, planPath: 'p.md', progress: buildProgress(plan) }).label).toBe(
'ревью плана и выбор режима исполнения',
)
const running = parseLedger('# SDD ledger — plan: p.md\nTask 1: fix round 1/5\n')
expect(stageOf({ ...base, planPath: 'p.md', ledger: running, progress: buildProgress(plan, running) })).toEqual({
label: 'исполнение: Task 1',
who: 'agent',
})
const reviewing = parseLedger('# SDD ledger — plan: p.md\nTask 1: complete\n')
expect(stageOf({ ...base, planPath: 'p.md', ledger: reviewing, progress: buildProgress(plan, reviewing) }).label).toBe(
'финальное ревью ветки',
)
const done = parseLedger('# SDD ledger — plan: p.md\nTask 1: complete\nFinal: clean\n')
const finished = { ...base, planPath: 'p.md', ledger: done, progress: buildProgress(plan, done) }
expect(stageOf(finished)).toEqual({ label: 'ручная проверка (Task 2)', who: 'you' })
expect(stageOf({ ...finished, pushed: true }).who).toBe('you')
expect(stageOf({ ...finished, pushed: true }).label).toMatch(/PR/)
expect(manualTasks(plan, undefined)).toEqual([2])
expect(stageOf({ ...base, bounded: true }).who).toBe('agent')
})
test('default tab follows the stage', async () => {
const plan = parsePlan('p.md', '### Task 1: A\n- [ ] s\n')
const ledger = parseLedger('# SDD ledger — plan: p.md\nTask 1: fix round 1/5\n')
const base = { docs: 3, rulings: 2, boundedNoPlan: false, pushed: false }
// Ревью спеки и плана — документы
expect(defaultTab(base)).toBe('docs')
expect(defaultTab({ ...base, progress: buildProgress(plan) })).toBe('docs')
// Исполнение и ручная проверка — задачи; bounded без плана и без документов — тоже
expect(defaultTab({ ...base, progress: buildProgress(plan, ledger), ledger })).toBe('tasks')
expect(defaultTab({ ...base, boundedNoPlan: true })).toBe('tasks')
expect(defaultTab({ ...base, docs: 0 })).toBe('tasks')
// Ветка на сервере — рулинги, если они есть
expect(defaultTab({ ...base, progress: buildProgress(plan, ledger), ledger, pushed: true })).toBe('rulings')
expect(defaultTab({ ...base, progress: buildProgress(plan, ledger), ledger, pushed: true, rulings: 0 })).toBe('tasks')
})
})
test('after the run: stage is yours, rulings and deferred unfold, Enter opens the document beside the session', async ($, on) => {
const W = '/repo/.claude/worktrees/feat-10'
const REL = 'docs/superpowers/plans/2026-10-15-wrapper.md'
const SDD = `${W}/.superpowers/sdd/2026-10-15-wrapper`
const DESIGN_PATH = `${W}/openspec/changes/obsidian-wrapper/design.md`
const ledger = `# SDD ledger — plan: ${REL}
Task 1: minor (deferred): T1.1 no restart test
Task 1: complete
Task 2: Ruling: R-1 (design #2) — keep the interval
Task 2: review: T2.I-1 tail chunk lost
Task 2: complete
Final: no Critical; M-1 deferred
`
const files: Record<string, string> = {
[`${SDD}/progress.md`]: ledger,
[`${W}/${REL}`]: PLAN,
[DESIGN_PATH]: DESIGN,
[`${W}/openspec/changes/obsidian-wrapper/proposal.md`]: '# P',
[`${W}/openspec/changes/obsidian-wrapper/specs/recording-lifecycle/spec.md`]: '# S',
}
const dirsOf: Record<string, string[]> = {
[`${W}/.superpowers/sdd`]: ['2026-10-15-wrapper'],
[`${W}/openspec/changes`]: ['obsidian-wrapper'],
[`${W}/openspec/changes/obsidian-wrapper/specs`]: ['recording-lifecycle'],
}
const runs: string[][] = []
on('session.start', () => ({ cwd: W }))
on('session.root', () => ({ value: W }))
on('command.register', ($, e) => ({ value: { command: e.name } }))
on('env.get', ($, e) => ({ value: e.name === 'TERM_PROGRAM' ? 'iTerm.app' : undefined }))
on('process.run', ($, e) => {
runs.push([...e.argv])
const args = e.argv.slice(1).join(' ')
const out = (stdout: string, exitCode = 0) => ({
value: { exitCode, stdout, stderr: '', isStdoutTruncated: false, isStderrTruncated: false },
})
if (args === 'worktree list --porcelain') return out(`worktree ${W}\nbranch refs/heads/feat/10-wrapper\n`)
if (args.startsWith('rev-parse')) return out('abc\n')
// Ещё не запушена
if (args.startsWith('rev-list')) return out('', 128)
if (e.argv[0] === '/usr/bin/which') return out('/opt/homebrew/bin/glow\n')
return out('')
})
on('fs.list', ($, e) => ({
value: (dirsOf[e.path] ?? []).map(name => ({ name, kind: 'dir' as const, size: 0, mtimeMs: 0, isLink: false })),
}))
on('fs.exists', ($, e) => ({ value: e.path in files || e.path in dirsOf }))
on('fs.read', ($, e) => (e.path in files ? { value: files[e.path] as string } : { deny: 'ENOENT' }))
on('fs.stat', () => ({ value: { kind: 'file' as const, size: 1, mtimeMs: 5, isLink: false } }))
on('ui.open', () => ({ value: { isPlaced: true } }))
await $.session.start({ surface: 'terminal', isInteractive: true, cwd: W })
await $.command.run({ command: 'feature', args: '', ...RUN })
const ui = await $.ui.mount({ ...PANE, surface: 'terminal' })
// Task 3 «Ручная проверка» — человеку; остальное готово, финальное ревью было
expect(await ui.find({ type: 'Text', text: 'Этап: ручная проверка (Task 3)' })).toBeDefined()
expect(await ui.find({ type: 'Text', text: 'ход за тобой' })).toBeDefined()
// Вкладки по порядку этапов, открыта вкладка задач; на вкладках счётчики. Решение, принятое человеком (#1), не в счёт
expect(await ui.find({ type: 'Text', text: 'Все задачи выполнены' })).toBeUndefined()
expect(await ui.find({ type: 'Text', text: /Дальше: Task 3/ })).toBeDefined()
expect(await ui.find({ type: 'Button', key: 'tab-rulings', text: /^○ Рулинги 2$/ })).toBeDefined()
expect(await ui.find({ type: 'Button', key: 'tab-rulings', text: /^○ Рулинги 3$/ })).toBeUndefined()
expect(await ui.find({ type: 'Button', key: 'tab-deferred', text: /^○ Отложено 2 !1$/ })).toBeDefined()
expect(await ui.find({ type: 'Button', key: 'tab-tasks', text: /^● Задачи 2\/3$/ })).toBeDefined()
expect(await ui.find({ type: 'Text', text: 'Очередь раз в 30 секунд.' })).toBeUndefined()
await ui.press({ key: 'tab-rulings' })
expect(await ui.find({ type: 'Text', text: 'Очередь раз в 30 секунд.' })).toBeDefined()
expect(await ui.find({ type: 'Text', text: /^\+ R-1 +#2/ })).toBeDefined()
// Enter раскрывает обоснование и цену ошибки прямо в списке, документ не открывается
expect(await ui.find({ type: 'Text', text: 'Почему: Повторы стоят 30 с.' })).toBeUndefined()
await ui.press({ key: 'rul-2' })
expect(await ui.find({ type: 'Text', text: 'Почему: Повторы стоят 30 с.' })).toBeDefined()
expect(await ui.find({ type: 'Text', text: 'Цена ошибки: Батарея.' })).toBeDefined()
expect(await ui.find({ type: 'Text', text: /^− R-1 +#2/ })).toBeDefined()
await ui.press({ key: 'rul-2' })
expect(await ui.find({ type: 'Text', text: 'Цена ошибки: Батарея.' })).toBeUndefined()
expect(runs.some(r => r[0] === 'osascript')).toBe(false)
expect(await ui.find({ type: 'Text', text: /Окно записи/ })).toBeUndefined()
// Отложенное по маркерам, открытые находки — отдельно и первыми
await ui.press({ key: 'tab-deferred' })
expect(await ui.find({ type: 'Text', text: /предварительно, по строкам ledger/ })).toBeDefined()
expect(await ui.find({ type: 'Text', text: 'Очередь раз в 30 секунд.' })).toBeUndefined()
expect(await ui.find({ type: 'Text', text: 'no restart test' })).toBeDefined()
expect(await ui.find({ type: 'Text', text: 'tail chunk lost' })).toBeDefined()
expect(await ui.find({ type: 'Text', text: /M-1/ })).toBeDefined()
// Документы — отдельная вкладка: на этапе после исполнения она не открыта и не шумит
expect(await ui.find({ type: 'Text', text: /Спека recording-lifecycle/ })).toBeUndefined()
// Вкладка «Документы»: Enter открывает glow в соседней панели iTerm
await ui.press({ key: 'tab-docs' })
expect(await ui.find({ type: 'Text', text: 'no restart test' })).toBeUndefined()
expect(await ui.find({ type: 'Text', text: 'Спека recording-lifecycle — требования и сценарии' })).toBeDefined()
expect(await ui.find({ type: 'Text', text: /^Proposal — / })).toBeDefined()
expect(await ui.find({ type: 'Text', text: /openspec\/changes/ })).toBeUndefined()
await ui.press({ key: 'doc-' + DESIGN_PATH })
const osa = runs.find(r => r[0] === 'osascript')
expect(osa?.slice(-2)).toEqual(['/opt/homebrew/bin/glow -t', DESIGN_PATH])
expect(osa?.join(' ')).toMatch(/iTerm2.*split vertically/)
// Выбор вкладки запоминается
await ui.press({ key: 'tab-deferred' })
expect(await ui.find({ type: 'Text', text: 'no restart test' })).toBeDefined()
await ui.unmount()
})
test('before the run the documents tab is the default: spec review', async ($, on) => {
const W = '/repo/.claude/worktrees/feat-12'
const CH = `${W}/openspec/changes/new-thing`
const files: Record<string, string> = { [`${CH}/proposal.md`]: '# P', [`${CH}/design.md`]: '# D' }
on('session.start', () => ({ cwd: W }))
on('session.root', () => ({ value: W }))
on('command.register', ($, e) => ({ value: { command: e.name } }))
on('process.run', ($, e) => {
const args = e.argv.slice(1).join(' ')
const out = (stdout: string, exitCode = 0) => ({
value: { exitCode, stdout, stderr: '', isStdoutTruncated: false, isStderrTruncated: false },
})
if (args === 'worktree list --porcelain') return out(`worktree ${W}\nbranch refs/heads/feat/12-new-thing\n`)
if (args.startsWith('rev-parse')) return out('abc\n')
if (args.startsWith('rev-list')) return out('', 128)
return out('')
})
on('fs.list', ($, e) => ({
value: (e.path === `${W}/openspec/changes` ? ['new-thing'] : []).map(name => ({
name,
kind: 'dir' as const,
size: 0,
mtimeMs: 0,
isLink: false,
})),
}))
on('fs.exists', ($, e) => ({ value: e.path in files }))
on('fs.read', ($, e) => (e.path in files ? { value: files[e.path] as string } : { deny: 'ENOENT' }))
on('fs.stat', () => ({ value: { kind: 'file' as const, size: 1, mtimeMs: 5, isLink: false } }))
on('ui.open', () => ({ value: { isPlaced: true } }))
await $.session.start({ surface: 'terminal', isInteractive: true, cwd: W })
await $.command.run({ command: 'feature', args: '', ...RUN })
const ui = await $.ui.mount({ ...PANE, surface: 'terminal' })
expect(await ui.find({ type: 'Text', text: 'Этап: ревью спеки' })).toBeDefined()
expect(await ui.find({ type: 'Text', text: /^Proposal — / })).toBeDefined()
expect(await ui.find({ type: 'Text', text: /Коммитов поверх main нет/ })).toBeUndefined()
await ui.press({ key: 'tab-tasks' })
expect(await ui.find({ type: 'Text', text: /^Proposal — / })).toBeUndefined()
expect(await ui.find({ type: 'Text', text: /Коммитов поверх main нет/ })).toBeDefined()
await ui.unmount()
})

View File

@ -0,0 +1,7 @@
{
"extends": "./.claude-plugin/types/tsconfig.json",
"compilerOptions": {
// parse.ts is imported with its extension, as the engine loads it
"allowImportingTsExtensions": true
}
}