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:
parent
2c599a1324
commit
069c5643b1
2
.gitignore
vendored
2
.gitignore
vendored
@ -1,3 +1,5 @@
|
||||
.superpowers/
|
||||
.bounded
|
||||
.DS_Store
|
||||
# Типы движка Claude Code: пишет сам движок при загрузке мода, у каждой версии свои
|
||||
plugins/*/.claude-plugin/types/
|
||||
|
||||
@ -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` проекта-пилота.
|
||||
|
||||
@ -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 результаты пилота и находки
|
||||
```
|
||||
|
||||
5
plugins/feature/.claude-plugin/plugin.json
Normal file
5
plugins/feature/.claude-plugin/plugin.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "feature",
|
||||
"version": "0.1.0",
|
||||
"description": "Фича по веткам: этап и чей ход, задачи плана, рулинги, отложенное, документы в glow"
|
||||
}
|
||||
47
plugins/feature/README.md
Normal file
47
plugins/feature/README.md
Normal 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`).
|
||||
1
plugins/feature/hooks/hooks.json
Normal file
1
plugins/feature/hooks/hooks.json
Normal file
@ -0,0 +1 @@
|
||||
{ "modules": ["./register.tsx"] }
|
||||
463
plugins/feature/hooks/parse.ts
Normal file
463
plugins/feature/hooks/parse.ts
Normal 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
|
||||
}
|
||||
893
plugins/feature/hooks/register.tsx
Normal file
893
plugins/feature/hooks/register.tsx
Normal 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>
|
||||
)
|
||||
})
|
||||
}
|
||||
673
plugins/feature/tests/feature.test.ts
Normal file
673
plugins/feature/tests/feature.test.ts
Normal 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()
|
||||
})
|
||||
7
plugins/feature/tsconfig.json
Normal file
7
plugins/feature/tsconfig.json
Normal 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
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user