Andrey Epifancev 069c5643b1 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>
2026-10-15 11:37:42 +04:00

674 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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()
})