Состояние 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
---
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 служит заголовком.
---
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).
---
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 шумит и мержи ложно конфликтуют), сериализатор обязан:
- Переводы строк — только LF (
\n), в том числе на Windows. Ровно один завершающий\nв конце файла. - Порядок ключей frontmatter — фиксированный, заданный этой спекой (а не порядком колонок БД или вставки). Список порядка — часть контракта.
- Списки сортируются детерминированно:
tags— по алфавиту;relevant_files,scope_paths— в объявленном пользователем порядке (он значим — конвенция #… о порядке гейтов), поэтому сохраняются как есть, но дубликаты убираются.edges— сортируются по кортежу(relation, target_type, target). - Даты — ISO-8601 UTC с суффиксом
Z, без микросекунд. - Пустые/
nullполя сериализуются явно (field: null), а не опускаются — чтобы отсутствие поля и его пустота не путались при импорте. - YAML без якорей, флоу-стиля и умных типов: строки, которые YAML мог бы прочитать как число/дату/bool (слаг
2026-01, статусon), берутся в кавычки. Многострочная проза живёт в теле, а не во frontmatter.
Свойство для гейта (state-git-roundtrip-gate): export(current_db) даёт файлы, побайтово равные тем, что лежат в git; и import(export(db)) даёт БД, эквивалентную исходной по множеству сущностей, полей и рёбер графа.
Когда проекция обновляется
Проекция следует за БД сама, без ручных команд. Обновление вызывает ЛЮБАЯ мутация сущности одного из пяти проецируемых кинд'ов — перечень задан один раз в state_serialize.ENTITY_DIRS, и обе стороны (экспорт и импорт) выводятся из него, а не объявляют свою копию:
| Кинд | Что триггерит |
|---|---|
epics | epic add, epic done, epic delete |
stories | story add, story done, story delete |
tasks | task add/quick, update, start, log, plan, step, block, unblock, review, move, done, delete |
decisions | decide — все ветки, включая привязанную к задаче и уехавшую в brain |
memory | memory 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) обновляются экспортом при следующей записи.
Границы и ошибки (негативные сценарии)
- Сущность без стабильного слага. До миграции
state-git-stable-idsрешения и часть памяти не имеют слага. Экспортировать их нельзя — экспорт обязан отказать с явным указанием, что сперва нужна миграция, а не молча пропустить или сгенерировать эфемерный слаг (он разъедется между машинами). Экспорт зависит отstate-git-stable-idsпо построению. - Недетерминированное поле. Любое поле-множество (теги, рёбра) без правила сортировки даёт разный файл на разных машинах → ложные конфликты. Правило нормализации выше обязано устранить недетерминизм для КАЖДОГО такого поля; гейт round-trip ловит пропущенное.
- Невалидный frontmatter, отредактированный руками. Файл с битым YAML или отсутствующим обязательным полем (
slug) импорт обязан отвергнуть с ошибкой, называющей файл и проблему, а не проглотить молча и не затереть БД частичными данными. Молчаливое проглатывание битого стейта — тот самый класс тихих ошибок, который проект запрещает. - Заголовок
##внутри прозы тела. Проза задачи (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 на локальных автоинкрементах столкнутся при мерже двух веток.