diff --git a/app/app/Http/Controllers/Api/ClientTg/CampaignController.php b/app/app/Http/Controllers/Api/ClientTg/CampaignController.php index 3aa08386..6be2dae5 100644 --- a/app/app/Http/Controllers/Api/ClientTg/CampaignController.php +++ b/app/app/Http/Controllers/Api/ClientTg/CampaignController.php @@ -15,6 +15,7 @@ use App\Services\ClientTg\TelegramAudienceService; use App\Services\ClientTg\TelegramCampaignChargeService; use App\Services\ClientTg\TelegramTariffService; use App\Support\PhoneNormalizer; +use App\Support\TelegramLink; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; @@ -73,6 +74,7 @@ class CampaignController extends Controller 'status' => Campaign::STATUS_DRAFT, 'ad_text' => $data['ad_text'], 'ad_link' => $data['ad_link'], + 'ad_headline' => $data['ad_headline'] ?? null, 'ord_category' => $data['ord_category'] ?? 'Размещение рекламы', 'budget_cap_rub' => $data['budget_cap_rub'], 'audience_kind' => $data['audience_kind'], @@ -325,9 +327,19 @@ class CampaignController extends Controller */ private function validatePayload(Request $request): array { + // 🔴 Заголовок обязателен ТОЛЬКО для рекламы сайта: кабинет МТС рисует поле + // «Заголовок объявления» лишь тогда, а без него молча не пускает дальше шага + // «Объявление» — уже ПОСЛЕ списания денег. Для ссылки на канал/бота поля нет, + // и требовать заголовок было бы лишним вопросом клиенту. Проверено глазами + // 31.07.2026 (кампании 2234454 и 2234462). + $headlineRule = TelegramLink::isTelegram((string) $request->input('ad_link', '')) + ? 'nullable|string|max:40' + : 'required|string|max:40'; // = колонка client_tg_campaigns.ad_headline varchar(40) + return $request->validate([ 'ad_text' => 'required|string|max:1000', 'ad_link' => 'required|url|max:500', // = колонка client_tg_campaigns.ad_link varchar(500) + 'ad_headline' => $headlineRule, 'audience_kind' => 'required|in:deals,base,list', 'audience_days' => 'required_if:audience_kind,deals|nullable|integer|min:1|max:365', 'budget_cap_rub' => 'required|numeric|min:1', diff --git a/app/app/Jobs/ClientTg/RunTelegramCampaignJob.php b/app/app/Jobs/ClientTg/RunTelegramCampaignJob.php index db7d9f73..f59164a4 100644 --- a/app/app/Jobs/ClientTg/RunTelegramCampaignJob.php +++ b/app/app/Jobs/ClientTg/RunTelegramCampaignJob.php @@ -109,6 +109,9 @@ class RunTelegramCampaignJob implements ShouldQueue payload: [ 'adText' => $campaign->ad_text, 'buttonUrl' => $campaign->ad_link, + // Заголовок нужен кабинету только для рекламы сайта; у телеграм- + // ссылки он null, и робот его не трогает (поля на странице нет). + 'adHeadline' => $campaign->ad_headline, 'budgetRub' => (string) $campaign->budget_cap_rub, 'ordCategory' => $campaign->ord_category, 'mediaFile' => $campaign->media_path, @@ -137,6 +140,7 @@ class RunTelegramCampaignJob implements ShouldQueue 'phonesFile' => $phonesFile, 'adText' => $campaign->ad_text, 'buttonUrl' => $campaign->ad_link, + 'adHeadline' => $campaign->ad_headline, 'budgetRub' => (string) $campaign->budget_cap_rub, 'ordCategory' => $campaign->ord_category, 'mediaFile' => $campaign->media_path, diff --git a/app/app/Models/ClientTg/Campaign.php b/app/app/Models/ClientTg/Campaign.php index 54949fe3..ba5c6305 100644 --- a/app/app/Models/ClientTg/Campaign.php +++ b/app/app/Models/ClientTg/Campaign.php @@ -106,6 +106,7 @@ class Campaign extends Model 'mts_campaign_id', 'ad_text', 'ad_link', + 'ad_headline', 'media_path', 'moderator_file_path', 'ord_category', diff --git a/app/app/Support/TelegramLink.php b/app/app/Support/TelegramLink.php new file mode 100644 index 00000000..8f5f9d26 --- /dev/null +++ b/app/app/Support/TelegramLink.php @@ -0,0 +1,39 @@ +string('ad_headline', 40)->nullable()->after('ad_link'); + }); + } + + public function down(): void + { + Schema::table('client_tg_campaigns', function (Blueprint $table) { + $table->dropColumn('ad_headline'); + }); + } +}; diff --git a/app/phpstan-baseline.neon b/app/phpstan-baseline.neon index 99a70187..21dc59db 100644 --- a/app/phpstan-baseline.neon +++ b/app/phpstan-baseline.neon @@ -5795,3 +5795,33 @@ parameters: identifier: argument.type count: 1 path: tests/Unit/Supplier/SupplierQuotaAllocatorTest.php + + - + message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#' + identifier: property.notFound + count: 2 + path: tests/Feature/ClientTg/AdHeadlineTest.php + + - + message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#' + identifier: property.notFound + count: 1 + path: tests/Feature/ClientTg/AdHeadlineTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#' + identifier: method.notFound + count: 1 + path: tests/Feature/ClientTg/AdHeadlineTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#' + identifier: method.notFound + count: 7 + path: tests/Feature/ClientTg/AdHeadlineTest.php + + - + message: '#^Offset ''adHeadline'' does not exist on string\.$#' + identifier: offsetAccess.notFound + count: 1 + path: tests/Feature/ClientTg/AdHeadlineTest.php diff --git a/app/resources/js/api/telegram.ts b/app/resources/js/api/telegram.ts index 1eda0c4e..a25fd3a5 100644 --- a/app/resources/js/api/telegram.ts +++ b/app/resources/js/api/telegram.ts @@ -54,6 +54,8 @@ export interface TelegramCreatePayload { audience_days?: number; ord_category?: string; phones?: string[]; + /** Заголовок объявления — кабинет МТС требует его для рекламы САЙТА (до 40 знаков). */ + ad_headline?: string; } /** GET /api/telegram/campaigns — список кампаний тенанта + режим + баланс. */ diff --git a/app/resources/js/views/advertising/AdvertisingTelegramView.vue b/app/resources/js/views/advertising/AdvertisingTelegramView.vue index 4eedb473..f0e364e3 100644 --- a/app/resources/js/views/advertising/AdvertisingTelegramView.vue +++ b/app/resources/js/views/advertising/AdvertisingTelegramView.vue @@ -28,6 +28,8 @@ const campaigns = ref([]); const adText = ref(''); const adLink = ref(''); +// Заголовок объявления — нужен ТОЛЬКО когда рекламируем сайт (см. nuzhenZagolovok). +const adHeadline = ref(''); const audienceKind = ref('deals'); const audienceDays = ref('30'); const phonesText = ref(''); @@ -73,10 +75,27 @@ const AUDIENCE_OPTIONS: { value: TelegramAudienceKind; label: string; testid: st { value: 'list', label: 'Свой список номеров', testid: 'tg-audience-list' }, ]; +/** + * Рекламируем сайт, а не телеграм-канал? + * + * 🔴 Кабинет МТС в этом случае требует «Заголовок объявления» (до 40 знаков). + * Без него кампания вставала на шаге «Объявление» — уже ПОСЛЕ списания денег + * (проверено глазами 31.07.2026). Поэтому спрашиваем заголовок здесь, заранее. + * Пустая ссылка заголовка не требует — человек ещё не дописал. + */ +const nuzhenZagolovok = computed(() => { + const link = adLink.value.trim().toLowerCase(); + if (link === '') return false; + if (link.startsWith('tg:')) return false; + return !/^https?:\/\/(www\.)?(t\.me|telegram\.me|telegram\.dog)(\/|$)/.test(link); +}); + /** Форма заполнена настолько, что можно запросить расчёт (смету/охват). */ const canCalc = computed(() => { if (calculating.value) return false; if (adText.value.trim() === '' || adLink.value.trim() === '' || budgetCap.value.trim() === '') return false; + // Реклама сайта без заголовка — кабинет не пустит, считать смету незачем. + if (nuzhenZagolovok.value && adHeadline.value.trim() === '') return false; // «Свой список»: без номеров считать нечего. if (audienceKind.value === 'list' && parsePhones().length === 0) return false; return true; @@ -110,6 +129,7 @@ function buildPayload(): TelegramCreatePayload { ad_text: adText.value.trim(), ad_link: adLink.value.trim(), audience_kind: audienceKind.value, + ...(nuzhenZagolovok.value ? { ad_headline: adHeadline.value.trim() } : {}), budget_cap_rub: budgetCap.value, ord_category: ordCategory.value.trim() || 'Размещение рекламы', }; @@ -159,7 +179,7 @@ async function launch(): Promise { // Любая правка формы делает прежний расчёт недействительным — прячем смету и // снова требуем «Рассчитать», чтобы запуск шёл строго по актуальным данным. -watch([adText, adLink, audienceKind, audienceDays, phonesText, budgetCap, ordCategory, mediaFile], () => { +watch([adText, adLink, adHeadline, audienceKind, audienceDays, phonesText, budgetCap, ordCategory, mediaFile], () => { draft.value = null; }); @@ -288,6 +308,21 @@ onUnmounted(() => { variant="outlined" /> + +
Кому показывать
tenant = Tenant::factory()->create(); + $this->user = User::factory()->create(['tenant_id' => $this->tenant->id]); + $this->actingAs($this->user); +}); + +/** @return array */ +function tgHeadlinePayload(array $overrides = []): array +{ + return array_merge([ + 'ad_text' => 'Приходите к нам', + 'ad_link' => 'https://t.me/example_channel', + 'audience_kind' => 'list', + 'budget_cap_rub' => '1000.00', + 'phones' => ['79990000001', '79990000002'], + ], $overrides); +} + +it('реклама САЙТА без заголовка — не создаём кампанию', function () { + $response = $this->postJson('/api/telegram/campaigns', tgHeadlinePayload([ + 'ad_link' => 'https://liderra.ru', + ])); + + $response->assertStatus(422)->assertJsonValidationErrors('ad_headline'); + expect(Campaign::count())->toBe(0); +}); + +it('реклама САЙТА с заголовком — кампания создаётся, заголовок сохранён', function () { + $response = $this->postJson('/api/telegram/campaigns', tgHeadlinePayload([ + 'ad_link' => 'https://liderra.ru', + 'ad_headline' => 'Лидерра — заявки для бизнеса', + ])); + + $response->assertCreated(); + expect(Campaign::first()->ad_headline)->toBe('Лидерра — заявки для бизнеса'); +}); + +it('реклама телеграм-канала — заголовок не нужен', function () { + $response = $this->postJson('/api/telegram/campaigns', tgHeadlinePayload()); + + $response->assertCreated(); + expect(Campaign::first()->ad_headline)->toBeNull(); +}); + +it('заголовок длиннее 40 знаков не принимается — кабинет режет молча', function () { + $response = $this->postJson('/api/telegram/campaigns', tgHeadlinePayload([ + 'ad_link' => 'https://liderra.ru', + 'ad_headline' => str_repeat('я', 41), + ])); + + $response->assertStatus(422)->assertJsonValidationErrors('ad_headline'); +}); + +it('ссылка на бота t.me тоже считается телеграмом — заголовок не требуем', function () { + $response = $this->postJson('/api/telegram/campaigns', tgHeadlinePayload([ + 'ad_link' => 'https://t.me/liderra_bot?start=1', + ])); + + $response->assertCreated(); +}); + +it('заголовок доезжает до задания робота', function () { + config(['client_tg.robot.transport' => 'poll', 'client_tg.sandbox' => true]); + Queue::fake(); + + $this->postJson('/api/telegram/campaigns', tgHeadlinePayload([ + 'ad_link' => 'https://liderra.ru', + 'ad_headline' => 'Лидерра', + ]))->assertCreated(); + + $campaign = Campaign::first(); + $this->postJson("/api/telegram/campaigns/{$campaign->id}/launch")->assertOk(); + + // Джоб в очереди подделан — выполняем его тело руками, как это делают + // соседние тесты канала: важно, что в задании робота есть заголовок. + (new RunTelegramCampaignJob($campaign->id, $this->tenant->id))->handle( + app(TelegramAudienceService::class), + app(TelegramRobotRunner::class), + ); + + expect(RobotJob::first()->payload['adHeadline'])->toBe('Лидерра'); +}); diff --git a/app/tests/Frontend/advertising-telegram-view.spec.ts b/app/tests/Frontend/advertising-telegram-view.spec.ts index 31a575b6..e0319638 100644 --- a/app/tests/Frontend/advertising-telegram-view.spec.ts +++ b/app/tests/Frontend/advertising-telegram-view.spec.ts @@ -477,3 +477,64 @@ describe('AdvertisingTelegramView — экран клиентской Telegram- expect(wrapper.find('[data-testid="tg-error"]').text()).toContain('Не хватает денег'); }); }); + +// Находка приёмки глазами 31.07.2026: кабинет МТС требует «Заголовок объявления» +// (до 40 знаков), когда рекламируется САЙТ. Без него кампания вставала уже ПОСЛЕ +// списания денег. Значит, спросить заголовок надо здесь — до запуска. +describe('AdvertisingTelegramView — заголовок для рекламы сайта', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('со ссылкой на сайт показывает поле заголовка', async () => { + const wrapper = mountView(); + await flushPromises(); + + await wrapper.find('[data-testid="tg-ad-link"] input').setValue('https://liderra.ru'); + await flushPromises(); + + expect(wrapper.find('[data-testid="tg-ad-headline"]').exists()).toBe(true); + }); + + it('со ссылкой на телеграм-канал поле заголовка не показывает', async () => { + const wrapper = mountView(); + await flushPromises(); + + await wrapper.find('[data-testid="tg-ad-link"] input').setValue('https://t.me/example'); + await flushPromises(); + + expect(wrapper.find('[data-testid="tg-ad-headline"]').exists()).toBe(false); + }); + + it('со ссылкой на сайт без заголовка не даёт считать смету', async () => { + const wrapper = mountView(); + await flushPromises(); + + await wrapper.find('[data-testid="tg-ad-text"] textarea').setValue('Приходите к нам'); + await wrapper.find('[data-testid="tg-ad-link"] input').setValue('https://liderra.ru'); + await wrapper.find('[data-testid="tg-audience-base"] input').setValue(); + await wrapper.find('[data-testid="tg-budget-cap"] input').setValue('1000'); + await flushPromises(); + + expect(wrapper.find('[data-testid="tg-calc"]').classes()).toContain('v-btn--disabled'); + }); + + it('заголовок уезжает на сервер вместе с кампанией', async () => { + const wrapper = mountView(); + await flushPromises(); + + await wrapper.find('[data-testid="tg-ad-text"] textarea').setValue('Приходите к нам'); + await wrapper.find('[data-testid="tg-ad-link"] input').setValue('https://liderra.ru'); + await wrapper.find('[data-testid="tg-ad-headline"] input').setValue('Лидерра'); + await wrapper.find('[data-testid="tg-audience-base"] input').setValue(); + await wrapper.find('[data-testid="tg-budget-cap"] input').setValue('1000'); + await flushPromises(); + + await wrapper.find('[data-testid="tg-calc"]').trigger('click'); + await flushPromises(); + + expect(createTelegramMock).toHaveBeenCalledWith( + expect.objectContaining({ ad_headline: 'Лидерра' }), + ); + }); +}); diff --git a/bots/mts-telegram-ads/src/cabinet.js b/bots/mts-telegram-ads/src/cabinet.js index 5d1fd115..8c8db2a6 100644 --- a/bots/mts-telegram-ads/src/cabinet.js +++ b/bots/mts-telegram-ads/src/cabinet.js @@ -64,6 +64,18 @@ export function parseModerationStatus(text) { // «Продолжить» остаётся неактивной. Подтверждено картой FLOW-FINDINGS.md. export const MIN_NON_MTS = 367; +// 🔴 Приёмка глазами 31.07.2026: когда рекламируется САЙТ (а не телеграм-канал), +// кабинет показывает ОБЯЗАТЕЛЬНОЕ поле «Заголовок объявления» (до 40 знаков) и +// «Текст на кнопке». Робот их не знал: «Продолжить» жалось, но страница молча +// оставалась на шаге «Объявление», а наверх уходило невнятное «не перешли на +// /budget». Падать надо СРАЗУ и своими словами — иначе причина не видна. +export function assertHeadlineWhenRequired(fieldPresent, headline) { + if (!fieldPresent) return; + if (typeof headline !== 'string' || headline.trim() === '') { + throw new Error('Кабинет требует заголовок объявления (реклама сайта), а он не задан'); + } +} + // 🔴 Ревью-фикс F6: кнопки, которые ТРАТЯТ живые деньги клиента / запускают // кампанию — робот НЕ жмёт их НИКОГДА (ни в draft, ни в live). Реальная отправка/ // оплата на шаге /payment («Списать с баланса…») wire-ится в Сессии 6 под флаг. @@ -427,6 +439,8 @@ export async function fillAd(page, config, task) { await fillAdText(page, config, task.adText); if (task.mediaFile) await fillAdMedia(page, config, task.mediaFile); await fillAdLink(page, config, task.buttonUrl); + // Заголовок — ПОСЛЕ ссылки: поле появляется в ответ на ссылку на сайт. + await fillAdHeadline(page, config, task.adHeadline); await selectOrdCategory(page, config, task.ordCategory); // Документ модератору при ПЕРЕСДАЧЕ (задача 3.6): без файла — шаг не трогаем. if (task.moderatorFile) await attachModeratorDoc(page, config, task.moderatorFile); @@ -447,6 +461,36 @@ export async function fillAdMedia(page, config, mediaFile) { await humanPause(config); } +// Заголовок объявления — поле «Название компании, сервиса, товара или услуги» +// (до 40 знаков). Кабинет рисует его ТОЛЬКО для рекламы сайта, поэтому наличие +// поля СПРАШИВАЕМ У СТРАНИЦЫ, а не угадываем по виду ссылки: правило «сайт vs +// канал» — их, а не наше, и может поменяться без предупреждения. +export async function fillAdHeadline(page, config, headline) { + // 🪤 Ловушка, стоившая четырёх живых прогонов 31.07.2026: серая надпись + // «Название компании, сервиса, товара или услуги» внутри поля — это НЕ атрибут + // placeholder, а нарисованная подпись. Поиск `input[placeholder^="Название + // компании"]` находил НОЛЬ совпадений, при том что поле было на экране (видно + // на снимке), и робот молча его пропускал. Опознаём поле так же, как человек: + // «первый ввод после видимой надписи "Заголовок объявления"» — проверено + // замером: 1 совпадение, текст доезжает. + // Кабинет дорисовывает блок в ответ на ссылку с задержкой — поэтому ЖДЁМ. + const pole = page + .locator('xpath=//*[contains(normalize-space(text()),"Заголовок объявления")]/following::input[1]') + .first(); + const estPole = await pole + .waitFor({ state: 'visible', timeout: 8000 }) + .then(() => true) + .catch(() => false); + + assertHeadlineWhenRequired(estPole, headline); + if (!estPole) return; + + // Кабинет режет по 40 знаков молча — режем сами, чтобы отправленное совпадало + // с тем, что видит клиент в портале. + await pole.fill(headline.trim().slice(0, 40)); + await humanPause(config); +} + // Ссылка «Что рекламируете» (канал/бот/сайт). export async function fillAdLink(page, config, url) { await page.locator('input[placeholder^="Ссылка на канал"]').fill(url); diff --git a/bots/mts-telegram-ads/src/task.js b/bots/mts-telegram-ads/src/task.js index ce5c5a8f..873d7ed4 100644 --- a/bots/mts-telegram-ads/src/task.js +++ b/bots/mts-telegram-ads/src/task.js @@ -57,10 +57,15 @@ export function parseTask(raw) { moderatorFile: raw.moderatorFile ?? null, clientTag: raw.clientTag ?? null, mode, - // Шаг «Объявление» (FLOW-FINDINGS.md §6): из обязательного — только категория - // ОРД (дефолт «Размещение рекламы», подтверждён живьём). Полей «Заголовок» и - // «Название компании» в кабинете НЕТ — рекламодатель (наш ИП) и маркировка - // подставляются автоматически, поэтому adTitle/ordCompanyName убраны. + // Шаг «Объявление» (FLOW-FINDINGS.md §6): из обязательного — категория ОРД + // (дефолт «Размещение рекламы», подтверждён живьём). Поля «Название компании» + // в кабинете нет — рекламодатель (наш ИП) подставляется автоматически. ordCategory: raw.ordCategory ?? 'Размещение рекламы', + // 🔴 Проверено глазами 31.07.2026: «Заголовок объявления» (до 40 знаков) + // кабинет показывает и ТРЕБУЕТ, только когда рекламируется САЙТ. Для ссылки + // на телеграм-канал/бота этого поля на странице нет. Раньше здесь стояло + // «поля Заголовок нет» — это было верно лишь для канала. + // Соседнее «Текст на кнопке» не задаём: кабинет сам ставит «Перейти». + adHeadline: raw.adHeadline ?? null, }; } diff --git a/bots/mts-telegram-ads/test/cabinet-guards.test.js b/bots/mts-telegram-ads/test/cabinet-guards.test.js index b19ca0ac..30acd709 100644 --- a/bots/mts-telegram-ads/test/cabinet-guards.test.js +++ b/bots/mts-telegram-ads/test/cabinet-guards.test.js @@ -1,6 +1,6 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { isForbiddenButtonText, assertCostWithinCap, hrefMatchesCampaignId, isNoPaymentSubmitText } from '../src/cabinet.js'; +import { isForbiddenButtonText, assertCostWithinCap, hrefMatchesCampaignId, isNoPaymentSubmitText, assertHeadlineWhenRequired } from '../src/cabinet.js'; // A-фикс: локатор строки кампании в СПИСКЕ. Живой прогон A1 показал: список // использует href `/cabinet/campaigns/telegram/{id}`, а детальная/визард-страница — @@ -101,3 +101,23 @@ test('без-оплаты не запрещена, «Списать…» зап assert.equal(isForbiddenButtonText('Отправить на модерацию без оплаты'), false); assert.equal(isForbiddenButtonText('Списать с баланса и отправить на модерацию'), true); }); + +// 31.07.2026 (приёмка глазами): со ссылкой на САЙТ кабинет показывает обязательное +// поле «Заголовок объявления». Робот его не заполнял — «Продолжить» молча не +// срабатывало, и кампания вставала с непонятным «не перешли на /budget». +// Правило: есть поле на странице, а заголовка не дали — падаем ВНЯТНО и сразу. +test('поле заголовка есть и заголовок задан — не бросает', () => { + assert.doesNotThrow(() => assertHeadlineWhenRequired(true, 'Лидерра')); +}); + +test('поле заголовка есть, а заголовка нет — бросает про заголовок', () => { + assert.throws(() => assertHeadlineWhenRequired(true, null), /заголов/i); +}); + +test('поле заголовка есть, заголовок из пробелов — бросает', () => { + assert.throws(() => assertHeadlineWhenRequired(true, ' '), /заголов/i); +}); + +test('поля заголовка нет (рекламируем канал) — не бросает и без заголовка', () => { + assert.doesNotThrow(() => assertHeadlineWhenRequired(false, null)); +}); diff --git a/bots/mts-telegram-ads/test/task.test.js b/bots/mts-telegram-ads/test/task.test.js index 6888fd26..44f16b64 100644 --- a/bots/mts-telegram-ads/test/task.test.js +++ b/bots/mts-telegram-ads/test/task.test.js @@ -58,3 +58,16 @@ test('parseTask: без moderatorFile → null (документ не прило const t = parseTask(base); assert.equal(t.moderatorFile, null); }); + +// 31.07.2026, приёмка живьём: кабинет МТС показывает ОБЯЗАТЕЛЬНОЕ поле «Заголовок +// объявления» (до 40 знаков) и «Текст на кнопке», когда рекламируется САЙТ, а не +// телеграм-канал. Со ссылкой на сайт кампания вставала на шаге «Объявление». +test('parseTask пробрасывает adHeadline', () => { + const t = parseTask({ ...base, adHeadline: 'Лидерра' }); + assert.equal(t.adHeadline, 'Лидерра'); +}); + +test('parseTask: без заголовка → null (телеграм-ссылке он не нужен)', () => { + const t = parseTask(base); + assert.equal(t.adHeadline, null); +}); diff --git a/docs/observer/STATUS.md b/docs/observer/STATUS.md index a98ca32c..7862367f 100644 --- a/docs/observer/STATUS.md +++ b/docs/observer/STATUS.md @@ -1,6 +1,6 @@ # Brain Status (auto-generated) -Last updated: 2026-07-31T07:10:49.300Z +Last updated: 2026-08-01T08:41:49.271Z | Контролёр | Состояние | Детали | |---|---|---| @@ -15,7 +15,7 @@ Last updated: 2026-07-31T07:10:49.300Z ⚠️ **ПОСТ ПУСТОЙ** — не зарегистрированы: enforce-floor.mjs, enforce-supreme-gate.mjs, enforce-normative-content-rules.mjs, enforce-read-path-deny.mjs, enforce-mcp-classification.mjs, enforce-judge-gate.mjs, enforce-snapshot.mjs, enforce-floor-escape-consume.mjs, enforce-skill-journaler.mjs, enforce-verify-gate.mjs, enforce-criterion-gate.mjs, enforce-coverage-verify.mjs, enforce-todowrite-skill-verifier.mjs (оборона НЕ подтверждена; SE-B/Δ8) -Судья М4: **live-block** (inert $0 / shadow / floor-only / live-block) +Судья М4: **inert** (inert $0 / shadow / floor-only / live-block) | Машина / страж | Хук | Зарегистрирован | |---|---|---| @@ -39,7 +39,7 @@ Last updated: 2026-07-31T07:10:49.300Z - Observer evidence: 0 episodes this month, 0 observer_error markers, 0 PII matches before filter - Legacy v1 episodes (not in factor analysis): 0 -- Last /brain-retro: 65 day(s) ago +- Last /brain-retro: 66 day(s) ago - Использование узлов: см. `/brain-retro` (раз в спринт). missed_activations: 0. **Неиспользованные узлы — не алерт, если профильной задачи не было** (Pravila §16.4 v1.36; capability-readiness; см. memory `feedback_brain_unused_tools_not_problem` — outside-repo memory store). ## Метрики дисциплины @@ -112,9 +112,9 @@ Episodes since last run: 542 / threshold: 10 | PID | Имя | CPU-время | Возраст | |---|---|---|---| -| 3544 | MsMpEng | 16.48ч | NaNч | -| 23936 | Code | 4.69ч | 0.0ч | -| 4 | System | 2.49ч | 0.0ч | +| 3544 | MsMpEng | 27.74ч | 0.0ч | +| 23936 | Code | 9.91ч | 221364.7ч | +| 4 | System | 4.07ч | NaNч | ⚠️ Проверь, не «осиротевшие» ли это процессы от завершённых Claude-сессий. @@ -130,13 +130,7 @@ Episodes since last run: 542 / threshold: 10 ## Целостность журналов действий -🔴 Битые цепочки (3 из 120): - -| session | broken at seq | -|---|---| -| `03437265-6d58-4622-aeed-c0eeac0f2c32` | 1 | -| `54594686-843c-4ea8-bcd3-5ae6a7244e30` | 14 | -| `9c02276d-dabb-40e4-9c04-44c18d47485a` | 14 | +Ключ подписанта не provisioned — проверка цепи недоступна (ключ — owner-шаг A3). ## Алерт-индикаторы