dsh-telegram-multiagent 1.2.3 → 1.3.0

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
@@ -49,6 +49,8 @@ see "Where to put the row" below:
49
49
  | `a2aDir` | no | Directory for a file-based agent-to-agent channel (`in/`, `out/`). Omit and no such channel exists. |
50
50
  | `a2aSession` | no | Session id used for that channel. Default `a2a`. |
51
51
  | `transcribeCommand` | no | External command for voice messages: `<cmd> <audio-file> auto` → transcript on stdout. Omit and voice is politely refused. |
52
+ | `goalUsers` | no | Telegram user ids allowed to run `/goal`. **Empty (default) = the command is refused for everyone.** Not inherited from `allowedUsers` on purpose: talking to an agent and starting a paid autonomous loop are different rights. |
53
+ | `goalA2ASenders` | no | Sender names allowed to run `/goal` from the agent-to-agent channel. The sender names itself in the first line of the file (`From: <name>`) — this is bookkeeping, not authentication; see 1.3.0 below. |
52
54
 
53
55
  ## Five things that cost us a day
54
56
 
@@ -171,3 +173,96 @@ MIT.
171
173
 
172
174
  **Урок общий:** канал доставки без повтора — это тихая потеря. Отказ, о котором никто не узнал,
173
175
  дороже отказа громкого: его невозможно даже сосчитать.
176
+
177
+ ## 1.3.0 — постановка цели прямо из канала
178
+
179
+ 🔴 **Зачем это вообще нужно.** Если цикл агента ведёт внешний движок — например, агент работает
180
+ через подписку, а не через родной цикл платформы, — родные инструменты платформы до модели **не
181
+ доходят вовсе**. Доходит только то, что имеет форму сообщения. Значит поставить агенту цель изнутри
182
+ разговора нельзя: руками в веб-интерфейсе можно, а из чата нет. Здесь мы даём постановку из любого
183
+ канала: сервису целей не нужен инструмент модели, он доступен коду плагина напрямую.
184
+
185
+ Команды одинаковы в обоих каналах:
186
+
187
+ ```
188
+ /goal показать состояние цели
189
+ /goal <замысел> поставить цель
190
+ /goal stop снять цель
191
+ ```
192
+
193
+ **Кто вправе — двумя отдельными списками, оба по умолчанию ПУСТЫЕ, то есть команда отказывает
194
+ всем.** Право не наследуется от `allowedUsers` сознательно: «с кем я разговариваю» и «кто вправе
195
+ завести дорогой автономный цикл» — разные вопросы, а в `allowedUsers` завтра добавят гостя.
196
+
197
+ ```yaml
198
+ goalUsers: [123456789] # из Telegram — по числовому id
199
+ goalA2ASenders: ["coordinator"] # из служебного канала — по имени в заголовке
200
+ ```
201
+
202
+ 🔴 **Чем за это платят, сказать обязано само описание.** Проверки платформы
203
+ (`currentInitiator`, `hasDirectHumanInput`, права на корневого агента) живут в её инструменте целей
204
+ и на этом пути **не участвуют**. Плагин не обходит политику — он пишет её заново, и две строки выше
205
+ есть вся политика целиком. Ставя `goalUsers`, вы соглашаетесь именно на это.
206
+
207
+ **Счётчик постановок: не более трёх за скользящий час на канал.** Предел раундов у платформы стоит
208
+ на ЦЕЛЬ, а не на агента: новая цель — новый счётчик, и поток целей ничем не ограничен. А каталог
209
+ входящих принадлежит самому агенту, то есть в автономном раунде он способен положить себе файл
210
+ `/goal …` и продлить цикл собственными руками.
211
+
212
+ 🔴 **Где счётчик НЕ применяется — назвать важнее, чем сам счётчик.** Он защищает от РАЗГОНА:
213
+ от петли, в которую механизм вошёл сам, от ошибки в коде, от лавины повторов. Он **не** защищает от
214
+ намеренного действия агента с правами администратора: такой агент снимет и счётчик, и предел
215
+ раундов одной командой. Изоляция здесь **поведенческая**, технической границы против самого агента
216
+ не существует. Настоящий сторож живёт снаружи машины. Снятию цели (`/goal stop`) счётчик не мешает
217
+ никогда: механизм, включаемый снаружи и невыключаемый, хуже отсутствия механизма.
218
+
219
+ **Заголовок отправителя — учёт, а не защита.** Служебный канал файловый, и вид источника получает
220
+ любое сообщение в нём; правом «всем, кто пишет в канал» вы раздали бы дорогие циклы кому попало.
221
+ Поэтому отправитель называет себя первой строкой файла:
222
+
223
+ ```
224
+ From: coordinator
225
+ /goal разобрать вчерашние отказы и доложить
226
+ ```
227
+
228
+ Имя подделает любой, кто может положить файл в каталог входящих. Настоящая граница здесь — права
229
+ ОС на этот каталог. Нужна проверка сильнее самоназвания — это общий секрет в файле с правами, и
230
+ заводить его надо отдельным решением, а не походя.
231
+
232
+ **Об исходе цели плагин сообщает сам.** Доводчик платформы при упоре в предел раундов переводит
233
+ цель в `blocked` — и делает это молча: снаружи автономный цикл просто перестаёт просыпаться, что
234
+ неотличимо от поломки. Плагин говорит об исходе ровно один раз и в тот канал, из которого цель
235
+ ставили.
236
+
237
+ **Служебный маркер цели вырезается из всего, что уходит наружу.** Его дело — остановить цикл, а не
238
+ попасть человеку в чат. Вырезается маркер последней строки; маркер в середине текста оставлен
239
+ видимым нарочно — он и так отвергнут, и пусть будет заметен читателю.
240
+
241
+ ### Ожидание сервисов: где оно есть и где его нет — решение по каждому месту
242
+
243
+ `ctx.get()` возвращает `undefined` **молча**, пока волокно поставщика не активно. «Сервиса нет в
244
+ сборке» и «сервис ещё поднимается» приходят одним и тем же значением — и код, написанный как
245
+ `const x = ctx.get('x'); if (x)`, тихо уходит по ветке «этой возможности просто нет».
246
+
247
+ | место | ожидание | почему |
248
+ |---|---|---|
249
+ | заведение агента (пресет, модель, персистенция) | **есть**, до 30 с | идёт вплотную к подъёму платформы, отказ молчалив и разрушителен |
250
+ | `/goal` → сервисы целей, маркера, моста | **есть**, коротко | ответ «сервис не подключён» был бы диагнозом, которого код поставить не может; но на том конце живой собеседник, и полминуты молчания в чате читаются как «бот умер» |
251
+ | вырезание маркера из исходящего | **нет** | место синхронное, а отказ не молчалив: маркер уедет в чат видимым текстом |
252
+ | сообщение об исходе цели | **нет** | сюда попадают только сессии, где цель уже поставлена, — значит пустота означает не «ещё не поднялся», а «сервис пропал», и это надо сказать, а не переждать |
253
+
254
+ Замечено на живом: порядок готовности волокон **меняется от подъёма к подъёму**. В одном подъёме
255
+ ждала фабрика агентов, в другом — набор пресета, причём гонка была в обоих. Значит судить о гонке
256
+ по соседнему сервису нельзя, и ожидание ставится по устройству места, а не по тому, где однажды
257
+ видели примету.
258
+
259
+ ### Чего эта версия НЕ делает
260
+
261
+ - **не даёт модели инструмент постановки цели** — команда приходит от человека или от координатора
262
+ через канал, модель её не вызывает и в заголовке запроса плагин невидим;
263
+ - **не заменяет права платформы** — она их здесь не применяет вовсе (см. выше);
264
+ - **не защищает от агента с правами администратора** — вся защита поведенческая;
265
+ - **не проверяет подлинность отправителя** в служебном канале — только самоназвание;
266
+ - **не переносит цель через перезапуск процесса**: счётчик постановок обнуляется, а связь
267
+ «сессия → куда сообщить об исходе» живёт в памяти процесса. Сама цель хранится платформой и
268
+ переживает перезапуск, а вот сообщение об её исходе после перезапуска не придёт.
package/cordis.patch.yml CHANGED
@@ -17,3 +17,13 @@
17
17
  # Empty means everyone can talk to an agent that usually has shell
