kin/tasks/KIN-013-spec.md

266 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

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

# KIN-013 — Settings + Obsidian Sync: Техническая спецификация
## Контекст
Фича добавляет:
1. Страницу Settings в GUI для управления конфигурацией проектов
2. Двусторонний Obsidian sync: decisions → .md-файлы, чекбоксы Obsidian → статус задач
Sync вызывается явно по кнопке (не демон), через API-эндпоинт.
---
## 1. Схема данных
### Изменение таблицы `projects`
Добавить колонку:
```sql
ALTER TABLE projects ADD COLUMN obsidian_vault_path TEXT;
```
**Миграция**: в `core/db.py``_migrate()`, по паттерну существующих миграций:
```python
if "obsidian_vault_path" not in proj_cols:
conn.execute("ALTER TABLE projects ADD COLUMN obsidian_vault_path TEXT")
conn.commit()
```
**Семантика**: путь к корневой папке Obsidian vault для данного проекта.
Пример: `/Users/grosfrumos/Library/Mobile Documents/iCloud~md~obsidian/Documents/MyVault`
---
## 2. Формат .md-файлов для decisions
### Расположение
```
{vault_path}/{project_id}/decisions/{id}-{slug}.md
```
Пример: `.../kin/decisions/42-proxy-jump-ssh-gotcha.md`
### Формат файла (YAML frontmatter + Markdown body)
```markdown
---
kin_decision_id: 42
project: kin
type: gotcha
category: testing
tags: [testing, mock, subprocess]
created_at: 2026-03-10
---
# Proxy через SSH не работает без ssh-agent
Описание: полный текст description из БД.
```
**Обоснование frontmatter**:
- Позволяет идентифицировать файл при импорте (поле `kin_decision_id`)
- Позволяет Obsidian показывать метаданные в Properties panel
- Поддерживает round-trip sync без парсинга имени файла
### Slug из заголовка
```python
import re
def _slug(title: str) -> str:
s = title.lower()
s = re.sub(r"[^a-zа-я0-9\s-]", "", s)
s = re.sub(r"\s+", "-", s.strip())
return s[:50]
```
---
## 3. Механизм двустороннего sync
### 3.1 Decisions → Obsidian (export)
- Создать/перезаписать `.md`-файл для каждого decision проекта
- Директория создаётся автоматически (`mkdir -p`)
- Если файл для `kin_decision_id` уже существует — перезаписать (идемпотентно)
- Решения, удалённые из БД → файлы НЕ удаляются (безопасно)
### 3.2 Obsidian чекбоксы → Tasks (import)
**Источник**: файлы `*.md` в `{vault_path}/{project_id}/tasks/`
Дополнительно: файлы `{vault_path}/{project_id}/*.md`
**Формат строки задачи**:
```
- [x] KIN-013 Title of the task
- [ ] KIN-014 Another task
```
**Алгоритм**:
1. Найти строки по паттерну: `^[-*]\s+\[([xX ])\]\s+([A-Z]+-\d+)\s+(.+)$`
2. Извлечь: `done` (bool), `task_id` (str), `title` (str)
3. Найти задачу в БД по `task_id`
4. Если `done=True` и `task.status != 'done'``update_task(conn, task_id, status='done')`
5. Если `done=False` → не трогать (не откатываем)
6. Если задача не найдена → пропустить (не создавать)
**Обоснование**: строгий маппинг только по task_id исключает случайное создание мусора.
### 3.3 Функция `sync_obsidian`
```python
def sync_obsidian(conn, project_id: str) -> dict:
"""
Returns:
{
"exported_decisions": int,
"tasks_updated": int,
"errors": list[str],
"vault_path": str
}
"""
```
---
## 4. Модуль `core/obsidian_sync.py`
### Публичный API модуля
```python
def export_decisions_to_md(
project_id: str,
decisions: list[dict],
vault_path: Path,
) -> list[Path]:
"""Экспортирует decisions в .md файлы Obsidian. Возвращает список созданных файлов."""
def parse_task_checkboxes(
vault_path: Path,
project_id: str,
) -> list[dict]:
"""Парсит *.md файлы в vault/{project_id}/tasks/ на чекбоксы с task ID.
Returns: [{"task_id": "KIN-013", "done": True, "title": "..."}]
"""
def sync_obsidian(conn, project_id: str) -> dict:
"""Оркестратор: export + import. Возвращает статистику."""
```
### Вспомогательные (приватные)
```python
def _slug(title: str) -> str # slug для имени файла
def _decision_to_md(decision: dict) -> str # форматирует .md с frontmatter
def _parse_frontmatter(text: str) -> dict # для будущего round-trip
```
### Зависимости
- Только стандартная библиотека Python: `pathlib`, `re`, `yaml` (через `import yaml`) или ручной YAML-парсер
- Важно: PyYAML может не быть установлен → использовать простой ручной вывод YAML-фронтматтера, парсинг через `re`
- Импортирует из `core.models`: `get_project`, `get_decisions`, `get_task`, `update_task`
- Импортирует из `core.db`: `get_connection`НЕ нужен, conn передаётся снаружи
---
## 5. API-эндпоинты
### 5.1 PATCH /api/projects/{project_id} — расширить
Добавить в `ProjectPatch`:
```python
class ProjectPatch(BaseModel):
execution_mode: str | None = None
autocommit_enabled: bool | None = None
obsidian_vault_path: str | None = None # новое поле
```
Обновить обработчик: если `obsidian_vault_path` provided → `update_project(conn, id, obsidian_vault_path=...)`
Убрать проверку "Nothing to update" → включить `obsidian_vault_path` в условие.
### 5.2 POST /api/projects/{project_id}/sync/obsidian — новый
```python
@app.post("/api/projects/{project_id}/sync/obsidian")
def sync_obsidian_endpoint(project_id: str):
conn = get_conn()
p = models.get_project(conn, project_id)
if not p:
conn.close()
raise HTTPException(404, ...)
if not p.get("obsidian_vault_path"):
conn.close()
raise HTTPException(400, "obsidian_vault_path not set for this project")
from core.obsidian_sync import sync_obsidian
result = sync_obsidian(conn, project_id)
conn.close()
return result
```
---
## 6. Frontend: Settings страница
### Маршрут
- Path: `/settings`
- Component: `web/frontend/src/views/SettingsView.vue`
- Регистрация в `main.ts`
### Навигация в `App.vue`
Добавить ссылку `Settings` в хедер рядом с `Kin`.
### SettingsView.vue — структура
```
Settings
├── Список проектов (v-for)
│ ├── Название + id
│ ├── Input: obsidian_vault_path (text input)
│ ├── [Save] кнопка → PATCH /api/projects/{id}
│ └── [Sync Obsidian] кнопка → POST /api/projects/{id}/sync/obsidian
│ └── Показывает результат: "Exported: 5 decisions, Updated: 2 tasks"
```
### api.ts — добавить методы
```typescript
// Обновить настройки проекта
patchProject(id: string, data: { obsidian_vault_path?: string, execution_mode?: string, autocommit_enabled?: boolean })
// Запустить Obsidian sync
syncObsidian(projectId: string): Promise<{ exported_decisions: number, tasks_updated: number, errors: string[] }>
```
---
## 7. Тесты
### `tests/test_obsidian_sync.py`
Обязательные кейсы:
1. `test_export_decisions_creates_md_files` — export создаёт файлы с правильным frontmatter
2. `test_export_idempotent` — повторный export перезаписывает, не дублирует
3. `test_parse_task_checkboxes_done``- [x] KIN-001 Title``{"task_id": "KIN-001", "done": True}`
4. `test_parse_task_checkboxes_pending``- [ ] KIN-002 Title``done: False`
5. `test_parse_task_checkboxes_no_id` — строки без task ID пропускаются
6. `test_sync_updates_task_status` — sync обновляет статус задачи если `done=True`
7. `test_sync_no_vault_path` — sync без vault_path выбрасывает ValueError
---
## 8. Риски и ограничения
1. **PyYAML не в зависимостях** → использовать ручную генерацию YAML-строки для frontmatter, парсинг `re`
2. **Vault path может быть недоступен** → sync возвращает error в `errors[]`, не падает
3. **Конфликт при rename decision** → файл со старым slug остаётся, создаётся новый. Приемлемо для v1
4. **Большой vault** → scan только в `{vault_path}/{project_id}/tasks/`, не весь vault
5. **Одновременный sync** → нет блокировки (SQLite WAL + file system). В v1 достаточно
---
## 9. Порядок реализации (для dev-агента)
1. `core/db.py` — добавить `obsidian_vault_path` в `_migrate()`
2. `core/obsidian_sync.py` — реализовать `export_decisions_to_md`, `parse_task_checkboxes`, `sync_obsidian`
3. `tests/test_obsidian_sync.py` — написать тесты (7 кейсов выше)
4. `web/api.py` — расширить `ProjectPatch`, добавить `/sync/obsidian` эндпоинт
5. `web/frontend/src/api.ts` — добавить `patchProject` обновление и `syncObsidian`
6. `web/frontend/src/views/SettingsView.vue` — создать компонент
7. `web/frontend/src/main.ts` — зарегистрировать `/settings` маршрут
8. `web/frontend/src/App.vue` — добавить ссылку Settings в nav