Files
gitmost/docs/features/page-work-time_design.md
T
agent_vscode 8f7664eb04 feat(agent-roles): restrict when the researcher reuses the current page
The researcher now reuses the current/already-open document only when the
user explicitly asked for it, OR the page is empty/near-empty AND its title
matches the research topic; otherwise it creates a new document.

- Rewrite the reuse rule in the WHERE TO WRITE THE RESULT section
- Reword "Create this document" -> "Set up this document" so it fits both
  the create-new and reuse-current-empty cases
- Apply identically to bundles/research/ru.yaml and en.yaml
- Bump researcher version 3 -> 4 in index.yaml; refresh content-hashes.json

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 21:29:03 +03:00

50 KiB

Фича «Время работы над статьёй» — дизайн-документ

Статус: черновик проектирования (код не пишется). Контекст: gitmost (форк Docmost). Зависит от PR #370 / PR #374 (типизированная история страниц).

1. Цель и не-цели

Цель. Показывать в UI страницы одно число — оценку времени, реально затраченного на работу над статьёй, собранную из истории правок. Число должно быть устойчиво к паузам: «в 21:00 одна правка, в 09:00 вторая» не должно превращаться в «работал 12 часов». По клику на число — раскрытие в суточный таймлайн: строка-день = 24-часовая дорожка с окнами активности и суммой за день (см. §6.2).

Явно не-цели.

  • Это НЕ инструмент для агента (не MCP-tool). Это число, отображаемое человеку в UI.
  • Не хронометраж с точностью до минуты — это заведомо оценка.
  • Не биллинг/тайм-трекинг сотрудников; не изменение долговечности черновика.

2. Проблема

История страницы в Docmost — таблица page_history: снимок на каждое сохранение. У снимка есть createdAt, автор (lastUpdatedById), тип источника (lastUpdatedSource: user/agent/git) и группировка агентских правок (lastUpdatedAiChatId).

Наивная оценка max(createdAt) − min(createdAt) завышает в разы: между крайними правками лежат сон, обед, дни простоя. На реальных данных статьи-примера (первая страница истории, 20 снимков) span между крайними снимками ≈ 60 часов, тогда как реальной работы — пара часов в 5–6 коротких заходов.

Правильная постановка: длительность — это не span между крайними правками, а сумма интервалов внутри «сессий»; историю режем по паузам бездействия. Это классическая сессионизация по таймауту неактивности (WakaTime / RescueTime / веб-аналитика).

3. Почему PR #374 — фундамент фичи

До #374 у сессионизации слепое пятно: если человек час пишет подряд и не жмёт «Сохранить», в page_history за этот час почти нет строк (тяжёлые автосейвы ydoc идут в pages/ydoc, а не в историю). Непрерывную работу нечем измерить.

PR #374 вводит типизированную историю через колонку page_history.kind:

kind что означает ценность для фичи
manual человек нажал Save сильный маркер активной работы человека
agent снимок правки агентом машинное время агента (группируется по aiChatId)
idle автоснимок idle-флеша (потолок ~maxWait) регулярный «пульс» непрерывной работы (~≤10м, §3)
boundary автоснимок на переходе актора (user↔agent↔git) бесплатная разметка «кто работал в этом сегменте»
null легаси-автосейв (старые страницы) обычный сэмпл активности

Два env-параметра #374 напрямую задают качество измерения:

  • IDLE_MAX_WAIT_USER=10м / AGENT=5м (потолок ожидания) — определяющий параметр. Проверено по computeHistoryJob (persistence.extension.ts): delay = max(0, min(interval, burstStart + maxWait − now)), а enqueuePageHistory сбрасывает burstStart каждые maxWait. Так как maxWait (10м/5м) < interval (60м/15м), потолок всегда доминирует: во время непрерывной работы idle-снимок форсится каждые ~maxWait, давая регулярный «пульс активности» с шагом ≤10 мин (user) / ≤5 мин (agent).
  • IDLE_INTERVAL_USER=60м / AGENT=15м — номинальный трейлинг-интервал, который потолок ожидания на практике всегда упреждает. Поэтому метка любого idle-снимка отстоит от реальной правки не более чем на ~maxWait (≤10м user / ≤5м agent), а не на 60 мин. (Это ключевой факт для точности: idle-метки — достоверный сигнал активности, не «хвост».)

Вывод: ядро алгоритма работает и на голых createdAt+lastUpdatedSource (есть с миграции 20260616T130000-agent-provenance), поэтому фича считает и на легаси-страницах — просто грубее. После #374 (пульс ≤10 мин + типы) — точно. Жёсткой блокировки на мёрж #374 нет, но полноценная точность появляется вместе с ним.

4. Входной сигнал