18
18
  # access. Set it.
19
19
  allowedUsers: []
20
+ # Goal control from the channel (1.3.0). BOTH lists are empty by
21
+ # default, which means the /goal command is refused for everyone —
22
+ # deliberately: starting an autonomous goal loop costs money, so the
23
+ # right is granted by name, never inherited from allowedUsers.
24
+ # Telegram user ids allowed to run /goal:
25
+ goalUsers: []
26
+ # Names allowed to run /goal from the agent-to-agent channel. The
27
+ # sender names itself in the first line of the file: "From: <name>".
28
+ # This is bookkeeping, not authentication — see README.
29
+ goalA2ASenders: []
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "dsh-telegram-multiagent",
3
- "version": "1.2.3",
4
- "description": "Telegram channel for DeepSeek Harness — one shared module serving several agents, with unforgeable sender marks, merged owner+coordinator memory, and a delivery mode you switch in a file outside the code.",
3
+ "version": "1.3.0",
4
+ "description": "Telegram channel for DeepSeek Harness — one shared module serving several agents, with unforgeable sender marks, merged owner+coordinator memory, a delivery mode you switch in a file outside the code, and goal control from the channel for agents whose loop runs outside the platform.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "files": [
8
8
  "src",
9
+ "test",
9
10
  "cordis.patch.yml",
10
11
  "README.md"
11
12
  ],
package/src/index.js CHANGED
@@ -304,6 +304,38 @@ export function apply(ctx, config = {}) {
304
304
  return undefined;
305
305
  }
306
306
 
