Мод 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>
674 lines
34 KiB
TypeScript
674 lines
34 KiB
TypeScript
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()
|
||
})
|