diff --git a/backend/alembic/versions/a1b2c3d4e5f7_agent_mandate.py b/backend/alembic/versions/a1b2c3d4e5f7_agent_mandate.py new file mode 100644 index 0000000..ee4f0c9 --- /dev/null +++ b/backend/alembic/versions/a1b2c3d4e5f7_agent_mandate.py @@ -0,0 +1,117 @@ +r"""Мандат ИИ-агента: доступность задачи, аренда, приёмка, журнал (ТЗ 3.20). + +Поля задачи: ai_eligible (доступно агенту; по умолчанию нет), created_by_kind / +created_by_name (кто создал — под фильтр «создано агентом»), done_by_kind, +accept_state (плоскость приёмки, отдельная от статуса, как detail_state), +claimed_by_token_id / claimed_by_name / claimed_at / claim_expires_at (аренда). + +Существующие задачи бэкфиллятся server_default: агенту недоступны, созданы +пользователем. accept_state и done_by_kind остаются NULL — приёмка к старым +закрытиям не применяется, а кто их закрыл, неизвестно; выдумывать не будем. + +Новая таблица task_events — журнал «кто, что и когда»: записи переживают задачу +(task_id обнуляется по ON DELETE SET NULL), заголовок хранится снапшотом. + +Revision ID: a1b2c3d4e5f7 +Revises: d4e5f6a7b8c9 +Create Date: 2026-10-10 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +revision: str = "a1b2c3d4e5f7" +down_revision: str | Sequence[str] | None = "d4e5f6a7b8c9" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column( + "tasks", + sa.Column("ai_eligible", sa.Boolean(), nullable=False, server_default=sa.false()), + ) + op.add_column( + "tasks", + sa.Column("created_by_kind", sa.String(16), nullable=False, server_default="user"), + ) + op.add_column("tasks", sa.Column("created_by_name", sa.String(100), nullable=True)) + op.add_column("tasks", sa.Column("done_by_kind", sa.String(16), nullable=True)) + op.add_column("tasks", sa.Column("accept_state", sa.String(16), nullable=True)) + op.add_column( + "tasks", + sa.Column( + "claimed_by_token_id", + sa.Integer(), + sa.ForeignKey("mcp_tokens.id", ondelete="SET NULL"), + nullable=True, + ), + ) + op.add_column("tasks", sa.Column("claimed_by_name", sa.String(100), nullable=True)) + op.add_column("tasks", sa.Column("claimed_at", sa.DateTime(timezone=True), nullable=True)) + op.add_column("tasks", sa.Column("claim_expires_at", sa.DateTime(timezone=True), nullable=True)) + + op.create_index("ix_tasks_user_ai_eligible", "tasks", ["user_id", "ai_eligible"]) + op.create_index("ix_tasks_user_accept_state", "tasks", ["user_id", "accept_state"]) + op.create_index("ix_tasks_user_created_by", "tasks", ["user_id", "created_by_kind"]) + + op.create_table( + "task_events", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("user_id", sa.String(64), sa.ForeignKey("users.id"), nullable=True), + sa.Column( + "task_id", + sa.Integer(), + sa.ForeignKey("tasks.id", ondelete="SET NULL"), + nullable=True, + ), + sa.Column("task_title", sa.String(500), nullable=False, server_default=""), + sa.Column( + "project_id", + sa.Integer(), + sa.ForeignKey("projects.id", ondelete="SET NULL"), + nullable=True, + ), + sa.Column("kind", sa.String(24), nullable=False), + sa.Column("actor_kind", sa.String(16), nullable=False, server_default="user"), + sa.Column("actor_name", sa.String(100), nullable=False, server_default=""), + sa.Column("via", sa.String(8), nullable=False, server_default="ui"), + sa.Column("comment", sa.Text(), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), + ) + op.create_index("ix_task_events_user_id", "task_events", ["user_id"]) + op.create_index("ix_task_events_user_created", "task_events", ["user_id", "created_at"]) + op.create_index("ix_task_events_user_kind", "task_events", ["user_id", "kind"]) + op.create_index("ix_task_events_user_task", "task_events", ["user_id", "task_id"]) + op.create_index("ix_task_events_user_actor", "task_events", ["user_id", "actor_kind"]) + + +def downgrade() -> None: + op.drop_index("ix_task_events_user_actor", table_name="task_events") + op.drop_index("ix_task_events_user_task", table_name="task_events") + op.drop_index("ix_task_events_user_kind", table_name="task_events") + op.drop_index("ix_task_events_user_created", table_name="task_events") + op.drop_index("ix_task_events_user_id", table_name="task_events") + op.drop_table("task_events") + + op.drop_index("ix_tasks_user_created_by", table_name="tasks") + op.drop_index("ix_tasks_user_accept_state", table_name="tasks") + op.drop_index("ix_tasks_user_ai_eligible", table_name="tasks") + + op.drop_column("tasks", "claim_expires_at") + op.drop_column("tasks", "claimed_at") + op.drop_column("tasks", "claimed_by_name") + op.drop_column("tasks", "claimed_by_token_id") + op.drop_column("tasks", "accept_state") + op.drop_column("tasks", "done_by_kind") + op.drop_column("tasks", "created_by_name") + op.drop_column("tasks", "created_by_kind") + op.drop_column("tasks", "ai_eligible") diff --git a/backend/app/models.py b/backend/app/models.py index 8fa2d68..e5abd1c 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -19,6 +19,8 @@ String, Text, UniqueConstraint, + false, + func, ) from sqlalchemy.orm import Mapped, mapped_column, relationship @@ -29,6 +31,15 @@ return datetime.now(UTC) +def _is_future(moment: datetime | None) -> bool: + """«Момент ещё не наступил?» — терпит naive-datetime из SQLite (тесты).""" + if moment is None: + return False + if moment.tzinfo is None: + moment = moment.replace(tzinfo=UTC) + return moment > utcnow() + + class User(Base): """Пользователь из SSO (gnexus-gauth): профиль синхронизируется при логине. @@ -65,6 +76,13 @@ class Task(Base): __tablename__ = "tasks" + # Индексы под новые фильтры списка задач (ТЗ 3.20): доступные агенту, + # ждущие приёмки, созданные агентом — все запросы идут от user_id + __table_args__ = ( + Index("ix_tasks_user_ai_eligible", "user_id", "ai_eligible"), + Index("ix_tasks_user_accept_state", "user_id", "accept_state"), + Index("ix_tasks_user_created_by", "user_id", "created_by_kind"), + ) id: Mapped[int] = mapped_column(Integer, primary_key=True) user_id: Mapped[str | None] = mapped_column( @@ -149,6 +167,45 @@ DateTime(timezone=True), nullable=True, default=None ) + # --- Мандат ИИ-агента (ТЗ 3.20, 0.85) --- + # Доступность задачи агенту: ставит владелец, агент — только при создании + # задачи (иначе правило «без флага не трогать» обходилось бы «пометил — закрыл») + ai_eligible: Mapped[bool] = mapped_column(Boolean, default=False, server_default=false()) + # Кто создал задачу: user | agent (у агента — label токена в created_by_name). + # Пометка нужна, чтобы список фильтровался по «создано агентом» без join'а + created_by_kind: Mapped[str] = mapped_column( + String(16), default="user", server_default="user" + ) + created_by_name: Mapped[str | None] = mapped_column(String(100), nullable=True, default=None) + # Кто закрыл задачу последним: user | agent; NULL — закрытие до 0.85, неизвестно + done_by_kind: Mapped[str | None] = mapped_column(String(16), nullable=True, default=None) + # Плоскость приёмки агентской работы (отдельная от статуса, как detail_state): + # pending (ждёт приёмки) | accepted | rejected; NULL — приёмка не применяется + accept_state: Mapped[str | None] = mapped_column(String(16), nullable=True, default=None) + # Аренда («задачу взял агент»): держим id токена — label не уникален, + # он только подпись для показа и журнала + claimed_by_token_id: Mapped[int | None] = mapped_column( + ForeignKey("mcp_tokens.id", ondelete="SET NULL"), nullable=True, default=None + ) + claimed_by_name: Mapped[str | None] = mapped_column(String(100), nullable=True, default=None) + claimed_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True, default=None + ) + # Протухшая аренда считается свободной сама (лениво, без фоновых задач) + claim_expires_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True, default=None + ) + + @property + def claim_is_alive(self) -> bool: + """Аренда агента ещё жива? Протухшая — как будто её нет (ТЗ 3.20).""" + return _is_future(self.claim_expires_at) + + @property + def claimed_by(self) -> str | None: + """Подпись арендатора наружу — только пока аренда жива.""" + return self.claimed_by_name if self.claim_is_alive else None + @property def description(self) -> str: return self.document.body if self.document else "" @@ -410,3 +467,49 @@ # Стадия роста растения: 0 росток, 1 куст, 2 цветение stage: Mapped[int] = mapped_column(Integer, default=0) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) + + +class TaskEvent(Base): + """Журнал действий по задачам (ТЗ 3.20): кто, что и когда. + + Пишется на каждое действие — из UI, REST и MCP-инструментов; читается + страницей «Журнал». Записи переживают задачу: при удалении задачи task_id + обнуляется (ON DELETE SET NULL), а заголовок остаётся снапшотом — журнал + читается как история, а не как список живых задач. + """ + + __tablename__ = "task_events" + # Все запросы журнала идут от user_id с фильтром (и сортировкой по времени) + __table_args__ = ( + Index("ix_task_events_user_created", "user_id", "created_at"), + Index("ix_task_events_user_kind", "user_id", "kind"), + Index("ix_task_events_user_task", "user_id", "task_id"), + Index("ix_task_events_user_actor", "user_id", "actor_kind"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + user_id: Mapped[str | None] = mapped_column( + ForeignKey("users.id"), nullable=True, default=None, index=True + ) + task_id: Mapped[int | None] = mapped_column( + ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True, default=None + ) + # Снапшот заголовка на момент действия: после удаления задачи читать нечего + task_title: Mapped[str] = mapped_column(String(500), default="") + project_id: Mapped[int | None] = mapped_column( + ForeignKey("projects.id", ondelete="SET NULL"), nullable=True, default=None + ) + # Вид события (см. services/tasklog.py): created | eligible_changed | claimed | + # released | completed | accepted | rejected | status_changed | deleted + kind: Mapped[str] = mapped_column(String(24)) + # Кто: user (владелец либо действие, объявленное от его имени) | agent + actor_kind: Mapped[str] = mapped_column(String(16), default="user", server_default="user") + # Имя владельца или label MCP-токена — подпись, а не ссылка (label не уникален) + actor_name: Mapped[str] = mapped_column(String(100), default="") + # Канал: ui (сессия) | api (Bearer на /api/*) | mcp (инструмент) + via: Mapped[str] = mapped_column(String(8), default="ui", server_default="ui") + # Что сделано (при закрытии — обязателен агенту) или причина возврата в работу + comment: Mapped[str | None] = mapped_column(Text, nullable=True, default=None) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=utcnow, server_default=func.now() + ) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 1425e07..ca90c24 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -86,6 +86,8 @@ title: str = Field(min_length=1, max_length=500) description: str = "" parent_task_id: int | None = None # подзадача (M3) + # Доступность задачи ИИ-агенту (ТЗ 3.20); агент вправе поставить её только здесь + ai_eligible: bool = False class TaskUpdate(BaseModel): @@ -113,8 +115,12 @@ recur_day_of_month: int | None = Field(None, ge=1, le=31) # «Ментально сложная» (ТЗ 3.13): метка «не хочется делать», +10 XP при закрытии mentally_hard: bool | None = None + # Доступность ИИ-агенту (ТЗ 3.20): меняет только владелец — агенту поле недоступно + ai_eligible: bool | None = None # Не поле задачи: пометка источника закрытия для геймификации ("options" — режим «3 вариантов») earned_via: str | None = None + # Не поле задачи: комментарий к закрытию (ТЗ 3.20) — уходит в журнал, агенту обязателен + close_comment: str | None = Field(None, max_length=2000) class TaskOut(BaseModel): @@ -143,11 +149,51 @@ recur_weekdays: str | None recur_day_of_month: int | None mentally_hard: bool + # Мандат ИИ-агента и приёмка (ТЗ 3.20). claimed_by — подпись арендатора и только + # пока аренда жива: протухшую аренду наружу не показываем (см. Task.claimed_by) + ai_eligible: bool + created_by_kind: str + created_by_name: str | None + done_by_kind: str | None + accept_state: str | None + claimed_by: str | None created_at: datetime approved_at: datetime | None done_at: datetime | None +class TaskEventOut(BaseModel): + """Запись журнала действий по задаче (ТЗ 3.20).""" + + model_config = ConfigDict(from_attributes=True) + + id: int + task_id: int | None + task_title: str + project_id: int | None + kind: str + actor_kind: str + actor_name: str + via: str + comment: str | None + created_at: datetime + + +class TaskEventPage(BaseModel): + """Страница журнала: единственный пагинированный список API (ТЗ 3.20).""" + + items: list[TaskEventOut] + total: int + page: int + per_page: int + + +class AcceptIn(BaseModel): + """Приёмка агентской работы: комментарий необязателен (у возврата — причина).""" + + comment: str | None = Field(None, max_length=2000) + + class ApproveIn(BaseModel): """Утверждение детализации; apply_proposal=True — принять предложение LLM.""" diff --git a/backend/app/services/recurrence.py b/backend/app/services/recurrence.py index 070d329..df56d32 100644 --- a/backend/app/services/recurrence.py +++ b/backend/app/services/recurrence.py @@ -72,7 +72,11 @@ def _copy(db: Session, source: Task) -> Task: - """Новый экземпляр регулярной задачи: те же метаданные, чистое состояние.""" + """Новый экземпляр регулярной задачи: те же метаданные, чистое состояние. + + Доступность агенту (ТЗ 3.20) наследуется: если владелец делегировал саму + задачу, её повтор — та же работа. Автор экземпляра — тот, кто создал правило. + """ clone = Task( user_id=source.user_id, title=source.title, @@ -90,6 +94,9 @@ recur_interval_days=source.recur_interval_days, recur_weekdays=source.recur_weekdays, recur_day_of_month=source.recur_day_of_month, + ai_eligible=source.ai_eligible, + created_by_kind=source.created_by_kind, + created_by_name=source.created_by_name, ) clone.tags = list(source.tags) db.add(clone) diff --git a/docs/TZ.md b/docs/TZ.md index f93a607..0641b60 100644 --- a/docs/TZ.md +++ b/docs/TZ.md @@ -4,7 +4,7 @@ | | | |---|---| -| Версия ТЗ | 0.84 | +| Версия ТЗ | 0.85 | | Дата | 2026-10-10 | | Статус | На обсуждении | @@ -137,6 +137,7 @@ - Статус `cancelled` и `deferred` — обратимы: задача может быть возвращена в `to_do`. - Удаление — отдельное явное действие, не статус. - Детализационный жизненный цикл (`raw` → `approved`) — **отдельная плоскость**, не статус выполнения: «сырая» задача из стека получает статус только после утверждения. +- **Приёмка агентской работы** (`accept_state`: нет | `pending` | `accepted` | `rejected`) — вторая отдельная плоскость (3.20): закрытие задачи ИИ-агентом помечается «ждёт приёмки», владелец принимает работу или возвращает задачу в работу. Набор статусов выполнения при этом не меняется. **Дедлайны — двух видов**: - *строгий*: «сделать до конкретной даты»; @@ -153,6 +154,7 @@ - Отдельный **тип задачи** — регулярная (повторяющаяся) в противовес разовой. - Повторение создаёт новую задачу по правилу после выполнения/истечения предыдущей. - Точный набор правил повторения (ежедневно/еженедельно/интервалы) — уточняется. +- Новый экземпляр наследует **доступность для ИИ-агента** (3.20) от предыдущего: цепочка регулярной не теряет мандат. ### 3.6. Представления задач @@ -520,7 +522,13 @@ restore возвращает; вызовы идемпотентны; архивные проекты видны через `list_projects(include_archived=True)`. Безвозвратного удаления проектов в MCP нет намеренно; - - `list_tags` — справочник тегов (id для параметра `tag_ids`). + - `list_tags` — справочник тегов (id для параметра `tag_ids`); + - `list_available_tasks` — задачи, доступные ИИ-агенту (3.20): помеченные владельцем, + в статусе `to_do`, со свободной арендой (0.85); + - `claim_task` / `release_task` — взять задачу в работу и вернуть её, если она не по + силам (0.85, 3.20); + - `complete_task` — закрытие задачи; для действия от имени агента **обязателен + комментарий** о том, что сделано (0.85). - Эргономика для агентов (в т.ч. небольших моделей): - серверные `instructions`: порядок работы, правило привязки задач к проекту, восстановление контекста через `get_project` после перерыва, запрет @@ -547,6 +555,12 @@ пути, что и в UI: спавн регулярной, XP, монеты (3.5, 3.13); в открытых вкладках — скромный тост о награде (3.14). Растение больше не выдаётся — сад отключён в 0.58. +- **Актор действия** (0.85, 3.20): у изменяющих тулов есть флаг `is_user` — по умолчанию + действие считается **агентским**, `is_user=true` объявляет действие от имени владельца + (тот же смысл у заголовка `X-Actor: user` на REST). Актор и канал (`ui` / `api` / `mcp`) + попадают в журнал `task_events`. Без флага агент изменяет и закрывает только задачи, + помеченные «доступно для ИИ-агента», и закрывает их в режим приёмки; флаг он может + поставить лишь при создании задачи. ### 3.11. Локальные AI-функции @@ -606,6 +620,9 @@ удаление задачи не отнимает заработанное. Хранение — таблица `xp_events` (поле `kind` разделяет события: `task` — закрытие, `create_task` / `create_project` — создание). +- **Награда за агентское закрытие** (0.85, 3.20): XP и монеты начисляются сразу при + закрытии задачи агентом — как за любое закрытие; ожидание приёмки награду не + откладывает, а возврат работы её не отзывает (принцип «только позитив»). - **Микронаграды за создание** (без конфетти — скромный тост «+N XP • +M монет»): создание задачи — +2 XP и +2 монеты, создание проекта — +5 XP и +4 монеты (0.48: монеты ×2). @@ -795,6 +812,10 @@ `task.deleted {id}`, `detail.changed {id}`, `project.changed {id}`, `project.deleted {id}`, `garden.changed {reason}`, `xp.changed {amount?, celebrate}`, `settings.changed`, `ready` (первый кадр), `ping`. + С 0.85 — ещё два кадра о работе агента (3.20): `task.claimed {id, claimed_by}` + (задачу взял агент — в интерфейсе появляется пометка «в работе у агента») и + `task.review {id, title}` (агент закрыл задачу — владельцу приходит тост + «Нави закрыла задачу: …», а задача встаёт в фильтр «Ждёт приёмки»). Упрощение: закрытие задачи через MCP не шлёт `garden.changed` — refetch по `task.changed` покрывает (сад отключён в 0.58, слушателей у события нет). - **Дедуп XP-тостов**: действие своей вкладки празднуется по заголовкам ответа @@ -1178,6 +1199,81 @@ и переданный извне `style` теряется. +### 3.20. Мандат ИИ-агента: доступность, взятие, приёмка, журнал (0.85) + +Агент ходит персональным токеном владельца (3.10), поэтому до 0.85 его действия были +неотличимы от пользовательских, а взять задачу в работу он мог только «вслепую». +Раздел вводит три вещи: **мандат** (какие задачи агенту можно), **взятие** (чтобы двое не +делали одну работу) и **журнал** (кто, что и когда — навсегда в БД). + +**1. Доступность задачи.** У задачи есть флаг `ai_eligible` — «доступно для ИИ-агента». + +- Ставит **владелец** — чекбокс в форме задачи и правкой существующей; по умолчанию + **нет**. +- Агент может поставить флаг только **при создании** задачи (своей): иначе правило «без + флага не трогать» обходилось бы вызовом «пометил — закрыл». +- Задачи, созданные агентом, помечаются в самой задаче (`created_by_kind = agent`, + `created_by_name` — label токена): в интерфейсе у них бейдж, а список по этому признаку + фильтруется (иначе признак пришлось бы вычислять join'ом). +- Регулярная задача наследует флаг от предыдущего экземпляра (3.5). +- Оценки «по силам ли задача агенту» система не делает: это решение владельца, и только + его. + +**2. Взятие в работу (аренда).** Задачу берут не «в уме», а явно — `claim_task`. + +- Взять можно только задачу с флагом, в статусе `to_do` и со свободной арендой; аренда + выдаётся **на 2 часа**, повторный вызов того же агента её продлевает (heartbeat). +- Живая чужая аренда блокирует не только взятие, но и **изменение** задачи агентом: двое + не сделают одну работу и не закроют задачу друг за другом. Владельцу аренда не мешает — + его действие её снимает. +- Протухшая аренда (агент пропал) считается свободной сама, по времени — без фоновых + задач и крона. Выход задачи из `to_do`/`in_progress`, закрытие и удаление снимают аренду. +- Агент, которому задача не по зубам, возвращает её: `release_task` с причиной. + +**3. Приёмка.** Работа агента не становится окончательной сама. + +- Закрывая задачу, агент **обязан** приложить комментарий — что сделано; закрытие без + комментария инструмент отклоняет. Владельцу комментарий необязателен. +- Закрытая агентом задача помечается «ждёт приёмки» (`accept_state = pending`) и попадает + в одноимённый фильтр списка задач, а на странице задачи появляются «Принять» и «Вернуть + в работу». +- Владелец либо **принимает** работу (`accepted`), либо **возвращает** её (`rejected` с + причиной): задача снова в `to_do`, аренда снята. XP и монеты не отзываются (3.13) — + принцип «только позитив»; второй награды за ту же задачу не будет (идемпотентность по + задаче, 8.7), как и второго экземпляра регулярной. +- Приёмка — **отдельная плоскость**, а не статус (3.4): набор статусов не меняется, `done` + остаётся «сделано» для статистики, теплокарты и спавна регулярной. Владелец, закрывший + задачу сам, приёмкой не занимается — его закрытие снимает пометку. +- Принимать и возвращать может только владелец; агенту инструмент отказывает. + +**4. Журнал** (`task_events`). Каждое действие по задаче оставляет запись: **кто** +(`actor_kind` — `user` | `agent`; `actor_name` — имя владельца или label токена), **что** +(`kind`: создание, смена статуса, смена доступности, взятие, отпуск, закрытие, приёмка, +возврат, удаление) и **когда**; канал — `ui` | `api` | `mcp`. Комментарий закрытия и +причина возврата живут в записи. + +- Страница **«Журнал»** (`/journal`, отдельный пункт меню) — таблица записей с фильтрами по + типу события, актору и проекту и с пагинацией. Это первый пагинированный список на + бэкенде: `GET /api/task-events` отвечает конвертом `{ items, total, page, per_page }` + (потолок `per_page` — 100). Вкладки-фильтры «Ждёт приёмки» и «Создано» (мной / агентом) + живут в списке задач (3.6). +- Записи переживают задачу: `task_id` при удалении обнуляется, но заголовок сохранён + снапшотом — журнал читается как история, а не как список живых задач. +- О работе агента узнают не только из журнала: взятие и закрытие публикуются по SSE + (3.14) — пометка в интерфейсе и тост владельцу. + +**Актор действия.** У изменяющих MCP-тулов есть флаг `is_user`: по умолчанию действие +считается **агентским**, `is_user=true` — «действую от имени владельца» (на REST тот же +смысл у заголовка `X-Actor: user`, им объявляет себя браузерное расширение). В журнале у +такого действия актор — владелец, но канал остаётся `mcp`/`api`: видно, что действие +заявлено от его имени и пришло от агента. + +**Это не охрана.** `ai_eligible` — не контроль доступа, а рамка делегирования. Держатель +валидного токена и так действует как владелец (токен — его принципал), и объявить +`is_user=true` он вправе в любой момент. Ценность мандата — в видимости и в защите от +случайности: агент не тронет задачу, которую владелец ему не поручал, а всё, что он сделал, +видно в журнале. Строить на `ai_eligible` разграничение прав нельзя. + ## 4. Нефункциональные требования | Требование | Значение | @@ -1253,6 +1349,16 @@ estimated_cost nullable — оценка затрат по задаче (сравнивается с бюджетом) actual_time nullable — фактические затраты (для обучения прогноза) created_at, approved_at, done_at + ai_eligible bool — задача доступна ИИ-агенту (3.20, 0.85); по умолчанию false + created_by_kind user | agent — кто создал задачу; created_by_name — label токена + агента (0.85, 3.20): по этим полям список фильтруется в UI + done_by_kind user | agent, nullable — кто закрыл задачу последним + (NULL — до 0.85, неизвестно) + accept_state pending | accepted | rejected, nullable — плоскость приёмки + агентской работы (3.20); NULL — приёмка не применяется + claim nullable — аренда агента (3.20): claimed_by_token_id (→ mcp_tokens, + ON DELETE SET NULL), claimed_by_name (label, только для показа), + claimed_at, claim_expires_at (время протухания) Project id, name @@ -1298,6 +1404,17 @@ не создаются: ленивая ensure_garden вызывается только из `GET /api/garden`, который UI не зовёт +TaskEvent — журнал действий по задачам (3.20, 0.85): кто, что и когда. + task_id (nullable, ON DELETE SET NULL — журнал переживает задачу), + task_title (снапшот заголовка, чтобы запись читалась после удаления), + project_id (nullable), kind (created | eligible_changed | claimed | + released | completed | accepted | rejected | status_changed | + deleted), actor_kind (user | agent), actor_name (имя владельца или + label токена), via (ui | api | mcp), comment (nullable — что сделано + или причина возврата), created_at. Единственный пагинированный + список на бэкенде: `GET /api/task-events` отвечает конвертом + { items, total, page, per_page }, потолок per_page — 100 + AppSetting — per-user настройки (валюта, язык): PK (user_id, key), value AppSettingGlobal — таблица-наследие однопользовательской версии (бывшая app_settings); источник claim при первом логине, @@ -1335,6 +1452,7 @@ | M3 — структура | Подзадачи (parent_task_id), классический список с фильтрами, заметки проектов | | M4 — умность | Прогнозирование времени, бюджет, выдача задачи | | M5 — MCP | MCP-сервер, инструменты для агентов | +| M5+ — мандат агента | Доступность задачи для ИИ-агента, взятие в работу (аренда), приёмка работы, журнал `task_events` (0.85, 3.20) | | M6 — мобильность | PWA (Android-приложение — позже, вне стартового скоупа) | | M7 — упаковка | Docker (docker-compose: API, PostgreSQL, фронт, Ollama + модель) | @@ -1352,4 +1470,5 @@ 8. ~~**Пиксель-арт сцена сада**~~ — **решено 2026-09-20**: сцена переведена на пиксель-арт (PixiJS 8, WebGL); спрайты генерируются из пиксель-матриц в коде (без внешних ассетов и лицензий), см. 3.13. **Отложено вместе с садом (0.58)** — код сохранён, вернётся при доработке сада. 9. **Premium-виды в семенах** — **отложено вместе с садом (0.58)**: premium-виды (сейчас кактус) рождаются только из rare/epic закрытий — «отпечаток крупной работы». Семена же продаются за монеты и дают common-растение; сейчас семя premium-вида купить можно. Оставить ли покупку семян premium-видов (простой доступ к виду) или запретить (эксклюзив rare/epic сохраняется) — решить при возврате сада. 10. **Самосоединяющийся забор** — **отложено вместе с садом (0.58)**: секции изгороди автоматически соединяются с соседними секциями (маска соседей N/E/S/W → текстура); ручного поворота нет. Соединяются только соседние секции забора — диагонали не считаются. Подтвердить при возврате сада. -11. **Возврат сада** (0.58): когда основной функционал будет в порядке, вернуть сад к доработке — выдача растений включится сама, если UI снова начнёт звать `GET /api/garden`; вопросы — что переделать в саду (сцена, маркет, прогрессия) — решать тогда, на накопленных данных. \ No newline at end of file +11. **Возврат сада** (0.58): когда основной функционал будет в порядке, вернуть сад к доработке — выдача растений включится сама, если UI снова начнёт звать `GET /api/garden`; вопросы — что переделать в саду (сцена, маркет, прогрессия) — решать тогда, на накопленных данных. +12. **Ретенция журнала** (0.85): `task_events` пишет всё жизненное, включая смену статуса, и растёт вместе с работой. Пока пагинации и потолка `per_page` хватает; решить позже — нужен ли срок хранения, архивация старых записей или их сжатие по задаче. \ No newline at end of file