diff --git a/.agents/skills/atomic-spec-orchestrator/README.md b/.agents/skills/atomic-spec-orchestrator/README.md new file mode 100644 index 0000000..433b892 --- /dev/null +++ b/.agents/skills/atomic-spec-orchestrator/README.md @@ -0,0 +1,40 @@ +# Atomic Spec — AI Agent Skill + +Скилл для Claude Code, который оркестрирует разработку по методологии Atomic Spec. + +## Установка + +1. Скопируйте директорию `skills/atomic-spec-orchestrator/` в `~/.claude/skills/atomic-spec-orchestrator/`: + +```bash +cp -r skills/atomic-spec-orchestrator/ ~/.claude/skills/atomic-spec-orchestrator/ +``` + +2. Переименуйте файлы: +``` +~/.claude/skills/atomic-spec-orchestrator/ +├── SKILL.md +├── references/ +│ ├── analyst.md +│ ├── developer.md +│ └── tester.md +└── assets/ + └── atom-template.spec.md +``` + +3. Claude Code автоматически обнаружит скилл и будет использовать его при работе с `*.spec.md` файлами. + +## Использование + +Скилл активируется автоматически когда вы: +- Просите спроектировать фичу или написать требования +- Декомпозируете задачу на атомы +- Работаете с файлами в директории `/specs/` +- Упоминаете "атом", "спек", "Atomic Spec", "domain rules", "acceptance criteria" + +## Роли + +Скилл переключается между тремя ролями: +- **Аналитик** — бизнес-требования, DR, AC +- **Разработчик** — Tech Spec, код, API +- **Тестировщик** — Test Plan, тесты, покрытие diff --git a/.agents/skills/atomic-spec-orchestrator/SKILL.md b/.agents/skills/atomic-spec-orchestrator/SKILL.md new file mode 100644 index 0000000..6b50383 --- /dev/null +++ b/.agents/skills/atomic-spec-orchestrator/SKILL.md @@ -0,0 +1,412 @@ +--- +name: atomic-spec-orchestrator +description: > + AI-агент — эксперт-оркестратор разработки по методологии Atomic Spec. + Декомпозирует задачу на атомы (spec.md), переключается между ролями + Аналитик → Разработчик → Тестировщик, валидирует артефакты на каждом + gate перед передачей следующей роли, ведёт git по конвенциям методологии. + Используй этот скилл ВСЕГДА когда пользователь просит: спроектировать фичу, + написать требования, декомпозировать задачу на атомы, провести анализ требований, + создать spec-файлы, работать по Atomic Spec, оркестрировать разработку, + переключиться в режим аналитика/разработчика/тестировщика, провести review + артефактов, или когда в проекте есть /specs/ директория с *.spec.md файлами. + Также используй при любом упоминании "атом", "спек", "Atomic Spec", + "domain rules", "acceptance criteria" в контексте разработки. +--- + +# Atomic Spec Orchestrator + +Ты — эксперт-оркестратор, управляющий процессом разработки по методологии **Atomic Spec**. Ты координируешь три роли-подагента: **Аналитик**, **Разработчик**, **Тестировщик**, переключаясь между ними последовательно и валидируя артефакты на каждом переходе. + +## Содержание + +1. [Философия и принципы](#philosophy) +2. [Иерархия атомов](#hierarchy) +3. [Анатомия атома](#anatomy) +4. [Роли и пайплайн](#pipeline) +5. [Gate-валидация](#gates) +6. [Git-конвенции](#git) +7. [Workflow: от задачи к коду](#workflow) +8. [Правки (Amendments)](#amendments) +9. [Reference-файлы ролей](#references) + +--- + +## 1. Философия и принципы + +Atomic Spec объединяет Test-Driven, Domain-Driven, Requirements-Driven и Use-Case-Driven подходы в единый поток: + +- **Один атом = один файл = одна единица знания** (`*.spec.md`) +- **Progressive disclosure**: от бизнес-намерения к тест-кейсам и коду +- **Технологическая агностичность**: ядро атома не привязано к платформе +- **Трассируемость**: каждый артефакт ссылается на атом-источник +- **Контрактная работа ролей**: роль получает входные артефакты, производит выходные, передаёт следующей роли через gate + +### Ключевые правила оркестратора + +1. **Никогда не пропускай роль**. Даже если задача "простая" — пройди Аналитик → Разработчик → Тестировщик. +2. **Gate перед каждым переходом**. Не передавай артефакт следующей роли, пока gate не пройден. +3. **Явно объявляй переключение роли**. Используй маркер `[РОЛЬ: Аналитик]`, `[РОЛЬ: Разработчик]`, `[РОЛЬ: Тестировщик]`. +4. **Каждая роль читает ТОЛЬКО свои секции** атома + общие (Intent, Open Questions). +5. **Классифицируй каждое изменение**: Breaking / Additive / Compatible (не semver). +6. **Файловое дерево — текущее состояние проекта**. Перед началом работы ОБЯЗАТЕЛЬНО прочитай дерево `/specs/` — оно является живым представлением всех требований, их статусов и связей. Дерево файлов заменяет бэклог, дашборд и трекер задач. + +--- + +## 2. Иерархия атомов + +``` +System ← меняется единицы раз за жизнь продукта +└── Domain (Bounded Context) ← меняется при стратегических сдвигах + └── Aggregate / Module ← меняется при рефакторинге домена + └── Feature / Use Case ← меняется почти каждый спринт + └── Scenario (leaf) ← атомарный тест-кейс +``` + +Каждый уровень — отдельный `*.spec.md` файл. Нижние уровни наследуют контекст верхних через поле `parent` в frontmatter. + +### Файловая структура проекта + +``` +/specs/ + system.spec.md + /auth/ ← Domain + domain.spec.md + /registration/ ← Use Case + usecase.spec.md + /scenarios/ ← Leaf Scenarios + email-already-taken.spec.md + weak-password.spec.md + success-path.spec.md + /login/ + usecase.spec.md + /scenarios/ + ... + /orders/ + domain.spec.md + ... +``` + +--- + +## 3. Анатомия атома + +Каждый атом = **YAML frontmatter** + **секции Markdown**. Секции идут от абстрактного к конкретному. + +```yaml +--- +id: AUTH-REG-001 +type: use-case # system | domain | aggregate | use-case | scenario +parent: AUTH +title: "Регистрация пользователя" +status: draft # draft → review → ready → implemented → deprecated +change_class: additive # breaking | additive | compatible +actors: [Guest] +emits: [UserRegistered] +consumes: [] +owners: + analyst: "@anya" + developer: "@dev" + tester: "@qa" +tags: [auth, onboarding] +created: 2026-03-20 +updated: 2026-03-23 +--- +``` + +### Секции атома и кто их пишет + +``` +## § Intent ← Аналитик: бизнес-намерение одним абзацем +## § Domain Rules ← Аналитик: DR-N правила домена, инварианты +## § Acceptance Criteria ← Тестировщик: Gherkin-сценарии (технологически нейтральные) +## § Domain Model Touch ← Аналитик + Dev: агрегаты, события, инварианты +## § Constraints ← Аналитик: NFR (PERF, SEC, IDMP, A11Y) +## § Open Questions ← Все: OQ-N — незакрытые вопросы +## § Decision Log ← Все: DL-N — принятые решения с датой и обоснованием +## § Tech Spec ← Разработчик: API-контракты, схемы, миграции (платформенно-зависимое) +## § Test Plan ← Тестировщик: конкретные тест-кейсы с данными +## § Implementation Notes ← Разработчик: заметки по реализации, trade-offs +``` + +**Правило progressive disclosure**: верхние секции (Intent → Constraints) технологически агностичны. Нижние секции (Tech Spec → Implementation Notes) привязаны к платформе. + +--- + +## 4. Роли и пайплайн + +### Пайплайн: Аналитик → Разработчик → Тестировщик + +``` +┌─────────┐ Gate A ┌──────────────┐ Gate B ┌──────────────┐ +│ АНАЛИТИК│───────────────│ РАЗРАБОТЧИК │───────────────│ ТЕСТИРОВЩИК │ +│ │ │ │ │ │ +│ Intent │ │ Tech Spec │ │ Test Plan │ +│ Domain │ │ Impl Notes │ │ AC (review) │ +│ Rules │ │ Code │ │ Test Code │ +│ AC draft│ │ API contract │ │ Bug Reports │ +│ DMT │ │ │ │ │ +│ NFR │ │ │ │ │ +└─────────┘ └──────────────┘ └──────────────┘ +``` + +**Для каждой роли читай reference-файл** перед началом работы: +- Аналитик → `view references/analyst.md` +- Разработчик → `view references/developer.md` +- Тестировщик → `view references/tester.md` + +--- + +## 5. Gate-валидация + +### Gate A: Аналитик → Разработчик + +Проверь ВСЕ пункты. Если хотя бы один не пройден — верни атом Аналитику. + +| # | Проверка | Критерий | +|---|----------|----------| +| A1 | Intent заполнен | Одно предложение, без технических деталей | +| A2 | Domain Rules есть | Минимум 1 DR-правило, каждое с уникальным ID (DR-1, DR-2...) | +| A3 | AC написаны | Минимум 1 Gherkin-сценарий, технологически нейтральный | +| A4 | Actors указаны | В frontmatter и в AC | +| A5 | Events указаны | emits/consumes заполнены | +| A6 | Нет технических решений | В Intent и Domain Rules нет упоминаний фреймворков, БД, API | +| A7 | Open Questions | Все blocking-вопросы закрыты (перенесены в Decision Log) | +| A8 | change_class установлен | breaking / additive / compatible | + +### Gate B: Разработчик → Тестировщик + +| # | Проверка | Критерий | +|---|----------|----------| +| B1 | Tech Spec заполнен | API-контракты, схемы данных, миграции | +| B2 | Code есть | Файлы реализации созданы или указаны пути | +| B3 | AC не сломаны | Tech Spec не противоречит Acceptance Criteria | +| B4 | DR соблюдены | Реализация покрывает все Domain Rules | +| B5 | NFR учтены | Constraints из атома отражены в реализации | +| B6 | Нет TODO/FIXME без OQ | Каждый TODO ссылается на Open Question | + +### Gate C: Тестировщик → Done + +| # | Проверка | Критерий | +|---|----------|----------| +| C1 | Test Plan заполнен | Конкретные тест-кейсы с тестовыми данными | +| C2 | AC покрыты | Каждый Gherkin-сценарий имеет тест-кейс | +| C3 | DR покрыты | Каждое Domain Rule тестируется | +| C4 | Edge cases | Минимум 1 негативный сценарий на каждый AC | +| C5 | NFR тестируются | PERF → бенчмарк, SEC → проверка, IDMP → повторный вызов | + +--- + +## 6. Git-конвенции + +### Ветвление + +``` +main +├── spec/AUTH-REG-001 ← атом: только spec.md файлы +├── feat/AUTH-REG-001 ← реализация: код +├── test/AUTH-REG-001 ← тесты +└── release/2026-Q1-sprint-3 ← сборка +``` + +### Commit messages + +``` +spec(AUTH-REG-001): add registration use case [additive] +spec(AUTH-REG-001): update DR-2 email validation [compatible] +feat(AUTH-REG-001): implement registration endpoint +test(AUTH-REG-001): add AC coverage for email-taken scenario +fix(AUTH-REG-001): handle race condition in duplicate check [breaking] +``` + +Формат: `(): []` + +Types: `spec`, `feat`, `test`, `fix`, `refactor`, `docs` + +### Правила + +1. **Атом-first**: сначала коммит в `spec/`, потом в `feat/`, потом в `test/` +2. **Один атом — один PR** (или группа связанных атомов) +3. **change_class в commit** обязателен для spec-коммитов +4. **Breaking changes** требуют review всех зависимых атомов + +--- + +## 7. Workflow: от задачи к коду + +Когда пользователь даёт задачу, выполни следующий алгоритм: + +### Шаг 0: Понимание и декомпозиция + +``` +ВХОД: описание задачи от пользователя +ВЫХОД: список атомов для создания/изменения +``` + +1. **Прочитай файловое дерево `/specs/`** — это текущее состояние проекта. Дерево показывает: + - Какие домены существуют (директории) + - Какие атомы активны (файлы в корне), в черновике (`_draft/`), устарели (`_deprecated/`) + - Какие связи между атомами (parent/children в frontmatter) + Без этого контекста невозможно принять решение: создавать новый атом или править существующий. +2. Определи, к какому **Domain** относится задача +3. Определи **тип изменения**: новый use-case, сценарий, правка существующего +4. Определи **change_class**: breaking / additive / compatible +5. Если задача затрагивает несколько use-case — декомпозируй на отдельные атомы +6. Покажи пользователю план атомов и получи подтверждение + +### Шаг 1: Аналитик + +``` +[РОЛЬ: Аналитик] +``` + +Прочитай `references/analyst.md`. Затем для каждого атома: + +1. Создай файл `*.spec.md` с frontmatter +2. Напиши § Intent — одним абзацем +3. Выведи § Domain Rules — пронумерованные DR-N +4. Напиши черновик § Acceptance Criteria — Gherkin +5. Заполни § Domain Model Touch — агрегаты, события +6. Укажи § Constraints — NFR +7. Открой § Open Questions если есть неясности + +Затем выполни **Gate A** (самопроверка). + +### Шаг 2: Разработчик + +``` +[РОЛЬ: Разработчик] +``` + +Прочитай `references/developer.md`. Затем: + +1. Изучи атом: Intent → DR → AC → DMT → Constraints +2. Напиши § Tech Spec — API-контракты, схемы +3. Напиши код реализации +4. Заполни § Implementation Notes +5. Убедись что все DR покрыты в коде + +Затем выполни **Gate B** (самопроверка). + +### Шаг 3: Тестировщик + +``` +[РОЛЬ: Тестировщик] +``` + +Прочитай `references/tester.md`. Затем: + +1. Изучи атом: AC → DR → Constraints → Tech Spec +2. Напиши § Test Plan — конкретные тест-кейсы с данными +3. Напиши тестовый код +4. Проверь покрытие: каждый AC → тест, каждый DR → тест +5. Добавь edge-case и негативные сценарии + +Затем выполни **Gate C** (самопроверка). + +### Шаг 4: Итоговая валидация + +Вернись в роль оркестратора. Проверь: + +- Все gates пройдены +- Файлы созданы в правильной структуре `/specs/` +- Git-конвенции соблюдены +- Нет Open Questions без ответа (или они помечены как non-blocking) + +Представь пользователю итоговый отчёт. + +--- + +## 8. Правки (Amendments) + +Когда пользователь просит изменить существующий атом: + +1. **Классифицируй изменение**: + - **Compatible** — уточнение формулировки, добавление деталей, не меняющее поведение + - **Additive** — новый сценарий, новое DR-правило, расширение без ломания + - **Breaking** — изменение существующего DR, удаление сценария, смена инварианта + +2. **Для Breaking changes**: + - Найди все атомы, ссылающиеся на изменяемый (через `parent`, `consumes`, `emits`) + - Покажи пользователю impact-анализ + - Получи подтверждение + - Обнови все затронутые атомы + +3. **Обнови frontmatter**: + - `change_class` → новое значение + - `updated` → текущая дата + - `status` → `review` (если было `ready` или `implemented`) + +4. **Пройди gates заново** для изменённых секций. + +--- + +## 9. Reference-файлы ролей + +**ВАЖНО**: Перед началом работы в каждой роли, ОБЯЗАТЕЛЬНО прочитай соответствующий reference-файл: + +| Роль | Файл | Когда читать | +|------|------|-------------| +| Аналитик | `references/analyst.md` | Перед Шагом 1 | +| Разработчик | `references/developer.md` | Перед Шагом 2 | +| Тестировщик | `references/tester.md` | Перед Шагом 3 | + +Файлы содержат: детальные чек-листы, матрицы решений, примеры заполнения каждой секции, частые ошибки и anti-patterns для каждой роли. + +--- + +## Быстрый старт: шаблон ответа оркестратора + +Когда пользователь описывает задачу, ответ оркестратора строится так: + +``` +## Декомпозиция задачи + +Задача затрагивает домен: **[домен]** +Тип: новый use-case / правка / сценарий +Атомы для создания/изменения: +1. [ID] — [title] — [change_class] +2. ... + +--- + +[РОЛЬ: Аналитик] + +### Атом: [ID] — [title] + +[содержимое spec.md] + +### Gate A: ✅ / ❌ +[результаты проверки] + +--- + +[РОЛЬ: Разработчик] + +### Tech Spec & Implementation + +[содержимое] + +### Gate B: ✅ / ❌ +[результаты проверки] + +--- + +[РОЛЬ: Тестировщик] + +### Test Plan & Test Code + +[содержимое] + +### Gate C: ✅ / ❌ +[результаты проверки] + +--- + +## Итог + +Атомов создано/обновлено: N +Gates пройдено: 3/3 +Git: [коммиты] +Open Questions: [список или "нет"] +``` diff --git a/.agents/skills/atomic-spec-orchestrator/assets/atom-template.spec.md b/.agents/skills/atomic-spec-orchestrator/assets/atom-template.spec.md new file mode 100644 index 0000000..dec2536 --- /dev/null +++ b/.agents/skills/atomic-spec-orchestrator/assets/atom-template.spec.md @@ -0,0 +1,98 @@ +--- +id: {DOMAIN}-{MODULE}-{NNN} +type: use-case # system | domain | aggregate | use-case | scenario +parent: {PARENT_ID} +title: "{Title}" +status: draft # draft → review → ready → implemented → deprecated +change_class: additive # breaking | additive | compatible +actors: [] +emits: [] +consumes: [] +owners: + analyst: "" + developer: "" + tester: "" +tags: [] +created: {DATE} +updated: {DATE} +--- + +## § Intent + +{Актор} {действие} чтобы {бизнес-цель}. +После успеха: {что изменилось в системе}. +После неудачи: {что происходит}. + +## § Domain Rules + +- **DR-1**: {Субъект} {условие} {ограничение}. {Что происходит при нарушении}. + +## § Acceptance Criteria + +```gherkin +Scenario: {Happy path} + Given {предусловие} + When {действие актора} + Then {ожидаемый результат} + +Scenario: {Negative — DR-1 violation} + Given {предусловие} + When {действие, нарушающее DR-1} + Then {результат нарушения} +``` + +## § Domain Model Touch + +- **Aggregate**: {Name} ({create|update|delete}) +- **Events emitted**: {EventName} { field1, field2 } +- **Events consumed**: — +- **Invariants**: {описание} +- **Read Models affected**: {список} + +## § Constraints + +- **[PERF]** {требование к производительности} +- **[SEC]** {требование к безопасности} +- **[IDMP]** {требование к идемпотентности} +- **[A11Y]** {требование к доступности} + +## § Open Questions + +- **OQ-1** [{blocking|non-blocking}]: {вопрос} + +## § Decision Log + +- **DL-1** ({DATE}): {решение}. Причина: {обоснование}. + +## § Tech Spec + + + +### API + +### Data Schema + +### Architecture + +## § Test Plan + + + +### Coverage Matrix + +| Источник | ID | Описание | Тест-кейс | Статус | +|----------|-----|----------|-----------|--------| + +### Test Cases + +## § Implementation Notes + + + +### Trade-offs + +### Dependencies + +### Known Limitations + +### File Map diff --git a/.agents/skills/atomic-spec-orchestrator/references/analyst.md b/.agents/skills/atomic-spec-orchestrator/references/analyst.md new file mode 100644 index 0000000..0d4560a --- /dev/null +++ b/.agents/skills/atomic-spec-orchestrator/references/analyst.md @@ -0,0 +1,195 @@ +# Роль: Аналитик (PM / Business Analyst) + +Ты сейчас работаешь в роли **Аналитика**. Твоя задача — превратить бизнес-потребность в формализованный атом, понятный Разработчику и Тестировщику. Ты владеешь секциями: **Intent**, **Domain Rules**, **Domain Model Touch**, **Constraints**, и пишешь черновик **Acceptance Criteria**. + +--- + +## Принципы Аналитика + +1. **Технологическая агностичность**. В твоих секциях НЕ должно быть: названий фреймворков, баз данных, API-эндпоинтов, SQL-запросов, HTTP-методов. Если хочется написать "POST /api/users" — остановись. Напиши "Система регистрирует пользователя". +2. **Одно намерение — один атом**. Если в задаче два разных бизнес-действия — два атома. +3. **Domain Rules — это инварианты**, а не пожелания. DR должны быть проверяемы: "email уникален" — хорошо; "должно быть удобно" — плохо. +4. **Acceptance Criteria — поведение**, а не реализация. Gherkin описывает ЧТО, а не КАК. + +--- + +## Чек-лист секций + +### § Intent + +**Формат**: один абзац, 1–3 предложения. + +**Шаблон**: +``` +[Актор] [действие] чтобы [бизнес-цель]. +После успеха: [что изменилось в системе]. +После неудачи: [что происходит]. +``` + +**Хороший пример**: +```markdown +## § Intent +Гость создаёт аккаунт, предоставляя email и пароль. +После успеха — аутентифицирован и попадает в систему как User. +После неудачи — получает объяснение причины отказа. +``` + +**Плохой пример** (технические детали): +```markdown +## § Intent +Пользователь отправляет POST-запрос на /api/register с JSON-телом +содержащим email и password. Сервер создаёт запись в таблице users +и возвращает JWT-токен. +``` + +### § Domain Rules + +**Формат**: пронумерованные правила DR-N. Каждое правило — проверяемое утверждение. + +**Шаблон правила**: +``` +- **DR-N**: [Субъект] [условие] [ограничение]. [Что происходит при нарушении]. +``` + +**Хороший пример**: +```markdown +## § Domain Rules +- **DR-1**: Email должен быть уникален в системе. При дубликате — отказ с указанием что email занят. +- **DR-2**: Пароль содержит минимум 8 символов, 1 заглавную, 1 цифру. При несоответствии — отказ с перечислением нарушенных правил. +- **DR-3**: Один Guest может совершить не более 5 попыток регистрации в час с одного IP. При превышении — блокировка на 1 час. +``` + +**Anti-patterns**: +- DR без последствий: "Email должен быть валидным" — а что если нет? +- DR с техническими деталями: "Email проверяется regex-ом ^[a-z]..." — это реализация +- Расплывчатые DR: "Пароль должен быть надёжным" — не проверяемо + +### § Acceptance Criteria + +**Формат**: Gherkin (Given/When/Then), технологически нейтральный. + +**Правила Gherkin для Аналитика**: +- **Given** — состояние системы ДО действия +- **When** — действие актора (одно действие!) +- **Then** — наблюдаемый результат +- **And** — расширение предыдущего блока +- Без технических деталей (нет HTTP, SQL, UI-элементов) + +**Хороший пример**: +```gherkin +## § Acceptance Criteria + +Scenario: Успешная регистрация + Given гость на странице регистрации + And email "new@example.com" не зарегистрирован + When гость вводит email "new@example.com" и пароль "Str0ngPass!" + And подтверждает регистрацию + Then создан пользователь с email "new@example.com" + And пользователь аутентифицирован + And пользователь перенаправлен в личный кабинет + +Scenario: Email уже занят (→ DR-1) + Given email "taken@example.com" уже зарегистрирован + When гость пытается зарегистрироваться с email "taken@example.com" + Then регистрация отклонена + And отображено сообщение "Email уже используется" + +Scenario: Слабый пароль (→ DR-2) + Given гость на странице регистрации + When гость вводит пароль "123" + Then регистрация отклонена + And отображены конкретные нарушения правил пароля +``` + +**Связывай AC с DR**: указывай `(→ DR-N)` в названии сценария. + +### § Domain Model Touch + +**Формат**: список затрагиваемых агрегатов, событий, инвариантов. + +```markdown +## § Domain Model Touch +- **Aggregate**: User (create) +- **Events emitted**: UserRegistered { userId, email, registeredAt } +- **Events consumed**: — +- **Invariants**: email uniqueness (global), password policy (local) +- **Read Models affected**: UserList, UserProfile +``` + +### § Constraints (NFR) + +**Формат**: пронумерованные нефункциональные требования с метками типа. + +```markdown +## § Constraints +- **[PERF]** Регистрация завершается за ≤ 500ms (p95) +- **[SEC]** Пароль хранится в хешированном виде, never plaintext +- **[SEC]** Rate limit: 5 попыток/час/IP (→ DR-3) +- **[IDMP]** Повторная отправка формы не создаёт дубликат пользователя +- **[A11Y]** Ошибки валидации озвучиваются screen reader +``` + +### § Open Questions + +**Формат**: пронумерованные вопросы OQ-N с приоритетом. + +```markdown +## § Open Questions +- **OQ-1** [blocking]: Нужна ли верификация email перед активацией аккаунта? +- **OQ-2** [non-blocking]: Поддерживаем ли OAuth-регистрацию в первой версии? +``` + +**Blocking** — нельзя передавать Разработчику пока не закрыт. +**Non-blocking** — можно передавать, но зафиксировать допущение. + +### § Decision Log + +**Формат**: решения с датой и обоснованием. + +```markdown +## § Decision Log +- **DL-1** (2026-03-20): Email-верификация — отложена на v2. Причина: MVP scope, low risk для внутреннего продукта. +- **DL-2** (2026-03-21): Rate limit = 5/час. Причина: баланс между защитой от bruteforce и UX для легитимных пользователей. +``` + +--- + +## Матрица решений: когда создавать новый атом vs. менять существующий + +| Ситуация | Действие | change_class | +|----------|----------|-------------| +| Новая фича, не связанная с существующими | Новый use-case атом | additive | +| Новый сценарий в существующем use-case | Новый scenario-атом + правка parent | additive | +| Уточнение формулировки DR без смены поведения | Правка существующего атома | compatible | +| Изменение DR, меняющее поведение | Правка + impact analysis | breaking | +| Добавление нового DR | Правка существующего | additive | +| Удаление DR | Правка + impact analysis | breaking | +| Добавление NFR | Правка существующего | additive | +| Ужесточение NFR (было 1s → стало 500ms) | Правка | breaking | + +--- + +## Частые ошибки Аналитика + +1. **Смешение Intent и Tech Spec**. Intent = зачем. Tech Spec = как. Если в Intent есть API — переделай. +2. **DR без последствий**. Каждое правило = условие + что происходит при нарушении. +3. **AC без конкретных данных**. "Given пользователь" — плохо. "Given пользователь с email 'test@example.com'" — хорошо. +4. **Забытые негативные сценарии**. На каждый happy-path AC нужен минимум один negative. +5. **Blocking OQ при передаче на Gate A**. Все blocking-вопросы должны быть закрыты (решены и перенесены в DL). + +--- + +## Self-check перед Gate A + +Перед передачей Разработчику задай себе эти вопросы: + +- [ ] Intent в 1–3 предложениях, без технических деталей? +- [ ] Каждый DR проверяем и имеет последствие? +- [ ] Каждый AC — Gherkin, без технических деталей? +- [ ] На каждый happy-path есть negative AC? +- [ ] AC ссылаются на DR? +- [ ] DMT заполнен? +- [ ] Constraints промаркированы [PERF], [SEC], [IDMP], [A11Y]? +- [ ] Blocking OQ закрыты? +- [ ] change_class в frontmatter установлен? +- [ ] Actors в frontmatter совпадают с actors в AC? diff --git a/.agents/skills/atomic-spec-orchestrator/references/developer.md b/.agents/skills/atomic-spec-orchestrator/references/developer.md new file mode 100644 index 0000000..b962a80 --- /dev/null +++ b/.agents/skills/atomic-spec-orchestrator/references/developer.md @@ -0,0 +1,208 @@ +# Роль: Разработчик (Developer) + +Ты сейчас работаешь в роли **Разработчика**. Ты получаешь атом, прошедший Gate A, и превращаешь его в работающий код. Ты владеешь секциями: **Tech Spec**, **Implementation Notes**. Ты ЧИТАЕШЬ, но НЕ МЕНЯЕШЬ: Intent, Domain Rules, AC, DMT, Constraints. + +--- + +## Принципы Разработчика + +1. **Атом — контракт**. DR и AC — это твоё ТЗ. Если что-то неясно — не додумывай, открой OQ и верни Аналитику. +2. **Tech Spec — это bridge**. Он соединяет бизнес-требования (Аналитик) с тест-планом (Тестировщик). +3. **Код следует из DR, не наоборот**. Не подгоняй требования под реализацию. +4. **Каждый DR должен быть прослеживаем в коде**. Комментируй ссылками на DR-N. + +--- + +## Workflow Разработчика + +### Фаза 1: Изучение атома + +Прочитай атом в следующем порядке: + +1. **Intent** — пойми бизнес-цель +2. **Domain Rules** — пойми инварианты (это твои constraints) +3. **Acceptance Criteria** — пойми ожидаемое поведение +4. **Domain Model Touch** — пойми какие агрегаты затрагиваются +5. **Constraints** — пойми NFR (производительность, безопасность) +6. **Open Questions** — проверь что все blocking закрыты +7. **Decision Log** — учти принятые решения + +**Если что-то неясно** → не кодь. Создай OQ и сообщи оркестратору. + +### Фаза 2: Tech Spec + +#### API-контракты + +```markdown +## § Tech Spec + +### API + +POST /api/v1/auth/register + Request: + { email: string, password: string } + Response 201: + { userId: string, token: string } + Response 409: ← DR-1: email уже занят + { error: "EMAIL_ALREADY_EXISTS" } + Response 422: ← DR-2: слабый пароль + { error: "WEAK_PASSWORD", violations: string[] } + Response 429: ← DR-3: rate limit + { error: "RATE_LIMITED", retryAfter: number } +``` + +**Правила оформления API**: +- Каждый error-response ссылается на DR-N +- Status codes соответствуют семантике HTTP +- Request/Response — с типами полей +- Версионирование API явное (v1) + +#### Схемы данных + +```markdown +### Data Schema + +User: + id: UUID (PK) + email: string (unique, indexed) ← DR-1 + passwordHash: string ← Constraints [SEC] + createdAt: datetime + updatedAt: datetime + +Migration: + CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() + ); + CREATE INDEX idx_users_email ON users(email); +``` + +#### Архитектурные решения + +```markdown +### Architecture + +- Password hashing: bcrypt (cost=12) ← Constraints [SEC] +- Rate limiting: sliding window per IP ← DR-3, Constraints [SEC] +- Idempotency: UNIQUE constraint + ON CONFLICT DO NOTHING ← Constraints [IDMP] +- Event: publish UserRegistered to message bus after commit +``` + +### Фаза 3: Реализация кода + +При написании кода: + +1. **Маркируй DR в коде** комментариями: + +```python +def register_user(email: str, password: str) -> User: + # DR-1: Email must be unique + if user_repo.exists_by_email(email): + raise EmailAlreadyExistsError(email) + + # DR-2: Password policy + violations = password_policy.validate(password) + if violations: + raise WeakPasswordError(violations) + + # DR-3: Rate limit (checked at middleware level) + # See: RateLimitMiddleware + + user = User.create(email=email, password=hash_password(password)) + user_repo.save(user) + + # Event: UserRegistered (see DMT) + event_bus.publish(UserRegistered( + user_id=user.id, + email=user.email, + registered_at=user.created_at + )) + + return user +``` + +2. **Не добавляй фичи, которых нет в атоме**. Если хочешь что-то добавить — это новый атом или OQ. + +3. **NFR — в коде или инфраструктуре**: + - `[PERF]` → профилируй критический путь + - `[SEC]` → примени защиту + - `[IDMP]` → обеспечь идемпотентность + - `[A11Y]` → передай Тестировщику для проверки + +### Фаза 4: Implementation Notes + +```markdown +## § Implementation Notes + +### Trade-offs +- bcrypt cost=12: ~250ms на хеш. Приемлемо для регистрации (разовая операция), + но при массовом импорте потребуется async processing. + +### Dependencies +- bcrypt >= 4.0 +- Requires message bus (RabbitMQ / Kafka) for UserRegistered event + +### Known Limitations +- Rate limit per IP не защищает от distributed attacks. + Для v2 рассмотреть CAPTCHA (→ OQ-3) + +### File Map +- src/auth/register.py — main handler +- src/auth/password_policy.py — DR-2 validation +- src/auth/events.py — UserRegistered event +- src/middleware/rate_limit.py — DR-3 enforcement +- migrations/001_create_users.sql — schema +``` + +--- + +## Чек-лист Tech Spec + +- [ ] Все DR покрыты в API (каждый error case → DR-N)? +- [ ] Все Constraints отражены в архитектуре? +- [ ] Схема данных включает все поля из DMT? +- [ ] Events из DMT описаны с payload? +- [ ] Миграции написаны? +- [ ] API версионирован? +- [ ] Идемпотентность обеспечена (если [IDMP] в Constraints)? + +--- + +## Матрица решений: когда возвращать атом Аналитику + +| Ситуация | Действие | +|----------|----------| +| DR противоречат друг другу | OQ → вернуть | +| AC невозможно реализовать технически | OQ → вернуть | +| NFR нереалистичен (p95 < 10ms для сложного query) | OQ → вернуть | +| Нет DR для edge-case, который ты обнаружил | OQ + предложи DR → вернуть | +| Не уверен в интерпретации DR | OQ → вернуть | +| Всё ясно, но хочешь добавить фичу | Новый атом (не правь текущий!) | + +--- + +## Частые ошибки Разработчика + +1. **Код без ссылок на DR**. Через месяц никто не поймёт, почему этот if здесь. +2. **Додумывание требований**. "Наверное, тут нужен email confirmation" — это OQ, а не код. +3. **Пустые Implementation Notes**. Trade-offs и limitations — обязательны. +4. **Добавление фич без атома**. Каждая фича трассируется к атому. +5. **Изменение AC / DR**. Ты НЕ МЕНЯЕШЬ секции Аналитика. Если что-то не так — OQ. +6. **TODO без OQ**. Каждый TODO/FIXME в коде → ссылка на OQ-N в атоме. + +--- + +## Self-check перед Gate B + +- [ ] Tech Spec заполнен: API, схемы, архитектура? +- [ ] Каждый DR-N прослеживается в коде (комментарий или тест)? +- [ ] Каждый error-response в API ссылается на DR-N? +- [ ] Events из DMT реализованы? +- [ ] Constraints учтены: [PERF], [SEC], [IDMP], [A11Y]? +- [ ] Implementation Notes заполнены: trade-offs, deps, limitations, file map? +- [ ] Нет TODO/FIXME без ссылки на OQ? +- [ ] AC не нарушены реализацией? +- [ ] Код создан/указаны файлы? diff --git a/.agents/skills/atomic-spec-orchestrator/references/tester.md b/.agents/skills/atomic-spec-orchestrator/references/tester.md new file mode 100644 index 0000000..2871921 --- /dev/null +++ b/.agents/skills/atomic-spec-orchestrator/references/tester.md @@ -0,0 +1,272 @@ +# Роль: Тестировщик (QA Engineer) + +Ты сейчас работаешь в роли **Тестировщика**. Ты получаешь атом, прошедший Gate B, и обеспечиваешь полное покрытие тестами. Ты владеешь секциями: **Acceptance Criteria** (review + расширение), **Test Plan**. Ты ЧИТАЕШЬ: Intent, DR, DMT, Constraints, Tech Spec, Implementation Notes. + +--- + +## Принципы Тестировщика + +1. **AC — это минимум, не максимум**. Аналитик написал happy-path и основные negative. Ты добавляешь edge-cases, boundary conditions, concurrency, error recovery. +2. **Тест должен быть воспроизводим**. Конкретные данные, конкретные шаги, конкретный ожидаемый результат. +3. **Каждый DR — минимум один тест**. Если DR-3 говорит "5 попыток/час" — тестируй 5-ю и 6-ю попытку. +4. **NFR тестируются инструментально**. PERF → бенчмарк. SEC → penetration check. IDMP → повторный вызов. + +--- + +## Workflow Тестировщика + +### Фаза 1: Аудит атома + +Прочитай атом в следующем порядке: + +1. **Acceptance Criteria** — это твоя основная матрица покрытия +2. **Domain Rules** — каждый DR должен быть покрыт тестом +3. **Constraints** — NFR-тесты +4. **Tech Spec** — пойми API-контракты и error-коды +5. **Implementation Notes** — known limitations → тесты на них +6. **Open Questions** — non-blocking OQ могут скрывать edge-cases + +**Составь матрицу покрытия** прежде чем писать тесты: + +```markdown +| Источник | ID | Описание | Тест-кейс | Статус | +|----------|-----|----------|-----------|--------| +| AC | SC-1 | Успешная регистрация | TC-001 | ✅ | +| AC | SC-2 | Email занят (DR-1) | TC-002 | ✅ | +| AC | SC-3 | Слабый пароль (DR-2) | TC-003 | ✅ | +| DR | DR-3 | Rate limit 5/час | TC-004 | ✅ | +| NFR | PERF | ≤ 500ms p95 | TC-005 | ✅ | +| NFR | SEC | Password hashed | TC-006 | ✅ | +| NFR | IDMP | Повторная отправка | TC-007 | ✅ | +| Edge | — | Пустой email | TC-008 | ✅ | +| Edge | — | SQL injection в email | TC-009 | ✅ | +| Edge | — | Unicode в пароле | TC-010 | ✅ | +| Edge | — | Concurrent registration | TC-011 | ✅ | +``` + +### Фаза 2: Расширение AC + +Аналитик написал бизнес-сценарии. Ты добавляешь технические: + +**Категории edge-cases** (проверь каждую категорию): + +1. **Boundary values**: минимальные/максимальные значения полей +2. **Empty/null/missing**: пустые строки, null, отсутствующие поля +3. **Format violations**: невалидные email-форматы, спецсимволы +4. **Injection**: SQL injection, XSS в полях ввода +5. **Concurrency**: одновременная регистрация с тем же email +6. **Idempotency**: повторная отправка формы +7. **Rate limiting**: граничные значения (5-я попытка OK, 6-я — блок) +8. **Encoding**: Unicode, emoji, RTL-текст в полях +9. **Error recovery**: что если БД недоступна? Message bus down? +10. **Timeout**: что если операция превышает PERF-лимит? + +### Фаза 3: Test Plan + +```markdown +## § Test Plan + +### TC-001: Успешная регистрация (→ AC SC-1) +- **Preconditions**: БД пуста; сервис запущен +- **Input**: POST /api/v1/auth/register + ```json + { "email": "newuser@example.com", "password": "Str0ngP@ss!" } + ``` +- **Expected**: 201, body содержит userId (UUID) и token (JWT) +- **Postconditions**: в БД есть запись users с email="newuser@example.com"; password_hash ≠ "Str0ngP@ss!" +- **Verify event**: UserRegistered опубликован с правильным payload + +### TC-002: Email уже занят (→ AC SC-2, DR-1) +- **Preconditions**: пользователь с email "taken@example.com" уже существует +- **Input**: POST /api/v1/auth/register + ```json + { "email": "taken@example.com", "password": "Str0ngP@ss!" } + ``` +- **Expected**: 409, body.error = "EMAIL_ALREADY_EXISTS" +- **Postconditions**: в БД НЕ создана вторая запись + +### TC-003: Слабый пароль (→ AC SC-3, DR-2) +- **Preconditions**: — +- **Input**: POST /api/v1/auth/register + ```json + { "email": "test@example.com", "password": "123" } + ``` +- **Expected**: 422, body.error = "WEAK_PASSWORD", body.violations содержит конкретные нарушения +- **Postconditions**: в БД НЕ создана запись + +### TC-004: Rate limit (→ DR-3) +- **Preconditions**: — +- **Steps**: + 1. Отправить 5 запросов регистрации с разными email с одного IP + 2. Все 5 → 201 или 409/422 (но не 429) + 3. Отправить 6-й запрос +- **Expected**: 6-й запрос → 429, body.error = "RATE_LIMITED", body.retryAfter > 0 +- **Postconditions**: после ожидания retryAfter секунд — запрос проходит + +### TC-005: Performance (→ NFR [PERF]) +- **Setup**: БД с 100k пользователей (предзагруженных) +- **Input**: 100 последовательных запросов регистрации +- **Expected**: p95 latency ≤ 500ms +- **Tool**: k6 / locust / custom benchmark + +### TC-006: Password security (→ NFR [SEC]) +- **Steps**: + 1. Зарегистрировать пользователя с паролем "Str0ngP@ss!" + 2. Прочитать password_hash из БД +- **Expected**: password_hash ≠ "Str0ngP@ss!" и начинается с "$2b$" (bcrypt) + +### TC-007: Idempotency (→ NFR [IDMP]) +- **Steps**: + 1. Отправить POST /api/v1/auth/register с email="idmp@example.com" + 2. Немедленно повторить тот же запрос +- **Expected**: + - Первый → 201 + - Второй → 409 (не 500, не дубликат в БД) + - В БД ровно одна запись с этим email + +### TC-008: Пустой email (Edge case) +- **Input**: { "email": "", "password": "Str0ngP@ss!" } +- **Expected**: 422, валидация + +### TC-009: SQL injection (Edge case, → NFR [SEC]) +- **Input**: { "email": "'; DROP TABLE users;--", "password": "Str0ngP@ss!" } +- **Expected**: 422, валидация; таблица users цела + +### TC-010: Unicode password (Edge case) +- **Input**: { "email": "unicode@example.com", "password": "Пароль123!Ñ" } +- **Expected**: 201 (если соответствует DR-2) или 422 с конкретными нарушениями + +### TC-011: Concurrent registration (Edge case) +- **Steps**: 10 параллельных POST с email="race@example.com" +- **Expected**: ровно 1 → 201, остальные → 409; в БД одна запись +``` + +### Фаза 4: Тестовый код + +Напиши автотесты, следуя шаблону: + +```python +import pytest +from httpx import AsyncClient + +class TestRegistration: + """Tests for AUTH-REG-001: User Registration""" + + # --- AC Coverage --- + + async def test_successful_registration(self, client: AsyncClient): + """TC-001 → AC SC-1: Happy path registration""" + response = await client.post("/api/v1/auth/register", json={ + "email": "newuser@example.com", + "password": "Str0ngP@ss!" + }) + assert response.status_code == 201 + data = response.json() + assert "userId" in data + assert "token" in data + + async def test_duplicate_email_rejected(self, client: AsyncClient, existing_user): + """TC-002 → AC SC-2, DR-1: Email uniqueness""" + response = await client.post("/api/v1/auth/register", json={ + "email": existing_user.email, + "password": "Str0ngP@ss!" + }) + assert response.status_code == 409 + assert response.json()["error"] == "EMAIL_ALREADY_EXISTS" + + # --- DR Coverage --- + + async def test_rate_limit_enforced(self, client: AsyncClient): + """TC-004 → DR-3: 5 attempts per hour per IP""" + for i in range(5): + await client.post("/api/v1/auth/register", json={ + "email": f"rate{i}@example.com", + "password": "Str0ngP@ss!" + }) + response = await client.post("/api/v1/auth/register", json={ + "email": "rate5@example.com", + "password": "Str0ngP@ss!" + }) + assert response.status_code == 429 + + # --- NFR Coverage --- + + async def test_password_hashed(self, client: AsyncClient, db): + """TC-006 → NFR [SEC]: Password stored as bcrypt hash""" + await client.post("/api/v1/auth/register", json={ + "email": "hash@example.com", + "password": "Str0ngP@ss!" + }) + user = await db.fetch_one("SELECT password_hash FROM users WHERE email='hash@example.com'") + assert user["password_hash"].startswith("$2b$") + + async def test_idempotent_registration(self, client: AsyncClient, db): + """TC-007 → NFR [IDMP]: Duplicate submit safety""" + await client.post("/api/v1/auth/register", json={ + "email": "idmp@example.com", "password": "Str0ngP@ss!" + }) + response = await client.post("/api/v1/auth/register", json={ + "email": "idmp@example.com", "password": "Str0ngP@ss!" + }) + assert response.status_code == 409 + count = await db.fetch_val("SELECT COUNT(*) FROM users WHERE email='idmp@example.com'") + assert count == 1 + + # --- Edge Cases --- + + async def test_sql_injection_safe(self, client: AsyncClient, db): + """TC-009 → NFR [SEC]: SQL injection protection""" + response = await client.post("/api/v1/auth/register", json={ + "email": "'; DROP TABLE users;--", + "password": "Str0ngP@ss!" + }) + assert response.status_code == 422 + # Verify table still exists + count = await db.fetch_val("SELECT COUNT(*) FROM users") + assert count is not None +``` + +**Правила тестового кода**: +- Docstring каждого теста → `TC-NNN → AC/DR/NFR` (трассируемость) +- Один тест — одно утверждение (или логически связанная группа) +- Тесты независимы друг от друга +- Fixtures для preconditions +- No hardcoded URLs — используй client fixture + +--- + +## Review AC Аналитика + +После написания Test Plan, проверь AC Аналитика: + +1. **Пропущенные сценарии**: есть ли поведение, не покрытое AC? +2. **Неоднозначные формулировки**: "отображено сообщение" — какое именно? +3. **Отсутствующие данные**: "Given пользователь" — с какими атрибутами? + +Если нашёл проблемы → добавь OQ в атом, сообщи оркестратору. + +--- + +## Частые ошибки Тестировщика + +1. **Тесты только на happy-path**. 70% багов — в edge-cases. +2. **Нет трассируемости**. Тест без ссылки на TC/AC/DR — непонятно что он проверяет. +3. **Зависимые тесты**. Тест B зависит от результата теста A — хрупко. +4. **Нет NFR-тестов**. PERF/SEC/IDMP должны тестироваться инструментально. +5. **Копирование AC вместо расширения**. AC — скелет. Test Plan — полное тело. +6. **Нет проверки postconditions**. Проверяй не только response, но и состояние БД/events. + +--- + +## Self-check перед Gate C + +- [ ] Матрица покрытия составлена? +- [ ] Каждый AC имеет минимум 1 тест-кейс? +- [ ] Каждый DR имеет минимум 1 тест-кейс? +- [ ] Каждый NFR имеет тест? +- [ ] Минимум 1 негативный сценарий на каждый AC? +- [ ] Edge-cases: boundary, empty, injection, concurrency, encoding? +- [ ] Test Plan с конкретными данными (не "какой-то email")? +- [ ] Тестовый код написан с трассируемостью (docstring → TC → AC/DR)? +- [ ] AC Аналитика проверены на полноту? +- [ ] Known Limitations из Implementation Notes учтены в тестах? diff --git a/.agents/skills/avoiding-common-lisp-pitfalls/SKILL.md b/.agents/skills/avoiding-common-lisp-pitfalls/SKILL.md new file mode 100644 index 0000000..56273ec --- /dev/null +++ b/.agents/skills/avoiding-common-lisp-pitfalls/SKILL.md @@ -0,0 +1,90 @@ +--- +name: avoiding-common-lisp-pitfalls +description: Use when writing new or large Common Lisp code where subtle library or language gotchas are likely — event-emitter argument order, parenthesis imbalance in a big file, or a defmethod whose specializers may silently collide with one in a dependency. Also apply when wiring defmain subcommands. +--- + +# Avoiding Common Lisp Pitfalls + +## Overview +Verified, non-obvious CL traps: the `event-emitter` library's inconsistent argument order, silent CLOS method replacement, parenthesis imbalance in large files, and `defmain` subcommand quirks. + +## When to Use +- Using the `event-emitter` library (`on`, `emit`, `remove-listener`, …) +- About to add a `defmethod` whose specializers may match a method defined in a dependency +- Writing or debugging a large Lisp file (>100 lines) and suspecting a paren error +- Using `defmain` for a CLI with subcommands + +## `event-emitter` has INCONSISTENT argument order +This is the most surprising trap — agents uniformly assume the order is consistent. It is not. Verified from `src/event-emitter.lisp`: + +```lisp +;; EVENT first +(on event object listener) +(once event object listener) +(emit event object &rest args) +;; OBJECT first +(add-listener object event listener) +(remove-listener object event listener &key (start 0)) +(remove-all-listeners object &optional event) +(listeners object event) +``` + +```lisp +(event-emitter:on :tool-call provider handler) ;; good +(event-emitter:on provider :tool-call handler) ;; BAD — NO-APPLICABLE-METHOD on the keyword +(event-emitter:emit :tool-call provider call-id tool args) ;; good +(event-emitter:remove-listener provider :tool-call handler) ;; good (object first) +``` + +**Mnemonic:** `on` / `once` / `emit` are **event-first**; anything named `*-listener` or `listeners` is **object-first**. + +## Silent CLOS method replacement +CLOS identifies a method by `(generic-function, qualifiers, specializer-list)`. Two `defmethod`s with the **same specializers and qualifiers** are the **same method** — the later load silently replaces the earlier one. No conflict is signaled, no merge happens. + +Before adding a `defmethod` on a generic from a dependency, check whether the base already defines a method with identical specializers — otherwise yours silently overrides it and callers get your return type unexpectedly. + +## `define-global-var` is not ordinary `let`-bindable state +Globals created with `global-vars:define-global-var` are a recurring trap in tests and temporary overrides. Treat them as mutable process-wide state, not as ordinary specials you can safely rebind with `let`. + +For temporary overrides, prefer: +```lisp +(let ((old-value *some-global*)) + (unwind-protect + (progn + (setf *some-global* new-value) + ...) + (setf *some-global* old-value))) +``` +If you try `(let ((*some-global* ...)) ...)`, compilation may fail or behavior may differ from ordinary special variables. + +## Parenthesis balance in large files +Don't write functions over ~100 lines; split into helpers. Before `load-system` on a freshly edited file, check paren balance with the bundled tool: + +```lisp +(load "check-paren-balance.lisp") +(check-paren-balance "src/big-file.lisp") +;; *** EXTRA `)` at line 47 +;; Final depth: 0 (0 = balanced) +``` + +Negative depth at line N → extra `)` on that line. Non-zero final depth → missing `)`. Count closing-paren runs (`))))`) carefully — the single most common error. + +## defmain subcommands (reference) +- A subcommand named after a CL symbol (`list`, `do`) trips `SYMBOL-PACKAGE-LOCKED-ERROR`. Fix with `(:shadow #:list)` in the `defpackage`, or just rename (`ls`, `serve`). +- The generated dispatch function is the symbol `DEFMAIN:SUBCOMMAND` (in the `defmain` package), not a local symbol — call `(defmain:subcommand)`, never a bare `(subcommand)`. +- Parent command arguments are passed as symbols from the **parent** package. Import them in the subcommand's package, or it sees unbound same-named symbols: `(:import-from #:myapp/cli/main #:main #:model #:output)`. + +## Common Mistakes +| Symptom | Fix | +|---|---| +| `NO-APPLICABLE-METHOD-ERROR` on a keyword (`SILO`) from `event-emitter:on` / `emit` | Swap to event-first: `(on :event object handler)` | +| `event-emitter:remove-listener` not finding the handler | It is object-first: `(remove-listener object :event handler)` | +| Your `defmethod` silently shadows one from a dependency | Check for existing same-specializer methods first; rename or change specializers | +| A `define-global-var` global cannot be used in `LET` | Save/restore with `setf` + `unwind-protect` instead of rebinding | +| `SYMBOL-PACKAGE-LOCKED-ERROR` loading a `defmain` subcommand | `(:shadow #:list)` in `defpackage`, or pick a non-CL name | +| `(subcommand)` → `UNBOUND-VARIABLE` | Use `(defmain:subcommand)` | +| Subcommand sees unbound parent-arg symbols | `(:import-from #:parent/pkg #:model #:output ...)` in the subcommand package | +| Extra/missing `)` in a big file | Run `check-paren-balance` (see `check-paren-balance.lisp`) before `load-system` | + +## Related skills +For the surrounding style: **writing-common-lisp**. For reloading a file after edits: **using-common-lisp-repl**. diff --git a/.agents/skills/avoiding-common-lisp-pitfalls/check-paren-balance.lisp b/.agents/skills/avoiding-common-lisp-pitfalls/check-paren-balance.lisp new file mode 100644 index 0000000..cff8c42 --- /dev/null +++ b/.agents/skills/avoiding-common-lisp-pitfalls/check-paren-balance.lisp @@ -0,0 +1,28 @@ +;;; check-paren-balance.lisp +;;; Tool for the "avoiding-common-lisp-pitfalls" skill. +;;; Usage from a REPL (e.g. via eval_lisp_form): +;;; (load "check-paren-balance.lisp") +;;; (check-paren-balance "src/big-file.lisp") +;;; Requires: uiop (bundled with ASDF). +;;; - A NEGATIVE depth at line N flags an extra `)` on that line. +;;; - A non-zero final depth means missing `)`. +;;; - "Final depth: 0" means the file is balanced. + +(defun check-paren-balance (path) + "Print paren-balance diagnostics for a Lisp source file." + (let ((content (uiop:read-file-string path)) + (depth 0)) + (loop for line in (uiop:split-string content :separator '(#\Newline)) + for i from 1 + do (let ((in-str nil) (in-com nil)) + (loop for ch across line + do (cond (in-com) + (in-str (when (char= ch #\") (setf in-str nil))) + ((char= ch #\;) (setf in-com t)) + ((char= ch #\") (setf in-str t)) + ((char= ch #\() (incf depth)) + ((char= ch #\)) (decf depth)))) + (when (< depth 0) + (format t "*** EXTRA `)` at line ~A~%" i) + (return))) + finally (format t "Final depth: ~A (0 = balanced)~%" depth)))) diff --git a/.agents/skills/brainstorming/SKILL.md b/.agents/skills/brainstorming/SKILL.md new file mode 100644 index 0000000..b56a3b5 --- /dev/null +++ b/.agents/skills/brainstorming/SKILL.md @@ -0,0 +1,250 @@ +--- +name: brainstorming +description: "You MUST use this before any creative work - creating features, building components, adding functionality, or modifying behavior. Explores user intent, requirements and design before implementation." +--- + +# Brainstorming Ideas Into Designs + +Help turn ideas into fully formed designs and specs through natural collaborative dialogue. + +Start by classifying how much process the request needs, then work +through your path: understand the context, refine the idea, present a +design, and get your human partner's approval. + + +Do NOT invoke any implementation skill, write any code, scaffold any +project, or take any implementation action until you have told your +human partner what you intend and they have approved it. This applies +to EVERY task on EVERY path below — the ceremony scales with the task; +the approval gate never does. + + +## Three Paths + +Before your first question, classify the request and say the +classification out loud — "this looks bounded, so I'll present a short +design here rather than write a spec" — so your human partner can +override it: + +- **Spike** — a feasibility question ("can we...", "is it possible...", + "quick and dirty is fine") whose output is an answer, not code you + keep. Present the question and what you'll try in 2-3 sentences, get + a nod, then find out as cheaply as correctness allows. No design + doc, no spec file. Report findings as a recommendation; anything you + built stays labeled throwaway. +- **Bounded** — a well-scoped change to code that already exists in + this repo: a new flag, a small endpoint, a one-file fix. + Understanding the kind of app is not enough — bounded means the flow + you are changing is already here to read. If there is no existing + flow to change, the task is not bounded. Ask the clarifying + questions that matter, present a short design IN CHAT (a few + sentences to a few short paragraphs), and STOP. Implementation + starts only after your human partner says yes to that design — a + bounded task's approval is as hard a gate as an architectural + one. No spec file, no implementation plan document. +- **Architectural** — new projects, new subsystems, changes that + restructure how components fit together or alter interfaces others + depend on. Follow the full process: questions, approaches, sectioned + design, written spec, then the writing-plans skill. + +When in doubt between two paths, take the heavier one. The ratchet is +one-way: hidden complexity discovered mid-task upgrades the path — +stop, say so, and step up. Nothing downgrades mid-task. + +## Anti-Pattern: "Too Simple To Need Approval" + +Every path ends with your human partner approving your intent before +implementation. A todo list, a single-function utility, a config +change — the design may be two sentences in chat, but you MUST present +it and get approval. "Simple" tasks are where unexamined assumptions +cause the most wasted work. What scales with simplicity is the +artifact, never the approval. + +## Red Flags + +| Thought | Reality | +|---------|---------| +| "This is too simple to need a design" | Simple means a short design, not no design. Two sentences in chat, then approval. | +| "I'll call it bounded and skip the spec" | Reaching for a label to skip work IS the doubt — take the heavier path. | +| "It's bounded and the design is obvious — I'll start while they read it" | The gate is the approval, not the design's length. Present, then stop until you hear yes. | +| "I understand this kind of app, so it's bounded" | Bounded measures the repo, not your familiarity. A new project has no existing flow — it is architectural. | +| "The spike works, so I'll keep the code" | A spike's output is an answer. Keeping the code is a new request — classify it. | +| "It grew, but I'm almost done — no need to re-classify" | Hidden complexity upgrades the path mid-task. Stop and say so. | +| "They approved the spike, so the follow-up change is approved too" | Each task gets its own classification and its own approval. | + +## Checklist + +Classify first, announce the path, then create a task for each item on +your path and complete them in order. + +**Spike:** +1. **Explore project context** — enough to frame the probe +2. **Present question + probe plan** — 2-3 sentences +3. **Get approval** — a nod is enough +4. **Investigate** — as cheaply as correctness allows +5. **Report findings** — a recommendation; label anything built as throwaway + +**Bounded:** +1. **Explore project context** — check files, docs, recent commits +2. **Ask clarifying questions** — one at a time, the ones that matter +3. **Present short design in chat** — approach, files touched, testing +4. **Get approval** — STOP and wait for an explicit yes; presenting the design and starting in the same breath is skipping the gate +5. **Implement** — proceed with the normal development workflow (TDD applies); no plan document + +**Architectural:** +1. **Explore project context** — check files, docs, recent commits +2. **Offer the visual companion just-in-time** — NOT upfront. The first time a question would genuinely be clearer shown than described, offer it then (its own message); on approval its browser tab opens for you. If no visual question ever arises, never offer it. See the Visual Companion section below. +3. **Ask clarifying questions** — one at a time, understand purpose/constraints/success criteria +4. **Propose 2-3 approaches** — with trade-offs and your recommendation +5. **Present design** — in sections scaled to their complexity, get user approval after each section +6. **Write design doc** — save to `docs/superpowers/specs/YYYY-MM-DD--design.md` and commit +7. **Spec self-review** — quick inline check for placeholders, contradictions, ambiguity, scope (see below) +8. **User reviews written spec** — ask user to review the spec file before proceeding +9. **Transition to implementation** — invoke writing-plans skill to create implementation plan + +## Process Flow + +```dot +digraph brainstorming { + "Classify: spike / bounded / architectural" [shape=diamond]; + "Present question + probe (2-3 sentences)" [shape=box]; + "Ask clarifying questions (bounded)" [shape=box]; + "Present short design in chat" [shape=box]; + "Human approves?" [shape=diamond]; + "Investigate; report recommendation" [shape=doublecircle]; + "Implement via normal workflow (no plan doc)" [shape=doublecircle]; + "Explore project context" [shape=box]; + "Ask clarifying questions" [shape=box]; + "Propose 2-3 approaches" [shape=box]; + "Present design sections" [shape=box]; + "User approves design?" [shape=diamond]; + "Write design doc" [shape=box]; + "Spec self-review\n(fix inline)" [shape=box]; + "User reviews spec?" [shape=diamond]; + "Invoke writing-plans skill" [shape=doublecircle]; + "Hidden complexity? Upgrade path" [shape=box]; + + "Classify: spike / bounded / architectural" -> "Present question + probe (2-3 sentences)" [label="spike"]; + "Classify: spike / bounded / architectural" -> "Ask clarifying questions (bounded)" [label="bounded"]; + "Classify: spike / bounded / architectural" -> "Explore project context" [label="architectural"]; + "Present question + probe (2-3 sentences)" -> "Human approves?"; + "Ask clarifying questions (bounded)" -> "Present short design in chat"; + "Present short design in chat" -> "Human approves?"; + "Human approves?" -> "Investigate; report recommendation" [label="spike: yes"]; + "Human approves?" -> "Implement via normal workflow (no plan doc)" [label="bounded: yes"]; + "Hidden complexity? Upgrade path" -> "Classify: spike / bounded / architectural"; + "Explore project context" -> "Ask clarifying questions"; + "Ask clarifying questions" -> "Propose 2-3 approaches"; + "Propose 2-3 approaches" -> "Present design sections"; + "Present design sections" -> "User approves design?"; + "User approves design?" -> "Present design sections" [label="no, revise"]; + "User approves design?" -> "Write design doc" [label="yes"]; + "Write design doc" -> "Spec self-review\n(fix inline)"; + "Spec self-review\n(fix inline)" -> "User reviews spec?"; + "User reviews spec?" -> "Write design doc" [label="changes requested"]; + "User reviews spec?" -> "Invoke writing-plans skill" [label="approved"]; +} +``` + +**Terminal states are path-bound.** Architectural: the ONLY skill you +invoke after brainstorming is writing-plans — never frontend-design, +mcp-builder, or any other implementation skill. Bounded: after +approval, implementation proceeds directly through the normal +development workflow; no plan document. Spike: the terminal state is a +reported recommendation. + +## The Process + +The subsections below serve the bounded and architectural paths (a +spike stops at "present the probe, get a nod"). Sections from +**Exploring approaches** onward are architectural-path depth — for +bounded work, context plus a few questions plus a short in-chat design +is the whole process. + +**Understanding the idea:** + +- Check out the current project state first (files, docs, recent commits) +- Before asking detailed questions, assess scope: if the request describes multiple independent subsystems (e.g., "build a platform with chat, file storage, billing, and analytics"), flag this immediately. Don't spend questions refining details of a project that needs to be decomposed first. +- If the project is too large for a single spec, help the user decompose into sub-projects: what are the independent pieces, how do they relate, what order should they be built? Then brainstorm the first sub-project through the normal design flow. Each sub-project gets its own spec → plan → implementation cycle. +- For appropriately-scoped projects, ask questions one at a time to refine the idea +- Prefer multiple choice questions when possible, but open-ended is fine too +- Only one question per message - if a topic needs more exploration, break it into multiple questions +- Focus on understanding: purpose, constraints, success criteria + +**Exploring approaches:** + +- Propose 2-3 different approaches with trade-offs +- Present options conversationally with your recommendation and reasoning +- Lead with your recommended option and explain why +- YAGNI ruthlessly - remove unnecessary features from every approach and design + +**Presenting the design:** + +- Once you believe you understand what you're building, present the design +- Scale each section to its complexity: a few sentences if straightforward, up to 200-300 words if nuanced +- Ask after each section whether it looks right so far +- Cover: architecture, components, data flow, error handling, testing +- Be ready to go back and clarify if something doesn't make sense + +**Design for isolation and clarity:** + +- Break the system into smaller units that each have one clear purpose, communicate through well-defined interfaces, and can be understood and tested independently +- For each unit, you should be able to answer: what does it do, how do you use it, and what does it depend on? +- Can someone understand what a unit does without reading its internals? Can you change the internals without breaking consumers? If not, the boundaries need work. +- Smaller, well-bounded units are also easier for you to work with - you reason better about code you can hold in context at once, and your edits are more reliable when files are focused. When a file grows large, that's often a signal that it's doing too much. + +**Working in existing codebases:** + +- Explore the current structure before proposing changes. Follow existing patterns. +- Where existing code has problems that affect the work (e.g., a file that's grown too large, unclear boundaries, tangled responsibilities), include targeted improvements as part of the design - the way a good developer improves code they're working in. +- Don't propose unrelated refactoring. Stay focused on what serves the current goal. + +## After the Design (architectural path) + +**Documentation:** + +- Write the validated design (spec) to `docs/superpowers/specs/YYYY-MM-DD--design.md` + - (User preferences for spec location override this default) +- Use elements-of-style:writing-clearly-and-concisely skill if available +- Commit the design document to git + +**Spec Self-Review:** +After writing the spec document, look at it with fresh eyes: + +1. **Placeholder scan:** Any "TBD", "TODO", incomplete sections, or vague requirements? Fix them. +2. **Internal consistency:** Do any sections contradict each other? Does the architecture match the feature descriptions? +3. **Scope check:** Is this focused enough for a single implementation plan, or does it need decomposition? +4. **Ambiguity check:** Could any requirement be interpreted two different ways? If so, pick one and make it explicit. + +Fix any issues inline. No need to re-review — just fix and move on. + +**User Review Gate:** +After the spec review loop passes, ask the user to review the written spec before proceeding: + +> "Spec written and committed to ``. Please review it and let me know if you want to make any changes before we start writing out the implementation plan." + +Wait for the user's response. If they request changes, make them and re-run the spec review loop. Only proceed once the user approves. + +**Implementation:** + +- Invoke the writing-plans skill to create a detailed implementation plan +- Do NOT invoke any other skill. writing-plans is the next step. + +## Visual Companion + +A browser-based companion for showing mockups, diagrams, and visual options during brainstorming. Available as a tool — not a mode. Accepting the companion means it's available for questions that benefit from visual treatment; it does NOT mean every question goes through the browser. + +**Offering the companion (just-in-time):** Do NOT offer it upfront. Wait until a question would genuinely be clearer shown than told — a real mockup / layout / diagram question, not merely a UI *topic*. The first time that happens, offer it then, as its own message: +> "This next part might be easier if I show you — I can put together mockups, diagrams, and comparisons in a browser tab as we go. It's still new and can be token-intensive. Want me to? I'll open it for you." + +**This offer MUST be its own message.** Only the offer — no clarifying question, summary, or other content. Wait for the user's response. If they accept, start the server with `--open` so their browser opens to the first screen automatically. If they decline, continue text-only and don't offer again unless they raise it. + +**Per-question decision:** Even after the user accepts, decide FOR EACH QUESTION whether to use the browser or the terminal. The test: **would the user understand this better by seeing it than reading it?** + +- **Use the browser** for content that IS visual — mockups, wireframes, layout comparisons, architecture diagrams, side-by-side visual designs +- **Use the terminal** for content that is text — requirements questions, conceptual choices, tradeoff lists, A/B/C/D text options, scope decisions + +A question about a UI topic is not automatically a visual question. "What does personality mean in this context?" is a conceptual question — use the terminal. "Which wizard layout works better?" is a visual question — use the browser. + +If they agree to the companion, read the detailed guide before proceeding: +`skills/brainstorming/visual-companion.md` diff --git a/.agents/skills/brainstorming/scripts/frame-template.html b/.agents/skills/brainstorming/scripts/frame-template.html new file mode 100644 index 0000000..f540bb8 --- /dev/null +++ b/.agents/skills/brainstorming/scripts/frame-template.html @@ -0,0 +1,213 @@ + + + + + Superpowers Brainstorming + + + +
+ +
Connecting…
+
+ +
+
+ +
+
+ + + diff --git a/.agents/skills/brainstorming/scripts/helper.js b/.agents/skills/brainstorming/scripts/helper.js new file mode 100644 index 0000000..e11d264 --- /dev/null +++ b/.agents/skills/brainstorming/scripts/helper.js @@ -0,0 +1,167 @@ +(function() { + const MIN_RECONNECT_MS = 500; + const MAX_RECONNECT_MS = 30000; + const TOMBSTONE_AFTER_MS = 15000; // show the "paused" overlay after this long disconnected + + // Pure: next backoff delay (doubles, capped). Exported for unit tests. + function nextReconnectDelay(current, max) { + return Math.min(current * 2, max); + } + if (typeof module !== 'undefined' && module.exports) { + module.exports = { nextReconnectDelay, MIN_RECONNECT_MS, MAX_RECONNECT_MS, TOMBSTONE_AFTER_MS }; + } + + // Everything below is browser-only; bail out when loaded in Node (tests). + if (typeof window === 'undefined') return; + + let ws = null; + let eventQueue = []; + let reconnectDelay = MIN_RECONNECT_MS; + let reconnectTimer = null; + let disconnectedSince = null; + let everConnected = false; + let tombstoneShown = false; + + function sessionKey() { + try { + return window.sessionStorage && window.sessionStorage.getItem('brainstorm-session-key'); + } catch (e) {} + return null; + } + + function websocketUrl() { + const key = sessionKey(); + return 'ws://' + window.location.host + (key ? '/?key=' + encodeURIComponent(key) : ''); + } + + function reloadAfterRecovery() { + const key = sessionKey(); + if (key) { + window.location.replace('/?key=' + encodeURIComponent(key)); + } else { + window.location.reload(); + } + } + + // Reflect connection state in the frame's status pill (absent on full-doc screens). + function setStatus(state) { + const el = document.querySelector('.status'); + if (!el) return; + const map = { + connecting: ['Connecting…', 'var(--text-tertiary)'], + connected: ['Connected', 'var(--success)'], + reconnecting: ['Reconnecting…', 'var(--warning)'], + disconnected: ['Disconnected', 'var(--error)'] + }; + const [text, color] = map[state] || map.disconnected; + el.textContent = text; + el.style.setProperty('--status-color', color); + } + + // Self-styled so it works on framed and full-document screens alike. + function showTombstone() { + if (tombstoneShown) return; + tombstoneShown = true; + const el = document.createElement('div'); + el.id = 'bs-tombstone'; + el.style.cssText = 'position:fixed;inset:0;z-index:99999;display:flex;' + + 'align-items:center;justify-content:center;padding:2rem;text-align:center;' + + 'background:rgba(20,20,22,0.92);color:#f5f5f7;font-family:system-ui,sans-serif'; + el.innerHTML = '
' + + '

Companion paused

' + + '

This brainstorm companion has stopped. ' + + 'Ask your coding agent to bring it back — this page reconnects automatically.

'; + if (document.body) document.body.appendChild(el); + } + + function connect() { + if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } + setStatus(everConnected ? 'reconnecting' : 'connecting'); + ws = new WebSocket(websocketUrl()); + + ws.onopen = () => { + const recovered = tombstoneShown; + everConnected = true; + disconnectedSince = null; + reconnectDelay = MIN_RECONNECT_MS; + tombstoneShown = false; + setStatus('connected'); + eventQueue.forEach(e => ws.send(JSON.stringify(e))); + eventQueue = []; + // Recovered from a tombstoned outage (e.g. the server restarted on the same + // port) — reload through the keyed bootstrap when possible so the cookie is + // refreshed before the visible URL returns to bare /. + if (recovered) reloadAfterRecovery(); + }; + + ws.onmessage = (msg) => { + let data; + try { data = JSON.parse(msg.data); } catch (e) { return; } + if (data.type === 'reload') window.location.reload(); + }; + + ws.onclose = () => { + ws = null; + if (disconnectedSince === null) disconnectedSince = Date.now(); + if (Date.now() - disconnectedSince >= TOMBSTONE_AFTER_MS) { + setStatus('disconnected'); + showTombstone(); + } else { + setStatus('reconnecting'); + } + reconnectTimer = setTimeout(connect, reconnectDelay); + reconnectDelay = nextReconnectDelay(reconnectDelay, MAX_RECONNECT_MS); + }; + + // Let onclose own reconnection so we don't schedule it twice. + ws.onerror = () => { try { ws.close(); } catch (e) {} }; + } + + function sendEvent(event) { + event.timestamp = Date.now(); + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify(event)); + } else { + eventQueue.push(event); + } + } + + // Capture clicks on choice elements + document.addEventListener('click', (e) => { + const target = e.target.closest('[data-choice]'); + if (!target) return; + + sendEvent({ + type: 'click', + text: target.textContent.trim(), + choice: target.dataset.choice, + id: target.id || null + }); + + }); + + // Frame UI: selection tracking + window.selectedChoice = null; + + window.toggleSelect = function(el) { + const container = el.closest('.options') || el.closest('.cards'); + const multi = container && container.dataset.multiselect !== undefined; + if (container && !multi) { + container.querySelectorAll('.option, .card').forEach(o => o.classList.remove('selected')); + } + if (multi) { + el.classList.toggle('selected'); + } else { + el.classList.add('selected'); + } + window.selectedChoice = el.dataset.choice; + }; + + // Expose API for explicit use + window.brainstorm = { + send: sendEvent, + choice: (value, metadata = {}) => sendEvent({ type: 'choice', value, ...metadata }) + }; + + connect(); +})(); diff --git a/.agents/skills/brainstorming/scripts/server.cjs b/.agents/skills/brainstorming/scripts/server.cjs new file mode 100644 index 0000000..a828b35 --- /dev/null +++ b/.agents/skills/brainstorming/scripts/server.cjs @@ -0,0 +1,723 @@ +const crypto = require('crypto'); +const http = require('http'); +const fs = require('fs'); +const path = require('path'); + +// ========== WebSocket Protocol (RFC 6455) ========== + +const OPCODES = { TEXT: 0x01, CLOSE: 0x08, PING: 0x09, PONG: 0x0A }; +const WS_MAGIC = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'; +const MAX_FRAME_PAYLOAD_BYTES = 10 * 1024 * 1024; + +function computeAcceptKey(clientKey) { + return crypto.createHash('sha1').update(clientKey + WS_MAGIC).digest('base64'); +} + +function encodeFrame(opcode, payload) { + const fin = 0x80; + const len = payload.length; + let header; + + if (len < 126) { + header = Buffer.alloc(2); + header[0] = fin | opcode; + header[1] = len; + } else if (len < 65536) { + header = Buffer.alloc(4); + header[0] = fin | opcode; + header[1] = 126; + header.writeUInt16BE(len, 2); + } else { + header = Buffer.alloc(10); + header[0] = fin | opcode; + header[1] = 127; + header.writeBigUInt64BE(BigInt(len), 2); + } + + return Buffer.concat([header, payload]); +} + +function decodeFrame(buffer) { + if (buffer.length < 2) return null; + + const secondByte = buffer[1]; + const opcode = buffer[0] & 0x0F; + const masked = (secondByte & 0x80) !== 0; + let payloadLen = secondByte & 0x7F; + let offset = 2; + + if (!masked) throw new Error('Client frames must be masked'); + + if (payloadLen === 126) { + if (buffer.length < 4) return null; + payloadLen = buffer.readUInt16BE(2); + offset = 4; + } else if (payloadLen === 127) { + if (buffer.length < 10) return null; + const extendedLen = buffer.readBigUInt64BE(2); + if (extendedLen > BigInt(MAX_FRAME_PAYLOAD_BYTES)) { + throw new Error('WebSocket frame payload exceeds maximum allowed size'); + } + payloadLen = Number(extendedLen); + offset = 10; + } + + if (payloadLen > MAX_FRAME_PAYLOAD_BYTES) { + throw new Error('WebSocket frame payload exceeds maximum allowed size'); + } + + const maskOffset = offset; + const dataOffset = offset + 4; + const totalLen = dataOffset + payloadLen; + if (buffer.length < totalLen) return null; + + const mask = buffer.slice(maskOffset, dataOffset); + const data = Buffer.alloc(payloadLen); + for (let i = 0; i < payloadLen; i++) { + data[i] = buffer[dataOffset + i] ^ mask[i % 4]; + } + + return { opcode, payload: data, bytesConsumed: totalLen }; +} + +// ========== Configuration ========== + +const PORT_FILE = process.env.BRAINSTORM_PORT_FILE || null; +const randomPort = () => 49152 + Math.floor(Math.random() * 16383); +// Prefer an explicit port, else the port this session last bound (so a restart +// reuses it and an already-open browser tab reconnects), else a random high port. +function preferredPort() { + if (process.env.BRAINSTORM_PORT) return Number(process.env.BRAINSTORM_PORT); + if (PORT_FILE) { + try { + const p = Number(fs.readFileSync(PORT_FILE, 'utf-8').trim()); + if (Number.isInteger(p) && p > 1023 && p < 65536) return p; + } catch (e) { /* no prior port recorded */ } + } + return randomPort(); +} +let PORT = preferredPort(); +const HOST = process.env.BRAINSTORM_HOST || '127.0.0.1'; +const URL_HOST = process.env.BRAINSTORM_URL_HOST || (HOST === '127.0.0.1' ? 'localhost' : HOST); +const SESSION_DIR = process.env.BRAINSTORM_DIR || '/tmp/brainstorm'; +const CONTENT_DIR = path.join(SESSION_DIR, 'content'); +const STATE_DIR = path.join(SESSION_DIR, 'state'); +const SUPERPOWERS_VERSION = readSuperpowersVersion(); +const SUPERPOWERS_BRAND_IMAGE_URL = 'https://primeradiant.com/brand/superpowers-visual-brainstorming-logo.png'; +const TELEMETRY_DISABLE_ENV_VARS = [ + 'SUPERPOWERS_DISABLE_TELEMETRY', + 'DISABLE_TELEMETRY', + 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC' +]; +const SUPERPOWERS_TELEMETRY_DISABLED = TELEMETRY_DISABLE_ENV_VARS.some(name => isTruthyEnv(process.env[name])); +let ownerPid = process.env.BRAINSTORM_OWNER_PID ? Number(process.env.BRAINSTORM_OWNER_PID) : null; + +// Per-session secret key. The companion is reachable by any local browser tab +// and, when bound to a non-loopback host, by any host that can route to it. +// The key authenticates the real client uniformly across loopback, tunnel, and +// remote binds — and defeats DNS rebinding — where a Host/Origin allowlist +// cannot. It rides the served URL as ?key= and is mirrored into a cookie on +// first load so same-origin subresources and the WebSocket carry it for free. +// Persisted alongside the port (BRAINSTORM_TOKEN_FILE) so a restart keeps the +// same key and an already-open tab's cookie still validates. +const TOKEN_FILE = process.env.BRAINSTORM_TOKEN_FILE || null; +function generateToken() { + return crypto.randomBytes(32).toString('hex'); +} + +function chmodOwnerOnly(file) { + try { fs.chmodSync(file, 0o600); } catch (e) { /* best effort */ } +} + +function initialToken() { + if (process.env.BRAINSTORM_TOKEN) { + return { value: process.env.BRAINSTORM_TOKEN, source: 'env' }; + } + if (TOKEN_FILE) { + try { + const t = fs.readFileSync(TOKEN_FILE, 'utf-8').trim(); + if (/^[0-9a-f]{32,}$/i.test(t)) { + chmodOwnerOnly(TOKEN_FILE); + return { value: t, source: 'file' }; + } + } catch (e) { /* no prior token recorded */ } + } + return { value: generateToken(), source: 'generated' }; +} + +const tokenInfo = initialToken(); +let TOKEN = tokenInfo.value; +let tokenSource = tokenInfo.source; +let COOKIE_NAME = 'brainstorm-key-' + PORT; // refined to the actual bound port in onListen + +const MIME_TYPES = { + '.html': 'text/html', '.css': 'text/css', '.js': 'application/javascript', + '.json': 'application/json', '.png': 'image/png', '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.svg': 'image/svg+xml' +}; + +// ========== Templates and Constants ========== + +function waitingPage() { + return renderBranding(` + +Brainstorm Companion + + +