307
+ /**
308
+ * 🔴 РЕШЕНИЕ ПО КАЖДОМУ ctx.get() В ЭТОМ ФАЙЛЕ. Проверять надо КАЖДЫЙ, а не
309
+ * тот, на котором обожглись, — иначе рецидив, и рецидив будет выглядеть новой
310
+ * болезнью, потому что рядом лежит доказательство «мы это уже лечили».
311
+ *
312
+ * ПРИЗНАК, по которому решается, а не «кажется безопасным»: ожидание нужно
313
+ * там, где место может сработать ДО подъёма сервиса И отказ при этом
314
+ * МОЛЧАЛИВ ИЛИ ЛОЖЕН. Одного условия мало.
315
+ *
316
+ * Отдельно замечено на живом: порядок готовности волокон МЕНЯЕТСЯ от подъёма
317
+ * к подъёму. В одном подъёме ждала фабрика агентов, в другом — набор пресета,
318
+ * причём гонка была в обоих. Значит судить о гонке по соседнему сервису
319
+ * нельзя: соседний в этот раз мог успеть. Ожидание ставится по устройству
320
+ * места, а не по тому, где однажды видели примету.
321
+ *
322
+ * 1) agentFor → agentPresets, agentDefaultModel, sessionPersistence.
323
+ * ОЖИДАНИЕ ЕСТЬ, предел 30 с: заведение агента идёт вплотную к подъёму
324
+ * платформы, а отказ здесь молчалив и разрушителен.
325
+ * 2) goalCommand → goals, goalMarker, mcpBridge. ОЖИДАНИЕ ЕСТЬ, предел
326
+ * короткий (GOAL_WAIT_TRIES): по undefined невозможно отличить «сервиса
327
+ * нет в сборке» от «ещё поднимается», а ответ «не подключён» — это
328
+ * диагноз, которого код поставить не может. Ждём мало, потому что на том
329
+ * конце живой собеседник.
330
+ * 3) stripGoalMarker → goalMarker. ОЖИДАНИЯ НЕТ намеренно: место
331
+ * СИНХРОННОЕ (обработчик события сессии не async), а отказ не молчалив —
332
+ * маркер уезжает в чат видимым текстом.
333
+ * 4) обработчик goal/change → goals. ОЖИДАНИЯ НЕТ намеренно: сюда попадают
334
+ * только те сессии, где цель уже поставлена через goalCommand, а тот без
335
+ * сервиса целей не проходит вовсе. Значит пустота здесь означает не «ещё
336
+ * не поднялся», а «сервис ПРОПАЛ», и это надо не переждать, а сказать.
337
+ */
338
+
307
339
  async function agentFor(chatId) {
308
340
  const key = String(chatId);
309
341
  const found = chats.get(key);
@@ -327,6 +359,9 @@ export function apply(ctx, config = {}) {
327
359
  const setupFn = async (agentCtx) => {
328
360
  platform.installModelSelection(agentCtx, { current: selection, assembled: undefined });
329
361
  if (presets && presetId) await presets.mount(agentCtx, presetId);
362
+ // Молча остаться без инструментов нельзя: снаружи это выглядит как
363
+ // «агент отупел», а причина — несмонтированный пресет.
364
+ else log(`[${key}] 🔴 пресет НЕ смонтирован (presets=${!!presets}, presetId=${presetId})`);
330
365
  };
331
366
 
332
367
  // 🔴 СЕССИЮ, КОТОРАЯ УЖЕ ЛЕЖИТ НА ДИСКЕ, НАДО ПРОДОЛЖАТЬ, А НЕ СОЗДАВАТЬ ЗАНОВО.
@@ -367,7 +402,7 @@ export function apply(ctx, config = {}) {
367
402
  // Кричать 🔴 на законном событии — это защита, которая приучает
368
403
  // не читать журнал.
369
404
  const spisok = known.slice(0, 12).join(', ') + (known.length > 12 ? `, …и ещё ${known.length - 12}` : '');
370
- log(`⚠️ [${key}] на диске ${known.length} сохранённых сессий, ${sessionId} среди них НЕТ: либо это первый разговор в чате, либо история потеряна. Продолжать нечего, создаю заново. Найдено: ${spisok}`);
405
+ log(`⚠️ [${key}] на диске ${known.length} сохранённых сессий, ${sessionId} среди них НЕТ: либо это первый разговор в чате, либо история потеряна — по одному списку не отличить. Продолжать нечего, создаю заново. Найдено: ${spisok}`);
371
406
  }
372
407
  }
373
408
  if (known !== undefined && known.includes(sessionId)) {
@@ -441,11 +476,240 @@ export function apply(ctx, config = {}) {
441
476
  }
442
477
  }
443
478
 
479
+ // ── ПОСТАНОВКА ЦЕЛИ ИЗ КАНАЛА (21.08.2026, задача владельца).
480
+ //
481
+ // ЗАЧЕМ. Когда цикл ведёт внешний движок (агент работает через подписку, а не
482
+ // через родной цикл платформы), инструменты платформы до модели НЕ ДОХОДЯТ
483
+ // вовсе — доходит только то, что имеет форму СООБЩЕНИЯ. Значит поставить цель
484
+ // изнутри разговора нельзя, а руками в веб-интерфейсе — можно. Здесь мы даём
485
+ // постановку из любого канала: сервис целей доступен КОДУ модуля как
486
+ // ctx.get('goals'), инструмент модели для этого не нужен.
487
+ //
488
+ // КТО ВПРАВЕ. Отдельные настройки, обе по умолчанию ВЫКЛЮЧЕНЫ:
489
+ // goalUsers: [<числовые id>] — кто ставит цель из Telegram
490
+ // goalA2ASenders: ["<имя>", …] — кто вправе из служебного канала
491
+ // Право НЕ наследуется от allowedUsers сознательно: allowedUsers — это
492
+ // «с кем я вообще разговариваю», и завтра туда добавят гостя. Право завести
493
+ // дорогой автономный цикл шире права поговорить.
494
+ //
495
+ // 🔴 ЧЕМ ЗА ЭТО ПЛАТИМ, назвать обязательно: проверки платформы
496
+ // (currentInitiator, hasDirectHumanInput, roots) живут в её инструменте целей
497
+ // и на ЭТОМ пути не участвуют. Мы не обходим политику — мы пишем её заново,
498
+ // и две строки ниже есть вся политика целиком. Пустые списки = постановка
499
+ // отключена; это умолчание выбрано нарочно.
500
+ const goalUsers = new Set((config.goalUsers ?? []).map(Number));
501
+ // 🔴 ПРАВО ИЗ СЛУЖЕБНОГО КАНАЛА — ПОИМЁННО, А НЕ «КАНАЛУ ЦЕЛИКОМ». Признак
502
+ // источника получает ЛЮБОЕ сообщение служебного канала, а каталог обмена
503
+ // бывает общим на несколько машин: правом «всем, кто пишет в канал» мы
504
+ // раздали бы возможность заводить дорогие циклы кому попало.
505
+ // Отправитель называет себя первой строкой файла: From: <имя>
506
+ // Нет заголовка или имени нет в списке — отказ, и он произносится вслух.
507
+ const goalA2ASenders = new Set(config.goalA2ASenders ?? []);
508
+ // sessionId → { channel, agent } — куда сообщить об исходе цели.
509
+ const goalOrigin = new Map();
510
+ // канал → метки времени постановок, для счётчика ниже.
511
+ const goalStarts = new Map();
512
+
513
+ // 🔴 СЧЁТЧИК ПОСТАНОВОК: не более трёх за скользящий час на канал.
514
+ //
515
+ // ОТ ЧЕГО ЗАЩИЩАЕТ: от СЛУЧАЙНОСТИ и РАЗГОНА — от петли, в которую механизм
516
+ // вошёл сам, от ошибки в коде, от лавины повторов. Предел раундов у платформы
517
+ // стоит на ЦЕЛЬ, а не на агента: новая цель — новый счётчик, и поток целей
518
+ // ничем не ограничен. Каталог входящих принадлежит самому агенту, то есть в
519
+ // автономном раунде он способен положить себе файл «/goal …» и продлить цикл.
520
+ //
521
+ // 🔴 ГДЕ НЕ ПРИМЕНЯЕТСЯ: счётчик НЕ защищает от намеренного действия агента,
522
+ // у которого есть права администратора на этой машине, — такой агент снимет
523
+ // и его, и предел раундов одной командой. Технической границы против самого
524
+ // агента здесь не существует, изоляция ПОВЕДЕНЧЕСКАЯ. Настоящий сторож —
525
+ // снаружи, на другой машине, вне досягаемости этого процесса.
526
+ //
527
+ // ГРАНИЦЫ САМОГО СЧЁТЧИКА: снятию цели (/goal stop) он не мешает никогда —
528
+ // механизм, включаемый снаружи и невыключаемый, хуже отсутствия механизма;
529
+ // обнуляется перезапуском процесса; ничего не чинит сам, только отказывает.
530
+ const GOAL_MAX_PER_HOUR = 3;
531
+
532
+ // Сколько секунд ждать сервис на пути КОМАНДЫ (не на заведении агента).
533
+ // Пять, а не тридцать: см. пояснение в goalCommand ниже.
534
+ const GOAL_WAIT_TRIES = 5;
535
+
536
+ function goalRateOk(channel) {
537
+ const now = Date.now();
538
+ const marks = (goalStarts.get(channel) ?? []).filter((t) => now - t < 3600000);
539
+ goalStarts.set(channel, marks);
540
+ return marks.length < GOAL_MAX_PER_HOUR;
541
+ }
542
+
543
+ // 🔴 ПРИЗНАК СОБСТВЕННОЙ ИСПРАВНОСТИ. Права на постановку цели заголовком
544
+ // запроса к модели НЕ проверяются в принципе: плагин не даёт модели ни одного
545
+ // инструмента и в заголовке невидим. Печатаем ЧИСЛА, а не «загрузился»: длина
546
+ // списка прав отличает верную настройку от пустого умолчания, при котором
547
+ // постановка отказывает молча и выглядит поломкой. Сами идентификаторы не
548
+ // печатаем — в журнал уходит устройство, а не список людей.
549
+ log(`[goal] смонтирован: goalUsers=${goalUsers.size} goalA2ASenders=${goalA2ASenders.size} `
550
+ + `предел постановок=${GOAL_MAX_PER_HOUR}/час`);
551
+
552
+ async function sayTo(channel, chatId, text) {
553
+ if (channel === 'a2a') {
554
+ if (!A2A_OUT) { log('[goal] 🔴 ответ в служебный канал не ушёл: каталог обмена не задан'); return; }
555
+ try {
556
+ fs.mkdirSync(A2A_OUT, { recursive: true });
557
+ fs.writeFileSync(path.join(A2A_OUT, `${Date.now()}-goal.txt`), text);
558
+ } catch (e) { log(`[goal] 🔴 ответ в служебный канал не ушёл: ${e?.message ?? e}`); }
559
+ } else {
560
+ try { await tg.send(chatId, text); } catch (e) { log(`[goal] 🔴 ответ в чат не ушёл: ${e?.message ?? e}`); }
561
+ }
562
+ }
563
+
564
+ /**
565
+ * Отделить заголовок отправителя от текста служебного сообщения.
566
+ *
567
+ * 🔴 ЧЕСТНАЯ ГРАНИЦА, назвать её обязательно: это УЧЁТ, А НЕ ЗАЩИТА.
568
+ * Отправитель называет себя сам, и подделать имя может любой, кто способен
569
+ * положить файл в каталог входящих. Настоящая граница здесь — права ОС на
570
+ * этот каталог: писать в него должны только сам агент и доставка. Нужна
571
+ * проверка сильнее самоназвания — это общий секрет в файле с правами, и
572
+ * заводить его нужно отдельным решением, а не походя.
573
+ */
574
+ function splitSender(raw) {
575
+ const nl = raw.indexOf('\n');
576
+ const head = (nl === -1 ? raw : raw.slice(0, nl)).trim();
577
+ const m = /^From:\s*(\S+)$/.exec(head);
578
+ if (!m) return { sender: undefined, text: raw };
579
+ return { sender: m[1], text: nl === -1 ? '' : raw.slice(nl + 1).trimStart() };
580
+ }
581
+
582
+ /**
583
+ * Убрать маркер цели из текста, уходящего наружу.
584
+ *
585
+ * 🔴 ЗДЕСЬ ОЖИДАНИЯ СЕРВИСА НЕТ, И ЭТО РЕШЕНИЕ, А НЕ ПРОПУСК. Место
586
+ * СИНХРОННОЕ (зовётся из обработчика session/event, который не async), и
587
+ * отказ здесь НЕ молчалив: маркер уезжает в чат видимым текстом, читатель
588
+ * увидит его сам. Ждать по 30 с на каждом исходящем сообщении было бы
589
+ * лечением хуже болезни — см. общее решение по ctx.get() выше.
590
+ */
591
+ function stripGoalMarker(raw) {
592
+ const marker = ctx.get('goalMarker');
593
+ return marker ? marker.strip(raw) : raw;
594
+ }
595
+
596
+ // Разбор команды. Возвращает текст ответа; отправку делает вызывающий.
597
+ // Отказы называем вслух: молчаливый отказ здесь неотличим от «агент не понял».
598
+ async function goalCommand(text, channel, agent) {
599
+ const rest = text.slice('/goal'.length).trim();
600
+ // 🔴 ЖДЁМ СЕРВИС, А НЕ ОТКАЗЫВАЕМ СРАЗУ — и ждём КОРОТКО. Без ожидания
601
+ // ответ «сервис не подключён» был бы диагнозом, которого код поставить не
602
+ // может: undefined одинаково означает и «нет в сборке», и «ещё поднимается».
603
+ // Предел короткий (а не 30 с, как при заведении агента) потому, что на том
604
+ // конце ждёт живой собеседник: полминуты молчания в чате читаются как
605
+ // «бот умер», и лечение снова оказалось бы хуже болезни.
606
+ const goals = await waitService('goals', '[goal]', 'команда постановки цели невыполнима', GOAL_WAIT_TRIES);
607
+ if (!goals) return 'Сервис целей на этом агенте не подключён.';
608
+ let current;
609
+ try { current = goals.get(agent); }
610
+ catch (e) { log(`[goal] 🔴 состояние цели не прочитано: ${e?.message ?? e}`); return `Не смог прочитать состояние цели: ${e?.message ?? e}`; }
611
+
612
+ if (rest === '') {
613
+ if (!current) return 'Цели нет.';
614
+ return `Цель ${current.id}: ${current.phase}, раунд ${current.roundsStarted} из ${current.maxGoalRounds}.`
615
+ + (current.blockedReason ? `\nПричина остановки: ${current.blockedReason}` : '')
616
+ + `\nЗамысел: ${String(current.objective).slice(0, 200)}`;
617
+ }
618
+
619
+ if (rest === 'stop') {
620
+ // Снятие счётчиком НЕ ограничено — см. границы выше.
621
+ if (!current) return 'Цели нет, снимать нечего.';
622
+ try {
623
+ goals.clear(agent, { id: current.id, revision: current.revision });
624
+ goalOrigin.delete(String(agent.session?.id ?? ''));
625
+ log(`[goal] снята ${current.id} из ${channel}`);
626
+ return `Цель ${current.id} снята.`;
627
+ } catch (e) {
628
+ log(`[goal] 🔴 снятие не удалось: ${e?.message ?? e}`);
629
+ return `Не смог снять цель: ${e?.message ?? e}`;
630
+ }
631
+ }
632
+
633
+ // Постановка. Предел раундов командой НЕ задаётся: он живёт в настройке
634
+ // платформы и не должен подниматься тем же путём, каким запускается
635
+ // дорогой цикл.
636
+ if (!goalRateOk(channel)) {
637
+ log(`[goal] ОТКАЗ ${channel}: исчерпан предел ${GOAL_MAX_PER_HOUR} постановок за час`);
638
+ return `Предел ${GOAL_MAX_PER_HOUR} постановок цели за час исчерпан. Зовите человека.`;
639
+ }
640
+ try {
641
+ let view = goals.create(agent, { objective: rest });
642
+ // 🔴 НАСТАВЛЕНИЕ ПРО МАРКЕР КЛАДЁМ В САМ ЗАМЫСЕЛ. Промпт автоматического
643
+ // раунда несёт только замысел и номер раунда — ни идентификатора цели, ни
644
+ // ревизии в нём нет, а без них модель не сложит маркер. Идентификатор
645
+ // известен только после create, поэтому edit вторым шагом; edit поднимает
646
+ // ревизию на единицу, её и называем в наставлении.
647
+ const marker = await waitService('goalMarker', '[goal]',
648
+ 'цель будет поставлена без наставления о маркере', GOAL_WAIT_TRIES);
649
+ if (marker) {
650
+ // 🔴 ИМЕНА ИНСТРУМЕНТОВ БЕРЁМ У МОСТА, А НЕ ПИШЕМ ПО ПАМЯТИ. Протокол
651
+ // MCP добавляет к базовому имени приставку mcp__<сервер>__; наставление,
652
+ // называющее инструмент БЕЗ неё, отсылает модель к несуществующему
653
+ // имени, и выглядит это как «мост не работает». Нет моста — нет и имён:
654
+ // тогда наставление честно говорит, что инструментов нет, и остаётся
655
+ // только пометка.
656
+ const bridge = await waitService('mcpBridge', '[goal]',
657
+ 'в наставлении останется только пометка, без имён инструментов', GOAL_WAIT_TRIES);
658
+ const goalTools = bridge?.modelToolName
659
+ ? { update: bridge.modelToolName('update_goal'), get: bridge.modelToolName('get_goal') }
660
+ : undefined;
661
+ log(`[goal] наставление: ${goalTools ? `инструмент ${goalTools.update}, пометка запасной линией` : 'моста нет, только пометка'}`);
662
+ view = goals.edit(agent, { id: view.id, revision: view.revision },
663
+ { objective: `${rest}\n\n${marker.guidance(view.id, view.revision + 1, goalTools)}` });
664
+ } else {
665
+ log('[goal] ⚠️ сервис goalMarker не смонтирован: цель поставлена, но закрыть её сама модель не сможет — цикл дойдёт до предела раундов');
666
+ }
667
+ goalStarts.get(channel).push(Date.now());
668
+ goalOrigin.set(String(agent.session?.id ?? ''), { channel, agent });
669
+ log(`[goal] поставлена ${view.id} из ${channel}, предел ${view.maxGoalRounds} раундов: ${rest.slice(0, 60)}`);
670
+ return `Цель ${view.id} поставлена, предел ${view.maxGoalRounds} раундов.`;
671
+ } catch (e) {
672
+ const code = e?.code ?? '';
673
+ log(`[goal] 🔴 постановка не удалась (${code}): ${e?.message ?? e}`);
674
+ if (code === 'GOAL_ALREADY_EXISTS') return 'Цель уже поставлена. Сначала /goal stop.';
675
+ return `Не смог поставить цель: ${e?.message ?? e}`;
676
+ }
677
+ }
678
+
444
679
  // ── ВЫХОД: события сессии → сообщения в Telegram
445
680
  ctx.on('session/event', (session, event) => {
446
681
  // 🔴 DEBUG 2026-08-18: все типы событий
447
682
  log(`[event] type=${event.type} session.id=${session?.id} knownSessions=${[...sessionToChat.keys()].join('|')}`);
448
683
  const sid = String(session?.id ?? '');
684
+ // 🔴 ИСХОД ЦЕЛИ СООБЩАЕМ САМИ. Доводчик при упоре в предел раундов переводит
685
+ // цель в blocked — и делает это молча: снаружи автономный цикл просто
686
+ // перестаёт просыпаться. Молчаливый останов неотличим от поломки, поэтому
687
+ // говорим в тот канал, из которого цель ставили, и ровно один раз.
688
+ // Блок стоит ДО проверки chatId нарочно: у служебного канала числового чата
689
+ // нет вовсе, и ранний выход съел бы событие.
690
+ if (event.type === 'goal/change') {
691
+ const origin = goalOrigin.get(sid);
692
+ if (origin) {
693
+ try {
694
+ // Ожидания сервиса здесь нет намеренно — см. решение по ctx.get выше:
695
+ // сюда попадают только сессии, где цель уже поставлена, значит пустота
696
+ // означает не «ещё не поднялся», а «пропал», и это стоит строки.
697
+ const goalsSvc = ctx.get('goals');
698
+ if (!goalsSvc) {
699
+ log(`[goal] 🔴 сервис целей исчез — исход цели по сессии ${sid} не сообщён`);
700
+ return;
701
+ }
702
+ const view = goalsSvc.get(origin.agent);
703
+ if (view && view.phase !== 'active') {
704
+ goalOrigin.delete(sid);
705
+ void sayTo(origin.channel, tgChatFor.get(sid) ?? sessionToChat.get(sid),
706
+ `Цель ${view.id}: ${view.phase}`
707
+ + (view.blockedReason ? `, причина: ${view.blockedReason}` : '')
708
+ + `. Раундов пройдено ${view.roundsStarted} из ${view.maxGoalRounds}.`);
709
+ }
710
+ } catch (e) { log(`[goal] исход цели не прочитан: ${e?.message ?? e}`); }
711
+ }
712
+ }
449
713
  // 🔴 При слиянии sessionToChat указывает на КЛЮЧ служебной сессии, а не на
450
714
  // числовой чат — отправка по нему молча не дойдёт. Настоящий чат берём из
451
715
  // карты, заполняемой на входе из мессенджера.
@@ -480,7 +744,12 @@ export function apply(ctx, config = {}) {
480
744
  && (turnAsk.get(sid)?.origin ?? lastOrigin.get(sid) ?? (chatId === A2A_CHAT ? 'a2a' : 'tg')) === 'a2a') {
481
745
  // ответ координатору — в файл, Telegram тут ни при чём
482
746
  const blocks = event.data?.message?.content ?? [];
483
- const text = blocks.filter((b) => b?.type === 'text').map((b) => b.text).join('\n').trim();
747
+ // 🔴 МАРКЕР ВЫРЕЗАЕМ ИЗ ВСЕГО, ЧТО УХОДИТ НАРУЖУ — из доставляемого
748
+ // текста и из копий. Он служебный: его дело остановить цикл, а не
749
+ // попасть человеку в чат. Вырезается только маркер последней строки;
750
+ // маркер в середине оставляем видимым нарочно — он и так отвергнут,
751
+ // и пусть будет заметен читателю.
752
+ const text = stripGoalMarker(blocks.filter((b) => b?.type === 'text').map((b) => b.text).join('\n')).trim();
484
753
  if (text) {
485
754
  try {
486
755
  fs.mkdirSync(A2A_OUT, { recursive: true });
@@ -492,11 +761,11 @@ export function apply(ctx, config = {}) {
492
761
  } else if (event.type === 'assistant/message') {
493
762
  // Берём только видимый текст: рассуждения модели наружу не отдаём.
494
763
  const blocks = event.data?.message?.content ?? [];
495
- const text = blocks
764
+ // Маркер цели наружу не отдаём — см. пояснение веткой выше.
765
+ const text = stripGoalMarker(blocks
496
766
  .filter((b) => b?.type === 'text')
497
767
  .map((b) => b.text)
498
- .join('\n')
499
- .trim();
768
+ .join('\n')).trim();
500
769
  if (text) {
501
770
  log(`[event] отправляю в Telegram chatId=${chatId} (${text.length} знаков)`);
502
771
  { const c = copyTo('tg'); if (c) sendCopy(c, sid, text); }
@@ -524,13 +793,12 @@ export function apply(ctx, config = {}) {
524
793
  return tmp;
525
794
  }
526
795
 
527
- // Транскрибирует голосовое сообщение через нашу локальную цепочку.
796
+ // Расшифровывает голосовое сообщение внешней командой из настройки.
528
797
  // Возвращает строку с текстом или null при ошибке.
529
798
  async function transcribeVoice(fileId) {
530
799
  let tmpAudio = null;
531
800
  try {
532
801
  tmpAudio = await downloadTelegramFile(fileId);
533
- // transcribe-local-shared — наша цепочка: sage-corrector → transcribe-whispercpp-core → whisper-warm.service
534
802
  // 🔴 Внешняя команда расшифровки — НАСТРОЙКА, а не зашитый путь: у каждого
535
803
  // своя цепочка. Не задана — голосовые просто не расшифровываются, и об этом
536
804
  // говорится в журнал, а не молча игнорируется.
@@ -608,6 +876,25 @@ export function apply(ctx, config = {}) {
608
876
  return;
609
877
  }
610
878
 
879
+ // Постановка цели — тоже команда канала, разбираем до передачи агенту.
880
+ if (text === '/goal' || text.startsWith('/goal ')) {
881
+ // Право проверяем ДО создания агента: отказ не должен заводить сессию.
882
+ if (!goalUsers.has(Number(userId))) {
883
+ log(`[goal] ОТКАЗ ${userId}: не в списке goalUsers`);
884
+ await tg.send(chatId, 'Постановка цели из этого канала вам не разрешена.');
885
+ return;
886
+ }
887
+ const key = (MERGE_CHAT && String(chatId) === MERGE_CHAT) ? A2A_CHAT : chatId;
888
+ try {
889
+ const { handle: gh } = await agentFor(key);
890
+ await sayTo('tg', chatId, await goalCommand(text, 'tg', gh.agent));
891
+ } catch (e) {
892
+ log(`[goal] 🔴 команда не выполнена: ${e?.message ?? e}`);
893
+ await tg.send(chatId, `Не смог выполнить команду: ${e?.message ?? e}`);
894
+ }
895
+ return;
896
+ }
897
+
611
898
  // При включённом слиянии владелец попадает в ту же сессию, что и служебный
612
899
  // канал: одна память на двоих. Ответ при этом обязан вернуться в мессенджер,
613
900
  // поэтому запоминаем настоящий чат отдельно.
@@ -711,10 +998,32 @@ export function apply(ctx, config = {}) {
711
998
  // который нам не по правам (например 600 root:root), молча оставался
712
999
  // лежать во входящих. Снаружи это «агент не отвечает», а на деле
713
1000
  // мы даже не смогли прочитать вопрос. Поймано 18.08.2026.
714
- log(`[a2a] 🔴 не смогла прочитать ${f}: ${e?.message ?? e} (права: попробуй chown dsh:dsh + chmod 644)`);
1001
+ log(`[a2a] 🔴 не смог прочитать ${f}: ${e?.message ?? e} (права: файл должен принадлежать владельцу агента и быть читаемым, chmod 644)`);
715
1002
  continue;
716
1003
  }
717
1004
  if (!text) continue;
1005
+ // Заголовок отправителя отделяем ВСЕГДА: он служебный и в разговоре лишний.
1006
+ const { sender, text: body } = splitSender(text);
1007
+ if (sender !== undefined) text = body;
1008
+ if (!text) continue;
1009
+ if (text === '/goal' || text.startsWith('/goal ')) {
1010
+ if (!goalA2ASenders.has(sender)) {
1011
+ log(`[goal] ОТКАЗ служебному каналу: отправитель ${sender ?? '(не назвался)'} не в списке goalA2ASenders`);
1012
+ await sayTo('a2a', null, sender === undefined
1013
+ ? 'Постановка цели из служебного канала требует заголовка первой строкой: "From: <имя>".'
1014
+ : `Отправитель ${sender} не вправе ставить цель.`);
1015
+ } else {
1016
+ try {
1017
+ const { handle: gh } = await agentFor(A2A_CHAT);
1018
+ await sayTo('a2a', null, await goalCommand(text, 'a2a', gh.agent));
1019
+ } catch (e) {
1020
+ log(`[goal] 🔴 команда не выполнена: ${e?.message ?? e}`);
1021
+ await sayTo('a2a', null, `Не смог выполнить команду: ${e?.message ?? e}`);
1022
+ }
1023
+ }
1024
+ try { fs.unlinkSync(full); } catch { /* уже убрали */ }
1025
+ continue;
1026
+ }
718
1027
  try {
719
1028
  const { handle, sessionId: a2aSessionId } = await agentFor(A2A_CHAT);
720
1029
  lastOrigin.set(a2aSessionId, 'a2a');
@@ -726,7 +1035,7 @@ export function apply(ctx, config = {}) {
726
1035
  // Наш агент на этом верно упёрся — отказался выполнять просьбу,
727
1036
  // подписанную чужим именем, пришедшую не из своего канала. Значит
728
1037
  // происхождение обязан сообщать код доставки, которому подделать нечем.
729
- text = `[служебный канал, от координатора]\n${stripMark(text)}`;
1038
+ text = `[служебный канал, от координатора${sender ? ` ${sender}` : ''}]\n${stripMark(text)}`;
730
1039
  handle.agent.send(platform.createUserMessage({
731
1040
  content: [{ type: 'text', text }],
732
1041
  source: { kind: 'user' },
@@ -0,0 +1,44 @@
1
+ // Вырезает тело именованной функции ИЗ ФАЙЛА пакета — стенд обязан проверять
2
+ // установленный код, а не его пересказ. Поиск по ИМЕНИ, не по номеру строки:
3
+ // номер строки протухает от первой же правки выше по файлу.
4
+ import fs from 'node:fs';
5
+
6
+ export function readSource(file) {
7
+ return fs.readFileSync(file, 'utf-8');
8
+ }
9
+
10
+ export function extractFunction(src, name) {
11
+ const re = new RegExp(`(?:^|\\n)\\s*(?:async\\s+)?function\\s+${name}\\s*\\(`);
12
+ const m = re.exec(src);
13
+ if (!m) throw new Error(`функция ${name} не найдена в исходнике`);
14
+ // 🔴 Начало берём от `async`, если оно есть: обрезав его, стенд получил бы
15
+ // обычную функцию с await внутри и упал бы с «await is only valid in async
16
+ // functions» — то есть покраснел бы на исправном коде.
17
+ const fnAt = src.indexOf('function', m.index);
18
+ const asyncAt = src.lastIndexOf('async', fnAt);
19
+ const start = (asyncAt !== -1 && /^async\s+function$/.test(src.slice(asyncAt, fnAt + 8).replace(/\s+/g, ' ').trim().replace(/ $/, '')))
20
+ ? asyncAt : fnAt;
21
+ const open = src.indexOf('{', src.indexOf('(', start));
22
+ let depth = 0, i = open, inStr = null, esc = false;
23
+ for (; i < src.length; i++) {
24
+ const c = src[i];
25
+ if (inStr) {
26
+ if (esc) { esc = false; continue; }
27
+ if (c === '\\') { esc = true; continue; }
28
+ if (c === inStr) inStr = null;
29
+ continue;
30
+ }
31
+ if (c === '"' || c === "'" || c === '`') { inStr = c; continue; }
32
+ if (c === '{') depth++;
33
+ else if (c === '}') { depth--; if (depth === 0) break; }
34
+ }
35
+ if (depth !== 0) throw new Error(`не удалось сбалансировать скобки у ${name}`);
36
+ return src.slice(start, i + 1);
37
+ }
38
+
39
+ export function extractConst(src, name) {
40
+ const re = new RegExp(`\\n\\s*const\\s+${name}\\s*=\\s*([^;\\n]+);`);
41
+ const m = re.exec(src);
42
+ if (!m) throw new Error(`константа ${name} не найдена`);
43
+ return m[1].trim();
44
+ }
@@ -0,0 +1,212 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Приёмка постановки цели из канала (1.3.0).
4
+ *
5
+ * Правила, по которым он написан:
6
+ * — текст проверяемых функций берётся ИЗ ФАЙЛА пакета, а не пишется заново;
7
+ * — рядом с вердиктом печатается сырьё, на котором он основан;
8
+ * — код возврата: 0 всё сошлось, 1 расхождение, 2 стенд не смог проверить.
9
+ *
10
+ * Гоняется и на распакованном тарболе: `node test/test-goal-control.mjs`.
11
+ */
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+ import { readSource, extractFunction, extractConst } from './extract.mjs';
16
+
17
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
18
+ const PKG = path.resolve(HERE, '..');
19
+ const SRC_FILE = path.join(PKG, 'src', 'index.js');
20
+
21
+ let ok = 0, bad = 0, blind = 0;
22
+ const pass = (t, raw) => { ok++; console.log(` ok ${t}${raw !== undefined ? ` [${raw}]` : ''}`); };
23
+ const fail = (t, raw) => { bad++; console.log(` FAIL ${t}${raw !== undefined ? ` [${raw}]` : ''}`); };
24
+ const blin = (t, raw) => { blind++; console.log(` ?? ${t} — СТЕНД НЕ СМОГ ПРОВЕРИТЬ${raw !== undefined ? ` [${raw}]` : ''}`); };
25
+ const eq = (t, got, want) => (String(got) === String(want) ? pass(t, `got=${got}`) : fail(t, `got=${got} want=${want}`));
26
+
27
+ let src;
28
+ try { src = readSource(SRC_FILE); }
29
+ catch (e) { console.error(`СТЕНД СЛЕП: не читается ${SRC_FILE}: ${e.message}`); process.exit(2); }
30
+
31
+ // ── 0. Манифест не обещает того, чего нет на диске ────────────────────────────
32
+ console.log('0. целость манифеста');
33
+ try {
34
+ const pj = JSON.parse(fs.readFileSync(path.join(PKG, 'package.json'), 'utf-8'));
35
+ const promised = new Set([...(pj.files ?? []), pj.main, pj.dsh?.bundle?.patch].filter(Boolean)
36
+ .map((f) => f.replace(/^\.\//, '')));
37
+ for (const f of promised) {
38
+ fs.existsSync(path.join(PKG, f))
39
+ ? pass(`манифест обещает ${f} — файл на месте`)
40
+ : fail(`манифест обещает ${f}, а его на диске НЕТ`);
41
+ }
42
+ eq('версия пакета', pj.version, '1.3.0');
43
+ } catch (e) { blin('манифест', e.message); }
44
+
45
+ // ── 1. Счётчик постановок ─────────────────────────────────────────────────────
46
+ console.log('1. счётчик постановок');
47
+ try {
48
+ const MAX = Number(extractConst(src, 'GOAL_MAX_PER_HOUR'));
49
+ eq('предел из файла', MAX, 3);
50
+ const goalStarts = new Map();
51
+ const goalRateOk = new Function('goalStarts', 'GOAL_MAX_PER_HOUR',
52
+ `${extractFunction(src, 'goalRateOk')}; return goalRateOk;`)(goalStarts, MAX);
53
+ let allowed = 0;
54
+ for (let i = 0; i < 5; i++) if (goalRateOk('tg')) { allowed++; goalStarts.get('tg').push(Date.now()); }
55
+ eq(`из пяти подряд пропущено ${MAX}`, allowed, MAX);
56
+ // старые метки выпадают из скользящего часа
57
+ goalStarts.set('tg', [Date.now() - 3600001, Date.now() - 3600001, Date.now() - 3600001]);
58
+ eq('метки старше часа не считаются', goalRateOk('tg'), true);
59
+ // счётчик отдельный на канал
60
+ goalStarts.set('tg', [Date.now(), Date.now(), Date.now()]);
61
+ eq('канал tg исчерпан', goalRateOk('tg'), false);
62
+ eq('канал a2a при этом свободен', goalRateOk('a2a'), true);
63
+ } catch (e) { blin('счётчик', e.message); }
64
+
65
+ // ── 2. Заголовок отправителя ──────────────────────────────────────────────────
66
+ console.log('2. заголовок отправителя');
67
+ try {
68
+ const splitSender = new Function(`${extractFunction(src, 'splitSender')}; return splitSender;`)();
69
+ let r = splitSender('From: coordinator\n/goal сделать дело');
70
+ eq('имя разобрано', r.sender, 'coordinator');
71
+ eq('заголовок отрезан', r.text, '/goal сделать дело');
72
+ r = splitSender('просто текст без заголовка');
73
+ eq('без заголовка — имени нет', r.sender, undefined);
74
+ eq('без заголовка текст цел', r.text, 'просто текст без заголовка');
75
+ r = splitSender('From: coordinator');
76
+ eq('заголовок без тела — пустой текст', r.text, '');
77
+ r = splitSender('From: two words\nтело');
78
+ eq('имя с пробелом заголовком не считается', r.sender, undefined);
79
+ } catch (e) { blin('splitSender', e.message); }
80
+
81
+ // ── 3. Ожидание сервиса ───────────────────────────────────────────────────────
82
+ console.log('3. ожидание сервиса');
83
+ try {
84
+ const lines = [];
85
+ const log = (m) => lines.push(m);
86
+ const mk = (ctx) => new Function('ctx', 'log', `${extractFunction(src, 'waitService')}; return waitService;`)(ctx, log);
87
+
88
+ let calls = 0;
89
+ const late = mk({ get: () => (++calls >= 3 ? { real: true } : undefined) });
90
+ const t0 = Date.now();
91
+ const got = await late('goals', '[t]', 'последствие названо', 5);
92
+ const spent = Date.now() - t0;
93
+ got ? pass(`сервис дождались за ${spent} мс`, `попыток=${calls}`) : fail('сервис не дождались, хотя он появился');
94
+ lines.some((l) => l.includes('ещё не активен')) ? pass('о начале ожидания сказано') : fail('ожидание молчаливое');
95
+
96
+ lines.length = 0;
97
+ const never = mk({ get: () => undefined });
98
+ const none = await never('goals', '[t]', 'ПОСЛЕДСТВИЕ-МЕТКА', 2);
99
+ eq('не дождались — undefined', none, undefined);
100
+ lines.some((l) => l.includes('ПОСЛЕДСТВИЕ-МЕТКА'))
101
+ ? pass('последствие названо в журнале', lines[lines.length - 1])
102
+ : fail('последствие НЕ названо', lines.join(' | '));
103
+
104
+ lines.length = 0;
105
+ const now = mk({ get: () => ({ real: true }) });
106
+ await now('goals', '[t]', 'последствие', 5);
107
+ lines.length === 0 ? pass('сервис готов сразу — лишних строк нет') : fail('печатает, когда ждать не пришлось', lines.join(' | '));
108
+ } catch (e) { blin('waitService', e.message); }
109
+
110
+ // ── 4. Команда целиком, на подставных сервисах ───────────────────────────────
111
+ console.log('4. команда /goal на подставных сервисах');
112
+ try {
113
+ const lines = [];
114
+ const log = (m) => lines.push(m);
115
+ const goalStarts = new Map();
116
+ const goalOrigin = new Map();
117
+ const agent = { session: { id: 'sess-1' } };
118
+ let created = null;
119
+ const goals = {
120
+ get: () => created,
121
+ create: (_a, { objective }) => (created = { id: 'g1', revision: 1, phase: 'active', roundsStarted: 0, maxGoalRounds: 12, objective }),
122
+ edit: (_a, _k, { objective }) => (created = { ...created, revision: created.revision + 1, objective }),
123
+ clear: () => { created = null; },
124
+ };
125
+ const marker = { guidance: (id, rev, tools) => `НАСТАВЛЕНИЕ ${id}/${rev} ${tools ? tools.update : 'без-инструментов'}` };
126
+ const bridge = { modelToolName: (n) => `mcp__srv__${n}` };
127
+ const services = { goals, goalMarker: marker, mcpBridge: bridge };
128
+ const ctx = { get: (n) => services[n] };
129
+
130
+ const src4 = `${extractFunction(src, 'waitService')}\n${extractFunction(src, 'goalRateOk')}\n${extractFunction(src, 'goalCommand')}\n; return goalCommand;`;
131
+ const goalCommand = new Function('ctx', 'log', 'goalStarts', 'goalOrigin', 'GOAL_MAX_PER_HOUR', 'GOAL_WAIT_TRIES', src4)(
132
+ ctx, log, goalStarts, goalOrigin, Number(extractConst(src, 'GOAL_MAX_PER_HOUR')), 1);
133
+
134
+ let r = await goalCommand('/goal', 'tg', agent);
135
+ eq('цели нет — так и сказано', r, 'Цели нет.');
136
+
137
+ r = await goalCommand('/goal разобрать отказы', 'tg', agent);
138
+ r.includes('поставлена') ? pass('цель поставлена', r) : fail('цель не поставлена', r);
139
+ created.objective.includes('НАСТАВЛЕНИЕ g1/2 mcp__srv__update_goal')
140
+ ? pass('наставление сложено с именем инструмента ОТ МОСТА', created.objective.split('\n').pop())
141
+ : fail('наставление без имени инструмента моста', created.objective);
142
+ eq('происхождение цели запомнено', goalOrigin.get('sess-1')?.channel, 'tg');
143
+
144
+ r = await goalCommand('/goal', 'tg', agent);
145
+ r.startsWith('Цель g1: active') ? pass('состояние показано', r.split('\n')[0]) : fail('состояние не показано', r);
146
+
147
+ r = await goalCommand('/goal stop', 'tg', agent);
148
+ eq('цель снята', r, 'Цель g1 снята.');
149
+ eq('происхождение забыто', goalOrigin.has('sess-1'), false);
150
+
151
+ // предел постановок
152
+ goalStarts.set('a2a', [Date.now(), Date.now(), Date.now()]);
153
+ r = await goalCommand('/goal ещё одна', 'a2a', agent);
154
+ r.includes('Предел') ? pass('четвёртая за час отвергнута', r) : fail('предел не сработал', r);
155
+
156
+ // 🔴 снятие пределом НЕ ограничено — механизм, который нельзя выключить, хуже отсутствия
157
+ created = { id: 'g2', revision: 1, phase: 'active', roundsStarted: 1, maxGoalRounds: 12, objective: 'x' };
158
+ r = await goalCommand('/goal stop', 'a2a', agent);
159
+ eq('снятие при исчерпанном пределе разрешено', r, 'Цель g2 снята.');
160
+
161
+ // сервиса целей нет вовсе
162
+ const lines2 = [];
163
+ const goalCommand2 = new Function('ctx', 'log', 'goalStarts', 'goalOrigin', 'GOAL_MAX_PER_HOUR', 'GOAL_WAIT_TRIES', src4)(
164
+ { get: () => undefined }, (m) => lines2.push(m), new Map(), new Map(), 3, 1);
165
+ r = await goalCommand2('/goal что-нибудь', 'tg', agent);
166
+ r.includes('не подключён') ? pass('без сервиса целей отказ внятный', r) : fail('без сервиса целей ответ невнятный', r);
167
+ lines2.some((l) => l.includes('невыполнима')) ? pass('и последствие названо в журнале', lines2[lines2.length - 1]) : fail('журнал молчит об отказе', lines2.join(' | '));
168
+
169
+ // маркера нет — цель ставится, но об этом сказано
170
+ const lines3 = [];
171
+ let created3 = null;
172
+ const goals3 = { get: () => created3,
173
+ create: (_a, { objective }) => (created3 = { id: 'g3', revision: 1, phase: 'active', roundsStarted: 0, maxGoalRounds: 12, objective }),
174
+ edit: () => { throw new Error('edit не должен зваться без маркера'); }, clear: () => {} };
175
+ const goalCommand3 = new Function('ctx', 'log', 'goalStarts', 'goalOrigin', 'GOAL_MAX_PER_HOUR', 'GOAL_WAIT_TRIES', src4)(
176
+ { get: (n) => (n === 'goals' ? goals3 : undefined) }, (m) => lines3.push(m), new Map(), new Map(), 3, 1);
177
+ r = await goalCommand3('/goal без маркера', 'tg', agent);
178
+ r.includes('поставлена') ? pass('без маркера цель всё равно поставлена', r) : fail('без маркера постановка сорвалась', r);
179
+ lines3.some((l) => l.includes('goalMarker') && l.includes('предела раундов'))
180
+ ? pass('последствие отсутствия маркера названо')
181
+ : fail('об отсутствии маркера промолчали', lines3.join(' | '));
182
+ } catch (e) { blin('goalCommand', e.message); }
183
+
184
+ // ── 5. Описание не расходится с кодом ────────────────────────────────────────
185
+ console.log('5. README, патч и код говорят одно');
186
+ try {
187
+ const readme = fs.readFileSync(path.join(PKG, 'README.md'), 'utf-8');
188
+ const patch = fs.readFileSync(path.join(PKG, 'cordis.patch.yml'), 'utf-8');
189
+ for (const field of ['goalUsers', 'goalA2ASenders']) {
190
+ const inCode = src.includes(`config.${field}`);
191
+ const inReadme = readme.includes(`\`${field}\``);
192
+ const inPatch = patch.includes(`${field}:`);
193
+ (inCode && inReadme && inPatch)
194
+ ? pass(`${field}: код+README+патч`, `код=${inCode} readme=${inReadme} патч=${inPatch}`)
195
+ : fail(`${field} описан не везде`, `код=${inCode} readme=${inReadme} патч=${inPatch}`);
196
+ }
197
+ const maxInCode = extractConst(src, 'GOAL_MAX_PER_HOUR');
198
+ readme.includes('не более трёх за скользящий час') && maxInCode === '3'
199
+ ? pass('предел в README и в коде сходится', `код=${maxInCode}`)
200
+ : fail('предел в README разошёлся с кодом', `код=${maxInCode}`);
201
+ } catch (e) { blin('README', e.message); }
202
+
203
+ // ── 6. Проверка «ничего нашего не уехало» ВЫНЕСЕНА ИЗ ПАКЕТА ───────────────
204
+ // Она лежит рядом со сборкой (proverka-sborki.mjs) и в тарбол не едет.
205
+ // Причина: её образцы — это и есть наши имена и пути, то есть проверка
206
+ // обезличенности сама несла их в опубликованный файл. Обнаружено на
207
+ // сборке 1.3.0: единственное вхождение имени агента в тарболе было
208
+ // внутри самой этой проверки. Чужому человеку она бесполезна — это
209
+ // проверка НАШЕГО процесса сборки, а не работы плагина.
210
+
211
+ console.log(`\nитог: ok=${ok} FAIL=${bad} слепота=${blind}`);
212
+ process.exit(bad > 0 ? 1 : (blind > 0 ? 2 : 0));