@smwb/onreel-git-mcp 0.1.0 → 0.2.1

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.
Files changed (70) hide show
  1. package/README.md +408 -0
  2. package/dist/chunk-3YP22YLA.js +50 -0
  3. package/dist/chunk-3YP22YLA.js.map +1 -0
  4. package/dist/chunk-4ZGRXIVA.js +259 -0
  5. package/dist/chunk-4ZGRXIVA.js.map +1 -0
  6. package/dist/chunk-C6PHNSDC.js +112 -0
  7. package/dist/chunk-C6PHNSDC.js.map +1 -0
  8. package/dist/chunk-GPRMI4PG.js +421 -0
  9. package/dist/chunk-GPRMI4PG.js.map +1 -0
  10. package/dist/chunk-L7CPE2SO.js +166 -0
  11. package/dist/chunk-L7CPE2SO.js.map +1 -0
  12. package/dist/contract/contract.const.d.ts +190 -0
  13. package/dist/contract/contract.lib.d.ts +22 -0
  14. package/dist/contract/contract.schema.d.ts +279 -0
  15. package/dist/contract/contract.types.d.ts +23 -0
  16. package/dist/files/files.const.d.ts +6 -0
  17. package/dist/files/files.lib.d.ts +14 -0
  18. package/dist/files/files.types.d.ts +13 -0
  19. package/dist/files/index.d.ts +3 -0
  20. package/dist/files/index.js +163 -0
  21. package/dist/files/index.js.map +1 -0
  22. package/dist/git/git.const.d.ts +4 -0
  23. package/dist/git/git.lib.d.ts +90 -0
  24. package/dist/git/git.types.d.ts +33 -0
  25. package/dist/git/index.d.ts +7 -0
  26. package/dist/git/index.js +97 -0
  27. package/dist/git/index.js.map +1 -0
  28. package/dist/git/paths.const.d.ts +5 -0
  29. package/dist/git/paths.lib.d.ts +39 -0
  30. package/dist/index.d.ts +5 -1
  31. package/dist/index.js +203 -2
  32. package/dist/index.js.map +1 -1
  33. package/dist/kernel/log.types.d.ts +5 -0
  34. package/dist/kernel/redact.lib.d.ts +25 -0
  35. package/dist/kernel/store.types.d.ts +109 -0
  36. package/dist/kernel/umask.lib.d.ts +7 -0
  37. package/dist/mcp/args.lib.d.ts +17 -0
  38. package/dist/mcp/envelope.lib.d.ts +20 -0
  39. package/dist/mcp/index.d.ts +8 -0
  40. package/dist/mcp/index.js +744 -0
  41. package/dist/mcp/index.js.map +1 -0
  42. package/dist/mcp/mcp.const.d.ts +23 -0
  43. package/dist/mcp/mcp.schema.d.ts +64 -0
  44. package/dist/mcp/mcp.types.d.ts +125 -0
  45. package/dist/mcp/principal.lib.d.ts +22 -0
  46. package/dist/mcp/server.lib.d.ts +3 -0
  47. package/dist/mcp/tools.lib.d.ts +16 -0
  48. package/dist/tracker/builtin.lib.d.ts +1 -0
  49. package/dist/tracker/github.lib.d.ts +2 -0
  50. package/dist/tracker/gitlab.lib.d.ts +2 -0
  51. package/dist/tracker/http.lib.d.ts +4 -0
  52. package/dist/tracker/index.d.ts +15 -0
  53. package/dist/tracker/index.js +840 -0
  54. package/dist/tracker/index.js.map +1 -0
  55. package/dist/tracker/jira.lib.d.ts +2 -0
  56. package/dist/tracker/local.lib.d.ts +2 -0
  57. package/dist/tracker/mapping.const.d.ts +26 -0
  58. package/dist/tracker/registry.lib.d.ts +5 -0
  59. package/dist/tracker/secrets.lib.d.ts +7 -0
  60. package/dist/tracker/sourcecraft.lib.d.ts +2 -0
  61. package/dist/tracker/tracker.const.d.ts +10 -0
  62. package/dist/tracker/tracker.lib.d.ts +21 -0
  63. package/dist/tracker/tracker.types.d.ts +77 -0
  64. package/dist/worktree/index.d.ts +4 -0
  65. package/dist/worktree/index.js +203 -0
  66. package/dist/worktree/index.js.map +1 -0
  67. package/dist/worktree/lifecycle.const.d.ts +2 -0
  68. package/dist/worktree/lifecycle.lib.d.ts +30 -0
  69. package/dist/worktree/lifecycle.types.d.ts +14 -0
  70. package/package.json +15 -3