Дешёвый проекционный запрос по page_history (без тяжёлой колонки content): на строку — createdAt, lastUpdatedById, lastUpdatedSource, lastUpdatedAiChatId, kind. Все поля (включая kind) уже в PageHistoryRepo.baseFields после #374 — схему трогать не нужно, findTimelineByPageId это лишь лёгкая проекция без content.

Важный факт об авторстве (проверено по persistence.extension.tsupdatePage): page_history.lastUpdatedById — это ВСЕГДА ответственный человек, даже для агентских снимков («human stays the responsible author»). Признак «человек vs агент» живёт в lastUpdatedSource (user/agent/git) и lastUpdatedAiChatId, а НЕ в lastUpdatedById. Отсюда: разделять человеко-/агенто-время нужно по lastUpdatedSource, а атрибутировать конкретному человеку — по lastUpdatedById/contributorIds.

5. Алгоритм: сессионизация по паузам

Параметры (env, в стиле #374; полный список — §10):

  • T_gap — таймаут неактивности: пауза между соседними сэмплами ≤ T_gap = непрерывная работа, больше = перерыв (в зачёт не идёт).
  • P_in / P_out — добивка МНОГОсэмпловой сессии (работа началась до первого и продолжалась после последнего сохранения).
  • P_single — блок ОДИНОЧНОЙ сессии (один сэмпл, соседей в пределах T_gap нет). Мал (дефолт ~2 мин): один автосейв/idle-пульс — это «была правка», но приписывать ему полный P_in+P_out = выдумывать время. НЕ путать с многосэмпловой добивкой.

Псевдокод (ОДИН проход по ВСЕМ сэмплам страницы; класс определяется у ГОТОВОЙ сессии — §5.1):

samples = ВСЕ history rows страницы, projected, sorted by createdAt ASC
    (все типы kind — сэмплы активности; idle — основной «пульс» непрерывной работы §3, НЕ исключается)

# коллапс агентских всплесков (§5.1)
collapse: подряд идущие сэмплы ОДНОГО aiChatId с source=agent → сегмент {t_start, t_end, source:'agent'}.
    Разрывает всплеск любой сэмпл, НЕ продолжающий тот же aiChatId-агент: source≠agent,
    boundary-переход, ИЛИ иной aiChatId — в т.ч. idle/boundary с ДРУГИМ aiChatId = НОВЫЙ ран,
    склеивать НЕЛЬЗЯ (иначе простой между двумя ИИ-ранами засчитается агенту). idle с ТЕМ ЖЕ
    (или null) aiChatId — продолжает сегмент. Дальше сегмент участвует в цикле как один «сэмпл» с
    .t_start/.t_end/.source (source='agent'); у скалярного сэмпла t_start == t_end == t.

gap_threshold(a, b) = (a и b оба source=agent) ? agentTGap : T_gap   # порог зависит от ПАРЫ (§10)

# сессионизация по паузам — ОДИН проход по ВСЕМ сэмплам (не по парам, не отдельно по классам)
sessions = []; cur = null
for s in samples:                              # s — скаляр или коллапс-сегмент
    if cur == null:            cur = { first: s, last: s, samples: [s] }
    elif s.t_start − cur.last.t_end ≤ gap_threshold(cur.last, s):
                               cur.last = s; cur.samples.push(s)         # непрерывная работа
    else:                      sessions.push(cur); cur = { first: s, last: s, samples: [s] }
if cur != null: sessions.push(cur)             # ОБЯЗАТЕЛЬНО закрыть последнюю (иначе теряется)

# класс и интервал каждой сессии
for sess in sessions:
    sess.class = sess.samples.every(is_agent) ? 'agent_only' : 'work'   # §5.1 (по source сэмплов)
    sess.iv    = (sess.first == sess.last && sess.first.t_start == sess.first.t_end)
        ? [ sess.t − P_single, sess.t ]                        # одиночный скаляр: pre-roll (без «будущей» работы)
        : [ sess.first.t_start − P_in, sess.last.t_end + P_out ]   # многосэмпловая / лон-сегмент

workMs      = duration( union( sess.iv : sess.class=='work' ) )         # union внутри класса
agentOnlyMs = duration( union( sess.iv : sess.class=='agent_only' ) )

Ключевые свойства:

  • Один проход, потом классификация → метрики дизъюнктны. Сессии не перекрываются (разделены гэпами > порога), каждая — ровно одного класса. Человек и агент, правящие ОДНОВРЕМЕННО, попадают в одну сессию класса work (надзор засчитан человеку, не дважды). work- и agent_only-интервалы не пересекаются по wall-clock и workMs + agentOnlyMs ≤ реально прошедшегопри рекомендованном T_gap ≥ P_in+P_out (§10): иначе P-добивка соседних сессий РАЗНЫХ классов может пересечься, и пересечение попадёт в обе метрики. Инвариант §6.3 (Σ perDay == work) от этого НЕ зависит.
  • Закрытие последней сессии обязательно (иначе теряется самая свежая): n=1 → одна одиночная сессия P_single, n=0 → 0.
  • union, а не Σ. Перекрытия (соседние ближе P; одновременное соредактирование) не задваиваются. Отсюда Σ perDay == work держится сам собой (§6.3) — без «клампа» и без условия T_gap ≥ P.
  • Калибровка T_gap по «пульсу» #374 (важно). После #374 непрерывная работа гарантированно оставляет history-строку не реже ~maxWait (idle-пульс §3, гейт isDeepStrictEqual). Значит гэп между соседними строками БОЛЬШЕ ~maxWait содержит участок без изменений контента = (частичное) бездействие — даже между двумя manual. Поэтому T_gap калибруется ОТ maxWait (реком. ~15м user / ~7м agent), а не ставится вольно: прежние «30 мин» переоценивали не-пульсирующие паузы. Гэп ≤ T_gap ≈ maxWait ПОДКРЕПЛЁН пульсом — это и оправдывает счёт его как работы. Легаси-страницы до #374 пульса не имеют → там T_gap вынужденно шире и оценка грубее (§7, §10).
  • Всё равно оценка, а не строгая граница: даже с пульсом «читал/думал 12 мин не печатая» не отличить от «отошёл»; подпись «≈» и показ T_gap обязательны (§6).
  • Почему интервалы, а не «правок × блок»: «снимок = +N мин» ломается на агентских всплесках (8 снимков за 7 минут); длина сегмента всплеска = его wall-clock, независимо от плотности снимков.

5.1. Классификация сэмплов и всплесков

  • Класс сэмпла (человек / агент) — по lastUpdatedSource: user→человек, agent→агент, git→исключаем (§10 excludeGit), legacy-null→человек. idle наследует source страницы на момент флеша (= source последней правки). boundary несёт СТАРЫЙ (pre-transition) source — трактуем как есть: он маркирует исходящую работу того актора (это осознанный компромисс, а не недосмотр — точность посекундная тут не нужна).
  • Класс СЕССИИ (нужен для метрик §6.1): сессия, где ВСЕ сэмплы source=agentagent_only (автономный прогон, не в основную метрику). Сессия хотя бы с одним человеческим сэмплом → work (человек + надзор за агентом ВНУТРИ сессии засчитывается человеку — по union'у, §5).
  • idle — полноценный сэмпл активности и основной «пульс» непрерывной работы (§3): его метка отстаёт от реальной правки ≤ maxWait (≤10м) — в пределах округления, отмотки не требует. Исключать нельзя: без него непрерывное письмо без ручных сохранений снова стало бы невидимым.

6. Что показывать в UI

6.1. Свёрнутое состояние — одно число

Две метрики, каждая = union-wall-clock СВОИХ сессий (§5):

  • work — headline, кликабельное число (≈ 4 ч 30 мин, рядом с панелью истории или в мета-инфо у заголовка): сессии класса work (≥1 человеческий сэмпл = человек + надзор за агентом внутри сессии). Именно это открывает таймлайн (§6.2), и именно к нему сходится сумма по дням.
  • agent_only — вторично: сессии автономных прогонов агента (ни одного человеческого сэмпла). На таймлайне — отдельным цветом; в work НЕ входит.
  • Подпись «≈» и показ T_gap обязательны (оценка, не хронометраж — §5). Округление headline — шаг 5–15 мин (точность оценки не оправдывает «4 ч 27 мин»).

6.2. Раскрытие по клику — суточный таймлайн (24 ч × дни)

Клик по числу открывает модалку/поповер с таймлайном по типу «punch-card»:

  • Строка = один календарный день; ширина строки = 24 часа (фиксированная шкала 00:00→24:00).
  • На дорожке дня закрашены окна активности — реальные интервалы работы в их часовом положении, так что видно «вечерний марафон» vs «утренняя сессия».
  • Справа от строки — сумма за день «ч мм» (напр. 3 ч 17 м); пустой день — пустая дорожка и «—».
  • Внизу — общий итог (= headline work §6.1) плюс подпись таймзоны и T_gap.

Это графическая версия таблицы-примера-картинки: там окна были текстом («18:46 → 00:58»), здесь то же рисуется отрезками на 24-часовой дорожке.

Детали:

  • Окна = сессии класса work (§5), обрезанные по границам суток; сессии agent_only — отдельным цветом (§6.1). По умолчанию work — один цвет «активность».
  • Сессия через полночь рисуется как отрезок до 24:00 в одном дне и продолжение с 00:00 в следующем — тот самый полуночный разрез (§6.3), теперь визуально очевиден.
  • Честность добивки. Окно включает P/P_single — это оценка «до/после», а не измеренный интервал. Одиночная сессия (P_single) рисуется минимальной видимой шириной и приглушённо (иначе исчезает и/или создаёт иллюзию плотной работы из одного клика). Общая подпись — «≈».

6.3. Агрегация по дням (алгоритм)

Вход — ВСЕ сессии из §5 (work и agent_only), каждая развёрнута в интервал (P_in/P_out или P_single).

bucketByDay(sessions, tz):
  U_work  = union( sess.iv : sess.class=='work' )       # снимаем перекрытия ОДИН раз (§5)
  U_agent = union( sess.iv : sess.class=='agent_only' )  # СИММЕТРИЧНО — иначе окна агента наложатся
  для каждого дня D в [первый_день … последний_день] по tz (dayjs+tz, startOf('day')):
    workWin[D]  = { u ∩ [начало D, начало D+1) : непусто, u в U_work }
    agentWin[D] = { u ∩ [начало D, начало D+1) : непусто, u в U_agent }
    activeMs[D] = Σ длительностей workWin[D]            # U_work без перекрытий → просто сумма
    agentMs[D]  = Σ длительностей agentWin[D]
  → perDay[] = [{ day, activeMs, agentMs, windows: (workWin[D] ⊕ agentWin[D]) с меткой класса }]
  • Инвариант согласованности Σ activeMs[D] == work держится ПО ПОСТРОЕНИЮ: день — это разбиение union'а U_work границами суток, ничего не теряется и не дублируется (в т.ч. на 23/25-часовых DST-сутках — §9#14). agent_only-окна рисуются, но в activeMs НЕ входят. Клампа и скрытого условия T_gap ≥ P не требуется (в отличие от наивного Σ длительностей).
  • Таймзона TZ определяет, где проходит «полночь» И в каких часовых координатах рисуются окна. Дефолт — таймзона зрителя (локаль браузера); альтернатива — UTC (как на картинке-примере «По дням (UTC)») или tz воркспейса. Влияет на раскладку по дням и положение окон, но НЕ на общий итог → настройка (§10).
  • Длинный диапазон (месяцы правок): строк ровно столько, сколько календарных дней в диапазоне. При большом числе дней — вертикальный скролл + сворачивание длинных серий пустых дней («× N дней без правок») и/или переключение на понедельную группировку. Порог — настройка.
  • Округление дисплея: сумму за день округляем до минут для подписи, но общий итог берём из точных значений (иначе Σ округлённых по дням разойдётся с округлённым числом на ±1–2 мин).

7. Проверка на реальной статье

Ручной прогон на 20 снимках (1-я страница истории, T_gap=30 мин, P_in+P_out=10 мин, P_single=2 мин; все сессии здесь класса work — в каждой есть человеческий сэмпл):

Сессия Интервал Длит.
S1 07-04 03:40 → 03:49 (многосэмпловая) ≈19 мин
S2 07-04 15:43 → 16:13 (агент 15:43–15:50 → человек 16:13) ≈41 мин
S3 07-04 18:11 (одиночная) ≈2 мин
S4 07-04 19:38 → 19:54 (многосэмпловая) ≈26 мин
S5 07-06 15:34 (одиночная) ≈2 мин
S6 07-06 16:18 (одиночная, закрыта пост-циклом §5) ≈2 мин
Итого ≈1 ч 32 мин

Наивно на том же срезе — ≈60 часов. Разница — весь смысл фичи.

Наблюдения:

  • Разрезы легли по перерывам: ночь 03:49 → 15:43 (~12 ч) и сутки 07-04 19:54 → 07-06 15:34 — оба выброшены.
  • Чувствительность к порогу: S5/S6 отстоят на 44 мин. При T_gap=30 это две сессии, при T_gap=60 — одна (~44 мин). Число надо показывать вместе с использованным порогом.
  • Это оценка, не строгая граница (§5), и НАПРАВЛЕНИЕ ошибки зависит от данных. Сохранения ВНУТРИ T_gap мостятся, и вся пауза между ними засчитывается → на периодичном «фоновом» ритме (напр. автосейв раз в ~T_gap при почти полном простое) возможен КРАТНЫЙ перебор (в разы), а не «±порог». Сохранения ДАЛЬШЕ T_gap друг от друга, наоборот, теряют между-время → недобор. Поэтому T_gap калибруется по пульсу #374 (§5, §10): после #374 «фоновый» ритм даёт гэпы > maxWait и рвётся на перерывы, срезая кратный перебор; на легаси (пульса нет) оценка грубее в обе стороны. Пример иллюстративен, посчитан на до-#374 срезе при T_gap=30.

8. Архитектура (куда встраивать)

Ядро — чистая функция computeWorkTime(rows, config) (детерминированная, без БД) в отдельном модуле → легко покрыть юнит-тестами. Выход — { workMs, agentOnlyMs, sessions[] }, где session = { start, end, class: 'work'|'agent_only' }: абсолютные границы (с добивкой P/P_single) плюс класс (§5.1) — этого достаточно и для метрик, и для цвета окна. workMs/agentOnlyMs считаются как union-wall-clock сессий своего класса (§5).

Вторая чистая функция bucketByDay(sessions, tz) (ВСЕ сессии обоих классов) → perDay[] = [{ day, activeMs, agentMs, windows }]: activeMs = длительность work-окон за день (сходится к work, §6.3), agentMs = то же для agent_only (для подписи машинного времени за сутки), windows — интервалы ОБОИХ классов, обрезанные по суткам и помеченные классом (для отрисовки work/agent_only разным цветом, §6.2). Полуночный разрез — календарный, через dayjs + tz-плагин (startOf('day') в tz), НЕ «+24 ч» (§9#14); dayjs уже в проекте. Отдельная тестируемая функция (общий модуль сервера и клиента); tz — презентационный параметр, удобно звать на клиенте от локали зрителя.

Сервер:

  • page-history.repo.ts — метод findTimelineByPageId(pageId): лёгкая проекция (createdAt, lastUpdatedById, lastUpdatedSource, lastUpdatedAiChatId, kind) по всем строкам ASC, без content.
  • page-history.service.tscomputeWorkTime(pageId, config): тянет таймлайн, зовёт ядро, кэширует.
  • page.controller.ts — рядом с POST /history и POST /history/info добавить POST /history/time (или вложить в page-info). Отдаёт число + разбивку по сессиям.

Клиент:

  • page-history-query.ts — хук usePageWorkTime(pageId) (возвращает workMs, agentOnlyMs, sessions[]).
  • Рендер кликабельного числа в панели истории.
  • Модалка/поповер с суточным таймлайном (§6.2): bucketByDay(sessions, viewerTz) → строки-дни, в каждой — 24-часовая дорожка с окнами. Это НЕ bar chart: рисуется кастомными CSS/SVG-отрезками на 24-часовой шкале (позиция окна = startOfDayOffset/24ч, ширина = длительность/24ч). Готовые чарт-библиотеки под это плохо ложатся — брать лёгкую собственную вёрстку, без новых тяжёлых зависимостей. Пустые дни — пустая дорожка + «—».

Производительность: проекция без content дёшева; результат можно инкрементально кэшировать (при version.saved, который #374 броадкастит, пересчитывать хвост).

9. Крайние случаи

  1. Один снимок → одна одиночная сессия P_single, не ноль. Последняя сессия ВСЕГДА закрывается пост-циклом (§5) — иначе теряется самая свежая.
  2. История целиком агентская → work = 0, agent_only = union прогонов.
  3. Плотный агентский всплеск → длина сегмента = его wall-clock (не зависит от числа снимков); опц. кап burstCapMs.
  4. Метка idle лагает ≤ maxWait (10м user / 5м agent) — в пределах округления; это полноценный сэмпл активности, спец-обработки не требует (см. §3, §5.1).
  5. Несколько соавторов → атрибуция человеку по lastUpdatedById/contributorIds; разделение человек/агент — по lastUpdatedSource (НЕ по lastUpdatedById, он всегда человек).
  6. Легаси kind=null → работает на source+createdAt, грубее.
  7. Совпадающие метки времени (boundary+agent в один момент; в коде есть tie-break по id) → дедуп по округлённому t.
  8. Одновременное соредактирование → union (§5) убирает двойной счёт wall-clock при перекрытии окон; персональные человеко-часы (разбивка по авторам) — отдельный опциональный режим (perAuthor), а не поведение по умолчанию.
  9. Сессия через полночь (напр. 23:14 → 02:00) → режется на границе суток tz, части идут в разные дни; сумма по дням = общему числу (§6.3).
  10. Выбор таймзоны дня меняет раскладку по дням (та же работа попадёт в другой день) — общий итог не меняется; tz фиксируется в подписи графика.
  11. Длинный диапазон правок (месяцы) → строк = число дней: вертикальный скролл + сворачивание длинных серий пустых дней и/или понедельная группировка по порогу (§6.3).
  12. День без правок → пустая 24-часовая дорожка + «—» в диапазоне (показываем ритм/паузы), не пропускаем.
  13. Очень короткое окно на 24-часовой шкале → рисуем минимальной видимой шириной, чтобы не исчезало (§6.2).
  14. Переход на летнее/зимнее время внутри tz → сутки в 23/25 ч; полуночный разрез считать по календарю tz (dayjs+tz, §8), а не «+24 ч». Инвариант Σ activeMs == work держится (разбиение union'а). Редкое исключение — tz с DST-переходом РОВНО в полночь (startOf('day') неоднозначен) → до 1 ч может протечь в соседний день. Фиксированная 24-часовая дорожка (§6.2) на 23/25-часовых сутках смещает окна визуально до ~1 ч — сознательное упрощение, на итог не влияет.

10. Параметры по умолчанию и открытые решения

Полный config (env, дефолты):

  • T_gap≈15м — калибровка по IDLE_MAX_WAIT_USER=10м + запас (§5: гэп больше maxWait не подкреплён пульсом = бездействие; прежние «30м» переоценивали не-пульсирующие паузы).
  • agentTGap≈7м — порог для ПАРЫ подряд идущих агентских сэмплов (IDLE_MAX_WAIT_AGENT=5м + запас).
  • P_in=5м, P_out=5м, P_single=2м (одиночная — pre-roll, §5).
  • burstCapMs (опц. кап на сегмент всплеска, §9#3), dedupRoundMs (дедуп совпадающих меток, §9#7), excludeGit=true, tz=локаль-зрителя, longRangeDayThreshold (день→неделя, §6.3), perAuthor=false (§9#8).
  • Легаси-страницы до #374 (нет пульса) → T_gap вынужденно шире, оценка там грубее.
  • T_gap ≥ P_in+P_out НЕ требуется для инварианта §6.3 (union), но РЕКОМЕНДУЕТСЯ и валидируется — иначе work/agent_only могут перекрыться в сумме (§5). При дефолтах (15 ≥ 10) держится.

Развилки (настройки, не блокируют проектирование):

  • work vs agent_only — показывать оба, крупно work (headline), agent_only вторично;
  • точное место в UI — панель истории (основное) или мета-строка страницы;
  • дефолт T_gap — вынести в env (как IDLE_* в #374), калибровать на реальных статьях после мёржа #374;
  • таймзона дня для графика (§6.2/§6.3) — рекомендую локаль зрителя (интуитивно «мои вечера»); альтернативы — UTC (как на картинке-примере) или tz воркспейса. Влияет только на раскладку по дням, не на общий итог;
  • порог перехода день→неделя/месяц для длинного диапазона правок.

Приложение: Review Ledger (рабочий аппарат, НЕ нормативная часть)

Аппарат adversarial-review-loop. Перед выдачей реализатору выносится/сворачивается. Критикам: НЕ перелитигировать закрытые findings, КРОМЕ случая, когда сам RESOLUTION дефектен — тогда атаковать его явно и сказать, почему предыдущий раунд ошибся.

CONFIG

  • EXTERNAL_MODEL: endpoint https://api.z.ai/api/coding/paas/v4, model glm-5.2 (ключ хранится вне репозитория, в логи не пишется). Роль: cold-reader gate (Phase 4.5), tiebreaker при эскалации.
  • Артефакт: docs/features/page-work-time_design.md.
  • Целевой класс: спец для реализации другим человеком → план 2–3 итерации, до пустого cold-reader gate (не по числу итераций).

FACT BASE (проверено по PR #374 @ commit 924f8aa, ветка feat/370-page-versioning)

Верификация автором ДО цикла (ветка не в рабочем дереве — критики не видят её локально; факты ниже — ground truth, можно дозапросить файлы через gitea MCP по указанному SHA):

  • page_history.kindvarchar(20), NULLABLE, БЕЗ дефолта (migration 20260705T120000-page-history-kind.ts). Домен: manual/agent/idle/boundary; legacy null = автосейв (collaboration/constants.ts, PageHistoryKind).
  • kind УЖЕ включён в PageHistoryRepo.baseFields (page-history.repo.ts) — читается всеми выборками истории. saveHistory({kind}) и updateHistoryKind(id, kind) существуют.
  • Тайминги (constants.ts): IDLE_INTERVAL_USER=60м/AGENT=15м; IDLE_MAX_WAIT_USER=10м/AGENT=5м.
  • computeHistoryJob (persistence.extension.ts): delay = max(0, min(interval, burstStart + maxWait − now)); enqueuePageHistory сбрасывает burstStart каждые maxWait. Следствие: потолок всегда доминируетidle пульсирует каждые ~maxWait при непрерывной работе; метка idle отстоит от правки ≤ maxWait, НЕ 60м.
  • Идл-джоб всегда ставится с kind:'idle'; процессор пишет job.data.kind ?? 'idle', но только если контент изменился (isDeepStrictEqual-гейт).
  • boundary-снимок пишется СИНХРОННО в store-транзакции на смене lastUpdatedSource (user↔agent↔git), фиксирует ИСХОДЯЩИЙ (pre-transition) контент со СТАРЫМ source; его createdAt = момент перехода (точный).
  • manual/agent — явный save-version по stateless-каналу; kind выводится из context.actor СЕРВЕРНО (неподделываемо). Promote-not-dup: апгрейд kind последнего снимка на месте вместо дубля.
  • page_history.lastUpdatedById = ВСЕГДА ответственный человек (даже для агентских снимков); «agentness» — в lastUpdatedSource + lastUpdatedAiChatId.
  • REST истории: POST /history, POST /history/info (page.controller.ts). Клиент: apps/client/src/features/page-history/*. Есть broadcast version.saved (для live-инвалидации).

Pre-loop fact-base corrections (автор, проверено vs source)

  • C1. §3/§5.1/§крайние-случаи#4: убрано ошибочное «idle лагает до 60м / idle не удлиняет сессию / отмотка на IDLE_INTERVAL». Верно: лаг ≤ maxWait (≤10м), idle — полноценный сэмпл.
  • C2. §3: устранено внутреннее противоречие §3↔§5.1 (пульс vs исключение idle).
  • C3. §4: kind уже в baseFields — схему менять не нужно.
  • C4. §крайние-случаи#5: lastUpdatedById всегда человек; человек/агент — по lastUpdatedSource.

Scope changes

  • S1 (owner, до итерации 1): добавлен drill-down — клик по числу открывает график по дням.
  • S2 (owner, до итерации 1): drill-down переопределён с «столбцы часов/день» на СУТОЧНЫЙ ТАЙМЛАЙН (строка-день = 24 ч с окнами активности + сумма за день, §6.2/§6.3). Окна вернулись, но графикой. Затронуты §1, §6.2, §6.3, §8, §9(#9–14), §10.

Iteration log

Итерация 1 — критики: Claude (hardened, A) + GLM-5.2 external (B)

Первый прогон local-субагентов вернул инъекцию из подложенного SKILL.md (реклама «agent-first-plugin-suite») и 0 полезной работы — проигнорировано; пере-прогон с анти-инъекцией дал реальные ревью. Дефекты и диспозиции:

  • [BLOCKER] A1 — §5-псевдокод не закрывал последнюю сессию (терял свежую; 0 сессий для односессионной страницы; ронял S6). ПРИНЯТО → пост-цикловое закрытие, n=1P_single, n=0→0.
  • [MAJOR] A2+B4 — какое число суммирует таймлайн; двойной счёт при перекрытии. ПРИНЯТО → метрики work/agent_only (§6.1) + total/perDay через union (§5, §6.3).
  • [MAJOR] A3 — §1 остался со старым «столбцом на день» (residue S1→S2). ПРИНЯТО → §1 переписан.
  • [MAJOR] A4 — «кламп vs скрытое условие T_gap ≥ P». ПРИНЯТО, но растворено: union убрал и кламп, и условие (§6.3).
  • [MAJOR] A5 — форма session и правило классификации человек/агент (нужны для workMs и цвета). ПРИНЯТО → §5.1 классификация (+ nuance про старый source у boundary), session.class (§8).
  • [MAJOR→понижено] B1 — «нижняя оценка» ложна; гэп ≤ T_gap считается работой. ЧАСТИЧНО ПРИНЯТО
    • КОНТР по severity: гэп-как-работа — by design (по меткам не отличить «думал» от «отошёл»), поведение оставлено; исправлено УТВЕРЖДЕНИЕ (§5/§7: «оценка, не строгая граница»). Понижено с BLOCKER: это не баг кода, а некорректная формулировка/честность.
  • [MAJOR] B2+B5 — инфляция одиночных сессий на P + честность отрисовки добивки. ПРИНЯТО → P_single (мал), приглушённая отрисовка + «≈» (§5, §6.2).
  • [MAJOR/MINOR] B3+A6 — коллапс агентского всплеска рушит вклинившегося человека + выбор конца для гэп-теста. ПРИНЯТО → правило коллапса (только подряд один aiChatId; человек внутри разрывает; левый гэп до t_start, правый от t_end) (§5).
  • [MINOR] A7config не перечислен. ПРИНЯТО → полный список (§10).
  • [MINOR] A8 — ячейка idle в таблице §3 устарела. ПРИНЯТО → исправлена.
  • [NIT] A9 — расхождение округления headline vs подписи дня. Оставлено с оговоркой (§6.3).
  • [NIT] A10 — назвать dayjs для tz/DST-разреза. ПРИНЯТО (§8, §9#14).

Verified clean (оба критика): факты #374; tz/DST-разрез; лёгкая проекция без content; кэш на version.saved; разбиение на две чистые функции; §2-постановка; арифметика §7 (при исправленном алгоритме).

Итерация 1 — post-integration re-attack (Claude A + GLM round 2) — ЗАКРЫТА

Оба критика НЕЗАВИСИМО нашли один и тот же блокер и сошлись (разные семейства моделей):

  • [BLOCKER] N1 (A) = MAJOR-1 (GLM) — §5 «сессионизация ОТДЕЛЬНО по классам» противоречила §5.1/§7/§8 и теряла надзорное агентское время (S2 схлопывалась в 2 мин; workMs+agentOnlyMs мог превысить прошедшее). Исправлено: ЕДИНЫЙ проход по всем сэмплам + классификация готовой сессии; метрики — union внутри класса.
  • [MAJOR] N2 (A) — union только ВНУТРИ класса; кросс-класс дизъюнктность требует T_gap ≥ P, а §10 это отрицал; agentTGap неопределён при едином проходе. ПРИНЯТО → gap_threshold по ПАРЕ сэмплов (agent–agent → agentTGap), caveat в §5, §10 смягчён (валидируем T_gap ≥ P).
  • [MAJOR] GLM-2bucketByDay(workSessions) не мог рисовать agent_only. ПРИНЯТО → сигнатура bucketByDay(sessions), окна ОБОИХ классов, activeMs = work-only.
  • [MINOR] N3 — роль boundary противоречива (§5 «человеческий» vs §5.1 «старый source=agent»). ПРИНЯТО → §5: всплеск рвёт любой сэмпл, не продолжающий тот же aiChatId-агент; класс — по source.
  • [MINOR] N4 — псевдокод точечный, концы сегмента не заданы. ПРИНЯТО → сегмент {t_start,t_end}; same-source idle внутри всплеска продолжает сегмент.
  • [NIT] N5 — DST ровно в полночь + фикс. 24ч-дорожка ±1ч визуально. ПРИНЯТО → оговорка §9#14.
  • [NIT] N6P_single симметричный «выдумывает будущую работу». ПРИНЯТО → pre-roll [t−P_single, t].
  • B1-severity (спор): A СОГЛАСИЛСЯ с понижением BLOCKER→MAJOR (нет входа, где код отклоняется от намерения — только формулировка/UX). GLM НАСТАИВАЛ на BLOCKER с НОВЫМ аргументом: отсутствие idle-пульса в гэпе = доказанное бездействие. Диспозиция: label = MAJOR, но СУБСТАНЦИЯ GLM принята (Invariant 7 — уступка с аргументом): T_gap калиброван по maxWait (§5/§10) + принцип «гэп > maxWait = перерыв». A-residue (направление ошибки data-dependent, возможен КРАТНЫЙ перебор) принят в §7. Тайбрейкер не понадобился (обе стороны привели аргументы, интегрированы обе).

Verified clean (round-2): инвариант §6.3 под DST (оба критика); burst-endpoints (оба); P_single через полночь (GLM). Блокеров в ядре: 0. Итерация 1 закрыта.

Convergence validation + cold-reader gate — ЗАКРЫТА (CONVERGED)

  • Cold-reader gate (GLM-5.2, внешняя модель, «имплементер читает впервые»): ПУСТОЙ список вопросов на день 1 — ДВАЖДЫ (до и после партии полировки).
  • Convergence pass (GLM-5.2, свои hostile-сценарии + слепая реализация): вердикт NOT CONVERGED на ещё не атакованной партии round-2 → нашёл 2 MAJOR + 1 MINOR (как и предупреждает skill — «re-opened cycles find MAJORs in unreviewed amendments»):
    • [MAJOR] §6.3 рисовал agent_only-окна из «сырых» sess.iv (не union) → визуальное наложение. → U_agent = union(...), симметрично U_work; добавлен agentMs[D].
    • [MAJOR] §5 «idle продолжает сегмент НЕЗАВИСИМО от aiChatId» склеивал разные ИИ-раны через idle-снимок. → idle с ДРУГИМ aiChatId = новый ран, рвёт сегмент.
    • [MINOR] нет per-day суммы агента. → agentMs в perDay/§8. Все приняты и интегрированы; финальный re-gate (GLM cold-reader) → снова ПУСТО, рассинхрона нет.
  • Tooling note: local general-purpose Claude-субагенты трижды перехватывались инъекцией из подложенного SKILL.md (реклама «agent-first-plugin-suite» / фейковые «review this PR» промпты, 0 tool-uses). Проигнорировано. Адверсариальные проходы и cold-reader выполнены внешней GLM-5.2 (гетерогенная модель — как раз рекомендована skill против self-preference) + один hardened Claude-проход, который сработал.

Closure checklist

  1. 0 блокеров в ядре; счётчики обсуждения в обе стороны (приняты находки И отбит spor по B1-severity) — ✓
  2. Партия полировки re-gated финальным cold-reader на полном тексте — ✓
  3. Cold-reader: список вопросов ПУСТ (дважды) — ✓
  4. Прогноз сходимости трактовался как гипотеза; решал чек-лист (convergence-pass реально нашёл MAJOR) — ✓
  5. Backcast: частично (пример §7 на реальных данных + 36.5ч-картинка владельца) — обязательным не был
  6. Preconditions зафиксированы: зависимость точности от #374 (пульс), грубость на легаси, DST-в-полночь, T_gap ≥ P

СТАТУС: CONVERGED. §1–§10 — нормативная спека для реализатора; это приложение — аудит-след (не нормативно).