Andrey Epifancev 069c5643b1 feat(plugins): мод /feature — панель фичи, перенос в обвязку (#7)
Мод Claude Code жил во временной папке сессии obsidian-voice-note и в
новых сессиях не загружался. Перенесён как есть в plugins/feature/,
плагин переименован plan-progress → feature (команда /feature, запасные
/progress и /feature-pane). Поведение не менялось.

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

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

Refs #7

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:37:42 +04:00

464 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

// 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
}