package/README.md ADDED
@@ -0,0 +1,408 @@
1
+ # @smwb/onreel-git-mcp
2
+
3
+ Библиотека модулей, выделенных из [onreel](https://git.sourcecraft.dev/elfenmail/onreel) в отдельно
4
+ версионируемый npm-пакет:
5
+
6
+ - **git-обвязка** — запуск `git` дочерним процессом через пул с таймаутами и ограничением параллелизма,
7
+ операции над worktree, ветками, статусом, diff, коммитом и rebase, и **безопасные пути**
8
+ (проверка confinement по realpath, отказ на симлинк);
9
+ - **листинги** — список записей одного каталога worktree для ревью;
10
+ - **жизненный цикл worktree** — замки, двухфазное создание и очистка, сверка при старте, свип и корзина —
11
+ против портов хранения, которые реализует потребитель;
12
+ - **адаптеры трекеров** — единая модель задач и пять адаптеров: GitHub, GitLab, Jira, SourceCraft и локальный
13
+ трекер поверх хранилища потребителя;
14
+ - **MCP stdio-сервер** — JSON-RPC 2.0 поверх stdin/stdout, инструменты строятся из таблицы команд вызывающего.
15
+
16
+ Пакет ESM, рантайм-зависимость одна — `zod`. Публичная поверхность держится документированной здесь:
17
+ новый субпуть или публичный символ добавляют вместе с записью в этом README.
18
+
19
+ ## Установка
20
+
21
+ ```sh
22
+ npm i @smwb/onreel-git-mcp
23
+ ```
24
+
25
+ Требуется **Node.js >= 24** (в `package.json` — `engines`) и **Linux**: код рассчитывает на маску прав
26
+ сессии (`umask`), групповой `SIGKILL` процесса git и чтение `/proc/<pid>/environ` в выборе принципала MCP.
27
+ CI и тесты идут на образе `node:24-bookworm`; в репозиторий git должен быть доступен как команда —
28
+ своих git-библиотек в зависимостях нет.
29
+
30
+ Импортируют по субпутям, ядро (`src/kernel/`) своего субпути не имеет — его типы достаются из тех
31
+ субпутей, где они нужны вызывающему.
32
+
33
+ | Субпуть | Что отдаёт | Ссылка |
34
+ |---|---|---|
35
+ | `@smwb/onreel-git-mcp` | срез контракта: константы env, zod-схемы и типы (`Issue`, `Worktree`, `ContractError`), чистые помощники (`contractError`, `sessionUmask`), `VERSION` | [Контракт](#контракт) |
36
+ | `…/git` | `runGit`, `GitError`, управление пулом, операции над репозиторием и worktree, безопасные пути, маскирование секретов | [git](#git) |
37
+ | `…/files` | `listWorktreeDir`, `DirRefusal` | [files](#files) |
38
+ | `…/worktree` | замки, `finishPreparing`/`finishCleaning`, reconciler, корзина — против порта `WorktreeStore` | [worktree](#worktree) |
39
+ | `…/tracker` | типы портов, реестр, `registerBuiltinTrackers()`, пять фабрик адаптеров, файловое хранилище токенов | [tracker](#tracker) |
40
+ | `…/mcp` | `serveMcp`, `mcpToolSpecs`, сборщики инструментов, выбор принципала | [mcp](#mcp) |
41
+ | `…/package.json` | манифест пакета | [package.json](#packagejson) |
42
+
43
+ Побочных эффектов при импорте нет нигде (R-001): регистрации, пулы и фоновые циклы запускаются только
44
+ явными вызовами — `registerBuiltinTrackers()`, `startWorktreeReconciler()`, `serveMcp()`.
45
+
46
+ Дальше — рабочие примеры; их же компилируемая версия лежит в [`examples/`](examples) и проверяется
47
+ `npm run typecheck` в CI. Примеры импортируют `src/` напрямую, а не субпути пакета: в CI линтер и
48
+ typecheck идут до `npm run build`, когда `dist/` ещё нет. Node 24 выполняет TypeScript напрямую, поэтому
49
+ каждый пример запускается и без сборки: `node examples/git.lib.ts`.
50
+
51
+ ## Контракт
52
+
53
+ Корневой субпуть — срез контракта onreel: константы имён переменных окружения, схемы и выведенные из них
54
+ типы, чистые помощники ошибок. Имена `ONREEL_*` заморожены как есть: их смена ломает interop onreel ↔ библиотека.
55
+
56
+ ```ts
57
+ import { ENV_TOKEN, HOST_CONTRACT_VERSION, VERSION, contractError, formatVersion } from '@smwb/onreel-git-mcp';
58
+
59
+ const version = formatVersion(HOST_CONTRACT_VERSION); // '1.14' — версия контракта host
60
+ console.log(`библиотека ${VERSION}, контракт host ${version}, токен читают из ${ENV_TOKEN}`);
61
+
62
+ // Ошибку собирают здесь и отдают наружу: код из каталога, непустое сообщение и обязательные nextSteps.
63
+ const error = contractError('not_found', 'Задачи 42 нет.', ['Откройте список задач.']);
64
+ console.log(error.code, error.message, error.nextSteps.join(' '));
65
+ ```
66
+
67
+ ## git
68
+
69
+ Каждая git-команда — дочерний процесс `git`: аргументы массивом (без shell), не больше четырёх
70
+ одновременно, таймаут 120 с (`GIT_TIMEOUT_MS`), для `worktree add` — 600 с (`GIT_CHECKOUT_TIMEOUT_MS`),
71
+ вывод не больше 16 МиБ. Всё, что приходит от клиента, git получает только после `--end-of-options`
72
+ или уже резолвленным в sha. Окружение ребёнка очищено от унаследованных `GIT_*`, права создаваемых
73
+ файлов берутся из маски сессии.
74
+
75
+ ```ts
76
+ import {
77
+ addWorktree,
78
+ changedFiles,
79
+ closeGitPool,
80
+ commitChanges,
81
+ currentBranch,
82
+ GitError,
83
+ headOf,
84
+ readWorktreeFile,
85
+ repoToplevel,
86
+ resolveCommit,
87
+ runGit,
88
+ } from '@smwb/onreel-git-mcp/git';
89
+
90
+ const repo = await repoToplevel('/home/user/project');
91
+ const branch = await currentBranch(repo); // detached HEAD — undefined
92
+ const baseSha = await headOf(repo);
93
+
94
+ await addWorktree(repo, '/home/user/.onreel/workspaces/project-1a2b3c/run-1/task-1', 'onreel/run-1/task-1', baseSha);
95
+
96
+ const wt = '/home/user/.onreel/workspaces/project-1a2b3c/run-1/task-1';
97
+ const outcome = await commitChanges(wt, '#12 Правка по задаче'); // committed: false, если коммитить нечего
98
+ const changes = await changedFiles(wt, baseSha); // отслеживаемые против базы; без неотслеживаемых
99
+ const file = await readWorktreeFile(wt, 'src/main.ts', 64 * 1024); // {size, binary, truncated, text?}
100
+ console.log(branch, outcome.committed, changes.length, file?.text === undefined);
101
+
102
+ // Нештатный код, таймаут или переполнение вывода — GitError.
103
+ const missing = await resolveCommit(repo, 'refs/heads/нет-такой-ветки'); // undefined, не ошибка
104
+ try {
105
+ await runGit(['rev-parse', '--verify', 'HEAD^1'], repo, { okCodes: [] });
106
+ } catch (error) {
107
+ if (error instanceof GitError) console.log(error.exitCode, error.timedOut, error.stderr);
108
+ }
109
+ ```
110
+
111
+ **Остановка процесса.** `closeGitPool()` вызывает потребитель в своей точке остановки host'а: очередь
112
+ отклоняется, запущенные git убиваются групповым `SIGKILL`. `isGitPoolClosed()` позволяет отличить сбой
113
+ «пул закрыт, мы останавливаемся» от настоящей ошибки git — в последнем случае запись `failed` не пишут.
114
+ `reopenGitPool()` — только для тестов.
115
+
116
+ **Маскирование секретов (D-272, D-194).** `GitError` маскирует аргументы и stderr **до** обрезки, иначе
117
+ срез оставил бы префикс секрета, который шаблон не узнаёт; stdout маскируется при первом чтении. Шаблонов
118
+ R1–R8 (URL с `user:pass@`, `Authorization:`, известные ключи доступа, JWT, PEM, `onr_…`-токен, присваивания
119
+ вида `token = …`, пароли в CLI-флагах) бывает мало — значение, не похожее ни на один шаблон, регистрируют
120
+ в приёмнике секретов:
121
+
122
+ ```ts
123
+ import { defaultSecretSink, redactSecretText, type SecretSink } from '@smwb/onreel-git-mcp/git';
124
+
125
+ defaultSecretSink.register(process.env.MYopaqueValue ?? ''); // короче 8 символов — игнорируется
126
+ console.log(redactSecretText('клон https://user:секрет@git.example/o/r и MYopaqueValue'));
127
+
128
+ // Потребитель со своим журналом подставляет свой приёмник — тем же типом SecretSink.
129
+ const known = new Set<string>();
130
+ const sink: SecretSink = {
131
+ register(secret: string): void {
132
+ known.add(secret);
133
+ },
134
+ };
135
+ ```
136
+
137
+ Значения `SecretSink`, `defaultSecretSink` и `redactSecretText` реэкспортированы из `…/git`; отдельного
138
+ субпути `…/kernel` у пакета нет.
139
+
140
+ ## files
141
+
142
+ `listWorktreeDir` отдаёт записи одного каталога worktree: не глубже одного уровня, симлинки не разыменовываются
143
+ (`symlink` остаётся листом), служебный `.git` пропускается без учёта регистра, каталог и путь запроса проверяются
144
+ на confinement по realpath. `truncated` ставится, если просмотрели больше `scanMax` записей или обрезали по
145
+ `maxEntries`; имя не в UTF-8 тоже обрезает список с этим признаком.
146
+
147
+ ```ts
148
+ import { DirRefusal, listWorktreeDir } from '@smwb/onreel-git-mcp/files';
149
+
150
+ try {
151
+ const root = await listWorktreeDir(dataDir, worktreePath, undefined);
152
+ const nested = await listWorktreeDir(dataDir, worktreePath, 'src', { maxEntries: 200, scanMax: 1000 });
153
+ console.log(root.truncated, root.entries.map((entry) => `${entry.kind} ${entry.path}`), nested.entries.length);
154
+ } catch (error) {
155
+ // Причина отказа — часть контракта: её контроллер переводит в ошибку worktree.files по матрице D-367.
156
+ if (error instanceof DirRefusal) console.log(error.reason); // 'outside_worktree' | 'excluded' | 'not_found' | …
157
+ else throw error;
158
+ }
159
+ ```
160
+
161
+ ## worktree
162
+
163
+ Двухфазные создание и очистка (onreel D-050): receipt-транзакция потребителя записала `preparing` или
164
+ `cleaning`, библиотека доводит состояние до `ready`, `cleaned` или `failed`, работая с git и диском вне
165
+ транзакции. Состояние читается и пишется через **порт `WorktreeStore`** — его реализует вызывающий (D-003);
166
+ в пакете нет ни SQLite, ни ledger, ни другой конкретной БД.
167
+
168
+ ```ts
169
+ import {
170
+ finishPreparing,
171
+ isWorktreeBusy,
172
+ startWorktreeReconciler,
173
+ withWorktreeLock,
174
+ type WorktreeLifecycleServices,
175
+ type WorktreeStore,
176
+ } from '@smwb/onreel-git-mcp/worktree';
177
+
178
+ const services: WorktreeLifecycleServices = {
179
+ store: myStore satisfies WorktreeStore,
180
+ dataDir: '/home/user/.onreel',
181
+ log: (line) => console.error(line),
182
+ };
183
+
184
+ await finishPreparing(services, 'wt-9'); // preparing → ready с HEAD; сбой → failed и сразу очистка остатков
185
+
186
+ const reconciler = startWorktreeReconciler(services); // 30 с по умолчанию (WORKTREE_RECONCILE_INTERVAL_MS)
187
+ await reconciler.kick(); // свип вне таймера: после мержа, после stop воркера
188
+ await reconciler.stop();
189
+
190
+ // Замок — один на worktree в пределах процесса: создание, очистка, коммит и rebase не перекрываются.
191
+ console.log(isWorktreeBusy('wt-9'));
192
+ await withWorktreeLock('wt-9', async () => {
193
+ /* работа с этим worktree */
194
+ });
195
+ ```
196
+
197
+ Стартует `startWorktreeReconciler` — он же делает сверку при старте (`reconcileWorktreesAtStart`: осиротевший
198
+ `preparing` → `failed` и в очистку) и доочищает корзину (`emptyTrash`). `sweepWorktrees` отдельно готовит к
199
+ очистке `ready`-worktree завершённых задач и доводит начатые `cleaning`. Незакоммиченные изменения без явного
200
+ решения вызывающего (`discardChanges`) очистка не выбрасывает: worktree уходит в `failed` и ждёт решения.
201
+
202
+ Порт `WorktreeStore` раскрывает пять методов и явную единицу работы — `inTransaction(fn)`, которой вызывают
203
+ все переходы (`markWorktreeReady`, `markWorktreeFailed`, `requestWorktreeCleanup`, `completeWorktreeCleanup`);
204
+ вложенность и асинхронные обратные вызовы не поддерживаются. **Эталон реализации — `test/helpers/memory-store.lib.ts`
205
+ в тестовом дереве пакета** (D-003): он же образец семантики для потребителей, включая копию-на-коммит в
206
+ `inTransaction` и отказ броска как откат.
207
+
208
+ ## tracker
209
+
210
+ Единая модель задач поверх внешних трекеров и локального хранилища. Адаптер — `TrackerAdapter` (list/get/create/
211
+ update/comments/comment), подключаемый трекер — `TrackerDefinition` (`descriptor` для интерфейса + фабрика).
212
+ `fetch` **приходит в контексте**, а не берётся из глобальной области: тесты подставляют свою функцию, потребитель —
213
+ свою обёртку с прокси.
214
+
215
+ ```ts
216
+ import {
217
+ findTracker,
218
+ localTracker,
219
+ registerBuiltinTrackers,
220
+ TrackerError,
221
+ type AdapterContext,
222
+ type TrackerAdapter,
223
+ type TrackerFetch,
224
+ } from '@smwb/onreel-git-mcp/tracker';
225
+
226
+ // Явный вызов, а не побочный эффект импорта (R-001); повтор — no-op.
227
+ registerBuiltinTrackers();
228
+ console.log(findTracker('github')?.descriptor.title); // 'GitHub'; реестр пуст без этого вызова
229
+
230
+ const fetch: TrackerFetch = (input, init) =>
231
+ Promise.resolve(new Response(JSON.stringify({ number: 7, title: 'Падает сборка', state: 'open', labels: [] }), { status: 200 }));
232
+
233
+ const context: AdapterContext = {
234
+ projectId: 'p1',
235
+ connection: { target: 'o/r', token: 'секрет-только-в-памяти' }, // расшифровку даёт хранилище вызывающего
236
+ fetch,
237
+ store: myLocalIssueStore, // порт LocalIssueStore — нужен только локальному трекеру
238
+ author: 'owner',
239
+ };
240
+
241
+ const adapter: TrackerAdapter = (findTracker('github') ?? localTracker()).create(context);
242
+ const issue = await adapter.get('7');
243
+ try {
244
+ await adapter.update('7', { status: 'in_progress' });
245
+ } catch (error) {
246
+ if (error instanceof TrackerError) console.log(error.failure); // 'failed' | 'unknown_issue' | 'invalid_request'
247
+ else throw error;
248
+ }
249
+ ```
250
+
251
+ Фабрики: `localTracker()`, `sourcecraftTracker()`, `githubTracker()`, `gitlabTracker()`, `jiraTracker()`.
252
+ Сопоставление статусов и приоритетов — открытые таблицы (`SOURCECRAFT_STATUSES`, `JIRA_PRIORITIES`,
253
+ `GITHUB_PRIORITY_LABELS`, `GITLAB_PRIORITY_LABELS`, …), чтобы потребитель видел, во что именно преводится его
254
+ статус. Свои трекеры добавляют в реестр явным `registerTracker(definition)`; повторный `kind` — исключение, а не
255
+ молчаливая замена. Типы порта `LocalIssueStore` (и его строк) реэкспортированы из `…/tracker` — реализацию, как и
256
+ для `WorktreeStore`, пишет потребитель; эталон в памяти — `test/helpers/tracker-fixtures.lib.ts`.
257
+
258
+ Таймаут запроса к трекеру — `TRACKER_TIMEOUT_MS` (15 с), тело ответа ошибки обрезают до
259
+ `TRACKER_ERROR_BODY_MAX` (300 символов) и маскируют; 404 с `missingIsIssue` — `unknown_issue`, а не «неверный адрес».
260
+
261
+ ## mcp
262
+
263
+ `serveMcp` — построчный JSON-RPC 2.0 на stdio, только инструменты: `initialize`, `ping`, `tools/list`,
264
+ `tools/call` и `notifications/cancelled`. Своей таблицы команд у библиотеки нет — она приходит параметром,
265
+ инструмент строится из строки таблицы (имя — путь через `_`, свойства — флаги без `--` плюс `request-id`).
266
+ Конверт результата тот же, что у CLI `--json`: `{ok: true, requestId, duplicate, result}` или
267
+ `{ok: false, requestId, error: {code, message, nextSteps}}`.
268
+
269
+ ```ts
270
+ import { mcpToolSpecs, serveMcp, type CommandSpec } from '@smwb/onreel-git-mcp/mcp';
271
+
272
+ const specs: readonly CommandSpec[] = [
273
+ {
274
+ path: ['orch', 'run', 'list'],
275
+ handler: 'verb',
276
+ verb: 'run.list',
277
+ mutation: false,
278
+ flags: [{ name: 'limit', parse: 'int', field: 'limit', help: 'размер страницы' }],
279
+ summary: 'Список Run',
280
+ roles: ['owner'],
281
+ },
282
+ {
283
+ path: ['orch', 'hello'],
284
+ handler: 'local',
285
+ flags: [{ name: 'name', parse: 'string', required: true, help: 'имя для приветствия' }],
286
+ summary: 'Сказать приветствие',
287
+ roles: ['owner'],
288
+ },
289
+ ];
290
+
291
+ const stop = new AbortController();
292
+ process.once('SIGINT', () => stop.abort());
293
+
294
+ await serveMcp({
295
+ specs: mcpToolSpecs(specs), // отсекает строку самой команды `orch mcp`
296
+ execute: async (spec, values, globals) => ({ result: { command: spec.path.join(' '), values }, requestId: globals.requestId }),
297
+ globals: { json: false, help: false },
298
+ io: {
299
+ env: process.env,
300
+ cwd: process.cwd(),
301
+ stdout: (text) => process.stdout.write(text), // канал протокола: больше никаких строк
302
+ stderr: (text) => process.stderr.write(text),
303
+ signal: stop.signal, // SIGINT: начатые вызовы отменяются
304
+ },
305
+ input: process.stdin,
306
+ discovery: (globals, env) => globals.dataDir ?? env['ONREEL_DATA_DIR'],
307
+ });
308
+ ```
309
+
310
+ Сервер отвечает `serverInfo` с `name: 'onreel'` и версией контракта host; согласовывает любую из
311
+ `MCP_PROTOCOL_VERSIONS` (`2025-11-25` … `2024-11-05`) и отдаёт `structuredContent` от `2025-06-18`. Прочее:
312
+ `notifications/progress` каждые `MCP_PROGRESS_INTERVAL_MS` (15 с), если клиент прислал `progressToken`; строка
313
+ длиннее `MCP_MAX_LINE_BYTES` (8 МиБ) отбрасывается с ошибкой парсинга; после конца stdin начатые вызовы живут
314
+ `MCP_EOF_GRACE_MS` (5 с).
315
+
316
+ **Принципал** (от чьего имени работает сервер) выбирают один раз при запуске: env агента
317
+ (`ONREEL_HOST_URL` + `ONREEL_TOKEN`) — агент; явный `--data-dir` — владелец; иначе владелец по `host.json`,
318
+ каталог для которого даёт `DiscoveryProvider` — библиотека не знает ни имени переменной, ни домашнего каталога CLI
319
+ потребителя. `findAgentAncestor()` поднимается по предкам через `/proc/<pid>/environ` и `/proc/<pid>/status`:
320
+ если сервер запущен внутри процесса агента с урезанным окружением, выбор владельца запрещён
321
+ (`{kind: 'refused'}`), и каждый вызов вернёт `unauthorized` с объяснением. **Это Linux-специфика:** вне Linux
322
+ `/proc` нет, цепочка обрывается, `findAgentAncestor()` возвращает `undefined` (исключения не бросается) и сервер
323
+ при урезанном env выберет владельца. Кому это важно — передаёт `agentAncestor` сам или задаёт `principal` явно.
324
+
325
+ ```ts
326
+ import { findAgentAncestor, mcpPrincipal, principalText, toolOf } from '@smwb/onreel-git-mcp/mcp';
327
+
328
+ const principal = mcpPrincipal({ json: false, help: false }, process.env, findAgentAncestor(), discovery);
329
+ console.log(principalText(principal)); // одна фраза — она попадает в instructions ответа initialize
330
+ console.log(toolOf(specs[0]!, process.env).inputSchema); // обязательны флаги, которые нечем заполнить из env
331
+ ```
332
+
333
+ ## package.json
334
+
335
+ Единственный не-API субпуть — манифест пакета, для инструментов, которым нужны имя и версия
336
+ установленной копии:
337
+
338
+ ```ts
339
+ import manifest from '@smwb/onreel-git-mcp/package.json' with { type: 'json' };
340
+
341
+ console.log(manifest.name, manifest.version);
342
+ ```
343
+
344
+ Коду вместо этого взять `VERSION` из корневого субпути: он типизирован и не требует
345
+ `resolveJsonModule` и import attributes.
346
+
347
+ ## Безопасность
348
+
349
+ - **Хранение токенов.** `writeTrackerToken(dataDir, projectId, token)` кладёт токены в
350
+ `<dataDir>/tracker-secrets.json` с правами **0600** (каталог — 0700), запись идёт через временный файл и
351
+ `rename`, одиночная запись сериализуется очередью, чтобы два подключения не затерли друг друга. `deleteTrackerToken`
352
+ и пустая строка убирают значение. Наружу из хранилища выходит только признак «токен есть» (`hasTrackerToken`),
353
+ а при чтении и записи значение регистрируется в приёмнике секретов.
354
+ - **Маскирование секретов.** `redactSecretText` и `SecretSink` (по умолчанию `defaultSecretSink`) — один redactor
355
+ на библиотеку: его применяют `GitError` (D-272), `requestJson` (тексты отказов трекера) и внутренний отказ
356
+ `serveMcp`. Свой журнал потребителя подключают приёмником `SecretSink`; известные значения добавляют до
357
+ форматирования текста, короткая строка (менее 8 символов) не регистрируется — она дала бы слишком много ложных
358
+ срабатываний.
359
+ - **Ограничение путей.** `checkRepoPath` отказывает базовому репозиторию, который абсолютным путём или по realpath
360
+ лежит внутри каталога данных (там ledger и токены), `prepareWorktreeParent` создаёт каталоги по одному и не
361
+ принимает симлинк, `checkManagedPath` перед удалением проверяет и сам каталог, и его realpath. `listWorktreeDir`
362
+ и `readWorktreeFile` работают только внутри realpath worktree, `.git` не показываются, симлинк за пределы —
363
+ `PathEscapeError` / `DirRefusal('unsafe_path')`.
364
+ - **Канал stdout.** В `serveMcp` `io.stdout` занят протоколом: вывод обработчика команды уходит в никуда, а
365
+ сообщения трекеров и git в журнал идут через `io.stderr` уже замаскированными.
366
+ - **Окружение детей.** `runGit` снимает унаследованные `GIT_*` (иначе `GIT_DIR` увёл бы host работать с чужим
367
+ репозиторием), ставит `GIT_TERMINAL_PROMPT=0`, `GIT_OPTIONAL_LOCKS=0` и `LC_ALL=C`, права создаваемых файлов
368
+ берёт из маски сессии (`ONREEL_SESSION_UMASK`).
369
+
370
+ ## Совместимость
371
+
372
+ - Только ESM (`"type": "module"`): CJS-потребителю нужен `import()` или обёртка.
373
+ - Node.js >= 24, Linux; macOS/Windows не тестируются (см. `/proc` и групповой `SIGKILL` выше).
374
+ - Публикуется собранный ESM и `.d.ts` из `dist/` (D-002) — суффиксы `.ts` в импортах исходников стирает сборка,
375
+ их не нужно править в потребителе.
376
+ - `sideEffects: false`, один рантайм-зависимый пакет — `zod`.
377
+ - Реестр трекеров, пул git, замки worktree и redactor — модульная глобальна на процесс: несколько независимых
378
+ «хостов» в одном процессе делят их. Запросы к внешним API идут `fetch` из контекста; узлы без TLS-цепей
379
+ внешнего трекера — задача вызывающего.
380
+ - Имена `ONREEL_*` и `HOST_CONTRACT_VERSION` (`1.14`) заморожены: они держат interop с onreel.
381
+
382
+ ## Как внести изменение
383
+
384
+ 1. Начните с задачи в трекере репозитория и переведите её в «в работе» (правила — в `AGENTS.md`).
385
+ 2. Реализуйте изменение; новый публичный символ или субпуть описывают здесь, в README, и добавляют
386
+ компилируемый пример в [`examples/`](examples).
387
+ 3. Прогоните проверки так же, как CI:
388
+
389
+ ```sh
390
+ npm run typecheck # src, test и examples
391
+ npm run lint
392
+ npm test
393
+ npm run build
394
+ ```
395
+
396
+ 4. Изменение публичной поверхности (субпутевые экспорты, имена и сигнатуры публичных символов) требует changeset:
397
+ `npx changeset`, выбрать этот пакет и `minor` для обратно-совместимого дополнения. Без changeset релизный
398
+ workflow не выпустит версию.
399
+ 5. Откройте PR с заголовком и коммитами, начинающимися с номера задачи (`#12 …`); в `main` — только через PR
400
+ с зелёным CI. После мержа push в `main` запускает релизный workflow: pending-ченджсеты собираются в
401
+ «Version Packages» PR, а когда их нет — `changeset publish` публикует пакет и ставит теги.
402
+
403
+ ## Происхождение
404
+
405
+ Код перенесён из onreel @ `b73407be076a5df73931d1340f70910ca13c908d` (`src/host/worktree/`,
406
+ `src/host/tracker/`, `src/cli/mcp/`, нужная часть `src/contract/`) с заменой внутренних связок на швы ядра.
407
+ Происхождение по файлам — в шапках исходников. Контроллеры `@smwb/srv`, CLI-обвязка и реализации хранилищ поверх
408
+ ledger остаются в onreel: это клей хоста, а не код библиотеки.
@@ -0,0 +1,50 @@
1
+ import {
2
+ CONTRACT_ERROR_MESSAGE_MAX,
3
+ CONTRACT_ERROR_NEXT_STEP_MAX,
4
+ DEFAULT_SESSION_UMASK,
5
+ ENV_SESSION_UMASK,
6
+ contractErrorSchema
7
+ } from "./chunk-4ZGRXIVA.js";
8
+
9
+ // src/contract/contract.lib.ts
10
+ function clip(text, max) {
11
+ return text.length <= max ? text : `${text.slice(0, max - 1)}\u2026`;
12
+ }
13
+ function contractError(code, message, nextSteps, data) {
14
+ const error = {
15
+ code,
16
+ message: clip(message, CONTRACT_ERROR_MESSAGE_MAX),
17
+ nextSteps: nextSteps.map((step) => clip(step, CONTRACT_ERROR_NEXT_STEP_MAX)),
18
+ ...data === void 0 ? {} : { data }
19
+ };
20
+ return contractErrorSchema.parse(error);
21
+ }
22
+ function formatVersion(version) {
23
+ return `${version.major}.${version.minor}`;
24
+ }
25
+ function parseUmask(value) {
26
+ if (value === void 0 || !/^[0-7]{1,4}$/.test(value)) return void 0;
27
+ const mask = parseInt(value, 8);
28
+ return mask <= 511 ? mask : void 0;
29
+ }
30
+ function formatUmask(mask) {
31
+ return mask.toString(8).padStart(4, "0");
32
+ }
33
+ function sessionUmask(env) {
34
+ return parseUmask(env[ENV_SESSION_UMASK]) ?? DEFAULT_SESSION_UMASK;
35
+ }
36
+ function inheritSessionUmask(env, current) {
37
+ if (parseUmask(env[ENV_SESSION_UMASK]) === void 0) env[ENV_SESSION_UMASK] = formatUmask(current);
38
+ return sessionUmask(env);
39
+ }
40
+
41
+ export {
42
+ clip,
43
+ contractError,
44
+ formatVersion,
45
+ parseUmask,
46
+ formatUmask,
47
+ sessionUmask,
48
+ inheritSessionUmask
49
+ };
50
+ //# sourceMappingURL=chunk-3YP22YLA.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/contract/contract.lib.ts"],"sourcesContent":["// Функции среза контракта onreel для библиотеки @smwb/onreel-git-mcp (заморожены в части B\n// выделения). Происхождение — onreel @ b73407be076a5df73931d1340f70910ca13c908d, дословные копии:\n// src/contract/errors.lib.ts — clip, contractError;\n// src/contract/umask.lib.ts — parseUmask, formatUmask, sessionUmask, inheritSessionUmask\n// (чистые функции: без process и встроенных модулей Node);\n// src/contract/version/version.lib.ts — formatVersion.\nimport { CONTRACT_ERROR_MESSAGE_MAX, CONTRACT_ERROR_NEXT_STEP_MAX, DEFAULT_SESSION_UMASK, ENV_SESSION_UMASK, type ErrorCode } from './contract.const.ts';\nimport { contractErrorSchema } from './contract.schema.ts';\nimport type { ContractError, ContractVersion } from './contract.types.ts';\n\n/** Обрезает строку до `max` символов с многоточием: в ошибку попадает и чужой ввод неизвестной длины. */\nexport function clip(text: string, max: number): string {\n return text.length <= max ? text : `${text.slice(0, max - 1)}…`;\n}\n\n/**\n * Собирает ошибку и проверяет её схемой: длинные `message` и шаги обрезаются, а пустой шаг —\n * ошибка программиста, и функция бросает исключение, а не отдаёт клиенту ответ, который он не разберёт.\n */\nexport function contractError(\n code: ErrorCode,\n message: string,\n nextSteps: [string, ...string[]],\n data?: Record<string, unknown>,\n): ContractError {\n const error = {\n code,\n message: clip(message, CONTRACT_ERROR_MESSAGE_MAX),\n nextSteps: nextSteps.map((step) => clip(step, CONTRACT_ERROR_NEXT_STEP_MAX)),\n ...(data === undefined ? {} : { data }),\n };\n return contractErrorSchema.parse(error);\n}\n\nexport function formatVersion(version: ContractVersion): string {\n return `${version.major}.${version.minor}`;\n}\n\n// --- Маска пользовательских сессий (onreel D-330) ---\n// Чистые функции маски сессий: без process и встроенных модулей Node, потому что contract\n// импортирует и UI. Сам process.umask читают и меняют вызывающие (kernel/umask.lib.ts).\n\n/** Разбирает восьмеричную маску без префикса: 1-4 цифры 0-7, не больше 0o777; иначе undefined. Пробелы не срезаются. */\nexport function parseUmask(value: string | undefined): number | undefined {\n if (value === undefined || !/^[0-7]{1,4}$/.test(value)) return undefined;\n const mask = parseInt(value, 8);\n return mask <= 0o777 ? mask : undefined;\n}\n\n/** Маска как четыре восьмеричные цифры: 0o22 -> «0022». */\nexport function formatUmask(mask: number): string {\n return mask.toString(8).padStart(4, '0');\n}\n\n/** Маска сессий из env; нет переменной или она неверна — DEFAULT_SESSION_UMASK. */\nexport function sessionUmask(env: Readonly<Record<string, string | undefined>>): number {\n return parseUmask(env[ENV_SESSION_UMASK]) ?? DEFAULT_SESSION_UMASK;\n}\n\n/**\n * Записывает current в env, если там нет верной маски, и возвращает маску сессий.\n * Верное значение, уже стоящее в env, не перезаписывается: его задал более ранний процесс цепочки\n * (CLI, Electron, systemd), а значение неверное считается незаданным.\n */\nexport function inheritSessionUmask(env: Record<string, string | undefined>, current: number): number {\n if (parseUmask(env[ENV_SESSION_UMASK]) === undefined) env[ENV_SESSION_UMASK] = formatUmask(current);\n return sessionUmask(env);\n}\n"],"mappings":";;;;;;;;;AAWO,SAAS,KAAK,MAAc,KAAqB;AACtD,SAAO,KAAK,UAAU,MAAM,OAAO,GAAG,KAAK,MAAM,GAAG,MAAM,CAAC,CAAC;AAC9D;AAMO,SAAS,cACd,MACA,SACA,WACA,MACe;AACf,QAAM,QAAQ;AAAA,IACZ;AAAA,IACA,SAAS,KAAK,SAAS,0BAA0B;AAAA,IACjD,WAAW,UAAU,IAAI,CAAC,SAAS,KAAK,MAAM,4BAA4B,CAAC;AAAA,IAC3E,GAAI,SAAS,SAAY,CAAC,IAAI,EAAE,KAAK;AAAA,EACvC;AACA,SAAO,oBAAoB,MAAM,KAAK;AACxC;AAEO,SAAS,cAAc,SAAkC;AAC9D,SAAO,GAAG,QAAQ,KAAK,IAAI,QAAQ,KAAK;AAC1C;AAOO,SAAS,WAAW,OAA+C;AACxE,MAAI,UAAU,UAAa,CAAC,eAAe,KAAK,KAAK,EAAG,QAAO;AAC/D,QAAM,OAAO,SAAS,OAAO,CAAC;AAC9B,SAAO,QAAQ,MAAQ,OAAO;AAChC;AAGO,SAAS,YAAY,MAAsB;AAChD,SAAO,KAAK,SAAS,CAAC,EAAE,SAAS,GAAG,GAAG;AACzC;AAGO,SAAS,aAAa,KAA2D;AACtF,SAAO,WAAW,IAAI,iBAAiB,CAAC,KAAK;AAC/C;AAOO,SAAS,oBAAoB,KAAyC,SAAyB;AACpG,MAAI,WAAW,IAAI,iBAAiB,CAAC,MAAM,OAAW,KAAI,iBAAiB,IAAI,YAAY,OAAO;AAClG,SAAO,aAAa,GAAG;AACzB;","names":[]}