textmachine/docs/BACKEND_BOOKWRITER_PACK_SESSION_PROMPT.md

374 lines
44 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

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

# Промт: движок, пак «писатель книги» — переведённая книга становится ФАЙЛОМ
## 0. Какая проблема и что решит твой результат
Проект переводит книги и **не умеет отдать книгу**. Ни одной стороной. Движок отдаёт только поток:
`renderExport` (`backend/cmd/tmctl/render.go:565`) печатает стабильный JSON `tm-export-v1` либо
`--plaintext` с аудит-баннерами `=== CHAPTER N CHUNK M ===`. Греп `epub|fb2|docx` по `backend/**/*.go` минус
`_test.go` даёт **43** строки, из них 26 в `internal/chunk/ingest.go` — всё на стороне ЧТЕНИЯ.
**И писатель EPUB в дереве УЖЕ ЕСТЬ**`backend/internal/chunk/chunktest/epub.go:37-112`
(`BuildEPUB`/`BuildEPUBAt`): настоящий OCF-контейнер, `mimetype` первым и `zip.Store`, `container.xml`,
`content.opf` с manifest+spine, XHTML. Он ТЕСТОВЫЙ, обратим ингесту по построению и запинен
(`ingest_test.go:537` `TestEPUBFixtureMimetypeIsOCFConformant`). Его шапка предупреждает: дублирование
zip/OPF-строителя развело бы две копии. **Начинать с него — как именно, размечено в §3.1.**
Платформа обещанные каноном ручки выдачи не монтирует, её `ExportFormats` захардкожен пустым
(`platform/cmd/tmplatformd/runner.go:191`).
**Что решит твой результат:** переведённую книгу можно будет открыть в читалке. Сегодня цель №1 канона
(«издательское художественное качество») не проверяется ничем на выходе: результат некуда открыть.
Носитель — строка **236** единого бэклога, вес «блокер-очереди», пункт 1 очереди №20.
**Чего твой результат НЕ решает:** дверь платформы (`createExport`/`getExport`) — отдельная работа
ПОСЛЕ живого прогона, потому что до него неизвестно, что реально кладётся в файл.
## 1. Зона записи и git
Пишешь только в `backend/`. **Не коммитишь**: дерево готовишь и передаёшь оркестратору. Канон git — в
`CLAUDE.md`, здесь не дублируется.
## 2. Карта чтения — ПЯТЬ позиций, дальше только по их ссылкам
1. `backend/internal/pipeline/export.go`**прежде всего блок CAVEATS над `Export`**: три состояния
КНИГИ, о которых файл обязан не соврать. ⚠ Ещё два состояния — ПОЮНИТНЫЕ, в CAVEATS их нет; они в §3.2.
2. `backend/cmd/tmctl/render.go:562-630`=`func renderExport` — существующий рендер: чем `--plaintext` НЕ годится в качестве
книги (аудит-баннеры, флаги в скобках) и арифметика `withheld`/`incomplete` (`:573-587`), которую
твой файл обязан повторить.
3. `backend/internal/chunk/ingest.go` — как книга РАЗБИРАЕТСЯ из epub: писатель обязан быть обратим к
этому чтению, иначе круг «загрузил → получил» не замыкается (§4 — равенством, не парсом).
4. `docs/architecture/12-go-style-notes.md` §0 — норматив общности: **пары языков это ДАННЫЕ**.
5. `docs/PROGRESS.md`, строка БЭКЛОГА **201** (`grep -n '^| 201 |'`) — синтетический заголовок едет
внутри текста юнита; твоя граница, см. §3.3.
## 3. Работа
### 3.1 Что построить (делай РОВНО так)
**Движковый писатель книги из `BookExport`**, вызываемый из `tmctl`. Минимум два формата: **EPUB**
(книга для читалки) и **чистый текст** — без аудит-баннеров `=== CHAPTER …`, без флагов в скобках и без
операторских маркеров вроде `gapMarker` (`render.go:56-67` — английская лексика аудит-поверхности,
читателю не годится). ⚠ Это запрет на ОПЕРАТОРСКИЙ словарь, не на честность: как показать читателю
неполноту — решение §3.2. Существующий `--plaintext` не трогать и не переименовывать — это его
ВЫВОД, запинённый `export_cli_test.go`/`exportgap_cli_test.go`; вынос арифметики withheld/incomplete в
общий хелпер допустим. JSON — машинная поверхность двух потребителей: полигон (`eval/README.md:80`=`для глаз`
«`--plaintext` для глаз, дефолтный JSON для скриптов») и платформа
(`platform/internal/runner/engine.go:148`=`func ExportArgs``:191` `ingest.DecodeExport`); менять форму `tm-export-v1` — значит менять платформу из
чужой зоны.
**ОДИН файл на книгу на формат** — так требует контракт (`docs/architecture/14-api-contract/openapi.yaml:2489-2492`=`A built copy of the book`:
`Export` = «A built copy of the book, behind a link»); деление на главы живёт ВНУТРИ файла (spine EPUB,
разделители в txt), а не набором файлов.
**Основа — `chunktest/epub.go` (инварианты — РОВНО так, форму выноса решаешь сам).** Напрямую из
`tmctl` он невызываем: принимает `*testing.T` и зовёт `t.Fatal`/`t.TempDir()` (`epub.go:37-50`), а
копировать его второй раз запрещает его же шапка. Значит общую OCF-часть (`mimetype` первым и
`zip.Store`, `container.xml`, запись entry) выносишь в прод-пакет без `import "testing"` и переводишь
`chunktest` на неё — либо пишешь прод-писатель от его инвариантов и аргументируешь, почему две копии не
разойдутся. Инварианты: `import "testing"` в прод-коде запрещён · тест-файлы, импортирующие `chunktest`
(их 9), остаются зелёными БЕЗ правки ассертов и сигнатур · в прод НЕ переезжает ничего тестового:
ручки `MType`/`EntryName`, OPF-заглушка с `<dc:title>Test</dc:title>` и висячим `unique-identifier="uid"`
(`epub.go:89-90`), XHTML-шаблон `epub.go:103` с `<title>c</title><style>.x{color:red}</style>` — это
ДЕКОЙ, на который сидит `ingest_test.go:95`; поднятый в прод, он уедет читателю в каждую главу.
**Файл обязан быть ДЕТЕРМИНИРОВАН:** два прогона на одних данных дают побайтово одинаковый файл.
EPUB — это zip. У `archive/zip` из stdlib таймметки записей по умолчанию нулевые, порядок записей =
порядок вызовов `Create` — стартовый `chunktest/epub.go` этим уже детерминирован. Утечку вносишь только
сам: `FileHeader.Modified`/`zip.FileInfoHeader` от файловой системы, обход map при записи, и — главное —
`<meta property="dcterms:modified">`, которую EPUB 3 ТРЕБУЕТ (§3.3-бис): значение бери детерминированное
из данных прогона, не `time.Now()`. ⛔ Провалившийся `cmp` чинить удалением этого элемента НЕЛЬЗЯ —
получишь неконформный EPUB. Докажи прогоном двух ПРОЦЕССОВ, а не рассуждением.
### 3.2 Пять состояний, о которых файл не имеет права соврать (делай РОВНО так)
Три — счётчики `BookExport`; `Export` их уже вычисляет (блок CAVEATS) — не изобретай заново:
- **`PendingUnits > 0`** — книга переведена НЕ ЦЕЛИКОМ.
- **`GhostRows > 0`** — есть строки вне текущего манифеста (в `Chunks` их нет по построению — состояние
доступно только счётчиком).
- **`ConfigDrift == true`** — текущий конфиг рендерит не тот снапшот, что несут строки.
⚠ Ещё ДВА — ПОЮНИТНЫЕ, в счётчиках `BookExport` их НЕТ; `renderExport` считает их сам
(`render.go:573-587`, баннеры `:601-622`) — повтори эту арифметику, не изобретай:
- **withheld** — `Disposition != "pending" && FinalText == ""`: substantive-флаг или skip выше по
конвейеру, текст сознательно не отгружен (D2: заражённый вывод не отгружается; `export.go:359-395`).
`PendingUnits` его НЕ считает. `ApplyHeading` на пустой текст заголовок не клеит, так что глава молча
начнётся со СЛЕДУЮЩЕГО юнита.
- **incomplete** — `DroppedMembers > 0` при непустом `FinalText`: c-lite выбросил член юнита, текст
настоящий, но с дырой без шва (`export.go:45-54`; причина — `DroppedReason`).
Живая посадка на оба`acceptance` (числа в §4): при `pending_units=0` там удержан открывающий юнит
книги и усечён юнит главы 10. Файл, сверенный только с тремя счётчиками, там выйдет «молча полным» —
худший исход этого пака.
**Рамка ратифицирована ДО этого пака и бьёт «решаешь сам»:**
- Контракт 14 (D39.99; `docs/architecture/14-api-contract/openapi.yaml:836-853`=`finished or not`, `createExport`): неполная
книга экспортируется — «may be exported, finished or not», а ЧТО содержит файл неполной книги — «not
fixed here», то есть решается ЗДЕСЬ. Следствие: «только отказ» как единственное поведение НЕДОПУСТИМО —
дверь платформы обязана уметь выдать неполную книгу.
- D29.1(б): reader-facing выдача — fail-closed (0 красных для `structurally_complete`). D25.1 требует
ратифицировать лестницу `run_complete / structurally_complete / quality_reviewed / publishable` ДО
первого reader-facing артефакта; семантика «publishable» и право waiver — у владельца. **Решение
оркестратора №20, не твой вопрос:** reader-facing артефакт — выдача читателю через дверь платформы;
этот пак строит МЕХАНИЗМ файла с двумя обязательными поведениями ниже, а лестница ратифицируется
владельцем между этим паком и дверью — по твоему вопросу в отчёте (какие ступени писатель обязан
различать, что печатать на титуле и в имени файла). Поэтому лестницу в коде не изобретай и в
`BookExport` не добавляй; молчаливый полный файл на книге с любой дырой НЕДОПУСТИМ.
**Делай РОВНО так:** файл с любой дырой (pending · withheld · incomplete) либо не пишется — отказ с
перечнем дыр (глава/юнит/причина), либо пишется с явной пометкой неполноты на титуле и видимым маркером
на месте КАЖДОЙ дыры. Оба поведения обязаны существовать (одно по умолчанию, другое флагом) — какое по
умолчанию и как выглядят пометка и маркер, решаешь и аргументируешь; словесная форма для читателя —
данные пары, не литерал в Go (§3.5). Для ghost и drift контракт ничего не фиксирует — отказ или пометка
на твой довод. Три ограничения на форму:
-**Не в снапшот.** Загрузчик пака складывает байты КАЖДОГО присутствующего опционального файла в
`pack.Version()` (`backend/internal/lang/langpack.go:289-298`=`h.Write`), а она входит в snapshot-id стадий
(`backend/internal/pipeline/snapshot.go:381`=`LangpackVersion`): новый файл-данные по этому контракту = resnapshot = перекупка каждой книги
с `langpack_root`. Читательские слова — факт РЕНДЕРА, не перевода: канал для них НЕ складывается в
`Version()` (отдельный не-фолдимый файл рядом с паком или вне пака) — форму решаешь и аргументируешь.
Доказательство командой: `current_snapshot` экспорта копии minirun до и после добавления твоего
файла-данных — тот же. ⚠ На стенде сдвиг НЕВИДИМ: minirun уже под дрейфом, `cmp` двух прогонов его не ловит.
-**Книга без langpack** (`acceptance`; `runner.go:346` → пак nil) словесной формы не имеет по
построению — а это единственная реальная посадка на withheld/incomplete. Там пометка и маркер —
несловесные (номер главы/юнита, символ) либо отказ; что именно — реши и аргументируй. Словесный
fallback в Go запрещён, `langpack_root` в копию — запрещён (§3.3).
- ⚠ Маркер дыры — НЕ в `FinalText` и не в `export.go`: провод платформы судит `translated`/`withheld`
предикатом «текст непуст» (`platform/internal/ingest/export.go:104`=`func unitState`).
### 3.3 Синтетический заголовок — НЕ твоя развилка (делай РОВНО так)
Заголовок «Глава N» сегодня клеится ВНУТРЬ текста юнита — `export.go:266`, и ещё в ДВУХ точках
`waverun.go:647` и `waverun.go:721` (третья — путь draft-only отгрузки). Инвариант, который они держат и
который назван в самом коде: `tmctl export` и `tmctl translate` отгружают побайтово одинаковый текст.
**Трогать `ApplyHeading` и любую из трёх точек ЗАПРЕЩЕНО.** Дизайн глав НЕ ратифицирован (D39.122
п.2д: `heading` манифеста — ВРЕМЕННЫЙ рендер движка), а форма для него уже назначена: строка бэклога
**160** «Структура глав, Этап 0», гейт которой пал словом владельца 15.08 (**D39.136 п.3**) — она едет
ОТДЕЛЬНЫМ, следующим бэкенд-паком с мандатом максимального качества; отсрочка — решение оркестратора
(очередь №20 ставит писателя первым), не твоё. Строка **201** — её родня и стоит «когда-нибудь (с 160)».
Этот пак её не решает и не предвосхищает.
**Что делать вместо:** заголовок для оглавления — тот же детерминированный литерал, который `Export`
УЖЕ держит в момент сборки записи: `u.Members[0].Heading` (`export.go:266`). Он приходит через
`readModelChunks` (`manifest.go:550`) — из сайдкара `ManifestChapter.Heading` (`manifest.go:108`), когда
тот есть, иначе из пере-нарезки; на обоих путях литерал один (проверено: экспорт minirun без сайдкара и
с ним побайтово одинаков). ⚠ Сайдкар `<project_db>.manifest.json` (`manifest.go:137`) у
minirun/acceptance/coldrun-a ОТСУТСТВУЕТ, `loadManifest` (`manifest.go:436-443`) на отсутствующий файл
МОЛЧА отдаёт nil, а `ChunkExport` заголовка не несёт вовсе. Поэтому: писатель сайдкар НЕ читает, `tmctl
manifest` НЕ требует и манифест побочно НЕ пишет. Донести заголовок до писателя — либо in-process
(писатель в `pipeline`, рядом с `Export`), либо аддитивным полем `ChunkExport` (`heading`, `omitempty`
безопасно, §3.3-бис). Форма `tm-export-v1` при этом не меняется, разбирать текст не надо. Если текст
юнита начинается тем же заголовком — срезать ИЗВЕСТНЫЕ байты префикса (`ApplyHeading` — чистая склейка
`heading + "\n\n" + text`, `chunker.go:186-192`), только при `Heading != ""`, а не угадывать эвристикой.
**`Heading` бывает `""`, и это ратифицированно легальное состояние** (D39.100 п.1, К-3: зашитой формы
«Глава N» не существует, легальна книга без глав вовсе; `manifest.go:102-103`). Два пути: книга без
правила заголовка — `acceptance/book.yaml` не объявляет `langpack_root` (`runner.go:346`), и у неё `""` у
ВСЕХ 25 глав; глава без структурного маркера — глава 1 той же книги остаётся `""` даже с правилом.
Не «чини» это добавлением `langpack_root` в копию: состав книги от этого не меняется (те же 37/0/20 —
экспорт джойнит по `(chapter, chunk_idx)`), но правило заголовка меняет ЭКСПОРТНЫЙ ТЕКСТ`ApplyHeading`
наклеит «Глава N» поверх модельного заголовка в 24 юнитах из 37 («Глава 3\n\nГлава 3: Иди-…»), файл
перестанет быть той книгой, что переводилась, а посадка «книга без правила» исчезнет. Что стоит в
оглавлении при `Heading == ""` — реши сам и аргументируй, при двух запретах: никакого словесного литерала
в Go («Глава», «Chapter», на любом языке) — только данные (номер главы числом, маркер из данных пары);
модельные заголовки внутри прозы («**Раздел второй…**», «### Шестой раздел…» и — при `Heading == ""`
«Глава 3: …» у юнитов 3/0, 12/0, 25/0 acceptance) не извлекать и не править — это строка 160; срез
префикса только по `u.Members[0].Heading`, никогда по слову «Глава». Пустой текст ссылки в nav —
невалидный EPUB. ⚠ Если найдёшь причину, по которой так нельзя, — **пинг, а
не своё решение**: вопрос гейчен владельцем.
### 3.3-бис Метаданных книги в `BookExport` НЕТ — учти до кода
`BookExport` несёт `Version`, `BookID`, `TotalUnits`, `PendingUnits`, `GhostRows`, `ConfigDrift`,
`CurrentSnapshot`, `Chunks` — и **ни заголовка, ни языка, ни автора**. Поля юнита — `ChunkExport`
(`export.go:25-55`): `Chapter`, `ChunkIdx`, `SnapshotID`, `Disposition` (ok|flagged|pending), `FlagReason`,
`Detail`, `FinalText`, `Source` (только `--pairs`), `DroppedMembers`/`DroppedReason`; заголовка нет — он
внутри `FinalText` (§3.3).
**EPUB 3 требует ПЯТЬ вещей, не три:** `dc:identifier` (на него ссылается `unique-identifier` пакета),
`dc:title`, `dc:language`, `<meta property="dcterms:modified">` в форме `CCYY-MM-DDThh:mm:ssZ` и
nav-документ (`properties="nav"` в manifest). Фикстура `chunktest` несёт только `dc:title` — epubcheck даёт
на ней 5 ошибок; она образец контейнера, не метаданных. ⛔ nav в spine НЕ класть: `ingest.go` читает
каждый spine-документ как главу (проверено: nav в spine → лишняя глава), и круг §4 ломается.
`dcterms:modified` — не `time.Now()` (§3.1): константа-эпоха либо величина из данных экспорта
(например, из `CurrentSnapshot`) — выбор аргументируй. `dc:identifier` — детерминированная строка от
`BookID`.
Заголовок и язык достижимы из `config.Book` (`book.go:25-27`: `Title`, `SourceLang`, `TargetLang`).
`Title` — отображаемое имя книги, не заголовок на языке цели: на стенде у всех книг `title: 蛊真人`, из
платформы приходит ввод пользователя или имя файла; поле входит в замороженный `BriefHash`
(`book.go:337-345`). Бери как есть, назови ограничение в отчёте; ⛔ перевод названия не выдумывать (ни
литералом, ни транслитерацией, ни платным вызовом) и `title` в book.yaml не менять (смена = re-bill).
**Автора в проекте нет вовсе** — источника для `dc:creator` не существует, и выдумывать его не надо.
`dc:language` берётся из `TargetLang`, а не литералом: пары — данные. **Решаешь сам:** расширять ли
`BookExport`/`ChunkExport` (⚠ добавление поля безопасно: `DecodeExport` платформы — `json.Unmarshal` без
`DisallowUnknownFields`, `platform/internal/ingest/export.go:78-80`=`func DecodeExport`; полигон формы не проверяет; ПЕРЕНОС
существующего поля — нет) или брать метаданные мимо него.
### 3.4 Куда пишется файл — размечено, не додумывай
`cmd/tmctl` сам файлов не пишет (греп `os.Create|os.WriteFile` по `cmd/tmctl/*.go` минус тесты пуст), но
движок пишет их слоем ниже, и у них есть конвенция: сайдкары через `pipeline.writeFileAtomic`
(`backend/internal/pipeline/artifact.go:26`=`func writeFileAtomic`; используют `manifest.go:401`, `bankexport.go:152`, `mining.go`) — место
рядом с `ProjectDB`, запись атомарная, путь ПУБЛИКУЕТСЯ в конверте `StatusArtifacts` (`status.go:115-145`),
и платформа берёт пути ТОЛЬКО оттуда (`platform/internal/runner/artifacts.go:39`=`no bank read-out path`; закон шва
`docs/architecture/17-seam-inbound-law.md` §1 — движок владеет путями своей схемы, платформа путь не
выводит сама). Приор (опровергается аргументом): файл книги — такой же движковый артефакт:
детерминированное место рядом с `ProjectDB`, атомарная запись, строка в `StatusArtifacts`; флаг явного
пути — сверх этого, не вместо. **Реши и аргументируй:** форма вызова — новый глагол `tmctl` или флаг у
`export` (глаголы — замороженный список `main.go:182`; форму потом читает дверь платформы) · флаг пути
или конвенция · поведение при существующем файле (перезапись или отказ — у бэкапа отказ,
`store/backup.go`) · публикуешь ли путь в конверте уже в этом паке. Коды выхода — замороженный словарь (`backend/cmd/tmctl/main.go:51-145`=`refusal band`, запинен
`exitcontract_test.go`): отказ §3.2 — новый `pipeline.RefusalClass` (`refusal.go`) с константой в полосе
1019, с доводом; чисел вне 05/1019 не заводить. Молча выбранная форма здесь дороже неверной: её потом
читает платформенная дверь выдачи.
### 3.5 Границы
-**Платформу не трогать вообще.** Её дверь — следующая работа, не твоя.
-**Формат `tm-export-v1` не менять** без явного довода: его потребляет полигон и платформа
(аддитивные поля по §3.3-бис — не смена формы).
-**Пары языков — данные.** Если твой писатель знает слово «Глава», «Оглавление» или порядок имени
автора — это утечка.
- **Внешние зависимости:** EPUB это zip + XHTML, и стандартная библиотека это умеет (`archive/zip`,
`encoding/xml`); норма — «least mechanism» (`docs/architecture/12-go-style-notes.md` §1, строка 24: сперва
stdlib, только потом своё/зависимость). ⚠ Носителя `STACK_DECISIONS` у бэкенда НЕТ — файлы с этим
именем лежат у фронта и платформы, это чужие зоны: новая зависимость = довод в записке-плане §6 и
пинг оркестратору ДО `go get`.
## 4. Самопроверка ИСПОЛНЕНИЕМ (без неё работа не принята)
-**ДАННЫЕ — замерено на копиях 30.08 (HEAD `8977001`), пере-прогони сам.** В `books/` нет ни одной
книги, которую `tmctl export` берёт как есть, и ни одной без `CONFIG-DRIFT`.
- **`coldrun-a` НЕ переведена — не строй на ней доказательство.** Только волна `draft` (20 строк
`chunk_status`), финальной стадии нет ни у одного юнита; схема v14; после миграции экспорт даёт
**14/14 pending** и drift. Годна лишь как естественный образец «pending».
- **`minirun`** (схема v10; `chunk_status` draft 20 / edit 14) — после миграции **14/14 ok, pending 0,
`config_drift: true`** (обе волны). Единственная «полная» реальная книга; заголовки есть
(`langpack_root` задан → первые юниты начинаются «Глава N\n\n»). Рядом трекнуты `export.json`/`export.txt`
экспорт ТОЙ ЖЕ БД июльским бинарём (`config_drift: false`, старая форма без `export_version`):
сегодняшний `tmctl` немигрированную v10 экспортировать отказывается (exit 13), так что это
единственный до-миграционный оракул; после миграции `final_text`/`snapshot_id`/`disposition` обязаны
совпасть 14/14 — покажи командой, это доказательство, что миграция и дрейф текст не тронули. Файлы
не править.
- **`acceptance`** (v7; `chunk_status` 57/57 строк — это НЕ юниты) — на HEAD экспорт даёт **37 юнитов,
`ghost_rows: 20`, drift, 35 ok + withheld 1/0 + incomplete 10/0**; нарезка сменилась с июля, «57» на
HEAD не существует, восстанавливать не надо. Заголовков нет (§3.3). Это посадка сразу на ghost,
drift, withheld и incomplete.
- **Дрейф на этом HEAD неустраним** — разошлись Go-константы снапшота (версии chunker/classifier/
sanitizer/style_check) и промты; снять его может только платный перепрогон (`--resnapshot` = re-pay,
⛔ не делать). Следствие для §3.2 — в записку-план, не в конец работы: чистый бездрейфовый файл на
реальной книге НЕДОСТИЖИМ; чистый путь и pending доказывай на синтетике (харнессы ниже), реальные
книги — как посадки на drift/ghost/withheld/incomplete. Выбрал «отказ при дрейфе» по умолчанию —
реального EPUB у тебя не будет: заяви это в плане с доводом.
-**`books/` — отдельный git-репозиторий со своим origin, `.db` в нём ТРЕКАЮТСЯ; работай на копии в
песочнице — но `cp -r` каталога КОПИЕЙ НЕ ЯВЛЯЕТСЯ.** `book.yaml`, `pipeline*.yaml` и `pairs/*.yaml`
всех трёх книг несут АБСОЛЮТНЫЕ пути, и на этой машине они РЕЗОЛВЯТСЯ: `/home/ubuntu/books` — симлинк на
оригинальный books-репозиторий, `/home/ubuntu/projects/textmachine/…` — ЧУЖОЙ ворктри (ветка polygon,
живая сессия). `LoadBook` абсолютный путь не пере-рутит (`backend/internal/config/book.go:173`=`filepath.IsAbs`), `tmctl migrate`
мигрирует `book.ProjectDB` на месте и кладёт `backups/` рядом (`migrate.go:83-84`); даже read-only
`export`/`status` открывают WAL и создают `.lock`/`-wal`/`-shm` рядом с оригиналом. Порядок: `go build
-o <песочница>/tmctl ./cmd/tmctl` из `backend/` → скопировать каталог книги + `gu-zhenren/langpack-extend`
+ сиды `gu-zhenren/guzhenren-seed*.yaml` + исходный txt в песочницу → переписать КАЖДЫЙ абсолютный
путь в копиях (`project_db` первым, затем `pipeline`, `source_file`, `glossary_seed`, `langpack_extend`,
`models`, `langpack_root`, `pairs/zh-ru.yaml: prompts_root`, `pipeline.yaml: prompt_override` и
`mining.contrast_path` — последний формально: файла в этом дереве нет, его открывает только майнер,
`mining.go:58`) на песочницу и на `backend/` ЭТОГО ворктри →
`grep -rn --include='*.yaml' '/home/ubuntu/books\|/home/ubuntu/projects/textmachine/' <копия>` обязан
быть пуст (старые пути в `run-*.log` и в комментарии `langpack-extend/zh/surnames-compound.txt` движок
не читает — не трогать) → только потом tmctl. Доказательство: `git -C books status --short -- gu-zhenren`
до и после — одинаковый вывод, без новых `backups/`, `.lock`, `-wal`, `-shm` (весь `books/` не
сравнивай — параллельная полигон-сессия пишет в `dovodka/` и `chtenie-rol/`; строки `D gu-zhenren/*/*.db-wal`/`-shm`
стоят там ДО тебя — репозиторий трекает wal/shm).
- **Хирургия конфигов В КОПИИ** (движок называет каждую причину сам, exit 10): у minirun и coldrun-a
удалить ретайрнутые `mined_delta:`/`mined_rejects:` (D39.156 п.3); у acceptance в
`pipeline-acceptance.yaml` убрать `context.stm_depth`/`overlap_tokens` и `prompt:` у обеих стадий
(промты резолвятся конвенцией `<prompts_root>/<пара>/<роль>.md`) и положить рядом `pairs/zh-ru.yaml`
(образец — `minirun/pairs/`, `prompts_root` на `backend/prompts` этого дерева). Все правки — списком
в отчёт.
- **Файл проходит валидатор EPUB — и это проверяется механически.** Проверено 30.08: `calibre`/
`ebook-convert`, python-библиотек epub и CLI `zip` на машине нет, но **`java` ЕСТЬ** (`/usr/bin/java`,
OpenJDK 21) и сеть доступна — epubcheck приносится одной командой в песочницу:
`curl -sL -o epubcheck.zip https://github.com/w3c/epubcheck/releases/download/v5.3.0/epubcheck-5.3.0.zip && unzip -q epubcheck.zip && java -jar epubcheck-5.3.0/epubcheck.jar <book>.epub`
(проверено: качается ~33 МБ, запускается, валидирует). Это официальный валидатор W3C — механический
верификатор оси «издательский результат». Прогони им КАЖДЫЙ сданный EPUB, порог — `0 fatals / 0 errors`,
warnings перечисли в отчёте с диспозицией; ошибки чинить у писателя, не глушить. Jar живёт в
песочнице — в репозиторий и в зависимости не попадает. Не удалось принести — в obstacle команда и её
вывод, и тогда «проверено структурно, не валидатором» — не выдавай второе за первое. ⚠ Валидатор — не
читалка: «валиден» ≠ «открылся»; читалки на машине нет, эту ось так и помечай.
- **Круг замыкается — РАВЕНСТВОМ, не парсом.** `chunk.IngestEncoded(<твой.epub>, "", "")` (epub-ветка
языка и langpack не читает — `ingest.go:77-82`) и покажи командой: (а) `len(doc.Chapters)` == число глав
книги — ингест кладёт в `doc.Chapters` КАЖДЫЙ XHTML-документ spine, пустой тоже (`ingest.go:534`;
пустота лишь лишает его плотного номера, `:540`), поэтому nav/титул в spine не класть даже без
текста, а глава без текста — легальная пустая глава; (б) абзацы каждой главы (разрез по пустым строкам, trim, пустые прочь —
как `splitParagraphs`, `chunker.go:458`) совпадают с абзацами, которые положил писатель, с учётом
решения §3.3 о заголовке. Абзац = свой блочный элемент (`<p>`); `<br/>` абзаца не даёт (`blockTags`,
`ingest.go:614`); побайтово текст не совпадёт — сравнивай абзацы. `err == nil` сам по себе не
доказывает ничего: вся книга одним документом «разбирается» одной главой.
- **Детерминизм:** два прогона в двух ПРОЦЕССАХ → `cmp` побайтно. Покажи команду и результат.
- **Пять состояний §3.2 — по посадке на каждое:** pending (coldrun-a или синтетика) · ghost, drift,
withheld, incomplete (все четыре есть на acceptance) · чистая полная книга — только синтетика. Две
посадки УЖЕ есть — не изобретай: `backend/internal/pipeline/export_test.go` `TestExportManifestPending`
(`:186`) и `TestExportDetectsConfigDrift` (`:229`) на хелперах из `runner_test.go`. ⚠ Ghost-посадки в
тестах НЕТ (греп `GhostRows` по `*_test.go` пуст) — строишь сам, отдельной строкой в отчёте.
Golden-харнесс (`golden_test.go`) — гард wire пайплайна, не инструмент для твоего файла;
`TM_UPDATE_GOLDEN` не трогать. Каждое состояние обязано отработать так, как ты решил, и это видно в
файле или в отказе. Файл реальной книги, её копии и cmp-вывод — рабочее временное в песочнице: в
отчёт идут команды и вывод; воспроизводимость на приёмке держит герметичный тест писателя в
`backend/` на синтетической книге (`chunktest.BuildEPUB`, образец герметичного `Export``export_test.go`).
-**Править или удалять тест ради зелени НЕДОПУСТИМО (D39.121).** Это не абстракция: любой вариант,
трогающий заголовок, красит `backend/internal/chunk/chunker_heading_test.go:121`, и самый дешёвый
способ вернуть зелень — правка ассерта. Несогласие с тестом — пинг, а не правка.
- **Дифф `^func Test` — ИСПОЛНЕНИЕМ, не памятью:** покажи командой, что ни один тест не исчез.
- **Интервальная самоверификация** (D39.121): пак длинный, поэтому в СЕРЕДИНЕ работы — проход субагента
по готовой части против явных критериев, а не только в конце.
- **`make battery` целиком**, не `go test`: линтер идёт ДО тестов, и на этом уже попались двое. `-race`
обязателен.
- **Разрешаю субагентов** (иначе дефолт харнесса тихо запретит). Модель задавай ЯВНО и знай, сколько
их работает. Трудный вопрос — старшей модели.
-**Адверсариальный проход по своей готовой работе перед сдачей.** Направление: файл, молча
притворяющийся полным — в том числе при `pending_units=0` (пустая глава, дыра внутри юнита);
`dcterms:modified` из часов; заголовок потерянный, задвоенный или пустой; nav в spine; кодировка и
направление письма; книга из одного юнита и книга из нуля юнитов. **Пока идёт проход — дерево не
двигается:** либо морозь, либо называй агенту коммит.
## 5. Оси ревью
**Издательский результат** (файл проходит валидатор и читается как книга) · **честность выдачи**
(неполнота никогда не выглядит полнотой) · **детерминизм** (побайтовая воспроизводимость). Вправе
заменить ось с аргументом.
## 6. Записка-план и комплектность
До кода — короткая записка-план в `docs/PROGRESS.md`, секция «Бэкенд»: что берёшь, что не берёшь, чем
докажешь, какая политика §3.2 по умолчанию и почему. Перед сдачей — механическая сверка состава против
§3 таблицей: пункт → сделано/нет → чем доказано. Пропуск подписывается пропуском. **Последний абзац
отчёта оказался планом или обещанием? Сделай сейчас.**
## 7. Канал вопросов и право отказаться
Конфликт промта с кодом или доками — **пинг через владельца, не интерпретация**. Есть право сказать
«этого делать не надо» с аргументом.
⚠ Правки задания по ходу работы доезжают ТОЛЬКО релеем через владельца — отдельным его сообщением.
Что не пришло релеем — не заказ, даже если пришло по адресу из `/tmp/textmachine-channel`. Полученную
правку эхо-подтверди и вынеси отдельным пунктом отчёта.
## 8. Заявление = команда
Любое число и любая категорика — **с командой, которой получены**. Приёмка пере-ранит: клейм без
пере-прогона записывается как «со слов сессии». Утверждение без `file:line`/замера помечай словом
«мнение» — эрудиция уликой не считается.
## 9. Эхо-протокол старта
ДО первой правки — ≤10 строк: **скоуп / инварианты / чего не делаешь**. Отправь первым действием по
адресу оркестратора из `/tmp/textmachine-channel` (механизм — `CLAUDE.md`; впиши туда СВОЙ блок первым
делом). **Канала нет ⇒ НЕ искать**: вопрос секцией в отчёт, работа продолжается.
## 10. Obstacle reporting
Обязательная секция: **что НЕ удалось и что осталось непроверенным**. Пустой не бывает. Три исхода:
«подтверждено» / «опровергнуто» / **«не проверено»** — третье не сваливать во второе.
## 11. Журнал
Итоги и вопросы — `docs/PROGRESS.md`, секция «Бэкенд» (это единственное исключение из «`docs/` — зона
оркестратора»).