// 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 deferred: number rulings: number parked: number // Номер решения в design.md → маркер рулинга: `R-3 (design #17)` rulingOf: Map // Задачи, которые контроллер отдал человеку: `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 = /(? 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() 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 }