Мод 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>
464 lines
19 KiB
TypeScript
464 lines
19 KiB
TypeScript
// 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
|
||
}
|