From 069c5643b19569bc47e036ec2de868a72ab1c01a Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 15 Oct 2026 11:37:42 +0400 Subject: [PATCH 1/3] =?UTF-8?q?feat(plugins):=20=D0=BC=D0=BE=D0=B4=20/feat?= =?UTF-8?q?ure=20=E2=80=94=20=D0=BF=D0=B0=D0=BD=D0=B5=D0=BB=D1=8C=20=D1=84?= =?UTF-8?q?=D0=B8=D1=87=D0=B8,=20=D0=BF=D0=B5=D1=80=D0=B5=D0=BD=D0=BE?= =?UTF-8?q?=D1=81=20=D0=B2=20=D0=BE=D0=B1=D0=B2=D1=8F=D0=B7=D0=BA=D1=83=20?= =?UTF-8?q?(#7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Мод 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 --- .gitignore | 2 + CLAUDE.md | 1 + README.md | 1 + plugins/feature/.claude-plugin/plugin.json | 5 + plugins/feature/README.md | 47 ++ plugins/feature/hooks/hooks.json | 1 + plugins/feature/hooks/parse.ts | 463 +++++++++++ plugins/feature/hooks/register.tsx | 893 +++++++++++++++++++++ plugins/feature/tests/feature.test.ts | 673 ++++++++++++++++ plugins/feature/tsconfig.json | 7 + 10 files changed, 2093 insertions(+) create mode 100644 plugins/feature/.claude-plugin/plugin.json create mode 100644 plugins/feature/README.md create mode 100644 plugins/feature/hooks/hooks.json create mode 100644 plugins/feature/hooks/parse.ts create mode 100644 plugins/feature/hooks/register.tsx create mode 100644 plugins/feature/tests/feature.test.ts create mode 100644 plugins/feature/tsconfig.json diff --git a/.gitignore b/.gitignore index 4b7e182..7147224 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ .superpowers/ .bounded .DS_Store +# Типы движка Claude Code: пишет сам движок при загрузке мода, у каждой версии свои +plugins/*/.claude-plugin/types/ diff --git a/CLAUDE.md b/CLAUDE.md index b93eee1..4da0a06 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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` проекта-пилота. diff --git a/README.md b/README.md index a405a70..bfbe1fe 100644 --- a/README.md +++ b/README.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 результаты пилота и находки ``` diff --git a/plugins/feature/.claude-plugin/plugin.json b/plugins/feature/.claude-plugin/plugin.json new file mode 100644 index 0000000..07343d5 --- /dev/null +++ b/plugins/feature/.claude-plugin/plugin.json @@ -0,0 +1,5 @@ +{ + "name": "feature", + "version": "0.1.0", + "description": "Фича по веткам: этап и чей ход, задачи плана, рулинги, отложенное, документы в glow" +} diff --git a/plugins/feature/README.md b/plugins/feature/README.md new file mode 100644 index 0000000..5663548 --- /dev/null +++ b/plugins/feature/README.md @@ -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//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//` | proposal, design, `specs/*/spec.md`; `## Decisions made without discussion` в design.md | +| `.bounded`, `feat/-…`, `@{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 /plugins/feature`. +- Логика разбора — чистые функции в `hooks/parse.ts` (тестируются напрямую), отрисовка и ввод-вывод — `hooks/register.tsx`. + +## Ограничения + +- Корень сессии должен быть git-репозиторием: workspace из нескольких репозиториев (`.wt/<фича>/<репо>`) пока не поддерживается. +- Фон панели в полноэкранном режиме рисует Claude Code ключом темы `composerSidebarBackground`; поменять его можно только своей темой (`~/.claude/themes/<имя>.json`). diff --git a/plugins/feature/hooks/hooks.json b/plugins/feature/hooks/hooks.json new file mode 100644 index 0000000..5a63f64 --- /dev/null +++ b/plugins/feature/hooks/hooks.json @@ -0,0 +1 @@ +{ "modules": ["./register.tsx"] } diff --git a/plugins/feature/hooks/parse.ts b/plugins/feature/hooks/parse.ts new file mode 100644 index 0000000..5f0a6c5 --- /dev/null +++ b/plugins/feature/hooks/parse.ts @@ -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 + 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 +} diff --git a/plugins/feature/hooks/register.tsx b/plugins/feature/hooks/register.tsx new file mode 100644 index 0000000..3e75772 --- /dev/null +++ b/plugins/feature/hooks/register.tsx @@ -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>() +// Выбранная вкладка по каждому Target; нет в карте — по этапу +const tabs = new Map() +// Раскрытые рулинги (номер решения) и находки (маркер) по каждому Target +const openRulings = new Map>() +const openFindings = new Map>() +let isOpen = false +let loaded = false +let error = '' +let loading: Promise | 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 { + 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 { + try { + return await $.fs.read(path) + } catch { + // Missing, or mid-write: skip it this round + return undefined + } +} + +async function mtime($: EngineInterface, path: string): Promise { + try { + return (await $.fs.stat(path)).mtimeMs + } catch { + return 0 + } +} + +async function dirs($: EngineInterface, path: string): Promise { + try { + return (await $.fs.list(path)).filter(e => e.kind === 'dir').map(e => e.name) + } catch { + return [] + } +} + +async function exists($: EngineInterface, path: string): Promise { + try { + return await $.fs.exists(path) + } catch { + return false + } +} + +async function viewer($: EngineInterface): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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(map: Map>, key: string): Set { + let set = map.get(key) + if (!set) { + set = new Set() + 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 = ( + , + { 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( + + {t.plan?.title || t.planPath} + , + + {bar(p.done, p.total, wide)} + {`${p.done}/${p.total}`} + {`задач · ${pct}%`} + , + + {p.next ? `Дальше: Task ${p.next.n} — ${p.next.title}` : 'Все задачи выполнены'} + , + , + ) + const shown = hideDone ? p.tasks.filter(x => x.status !== 'done') : p.tasks + const hidden = p.tasks.length - shown.length + if (hidden > 0) body.push({`✓ скрыто готовых: ${hidden}`}) + + 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, + , + style, + ), + ) + const indent = ' '.repeat(prefix.length) + if (task.note) { + body.push(hang('n' + task.n, indent, {task.note}, { 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 на шаге сворачивает задачу и возвращает на неё фокус + , + { dimColor: done }, + ), + ) + }) + }) + } + + // Без плана на вкладке задач — что уже в ветке + if (tab === 'tasks' && !p) { + if (t.planPath) body.push({'План не найден: ' + t.planPath}) + else empty(t.bounded ? 'Bounded-задача, плана нет' : 'Плана нет') + body.push( + + {t.commits.length + ? `Коммиты в ветке (${t.commits.length}${t.commits.length === 8 ? '+' : ''}):` + : 'Коммитов поверх main нет'} + , + ) + t.commits.forEach((c, i) => + body.push( + + {' ' + c} + , + ), + ) + } + + // ── 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 раскрывает обоснование и цену ошибки прямо в списке + , + { 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, {'Почему: ' + r.why})) + if (r.cost) body.push(hang('rc' + r.n, indent, {'Цена ошибки: ' + r.cost})) + }) + } + + // ── 4. Отложено: открытые находки первыми, затем deferred + if (tab === 'deferred') { + if (!f) empty('SDD-ledger нет: находок пока нет') + else if (!deferred.length && !openItems.length) empty('Отложенного нет') + else + body.push( + + {f.final ? 'по таблице ## Findings в ledger' : 'предварительно, по строкам ledger: таблицы ## Findings ещё нет'} + , + ) + 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 раскрывает пояснение к статусу, если оно есть + , + { color: isOpenItem ? 'warning' : undefined, bold: isOpenItem }, + ), + ) + if (isOpenNote) { + body.push(hang('fn' + item.marker, ' '.repeat(prefix.length), {note})) + } + }) + } + + rows = focusRows + + // Полоса вкладок: цифры 1–4 работают в любой раскладке; в подписи — счётчик вкладки + const counts: Record = { + 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 = { docs: 'Документы', tasks: 'Задачи', rulings: 'Рулинги', deferred: 'Отложено' } + const tabBar = ( + // Вкладки растянуты по ширине панели + + {TABS.map((id, i) => ( +