dsh-edt-projects 0.12.0 → 0.12.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.
package/README.md CHANGED
@@ -14,8 +14,14 @@ SonarQube/GitLab.
14
14
  В **linked git worktree** плагин поднимает отдельное окружение EDT
15
15
  (`<worktree>/build/ws`, свой порт и логи), импортирует проекты и монтирует
16
16
  `mcp__edt__*` туда; включённость и настройки наследуются от основного checkout.
17
+ Работает совместно с [dsh-git-worktree](https://github.com/LaoYueHanNi/dsh-git-worktree)
18
+ (`<repo>/.dsh/gitworktree/<branch>/`), `build/` не требует ручной настройки.
17
19
  Управление — инструмент `edt_workspace`.
18
20
 
21
+ В workspace включаются plain text mode (DSH не рендерит embedded resource) и
22
+ `get_form_screenshot` с JVM-флагом `-DnativeFormBufferedLayoutRender=true`;
23
+ выключаются настройками `workspace.plainText` и `workspace.formScreenshots`.
24
+
19
25
  ## Установка
20
26
 
21
27
  ```text
package/lib/index.js CHANGED
@@ -44,7 +44,7 @@ import { normalizeTestTooling } from './tests.mjs';
44
44
  import { initFile, upgradeFile } from './config-file.mjs';
45
45
  import { stringifyConfig, validateConfig } from './schema.mjs';
46
46
  import { createSkillsProvider } from './skills.mjs';
47
- import { repositoriesUnder } from './repo.mjs';
47
+ import { repositoriesUnder, repositoryAt, resolveContextCwd } from './repo.mjs';
48
48
  import {
49
49
  BUILD_DIR,
50
50
  detectEdtHome,
@@ -54,16 +54,20 @@ import {
54
54
  pickFreePort,
55
55
  pidAlive,
56
56
  prepareWorkspaceSnapshot,
57
+ prefValue,
57
58
  probeHealth,
58
59
  readPort,
60
+ readPrefs,
59
61
  removeStamp,
62
+ requiredJava,
63
+ resolveEdtVm,
60
64
  resolveWorkspaceTarget,
61
65
  stateOf as workspaceState,
66
+ tailFile,
62
67
  writePort,
63
68
  writeStamp,
64
- writeWorkspaceSnapshot,
65
69
  } from './workspace.mjs';
66
- import { cmdStop as stopWorkspaceNow } from './workspace-run.mjs';
70
+ import { cmdInit, cmdStop as stopWorkspaceNow, lockState } from './workspace-run.mjs';
67
71
 
68
72
  const HEADER = `## Работа с 1С через MCP
69
73
 
@@ -393,7 +397,20 @@ export async function apply(ctx, config) {
393
397
  const loggedErrors = new Set();
394
398
  let skillsControl;
395
399
 
396
- const cwdOf = (agent) => sessionCwd(agent, ctx.workingDirectory);
400
+ const originCwdOf = (agent) => sessionCwd(agent, ctx.workingDirectory);
401
+ const workCwdOf = (agent) => {
402
+ const session = agent?.session;
403
+ if (session === undefined) return originCwdOf(agent);
404
+ try {
405
+ const dir = ctx.workingDirectory?.get?.(session);
406
+ return typeof dir === 'string' && dir !== '' ? dir : originCwdOf(agent);
407
+ } catch {
408
+ return originCwdOf(agent);
409
+ }
410
+ };
411
+ // Контекст сессии — эффективный каталог, если это worktree того же проекта.
412
+ const cwdOf = (agent) =>
413
+ resolveContextCwd({ origin: originCwdOf(agent), work: workCwdOf(agent) });
397
414
 
398
415
  /** Уникальные пути в порядке появления (для слоёв настроек и allowlist). */
399
416
  const uniquePaths = (values) => {
@@ -448,7 +465,24 @@ export async function apply(ctx, config) {
448
465
  return { cwd, repositories, enabledRepositories, paths, effective, activation, workspace };
449
466
  };
450
467
 
451
- const contextFor = (agent) => projectContextForCwd(cwdOf(agent));
468
+ const contextFor = (agent) => {
469
+ const base = projectContextForCwd(cwdOf(agent));
470
+ const override = agent === undefined ? undefined : agentStates.get(agent)?.workspaceRoot;
471
+ if (override === undefined) return base;
472
+
473
+ const repo = repositoryAt(override);
474
+ const repositories =
475
+ repo === undefined
476
+ ? [{ root: override, mainRoot: base.workspace?.mainRoot ?? override, isWorktree: true }]
477
+ : [{ ...repo, isWorktree: true }];
478
+ const workspace = resolveWorkspaceTarget({
479
+ cwd: override,
480
+ config: base.effective.config,
481
+ repositories,
482
+ env: process.env,
483
+ });
484
+ return { ...base, cwd: override, repositories, workspace, workspaceOverride: true };
485
+ };
452
486
  const effectiveFor = (agent) => contextFor(agent).effective;
453
487
 
454
488
  /** Каталог, от которого считается корень BSL и относительный workspace.root. */
@@ -485,6 +519,7 @@ export async function apply(ctx, config) {
485
519
  phase: phase?.phase,
486
520
  since: phase?.since,
487
521
  detail: phase?.detail,
522
+ warning: phase?.warning,
488
523
  imported: current.imported?.projects?.length,
489
524
  settings: plan.settings,
490
525
  warnings: plan.warnings ?? [],
@@ -559,6 +594,11 @@ export async function apply(ctx, config) {
559
594
  if (entry.unavailable) value = 'unavailable';
560
595
  if (item?.fiber && !item.error) value = 'mounted';
561
596
  if (item?.error) value = 'failed';
597
+ // Пока EDT-окружение не готово, mounted с нулём инструментов выглядит
598
+ // как поломка: показываем фазу подъёма.
599
+ if (entry.name === 'edt' && item?.fiber && !item.error && workspace.state === 'worktree' && workspace.status !== 'ready') {
600
+ value = workspace.status === 'failed' || workspace.status === 'stale' ? 'failed' : 'starting';
601
+ }
562
602
  return {
563
603
  name: entry.name,
564
604
  state: value,
@@ -777,6 +817,25 @@ export async function apply(ctx, config) {
777
817
  return Date.now() - updated < budget;
778
818
  };
779
819
 
820
+ /** Убеждается, что фоновый run действительно начал работу (а не упал на lock). */
821
+ const launchStarted = async (buildDir, settings = {}, timeoutMs = 5000) => {
822
+ // Тот же срок устаревания, что в cmdInit/cmdRun: иначе lock, который они
823
+ // уже считают осиротевшим, здесь выглядел бы как живой запуск.
824
+ const maxAgeMs = (Math.max(settings.startTimeout ?? 1800, settings.importTimeout ?? 3600) + 600) * 1000;
825
+ const deadline = Date.now() + timeoutMs;
826
+ while (Date.now() < deadline) {
827
+ const current = workspaceState(buildDir, { alive: cheapAlive });
828
+ const phase = current.progress?.edt?.phase;
829
+ if (current.label === 'starting' || current.label === 'ready' || current.label === 'failed') return true;
830
+ if (phase !== undefined && phase !== 'ready' && phase !== 'failed') return true;
831
+ // run мог только взяться за lock, ещё не записав ни progress, ни pid.
832
+ const runLock = lockState(join(buildDir, 'init-run.lock'), { maxAgeMs });
833
+ if (runLock.exists === true && runLock.stale === false) return true;
834
+ await new Promise((resolve) => setTimeout(resolve, 250));
835
+ }
836
+ return false;
837
+ };
838
+
780
839
  /**
781
840
  * Гарантирует подъём окружения: занимает порт, пишет снимок и запускает
782
841
  * фоновый init. Готовое/стартующее окружение только принимается.
@@ -835,9 +894,23 @@ export async function apply(ctx, config) {
835
894
  settings: plan.settings,
836
895
  });
837
896
  if (options.import === true) workspaceSnapshot.forceImport = true;
838
- writeWorkspaceSnapshot(key, workspaceSnapshot);
897
+ const initLog = join(key, 'logs', 'init.log');
898
+ const code = await cmdInit(plan.root, {}, { snapshot: workspaceSnapshot });
899
+ if (code !== 0) {
900
+ const tail = tailFile(initLog);
901
+ return {
902
+ ...workspaceState(key, { alive: cheapAlive }),
903
+ launchError: `init завершился с кодом ${code}${tail === null ? '' : `: ${tail}`}`,
904
+ };
905
+ }
839
906
  record.startedByUs = true;
840
- spawnInitializer(plan.root, 'init');
907
+ if (!(await launchStarted(key, plan.settings))) {
908
+ const tail = tailFile(initLog);
909
+ return {
910
+ ...workspaceState(key, { alive: cheapAlive }),
911
+ launchError: `run не стартовал${tail === null ? '' : `: ${tail}`}`,
912
+ };
913
+ }
841
914
  return workspaceState(key, { alive: cheapAlive });
842
915
  } catch (error) {
843
916
  ctx.logger?.warn?.(`mcp-hub: окружение EDT не поднялось: ${error instanceof Error ? error.message : String(error)}`);
@@ -874,7 +947,8 @@ export async function apply(ctx, config) {
874
947
  // Окружение поднимается до монтирования edt: нужен порт для адреса.
875
948
  if (context.workspace.state === 'ok') {
876
949
  try {
877
- await ensureWorkspace(agent, context);
950
+ const launched = await ensureWorkspace(agent, context);
951
+ if (launched?.launchError !== undefined) ctx.logger?.warn?.(`mcp-hub: ${launched.launchError}`);
878
952
  } catch (error) {
879
953
  ctx.logger?.warn?.(`mcp-hub: окружение EDT не поднялось: ${error instanceof Error ? error.message : String(error)}`);
880
954
  }
@@ -894,6 +968,7 @@ export async function apply(ctx, config) {
894
968
  const state = agentStates.get(agent);
895
969
  if (state === undefined || !state.active) return;
896
970
  state.active = false;
971
+ state.workspaceRoot = undefined;
897
972
  for (const dispose of state.disposers.splice(0)) {
898
973
  try {
899
974
  dispose();
@@ -924,7 +999,8 @@ export async function apply(ctx, config) {
924
999
  }
925
1000
 
926
1001
  if (context.workspace.state === 'ok') {
927
- await ensureWorkspace(agent, context);
1002
+ const launched = await ensureWorkspace(agent, context);
1003
+ if (launched?.launchError !== undefined) ctx.logger?.warn?.(`mcp-hub: ${launched.launchError}`);
928
1004
  // Порт появился после init — адрес edt изменился, перемонтируем.
929
1005
  try {
930
1006
  await reload(agent, 'edt');
@@ -1084,13 +1160,35 @@ export async function apply(ctx, config) {
1084
1160
  }
1085
1161
  if (info.imported !== undefined) lines.push(`Импортировано проектов: ${info.imported}`);
1086
1162
  const home = detectEdtHome(info.settings, { platform: process.platform, env: process.env });
1087
- lines.push(
1088
- `EDT: ${home.home ?? 'не найден'}${home.error === undefined ? '' : ` — ${home.error}`}${home.mcpBundle === false ? ' (бандл EDT-MCP не найден в plugins/features)' : ''}`,
1089
- );
1163
+ const required = requiredJava(home.home, { platform: process.platform });
1164
+ const vm = resolveEdtVm(info.settings, home.home, { platform: process.platform, env: process.env });
1165
+ lines.push(`EDT: ${home.home ?? 'не найден'}${home.error === undefined ? '' : ` — ${home.error}`}`);
1166
+ lines.push(`Java: ${vm ?? 'авто'}${required.major === undefined ? '' : ` (EDT требует ${required.major})`}`);
1167
+ if (info.status !== 'ready') {
1168
+ if (home.mcpBundle === false) {
1169
+ lines.push('EDT-MCP: по файлам не подтверждён; готовность проверит /health');
1170
+ } else if (Array.isArray(home.mcpEvidence) && home.mcpEvidence.length > 0) {
1171
+ lines.push(`EDT-MCP: ${home.mcpEvidence[0]}`);
1172
+ }
1173
+ }
1090
1174
  const importMode = info.settings.import;
1091
1175
  lines.push(`Импорт проектов: ${importMode === true ? 'да' : importMode === false ? 'нет' : 'авто (если workspace пуст)'}`);
1176
+ lines.push(`Plain text: ${info.settings.plainText === false ? 'нет' : 'да'}; скриншоты форм: ${info.settings.formScreenshots === false ? 'нет' : 'да'}`);
1177
+ const prefsText = readPrefs(info.buildDir);
1178
+ const plainPref = prefsText === undefined ? undefined : prefValue(prefsText, 'mcpPlainTextMode');
1179
+ lines.push(
1180
+ `Plain text в prefs: ${prefsText === undefined ? 'файла ещё нет' : plainPref === undefined ? 'ключа нет' : plainPref === 'true' ? 'да' : 'нет'}`,
1181
+ );
1182
+ if (prefsText !== undefined && info.settings.plainText !== false && plainPref !== 'true') {
1183
+ lines.push('! prefs не содержат mcpPlainTextMode=true — выполните restart окружения');
1184
+ }
1185
+ if (info.warning !== undefined) {
1186
+ const warning = info.warning;
1187
+ const memory = warning.used === undefined ? '' : ` (${warning.used}/${warning.total} MB)`;
1188
+ lines.push(`! импорт шёл на пределе памяти${memory} — поднимите workspace.xmxImport`);
1189
+ }
1092
1190
  for (const warning of info.warnings) lines.push(`! ${warning}`);
1093
- lines.push(`Логи: ${join(info.buildDir, 'logs')}`);
1191
+ lines.push(`Логи: ${join(info.buildDir, 'logs')}; импорт: ${join(info.wsDir, '.metadata', '.log')}`);
1094
1192
  return lines.join('\n');
1095
1193
  };
1096
1194
 
@@ -1115,6 +1213,9 @@ export async function apply(ctx, config) {
1115
1213
  if (plan === undefined || plan.state !== 'ok') {
1116
1214
  throw new Error(`EDT-окружение недоступно: ${plan?.message ?? workspaceText(plan)}`);
1117
1215
  }
1216
+ // root=… не только поднимает окружение, но и становится контекстом
1217
+ // сессии: entriesFor подменит URL edt на порт этого workspace.
1218
+ if (args.root !== undefined && args.root !== '') stateOf(exec?.agent).workspaceRoot = plan.root;
1118
1219
  const record = registerWorkspaceSession(plan.buildDir, exec?.agent, plan.settings);
1119
1220
 
1120
1221
  if (action === 'stop') {
@@ -1137,6 +1238,16 @@ export async function apply(ctx, config) {
1137
1238
  const forceImport = args.import === true || action === 'import';
1138
1239
  const result = await ensureWorkspace(exec?.agent, { ...context, workspace: plan }, { force: true, import: forceImport });
1139
1240
  if (result === undefined) throw new Error('Не удалось запустить подъём окружения: смотрите логи build/logs.');
1241
+ if (result.launchError !== undefined) throw new Error(`${result.launchError}\n\n${workspaceText(plan)}`);
1242
+ if (result.label === 'failed') {
1243
+ const info = describeWorkspace(plan, { alive: cheapAlive });
1244
+ const warning = info.warning;
1245
+ const memory =
1246
+ warning === undefined
1247
+ ? ''
1248
+ : `\n! импорт шёл на пределе памяти${warning.used === undefined ? '' : ` (${warning.used}/${warning.total} MB)`} — поднимите workspace.xmxImport`;
1249
+ throw new Error(`EDT-окружение не поднялось: ${info.detail ?? 'см. build/logs/edt.log'}${memory}`);
1250
+ }
1140
1251
  if (result.label === 'ready') {
1141
1252
  return { text: `EDT уже готов (порт ${result.port ?? '-'}).\n\n${workspaceText(plan)}` };
1142
1253
  }
@@ -1181,6 +1292,11 @@ export async function apply(ctx, config) {
1181
1292
  const { errors, warnings } = validateConfig(effective.config);
1182
1293
  const activation = context.activation;
1183
1294
  const workspace = describeWorkspace(context.workspace, { alive: cheapAlive });
1295
+ const origin = originCwdOf(exec?.agent);
1296
+ const dirsLine =
1297
+ origin === context.cwd
1298
+ ? `Каталог: ${context.cwd}`
1299
+ : `Каталог: исходный ${origin}, рабочий ${context.cwd}${context.workspaceOverride === true ? ' (через edt_workspace root)' : ''}`;
1184
1300
  const workspaceLine =
1185
1301
  workspace.state === 'worktree'
1186
1302
  ? `EDT-окружение worktree: ${workspace.status}, порт ${workspace.port ?? '-'}, ${workspace.root}`
@@ -1189,6 +1305,7 @@ export async function apply(ctx, config) {
1189
1305
  : 'EDT-окружение worktree: выключено (поднимается только в git worktree)';
1190
1306
  const head = [
1191
1307
  `Проект: ${activation.enabled ? 'включён' : 'выключен'} — ${activationReason(activation)}`,
1308
+ dirsLine,
1192
1309
  `Источники: ${effective.sources.length > 0 ? effective.sources.join(', ') : 'только cordis.patch.yml'}`,
1193
1310
  workspaceLine,
1194
1311
  `Действующие настройки:\n${stringifyConfig(effective.config)}`,
package/lib/repo.mjs CHANGED
@@ -11,7 +11,7 @@
11
11
  import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from 'node:fs';
12
12
  import path from 'node:path';
13
13
 
14
- import { normalizeProjectPath } from './enable.mjs';
14
+ import { normalizeProjectPath, matchesProject } from './enable.mjs';
15
15
 
16
16
  /** Сколько родителей просматривать в поисках `.git`. */
17
17
  const MAX_UP = 8;
@@ -151,3 +151,35 @@ export function uniqueRepositories(repositories, { platform = process.platform }
151
151
  }
152
152
  return out;
153
153
  }
154
+
155
+ /**
156
+ * Каталог, от которого плагин считает проект.
157
+ *
158
+ * `origin` — неизменяемый `session.header.cwd`, `work` — эффективный каталог
159
+ * сессии (`ctx.workingDirectory`). Переключаемся на work только если это
160
+ * linked worktree, связанный с исходным репозиторием: `cd` в подкаталог или в
161
+ * чужой репозиторий проект не переключает, а worktree того же репозитория —
162
+ * переключает. Возвращается корень worktree, а не подкаталог, чтобы настройки
163
+ * и окружение не зависели от `cd` внутри него.
164
+ * @returns {string} абсолютный каталог проекта.
165
+ */
166
+ export function resolveContextCwd({ origin, work, platform = process.platform } = {}) {
167
+ if (typeof origin !== 'string' || origin === '') {
168
+ return typeof work === 'string' && work !== '' ? work : process.cwd();
169
+ }
170
+ if (typeof work !== 'string' || work === '' || isSamePath(work, origin, { platform })) return origin;
171
+
172
+ const workRepo = repositoryAt(work, { platform });
173
+ if (workRepo === undefined || workRepo.isWorktree !== true) return origin;
174
+
175
+ const originRepo = repositoryAt(origin, { platform });
176
+ if (originRepo === undefined) return origin;
177
+
178
+ const related =
179
+ isSamePath(workRepo.mainRoot, originRepo.root, { platform }) ||
180
+ isSamePath(workRepo.mainRoot, origin, { platform }) ||
181
+ matchesProject(originRepo.root, workRepo.mainRoot, { platform }) ||
182
+ matchesProject(origin, workRepo.mainRoot, { platform });
183
+
184
+ return related ? workRepo.root : origin;
185
+ }
package/lib/schema.mjs CHANGED
@@ -33,6 +33,10 @@ export const DEFAULTS = {
33
33
  projects: [],
34
34
  ports: { min: 20000, max: 39999 },
35
35
  stopOnSessionEnd: true,
36
+ // MCP-инструменты EDT-MCP, которые нам нужны в worktree: plain text
37
+ // вместо embedded resource (DSH их не рендерит) и скриншоты форм.
38
+ plainText: true,
39
+ formScreenshots: true,
36
40
  },
37
41
  tests: {
38
42
  yaxunit: { enabled: false, path: '', project: '' },
@@ -53,12 +57,24 @@ export const KNOWN = {
53
57
  'servers.*.url': { type: 'string', description: 'Адрес MCP-сервера (streamable-http).' },
54
58
  'servers.*.headers': { type: 'object', description: 'Заголовки запросов, например Authorization.' },
55
59
  'servers.*.root': { type: 'string', description: 'Корень рабочего пространства (для bsl).' },
60
+ 'servers.*.transport': { type: 'string', description: 'Транспорт: stdio или streamable-http.' },
61
+ 'servers.*.disabled': { type: 'boolean', description: 'Не монтировать сервер.' },
62
+ 'servers.*.unavailable': { type: 'boolean', description: 'Сервер описан, но не настроен (нет переменных окружения).' },
63
+ 'servers.*.command': { type: 'string', description: 'Команда запуска для stdio-сервера.' },
64
+ 'servers.*.args': { type: 'array', description: 'Аргументы команды stdio-сервера.' },
65
+ 'servers.*.env': { type: 'object', description: 'Переменные окружения stdio-сервера.' },
66
+ 'servers.*.cwd': { type: 'string', description: 'Рабочий каталог stdio-сервера.' },
67
+ 'servers.*.maxInstructionBytes': { type: 'number', description: 'Предел текста инструкций сервера.' },
56
68
  'servers.*.lazy': { type: 'boolean', description: 'Не подключать на старте, только по edt_pin.' },
57
69
  'servers.*.shared': { type: 'boolean', description: 'Устарело: серверы всегда монтируются в скоупе сессии.' },
58
70
  'servers.*.provision': { type: 'boolean', description: 'Параметры запуска добираются на edt_pin.' },
59
71
  'servers.*.toolCallTimeoutMs': { type: 'number', description: 'Таймаут вызова инструмента.' },
60
72
  'servers.*.failOnStartupError': { type: 'boolean', description: 'Падать, если стартовое подключение не удалось.' },
61
73
  'servers.*.reconnect': { type: 'object', description: 'Политика переподключения.' },
74
+ 'servers.*.reconnect.enabled': { type: 'boolean', description: 'Включено ли переподключение.' },
75
+ 'servers.*.reconnect.initialDelayMs': { type: 'number', description: 'Начальная задержка переподключения, мс.' },
76
+ 'servers.*.reconnect.maxDelayMs': { type: 'number', description: 'Максимальная задержка переподключения, мс.' },
77
+ 'servers.*.reconnect.maxAttempts': { type: 'number', description: 'Максимум попыток переподключения.' },
62
78
  'servers.bsl.jar': { type: 'string', description: 'Готовый jar BSL Language Server.' },
63
79
  'servers.bsl.url': { type: 'string', description: 'Прямой URL на jar вместо GitHub.' },
64
80
  'servers.bsl.version': { type: 'string', description: 'Тег релиза BSL LS, например v1.0.7.' },
@@ -86,6 +102,8 @@ export const KNOWN = {
86
102
  'workspace.ports.min': { type: 'number', description: 'Нижняя граница диапазона портов.' },
87
103
  'workspace.ports.max': { type: 'number', description: 'Верхняя граница диапазона портов.' },
88
104
  'workspace.stopOnSessionEnd': { type: 'boolean', description: 'Останавливать EDT, когда закрылась последняя сессия на этом worktree.' },
105
+ 'workspace.plainText': { type: 'boolean', description: 'Plain text mode EDT-MCP вместо embedded resource (DSH их не рендерит).' },
106
+ 'workspace.formScreenshots': { type: 'boolean', description: 'Включить get_form_screenshot и JVM-флаг nativeFormBufferedLayoutRender.' },
89
107
  'tests.yaxunit.enabled': { type: 'boolean', description: 'Включить правила тестируемости YAxUnit.' },
90
108
  'tests.yaxunit.path': { type: 'string', description: 'Каталог тестов как образец стиля.' },
91
109
  'tests.yaxunit.project': { type: 'string', description: 'EDT-проект, в который писать тесты.' },
@@ -217,6 +235,15 @@ export function validateConfig(config) {
217
235
  errors.push('projects: ожидался массив строк');
218
236
  }
219
237
 
238
+ if (isPlainObject(config.servers)) {
239
+ for (const [name, server] of Object.entries(config.servers)) {
240
+ if (!isPlainObject(server)) continue;
241
+ if (Array.isArray(server.args) && !server.args.every((item) => typeof item === 'string')) {
242
+ errors.push(`servers.${name}.args: ожидался массив строк`);
243
+ }
244
+ }
245
+ }
246
+
220
247
  if (isPlainObject(config.workspace)) {
221
248
  const workspace = config.workspace;
222
249
  if (workspace.import !== undefined && ![true, false, 'auto'].includes(workspace.import)) {
@@ -14,12 +14,13 @@
14
14
  // Все зависимости, которые трогают процессы и время, передаются через `deps` —
15
15
  // так сценарии проверяются без запуска настоящего EDT.
16
16
  import { execFile, execFileSync, spawn } from 'node:child_process';
17
- import { closeSync, mkdirSync, openSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
17
+ import { closeSync, mkdirSync, openSync, readdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
18
18
  import { promisify } from 'node:util';
19
19
  import path from 'node:path';
20
20
  import { fileURLToPath, pathToFileURL } from 'node:url';
21
21
 
22
22
  import {
23
+ appendImportDigest,
23
24
  buildWorkspaceSnapshot,
24
25
  clearProgress,
25
26
  detectEdtHome,
@@ -28,6 +29,8 @@ import {
28
29
  formatElapsed,
29
30
  guiArgs,
30
31
  importArgs,
32
+ importDigest,
33
+ importMemoryWarning,
31
34
  importScriptText,
32
35
  isPortFree,
33
36
  killTree,
@@ -40,6 +43,7 @@ import {
40
43
  readPort,
41
44
  readWorkspaceSnapshot,
42
45
  registeredProjectsDir,
46
+ requiredJava,
43
47
  resolveEdtVm,
44
48
  stateOf,
45
49
  tailFile,
@@ -134,25 +138,70 @@ function lockInfo(file) {
134
138
  }
135
139
  }
136
140
 
137
- /** Захватывает lock; чужой живой lock — сигнал «уже идёт». */
141
+ /** Сколько живёт lock без обновления, если владелец не отвечает. */
142
+ const LOCK_MAX_AGE_MS = 6 * 60 * 60_000;
143
+
144
+ /** Состояние lock для диагностики: живой ли pid, возраст, просрочен ли. */
145
+ export function lockState(file, deps = {}) {
146
+ const info = lockInfo(file);
147
+ if (info === null) return { exists: false };
148
+ const pid = Number.parseInt(info.pid, 10);
149
+ const at = Date.parse(info.at ?? '');
150
+ const ageMs = Number.isNaN(at) ? undefined : Math.max(0, Date.now() - at);
151
+ const maxAgeMs = deps.maxAgeMs ?? LOCK_MAX_AGE_MS;
152
+ const alive = deps.alive ?? ((value) => pidAlive(value));
153
+ const live = Number.isInteger(pid) && pid > 0 && alive(pid);
154
+ return {
155
+ exists: true,
156
+ pid: Number.isInteger(pid) ? pid : undefined,
157
+ at: Number.isNaN(at) ? undefined : new Date(at).toISOString(),
158
+ ageMs,
159
+ stale: !live || (ageMs !== undefined && ageMs > maxAgeMs),
160
+ };
161
+ }
162
+
163
+ /**
164
+ * Захватывает lock. Чужой lock считается stale, если pid не жив, образ не
165
+ * совпадает (`image`) или возраст больше `maxAgeMs`; stale перехватывается.
166
+ */
138
167
  export function acquireLock(file, deps = {}) {
139
168
  mkdirSync(path.dirname(file), { recursive: true });
140
169
  const alive = deps.alive ?? ((pid) => pidAlive(pid));
141
- const existing = lockInfo(file);
142
- if (existing?.pid !== undefined) {
143
- if (alive(existing.pid)) return false;
170
+ const image = deps.image;
171
+ const maxAgeMs = deps.maxAgeMs ?? LOCK_MAX_AGE_MS;
172
+ const now = deps.now?.() ?? new Date();
173
+
174
+ for (let attempt = 0; attempt < 3; attempt++) {
175
+ const existing = lockInfo(file);
176
+ if (existing !== null) {
177
+ const pid = Number.parseInt(existing.pid, 10);
178
+ const at = Date.parse(existing.at ?? '');
179
+ const tooOld = !Number.isNaN(at) && now.getTime() - at > maxAgeMs;
180
+ const live = Number.isInteger(pid) && pid > 0 && alive(pid);
181
+ const sameImage = live && (image === undefined || pidAlive(pid, [image]));
182
+ if (live && sameImage && !tooOld) return false;
183
+ try {
184
+ unlinkSync(file);
185
+ } catch {
186
+ // lock мог исчезнуть между чтением и удалением
187
+ }
188
+ }
144
189
  try {
145
- unlinkSync(file);
190
+ writeFileSync(
191
+ file,
192
+ JSON.stringify({
193
+ pid: process.pid,
194
+ at: now.toISOString(),
195
+ image: path.basename(process.execPath),
196
+ }),
197
+ { flag: 'wx' },
198
+ );
199
+ return true;
146
200
  } catch {
147
- // lock мог уже исчезнуть
201
+ // кто-то успел создать lock — повторим проверку
148
202
  }
149
203
  }
150
- try {
151
- writeFileSync(file, JSON.stringify({ pid: process.pid, at: new Date().toISOString() }), { flag: 'wx' });
152
- return true;
153
- } catch {
154
- return false;
155
- }
204
+ return false;
156
205
  }
157
206
 
158
207
  export function releaseLock(file) {
@@ -233,25 +282,54 @@ export async function cmdInit(root, args = {}, deps = {}) {
233
282
  mkdirSync(buildDir, { recursive: true });
234
283
  writeWorkspaceSnapshot(buildDir, snapshot);
235
284
 
236
- const state = stateOf(buildDir, { alive: deps.alive });
237
- if (state.label === 'ready' || state.label === 'starting') {
238
- logTo(buildDir, `init: окружение уже ${state.label === 'ready' ? 'готово' : 'поднимается'}, делать нечего`);
239
- return 0;
240
- }
241
-
242
285
  const lock = filePath(buildDir, 'lock');
243
286
  if (!acquireLock(lock, deps)) {
244
287
  logTo(buildDir, 'init: другой процесс уже занимает порт, выходим');
245
288
  return 0;
246
289
  }
247
290
  try {
291
+ // Осиротевший run-lock не должен блокировать повторный init.
292
+ const runLock = filePath(buildDir, 'runLock');
293
+ const maxAgeMs = (Math.max(snapshot.settings.startTimeout, snapshot.settings.importTimeout) + 600) * 1000;
294
+ const running = lockState(runLock, { ...deps, maxAgeMs });
295
+ if (running.exists && running.stale !== true) {
296
+ logTo(buildDir, `init: run уже идёт (pid ${running.pid ?? '?'}, возраст ${running.ageMs === undefined ? '?' : formatElapsed(new Date(Date.now() - running.ageMs).toISOString())})`);
297
+ return 4;
298
+ }
299
+ if (running.exists) {
300
+ try {
301
+ unlinkSync(runLock);
302
+ } catch {
303
+ // lock уже исчез
304
+ }
305
+ logTo(buildDir, 'init: снят осиротевший run-lock');
306
+ }
307
+
308
+ const state = stateOf(buildDir, { alive: deps.alive });
309
+ if (state.label === 'ready') {
310
+ logTo(buildDir, 'init: окружение уже готово, делать нечего');
311
+ return 0;
312
+ }
313
+ const alive = deps.alive ?? ((pid) => pidAlive(pid, ['1cedt']));
314
+ if (state.label === 'starting' && state.pid !== null && alive(state.pid.pid)) {
315
+ logTo(buildDir, 'init: окружение уже поднимается, делать нечего');
316
+ return 0;
317
+ }
318
+ if (state.label === 'starting') {
319
+ // Осталась только запись прогресса от упавшего run: начинаем заново.
320
+ clearProgress(buildDir);
321
+ }
322
+
248
323
  let port = readPort(buildDir);
249
324
  const free = deps.isPortFree ?? isPortFree;
250
325
  if (port === null || !(await free(port))) {
251
326
  port = await (deps.pickFreePort ?? pickFreePort)(snapshot.settings.ports, { isFree: free });
252
327
  writePort(buildDir, port);
253
328
  }
254
- const prefs = writePrefs(buildDir, port);
329
+ const prefs = writePrefs(buildDir, port, {
330
+ plainText: snapshot.settings.plainText,
331
+ formScreenshots: snapshot.settings.formScreenshots,
332
+ });
255
333
  logTo(buildDir, `init: порт ${port}, prefs ${path.relative(root, prefs)}, запускаю run в фоне`);
256
334
  const logPath = path.join(buildDir, 'logs', 'init.log');
257
335
  (deps.spawnDetached ?? spawnDetached)(process.execPath, [SELF, 'run', '--root', root], logPath, root, deps);
@@ -277,9 +355,11 @@ export async function cmdRun(root, args = {}, deps = {}) {
277
355
  mkdirSync(path.join(buildDir, 'logs'), { recursive: true });
278
356
 
279
357
  const lock = filePath(buildDir, 'runLock');
280
- if (!acquireLock(lock, deps)) {
281
- log('run: другой запуск уже идёт, выходим');
282
- return 0;
358
+ const maxAgeMs = (Math.max(settings.startTimeout, settings.importTimeout) + 600) * 1000;
359
+ if (!acquireLock(lock, { ...deps, maxAgeMs, image: path.basename(process.execPath) })) {
360
+ const holder = lockState(lock, { ...deps, maxAgeMs });
361
+ log(`run: другой запуск уже идёт (pid ${holder.pid ?? '?'}, возраст ${holder.ageMs === undefined ? '?' : `${Math.round(holder.ageMs / 1000)}s`}), выходим`);
362
+ return 2;
283
363
  }
284
364
 
285
365
  try {
@@ -297,7 +377,11 @@ export async function cmdRun(root, args = {}, deps = {}) {
297
377
  return 1;
298
378
  }
299
379
  if (detected.mcpBundle !== true) {
300
- log('edt: предупреждение — в установке не найден бандл EDT-MCP (проверьте plugins/features)');
380
+ // Это только файловая эвристика: бандл может стоять в пользовательской
381
+ // configuration area, а готовность подтверждает /health.
382
+ log('edt: EDT-MCP не подтверждён по файлам; готовность проверит /health');
383
+ } else if (Array.isArray(detected.mcpEvidence) && detected.mcpEvidence.length > 0) {
384
+ log(`edt: EDT-MCP найден: ${detected.mcpEvidence[0]}`);
301
385
  }
302
386
 
303
387
  const port = readPort(buildDir);
@@ -315,14 +399,37 @@ export async function cmdRun(root, args = {}, deps = {}) {
315
399
  log('edt: FAILED: в установке нет 1cedtcli, импорт проектов невозможен');
316
400
  return 1;
317
401
  }
402
+
403
+ let importWarning;
404
+ let lastImportDetail;
318
405
  if (importNeeded) {
406
+ const metadataLog = path.join(snapshot.wsDir, '.metadata', '.log');
407
+ let offset = 0;
408
+ try {
409
+ offset = statSync(metadataLog).size;
410
+ } catch {
411
+ offset = 0;
412
+ }
413
+ const total = snapshot.projects.length;
319
414
  const scriptPath = writeImportScript(buildDir, snapshot.projects);
320
- writeProgress(buildDir, 'edt', 'importing', tailFile(path.join(buildDir, 'logs', 'edt-import.log')));
321
- log(`edt: импорт ${snapshot.projects.length} проект(ов) в ${path.relative(root, snapshot.wsDir)} (xmx ${settings.xmxImport}, timeout ${settings.importTimeout}s)`);
322
- const ticker = setInterval(() => {
323
- const detail = tailFile(path.join(buildDir, 'logs', 'edt-import.log'));
324
- if (detail) writeProgress(buildDir, 'edt', 'importing', detail);
325
- }, 10_000);
415
+ const seenProjects = new Set();
416
+ let lastProject;
417
+ const tick = () => {
418
+ const digest = (deps.importDigest ?? importDigest)(buildDir, offset, total, deps);
419
+ offset = digest.offset;
420
+ (deps.appendImportDigest ?? appendImportDigest)(buildDir, digest.lines);
421
+ const memory = importMemoryWarning(digest.text);
422
+ if (memory !== undefined) importWarning = memory;
423
+ for (const name of digest.started ?? []) seenProjects.add(name);
424
+ if (digest.current !== undefined) lastProject = digest.current;
425
+ const count = total > 0 ? Math.min(seenProjects.size, total) : seenProjects.size;
426
+ lastImportDetail = `${count}/${total}${lastProject === undefined ? '' : ` — ${lastProject}`}`;
427
+ writeProgress(buildDir, 'edt', 'importing', lastImportDetail, deps.now?.(), memory === undefined ? undefined : { warning: memory });
428
+ };
429
+ lastImportDetail = `0/${total}`;
430
+ writeProgress(buildDir, 'edt', 'importing', lastImportDetail, deps.now?.());
431
+ log(`edt: импорт ${total} проект(ов) в ${path.relative(root, snapshot.wsDir)} (xmx ${settings.xmxImport}, timeout ${settings.importTimeout}s)`);
432
+ const ticker = setInterval(tick, deps.importPollMs ?? 5000);
326
433
  ticker.unref?.();
327
434
  try {
328
435
  await (deps.runCapture ?? runCapture)(
@@ -333,15 +440,20 @@ export async function cmdRun(root, args = {}, deps = {}) {
333
440
  );
334
441
  } catch (error) {
335
442
  clearInterval(ticker);
336
- const tail = tailFile(path.join(buildDir, 'logs', 'edt-import.log'));
337
- writeProgress(buildDir, 'edt', 'import-failed', `${tail ?? error?.message ?? error}; см. build/logs/edt-import.log`);
443
+ tick();
444
+ const detail = `импорт не удался (${lastImportDetail}): ${error?.message ?? error}; см. build/ws/.metadata/.log`;
445
+ writeProgress(buildDir, 'edt', 'import-failed', detail, deps.now?.(), importWarning === undefined ? undefined : { warning: importWarning });
338
446
  writeStamp(buildDir, 'failed', deps.now?.());
339
- log(`edt: FAILED: импорт не удался — ${error?.message ?? error}; лог build/logs/edt-import.log`);
447
+ log(`edt: FAILED: ${detail}`);
340
448
  return 1;
341
449
  }
342
450
  clearInterval(ticker);
451
+ tick();
343
452
  writeImported(buildDir, snapshot.projects, deps.now?.());
344
- log('edt: импорт завершён');
453
+ log(`edt: импорт завершён (${lastImportDetail})`);
454
+ if (importWarning !== undefined) {
455
+ log(`edt: импорт шёл на пределе памяти (${importWarning.used ?? '?'}/${importWarning.total ?? '?'} MB) — поднимите workspace.xmxImport`);
456
+ }
345
457
  if (snapshot.forceImport === true) {
346
458
  const { forceImport, ...rest } = snapshot;
347
459
  writeWorkspaceSnapshot(buildDir, rest);
@@ -350,16 +462,26 @@ export async function cmdRun(root, args = {}, deps = {}) {
350
462
  log('edt: импорт не требуется');
351
463
  }
352
464
 
353
- writePrefs(buildDir, port);
465
+ writePrefs(buildDir, port, {
466
+ plainText: settings.plainText,
467
+ formScreenshots: settings.formScreenshots,
468
+ });
354
469
  const runningPid = readPid(buildDir)?.pid;
355
470
  if (runningPid !== undefined && (deps.alive ?? ((pid) => pidAlive(pid, ['1cedt'])))(runningPid)) {
356
471
  log(`edt: уже запущен (pid ${runningPid})`);
357
472
  } else {
358
473
  const vm = (deps.resolveEdtVm ?? resolveEdtVm)(settings, detected.home, deps);
474
+ const required = (deps.requiredJava ?? requiredJava)(detected.home, { platform: process.platform });
475
+ log(`edt: Java ${vm ?? 'авто'}${required.major === undefined ? '' : ` (EDT требует ${required.major})`}`);
359
476
  writeProgress(buildDir, 'edt', 'gui');
360
477
  const gui = (deps.spawnDetached ?? spawnDetached)(
361
478
  detected.bin,
362
- guiArgs({ wsDir: snapshot.wsDir, xmx: settings.xmxIde, vm }),
479
+ guiArgs({
480
+ wsDir: snapshot.wsDir,
481
+ xmx: settings.xmxIde,
482
+ vm,
483
+ nativeFormBufferedLayoutRender: settings.formScreenshots,
484
+ }),
363
485
  path.join(buildDir, 'logs', 'edt.log'),
364
486
  root,
365
487
  deps,
@@ -377,17 +499,30 @@ export async function cmdRun(root, args = {}, deps = {}) {
377
499
  removeStamp(buildDir, 'ready');
378
500
  writeProgress(buildDir, 'edt', 'health');
379
501
  log(`edt: жду /health на порту ${port} (до ${Math.round(settings.startTimeout / 60)} мин)`);
380
- const ready = await (deps.waitHealth ?? waitHealth)(port, settings.startTimeout * 1000, deps);
381
- if (ready) {
382
- writeProgress(buildDir, 'edt', 'ready');
502
+ const ready = await (deps.waitReady ?? waitReady)(
503
+ {
504
+ port,
505
+ pid: readPid(buildDir)?.pid,
506
+ alive: (value) => (deps.alive ?? ((pid) => pidAlive(pid, ['1cedt'])))(value),
507
+ logPath: path.join(buildDir, 'logs', 'edt.log'),
508
+ timeoutMs: settings.startTimeout * 1000,
509
+ },
510
+ deps,
511
+ );
512
+ if (ready.ok === true) {
513
+ writeProgress(buildDir, 'edt', 'ready', undefined, deps.now?.(), importWarning === undefined ? undefined : { warning: importWarning });
383
514
  writeStamp(buildDir, 'ready', deps.now?.());
384
515
  removeStamp(buildDir, 'failed');
385
516
  log('edt: READY');
386
517
  return 0;
387
518
  }
388
- writeProgress(buildDir, 'edt', 'failed', 'health timeout');
519
+ const detail =
520
+ ready.reason === 'process-exited'
521
+ ? `процесс EDT завершился${ready.detail === undefined ? '' : `: ${ready.detail}`}`
522
+ : `health timeout${ready.detail === undefined ? '' : `: ${ready.detail}`}`;
523
+ writeProgress(buildDir, 'edt', 'failed', detail, deps.now?.(), importWarning === undefined ? undefined : { warning: importWarning });
389
524
  writeStamp(buildDir, 'failed', deps.now?.());
390
- log('edt: FAILED: health timeout');
525
+ log(`edt: FAILED: ${detail}`);
391
526
  return 1;
392
527
  } finally {
393
528
  releaseLock(lock);
@@ -413,6 +548,33 @@ export async function waitHealth(port, timeoutMs, deps = {}) {
413
548
  return false;
414
549
  }
415
550
 
551
+ /**
552
+ * Ждёт готовности EDT и замечает смерть процесса: если pid умер, а /health
553
+ * молчит grace-период, возвращает причину сразу, не выжидая весь startTimeout.
554
+ * @returns {Promise<{ ok: boolean, reason?: 'process-exited'|'timeout', detail?: string }>}
555
+ */
556
+ export async function waitReady({ port, pid, alive, logPath, timeoutMs }, deps = {}) {
557
+ const probe = deps.probeHealth ?? probeHealth;
558
+ const delay = deps.sleep ?? sleep;
559
+ const pollMs = deps.pollMs ?? HEALTH_POLL_MS;
560
+ const graceMs = deps.processGraceMs ?? 15_000;
561
+ const deadline = Date.now() + timeoutMs;
562
+ let deadSince;
563
+ const detail = () => (typeof logPath === 'string' ? tailFile(logPath, 300) ?? undefined : undefined);
564
+
565
+ while (Date.now() < deadline) {
566
+ if (await probe(port, deps)) return { ok: true };
567
+ if (pid !== undefined && typeof alive === 'function' && !alive(pid)) {
568
+ deadSince ??= Date.now();
569
+ if (Date.now() - deadSince >= graceMs) return { ok: false, reason: 'process-exited', detail: detail() };
570
+ } else {
571
+ deadSince = undefined;
572
+ }
573
+ await delay(pollMs);
574
+ }
575
+ return { ok: false, reason: 'timeout', detail: detail() };
576
+ }
577
+
416
578
  /** Останавливает EDT этого worktree. */
417
579
  export async function cmdStop(root, deps = {}) {
418
580
  const buildDir = path.join(root, 'build');
@@ -471,6 +633,13 @@ export function statusReport(root, deps = {}) {
471
633
  `workspace: ${snapshot?.wsDir ?? path.join(buildDir, 'ws')}`,
472
634
  `порт: ${state.port ?? '-'} pid: ${state.pid?.pid ?? '-'}`,
473
635
  ];
636
+ const settings = snapshot?.settings ?? normalizeWorkspaceSettings({});
637
+ const home = detectEdtHome(settings, { platform: process.platform, env: process.env });
638
+ const required = requiredJava(home.home, { platform: process.platform });
639
+ const vm = resolveEdtVm(settings, home.home, { platform: process.platform, env: process.env });
640
+ lines.push(`EDT: ${home.home ?? 'не найден'}`);
641
+ lines.push(`Java: ${vm ?? 'авто'}${required.major === undefined ? '' : ` (EDT требует ${required.major})`}`);
642
+ lines.push(`Plain text: ${settings.plainText === false ? 'нет' : 'да'}; скриншоты форм: ${settings.formScreenshots === false ? 'нет' : 'да'}`);
474
643
  if (progress?.phase) {
475
644
  lines.push(`фаза: ${progress.phase}${progress.since ? ` (${formatElapsed(progress.since)})` : ''}${progress.detail ? ` — ${progress.detail}` : ''}`);
476
645
  }
package/lib/workspace.mjs CHANGED
@@ -10,17 +10,22 @@
10
10
  // поднятое opencode в том же worktree, плагин принимает как готовое.
11
11
  import { execFileSync } from 'node:child_process';
12
12
  import {
13
+ appendFileSync,
14
+ closeSync,
13
15
  existsSync,
14
16
  mkdirSync,
17
+ openSync,
15
18
  readFileSync,
16
19
  readdirSync,
17
20
  readlinkSync,
21
+ readSync,
18
22
  renameSync,
19
23
  rmSync,
20
24
  statSync,
21
25
  writeFileSync,
22
26
  } from 'node:fs';
23
27
  import { createServer, connect } from 'node:net';
28
+ import { homedir } from 'node:os';
24
29
  import path from 'node:path';
25
30
 
26
31
  import { DEFAULTS } from './schema.mjs';
@@ -115,6 +120,8 @@ export function normalizeWorkspaceSettings(raw = {}, env = process.env) {
115
120
  projects: Array.isArray(value.projects) ? value.projects.filter((item) => text(item) !== undefined).map((item) => item.trim()) : [],
116
121
  ports: min <= max ? { min, max } : { min: Math.min(min, max), max: Math.max(min, max) },
117
122
  stopOnSessionEnd: pick('stopOnSessionEnd', defaults.stopOnSessionEnd) !== false,
123
+ plainText: pick('plainText', defaults.plainText) !== false,
124
+ formScreenshots: pick('formScreenshots', defaults.formScreenshots) !== false,
118
125
  };
119
126
  }
120
127
 
@@ -318,12 +325,60 @@ export function mergePrefsText(current, values = {}) {
318
325
  return out;
319
326
  }
320
327
 
328
+ /** Значения инструментов, отключённых в EDT-MCP по умолчанию. */
329
+ export const DEFAULT_DISABLED_TOOLS = ['git', 'ask_workmate', 'get_form_screenshot'];
330
+
331
+ /** Значение ключа prefs (первое вхождение). */
332
+ export function prefValue(text, key) {
333
+ for (const line of String(text ?? '').split(/\r?\n/)) {
334
+ const index = line.indexOf('=');
335
+ if (index > 0 && line.slice(0, index) === key) return line.slice(index + 1);
336
+ }
337
+ return undefined;
338
+ }
339
+
340
+ /** Список отключённых инструментов из prefs (или дефолт EDT-MCP). */
341
+ function disabledToolsList(current) {
342
+ const raw = prefValue(current, 'mcpDisabledTools');
343
+ return (raw === undefined ? DEFAULT_DISABLED_TOOLS : raw.split(','))
344
+ .map((item) => item.trim())
345
+ .filter((item) => item !== '');
346
+ }
347
+
348
+ function disabledToolsWithout(current, tool) {
349
+ return [...new Set(disabledToolsList(current).filter((item) => item !== tool))].join(',');
350
+ }
351
+
352
+ function disabledToolsWith(current, tool) {
353
+ return [...new Set([...disabledToolsList(current), tool])].join(',');
354
+ }
355
+
356
+ /** Содержимое prefs EDT-MCP этого workspace (undefined — файла нет). */
357
+ export function readPrefs(buildDir) {
358
+ return readText(prefsPath(buildDir));
359
+ }
360
+
321
361
  /** prefs, включающие MCP-сервер EDT на нужном порту при старте. */
322
- export function writePrefs(buildDir, port) {
362
+ export function writePrefs(buildDir, port, options = {}) {
323
363
  const file = prefsPath(buildDir);
324
364
  mkdirSync(path.dirname(file), { recursive: true });
325
365
  const current = existsSync(file) ? readFileSync(file, 'utf8') : '';
326
- const next = mergePrefsText(current, { mcpServerAutoStart: 'true', mcpServerPort: port });
366
+ const values = {
367
+ mcpServerHttpEnabled: 'true',
368
+ mcpServerAutoStart: 'true',
369
+ mcpServerPort: port,
370
+ // DSH не рендерит embedded resource: markdown-инструменты EDT-MCP должны
371
+ // отдавать текст (Plain text mode).
372
+ mcpPlainTextMode: String(options.plainText !== false),
373
+ };
374
+ // get_form_screenshot выключен в EDT-MCP по умолчанию. Список всегда
375
+ // приводим к настройке: при true убираем инструмент, при false — возвращаем,
376
+ // иначе prefs от прошлого запуска оставят его включённым.
377
+ values.mcpDisabledTools =
378
+ options.formScreenshots !== false
379
+ ? disabledToolsWithout(current, 'get_form_screenshot')
380
+ : disabledToolsWith(current, 'get_form_screenshot');
381
+ const next = mergePrefsText(current, values);
327
382
  atomicWrite(file, next);
328
383
  return file;
329
384
  }
@@ -472,12 +527,17 @@ export function readProgress(buildDir) {
472
527
  return readJson(filePath(buildDir, 'progress')) ?? null;
473
528
  }
474
529
 
475
- export function writeProgress(buildDir, kind, phase, detail) {
476
- const prev = readProgress(buildDir) ?? { startedAt: new Date().toISOString() };
477
- const now = new Date().toISOString();
530
+ /**
531
+ * @param now момент записи (для тестов передаётся явно: `since` не должен
532
+ * зависеть от разрешения часов).
533
+ */
534
+ export function writeProgress(buildDir, kind, phase, detail, now = new Date(), extra = undefined) {
535
+ const prev = readProgress(buildDir) ?? { startedAt: now.toISOString() };
536
+ const stamp = now.toISOString();
478
537
  const was = prev[kind];
479
- prev[kind] = { phase, since: was?.phase === phase && was.since ? was.since : now, updated: now };
538
+ prev[kind] = { phase, since: was?.phase === phase && was.since ? was.since : stamp, updated: stamp };
480
539
  if (detail !== undefined) prev[kind].detail = detail;
540
+ if (extra !== undefined && typeof extra === 'object') Object.assign(prev[kind], extra);
481
541
  atomicWrite(filePath(buildDir, 'progress'), `${JSON.stringify(prev, null, 2)}\n`);
482
542
  }
483
543
 
@@ -499,6 +559,111 @@ export function tailFile(file, maxLen = 200) {
499
559
  return lines.length === 0 ? null : lines[lines.length - 1].slice(0, maxLen);
500
560
  }
501
561
 
562
+ /** Длина буфера без обрезанной многобайтовой UTF-8 последовательности. */
563
+ function completeUtf8Length(buffer) {
564
+ let index = buffer.length - 1;
565
+ let continuation = 0;
566
+ while (index >= 0 && (buffer[index] & 0xc0) === 0x80 && continuation < 3) {
567
+ index -= 1;
568
+ continuation += 1;
569
+ }
570
+ if (index < 0) return buffer.length;
571
+ const lead = buffer[index];
572
+ let needed;
573
+ if ((lead & 0x80) === 0) needed = 1;
574
+ else if ((lead & 0xe0) === 0xc0) needed = 2;
575
+ else if ((lead & 0xf0) === 0xe0) needed = 3;
576
+ else if ((lead & 0xf8) === 0xf0) needed = 4;
577
+ else return buffer.length;
578
+ const have = buffer.length - index;
579
+ return have >= needed ? buffer.length : index;
580
+ }
581
+
582
+ /** Читает файл с байтового смещения (для растущего workspace `.metadata/.log`). */
583
+ export function readFromOffset(file, offset, { maxBytes = 512 * 1024 } = {}) {
584
+ let fd;
585
+ try {
586
+ fd = openSync(file, 'r');
587
+ const size = statSync(file).size;
588
+ const start = Math.max(0, Math.min(offset ?? 0, size));
589
+ const length = Math.min(Math.max(0, size - start), maxBytes);
590
+ if (length === 0) return { text: '', offset: size };
591
+ const buffer = Buffer.alloc(length);
592
+ readSync(fd, buffer, 0, length, start);
593
+ const complete = completeUtf8Length(buffer);
594
+ return {
595
+ text: buffer.subarray(0, complete).toString('utf8'),
596
+ // Следующий вызов дочитает остаток, а не пропустит его.
597
+ offset: start + complete,
598
+ };
599
+ } catch {
600
+ return { text: '', offset: offset ?? 0 };
601
+ } finally {
602
+ if (fd !== undefined) closeSync(fd);
603
+ }
604
+ }
605
+
606
+ const PROJECT_STARTED = /Project context is started:\s*(.+?)\s*$/;
607
+ const PROJECT_STARTING = /Project context is being started:\s*(.+?)(?:\s+\([A-Z_]+\))?\s*$/;
608
+
609
+ /**
610
+ * Прогресс импорта по `build/ws/.metadata/.log`: сколько проектов стартовало
611
+ * и какой проект сейчас в работе. Счётчик ограничен `total`.
612
+ */
613
+ export function parseImportProgress(text, total = 0) {
614
+ const started = new Set();
615
+ let lastStarted;
616
+ let lastStarting;
617
+ for (const line of String(text ?? '').split(/\r?\n/)) {
618
+ const startedMatch = PROJECT_STARTED.exec(line);
619
+ if (startedMatch !== null) {
620
+ lastStarted = startedMatch[1].trim();
621
+ started.add(lastStarted);
622
+ continue;
623
+ }
624
+ const startingMatch = PROJECT_STARTING.exec(line);
625
+ if (startingMatch !== null) lastStarting = startingMatch[1].trim();
626
+ }
627
+ const count = total > 0 ? Math.min(started.size, total) : started.size;
628
+ const current = lastStarting !== undefined && !started.has(lastStarting) ? lastStarting : lastStarted;
629
+ return { started: [...started], startedCount: count, total, current };
630
+ }
631
+
632
+ /** Свежий кусок metadata-лога, прогресс импорта и полезные строки. */
633
+ export function importDigest(buildDir, offset, total, deps = {}) {
634
+ const file = path.join(buildDir, 'ws', '.metadata', '.log');
635
+ const { text, offset: next } = (deps.readFromOffset ?? readFromOffset)(file, offset);
636
+ const progress = parseImportProgress(text, total);
637
+ const lines = text
638
+ .split(/\r?\n/)
639
+ .filter((line) => /Project context|CPU overload|Memory \d+ MB|!ERROR|Exception/.test(line));
640
+ return { ...progress, offset: next, lines, text };
641
+ }
642
+
643
+ /** Копирует значимые строки импорта в build/logs/edt-import.log. */
644
+ export function appendImportDigest(buildDir, lines) {
645
+ if (!Array.isArray(lines) || lines.length === 0) return;
646
+ const file = path.join(buildDir, 'logs', 'edt-import.log');
647
+ mkdirSync(path.dirname(file), { recursive: true });
648
+ appendFileSync(file, `${lines.join('\n')}\n`, 'utf8');
649
+ }
650
+
651
+ /**
652
+ * Признак нехватки памяти при импорте: `CPU overload. Memory X MB used of Y MB`
653
+ * или явный `CPU overload`. Возвращает последнее измерение, если загрузка ≥85 %.
654
+ */
655
+ export function importMemoryWarning(text) {
656
+ const matches = [...String(text ?? '').matchAll(/Memory\s+(\d+)\s+MB\s+used\s+of\s+(\d+)\s+MB/gi)];
657
+ const last = matches.at(-1);
658
+ const cpuOverload = /CPU overload/i.test(String(text ?? ''));
659
+ if (last === undefined) return cpuOverload ? { cpuOverload: true } : undefined;
660
+ const used = Number.parseInt(last[1], 10);
661
+ const total = Number.parseInt(last[2], 10);
662
+ if (!Number.isFinite(used) || !Number.isFinite(total) || total <= 0) return undefined;
663
+ const ratio = used / total;
664
+ return ratio >= 0.85 || cpuOverload ? { used, total, ratio, cpuOverload } : undefined;
665
+ }
666
+
502
667
  /** Снимок настроек окружения — для диагностики и фонового процесса. */
503
668
  export function buildWorkspaceSnapshot({ root, buildDir, wsDir, mainRoot, settings, projects, now = new Date() }) {
504
669
  return {
@@ -598,18 +763,97 @@ export function killTree(pid, { force = false, platform = process.platform, run
598
763
 
599
764
  // --- установка EDT ----------------------------------------------------------
600
765
 
601
- /** Установлен ли в этой EDT плагин EDT-MCP (эвристика по именам бандлов). */
602
- export function hasMcpBundle(home) {
603
- for (const dir of ['plugins', 'features']) {
766
+ const MCP_BUNDLE = /com\.ditrix\.edt\.mcp\.server|ditrix/i;
767
+
768
+ function bundleNames(dir) {
769
+ try {
770
+ return readdirSync(dir).filter((name) => MCP_BUNDLE.test(name));
771
+ } catch {
772
+ return [];
773
+ }
774
+ }
775
+
776
+ /** Значение ключа Eclipse `config.ini` (без разбора секций). */
777
+ function iniValue(text, key) {
778
+ const match = new RegExp(`^\\s*${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*=\\s*(.+)$`, 'm').exec(String(text ?? ''));
779
+ return match === null ? undefined : match[1].trim();
780
+ }
781
+
782
+ /** `file\:configuration/` -> абсолютный путь. */
783
+ function resolveEclipsePath(value, baseDir) {
784
+ if (typeof value !== 'string' || value === '') return undefined;
785
+ const raw = value.replace(/\\:/g, ':').replace(/^file:/i, '');
786
+ if (raw === '') return undefined;
787
+ return path.isAbsolute(raw) ? path.normalize(raw) : path.resolve(baseDir, raw);
788
+ }
789
+
790
+ /** Области конфигурации, в которых Eclipse ищет бандлы. */
791
+ function configurationAreas(home, { platform = process.platform, eclipseRoots } = {}) {
792
+ const areas = [];
793
+ if (typeof home === 'string' && home !== '') {
794
+ // Сам каталог установки: <home>/plugins и <home>/features.
795
+ areas.push(home);
796
+ const configDir = path.join(home, 'configuration');
797
+ areas.push(configDir);
798
+ try {
799
+ const shared = iniValue(readFileSync(path.join(configDir, 'config.ini'), 'utf8'), 'osgi.sharedConfiguration.area');
800
+ const resolved = resolveEclipsePath(shared, configDir);
801
+ if (resolved !== undefined && resolved !== configDir) areas.push(resolved);
802
+ } catch {
803
+ // config.ini может отсутствовать — не мешает проверке установки
804
+ }
805
+ try {
806
+ const ini = readFileSync(path.join(home, platform === 'win32' ? '1cedt.ini' : '1cedt.ini'), 'utf8');
807
+ const line = /^-configuration\s+(.+)$/m.exec(ini)?.[1]?.trim();
808
+ const resolved = resolveEclipsePath(line, home);
809
+ if (resolved !== undefined) areas.push(resolved);
810
+ } catch {
811
+ // 1cedt.ini может отсутствовать
812
+ }
813
+ }
814
+
815
+ const homeDir = platform === 'win32' ? homedir() : (process.env.HOME ?? homedir());
816
+ for (const root of eclipseRoots ?? [path.join(homeDir, '.eclipse')]) {
604
817
  let entries;
605
818
  try {
606
- entries = readdirSync(path.join(home, dir));
819
+ entries = readdirSync(root, { withFileTypes: true });
607
820
  } catch {
608
821
  continue;
609
822
  }
610
- if (entries.some((name) => /mcp/i.test(name) || /ditrix/i.test(name))) return true;
823
+ for (const entry of entries) {
824
+ if (!entry.isDirectory() || !/^org\.eclipse\.platform_/.test(entry.name)) continue;
825
+ areas.push(path.join(root, entry.name, 'configuration'));
826
+ }
611
827
  }
612
- return false;
828
+ return [...new Set(areas)];
829
+ }
830
+
831
+ /**
832
+ * Где реально лежит EDT-MCP: установка, shared area или пользовательская
833
+ * configuration area (`~/.eclipse/org.eclipse.platform_*`). Это подсказка для
834
+ * диагностики, а не критерий выбора установки.
835
+ * @returns {{ found: boolean, evidence: string[] }}
836
+ */
837
+ export function mcpEvidence(home, options = {}) {
838
+ const evidence = [];
839
+ for (const area of configurationAreas(home, options)) {
840
+ for (const sub of ['plugins', 'features']) {
841
+ const hits = bundleNames(path.join(area, sub));
842
+ if (hits.length > 0) evidence.push(`${path.join(area, sub)}: ${hits[0]}`);
843
+ }
844
+ const bundles = path.join(area, 'org.eclipse.equinox.simpleconfigurator', 'bundles.info');
845
+ try {
846
+ if (MCP_BUNDLE.test(readFileSync(bundles, 'utf8'))) evidence.push(bundles);
847
+ } catch {
848
+ // bundles.info может отсутствовать
849
+ }
850
+ }
851
+ return { found: evidence.length > 0, evidence };
852
+ }
853
+
854
+ /** Установлен ли в этой EDT плагин EDT-MCP (обёртка над mcpEvidence). */
855
+ export function hasMcpBundle(home, options = {}) {
856
+ return mcpEvidence(home, options).found;
613
857
  }
614
858
 
615
859
  const VERSION_PATTERN = /^1c-edt-(\d{4})\.(\d+)\.(\d+)\+(\d+)-/;
@@ -631,9 +875,13 @@ const compareVersion = (left, right) => {
631
875
 
632
876
  /**
633
877
  * Установка EDT: явная из настроек/`DSH_1C_EDT_HOME` либо старшая найденная.
634
- * @returns {{ home?: string, bin?: string, cli?: string, mcpBundle?: boolean, source?: string, error?: string }}
878
+ * Наличие EDT-MCP в файлах — только диагностическая подсказка: бандл может
879
+ * стоять в пользовательской configuration area, а окончательно готовность
880
+ * подтверждает `/health`.
881
+ * @returns {{ home?: string, bin?: string, cli?: string, mcpBundle?: boolean,
882
+ * mcpEvidence?: string[], source?: string, error?: string }}
635
883
  */
636
- export function detectEdtHome(settings, { platform = process.platform, env = process.env, roots } = {}) {
884
+ export function detectEdtHome(settings, { platform = process.platform, env = process.env, roots, eclipseRoots } = {}) {
637
885
  const binary = platform === 'win32' ? '1cedt.exe' : '1cedt';
638
886
  const cli = platform === 'win32' ? '1cedtcli.exe' : '1cedtcli';
639
887
  const explicit = text(env?.DSH_1C_EDT_HOME) ?? text(settings?.home?.[platform]);
@@ -642,11 +890,13 @@ export function detectEdtHome(settings, { platform = process.platform, env = pro
642
890
  const bin = path.join(home, binary);
643
891
  if (!existsSync(bin)) return { error: `в ${home} нет ${binary}`, home };
644
892
  const cliPath = path.join(home, cli);
893
+ const evidence = mcpEvidence(home, { platform, env, eclipseRoots });
645
894
  return {
646
895
  home,
647
896
  bin,
648
897
  cli: existsSync(cliPath) ? cliPath : undefined,
649
- mcpBundle: hasMcpBundle(home),
898
+ mcpBundle: evidence.found,
899
+ mcpEvidence: evidence.evidence,
650
900
  source: text(env?.DSH_1C_EDT_HOME) ? 'env' : 'config',
651
901
  };
652
902
  }
@@ -666,48 +916,111 @@ export function detectEdtHome(settings, { platform = process.platform, env = pro
666
916
  if (version === null) continue;
667
917
  const home = path.join(root, entry.name);
668
918
  if (!existsSync(path.join(home, binary))) continue;
669
- candidates.push({ home, version, mcpBundle: hasMcpBundle(home) });
919
+ candidates.push({ home, version });
670
920
  }
671
921
  }
672
922
  if (candidates.length === 0) {
673
923
  return { error: `1C:EDT не найден; задайте workspace.home или DSH_1C_EDT_HOME (искали в ${searchRoots.join(', ')})` };
674
924
  }
925
+ // Выбор только по версии: наличие бандла не должно решать, какую установку
926
+ // запускать (бандл может лежать в пользовательской configuration area).
675
927
  candidates.sort((left, right) => compareVersion(right.version, left.version));
676
- const chosen = candidates.find((candidate) => candidate.mcpBundle) ?? candidates[0];
928
+ const chosen = candidates[0];
677
929
  const cliPath = path.join(chosen.home, cli);
930
+ const evidence = mcpEvidence(chosen.home, { platform, env, eclipseRoots });
678
931
  return {
679
932
  home: chosen.home,
680
933
  bin: path.join(chosen.home, binary),
681
934
  cli: existsSync(cliPath) ? cliPath : undefined,
682
- mcpBundle: chosen.mcpBundle,
935
+ mcpBundle: evidence.found,
936
+ mcpEvidence: evidence.evidence,
683
937
  source: 'autodetect',
684
938
  };
685
939
  }
686
940
 
687
- /** Java для EDT: явная настройка, env либо jdk-full-17 рядом с установкой. */
941
+ /**
942
+ * Требования EDT к Java из `1cedt.ini`: major из
943
+ * `-Dosgi.requiredJavaVersion` и явный `-vm` (до `-vmargs`), если задан.
944
+ */
945
+ export function requiredJava(home, { platform = process.platform } = {}) {
946
+ const result = { major: undefined, vm: undefined };
947
+ if (typeof home !== 'string' || home === '') return result;
948
+ let ini;
949
+ try {
950
+ ini = readFileSync(path.join(home, '1cedt.ini'), 'utf8');
951
+ } catch {
952
+ return result;
953
+ }
954
+ const major = /-Dosgi\.requiredJavaVersion=(\d+)/.exec(ini)?.[1];
955
+ if (major !== undefined) result.major = Number.parseInt(major, 10);
956
+
957
+ const lines = ini.split(/\r?\n/);
958
+ const vmargsAt = lines.findIndex((line) => line.trim() === '-vmargs');
959
+ const launchLines = vmargsAt === -1 ? lines : lines.slice(0, vmargsAt);
960
+ const vmAt = launchLines.findIndex((line) => line.trim() === '-vm');
961
+ let vm;
962
+ if (vmAt !== -1) {
963
+ vm = launchLines.slice(vmAt + 1).find((line) => line.trim() !== '')?.trim();
964
+ } else {
965
+ vm = /^\s*-vm\s+(.+)$/m.exec(launchLines.join('\n'))?.[1]?.trim();
966
+ }
967
+ if (vm !== undefined && vm !== '') {
968
+ result.vm = path.isAbsolute(vm) ? path.normalize(vm) : path.resolve(home, vm);
969
+ }
970
+ return result;
971
+ }
972
+
973
+ /** JDK рядом с установкой EDT: `{ major, path }`, по убыванию major. */
974
+ function jdkCandidates(components, javaBinary) {
975
+ let entries;
976
+ try {
977
+ entries = readdirSync(components, { withFileTypes: true });
978
+ } catch {
979
+ return [];
980
+ }
981
+ const candidates = [];
982
+ for (const entry of entries) {
983
+ if (!entry.isDirectory()) continue;
984
+ const major = /jdk-(?:full-)?(\d+)/i.exec(entry.name)?.[1];
985
+ if (major === undefined) continue;
986
+ const bin = path.join(components, entry.name, 'bin', javaBinary);
987
+ if (!existsSync(bin)) continue;
988
+ candidates.push({ major: Number.parseInt(major, 10), path: bin });
989
+ }
990
+ return candidates.sort((left, right) => right.major - left.major);
991
+ }
992
+
993
+ /**
994
+ * Java для EDT: явная настройка → `-vm` из ini → JDK рядом с установкой по
995
+ * требуемой major (точная, затем ближайшая старшая, затем старшая доступная).
996
+ */
688
997
  export function resolveEdtVm(settings, home, { platform = process.platform, env = process.env } = {}) {
689
998
  const explicit = text(env?.DSH_1C_EDT_VM) ?? text(settings?.vm);
690
999
  if (explicit !== undefined) return explicit;
691
1000
  if (typeof home !== 'string' || home === '') return undefined;
1001
+
1002
+ const required = requiredJava(home, { platform });
1003
+ if (required.vm !== undefined && existsSync(required.vm)) return required.vm;
1004
+
692
1005
  const javaBinary = platform === 'win32' ? 'javaw.exe' : 'java';
693
- try {
694
- const components = path.dirname(home);
695
- const jdk = readdirSync(components, { withFileTypes: true })
696
- .filter((entry) => entry.isDirectory() && /jdk-full-17/.test(entry.name))
697
- .map((entry) => path.join(components, entry.name, 'bin', javaBinary))
698
- .filter((candidate) => existsSync(candidate))
699
- .sort();
700
- return jdk[0];
701
- } catch {
702
- return undefined;
703
- }
1006
+ const candidates = jdkCandidates(path.dirname(home), javaBinary);
1007
+ if (candidates.length === 0) return undefined;
1008
+ if (required.major === undefined) return candidates[0].path;
1009
+
1010
+ const exact = candidates.find((candidate) => candidate.major === required.major);
1011
+ if (exact !== undefined) return exact.path;
1012
+ const newer = [...candidates].reverse().find((candidate) => candidate.major >= required.major);
1013
+ return (newer ?? candidates[0]).path;
704
1014
  }
705
1015
 
706
1016
  /** Аргументы запуска EDT GUI с отдельным workspace. */
707
- export function guiArgs({ wsDir, xmx, vm }) {
1017
+ export function guiArgs({ wsDir, xmx, vm, nativeFormBufferedLayoutRender = false }) {
708
1018
  const args = ['-nosplash', '-data', wsDir, '--launcher.appendVmargs'];
709
1019
  if (typeof vm === 'string' && vm !== '') args.push('-vm', vm);
710
1020
  args.push('-vmargs', `-Xmx${xmx}`);
1021
+ // Требование EDT-MCP для get_form_screenshot: свойство должно идти после
1022
+ // -vmargs. На Windows включает буферизованный рендер форм на всю сессию.
1023
+ if (nativeFormBufferedLayoutRender) args.push('-DnativeFormBufferedLayoutRender=true');
711
1024
  return args;
712
1025
  }
713
1026
 
package/locale/en.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "1C:EDT + BSL MCP",
4
- "description": "MCP hub for 1C development: active only in selected projects (.dsh-1c.json or the projects list). There EDT is connected up front, while BSL Language Server (diagnostics, types, navigation), standards and a task tracker are mounted on demand via edt_pin; in a git worktree the plugin raises its own EDT workspace under build/ws. 1C rules enter the prompt only there."
4
+ "description": "MCP hub for 1C development: active only in selected projects (.dsh-1c.json or the projects list). There EDT is connected up front, while BSL Language Server (diagnostics, types, navigation), standards and a task tracker are mounted on demand via edt_pin; in a git worktree (including dsh-git-worktree) the plugin raises its own EDT workspace under build/ws with plain text and form screenshots. 1C rules enter the prompt only there."
5
5
  }
6
6
  }
package/locale/ru.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "1C:EDT + BSL MCP",
4
- "description": "Хаб MCP-серверов для разработки на 1С: включается только в выбранных проектах (файл .dsh-1c.json или список projects). В них EDT подключён сразу, BSL Language Server (диагностика, типы, навигация), стандарты и система задач — по требованию через edt_pin; в git worktree плагин поднимает отдельный EDT workspace в build/ws. Правила 1С появляются в промпте только там."
4
+ "description": "Хаб MCP-серверов для разработки на 1С: включается только в выбранных проектах (файл .dsh-1c.json или список projects). В них EDT подключён сразу, BSL Language Server (диагностика, типы, навигация), стандарты и система задач — по требованию через edt_pin; в git worktree (в т.ч. dsh-git-worktree) плагин поднимает отдельный EDT workspace в build/ws с plain text и скриншотами форм. Правила 1С появляются в промпте только там."
5
5
  }
6
6
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-edt-projects",
3
- "version": "0.12.0",
3
+ "version": "0.12.1",
4
4
  "type": "module",
5
5
  "description": "Project-scoped MCP hub for 1C development: active only in selected projects, EDT connected up front there, a per-worktree EDT workspace under build/ws, BSL Language Server, a task tracker and the v8std standards MCP mounted on demand, bundled SonarQube and GitLab skills, with model-facing usage rules",
6
6
  "license": "MIT",