@sberbusiness/triplex-next-mcp-server 1.43.0-beta → 1.44.0-beta

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,436 @@
1
+ ---
2
+ component: TreeViewNode
3
+ category: TreeView
4
+ related: [TreeView]
5
+ tokens: []
6
+ stories: stories/TreeView/TreeView.stories.tsx
7
+ version: "1.0"
8
+ ---
9
+
10
+ # TreeViewNode
11
+
12
+ ## Назначение
13
+
14
+ Узел визуального дерева `TreeView` (доступен также как `TreeView.Node`). Рендерит
15
+ `<li role="treeitem">` с `aria-expanded` и `tabIndex`, регистрирует себя в
16
+ абстрактном дереве `TreeView`, обрабатывает `focus`/`blur` для перемещения
17
+ активной ноды с клавиатуры и отдаёт потребителю пять provide-props через
18
+ render-функцию `children`.
19
+
20
+ Собственного оформления у ноды нет: она добавляет только семантику и сброс
21
+ маркеров списка — всю разметку узла (шеврон, подпись, линии-коннекторы,
22
+ подсветку активного состояния) рисует потребитель.
23
+
24
+ Используй когда: строишь дерево на `TreeView` и нужен полный контроль над
25
+ разметкой узла.
26
+
27
+ Не используй когда:
28
+ - нужно типовое дерево «папка/файл» с шевроном, hover и focus-visible — бери
29
+ `CollapsibleTree`;
30
+ - нужен свой UI узла, но с анимацией раскрытия и controlled/uncontrolled
31
+ состоянием — бери `CollapsibleTreeExtended`, он оборачивает `TreeView.Node`
32
+ и добавляет accordion-логику;
33
+ - узел нужен вне `TreeView` — вне провайдера контекста нода получает
34
+ `initialTreeContext`, где `registerNode` и остальные методы пустые: `<li>`
35
+ отрисуется, но в дерево нода не попадёт, `tabIndex` останется `-1`,
36
+ `hasChildNodes` всегда будет `false` (дети не регистрируются), а
37
+ `isLastNode` — всегда `true` (у ноды нет родителя).
38
+
39
+ ---
40
+
41
+ ## Варианты и props
42
+
43
+ Экспортируемый `TreeViewNode` — это `withTreeViewContext(TreeViewNodeWithContext)`,
44
+ то есть class-компонент, обёрнутый HOC-ом контекста. Prop `treeViewContext`
45
+ подставляет HOC, снаружи его передавать не нужно.
46
+
47
+ ### Обязательные props
48
+
49
+ | Prop | Тип | Описание |
50
+ |---|---|---|
51
+ | `id` | `string` | Идентификатор ноды, уникальный в пределах дерева. По нему абстрактное дерево ищет и связывает ноды, на него ссылаются `prevNodeId` / `nextNodeId` соседей |
52
+ | `children` | `(props: ITreeViewNodeProvideProps) => JSX.Element` | Render-функция разметки узла. Не ReactNode: нода отдаёт своё состояние только через аргумент |
53
+
54
+ ### Опциональные props
55
+
56
+ | Prop | Тип | По умолчанию | Описание |
57
+ |---|---|---|---|
58
+ | `opened` | `boolean` | `false` | Состояние раскрытия. Синхронизируется в абстрактное дерево в конструкторе и далее в `componentDidUpdate` при смене значения |
59
+ | `prevNodeId` | `string` | — | Id ноды, после которой вставить текущую в абстрактное дерево |
60
+ | `nextNodeId` | `string` | — | Id ноды, перед которой вставить текущую в абстрактное дерево |
61
+ | `...HTMLAttributes` | — | — | Уходят на `<li>`. `className` мерджится с собственным классом, `onFocus` / `onBlur` вызываются после внутренних обработчиков |
62
+
63
+ `ITreeViewNodeProps` расширяет `Omit<React.HTMLAttributes<HTMLElement>, "children">` —
64
+ `children` переопределён под render-функцию намеренно.
65
+
66
+ ### Provide-props render-функции (`ITreeViewNodeProvideProps`)
67
+
68
+ | Поле | Описание |
69
+ |---|---|
70
+ | `activeNode` | Нода активна при перемещении с клавиатуры. Подсветку рисует потребитель |
71
+ | `openedNode` | Нода раскрыта — значение из абстрактного дерева, а не из prop `opened` |
72
+ | `hasChildNodes` | У ноды есть **зарегистрированные** дочерние ноды |
73
+ | `isLastNode` | Нода последняя в дереве целиком (последняя на своём уровне и все её предки — последние). Используется для линий-коннекторов |
74
+ | `setOpenedNode(opened)` | Пишет флаг раскрытия в абстрактную ноду и инкрементит `updateCount` в `TreeView` для перерендера. Uncontrolled-путь: prop `opened` при этом не меняется |
75
+
76
+ ### Порядок нод и `prevNodeId` / `nextNodeId`
77
+
78
+ Абстрактное дерево заполняется в **конструкторах** нод, то есть в порядке
79
+ рендера. `prevNodeId` / `nextNodeId` позволяют вставить ноду в произвольное
80
+ место уровня: порядок в абстрактном дереве (а значит `isLastNode` и порядок
81
+ обхода стрелками) может отличаться от порядка в разметке. Ссылаться можно
82
+ только на **уже зарегистрированную** ноду: к моменту конструктора текущей ноды
83
+ конструктор соседа должен был отработать — монтирование при этом не требуется,
84
+ оно происходит позже. Id соседей выводят из массива данных
85
+ `CollapsibleTree` и `CheckboxTree`; `CollapsibleTreeExtended` своего массива не
86
+ имеет и пробрасывает `prevNodeId` / `nextNodeId` как есть от потребителя.
87
+
88
+ Промаха по id компонент не сигнализирует, но два случая промаха ведут себя
89
+ по-разному. Ноду с указанным id ищут **в поддереве текущего родителя**, а не по
90
+ всему дереву.
91
+
92
+ - **Id не найден** — нода добавляется в конец своего уровня, то есть в порядке
93
+ рендера. Детерминированно и безопасно; это штатный режим, а не ошибка.
94
+ Именно так работает `nextNodeId` в `CollapsibleTree` и `CheckboxTree`: они
95
+ передают id следующей ноды из массива данных, а та на момент конструктора
96
+ текущей ещё не зарегистрирована — промах и откат в порядок рендера здесь
97
+ происходят всегда.
98
+ - **Id найден, но это не прямой ребёнок того же родителя** — позиция
99
+ определяется индексной арифметикой `splice` в `AbstractTreeNode.addChild`.
100
+ Это **деталь текущей реализации, а не контракт**: тестами не закреплена,
101
+ класс общий для всех абстрактных деревьев, полагаться нельзя.
102
+
103
+ Правильное употребление одно: ссылаться на уже зарегистрированную соседнюю ноду
104
+ того же уровня.
105
+
106
+ ### Раскрытие и регистрация детей
107
+
108
+ `hasChildNodes` вычисляется по абстрактному дереву, поэтому дочерние ноды
109
+ должны быть **смонтированы**. Если размонтировать `TreeView.Group` в свёрнутом
110
+ состоянии, родитель получит `hasChildNodes === false` и, например, не отрисует
111
+ шеврон. Рабочий приём (используется в stories и в `CollapsibleTreeExtended`) —
112
+ держать группу в разметке всегда, скрывая её стилями.
113
+
114
+ ---
115
+
116
+ ## Дизайн-токены
117
+
118
+ Собственных CSS-переменных нода не определяет. Класс `treeViewNode` в
119
+ `styles/TreeView.module.less` задаёт только сброс отступов и маркера списка
120
+ плюс `outline: none` — focus-стиль рисует потребитель.
121
+
122
+ ---
123
+
124
+ ## Инварианты
125
+
126
+ - **`TreeViewNodeWithContext` — class-компонент, и `forwardRef` на нём нет.**
127
+ Регистрация в абстрактном дереве происходит в **конструкторе** (до маунта
128
+ детей), а снятие — в `componentWillUnmount`; порядок этих вызовов и есть
129
+ порядок нод в дереве. Перевод на функциональный компонент меняет момент
130
+ регистрации и ломает `prevNodeId` / `nextNodeId`. Не мигрировать без
131
+ отдельного решения мейнтейнера.
132
+ - **Корневой DOM-элемент — `<li>`.** Менять тег нельзя: `role="treeitem"`
133
+ валиден только внутри `<ul role="tree">` / `<ul role="group">`.
134
+ - **`role="treeitem"` и `aria-expanded` выставляются после `{...props}`** —
135
+ семантика узла часть контракта компонента, потребитель её не переопределяет.
136
+ `tabIndex` остаётся **до** спреда: roving tabindex — поведение, а не
137
+ семантика, и потребитель при желании может задать его сам. Но переданный
138
+ `tabIndex` побеждает навсегда: `TreeView.updateTabIndexNodes` продолжит
139
+ пересчитывать значение в абстрактной ноде, на DOM это уже не повлияет. Поставив
140
+ `tabIndex={0}` всем нодам, потребитель получит не свой roving tabindex, а
141
+ дерево, где каждая нода — отдельная остановка Tab. Переопределять — только если
142
+ берёшь управление фокусом целиком на себя.
143
+ - **`event.stopPropagation()` в `handleFocus` / `handleBlur` обязателен.**
144
+ `focusin` всплывает, и без остановки активной становилась бы не текущая нода,
145
+ а её родитель. Пользовательские `onFocus` / `onBlur` вызываются после
146
+ внутренней логики — порядок менять нельзя.
147
+ - **`children` — функция, а не `ReactNode`.** Смена сигнатуры — breaking change
148
+ для всей цепочки потребителей. Напрямую `TreeView.Node` рендерит только
149
+ `CollapsibleTreeExtended`; `CollapsibleTree` строится поверх него, а
150
+ `CheckboxTree` доходит до ноды через `CheckboxTreeExtended` →
151
+ `CollapsibleTreeExtended`.
152
+ - **Barrel `src/components/TreeView/index.ts`** реэкспортирует модуль ноды
153
+ целиком (`export * from "./components/TreeViewNode"`), поэтому публичны и
154
+ `TreeViewNode`, и класс `TreeViewNodeWithContext`, и типы
155
+ `ITreeViewNodeProps`, `ITreeViewNodeProvideProps`,
156
+ `ITreeViewNodePropsWithContext` — всё это через `src/components/index.ts`
157
+ доходит до корня пакета. Состав и имена менять нельзя.
158
+ - **`displayName` класса — `"TreeViewNodeWithContext"`**, у экспортируемой
159
+ обёртки — `"WithTreeViewContext(TreeViewNodeWithContext)"` (добавляет HOC).
160
+ Значения видны в React DevTools и в snapshot-тестах.
161
+ - **Абстрактная нода мутируемая.** `this.abstractNode` меняется на месте, и сам
162
+ по себе перерендер не вызывает: любая мутация должна идти через методы
163
+ контекста (`setOpenedNode`, `setActiveNode`, `registerNode`, `removeNode`),
164
+ которые инкрементят `updateCount` в состоянии `TreeView`. Прямая запись в
165
+ `abstractNode` в обход контекста оставит ноду с устаревшими
166
+ `hasChildNodes` / `isLastNode`.
167
+
168
+ ---
169
+
170
+ ## Accessibility
171
+
172
+ Ориентир — WAI-ARIA Authoring Practices,
173
+ [treeview-2a](https://www.w3.org/TR/wai-aria-practices-1.1/examples/treeview/treeview-2/treeview-2a.html).
174
+
175
+ - Узел — `<li role="treeitem">` с `aria-expanded`, отражающим состояние
176
+ раскрытия из абстрактного дерева. Вложенные ноды оборачиваются в
177
+ `TreeView.Group` (`<ul role="group">`).
178
+ - **Roving tabindex.** `tabIndex` нода читает из абстрактной ноды
179
+ (`getTabIndex()`): первая нода первого уровня получает `0`, остальные `-1`.
180
+ Пересчитывает значения `TreeView` при регистрации и удалении нод — в дерево
181
+ можно попасть одним Tab.
182
+ - **Активная нода ≠ DOM-фокус.** Активной нода становится по `focus`, а стрелки
183
+ (`ArrowDown` / `ArrowUp`, обрабатываются в `TreeView` на `window`) двигают
184
+ только флаг активности в абстрактном дереве — DOM-фокус остаётся на исходном
185
+ узле. Потребитель обязан визуально отрисовать активное состояние по
186
+ provide-prop `activeNode`, иначе клавиатурная навигация не видна.
187
+ - `blur` снимает активность; когда активной ноды в дереве не остаётся,
188
+ `TreeView` отписывает слушатель стрелок.
189
+ - Раскрытие/сворачивание по клавиатуре (`ArrowRight` / `ArrowLeft`,
190
+ `Home` / `End`) нода **не** реализует — это на потребителе, в его разметке
191
+ узла.
192
+ - Текстовые подписи и `aria-label` нода не хардкодит: библиотека мультиязычная,
193
+ всю разметку узла даёт потребитель. `aria-label` на дерево вешается на
194
+ `TreeView`.
195
+
196
+ ---
197
+
198
+ ## Связанные компоненты
199
+
200
+ - `TreeView` (`src/components/TreeView`) — родительский компонент и владелец
201
+ абстрактного дерева: регистрирует ноды, считает `tabIndex`, обрабатывает
202
+ стрелки. Нода без него не работает.
203
+ - `TreeViewGroup` (`TreeView.Group`) — `<ul role="group">`, обёртка вложенных
204
+ нод. Тривиальная обёртка (`forwardRef` + `clsx` + спред), отдельного AI.md
205
+ нет.
206
+ - `CollapsibleTreeExtended` (`src/components/CollapsibleTreeExtended`) — обёртка
207
+ над `TreeView.Node`, добавляющая анимацию раскрытия и
208
+ controlled/uncontrolled состояние. Берут вместо голой ноды, когда свой UI
209
+ узла нужен, а возиться с состоянием — нет.
210
+ - `CollapsibleTree` (`src/components/CollapsibleTree`) и `CheckboxTree`
211
+ (`src/components/CheckboxTree`) — готовые деревья поверх ноды; оба
212
+ протаскивают `prevNodeId` / `nextNodeId` из массива данных.
213
+
214
+ ---
215
+
216
+ ## Stories
217
+
218
+ Отдельных stories у ноды нет: без `TreeView` она не рендерится, а собственного
219
+ оформления у неё нет — все её состояния показаны в stories родителя. Ниже —
220
+ только те, что демонстрируют API самой ноды; полный список историй семейства
221
+ (`Playground`, `Default`) — в [TreeView-ai.md](./TreeView-ai.md).
222
+
223
+ Основные истории: `stories/TreeView/TreeView.stories.tsx`
224
+ Файлы примеров: `stories/TreeView/examples/`
225
+
226
+ | Story | Example file | Что демонстрирует |
227
+ |---|---|---|
228
+ | `Nested` | `Nested.tsx` | Вложенность через `TreeView.Group`, prop `opened`, provide-props `openedNode` и `hasChildNodes` |
229
+ | `Keyboard navigation` | `KeyboardNavigation.tsx` | Перемещение `activeNode` стрелками, подсветка активной ноды, `isLastNode` |
230
+ | `VisualTests` | `VisualTests.tsx` | Свёрнутая и раскрытая нода, узел с детьми и лист, `isLastNode`; активная нода снимается через `play` |
231
+
232
+ ---
233
+
234
+ ## История изменений
235
+
236
+ | Дата | Изменение |
237
+ |---|---|
238
+ | 2026-08-26 | Создан документ. Описание ноды вынесено из `TreeView-ai.md`. Добавлен unit-тест на `prevNodeId` — единственный публичный prop ноды, не покрытый тестами |
239
+
240
+ ---
241
+
242
+ ## Примеры использования
243
+
244
+ ### Nested
245
+
246
+ `stories/TreeView/examples/Nested.tsx`
247
+
248
+ ```tsx
249
+ import React from "react";
250
+ import clsx from "clsx";
251
+ import { CaretrightStrokeSrvIcon24 } from "@sberbusiness/icons-next";
252
+ import { ETextSize, Text, TreeView } from "@sberbusiness/triplex-next";
253
+ import "./Nested.less";
254
+
255
+ interface INodeData {
256
+ id: string;
257
+ label: string;
258
+ children?: INodeData[];
259
+ }
260
+
261
+ const nodes: INodeData[] = [
262
+ {
263
+ id: "folder-1",
264
+ label: "Folder text",
265
+ children: [
266
+ { id: "file-1-1", label: "File text" },
267
+ { id: "file-1-2", label: "File text" },
268
+ ],
269
+ },
270
+ {
271
+ id: "folder-2",
272
+ label: "Folder text",
273
+ children: [
274
+ {
275
+ id: "folder-2-1",
276
+ label: "Folder text",
277
+ children: [{ id: "file-2-1-1", label: "File text" }],
278
+ },
279
+ ],
280
+ },
281
+ { id: "file-3", label: "File text" },
282
+ ];
283
+
284
+ interface ITreeNodeProps {
285
+ node: INodeData;
286
+ }
287
+
288
+ const TreeNode = ({ node }: ITreeNodeProps) => {
289
+ const [opened, setOpened] = React.useState(false);
290
+ const branch = Boolean(node.children?.length);
291
+
292
+ const toggle = () => setOpened((prevOpened) => !prevOpened);
293
+
294
+ const handleClick = (event: React.MouseEvent<HTMLElement>) => {
295
+ // Клик по вложенной ноде не должен раскрывать родительскую.
296
+ event.stopPropagation();
297
+
298
+ if (branch) {
299
+ toggle();
300
+ }
301
+ };
302
+
303
+ const handleKeyDown = (event: React.KeyboardEvent<HTMLElement>) => {
304
+ if (event.key !== "Enter" && event.key !== " ") {
305
+ return;
306
+ }
307
+
308
+ event.preventDefault();
309
+ event.stopPropagation();
310
+
311
+ if (branch) {
312
+ toggle();
313
+ }
314
+ };
315
+
316
+ return (
317
+ <TreeView.Node id={node.id} opened={opened} onClick={handleClick} onKeyDown={handleKeyDown}>
318
+ {({ hasChildNodes, openedNode }) => (
319
+ <>
320
+ <div className={clsx("tree-view-nested-example-row", { branch })}>
321
+ {hasChildNodes ? (
322
+ <CaretrightStrokeSrvIcon24
323
+ paletteIndex={5}
324
+ className={clsx("tree-view-nested-example-chevron", { opened: openedNode })}
325
+ aria-hidden
326
+ />
327
+ ) : (
328
+ <span className="tree-view-nested-example-chevron-placeholder" />
329
+ )}
330
+ <Text size={ETextSize.B1} tag="span">
331
+ {node.label}
332
+ </Text>
333
+ </div>
334
+ {node.children ? (
335
+ // Группа остаётся смонтированной: так вложенные ноды зарегистрированы в дереве
336
+ // и родитель знает про hasChildNodes даже в свёрнутом состоянии.
337
+ <TreeView.Group className={clsx("tree-view-nested-example-group", { collapsed: !openedNode })}>
338
+ {node.children.map((child) => (
339
+ <TreeNode key={child.id} node={child} />
340
+ ))}
341
+ </TreeView.Group>
342
+ ) : null}
343
+ </>
344
+ )}
345
+ </TreeView.Node>
346
+ );
347
+ };
348
+
349
+ export const Nested = () => (
350
+ <div className="tree-view-nested-example" style={{ maxWidth: 420 }}>
351
+ <TreeView aria-label="Tree">
352
+ {nodes.map((node) => (
353
+ <TreeNode key={node.id} node={node} />
354
+ ))}
355
+ </TreeView>
356
+ </div>
357
+ );
358
+ ```
359
+
360
+ ### Keyboard navigation
361
+
362
+ `stories/TreeView/examples/KeyboardNavigation.tsx`
363
+
364
+ ```tsx
365
+ import React from "react";
366
+ import clsx from "clsx";
367
+ import { ECaptionSize, ETextSize, Caption, Text, TreeView } from "@sberbusiness/triplex-next";
368
+ import "./KeyboardNavigation.less";
369
+
370
+ interface INodeData {
371
+ id: string;
372
+ label: string;
373
+ children?: INodeData[];
374
+ }
375
+
376
+ const nodes: INodeData[] = [
377
+ {
378
+ id: "folder-1",
379
+ label: "Folder text",
380
+ children: [
381
+ { id: "file-1-1", label: "File text" },
382
+ { id: "file-1-2", label: "File text" },
383
+ ],
384
+ },
385
+ {
386
+ id: "folder-2",
387
+ label: "Folder text",
388
+ children: [{ id: "file-2-1", label: "File text" }],
389
+ },
390
+ ];
391
+
392
+ interface ITreeNodeProps {
393
+ node: INodeData;
394
+ }
395
+
396
+ const TreeNode = ({ node }: ITreeNodeProps) => (
397
+ // Ветки раскрыты сразу: стрелки спускаются только в раскрытые ноды.
398
+ <TreeView.Node id={node.id} opened>
399
+ {({ activeNode, isLastNode }) => (
400
+ <>
401
+ <div className={clsx("tree-view-keyboard-example-row", { active: activeNode })}>
402
+ <Text size={ETextSize.B1} tag="span">
403
+ {node.label}
404
+ </Text>
405
+ {isLastNode ? (
406
+ <Caption size={ECaptionSize.C2} tag="span">
407
+ isLastNode
408
+ </Caption>
409
+ ) : null}
410
+ </div>
411
+ {node.children ? (
412
+ <TreeView.Group>
413
+ {node.children.map((child) => (
414
+ <TreeNode key={child.id} node={child} />
415
+ ))}
416
+ </TreeView.Group>
417
+ ) : null}
418
+ </>
419
+ )}
420
+ </TreeView.Node>
421
+ );
422
+
423
+ export const KeyboardNavigation = () => (
424
+ <div className="tree-view-keyboard-example" style={{ maxWidth: 420 }}>
425
+ <Caption size={ECaptionSize.C1} tag="p">
426
+ Поставьте фокус на любой узел и используйте стрелки вверх/вниз.
427
+ </Caption>
428
+ <TreeView aria-label="Tree">
429
+ {nodes.map((node) => (
430
+ <TreeNode key={node.id} node={node} />
431
+ ))}
432
+ </TreeView>
433
+ </div>
434
+ );
435
+ ```
436
+
@@ -56,11 +56,14 @@ TRI-21-alert-ai-ready
56
56
  `docs/human/release.md`. Прямой push в `release-0` проверяется CI:
57
57
  `ci.yml` триггерится на `push: branches: [release-0]`.
58
58
 
59
- **Единственное исключение для агента** — релизный коммит `release-react17`
60
- (бамп версии + release notes): он тоже пушится в `release-0` напрямую, без
61
- PR. Прогон CI на него стартует, но дождаться его нельзя — GitHub Release
62
- создаётся сразу после push. Поэтому исключение компенсируется обязательными
63
- локальными гейтами (`tsc`, `test-unit`, `build`).
59
+ **Единственное исключение** — релизный коммит (бамп версии + release notes):
60
+ он тоже попадает в `release-0` напрямую, без PR. В еженедельном релизе его
61
+ делает [`release-weekly.yml`](../../.github/workflows/release-weekly.yml),
62
+ при ручном выпуске — skill `release-react17`. Прогон CI на этот коммит
63
+ стартует, но дождаться его нельзя: релиз создаётся сразу после push. Поэтому
64
+ исключение компенсируется гейтами перед пушем — `npm run test-unit`
65
+ и `npm run build` (последний в `prebuild` гоняет `tsc -p tsconfig.build.json`;
66
+ полный `tsc` гейтом быть не может — он даёт давние ошибки в `stories/`).
64
67
 
65
68
  ---
66
69
 
@@ -86,6 +89,24 @@ TRI-21 Alert: AI refactoring + unit-тесты
86
89
  - Тело коммита опционально (для сложных изменений).
87
90
  - Язык: русский или английский — не смешивать в одном коммите.
88
91
 
92
+ **Исключение — релизные коммиты еженедельного релиза.** Их делает
93
+ [`release-weekly.yml`](../../.github/workflows/release-weekly.yml) в GitHub
94
+ Actions, и они идут **без префикса задачи**:
95
+
96
+ ```text
97
+ Подготовка релиза 0.44.0
98
+ Подготовка релиза 1.44.0
99
+ ```
100
+
101
+ Причина техническая: у Actions нет доступа к Linear, номер задачи взять
102
+ неоткуда, а выдумывать его нельзя. Связь с трекером даёт skill `release-auto`
103
+ — он заводит задачу «Релиз 1.Y.0 / 0.Y.0» по итогам прогона и складывает
104
+ туда ссылки на релизы. Коммит машинный, его авторство видно по автору
105
+ (`dudim-ai`), поэтому потеря префикса не мешает разбору истории.
106
+
107
+ При **ручном** выпуске (`/release`) префикс обязателен, как обычно: там
108
+ задача Linear заводится до начала работы.
109
+
89
110
  ---
90
111
 
91
112
  ## Когда запускать линтер
@@ -140,7 +161,8 @@ npx prettier --write src/components/Button/Button.tsx
140
161
 
141
162
  1. делает коммит по конвенциям этого файла (skill `commit-component`);
142
163
  2. пушит ветку;
143
- 3. создаёт PR (см. «PR-воркфлоу»);
164
+ 3. дожидается PR от workflow `auto-pr.yml` (см. «PR-воркфлоу»; сам
165
+ `gh pr create` в ветках `TRI-*` не вызывает);
144
166
  4. если менялись stories или визуал компонентов — обновляет baseline-скриншоты
145
167
  через skill `update-visual-baselines` (CI-workflow «Update Visual
146
168
  Snapshots» + чистка orphan-скриншотов);
@@ -161,9 +183,9 @@ npx prettier --write src/components/Button/Button.tsx
161
183
 
162
184
  ## GitHub-операции от аккаунта бота
163
185
 
164
- Автоматические GitHub-операции (создание PR, комментарии в PR, запуск
165
- workflow, авторство коммитов автофинала) выполняются от отдельного
166
- machine-аккаунта, если у разработчика задан его токен:
186
+ Автоматические GitHub-операции (комментарии в PR, запуск workflow,
187
+ авторство коммитов автофинала) выполняются от отдельного machine-аккаунта,
188
+ если у разработчика задан его токен:
167
189
 
168
190
  ```bash
169
191
  # fine-grained PAT бота: contents:write, pull-requests:write, actions:write
@@ -171,11 +193,18 @@ export TRIPLEX_BOT_GH_TOKEN=... # в ~/.zshenv (не ~/.zshrc — агент
171
193
  # в неинтерактивном shell), НЕ в репозитории
172
194
  ```
173
195
 
196
+ Тот же PAT хранится как repository secret `DUDIM_AI_GH_TOKEN` — его
197
+ использует workflow `auto-pr.yml`, который создаёт PR для веток `TRI-*`
198
+ (см. «PR-воркфлоу»). Благодаря этому автор PR один и тот же независимо от
199
+ того, где шла сессия агента — локально или в облаке. При ротации PAT
200
+ обнови оба места: `~/.zshenv` и secret
201
+ (`gh secret set DUDIM_AI_GH_TOKEN --body "$TRIPLEX_BOT_GH_TOKEN"`).
202
+
174
203
  **Паттерн для агентов** — в начале Bash-вызова с командами `gh`:
175
204
 
176
205
  ```bash
177
206
  if [ -n "$TRIPLEX_BOT_GH_TOKEN" ]; then export GH_TOKEN="$TRIPLEX_BOT_GH_TOKEN"; fi
178
- gh pr create ... # выполнится от бота; без токена — от разработчика (fallback)
207
+ gh pr comment ... # выполнится от бота; без токена — от разработчика (fallback)
179
208
  ```
180
209
 
181
210
  **Авторство коммитов автофинала** (identity бота выводится из токена,
@@ -217,32 +246,83 @@ fi
217
246
  permitted for this session type / through this proxy» (у fine-grained
218
247
  PAT было бы «…by personal access token»).
219
248
 
220
- Что прокси **разрешает** из облака: push в ветки, создание и мерж PR,
221
- комментарии, чтение Actions, диспатч нерелизных workflow (например,
222
- `visual-update.yml` — подтверждено прогонами). Что **запрещает** (зонд
223
- TRI-117, 2026-08-14): создание релизов, диспатч `release.yml`,
224
- `repository_dispatch`, создание, push и удаление тегов, удаление веток —
225
- блокируется публикация релизов любым маршрутом. Поэтому публикация релизов из
226
- облака невозможна — облачный прогон готовит релиз и передаёт публикацию
227
- человеку (см. `.agents/skills/release-auto/SKILL.md`). Диагностический
228
- зонд права на диспатч: POST на
229
- `actions/workflows/release.yml/dispatches` с несуществующим `ref` —
230
- `422 No ref found` означает «право есть», `403` — «нет».
249
+ Что доступно из облака — проверено зондами TRI-117 (2026-08-14) и TRI-119
250
+ (2026-08-21):
251
+
252
+ | Операция | Из облака |
253
+ |---|---|
254
+ | `git push` ветки, включая защищённый `main` | ✅ |
255
+ | Создание и мерж PR, комментарии | ✅ |
256
+ | Чтение REST и Actions | ✅ |
257
+ | Диспатч нерелизных workflow (`visual-update.yml`) | ✅ |
258
+ | `git push` тега | ❌ `RPC failed; HTTP 403` |
259
+ | `POST /git/refs`, `PATCH /git/refs/heads/*` | ❌ |
260
+ | `gh release create` | ❌ |
261
+ | `gh workflow run release.yml`, `repository_dispatch` | ❌ |
262
+ | Удаление веток и тегов | ❌ |
263
+
264
+ Отказы приходят с текстом `Write access to this GitHub API path is not
265
+ permitted through this proxy` — это ограничение прокси, а не прав токена
266
+ и не настроек репозитория. Менять branch protection бесполезно.
267
+
268
+ **Следствие для релиза:** выпустить его из облачной сессии нельзя ни одним
269
+ маршрутом. Поэтому механика релиза живёт в GitHub Actions
270
+ (`.github/workflows/release-weekly.yml`), расписание держит сам GitHub, а
271
+ за облачной routine остаётся отчёт — см.
272
+ `.agents/skills/release-auto/SKILL.md`.
231
273
 
232
274
  ---
233
275
 
234
276
  ## PR-воркфлоу
235
277
 
236
- **Работа по задаче Linear:** после коммита агент сам пушит ветку и создаёт PR:
278
+ **Работа по задаче Linear:** PR для веток `TRI-*` создаёт workflow
279
+ `auto-pr.yml` (триггер `on: push`) от аккаунта бота — secret
280
+ `DUDIM_AI_GH_TOKEN`. Так автор PR не зависит от среды сессии: локальный
281
+ `gh pr create` шёл бы от PAT бота, а облачный — от GitHub App `claude`
282
+ (прокси подменяет авторизацию, см. «Облачные сессии»). Агент **не вызывает
283
+ `gh pr create` в ветках `TRI-*`** — иначе облачная сессия успеет создать
284
+ PR от `claude[bot]` раньше workflow.
285
+
286
+ Финальный коммит — это и есть источник PR: заголовок берётся из первой
287
+ строки (`TRI-XXX Краткое описание`), описание — из тела коммита. Поэтому
288
+ в автофинале тело коммита обязательно и содержит описание PR
289
+ («Что сделано» / «Как проверить»).
237
290
 
238
291
  ```bash
239
292
  git push -u origin TRI-XXX-feature-name
240
- gh pr create \
241
- --title "TRI-XXX Краткое описание" \
242
- --body "## Что сделано\n- ...\n\n## Как проверить\n- ..."
293
+ # PR создаст auto-pr.yml через ~20–60 с; дождись его появления:
294
+ for i in $(seq 1 12); do
295
+ pr_url=$(gh pr list --head "TRI-XXX-feature-name" --base main --state open \
296
+ --json url --jq '.[0].url // empty') && [ -n "$pr_url" ] && break
297
+ sleep 10
298
+ done
299
+ echo "${pr_url:-PR не появился}"
243
300
  ```
244
301
 
245
- После создания PR финал продолжается автоматически: skill
302
+ Если PR не появился за ~2 минуты — проверь прогон workflow именно для
303
+ текущего коммита (не по ветке: `--limit 1` по ветке может показать прогон
304
+ предыдущего пуша):
305
+ `gh run list --workflow=auto-pr.yml --commit $(git rev-parse HEAD) --limit 1`.
306
+ Дальше по статусу:
307
+
308
+ - `queued` / `in_progress` — прогон ещё идёт, продолжай ждать;
309
+ - `completed` + `success` без PR — сработал guard (маркер `[no-pr]`,
310
+ subject не по формату `TRI-XXX ...` или ветка без новых коммитов
311
+ относительно `main`) — штатное завершение без PR, fallback не нужен;
312
+ - `completed` + `failure` или прогона нет (например, протух secret) —
313
+ fallback.
314
+ Fallback — прежняя схема: `gh pr create` с паттерном bot-токена из
315
+ раздела выше.
316
+ Fallback безопасен: раз workflow PR не создал, гонки нет. В облачной
317
+ сессии fallback-PR будет от `claude[bot]` (прокси подменит PAT — см.
318
+ «Облачные сессии») — это осознанная деградация, лучше PR от app'а, чем
319
+ отсутствие PR.
320
+
321
+ Обход автосоздания (WIP-пуш ветки `TRI-*` без PR): добавь маркер `[no-pr]`
322
+ в первую строку HEAD-коммита. Коммиты не по формату `TRI-XXX ...`
323
+ (например, `chore: ...`) PR также не создают.
324
+
325
+ После появления PR финал продолжается автоматически: skill
246
326
  `update-visual-baselines` (если менялись stories/визуал) и skill
247
327
  `finish-task` — комментарий-резюме в задаче Linear и проверка линковки PR
248
328
  (GitHub-интеграция Linear подхватит PR по `TRI-XXX` в имени ветки).