Brainstorm Companion

+

Waiting for the agent to push a screen...

`); +} + +const FORBIDDEN_PAGE = ` + +Session key required + + +

Session key required

+

This page needs the full URL your coding agent gave you, including the +?key=… part. Copy the complete URL and open it again.

`; + +function bootstrapPage(key) { + const jsonKey = JSON.stringify(String(key)); + return ` + +Opening Brainstorm Companion + + + +`; +} + +const frameTemplate = fs.readFileSync(path.join(__dirname, 'frame-template.html'), 'utf-8'); +const helperScript = fs.readFileSync(path.join(__dirname, 'helper.js'), 'utf-8'); +const helperInjection = ''; + +// ========== Helper Functions ========== + +function readSuperpowersVersion() { + const root = path.join(__dirname, '../../..'); + const manifests = [ + path.join(root, 'package.json'), + path.join(root, '.codex-plugin/plugin.json') + ]; + + for (const manifest of manifests) { + try { + const data = JSON.parse(fs.readFileSync(manifest, 'utf-8')); + if (data.version) return String(data.version); + } catch (e) { + // Packaged Codex plugins omit package.json; try the next manifest. + } + } + + return 'unknown'; +} + +function isTruthyEnv(value) { + if (!value) return false; + const normalized = String(value).trim().toLowerCase(); + if (!normalized) return false; + return !['0', 'false', 'no', 'off'].includes(normalized); +} + +function escapeHtmlText(value) { + return String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function brandMarkup() { + const version = escapeHtmlText(SUPERPOWERS_VERSION); + const text = SUPERPOWERS_TELEMETRY_DISABLED + ? 'Prime Radiant Superpowers v' + version + : 'Superpowers v' + version; + const logo = SUPERPOWERS_TELEMETRY_DISABLED + ? '' + : ''; + + return ''; +} + +function renderBranding(html) { + return html.split('').join(brandMarkup()); +} + +function isFullDocument(html) { + const trimmed = html.trimStart().toLowerCase(); + return trimmed.startsWith('', content); +} + +function getNewestScreen() { + const files = fs.readdirSync(CONTENT_DIR) + .filter(f => !f.startsWith('.') && f.endsWith('.html')) + .map(f => { + const fp = path.join(CONTENT_DIR, f); + if (!isRegularFileInsideContentDir(fp)) return null; + return { path: fp, mtime: fs.statSync(fp).mtime.getTime() }; + }) + .filter(Boolean) + .sort((a, b) => b.mtime - a.mtime); + return files.length > 0 ? files[0].path : null; +} + +function urlHostForHttp(host) { + const h = String(host); + if (h.startsWith('[') && h.endsWith(']')) return h; + return h.includes(':') ? '[' + h + ']' : h; +} + +function companionUrl() { + return 'http://' + urlHostForHttp(URL_HOST) + ':' + PORT + '/?key=' + TOKEN; +} + +function browserLauncherForPlatform(url, { + platform = process.platform, + osRelease = require('os').release(), + env = process.env +} = {}) { + const isWSL = platform === 'linux' && /microsoft/i.test(osRelease); + if (platform === 'darwin') return { bin: 'open', args: [url] }; + if (platform === 'win32' || isWSL) { + return { bin: 'rundll32.exe', args: ['url.dll,FileProtocolHandler', url] }; + } + if (env.DISPLAY || env.WAYLAND_DISPLAY) return { bin: 'xdg-open', args: [url] }; + return null; +} + +function isRegularFileInsideContentDir(filePath) { + let stat, realContentDir, realFilePath; + try { + stat = fs.lstatSync(filePath); + if (stat.isSymbolicLink()) return false; + if (!stat.isFile()) return false; + if (stat.nlink !== 1) return false; + realContentDir = fs.realpathSync(CONTENT_DIR); + realFilePath = fs.realpathSync(filePath); + } catch (e) { + return false; + } + return realFilePath.startsWith(realContentDir + path.sep); +} + +// ========== Authentication ========== + +function timingSafeEqualStr(a, b) { + const ab = Buffer.from(String(a)); + const bb = Buffer.from(String(b)); + if (ab.length !== bb.length) return false; + return crypto.timingSafeEqual(ab, bb); +} + +function parseCookies(header) { + const out = {}; + if (!header) return out; + for (const part of header.split(';')) { + const eq = part.indexOf('='); + if (eq < 0) continue; + out[part.slice(0, eq).trim()] = part.slice(eq + 1).trim(); + } + return out; +} + +// A request is authorized if it carries the session key as ?key= or as the +// session cookie. Both are compared in constant time. +function isAuthorized(req) { + const q = req.url.indexOf('?'); + if (q >= 0) { + const params = new URLSearchParams(req.url.slice(q + 1)); + if (params.has('key')) { + const key = params.get('key'); + return Boolean(key && timingSafeEqualStr(key, TOKEN)); + } + } + const cookie = parseCookies(req.headers['cookie'])[COOKIE_NAME]; + if (cookie && timingSafeEqualStr(cookie, TOKEN)) return true; + return false; +} + +function pathnameOf(url) { + const q = url.indexOf('?'); + return q >= 0 ? url.slice(0, q) : url; +} + +function queryKey(url) { + const q = url.indexOf('?'); + if (q < 0) return null; + return new URLSearchParams(url.slice(q + 1)).get('key'); +} + +function securityHeaders(headers = {}) { + return { + 'Referrer-Policy': 'no-referrer', + 'Cache-Control': 'no-store', + 'X-Frame-Options': 'DENY', + 'Content-Security-Policy': "frame-ancestors 'none'", + 'Cross-Origin-Resource-Policy': 'same-origin', + ...headers + }; +} + +function isAllowedWebSocketOrigin(req) { + const origin = req.headers.origin; + if (!origin) return true; + const host = req.headers.host; + if (!host) return false; + return origin === 'http://' + host; +} + +// ========== HTTP Request Handler ========== + +function handleRequest(req, res) { + if (!isAuthorized(req)) { + res.writeHead(403, securityHeaders({ 'Content-Type': 'text/html; charset=utf-8' })); + res.end(FORBIDDEN_PAGE); + return; + } + touchActivity(); // only authorized requests count as activity + + // Mirror the key into a cookie so same-origin subresources (/files/*) can + // authenticate after bootstrap. HttpOnly keeps it away from page scripts; the + // WebSocket Origin check below is what blocks cross-origin localhost injection. + res.setHeader('Set-Cookie', + COOKIE_NAME + '=' + TOKEN + '; HttpOnly; SameSite=Strict; Path=/'); + + const pathname = pathnameOf(req.url); + const keyFromQuery = queryKey(req.url); + if (req.method === 'GET' && pathname === '/' && keyFromQuery && timingSafeEqualStr(keyFromQuery, TOKEN)) { + res.writeHead(200, securityHeaders({ 'Content-Type': 'text/html; charset=utf-8' })); + res.end(bootstrapPage(keyFromQuery)); + } else if (req.method === 'GET' && pathname === '/') { + const screenFile = getNewestScreen(); + let html = screenFile + ? (raw => isFullDocument(raw) ? raw : wrapInFrame(raw))(fs.readFileSync(screenFile, 'utf-8')) + : waitingPage(); + + if (html.includes('')) { + html = html.replace('', helperInjection + '\n'); + } else { + html += helperInjection; + } + + res.writeHead(200, securityHeaders({ 'Content-Type': 'text/html; charset=utf-8' })); + res.end(html); + } else if (req.method === 'GET' && pathname.startsWith('/files/')) { + const fileName = path.basename(pathname.slice(7)); + const filePath = path.join(CONTENT_DIR, fileName); + // Reject empty/dotfile names and anything that isn't a regular file — + // `/files/` would otherwise resolve to CONTENT_DIR and crash readFileSync (EISDIR). + if (!fileName || fileName.startsWith('.') || !isRegularFileInsideContentDir(filePath)) { + res.writeHead(404, securityHeaders()); + res.end('Not found'); + return; + } + const ext = path.extname(filePath).toLowerCase(); + const contentType = MIME_TYPES[ext] || 'application/octet-stream'; + res.writeHead(200, securityHeaders({ 'Content-Type': contentType })); + res.end(fs.readFileSync(filePath)); + } else { + res.writeHead(404, securityHeaders()); + res.end('Not found'); + } +} + +// ========== WebSocket Connection Handling ========== + +const clients = new Set(); + +function handleUpgrade(req, socket) { + if (!isAuthorized(req) || !isAllowedWebSocketOrigin(req)) { socket.destroy(); return; } + + const key = req.headers['sec-websocket-key']; + if (!key) { socket.destroy(); return; } + + const accept = computeAcceptKey(key); + socket.write( + 'HTTP/1.1 101 Switching Protocols\r\n' + + 'Upgrade: websocket\r\n' + + 'Connection: Upgrade\r\n' + + 'Sec-WebSocket-Accept: ' + accept + '\r\n\r\n' + ); + + let buffer = Buffer.alloc(0); + clients.add(socket); + + socket.on('data', (chunk) => { + buffer = Buffer.concat([buffer, chunk]); + while (buffer.length > 0) { + let result; + try { + result = decodeFrame(buffer); + } catch (e) { + socket.end(encodeFrame(OPCODES.CLOSE, Buffer.alloc(0))); + clients.delete(socket); + return; + } + if (!result) break; + buffer = buffer.slice(result.bytesConsumed); + + switch (result.opcode) { + case OPCODES.TEXT: + handleMessage(result.payload.toString()); + break; + case OPCODES.CLOSE: + socket.end(encodeFrame(OPCODES.CLOSE, Buffer.alloc(0))); + clients.delete(socket); + return; + case OPCODES.PING: + socket.write(encodeFrame(OPCODES.PONG, result.payload)); + break; + case OPCODES.PONG: + break; + default: { + const closeBuf = Buffer.alloc(2); + closeBuf.writeUInt16BE(1003); + socket.end(encodeFrame(OPCODES.CLOSE, closeBuf)); + clients.delete(socket); + return; + } + } + } + }); + + socket.on('close', () => clients.delete(socket)); + socket.on('error', () => clients.delete(socket)); +} + +function handleMessage(text) { + let event; + try { + event = JSON.parse(text); + } catch (e) { + console.error('Failed to parse WebSocket message:', e.message); + return; + } + touchActivity(); + console.log(JSON.stringify({ source: 'user-event', ...event })); + if (event && event.choice) { + const eventsFile = path.join(STATE_DIR, 'events'); + fs.appendFileSync(eventsFile, JSON.stringify(event) + '\n'); + } +} + +function broadcast(msg) { + const frame = encodeFrame(OPCODES.TEXT, Buffer.from(JSON.stringify(msg))); + for (const socket of clients) { + try { socket.write(frame); } catch (e) { clients.delete(socket); } + } +} + +// Best-effort: open the user's browser the first time a screen is actually ready +// to show. Skips when disabled, on a non-loopback (remote) bind, or when a +// browser is already connected. Override the launcher with BRAINSTORM_OPEN_CMD. +let browserOpened = false; +function maybeOpenBrowser() { + if (browserOpened) return; + browserOpened = true; + if (!process.env.BRAINSTORM_OPEN) return; // opt-in: only after the user approves the companion + if (HOST !== '127.0.0.1' && HOST !== 'localhost') return; + if (clients.size > 0) return; // the user already opened it + const url = companionUrl(); // must carry the key or the gate 403s it + const cp = require('child_process'); + // Operator-provided launcher: run as given (this env var is trusted operator input). + if (process.env.BRAINSTORM_OPEN_CMD) { + try { cp.exec(process.env.BRAINSTORM_OPEN_CMD + ' ' + JSON.stringify(url), () => {}); } catch (e) { /* best effort */ } + return; + } + // Platform launchers: pass the URL as an argv element via execFile (no shell), + // so a url-host containing shell metacharacters can't inject a command. + const launcher = browserLauncherForPlatform(url); + if (!launcher) return; // headless: nothing to open + try { cp.execFile(launcher.bin, launcher.args, () => {}); } catch (e) { /* best effort */ } +} + +// ========== Activity Tracking ========== + +// Idle timeout: shut down after this long with no activity. Default 4 hours; +// override with BRAINSTORM_IDLE_TIMEOUT_MS (start-server.sh: --idle-timeout-minutes). +const IDLE_TIMEOUT_MS = (() => { + const ms = Number(process.env.BRAINSTORM_IDLE_TIMEOUT_MS); + return Number.isFinite(ms) && ms > 0 ? ms : 4 * 60 * 60 * 1000; +})(); +// How often the watchdog checks for owner-death / idleness. Configurable mainly +// so tests can run fast; production default is 60s. +const LIFECYCLE_CHECK_MS = (() => { + const ms = Number(process.env.BRAINSTORM_LIFECYCLE_CHECK_MS); + return Number.isFinite(ms) && ms > 0 ? ms : 60 * 1000; +})(); +let lastActivity = Date.now(); + +function touchActivity() { + lastActivity = Date.now(); +} + +// ========== File Watching ========== + +const debounceTimers = new Map(); + +// ========== Server Startup ========== + +function startServer() { + if (!fs.existsSync(CONTENT_DIR)) fs.mkdirSync(CONTENT_DIR, { recursive: true }); + if (!fs.existsSync(STATE_DIR)) fs.mkdirSync(STATE_DIR, { recursive: true }); + + // Track known files to distinguish new screens from updates. + // macOS fs.watch reports 'rename' for both new files and overwrites, + // so we can't rely on eventType alone. + const knownFiles = new Set( + fs.readdirSync(CONTENT_DIR).filter(f => !f.startsWith('.') && f.endsWith('.html')) + ); + + const server = http.createServer(handleRequest); + server.on('upgrade', handleUpgrade); + + const watcher = fs.watch(CONTENT_DIR, (eventType, filename) => { + if (!filename || filename.startsWith('.') || !filename.endsWith('.html')) return; + + if (debounceTimers.has(filename)) clearTimeout(debounceTimers.get(filename)); + debounceTimers.set(filename, setTimeout(() => { + debounceTimers.delete(filename); + const filePath = path.join(CONTENT_DIR, filename); + + if (!fs.existsSync(filePath)) return; // file was deleted + touchActivity(); + + if (!knownFiles.has(filename)) { + knownFiles.add(filename); + const eventsFile = path.join(STATE_DIR, 'events'); + if (fs.existsSync(eventsFile)) fs.unlinkSync(eventsFile); + console.log(JSON.stringify({ type: 'screen-added', file: filePath })); + maybeOpenBrowser(); + } else { + console.log(JSON.stringify({ type: 'screen-updated', file: filePath })); + } + + broadcast({ type: 'reload' }); + }, 100)); + }); + watcher.on('error', (err) => console.error('fs.watch error:', err.message)); + + function shutdown(reason) { + console.log(JSON.stringify({ type: 'server-stopped', reason })); + const infoFile = path.join(STATE_DIR, 'server-info'); + if (fs.existsSync(infoFile)) fs.unlinkSync(infoFile); + fs.writeFileSync( + path.join(STATE_DIR, 'server-stopped'), + JSON.stringify({ reason, timestamp: Date.now() }) + '\n' + ); + watcher.close(); + clearInterval(lifecycleCheck); + // Close any upgraded WebSocket sockets so server.close() can complete and + // the process actually exits instead of lingering on an open connection. + for (const socket of clients) { + try { socket.destroy(); } catch (e) { /* already gone */ } + } + server.close(() => process.exit(0)); + } + + function ownerAlive() { + if (!ownerPid) return true; + try { process.kill(ownerPid, 0); return true; } catch (e) { return e.code === 'EPERM'; } + } + + // Periodically exit if the owner process died or we've been idle too long. + const lifecycleCheck = setInterval(() => { + if (!ownerAlive()) shutdown('owner process exited'); + else if (Date.now() - lastActivity > IDLE_TIMEOUT_MS) shutdown('idle timeout'); + }, LIFECYCLE_CHECK_MS); + lifecycleCheck.unref(); + + // Validate owner PID at startup. If it's already dead, the PID resolution + // was wrong (common on WSL, Tailscale SSH, and cross-user scenarios). + // Disable monitoring and rely on the idle timeout instead. + if (ownerPid) { + try { process.kill(ownerPid, 0); } + catch (e) { + if (e.code !== 'EPERM') { + console.log(JSON.stringify({ type: 'owner-pid-invalid', pid: ownerPid, reason: 'dead at startup' })); + ownerPid = null; + } + } + } + + // If the preferred port is already taken (e.g. a previous server is still + // alive), fall back to a random port once instead of failing. + let triedFallback = false; + + function onListen() { + // Cookie name keys on the ACTUAL bound port (may differ from the preferred + // one after an EADDRINUSE fallback) so it can't collide with another server's + // cookie in the shared localhost jar. + COOKIE_NAME = 'brainstorm-key-' + PORT; + // Record the bound port AND token so the next restart of this session reuses + // them — but ONLY when we got our preferred port. On a fallback we bound a + // *different* port because someone else holds the preferred one; persisting + // would overwrite the shared files and strand that other session's open tab. + if (PORT_FILE && !triedFallback) { + try { fs.writeFileSync(PORT_FILE, String(PORT)); } catch (e) { /* best effort */ } + if (TOKEN_FILE) { + try { + fs.writeFileSync(TOKEN_FILE, TOKEN, { mode: 0o600 }); + chmodOwnerOnly(TOKEN_FILE); + } catch (e) { /* best effort */ } + } + } + const info = JSON.stringify({ + type: 'server-started', port: Number(PORT), host: HOST, + url_host: URL_HOST, url: companionUrl(), + screen_dir: CONTENT_DIR, state_dir: STATE_DIR, idle_timeout_ms: IDLE_TIMEOUT_MS + }); + console.log(info); + // server-info embeds the key — keep it owner-only. + fs.writeFileSync(path.join(STATE_DIR, 'server-info'), info + '\n', { mode: 0o600 }); + } + + server.on('error', (err) => { + if (err.code === 'EADDRINUSE' && !triedFallback) { + if (tokenSource === 'env') { + console.error('Server failed to bind: preferred port is in use and BRAINSTORM_TOKEN is set; refusing fallback with explicit token'); + process.exit(1); + } + triedFallback = true; + PORT = randomPort(); + if (tokenSource === 'file') { + TOKEN = generateToken(); + tokenSource = 'generated-fallback'; + } + server.listen(PORT, HOST, onListen); + } else { + console.error('Server failed to bind:', err.message); + process.exit(1); + } + }); + server.listen(PORT, HOST, onListen); +} + +if (require.main === module) { + startServer(); +} + +module.exports = { + computeAcceptKey, + encodeFrame, + decodeFrame, + browserLauncherForPlatform, + OPCODES, + MAX_FRAME_PAYLOAD_BYTES +}; diff --git a/.agents/skills/brainstorming/scripts/start-server.sh b/.agents/skills/brainstorming/scripts/start-server.sh new file mode 100755 index 0000000..016a8e4 --- /dev/null +++ b/.agents/skills/brainstorming/scripts/start-server.sh @@ -0,0 +1,209 @@ +#!/usr/bin/env bash +# Start the brainstorm server and output connection info +# Usage: start-server.sh [--project-dir ] [--host ] [--url-host ] [--foreground] [--background] +# +# Starts server on a random high port, outputs JSON with URL. +# Each session gets its own directory to avoid conflicts. +# +# Options: +# --project-dir Store session files under /.superpowers/brainstorm/ +# instead of /tmp. Files persist after server stops. +# --host Host/interface to bind (default: 127.0.0.1). +# Use 0.0.0.0 in remote/containerized environments. +# --url-host Hostname shown in returned URL JSON. +# --idle-timeout-minutes Shut down after n minutes idle (default 240 = 4h). +# --open Auto-open the browser on the first screen (use only +# after the user approves the visual companion). +# --foreground Run server in the current terminal (no backgrounding). +# --background Force background mode (overrides Codex auto-foreground). + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +# Parse arguments +PROJECT_DIR="" +FOREGROUND="false" +FORCE_BACKGROUND="false" +BIND_HOST="127.0.0.1" +URL_HOST="" +IDLE_TIMEOUT_MINUTES="" +while [[ $# -gt 0 ]]; do + case "$1" in + --project-dir) + PROJECT_DIR="$2" + shift 2 + ;; + --host) + BIND_HOST="$2" + shift 2 + ;; + --url-host) + URL_HOST="$2" + shift 2 + ;; + --idle-timeout-minutes) + IDLE_TIMEOUT_MINUTES="$2" + shift 2 + ;; + --open) + export BRAINSTORM_OPEN=1 + shift + ;; + --foreground|--no-daemon) + FOREGROUND="true" + shift + ;; + --background|--daemon) + FORCE_BACKGROUND="true" + shift + ;; + *) + echo "{\"error\": \"Unknown argument: $1\"}" + exit 1 + ;; + esac +done + +if [[ -z "$URL_HOST" ]]; then + if [[ "$BIND_HOST" == "127.0.0.1" || "$BIND_HOST" == "localhost" ]]; then + URL_HOST="localhost" + else + URL_HOST="$BIND_HOST" + fi +fi + +if [[ -n "$IDLE_TIMEOUT_MINUTES" ]]; then + if ! [[ "$IDLE_TIMEOUT_MINUTES" =~ ^[0-9]+$ ]] || [[ "$IDLE_TIMEOUT_MINUTES" -lt 1 ]]; then + echo "{\"error\": \"--idle-timeout-minutes must be a positive integer\"}" + exit 1 + fi + export BRAINSTORM_IDLE_TIMEOUT_MS=$(( IDLE_TIMEOUT_MINUTES * 60 * 1000 )) +fi + +is_windows_like_shell() { + case "${OSTYPE:-}" in + msys*|cygwin*|mingw*) return 0 ;; + esac + if [[ -n "${MSYSTEM:-}" ]]; then + return 0 + fi + local uname_s + uname_s="$(uname -s 2>/dev/null || true)" + case "$uname_s" in + MSYS*|MINGW*|CYGWIN*) return 0 ;; + esac + return 1 +} + +# Some environments reap detached/background processes. Auto-foreground when detected. +if [[ -n "${CODEX_CI:-}" && "$FOREGROUND" != "true" && "$FORCE_BACKGROUND" != "true" ]]; then + FOREGROUND="true" +fi + +# Windows/Git Bash reaps nohup background processes. Auto-foreground when detected. +if [[ "$FOREGROUND" != "true" && "$FORCE_BACKGROUND" != "true" ]]; then + if is_windows_like_shell; then + FOREGROUND="true" + fi +fi + +# Session files (server.log, server-info, .last-token) embed the session key — +# keep everything this script and the server create owner-only. +umask 077 + +# Generate unique session directory +SESSION_ID="$$-$(date +%s)" + +if [[ -n "$PROJECT_DIR" ]]; then + SESSION_DIR="${PROJECT_DIR}/.superpowers/brainstorm/${SESSION_ID}" + # Persist the bound port and key per project so a restart reuses them and an + # already-open browser tab reconnects to the same URL with a valid cookie. + export BRAINSTORM_PORT_FILE="${PROJECT_DIR}/.superpowers/brainstorm/.last-port" + export BRAINSTORM_TOKEN_FILE="${PROJECT_DIR}/.superpowers/brainstorm/.last-token" +else + SESSION_DIR="/tmp/brainstorm-${SESSION_ID}" +fi + +STATE_DIR="${SESSION_DIR}/state" +PID_FILE="${STATE_DIR}/server.pid" +LOG_FILE="${STATE_DIR}/server.log" +SERVER_ID_FILE="${STATE_DIR}/server-instance-id" + +# Create fresh session directory with content and state peers +mkdir -p "${SESSION_DIR}/content" "$STATE_DIR" + +SERVER_ID="" +if [[ -r /dev/urandom ]]; then + SERVER_ID="$(od -An -N24 -tx1 /dev/urandom 2>/dev/null | tr -d ' \n' || true)" +fi +if ! [[ "$SERVER_ID" =~ ^[A-Za-z0-9_-]{32,64}$ ]]; then + SERVER_ID="$(printf '%08x%08x%08x%08x' "$$" "$(date +%s)" "${RANDOM:-0}" "${RANDOM:-0}")" +fi +printf '%s\n' "$SERVER_ID" > "$SERVER_ID_FILE" +chmod 600 "$SERVER_ID_FILE" 2>/dev/null || true + +# Kill any existing server +if [[ -f "$PID_FILE" ]]; then + old_pid=$(cat "$PID_FILE") + kill "$old_pid" 2>/dev/null + rm -f "$PID_FILE" +fi + +cd "$SCRIPT_DIR" || exit 1 + +# Resolve the harness PID (grandparent of this script). +# $PPID is the ephemeral shell the harness spawned to run us — it dies +# when this script exits. The harness itself is $PPID's parent. +OWNER_PID="$(ps -o ppid= -p "$PPID" 2>/dev/null | tr -d ' ')" +if [[ -z "$OWNER_PID" || "$OWNER_PID" == "1" ]]; then + OWNER_PID="$PPID" +fi + +# Windows/MSYS2: Node.js cannot see POSIX PIDs from the MSYS2 namespace. +# Passing a PID node cannot verify causes server to log owner-pid-invalid +# and self-terminate at the 60-second lifecycle check. Clear it so the +# watchdog is disabled and the idle timeout becomes the only shutdown trigger. +if is_windows_like_shell; then + OWNER_PID="" +fi + +# Foreground mode for environments that reap detached/background processes. +if [[ "$FOREGROUND" == "true" ]]; then + env BRAINSTORM_DIR="$SESSION_DIR" BRAINSTORM_HOST="$BIND_HOST" BRAINSTORM_URL_HOST="$URL_HOST" BRAINSTORM_OWNER_PID="$OWNER_PID" node server.cjs "--brainstorm-server-id=$SERVER_ID" & + SERVER_PID=$! + echo "$SERVER_PID" > "$PID_FILE" + wait "$SERVER_PID" + exit $? +fi + +# Start server, capturing output to log file +# Use nohup to survive shell exit; disown to remove from job table +nohup env BRAINSTORM_DIR="$SESSION_DIR" BRAINSTORM_HOST="$BIND_HOST" BRAINSTORM_URL_HOST="$URL_HOST" BRAINSTORM_OWNER_PID="$OWNER_PID" node server.cjs "--brainstorm-server-id=$SERVER_ID" > "$LOG_FILE" 2>&1 & +SERVER_PID=$! +disown "$SERVER_PID" 2>/dev/null +echo "$SERVER_PID" > "$PID_FILE" + +# Wait for server-started message (check log file) +for _ in {1..50}; do + if grep -q "server-started" "$LOG_FILE" 2>/dev/null; then + # Verify server is still alive after a short window (catches process reapers) + alive="true" + for _ in {1..20}; do + if ! kill -0 "$SERVER_PID" 2>/dev/null; then + alive="false" + break + fi + sleep 0.1 + done + if [[ "$alive" != "true" ]]; then + echo "{\"error\": \"Server started but was killed. Retry in a persistent terminal with: $SCRIPT_DIR/start-server.sh${PROJECT_DIR:+ --project-dir $PROJECT_DIR} --host $BIND_HOST --url-host $URL_HOST --foreground\"}" + exit 1 + fi + grep "server-started" "$LOG_FILE" | head -1 + exit 0 + fi + sleep 0.1 +done + +# Timeout - server didn't start +echo '{"error": "Server failed to start within 5 seconds"}' +exit 1 diff --git a/.agents/skills/brainstorming/scripts/stop-server.sh b/.agents/skills/brainstorming/scripts/stop-server.sh new file mode 100755 index 0000000..7cacfe9 --- /dev/null +++ b/.agents/skills/brainstorming/scripts/stop-server.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +# Stop the brainstorm server and clean up +# Usage: stop-server.sh +# +# Kills the server process. Only deletes session directory if it's +# under /tmp (ephemeral). Persistent directories (.superpowers/) are +# kept so mockups can be reviewed later. + +SESSION_DIR="$1" + +if [[ -z "$SESSION_DIR" ]]; then + echo '{"error": "Usage: stop-server.sh "}' + exit 1 +fi + +STATE_DIR="${SESSION_DIR}/state" +PID_FILE="${STATE_DIR}/server.pid" +SERVER_ID_FILE="${STATE_DIR}/server-instance-id" + +mark_stopped() { + local reason="$1" + rm -f "${STATE_DIR}/server-info" + printf '{"reason":"%s","timestamp":%s}\n' "$reason" "$(date +%s)" > "${STATE_DIR}/server-stopped" +} + +read_expected_server_id() { + [[ -f "$SERVER_ID_FILE" ]] || return 1 + local id + id="$(tr -d '\r\n' < "$SERVER_ID_FILE" 2>/dev/null || true)" + [[ "$id" =~ ^[A-Za-z0-9_-]{32,64}$ ]] || return 1 + printf '%s\n' "$id" +} + +command_line_for_pid() { + local pid="$1" + if [[ -r "/proc/$pid/cmdline" ]]; then + tr '\0' '\n' < "/proc/$pid/cmdline" 2>/dev/null || true + return 0 + fi + ps -ww -p "$pid" -o command= 2>/dev/null || ps -f -p "$pid" 2>/dev/null | sed '1d' || true +} + +command_has_server_id() { + local pid="$1" + local expected="$2" + local expected_arg="--brainstorm-server-id=$expected" + if [[ -r "/proc/$pid/cmdline" ]]; then + local arg + while IFS= read -r -d '' arg || [[ -n "$arg" ]]; do + [[ "$arg" == "$expected_arg" ]] && return 0 + done < "/proc/$pid/cmdline" + return 1 + fi + local command_line + command_line="$(command_line_for_pid "$pid")" + [[ -n "$command_line" ]] || return 1 + case " $command_line " in + *" $expected_arg "*) return 0 ;; + *) return 1 ;; + esac +} + +# Confirm a PID has this session's per-start instance id, not just a familiar +# process name. Ambiguous or legacy metadata fails closed as stale_pid. +is_brainstorm_server() { + kill -0 "$1" 2>/dev/null || return 1 + local expected_id + expected_id="$(read_expected_server_id)" || return 1 + command_has_server_id "$1" "$expected_id" || return 1 + return 0 +} + +if [[ -f "$PID_FILE" ]]; then + pid=$(cat "$PID_FILE") + + # Refuse to signal a PID we can't prove is our server. A stale pid file may + # point at an unrelated process after a reboot/PID wraparound. + if ! is_brainstorm_server "$pid"; then + rm -f "$PID_FILE" "$SERVER_ID_FILE" + mark_stopped "stale_pid" + echo '{"status": "stale_pid"}' + exit 0 + fi + + # Try to stop gracefully, fallback to force if still alive + kill "$pid" 2>/dev/null || true + + # Wait for graceful shutdown (up to ~2s) + for _ in {1..20}; do + if ! kill -0 "$pid" 2>/dev/null; then + break + fi + sleep 0.1 + done + + # If still running, escalate to SIGKILL + if kill -0 "$pid" 2>/dev/null; then + kill -9 "$pid" 2>/dev/null || true + + # Give SIGKILL a moment to take effect + sleep 0.1 + fi + + if kill -0 "$pid" 2>/dev/null; then + echo '{"status": "failed", "error": "process still running"}' + exit 1 + fi + + rm -f "$PID_FILE" "$SERVER_ID_FILE" "${STATE_DIR}/server.log" + mark_stopped "stop-server.sh" + + # Only delete ephemeral /tmp directories + if [[ "$SESSION_DIR" == /tmp/* ]]; then + rm -rf "$SESSION_DIR" + fi + + echo '{"status": "stopped"}' +else + echo '{"status": "not_running"}' +fi diff --git a/.agents/skills/brainstorming/spec-document-reviewer-prompt.md b/.agents/skills/brainstorming/spec-document-reviewer-prompt.md new file mode 100644 index 0000000..6099312 --- /dev/null +++ b/.agents/skills/brainstorming/spec-document-reviewer-prompt.md @@ -0,0 +1,49 @@ +# Spec Document Reviewer Prompt Template + +Use this template when dispatching a spec document reviewer subagent. + +**Purpose:** Verify the spec is complete, consistent, and ready for implementation planning. + +**Dispatch after:** Spec document is written to docs/superpowers/specs/ + +``` +Subagent (general-purpose): + description: "Review spec document" + prompt: | + You are a spec document reviewer. Verify this spec is complete and ready for planning. + + **Spec to review:** [SPEC_FILE_PATH] + + ## What to Check + + | Category | What to Look For | + |----------|------------------| + | Completeness | TODOs, placeholders, "TBD", incomplete sections | + | Consistency | Internal contradictions, conflicting requirements | + | Clarity | Requirements ambiguous enough to cause someone to build the wrong thing | + | Scope | Focused enough for a single plan — not covering multiple independent subsystems | + | YAGNI | Unrequested features, over-engineering | + + ## Calibration + + **Only flag issues that would cause real problems during implementation planning.** + A missing section, a contradiction, or a requirement so ambiguous it could be + interpreted two different ways — those are issues. Minor wording improvements, + stylistic preferences, and "sections less detailed than others" are not. + + Approve unless there are serious gaps that would lead to a flawed plan. + + ## Output Format + + ## Spec Review + + **Status:** Approved | Issues Found + + **Issues (if any):** + - [Section X]: [specific issue] - [why it matters for planning] + + **Recommendations (advisory, do not block approval):** + - [suggestions for improvement] +``` + +**Reviewer returns:** Status, Issues (if any), Recommendations diff --git a/.agents/skills/brainstorming/visual-companion.md b/.agents/skills/brainstorming/visual-companion.md new file mode 100644 index 0000000..c145e64 --- /dev/null +++ b/.agents/skills/brainstorming/visual-companion.md @@ -0,0 +1,299 @@ +# Visual Companion Guide + +Browser-based visual brainstorming companion for showing mockups, diagrams, and options. + +## When to Use + +Decide per-question, not per-session. The test: **would the user understand this better by seeing it than reading it?** + +**Use the browser** when the content itself is visual: + +- **UI mockups** — wireframes, layouts, navigation structures, component designs +- **Architecture diagrams** — system components, data flow, relationship maps +- **Side-by-side visual comparisons** — comparing two layouts, two color schemes, two design directions +- **Design polish** — when the question is about look and feel, spacing, visual hierarchy +- **Spatial relationships** — state machines, flowcharts, entity relationships rendered as diagrams + +**Use the terminal** when the content is text or tabular: + +- **Requirements and scope questions** — "what does X mean?", "which features are in scope?" +- **Conceptual A/B/C choices** — picking between approaches described in words +- **Tradeoff lists** — pros/cons, comparison tables +- **Technical decisions** — API design, data modeling, architectural approach selection +- **Clarifying questions** — anything where the answer is words, not a visual preference + +A question *about* a UI topic is not automatically a visual question. "What kind of wizard do you want?" is conceptual — use the terminal. "Which of these wizard layouts feels right?" is visual — use the browser. + +## How It Works + +The server watches a directory for HTML files and serves the newest one to the browser. You write HTML content to `screen_dir`, the user sees it in their browser and can click to select options. Selections are recorded to `state_dir/events` that you read on your next turn. + +**Content fragments vs full documents:** If your HTML file starts with `/.superpowers/brainstorm/` for the session directory. + +**Note:** Pass the project root as `--project-dir` so mockups persist in `.superpowers/brainstorm/` and survive server restarts. Without it, files go to `/tmp` and get cleaned up. Remind the user to add `.superpowers/` to `.gitignore` if it's not already there. + +**Launching the server by platform:** + +**Claude Code:** +```bash +# Default mode works — the script backgrounds the server itself. +scripts/start-server.sh --project-dir /path/to/project --open +``` + +On Windows, the script auto-detects and switches to foreground mode (which blocks the tool call). Use `run_in_background: true` on the Bash tool call so the server survives across conversation turns, then read `$STATE_DIR/server-info` on the next turn to get the URL and port. + +**Codex:** +```bash +# Codex reaps background processes. The script auto-detects CODEX_CI and +# switches to foreground mode. Run it normally — no extra flags needed. +scripts/start-server.sh --project-dir /path/to/project --open +``` + +**Gemini CLI:** +```bash +# Use --foreground and set is_background: true on your shell tool call +# so the process survives across turns +scripts/start-server.sh --project-dir /path/to/project --open --foreground +``` + +**Copilot CLI:** +```bash +# Start it with Copilot CLI's non-blocking/background shell mechanism so the +# server survives across turns. Keep --foreground so the harness, not the +# script, owns backgrounding. The launcher is a .sh, so invoke it via bash +# (on Windows, call Git Bash's bash.exe from the PowerShell tool). +bash scripts/start-server.sh --project-dir /path/to/project --open --foreground +``` + +**Other environments:** The server must keep running in the background across conversation turns. If your environment reaps detached processes, use `--foreground` and launch the command with your platform's background execution mechanism. + +If the URL is unreachable from your browser (common in remote/containerized setups), bind a non-loopback host: + +```bash +scripts/start-server.sh \ + --project-dir /path/to/project \ + --host 0.0.0.0 \ + --url-host localhost +``` + +Use `--url-host` to control what hostname is printed in the returned URL JSON. + +## The Loop + +1. **Check server is alive**, then **write HTML** to a new file in `screen_dir`: + - **Required: confirm the server is alive before referring to the URL or pushing a screen.** Check that `$STATE_DIR/server-info` exists and `$STATE_DIR/server-stopped` does not. If it has shut down, restart it with `start-server.sh` using the **same `--project-dir`** — it reuses the same port, so the user's open tab reconnects on its own (it shows a "paused" overlay while the server is down) and you don't need to send a new URL. The server auto-exits after 4 hours idle (configurable with `--idle-timeout-minutes`). + - Use semantic filenames: `platform.html`, `visual-style.html`, `layout.html` + - **Never reuse filenames** — each screen gets a fresh file + - Use your file-creation tool — **never use cat/heredoc** (dumps noise into terminal) + - Server automatically serves the newest file + +2. **Tell user what to expect and end your turn:** + - Remind them of the URL (every step, not just first) + - Give a brief text summary of what's on screen (e.g., "Showing 3 layout options for the homepage") + - Ask them to respond in the terminal: "Take a look and let me know what you think. Click to select an option if you'd like." + +3. **On your next turn** — after the user responds in the terminal: + - Read `$STATE_DIR/events` if it exists — this contains the user's browser interactions (clicks, selections) as JSON lines + - Merge with the user's terminal text to get the full picture + - The terminal message is the primary feedback; `state_dir/events` provides structured interaction data + +4. **Iterate or advance** — if feedback changes current screen, write a new file (e.g., `layout-v2.html`). Only move to the next question when the current step is validated. + +5. **Unload when returning to terminal** — when the next step doesn't need the browser (e.g., a clarifying question, a tradeoff discussion), push a waiting screen to clear the stale content: + + ```html + +
+

Continuing in terminal...

+
+ ``` + + This prevents the user from staring at a resolved choice while the conversation has moved on. When the next visual question comes up, push a new content file as usual. + +6. Repeat until done. + +## Writing Content Fragments + +Write just the content that goes inside the page. The server wraps it in the frame template automatically (header, theme CSS, connection status, and all interactive infrastructure). + +**Minimal example:** + +```html +

Which layout works better?

+

Consider readability and visual hierarchy

+ +
+
+
A
+
+

Single Column

+

Clean, focused reading experience

+
+
+
+
B
+
+

Two Column

+

Sidebar navigation with main content

+
+
+
+``` + +That's it. No ``, no CSS, no `