{"_id":"@aimana/daemon","name":"@aimana/daemon","dist-tags":{"latest":"0.1.0"},"versions":{"0.1.0":{"name":"@aimana/daemon","version":"0.1.0","description":"AIMana daemon: bridge between web UI and Claude Code","type":"module","engines":{"node":">=22"},"bin":{"aimana-daemon":"./dist/bin.js","aimana-mcp":"./dist/mcp.js","aimana-desktop":"./dist/desktop.js"},"main":"./dist/index.js","types":"./dist/index.d.ts","exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js"},"./package.json":"./package.json"},"publishConfig":{"access":"public"},"dependencies":{"@aimana/core":"0.1.0","@aimana/web":"0.1.0","@anthropic-ai/claude-agent-sdk":"0.3.260","@anthropic-ai/sdk":"^0.123.0","@fastify/static":"^9.0.0","@fastify/websocket":"^11.3.0","@modelcontextprotocol/sdk":"^1.30.0","better-sqlite3":"^13.0.3","chokidar":"^5.0.0","croner":"^10.0.1","fastify":"^5.12.1","pino":"^10.3.1","pino-roll":"^4.0.0","ws":"^8.21.3","zod":"^4.5.4"},"devDependencies":{"@types/better-sqlite3":"^9.6.0","@types/ws":"^8.18.1"},"scripts":{"build":"tsup","dev":"tsup --watch","typecheck":"tsc --noEmit"},"_nodeVersion":"25.9.0","_id":"@aimana/daemon@0.1.0","dist":{"integrity":"sha512-w/YWyecStrdy2HH9iu7by3BCOc7hy7mR0Q7VqlgDK8NrrlywvsA5hTuy1cBtv46oyeypUP2ghCHFlNAXZAOawg==","shasum":"1c911d432638674b99ba7951854c99447f116287","tarball":"https://registry.npmjs.org/@aimana/daemon/-/daemon-0.1.0.tgz","fileCount":22,"unpackedSize":2567283,"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEQCIGMEY4BmoUkFCYfjCmMALI3jeGRIEUXUZmjRLKMTMU85AiBkqJooJqPcviPUdE7XGdA/u9EQmikPCfXJIrcreGRHgQ=="}]},"_npmUser":{"name":"technofrog","email":"alexei@mikhaltsov.pro"},"directories":{},"maintainers":[{"name":"technofrog","email":"alexei@mikhaltsov.pro"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/daemon_0.1.0_1788998645534_0.04923313801988183"},"_hasShrinkwrap":false}},"time":{"created":"2026-09-10T00:04:05.370Z","0.1.0":"2026-09-10T00:04:05.720Z","modified":"2026-09-10T00:04:05.932Z"},"maintainers":[{"name":"technofrog","email":"alexei@mikhaltsov.pro"}],"description":"AIMana daemon: bridge between web UI and Claude Code","readme":"# @aimana/daemon\n\nЛокальный демон AIMana: реестр проектов, состояние `.aimana/` по REST, уведомления\nоб изменениях по WebSocket, запуск Claude Code через Agent SDK с потоком событий и\nобратный канал для модели — MCP-сервер `aimana`. Два бинарника: `aimana-daemon` и\n`aimana-mcp` (последний запускает не человек, а SDK для каждого запуска).\n\n## Запуск\n\nОбычный путь: `aimana daemon start` (detached, см. README `@aimana/cli`) или просто\n`aimana open`. Напрямую бинарник запускается так:\n\n```bash\naimana-daemon\n```\n\n| Переменная                 | По умолчанию      | Что делает                                                                        |\n| -------------------------- | ----------------- | --------------------------------------------------------------------------------- |\n| `AIMANA_HOME`              | `~/.aimana`       | Каталог токена, БД, логов и pid-файла                                             |\n| `AIMANA_HOST`              | `127.0.0.1`       | Адрес прослушивания                                                               |\n| `AIMANA_PORT`              | `4817`            | Порт, `0` = случайный                                                             |\n| `AIMANA_LOG_LEVEL`         | `info`            | Уровень pino                                                                      |\n| `AIMANA_LOG_STDERR`        | не задано         | `1`: логи в stderr вместо файла                                                   |\n| `AIMANA_CLAUDE_PATH`       | не задано         | Явный бинарник `claude`; без него SDK использует свой встроенный                  |\n| `AIMANA_DEFAULT_MODEL`     | `claude-sonnet-5` | Модель для запусков без явной                                                     |\n| `AIMANA_MODELS`            | встроенный список | Модели для выбора в вебе, id через запятую; дефолтная всегда в списке             |\n| `AIMANA_WEB_DIST`          | не задано         | Каталог собранного веба; без него берётся `@aimana/web/dist`                      |\n| `AIMANA_WEB_URL`           | адрес демона      | Куда ведёт клик по уведомлению; в dev — Vite, а не собранный веб демона           |\n| `AIMANA_MCP_PATH`          | не задано         | Явный путь к `dist/mcp.js`; без него ищется рядом с модулем демона                |\n| `AIMANA_CONTEXT_BUDGET`    | `40000`           | Бюджет контекста промпта стейджа в токенах оценки; целое > 0                      |\n| `AIMANA_GATE_TIMEOUT_MS`   | `600000`          | Сколько живёт одна команда гейта до убийства группы процессов; целое > 0          |\n| `AIMANA_PROMPT_TIMEOUT_MS` | `1800000`         | Сколько промпт ждёт человека, прежде чем истечь и запретить инструмент; целое > 0 |\n| `AIMANA_RUNNER`            | `claude`          | Движок агента: `claude` или `command`                                             |\n| `AIMANA_RUNNER_COMMAND`    | не задано         | Чем запускать внешнего агента; обязателен при `AIMANA_RUNNER=command`             |\n| `AIMANA_RUNNER_ARGS`       | не задано         | Его аргументы через пробел, с подстановками `{prompt}`, `{model}`, `{cwd}`        |\n| `AIMANA_SKIP_PERMISSIONS`  | `1`               | `0`: вернуть вопросы человеку; по умолчанию запуски идут в `bypassPermissions`    |\n\n**Разрешений по умолчанию нет.** Запуск выполняет любую команду в дереве, где идёт, и ничего\nне спрашивает: очередь, которая спрашивает про каждый `mkdir`, — это не человек в контуре, а\nчеловек поперёк дороги. При старте демон пишет об этом в лог, а веб говорит это в диалоге\nзапуска. Вернуть вопросы можно на весь демон (`AIMANA_SKIP_PERMISSIONS=0`) или на один запуск\n(`permissionMode` в теле `POST /projects/:id/runs`).\n\nФайлы в `AIMANA_HOME`:\n\n```\ntoken          токен доступа, 64 hex, права 0600, создаётся при первом старте\naimana.db       SQLite (WAL): projects, runs, run_events, prompts, settings, _migrations\nlogs/          daemon.N.log с ротацией (pino-roll, 10 МБ × 5) и daemon.out.log (stdout/stderr\n               detached-процесса, пишет aimana daemon start)\ndaemon.pid     pid работающего демона, удаляется при остановке\n```\n\nПовторный запуск при живом pid из `daemon.pid` отклоняется. `SIGINT`/`SIGTERM`\nостанавливают демон аккуратно: активные запуски отменяются, затем watcher, WS-клиенты\n(код 1001), HTTP, БД, pid. При старте все запуски, оставшиеся `running` от прошлого\nпроцесса, помечаются `failed` с ошибкой `daemon restarted`.\n\n## Веб\n\nВ prod демон сам раздаёт собранный SPA (`pnpm build` → `packages/web/dist`): `GET /` отдаёт\n`index.html`, `/assets/*` файлы, любой другой путь вне API с `Accept: text/html` тоже\n`index.html` (SPA-роутер). Статика и `GET /health` публичны: браузер не может приложить\nтокен к запросу ассета. Если веб не собран, `GET /` отвечает 404 с подсказкой, API работает.\n\nВсё API под токеном живёт в инкапсулированном контексте Fastify с auth-хуком, маршруты\nобязаны начинаться с `API_PREFIXES` (`/projects`, `/runs`, `/claude`, `/ws`), иначе демон\nпадает при старте с понятной ошибкой: так новый маршрут не окажется публичным случайно.\n\nВ dev веб раздаёт Vite (`pnpm --filter @aimana/web dev`, порт 5173) и проксирует API и WS\nна демон (`packages/web/vite.config.ts`, цель `AIMANA_DAEMON_URL` или `127.0.0.1:4817`),\nпоэтому CORS не нужен. `aimana open --dev` открывает адрес Vite с тем же токеном.\n\n## Движки агентов\n\nРазговор с агентом отделён от всего, что демон делает вокруг него. Граница — интерфейс\n`AgentEngine` (`src/runs/engine.ts`):\n\n```ts\nrun(input: AgentEngineRun, signal: AbortSignal): AsyncIterable<AgentEvent>;\n```\n\nДвижку дают промпт, модель и рабочий каталог; он отдаёт поток нормализованных событий:\n`session` (id сессии, если она у агента есть), `event` (`RunEvent` плюс сырое сообщение),\n`usage` (токены и деньги, если агент их считает) и, последним, `result` — `done` или\n`failed`. Всё остальное — не его дело:\n\n| Делает демон (`ClaudeRunner`)                                | Делает движок                       |\n| ------------------------------------------------------------ | ----------------------------------- |\n| очередь, лимит запусков, занятость рабочего каталога         | разговор с агентом                  |\n| запись в `runs` и `run_events`, публикация в WS              | нормализация того, что агент сказал |\n| статус запуска: `done`, `failed`, `cancelled`, `interrupted` | `done` или `failed` — и только      |\n| гейты, стейджи, цикл таски, очередь вопросов человеку        | —                                   |\n\nДва правила, на которых всё держится:\n\n- **Движок не бросает за то, что сделал агент.** Падение, ненулевой код выхода и отказ —\n  это `result: failed`, а не исключение. Всё, что всё-таки долетело до `catch` в рунере,\n  помечается как ошибка движка, а не как молчаливый конец запуска.\n- **Отменённый движок не отдаёт итога вовсе.** `cancelled` против `interrupted` решает\n  рунер: отменённый запуск закончился, прерванный ждёт, что ему скажут дальше.\n\n### Что умеет каждый\n\n| Движок               | Разрешения (`canUseTool`) | MCP-мост `aimana` | Продолжение сессии |\n| -------------------- | ------------------------- | ----------------- | ------------------ |\n| `claude` (по умолч.) | да                        | да                | да                 |\n| `command`            | **нет**                   | **нет**           | **нет**            |\n\n`EngineSupport` — это поле движка, а не комментарий: демон отказывает заранее и текстом.\nПопытка продолжить сессию внешним агентом — `422` с `code: \"engine-unsupported\"`, потому\nчто ждать и повторять бесполезно, этот движок не сможет никогда.\n\n### `command`: любой CLI-агент\n\nВнешний агент запускается процессом в рабочем каталоге запуска. Промпт уходит на stdin —\nили аргументом, если в `AIMANA_RUNNER_ARGS` написан `{prompt}` (тогда stdin сразу\nзакрывается: иначе агент ждал бы ввода, повторяющего сказанное). Аргументы режутся по\nпробелам, но промпт с пробелами всё равно приходит одним аргументом — подстановка идёт\nпосле разреза.\n\n```bash\nAIMANA_RUNNER=command \\\nAIMANA_RUNNER_COMMAND=my-agent \\\nAIMANA_RUNNER_ARGS='--output stream-json --model {model} {prompt}' \\\n  aimana-daemon\n```\n\nВывод разбирается построчно: строка, которая парсится как JSON-объект с полем `type`,\nчитается как сообщение stream-json (словарь Claude Code — тот, который агенты уже\nкопируют); всё остальное, включая строку, случайно оказавшуюся валидным JSON, — текст\nответа. Незнакомая форма сообщения становится `system.other`, а не исключением посреди\nзапуска. stderr идёт в события `error` и в текст падения; код выхода решает исход: `0` —\n`done`, иначе `failed` с хвостом stderr. Отмена убивает процесс (`SIGTERM`, через 5 секунд\n`SIGKILL`).\n\nЧего у внешнего агента нет и не будет без отдельной работы: **разрешений** (человек не\nувидит, что агент собирается сделать, — агент делает это у себя по своим правилам),\n**MCP-моста `aimana`** (агент не отметит критерии и не запишет решения, а значит стейдж\nпридётся закрывать руками) и **продолжения сессии** («стоп и скажи» с ним не работает).\nДемон пишет об этом предупреждение при каждом старте.\n\nЗачем это вообще: интерфейс с одной реализацией — переименование, а не абстракция. Вторая\nреализация — доказательство, что граница проходит там, где заявлено, и цена ей известна:\nиз таблицы выше видно, сколько всего в AIMana держится именно на Claude Code.\n\nКолонка `runner` в `runs` помнит, каким движком сделан запуск: `NULL` означает «запись из\nвремён до движков».\n\n## Запуски Claude\n\nДемон запускает Claude Code через `@anthropic-ai/claude-agent-sdk` (`query()`), версия SDK\nзафиксирована точно. SDK везёт собственный бинарник CLI, поэтому системный `claude` не\nобязателен; `AIMANA_CLAUDE_PATH` переключает на него. Авторизация та же, что у интерактивного\nClaude Code на этой машине.\n\n- Один активный запуск на проект (`409` на второй). Снимается в T-110.\n- Режим разрешений по умолчанию `default`, разрешённые инструменты `Read`, `Write`,\n  `Edit`, `Glob`, `Grep`. Остальное (`Bash`, MCP, ...) отклоняется через `canUseTool` с\n  сообщением про T-022, где появится проброс запросов в веб. `dangerouslySkipPermissions: true`\n  в теле запроса включает `bypassPermissions` (только для отладки, пишется `warn` в лог).\n  SDK при этом печатает в stderr предупреждение `CLAUDE_SDK_CAN_USE_TOOL_SHADOWED`: имена в\n  `allowedTools` одобряются до `canUseTool`, это ожидаемо.\n- Загружаются настройки пользователя и репозитория (`settingSources: user, project, local`),\n  системный промпт `claude_code` с опциональным хвостом.\n- После `result` SDK может прислать ещё системные сообщения (например `task_summary`) — они\n  попадают в `system.other`, поэтому «последнее событие всегда `result`» неверно.\n- Статусы: `running` → `done` | `failed` | `cancelled`. `result` SDK главнее исключения\n  генератора; `abort` даёт `cancelled`; исключение без `result` даёт `failed` и событие `error`.\n- В `runs` сохраняются `session_id` (для будущего `resume`), usage (`input/output/cache\nread/cache write tokens`, `cost_usd`, `num_turns`, `duration_ms`, `model_usage` JSON).\n- События пишутся пачками (100 мс или 50 штук) в `run_events` с сырым сообщением SDK в `raw`,\n  и сразу публикуются в WS в порядке `seq`.\n- Проверка окружения при старте не блокирует демон: `GET /claude` отдаёт версию SDK, путь и\n  версию CLI, результат `claude auth status --json`. Ответ `loggedIn: false` из\n  неинтерактивной оболочки бывает ложным, поэтому запуски не блокируются по нему: ошибка\n  авторизации приходит событием `error` и статусом `failed` с понятным текстом. Запуск\n  отклоняется (`503`) только если задан `AIMANA_CLAUDE_PATH`, который не запускается.\n\n### События `RunEvent`\n\n| type                 | Поля                                                                                                                                                           |\n| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `system.init`        | `sessionId`, `model`, `cliVersion`, `tools[]`, `apiKeySource`, `mcpServers[]`                                                                                  |\n| `assistant.text`     | `text`                                                                                                                                                         |\n| `assistant.thinking` | `text` (`[redacted]` для скрытого)                                                                                                                             |\n| `assistant.tool_use` | `toolUseId`, `name`, `input`                                                                                                                                   |\n| `tool.result`        | `toolUseId`, `content` (строка), `isError`                                                                                                                     |\n| `usage`              | `inputTokens`, `outputTokens`, `cacheReadTokens`, `cacheWriteTokens`, `costUsd`, `model?`                                                                      |\n| `result`             | `status: done \\| failed`, `subtype`, `numTurns`, `durationMs`, `text`, `errors[]`                                                                              |\n| `error`              | `message`, `code?` (например `authentication_failed`)                                                                                                          |\n| `limits`             | `status`, `rateLimitType?`, `utilization?` (0–100), `resetsAt?` (ISO), `overageStatus?`, `overageResetsAt?` (ISO), `isUsingOverage?`, `overageDisabledReason?` |\n| `system.other`       | `subtype` (прочие системные сообщения SDK, только с `raw`)                                                                                                     |\n\n`limits` приходит только от движка `claude`: внешний CLI-агент про свои лимиты ничего не\nговорит, и в его потоке такого сообщения нет. Поля-перечисления (`status`, `rateLimitType`,\n`overageStatus`, `overageDisabledReason`) хранятся строками, а не литеральными типами:\nAnthropic заводит новые виды окон не спрашивая, и значение из будущего SDK должно доехать до\nбазы и вернуться обратно, а не уронить историю на валидации.\n\n`StoredRunEvent`: `{ id, runId, seq, ts, event, raw? }`.\n\n## MCP-сервер `aimana`\n\nОбратный канал: демон подкладывает каждому запуску MCP-сервер `aimana`, через который модель\nполучает контекст и отчитывается. Прямая правка `.aimana/*.md` модели запрещена (правило в\nсистемном хвосте промпта из `@aimana/core`), потому что писать туда можно только через core —\nиначе документы разъезжаются со схемой.\n\n| Инструмент                  | Что делает                                                                                                               |\n| --------------------------- | ------------------------------------------------------------------------------------------------------------------------ |\n| `aimana_get_context`        | ТЗ стейджа, ожидаемые артефакты, правила и политики проекта, результаты предыдущих стейджей, ADR. `section` сужает вывод |\n| `aimana_report_progress`    | Отметить критерии приёмки (`criteria`), записать итог (`summary`) и заметку (`note`)                                     |\n| `aimana_report_artifact`    | Записать артефакт в результат стейджа; демон проверяет, что файл существует                                              |\n| `aimana_submit_plan`        | Сдать план таски по стейджам. Только для запусков `kind: plan`; пишет `stages` и секции плана в `TASK.md`                |\n| `aimana_submit_stage_spec`  | Сдать ТЗ одного стейджа. Только для запусков `kind: spec`; пишет `## ТЗ` в `STAGE-xx.md`                                 |\n| `aimana_submit_report`      | Сдать отчёт анализа. Только для запусков `kind: analyze`; пишет `.aimana/reports/<дата>-<slug>.md`                       |\n| `aimana_log_decision`       | Создать ADR `.aimana/decisions/NNNN-slug.md`                                                                             |\n| `aimana_ask_human`          | Вопрос человеку. **До T-022** очереди нет: инструмент отвечает, что ответа не будет, и просит зафиксировать допущение    |\n| `aimana_request_stage_spec` | Запрос перегенерировать ТЗ стейджа. Ставится в очередь и выполняется, когда текущий запуск завершится                    |\n\nКак это собрано:\n\n```\nClaude Code (Agent SDK)\n  └─ mcpServers.aimana: stdio, node dist/mcp.js\n       env AIMANA_URL, AIMANA_TOKEN, AIMANA_RUN_ID\n       └─ POST {AIMANA_URL}/runs/{AIMANA_RUN_ID}/mcp/{tool}\n            └─ McpToolService → core (AimanaFs, ops/report) → .aimana/*.md\n                 └─ событие state.changed → WS → веб\n```\n\n- Отдельный процесс, а не in-process сервер SDK: так `aimana` выглядит для Claude Code обычным\n  MCP-сервером, и его можно натравить на демон из чего угодно.\n- Модель видит инструменты как `mcp__aimana__aimana_get_context` и т. д.; эти имена добавляются в\n  `allowedTools` запуска, поэтому до `canUseTool` они не доходят. `alwaysLoad: true` — инструменты\n  должны быть в промпте с первого хода, о них говорит системный хвост.\n- Токен демона уходит дочернему процессу через env. Это тот же токен, что лежит в\n  `~/.aimana/token` у того же пользователя, так что новых секретов не появляется.\n- Каждый вызов привязан к `runId` из env: инструмент пишет только в тот проект и стейдж, к\n  которым привязан запуск.\n- Отказ инструмента (плохой вход, промах по критерию, нет стейджа) — это `isError`-ответ с\n  текстом, а не сорванный вызов: модель читает причину и повторяет.\n- Если `dist/mcp.js` не собран, демон пишет один `warn` при старте и работает без обратного\n  канала. Перед проверкой с реальным Claude нужен `pnpm build`.\n\n## REST\n\nВсе запросы к API (`/projects`, `/runs`, `/claude`, `/ws`, `/users`, `/audit`, `/limits`)\nтребуют токен: заголовок `Authorization: Bearer <token>` или query `?token=<token>`.\nТокен, за которым нет пользователя, — 401; роль, которой не хватает на этот маршрут, — 403.\n`GET /health` и статика веба публичны.\n\n| Метод    | Путь                                           | Ответ                                                                                                                                                       |\n| -------- | ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `GET`    | `/health`                                      | `{ ok, version }`                                                                                                                                           |\n| `GET`    | `/claude?refresh=1`                            | `ClaudeEnv`: `{ sdkVersion, cliPath?, cliVersion?, auth, checkedAt }`; `refresh` пересчитывает                                                              |\n| `GET`    | `/claude/models`                               | `{ defaultModel, models: { id, label }[], skipPermissions }` из конфига (`AIMANA_DEFAULT_MODEL`, `AIMANA_MODELS`, `AIMANA_SKIP_PERMISSIONS`)                |\n| `GET`    | `/limits`                                      | `{ available, snapshot, ageMs }` — последний снимок лимитов аккаунта и его возраст в мс; снимка ещё нет — 200 с `available: false`, а не 404; роль `viewer` |\n| `GET`    | `/users/me`                                    | `{ user }` — кто я и какая у меня роль; доступно всем, кого пустили                                                                                         |\n| `GET`    | `/users`                                       | `{ users: UserRecord[] }` — только владелец                                                                                                                 |\n| `POST`   | `/users` `{name, role}`                        | 201 `{ user, token }` — токен показывается один раз; 409 имя занято                                                                                         |\n| `PATCH`  | `/users/:userId` `{role}`                      | 200 `{ user }`; 404 нет такого; 409 последний владелец                                                                                                      |\n| `DELETE` | `/users/:userId`                               | 204; 404 нет такого; 409 встроенный владелец или последний владелец                                                                                         |\n| `GET`    | `/audit?user=&project=&limit=`                 | `{ entries }` — журнал действий, новые первыми; только владелец                                                                                             |\n| `GET`    | `/projects`                                    | `{ projects: ProjectRecord[] }`                                                                                                                             |\n| `POST`   | `/projects` `{root}`                           | 201 `{ project }`; 200 если уже есть; 404 нет каталога; 400 `{ error, issues }` если `.aimana/PROJECT.md` нет или сломан                                    |\n| `GET`    | `/projects/:id`                                | `{ project }` или 404                                                                                                                                       |\n| `DELETE` | `/projects/:id`                                | 204 или 404                                                                                                                                                 |\n| `GET`    | `/projects/:id/state`                          | `{ project, tree, state }`; 422 `{ error, issues }` если PROJECT.md стал невалидным                                                                         |\n| `POST`   | `/projects/:id/features`                       | 201 `{ feature }` (документ FEATURE.md); 400 тело; 404 проект; 409 уже есть; 422 `{ error, issues }` невалидный id                                          |\n| `POST`   | `/projects/:id/features/:fid/tasks`            | 201 `{ task }` (документ TASK.md); 400 тело; 404 проект или фича; 409 уже есть; 422 невалидный id или `depends_on`                                          |\n| `PATCH`  | `/projects/:id/features/:fid`                  | 200 `{ feature }` — заменяет тело FEATURE.md; 400 тело; 404 проект или фича                                                                                 |\n| `PATCH`  | `…/features/:fid/tasks/:tid`                   | 200 `{ task }` — заменяет тело TASK.md; 400 тело; 404 проект, фича или таска                                                                                |\n| `PATCH`  | `…/tasks/:tid/stages/:sid`                     | 200 `{ stage }` — заменяет тело STAGE-xx.md; 400 тело; 404 проект, фича, таска или стейдж                                                                   |\n| `PATCH`  | `/projects/:id`                                | 200 `{ project }` — заменяет тело PROJECT.md; 400 тело; 404 проект                                                                                          |\n| `PATCH`  | `/projects/:id/settings`                       | 200 `{ project }` — частичная правка frontmatter; 400 тело; 404 проект; 422 `{ error, issues }`                                                             |\n| `POST`   | `/projects/:id/runs`                           | 202 `{ run }`; 400 тело; 404 проект; 409 `{ error, runId }` уже есть активный; 503 `{ error, env }` окружение не готово                                     |\n| `GET`    | `/projects/:id/runs?limit=50`                  | `{ runs: RunRecord[] }`, новые первыми; `status=` и `kind=` фильтруют, мусор в них даёт 400                                                                 |\n| `GET`    | `/runs/:id`                                    | `{ run }` или 404                                                                                                                                           |\n| `GET`    | `/runs/:id/events?after=0&limit=1000`          | `{ events: StoredRunEvent[] }` с `seq > after`                                                                                                              |\n| `POST`   | `/runs/:id/cancel`                             | 202 `{ run }`; 409 `{ error, run }` уже завершён; 404                                                                                                       |\n| `POST`   | `/runs/:id/interrupt`                          | 202 `{ run }` — остановить ход, запуск станет `interrupted`; 409 уже завершён; 404                                                                          |\n| `POST`   | `/runs/:id/say`                                | 202 `{ run, previous }` — продолжить сессию сообщением; 400 тело; 422 `no-session`; 409 занято; 404; 503 окружение                                          |\n| `GET`    | `/projects/:id/runs/active`                    | `{ active, queued, limit, running }` — что идёт, что ждёт и почему; 404 проект                                                                              |\n| `GET`    | `/projects/:id/sessions?limit=50`              | `{ sessions, withoutSession }` — разговоры проекта, сгруппированные по сессии SDK; 404 проект                                                               |\n| `GET`    | `/projects/:id/runs/search?q=&limit=`          | `{ hits }` — поиск по тексту событий с фрагментами; 400 без запроса; 404 проект                                                                             |\n| `POST`   | `…/runs/queued/:ticket/cancel`                 | 204 — снять ожидающего с очереди; 404 проект или место в очереди                                                                                            |\n| `GET`    | `/runs/:id/mcp/tools`                          | `{ tools: { name, title, description }[] }` — каталог инструментов `aimana`                                                                                 |\n| `POST`   | `/runs/:id/mcp/:tool`                          | 200 `{ text, isError?, data? }`; 404 `{ error, code }` нет запуска, проекта или инструмента; 400 прочие коды                                                |\n| `POST`   | `…/tasks/:tid/plan`                            | 202 `{ run, task }`; 400 тело; 404 проект, фича или таска; 409 статус или активный запуск; 422 пайплайн; 503 окружение                                      |\n| `POST`   | `…/tasks/:tid/plan/approve`                    | 200 `{ task, plan }`; 404 проект или таска; 409 статус не `plan-review`; 422 план пуст                                                                      |\n| `POST`   | `…/tasks/:tid/specs`                           | 202 `{ run, task }`; 400 тело; 404 проект, фича или таска; 409 активный запуск; 422 план/стейдж/шаблон; 503 окружение                                       |\n| `POST`   | `…/tasks/:tid/specs/approve`                   | 200 `{ task, specs: { approved, missing } }`; 400 тело; 404; 422 подтверждать нечего                                                                        |\n| `GET`    | `…/tasks/:tid/specs`                           | `{ spec_mode, auto_approve_specs, stages: [...] }` — весь пакет ТЗ одним документом                                                                         |\n| `PATCH`  | `…/tasks/:tid/settings`                        | 200 `{ task }`; 400 пустое тело; 404. Переключает `spec_mode`, `auto_approve_specs`, модель, пайплайн                                                       |\n| `POST`   | `…/tasks/:tid/stages/:sid/run`                 | 202 `{ run, stage }`; 400 тело или номер стейджа; 404 проект или стейдж; 409/422 `{ error, code }` предусловия; 503 окружение                               |\n| `POST`   | `…/tasks/:tid/run-all`                         | 202 `{ loop }`; 400 тело; 404 проект или таска; 409 `{ error, code }` цикл или статус таски; 422 `until`; 503 окружение                                     |\n| `POST`   | `…/tasks/:tid/run-all/pause`                   | 200 `{ loop }`; 404 нет идущего цикла; 409 цикл не `running`                                                                                                |\n| `POST`   | `…/tasks/:tid/run-all/resume`                  | 200 `{ loop }`; 400 тело; 404 нет идущего цикла; 409 цикл не `paused`; 503 окружение                                                                        |\n| `POST`   | `…/tasks/:tid/run-all/stop`                    | 200 `{ loop }`; 404 нет идущего цикла; 409 цикл уже закончился                                                                                              |\n| `POST`   | `/projects/:id/analyze`                        | 202 `{ run }`; 400 тело или фокус `custom` без вопроса; 404 проект; 409 активный запуск; 503 окружение                                                      |\n| `GET`    | `/projects/:id/reports`                        | `{ reports, errors }` — отчёты новыми вперёд плюс файлы, которые не прочитались; 404 проект                                                                 |\n| `GET`    | `/projects/:id/reports/:reportId`              | `{ report }` — разобранный отчёт: итог, находки, рекомендации, тело; 404 проект или отчёт                                                                   |\n| `GET`    | `/projects/:id/metrics`                        | `{ gateFailuresByKind, gateFailuresByModel, runsPerStage, closedTasks, closed }`; 404 проект                                                                |\n| `GET`    | `/projects/:id/usage?from&to&groupBy`          | `{ totals, runsWithoutUsage, groups }` — токены и стоимость; 400 кривая дата или разрез; 404 проект                                                         |\n| `GET`    | `…/tasks/:tid/run-all`                         | 200 `{ loop }` — идущий цикл, иначе последний; 404 проект или циклов не было                                                                                |\n| `GET`    | `/projects/:id/task-runs?limit=`               | 200 `{ loops }` — циклы проекта, новые сверху; 400 `limit`; 404 проект                                                                                      |\n| `GET`    | `/projects/:id/prompts?status=&limit=`         | 200 `{ prompts }` — очередь проекта, новые сверху; 400 `status`/`limit`; 404 проект                                                                         |\n| `GET`    | `/runs/:id/prompts?status=`                    | 200 `{ prompts }` — промпты одного запуска; 404 запуск                                                                                                      |\n| `POST`   | `/runs/:id/prompts/:promptId`                  | 200 `{ prompt, ruleAdded? }`; 400 тело; 404 запуск или промпт; 409 `prompt-settled` / `settings-unwritable`; 422 вход                                       |\n| `GET`    | `…/stages/:sid/gates/:name/log?tail=`          | 200 `{ name, file?, text, truncated, missing }`; 400 `tail` или номер стейджа; 404 проект или стейдж; 422 `gate-unknown`                                    |\n| `GET`    | `/projects/:id/architecture/templates`         | 200 `{ templates: ArchitectureSummary[] }` — шаблоны архитектур проекта; 404 проект                                                                         |\n| `GET`    | `…/architecture/preview?template=`             | 200 `{ preview }` — что изменится, ничего не записывая; 400 без `template`; 404 проект или шаблон                                                           |\n| `POST`   | `/projects/:id/architecture`                   | 200 `{ result }`; 400 тело; 404 проект или шаблон; 409 `ARCHITECTURE.md` написан руками; 422 `PROJECT.md` не по схеме                                       |\n| `GET`    | `/projects/:id/stack/presets`                  | 200 `{ stacks: StackSummary[] }` — пресеты стека проекта; 404 проект                                                                                        |\n| `GET`    | `…/stack/preview?stack=`                       | 200 `{ preview }` — что изменится, ничего не записывая; 400 без `stack`; 404 проект или пресет                                                              |\n| `POST`   | `/projects/:id/stack`                          | 200 `{ result }`; 400 тело; 404 проект или пресет; 422 `PROJECT.md` не по схеме                                                                             |\n| `POST`   | `/projects/:id/apps/detect`                    | 200 `{ candidates }` — из чего состоит репозиторий; ничего не пишет; 404 проект                                                                             |\n| `GET`    | `/projects/:id/apps`                           | 200 `{ apps: { app, resolved }[] }` — как есть и как разрешено от проекта; 404 проект                                                                       |\n| `PUT`    | `…/apps/:appId`                                | 201/200 `{ app, outcome }`; 400 тело; 404 проект; 422 `apps.yaml` не по схеме                                                                               |\n| `DELETE` | `…/apps/:appId`                                | 200 `{ id, detached }` — отвязанные таски; 404 проект или приложение                                                                                        |\n| `GET`    | `…/apps/:appId/stack/preview?stack=`           | 200 `{ preview }`; 400 без `stack`; 404 проект, приложение или пресет                                                                                       |\n| `POST`   | `…/apps/:appId/stack`                          | 200 `{ result }`; 400 тело; 404 проект, приложение или пресет                                                                                               |\n| `GET`    | `…/apps/:appId/architecture/preview?template=` | 200 `{ preview }`; 400 без `template`; 404 проект, приложение или шаблон                                                                                    |\n| `POST`   | `…/apps/:appId/architecture`                   | 200 `{ result }`; 400 тело; 404 проект, приложение или шаблон                                                                                               |\n| `GET`    | `…/tasks/:tid/stages/:sid/diff?limit=`         | 200 `GitDiffView` — unified diff стейджа; 400 `limit` или номер стейджа; 404 проект, таска или стейдж                                                       |\n| `GET`    | `…/tasks/:tid/diff?limit=`                     | 200 `GitDiffView` — unified diff всей таски; 400 `limit`; 404 проект или таска                                                                              |\n| `POST`   | `…/tasks/:tid/pr`                              | 200 `{ status, url?, branch, base?, title, truncated, note?, changed }`; 400 тело; 404 проект или таска; 409 отказ с `reason`                               |\n\nТело `POST /projects/:id/runs`: `{ prompt, model?, effort?, maxTurns?, maxBudgetUsd?,\npermissionMode?: default | acceptEdits | plan | dontAsk, dangerouslySkipPermissions?,\nresumeSessionId?, taskRef?: \"фича/таска\", stageId?: \"03\" }`. `taskRef` и `stageId` привязывают\nзапуск к стейджу: без них инструменты `aimana_report_progress` и `aimana_report_artifact`\nотвечают, что писать некуда. Полноценно их будет проставлять StageRunner (T-024).\n\nТело `POST /projects/:id/features`: `{ id, title, description?, depends_on?: featureId[],\npriority?: P0..P3, tags? }`. Тело `POST /projects/:id/features/:fid/tasks`: `{ id, title,\ndescription?, depends_on?: \"feature/task\"[], spec_mode?: full | per-stage, model? }`. Файлы\nсоздаются через `createFeature`/`createTask` из core (статус `todo`, тело по\n`plan/03-repo-format.md`), формат id проверяет core, поэтому невалидный id даёт 422 с\n`issues`, а не 400. После записи watcher присылает `state.changed` как при любой правке\n`.aimana/`.\n\nТело трёх `PATCH`: `{ body: string }` — целиком новое markdown-тело документа. Пустая строка\nдопустима (описание можно стереть). Frontmatter из тела не берётся и не меняется, кроме\n`updated` у фичи и таски; у стейджа такого поля нет. Тело нормализуется до одного `\\n` в\nконце, чтобы правка из браузера не плодила diff-шум. Пишет `updateFeatureBody`/\n`updateTaskBody`/`updateStageBody` из core; нет документа → 404 (а не 422 общего обработчика).\nОтдельного `GET` документа нет: тела приходят целиком в `GET /projects/:id/state`.\n\n### План таски\n\n`POST /projects/:id/features/:fid/tasks/:tid/plan` с телом `{ model?, effort?, pipeline?,\nspec_mode?: full | per-stage, comment? }` запускает планирование: демон грузит пайплайн,\nставит таске `planning`, рендерит промпт `plan-task` из шаблонов core и стартует запуск\n`kind: plan` только на чтение (`Read`, `Glob`, `Grep` плюс инструменты `aimana`).\n\nЖизненный цикл: `todo` → `planning` → `plan-review` → (approve) → `planned`.\n\n- План приходит вызовом `aimana_submit_plan`. Если модель его не позвала, демон разбирает\n  блок JSON из текста ответа; если и там пусто, таска возвращается в `todo`, а причина\n  уходит в лог. В `planning` таска не залипает никогда.\n- **Отклонение плана — это повторный `POST …/plan` с `comment`**: комментарий попадает в\n  промпт отдельной секцией «Замечания к прошлому плану». Отдельного `plan/reject` нет.\n- До подтверждения план можно править руками: `approve` перечитывает секцию `## План` из\n  `TASK.md`, а не то, что записала модель. Дописанный руками стейдж попадёт во frontmatter\n  и получит свой файл.\n- `POST …/plan/approve` создаёт недостающие `STAGE-xx.md` (frontmatter + цель, ожидаемые\n  артефакты и критерии приёмки чекбоксами; само ТЗ генерирует T-032) и отвечает\n  `{ task, plan: { stages, created, existing, extra } }`. Существующие файлы не\n  перезаписываются, лишние (`extra`) не удаляются.\n- **В сессии этот запрос никто не делает руками.** Наткнувшись на таску в `todo`, рабочая\n  сессия сама запускает планирование (T-921): элемент становится `running`, причина сессии —\n  «пишу план», а не «ждёт человека». План на элемент заказывается **один раз**: не\n  появившийся план — провал элемента с причиной, а не второй запуск за те же деньги.\n  Дальше решает `defaults.auto_approve_plans` в `PROJECT.md`: `true` — сессия подтверждает\n  план сама и идёт в цикл таски; иначе элемент встаёт в `waiting-human` с «план готов:\n  подтверди, и сессия продолжит», а кнопка подтверждения есть на карточке элемента в вебе.\n  То же и для таски, которую сессия нашла уже в `plan-review`.\n\n### ТЗ стейджей\n\n`POST /projects/:id/features/:fid/tasks/:tid/specs` с телом `{ model?, effort?, stage?,\ncomment?, spec_mode? }` пишет ТЗ. Что именно запустится:\n\n- **со `stage`** — перегенерация ТЗ ровно этого стейджа (в любом режиме), промпт\n  `spec-stage`, запуск привязан к стейджу;\n- **без `stage`, режим `full`** — один запуск на все стейджи без подтверждённого ТЗ, промпт\n  `spec-full`, запуск привязан к таске, но не к стейджу;\n- **без `stage`, режим `per-stage`** — первый стейдж без подтверждённого ТЗ; в промпт\n  попадают секции «Результат» предыдущих стейджей, поэтому ТЗ пишется под то, как всё\n  получилось на самом деле. Если подтверждено всё — 422.\n\n`spec_mode` в теле переключает режим перед генерацией. Запуск идёт `kind: spec` только на\nчтение (`Read`, `Glob`, `Grep` плюс инструменты `aimana`).\n\nСтатусы стейджа: `todo | spec-ready` → `spec-pending` (пишется) → `spec-ready`. Подтверждение\n— это не статус, а `spec_approved_at` во frontmatter стейджа: `POST …/specs/approve` (весь\nпакет или `{ stage }`), либо сразу при записи, если у таски `auto_approve_specs: true`.\n\n- ТЗ приходит вызовами `aimana_submit_stage_spec`. Если модель их не позвала, демон разбирает\n  блоки JSON из текста ответа. Стейджи, которым ТЗ так и не написали, возвращаются в прежний\n  статус: в `spec-pending` не залипает никто, а повторный `POST …/specs` дошлёт недостающее.\n- **Отклонение ТЗ — это повторный `POST …/specs` с `stage` и `comment`**: комментарий уходит\n  в промпт секцией «Замечания к прошлому ТЗ». Перегенерация снимает прежнее подтверждение.\n- Уже подтверждённое человеком ТЗ запуск режима `full` перезаписать не может — только\n  адресная перегенерация со `stage`.\n- Переключение режима (`PATCH …/settings`) ничего не стирает: написанное остаётся на месте.\n\n### Запуск стейджа\n\n`POST /projects/:id/features/:fid/tasks/:tid/stages/:sid/run` с телом\n`{ model?, effort?, force?, comment? }` выполняет один стейдж целиком: предусловия → ТЗ →\ncontext pack → промпт по `kind` стейджа → запуск Claude с MCP-мостом → итог →\n`awaiting-gates`. Отмена — общая ручка `POST /runs/:id/cancel`.\n\nПредусловия проверяются до любых записей и отвечают кодом в теле, а не только статусом:\n\n| `code`              | Статус | Что значит                                                        |\n| ------------------- | ------ | ----------------------------------------------------------------- |\n| `deps-not-done`     | 409    | у таски не закрыты зависимости (список в `blockedBy`)             |\n| `task-blocked`      | 409    | нет плана, статус таски не рабочий, стейджа нет в плане           |\n| `stage-done`        | 409    | стейдж уже `done` или ждёт гейтов                                 |\n| `stage-running`     | 409    | стейдж уже выполняется                                            |\n| `previous-not-done` | 409    | предыдущие стейджи не закрыты; снимается `force: true`            |\n| `spec-missing`      | 422    | ТЗ нет; в режиме `per-stage` демон сначала сгенерирует его сам    |\n| `spec-not-approved` | 422    | ТЗ ждёт подтверждения; при `auto_approve_specs` подтвердится само |\n\n- В режиме `per-stage` без ТЗ сначала идёт отдельный запуск `kind: spec`, и только после\n  него — сам стейдж: слот запуска на проект один.\n- Стейдж помечается `running` **до первого хода модели** (`beforeStart` у `startRun`): иначе\n  модель, которая сразу зовёт `aimana_report_progress`, затирает статус своей записью,\n  сделанной по прежней версии документа.\n- Неподтверждённое ТЗ при `auto_approve_specs: true` **подтверждается**, а не\n  перегенерируется: иначе правка руками потерялась бы.\n- Список файлов в промпте — артефакты ТЗ, затем изменённые файлы рабочего дерева из\n  `git status`; при превышении `AIMANA_CONTEXT_BUDGET` режутся сначала файлы, потом ADR,\n  потом итоги предыдущих стейджей. ТЗ и правила проекта не режутся никогда.\n- Если модель не записала итог, демон делает **один** дополнительный ход `summarize-result`\n  в той же сессии (только чтение). Не помогло — заметка в журнал, но не `failed`.\n- Упавший или отменённый запуск переводит стейдж в `failed` с причиной в `### Журнал`.\n  Успешный уходит на гейты (см. ниже) — отдельная ручка для этого не нужна.\n\n`RunRecord`: `{ id, projectId, kind, taskRef?, stageId?, status, model?, prompt, sessionId?,\nstartedAt, finishedAt?, error?, usage? }`. `ProjectRecord`: `{ id, root, name, addedAt }`,\n`id` детерминирован: первые 12 hex sha256 от абсолютного пути. `tree` это `ProjectTree` из\ncore, `state` это `ProjectState` (`computeState`).\n\n### Гейты\n\nГейт это детерминированная проверка, которую выполняет демон, а не модель. Имена гейтов\nстейдж получает из плана (`gates: [...]` в `STAGE-xx.md`), а что за именем стоит, решается\nтак: явный `type` в записи гейта → команда с этим именем в `PROJECT.md: gates` →\nзарезервированные имена → ручной гейт.\n\n| Вид         | Откуда                                     | Как проверяется                             |\n| ----------- | ------------------------------------------ | ------------------------------------------- |\n| `command`   | имя есть в `PROJECT.md: gates`             | команда в оболочке, код возврата 0          |\n| `artifact`  | имена `artifacts`, `artifact`, `артефакты` | файлы из `### Ожидаемые артефакты` на месте |\n| `checklist` | имена `checklist`, `criteria`, `критерии`  | все критерии приёмки отмечены               |\n| `manual`    | всё остальное, включая опечатку в плане    | только человеком, через `accept`            |\n\nНеизвестное имя становится ручным гейтом, а не молчаливым пропуском: опечатка в плане не\nдолжна превращаться в отсутствующую проверку.\n\nИтог прогона: хоть один `failed` → стейдж `failed`; остался `pending` (ручной гейт) → стейдж\nждёт в `awaiting-gates`; все `passed`/`accepted` → `done`. Гейт, принятый вручную,\nпрогоном не перезапускается.\n\n```\nGET  /projects/:id/features/:fid/tasks/:tid/stages/:sid/gates\nPOST /projects/:id/features/:fid/tasks/:tid/stages/:sid/gates/run    {only?: string[]}\nPOST /projects/:id/features/:fid/tasks/:tid/stages/:sid/gates/retry  {comment?, model?, effort?}\nPOST /projects/:id/features/:fid/tasks/:tid/stages/:sid/gates/:name/accept {comment}\nGET  /projects/:id/features/:fid/tasks/:tid/stages/:sid/gates/:name/log?tail=20000\n```\n\n- `run` отвечает `202` сразу и гоняет гейты в фоне: `pnpm test` идёт минутами, держать\n  запрос столько нельзя. Результат виден через `GET …/gates` и событие `state.changed`;\n  каждый гейт пишется в `STAGE-xx.md` сразу, а не пачкой в конце.\n- Гейты идут **по очереди**: параллельные `build` и `test` дерутся за порты, кеши и `dist/`.\n- `retry` повторяет стейдж в **той же сессии** (`resume`) с хвостом лога упавшего гейта и\n  комментарием человека в промпте; перед запуском все гейты, кроме принятых вручную,\n  сбрасываются в `pending`. Повторить можно упавший стейдж и стейдж, который ждёт гейтов.\n- `accept` требует комментарий (он остаётся в frontmatter) и закрывает стейдж, если больше\n  ничего не мешает.\n- Коды отказов в теле: `stage-running`, `stage-not-ready`, `stage-done` (409),\n  `gates-running` (409, прогон уже идёт), `gate-unknown` (422).\n\nПолный вывод команды пишется в `~/.aimana/logs/run-<runId>-gate-<name>.log` (прогон руками —\n`<feature>-<task>-<stage>-gate-<name>.log`), путь в таком же виде лежит в `gates[].log`\nстейджа. Файл ограничен 2 МиБ, в промпт и в веб уходит хвост. Команда убивается по\n`AIMANA_GATE_TIMEOUT_MS` (по умолчанию 600000) вместе со всей группой процессов.\n\nВеб читает этот хвост ручкой `GET …/gates/:name/log`: `tail` задаёт длину (по умолчанию\n20000 символов, потолок 200000), `truncated` говорит, что лог обрезан слева. `missing: true`\n— **нормальное состояние**, а не ошибка: у `pending` и ручных гейтов лога нет, а файл могли\nудалить. Путь берётся из `STAGE-xx.md`, который правят и человек, и модель, поэтому он\nсчитается недоверенным: всё, что резолвится за пределы каталога логов демона, не читается\nвовсе — ручка отвечает `missing: true` и пишет `warn` в лог.\n\nПример:\n\n```bash\ncurl -sX POST -H \"Authorization: Bearer $TOKEN\" -H 'content-type: application/json' \\\n  -d '{\"comment\":\"почини типы, а не отключай проверку\"}' \\\n  \"$AIMANA_URL/projects/$PID/features/auth/tasks/001-login-form/stages/01/gates/retry\"\n```\n\n### Таска целиком (run all)\n\nЦикл `TaskLoopService` ведёт таску по стейджам: стейдж → гейты → следующий стейдж, пока\nплан не кончится или пока не понадобится человек. Состояние цикла лежит в таблице\n`task_runs`, поэтому оно переживает рестарт демона; активный цикл у таски ровно один\n(гарантия — частичный уникальный индекс в БД).\n\n```\nPOST /projects/:id/features/:fid/tasks/:tid/run-all        {until?, model?, effort?}\nPOST /projects/:id/features/:fid/tasks/:tid/run-all/pause\nPOST /projects/:id/features/:fid/tasks/:tid/run-all/resume {until?, model?, effort?}\nPOST /projects/:id/features/:fid/tasks/:tid/run-all/stop\nGET  /projects/:id/features/:fid/tasks/:tid/run-all\nGET  /projects/:id/task-runs?limit=\n```\n\n- `run-all` отвечает `202` и уходит в фон: таска идёт часами. Ход цикла виден в\n  `GET …/run-all`, в `state.changed` и в событии `task-run.changed`.\n- Перед первым стейджем таска переводится в `in-progress`, а фича из `todo` — тоже в\n  работу. Когда все стейджи `done`, таска закрывается, а если это была последняя открытая\n  таска фичи, закрывается и фича.\n- `until` — верхняя граница: цикл останавливается, закрыв названный стейдж, и таску не\n  закрывает.\n- Идентификатор цикла в путях не нужен: у таски он один, и роуты работают с её живым циклом.\n\nЦикл **никогда не повторяет упавший стейдж сам**. Модель, уронившая гейт, уронит его и во\nвторой раз, а деньги потратит; повтор (`POST …/gates/retry`) остаётся решением человека.\n\n| Причина          | Статус               | Что случилось                             |\n| ---------------- | -------------------- | ----------------------------------------- |\n| `gate-failed`    | `paused`             | стейдж не прошёл гейты                    |\n| `stage-failed`   | `paused`             | стейдж уже числился упавшим до цикла      |\n| `gates-waiting`  | `paused`             | висит ручной гейт                         |\n| `question`       | `paused`             | модель задала вопрос (`aimana_ask_human`) |\n| `spec-approval`  | `paused`             | ТЗ стейджа не подтверждено                |\n| `spec-missing`   | `paused`             | у стейджа нет ТЗ (режим `full`)           |\n| `blocked`        | `paused`             | предусловия стейджа не выполнены          |\n| `run-active`     | `paused`             | в проекте уже идёт другой запуск          |\n| `cancelled`      | `paused`             | запуск стейджа отменён человеком          |\n| `daemon-restart` | `paused`             | демон перезапустился посреди стейджа      |\n| `human`          | `paused` / `stopped` | пауза или остановка руками                |\n| `until`          | `done`               | дошли до стейджа из `until`               |\n\n- `pause` и `stop` **не убивают идущий стейдж**: он доигрывает, а цикл встаёт перед\n  следующим. Прервать прямо сейчас — это `POST /runs/:id/cancel`, цикл увидит отменённый\n  запуск и встанет с причиной `cancelled`.\n- `resume` продолжает с того стейджа, на котором встали, и принимает новую модель, усилие\n  и границу.\n- После рестарта демона цикл сам не продолжается: стейдж, на котором его оборвали, не\n  дописан, и решать про повтор человеку. Цикл ждёт в `paused` с `daemon-restart`.\n- Коды отказов в теле: `loop-active` (409, цикл уже идёт), `loop-state` (409, действие не\n  по статусу), `task-not-ready` (409, таска не в `planned`/`in-progress`).\n\nПример:\n\n```bash\ncurl -sX POST -H \"Authorization: Bearer $TOKEN\" -H 'content-type: application/json' \\\n  -d '{\"until\":\"04\"}' \\\n  \"$AIMANA_URL/projects/$PID/features/auth/tasks/001-login-form/run-all\"\n```\n\n## Анализ состояния проекта\n\n`POST /projects/:id/analyze` с телом `{ focus, question?, depth?, model?, effort? }` смотрит на\nпроект целиком и оставляет после себя отчёт в `.aimana/reports/<дата>-<slug>.md`.\n\n- Фокусы: `architecture`, `tech-debt`, `security`, `tests`, `dependencies`, `custom`. Последний\n  требует `question`: без вопроса отвечать нечего, и это 400, а не пустой запуск.\n- Глубина `overview` (по умолчанию) или `detailed`. Она меняет и промпт, и контекст: быстрый\n  обзор перечисляет фичи, подробный добавляет таски незакрытых фич.\n- Запуск идёт **только на чтение** (`Read`, `Glob`, `Grep` плюс инструменты `aimana`): анализ\n  ничего не чинит, он рассказывает.\n- Отчёт приходит вызовом `aimana_submit_report`. Фокус, глубину и вопрос инструмент берёт у\n  демона, а не из своего входа: запись о том, что заказывали, не должна зависеть от того,\n  верно ли модель повторила фокус.\n- Если инструмент так и не позвали, демон разбирает финальный текст ответа (тот же\n  JSON-fallback, что у плана) и пишет отчёт сам. Ответ без JSON отчёта не оставляет — это\n  видно в логе, запуск при этом остаётся `succeeded`.\n- Стоимость запуска в frontmatter отчёта — T-080.\n\n### Субагенты стейджа\n\nУ стейджа пайплайна может быть список `agents`: имя, описание («когда меня звать»), промпт и,\nесли нужно, модель и инструменты. Перед запуском стейджа демон раскладывает их в\n`.claude/agents/<имя>.md` рабочего каталога, а системный хвост промпта перечисляет их модели.\n\n**Запускает субагентов сама модель**, через `Task`, когда сочтёт нужным. Демон их не вызывает и\nне может — писать «демон запустил ревьюера» было бы неправдой.\n\n`.claude/agents/` принадлежит человеку, поэтому правило обратное обычному: файл **без** маркера\n`<!-- aimana:agent -->` не перезаписывается никогда. Потерять чужого агента ради дефолта из\nпайплайна непростительно; свой файл AIMana переписывает свободно.\n\nАгентов должно быть мало: каждый стоит собственных токенов, и «на всякий случай» здесь означает\n«вдвое дороже».\n\n### Бэклог\n\nЗаметки, записанные человеком в один клик: `backlog_items` в базе демона.\n\n```\nGET    /projects/:id/backlog?status=&tags=&q=&limit=   список, теги в ходу, число новых\nPOST   /projects/:id/backlog                            { text, tags? } → 201 { item }\nPATCH  /projects/:id/backlog/:itemId                    { text?, status?, tags?, linkedRefs? }\nDELETE /projects/:id/backlog/:itemId\n```\n\nСтатусы: `new`, `planned`, `done`, `archived`. Фильтры **сужают**: список по тегам требует\nвсе названные теги, а не любой из них. Событие `backlog.changed` несёт число новых заметок,\nчтобы счётчик в вебе не опрашивался по кругу.\n\nПочему в базе, а не в `.aimana/`: заметка — строка, набранная по дороге к другому делу, и\nпока никто не решил, что это (фича, таска или недоразумение), она черновик. `.aimana/` —\nправда о проекте, которая едет в git и проходит по схеме. Всё, во что заметка превращается,\nзаписывается туда через core — и только тогда, когда человек сказал, во что именно.\n\n### Плагины\n\nПлагин — npm-пакет с `aimana-plugin.yaml` в корне: шаблоны стейджей, пайплайны, политики и\nрецепты плюс, если нужно, гейты и MCP-серверы. Его каталог шаблонов встаёт **четвёртым уровнем**\nцепочки: проект → пользователь → плагин → встроенное. Плагин — чужое мнение о том, как\nработать: полезное, но никогда не громче двух людей, которые работают на самом деле.\n\nЧего плагин **не** может, и это сознательное сужение обещания из `plan/07`:\n\n- **Код плагина в демоне не выполняется.** В роадмапе были «гейты (JS-функции)»; гейт плагина —\n  это команда проекта, как у политик. Функция исполнялась бы внутри демона, у которого токен,\n  база и все проекты машины, а пакет приехал из npm.\n- Манифест с ключом `script`, `hook`, `main` и подобными **отвергается по имени**: автор должен\n  услышать, что так не будет, а не гадать, почему ничего не происходит.\n- Инструменты плагин отдаёт **MCP-сервером** — отдельным процессом с командой и аргументами,\n  как и мост `aimana`. Доступа к базе демона у него нет.\n- AIMana **не ставит пакеты сам**: `npm install` делает пакетный менеджер человека, а `aimana\nplugin add` только записывает плагин в настройки проекта.\n\n### Уведомления\n\nДемон зовёт человека, когда работа встала или закончилась: **вопрос модели**, **ожидание\nразрешения**, **упавший гейт**, **завершившийся цикл таски**. Больше поводов нет и не будет —\nуведомление обо всём подряд учит человека их игнорировать.\n\n**Один звонок на очередь, а не на инструмент.** Стейдж просит разрешение десяток раз подряд;\nдесять баннеров об одном и том же ожидании учат смахивать их не читая. Демон звонит на первый\nпромпт запуска и снова — когда очередь опустела и появился новый.\n\n**Пока за проектом следит вкладка, демон молчит.** Открытый веб сам зовёт человека и говорит об\nэтом демону сообщением `notify.self` (см. WS-протокол); демон — резерв для закрытой вкладки,\nровно как обещано в интерфейсе. Молчит ровно о том, о чём звонит вкладка: промпты, сессии,\nциклы тасок. Про готовые планы и вопрос модели зовёт по-прежнему он один. Web push\n(сервис-воркер, сервер подписок) не делался и не обещан.\n\n**Одни и те же слова второй раз не звонят.** Сессия, которую человек отправил дальше и которая\nупёрлась в ту же стену, говорит ровно ту же фразу — «таска ещё не спланирована» про ту же\nтаску. Первый баннер её уже сказал; каждое «Продолжить», приводящее сюда же, сказало бы её\nснова. Повтор того же повода молчит 10 секунд, повтор теми же словами — час.\n\n**В ссылке есть токен.** Клик открывает **новую** вкладку, а веб держит токен в\n`sessionStorage`, которого у новой вкладки нет: без токена любое уведомление приводило на\n«Токен не принят». Демон кладёт в ссылку тот же токен и тот же проект, что и `aimana open`;\nмашину она не покидает — оба файла, через которые она едет, лежат рядом с `<home>/token` и с\nтем же режимом 0600.\n\n**Клик по баннеру — отдельная фича, и она не включена по умолчанию.** `display notification` из\n`osascript` принадлежит Script Editor: клик открывает **его**, а не AIMana, и задать цель клика\nAppleScript не умеет. Умеет приложение: баннер, отправленный приложением, принадлежит ему, и\nклик его запускает. `aimana notify install` собирает из десяти строк AppleScript\n`<home>/AIMana.app` (`osacompile` есть в любой macOS, зависимостей не прибавляется), демон видит\nего в доме и дальше зовёт через него; страницу запуска или таски приложение читает из\n`<home>/notify-url`, куда демон пишет её перед звонком — у самого уведомления полезной нагрузки\nнет, поэтому клик открывает то, что звонило последним.\n\nДемон **не отправляет приложению ничего**: он кладёт звонок в `<home>/notify-pending` и делает\n`open -g`. Apple event попросил бы у человека права на автоматизацию, а пока апплет стартует —\nещё и отвечает `-609`. Приложение по наличию файла и понимает, зачем его запустили: файл есть —\nзвонить, файла нет — открыть страницу, то есть по баннеру кликнули.\n\nСам демон приложение **не собирает**: это настоящее приложение в доме человека, оно появляется в\n«Системных настройках → Уведомления». Такое не должно случаться от обновления — только от\nкоманды. Не собирали — демон звонит через `terminal-notifier`, если тот есть в PATH, иначе через\n`osascript`: зовёт как прежде, просто клик бесполезен. Проверить, куда ведёт клик, —\n`aimana notify test`, убрать — `aimana notify remove`.\n\nНа других системах уведомления **молча выключены**: `osascript` там нет, и запускать\nнесуществующий бинарник на каждый вопрос значит просто засорять лог. `AIMANA_NOTIFY=off`\nвыключает их совсем.\n\nПод тестами демон не звонит **никогда**. Тесты поднимают настоящие демоны, и закрывшийся цикл\nтаски в фикстуре звонит ровно так же, как настоящий: `pnpm test` вызывал человека каждые\nнесколько минут по поводу `auth/001-login-form`. Выключен именно живой нотификатор — тест,\nкоторый передал свой `exec`, тестирует этот класс и получает всё, о чём просил.\n\nОдин и тот же повод не повторяется чаще раза в десять секунд. Текст экранируется для\nAppleScript: кавычка в вопросе модели иначе оборвала бы команду на середине.\n\n### История и поиск по транскриптам\n\n`GET /projects/:id/sessions` группирует запуски по `session_id` SDK. Несколько запусков\nоказываются в одной сессии только потому, что кто-то продолжил её через `say`, — это и есть\n«один разговор». Запуски без `session_id` считаются **отдельным числом**, а не прячутся:\nиначе сумма не сходится и непонятно почему.\n\n`GET /projects/:id/runs/search?q=…` ищет по тексту событий через FTS5 (`run_events_fts`):\nиндексируются только события с текстом — реплики модели, результаты инструментов, ошибки.\nИндекс держит свою копию текста, потому что contentless-таблица FTS5 не умеет `snippet()`, а\nпоиск без фрагмента — это список номеров строк.\n\nЧестно про поиск: он **по словам**, а не по смыслу. Морфологии нет — «стейдж» не найдёт\n«стейджа», и обещать обратное было бы враньём. Кривой запрос (незакрытая кавычка, голая\nзвёздочка) возвращает пустой список: человек в поле поиска не пишет на языке запросов.\n\n### Прерывание и «стоп и скажи»\n\nТри разных действия, которые легко перепутать:\n\n- `cancel` — запуск закончился. Точка.\n- `interrupt` — человек остановил ход, чтобы что-то сказать. Запуск получает статус\n  `interrupted`, и это **не** ошибка: у него есть продолжение.\n- `say` — то и другое одним запросом: идущий запуск прерывается, его сессия поднимается\n  заново (`resume`) с сообщением человека. Двумя запросами это делать нельзя — если второй не\n  дойдёт, работа останется стоять молча.\n\nПродолжение идёт **в том же рабочем каталоге**, что и прерванный запуск (колонка `workspace`\nв `runs`): продолжать стейдж фичи в корне проекта значило бы поставить модель перед файлами,\nкоторых она не трогала.\n\nПрерывание теряет незаконченную работу текущего хода — за неё уже заплачено, и интерфейс\nобязан сказать это прямо, а не выяснять по счёту.\n\n### Параллельные запуски\n\nЗанятость считается **по рабочему каталогу**, а не по проекту: фича со своим worktree (T-051)\n— это отдельный каталог, и стейджи двух разных фич больше не стоят друг за другом. Два запуска\nв одном каталоге по-прежнему конфликтуют: они правят одни файлы и гоняют по ним одни гейты.\n\n`AIMANA_MAX_CONCURRENT_RUNS` — сколько запусков демон ведёт одновременно, **по умолчанию 1**.\nПараллельные запуски тратят деньги параллельно, поэтому потолок поднимает человек, а не\nобновление демона. Отказ различает причины: занят каталог или нет свободного слота.\n\nУ проекта со `strategy: none` все фичи живут в одном каталоге, и параллелить их нельзя — это\nне ограничение AIMana, а то, как устроен рабочий каталог git.\n\n**Очередь.** Запуск, которому не хватило слота или чей каталог занят, может подождать вместо\nотказа: так работает `run-all` и планировщик — «сделай, когда сможешь». Человек за кнопкой\nполучает отказ сразу: «занято» — это ответ, а не повод молча поставить работу в план на ночь.\n\n- Порядок — порядок постановки, с одним исключением: тикет, ждущий занятого каталога, не\n  держит тех, кто за ним. Иначе слоты простаивали бы, пока первый в очереди ждёт соседа.\n- Запись запуска в базе появляется в момент **реального старта**: список запусков не\n  заполняется тем, что ещё не начиналось.\n- `GET /projects/:id/runs/active` показывает идущие, ожидающих с причиной (`slot` или\n  `workspace`), потолок и текущее число запусков. Снять ожидающего — `POST\n…/runs/queued/:ticket/cancel`.\n\n### Расписания\n\n`.aimana/schedules.yaml` — программируемые таски. Файл ведёт человек, а демон исполняет:\n\n```yaml\nschedules:\n  - id: nightly-security\n    title: Ночной аудит безопасности\n    cron: 0 3 * * *\n    enabled: true\n    model: claude-opus-5\n    action: analyze # analyze | run-task | custom-prompt\n    focus: security\n    depth: detailed\n  - id: finish-login\n    cron: 0 9 * * 1\n    action: run-task\n    task: auth/001-login-form\n    until: '03'\n  - id: docs-drift\n    cron: 0 9 1 * *\n    action: custom-prompt\n    prompt: Проверь, что README соответствует коду.\n```\n\n- Действие — размеченное объединение: у каждого свои параметры, и `run-task` без таски не\n  проходит ни схему core, ни `PUT`. **Произвольной shell-команды в списке действий нет** и не\n  будет: то, что просыпается в три ночи без человека рядом, работает по закрытому списку.\n- Cron разбирает `croner` — тот же экземпляр, что потом ставит таймер. Кривое выражение\n  помечает **своё** расписание ошибкой (`cronError`) и не ставится; остальные работают.\n- Срабатывание в занятом проекте **пропускается** с причиной в `lastSkip`, очередь не копится:\n  ночь накопившихся тиков перед одним залипшим запуском — это счёт, а не автоматизация.\n- Ручной запуск (`POST …/run`) делает ровно то же, что сделал бы таймер, и отвечает 409, если\n  проект занят.\n- Правка `schedules.yaml` руками переставляет таймеры: файл лежит под watcher-ом, и `state.changed`\n  заставляет планировщик перечитать его.\n\n### Рецепты расписаний\n\nГотовые расписания живут шаблонами: `recipes/<id>.yaml` в `packages/core/templates`, в\n`~/.aimana/templates` и в `.aimana/templates` — с той же цепочкой уровней и тем же «список плюс\nошибки», что у политик и пресетов стека.\n\nВстроены: ночной аудит безопасности, еженедельные обзоры зависимостей, техдолга и тестов,\nежемесячная сверка документации с кодом.\n\n- Файл рецепта устроен как запись `schedules.yaml` плюс `description` и `enabled_by_default`:\n  второй формат учить не надо.\n- `POST …/schedules/recipes/:recipeId` разворачивает рецепт в запись и пишет её через core.\n  Запись приходит **выключенной**: расписание тратит деньги без человека рядом, и «добавить» —\n  не то же самое, что «начинай сегодня ночью». Включает переключатель в вебе или `PATCH`.\n- Повторное применение — 409 `schedule-exists`: перезапись молча выкинула бы то, что человек\n  успел поправить в записи.\n\n### Claude Desktop\n\nУ демона два MCP-сервера, и они про разное. `aimana` (`aimana-mcp`) привязан к запуску: его\nинструменты для модели, которая делает стейдж. `aimana-desktop` — про машину целиком, и\nего подключают к Claude Desktop, чтобы спрашивать про проекты не открывая веб.\n\n| Инструмент               | Что делает                                              |\n| ------------------------ | ------------------------------------------------------- |\n| `aimana_projects`        | какие проекты держит демон: id, имя, путь               |\n| `aimana_project_status`  | фичи с прогрессом, что в работе, что можно брать        |\n| `aimana_task`            | одна таска: статус, стейджи, чего ждёт                  |\n| `aimana_pending_prompts` | кто ждёт ответа — первое, что смотрят, если «всё висит» |\n| `aimana_run_stage`       | запустить стейдж — **тратит деньги**                    |\n| `aimana_run_task`        | провести таску по стейджам — **тратит деньги**          |\n\nОтвечают они фразами, а не JSON: ответ читает вслух ассистент, и таблица полей\nпересказывается хуже предложения.\n\nАдрес и токен сервер берёт из дома демона при старте — Claude Desktop запускает MCP-серверы\nс пустым окружением, а токен в `claude_desktop_config.json` попадать не должен: этот файл\nкопируют между машинами. Дома нет — сервер всё равно поднимается и каждым инструментом\nотвечает, что надо запустить демон.\n\n**Деньги.** Два инструмента запускают Claude Code, и это сказано в их описаниях. Если такого\nдоступа давать не хочется, ограничение честное и простое: пусть в `<home>/token` лежит токен\nпользователя с ролью `viewer` — тогда запуск вернёт 403 и объяснит, чего не хватило.\n\n**Оболочки (Tauri, Electron) нет.** Строка роадмапа была «Tauri-оболочка **или** интеграция с\nClaude Desktop», и выбран второй путь: Tauri тянет Rust в монорепозиторий, где его никто не\nпроверит гейтами, а окно с вебом и треем даёт ро","readmeFilename":"","_rev":"1-ecc2e7f3b437f791b38db0cde78dabc6"}