Skip to content

Состояние TAUSIK в git — контракт (team-state-in-git)

Спека формата git-native проекции состояния проекта. На неё опираются задачи state-git-stable-ids, state-git-export, state-git-import, state-git-triggers, state-git-roundtrip-gate. Решение — #172.

Зачем

Весь .tausik/ в .gitignore, а tausik.db — бинарный SQLite: тиммейт после git clone не видит ни задач, ни решений, ни памяти проекта, а две правки в одну БД неслиянны. Стейт при этом ещё и branch-blind — переключение ветки не меняет .tausik/.

Решение (#172): durable-состояние проекта едет git-native текстовыми файлами в ветке, по файлу на сущность. Git — канонический источник правды, БД — пересобираемый рабочий кэш. Слияние стейта = git merge веток: решение, принятое на feature-A, приезжает в main ровно тогда, когда приезжает код feature-A. Внешний стор (Notion/сервер) для этого непригоден — он branch-agnostic и расцепляет стейт с кодом.

Два этажа. Этаж 1 (эта спека): durable, сцеплён с кодом. Этаж 2 (отложен): живая координация «кто над чем сейчас, до мержа» — здесь не описывается.

Что едет в git, что нет

Проекция — это durable-намерение и результат, а не рантайм-телеметрия. Правило: едет то, что осмысленно читать в PR коллеги; не едет то, что привязано к конкретной машине/прогону и быстро устаревает.

ТаблицаВ git?Почему
tasksда (подмножество полей, см. ниже)ядро: что делается, цели, AC, план
task_logsдажурнал хода — append-only, сливается как добавленные строки
epicsдаструктура работы
storiesдаструктура работы
decisionsдаархитектурные решения проекта
memoryдапаттерны/грабли/конвенции ЭТОГО проекта
memory_edgesдаграф связей памяти/решений
task_specsдапривязка задач к спекам
specsдаспецификации проекта
verification_runsнетдоказательство прогона на КОНКРЕТНОЙ машине; подписи привязаны к локальному ключу; быстро устаревает
gate_runsнеттелеметрия прогонов гейтов
reviewsнетпривязаны к прогону/ревьюеру
sessionsнетэфемерное: кто когда сидел
session_usage_metricsнеттелеметрия
usage_eventsнеттелеметрия
events, events_anchorнетлокальная hash-chain аудита; неслиянна по построению
reasoning_stepsнет (v1)RENAR-трасса хода; кандидат на этаж 2, пока локально
explorationsнетэфемерные тайм-боксы исследования
roles, adapts*, snippets, brain_events, meta, sync_stateнетконфиг/рантайм/меж-проектное, не командный стейт

Поля tasks — что durable, что рантайм

Едет (намерение и результат): slug, title, status, stack, complexity, role, tier, goal, plan, acceptance_criteria, rollback_plan, scope, scope_exclude, scope_paths, scope_tools, relevant_files, defect_of, call_budget, completed_at, ссылка на story.

НЕ едет (локальный рантайм/телеметрия): id, score, attempts, claimed_by, call_actual, cost_budget_usd, cost_actual_usd, token_budget, tokens_actual, risk_score, risk_json, started_model_id, started_model_version, done_model_id, done_model_version, model_mismatch, no_file_changes_declared, started_at, blocked_at, created_at, updated_at, archived_at. notes не едет отдельным полем — журнал ведётся через task_logs.

Раскладка каталога

Корень проекции — tausik/ в корне репозитория (НЕ .tausik/, который остаётся приватным и игнорируемым). По файлу на сущность:

tausik/
  epics/<epic-slug>.md
  stories/<story-slug>.md
  tasks/<task-slug>.md
  decisions/<decision-slug>.md
  memory/<memory-slug>.md
  specs/<spec-slug>.md

Один файл на сущность — чтобы правки двух инженеров по разным задачам мёржились чисто, а конфликт локализовался в один файл. Имя файла — стабильный слаг сущности (не локальный id).

Формат файла

Markdown + YAML-frontmatter. Frontmatter — машинные поля (идентичность, статус, ссылки, перечисления). Тело — проза (цель, AC, план, журнал, содержание). Такое деление позволяет открывать файлы Obsidian'ом как vault, а человеку — читать diff.

Задача — tausik/tasks/<slug>.md

markdown
---
slug: state-git-export
title: "Экспорт БД → git-native файлы"
status: planning
epic: team-state-in-git
story: state-in-branch-mvp
complexity: complex
role: developer
stack: python
tier: substantial
call_budget: 120
defect_of: null
scope: "scripts/state_*.py, tests/*"
scope_exclude: "state-git-import"
relevant_files:
  - scripts/state_export.py
  - tests/test_state_export.py
scope_paths:
  - scripts/state_*.py
  - tests/*
scope_tools: []
completed_at: null
---

## Goal

<текст goal>

## Acceptance Criteria

<текст acceptance_criteria>

## Plan

<текст plan>

## Rollback

<текст rollback_plan>

## Journal

- 2026-07-24T15:00:00Z — сообщение первого лога
- 2026-07-24T15:20:00Z [verify] — сообщение с фазой

Journal — проекция task_logs, append-only: новые записи только дописываются в конец. Формат строки: - <created_at> [<phase>] — <message> (секция [<phase>] опускается при пустой фазе). Так журнал двух веток сливается как добавленные строки, почти без конфликтов.

Решение — tausik/decisions/<slug>.md

У decisions в БД нет ни слага, ни заголовка. Стабильный слаг генерируется в задаче state-git-stable-ids (детерминированно из содержания+даты). Первая строка decision служит заголовком.

markdown
---
slug: state-in-branch-over-external-store
task: state-git-spec
date: "2026-07-24"
edges: []
---

## Decision

<текст decision>

## Rationale

<текст rationale>

decisions — валидный source_type в memory_edges, поэтому у решения тоже есть edges (проекция рёбер, где решение — источник; пусто → []), формат тот же, что у памяти ниже. date берётся в кавычки (правило 6): иначе YAML прочитал бы его как тип-дату, а не строку.

Память — tausik/memory/<slug>.md

Слаг выводится из title (kebab-case, стабилизируется в state-git-stable-ids).

markdown
---
slug: state-decoupled-from-code-lies
title: "Состояние, расцепленное с кодом, лжёт"
type: convention
tags:
  - git
  - team
  - state
task: state-git-spec
edges:
  - relation: relates_to
    target_type: decision
    target: state-in-branch-over-external-store
---

<текст content>

title — durable-поле memory (NOT NULL), а слаг из него выводится необратимо (kebab-case, обрезка), поэтому для round-trip заголовок сериализуется отдельным ключом, а не восстанавливается из слага.

edges — проекция memory_edges, где эта запись выступает источником. targetстабильный слаг целевой сущности, а не целочисленный id.

Эпик / стори / спека

epics/<slug>.md и stories/<slug>.md — frontmatter (slug, title, status; у стори ещё epic) + тело description. specs/<slug>.md — аналогично, с телом спеки и task_specs-привязками во frontmatter.

Контракт round-trip (нормализация)

export(БД) → файлы и import(файлы) → БД обязаны быть взаимно обратными. Чтобы один и тот же стейт давал байт-идентичный файл на любой машине и при повторном экспорте (иначе diff шумит и мержи ложно конфликтуют), сериализатор обязан:

  1. Переводы строк — только LF (\n), в том числе на Windows. Ровно один завершающий \n в конце файла.
  2. Порядок ключей frontmatter — фиксированный, заданный этой спекой (а не порядком колонок БД или вставки). Список порядка — часть контракта.
  3. Списки сортируются детерминированно: tags — по алфавиту; relevant_files, scope_paths — в объявленном пользователем порядке (он значим — конвенция #… о порядке гейтов), поэтому сохраняются как есть, но дубликаты убираются. edges — сортируются по кортежу (relation, target_type, target).
  4. Даты — ISO-8601 UTC с суффиксом Z, без микросекунд.
  5. Пустые/null поля сериализуются явно (field: null), а не опускаются — чтобы отсутствие поля и его пустота не путались при импорте.
  6. YAML без якорей, флоу-стиля и умных типов: строки, которые YAML мог бы прочитать как число/дату/bool (слаг 2026-01, статус on), берутся в кавычки. Многострочная проза живёт в теле, а не во frontmatter.

Свойство для гейта (state-git-roundtrip-gate): export(current_db) даёт файлы, побайтово равные тем, что лежат в git; и import(export(db)) даёт БД, эквивалентную исходной по множеству сущностей, полей и рёбер графа.

Когда проекция обновляется

Проекция следует за БД сама, без ручных команд. Обновление вызывает ЛЮБАЯ мутация сущности одного из пяти проецируемых кинд'ов — перечень задан один раз в state_serialize.ENTITY_DIRS, и обе стороны (экспорт и импорт) выводятся из него, а не объявляют свою копию:

КиндЧто триггерит
epicsepic add, epic done, epic delete
storiesstory add, story done, story delete
taskstask add/quick, update, start, log, plan, step, block, unblock, review, move, done, delete
decisionsdecide — все ветки, включая привязанную к задаче и уехавшую в brain
memorymemory add, dead-end, memory delete, memory link/unlink, memory archive

Сущность, покинувшая проекцию (удалена или, для памяти, архивирована), теряет свой файл: дерево обязано уметь сжиматься, иначе накапливаются файлы-призраки, описывающие строки, которых в БД уже нет.

Не триггерят намеренно: task claim / task unclaim. Поле claimed_by не входит в набор колонок, которые сериализует state_export.export_one, — значит изменить проекцию они не могут. Это единственное исключение, и оно проверяемо: свойство ниже упало бы, будь оно неверным.

Как это гарантируется. Не перечнем вызовов — первый заход именно так и был сделан, и 18 методов из ~20 экспорт пропускали. Гарантия — свойство, проверяемое тестом tests/test_state_projection_tracks_db.py:

после произвольной последовательности мутаций, без единой ручной команды между ними, файлы на диске побайтово равны build_tree(БД)

Свойство не зависит от того, КАК сделан экспорт, поэтому новый мутатор, забывший спроецировать, роняет тест, а рефакторинг, переносящий экспорт в другое место, — нет. Плюс coverage-рэтчет: набор кинд'ов, прогоняемых тестом, сверяется с ENTITY_DIRS, так что шестой кинд нельзя добавить в реестр, не расширив прогон.

Цена. Экспорт перерисовывает документ сущности целиком, чтобы сравнить его с файлом на диске. На самом горячем мутаторе (task log) и самом длинном журнале проекта — 21 запись, документ 40 КБ — это 26.6 мс против 5.3 мс без экспорта. Замер, не оценка.

Весь механизм за флагом state.auto_export; при выключенном флаге не пишется ничего, и дерево обновляется только явным tausik state export.

Слияние в git

  • Разные сущности (A правил задачу X, B — задачу Y) → разные файлы → git merge сливает чисто.
  • Одна сущность (оба правили задачу X) → конфликт в одном файле, разрешается руками. Локализация — главная выгода «файл на сущность» против бинарной БД, где конфликтовал бы весь стейт.
  • Журнал одной задачи, дополненный на обеих ветках → git видит две группы добавленных строк; порядок восстанавливается сортировкой по created_at при следующем импорте (append-only, поэтому содержательного конфликта нет).
  • Удаление сущности — удаление файла. Импорт трактует отсутствие файла как «сущности больше нет» ТОЛЬКО при явной синхронизации-зеркале; обычный инкрементальный импорт не удаляет (чтобы git checkout одной ветки не стирал задачи, ещё не влитые из другой). Политика удаления уточняется в state-git-import.
  • Переименование слага — переименование файла (git mv). Поскольку слаг — идентичность, переименование = новая сущность + удаление старой; ссылки (edges, story, defect_of) обновляются экспортом при следующей записи.

Границы и ошибки (негативные сценарии)

  1. Сущность без стабильного слага. До миграции state-git-stable-ids решения и часть памяти не имеют слага. Экспортировать их нельзя — экспорт обязан отказать с явным указанием, что сперва нужна миграция, а не молча пропустить или сгенерировать эфемерный слаг (он разъедется между машинами). Экспорт зависит от state-git-stable-ids по построению.
  2. Недетерминированное поле. Любое поле-множество (теги, рёбра) без правила сортировки даёт разный файл на разных машинах → ложные конфликты. Правило нормализации выше обязано устранить недетерминизм для КАЖДОГО такого поля; гейт round-trip ловит пропущенное.
  3. Невалидный frontmatter, отредактированный руками. Файл с битым YAML или отсутствующим обязательным полем (slug) импорт обязан отвергнуть с ошибкой, называющей файл и проблему, а не проглотить молча и не затереть БД частичными данными. Молчаливое проглатывание битого стейта — тот самый класс тихих ошибок, который проект запрещает.
  4. Заголовок ## внутри прозы тела. Проза задачи (goal/plan/acceptance_criteria/ rollback_plan) может содержать строку вида ## Journal или ## Plan — это валидный markdown в тексте пользователя, и экспорт его не экранирует (иначе диф стал бы нечитаемым, а экранирование потребовало бы обратной операции на импорте). Поэтому state-git-import обязан восстанавливать секции тела по фиксированному упорядоченному набору известных заголовков (## Goal## Acceptance Criteria## Plan## Rollback## Journal), а не по «первому встречному ##»: журналом считается ТОЛЬКО последняя секция ## Journal, всё до неё в границах предыдущего известного заголовка — тело соответствующего поля. Наивный парсер по любому ## даёт подделку журнала (строка - <ts> — … внутри Plan читается как настоящая запись аудита) и разъезд полей. Экспорт детерминирован в любом случае; грамматику держит импорт.

Зависимости

Порядок реализации задан story state-in-branch-mvp. Несущая — state-git-stable-ids: без стабильной идентичности решений и памяти экспорт пункта (1) невозможен, а memory_edges/decisions на локальных автоинкрементах столкнутся при мерже двух веток.