Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7df4786499 | |||
| 162fe010fe | |||
| 426983ffaa | |||
| 87c5eb6323 | |||
| cb864b18a5 | |||
| 4b4c8d94b9 | |||
| dd0a9ffea6 | |||
| 353b1599b6 | |||
| 97388cf840 | |||
| 8f5a399a25 | |||
| efd3e73aa2 | |||
| 0f1b604554 | |||
| 48d7303963 | |||
| b9e72e6231 | |||
| 80c5f6289a | |||
| 895975482d | |||
| e81cd8ed2c | |||
| bff5faf02b | |||
| 8df5a3fe00 | |||
| 83295a25f3 | |||
| 0fad4305d4 | |||
| 2f60910b09 | |||
| f48d5115ce | |||
| 774763c21c | |||
| c1b690edd3 | |||
| e34b11aca5 | |||
| b4f4f441b5 | |||
| 475e233c2a | |||
| 3e289479f0 | |||
| 0cee520f0d | |||
| c3392bef13 | |||
| 7fed5bc18b | |||
| 43028228c8 | |||
| f1092772fb | |||
| 702c2ff7b5 | |||
| b75f9e3d21 | |||
| 2e26edbb3a | |||
| 643e1a5dcf |
@@ -7,11 +7,24 @@ namespace App\Models;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* Очередь яруса 3 резерва канала миграции проектов.
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-19-supplier-project-channel-failover-design.md §4.5
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $project_id
|
||||
* @property string $platform
|
||||
* @property string $operation
|
||||
* @property string|null $external_id
|
||||
* @property array<string, mixed> $payload_snapshot
|
||||
* @property string $failure_reason
|
||||
* @property string $status
|
||||
* @property int|null $resolved_by_user_id
|
||||
* @property Carbon|null $created_at
|
||||
* @property Carbon|null $resolved_at
|
||||
*/
|
||||
class SupplierManualSyncQueue extends Model
|
||||
{
|
||||
|
||||
@@ -320,9 +320,43 @@ class SupplierPortalClient
|
||||
);
|
||||
}
|
||||
|
||||
// Defense-in-depth: портал отдаёт логин-страницу с HTTP 200 при истекшей
|
||||
// сессии middle-of-use (вместо 401/403). Детектим Yii2-маркер и форсим
|
||||
// refresh+retry. Verified 2026-05-19: refresh-session.js ловит #loginform-username.
|
||||
if ($this->isHtmlLoginPage($response)) {
|
||||
if ($isRetry) {
|
||||
throw new SupplierAuthException(
|
||||
"Portal returned login page after refresh on {$path}",
|
||||
httpStatus: $response->status(),
|
||||
responseBody: $response->body(),
|
||||
);
|
||||
}
|
||||
try {
|
||||
dispatch_sync(app(RefreshSupplierSessionJob::class));
|
||||
} catch (\Throwable $e) {
|
||||
throw new SupplierAuthException(
|
||||
"Session refresh failed during HTML-login retry on {$path}: {$e->getMessage()}",
|
||||
httpStatus: $response->status(),
|
||||
previous: $e,
|
||||
);
|
||||
}
|
||||
|
||||
return $this->request($method, $path, $body, isRetry: true, asJson: $asJson);
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
private function isHtmlLoginPage(Response $response): bool
|
||||
{
|
||||
$contentType = $response->header('Content-Type');
|
||||
if (! str_starts_with(mb_strtolower($contentType), 'text/html')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return preg_match('~loginform-(username|password)~i', $response->body()) === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{phpsessid: string, csrf: string, refreshed_at?: string}
|
||||
*/
|
||||
|
||||
+66
-18
@@ -252,6 +252,12 @@ parameters:
|
||||
count: 1
|
||||
path: app/Services/Project/ProjectService.php
|
||||
|
||||
-
|
||||
message: '#^Call to function is_array\(\) with array\<string, mixed\> will always evaluate to true\.$#'
|
||||
identifier: function.alreadyNarrowedType
|
||||
count: 1
|
||||
path: app/Services/Supplier/Channel/AjaxProjectChannel.php
|
||||
|
||||
-
|
||||
message: '#^Return type \(array\<string, mixed\>\) of method Database\\Factories\\BalanceTransactionFactory\:\:definition\(\) should be compatible with return type \(array\<model property of App\\Models\\BalanceTransaction, mixed\>\) of method Illuminate\\Database\\Eloquent\\Factories\\Factory\<App\\Models\\BalanceTransaction\>\:\:definition\(\)$#'
|
||||
identifier: method.childReturnType
|
||||
@@ -318,6 +324,18 @@ parameters:
|
||||
count: 1
|
||||
path: tests/Feature/Admin/AdminPricingTiersControllerTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 3
|
||||
path: tests/Feature/Admin/AdminSupplierIntegrationTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Admin/AdminSupplierIntegrationTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
@@ -330,6 +348,24 @@ parameters:
|
||||
count: 3
|
||||
path: tests/Feature/Admin/AdminSuppliersControllerTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 4
|
||||
path: tests/Feature/Admin/SupplierManualQueueTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 2
|
||||
path: tests/Feature/Admin/SupplierManualQueueTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 2
|
||||
path: tests/Feature/Admin/SupplierManualQueueTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
@@ -1746,6 +1782,36 @@ parameters:
|
||||
count: 1
|
||||
path: tests/Feature/Supplier/AutoPauseFlowTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Services\\Supplier\\PlaywrightBridge\:\:\$lastArgs\.$#'
|
||||
identifier: property.notFound
|
||||
count: 7
|
||||
path: tests/Feature/Supplier/Channel/FormProjectChannelTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Illuminate\\Contracts\\Cache\\Repository\:\:lock\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Supplier/CsvReconcileJobTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Mockery\\ExpectationInterface\|Mockery\\HigherOrderMessage\:\:andThrow\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 3
|
||||
path: tests/Feature/Supplier/FailoverProjectChannelLiveSmokeTest.php
|
||||
|
||||
-
|
||||
message: '#^Parameter \#1 \$tier1 of class App\\Services\\Supplier\\Channel\\FailoverProjectChannel constructor expects App\\Services\\Supplier\\Channel\\SupplierProjectChannel, Mockery\\MockInterface given\.$#'
|
||||
identifier: argument.type
|
||||
count: 2
|
||||
path: tests/Feature/Supplier/FailoverProjectChannelLiveSmokeTest.php
|
||||
|
||||
-
|
||||
message: '#^Parameter \#2 \$tier2 of class App\\Services\\Supplier\\Channel\\FailoverProjectChannel constructor expects App\\Services\\Supplier\\Channel\\SupplierProjectChannel, Mockery\\MockInterface given\.$#'
|
||||
identifier: argument.type
|
||||
count: 2
|
||||
path: tests/Feature/Supplier/FailoverProjectChannelLiveSmokeTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:artisan\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
@@ -1943,21 +2009,3 @@ parameters:
|
||||
identifier: argument.type
|
||||
count: 3
|
||||
path: tests/Unit/Supplier/SupplierQuotaAllocatorTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 3
|
||||
path: tests/Feature/Admin/AdminSupplierIntegrationTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Admin/AdminSupplierIntegrationTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Illuminate\\Contracts\\Cache\\Repository\:\:lock\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Supplier/CsvReconcileJobTest.php
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"><title>RT Project Form Fixture — Element UI + Vuetify dialog</title>
|
||||
<style>
|
||||
/* Minimal stubs so Playwright class-based locators work */
|
||||
.el-form-item { margin-bottom: 12px; }
|
||||
.el-form-item__label { display: inline-block; min-width: 140px; }
|
||||
.el-form-item__content { display: inline-block; }
|
||||
.el-input__inner { border: 1px solid #cccccc; padding: 4px 8px; }
|
||||
.el-checkbox { cursor: pointer; margin-right: 8px; }
|
||||
.el-checkbox__input.is-checked .el-checkbox__inner { background: #409eff; }
|
||||
.el-checkbox__inner { display: inline-block; width: 14px; height: 14px; border: 1px solid #cccccc; }
|
||||
.el-switch { cursor: pointer; }
|
||||
.el-switch.is-checked .el-switch__core { background: #409eff; }
|
||||
.el-switch__core { display: inline-block; width: 40px; height: 20px; border-radius: 10px; background: #cccccc; }
|
||||
.el-select-dropdown { position: absolute; background: #ffffff; border: 1px solid #cccccc; z-index: 9999; min-width: 120px; }
|
||||
.el-select-dropdown__item { padding: 6px 12px; cursor: pointer; }
|
||||
.el-select-dropdown__item:hover { background: #f5f7fa; }
|
||||
.el-button { padding: 6px 16px; cursor: pointer; border: 1px solid #cccccc; background: #ffffff; }
|
||||
.el-input-number .el-input__inner { width: 80px; }
|
||||
</style>
|
||||
</head><body>
|
||||
|
||||
<!-- Vuetify dialog wrapper — required by manage-project.js locator ".v-dialog--active button:has-text(...)" -->
|
||||
<div class="v-dialog v-dialog--active v-dialog--persistent" style="padding:16px;">
|
||||
|
||||
<form class="el-form el-form--label-left">
|
||||
|
||||
<!-- 1. Tag -->
|
||||
<div class="el-form-item">
|
||||
<label class="el-form-item__label" for="tag">Тег</label>
|
||||
<div class="el-form-item__content">
|
||||
<div class="el-input">
|
||||
<input type="text" class="el-input__inner" id="tag-fixture">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2. Источник данных (B1/B2/B3 checkboxes) — label for="srcrt" -->
|
||||
<div class="el-form-item">
|
||||
<label class="el-form-item__label" for="srcrt">Источник данных</label>
|
||||
<div class="el-form-item__content" id="srcrt-container">
|
||||
<label class="el-checkbox is-checked" data-platform="B1">
|
||||
<span class="el-checkbox__input is-checked">
|
||||
<span class="el-checkbox__inner"></span>
|
||||
<input type="checkbox" class="el-checkbox__original" checked>
|
||||
</span>
|
||||
<span class="el-checkbox__label">B1</span>
|
||||
</label>
|
||||
<label class="el-checkbox is-checked" data-platform="B2">
|
||||
<span class="el-checkbox__input is-checked">
|
||||
<span class="el-checkbox__inner"></span>
|
||||
<input type="checkbox" class="el-checkbox__original" checked>
|
||||
</span>
|
||||
<span class="el-checkbox__label">B2</span>
|
||||
</label>
|
||||
<label class="el-checkbox is-checked" data-platform="B3">
|
||||
<span class="el-checkbox__input is-checked">
|
||||
<span class="el-checkbox__inner"></span>
|
||||
<input type="checkbox" class="el-checkbox__original" checked>
|
||||
</span>
|
||||
<span class="el-checkbox__label">B3</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 3. Name — label for="name" -->
|
||||
<div class="el-form-item">
|
||||
<label class="el-form-item__label" for="name">Название проекта</label>
|
||||
<div class="el-form-item__content">
|
||||
<div class="el-input">
|
||||
<input type="text" class="el-input__inner" id="name-fixture">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 4. Type select — label for="type" -->
|
||||
<div class="el-form-item">
|
||||
<label class="el-form-item__label" for="type">Источники сбора</label>
|
||||
<div class="el-form-item__content">
|
||||
<div class="el-select" id="type-select-container">
|
||||
<!-- readonly input that shows selected value; clicking it opens dropdown popup in body -->
|
||||
<div class="el-input">
|
||||
<input type="text" class="el-input__inner" id="type-select-input" readonly
|
||||
value="Сайты" placeholder="Выберите" data-current-value="Сайты">
|
||||
<span class="el-input__suffix"><span class="el-select__caret">▼</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 5. Slider «Период» — no label-for, no DTO field, leave default -->
|
||||
<div class="el-form-item">
|
||||
<label class="el-form-item__label">Период</label>
|
||||
<div class="el-form-item__content">
|
||||
<div class="el-slider" aria-valuemin="0" aria-valuemax="24" aria-valuetext="10-18">
|
||||
<span style="font-size:12px;color:#999999">10-18 (default)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 6. Switch «Включить» — no label-for; identified by .el-switch in form-item -->
|
||||
<div class="el-form-item" id="switch-form-item">
|
||||
<label class="el-form-item__label">Статус</label>
|
||||
<div class="el-form-item__content">
|
||||
<div class="el-switch" id="active-switch">
|
||||
<input type="checkbox" class="el-switch__input" id="active-switch-input">
|
||||
<span class="el-switch__core"></span>
|
||||
<span>Включить</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 7. Regions — label for="regions", el-select multiple -->
|
||||
<div class="el-form-item">
|
||||
<label class="el-form-item__label" for="regions">Регион</label>
|
||||
<div class="el-form-item__content">
|
||||
<div class="el-select el-select--multiple">
|
||||
<input type="text" class="el-input__inner" id="regions-input" placeholder="Выберите регионы">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 8. limit_off — no label-for, no DTO field -->
|
||||
<div class="el-form-item">
|
||||
<label class="el-form-item__label" for="limit_off">Разделять по проектам</label>
|
||||
<div class="el-form-item__content">
|
||||
<label class="el-checkbox">
|
||||
<span class="el-checkbox__input">
|
||||
<span class="el-checkbox__inner"></span>
|
||||
<input type="checkbox" class="el-checkbox__original">
|
||||
</span>
|
||||
<span class="el-checkbox__label">Да</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 9. Content (uniqueKey / domains) — label for="content", el-tabs -->
|
||||
<div class="el-form-item">
|
||||
<label class="el-form-item__label" for="content">Список сайтов</label>
|
||||
<div class="el-form-item__content">
|
||||
<div class="el-tabs">
|
||||
<div class="el-tabs__header">
|
||||
<div class="el-tabs__item is-active" data-tab="list">Список</div>
|
||||
<div class="el-tabs__item" data-tab="file">Файл</div>
|
||||
</div>
|
||||
<div class="el-tabs__content">
|
||||
<textarea class="el-textarea__inner" id="content-textarea" rows="4" style="width:100%"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 10. Limit — label for="limit", el-input-number -->
|
||||
<div class="el-form-item">
|
||||
<label class="el-form-item__label" for="limit">Лимит в день</label>
|
||||
<div class="el-form-item__content">
|
||||
<div class="el-input-number">
|
||||
<span class="el-input-number__decrease">-</span>
|
||||
<div class="el-input">
|
||||
<input type="text" class="el-input__inner" id="limit-input" value="10">
|
||||
</div>
|
||||
<span class="el-input-number__increase">+</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</form><!-- end .el-form -->
|
||||
|
||||
<!-- Save/Cancel buttons OUTSIDE form, INSIDE .v-dialog--active -->
|
||||
<div style="margin-top:16px;">
|
||||
<button type="button" class="el-button" id="save-btn">Сохранить</button>
|
||||
<button type="button" class="el-button" id="cancel-btn">Отмена</button>
|
||||
</div>
|
||||
|
||||
</div><!-- end .v-dialog--active -->
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// ---- Checkbox toggle behaviour ----
|
||||
// Click on .el-checkbox toggles .is-checked on itself and .el-checkbox__input child
|
||||
document.querySelectorAll('#srcrt-container .el-checkbox').forEach(function(cb) {
|
||||
cb.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
var isChecked = cb.classList.contains('is-checked');
|
||||
cb.classList.toggle('is-checked', !isChecked);
|
||||
var cbInput = cb.querySelector('.el-checkbox__input');
|
||||
if (cbInput) cbInput.classList.toggle('is-checked', !isChecked);
|
||||
var rawInput = cb.querySelector('input.el-checkbox__original');
|
||||
if (rawInput) rawInput.checked = !isChecked;
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Switch toggle behaviour ----
|
||||
var switchEl = document.getElementById('active-switch');
|
||||
if (switchEl) {
|
||||
switchEl.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
var isChecked = switchEl.classList.contains('is-checked');
|
||||
switchEl.classList.toggle('is-checked', !isChecked);
|
||||
var inp = document.getElementById('active-switch-input');
|
||||
if (inp) inp.checked = !isChecked;
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Type select popup ----
|
||||
// When input#type-select-input is clicked, create a dropdown in body
|
||||
var typeInput = document.getElementById('type-select-input');
|
||||
var typeOptions = ['Сайты', 'Звонки', 'СМС', 'Ретро сайты', 'Ретро звонки'];
|
||||
|
||||
function removeDropdown() {
|
||||
var existing = document.querySelector('body > .el-select-dropdown');
|
||||
if (existing) existing.remove();
|
||||
}
|
||||
|
||||
if (typeInput) {
|
||||
typeInput.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
removeDropdown();
|
||||
var dropdown = document.createElement('div');
|
||||
dropdown.className = 'el-select-dropdown el-popper';
|
||||
dropdown.style.position = 'absolute';
|
||||
dropdown.style.left = '20px';
|
||||
dropdown.style.top = '200px';
|
||||
var ul = document.createElement('ul');
|
||||
ul.className = 'el-scrollbar__view el-select-dropdown__list';
|
||||
typeOptions.forEach(function(opt) {
|
||||
var li = document.createElement('li');
|
||||
li.className = 'el-select-dropdown__item';
|
||||
li.textContent = opt;
|
||||
li.addEventListener('click', function(e2) {
|
||||
e2.stopPropagation();
|
||||
typeInput.value = opt;
|
||||
typeInput.setAttribute('data-current-value', opt);
|
||||
removeDropdown();
|
||||
});
|
||||
ul.appendChild(li);
|
||||
});
|
||||
dropdown.appendChild(ul);
|
||||
document.body.appendChild(dropdown);
|
||||
});
|
||||
}
|
||||
|
||||
// Close dropdown on outside click
|
||||
document.addEventListener('click', function() {
|
||||
removeDropdown();
|
||||
});
|
||||
|
||||
// ---- Save button: POST to /admin/visit/rt-project-save on the same origin ----
|
||||
// NOTE: NO fetch mock here — the HTTP server (manage-project.test.js) handles
|
||||
// this route and returns {status:"OK",id:"99001"}. Playwright's waitForResponse
|
||||
// intercepts real network requests, not mocked fetch.
|
||||
document.getElementById('save-btn').addEventListener('click', function() {
|
||||
var payload = {
|
||||
tag: document.getElementById('tag-fixture') ? document.getElementById('tag-fixture').value : '',
|
||||
name: document.getElementById('name-fixture') ? document.getElementById('name-fixture').value : '',
|
||||
type: typeInput ? typeInput.getAttribute('data-current-value') : 'Сайты',
|
||||
limit: document.getElementById('limit-input') ? document.getElementById('limit-input').value : '10',
|
||||
};
|
||||
fetch('/admin/visit/rt-project-save', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
});
|
||||
|
||||
})();
|
||||
</script>
|
||||
</body></html>
|
||||
@@ -18,11 +18,35 @@
|
||||
* 4 — invalid input или другая ошибка
|
||||
*
|
||||
* Spec §4.3.
|
||||
*
|
||||
* KNOWN GAPS (Tier-2 MVP, зафиксированы по recon 2026-05-19):
|
||||
* - workdays: поле add-project форм НЕ содержит чекбоксы дней недели (только slider «Период»
|
||||
* часы 0-24). DTO.workdays игнорируется; портал применяет дефолт (все 7 дней).
|
||||
* Для точной настройки workdays используйте Tier-1 (AJAX).
|
||||
* - regions: форма требует имена регионов, DTO несёт int[] id. Mapping id→name не реализован.
|
||||
* Tier-2 всегда передаёт пустой массив регионов (нет фильтрации). Регионы должны быть
|
||||
* настроены вручную или через Tier-1.
|
||||
*/
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
const TIMEOUT_MS = 90_000;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Возвращает локатор form-item по значению атрибута for= у label.
|
||||
* Стратегия: .el-form-item:has(.el-form-item__label[for="<attrFor>"])
|
||||
*/
|
||||
function fieldByFor(page, attrFor) {
|
||||
return page.locator(`.el-form-item:has(.el-form-item__label[for="${attrFor}"])`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Login
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function login(page, args) {
|
||||
// skipLogin: args.url — статическая фикстура формы (тестовый режим),
|
||||
// открываем её напрямую и не логинимся.
|
||||
@@ -39,98 +63,301 @@ async function login(page, args) {
|
||||
]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// fillForm — Element UI label-for локаторы (recon 2026-05-19)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function fillForm(page, dto) {
|
||||
const activeChecked = await page.locator('input[name=active]').isChecked();
|
||||
if (activeChecked !== !!dto.active) await page.locator('input[name=active]').click();
|
||||
// NOTE: статус active/paused НЕ выставляется через форму. Единственный
|
||||
// .el-switch на форме — это include/exclude регионов («Включить/Исключить»,
|
||||
// recon 2026-05-19 row 6), НЕ статус проекта. Статус задаётся дефолтом
|
||||
// портала (active). dto.active игнорируется в Tier-2; switch не трогаем
|
||||
// (regions skip — см. ниже). Verified live 2026-05-19.
|
||||
|
||||
if (dto.tag) await page.fill('input[name=tag]', dto.tag);
|
||||
// --- 1. Tag ---
|
||||
if (dto.tag !== undefined && dto.tag !== null) {
|
||||
await fieldByFor(page, 'tag').locator('input.el-input__inner').fill(String(dto.tag));
|
||||
}
|
||||
|
||||
// --- 2. Platforms (srcrt) — B1/B2/B3 checkboxes ---
|
||||
// Initial: все три checked. Нужно включить только те, что в dto.platforms, остальные выключить.
|
||||
const platformContainer = fieldByFor(page, 'srcrt');
|
||||
for (const p of ['B1', 'B2', 'B3']) {
|
||||
const wanted = (dto.platforms || []).includes(p);
|
||||
const sel = `input[name="platform[]"][value="${p}"]`;
|
||||
const checked = await page.locator(sel).isChecked();
|
||||
if (checked !== wanted) await page.locator(sel).click();
|
||||
// Identification — по `.el-checkbox__label` textContent (per recon-doc
|
||||
// 2026-05-19-rt-project-form-locators.md row 2: реальный портал НЕ имеет
|
||||
// `data-platform`-атрибута, inputs без `name`). Whitespace-tolerant `^\s*B1\s*$`.
|
||||
const cb = platformContainer.locator('.el-checkbox').filter({
|
||||
has: page.locator('.el-checkbox__label', { hasText: new RegExp(`^\\s*${p}\\s*$`) }),
|
||||
}).first();
|
||||
const cbClass = await cb.getAttribute('class').catch(() => '');
|
||||
const isChecked = (cbClass || '').includes('is-checked');
|
||||
if (!!isChecked !== wanted) {
|
||||
await cb.click();
|
||||
}
|
||||
}
|
||||
|
||||
await page.fill('input[name=name]', dto.name);
|
||||
|
||||
const signalLabel = { site: 'Сайты', call: 'Звонки', sms: 'СМС' }[dto.signal_type] || 'Сайты';
|
||||
await page.selectOption('select[name=signal_type]', { label: signalLabel });
|
||||
|
||||
if (dto.region_mode === 'exclude') {
|
||||
await page.locator('input[name=region_mode][value=exclude]').click();
|
||||
// --- 3. Name (label for="name") ---
|
||||
// В реальном портале dto.name заполняется в поле «Название проекта»,
|
||||
// а dto.uniqueKey (список сайтов/номеров) — в textarea «content».
|
||||
// manage-project.js получает dto.name напрямую.
|
||||
if (dto.name !== undefined) {
|
||||
await fieldByFor(page, 'name').locator('input.el-input__inner').fill(String(dto.name));
|
||||
}
|
||||
|
||||
if (dto.domains && dto.domains.length) {
|
||||
await page.fill('textarea[name=domains]', dto.domains.join('\n'));
|
||||
// --- 4. Type select (label for="type") ---
|
||||
// El-select readonly input. Клик открывает popup в body > .el-select-dropdown.
|
||||
const signalTypeMap = { site: 'Сайты', call: 'Звонки', sms: 'СМС' };
|
||||
const signalLabel = signalTypeMap[dto.signal_type];
|
||||
if (!signalLabel) {
|
||||
throw new Error(
|
||||
`Unsupported signal_type "${dto.signal_type}". Supported: site, call, sms. ` +
|
||||
'"Ретро сайты" / "Ретро звонки" are not supported in Tier-2 form channel.',
|
||||
);
|
||||
}
|
||||
// Тип меняем ТОЛЬКО если текущее значение ≠ нужное. Смена типа ремоунтит
|
||||
// content tab-pane (Сайты/Звонки/СМС — разные поля сбора) → если сразу
|
||||
// после type-select заполнять content, fill попадёт в detached textarea
|
||||
// (Vue ещё не закончил ре-рендер) → rt-project-save уходит с пустым
|
||||
// `content` → портал «Введите домены». Verified live 2026-05-19.
|
||||
const typeInput = fieldByFor(page, 'type').locator('.el-select input.el-input__inner');
|
||||
const currentType = (await typeInput.inputValue().catch(() => '')).trim();
|
||||
if (currentType !== signalLabel) {
|
||||
await typeInput.click();
|
||||
// Dropdown рендерится снаружи формы в body — ждём его появления
|
||||
const dropdownOption = page.locator('.el-select-dropdown__item', {
|
||||
hasText: new RegExp(`^${signalLabel}$`),
|
||||
});
|
||||
await dropdownOption.waitFor({ state: 'visible', timeout: TIMEOUT_MS });
|
||||
await dropdownOption.click();
|
||||
// Ждём, пока Vue завершит ре-рендер content tab-pane после смены типа.
|
||||
await page.waitForTimeout(1000);
|
||||
}
|
||||
|
||||
await page.fill('input[name=limit]', String(dto.limit));
|
||||
// --- 7. Regions (label for="regions") — SKIP, gap зафиксирован в JSDoc ---
|
||||
// DTO несёт int[] id; форма требует имена. Mapping не реализован для MVP.
|
||||
if (dto.regions && dto.regions.length > 0) {
|
||||
process.stderr.write(
|
||||
JSON.stringify({
|
||||
warning: 'regions skipped in Tier-2 form channel: DTO carries int[] ids but form requires region names. ' +
|
||||
'Region filtering will not be applied. Configure regions manually or use Tier-1.',
|
||||
regions_received: dto.regions,
|
||||
}) + '\n',
|
||||
);
|
||||
}
|
||||
|
||||
for (let d = 1; d <= 7; d++) {
|
||||
const wanted = (dto.workdays || [1, 2, 3, 4, 5, 6, 7]).includes(d);
|
||||
const sel = `input[name="workdays[]"][value="${d}"]`;
|
||||
const checked = await page.locator(sel).isChecked();
|
||||
if (checked !== wanted) await page.locator(sel).click();
|
||||
// --- 9. Content — список сайтов/номеров/отправителей (label for="content") ---
|
||||
// Вкладка «Список» (default active). dto.domains — массив строк или dto.uniqueKey — строка.
|
||||
const contentLines = dto.domains && dto.domains.length
|
||||
? dto.domains.join('\n')
|
||||
: dto.uniqueKey
|
||||
? String(dto.uniqueKey)
|
||||
: null;
|
||||
if (contentLines) {
|
||||
const contentField = fieldByFor(page, 'content');
|
||||
// Вкладка «Список» — default active. Кликаем ТОЛЬКО если она НЕ активна:
|
||||
// клик по вкладке Element UI ремоунтит tab-pane → textarea детачится,
|
||||
// и последующий .fill() гонится с ре-рендером (домены теряются →
|
||||
// rt-project-save уходит с пустым `content` → портал «Введите домены»).
|
||||
// Verified live 2026-05-19: re-click активной вкладки ломал save.
|
||||
const listTab = contentField.locator('.el-tabs__item', { hasText: 'Список' }).first();
|
||||
if ((await listTab.count()) > 0) {
|
||||
const tabClass = (await listTab.getAttribute('class')) || '';
|
||||
if (!tabClass.includes('is-active')) {
|
||||
await listTab.click();
|
||||
await contentField.locator('textarea.el-textarea__inner')
|
||||
.waitFor({ state: 'visible', timeout: TIMEOUT_MS });
|
||||
}
|
||||
}
|
||||
const contentTa = contentField.locator('textarea.el-textarea__inner');
|
||||
await contentTa.fill(contentLines);
|
||||
// Defensive: убедиться, что значение действительно осело в textarea
|
||||
// (если поле детачнулось ре-рендером — fill уйдёт в пустоту).
|
||||
const filledValue = await contentTa.inputValue();
|
||||
if (filledValue.trim() === '') {
|
||||
throw new Error(
|
||||
'Content textarea empty after fill — likely tab/type re-render race; domains lost',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- 10. Limit (label for="limit") ---
|
||||
if (dto.limit !== undefined) {
|
||||
await fieldByFor(page, 'limit').locator('input.el-input__inner').fill(String(dto.limit));
|
||||
}
|
||||
|
||||
// NOTE: workdays — gap зафиксирован в JSDoc. Форма add-project не содержит
|
||||
// чекбоксы дней недели. dto.workdays игнорируется.
|
||||
if (dto.workdays && dto.workdays.length !== 7) {
|
||||
process.stderr.write(
|
||||
JSON.stringify({
|
||||
warning: 'workdays ignored in Tier-2 form channel: add-project form has no workdays field. ' +
|
||||
'Portal will apply default (all 7 days). Configure workdays manually or use Tier-1.',
|
||||
workdays_received: dto.workdays,
|
||||
}) + '\n',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// createOp
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function createOp(page, args) {
|
||||
await login(page, args);
|
||||
|
||||
if (!args.skipLogin) {
|
||||
await page.goto(args.url.replace(/\/$/, '') + '/admin/visit/rt', { waitUntil: 'load', timeout: TIMEOUT_MS });
|
||||
await page.click('button:has-text("Добавить проект")');
|
||||
await page.waitForSelector('#add-project-modal', { state: 'visible', timeout: TIMEOUT_MS });
|
||||
await page.goto(
|
||||
args.url.replace(/\/$/, '') + '/admin/visit/rt',
|
||||
{ waitUntil: 'load', timeout: TIMEOUT_MS },
|
||||
);
|
||||
// Кнопка «Добавить проект» — recon: label [title="Добавить проект"]
|
||||
await page.locator('button:has-text("Добавить проект")').click();
|
||||
// Ждём появления формы — label for="name" внутри .el-form
|
||||
await page.locator('.el-form-item__label[for="name"]').waitFor({
|
||||
state: 'visible',
|
||||
timeout: TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
|
||||
await fillForm(page, args.dto);
|
||||
const beforeRows = await page.locator('#projects-table tbody tr').count();
|
||||
await page.click('#save-btn');
|
||||
await page.waitForFunction(
|
||||
(before) => document.querySelectorAll('#projects-table tbody tr').length > before,
|
||||
beforeRows,
|
||||
{ timeout: TIMEOUT_MS },
|
||||
);
|
||||
|
||||
const newRow = page.locator('#projects-table tbody tr').last();
|
||||
const externalId = await newRow.getAttribute('data-id');
|
||||
// Кликаем «Сохранить» + перехватываем ответ rt-project-save
|
||||
const [saveResponse] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(r) => r.url().endsWith('/admin/visit/rt-project-save') && r.request().method() === 'POST',
|
||||
{ timeout: TIMEOUT_MS },
|
||||
),
|
||||
page.locator('.v-dialog--active button:has-text("Сохранить")').click(),
|
||||
]);
|
||||
|
||||
const body = await saveResponse.json();
|
||||
if (body.status !== 'OK') {
|
||||
// DIAG: дамп фактически отправленного тела — для расследования "Введите домены"
|
||||
const sentBody = saveResponse.request().postData();
|
||||
process.stderr.write(JSON.stringify({ diag_sent_body: sentBody }) + '\n');
|
||||
throw new Error(`Portal rejected save: ${body.message || 'unknown error'}`);
|
||||
}
|
||||
const externalId = String(body.id ?? '');
|
||||
if (!externalId) {
|
||||
throw new Error('Portal returned status=OK but empty id');
|
||||
}
|
||||
|
||||
return { external_id: externalId };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// updateOp
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function updateOp(page, args) {
|
||||
await login(page, args);
|
||||
|
||||
if (!args.skipLogin) {
|
||||
await page.goto(args.url.replace(/\/$/, '') + '/admin/visit/rt', { waitUntil: 'load', timeout: TIMEOUT_MS });
|
||||
await page.goto(
|
||||
args.url.replace(/\/$/, '') + '/admin/visit/rt',
|
||||
{ waitUntil: 'load', timeout: TIMEOUT_MS },
|
||||
);
|
||||
}
|
||||
|
||||
const row = page.locator(`#projects-table tbody tr[data-id="${args.externalId}"]`);
|
||||
await row.locator('button.edit').click();
|
||||
await page.waitForSelector('#add-project-modal', { state: 'visible', timeout: TIMEOUT_MS });
|
||||
// Найти строку таблицы по externalId и кликнуть кнопку редактирования.
|
||||
// Реальная таблица портала — Vuetify data-table; строки по data-id или текстовому совпадению.
|
||||
// Стратегия 1: строка с атрибутом data-id
|
||||
const rowLocator = page.locator(`tr[data-id="${args.externalId}"], [data-id="${args.externalId}"]`);
|
||||
const rowCount = await rowLocator.count();
|
||||
if (rowCount > 0) {
|
||||
await rowLocator.first().locator('button').first().click();
|
||||
} else {
|
||||
// Стратегия 2: найти строку содержащую текст externalId и кликнуть edit-кнопку
|
||||
await page.locator(`tr:has-text("${args.externalId}")`).first().locator('button').first().click();
|
||||
}
|
||||
|
||||
// Дождаться формы
|
||||
await page.locator('.el-form-item__label[for="name"]').waitFor({
|
||||
state: 'visible',
|
||||
timeout: TIMEOUT_MS,
|
||||
});
|
||||
|
||||
await fillForm(page, args.dto);
|
||||
await page.click('#save-btn');
|
||||
await page.waitForSelector('#add-project-modal', { state: 'hidden', timeout: TIMEOUT_MS });
|
||||
|
||||
// Перехватываем ответ rt-project-save при update (тот же endpoint)
|
||||
const [saveResponse] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(r) => r.url().endsWith('/admin/visit/rt-project-save') && r.request().method() === 'POST',
|
||||
{ timeout: TIMEOUT_MS },
|
||||
),
|
||||
page.locator('.v-dialog--active button:has-text("Сохранить")').click(),
|
||||
]);
|
||||
|
||||
const body = await saveResponse.json();
|
||||
if (body.status !== 'OK') {
|
||||
throw new Error(`Portal rejected update: ${body.message || 'unknown error'}`);
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// listOp
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function listOp(page, args) {
|
||||
await login(page, args);
|
||||
|
||||
if (!args.skipLogin) {
|
||||
await page.goto(args.url.replace(/\/$/, '') + '/admin/visit/rt', { waitUntil: 'load', timeout: TIMEOUT_MS });
|
||||
await page.goto(
|
||||
args.url.replace(/\/$/, '') + '/admin/visit/rt',
|
||||
{ waitUntil: 'load', timeout: TIMEOUT_MS },
|
||||
);
|
||||
}
|
||||
|
||||
const rows = await page.locator('#projects-table tbody tr').evaluateAll((nodes) =>
|
||||
nodes.map((n) => ({
|
||||
id: parseInt(n.dataset.id, 10),
|
||||
name: n.querySelector('td:nth-child(2)') ? n.querySelector('td:nth-child(2)').textContent : null,
|
||||
// Стратегия 1: Vuex state (если доступен)
|
||||
const projects = await page.evaluate(() => {
|
||||
try {
|
||||
if (window.app && window.app.$store && window.app.$store.state) {
|
||||
const st = window.app.$store.state;
|
||||
const list = st.projects || st.rtProjects || st.visitProjects || null;
|
||||
if (Array.isArray(list)) {
|
||||
return list.map((p) => ({
|
||||
id: parseInt(p.id, 10),
|
||||
name: p.name || p.title || null,
|
||||
platform: p.platform || null,
|
||||
signal_type: p.type || p.signal_type || null,
|
||||
unique_key: p.content || p.unique_key || null,
|
||||
}));
|
||||
}
|
||||
}
|
||||
} catch (_) { /* Vuex недоступен */ }
|
||||
return null;
|
||||
});
|
||||
|
||||
if (projects !== null) {
|
||||
return { projects };
|
||||
}
|
||||
|
||||
// Стратегия 2: DOM-скрейп таблицы
|
||||
// Реальная таблица портала: строки tr с data-id или стандартные td
|
||||
const rows = await page.locator('table tbody tr[data-id], .v-data-table tbody tr[data-id]').evaluateAll(
|
||||
(nodes) => nodes.map((n) => ({
|
||||
id: parseInt(n.dataset.id || '0', 10),
|
||||
name: n.querySelector('td:nth-child(2)')
|
||||
? n.querySelector('td:nth-child(2)').textContent.trim()
|
||||
: null,
|
||||
})),
|
||||
);
|
||||
|
||||
return { projects: rows };
|
||||
if (rows.length > 0) {
|
||||
return { projects: rows };
|
||||
}
|
||||
|
||||
// Стратегия 3: фикстура / пустая страница — возвращаем пустой массив
|
||||
return { projects: [] };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Entry point
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function run(args) {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
@@ -148,8 +375,14 @@ async function run(args) {
|
||||
} catch (err) {
|
||||
process.stderr.write(JSON.stringify({ error: err.message }));
|
||||
if (err.message.includes('Timeout')) process.exit(3);
|
||||
if (err.message.toLowerCase().includes('selector') || err.message.toLowerCase().includes('locator')) process.exit(2);
|
||||
if (err.message.toLowerCase().includes('login') || err.message.toLowerCase().includes('auth')) process.exit(1);
|
||||
if (
|
||||
err.message.toLowerCase().includes('selector') ||
|
||||
err.message.toLowerCase().includes('locator')
|
||||
) process.exit(2);
|
||||
if (
|
||||
err.message.toLowerCase().includes('login') ||
|
||||
err.message.toLowerCase().includes('auth')
|
||||
) process.exit(1);
|
||||
process.exit(4);
|
||||
} finally {
|
||||
await browser.close();
|
||||
@@ -160,8 +393,10 @@ let input = '';
|
||||
process.stdin.on('data', (c) => { input += c; });
|
||||
process.stdin.on('end', () => {
|
||||
let args;
|
||||
try { args = JSON.parse(input); }
|
||||
catch (e) { process.stderr.write(JSON.stringify({ error: 'invalid JSON on stdin' })); process.exit(4); }
|
||||
try { args = JSON.parse(input); } catch (e) {
|
||||
process.stderr.write(JSON.stringify({ error: 'invalid JSON on stdin' }));
|
||||
process.exit(4);
|
||||
}
|
||||
if (!args.operation || !args.url) {
|
||||
process.stderr.write(JSON.stringify({ error: 'missing required: operation, url' }));
|
||||
process.exit(4);
|
||||
|
||||
@@ -1,65 +1,137 @@
|
||||
/**
|
||||
* Фикстурный тест manage-project.js — против локального HTML, без живого портала.
|
||||
* Фикстурный тест manage-project.js — против локального HTTP-сервера с Element UI фикстурой.
|
||||
*
|
||||
* Runner: встроенный node:test (проект не использует @playwright/test —
|
||||
* в app/playwright только playwright core). Запуск: `node --test manage-project.test.js`.
|
||||
* Почему HTTP, не file://: manage-project.js перехватывает ответ page.waitForResponse()
|
||||
* с URL endsWith('/admin/visit/rt-project-save'). Браузер не шлёт network-запросы при
|
||||
* file://-origin fetch из-за CORS/same-origin ограничений в Chromium.
|
||||
*
|
||||
* Runner: встроенный node:test (Node 18+). Запуск: `node --test manage-project.test.js`.
|
||||
*/
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const { execFile } = require('node:child_process');
|
||||
const http = require('node:http');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const SCRIPT = path.resolve(__dirname, 'manage-project.js');
|
||||
const FIXTURE_URL = 'file://' + path.resolve(__dirname, '../tests/fixtures/supplier-portal/rt-add-project-form.html');
|
||||
const FIXTURE_PATH = path.resolve(__dirname, 'fixtures', 'rt-form-element-ui.html');
|
||||
|
||||
/** Запустить ephemeral HTTP-сервер, отдающий фикстуру и обрабатывающий mock-эндпоинты. */
|
||||
function startFixtureServer() {
|
||||
return new Promise((resolve) => {
|
||||
const html = fs.readFileSync(FIXTURE_PATH, 'utf8');
|
||||
const server = http.createServer((req, res) => {
|
||||
// Mock rt-project-save — Playwright перехватывает реальный сетевой запрос
|
||||
if (req.url && req.url.includes('rt-project-save') && req.method === 'POST') {
|
||||
// Consume request body (important — don't hang connection)
|
||||
let body = '';
|
||||
req.on('data', (c) => { body += c; });
|
||||
req.on('end', () => {
|
||||
const payload = JSON.stringify({ status: 'OK', message: '', result: null, id: '99001' });
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(payload);
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Default: serve fixture HTML
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(html);
|
||||
});
|
||||
server.listen(0, '127.0.0.1', () => resolve(server));
|
||||
});
|
||||
}
|
||||
|
||||
/** Спавнить manage-project.js, подать JSON на stdin, вернуть {code, stdout, stderr}. */
|
||||
function runScript(input) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = execFile('node', [SCRIPT], { timeout: 60000 }, (err, stdout, stderr) => {
|
||||
if (err && err.code !== undefined && typeof err.code !== 'number') {
|
||||
return reject(err);
|
||||
}
|
||||
resolve({ stdout: stdout.toString(), stderr: stderr.toString() });
|
||||
});
|
||||
const child = execFile(
|
||||
'node',
|
||||
[SCRIPT],
|
||||
{ timeout: 90_000 },
|
||||
(err, stdout, stderr) => {
|
||||
if (err && err.killed) return reject(new Error('Process killed / timed out'));
|
||||
// err.code — exit code; treat as expected (tests assert on code)
|
||||
resolve({
|
||||
code: err ? err.code : 0,
|
||||
stdout: stdout.toString(),
|
||||
stderr: stderr.toString(),
|
||||
});
|
||||
},
|
||||
);
|
||||
child.stdin.write(JSON.stringify(input));
|
||||
child.stdin.end();
|
||||
});
|
||||
}
|
||||
|
||||
test('createProject fills form and returns row id', async () => {
|
||||
const result = await runScript({
|
||||
operation: 'create',
|
||||
login: 'fixture-noop',
|
||||
password: 'fixture-noop',
|
||||
url: FIXTURE_URL,
|
||||
skipLogin: true,
|
||||
dto: {
|
||||
tag: 'TEST',
|
||||
name: 'Test Project',
|
||||
platforms: ['B1', 'B2'],
|
||||
signal_type: 'site',
|
||||
limit: 25,
|
||||
workdays: [1, 2, 3, 4, 5],
|
||||
regions: [],
|
||||
region_mode: 'include',
|
||||
domains: ['example.com'],
|
||||
active: true,
|
||||
},
|
||||
});
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 1 — createProject через Element UI фикстуру → external_id из mock-response
|
||||
// ---------------------------------------------------------------------------
|
||||
test('createProject fills Element UI form and returns external_id from intercept response', async () => {
|
||||
const server = await startFixtureServer();
|
||||
try {
|
||||
const { port } = server.address();
|
||||
const url = `http://127.0.0.1:${port}`;
|
||||
|
||||
const out = JSON.parse(result.stdout);
|
||||
assert.ok(out.external_id, 'external_id should be truthy');
|
||||
assert.match(out.external_id, /^\d+$/, 'external_id should be numeric string');
|
||||
const result = await runScript({
|
||||
operation: 'create',
|
||||
url,
|
||||
skipLogin: true,
|
||||
dto: {
|
||||
tag: '_lidpotok',
|
||||
name: 'example.com',
|
||||
platforms: ['B1'],
|
||||
signal_type: 'site',
|
||||
limit: 5,
|
||||
workdays: [1, 2, 3, 4, 5],
|
||||
domains: ['example.com'],
|
||||
region_mode: 'include',
|
||||
regions: [],
|
||||
active: true,
|
||||
},
|
||||
});
|
||||
|
||||
assert.strictEqual(result.code, 0, `Expected exit 0, got ${result.code}. stderr: ${result.stderr}`);
|
||||
|
||||
let out;
|
||||
try {
|
||||
out = JSON.parse(result.stdout);
|
||||
} catch (e) {
|
||||
assert.fail(`stdout is not valid JSON: ${result.stdout}\nstderr: ${result.stderr}`);
|
||||
}
|
||||
assert.strictEqual(out.external_id, '99001', `expected external_id "99001", got ${JSON.stringify(out)}`);
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('listProjects returns array', async () => {
|
||||
const result = await runScript({
|
||||
operation: 'list',
|
||||
login: 'fixture-noop',
|
||||
password: 'fixture-noop',
|
||||
url: FIXTURE_URL,
|
||||
skipLogin: true,
|
||||
});
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 2 — listProjects в skipLogin-режиме возвращает массив projects
|
||||
// ---------------------------------------------------------------------------
|
||||
test('listProjects returns array (skipLogin mode, fixture page)', async () => {
|
||||
const server = await startFixtureServer();
|
||||
try {
|
||||
const { port } = server.address();
|
||||
const url = `http://127.0.0.1:${port}`;
|
||||
|
||||
const out = JSON.parse(result.stdout);
|
||||
assert.ok(Array.isArray(out.projects), 'projects should be an array');
|
||||
const result = await runScript({
|
||||
operation: 'list',
|
||||
url,
|
||||
skipLogin: true,
|
||||
});
|
||||
|
||||
// listOp в skipLogin-режиме не навигирует на /admin/visit/rt — просто открывает url.
|
||||
// Фикстура не содержит Vuex и таблицы с проектами → возвращает {projects: []}.
|
||||
assert.strictEqual(result.code, 0, `Expected exit 0. stderr: ${result.stderr}`);
|
||||
|
||||
let out;
|
||||
try {
|
||||
out = JSON.parse(result.stdout);
|
||||
} catch (e) {
|
||||
assert.fail(`stdout is not valid JSON: ${result.stdout}`);
|
||||
}
|
||||
assert.ok(Array.isArray(out.projects), `expected projects array, got: ${JSON.stringify(out)}`);
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -34,10 +34,29 @@ async function refresh(args) {
|
||||
|
||||
await page.fill(loginSelector, args.login);
|
||||
await page.fill(passwordSelector, args.password);
|
||||
await Promise.all([
|
||||
page.waitForLoadState('networkidle', { timeout: TIMEOUT_MS }),
|
||||
page.click(submitSelector),
|
||||
]);
|
||||
|
||||
// Сабмит + ОЖИДАНИЕ пост-логин перехода.
|
||||
// Старый Promise.all([waitForLoadState('networkidle'), click]) — гонка:
|
||||
// логин-страница уже в состоянии networkidle, поэтому waitForLoadState
|
||||
// резолвился мгновенно (ДО редиректа), и скрипт хватал PHPSESSID
|
||||
// неаутентифицированной логин-страницы. Ждём, пока логин-форма исчезнет
|
||||
// из DOM — waitForFunction опрашивает и переживает навигацию.
|
||||
await page.click(submitSelector);
|
||||
await page
|
||||
.waitForFunction(
|
||||
(sel) => !document.querySelector(sel),
|
||||
loginSelector,
|
||||
{ timeout: TIMEOUT_MS },
|
||||
)
|
||||
.catch(() => { /* форма осталась — логин отклонён, ловится guard'ом ниже */ });
|
||||
await page.waitForLoadState('networkidle', { timeout: TIMEOUT_MS }).catch(() => {});
|
||||
|
||||
// Verify: логин-форма всё ещё на странице → вход НЕ удался. Не возвращаем
|
||||
// мусорную (неаутентифицированную) сессию как «успех» (exit 0).
|
||||
if ((await page.locator(loginSelector).count()) > 0) {
|
||||
process.stderr.write(JSON.stringify({ error: 'login rejected: still on login page after submit' }));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let csrf = null;
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Exceptions\Supplier\SupplierClientException;
|
||||
use App\Exceptions\Supplier\SupplierTransientException;
|
||||
use App\Mail\SupplierCriticalAlertMail;
|
||||
use App\Models\Project;
|
||||
use App\Models\SupplierManualSyncQueue;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Supplier\Channel\Exceptions\TierEscalatedException;
|
||||
use App\Services\Supplier\Channel\FailoverProjectChannel;
|
||||
use App\Services\Supplier\Channel\SupplierProjectChannel;
|
||||
use App\Services\Supplier\Dto\SupplierProjectDto;
|
||||
use Illuminate\Contracts\Mail\Mailer;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
test('Tier-1 fail + Tier-2 fail → Tier-3 escalation creates manual queue row + queues alert mail', function (): void {
|
||||
Mail::fake();
|
||||
config(['services.supplier.alert_email' => 'ops@liderra.local']);
|
||||
|
||||
$tenant = Tenant::factory()->create();
|
||||
$project = Project::factory()->for($tenant)->create();
|
||||
|
||||
$tier1 = mock(SupplierProjectChannel::class);
|
||||
$tier1->shouldReceive('listProjects')->andReturn([]); // dedup-сверка: нет совпадений
|
||||
$tier1->shouldReceive('createProject')->andThrow(new SupplierClientException('Tier-1 mock fail'));
|
||||
|
||||
$tier2 = mock(SupplierProjectChannel::class);
|
||||
$tier2->shouldReceive('createProject')->andThrow(new RuntimeException('Tier-2 manage-project.js selector break'));
|
||||
|
||||
$channel = new FailoverProjectChannel($tier1, $tier2, app(Mailer::class));
|
||||
|
||||
$dto = new SupplierProjectDto(
|
||||
platform: 'B1', signalType: 'site', uniqueKey: 'failover-smoke.example',
|
||||
limit: 1, workdays: [1, 2, 3, 4, 5], regions: [], regionsReverse: false, status: 'active',
|
||||
);
|
||||
|
||||
expect(fn () => $channel->createProjectForLiderra($project, $dto))
|
||||
->toThrow(TierEscalatedException::class);
|
||||
|
||||
expect(SupplierManualSyncQueue::where('project_id', $project->id)->count())->toBe(1);
|
||||
|
||||
Mail::assertQueued(SupplierCriticalAlertMail::class, fn ($m) => $m->alertType === 'manual_required');
|
||||
});
|
||||
|
||||
test('Tier-1 transient fail (portal unreachable) bypasses Tier-2 and goes straight to Tier-3', function (): void {
|
||||
Mail::fake();
|
||||
config(['services.supplier.alert_email' => 'ops@liderra.local']);
|
||||
|
||||
$tenant = Tenant::factory()->create();
|
||||
$project = Project::factory()->for($tenant)->create();
|
||||
|
||||
$tier1 = mock(SupplierProjectChannel::class);
|
||||
$tier1->shouldReceive('listProjects')->andReturn([]);
|
||||
$tier1->shouldReceive('createProject')->andThrow(new SupplierTransientException('Connection refused'));
|
||||
|
||||
$tier2 = mock(SupplierProjectChannel::class);
|
||||
$tier2->shouldNotReceive('createProject'); // КЛЮЧЕВОЕ — transient НЕ должен попасть в tier-2
|
||||
|
||||
$channel = new FailoverProjectChannel($tier1, $tier2, app(Mailer::class));
|
||||
|
||||
$dto = new SupplierProjectDto(
|
||||
platform: 'B1', signalType: 'site', uniqueKey: 'transient-smoke.example',
|
||||
limit: 1, workdays: [1, 2, 3, 4, 5], regions: [], regionsReverse: false, status: 'active',
|
||||
);
|
||||
|
||||
expect(fn () => $channel->createProjectForLiderra($project, $dto))
|
||||
->toThrow(TierEscalatedException::class);
|
||||
|
||||
$row = SupplierManualSyncQueue::where('project_id', $project->id)->first();
|
||||
expect($row->failure_reason)->toBe('portal_unreachable');
|
||||
});
|
||||
@@ -224,3 +224,65 @@ test('RefreshSupplierSessionJob throws during initial loadSession translated to
|
||||
->and($caught->getPrevious())->toBeInstanceOf(RuntimeException::class)
|
||||
->and($caught->getPrevious()?->getMessage())->toBe('Simulated Playwright crash during loadSession');
|
||||
});
|
||||
|
||||
test('200 HTML login page triggers RefreshSupplierSessionJob sync and retries once', function (): void {
|
||||
Bus::fake([RefreshSupplierSessionJob::class]);
|
||||
|
||||
Http::fakeSequence('crm.bp-gr.ru/*')
|
||||
->push(
|
||||
'<html><body><form action="/login"><input id="loginform-username" name="LoginForm[username]"></form></body></html>',
|
||||
200,
|
||||
['Content-Type' => 'text/html; charset=utf-8'],
|
||||
)
|
||||
->push('{"projects":[]}', 200, ['Content-Type' => 'application/json']);
|
||||
|
||||
app(SupplierPortalClient::class)->listProjects();
|
||||
|
||||
Bus::assertDispatchedSync(RefreshSupplierSessionJob::class);
|
||||
Http::assertSentCount(2);
|
||||
});
|
||||
|
||||
test('sticky HTML login page after retry throws SupplierAuthException', function (): void {
|
||||
Bus::fake([RefreshSupplierSessionJob::class]);
|
||||
|
||||
Http::fakeSequence('crm.bp-gr.ru/*')
|
||||
->push(
|
||||
'<html><input id="loginform-username"></html>',
|
||||
200,
|
||||
['Content-Type' => 'text/html; charset=utf-8'],
|
||||
)
|
||||
->push(
|
||||
'<html><input id="loginform-username"></html>',
|
||||
200,
|
||||
['Content-Type' => 'text/html; charset=utf-8'],
|
||||
);
|
||||
|
||||
expect(fn () => app(SupplierPortalClient::class)->listProjects())
|
||||
->toThrow(SupplierAuthException::class, 'Portal returned login page after refresh');
|
||||
});
|
||||
|
||||
test('JSON response with substring "loginform-username" is NOT misclassified as login page', function (): void {
|
||||
Http::fake([
|
||||
'crm.bp-gr.ru/*' => Http::response(
|
||||
'{"projects":[{"name":"loginform-username is just a string here"}]}',
|
||||
200,
|
||||
['Content-Type' => 'application/json'],
|
||||
),
|
||||
]);
|
||||
|
||||
$result = app(SupplierPortalClient::class)->listProjects();
|
||||
|
||||
expect($result)->toHaveCount(1);
|
||||
Http::assertSentCount(1); // no retry — JSON header skips login-detect
|
||||
});
|
||||
|
||||
test('200 response without Content-Type header is NOT detected as login page', function (): void {
|
||||
// Документирует контракт: пустой Content-Type → str_starts_with('','text/html') === false → детект пропускается.
|
||||
Http::fake([
|
||||
'crm.bp-gr.ru/*' => Http::response('{"projects":[]}', 200), // no Content-Type header
|
||||
]);
|
||||
|
||||
app(SupplierPortalClient::class)->listProjects();
|
||||
|
||||
Http::assertSentCount(1); // no retry — empty Content-Type fails the text/html gate
|
||||
});
|
||||
|
||||
+11
-1
@@ -1475,7 +1475,6 @@ DWC
|
||||
инжектим
|
||||
фикстурный
|
||||
роута
|
||||
|
||||
# Brain dashboard design spec (2026-05-19)
|
||||
визуализирующий
|
||||
анимируются
|
||||
@@ -1488,3 +1487,14 @@ DWC
|
||||
visualises
|
||||
AGD
|
||||
agg
|
||||
|
||||
# Supplier migration follow-up (2026-05-19)
|
||||
ретрая
|
||||
детекта
|
||||
Регэксп
|
||||
фрэш
|
||||
дебагом
|
||||
srcrt
|
||||
srcbl
|
||||
srcmt
|
||||
симв
|
||||
|
||||
@@ -0,0 +1,601 @@
|
||||
// ════════════════════════════════════════════════════
|
||||
// automation-graph-data.js — shared topology constants
|
||||
// Consumed by:
|
||||
// • docs/automation-graph.html (classic <script>, reads bare consts via shared lexical scope)
|
||||
// • docs/observer/dashboard.html (classic <script>, same mechanism)
|
||||
// Do NOT add ES-module syntax (import/export) — keep as classic script.
|
||||
// ════════════════════════════════════════════════════
|
||||
|
||||
// ════════════════════════════════════════════════════
|
||||
// SECTION 1: NODES
|
||||
// ════════════════════════════════════════════════════
|
||||
|
||||
// Радиально-секторная компоновка.
|
||||
// Сектора (по 90°): N=workflow (0–90), E=UI (90–180), S=infra (180–270), W=data/RLS (270–360).
|
||||
const RADII = [0, 220, 400, 600, 800, 1000, 1180];
|
||||
function pos(ring, angleDeg) {
|
||||
const r = RADII[ring];
|
||||
const a = angleDeg * Math.PI / 180;
|
||||
return { x: Math.round(r * Math.cos(a)), y: Math.round(r * Math.sin(a)) };
|
||||
}
|
||||
|
||||
const NODES = [
|
||||
// ── ПРАВИЛА (5) ── центр + первое кольцо ───────
|
||||
{ id: 'pravila', label: 'Pravila v1.33', group: 'rules', size: 38, ring: 0, ...pos(0, 0) },
|
||||
{ id: 'claude_md', label: 'CLAUDE.md v2.20', group: 'rules', size: 34, ring: 1, ...pos(1, 30) },
|
||||
{ id: 'psr_v1', label: 'PSR_v1 v3.17', group: 'rules', size: 32, ring: 1, ...pos(1, 150) },
|
||||
{ id: 'tooling', label: 'Tooling v2.17', group: 'rules', size: 30, ring: 1, ...pos(1, 270) },
|
||||
{ id: 'router_procedure', label: 'router-procedure v1.0', group: 'rules', size: 24, ring: 1, ...pos(1, 210) },
|
||||
|
||||
// ── ПЛАГИНЫ (13) ── второе кольцо ──────────────
|
||||
{ id: 'superpowers', label: 'Superpowers v5.1', group: 'plugins', size: 30, ring: 2, ...pos(2, 45) },
|
||||
{ id: 'fd_plugin', label: 'Frontend Design', group: 'plugins', size: 26, ring: 2, ...pos(2, 135) },
|
||||
{ id: 'upm', label: 'UI UX Pro Max', group: 'plugins', size: 22, ring: 2, ...pos(2, 165) },
|
||||
{ id: 'claude_md_mgmt', label: 'claude-md-mgmt', group: 'plugins', size: 22, ring: 2, ...pos(2, 225) },
|
||||
{ id: 'hookify_plugin', label: 'hookify (плагин)', group: 'plugins', size: 22, ring: 2, ...pos(2, 200) },
|
||||
{ id: 'skill_creator', label: 'skill-creator', group: 'plugins', size: 20, ring: 2, ...pos(2, 70) },
|
||||
{ id: 'claude_setup', label: 'claude-code-setup', group: 'plugins', size: 22, ring: 2, ...pos(2, 90) },
|
||||
{ id: 'plugin_dev', label: 'plugin-dev', group: 'plugins', size: 22, ring: 2, ...pos(2, 290) },
|
||||
{ id: 'context7', label: 'context7 (docs MCP)', group: 'plugins', size: 20, ring: 2, ...pos(2, 315) },
|
||||
// A6 architecture-tooling — adr-kit / architecture-patterns (плагины) + deptrac (composer dev-dep, job 10) — раздел «Архитектура систем»
|
||||
{ id: 'adr_kit', label: 'adr-kit', group: 'plugins', size: 22, ring: 2, ...pos(2, 240) },
|
||||
{ id: 'arch_patterns', label: 'architecture-patterns',group: 'plugins', size: 20, ring: 2, ...pos(2, 250) },
|
||||
{ id: 'deptrac', label: 'deptrac', group: 'plugins', size: 20, ring: 2, ...pos(2, 260) },
|
||||
// D3 audit-security (17.05.2026) — 2 плагина раздела «Аудит и управление рисками»
|
||||
{ id: 'tob_skills', label: 'Trail of Bits\nskills', group: 'plugins', size: 22, ring: 2, ...pos(2, 330) },
|
||||
{ id: 'sec_guidance', label: 'Security\nGuidance', group: 'plugins', size: 20, ring: 2, ...pos(2, 345) },
|
||||
// C9 project-management-tooling (17.05.2026) — плагин раздела «Управление проектами»
|
||||
{ id: 'product_mgmt', label: 'product-\nmanagement', group: 'plugins', size: 20, ring: 2, ...pos(2, 355) },
|
||||
// A4 design-tooling (17.05.2026) — раздел «Дизайн (UI/UX, графика, бренд)» (плагины)
|
||||
{ id: 'design_plugin', label: 'Design\nplugin', group: 'plugins', size: 20, ring: 2, ...pos(2, 155) },
|
||||
|
||||
// ── СКИЛЫ SUPERPOWERS (14) — N sector (0–90) ────
|
||||
{ id: 'sk_brainstorm', label: 'brainstorming', group: 'skills_sp', size: 18, ring: 3, ...pos(3, 5) },
|
||||
{ id: 'sk_wplans', label: 'writing-plans', group: 'skills_sp', size: 20, ring: 3, ...pos(3, 11) },
|
||||
{ id: 'sk_eplans', label: 'executing-plans', group: 'skills_sp', size: 18, ring: 3, ...pos(3, 17) },
|
||||
{ id: 'sk_subagent', label: 'subagent-driven', group: 'skills_sp', size: 20, ring: 3, ...pos(3, 23) },
|
||||
{ id: 'sk_tdd', label: 'TDD', group: 'skills_sp', size: 18, ring: 3, ...pos(3, 29) },
|
||||
{ id: 'sk_verify', label: 'verification-before-completion', group: 'skills_sp', size: 18, ring: 3, ...pos(3, 36) },
|
||||
{ id: 'sk_debug', label: 'systematic-debugging', group: 'skills_sp', size: 18, ring: 3, ...pos(3, 43) },
|
||||
{ id: 'sk_parallel', label: 'parallel-work', group: 'skills_sp', size: 18, ring: 3, ...pos(3, 50) },
|
||||
{ id: 'sk_worktree', label: 'worktree', group: 'skills_sp', size: 18, ring: 3, ...pos(3, 57) },
|
||||
{ id: 'sk_pr', label: 'finishing-pr', group: 'skills_sp', size: 18, ring: 3, ...pos(3, 64) },
|
||||
{ id: 'sk_coderev', label: 'code-review', group: 'skills_sp', size: 16, ring: 3, ...pos(3, 71) },
|
||||
{ id: 'sk_spreview', label: 'spec-review', group: 'skills_sp', size: 16, ring: 3, ...pos(3, 78) },
|
||||
{ id: 'sk_wskills', label: 'writing-skills', group: 'skills_sp', size: 16, ring: 3, ...pos(3, 85) },
|
||||
{ id: 'sk_elements', label: 'elements-of-style', group: 'skills_sp', size: 16, ring: 3, ...pos(3, 92) },
|
||||
|
||||
// ── СКИЛЫ ПРОЕКТА (6) — W sector (RLS/arch/audit) ────
|
||||
{ id: 'sk_rls', label: 'rls-check', group: 'skills_proj', size: 20, ring: 3, ...pos(3, 305) },
|
||||
{ id: 'sk_qitem', label: 'q-item-add', group: 'skills_proj', size: 20, ring: 3, ...pos(3, 220) },
|
||||
{ id: 'sk_regression', label: 'regression', group: 'skills_proj', size: 20, ring: 3, ...pos(3, 260) },
|
||||
// A6 architecture-tooling (17.05.2026) — вендоренный скил диаграмм
|
||||
{ id: 'mermaid_skill', label: 'mermaid (skill)', group: 'skills_proj', size: 18, ring: 3, ...pos(3, 280) },
|
||||
// D3 audit-security (17.05.2026) — скилы раздела «Аудит и управление рисками»
|
||||
{ id: 'sk_security_review', label: 'security-review', group: 'skills_proj', size: 20, ring: 3, ...pos(3, 315) },
|
||||
{ id: 'sk_audit_portal', label: 'audit-portal', group: 'skills_proj', size: 20, ring: 3, ...pos(3, 325) },
|
||||
// C9 project-management-tooling (17.05.2026) — вендоренный скил раздела «Управление проектами»
|
||||
{ id: 'ccpm', label: 'CCPM\n(skill)', group: 'skills_proj', size: 18, ring: 3, ...pos(3, 335) },
|
||||
// A11 ml-ai-tooling (17.05.2026) — скилы и CLI раздела «ML / AI-разработка»
|
||||
{ id: 'claude_api', label: 'claude-api\n(skill)', group: 'skills_proj', size: 18, ring: 3, ...pos(3, 345) },
|
||||
{ id: 'data_scientist', label: 'Data Scientist\n(skill)', group: 'skills_proj', size: 18, ring: 3, ...pos(3, 355) },
|
||||
{ id: 'promptfoo', label: 'promptfoo', group: 'plugins', size: 20, ring: 2, ...pos(2, 365) },
|
||||
// C10 business-process (17.05.2026) — плагин и скилы раздела «Бизнес-процессы (общее)»
|
||||
{ id: 'ops_plugin', label: 'operations\n(plugin)', group: 'plugins', size: 20, ring: 2, ...pos(2, 385) },
|
||||
{ id: 'process_modeling', label: 'process-modeling\n(skill)', group: 'skills_proj', size: 18, ring: 3, ...pos(3, 367) },
|
||||
{ id: 'process_analysis', label: 'process-analysis\n(skill)', group: 'skills_proj', size: 18, ring: 3, ...pos(3, 377) },
|
||||
// discovery-tooling (18.05.2026) — self-authored скил интервью-discovery
|
||||
{ id: 'discovery_interview', label: 'discovery-interview\n(skill)', group: 'skills_proj', size: 18, ring: 3, ...pos(3, 387) },
|
||||
// brain governance iter9 (19.05.2026) — проектный скил факторного анализа
|
||||
{ id: 'sk_brain_retro', label: '/brain-retro\n(skill)', group: 'skills_proj', size: 18, ring: 3, ...pos(3, 210) },
|
||||
|
||||
// ── ХУКИ (13) — S+infra + E (economy/skill/brain) ───
|
||||
{ id: 'hk_session', label: 'SessionStart:\ncontext-inject', group: 'hooks', size: 24, ring: 4, ...pos(4, 100) },
|
||||
{ id: 'hk_economy', label: 'UserPromptSubmit:\neconomy-mode', group: 'hooks', size: 22, ring: 4, ...pos(4, 95) },
|
||||
{ id: 'hk_pre_claude', label: 'PreToolUse:\nCLAUDE.md-warn', group: 'hooks', size: 22, ring: 4, ...pos(4, 215) },
|
||||
{ id: 'hk_post_md', label: 'PostToolUse:\nmarkdownlint', group: 'hooks', size: 20, ring: 4, ...pos(4, 195) },
|
||||
{ id: 'hk_post_schema', label: 'PostToolUse:\nschema-changelog',group: 'hooks', size: 20, ring: 4, ...pos(4, 300) },
|
||||
{ id: 'hk_self_check', label: 'SessionStart:\neconomy-self-check', group: 'hooks', size: 20, ring: 4, ...pos(4, 105) },
|
||||
{ id: 'hk_skill_marker', label: 'PreToolUse:\nskill-marker', group: 'hooks', size: 20, ring: 4, ...pos(4, 115) },
|
||||
{ id: 'hk_skill_check', label: 'PreToolUse:\nskill-check', group: 'hooks', size: 20, ring: 4, ...pos(4, 125) },
|
||||
{ id: 'hk_state_guard', label: 'PreToolUse:\neconomy-state-guard', group: 'hooks', size: 20, ring: 4, ...pos(4, 135) },
|
||||
{ id: 'hk_postcompact', label: 'PostCompact:\neconomy-postcompact', group: 'hooks', size: 20, ring: 4, ...pos(4, 145) },
|
||||
{ id: 'hk_verifier', label: 'Stop:\neconomy-verifier (агент)', group: 'hooks', size: 22, ring: 4, ...pos(4, 155) },
|
||||
{ id: 'hk_ruflo_queen', label: 'UserPromptSubmit:\nruflo-queen-hook', group: 'ruflo', size: 20, ring: 4, ...pos(4, 165) },
|
||||
// brain governance iter9 (19.05.2026) — Stop-хук observer
|
||||
{ id: 'observer_stophook', label: 'Stop:\nobserver-stop-hook', group: 'hooks', size: 22, ring: 4, ...pos(4, 205) },
|
||||
|
||||
// ── АГЕНТЫ (11) — N (workflow) + W (RLS) ──────
|
||||
{ id: 'ag_explore', label: 'Explore', group: 'agents', size: 20, ring: 4, ...pos(4, 10) },
|
||||
{ id: 'ag_general', label: 'general-purpose', group: 'agents', size: 20, ring: 4, ...pos(4, 25) },
|
||||
{ id: 'ag_plan', label: 'Plan', group: 'agents', size: 20, ring: 4, ...pos(4, 40) },
|
||||
{ id: 'ag_pest', label: 'pest-parallel-debugger', group: 'agents', size: 24, ring: 4, ...pos(4, 55) },
|
||||
{ id: 'ag_guide', label: 'claude-code-guide', group: 'agents', size: 18, ring: 4, ...pos(4, 70) },
|
||||
{ id: 'ag_statusline', label: 'statusline-setup', group: 'agents', size: 18, ring: 4, ...pos(4, 85) },
|
||||
{ id: 'ag_hookify', label: 'hookify:\nconversation-analyzer', group: 'agents', size: 18, ring: 4, ...pos(4, 230) },
|
||||
{ id: 'ag_pcreator', label: 'plugin-dev:\nagent-creator', group: 'agents', size: 16, ring: 4, ...pos(4, 245) },
|
||||
{ id: 'ag_pvalid', label: 'plugin-dev:\nplugin-validator',group: 'agents', size: 16, ring: 4, ...pos(4, 260) },
|
||||
{ id: 'ag_skreview', label: 'plugin-dev:\nskill-reviewer', group: 'agents', size: 16, ring: 4, ...pos(4, 275) },
|
||||
{ id: 'ag_rls', label: 'rls-reviewer', group: 'agents', size: 22, ring: 4, ...pos(4, 315) },
|
||||
// A3 integration-tooling (17.05.2026) — agent раздела «Программирование — интеграции»
|
||||
{ id: 'ag_apidocs', label: 'api-docs (agent)', group: 'agents', size: 18, ring: 4, ...pos(4, 175) },
|
||||
|
||||
// ── MCP-СЕРВЕРЫ (9) — E (UI) + W (data) ───────
|
||||
{ id: 'mcp_21st', label: 'MCP: 21st.dev Magic', group: 'mcp', size: 20, ring: 5, ...pos(5, 130) },
|
||||
// A4 design-tooling (17.05.2026) — MCP-серверы раздела «Дизайн (UI/UX, графика, бренд)»
|
||||
{ id: 'mcp_figma', label: 'MCP: Figma\n(DEFERRED)', group: 'mcp', size: 18, ring: 5, ...pos(5, 140) },
|
||||
{ id: 'mcp_icons', label: 'MCP: Universal\nIcons', group: 'mcp', size: 18, ring: 5, ...pos(5, 120) },
|
||||
{ id: 'mcp_pw', label: 'MCP: playwright', group: 'mcp', size: 22, ring: 5, ...pos(5, 110) },
|
||||
{ id: 'mcp_gh', label: 'MCP: github', group: 'mcp', size: 22, ring: 5, ...pos(5, 75) },
|
||||
{ id: 'mcp_boost', label: 'MCP: laravel-boost', group: 'mcp', size: 24, ring: 5, ...pos(5, 290) },
|
||||
{ id: 'mcp_redis', label: 'MCP: redis', group: 'mcp', size: 22, ring: 5, ...pos(5, 310) },
|
||||
{ id: 'mcp_sentry', label: 'MCP: sentry', group: 'mcp', size: 22, ring: 5, ...pos(5, 330) },
|
||||
{ id: 'mcp_semgrep', label: 'MCP: semgrep', group: 'mcp', size: 20, ring: 5, ...pos(5, 350) },
|
||||
// A3 integration-tooling (17.05.2026) — MCP-сервер раздела «Программирование — интеграции»
|
||||
{ id: 'mcp_openapi', label: 'MCP: openapi', group: 'mcp', size: 20, ring: 5, ...pos(5, 5) },
|
||||
|
||||
// ── LEFTHOOK JOBS (15) — S+W (infra/data/brain) ─────
|
||||
{ id: 'lh_mdlint', label: 'lefthook:\nmarkdownlint', group: 'lefthook', size: 18, ring: 5, ...pos(5, 185) },
|
||||
{ id: 'lh_cspell', label: 'lefthook:\ncspell', group: 'lefthook', size: 18, ring: 5, ...pos(5, 200) },
|
||||
{ id: 'lh_stylelint', label: 'lefthook:\nstylelint', group: 'lefthook', size: 16, ring: 5, ...pos(5, 215) },
|
||||
{ id: 'lh_eslint', label: 'lefthook:\neslint-vue', group: 'lefthook', size: 18, ring: 5, ...pos(5, 230) },
|
||||
{ id: 'lh_lychee', label: 'lefthook:\nlychee-links', group: 'lefthook', size: 18, ring: 5, ...pos(5, 245) },
|
||||
{ id: 'lh_gitleaks', label: 'lefthook:\ngitleaks', group: 'lefthook', size: 18, ring: 5, ...pos(5, 260) },
|
||||
{ id: 'lh_gitleaks2', label: 'lefthook:\ngitleaks pre-push', group: 'lefthook', size: 18, ring: 5, ...pos(5, 275) },
|
||||
{ id: 'lh_pint', label: 'lefthook:\npint', group: 'lefthook', size: 18, ring: 5, ...pos(5, 25) },
|
||||
{ id: 'lh_larastan', label: 'lefthook:\nlarastan', group: 'lefthook', size: 18, ring: 5, ...pos(5, 50) },
|
||||
{ id: 'lh_squawk', label: 'lefthook:\nsquawk', group: 'lefthook', size: 18, ring: 5, ...pos(5, 320) },
|
||||
// brain governance iter9 (19.05.2026) — 5 контролёров C1-C5 (lefthook jobs 11-15)
|
||||
{ id: 'lh_l1watcher', label: 'lefthook:\nl1-watcher (C1)', group: 'lefthook', size: 16, ring: 5, ...pos(5, 150) },
|
||||
{ id: 'lh_crossref', label: 'lefthook:\ncross-ref-checker (C2)', group: 'lefthook', size: 16, ring: 5, ...pos(5, 157) },
|
||||
{ id: 'lh_obs_obs', label: 'lefthook:\nobserver-of-observer (C3)',group: 'lefthook', size: 16, ring: 5, ...pos(5, 164) },
|
||||
{ id: 'lh_status_md', label: 'lefthook:\nstatus-md (C4)', group: 'lefthook', size: 16, ring: 5, ...pos(5, 171) },
|
||||
{ id: 'lh_obs_cov', label: 'lefthook:\nobserver-coverage (C5)', group: 'lefthook', size: 16, ring: 5, ...pos(5, 178) },
|
||||
|
||||
// ── MEMORY FILES (24) — внешнее кольцо ──────────
|
||||
{ id: 'mem_user', label: 'memory:\nuser_profile', group: 'memory', size: 16, ring: 6, ...pos(6, 0) },
|
||||
{ id: 'mem_comm', label: 'memory:\nfeedback_comm', group: 'memory', size: 14, ring: 6, ...pos(6, 24) },
|
||||
{ id: 'mem_env', label: 'memory:\nfeedback_env', group: 'memory', size: 16, ring: 6, ...pos(6, 48) },
|
||||
{ id: 'mem_sp', label: 'memory:\nfeedback_superpowers',group: 'memory', size: 16, ring: 6, ...pos(6, 72) },
|
||||
{ id: 'mem_plugins', label: 'memory:\nfeedback_plugins', group: 'memory', size: 16, ring: 6, ...pos(6, 96) },
|
||||
{ id: 'mem_handoff', label: 'memory:\nreference_handoff', group: 'memory', size: 14, ring: 6, ...pos(6, 120) },
|
||||
{ id: 'mem_redesign', label: 'memory:\nportal_redesign', group: 'memory', size: 14, ring: 6, ...pos(6, 144) },
|
||||
{ id: 'mem_devindices', label: 'memory:\ndev_indices', group: 'memory', size: 12, ring: 6, ...pos(6, 168) },
|
||||
{ id: 'mem_phase1', label: 'memory:\nphase1_strategy', group: 'memory', size: 14, ring: 6, ...pos(6, 192) },
|
||||
{ id: 'mem_state', label: 'memory:\nproject_state', group: 'memory', size: 16, ring: 6, ...pos(6, 216) },
|
||||
{ id: 'mem_brain', label: 'memory:\nclaude_brain', group: 'memory', size: 14, ring: 6, ...pos(6, 240) },
|
||||
{ id: 'mem_supplier', label: 'memory:\nsupplier_integration',group: 'memory', size: 14, ring: 6, ...pos(6, 264) },
|
||||
{ id: 'mem_audit', label: 'memory:\naudit_2026-05-13', group: 'memory', size: 14, ring: 6, ...pos(6, 288) },
|
||||
{ id: 'mem_archive', label: 'memory:\nreference_archive', group: 'memory', size: 14, ring: 6, ...pos(6, 312) },
|
||||
{ id: 'mem_github', label: 'memory:\nreference_github', group: 'memory', size: 14, ring: 6, ...pos(6, 336) },
|
||||
{ id: 'mem_audit_b', label: 'memory:\naudit_B_status', group: 'memory', size: 12, ring: 6, ...pos(6, 12) },
|
||||
{ id: 'mem_audit_c', label: 'memory:\naudit_C_pending', group: 'memory', size: 12, ring: 6, ...pos(6, 36) },
|
||||
{ id: 'mem_suppliercrm',label: 'memory:\nsupplier_crm', group: 'memory', size: 12, ring: 6, ...pos(6, 60) },
|
||||
{ id: 'mem_audit12', label: 'memory:\nfull_audit_05-12', group: 'memory', size: 12, ring: 6, ...pos(6, 84) },
|
||||
{ id: 'mem_audit14', label: 'memory:\nfull_audit_05-14', group: 'memory', size: 12, ring: 6, ...pos(6, 108) },
|
||||
{ id: 'mem_sprint1', label: 'memory:\nsprint1_p0_closure', group: 'memory', size: 12, ring: 6, ...pos(6, 132) },
|
||||
{ id: 'mem_sprint2', label: 'memory:\nsprint2_p1_progress', group: 'memory', size: 12, ring: 6, ...pos(6, 156) },
|
||||
{ id: 'mem_sprint3', label: 'memory:\nsprint3_progress', group: 'memory', size: 12, ring: 6, ...pos(6, 180) },
|
||||
// brain governance iter9 (19.05.2026) — хранилище evidence «мозга»
|
||||
{ id: 'observer_evidence', label: 'docs/observer/\nepisodes+STATUS', group: 'memory', size: 16, ring: 6, ...pos(6, 204) },
|
||||
|
||||
// ── RUFLO ОРКЕСТРАТОР (9) — фактический реколлаж iter5 — кластер вне радиального layout (верх-лево) ──
|
||||
{ id: 'ruflo_queen', label: 'ruflo Queen\n(hive-mind)', group: 'ruflo', size: 44, x: -1340, y: -700 },
|
||||
{ id: 'ruflo_plugins', label: 'плагины ruflo\n0 из 20 · скилов 0', group: 'ruflo', size: 20, x: -1340, y: -880 },
|
||||
{ id: 'ruflo_workers', label: '10 воркеров\nhive-mind (idle)', group: 'ruflo', size: 26, x: -1160, y: -800 },
|
||||
{ id: 'ruflo_agents_catalog', label: 'каталог агентов ruflo\n(100 определений)', group: 'ruflo', size: 24, x: -1530, y: -830 },
|
||||
{ id: 'ruflo_commands', label: 'slash-команды\nruflo (88)', group: 'ruflo', size: 22, x: -1140, y: -630 },
|
||||
{ id: 'ruflo_daemon', label: 'демон ruflo\n(воркеры падают)', group: 'ruflo', size: 24, x: -1560, y: -650 },
|
||||
{ id: 'ruflo_memory', label: 'память ruflo\n(~0 записей)', group: 'ruflo', size: 24, x: -1380, y: -500 },
|
||||
{ id: 'ruflo_mcp', label: 'ruflo MCP\n(~210 инструментов)', group: 'ruflo', size: 26, x: -1190, y: -460 },
|
||||
{ id: 'ruflo_recall_hook', label: 'хук recall\n(UserPromptSubmit)', group: 'ruflo', size: 22, x: -1570, y: -470 },
|
||||
|
||||
// ── MEMORY +1 (артефакт ruflo big-bang) ──
|
||||
{ id: 'mem_ruflo', label: 'memory:\nproject_ruflo_integration', group: 'memory', size: 14, x: -1740, y: -620 },
|
||||
];
|
||||
|
||||
// ════════════════════════════════════════════════════
|
||||
// SECTION 2: EDGES
|
||||
// ════════════════════════════════════════════════════
|
||||
const CONFLICT_TYPES = {
|
||||
RED: { color: '#ff5f57', bg: '#2d0000', emoji: '🔴', label: 'Не закрыт правилом', rank: 1 },
|
||||
BLACK: { color: '#888888', bg: '#1a1a1a', emoji: '⚫', label: 'Возник на практике', rank: 2 },
|
||||
GREEN: { color: '#859900', bg: '#0e1a00', emoji: '🟢', label: 'Закрыт правилом', rank: 3 },
|
||||
};
|
||||
const E = (from, to, label) => ({
|
||||
from, to,
|
||||
title: label,
|
||||
color: { color: '#586e75', highlight: '#93a1a1', hover: '#93a1a1' },
|
||||
arrows: { to: { enabled: true, scaleFactor: 0.6 } },
|
||||
smooth: { type: 'continuous', roundness: 0.5 }
|
||||
});
|
||||
const CONFLICT = (from, to, label, type = 'RED') => ({
|
||||
from, to,
|
||||
title: label,
|
||||
label: CONFLICT_TYPES[type].emoji,
|
||||
dashes: true,
|
||||
width: 2,
|
||||
color: { color: CONFLICT_TYPES[type].color, highlight: '#ff8880', hover: '#ff8880' },
|
||||
arrows: { to: { enabled: true, scaleFactor: 0.7 }, from: { enabled: true, scaleFactor: 0.7 } },
|
||||
font: { color: CONFLICT_TYPES[type].color, size: 14, align: 'middle', strokeWidth: 3, strokeColor: '#1e1e2e' },
|
||||
smooth: { type: 'curvedCW', roundness: 0.35 }
|
||||
});
|
||||
|
||||
const EDGES = [
|
||||
// ── ПРАВИЛА — иерархия ──────────────────────────
|
||||
E('pravila', 'claude_md', 'подчиняет\n(уровень 1→2a)'),
|
||||
E('pravila', 'psr_v1', 'подчиняет\n(уровень 1→3)'),
|
||||
E('claude_md', 'tooling', 'ссылается\nна реестр'),
|
||||
E('pravila', 'superpowers', '§12: обязывает\nинвокировать 1-м'),
|
||||
|
||||
// ── PSR_v1 координирует плагины ─────────────────
|
||||
E('psr_v1', 'superpowers', 'R5: координирует\nпарный стек'),
|
||||
E('psr_v1', 'fd_plugin', 'R5: координирует\nпарный стек'),
|
||||
E('psr_v1', 'upm', 'R14.3: активирует\nтолько через pipeline'),
|
||||
E('psr_v1', 'mcp_21st', 'R14.4: активирует\nтолько через pipeline'),
|
||||
E('psr_v1', 'claude_md_mgmt','R10.1 блок 1:\nинфраструктурный'),
|
||||
|
||||
// ── CLAUDE.md ────────────────────────────────────
|
||||
E('claude_md', 'mcp_boost', 'описывает §3.2'),
|
||||
E('claude_md', 'mcp_sentry', 'описывает §4.8'),
|
||||
E('claude_md', 'mcp_redis', 'описывает §4.9'),
|
||||
E('claude_md', 'claude_md_mgmt', '§5п.10:\nединственный канал'),
|
||||
E('claude_md', 'ag_pest', 'описывает\nкогда вызывать'),
|
||||
E('claude_md', 'ag_rls', 'описывает\nкогда вызывать'),
|
||||
|
||||
// ── ХУКИ ────────────────────────────────────────
|
||||
E('hk_pre_claude', 'claude_md', 'проверяет\nпри Edit/Write'),
|
||||
E('hk_post_md', 'lh_mdlint', 'дублирует задачу\n(локально)'),
|
||||
E('hk_post_schema', 'claude_md', 'напоминает про\nCHANGELOG_schema'),
|
||||
E('hk_session', 'mem_user', 'читает\nпри старте'),
|
||||
E('hk_session', 'mem_env', 'читает\nпри старте'),
|
||||
E('hk_session', 'mem_sp', 'читает\nпри старте'),
|
||||
E('hk_session', 'mem_plugins', 'читает\nпри старте'),
|
||||
E('hk_session', 'mem_state', 'читает\nпри старте'),
|
||||
E('hk_economy', 'superpowers', 'парсит уровень\nэкономии'),
|
||||
|
||||
// ── SUPERPOWERS содержит скилы ──────────────────
|
||||
E('superpowers', 'sk_brainstorm', 'содержит'),
|
||||
E('superpowers', 'sk_tdd', 'содержит'),
|
||||
E('superpowers', 'sk_debug', 'содержит'),
|
||||
E('superpowers', 'sk_wplans', 'содержит'),
|
||||
E('superpowers', 'sk_eplans', 'содержит'),
|
||||
E('superpowers', 'sk_verify', 'содержит'),
|
||||
E('superpowers', 'sk_parallel', 'содержит'),
|
||||
E('superpowers', 'sk_worktree', 'содержит'),
|
||||
E('superpowers', 'sk_pr', 'содержит'),
|
||||
E('superpowers', 'sk_subagent', 'содержит'),
|
||||
E('superpowers', 'sk_wskills', 'содержит'),
|
||||
E('superpowers', 'sk_spreview', 'содержит'),
|
||||
E('superpowers', 'sk_coderev', 'содержит'),
|
||||
E('superpowers', 'sk_elements', 'содержит'),
|
||||
|
||||
// ── СКИЛЫ вызывают друг друга ───────────────────
|
||||
E('sk_brainstorm', 'sk_wplans', 'вызывает\nпосле дизайна'),
|
||||
E('sk_wplans', 'sk_eplans', 'вызывает\nдля выполнения'),
|
||||
E('sk_wplans', 'sk_subagent','альтернатива\nexecuting-plans'),
|
||||
E('sk_subagent', 'ag_explore', 'запускает\nдля поиска'),
|
||||
E('sk_subagent', 'ag_general', 'запускает\nдля задач'),
|
||||
E('sk_subagent', 'ag_plan', 'запускает\nдля архитектуры'),
|
||||
E('sk_parallel', 'sk_worktree','использует\nдля изоляции'),
|
||||
|
||||
// ── СКИЛЫ ПРОЕКТА ───────────────────────────────
|
||||
E('sk_rls', 'tooling', 'использует\nsquawk + grep §3.2'),
|
||||
E('sk_rls', 'mcp_boost', 'SQL запросы\nк схеме'),
|
||||
E('sk_qitem', 'claude_md_mgmt','делегирует\nправку CLAUDE.md'),
|
||||
|
||||
// ── CLAUDE-MD-MGMT ──────────────────────────────
|
||||
E('claude_md_mgmt', 'claude_md', 'единственный\nканал правок'),
|
||||
|
||||
// ── HOOKIFY ─────────────────────────────────────
|
||||
E('ag_hookify', 'hookify_plugin', 'передаёт\nанализ'),
|
||||
E('hookify_plugin', 'hk_pre_claude', 'может создавать\nновые хуки'),
|
||||
E('hookify_plugin', 'hk_economy', 'может создавать\nновые хуки'),
|
||||
|
||||
// ── АГЕНТЫ используют MCP ───────────────────────
|
||||
E('ag_pest', 'mcp_redis', 'читает\nочереди/кэш'),
|
||||
E('ag_rls', 'mcp_boost', 'SQL запросы\nк БД'),
|
||||
E('ag_guide', 'mcp_gh', 'ищет\nв репозитории'),
|
||||
|
||||
// ── LEFTHOOK вызывается git ──────────────────────
|
||||
E('lh_gitleaks', 'mem_plugins', 'блокирует коммит\nпри ПДн в staged'),
|
||||
E('lh_larastan', 'mcp_boost', 'Boost даёт\nконтекст типов'),
|
||||
E('lh_squawk', 'tooling', 'соответствует\n§3.2 #15'),
|
||||
E('lh_gitleaks2', 'lh_gitleaks', 'строже:\nвся история'),
|
||||
E('lh_lychee', 'claude_md', 'проверяет\nссылки в .md'),
|
||||
|
||||
// ── MEMORY читается Claude ──────────────────────
|
||||
E('mem_env', 'ag_pest', 'квирки 73/77\nиспользует агент'),
|
||||
E('mem_plugins', 'psr_v1', 'отражает\nтекущие версии'),
|
||||
E('mem_archive', 'claude_md', 'синхронизирует\nверсии доков'),
|
||||
|
||||
// ── MCP ─────────────────────────────────────────
|
||||
E('mcp_pw', 'hk_session', 'используется\nдля a11y smoke'),
|
||||
E('mcp_gh', 'sk_pr', 'PR, issues\nпри finishing-pr'),
|
||||
E('mcp_boost', 'ag_rls', 'схема БД\nдля RLS-review'),
|
||||
|
||||
// ── АУДИТ-АКТУАЛИЗАЦИЯ 16.05.2026 — связи новых узлов ──
|
||||
// 4 ребра psr_v1→skill_creator/claude_setup/plugin_dev/context7 — перенесены
|
||||
// в ADT-блок 18.05.2026 (точные категории authoring-tooling/dev-support, дедуп)
|
||||
E('plugin_dev', 'ag_pcreator', 'содержит\nагента'),
|
||||
E('plugin_dev', 'ag_pvalid', 'содержит\nагента'),
|
||||
E('plugin_dev', 'ag_skreview', 'содержит\nагента'),
|
||||
E('skill_creator', 'sk_wskills', 'обе создают\nскилы'),
|
||||
E('hk_self_check', 'hk_economy', 'система\nэкономии'),
|
||||
E('hk_skill_marker', 'hk_skill_check', 'пара\nmarker/check'),
|
||||
E('hk_skill_check', 'superpowers', 'энфорсит §12:\nскил перед кодом'),
|
||||
E('hk_state_guard', 'hk_economy', 'система\nэкономии'),
|
||||
E('hk_postcompact', 'hk_economy', 'переинжект\nрежима после компакта'),
|
||||
E('hk_verifier', 'sk_verify', 'энфорсит\nпроверку готовности'),
|
||||
E('hk_ruflo_queen', 'ruflo_queen', '§14: маршрут\nqueen-задач'),
|
||||
E('sk_regression', 'ag_pest', 'передаёт разбор\nпадений Pest --parallel'),
|
||||
|
||||
// ── A6 ARCHITECTURE-TOOLING 17.05.2026 — связи новых узлов ──
|
||||
E('psr_v1', 'adr_kit', 'R10.1 блок 1:\narchitecture-tooling'),
|
||||
E('psr_v1', 'arch_patterns', 'R10.1 блок 1:\narchitecture-tooling'),
|
||||
E('tooling', 'mermaid_skill', '§4.12: реестр\n(вендоренный скил)'),
|
||||
E('psr_v1', 'deptrac', 'R10.1 блок 1 note:\narchitecture-tooling'),
|
||||
|
||||
// ── A4 DESIGN-TOOLING 17.05.2026 — связи новых узлов ──
|
||||
E('psr_v1', 'design_plugin', 'R10.1 блок 1:\ndesign-tooling'),
|
||||
E('psr_v1', 'mcp_icons', 'R10.1 блок 3:\ndesign-tooling'),
|
||||
E('psr_v1', 'mcp_figma', 'R10.1 блок 3:\ndesign-tooling (DEFERRED)'),
|
||||
|
||||
// ── D3 AUDIT-SECURITY 17.05.2026 — связи новых узлов ──
|
||||
E('psr_v1', 'tob_skills', 'R10.1 блок 1:\naudit-security'),
|
||||
E('psr_v1', 'sec_guidance', 'R10.1 блок 1:\naudit-security'),
|
||||
E('tooling', 'tob_skills', '§4.14 #39 — реестр'),
|
||||
E('tooling', 'sec_guidance', '§4.15 #40 — реестр'),
|
||||
E('sk_audit_portal', 'sk_security_review', 'оркеструет\nкак фазу аудита'),
|
||||
E('sk_audit_portal', 'tob_skills', 'оркеструет\nглубокие кампании'),
|
||||
E('sk_audit_portal', 'sk_regression', 'использует\nна фазе тестов'),
|
||||
CONFLICT('tob_skills', 'mcp_semgrep', 'TB1: граница разграничена — Semgrep = inline SAST, Trail of Bits = глубокие on-demand аудит-кампании. Параллельное использование разрешено при разных сценариях.', 'GREEN'),
|
||||
|
||||
// ── A3 INTEGRATION-TOOLING 17.05.2026 — связи новых узлов ──
|
||||
E('psr_v1', 'mcp_openapi', 'R10.1 блок 3:\nintegration-tooling'),
|
||||
E('tooling', 'mcp_openapi', '§4.22 #47 — реестр'),
|
||||
E('ag_apidocs', 'mcp_openapi', 'спека → MCP-ресурс'),
|
||||
|
||||
// ── A11 ML-AI-TOOLING 17.05.2026 — связи новых узлов ──
|
||||
E('psr_v1', 'promptfoo', 'R10.1 блок 1:\nml-ai-tooling'),
|
||||
E('tooling', 'claude_api', 'reuse — built-in skill\n(PSR_v1 R10.1 блок 2)'),
|
||||
E('tooling', 'data_scientist', '§4.24 #49 — реестр'),
|
||||
|
||||
// ── C10 BUSINESS-PROCESS 17.05.2026 — связи новых узлов ──
|
||||
E('psr_v1', 'ops_plugin', 'R10.1 блок 1:\nbusiness-process'),
|
||||
E('tooling', 'process_modeling', '§4.27 #52 — реестр'),
|
||||
E('tooling', 'process_analysis', '§4.28 #53 — реестр'),
|
||||
|
||||
// ── DISCOVERY-TOOLING 18.05.2026 — связи узла discovery-interview ──
|
||||
E('tooling', 'discovery_interview', '§4.30 #55 — реестр'),
|
||||
E('psr_v1', 'discovery_interview', 'R10.1 блок 1 note:\ndiscovery-tooling'),
|
||||
E('discovery_interview', 'sk_brainstorm', 'хэндофф:\nFEATURE-brief'),
|
||||
E('discovery_interview', 'process_analysis', 'граница: слой-источник\n(ADR-009 DI2)'),
|
||||
|
||||
// ── ANTHROPIC DEV-TOOLING 18.05.2026 — связи 5 узлов ──
|
||||
E('psr_v1', 'skill_creator', 'R10.1 блок 1:\nauthoring-tooling'),
|
||||
E('psr_v1', 'plugin_dev', 'R10.1 блок 1:\nauthoring-tooling'),
|
||||
E('psr_v1', 'hookify_plugin', 'R10.1 блок 1:\nauthoring-tooling (HK1)'),
|
||||
E('psr_v1', 'claude_setup', 'R10.1 блок 1:\ndev-support'),
|
||||
E('psr_v1', 'context7', 'R10.1 блок 1:\ndev-support'),
|
||||
|
||||
// ── BRAIN GOVERNANCE iter9 (19.05.2026, ADR-011) — связи 9 новых узлов ──
|
||||
E('claude_md', 'router_procedure', '§3.6: SoT\nпроцедуры роутера'),
|
||||
E('tooling', 'router_procedure', '§4.X реестр →\nшаг 3 роутера'),
|
||||
E('pravila', 'router_procedure', '§12/§14/§15\nhard-floor'),
|
||||
E('pravila', 'observer_stophook', '§16: observer\n+ routing-тег'),
|
||||
E('observer_stophook', 'observer_evidence', 'пишет эпизоды\n+ routing-gate'),
|
||||
E('pravila', 'sk_brain_retro', '§16: факторный\nанализ раз в спринт'),
|
||||
E('sk_brain_retro', 'observer_evidence', 'читает эпизоды\n(факторный анализ)'),
|
||||
E('lh_l1watcher', 'tooling', 'C1 STRICT: settings.json\n↔ Tooling drift'),
|
||||
E('lh_crossref', 'claude_md', 'C2 STRICT: version\ndrift §0 cross-refs'),
|
||||
E('lh_obs_obs', 'observer_evidence', 'C3 warn: счётчик\n+54w self-prune'),
|
||||
E('lh_status_md', 'observer_evidence', 'C4: генерит\nSTATUS.md'),
|
||||
E('lh_obs_cov', 'observer_evidence', 'C5 warn: покрытие\n+ регистрация'),
|
||||
|
||||
// ══════════════════════════════════════════════════
|
||||
// КОНФЛИКТЫ — 3-color classification (iter2 §4)
|
||||
// 🔴 не закрыт правилом / ⚫ возник на практике / 🟢 закрыт правилом
|
||||
// ══════════════════════════════════════════════════
|
||||
CONFLICT('sk_rls', 'ag_rls', 'RLS: граница задана — скил по таблице, агент по diff/PR (spec 2026-05-16)', 'GREEN'),
|
||||
CONFLICT('hookify_plugin', 'hk_pre_claude', 'Закрыто правилом HK1 (ADR-010, PSR_v1 R10.1 v3.14): hookify вызывается только по явному /hookify + обязательный pre-check на коллизию с зарегистрированными хуками; перезапись economy/skill-discipline архитектуры запрещена', 'GREEN'),
|
||||
CONFLICT('mcp_pw', 'sk_parallel', 'Профиль Playwright MCP хэшируется per-cwd (квирк #95) → worktrees получают разные mcp-chrome-{hash}, не конфликтуют. Same-dir parallel — редкий случай (две Claude-сессии в одной dir), регулируется Pravila §15.2 claim в docs/sessions/CURRENT.md', 'GREEN'),
|
||||
CONFLICT('ag_pest', 'mcp_redis', 'Квирк 72 устранён 16.05.2026 (commit 0fa1a73 — array-стор в тестах): гонки в Redis при Pest --parallel больше нет', 'GREEN'),
|
||||
CONFLICT('psr_v1', 'claude_md', 'Закрыто §5п.10 CLAUDE.md + хук CLAUDE.md-warn', 'GREEN'),
|
||||
CONFLICT('upm', 'fd_plugin', 'PSR_v1 R14.5: не параллельно', 'GREEN'),
|
||||
CONFLICT('mcp_21st', 'fd_plugin', 'PSR_v1 R14.5: не параллельно', 'GREEN'),
|
||||
CONFLICT('hk_economy', 'superpowers', '§12 — hard-rule уровня 0; economy-режим §12 не отменяет (Pravila §12.4)', 'GREEN'),
|
||||
CONFLICT('observer_stophook', 'hk_verifier', 'HK1 §5.3: оба на Stop-event — коллизии нет (append-chain). Оба способны decision:block; Claude Code прогоняет все Stop-хуки, любой block ⇒ продолжение хода. observer-gate детерминированный и дешёвый.', 'GREEN'),
|
||||
|
||||
// ══════════════════════════════════════════════════
|
||||
// RUFLO ОРКЕСТРАТОР — фактический реколлаж (iter5, 2026-05-15)
|
||||
// ══════════════════════════════════════════════════
|
||||
// Queen → артефакты установки ruflo init (рой idle, артефакты не задействованы)
|
||||
E('ruflo_queen', 'ruflo_workers', 'координирует\n(0 задач)'),
|
||||
E('ruflo_queen', 'ruflo_agents_catalog', 'ruflo init высыпал\n(не задействовано)'),
|
||||
E('ruflo_queen', 'ruflo_commands', 'ruflo init высыпал\n(не задействовано)'),
|
||||
E('ruflo_queen', 'ruflo_plugins', 'плагинов ruflo:\n0 установлено'),
|
||||
// MCP-сервер ruflo — связывает половины кластера + читает/пишет память
|
||||
E('ruflo_mcp', 'ruflo_queen', 'инструменты\nуправления роем'),
|
||||
E('ruflo_mcp', 'ruflo_memory', 'читает/пишет\nпамять'),
|
||||
// память ruflo — recall-хук и воркер consolidate демона
|
||||
E('ruflo_recall_hook', 'ruflo_memory', 'запускает\nruflo memory search'),
|
||||
E('ruflo_daemon', 'ruflo_memory', 'воркер consolidate\nобращается к памяти'),
|
||||
// 4 узла-правила → Queen (реколлаж 16.05.2026: ruflo — advisory-подсистема; Pravila §14 — queen-триггер)
|
||||
E('pravila', 'ruflo_queen', '§14:\nqueen-триггер'),
|
||||
E('claude_md', 'ruflo_queen', '§3.5: описывает\n(advisory-подсистема)'),
|
||||
E('psr_v1', 'ruflo_queen', '§14:\ncross-ref'),
|
||||
E('tooling', 'ruflo_queen', '§4.10: реестр\n(advisory-подсистема)'),
|
||||
// memory → ruflo
|
||||
E('mem_ruflo', 'ruflo_queen', 'документирует\nинтеграцию'),
|
||||
|
||||
// 3 конфликта ruflo (3-color, iter2 §4)
|
||||
CONFLICT('ruflo_queen', 'pravila', 'Закрыто реколлажем 16.05.2026: нормативка приведена к рантайму — ruflo переописан в advisory/automation-подсистему, декларация уровня −1 убрана', 'GREEN'),
|
||||
CONFLICT('ruflo_memory', 'mem_state', 'Два хранилища памяти не синхронизированы; память ruflo почти пуста (0 записей)', 'BLACK'),
|
||||
CONFLICT('ruflo_daemon', 'ag_pest', 'Worker-jitter демона ruflo усиливает Pest-квирки 73/77 (квирк 72 устранён 16.05 — его jitter больше не усиливает)', 'BLACK'),
|
||||
];
|
||||
|
||||
// ════════════════════════════════════════════════════
|
||||
// SECTION 3: CATEGORY LABELS
|
||||
// ════════════════════════════════════════════════════
|
||||
const CATEGORY_LABELS = {
|
||||
rules: 'Правило', plugins: 'Плагин', skills_sp: 'Скил Superpowers',
|
||||
skills_proj: 'Скил проекта', hooks: 'Хук .claude', agents: 'Агент',
|
||||
mcp: 'MCP-сервер', lefthook: 'Lefthook job', memory: 'Memory-файл',
|
||||
ruflo: 'ruflo (изолирован)'
|
||||
};
|
||||
|
||||
// ════════════════════════════════════════════════════
|
||||
// SECTION 3.4: SECTION BUCKETS & SECTIONS
|
||||
// ════════════════════════════════════════════════════
|
||||
const SECTION_BUCKETS = [
|
||||
{ id: 'A', label: 'Технические и продуктовые' },
|
||||
{ id: 'B', label: 'Коммуникации' },
|
||||
{ id: 'C', label: 'Бизнес и операции' },
|
||||
{ id: 'D', label: 'Право и комплаенс' },
|
||||
{ id: 'E', label: 'Мета и управление' },
|
||||
];
|
||||
const SECTIONS = [
|
||||
{ id: 'A1', bucket: 'A', label: 'Программирование — backend' },
|
||||
{ id: 'A2', bucket: 'A', label: 'Программирование — frontend' },
|
||||
{ id: 'A3', bucket: 'A', label: 'Программирование — интеграции (API, вебхуки)' },
|
||||
{ id: 'A4', bucket: 'A', label: 'Дизайн (UI/UX, графика, бренд)' },
|
||||
{ id: 'A5', bucket: 'A', label: 'Тестирование, QA и отладка' },
|
||||
{ id: 'A6', bucket: 'A', label: 'Архитектура систем' },
|
||||
{ id: 'A7', bucket: 'A', label: 'DevOps, инфраструктура, деплой' },
|
||||
{ id: 'A8', bucket: 'A', label: 'Информационная безопасность' },
|
||||
{ id: 'A9', bucket: 'A', label: 'Работа с данными (БД, миграции, RLS)' },
|
||||
{ id: 'A10', bucket: 'A', label: 'Аналитика и отчётность (BI)' },
|
||||
{ id: 'A11', bucket: 'A', label: 'ML / AI-разработка' },
|
||||
{ id: 'B1', bucket: 'B', label: 'Голосовое общение по телефону' },
|
||||
{ id: 'B2', bucket: 'B', label: 'Мессенджеры' },
|
||||
{ id: 'B3', bucket: 'B', label: 'Электронная почта' },
|
||||
{ id: 'B4', bucket: 'B', label: 'SMS-рассылки' },
|
||||
{ id: 'B5', bucket: 'B', label: 'Видеосвязь' },
|
||||
{ id: 'B6', bucket: 'B', label: 'Чат на сайте / онлайн-консультант' },
|
||||
{ id: 'B7', bucket: 'B', label: 'Социальные сети' },
|
||||
{ id: 'B8', bucket: 'B', label: 'Push / in-app уведомления' },
|
||||
{ id: 'C1', bucket: 'C', label: 'Маркетинг и лидогенерация' },
|
||||
{ id: 'C2', bucket: 'C', label: 'Продажи' },
|
||||
{ id: 'C3', bucket: 'C', label: 'Квалификация и обработка лидов' },
|
||||
{ id: 'C4', bucket: 'C', label: 'Работа с поставщиками лидов' },
|
||||
{ id: 'C5', bucket: 'C', label: 'Клиентский успех, поддержка, удержание' },
|
||||
{ id: 'C6', bucket: 'C', label: 'Финансы — биллинг и тарификация' },
|
||||
{ id: 'C7', bucket: 'C', label: 'Финансы — бухгалтерия и налоги' },
|
||||
{ id: 'C8', bucket: 'C', label: 'HR и управление персоналом' },
|
||||
{ id: 'C9', bucket: 'C', label: 'Управление проектами' },
|
||||
{ id: 'C10', bucket: 'C', label: 'Бизнес-процессы (общее)' },
|
||||
{ id: 'D1', bucket: 'D', label: 'Юриспруденция и договорная работа' },
|
||||
{ id: 'D2', bucket: 'D', label: 'Защита ПДн (152-ФЗ, РКН)' },
|
||||
{ id: 'D3', bucket: 'D', label: 'Аудит и управление рисками' },
|
||||
{ id: 'E1', bucket: 'E', label: 'Мета — правила и нормативка' },
|
||||
{ id: 'E2', bucket: 'E', label: 'Мета — оркестрация и автоматизация (Claude-воркфлоу)' },
|
||||
{ id: 'E3', bucket: 'E', label: 'Документация' },
|
||||
{ id: 'E4', bucket: 'E', label: 'Управление знаниями и память' },
|
||||
{ id: 'E5', bucket: 'E', label: 'Стратегия и принятие решений' },
|
||||
{ id: 'E6', bucket: 'E', label: 'Обучение и онбординг' },
|
||||
{ id: 'E7', bucket: 'E', label: 'Исследования' },
|
||||
{ id: 'E8', bucket: 'E', label: 'Самообучение Claude' },
|
||||
];
|
||||
// Узел -> раздел. Покрывает все 134 узла карты.
|
||||
const NODE_SECTION = {
|
||||
// правила (4)
|
||||
pravila: 'E1', claude_md: 'E1', psr_v1: 'E1', tooling: 'E1',
|
||||
// плагины (5)
|
||||
superpowers: 'E2', fd_plugin: 'A4', upm: 'A4', claude_md_mgmt: 'E1', hookify_plugin: 'E2',
|
||||
// скилы superpowers (14)
|
||||
sk_brainstorm: 'E5', sk_wplans: 'E2', sk_eplans: 'E2', sk_subagent: 'E2',
|
||||
sk_tdd: 'A5', sk_verify: 'A5', sk_debug: 'A5', sk_parallel: 'E2',
|
||||
sk_worktree: 'E2', sk_pr: 'E2', sk_coderev: 'A5', sk_spreview: 'A5',
|
||||
sk_wskills: 'E2', sk_elements: 'E3',
|
||||
// скилы проекта (2)
|
||||
sk_rls: 'A9', sk_qitem: 'E3',
|
||||
// хуки (5)
|
||||
hk_session: 'E4', hk_economy: 'E2', hk_pre_claude: 'E1', hk_post_md: 'E3', hk_post_schema: 'A9',
|
||||
// агенты (11)
|
||||
ag_explore: 'E2', ag_general: 'E2', ag_plan: 'E2', ag_pest: 'A5', ag_guide: 'E6',
|
||||
ag_statusline: 'E2', ag_hookify: 'E2', ag_pcreator: 'E2', ag_pvalid: 'E2',
|
||||
ag_skreview: 'E2', ag_rls: 'A9',
|
||||
// MCP-серверы (7)
|
||||
mcp_21st: 'A4', mcp_pw: 'A5', mcp_gh: 'A7', mcp_boost: 'A1',
|
||||
mcp_redis: 'A7', mcp_sentry: 'A7', mcp_semgrep: 'A8',
|
||||
// lefthook jobs (10)
|
||||
lh_mdlint: 'E3', lh_cspell: 'E3', lh_stylelint: 'A2', lh_eslint: 'A2',
|
||||
lh_lychee: 'E3', lh_gitleaks: 'A8', lh_gitleaks2: 'A8', lh_pint: 'A1',
|
||||
lh_larastan: 'A1', lh_squawk: 'A9',
|
||||
// memory files (16)
|
||||
mem_user: 'E4', mem_comm: 'E4', mem_env: 'E4', mem_sp: 'E4', mem_plugins: 'E4',
|
||||
mem_handoff: 'E4', mem_redesign: 'E4', mem_devindices: 'E4', mem_phase1: 'E4',
|
||||
mem_state: 'E4', mem_brain: 'E4', mem_supplier: 'E4', mem_audit: 'E4',
|
||||
mem_archive: 'E4', mem_github: 'E4', mem_ruflo: 'E4',
|
||||
// ruflo (9)
|
||||
ruflo_queen: 'E2', ruflo_plugins: 'E2', ruflo_workers: 'E2', ruflo_agents_catalog: 'E2',
|
||||
ruflo_commands: 'E2', ruflo_daemon: 'E2', ruflo_memory: 'E4', ruflo_mcp: 'E2',
|
||||
ruflo_recall_hook: 'E4',
|
||||
// АУДИТ-АКТУАЛИЗАЦИЯ 16.05.2026 — новые узлы
|
||||
skill_creator: 'E8', claude_setup: 'E8', plugin_dev: 'E2', context7: 'E7',
|
||||
hk_self_check: 'E2', hk_skill_marker: 'E2', hk_skill_check: 'E2', hk_state_guard: 'E2',
|
||||
hk_postcompact: 'E2', hk_verifier: 'E2', hk_ruflo_queen: 'E2',
|
||||
sk_regression: 'A5',
|
||||
mem_audit_b: 'E4', mem_audit_c: 'E4', mem_suppliercrm: 'E4', mem_audit12: 'E4',
|
||||
mem_audit14: 'E4', mem_sprint1: 'E4', mem_sprint2: 'E4', mem_sprint3: 'E4',
|
||||
// A6 architecture-tooling 17.05.2026 — раздел «Архитектура систем» наполнен (+deptrac)
|
||||
adr_kit: 'A6', arch_patterns: 'A6', mermaid_skill: 'A6', deptrac: 'A6',
|
||||
// D3 audit-security 17.05.2026 — раздел «Аудит и управление рисками» наполнен
|
||||
tob_skills: 'D3', sec_guidance: 'D3', sk_security_review: 'D3', sk_audit_portal: 'D3',
|
||||
// C9 project-management-tooling 17.05.2026 — раздел «Управление проектами» наполнен
|
||||
ccpm: 'C9', product_mgmt: 'C9',
|
||||
// A4 design-tooling 17.05.2026 — раздел «Дизайн (UI/UX, графика, бренд)» расширен (3→6 узлов)
|
||||
mcp_figma: 'A4', mcp_icons: 'A4', design_plugin: 'A4',
|
||||
// A3 integration-tooling 17.05.2026 — раздел «Программирование — интеграции» наполнен
|
||||
ag_apidocs: 'A3', mcp_openapi: 'A3',
|
||||
// A11 ml-ai-tooling 17.05.2026 — раздел «ML / AI-разработка» наполнен
|
||||
claude_api: 'A11', promptfoo: 'A11', data_scientist: 'A11',
|
||||
// C10 business-process 17.05.2026 — раздел «Бизнес-процессы (общее)» наполнен
|
||||
ops_plugin: 'C10', process_modeling: 'C10', process_analysis: 'C10',
|
||||
// discovery-interview 18.05.2026 — раздел E5 «Стратегия и принятие решений» (рядом с brainstorming)
|
||||
discovery_interview: 'E5',
|
||||
// brain governance iter9 19.05.2026 — ADR-011 подсистема
|
||||
router_procedure: 'E1', observer_stophook: 'E2', sk_brain_retro: 'E8', observer_evidence: 'E4',
|
||||
lh_l1watcher: 'E1', lh_crossref: 'E1', lh_obs_obs: 'E2', lh_status_md: 'E2', lh_obs_cov: 'E2',
|
||||
};
|
||||
// Вторичная классификация: узел первично в NODE_SECTION, дополнительно — в этих
|
||||
// разделах (кросс-реф). Введено A3-интеграцией 17.05.2026 — раздел A3 наполняется
|
||||
// частично кросс-реф существующих интеграционных инструментов. NODE_SECTION 1:1 не трогается.
|
||||
const NODE_SECTION_SECONDARY = {
|
||||
mcp_boost: ['A3'],
|
||||
context7: ['A3'],
|
||||
ag_pest: ['A3'],
|
||||
mcp_semgrep: ['A3'],
|
||||
mcp_sentry: ['A3'],
|
||||
// C10 business-process 17.05.2026 — кросс-реф reuse-инструментов раздела «Бизнес-процессы»
|
||||
mermaid_skill: ['C10'],
|
||||
arch_patterns: ['C10'],
|
||||
ccpm: ['C10'],
|
||||
product_mgmt: ['C10'],
|
||||
sk_wplans: ['C10'],
|
||||
};
|
||||
|
||||
// ════════════════════════════════════════════════════
|
||||
// SECTION 4: VIS GROUPS
|
||||
// ════════════════════════════════════════════════════
|
||||
const GROUPS = {
|
||||
rules: { color: { background: '#073642', border: '#268bd2', highlight: { border: '#93a1a1', background: '#0d4a5a' } }, font: { color: '#fdf6e3', size: 13, bold: true } },
|
||||
plugins: { color: { background: '#001a00', border: '#859900', highlight: { border: '#b8cc00', background: '#002600' } }, font: { color: '#fdf6e3', size: 12 } },
|
||||
skills_sp: { color: { background: '#1a0033', border: '#6c71c4', highlight: { border: '#9b9fea', background: '#250047' } }, font: { color: '#fdf6e3', size: 11 } },
|
||||
skills_proj: { color: { background: '#2d0020', border: '#d33682', highlight: { border: '#e869a8', background: '#3d0028' } }, font: { color: '#fdf6e3', size: 12 } },
|
||||
hooks: { color: { background: '#002233', border: '#2aa198', highlight: { border: '#4dd7ce', background: '#003344' } }, font: { color: '#fdf6e3', size: 11 } },
|
||||
agents: { color: { background: '#1a1200', border: '#b58900', highlight: { border: '#e0ad00', background: '#261a00' } }, font: { color: '#fdf6e3', size: 11 } },
|
||||
mcp: { color: { background: '#2d1200', border: '#cb4b16', highlight: { border: '#ff6b30', background: '#3d1900' } }, font: { color: '#fdf6e3', size: 11 } },
|
||||
lefthook: { color: { background: '#2d0000', border: '#dc322f', highlight: { border: '#ff5f5c', background: '#3d0000' } }, font: { color: '#fdf6e3', size: 10 } },
|
||||
memory: { color: { background: '#112233', border: '#586e75', highlight: { border: '#839496', background: '#1a2f40' } }, font: { color: '#eee8d5', size: 10 } },
|
||||
ruflo: { color: { background: '#262626', border: '#555555', highlight: { border: '#777777', background: '#333333' } }, font: { color: '#8a8a8a', size: 12, bold: true }, shapeProperties: { borderDashes: [4, 4] } },
|
||||
};
|
||||
|
||||
// Expose for ES-module consumers (the dashboard). The map's classic inline
|
||||
// script reads the bare consts directly via the shared global lexical scope.
|
||||
window.AGD = {
|
||||
NODES, EDGES, SECTIONS, SECTION_BUCKETS,
|
||||
NODE_SECTION, NODE_SECTION_SECONDARY,
|
||||
CONFLICT_TYPES, GROUPS, CATEGORY_LABELS,
|
||||
};
|
||||
+127
-538
@@ -5,6 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Система автоматизации Лидерры</title>
|
||||
<script src="https://unpkg.com/vis-network@9.1.9/standalone/umd/vis-network.min.js"></script>
|
||||
<script src="automation-graph-data.js"></script>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { background: #0d0d1a; color: #fdf6e3; font-family: 'Segoe UI', system-ui, sans-serif; height: 100vh; display: flex; flex-direction: column; overflow: hidden; }
|
||||
@@ -217,412 +218,21 @@
|
||||
// SECTION 1: NODES
|
||||
// ════════════════════════════════════════════════════
|
||||
|
||||
// Радиально-секторная компоновка.
|
||||
// Сектора (по 90°): N=workflow (0–90), E=UI (90–180), S=infra (180–270), W=data/RLS (270–360).
|
||||
const RADII = [0, 220, 400, 600, 800, 1000, 1180];
|
||||
function pos(ring, angleDeg) {
|
||||
const r = RADII[ring];
|
||||
const a = angleDeg * Math.PI / 180;
|
||||
return { x: Math.round(r * Math.cos(a)), y: Math.round(r * Math.sin(a)) };
|
||||
}
|
||||
// RADII, pos() — moved to automation-graph-data.js
|
||||
|
||||
const NODES = [
|
||||
// ── ПРАВИЛА (4) ── центр + первое кольцо ───────
|
||||
{ id: 'pravila', label: 'Pravila v1.29', group: 'rules', size: 38, ring: 0, ...pos(0, 0) },
|
||||
{ id: 'claude_md', label: 'CLAUDE.md v2.16', group: 'rules', size: 34, ring: 1, ...pos(1, 30) },
|
||||
{ id: 'psr_v1', label: 'PSR_v1 v3.14', group: 'rules', size: 32, ring: 1, ...pos(1, 150) },
|
||||
{ id: 'tooling', label: 'Tooling v2.15', group: 'rules', size: 30, ring: 1, ...pos(1, 270) },
|
||||
|
||||
// ── ПЛАГИНЫ (13) ── второе кольцо ──────────────
|
||||
{ id: 'superpowers', label: 'Superpowers v5.1', group: 'plugins', size: 30, ring: 2, ...pos(2, 45) },
|
||||
{ id: 'fd_plugin', label: 'Frontend Design', group: 'plugins', size: 26, ring: 2, ...pos(2, 135) },
|
||||
{ id: 'upm', label: 'UI UX Pro Max', group: 'plugins', size: 22, ring: 2, ...pos(2, 165) },
|
||||
{ id: 'claude_md_mgmt', label: 'claude-md-mgmt', group: 'plugins', size: 22, ring: 2, ...pos(2, 225) },
|
||||
{ id: 'hookify_plugin', label: 'hookify (плагин)', group: 'plugins', size: 22, ring: 2, ...pos(2, 200) },
|
||||
{ id: 'skill_creator', label: 'skill-creator', group: 'plugins', size: 20, ring: 2, ...pos(2, 70) },
|
||||
{ id: 'claude_setup', label: 'claude-code-setup', group: 'plugins', size: 22, ring: 2, ...pos(2, 90) },
|
||||
{ id: 'plugin_dev', label: 'plugin-dev', group: 'plugins', size: 22, ring: 2, ...pos(2, 290) },
|
||||
{ id: 'context7', label: 'context7 (docs MCP)', group: 'plugins', size: 20, ring: 2, ...pos(2, 315) },
|
||||
// A6 architecture-tooling — adr-kit / architecture-patterns (плагины) + deptrac (composer dev-dep, job 10) — раздел «Архитектура систем»
|
||||
{ id: 'adr_kit', label: 'adr-kit', group: 'plugins', size: 22, ring: 2, ...pos(2, 240) },
|
||||
{ id: 'arch_patterns', label: 'architecture-patterns',group: 'plugins', size: 20, ring: 2, ...pos(2, 250) },
|
||||
{ id: 'deptrac', label: 'deptrac', group: 'plugins', size: 20, ring: 2, ...pos(2, 260) },
|
||||
// D3 audit-security (17.05.2026) — 2 плагина раздела «Аудит и управление рисками»
|
||||
{ id: 'tob_skills', label: 'Trail of Bits\nskills', group: 'plugins', size: 22, ring: 2, ...pos(2, 330) },
|
||||
{ id: 'sec_guidance', label: 'Security\nGuidance', group: 'plugins', size: 20, ring: 2, ...pos(2, 345) },
|
||||
// C9 project-management-tooling (17.05.2026) — плагин раздела «Управление проектами»
|
||||
{ id: 'product_mgmt', label: 'product-\nmanagement', group: 'plugins', size: 20, ring: 2, ...pos(2, 355) },
|
||||
// A4 design-tooling (17.05.2026) — раздел «Дизайн (UI/UX, графика, бренд)» (плагины)
|
||||
{ id: 'design_plugin', label: 'Design\nplugin', group: 'plugins', size: 20, ring: 2, ...pos(2, 155) },
|
||||
|
||||
// ── СКИЛЫ SUPERPOWERS (14) — N sector (0–90) ────
|
||||
{ id: 'sk_brainstorm', label: 'brainstorming', group: 'skills_sp', size: 18, ring: 3, ...pos(3, 5) },
|
||||
{ id: 'sk_wplans', label: 'writing-plans', group: 'skills_sp', size: 20, ring: 3, ...pos(3, 11) },
|
||||
{ id: 'sk_eplans', label: 'executing-plans', group: 'skills_sp', size: 18, ring: 3, ...pos(3, 17) },
|
||||
{ id: 'sk_subagent', label: 'subagent-driven', group: 'skills_sp', size: 20, ring: 3, ...pos(3, 23) },
|
||||
{ id: 'sk_tdd', label: 'TDD', group: 'skills_sp', size: 18, ring: 3, ...pos(3, 29) },
|
||||
{ id: 'sk_verify', label: 'verification-before-completion', group: 'skills_sp', size: 18, ring: 3, ...pos(3, 36) },
|
||||
{ id: 'sk_debug', label: 'systematic-debugging', group: 'skills_sp', size: 18, ring: 3, ...pos(3, 43) },
|
||||
{ id: 'sk_parallel', label: 'parallel-work', group: 'skills_sp', size: 18, ring: 3, ...pos(3, 50) },
|
||||
{ id: 'sk_worktree', label: 'worktree', group: 'skills_sp', size: 18, ring: 3, ...pos(3, 57) },
|
||||
{ id: 'sk_pr', label: 'finishing-pr', group: 'skills_sp', size: 18, ring: 3, ...pos(3, 64) },
|
||||
{ id: 'sk_coderev', label: 'code-review', group: 'skills_sp', size: 16, ring: 3, ...pos(3, 71) },
|
||||
{ id: 'sk_spreview', label: 'spec-review', group: 'skills_sp', size: 16, ring: 3, ...pos(3, 78) },
|
||||
{ id: 'sk_wskills', label: 'writing-skills', group: 'skills_sp', size: 16, ring: 3, ...pos(3, 85) },
|
||||
{ id: 'sk_elements', label: 'elements-of-style', group: 'skills_sp', size: 16, ring: 3, ...pos(3, 92) },
|
||||
|
||||
// ── СКИЛЫ ПРОЕКТА (6) — W sector (RLS/arch/audit) ────
|
||||
{ id: 'sk_rls', label: 'rls-check', group: 'skills_proj', size: 20, ring: 3, ...pos(3, 305) },
|
||||
{ id: 'sk_qitem', label: 'q-item-add', group: 'skills_proj', size: 20, ring: 3, ...pos(3, 220) },
|
||||
{ id: 'sk_regression', label: 'regression', group: 'skills_proj', size: 20, ring: 3, ...pos(3, 260) },
|
||||
// A6 architecture-tooling (17.05.2026) — вендоренный скил диаграмм
|
||||
{ id: 'mermaid_skill', label: 'mermaid (skill)', group: 'skills_proj', size: 18, ring: 3, ...pos(3, 280) },
|
||||
// D3 audit-security (17.05.2026) — скилы раздела «Аудит и управление рисками»
|
||||
{ id: 'sk_security_review', label: 'security-review', group: 'skills_proj', size: 20, ring: 3, ...pos(3, 315) },
|
||||
{ id: 'sk_audit_portal', label: 'audit-portal', group: 'skills_proj', size: 20, ring: 3, ...pos(3, 325) },
|
||||
// C9 project-management-tooling (17.05.2026) — вендоренный скил раздела «Управление проектами»
|
||||
{ id: 'ccpm', label: 'CCPM\n(skill)', group: 'skills_proj', size: 18, ring: 3, ...pos(3, 335) },
|
||||
// A11 ml-ai-tooling (17.05.2026) — скилы и CLI раздела «ML / AI-разработка»
|
||||
{ id: 'claude_api', label: 'claude-api\n(skill)', group: 'skills_proj', size: 18, ring: 3, ...pos(3, 345) },
|
||||
{ id: 'data_scientist', label: 'Data Scientist\n(skill)', group: 'skills_proj', size: 18, ring: 3, ...pos(3, 355) },
|
||||
{ id: 'promptfoo', label: 'promptfoo', group: 'plugins', size: 20, ring: 2, ...pos(2, 365) },
|
||||
// C10 business-process (17.05.2026) — плагин и скилы раздела «Бизнес-процессы (общее)»
|
||||
{ id: 'ops_plugin', label: 'operations\n(plugin)', group: 'plugins', size: 20, ring: 2, ...pos(2, 385) },
|
||||
{ id: 'process_modeling', label: 'process-modeling\n(skill)', group: 'skills_proj', size: 18, ring: 3, ...pos(3, 367) },
|
||||
{ id: 'process_analysis', label: 'process-analysis\n(skill)', group: 'skills_proj', size: 18, ring: 3, ...pos(3, 377) },
|
||||
// discovery-tooling (18.05.2026) — self-authored скил интервью-discovery
|
||||
{ id: 'discovery_interview', label: 'discovery-interview\n(skill)', group: 'skills_proj', size: 18, ring: 3, ...pos(3, 387) },
|
||||
|
||||
// ── ХУКИ (12) — S+infra + E (economy/skill) ───
|
||||
{ id: 'hk_session', label: 'SessionStart:\ncontext-inject', group: 'hooks', size: 24, ring: 4, ...pos(4, 100) },
|
||||
{ id: 'hk_economy', label: 'UserPromptSubmit:\neconomy-mode', group: 'hooks', size: 22, ring: 4, ...pos(4, 95) },
|
||||
{ id: 'hk_pre_claude', label: 'PreToolUse:\nCLAUDE.md-warn', group: 'hooks', size: 22, ring: 4, ...pos(4, 215) },
|
||||
{ id: 'hk_post_md', label: 'PostToolUse:\nmarkdownlint', group: 'hooks', size: 20, ring: 4, ...pos(4, 195) },
|
||||
{ id: 'hk_post_schema', label: 'PostToolUse:\nschema-changelog',group: 'hooks', size: 20, ring: 4, ...pos(4, 300) },
|
||||
{ id: 'hk_self_check', label: 'SessionStart:\neconomy-self-check', group: 'hooks', size: 20, ring: 4, ...pos(4, 105) },
|
||||
{ id: 'hk_skill_marker', label: 'PreToolUse:\nskill-marker', group: 'hooks', size: 20, ring: 4, ...pos(4, 115) },
|
||||
{ id: 'hk_skill_check', label: 'PreToolUse:\nskill-check', group: 'hooks', size: 20, ring: 4, ...pos(4, 125) },
|
||||
{ id: 'hk_state_guard', label: 'PreToolUse:\neconomy-state-guard', group: 'hooks', size: 20, ring: 4, ...pos(4, 135) },
|
||||
{ id: 'hk_postcompact', label: 'PostCompact:\neconomy-postcompact', group: 'hooks', size: 20, ring: 4, ...pos(4, 145) },
|
||||
{ id: 'hk_verifier', label: 'Stop:\neconomy-verifier (агент)', group: 'hooks', size: 22, ring: 4, ...pos(4, 155) },
|
||||
{ id: 'hk_ruflo_queen', label: 'UserPromptSubmit:\nruflo-queen-hook', group: 'ruflo', size: 20, ring: 4, ...pos(4, 165) },
|
||||
|
||||
// ── АГЕНТЫ (11) — N (workflow) + W (RLS) ──────
|
||||
{ id: 'ag_explore', label: 'Explore', group: 'agents', size: 20, ring: 4, ...pos(4, 10) },
|
||||
{ id: 'ag_general', label: 'general-purpose', group: 'agents', size: 20, ring: 4, ...pos(4, 25) },
|
||||
{ id: 'ag_plan', label: 'Plan', group: 'agents', size: 20, ring: 4, ...pos(4, 40) },
|
||||
{ id: 'ag_pest', label: 'pest-parallel-debugger', group: 'agents', size: 24, ring: 4, ...pos(4, 55) },
|
||||
{ id: 'ag_guide', label: 'claude-code-guide', group: 'agents', size: 18, ring: 4, ...pos(4, 70) },
|
||||
{ id: 'ag_statusline', label: 'statusline-setup', group: 'agents', size: 18, ring: 4, ...pos(4, 85) },
|
||||
{ id: 'ag_hookify', label: 'hookify:\nconversation-analyzer', group: 'agents', size: 18, ring: 4, ...pos(4, 230) },
|
||||
{ id: 'ag_pcreator', label: 'plugin-dev:\nagent-creator', group: 'agents', size: 16, ring: 4, ...pos(4, 245) },
|
||||
{ id: 'ag_pvalid', label: 'plugin-dev:\nplugin-validator',group: 'agents', size: 16, ring: 4, ...pos(4, 260) },
|
||||
{ id: 'ag_skreview', label: 'plugin-dev:\nskill-reviewer', group: 'agents', size: 16, ring: 4, ...pos(4, 275) },
|
||||
{ id: 'ag_rls', label: 'rls-reviewer', group: 'agents', size: 22, ring: 4, ...pos(4, 315) },
|
||||
// A3 integration-tooling (17.05.2026) — agent раздела «Программирование — интеграции»
|
||||
{ id: 'ag_apidocs', label: 'api-docs (agent)', group: 'agents', size: 18, ring: 4, ...pos(4, 175) },
|
||||
|
||||
// ── MCP-СЕРВЕРЫ (9) — E (UI) + W (data) ───────
|
||||
{ id: 'mcp_21st', label: 'MCP: 21st.dev Magic', group: 'mcp', size: 20, ring: 5, ...pos(5, 130) },
|
||||
// A4 design-tooling (17.05.2026) — MCP-серверы раздела «Дизайн (UI/UX, графика, бренд)»
|
||||
{ id: 'mcp_figma', label: 'MCP: Figma\n(DEFERRED)', group: 'mcp', size: 18, ring: 5, ...pos(5, 140) },
|
||||
{ id: 'mcp_icons', label: 'MCP: Universal\nIcons', group: 'mcp', size: 18, ring: 5, ...pos(5, 120) },
|
||||
{ id: 'mcp_pw', label: 'MCP: playwright', group: 'mcp', size: 22, ring: 5, ...pos(5, 110) },
|
||||
{ id: 'mcp_gh', label: 'MCP: github', group: 'mcp', size: 22, ring: 5, ...pos(5, 75) },
|
||||
{ id: 'mcp_boost', label: 'MCP: laravel-boost', group: 'mcp', size: 24, ring: 5, ...pos(5, 290) },
|
||||
{ id: 'mcp_redis', label: 'MCP: redis', group: 'mcp', size: 22, ring: 5, ...pos(5, 310) },
|
||||
{ id: 'mcp_sentry', label: 'MCP: sentry', group: 'mcp', size: 22, ring: 5, ...pos(5, 330) },
|
||||
{ id: 'mcp_semgrep', label: 'MCP: semgrep', group: 'mcp', size: 20, ring: 5, ...pos(5, 350) },
|
||||
// A3 integration-tooling (17.05.2026) — MCP-сервер раздела «Программирование — интеграции»
|
||||
{ id: 'mcp_openapi', label: 'MCP: openapi', group: 'mcp', size: 20, ring: 5, ...pos(5, 5) },
|
||||
|
||||
// ── LEFTHOOK JOBS (10) — S+W (infra/data) ─────
|
||||
{ id: 'lh_mdlint', label: 'lefthook:\nmarkdownlint', group: 'lefthook', size: 18, ring: 5, ...pos(5, 185) },
|
||||
{ id: 'lh_cspell', label: 'lefthook:\ncspell', group: 'lefthook', size: 18, ring: 5, ...pos(5, 200) },
|
||||
{ id: 'lh_stylelint', label: 'lefthook:\nstylelint', group: 'lefthook', size: 16, ring: 5, ...pos(5, 215) },
|
||||
{ id: 'lh_eslint', label: 'lefthook:\neslint-vue', group: 'lefthook', size: 18, ring: 5, ...pos(5, 230) },
|
||||
{ id: 'lh_lychee', label: 'lefthook:\nlychee-links', group: 'lefthook', size: 18, ring: 5, ...pos(5, 245) },
|
||||
{ id: 'lh_gitleaks', label: 'lefthook:\ngitleaks', group: 'lefthook', size: 18, ring: 5, ...pos(5, 260) },
|
||||
{ id: 'lh_gitleaks2', label: 'lefthook:\ngitleaks pre-push', group: 'lefthook', size: 18, ring: 5, ...pos(5, 275) },
|
||||
{ id: 'lh_pint', label: 'lefthook:\npint', group: 'lefthook', size: 18, ring: 5, ...pos(5, 25) },
|
||||
{ id: 'lh_larastan', label: 'lefthook:\nlarastan', group: 'lefthook', size: 18, ring: 5, ...pos(5, 50) },
|
||||
{ id: 'lh_squawk', label: 'lefthook:\nsquawk', group: 'lefthook', size: 18, ring: 5, ...pos(5, 320) },
|
||||
|
||||
// ── MEMORY FILES (23) — внешнее кольцо ──────────
|
||||
{ id: 'mem_user', label: 'memory:\nuser_profile', group: 'memory', size: 16, ring: 6, ...pos(6, 0) },
|
||||
{ id: 'mem_comm', label: 'memory:\nfeedback_comm', group: 'memory', size: 14, ring: 6, ...pos(6, 24) },
|
||||
{ id: 'mem_env', label: 'memory:\nfeedback_env', group: 'memory', size: 16, ring: 6, ...pos(6, 48) },
|
||||
{ id: 'mem_sp', label: 'memory:\nfeedback_superpowers',group: 'memory', size: 16, ring: 6, ...pos(6, 72) },
|
||||
{ id: 'mem_plugins', label: 'memory:\nfeedback_plugins', group: 'memory', size: 16, ring: 6, ...pos(6, 96) },
|
||||
{ id: 'mem_handoff', label: 'memory:\nreference_handoff', group: 'memory', size: 14, ring: 6, ...pos(6, 120) },
|
||||
{ id: 'mem_redesign', label: 'memory:\nportal_redesign', group: 'memory', size: 14, ring: 6, ...pos(6, 144) },
|
||||
{ id: 'mem_devindices', label: 'memory:\ndev_indices', group: 'memory', size: 12, ring: 6, ...pos(6, 168) },
|
||||
{ id: 'mem_phase1', label: 'memory:\nphase1_strategy', group: 'memory', size: 14, ring: 6, ...pos(6, 192) },
|
||||
{ id: 'mem_state', label: 'memory:\nproject_state', group: 'memory', size: 16, ring: 6, ...pos(6, 216) },
|
||||
{ id: 'mem_brain', label: 'memory:\nclaude_brain', group: 'memory', size: 14, ring: 6, ...pos(6, 240) },
|
||||
{ id: 'mem_supplier', label: 'memory:\nsupplier_integration',group: 'memory', size: 14, ring: 6, ...pos(6, 264) },
|
||||
{ id: 'mem_audit', label: 'memory:\naudit_2026-05-13', group: 'memory', size: 14, ring: 6, ...pos(6, 288) },
|
||||
{ id: 'mem_archive', label: 'memory:\nreference_archive', group: 'memory', size: 14, ring: 6, ...pos(6, 312) },
|
||||
{ id: 'mem_github', label: 'memory:\nreference_github', group: 'memory', size: 14, ring: 6, ...pos(6, 336) },
|
||||
{ id: 'mem_audit_b', label: 'memory:\naudit_B_status', group: 'memory', size: 12, ring: 6, ...pos(6, 12) },
|
||||
{ id: 'mem_audit_c', label: 'memory:\naudit_C_pending', group: 'memory', size: 12, ring: 6, ...pos(6, 36) },
|
||||
{ id: 'mem_suppliercrm',label: 'memory:\nsupplier_crm', group: 'memory', size: 12, ring: 6, ...pos(6, 60) },
|
||||
{ id: 'mem_audit12', label: 'memory:\nfull_audit_05-12', group: 'memory', size: 12, ring: 6, ...pos(6, 84) },
|
||||
{ id: 'mem_audit14', label: 'memory:\nfull_audit_05-14', group: 'memory', size: 12, ring: 6, ...pos(6, 108) },
|
||||
{ id: 'mem_sprint1', label: 'memory:\nsprint1_p0_closure', group: 'memory', size: 12, ring: 6, ...pos(6, 132) },
|
||||
{ id: 'mem_sprint2', label: 'memory:\nsprint2_p1_progress', group: 'memory', size: 12, ring: 6, ...pos(6, 156) },
|
||||
{ id: 'mem_sprint3', label: 'memory:\nsprint3_progress', group: 'memory', size: 12, ring: 6, ...pos(6, 180) },
|
||||
|
||||
// ── RUFLO ОРКЕСТРАТОР (9) — фактический реколлаж iter5 — кластер вне радиального layout (верх-лево) ──
|
||||
{ id: 'ruflo_queen', label: 'ruflo Queen\n(hive-mind)', group: 'ruflo', size: 44, x: -1340, y: -700 },
|
||||
{ id: 'ruflo_plugins', label: 'плагины ruflo\n0 из 20 · скилов 0', group: 'ruflo', size: 20, x: -1340, y: -880 },
|
||||
{ id: 'ruflo_workers', label: '10 воркеров\nhive-mind (idle)', group: 'ruflo', size: 26, x: -1160, y: -800 },
|
||||
{ id: 'ruflo_agents_catalog', label: 'каталог агентов ruflo\n(100 определений)', group: 'ruflo', size: 24, x: -1530, y: -830 },
|
||||
{ id: 'ruflo_commands', label: 'slash-команды\nruflo (88)', group: 'ruflo', size: 22, x: -1140, y: -630 },
|
||||
{ id: 'ruflo_daemon', label: 'демон ruflo\n(воркеры падают)', group: 'ruflo', size: 24, x: -1560, y: -650 },
|
||||
{ id: 'ruflo_memory', label: 'память ruflo\n(~0 записей)', group: 'ruflo', size: 24, x: -1380, y: -500 },
|
||||
{ id: 'ruflo_mcp', label: 'ruflo MCP\n(~210 инструментов)', group: 'ruflo', size: 26, x: -1190, y: -460 },
|
||||
{ id: 'ruflo_recall_hook', label: 'хук recall\n(UserPromptSubmit)', group: 'ruflo', size: 22, x: -1570, y: -470 },
|
||||
|
||||
// ── MEMORY +1 (артефакт ruflo big-bang) ──
|
||||
{ id: 'mem_ruflo', label: 'memory:\nproject_ruflo_integration', group: 'memory', size: 14, x: -1740, y: -620 },
|
||||
];
|
||||
// NODES — moved to automation-graph-data.js
|
||||
|
||||
// ════════════════════════════════════════════════════
|
||||
// SECTION 2: EDGES
|
||||
// ════════════════════════════════════════════════════
|
||||
const CONFLICT_TYPES = {
|
||||
RED: { color: '#ff5f57', bg: '#2d0000', emoji: '🔴', label: 'Не закрыт правилом', rank: 1 },
|
||||
BLACK: { color: '#888888', bg: '#1a1a1a', emoji: '⚫', label: 'Возник на практике', rank: 2 },
|
||||
GREEN: { color: '#859900', bg: '#0e1a00', emoji: '🟢', label: 'Закрыт правилом', rank: 3 },
|
||||
};
|
||||
const E = (from, to, label) => ({
|
||||
from, to,
|
||||
title: label,
|
||||
color: { color: '#586e75', highlight: '#93a1a1', hover: '#93a1a1' },
|
||||
arrows: { to: { enabled: true, scaleFactor: 0.6 } },
|
||||
smooth: { type: 'continuous', roundness: 0.5 }
|
||||
});
|
||||
const CONFLICT = (from, to, label, type = 'RED') => ({
|
||||
from, to,
|
||||
title: label,
|
||||
label: CONFLICT_TYPES[type].emoji,
|
||||
dashes: true,
|
||||
width: 2,
|
||||
color: { color: CONFLICT_TYPES[type].color, highlight: '#ff8880', hover: '#ff8880' },
|
||||
arrows: { to: { enabled: true, scaleFactor: 0.7 }, from: { enabled: true, scaleFactor: 0.7 } },
|
||||
font: { color: CONFLICT_TYPES[type].color, size: 14, align: 'middle', strokeWidth: 3, strokeColor: '#1e1e2e' },
|
||||
smooth: { type: 'curvedCW', roundness: 0.35 }
|
||||
});
|
||||
// CONFLICT_TYPES, E, CONFLICT — moved to automation-graph-data.js
|
||||
|
||||
const EDGES = [
|
||||
// ── ПРАВИЛА — иерархия ──────────────────────────
|
||||
E('pravila', 'claude_md', 'подчиняет\n(уровень 1→2a)'),
|
||||
E('pravila', 'psr_v1', 'подчиняет\n(уровень 1→3)'),
|
||||
E('claude_md', 'tooling', 'ссылается\nна реестр'),
|
||||
E('pravila', 'superpowers', '§12: обязывает\nинвокировать 1-м'),
|
||||
|
||||
// ── PSR_v1 координирует плагины ─────────────────
|
||||
E('psr_v1', 'superpowers', 'R5: координирует\nпарный стек'),
|
||||
E('psr_v1', 'fd_plugin', 'R5: координирует\nпарный стек'),
|
||||
E('psr_v1', 'upm', 'R14.3: активирует\nтолько через pipeline'),
|
||||
E('psr_v1', 'mcp_21st', 'R14.4: активирует\nтолько через pipeline'),
|
||||
E('psr_v1', 'claude_md_mgmt','R10.1 блок 1:\nинфраструктурный'),
|
||||
|
||||
// ── CLAUDE.md ────────────────────────────────────
|
||||
E('claude_md', 'mcp_boost', 'описывает §3.2'),
|
||||
E('claude_md', 'mcp_sentry', 'описывает §4.8'),
|
||||
E('claude_md', 'mcp_redis', 'описывает §4.9'),
|
||||
E('claude_md', 'claude_md_mgmt', '§5п.10:\nединственный канал'),
|
||||
E('claude_md', 'ag_pest', 'описывает\nкогда вызывать'),
|
||||
E('claude_md', 'ag_rls', 'описывает\nкогда вызывать'),
|
||||
|
||||
// ── ХУКИ ────────────────────────────────────────
|
||||
E('hk_pre_claude', 'claude_md', 'проверяет\nпри Edit/Write'),
|
||||
E('hk_post_md', 'lh_mdlint', 'дублирует задачу\n(локально)'),
|
||||
E('hk_post_schema', 'claude_md', 'напоминает про\nCHANGELOG_schema'),
|
||||
E('hk_session', 'mem_user', 'читает\nпри старте'),
|
||||
E('hk_session', 'mem_env', 'читает\nпри старте'),
|
||||
E('hk_session', 'mem_sp', 'читает\nпри старте'),
|
||||
E('hk_session', 'mem_plugins', 'читает\nпри старте'),
|
||||
E('hk_session', 'mem_state', 'читает\nпри старте'),
|
||||
E('hk_economy', 'superpowers', 'парсит уровень\nэкономии'),
|
||||
|
||||
// ── SUPERPOWERS содержит скилы ──────────────────
|
||||
E('superpowers', 'sk_brainstorm', 'содержит'),
|
||||
E('superpowers', 'sk_tdd', 'содержит'),
|
||||
E('superpowers', 'sk_debug', 'содержит'),
|
||||
E('superpowers', 'sk_wplans', 'содержит'),
|
||||
E('superpowers', 'sk_eplans', 'содержит'),
|
||||
E('superpowers', 'sk_verify', 'содержит'),
|
||||
E('superpowers', 'sk_parallel', 'содержит'),
|
||||
E('superpowers', 'sk_worktree', 'содержит'),
|
||||
E('superpowers', 'sk_pr', 'содержит'),
|
||||
E('superpowers', 'sk_subagent', 'содержит'),
|
||||
E('superpowers', 'sk_wskills', 'содержит'),
|
||||
E('superpowers', 'sk_spreview', 'содержит'),
|
||||
E('superpowers', 'sk_coderev', 'содержит'),
|
||||
E('superpowers', 'sk_elements', 'содержит'),
|
||||
|
||||
// ── СКИЛЫ вызывают друг друга ───────────────────
|
||||
E('sk_brainstorm', 'sk_wplans', 'вызывает\nпосле дизайна'),
|
||||
E('sk_wplans', 'sk_eplans', 'вызывает\nдля выполнения'),
|
||||
E('sk_wplans', 'sk_subagent','альтернатива\nexecuting-plans'),
|
||||
E('sk_subagent', 'ag_explore', 'запускает\nдля поиска'),
|
||||
E('sk_subagent', 'ag_general', 'запускает\nдля задач'),
|
||||
E('sk_subagent', 'ag_plan', 'запускает\nдля архитектуры'),
|
||||
E('sk_parallel', 'sk_worktree','использует\nдля изоляции'),
|
||||
|
||||
// ── СКИЛЫ ПРОЕКТА ───────────────────────────────
|
||||
E('sk_rls', 'tooling', 'использует\nsquawk + grep §3.2'),
|
||||
E('sk_rls', 'mcp_boost', 'SQL запросы\nк схеме'),
|
||||
E('sk_qitem', 'claude_md_mgmt','делегирует\nправку CLAUDE.md'),
|
||||
|
||||
// ── CLAUDE-MD-MGMT ──────────────────────────────
|
||||
E('claude_md_mgmt', 'claude_md', 'единственный\nканал правок'),
|
||||
|
||||
// ── HOOKIFY ─────────────────────────────────────
|
||||
E('ag_hookify', 'hookify_plugin', 'передаёт\nанализ'),
|
||||
E('hookify_plugin', 'hk_pre_claude', 'может создавать\nновые хуки'),
|
||||
E('hookify_plugin', 'hk_economy', 'может создавать\nновые хуки'),
|
||||
|
||||
// ── АГЕНТЫ используют MCP ───────────────────────
|
||||
E('ag_pest', 'mcp_redis', 'читает\nочереди/кэш'),
|
||||
E('ag_rls', 'mcp_boost', 'SQL запросы\nк БД'),
|
||||
E('ag_guide', 'mcp_gh', 'ищет\nв репозитории'),
|
||||
|
||||
// ── LEFTHOOK вызывается git ──────────────────────
|
||||
E('lh_gitleaks', 'mem_plugins', 'блокирует коммит\nпри ПДн в staged'),
|
||||
E('lh_larastan', 'mcp_boost', 'Boost даёт\nконтекст типов'),
|
||||
E('lh_squawk', 'tooling', 'соответствует\n§3.2 #15'),
|
||||
E('lh_gitleaks2', 'lh_gitleaks', 'строже:\nвся история'),
|
||||
E('lh_lychee', 'claude_md', 'проверяет\nссылки в .md'),
|
||||
|
||||
// ── MEMORY читается Claude ──────────────────────
|
||||
E('mem_env', 'ag_pest', 'квирки 73/77\nиспользует агент'),
|
||||
E('mem_plugins', 'psr_v1', 'отражает\nтекущие версии'),
|
||||
E('mem_archive', 'claude_md', 'синхронизирует\nверсии доков'),
|
||||
|
||||
// ── MCP ─────────────────────────────────────────
|
||||
E('mcp_pw', 'hk_session', 'используется\nдля a11y smoke'),
|
||||
E('mcp_gh', 'sk_pr', 'PR, issues\nпри finishing-pr'),
|
||||
E('mcp_boost', 'ag_rls', 'схема БД\nдля RLS-review'),
|
||||
|
||||
// ── АУДИТ-АКТУАЛИЗАЦИЯ 16.05.2026 — связи новых узлов ──
|
||||
// 4 ребра psr_v1→skill_creator/claude_setup/plugin_dev/context7 — перенесены
|
||||
// в ADT-блок 18.05.2026 (точные категории authoring-tooling/dev-support, дедуп)
|
||||
E('plugin_dev', 'ag_pcreator', 'содержит\nагента'),
|
||||
E('plugin_dev', 'ag_pvalid', 'содержит\nагента'),
|
||||
E('plugin_dev', 'ag_skreview', 'содержит\nагента'),
|
||||
E('skill_creator', 'sk_wskills', 'обе создают\nскилы'),
|
||||
E('hk_self_check', 'hk_economy', 'система\nэкономии'),
|
||||
E('hk_skill_marker', 'hk_skill_check', 'пара\nmarker/check'),
|
||||
E('hk_skill_check', 'superpowers', 'энфорсит §12:\nскил перед кодом'),
|
||||
E('hk_state_guard', 'hk_economy', 'система\nэкономии'),
|
||||
E('hk_postcompact', 'hk_economy', 'переинжект\nрежима после компакта'),
|
||||
E('hk_verifier', 'sk_verify', 'энфорсит\nпроверку готовности'),
|
||||
E('hk_ruflo_queen', 'ruflo_queen', '§14: маршрут\nqueen-задач'),
|
||||
E('sk_regression', 'ag_pest', 'передаёт разбор\nпадений Pest --parallel'),
|
||||
|
||||
// ── A6 ARCHITECTURE-TOOLING 17.05.2026 — связи новых узлов ──
|
||||
E('psr_v1', 'adr_kit', 'R10.1 блок 1:\narchitecture-tooling'),
|
||||
E('psr_v1', 'arch_patterns', 'R10.1 блок 1:\narchitecture-tooling'),
|
||||
E('tooling', 'mermaid_skill', '§4.12: реестр\n(вендоренный скил)'),
|
||||
E('psr_v1', 'deptrac', 'R10.1 блок 1 note:\narchitecture-tooling'),
|
||||
|
||||
// ── A4 DESIGN-TOOLING 17.05.2026 — связи новых узлов ──
|
||||
E('psr_v1', 'design_plugin', 'R10.1 блок 1:\ndesign-tooling'),
|
||||
E('psr_v1', 'mcp_icons', 'R10.1 блок 3:\ndesign-tooling'),
|
||||
E('psr_v1', 'mcp_figma', 'R10.1 блок 3:\ndesign-tooling (DEFERRED)'),
|
||||
|
||||
// ── D3 AUDIT-SECURITY 17.05.2026 — связи новых узлов ──
|
||||
E('psr_v1', 'tob_skills', 'R10.1 блок 1:\naudit-security'),
|
||||
E('psr_v1', 'sec_guidance', 'R10.1 блок 1:\naudit-security'),
|
||||
E('tooling', 'tob_skills', '§4.14 #39 — реестр'),
|
||||
E('tooling', 'sec_guidance', '§4.15 #40 — реестр'),
|
||||
E('sk_audit_portal', 'sk_security_review', 'оркеструет\nкак фазу аудита'),
|
||||
E('sk_audit_portal', 'tob_skills', 'оркеструет\nглубокие кампании'),
|
||||
E('sk_audit_portal', 'sk_regression', 'использует\nна фазе тестов'),
|
||||
CONFLICT('tob_skills', 'mcp_semgrep', 'TB1: граница разграничена — Semgrep = inline SAST, Trail of Bits = глубокие on-demand аудит-кампании. Параллельное использование разрешено при разных сценариях.', 'GREEN'),
|
||||
|
||||
// ── A3 INTEGRATION-TOOLING 17.05.2026 — связи новых узлов ──
|
||||
E('psr_v1', 'mcp_openapi', 'R10.1 блок 3:\nintegration-tooling'),
|
||||
E('tooling', 'mcp_openapi', '§4.22 #47 — реестр'),
|
||||
E('ag_apidocs', 'mcp_openapi', 'спека → MCP-ресурс'),
|
||||
|
||||
// ── A11 ML-AI-TOOLING 17.05.2026 — связи новых узлов ──
|
||||
E('psr_v1', 'promptfoo', 'R10.1 блок 1:\nml-ai-tooling'),
|
||||
E('tooling', 'claude_api', 'reuse — built-in skill\n(PSR_v1 R10.1 блок 2)'),
|
||||
E('tooling', 'data_scientist', '§4.24 #49 — реестр'),
|
||||
|
||||
// ── C10 BUSINESS-PROCESS 17.05.2026 — связи новых узлов ──
|
||||
E('psr_v1', 'ops_plugin', 'R10.1 блок 1:\nbusiness-process'),
|
||||
E('tooling', 'process_modeling', '§4.27 #52 — реестр'),
|
||||
E('tooling', 'process_analysis', '§4.28 #53 — реестр'),
|
||||
|
||||
// ── DISCOVERY-TOOLING 18.05.2026 — связи узла discovery-interview ──
|
||||
E('tooling', 'discovery_interview', '§4.30 #55 — реестр'),
|
||||
E('psr_v1', 'discovery_interview', 'R10.1 блок 1 note:\ndiscovery-tooling'),
|
||||
E('discovery_interview', 'sk_brainstorm', 'хэндофф:\nFEATURE-brief'),
|
||||
E('discovery_interview', 'process_analysis', 'граница: слой-источник\n(ADR-009 DI2)'),
|
||||
|
||||
// ── ANTHROPIC DEV-TOOLING 18.05.2026 — связи 5 узлов ──
|
||||
E('psr_v1', 'skill_creator', 'R10.1 блок 1:\nauthoring-tooling'),
|
||||
E('psr_v1', 'plugin_dev', 'R10.1 блок 1:\nauthoring-tooling'),
|
||||
E('psr_v1', 'hookify_plugin', 'R10.1 блок 1:\nauthoring-tooling (HK1)'),
|
||||
E('psr_v1', 'claude_setup', 'R10.1 блок 1:\ndev-support'),
|
||||
E('psr_v1', 'context7', 'R10.1 блок 1:\ndev-support'),
|
||||
|
||||
// ══════════════════════════════════════════════════
|
||||
// КОНФЛИКТЫ — 3-color classification (iter2 §4)
|
||||
// 🔴 не закрыт правилом / ⚫ возник на практике / 🟢 закрыт правилом
|
||||
// ══════════════════════════════════════════════════
|
||||
CONFLICT('sk_rls', 'ag_rls', 'RLS: граница задана — скил по таблице, агент по diff/PR (spec 2026-05-16)', 'GREEN'),
|
||||
CONFLICT('hookify_plugin', 'hk_pre_claude', 'Закрыто правилом HK1 (ADR-010, PSR_v1 R10.1 v3.14): hookify вызывается только по явному /hookify + обязательный pre-check на коллизию с зарегистрированными хуками; перезапись economy/skill-discipline архитектуры запрещена', 'GREEN'),
|
||||
CONFLICT('mcp_pw', 'sk_parallel', 'Профиль Playwright MCP хэшируется per-cwd (квирк #95) → worktrees получают разные mcp-chrome-{hash}, не конфликтуют. Same-dir parallel — редкий случай (две Claude-сессии в одной dir), регулируется Pravila §15.2 claim в docs/sessions/CURRENT.md', 'GREEN'),
|
||||
CONFLICT('ag_pest', 'mcp_redis', 'Квирк 72 устранён 16.05.2026 (commit 0fa1a73 — array-стор в тестах): гонки в Redis при Pest --parallel больше нет', 'GREEN'),
|
||||
CONFLICT('psr_v1', 'claude_md', 'Закрыто §5п.10 CLAUDE.md + хук CLAUDE.md-warn', 'GREEN'),
|
||||
CONFLICT('upm', 'fd_plugin', 'PSR_v1 R14.5: не параллельно', 'GREEN'),
|
||||
CONFLICT('mcp_21st', 'fd_plugin', 'PSR_v1 R14.5: не параллельно', 'GREEN'),
|
||||
CONFLICT('hk_economy', 'superpowers', '§12 — hard-rule уровня 0; economy-режим §12 не отменяет (Pravila §12.4)', 'GREEN'),
|
||||
|
||||
// ══════════════════════════════════════════════════
|
||||
// RUFLO ОРКЕСТРАТОР — фактический реколлаж (iter5, 2026-05-15)
|
||||
// ══════════════════════════════════════════════════
|
||||
// Queen → артефакты установки ruflo init (рой idle, артефакты не задействованы)
|
||||
E('ruflo_queen', 'ruflo_workers', 'координирует\n(0 задач)'),
|
||||
E('ruflo_queen', 'ruflo_agents_catalog', 'ruflo init высыпал\n(не задействовано)'),
|
||||
E('ruflo_queen', 'ruflo_commands', 'ruflo init высыпал\n(не задействовано)'),
|
||||
E('ruflo_queen', 'ruflo_plugins', 'плагинов ruflo:\n0 установлено'),
|
||||
// MCP-сервер ruflo — связывает половины кластера + читает/пишет память
|
||||
E('ruflo_mcp', 'ruflo_queen', 'инструменты\nуправления роем'),
|
||||
E('ruflo_mcp', 'ruflo_memory', 'читает/пишет\nпамять'),
|
||||
// память ruflo — recall-хук и воркер consolidate демона
|
||||
E('ruflo_recall_hook', 'ruflo_memory', 'запускает\nruflo memory search'),
|
||||
E('ruflo_daemon', 'ruflo_memory', 'воркер consolidate\nобращается к памяти'),
|
||||
// 4 узла-правила → Queen (реколлаж 16.05.2026: ruflo — advisory-подсистема; Pravila §14 — queen-триггер)
|
||||
E('pravila', 'ruflo_queen', '§14:\nqueen-триггер'),
|
||||
E('claude_md', 'ruflo_queen', '§3.5: описывает\n(advisory-подсистема)'),
|
||||
E('psr_v1', 'ruflo_queen', '§14:\ncross-ref'),
|
||||
E('tooling', 'ruflo_queen', '§4.10: реестр\n(advisory-подсистема)'),
|
||||
// memory → ruflo
|
||||
E('mem_ruflo', 'ruflo_queen', 'документирует\nинтеграцию'),
|
||||
|
||||
// 3 конфликта ruflo (3-color, iter2 §4)
|
||||
CONFLICT('ruflo_queen', 'pravila', 'Закрыто реколлажем 16.05.2026: нормативка приведена к рантайму — ruflo переописан в advisory/automation-подсистему, декларация уровня −1 убрана', 'GREEN'),
|
||||
CONFLICT('ruflo_memory', 'mem_state', 'Два хранилища памяти не синхронизированы; память ruflo почти пуста (0 записей)', 'BLACK'),
|
||||
CONFLICT('ruflo_daemon', 'ag_pest', 'Worker-jitter демона ruflo усиливает Pest-квирки 73/77 (квирк 72 устранён 16.05 — его jitter больше не усиливает)', 'BLACK'),
|
||||
];
|
||||
// EDGES — moved to automation-graph-data.js
|
||||
|
||||
// ════════════════════════════════════════════════════
|
||||
// SECTION 3: NODE DETAILS
|
||||
// ════════════════════════════════════════════════════
|
||||
const CATEGORY_LABELS = {
|
||||
rules: 'Правило', plugins: 'Плагин', skills_sp: 'Скил Superpowers',
|
||||
skills_proj: 'Скил проекта', hooks: 'Хук .claude', agents: 'Агент',
|
||||
mcp: 'MCP-сервер', lefthook: 'Lefthook job', memory: 'Memory-файл',
|
||||
ruflo: 'ruflo (изолирован)'
|
||||
};
|
||||
// CATEGORY_LABELS — moved to automation-graph-data.js
|
||||
|
||||
function nd(desc, when, limits, reportsTo, manages, together, conflicts) {
|
||||
// Backward-compat: old 5-arg signature was nd(desc, reportsTo, manages, together, conflicts).
|
||||
@@ -1751,6 +1361,88 @@ const NODE_DETAILS = {
|
||||
'Снимок-история, обновляется по ходу спринта.',
|
||||
[], [], []
|
||||
),
|
||||
// ── BRAIN GOVERNANCE iter9 (19.05.2026, ADR-011) ──
|
||||
router_procedure: nd(
|
||||
'Единый источник истины процедуры роутера «задача → узел(ы)» — docs/router-procedure.md v1.0. 5 шагов: hard-floor (§12/§14/§15) → классификация → выбор по триггерам (Tooling Прил. Н §4.X) → проверка связок L1-L12 → исполнение. ADR-011.',
|
||||
'При любой задаче (имплицитно) определяет узел/связку; явно — при разборе routing-решений и в /brain-retro.',
|
||||
'Не вводит новый реестр — формализует процедуру над существующим (Tooling §4.X). Кэша «проверенных цепочек» нет (router-only). Каждая задача — свежая сборка пути.',
|
||||
[{ name: 'Pravila §12/§14/§15', cond: 'hard-floor — шаг 1 процедуры' }, { name: 'CLAUDE.md §3.6', cond: 'cross-ref на router-procedure.md' }],
|
||||
[{ name: 'Tooling Прил. Н §4.X', cond: 'реестр узлов — вход шага 3' }],
|
||||
[{ name: 'observer (Stop-хук)', cond: 'пишет evidence о routing-решениях' }, { name: '/brain-retro', cond: 'факторный анализ routing' }],
|
||||
[]
|
||||
),
|
||||
observer_stophook: nd(
|
||||
'Stop-хук observer (tools/observer-stop-hook.mjs, project-level) — пишет один JSONL-эпизод в docs/observer/episodes-YYYY-MM.jsonl в конце каждого хода + routing-gate. Внутри: transcript-parser (схема v2), routing-detector + choice-detector (provenance), pii-filter (маскирование ПДн). ADR-011 + observer factor-analysis.',
|
||||
'Конец каждого хода (Stop-event). routing-gate: при навязанном методе без routing-тега → decision:block (необойдёмо).',
|
||||
'Только пишет evidence, не вмешивается в нормативку. При внутреннем отказе — маркер observer_error, не тихий пропуск. HK1 §5.3: сосуществует с economy-verifier на Stop (append-chain).',
|
||||
[{ name: 'Pravila §16', cond: 'observer + routing-тег-дисциплина' }, { name: '.claude/settings.json', cond: 'зарегистрирован как Stop-хук' }],
|
||||
[{ name: 'observer-transcript-parser / routing-detector / choice-detector / pii-filter', cond: 'внутренние .mjs модули' }],
|
||||
[{ name: 'docs/observer/ evidence', cond: 'пишет эпизоды' }, { name: '/brain-retro', cond: 'читает то, что хук пишет' }],
|
||||
[{ name: 'hk_verifier', desc: 'HK1 §5.3: оба на Stop-event — коллизии нет (append-chain), оба decision:block отрабатываются', type: 'GREEN' }]
|
||||
),
|
||||
sk_brain_retro: nd(
|
||||
'Проектный скил /brain-retro (.claude/skills/brain-retro/) — раз в спринт читает docs/observer/episodes-*.jsonl и строит факторный анализ: распределение path_type, топ-узлы/связки, вывод исхода, факторная матрица (9 осей × outcome). Анализатор tools/brain-retro-analyzer.mjs.',
|
||||
'Раз в спринт по команде заказчика («брейн-ретро»). Read-only агрегатор.',
|
||||
'Только читает и предлагает кандидатов на корректировку нормативки — не пишет в логи, не правит Tooling/Pravila/PSR_v1. Решение по правкам — за заказчиком.',
|
||||
[{ name: 'Pravila §16', cond: 'evidence-loop, раз в спринт' }, { name: 'PSR_v1 R16', cond: 'brain evidence loop' }],
|
||||
[{ name: 'tools/brain-retro-analyzer.mjs', cond: 'детерминированный анализатор' }],
|
||||
[{ name: 'docs/observer/ evidence', cond: 'читает эпизоды' }],
|
||||
[]
|
||||
),
|
||||
observer_evidence: nd(
|
||||
'Хранилище evidence «мозга» — docs/observer/: помесячные episodes-YYYY-MM.jsonl (схема v2), STATUS.md (панель C1-C5), .read-counter.json (для C3), notes/. Визуализируется страницей docs/observer/dashboard.html (Карта/Лента/Разбор/Агрегат/конфликты; кормится из общего automation-graph-data.js).',
|
||||
'Пишется Stop-хуком (эпизоды) + контролёрами (STATUS.md, счётчик); читается /brain-retro и dashboard.',
|
||||
'ПДн маскируется pii-filter перед записью (§5.4). Помесячное rotation; архив после 12 месяцев. Память ruflo (.swarm/memory.db) — отдельное хранилище, не связано.',
|
||||
[{ name: 'observer Stop-хук', cond: 'источник эпизодов' }],
|
||||
[],
|
||||
[{ name: '/brain-retro', cond: 'читатель' }, { name: 'C3/C4/C5 контролёры', cond: 'счётчик / STATUS / покрытие' }],
|
||||
[]
|
||||
),
|
||||
lh_l1watcher: nd(
|
||||
'Контролёр C1 (lefthook pre-commit job 11, tools/l1-watcher.mjs) — детектор «плагин включён в settings.json без формализации в Tooling Прил. Н». Закрывает трижды повторившийся L1-паттерн (UPM/21st, Sentry/Redis, Anthropic dev-tooling). 0 LLM-вызовов.',
|
||||
'pre-commit при правке .claude/settings.json или docs/Tooling_v8_3.md.',
|
||||
'STRICT: блокирует коммит при drift. Групповые/human-имена разрешаются через tools/.l1-watcher-aliases.txt. ADR-011 spec §6.1.',
|
||||
[{ name: 'lefthook.yml', cond: 'job 11 pre-commit' }, { name: 'ADR-011 §6.1', cond: 'C1' }],
|
||||
[],
|
||||
[{ name: 'tooling', cond: 'сверяет settings.json ↔ Tooling' }, { name: 'C2 cross-ref', cond: 'оба — нормативная консистентность' }],
|
||||
[]
|
||||
),
|
||||
lh_crossref: nd(
|
||||
'Контролёр C2 (lefthook pre-commit job 12, tools/cross-ref-checker.mjs) — детектор version drift между нормативными файлами (Tooling v2.11 collision 17.05). Сверяет версии в §0 cross-refs vs шапки целевых файлов. 0 LLM-вызовов.',
|
||||
'pre-commit при правке Pravila / Tooling / PSR_v1 / CLAUDE.md / MEMORY.md.',
|
||||
'STRICT: блокирует коммит при расхождении версии. Link-anchored детекция + scope-cut по history-маркерам (исторические «наследие»-цепочки не дают ложных срабатываний). ADR-011 spec §6.2.',
|
||||
[{ name: 'lefthook.yml', cond: 'job 12 pre-commit' }, { name: 'ADR-011 §6.2', cond: 'C2' }],
|
||||
[],
|
||||
[{ name: 'claude_md / pravila / tooling / psr_v1', cond: 'сверяет 5 нормативных файлов' }, { name: 'C1 l1-watcher', cond: 'оба — нормативная консистентность' }],
|
||||
[]
|
||||
),
|
||||
lh_obs_obs: nd(
|
||||
'Контролёр C3 (lefthook pre-commit job 13, tools/observer-of-observer.mjs) — счётчик чтений docs/observer/ + 54-недельный self-prune. «Кто наблюдает за наблюдателями»: если evidence-loop не читается ≥54 недель — предлагает архивировать observer.',
|
||||
'pre-commit (каждый коммит) — обновляет/проверяет docs/observer/.read-counter.json.',
|
||||
'Warn-only (скрипт всегда exit 0) — не блокирует. 54 недели (≈год) — порог осознанно поднят заказчиком с 4 недель. ADR-011 spec §6.3.',
|
||||
[{ name: 'lefthook.yml', cond: 'job 13 pre-commit' }, { name: 'ADR-011 §6.3', cond: 'C3' }],
|
||||
[],
|
||||
[{ name: 'docs/observer/ evidence', cond: 'читает .read-counter.json' }],
|
||||
[]
|
||||
),
|
||||
lh_status_md: nd(
|
||||
'Контролёр C4 (lefthook post-commit job, tools/status-md-generator.mjs) — генерит docs/observer/STATUS.md (панель: C1-C5 + информационные метрики). Pure JS, Security Guidance #40 compliant.',
|
||||
'post-commit (после каждого коммита) — перегенерит STATUS.md, git add (для следующего коммита).',
|
||||
'Через `|| true` — не блокирует. Метрика «N раз использован» — информационная, не алерт (capability-readiness). ADR-011 spec §6.4.',
|
||||
[{ name: 'lefthook.yml', cond: 'post-commit job' }, { name: 'ADR-011 §6.4', cond: 'C4' }],
|
||||
[],
|
||||
[{ name: 'docs/observer/ evidence', cond: 'пишет STATUS.md' }, { name: 'C1/C2/C3', cond: 'агрегирует их сигнал' }],
|
||||
[]
|
||||
),
|
||||
lh_obs_cov: nd(
|
||||
'Контролёр C5 (lefthook pre-commit job 15, tools/observer-coverage-checker.mjs) — observer factor-analysis spec §5.2. Флагует пропуски покрытия (git-активность есть, эпизодов 0) + поломки регистрации (Stop-хук снят из settings.json, post-commit не установлен).',
|
||||
'pre-commit (каждый коммит).',
|
||||
'Warn-only (скрипт всегда exit 0) — не блокирует; находки в docs/observer/STATUS.md строка C5.',
|
||||
[{ name: 'lefthook.yml', cond: 'job 15 pre-commit' }, { name: 'observer factor-analysis §5.2', cond: 'C5' }],
|
||||
[],
|
||||
[{ name: 'docs/observer/ evidence', cond: 'проверяет покрытие + регистрацию' }, { name: 'C4 status-md', cond: 'находки в STATUS.md' }],
|
||||
[]
|
||||
),
|
||||
};
|
||||
|
||||
// ════════════════════════════════════════════════════
|
||||
@@ -1871,6 +1563,20 @@ const EDGE_DETAILS = {
|
||||
'mem_ruflo->ruflo_queen': { type: 'документирует', when: 'memory-файл хранит историю ruflo-интеграции', transfers: 'данные', mandatory: 'рекомендуется', rule: 'memory/project_ruflo_integration.md' },
|
||||
'ruflo_memory->mem_state': { type: 'конфликт', when: 'два хранилища памяти не синхронизированы; память ruflo почти пуста', transfers: 'coverage', mandatory: 'опционально', rule: 'нет регламента синхронизации (alpha-баг HNSW #1122)' },
|
||||
'ruflo_daemon->ag_pest': { type: 'конфликт', when: 'daemon worker-jitter усиливает частоту Pest-квирка 72', transfers: 'coverage', mandatory: 'опционально', rule: 'memory feedback_environment квирк #93' },
|
||||
|
||||
// ── BRAIN GOVERNANCE iter9 (19.05.2026, ADR-011) ──
|
||||
'claude_md->router_procedure': { type: 'документирует', when: 'CLAUDE.md §3.6 — cross-ref на router-procedure.md v1.0', transfers: 'документация', mandatory: 'обязательно', rule: 'CLAUDE.md §3.6 (single SoT routing procedure)' },
|
||||
'tooling->router_procedure': { type: 'питает', when: 'реестр Прил. Н §4.X — вход шага 3 процедуры роутера', transfers: 'данные', mandatory: 'обязательно', rule: 'router-procedure.md §4.2 шаг 3' },
|
||||
'pravila->router_procedure': { type: 'подчиняет', when: 'hard-floor §12/§14/§15 — шаг 1 процедуры роутера', transfers: 'контроль', mandatory: 'hard-floor', rule: 'router-procedure.md §4.2 шаг 1 (Pravila §12/§14/§15)' },
|
||||
'pravila->observer_stophook': { type: 'подчиняет', when: '§16: observer + routing-тег-дисциплина', transfers: 'контроль', mandatory: 'обязательно', rule: 'Pravila §16.2/§16.7 (ADR-011)' },
|
||||
'observer_stophook->observer_evidence': { type: 'пишет', when: 'конец каждого хода (Stop-event)', transfers: 'данные (эпизод JSONL)', mandatory: 'обязательно (exit-0-safe)', rule: 'ADR-011 §5.2 (observer scope B)' },
|
||||
'pravila->sk_brain_retro': { type: 'подчиняет', when: '§16: факторный анализ раз в спринт', transfers: 'контроль', mandatory: 'по команде заказчика', rule: 'Pravila §16 + PSR_v1 R16' },
|
||||
'sk_brain_retro->observer_evidence': { type: 'читает', when: 'раз в спринт — агрегирует эпизоды', transfers: 'данные', mandatory: 'read-only', rule: 'ADR-011 §5.5 (/brain-retro — читатель)' },
|
||||
'lh_l1watcher->tooling': { type: 'проверяет', when: 'pre-commit при правке settings.json / Tooling', transfers: 'проверка', mandatory: 'STRICT (блокирует)', rule: 'ADR-011 §6.1 (C1) + lefthook.yml job 11' },
|
||||
'lh_crossref->claude_md': { type: 'проверяет', when: 'pre-commit при правке любого из 5 нормативных файлов', transfers: 'проверка', mandatory: 'STRICT (блокирует)', rule: 'ADR-011 §6.2 (C2) + lefthook.yml job 12' },
|
||||
'lh_obs_obs->observer_evidence': { type: 'проверяет', when: 'pre-commit (каждый коммит) — счётчик чтений', transfers: 'проверка', mandatory: 'warn-only', rule: 'ADR-011 §6.3 (C3) + lefthook.yml job 13' },
|
||||
'lh_status_md->observer_evidence': { type: 'пишет', when: 'post-commit — перегенерит STATUS.md', transfers: 'данные', mandatory: 'не блокирует (|| true)', rule: 'ADR-011 §6.4 (C4) + lefthook.yml post-commit' },
|
||||
'lh_obs_cov->observer_evidence': { type: 'проверяет', when: 'pre-commit (каждый коммит) — покрытие + регистрация', transfers: 'проверка', mandatory: 'warn-only', rule: 'observer factor-analysis §5.2 (C5) + lefthook.yml job 15' },
|
||||
};
|
||||
|
||||
// ════════════════════════════════════════════════════
|
||||
@@ -1889,18 +1595,18 @@ const EDGE_DETAILS = {
|
||||
// hookify_plugin, ruflo_daemon, ruflo_memory, фоновые economy/skill-discipline
|
||||
// хуки (hk_self_check / skill_marker / skill_check / state_guard / postcompact /
|
||||
// verifier / ruflo_queen) и старые mem_* без активных Read-вызовов в окне.
|
||||
const META_SNAPSHOT = '18.05.2026'; // дата генерации значений
|
||||
const META_WINDOW = '09–18.05.2026'; // окно подсчёта использования (10 дней)
|
||||
const META_SNAPSHOT = '20.05.2026'; // дата генерации значений
|
||||
const META_WINDOW = '09–20.05.2026'; // окно подсчёта использования (12 дней)
|
||||
|
||||
// uses: number — измеримый узел (0 = реально простаивал); null — измерить нельзя
|
||||
// (узел-правило / плагин-обёртка / автономный демон / пассивное хранилище) → «нет данных».
|
||||
// usesSrc: 'скил' | 'агент' | 'MCP' | 'хук' | 'memory-чтение' | 'коммиты' | 'инспекция' | 'интеграция' | 'DEFERRED' | '—'
|
||||
const NODE_META = {
|
||||
// ── ПРАВИЛА (4) — узлы-правила, напрямую не вызываются ──
|
||||
pravila: { since: '06.05.2026', changed: '18.05.2026', uses: null, usesSrc: '—' },
|
||||
claude_md: { since: '06.05.2026', changed: '18.05.2026', uses: null, usesSrc: '—' },
|
||||
psr_v1: { since: '09.05.2026', changed: '18.05.2026', uses: null, usesSrc: '—' },
|
||||
tooling: { since: '06.05.2026', changed: '18.05.2026', uses: null, usesSrc: '—' },
|
||||
pravila: { since: '06.05.2026', changed: '19.05.2026', uses: null, usesSrc: '—' },
|
||||
claude_md: { since: '06.05.2026', changed: '19.05.2026', uses: null, usesSrc: '—' },
|
||||
psr_v1: { since: '09.05.2026', changed: '19.05.2026', uses: null, usesSrc: '—' },
|
||||
tooling: { since: '06.05.2026', changed: '19.05.2026', uses: null, usesSrc: '—' },
|
||||
|
||||
// ── ПЛАГИНЫ (5) ──
|
||||
superpowers: { since: '09.05.2026', changed: '—', uses: null, usesSrc: '—' },
|
||||
@@ -2069,6 +1775,20 @@ const NODE_META = {
|
||||
// ── DISCOVERY-TOOLING (18.05.2026, iter8: factual в сессии) ──
|
||||
// snapshot 2026-05-18-system-audit-brain.md (утро) + это интервью (вечер) + последующие вызовы
|
||||
discovery_interview: { since: '18.05.2026', changed: '—', uses: 3, usesSrc: 'скил, factual' },
|
||||
|
||||
// ── BRAIN GOVERNANCE iter9 (19.05.2026, ADR-011) ──
|
||||
// uses: observer_stophook=31 эпизодов; lh_obs_obs/status_md/obs_cov=112 коммитов с 19.05
|
||||
// (glob-less, каждый коммит); lh_l1watcher=10, lh_crossref=13 (коммиты по glob с 19.05);
|
||||
// observer_evidence=0 (.read-counter.json — 0 чтений); router_procedure=null (rule-like).
|
||||
router_procedure: { since: '19.05.2026', changed: '—', uses: null, usesSrc: '—' },
|
||||
observer_stophook: { since: '19.05.2026', changed: '—', uses: 31, usesSrc: 'хук (эпизоды)' },
|
||||
sk_brain_retro: { since: '19.05.2026', changed: '—', uses: 1, usesSrc: 'интеграция' },
|
||||
observer_evidence: { since: '19.05.2026', changed: '—', uses: 0, usesSrc: 'observer counter' },
|
||||
lh_l1watcher: { since: '19.05.2026', changed: '—', uses: 10, usesSrc: 'коммиты (с 19.05)' },
|
||||
lh_crossref: { since: '19.05.2026', changed: '—', uses: 13, usesSrc: 'коммиты (с 19.05)' },
|
||||
lh_obs_obs: { since: '19.05.2026', changed: '—', uses: 112, usesSrc: 'коммиты (с 19.05)' },
|
||||
lh_status_md: { since: '19.05.2026', changed: '—', uses: 112, usesSrc: 'коммиты (с 19.05)' },
|
||||
lh_obs_cov: { since: '19.05.2026', changed: '—', uses: 112, usesSrc: 'коммиты (с 19.05)' },
|
||||
};
|
||||
|
||||
// Явные парные дубли (Фича 3) — попадают в кнопку «⧉ Дубли».
|
||||
@@ -2102,130 +1822,10 @@ const DUP_NODE_SET = new Set(DUP_BY_NODE.keys()); // 12 узлов-членов
|
||||
// (NODE_SECTION). Часть разделов пока пустая — это бизнес-домены, под которые
|
||||
// в карте dev-автоматики ещё нет узлов. Основа будущего «мозга»: 1 раздел =
|
||||
// 1 playbook «как и что делать».
|
||||
const SECTION_BUCKETS = [
|
||||
{ id: 'A', label: 'Технические и продуктовые' },
|
||||
{ id: 'B', label: 'Коммуникации' },
|
||||
{ id: 'C', label: 'Бизнес и операции' },
|
||||
{ id: 'D', label: 'Право и комплаенс' },
|
||||
{ id: 'E', label: 'Мета и управление' },
|
||||
];
|
||||
const SECTIONS = [
|
||||
{ id: 'A1', bucket: 'A', label: 'Программирование — backend' },
|
||||
{ id: 'A2', bucket: 'A', label: 'Программирование — frontend' },
|
||||
{ id: 'A3', bucket: 'A', label: 'Программирование — интеграции (API, вебхуки)' },
|
||||
{ id: 'A4', bucket: 'A', label: 'Дизайн (UI/UX, графика, бренд)' },
|
||||
{ id: 'A5', bucket: 'A', label: 'Тестирование, QA и отладка' },
|
||||
{ id: 'A6', bucket: 'A', label: 'Архитектура систем' },
|
||||
{ id: 'A7', bucket: 'A', label: 'DevOps, инфраструктура, деплой' },
|
||||
{ id: 'A8', bucket: 'A', label: 'Информационная безопасность' },
|
||||
{ id: 'A9', bucket: 'A', label: 'Работа с данными (БД, миграции, RLS)' },
|
||||
{ id: 'A10', bucket: 'A', label: 'Аналитика и отчётность (BI)' },
|
||||
{ id: 'A11', bucket: 'A', label: 'ML / AI-разработка' },
|
||||
{ id: 'B1', bucket: 'B', label: 'Голосовое общение по телефону' },
|
||||
{ id: 'B2', bucket: 'B', label: 'Мессенджеры' },
|
||||
{ id: 'B3', bucket: 'B', label: 'Электронная почта' },
|
||||
{ id: 'B4', bucket: 'B', label: 'SMS-рассылки' },
|
||||
{ id: 'B5', bucket: 'B', label: 'Видеосвязь' },
|
||||
{ id: 'B6', bucket: 'B', label: 'Чат на сайте / онлайн-консультант' },
|
||||
{ id: 'B7', bucket: 'B', label: 'Социальные сети' },
|
||||
{ id: 'B8', bucket: 'B', label: 'Push / in-app уведомления' },
|
||||
{ id: 'C1', bucket: 'C', label: 'Маркетинг и лидогенерация' },
|
||||
{ id: 'C2', bucket: 'C', label: 'Продажи' },
|
||||
{ id: 'C3', bucket: 'C', label: 'Квалификация и обработка лидов' },
|
||||
{ id: 'C4', bucket: 'C', label: 'Работа с поставщиками лидов' },
|
||||
{ id: 'C5', bucket: 'C', label: 'Клиентский успех, поддержка, удержание' },
|
||||
{ id: 'C6', bucket: 'C', label: 'Финансы — биллинг и тарификация' },
|
||||
{ id: 'C7', bucket: 'C', label: 'Финансы — бухгалтерия и налоги' },
|
||||
{ id: 'C8', bucket: 'C', label: 'HR и управление персоналом' },
|
||||
{ id: 'C9', bucket: 'C', label: 'Управление проектами' },
|
||||
{ id: 'C10', bucket: 'C', label: 'Бизнес-процессы (общее)' },
|
||||
{ id: 'D1', bucket: 'D', label: 'Юриспруденция и договорная работа' },
|
||||
{ id: 'D2', bucket: 'D', label: 'Защита ПДн (152-ФЗ, РКН)' },
|
||||
{ id: 'D3', bucket: 'D', label: 'Аудит и управление рисками' },
|
||||
{ id: 'E1', bucket: 'E', label: 'Мета — правила и нормативка' },
|
||||
{ id: 'E2', bucket: 'E', label: 'Мета — оркестрация и автоматизация (Claude-воркфлоу)' },
|
||||
{ id: 'E3', bucket: 'E', label: 'Документация' },
|
||||
{ id: 'E4', bucket: 'E', label: 'Управление знаниями и память' },
|
||||
{ id: 'E5', bucket: 'E', label: 'Стратегия и принятие решений' },
|
||||
{ id: 'E6', bucket: 'E', label: 'Обучение и онбординг' },
|
||||
{ id: 'E7', bucket: 'E', label: 'Исследования' },
|
||||
{ id: 'E8', bucket: 'E', label: 'Самообучение Claude' },
|
||||
];
|
||||
// Узел -> раздел. Покрывает все 125 узлов карты.
|
||||
const NODE_SECTION = {
|
||||
// правила (4)
|
||||
pravila: 'E1', claude_md: 'E1', psr_v1: 'E1', tooling: 'E1',
|
||||
// плагины (5)
|
||||
superpowers: 'E2', fd_plugin: 'A4', upm: 'A4', claude_md_mgmt: 'E1', hookify_plugin: 'E2',
|
||||
// скилы superpowers (14)
|
||||
sk_brainstorm: 'E5', sk_wplans: 'E2', sk_eplans: 'E2', sk_subagent: 'E2',
|
||||
sk_tdd: 'A5', sk_verify: 'A5', sk_debug: 'A5', sk_parallel: 'E2',
|
||||
sk_worktree: 'E2', sk_pr: 'E2', sk_coderev: 'A5', sk_spreview: 'A5',
|
||||
sk_wskills: 'E2', sk_elements: 'E3',
|
||||
// скилы проекта (2)
|
||||
sk_rls: 'A9', sk_qitem: 'E3',
|
||||
// хуки (5)
|
||||
hk_session: 'E4', hk_economy: 'E2', hk_pre_claude: 'E1', hk_post_md: 'E3', hk_post_schema: 'A9',
|
||||
// агенты (11)
|
||||
ag_explore: 'E2', ag_general: 'E2', ag_plan: 'E2', ag_pest: 'A5', ag_guide: 'E6',
|
||||
ag_statusline: 'E2', ag_hookify: 'E2', ag_pcreator: 'E2', ag_pvalid: 'E2',
|
||||
ag_skreview: 'E2', ag_rls: 'A9',
|
||||
// MCP-серверы (7)
|
||||
mcp_21st: 'A4', mcp_pw: 'A5', mcp_gh: 'A7', mcp_boost: 'A1',
|
||||
mcp_redis: 'A7', mcp_sentry: 'A7', mcp_semgrep: 'A8',
|
||||
// lefthook jobs (10)
|
||||
lh_mdlint: 'E3', lh_cspell: 'E3', lh_stylelint: 'A2', lh_eslint: 'A2',
|
||||
lh_lychee: 'E3', lh_gitleaks: 'A8', lh_gitleaks2: 'A8', lh_pint: 'A1',
|
||||
lh_larastan: 'A1', lh_squawk: 'A9',
|
||||
// memory files (16)
|
||||
mem_user: 'E4', mem_comm: 'E4', mem_env: 'E4', mem_sp: 'E4', mem_plugins: 'E4',
|
||||
mem_handoff: 'E4', mem_redesign: 'E4', mem_devindices: 'E4', mem_phase1: 'E4',
|
||||
mem_state: 'E4', mem_brain: 'E4', mem_supplier: 'E4', mem_audit: 'E4',
|
||||
mem_archive: 'E4', mem_github: 'E4', mem_ruflo: 'E4',
|
||||
// ruflo (9)
|
||||
ruflo_queen: 'E2', ruflo_plugins: 'E2', ruflo_workers: 'E2', ruflo_agents_catalog: 'E2',
|
||||
ruflo_commands: 'E2', ruflo_daemon: 'E2', ruflo_memory: 'E4', ruflo_mcp: 'E2',
|
||||
ruflo_recall_hook: 'E4',
|
||||
// АУДИТ-АКТУАЛИЗАЦИЯ 16.05.2026 — новые узлы
|
||||
skill_creator: 'E8', claude_setup: 'E8', plugin_dev: 'E2', context7: 'E7',
|
||||
hk_self_check: 'E2', hk_skill_marker: 'E2', hk_skill_check: 'E2', hk_state_guard: 'E2',
|
||||
hk_postcompact: 'E2', hk_verifier: 'E2', hk_ruflo_queen: 'E2',
|
||||
sk_regression: 'A5',
|
||||
mem_audit_b: 'E4', mem_audit_c: 'E4', mem_suppliercrm: 'E4', mem_audit12: 'E4',
|
||||
mem_audit14: 'E4', mem_sprint1: 'E4', mem_sprint2: 'E4', mem_sprint3: 'E4',
|
||||
// A6 architecture-tooling 17.05.2026 — раздел «Архитектура систем» наполнен (+deptrac)
|
||||
adr_kit: 'A6', arch_patterns: 'A6', mermaid_skill: 'A6', deptrac: 'A6',
|
||||
// D3 audit-security 17.05.2026 — раздел «Аудит и управление рисками» наполнен
|
||||
tob_skills: 'D3', sec_guidance: 'D3', sk_security_review: 'D3', sk_audit_portal: 'D3',
|
||||
// C9 project-management-tooling 17.05.2026 — раздел «Управление проектами» наполнен
|
||||
ccpm: 'C9', product_mgmt: 'C9',
|
||||
// A4 design-tooling 17.05.2026 — раздел «Дизайн (UI/UX, графика, бренд)» расширен (3→6 узлов)
|
||||
mcp_figma: 'A4', mcp_icons: 'A4', design_plugin: 'A4',
|
||||
// A3 integration-tooling 17.05.2026 — раздел «Программирование — интеграции» наполнен
|
||||
ag_apidocs: 'A3', mcp_openapi: 'A3',
|
||||
// A11 ml-ai-tooling 17.05.2026 — раздел «ML / AI-разработка» наполнен
|
||||
claude_api: 'A11', promptfoo: 'A11', data_scientist: 'A11',
|
||||
// C10 business-process 17.05.2026 — раздел «Бизнес-процессы (общее)» наполнен
|
||||
ops_plugin: 'C10', process_modeling: 'C10', process_analysis: 'C10',
|
||||
// discovery-interview 18.05.2026 — раздел E5 «Стратегия и принятие решений» (рядом с brainstorming)
|
||||
discovery_interview: 'E5',
|
||||
};
|
||||
// Вторичная классификация: узел первично в NODE_SECTION, дополнительно — в этих
|
||||
// разделах (кросс-реф). Введено A3-интеграцией 17.05.2026 — раздел A3 наполняется
|
||||
// частично кросс-реф существующих интеграционных инструментов. NODE_SECTION 1:1 не трогается.
|
||||
const NODE_SECTION_SECONDARY = {
|
||||
mcp_boost: ['A3'],
|
||||
context7: ['A3'],
|
||||
ag_pest: ['A3'],
|
||||
mcp_semgrep: ['A3'],
|
||||
mcp_sentry: ['A3'],
|
||||
// C10 business-process 17.05.2026 — кросс-реф reuse-инструментов раздела «Бизнес-процессы»
|
||||
mermaid_skill: ['C10'],
|
||||
arch_patterns: ['C10'],
|
||||
ccpm: ['C10'],
|
||||
product_mgmt: ['C10'],
|
||||
sk_wplans: ['C10'],
|
||||
};
|
||||
// SECTION_BUCKETS — moved to automation-graph-data.js
|
||||
// SECTIONS — moved to automation-graph-data.js
|
||||
// NODE_SECTION — moved to automation-graph-data.js
|
||||
// NODE_SECTION_SECONDARY — moved to automation-graph-data.js
|
||||
// Производные индексы для рендера панели и Паспорта.
|
||||
const SECTION_BY_ID = new Map(SECTIONS.map(s => [s.id, s]));
|
||||
const SECTION_NODES = new Map(SECTIONS.map(s => [s.id, []]));
|
||||
@@ -2265,18 +1865,7 @@ const WISHLIST = [
|
||||
// ════════════════════════════════════════════════════
|
||||
// SECTION 4: VIS INIT
|
||||
// ════════════════════════════════════════════════════
|
||||
const GROUPS = {
|
||||
rules: { color: { background: '#073642', border: '#268bd2', highlight: { border: '#93a1a1', background: '#0d4a5a' } }, font: { color: '#fdf6e3', size: 13, bold: true } },
|
||||
plugins: { color: { background: '#001a00', border: '#859900', highlight: { border: '#b8cc00', background: '#002600' } }, font: { color: '#fdf6e3', size: 12 } },
|
||||
skills_sp: { color: { background: '#1a0033', border: '#6c71c4', highlight: { border: '#9b9fea', background: '#250047' } }, font: { color: '#fdf6e3', size: 11 } },
|
||||
skills_proj: { color: { background: '#2d0020', border: '#d33682', highlight: { border: '#e869a8', background: '#3d0028' } }, font: { color: '#fdf6e3', size: 12 } },
|
||||
hooks: { color: { background: '#002233', border: '#2aa198', highlight: { border: '#4dd7ce', background: '#003344' } }, font: { color: '#fdf6e3', size: 11 } },
|
||||
agents: { color: { background: '#1a1200', border: '#b58900', highlight: { border: '#e0ad00', background: '#261a00' } }, font: { color: '#fdf6e3', size: 11 } },
|
||||
mcp: { color: { background: '#2d1200', border: '#cb4b16', highlight: { border: '#ff6b30', background: '#3d1900' } }, font: { color: '#fdf6e3', size: 11 } },
|
||||
lefthook: { color: { background: '#2d0000', border: '#dc322f', highlight: { border: '#ff5f5c', background: '#3d0000' } }, font: { color: '#fdf6e3', size: 10 } },
|
||||
memory: { color: { background: '#112233', border: '#586e75', highlight: { border: '#839496', background: '#1a2f40' } }, font: { color: '#eee8d5', size: 10 } },
|
||||
ruflo: { color: { background: '#262626', border: '#555555', highlight: { border: '#777777', background: '#333333' } }, font: { color: '#8a8a8a', size: 12, bold: true }, shapeProperties: { borderDashes: [4, 4] } },
|
||||
};
|
||||
// GROUPS — moved to automation-graph-data.js
|
||||
|
||||
const nodesDS = new vis.DataSet(NODES);
|
||||
const edgesDS = new vis.DataSet(EDGES);
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
# Локаторы формы добавления rt-проекта crm.bp-gr.ru (recon 2026-05-19)
|
||||
|
||||
**Среда:** `https://crm.bp-gr.ru/admin/visit/rt`, кнопка «Добавить проект» (label `[title="Добавить проект"]`, классы `el-button deal-req-is-empty-btn el-button--default`) открывает диалог.
|
||||
|
||||
**Стек:** **смешанный** — внешний контейнер `v-dialog v-dialog--active v-dialog--persistent` (Vuetify), внутри форма `form.el-form.el-form--label-left` (Element UI).
|
||||
|
||||
**Метод записи:** Playwright MCP `browser_evaluate` querySelector + `closest('.el-form')` от `[for="srcrt"]`. 10 `.el-form-item` в форме (verified `form.querySelectorAll('.el-form-item').length === 10`).
|
||||
|
||||
## Маппинг формы → DTO
|
||||
|
||||
| # | label `for=` | UI-поле | DTO-поле | Контракт |
|
||||
|---|---|---|---|---|
|
||||
| 1 | `tag` | Тег | `dto.tag` | el-input text |
|
||||
| 2 | `srcrt` | Источник данных | `dto.platform` ⇒ ровно 1 включённый из B1/B2/B3 | 3 el-checkbox с textContent `B1`/`B2`/`B3`. Initial — **все три checked**. Inputs **не имеют `name` атрибута**. Идентификация — по `textContent`. Состояние — `.is-checked` класс на родительском `.el-checkbox`. |
|
||||
| 3 | `name` | Название проекта | `dto.uniqueKey` | el-input text |
|
||||
| 4 | `type` | Источники сбора | `dto.signalType` | el-select **readonly input** (открывается кликом). 5 опций: `Сайты`, `Звонки`, `СМС`, `Ретро сайты`, `Ретро звонки`. **Только первые три используются**: `site → "Сайты"`, `call → "Звонки"`, `sms → "СМС"`. Initial value — `Сайты`. |
|
||||
| 5 | (нет) | Период (slider 0–24, value=«HH-HH») | **НЕТ в DTO** — поле новое, отсутствует в `SupplierProjectDto` | `.el-slider[aria-valuemin=0][aria-valuemax=24]`, aria-valuetext формата `"10-18"`. Default — `10-18`. **Tier-2 оставляет default** (DTO не несёт это поле). |
|
||||
| 6 | (нет) | switch «Включить/Исключить» — режим регионов | `dto.regionsReverse` (`true` ⇒ «Исключить») | **ИСПРАВЛЕНО live-дебагом 2026-05-19:** единственный `.el-switch` на форме — это **include/exclude регионов** (`regions_reverse`), а НЕ статус active/paused. Текст — «ВключитьИсключить» (две метки). Статус проекта (`status`) задаётся дефолтом формы (`true`); отдельного UI-switch для active/paused НЕТ. `manage-project.js` этот switch не трогает (regions skip в Tier-2 MVP). |
|
||||
| 7 | `regions` | Регион | `dto.regions[]` + `dto.regionsReverse` | el-select multiple. Опции — **имена регионов** (например, `Республика Адыгея`), не id. **Архитектурный gap: DTO несёт int[] (id), форма требует имена** — нужен mapping id→name. См. секцию «Открытые вопросы». В рамках live-теста (Task 4) tested с **пустым** `regions=[]`. |
|
||||
| 8 | `limit_off` | Разделять по проектам | **НЕТ в DTO** | el-checkbox. Initial unchecked. Tier-2 оставляет default. |
|
||||
| 9 | `content` | Список сайтов / номеров / отправителей | `dto.uniqueKey` ⇒ textarea content | el-tabs `Список` (active) / `Файл`. Нам нужна вкладка `Список` (default active). Внутри textarea. Label меняется в зависимости от `type`: для `Сайты` — `Список сайтов`, для `Звонки` — `Список номеров`, для `СМС` — `Список отправителей` (не verified — см. Открытые вопросы). |
|
||||
| 10 | `limit` | Лимит в день | `dto.limit` | `.el-input-number` ⇒ внутри `.el-input input.el-input__inner` (тип text, не number). Кнопки +/− — `.el-input-number__increase` / `.el-input-number__decrease`. Для надёжности — `fill(String(dto.limit))` в input напрямую. |
|
||||
|
||||
## Кнопки
|
||||
|
||||
| Действие | Локатор | Notes |
|
||||
|---|---|---|
|
||||
| Save | `.v-dialog--active button:has-text("Сохранить")` | `el-button el-button--default` (НЕ primary; нет цветового акцента). Сохраняет + POST на `/admin/visit/rt-project-save`. |
|
||||
| Cancel | `.v-dialog--active button:has-text("Отмена")` | Закрывает диалог. |
|
||||
|
||||
## Канонические локаторы Playwright (для Task 3 manage-project.js)
|
||||
|
||||
```javascript
|
||||
// Helper: form-item с конкретным `for=` атрибутом
|
||||
function fieldByFor(page, attrFor) {
|
||||
return page.locator(`.el-form-item:has(.el-form-item__label[for="${attrFor}"])`);
|
||||
}
|
||||
|
||||
// 1. Tag — text input
|
||||
await fieldByFor(page, 'tag').locator('input.el-input__inner').fill(dto.tag);
|
||||
|
||||
// 2. Platforms (srcrt) — sub-checkboxes B1/B2/B3 by textContent
|
||||
const platformContainer = fieldByFor(page, 'srcrt');
|
||||
for (const p of ['B1', 'B2', 'B3']) {
|
||||
const cb = platformContainer.locator('.el-checkbox', {hasText: new RegExp(`^${p}$`)});
|
||||
const wanted = (dto.platforms || []).includes(p);
|
||||
const isChecked = (await cb.getAttribute('class'))?.includes('is-checked');
|
||||
if (!!isChecked !== wanted) await cb.click();
|
||||
}
|
||||
|
||||
// 3. Name
|
||||
await fieldByFor(page, 'name').locator('input.el-input__inner').fill(dto.name);
|
||||
|
||||
// 4. Type — el-select with label match
|
||||
const typeLabel = {site: 'Сайты', call: 'Звонки', sms: 'СМС'}[dto.signal_type];
|
||||
await fieldByFor(page, 'type').locator('.el-select input.el-input__inner').click();
|
||||
// Wait for dropdown popup (rendered outside form into body)
|
||||
await page.locator('.el-select-dropdown__item', {hasText: new RegExp(`^${typeLabel}$`)}).click();
|
||||
|
||||
// 6. Switch (active) — by class .el-switch in form-item without label-for
|
||||
const switchItem = page.locator('.el-form-item').filter({has: page.locator('.el-switch span:has-text("Включить")')});
|
||||
const switchEl = switchItem.locator('.el-switch');
|
||||
const isActive = (await switchEl.getAttribute('class'))?.includes('is-checked');
|
||||
if (!!isActive !== !!dto.active) await switchEl.click();
|
||||
|
||||
// 9. Content list — текстbox in active tab "Список"
|
||||
await fieldByFor(page, 'content').locator('.el-tabs__item:has-text("Список")').click(); // ensure tab active
|
||||
await fieldByFor(page, 'content').locator('textarea.el-textarea__inner').fill(dto.domains.join('\n'));
|
||||
|
||||
// 10. Limit
|
||||
await fieldByFor(page, 'limit').locator('input.el-input__inner').fill(String(dto.limit));
|
||||
|
||||
// Save (intercept response)
|
||||
const [saveResp] = await Promise.all([
|
||||
page.waitForResponse(r => r.url().endsWith('/admin/visit/rt-project-save') && r.request().method() === 'POST'),
|
||||
page.locator('.v-dialog--active button:has-text("Сохранить")').click(),
|
||||
]);
|
||||
const body = await saveResp.json();
|
||||
if (body.status !== 'OK') throw new Error(`Portal rejected save: ${body.message}`);
|
||||
const externalId = String(body.id);
|
||||
```
|
||||
|
||||
## Открытые вопросы (gaps между формой и DTO)
|
||||
|
||||
1. **`workdays` отсутствует на форме create.** DTO имеет `workdays: int[1..7]` (дни недели). На форме add-project — **только slider «Период» (часы 0-24)**, дни недели отсутствуют. Возможные стратегии для Tier-2:
|
||||
- **(a)** После `rt-project-save` сделать дополнительный AJAX-апдейт через `SupplierPortalClient::updateProject` с workdays — но это противоречит идее Tier-2 как пути отказа от Tier-1 (если Tier-1 не работает, дополнительный AJAX от Tier-2 тоже скорее всего не сработает).
|
||||
- **(b)** Принять, что Tier-2 не выставляет workdays — портал применяет default (все 7 дней?). Зафиксировать в Tier-3 manual queue payload, чтобы оператор скорректировал вручную.
|
||||
- **(c)** Workdays задаются на странице **редактирования** rt-проекта, не создания — проверить.
|
||||
- **Решение принять в Task 3 design**. Скорее всего (b) — Tier-2 — fallback, не идеальная замена.
|
||||
|
||||
2. **`regions` mapping id → name.** DTO несёт `int[]` (id регионов), форма требует имена. Mapping должен быть:
|
||||
- **(a)** В JS-bridge: жёстко зашить регионы id↔name в `manage-project.js` (на ~89 регионов, ~3 KB словарь).
|
||||
- **(b)** В PHP: `FormProjectChannel::mapDto` конвертирует id→name перед отправкой в bridge.
|
||||
- **(c)** В Tier-2 — игнорировать regions (передавать пустой массив, регионы выставлять отдельным AJAX-апдейтом).
|
||||
- **Решение в Task 3 design.** Скорее всего (c) для MVP — Tier-2 редко используется, регионы — некритичный default.
|
||||
|
||||
3. **Label вкладки «Список» меняется по типу.** Verified: для `type=Сайты` label — `Список сайтов`. Для `type=Звонки` / `Сайты` / `СМС` метки textarea-вкладки могут отличаться. Но `for="content"` на label form-item стабильно — селектор `fieldByFor(page, 'content')` достаточен независимо от type.
|
||||
|
||||
4. **«Период» (slider 10-24).** Default `10-18` (часы активности). DTO не несёт, оставляем default. Если в будущем понадобится — расширять DTO + добавить slider-control в bridge.
|
||||
|
||||
5. **«Разделять по проектам» (`limit_off`).** Семантика не verified — оставляем unchecked (default).
|
||||
|
||||
6. **«Ретро сайты» / «Ретро звонки» type'ы.** Не в DTO (мы используем только site/call/sms). Зафиксировать как **не поддерживается** в FormProjectChannel — выкинуть `InvalidArgumentException` если DTO.signalType не в `{site,call,sms}`.
|
||||
|
||||
## Снимки страницы
|
||||
|
||||
Все Playwright snapshots в `.playwright-mcp/page-2026-05-19T13-2*.yml` (untracked, gitignored).
|
||||
|
||||
## Live-smoke (Task 4) — 2026-05-19
|
||||
|
||||
`_smoke_form_channel.php` (DTO platform B1 / site / limit 10): create через Tier-2
|
||||
(`FormProjectChannel` → `manage-project.js`) → `external_id=12731690` → delete через
|
||||
Tier-1 AJAX → **OK**. Form-канал доказан end-to-end против живого портала.
|
||||
|
||||
### Находки live-дебага
|
||||
|
||||
1. **Портал валидирует формат домена.** `content` (домены для site-проекта)
|
||||
должен быть валидным хостом — **lowercase, дефисы, без underscore, без
|
||||
uppercase**. Невалидный (`lidpotok-smoke-LIDERRA_FORM_SMOKE_NNN.example`) →
|
||||
`rt-project-save` отвечает `{status:"Error",message:"Введите домены"}` (HTTP 200).
|
||||
Для site-проектов `SupplierProjectDto::uniqueKey` обязан быть валидным доменом.
|
||||
|
||||
2. **Multi-source save создаёт N rt-проектов.** Если в форме включено несколько
|
||||
`srcrt`/`srcbl`/`srcmt` (B1/B2/B3), один `rt-project-save` создаёт по проекту
|
||||
на каждый источник; `id` в ответе — последний. `manage-project.js` снимает
|
||||
лишние чекбоксы под `dto.platform` (single) → ровно 1 проект. При работе
|
||||
напрямую с `SupplierPortalClient::saveProject` помнить: дефолт формы — все 3
|
||||
источника включены.
|
||||
|
||||
3. **Единственный `.el-switch` на форме — `regions_reverse`** (include/exclude
|
||||
регионов, текст «Включить/Исключить»), НЕ статус active/paused. Статус проекта
|
||||
(`status`) задаётся дефолтом формы (`true`), отдельного UI-switch нет. Recon
|
||||
row 6 (выше) скорректирован: switch ≠ status.
|
||||
|
||||
4. **type-select / клик вкладки ремоунтят content tab-pane.** Element UI: re-click
|
||||
уже-активного значения select'а / вкладки пере-рендерит pane → textarea
|
||||
детачится. `manage-project.js` кликает type/вкладку только при реальной смене
|
||||
значения (commit `b9791c5`).
|
||||
|
||||
## 3-tier failover live-smoke (Task 5b) — 2026-05-19
|
||||
|
||||
`_smoke_failover_3tier.php` против живого портала:
|
||||
|
||||
| Прогон | Сценарий | Результат |
|
||||
|---|---|---|
|
||||
| 1 | Tier-1 live (`AjaxProjectChannel`) | OK — `external_id=12732078`, удалён |
|
||||
| 2 | force-fail tier-1 (DI-стаб) → Tier-2 form (`FormProjectChannel`) | OK — `external_id=12732091`, удалён |
|
||||
| 3 | force-fail tier-1+2 → Tier-3 (`escalateToTier3`) | `TierEscalatedException` + `SupplierManualSyncQueue` row (`reason=form_save_error`) + alert mail; queue row удалён |
|
||||
|
||||
`FailoverProjectChannel` эскалация доказана end-to-end. Полный Supplier-suite
|
||||
(`tests/Feature/Supplier` + `tests/Unit/Supplier` + `tests/Feature/Integration`)
|
||||
— **156/156 passed**, 0 регрессий. Лог: `app/storage/logs/smoke-failover-2026-05-19.log`.
|
||||
@@ -0,0 +1,80 @@
|
||||
# Discovery-brief: переделка миграции проектов + распределения лидов
|
||||
|
||||
**Дата:** 2026-05-20 · **Режим:** FEATURE (discovery-interview) · **Статус:** зафиксировано заказчиком, реализация НЕ начата.
|
||||
|
||||
## Проблема
|
||||
|
||||
Два связанных изменения в логике создания/миграции проектов:
|
||||
|
||||
1. Экспорт проекта Лидерра → портал поставщика crm.bp-gr.ru сейчас неполный и отложенный (каркас при создании, параметры — только ночью).
|
||||
2. Алгоритм распределения входящих лидов между клиентами не имеет потолка получателей — один номер может уйти 20 клиентам, владелец номера «сходит с ума».
|
||||
|
||||
## Архитектура (как есть)
|
||||
|
||||
- Клиент (tenant) создаёт/правит проект в ЛК → `ProjectService` запускает `SyncSupplierProjectJob` (очередь) — ставит на портал **каркас** (лимит 0, дни — вся неделя, регионы пусто).
|
||||
- Ночной `SyncSupplierProjectsJob` (крон 20:30 МСК, `app/routes/console.php:52`) сверяет квоты/дни/регионы и дописывает на портал через `FailoverProjectChannel` (ярус1 AJAX → ярус2 форма → ярус3 ручная очередь).
|
||||
- Входящий лид → `RouteSupplierLeadJob` → `LeadRouter::matchEligibleProjects` → Deal-копия каждому eligible клиенту.
|
||||
|
||||
## Зафиксированные требования
|
||||
|
||||
### Канал экспорта — два режима
|
||||
|
||||
- **R1. Режим «Онлайн».** Создание/изменение проекта в ЛК → перенос поставщику сразу, с полными параметрами (лимит/дни/регионы), не каркасом.
|
||||
- **R2. Режим «Пакетный»** (текущий ночной) — оставить, но время **20:30 → 18:00 МСК**. Снижает нагрузку при многократных правках одного проекта за день.
|
||||
- **R3.** Выбор режима — переключатель в админке.
|
||||
- Мотив: онлайн нужен для быстрой отработки/тестирования миграции; пакетный — для прод-нагрузки.
|
||||
|
||||
### Маппинг формы проекта (подтверждено живым тестом на портале)
|
||||
|
||||
- **R5.** Слать **один** `save` с тремя флагами `srcrt+srcbl+srcmt` — портал сам создаёт 3 проекта (B1/B2/B3). Сейчас код шлёт 3 раздельных save. Меньше нагрузки.
|
||||
- **R6.** Лимит делит **сам портал поровну** (проверено: лимит 15 → проекты по 5). Убрать наш ручной split в `SupplierQuotaAllocator::distributeForPlatform`.
|
||||
- **R7.** `tag` = **название региона** клиента (не `_lidpotok`). При 2+ регионах — **отдельный save на каждый регион** (1 регион → 3 проекта, 2 → 6). Тег региона приходит обратно в лиде (`raw_payload['tag']`) → **протянуть в `deals`** (поле тег = регион, для дальнейшей работы со сделками).
|
||||
|
||||
### Алгоритм распределения лидов (полностью пересмотрен — группировка ВЫКИНУТА)
|
||||
|
||||
Решения, принятые в диалоге (нюансы заказчика: заказ ≠ поставка; платим за фактически поступившие лиды; лимит — жёсткий потолок, недобор допустим):
|
||||
|
||||
- **Заказ у поставщика** = `max( наибольший_лимит , ceil(Σ всех лимитов / 3) )`.
|
||||
- `ceil(Σ/3)` — ёмкость шаринга (один лид продаётся максимум 3 раза).
|
||||
- `наибольший_лимит` — крупнейший клиент должен иметь достаточно разных лидов, чтобы добрать.
|
||||
- Заказ = потолок запроса; придёт ≤; платим за фактически поступившие.
|
||||
- **Распределение лида** = 3 случайным клиентам из тех, у кого остаток лимита > 0 (`получено_сегодня < лимит`).
|
||||
- cap=3 — защита владельца номера;
|
||||
- выбор только из недобравших → лимит-потолок не превышается;
|
||||
- недобор допустим (поставщик шлёт сколько хочет).
|
||||
- **Группировка клиентов НЕ нужна** — рандом из недобравших сам обеспечивает cap=3 + соблюдение лимита + максимизацию шаринга.
|
||||
|
||||
### Примеры расчёта заказа (verified в диалоге)
|
||||
|
||||
| Клиенты | Σ | наиб. лимит | ceil(Σ/3) | Заказ |
|
||||
|---|---|---|---|---|
|
||||
| 5, 5, 10, 20 | 40 | 20 | 14 | **20** |
|
||||
| 15×5 + 10 (16 клиентов) | 85 | 10 | 29 | **29** |
|
||||
| 3×15 | 45 | 15 | 15 | **15** |
|
||||
| 3×15 + 30 | 75 | 30 | 25 | **30** |
|
||||
| 4×10 | 40 | 10 | 14 | **14** |
|
||||
|
||||
## Что НЕ так в текущей реализации (пины)
|
||||
|
||||
- **Заказ:** `SupplierQuotaAllocator::allocate` (`app/app/Services/Supplier/SupplierQuotaAllocator.php:55`) суммирует `Σ daily_limit` + делит на B1/B2/B3 (`:73`). Надо: формула `max(наиб, ceil(Σ/3))`, split убрать (портал делит сам).
|
||||
- **Распределение/cap:** `LeadRouter::matchEligibleProjects` (`app/app/Services/LeadRouter.php:46`) возвращает всех eligible; `RouteSupplierLeadJob` (`app/app/Jobs/RouteSupplierLeadJob.php:115`) создаёт копию каждому — нет cap=3, нет рандома.
|
||||
- **Время крона:** `app/routes/console.php:52` — 20:30, надо 18:00.
|
||||
- **Экспорт по одному флагу:** `SupplierPortalClient::toPayload` (`app/app/Services/Supplier/SupplierPortalClient.php:422`) шлёт один src-флаг; `SyncSupplierProjectJob` (`app/app/Jobs/SyncSupplierProjectJob.php:64`) — раздельные save по платформам.
|
||||
|
||||
## Открытые под-вопросы (для brainstorming перед реализацией)
|
||||
|
||||
1. Scope переключателя режима — глобально (SaaS) или per-tenant?
|
||||
2. Поведение онлайн-режима при недоступном портале — эскалация в ярус-3 очередь, как сейчас?
|
||||
3. Тег при «вся РФ» (регион не выбран) — пустой?
|
||||
4. Имя «Конкурент 1» на портал не уходит (в name едет номер донора) — нужно ли тянуть человекочитаемое имя?
|
||||
5. Ключ конкуренции клиентов за поток (источник+регион+день) — как именно сопоставляется регион.
|
||||
|
||||
## Следующий шаг
|
||||
|
||||
Эпик (новые режимы экспорта + переписка квот/маршрутизации + админка). Реализацию начинать через `brainstorming` (закрыть под-вопросы 1–5) → `writing-plans` → TDD.
|
||||
|
||||
## Прочее (сессия 2026-05-20)
|
||||
|
||||
- Webhook-канал чинили (secret <32 после re-seed → 404; восстановлен).
|
||||
- CSV reconcile здоров.
|
||||
- Тестовые проекты на портале от живого теста R5: id `12742042/12742043/12742044` (`*_LIDERRA_TEST_DELETE_ME`) — заказчик удалит сам.
|
||||
@@ -8,6 +8,7 @@ Passive evidence-loop for the Лидерра «brain» per ADR-011.
|
||||
- `notes/YYYY-MM-DD-<slug>.md` — optional MD notes for sessions with qualitative history.
|
||||
- `STATUS.md` — auto-generated dashboard. Regenerated per-commit by `tools/status-md-generator.mjs`.
|
||||
- `.read-counter.json` — C3 observer-of-observer counter. Updated on Read of observer files.
|
||||
- `dashboard.html` + `dashboard.js` + `dashboard-core.js` — Brain Dashboard: visualises the episode log over the automation-graph topology (4 views — Карта / Разбор / Лента / Агрегат). Run `npm run brain:dashboard`, open the printed localhost URL. `dashboard-core.js` is pure logic, unit-tested in `tools/brain-dashboard-core.test.mjs`.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Brain Status (auto-generated)
|
||||
|
||||
Last updated: 2026-05-19T11:22:16.708Z
|
||||
Last updated: 2026-05-19T12:44:43.305Z
|
||||
|
||||
| Контролёр | Состояние | Детали |
|
||||
|---|---|---|
|
||||
@@ -8,11 +8,11 @@ Last updated: 2026-05-19T11:22:16.708Z
|
||||
| C2 Cross-ref consistency | ✅ | [cross-ref-checker] OK — 0 drift in 4 files |
|
||||
| C3 Observer-of-observer | ✅ | [observer-of-observer] OK — last read 0 week(s) ago |
|
||||
| C4 Сигнальный статус | ✅ | This file (self-reference) |
|
||||
| C5 Observer-coverage | ✅ | 23 episode(s), 982 recent commit(s) · Stop-hook + post-commit OK |
|
||||
| C5 Observer-coverage | ✅ | 17 episode(s), 988 recent commit(s) · Stop-hook + post-commit OK |
|
||||
|
||||
## Метрики (информационные, не алерты)
|
||||
|
||||
- Observer evidence: 23 episodes this month, 0 observer_error markers, 0 PII matches before filter
|
||||
- Observer evidence: 17 episodes this month, 0 observer_error markers, 0 PII matches before filter
|
||||
- Использование узлов: см. `/brain-retro` (раз в спринт). **Неиспользованные узлы — не проблема** (capability-readiness; см. memory `feedback_brain_unused_tools_not_problem` — outside-repo memory store).
|
||||
|
||||
## Алерт-индикаторы
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
// Pure logic for the Brain Dashboard. Browser-safe ES module (no node: APIs)
|
||||
// so it loads both in the browser and under Vitest's node environment.
|
||||
|
||||
export function normalizeEpisode(raw) {
|
||||
const v2 = raw.schema_version === 2;
|
||||
const pr = raw.primary_rationale || {};
|
||||
const events = Array.isArray(raw.events) ? raw.events : [];
|
||||
const tools = {};
|
||||
for (const ev of events) {
|
||||
if (ev.kind === 'tool_summary' && ev.counts) {
|
||||
for (const [k, n] of Object.entries(ev.counts)) tools[k] = (tools[k] || 0) + n;
|
||||
}
|
||||
}
|
||||
const started = raw.timestamps?.started_at || null;
|
||||
const ended = raw.timestamps?.ended_at || null;
|
||||
return {
|
||||
schemaVersion: v2 ? 2 : 1,
|
||||
taskId: raw.task_id || null,
|
||||
taskRef: raw.task_ref || raw.task_id || null,
|
||||
startedAt: started,
|
||||
endedAt: ended,
|
||||
durationMs: started && ended ? Date.parse(ended) - Date.parse(started) : null,
|
||||
pathType: raw.path_type || null,
|
||||
outcome: raw.outcome || 'unknown',
|
||||
promptSignal: v2 ? raw.prompt_signal || null : null,
|
||||
decisionProvenance: v2 ? raw.decision_provenance || null : null,
|
||||
environment: v2 ? raw.environment || null : null,
|
||||
taskSize: v2 ? raw.task_size || null : null,
|
||||
taskClassification: pr.task_classification || null,
|
||||
nodeChosen: pr.node_chosen || null,
|
||||
hardFloor: pr.hard_floor || { invoked: false, rules: [] },
|
||||
skills: events.filter((e) => e.kind === 'skill_invoked').map((e) => e.skill),
|
||||
tools,
|
||||
errorCount: events.filter((e) => e.kind === 'error').length,
|
||||
retryCount: events.filter((e) => e.kind === 'retry').length,
|
||||
interruptCount: events.filter((e) => e.kind === 'interrupt').length,
|
||||
events,
|
||||
raw,
|
||||
};
|
||||
}
|
||||
|
||||
// episode skill name → automation-graph node id (see tools/observer-known-nodes.txt
|
||||
// for the routable vocabulary; only skills that have a graph node are listed).
|
||||
export const SKILL_TO_NODE = {
|
||||
brainstorming: 'sk_brainstorm',
|
||||
'writing-plans': 'sk_wplans',
|
||||
'executing-plans': 'sk_eplans',
|
||||
'subagent-driven-development': 'sk_subagent',
|
||||
'test-driven-development': 'sk_tdd',
|
||||
'systematic-debugging': 'sk_debug',
|
||||
'verification-before-completion': 'sk_verify',
|
||||
'requesting-code-review': 'sk_coderev',
|
||||
'using-git-worktrees': 'sk_worktree',
|
||||
'finishing-a-development-branch': 'sk_pr',
|
||||
'writing-skills': 'sk_wskills',
|
||||
'discovery-interview': 'discovery_interview',
|
||||
'audit-portal': 'sk_audit_portal',
|
||||
regression: 'sk_regression',
|
||||
'process-modeling': 'process_modeling',
|
||||
'process-analysis': 'process_analysis',
|
||||
ccpm: 'ccpm',
|
||||
'security-review': 'sk_security_review',
|
||||
'claude-md-management': 'claude_md_mgmt',
|
||||
};
|
||||
|
||||
// mcp__<server>__<tool> → automation-graph node id.
|
||||
export const MCP_SERVER_TO_NODE = {
|
||||
github: 'mcp_gh',
|
||||
playwright: 'mcp_pw',
|
||||
'laravel-boost': 'mcp_boost',
|
||||
redis: 'mcp_redis',
|
||||
sentry: 'mcp_sentry',
|
||||
semgrep: 'mcp_semgrep',
|
||||
openapi: 'mcp_openapi',
|
||||
magic: 'mcp_21st',
|
||||
'universal-icons': 'mcp_icons',
|
||||
};
|
||||
|
||||
// "superpowers:systematic-debugging" → "systematic-debugging"
|
||||
function skillBase(name) {
|
||||
const s = String(name || '');
|
||||
return s.includes(':') ? s.split(':').pop() : s;
|
||||
}
|
||||
|
||||
// Returns { nodeIds: string[], signals: number, attributed: number }.
|
||||
// A "signal" is an episode datum that names a routable node (a skill id or an
|
||||
// mcp__ tool). Builtin Claude tools are not signals.
|
||||
export function attributeNodes(episode) {
|
||||
const ids = new Set();
|
||||
let signals = 0;
|
||||
let attributed = 0;
|
||||
const consider = (nodeId) => {
|
||||
signals++;
|
||||
if (nodeId) {
|
||||
ids.add(nodeId);
|
||||
attributed++;
|
||||
}
|
||||
};
|
||||
if (episode.nodeChosen && episode.nodeChosen !== 'direct') {
|
||||
consider(SKILL_TO_NODE[skillBase(episode.nodeChosen)]);
|
||||
}
|
||||
for (const s of episode.skills) consider(SKILL_TO_NODE[skillBase(s)]);
|
||||
for (const toolName of Object.keys(episode.tools)) {
|
||||
const m = /^mcp__(.+?)__/.exec(toolName);
|
||||
if (m) consider(MCP_SERVER_TO_NODE[m[1]]);
|
||||
}
|
||||
return { nodeIds: [...ids], signals, attributed };
|
||||
}
|
||||
|
||||
// Groups episodes by taskRef. Each group's episodes are sorted newest-first;
|
||||
// groups are ordered by their newest episode, newest group first.
|
||||
export function groupBySession(episodes) {
|
||||
const byRef = new Map();
|
||||
for (const e of episodes) {
|
||||
const key = e.taskRef || e.taskId || 'unknown';
|
||||
if (!byRef.has(key)) byRef.set(key, []);
|
||||
byRef.get(key).push(e);
|
||||
}
|
||||
const groups = [...byRef.entries()].map(([taskRef, eps]) => {
|
||||
eps.sort((a, b) => String(b.startedAt).localeCompare(String(a.startedAt)));
|
||||
return { taskRef, episodes: eps, newest: eps[0]?.startedAt || '' };
|
||||
});
|
||||
groups.sort((a, b) => String(b.newest).localeCompare(String(a.newest)));
|
||||
return groups;
|
||||
}
|
||||
|
||||
// filter: { classification?, outcome?, pathType?, withErrors?, dateFrom?, dateTo? }
|
||||
export function filterEpisodes(episodes, filter = {}) {
|
||||
return episodes.filter((e) => {
|
||||
if (filter.classification && e.taskClassification !== filter.classification) return false;
|
||||
if (filter.outcome && e.outcome !== filter.outcome) return false;
|
||||
if (filter.pathType && e.pathType !== filter.pathType) return false;
|
||||
if (filter.withErrors && e.errorCount === 0 && e.retryCount === 0) return false;
|
||||
if (filter.dateFrom && String(e.startedAt) < filter.dateFrom) return false;
|
||||
if (filter.dateTo && String(e.startedAt) > filter.dateTo) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// Three honest layers (spec §6):
|
||||
// design — the dashed conflict edges (fact, from topology)
|
||||
// friction — node id → count of errored/retried episodes attributed to it
|
||||
// correlation — errored episodes that span both ends of a design-conflict edge
|
||||
export function inferConflicts(episodes, edges) {
|
||||
const design = edges.filter((e) => e.dashes === true);
|
||||
const friction = {};
|
||||
const correlation = [];
|
||||
for (const e of episodes) {
|
||||
if (e.errorCount === 0 && e.retryCount === 0) continue;
|
||||
const ids = attributeNodes(e).nodeIds;
|
||||
for (const id of ids) friction[id] = (friction[id] || 0) + 1;
|
||||
if (e.errorCount > 0) {
|
||||
for (const edge of design) {
|
||||
if (ids.includes(edge.from) && ids.includes(edge.to)) {
|
||||
correlation.push({ episode: e.taskId, pair: [edge.from, edge.to], conflict: edge.title || '' });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return { design, friction, correlation };
|
||||
}
|
||||
|
||||
// Aggregates a list of episodes into dashboard metrics.
|
||||
export function aggregate(episodes) {
|
||||
const nodeHeat = {};
|
||||
const pathType = {};
|
||||
const outcome = {};
|
||||
const classification = {};
|
||||
const economy = {};
|
||||
let totalErrors = 0;
|
||||
let totalRetries = 0;
|
||||
let redirects = 0;
|
||||
for (const e of episodes) {
|
||||
for (const id of attributeNodes(e).nodeIds) nodeHeat[id] = (nodeHeat[id] || 0) + 1;
|
||||
if (e.pathType) pathType[e.pathType] = (pathType[e.pathType] || 0) + 1;
|
||||
outcome[e.outcome] = (outcome[e.outcome] || 0) + 1;
|
||||
if (e.taskClassification) classification[e.taskClassification] = (classification[e.taskClassification] || 0) + 1;
|
||||
const lvl = e.environment ? e.environment.economy_level : null;
|
||||
const key = lvl == null ? 'n/a' : String(lvl);
|
||||
economy[key] = (economy[key] || 0) + 1;
|
||||
totalErrors += e.errorCount;
|
||||
totalRetries += e.retryCount;
|
||||
if (e.decisionProvenance && e.decisionProvenance.kind === 'user_directed_method') redirects++;
|
||||
}
|
||||
return {
|
||||
nodeHeat,
|
||||
pathType,
|
||||
outcome,
|
||||
classification,
|
||||
economy,
|
||||
totalErrors,
|
||||
totalRetries,
|
||||
redirectRate: episodes.length ? redirects / episodes.length : 0,
|
||||
count: episodes.length,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseEpisodes(text) {
|
||||
const episodes = [];
|
||||
let skipped = 0;
|
||||
for (const line of String(text).split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
let raw;
|
||||
try {
|
||||
raw = JSON.parse(trimmed);
|
||||
} catch {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
if (!raw || typeof raw !== 'object' || raw.observer_error) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
episodes.push(normalizeEpisode(raw));
|
||||
}
|
||||
return { episodes, skipped };
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Дашборд мозга — Лидерра</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #F6F3EC; --ink: #012019; --teal: #0F6E56;
|
||||
--panel: #ffffff; --line: #d8d2c4;
|
||||
--mono: 'JetBrains Mono', ui-monospace, monospace;
|
||||
--sans: 'Inter', system-ui, sans-serif;
|
||||
}
|
||||
body { margin:0; height:100vh; display:flex; flex-direction:column; background:var(--bg); color:var(--ink); font-family:var(--sans); overflow:hidden; }
|
||||
#tabbar { background:var(--panel); border-bottom:1px solid var(--line); padding:8px 12px; display:flex; align-items:center; gap:10px; flex-shrink:0; }
|
||||
#tabbar button { background:var(--panel); border:1px solid var(--line); color:var(--ink); border-radius:5px; padding:6px 14px; font-size:13px; cursor:pointer; font-family:var(--sans); }
|
||||
#tabbar button.active { background:var(--teal); color:#ffffff; border-color:var(--teal); }
|
||||
#tabbar button:hover { background:rgba(15,110,86,0.08); }
|
||||
#status { margin-left:auto; font-size:12px; color:var(--ink); font-family:var(--mono); opacity:0.7; }
|
||||
#graph { height:40vh; background:#1e1e2e; flex-shrink:0; border-bottom:1px solid var(--line); }
|
||||
#network { background:#1e1e2e; }
|
||||
#workarea { flex:1; overflow:auto; padding:16px; }
|
||||
.view { display:none; }
|
||||
.view.active { display:block; }
|
||||
h3, h4 { color:var(--teal); margin:8px 0; }
|
||||
#agg-tiles { display:grid; grid-template-columns:repeat(auto-fill, minmax(220px, 1fr)); gap:12px; }
|
||||
.tile { background:var(--panel); border:1px solid var(--line); border-radius:6px; padding:12px; }
|
||||
.tile h4 { margin:0 0 6px; font-size:11px; text-transform:uppercase; letter-spacing:0.06em; }
|
||||
.tile p { margin:0; font-family:var(--mono); font-size:13px; }
|
||||
.feed-group { margin-bottom:16px; }
|
||||
.feed-card { background:var(--panel); border:1px solid var(--line); border-radius:4px; padding:8px 10px; margin-bottom:6px; font-family:var(--mono); font-size:12px; }
|
||||
#replay-list { float:left; width:40%; padding-right:12px; box-sizing:border-box; }
|
||||
#replay-detail { float:left; width:60%; }
|
||||
#replay-episodes { list-style:none; padding:0; max-height:50vh; overflow:auto; }
|
||||
#replay-episodes li { background:var(--panel); border:1px solid var(--line); border-radius:4px; padding:6px 10px; margin-bottom:4px; cursor:pointer; font-family:var(--mono); font-size:11px; }
|
||||
#replay-episodes li:hover { background:rgba(15,110,86,0.06); }
|
||||
#agg-conflicts { margin-top:16px; }
|
||||
#agg-conflicts p { font-family:var(--mono); font-size:12px; }
|
||||
#feed-pause { background:var(--panel); border:1px solid var(--line); color:var(--ink); border-radius:5px; padding:4px 10px; cursor:pointer; font-family:var(--sans); }
|
||||
#feed-poll-state { margin-left:8px; font-family:var(--mono); font-size:11px; color:var(--ink); opacity:0.7; }
|
||||
#map-conflicts { font-family:var(--mono); font-size:12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header id="tabbar">
|
||||
<button data-view="map">Карта</button>
|
||||
<button data-view="replay">Разбор</button>
|
||||
<button data-view="feed">Лента</button>
|
||||
<button data-view="aggregate">Агрегат</button>
|
||||
<span id="status"></span>
|
||||
</header>
|
||||
<section id="graph">
|
||||
<div id="network" style="width:100%;height:100%"></div>
|
||||
</section>
|
||||
<section id="workarea">
|
||||
<div class="view" id="view-map">
|
||||
<p>Топология мозга: 124 узла, рёбра, 11 размеченных дизайн-конфликтов. Это нулевое состояние холста — без оверлеев.</p>
|
||||
<ul id="map-conflicts"></ul>
|
||||
</div>
|
||||
<div class="view" id="view-replay">
|
||||
<div id="replay-list">
|
||||
<select id="f-classification"><option value="">все</option><option value="bugfix">bugfix</option><option value="feature">feature</option><option value="refactor">refactor</option><option value="docs">docs</option><option value="question">question</option><option value="other">other</option></select>
|
||||
<select id="f-outcome"><option value="">все</option><option value="success">success</option><option value="unknown">unknown</option><option value="failure">failure</option></select>
|
||||
<label><input type="checkbox" id="f-errors"> только с ошибками</label>
|
||||
<ul id="replay-episodes"></ul>
|
||||
</div>
|
||||
<div id="replay-detail"></div>
|
||||
</div>
|
||||
<div class="view" id="view-feed">
|
||||
<button id="feed-pause">Пауза</button>
|
||||
<span id="feed-poll-state"></span>
|
||||
<div id="feed-stream"></div>
|
||||
</div>
|
||||
<div class="view" id="view-aggregate">
|
||||
<div id="agg-tiles"></div>
|
||||
<div id="agg-conflicts"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script src="https://unpkg.com/vis-network@9.1.9/standalone/umd/vis-network.min.js"></script>
|
||||
<script src="../automation-graph-data.js"></script>
|
||||
<script type="module" src="dashboard.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,237 @@
|
||||
import { parseEpisodes, filterEpisodes, attributeNodes, groupBySession, aggregate, inferConflicts } from './dashboard-core.js';
|
||||
|
||||
const AGD = window.AGD;
|
||||
let episodes = [];
|
||||
let skipped = 0;
|
||||
let network = null;
|
||||
|
||||
// ── data loading ──────────────────────────────────────────────
|
||||
async function loadEpisodes() {
|
||||
const files = await fetch('/api/episodes').then((r) => r.json());
|
||||
const all = [];
|
||||
let skip = 0;
|
||||
for (const f of files) {
|
||||
const url = '/docs/observer/' + f;
|
||||
const text = await fetch(url).then((r) => (r.ok ? r.text() : ''));
|
||||
const r = parseEpisodes(text);
|
||||
all.push(...r.episodes);
|
||||
skip += r.skipped;
|
||||
}
|
||||
all.sort((a, b) => String(a.startedAt).localeCompare(String(b.startedAt)));
|
||||
episodes = all;
|
||||
skipped = skip;
|
||||
document.getElementById('status').textContent =
|
||||
`${episodes.length} эпизодов · ${skipped} пропущено`;
|
||||
}
|
||||
|
||||
// ── graph banner ──────────────────────────────────────────────
|
||||
function renderGraph() {
|
||||
const nodes = new vis.DataSet(AGD.NODES);
|
||||
const edges = new vis.DataSet(AGD.EDGES);
|
||||
network = new vis.Network(
|
||||
document.getElementById('network'),
|
||||
{ nodes, edges },
|
||||
{
|
||||
groups: AGD.GROUPS,
|
||||
nodes: { shape: 'dot', borderWidth: 2, font: { multi: 'html' } },
|
||||
edges: { smooth: { type: 'continuous', roundness: 0.5 } },
|
||||
physics: { enabled: false },
|
||||
interaction: { hover: true, tooltipDelay: 400 },
|
||||
}
|
||||
);
|
||||
network.once('afterDrawing', () => network.fit());
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
// ── view switching ────────────────────────────────────────────
|
||||
const views = {};
|
||||
let activeView = 'map';
|
||||
|
||||
views.map = function renderMapView() {
|
||||
// Plain mode: clear any overlay coloring applied by other views.
|
||||
window.__graph.nodes.update(AGD.NODES.map((n) => ({ id: n.id, color: undefined })));
|
||||
// List the design-time conflict edges (dashed edges carry an emoji label).
|
||||
const conflicts = AGD.EDGES.filter((e) => e.dashes === true);
|
||||
const ul = document.getElementById('map-conflicts');
|
||||
ul.innerHTML = '';
|
||||
for (const c of conflicts) {
|
||||
const li = document.createElement('li');
|
||||
li.textContent = `${c.label || '•'} ${c.from} ↔ ${c.to}: ${c.title || ''}`;
|
||||
ul.appendChild(li);
|
||||
}
|
||||
};
|
||||
|
||||
views.replay = function renderReplayView() {
|
||||
const filter = {
|
||||
classification: document.getElementById('f-classification').value || undefined,
|
||||
outcome: document.getElementById('f-outcome').value || undefined,
|
||||
withErrors: document.getElementById('f-errors').checked || undefined,
|
||||
};
|
||||
const list = filterEpisodes(episodes, filter);
|
||||
const ul = document.getElementById('replay-episodes');
|
||||
ul.innerHTML = '';
|
||||
list.forEach((ep) => {
|
||||
const li = document.createElement('li');
|
||||
li.textContent = `${ep.startedAt} · ${ep.taskClassification || '—'} · ${ep.outcome}`
|
||||
+ (ep.errorCount ? ` · ⚠${ep.errorCount}` : '');
|
||||
li.addEventListener('click', () => selectEpisode(ep));
|
||||
ul.appendChild(li);
|
||||
});
|
||||
};
|
||||
|
||||
function selectEpisode(ep) {
|
||||
const attr = attributeNodes(ep);
|
||||
window.__graph.nodes.update(
|
||||
AGD.NODES.map((n) => ({
|
||||
id: n.id,
|
||||
color: attr.nodeIds.includes(n.id)
|
||||
? { background: '#268bd2', border: '#93a1a1' }
|
||||
: { background: '#2a2a3a', border: '#444' },
|
||||
}))
|
||||
);
|
||||
const d = document.getElementById('replay-detail');
|
||||
const prov = ep.decisionProvenance;
|
||||
const provLine = prov && prov.kind === 'user_directed_method'
|
||||
? `перенаправление: выбран ${prov.node || '?'}, автономно был бы ${prov.claude_would_have_chosen || '?'}`
|
||||
: prov ? prov.kind : '—';
|
||||
const env = ep.environment || {};
|
||||
d.innerHTML = `
|
||||
<h3>${ep.taskClassification || '—'} · ${ep.pathType || '—'} · ${ep.outcome}</h3>
|
||||
<p>provenance: ${provLine}</p>
|
||||
<p>hard-floor: ${ep.hardFloor.invoked ? (ep.hardFloor.rules || []).join(', ') : 'нет'}</p>
|
||||
<p>окружение: economy=${env.economy_level ?? '—'} · ${env.model || '—'} · turn ${env.session_turn ?? '—'}${env.post_compaction ? ' · post-compaction' : ''}${env.parallel_session ? ' · parallel' : ''}</p>
|
||||
<p>атрибутировано узлов: ${attr.attributed} из ${attr.signals} сигналов</p>
|
||||
<h4>События</h4>
|
||||
<ol>${ep.events.map((e) => `<li>${eventLine(e)}</li>`).join('')}</ol>`;
|
||||
}
|
||||
|
||||
views.feed = function renderFeedView() {
|
||||
const groups = groupBySession(episodes);
|
||||
const root = document.getElementById('feed-stream');
|
||||
root.innerHTML = groups.map((g) => `
|
||||
<section class="feed-group">
|
||||
<h4>сессия ${g.taskRef.slice(0, 8)} · ${g.episodes.length} ходов</h4>
|
||||
${g.episodes.map(feedCard).join('')}
|
||||
</section>`).join('');
|
||||
};
|
||||
|
||||
views.aggregate = function renderAggregateView() {
|
||||
const a = aggregate(episodes);
|
||||
applyHeat(a.nodeHeat);
|
||||
const dist = (obj) => Object.entries(obj).map(([k, v]) => `${k}: ${v}`).join(' · ') || '—';
|
||||
const topNodes = Object.entries(a.nodeHeat).sort((x, y) => y[1] - x[1]).slice(0, 10);
|
||||
document.getElementById('agg-tiles').innerHTML = `
|
||||
<div class="tile"><h4>Эпизодов</h4><p>${a.count}</p></div>
|
||||
<div class="tile"><h4>Ошибки / ретраи</h4><p>${a.totalErrors} / ${a.totalRetries}</p></div>
|
||||
<div class="tile"><h4>Доля перенаправлений</h4><p>${(a.redirectRate * 100).toFixed(0)}%</p></div>
|
||||
<div class="tile"><h4>path_type</h4><p>${dist(a.pathType)}</p></div>
|
||||
<div class="tile"><h4>outcome</h4><p>${dist(a.outcome)}</p></div>
|
||||
<div class="tile"><h4>классы задач</h4><p>${dist(a.classification)}</p></div>
|
||||
<div class="tile"><h4>economy-уровни</h4><p>${dist(a.economy)}</p></div>
|
||||
<div class="tile"><h4>Топ узлов</h4><p>${topNodes.map(([k, v]) => `${k}×${v}`).join(' · ') || '—'}</p></div>`;
|
||||
const c = inferConflicts(episodes, AGD.EDGES);
|
||||
const top = (obj) => Object.entries(obj).sort((x, y) => y[1] - x[1]).map(([k, v]) => `${k}×${v}`).join(' · ') || '—';
|
||||
document.getElementById('agg-conflicts').innerHTML = `
|
||||
<h4>Конфликты — три слоя</h4>
|
||||
<p><b>Дизайн-конфликты (факт):</b> ${c.design.length} размеченных рёбер</p>
|
||||
<p><b>Трение (инференс):</b> ${top(c.friction)}</p>
|
||||
<p><b>Корреляция (эвристика):</b> ${c.correlation.length} ходов с ошибкой на паре конфликтующих узлов</p>`;
|
||||
};
|
||||
|
||||
function applyHeat(nodeHeat) {
|
||||
const max = Math.max(1, ...Object.values(nodeHeat));
|
||||
window.__graph.nodes.update(
|
||||
AGD.NODES.map((n) => {
|
||||
const h = nodeHeat[n.id] || 0;
|
||||
const t = h / max;
|
||||
return {
|
||||
id: n.id,
|
||||
color: h
|
||||
? { background: `rgba(38,139,210,${0.25 + 0.6 * t})`, border: '#93a1a1' }
|
||||
: { background: '#2a2a3a', border: '#444' },
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function feedCard(ep) {
|
||||
const dur = ep.durationMs != null ? Math.round(ep.durationMs / 1000) + 's' : '—';
|
||||
const redirect = ep.decisionProvenance && ep.decisionProvenance.kind === 'user_directed_method' ? ' ↪' : '';
|
||||
return `<div class="feed-card">
|
||||
${ep.startedAt} · ${ep.taskClassification || '—'} · ${ep.pathType || '—'} · ${ep.nodeChosen || '—'}
|
||||
· ${dur}${ep.errorCount ? ' · ⚠' + ep.errorCount : ''}${ep.retryCount ? ' · ↻' + ep.retryCount : ''}${redirect}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function eventLine(e) {
|
||||
switch (e.kind) {
|
||||
case 'skill_invoked': return `skill: ${e.skill}`;
|
||||
case 'error': return `error: ${e.message || ''}`;
|
||||
case 'retry': return 'retry';
|
||||
case 'interrupt': return 'interrupt';
|
||||
case 'hook_fired': return `hooks (${Object.keys(e.counts || {}).length} типов, errors ${e.errors || 0})`;
|
||||
case 'tool_summary': return `инструменты: ${Object.entries(e.counts || {}).map(([k, v]) => `${k}×${v}`).join(', ')}`;
|
||||
case 'time_burn': return `time_burn: ${e.duration_ms} ms`;
|
||||
case 'parse_gap': return `parse_gap: ${e.broken}/${e.total}`;
|
||||
default: return e.kind;
|
||||
}
|
||||
}
|
||||
|
||||
function switchView(name) {
|
||||
activeView = name;
|
||||
for (const v of ['map', 'replay', 'feed', 'aggregate']) {
|
||||
document.getElementById('view-' + v).style.display = v === name ? 'block' : 'none';
|
||||
}
|
||||
document.querySelectorAll('#tabbar button').forEach((b) => {
|
||||
b.classList.toggle('active', b.dataset.view === name);
|
||||
});
|
||||
if (views[name]) views[name]();
|
||||
if (name === 'feed') startPolling(); else stopPolling();
|
||||
}
|
||||
|
||||
// ── boot ──────────────────────────────────────────────────────
|
||||
async function boot() {
|
||||
const gds = renderGraph();
|
||||
window.__graph = { network, ...gds };
|
||||
document.querySelectorAll('#tabbar button').forEach((b) => {
|
||||
b.addEventListener('click', () => switchView(b.dataset.view));
|
||||
});
|
||||
['f-classification', 'f-outcome', 'f-errors'].forEach((id) => {
|
||||
document.getElementById(id).addEventListener('change', () => {
|
||||
if (activeView === 'replay') views.replay();
|
||||
});
|
||||
});
|
||||
document.getElementById('feed-pause').addEventListener('click', () => {
|
||||
if (pollTimer) stopPolling(); else startPolling();
|
||||
});
|
||||
await loadEpisodes();
|
||||
switchView('map');
|
||||
}
|
||||
|
||||
// ── live polling for the Лента view ───────────────────────────
|
||||
const POLL_MS = 5000;
|
||||
let pollTimer = null;
|
||||
|
||||
async function pollTick() {
|
||||
const before = episodes.length;
|
||||
await loadEpisodes();
|
||||
if (episodes.length !== before && activeView === 'feed') views.feed();
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
if (pollTimer) return;
|
||||
pollTimer = setInterval(pollTick, POLL_MS);
|
||||
const el = document.getElementById('feed-poll-state');
|
||||
if (el) el.textContent = `автоопрос каждые ${POLL_MS / 1000}s`;
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
const el = document.getElementById('feed-poll-state');
|
||||
if (el) el.textContent = 'опрос на паузе';
|
||||
}
|
||||
|
||||
export function getEpisodes() { return episodes; }
|
||||
export { views, switchView };
|
||||
boot();
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
**Conventions:**
|
||||
|
||||
- Run tools tests from repo root: `node app/node_modules/vitest/vitest.mjs --config app/vitest.config.tools.mjs run`. The config globs `../tools/*.test.mjs`, so new `tools/brain-dashboard-*.test.mjs` are auto-included.
|
||||
- Run tools tests from `app/` (the config's `../tools` glob resolves relative to it): `cd app && node node_modules/vitest/vitest.mjs --config vitest.config.tools.mjs run`. The config globs `../tools/*.test.mjs`, so new `tools/brain-dashboard-*.test.mjs` are auto-included. Verified baseline 2026-05-19 — 11 files, 169 tests passing.
|
||||
- `dashboard-core.js` is browser-safe (no `node:` APIs) so it loads both in the browser and under Vitest's `node` environment.
|
||||
- Commit messages: `feat(brain): …` / `refactor(brain): …` / `test(brain): …`.
|
||||
- Forest palette / Inter / JetBrains Mono polish is applied in Task 13 (final), not earlier — earlier tasks use neutral styling.
|
||||
@@ -100,7 +100,7 @@ describe('contentType', () => {
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `node app/node_modules/vitest/vitest.mjs --config app/vitest.config.tools.mjs run brain-dashboard-server`
|
||||
Run: `cd app && node node_modules/vitest/vitest.mjs --config vitest.config.tools.mjs run brain-dashboard-server`
|
||||
Expected: FAIL — `Failed to resolve import "./brain-dashboard-server.mjs"`.
|
||||
|
||||
- [ ] **Step 3: Implement the server**
|
||||
@@ -178,7 +178,7 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `node app/node_modules/vitest/vitest.mjs --config app/vitest.config.tools.mjs run brain-dashboard-server`
|
||||
Run: `cd app && node node_modules/vitest/vitest.mjs --config vitest.config.tools.mjs run brain-dashboard-server`
|
||||
Expected: PASS — 6 tests.
|
||||
|
||||
- [ ] **Step 5: Add npm script**
|
||||
@@ -374,7 +374,7 @@ describe('normalizeEpisode', () => {
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `node app/node_modules/vitest/vitest.mjs --config app/vitest.config.tools.mjs run brain-dashboard-core`
|
||||
Run: `cd app && node node_modules/vitest/vitest.mjs --config vitest.config.tools.mjs run brain-dashboard-core`
|
||||
Expected: FAIL — cannot resolve `../docs/observer/dashboard-core.js`.
|
||||
|
||||
- [ ] **Step 3: Implement parser + normalizer**
|
||||
@@ -448,7 +448,7 @@ export function parseEpisodes(text) {
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `node app/node_modules/vitest/vitest.mjs --config app/vitest.config.tools.mjs run brain-dashboard-core`
|
||||
Run: `cd app && node node_modules/vitest/vitest.mjs --config vitest.config.tools.mjs run brain-dashboard-core`
|
||||
Expected: PASS — 8 tests.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
@@ -511,7 +511,7 @@ Note: builtin tools (`Read`, `Edit`, `Bash`, …) are intentionally **not** coun
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `node app/node_modules/vitest/vitest.mjs --config app/vitest.config.tools.mjs run brain-dashboard-core`
|
||||
Run: `cd app && node node_modules/vitest/vitest.mjs --config vitest.config.tools.mjs run brain-dashboard-core`
|
||||
Expected: FAIL — `attributeNodes` is not exported.
|
||||
|
||||
- [ ] **Step 3: Implement attribution**
|
||||
@@ -590,7 +590,7 @@ export function attributeNodes(episode) {
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `node app/node_modules/vitest/vitest.mjs --config app/vitest.config.tools.mjs run brain-dashboard-core`
|
||||
Run: `cd app && node node_modules/vitest/vitest.mjs --config vitest.config.tools.mjs run brain-dashboard-core`
|
||||
Expected: PASS — 13 tests total.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
@@ -805,7 +805,7 @@ describe('filterEpisodes', () => {
|
||||
|
||||
- [ ] **Step 2: Run test, verify it fails**
|
||||
|
||||
Run: `node app/node_modules/vitest/vitest.mjs --config app/vitest.config.tools.mjs run brain-dashboard-core`
|
||||
Run: `cd app && node node_modules/vitest/vitest.mjs --config vitest.config.tools.mjs run brain-dashboard-core`
|
||||
Expected: FAIL — `filterEpisodes` not exported.
|
||||
|
||||
- [ ] **Step 3: Implement `filterEpisodes`**
|
||||
@@ -829,7 +829,7 @@ export function filterEpisodes(episodes, filter = {}) {
|
||||
|
||||
- [ ] **Step 4: Run test, verify it passes**
|
||||
|
||||
Run: `node app/node_modules/vitest/vitest.mjs --config app/vitest.config.tools.mjs run brain-dashboard-core`
|
||||
Run: `cd app && node node_modules/vitest/vitest.mjs --config vitest.config.tools.mjs run brain-dashboard-core`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Add the Разбор panel markup**
|
||||
@@ -1148,7 +1148,7 @@ describe('aggregate', () => {
|
||||
|
||||
- [ ] **Step 2: Run tests, verify they fail.**
|
||||
|
||||
Run: `node app/node_modules/vitest/vitest.mjs --config app/vitest.config.tools.mjs run brain-dashboard-core` — FAIL (`aggregate` not exported).
|
||||
Run: `cd app && node node_modules/vitest/vitest.mjs --config vitest.config.tools.mjs run brain-dashboard-core` — FAIL (`aggregate` not exported).
|
||||
|
||||
- [ ] **Step 3: Implement `aggregate`**
|
||||
|
||||
@@ -1413,7 +1413,7 @@ In `docs/observer/README.md`, under `## Files`, add a bullet:
|
||||
|
||||
- [ ] **Step 4: Run the full tools test suite**
|
||||
|
||||
Run: `node app/node_modules/vitest/vitest.mjs --config app/vitest.config.tools.mjs run`
|
||||
Run: `cd app && node node_modules/vitest/vitest.mjs --config vitest.config.tools.mjs run`
|
||||
Expected: all `brain-dashboard-*` suites PASS alongside the existing observer suites; 0 failures.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,402 @@
|
||||
# Карта узлов iter9 — подсистема brain governance — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Синхронизировать «карту узлов» (`docs/automation-graph.html` + `docs/automation-graph-data.js`) с подсистемой brain governance (ADR-011, 19.05.2026): +9 узлов, +12 рёбер, +1 GREEN-конфликт, +9 паспортов/edge-details/NODE_META, версии-метки ×4 — iter9.
|
||||
|
||||
**Architecture:** Подход A (subsystem-level) — новые узлы в их естественных функциональных группах. data.js — общий: правка кормит и `docs/observer/dashboard.html`. Верификация — рендером (нет unit-тестов у статической data-viz).
|
||||
|
||||
**Tech Stack:** vis-network 9.x, классический `<script>` (без ES-модулей в data.js), Node (sanity `node --check`), Playwright MCP (рендер-верификация), lefthook pre-commit.
|
||||
|
||||
**Спек:** `docs/superpowers/specs/2026-05-20-automation-map-iter9-brain-governance-design.md`
|
||||
**Ветка:** `feat/automation-map-iter9` (уже создана; на ней лежит спек).
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- **`docs/automation-graph-data.js`** (общий источник топологии): `NODES` (+9), версии-метки ×4, `EDGES` (+12), `CONFLICT` (+1 GREEN), `NODE_SECTION` (+9), count-комментарии, фикс шапки (строка 5).
|
||||
- **`docs/automation-graph.html`** (паспорта/мета/детали): `NODE_DETAILS` (+9), `EDGE_DETAILS` (+12), `NODE_META` (+9 + bump `META_SNAPSHOT`/`META_WINDOW` + `changed` ×4).
|
||||
- НЕ трогаем: `docs/observer/dashboard.html` (кормится из data.js), нормативку, `GROUPS`/`CATEGORY_LABELS` (новых групп нет).
|
||||
|
||||
**9 новых узлов** (id → group → ring·angle): `router_procedure`→rules·1·210; `observer_stophook`→hooks·4·205; `sk_brain_retro`→skills_proj·3·210; `observer_evidence`→memory·6·204; `lh_l1watcher`→lefthook·5·150; `lh_crossref`→lefthook·5·157; `lh_obs_obs`→lefthook·5·164; `lh_status_md`→lefthook·5·171; `lh_obs_cov`→lefthook·5·178.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: data.js — NODES (+9), версии-метки (×4), count-комментарии, фикс шапки
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `docs/automation-graph-data.js`
|
||||
|
||||
- [ ] **Step 1: Фикс шапки (строка 5) — устаревшее имя дашборда**
|
||||
|
||||
Заменить:
|
||||
|
||||
```js
|
||||
// • docs/brain-dashboard.html (classic <script>, same mechanism)
|
||||
```
|
||||
|
||||
на:
|
||||
|
||||
```js
|
||||
// • docs/observer/dashboard.html (classic <script>, same mechanism)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Версии-метки 4 узлов-правил**
|
||||
|
||||
В блоке `// ── ПРАВИЛА (4) ──` заменить лейблы:
|
||||
|
||||
- `label: 'Pravila v1.29'` → `label: 'Pravila v1.33'`
|
||||
- `label: 'CLAUDE.md v2.16'` → `label: 'CLAUDE.md v2.20'`
|
||||
- `label: 'PSR_v1 v3.14'` → `label: 'PSR_v1 v3.17'`
|
||||
- `label: 'Tooling v2.15'` → `label: 'Tooling v2.17'`
|
||||
|
||||
- [ ] **Step 3: +router_procedure (rules)**
|
||||
|
||||
Заменить заголовок блока `// ── ПРАВИЛА (4) ── центр + первое кольцо ───────` → `// ── ПРАВИЛА (5) ── центр + первое кольцо ───────`.
|
||||
После строки `tooling` (`{ id: 'tooling', ... ...pos(1, 270) },`) добавить:
|
||||
|
||||
```js
|
||||
{ id: 'router_procedure', label: 'router-procedure v1.0', group: 'rules', size: 24, ring: 1, ...pos(1, 210) },
|
||||
```
|
||||
|
||||
- [ ] **Step 4: +observer_stophook (hooks)**
|
||||
|
||||
Заменить заголовок `// ── ХУКИ (12) — S+infra + E (economy/skill) ───` → `// ── ХУКИ (13) — S+infra + E (economy/skill/brain) ───`.
|
||||
После строки `hk_ruflo_queen` добавить:
|
||||
|
||||
```js
|
||||
{ id: 'observer_stophook', label: 'Stop:\nobserver-stop-hook', group: 'hooks', size: 22, ring: 4, ...pos(4, 205) },
|
||||
```
|
||||
|
||||
- [ ] **Step 5: +sk_brain_retro (skills_proj)**
|
||||
|
||||
После строки `discovery_interview` (`{ id: 'discovery_interview', ... ...pos(3, 387) },`) добавить:
|
||||
|
||||
```js
|
||||
// brain governance iter9 (19.05.2026) — проектный скил факторного анализа
|
||||
{ id: 'sk_brain_retro', label: '/brain-retro\n(skill)', group: 'skills_proj', size: 18, ring: 3, ...pos(3, 210) },
|
||||
```
|
||||
|
||||
- [ ] **Step 6: +observer_evidence (memory)**
|
||||
|
||||
Заменить заголовок `// ── MEMORY FILES (23) — внешнее кольцо ──────────` → `// ── MEMORY FILES (24) — внешнее кольцо ──────────`.
|
||||
После строки `mem_sprint3` (`{ id: 'mem_sprint3', ... ...pos(6, 180) },`) добавить:
|
||||
|
||||
```js
|
||||
// brain governance iter9 (19.05.2026) — хранилище evidence «мозга»
|
||||
{ id: 'observer_evidence', label: 'docs/observer/\nepisodes+STATUS', group: 'memory', size: 16, ring: 6, ...pos(6, 204) },
|
||||
```
|
||||
|
||||
- [ ] **Step 7: +5 контролёров (lefthook)**
|
||||
|
||||
Заменить заголовок `// ── LEFTHOOK JOBS (10) — S+W (infra/data) ─────` → `// ── LEFTHOOK JOBS (15) — S+W (infra/data/brain) ─────`.
|
||||
После строки `lh_squawk` (`{ id: 'lh_squawk', ... ...pos(5, 320) },`) добавить:
|
||||
|
||||
```js
|
||||
// brain governance iter9 (19.05.2026) — 5 контролёров C1-C5 (lefthook jobs 11-15)
|
||||
{ id: 'lh_l1watcher', label: 'lefthook:\nl1-watcher (C1)', group: 'lefthook', size: 16, ring: 5, ...pos(5, 150) },
|
||||
{ id: 'lh_crossref', label: 'lefthook:\ncross-ref-checker (C2)', group: 'lefthook', size: 16, ring: 5, ...pos(5, 157) },
|
||||
{ id: 'lh_obs_obs', label: 'lefthook:\nobserver-of-observer (C3)',group: 'lefthook', size: 16, ring: 5, ...pos(5, 164) },
|
||||
{ id: 'lh_status_md', label: 'lefthook:\nstatus-md (C4)', group: 'lefthook', size: 16, ring: 5, ...pos(5, 171) },
|
||||
{ id: 'lh_obs_cov', label: 'lefthook:\nobserver-coverage (C5)', group: 'lefthook', size: 16, ring: 5, ...pos(5, 178) },
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Sanity — синтаксис data.js**
|
||||
|
||||
Run: `cd "c:/моя/проекты/портал crm/Документация" && node --check docs/automation-graph-data.js && echo OK`
|
||||
Expected: `OK` (нет SyntaxError).
|
||||
|
||||
---
|
||||
|
||||
### Task 2: data.js — EDGES (+12) + CONFLICT (+1 GREEN)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `docs/automation-graph-data.js`
|
||||
|
||||
- [ ] **Step 1: +12 рёбер**
|
||||
|
||||
Перед строкой-комментарием `// ══════════════════════════════════════════════════` (которая начинает блок `// КОНФЛИКТЫ — 3-color classification (iter2 §4)`) добавить:
|
||||
|
||||
```js
|
||||
// ── BRAIN GOVERNANCE iter9 (19.05.2026, ADR-011) — связи 9 новых узлов ──
|
||||
E('claude_md', 'router_procedure', '§3.6: SoT\nпроцедуры роутера'),
|
||||
E('tooling', 'router_procedure', '§4.X реестр →\nшаг 3 роутера'),
|
||||
E('pravila', 'router_procedure', '§12/§14/§15\nhard-floor'),
|
||||
E('pravila', 'observer_stophook', '§16: observer\n+ routing-тег'),
|
||||
E('observer_stophook', 'observer_evidence', 'пишет эпизоды\n+ routing-gate'),
|
||||
E('pravila', 'sk_brain_retro', '§16: факторный\nанализ раз в спринт'),
|
||||
E('sk_brain_retro', 'observer_evidence', 'читает эпизоды\n(факторный анализ)'),
|
||||
E('lh_l1watcher', 'tooling', 'C1 STRICT: settings.json\n↔ Tooling drift'),
|
||||
E('lh_crossref', 'claude_md', 'C2 STRICT: version\ndrift §0 cross-refs'),
|
||||
E('lh_obs_obs', 'observer_evidence', 'C3 warn: счётчик\n+54w self-prune'),
|
||||
E('lh_status_md', 'observer_evidence', 'C4: генерит\nSTATUS.md'),
|
||||
E('lh_obs_cov', 'observer_evidence', 'C5 warn: покрытие\n+ регистрация'),
|
||||
|
||||
```
|
||||
|
||||
- [ ] **Step 2: +1 GREEN-конфликт**
|
||||
|
||||
После строки `CONFLICT('hk_economy', 'superpowers', '§12 — hard-rule уровня 0; economy-режим §12 не отменяет (Pravila §12.4)', 'GREEN'),` добавить:
|
||||
|
||||
```js
|
||||
CONFLICT('observer_stophook', 'hk_verifier', 'HK1 §5.3: оба на Stop-event — коллизии нет (append-chain). Оба способны decision:block; Claude Code прогоняет все Stop-хуки, любой block ⇒ продолжение хода. observer-gate детерминированный и дешёвый.', 'GREEN'),
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Sanity**
|
||||
|
||||
Run: `cd "c:/моя/проекты/портал crm/Документация" && node --check docs/automation-graph-data.js && echo OK`
|
||||
Expected: `OK`.
|
||||
|
||||
---
|
||||
|
||||
### Task 3: data.js — NODE_SECTION (+9) + coverage-комментарий
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `docs/automation-graph-data.js`
|
||||
|
||||
- [ ] **Step 1: Обновить coverage-комментарий**
|
||||
|
||||
Заменить `// Узел -> раздел. Покрывает все 125 узлов карты.` → `// Узел -> раздел. Покрывает все 134 узла карты.`
|
||||
|
||||
- [ ] **Step 2: +9 записей NODE_SECTION**
|
||||
|
||||
После строки `discovery_interview: 'E5',` (последняя перед закрывающей `};` объекта `NODE_SECTION`) добавить:
|
||||
|
||||
```js
|
||||
// brain governance iter9 19.05.2026 — ADR-011 подсистема
|
||||
router_procedure: 'E1', observer_stophook: 'E2', sk_brain_retro: 'E8', observer_evidence: 'E4',
|
||||
lh_l1watcher: 'E1', lh_crossref: 'E1', lh_obs_obs: 'E2', lh_status_md: 'E2', lh_obs_cov: 'E2',
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Sanity**
|
||||
|
||||
Run: `cd "c:/моя/проекты/портал crm/Документация" && node --check docs/automation-graph-data.js && echo OK`
|
||||
Expected: `OK`.
|
||||
|
||||
---
|
||||
|
||||
### Task 4: html — NODE_DETAILS (+9 паспортов)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `docs/automation-graph.html`
|
||||
|
||||
- [ ] **Step 1: Вставить 9 паспортов**
|
||||
|
||||
В объекте `NODE_DETAILS` (в `<script>` файла `automation-graph.html`) перед его закрывающей `};` (сразу после записи `mem_sprint3: nd(...)`) добавить блок:
|
||||
|
||||
```js
|
||||
// ── BRAIN GOVERNANCE iter9 (19.05.2026, ADR-011) ──
|
||||
router_procedure: nd(
|
||||
'Единый источник истины процедуры роутера «задача → узел(ы)» — docs/router-procedure.md v1.0. 5 шагов: hard-floor (§12/§14/§15) → классификация → выбор по триггерам (Tooling Прил. Н §4.X) → проверка связок L1-L12 → исполнение. ADR-011.',
|
||||
'При любой задаче (имплицитно) определяет узел/связку; явно — при разборе routing-решений и в /brain-retro.',
|
||||
'Не вводит новый реестр — формализует процедуру над существующим (Tooling §4.X). Кэша «проверенных цепочек» нет (router-only). Каждая задача — свежая сборка пути.',
|
||||
[{ name: 'Pravila §12/§14/§15', cond: 'hard-floor — шаг 1 процедуры' }, { name: 'CLAUDE.md §3.6', cond: 'cross-ref на router-procedure.md' }],
|
||||
[{ name: 'Tooling Прил. Н §4.X', cond: 'реестр узлов — вход шага 3' }],
|
||||
[{ name: 'observer (Stop-хук)', cond: 'пишет evidence о routing-решениях' }, { name: '/brain-retro', cond: 'факторный анализ routing' }],
|
||||
[]
|
||||
),
|
||||
observer_stophook: nd(
|
||||
'Stop-хук observer (tools/observer-stop-hook.mjs, project-level) — пишет один JSONL-эпизод в docs/observer/episodes-YYYY-MM.jsonl в конце каждого хода + routing-gate. Внутри: transcript-parser (схема v2), routing-detector + choice-detector (provenance), pii-filter (маскирование ПДн). ADR-011 + observer factor-analysis.',
|
||||
'Конец каждого хода (Stop-event). routing-gate: при навязанном методе без routing-тега → decision:block (необойдёмо).',
|
||||
'Только пишет evidence, не вмешивается в нормативку. При внутреннем отказе — маркер observer_error, не тихий пропуск. HK1 §5.3: сосуществует с economy-verifier на Stop (append-chain).',
|
||||
[{ name: 'Pravila §16', cond: 'observer + routing-тег-дисциплина' }, { name: '.claude/settings.json', cond: 'зарегистрирован как Stop-хук' }],
|
||||
[{ name: 'observer-transcript-parser / routing-detector / choice-detector / pii-filter', cond: 'внутренние .mjs модули' }],
|
||||
[{ name: 'docs/observer/ evidence', cond: 'пишет эпизоды' }, { name: '/brain-retro', cond: 'читает то, что хук пишет' }],
|
||||
[{ name: 'hk_verifier', desc: 'HK1 §5.3: оба на Stop-event — коллизии нет (append-chain), оба decision:block отрабатываются', type: 'GREEN' }]
|
||||
),
|
||||
sk_brain_retro: nd(
|
||||
'Проектный скил /brain-retro (.claude/skills/brain-retro/) — раз в спринт читает docs/observer/episodes-*.jsonl и строит факторный анализ: распределение path_type, топ-узлы/связки, вывод исхода, факторная матрица (9 осей × outcome). Анализатор tools/brain-retro-analyzer.mjs.',
|
||||
'Раз в спринт по команде заказчика («брейн-ретро»). Read-only агрегатор.',
|
||||
'Только читает и предлагает кандидатов на корректировку нормативки — не пишет в логи, не правит Tooling/Pravila/PSR_v1. Решение по правкам — за заказчиком.',
|
||||
[{ name: 'Pravila §16', cond: 'evidence-loop, раз в спринт' }, { name: 'PSR_v1 R16', cond: 'brain evidence loop' }],
|
||||
[{ name: 'tools/brain-retro-analyzer.mjs', cond: 'детерминированный анализатор' }],
|
||||
[{ name: 'docs/observer/ evidence', cond: 'читает эпизоды' }],
|
||||
[]
|
||||
),
|
||||
observer_evidence: nd(
|
||||
'Хранилище evidence «мозга» — docs/observer/: помесячные episodes-YYYY-MM.jsonl (схема v2), STATUS.md (панель C1-C5), .read-counter.json (для C3), notes/. Визуализируется страницей docs/observer/dashboard.html (Карта/Лента/Разбор/Агрегат/конфликты; кормится из общего automation-graph-data.js).',
|
||||
'Пишется Stop-хуком (эпизоды) + контролёрами (STATUS.md, счётчик); читается /brain-retro и dashboard.',
|
||||
'ПДн маскируется pii-filter перед записью (§5.4). Помесячное rotation; архив после 12 месяцев. Память ruflo (.swarm/memory.db) — отдельное хранилище, не связано.',
|
||||
[{ name: 'observer Stop-хук', cond: 'источник эпизодов' }],
|
||||
[],
|
||||
[{ name: '/brain-retro', cond: 'читатель' }, { name: 'C3/C4/C5 контролёры', cond: 'счётчик / STATUS / покрытие' }],
|
||||
[]
|
||||
),
|
||||
lh_l1watcher: nd(
|
||||
'Контролёр C1 (lefthook pre-commit job 11, tools/l1-watcher.mjs) — детектор «плагин включён в settings.json без формализации в Tooling Прил. Н». Закрывает трижды повторившийся L1-паттерн (UPM/21st, Sentry/Redis, Anthropic dev-tooling). 0 LLM-вызовов.',
|
||||
'pre-commit при правке .claude/settings.json или docs/Tooling_v8_3.md.',
|
||||
'STRICT: блокирует коммит при drift. Групповые/human-имена разрешаются через tools/.l1-watcher-aliases.txt. ADR-011 spec §6.1.',
|
||||
[{ name: 'lefthook.yml', cond: 'job 11 pre-commit' }, { name: 'ADR-011 §6.1', cond: 'C1' }],
|
||||
[],
|
||||
[{ name: 'tooling', cond: 'сверяет settings.json ↔ Tooling' }, { name: 'C2 cross-ref', cond: 'оба — нормативная консистентность' }],
|
||||
[]
|
||||
),
|
||||
lh_crossref: nd(
|
||||
'Контролёр C2 (lefthook pre-commit job 12, tools/cross-ref-checker.mjs) — детектор version drift между нормативными файлами (Tooling v2.11 collision 17.05). Сверяет версии в §0 cross-refs vs шапки целевых файлов. 0 LLM-вызовов.',
|
||||
'pre-commit при правке Pravila / Tooling / PSR_v1 / CLAUDE.md / MEMORY.md.',
|
||||
'STRICT: блокирует коммит при расхождении версии. Link-anchored детекция + scope-cut по history-маркерам (исторические «наследие»-цепочки не дают ложных срабатываний). ADR-011 spec §6.2.',
|
||||
[{ name: 'lefthook.yml', cond: 'job 12 pre-commit' }, { name: 'ADR-011 §6.2', cond: 'C2' }],
|
||||
[],
|
||||
[{ name: 'claude_md / pravila / tooling / psr_v1', cond: 'сверяет 5 нормативных файлов' }, { name: 'C1 l1-watcher', cond: 'оба — нормативная консистентность' }],
|
||||
[]
|
||||
),
|
||||
lh_obs_obs: nd(
|
||||
'Контролёр C3 (lefthook pre-commit job 13, tools/observer-of-observer.mjs) — счётчик чтений docs/observer/ + 54-недельный self-prune. «Кто наблюдает за наблюдателями»: если evidence-loop не читается ≥54 недель — предлагает архивировать observer.',
|
||||
'pre-commit (каждый коммит) — обновляет/проверяет docs/observer/.read-counter.json.',
|
||||
'Warn-only (скрипт всегда exit 0) — не блокирует. 54 недели (≈год) — порог осознанно поднят заказчиком с 4 недель. ADR-011 spec §6.3.',
|
||||
[{ name: 'lefthook.yml', cond: 'job 13 pre-commit' }, { name: 'ADR-011 §6.3', cond: 'C3' }],
|
||||
[],
|
||||
[{ name: 'docs/observer/ evidence', cond: 'читает .read-counter.json' }],
|
||||
[]
|
||||
),
|
||||
lh_status_md: nd(
|
||||
'Контролёр C4 (lefthook post-commit job, tools/status-md-generator.mjs) — генерит docs/observer/STATUS.md (панель: C1-C5 + информационные метрики). Pure JS, Security Guidance #40 compliant.',
|
||||
'post-commit (после каждого коммита) — перегенерит STATUS.md, git add (для следующего коммита).',
|
||||
'Через `|| true` — не блокирует. Метрика «N раз использован» — информационная, не алерт (capability-readiness). ADR-011 spec §6.4.',
|
||||
[{ name: 'lefthook.yml', cond: 'post-commit job' }, { name: 'ADR-011 §6.4', cond: 'C4' }],
|
||||
[],
|
||||
[{ name: 'docs/observer/ evidence', cond: 'пишет STATUS.md' }, { name: 'C1/C2/C3', cond: 'агрегирует их сигнал' }],
|
||||
[]
|
||||
),
|
||||
lh_obs_cov: nd(
|
||||
'Контролёр C5 (lefthook pre-commit job 15, tools/observer-coverage-checker.mjs) — observer factor-analysis spec §5.2. Флагует пропуски покрытия (git-активность есть, эпизодов 0) + поломки регистрации (Stop-хук снят из settings.json, post-commit не установлен).',
|
||||
'pre-commit (каждый коммит).',
|
||||
'Warn-only (скрипт всегда exit 0) — не блокирует; находки в docs/observer/STATUS.md строка C5.',
|
||||
[{ name: 'lefthook.yml', cond: 'job 15 pre-commit' }, { name: 'observer factor-analysis §5.2', cond: 'C5' }],
|
||||
[],
|
||||
[{ name: 'docs/observer/ evidence', cond: 'проверяет покрытие + регистрацию' }, { name: 'C4 status-md', cond: 'находки в STATUS.md' }],
|
||||
[]
|
||||
),
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: html — EDGE_DETAILS (+12)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `docs/automation-graph.html`
|
||||
|
||||
- [ ] **Step 1: Вставить 12 edge-details**
|
||||
|
||||
В объекте `EDGE_DETAILS` перед его закрывающей `};` (после последней ruflo-записи `'ruflo_daemon->ag_pest': { ... }`) добавить:
|
||||
|
||||
```js
|
||||
// ── BRAIN GOVERNANCE iter9 (19.05.2026, ADR-011) ──
|
||||
'claude_md->router_procedure': { type: 'документирует', when: 'CLAUDE.md §3.6 — cross-ref на router-procedure.md v1.0', transfers: 'документация', mandatory: 'обязательно', rule: 'CLAUDE.md §3.6 (single SoT routing procedure)' },
|
||||
'tooling->router_procedure': { type: 'питает', when: 'реестр Прил. Н §4.X — вход шага 3 процедуры роутера', transfers: 'данные', mandatory: 'обязательно', rule: 'router-procedure.md §4.2 шаг 3' },
|
||||
'pravila->router_procedure': { type: 'подчиняет', when: 'hard-floor §12/§14/§15 — шаг 1 процедуры роутера', transfers: 'контроль', mandatory: 'hard-floor', rule: 'router-procedure.md §4.2 шаг 1 (Pravila §12/§14/§15)' },
|
||||
'pravila->observer_stophook': { type: 'подчиняет', when: '§16: observer + routing-тег-дисциплина', transfers: 'контроль', mandatory: 'обязательно', rule: 'Pravila §16.2/§16.7 (ADR-011)' },
|
||||
'observer_stophook->observer_evidence': { type: 'пишет', when: 'конец каждого хода (Stop-event)', transfers: 'данные (эпизод JSONL)', mandatory: 'обязательно (exit-0-safe)', rule: 'ADR-011 §5.2 (observer scope B)' },
|
||||
'pravila->sk_brain_retro': { type: 'подчиняет', when: '§16: факторный анализ раз в спринт', transfers: 'контроль', mandatory: 'по команде заказчика', rule: 'Pravila §16 + PSR_v1 R16' },
|
||||
'sk_brain_retro->observer_evidence': { type: 'читает', when: 'раз в спринт — агрегирует эпизоды', transfers: 'данные', mandatory: 'read-only', rule: 'ADR-011 §5.5 (/brain-retro — читатель)' },
|
||||
'lh_l1watcher->tooling': { type: 'проверяет', when: 'pre-commit при правке settings.json / Tooling', transfers: 'проверка', mandatory: 'STRICT (блокирует)', rule: 'ADR-011 §6.1 (C1) + lefthook.yml job 11' },
|
||||
'lh_crossref->claude_md': { type: 'проверяет', when: 'pre-commit при правке любого из 5 нормативных файлов', transfers: 'проверка', mandatory: 'STRICT (блокирует)', rule: 'ADR-011 §6.2 (C2) + lefthook.yml job 12' },
|
||||
'lh_obs_obs->observer_evidence': { type: 'проверяет', when: 'pre-commit (каждый коммит) — счётчик чтений', transfers: 'проверка', mandatory: 'warn-only', rule: 'ADR-011 §6.3 (C3) + lefthook.yml job 13' },
|
||||
'lh_status_md->observer_evidence': { type: 'пишет', when: 'post-commit — перегенерит STATUS.md', transfers: 'данные', mandatory: 'не блокирует (|| true)', rule: 'ADR-011 §6.4 (C4) + lefthook.yml post-commit' },
|
||||
'lh_obs_cov->observer_evidence': { type: 'проверяет', when: 'pre-commit (каждый коммит) — покрытие + регистрация', transfers: 'проверка', mandatory: 'warn-only', rule: 'observer factor-analysis §5.2 (C5) + lefthook.yml job 15' },
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: html — NODE_META (+9 + bump снимка/окна + changed ×4)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `docs/automation-graph.html`
|
||||
|
||||
- [ ] **Step 1: Bump снимка и окна**
|
||||
|
||||
- `const META_SNAPSHOT = '18.05.2026';` → `const META_SNAPSHOT = '20.05.2026';`
|
||||
- `const META_WINDOW = '09–18.05.2026';` → `const META_WINDOW = '09–20.05.2026';`
|
||||
|
||||
- [ ] **Step 2: changed ×4 на узлах-правилах**
|
||||
|
||||
В блоке `// ── ПРАВИЛА (4) ──` объекта `NODE_META` заменить `changed: '18.05.2026'` → `changed: '19.05.2026'` у `pravila`, `claude_md`, `psr_v1`, `tooling` (4 строки; остальные поля без изменений).
|
||||
|
||||
- [ ] **Step 3: +9 записей NODE_META**
|
||||
|
||||
Перед закрывающей `};` объекта `NODE_META` (после блока discovery-tooling `discovery_interview: { ... }`) добавить:
|
||||
|
||||
```js
|
||||
// ── BRAIN GOVERNANCE iter9 (19.05.2026, ADR-011) ──
|
||||
// uses: observer_stophook=31 эпизодов; lh_obs_obs/status_md/obs_cov=112 коммитов с 19.05
|
||||
// (glob-less, каждый коммит); lh_l1watcher=10, lh_crossref=13 (коммиты по glob с 19.05);
|
||||
// observer_evidence=0 (.read-counter.json — 0 чтений); router_procedure=null (rule-like).
|
||||
router_procedure: { since: '19.05.2026', changed: '—', uses: null, usesSrc: '—' },
|
||||
observer_stophook: { since: '19.05.2026', changed: '—', uses: 31, usesSrc: 'хук (эпизоды)' },
|
||||
sk_brain_retro: { since: '19.05.2026', changed: '—', uses: 1, usesSrc: 'интеграция' },
|
||||
observer_evidence: { since: '19.05.2026', changed: '—', uses: 0, usesSrc: 'observer counter' },
|
||||
lh_l1watcher: { since: '19.05.2026', changed: '—', uses: 10, usesSrc: 'коммиты (с 19.05)' },
|
||||
lh_crossref: { since: '19.05.2026', changed: '—', uses: 13, usesSrc: 'коммиты (с 19.05)' },
|
||||
lh_obs_obs: { since: '19.05.2026', changed: '—', uses: 112, usesSrc: 'коммиты (с 19.05)' },
|
||||
lh_status_md: { since: '19.05.2026', changed: '—', uses: 112, usesSrc: 'коммиты (с 19.05)' },
|
||||
lh_obs_cov: { since: '19.05.2026', changed: '—', uses: 112, usesSrc: 'коммиты (с 19.05)' },
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Верификация (рендер + структурная сверка)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Read-only проверки + 1 PNG-артефакт.
|
||||
|
||||
- [ ] **Step 1: Синтаксис data.js**
|
||||
|
||||
Run: `cd "c:/моя/проекты/портал crm/Документация" && node --check docs/automation-graph-data.js && echo OK`
|
||||
Expected: `OK`.
|
||||
|
||||
- [ ] **Step 2: Структурная сверка счётчиков (без исполнения data.js — только regex по тексту)**
|
||||
|
||||
Run (число определений узлов = вхождения `{ id: '` в тексте файла; ожидаем 134):
|
||||
|
||||
```bash
|
||||
cd "c:/моя/проекты/портал crm/Документация"
|
||||
node -e "const s=require('fs').readFileSync('docs/automation-graph-data.js','utf8');console.log('node defs:',(s.match(/\{ id: '/g)||[]).length);"
|
||||
```
|
||||
|
||||
Expected: `node defs: 134`. Полнота покрытия NODE_SECTION гарантируется Task 3 (добавлены все 9 id) и подтверждается рендером Step 3 (паспорт показывает раздел). `vm`/`eval` не используем — Security Guidance #40.
|
||||
|
||||
- [ ] **Step 3: Рендер карты — Playwright MCP**
|
||||
|
||||
`browser_navigate` на `file:///c:/моя/проекты/портал crm/Документация/docs/automation-graph.html` → `browser_console_messages` (0 errors) → `browser_snapshot`. Проверить: граф отрисован; поиск «observer-stop-hook», «brain-retro», «router-procedure», «l1-watcher» находит узлы; клик по `observer_stophook` открывает паспорт с GREEN-конфликтом; клик по ребру `observer_stophook→observer_evidence` открывает edge-details. Визуально убедиться в отсутствии наложений 9 новых узлов на соседей.
|
||||
|
||||
- [ ] **Step 4: Рендер дашборда (тот же data.js)**
|
||||
|
||||
`browser_navigate` на `file:///c:/моя/проекты/портал crm/Документация/docs/observer/dashboard.html` → `browser_console_messages` (0 errors) → его Карта-view содержит новые узлы (кормится из общего AGD). Если dashboard падает из file:// (относительные пути / fetch) — зафиксировать как ограничение, не блокер: data.js валиден (Step 1-2).
|
||||
|
||||
- [ ] **Step 5: Smoke-PNG**
|
||||
|
||||
`browser_take_screenshot` карты → сохранить как `automation-graph-smoke.png` в корне (перезаписать существующий).
|
||||
|
||||
- [ ] **Step 6: Финальный коммит (один логический change = iter9)**
|
||||
|
||||
Перед коммитом pre-commit прогонится автоматически (stylelint на .html — CSS не трогали; gitleaks protect --staged; markdownlint/cspell — .md не затронут; cross-ref-checker/l1-watcher — glob не на эти файлы). Стейджить только 3 файла явными путями:
|
||||
|
||||
```bash
|
||||
cd "c:/моя/проекты/портал crm/Документация"
|
||||
git add docs/automation-graph-data.js docs/automation-graph.html automation-graph-smoke.png
|
||||
git commit -m "feat(map): iter9 — brain governance subsystem (+9 nodes, +12 edges, +1 GREEN)" -m "Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>" -- docs/automation-graph-data.js docs/automation-graph.html automation-graph-smoke.png
|
||||
```
|
||||
|
||||
Expected: pre-commit зелёный; коммит создан на `feat/automation-map-iter9`.
|
||||
|
||||
- [ ] **Step 7: verification-before-completion**
|
||||
|
||||
Invoke `superpowers:verification-before-completion` — подтвердить фактическим выводом: node --check OK, node defs=134, рендер без console-errors + 4 узла находятся + паспорт/edge открываются, коммит создан. Только после этого — claim «iter9 готов».
|
||||
|
||||
---
|
||||
|
||||
## Self-Review (выполнено при написании плана)
|
||||
|
||||
**1. Spec coverage:** §3 (9 узлов) → Task 1; §4 (версии-метки + NODE_META) → Task 1 Step 2 + Task 6; §5 (12 рёбер) → Task 2 Step 1; §6 (1 GREEN) → Task 2 Step 2; §7 (секции+счётчики) → Task 3 + count-комментарии в Task 1; §8 паспорта/детали/верификация → Tasks 4/5/7; §9 вне scope (нормативка не трогается) — соблюдено; §10 файлы → File Structure. Пробелов нет.
|
||||
|
||||
**2. Placeholder scan:** все uses-числа конкретны (31/112/10/13/0/null/1); паспорта и edge-details — полный текст; команды точные. Нет TBD/TODO. eval() убран (Security Guidance #40).
|
||||
|
||||
**3. Type consistency:** id узлов в NODES (Task 1) ↔ NODE_SECTION (Task 3) ↔ NODE_DETAILS (Task 4) ↔ NODE_META (Task 6) ↔ EDGES from/to (Task 2) ↔ EDGE_DETAILS ключи `from->to` (Task 5) — сверены, совпадают (router_procedure, observer_stophook, sk_brain_retro, observer_evidence, lh_l1watcher, lh_crossref, lh_obs_obs, lh_status_md, lh_obs_cov). GREEN-конфликт `observer_stophook↔hk_verifier` — `hk_verifier` существует в карте (узел economy-verifier).
|
||||
@@ -125,10 +125,10 @@ Stop-событие сейчас несёт: user-level economy-verifier (agent)
|
||||
|
||||
`/brain-retro` остаётся read-only агрегатором (не мутирует JSONL). Расширения:
|
||||
|
||||
- **Вывод настоящего исхода.** Для каждого эпизода смотрит первый user-prompt **следующего** эпизода той же сессии: коррекция (`не то` / `не так` / `переделай` / `отбой` / `стоп` / `почему ты`) → исход прошлого = `rework`; одобрение/новая задача (`ок` / `спасибо` / `дальше` / `готово`) → `success`; `interrupt`-событие → `partial`; нет следующего → `unknown`. Уточнённый исход — в retro-ноте (JSONL не трогается, append-only).
|
||||
- **Вывод настоящего исхода.** Для каждого эпизода: `interrupt`-событие → `partial`; больше `error`-событий, чем `retry` (невосстановленный сбой) → `blocked`; иначе — по первому user-prompt **следующего** эпизода той же сессии: коррекция (`не то` / `не так` / `переделай` / `откати` / `сломал` / `не работает` / `revert` / … — расширенный набор) → исход прошлого = `rework`; одобрение/новая задача (`ок` / `спасибо` / `дальше` / `готово`) → `success`; нет следующего → `unknown`. `failure` детерминированно невосстановим (суждение «работа неверна И не исправлена») — отложен в фазу 2 (agent-судья). Уточнённый исход — в retro-ноте (JSONL не трогается, append-only).
|
||||
- **Группировка «эпизоды → задача».** `task_ref` по sessionId; сегментация — новая задача начинается с top-level user-prompt после `success` или после паузы.
|
||||
- **Каузальные цепочки.** Детерминированная корреляция: эпизоды, делящие `files_touched`; `error` в N → исправление того же файла в N+1. Surface как «кандидаты цепочек».
|
||||
- **Факторная матрица.** Строки — факторы (`decision_provenance.kind`, `economy_level`, `model`, `post_compaction`, бакет `task_size`, `node_chosen`, `task_classification`); столбцы — распределение `outcome`. Пример вывода: «`user_directed_method`: 40% rework против `autonomous` 12%» — прямой ответ на вопрос заказчика «моя ли вина».
|
||||
- **Факторная матрица.** Строки — факторы (`decision_provenance.kind`, `economy_level`, `model`, `post_compaction`, бакет `session_turn`, `parallel_session`, бакет `task_size`, `node_chosen`, `task_classification` — 9 осей); столбцы — распределение `outcome`. Пример вывода: «`user_directed_method`: 40% rework против `autonomous` 12%» — прямой ответ на вопрос заказчика «моя ли вина».
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
# Карта узлов iter9 — подсистема brain governance — design
|
||||
|
||||
**Версия:** 1.0
|
||||
**Дата:** 2026-05-20
|
||||
**Статус:** design (approved by user «рк» 2026-05-20) → awaiting written-spec review → writing-plans
|
||||
**Автор:** Дмитрий (заказчик) + Claude (Opus 4.7) via superpowers:brainstorming
|
||||
**Связано:** `docs/automation-graph.html`, `docs/automation-graph-data.js`, `docs/brain-dashboard.html`,
|
||||
ADR-011, spec `2026-05-19-brain-governance-design.md`, spec `2026-05-19-observer-factor-analysis-design.md`,
|
||||
`lefthook.yml`, memory `project_automation_map.md`
|
||||
|
||||
---
|
||||
|
||||
## 1. Контекст и разрыв
|
||||
|
||||
«Карта» (`docs/automation-graph.html` + общий `docs/automation-graph-data.js`) застыла на iter8
|
||||
(снимок 18.05.2026). 19.05.2026 приехала подсистема **brain governance (ADR-011)** + observer
|
||||
factor-analysis — её на карте нет. data.js — общий: его правка автоматически кормит и
|
||||
`docs/brain-dashboard.html` (показывает ту же карту; сам себя узлом не рисует).
|
||||
|
||||
Разрыв (подтверждён data.js + спеками 19.05 + `lefthook.yml` + git-логом):
|
||||
|
||||
1. **Устаревшие версии в 4 узлах-правилах:** `pravila v1.29→v1.33`, `claude_md v2.16→v2.20`,
|
||||
`psr_v1 v3.14→v3.17`, `tooling v2.15→v2.17`.
|
||||
2. **Подсистема brain governance отсутствует:** `router-procedure.md` v1.0; Stop-хук
|
||||
`observer-stop-hook.mjs` (пишет эпизоды + routing-gate `decision:block`); скил `/brain-retro`;
|
||||
5 контролёров lefthook jobs 11–15 (C1 l1-watcher, C2 cross-ref-checker, C3 observer-of-observer,
|
||||
C4 status-md post-commit, C5 observer-coverage-checker); observer-инфраструктура `docs/observer/`
|
||||
(episodes JSONL, STATUS.md, .read-counter, notes); внутренние `.mjs` (transcript-parser,
|
||||
routing-detector, choice-detector, brain-retro-analyzer).
|
||||
3. **NODE_META** — снимок iter8; новых узлов нет.
|
||||
|
||||
## 2. Решения (согласованы с заказчиком)
|
||||
|
||||
- **Объём:** полный iter9 (а не light-sync).
|
||||
- **Подход:** A — subsystem-level: +9 узлов в их **естественных функциональных группах**
|
||||
(контролёры — с lefthook, Stop-хук — с hooks, скил — с skills_proj), как iter A6/D3/C9.
|
||||
Внутренние `.mjs` и `brain-dashboard.html` — в паспортах, не отдельными узлами
|
||||
(отвергнуты: B fine-grained ~13 узлов; C — отдельный кластер, как ruflo).
|
||||
|
||||
## 3. 9 новых узлов
|
||||
|
||||
| id | label | group | ring·angle | size |
|
||||
|---|---|---|---|---|
|
||||
| `router_procedure` | router-procedure v1.0 | rules | 1·210 | 24 |
|
||||
| `observer_stophook` | Stop: observer-stop-hook | hooks | 4·205 | 22 |
|
||||
| `sk_brain_retro` | /brain-retro (skill) | skills_proj | 3·210 | 18 |
|
||||
| `observer_evidence` | docs/observer/ episodes+STATUS | memory | 6·204 | 16 |
|
||||
| `lh_l1watcher` | lefthook: l1-watcher (C1) | lefthook | 5·150 | 16 |
|
||||
| `lh_crossref` | lefthook: cross-ref-checker (C2) | lefthook | 5·157 | 16 |
|
||||
| `lh_obs_obs` | lefthook: observer-of-observer (C3) | lefthook | 5·164 | 16 |
|
||||
| `lh_status_md` | lefthook: status-md (C4) | lefthook | 5·171 | 16 |
|
||||
| `lh_obs_cov` | lefthook: observer-coverage (C5) | lefthook | 5·178 | 16 |
|
||||
|
||||
Углы выбраны в свободных слотах (≥7° от соседей того же кольца); не-перекрытие проверяется
|
||||
рендером (см. §8). Новых групп/цветов нет — все 9 в существующих `GROUPS`.
|
||||
|
||||
## 4. Версии-метки + NODE_META (iter9)
|
||||
|
||||
- 4 узла-правила: версии-метки → v1.33 / v2.20 / v3.17 / v2.17; их `changed` → `19.05.2026`.
|
||||
- `META_SNAPSHOT '18.05.2026'→'20.05.2026'`; `META_WINDOW '09–18.05.2026'→'09–20.05.2026'`.
|
||||
- **Методология (как iter8, честная):** числа использования **старых** узлов НЕ перемеряются —
|
||||
транскрипты Claude Code недоступны как источник в репо (задокументированный предел iter8);
|
||||
только bump окна/снимка. Новым узлам `since='19.05.2026'`, `changed='—'`, `uses`:
|
||||
- `lh_l1watcher`/`lh_crossref`/`lh_obs_obs`/`lh_status_md`/`lh_obs_cov` — git-commit-count,
|
||||
затрагивающий их триггер-glob в окне (`usesSrc:'коммиты'`), подсчитывается при реализации.
|
||||
- `observer_stophook` — число строк-эпизодов в `docs/observer/episodes-2026-05.jsonl`
|
||||
(`usesSrc:'хук (эпизоды)'`), подсчитывается при реализации.
|
||||
- `observer_evidence` — значение из `.read-counter.json`, иначе baseline 1 (`usesSrc:'observer counter'`).
|
||||
- `sk_brain_retro` — baseline 1 (`usesSrc:'интеграция'`).
|
||||
- `router_procedure` — `uses:null` (rule-like, напрямую не вызывается; `usesSrc:'—'`).
|
||||
|
||||
## 5. 12 новых рёбер (EDGES)
|
||||
|
||||
- `claude_md→router_procedure` (§3.6 SoT), `tooling→router_procedure` (§4.X реестр → шаг 3),
|
||||
`pravila→router_procedure` (§12/§14/§15 hard-floor).
|
||||
- `pravila→observer_stophook` (§16: observer + routing-тег),
|
||||
`observer_stophook→observer_evidence` (пишет эпизоды + routing-gate).
|
||||
- `pravila→sk_brain_retro` (§16: факторный анализ раз в спринт),
|
||||
`sk_brain_retro→observer_evidence` (читает эпизоды).
|
||||
- `lh_l1watcher→tooling` (C1 STRICT: settings.json↔Tooling drift),
|
||||
`lh_crossref→claude_md` (C2 STRICT: version drift §0 cross-refs; репрезентирует 5-файловую проверку),
|
||||
`lh_obs_obs→observer_evidence` (C3 warn: счётчик +54w),
|
||||
`lh_status_md→observer_evidence` (C4: генерит STATUS.md),
|
||||
`lh_obs_cov→observer_evidence` (C5 warn: покрытие + регистрация).
|
||||
|
||||
## 6. 1 новый конфликт (3-color)
|
||||
|
||||
🟢 GREEN `observer_stophook ↔ hk_verifier`: оба на Stop-event; HK1 §5.3 — коллизии нет
|
||||
(append-chain), оба способны `decision:block`, Claude Code прогоняет все Stop-хуки, любой
|
||||
block ⇒ продолжение хода. Новых 🔴/⚫ нет (подсистема спроектирована бесконфликтно).
|
||||
|
||||
## 7. Секции (NODE_SECTION) + счётчики
|
||||
|
||||
`router_procedure→E1`, `observer_stophook→E2`, `sk_brain_retro→E8`, `observer_evidence→E4`,
|
||||
`lh_l1watcher→E1`, `lh_crossref→E1`, `lh_obs_obs→E2`, `lh_status_md→E2`, `lh_obs_cov→E2`.
|
||||
|
||||
Count-комментарии: узлы 125→134; rules 4→5; hooks 11→12; skills_proj 12→13; memory 24→25;
|
||||
lefthook 10→15; «Покрывает все N узлов» 125→134.
|
||||
|
||||
## 8. Паспорта/детали + верификация
|
||||
|
||||
- Каждому из 9 узлов — полный паспорт `nd()` (desc/when/limits/reportsTo/manages/together/conflicts);
|
||||
каждому из 13 рёбер — полный `EDGE_DETAILS` (type/when/transfers/mandatory/rule).
|
||||
Источник фактов — спеки 19.05 + **как-built `lefthook.yml`**: C1/C2 STRICT (блокируют коммит);
|
||||
C3/C5 warn-only (скрипт всегда exit 0); C4 post-commit `|| true`. Внутренние `.mjs` —
|
||||
в desc/limits родителя; `brain-dashboard.html` — в паспорте `observer_evidence`.
|
||||
- **Верификация:** рендер `automation-graph.html` через Playwright MCP (0 JS-ошибок; 9 узлов видны;
|
||||
без перекрытий; паспорта открываются; рёбра + GREEN-конфликт рисуются); `brain-dashboard.html`
|
||||
грузится с теми же данными (его Карта-view показывает новые узлы); sanity `node --check` на data.js;
|
||||
сверка count-комментариев с фактической длиной массивов; обновить smoke-PNG.
|
||||
|
||||
## 9. Вне scope
|
||||
|
||||
- Нормативка (Pravila/PSR_v1/Tooling/CLAUDE.md) — НЕ правится (это задача о карте, не о нормативке).
|
||||
- **Наблюдение (фиксирую, не правлю):** CLAUDE.md v2.18 / memory утверждают «C1+C2 WARN-only via
|
||||
`|| true` jobs 11-14» — расходится с как-built `lefthook.yml` (C1/C2 STRICT). Карта отрисует факт;
|
||||
правка нормативки — отдельная задача через `claude-md-management`.
|
||||
- `brain-dashboard.html` не редактируется (кормится из общего data.js).
|
||||
|
||||
## 10. Файлы
|
||||
|
||||
- `docs/automation-graph-data.js` — NODES (+9, версии-метки ×4), EDGES (+12), CONFLICT (+1 GREEN),
|
||||
NODE_SECTION (+9), count-комментарии.
|
||||
- `docs/automation-graph.html` — NODE_DETAILS (+9), EDGE_DETAILS (+13), NODE_META (+9 + bump
|
||||
снимка/окна + `changed` на 4 узлах-правилах).
|
||||
|
||||
Коммиты: 1–2 атомарных (data.js структура + html паспорта/мета), сообщение
|
||||
`feat(map): iter9 — brain governance subsystem`.
|
||||
+2
-1
@@ -16,7 +16,8 @@
|
||||
"a11y:handoff": "pa11y-ci --config pa11y-handoff.config.json",
|
||||
"check:docs": "run-p lint:md spell links a11y",
|
||||
"sast": "semgrep --config=p/php --config=p/javascript --config=p/typescript --config=p/secrets --config=.semgrep.yml --error --time",
|
||||
"eval:llm": "promptfoo eval -c docs/ml/promptfoo-example/promptfooconfig.yaml"
|
||||
"eval:llm": "promptfoo eval -c docs/ml/promptfoo-example/promptfooconfig.yaml",
|
||||
"brain:dashboard": "node tools/brain-dashboard-server.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cspell/dict-en_us": "^4.4.33",
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseEpisodes, normalizeEpisode, attributeNodes, filterEpisodes, groupBySession, aggregate, inferConflicts } from '../docs/observer/dashboard-core.js';
|
||||
|
||||
const v1 = {
|
||||
task_id: 'a', timestamps: { started_at: '2026-05-19T05:18:16.342Z', ended_at: '2026-05-19T06:05:55.439Z' },
|
||||
path_type: 'improvised', outcome: 'success',
|
||||
primary_rationale: { node_chosen: 'direct', hard_floor: { invoked: false, rules: [] }, task_classification: 'refactor' },
|
||||
events: [{ kind: 'tool_summary', counts: { TodoWrite: 2, AskUserQuestion: 5 } }],
|
||||
};
|
||||
const v2 = {
|
||||
schema_version: 2, task_id: 'b', task_ref: 'b',
|
||||
timestamps: { started_at: '2026-05-19T08:06:30.059Z', ended_at: '2026-05-19T08:10:43.437Z' },
|
||||
path_type: 'improvised', outcome: 'unknown', prompt_signal: 'new_task',
|
||||
decision_provenance: { kind: 'autonomous', claude_would_have_chosen: null },
|
||||
environment: { economy_level: 5, model: 'claude-opus-4-7', post_compaction: true, session_turn: 82, parallel_session: true },
|
||||
task_size: { tool_calls: 12, files_touched: 1, files: ['x'] },
|
||||
primary_rationale: { node_chosen: 'direct', hard_floor: { invoked: false, rules: [] }, task_classification: 'bugfix' },
|
||||
events: [{ kind: 'tool_summary', counts: { Edit: 5 } }, { kind: 'error', message: 'e' }, { kind: 'retry' }],
|
||||
};
|
||||
|
||||
describe('parseEpisodes', () => {
|
||||
it('parses valid JSONL lines', () => {
|
||||
const text = [JSON.stringify(v1), JSON.stringify(v2)].join('\n');
|
||||
const r = parseEpisodes(text);
|
||||
expect(r.episodes).toHaveLength(2);
|
||||
expect(r.skipped).toBe(0);
|
||||
});
|
||||
|
||||
it('skips broken lines and counts them', () => {
|
||||
const text = [JSON.stringify(v1), '{ broken', '', JSON.stringify(v2)].join('\n');
|
||||
const r = parseEpisodes(text);
|
||||
expect(r.episodes).toHaveLength(2);
|
||||
expect(r.skipped).toBe(1);
|
||||
});
|
||||
|
||||
it('skips observer_error marker lines', () => {
|
||||
const text = [JSON.stringify({ observer_error: 'hook failed' }), JSON.stringify(v1)].join('\n');
|
||||
const r = parseEpisodes(text);
|
||||
expect(r.episodes).toHaveLength(1);
|
||||
expect(r.skipped).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeEpisode', () => {
|
||||
it('normalizes a v1 episode — v2-only fields are null', () => {
|
||||
const e = normalizeEpisode(v1);
|
||||
expect(e.schemaVersion).toBe(1);
|
||||
expect(e.outcome).toBe('success');
|
||||
expect(e.environment).toBeNull();
|
||||
expect(e.decisionProvenance).toBeNull();
|
||||
expect(e.taskSize).toBeNull();
|
||||
expect(e.durationMs).toBe(Date.parse(v1.timestamps.ended_at) - Date.parse(v1.timestamps.started_at));
|
||||
expect(e.tools).toEqual({ TodoWrite: 2, AskUserQuestion: 5 });
|
||||
});
|
||||
|
||||
it('normalizes a v2 episode with all fields', () => {
|
||||
const e = normalizeEpisode(v2);
|
||||
expect(e.schemaVersion).toBe(2);
|
||||
expect(e.environment.economy_level).toBe(5);
|
||||
expect(e.errorCount).toBe(1);
|
||||
expect(e.retryCount).toBe(1);
|
||||
expect(e.taskClassification).toBe('bugfix');
|
||||
});
|
||||
|
||||
it('merges tool_summary counts across multiple events', () => {
|
||||
const e = normalizeEpisode({
|
||||
...v1,
|
||||
events: [{ kind: 'tool_summary', counts: { Read: 2 } }, { kind: 'tool_summary', counts: { Read: 3, Bash: 1 } }],
|
||||
});
|
||||
expect(e.tools).toEqual({ Read: 5, Bash: 1 });
|
||||
});
|
||||
|
||||
it('collects skill_invoked skills in order', () => {
|
||||
const e = normalizeEpisode({
|
||||
...v1,
|
||||
events: [{ kind: 'skill_invoked', skill: 'superpowers:writing-plans' }, { kind: 'skill_invoked', skill: 'superpowers:test-driven-development' }],
|
||||
});
|
||||
expect(e.skills).toEqual(['superpowers:writing-plans', 'superpowers:test-driven-development']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('attributeNodes', () => {
|
||||
const ep = (over) => normalizeEpisode({ ...v1, ...over });
|
||||
|
||||
it('maps node_chosen skill id to a graph node', () => {
|
||||
const r = attributeNodes(ep({ primary_rationale: { node_chosen: 'superpowers:systematic-debugging', hard_floor: {} } }));
|
||||
expect(r.nodeIds).toContain('sk_debug');
|
||||
});
|
||||
|
||||
it('ignores node_chosen === "direct"', () => {
|
||||
const r = attributeNodes(ep({ primary_rationale: { node_chosen: 'direct', hard_floor: {} } }));
|
||||
expect(r.nodeIds).toEqual([]);
|
||||
});
|
||||
|
||||
it('maps skill_invoked events to graph nodes', () => {
|
||||
const r = attributeNodes(ep({ events: [{ kind: 'skill_invoked', skill: 'superpowers:writing-plans' }] }));
|
||||
expect(r.nodeIds).toContain('sk_wplans');
|
||||
});
|
||||
|
||||
it('maps mcp__<server>__ tool names to MCP graph nodes', () => {
|
||||
const r = attributeNodes(ep({ events: [{ kind: 'tool_summary', counts: { 'mcp__github__get_issue': 2, 'mcp__laravel-boost__database-query': 1, Read: 4 } }] }));
|
||||
expect(r.nodeIds).toContain('mcp_gh');
|
||||
expect(r.nodeIds).toContain('mcp_boost');
|
||||
});
|
||||
|
||||
it('counts signals vs attributed — builtin tools are not signals', () => {
|
||||
const r = attributeNodes(ep({ events: [{ kind: 'tool_summary', counts: { Read: 1, 'mcp__github__x': 1 } }],
|
||||
primary_rationale: { node_chosen: 'superpowers:test-driven-development', hard_floor: {} } }));
|
||||
expect(r.attributed).toBe(2); // tdd skill + github mcp
|
||||
expect(r.signals).toBe(2); // only the tdd skill and the mcp tool count as signals
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterEpisodes', () => {
|
||||
const list = [
|
||||
normalizeEpisode({ ...v1, primary_rationale: { node_chosen: 'direct', hard_floor: {}, task_classification: 'refactor' }, events: [] }),
|
||||
normalizeEpisode({ ...v2, primary_rationale: { node_chosen: 'direct', hard_floor: {}, task_classification: 'bugfix' }, events: [{ kind: 'error', message: 'e' }] }),
|
||||
];
|
||||
it('returns all with an empty filter', () => {
|
||||
expect(filterEpisodes(list, {})).toHaveLength(2);
|
||||
});
|
||||
it('filters by task classification', () => {
|
||||
expect(filterEpisodes(list, { classification: 'bugfix' })).toHaveLength(1);
|
||||
});
|
||||
it('filters to episodes with errors only', () => {
|
||||
expect(filterEpisodes(list, { withErrors: true })).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupBySession', () => {
|
||||
it('groups episodes by taskRef, newest first within and across groups', () => {
|
||||
const a1 = normalizeEpisode({ ...v2, task_ref: 'S', timestamps: { started_at: '2026-05-19T08:00:00Z', ended_at: '2026-05-19T08:01:00Z' } });
|
||||
const a2 = normalizeEpisode({ ...v2, task_ref: 'S', timestamps: { started_at: '2026-05-19T09:00:00Z', ended_at: '2026-05-19T09:01:00Z' } });
|
||||
const b1 = normalizeEpisode({ ...v2, task_ref: 'T', timestamps: { started_at: '2026-05-19T07:00:00Z', ended_at: '2026-05-19T07:01:00Z' } });
|
||||
const groups = groupBySession([a1, a2, b1]);
|
||||
const s = groups.find((g) => g.taskRef === 'S');
|
||||
expect(s.episodes[0].startedAt).toBe('2026-05-19T09:00:00Z');
|
||||
expect(groups[0].taskRef).toBe('S');
|
||||
});
|
||||
});
|
||||
|
||||
describe('aggregate', () => {
|
||||
const mk = (over) => normalizeEpisode({ ...v2, ...over });
|
||||
it('counts node heat from attributed nodes', () => {
|
||||
const list = [
|
||||
mk({ events: [{ kind: 'skill_invoked', skill: 'superpowers:writing-plans' }] }),
|
||||
mk({ events: [{ kind: 'skill_invoked', skill: 'superpowers:writing-plans' }] }),
|
||||
];
|
||||
expect(aggregate(list).nodeHeat.sk_wplans).toBe(2);
|
||||
});
|
||||
it('computes redirect rate', () => {
|
||||
const list = [
|
||||
mk({ decision_provenance: { kind: 'user_directed_method', claude_would_have_chosen: 'x' } }),
|
||||
mk({ decision_provenance: { kind: 'autonomous', claude_would_have_chosen: null } }),
|
||||
];
|
||||
expect(aggregate(list).redirectRate).toBe(0.5);
|
||||
});
|
||||
it('tallies path_type and outcome distributions', () => {
|
||||
const list = [mk({ path_type: 'improvised', outcome: 'unknown' }), mk({ path_type: 'regulated', outcome: 'success' })];
|
||||
const a = aggregate(list);
|
||||
expect(a.pathType).toEqual({ improvised: 1, regulated: 1 });
|
||||
expect(a.outcome).toEqual({ unknown: 1, success: 1 });
|
||||
});
|
||||
it('reports total error and retry counts', () => {
|
||||
const list = [mk({ events: [{ kind: 'error', message: 'e' }, { kind: 'retry' }] })];
|
||||
const a = aggregate(list);
|
||||
expect(a.totalErrors).toBe(1);
|
||||
expect(a.totalRetries).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('inferConflicts', () => {
|
||||
const conflictEdges = [{ from: 'sk_wplans', to: 'sk_debug', dashes: true, label: '⚫', title: 't' }];
|
||||
it('returns design conflicts from dashed edges', () => {
|
||||
const r = inferConflicts([], conflictEdges);
|
||||
expect(r.design).toHaveLength(1);
|
||||
});
|
||||
it('reports friction — episodes with errors attributed to nodes', () => {
|
||||
const ep = normalizeEpisode({ ...v2,
|
||||
events: [{ kind: 'error', message: 'e' }, { kind: 'skill_invoked', skill: 'superpowers:writing-plans' }] });
|
||||
const r = inferConflicts([ep], conflictEdges);
|
||||
expect(r.friction.sk_wplans).toBe(1);
|
||||
});
|
||||
it('reports correlation when an errored episode spans a conflict-edge pair', () => {
|
||||
const ep = normalizeEpisode({ ...v2, events: [
|
||||
{ kind: 'error', message: 'e' },
|
||||
{ kind: 'skill_invoked', skill: 'superpowers:writing-plans' },
|
||||
{ kind: 'skill_invoked', skill: 'superpowers:systematic-debugging' },
|
||||
] });
|
||||
const r = inferConflicts([ep], conflictEdges);
|
||||
expect(r.correlation).toHaveLength(1);
|
||||
expect(r.correlation[0].pair).toEqual(['sk_wplans', 'sk_debug']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
// Static file server for the Brain Dashboard. Serves the repo root over
|
||||
// localhost so dashboard.html can fetch() episodes-*.jsonl (file:// cannot).
|
||||
// Run: node tools/brain-dashboard-server.mjs (npm run brain:dashboard)
|
||||
import { createServer as httpCreateServer } from 'node:http';
|
||||
import { readFileSync, existsSync, statSync, readdirSync } from 'node:fs';
|
||||
import { join, resolve, extname, sep } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const REPO_ROOT = resolve(fileURLToPath(import.meta.url), '..', '..');
|
||||
const PORT = Number(process.env.BRAIN_DASHBOARD_PORT) || 7700;
|
||||
|
||||
const MIME = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.jsonl': 'application/x-ndjson; charset=utf-8',
|
||||
'.svg': 'image/svg+xml',
|
||||
};
|
||||
|
||||
export function contentType(ext) {
|
||||
return MIME[ext] || 'application/octet-stream';
|
||||
}
|
||||
|
||||
export function listEpisodeFiles(root) {
|
||||
const dir = join(root, 'docs', 'observer');
|
||||
if (!existsSync(dir)) return [];
|
||||
return readdirSync(dir)
|
||||
.filter((f) => /^episodes-\d{4}-\d{2}\.jsonl$/.test(f))
|
||||
.sort();
|
||||
}
|
||||
|
||||
// Resolve a URL path to an absolute path inside root; null if it escapes root.
|
||||
export function resolveStaticPath(urlPath, root) {
|
||||
const clean = decodeURIComponent(urlPath.split('?')[0]).replace(/^\/+/, '');
|
||||
// Use resolve for the traversal check (canonicalizes both sides consistently)
|
||||
const normRoot = resolve(root);
|
||||
const abs = resolve(normRoot, clean);
|
||||
if (abs !== normRoot && !abs.startsWith(normRoot + sep)) return null;
|
||||
// Return join-based path so callers get root-relative path with root's own separators
|
||||
return join(root, clean);
|
||||
}
|
||||
|
||||
export function createServer(root = REPO_ROOT) {
|
||||
return httpCreateServer((req, res) => {
|
||||
const url = req.url || '/';
|
||||
if (url.split('?')[0] === '/api/episodes') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
||||
res.end(JSON.stringify(listEpisodeFiles(root)));
|
||||
return;
|
||||
}
|
||||
let path = url.split('?')[0];
|
||||
if (path === '/') {
|
||||
// Redirect (not rewrite) so the browser's base URL becomes /docs/observer/,
|
||||
// which makes relative <script src="dashboard.js"> and ../automation-graph-data.js resolve correctly.
|
||||
res.writeHead(302, { Location: '/docs/observer/dashboard.html' });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
const abs = resolveStaticPath(path, root);
|
||||
if (!abs || !existsSync(abs) || !statSync(abs).isFile()) {
|
||||
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
|
||||
res.end('404');
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { 'Content-Type': contentType(extname(abs)) });
|
||||
res.end(readFileSync(abs));
|
||||
});
|
||||
}
|
||||
|
||||
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
createServer().listen(PORT, '127.0.0.1', () => {
|
||||
console.log(`Brain Dashboard: http://localhost:${PORT}/ (Ctrl+C to stop)`);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { listEpisodeFiles, resolveStaticPath, contentType } from './brain-dashboard-server.mjs';
|
||||
import { mkdtempSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
describe('listEpisodeFiles', () => {
|
||||
it('returns episodes-*.jsonl filenames sorted, ignores other files', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'bd-'));
|
||||
const obs = join(root, 'docs', 'observer');
|
||||
mkdirSync(obs, { recursive: true });
|
||||
writeFileSync(join(obs, 'episodes-2026-05.jsonl'), '');
|
||||
writeFileSync(join(obs, 'episodes-2026-04.jsonl'), '');
|
||||
writeFileSync(join(obs, 'STATUS.md'), '');
|
||||
expect(listEpisodeFiles(root)).toEqual(['episodes-2026-04.jsonl', 'episodes-2026-05.jsonl']);
|
||||
});
|
||||
|
||||
it('returns [] when the observer dir is missing', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'bd-'));
|
||||
expect(listEpisodeFiles(root)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveStaticPath', () => {
|
||||
it('resolves a path inside root', () => {
|
||||
const root = '/srv/app';
|
||||
expect(resolveStaticPath('/docs/observer/dashboard.html', root))
|
||||
.toBe(join(root, 'docs', 'observer', 'dashboard.html'));
|
||||
});
|
||||
|
||||
it('rejects path traversal with null', () => {
|
||||
expect(resolveStaticPath('/../../etc/passwd', '/srv/app')).toBeNull();
|
||||
expect(resolveStaticPath('/docs/../../secret', '/srv/app')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('contentType', () => {
|
||||
it('maps known extensions', () => {
|
||||
expect(contentType('.html')).toBe('text/html; charset=utf-8');
|
||||
expect(contentType('.js')).toBe('text/javascript; charset=utf-8');
|
||||
expect(contentType('.jsonl')).toBe('application/x-ndjson; charset=utf-8');
|
||||
});
|
||||
it('falls back to octet-stream', () => {
|
||||
expect(contentType('.xyz')).toBe('application/octet-stream');
|
||||
});
|
||||
});
|
||||
@@ -26,11 +26,22 @@ export function dedupeEpisodes(episodes) {
|
||||
return [...byKey.values(), ...errors];
|
||||
}
|
||||
|
||||
/** Infer the true outcome of an episode from the next episode's opening prompt. */
|
||||
/** Infer the true outcome of an episode from its events + the next episode's prompt. */
|
||||
export function inferOutcome(episode, nextEpisode) {
|
||||
if (episode && Array.isArray(episode.events) && episode.events.some((e) => e.kind === 'interrupt')) {
|
||||
const events = episode && Array.isArray(episode.events) ? episode.events : [];
|
||||
if (events.some((e) => e.kind === 'interrupt')) {
|
||||
return 'partial';
|
||||
}
|
||||
// A turn that hit more tool errors than it retried away ended on an
|
||||
// unrecovered failure — the work was blocked, not merely reworked later.
|
||||
const errorCount = events.filter((e) => e.kind === 'error').length;
|
||||
const retryCount = events.filter((e) => e.kind === 'retry').length;
|
||||
if (errorCount > retryCount) {
|
||||
return 'blocked';
|
||||
}
|
||||
// 'failure' (work wrong AND never corrected) is a judgment, not
|
||||
// deterministically recoverable from a transcript — deferred to the phase-2
|
||||
// agent-judge. Until then a wrong-then-corrected turn surfaces as 'rework'.
|
||||
if (!nextEpisode) return 'unknown';
|
||||
if (nextEpisode.prompt_signal === 'correction') return 'rework';
|
||||
if (nextEpisode.prompt_signal === 'approval' || nextEpisode.prompt_signal === 'new_task') return 'success';
|
||||
@@ -108,11 +119,22 @@ function sizeBucket(toolCalls) {
|
||||
return n < SIZE_SMALL ? 'small' : n <= SIZE_LARGE ? 'medium' : 'large';
|
||||
}
|
||||
|
||||
const SESSION_TURN_EARLY = 10;
|
||||
const SESSION_TURN_LATE = 40;
|
||||
|
||||
function sessionTurnBucket(turn) {
|
||||
const n = Number(turn);
|
||||
if (!Number.isFinite(n)) return 'null';
|
||||
return n < SESSION_TURN_EARLY ? 'early' : n <= SESSION_TURN_LATE ? 'mid' : 'late';
|
||||
}
|
||||
|
||||
const FACTOR_FNS = {
|
||||
decision_provenance: (e) => (e.decision_provenance || {}).kind || 'unknown',
|
||||
economy_level: (e) => String((e.environment || {}).economy_level ?? 'null'),
|
||||
model: (e) => (e.environment || {}).model || 'null',
|
||||
post_compaction: (e) => String((e.environment || {}).post_compaction ?? false),
|
||||
session_turn: (e) => sessionTurnBucket((e.environment || {}).session_turn),
|
||||
parallel_session: (e) => String((e.environment || {}).parallel_session ?? false),
|
||||
task_size: (e) => sizeBucket((e.task_size || {}).tool_calls),
|
||||
node_chosen: (e) => (e.primary_rationale || {}).node_chosen || 'direct',
|
||||
task_classification: (e) => (e.primary_rationale || {}).task_classification || 'other',
|
||||
@@ -136,7 +158,11 @@ export function buildFactorMatrix(episodesWithOutcome) {
|
||||
/** Full deterministic aggregation: dedup → infer outcomes → group → chains → matrix. */
|
||||
export function analyze(episodes) {
|
||||
const deduped = dedupeEpisodes(episodes);
|
||||
const normal = deduped.filter((e) => !e.observer_error);
|
||||
const allNormal = deduped.filter((e) => !e.observer_error);
|
||||
// v1 episodes lack environment / prompt_signal / decision_provenance — they
|
||||
// pollute the factor matrix and break outcome inference. Analyze v2 only.
|
||||
const normal = allNormal.filter((e) => e.schema_version === 2);
|
||||
const v1SkippedCount = allNormal.length - normal.length;
|
||||
for (const eps of bySessionSorted(normal).values()) {
|
||||
eps.forEach((episode, i) => {
|
||||
episode._inferredOutcome = inferOutcome(episode, eps[i + 1]);
|
||||
@@ -144,7 +170,8 @@ export function analyze(episodes) {
|
||||
}
|
||||
return {
|
||||
episodeCount: normal.length,
|
||||
observerErrorCount: deduped.length - normal.length,
|
||||
v1SkippedCount,
|
||||
observerErrorCount: deduped.length - allNormal.length,
|
||||
tasks: groupEpisodesToTasks(normal),
|
||||
causalChains: findCausalChains(normal),
|
||||
factorMatrix: buildFactorMatrix(normal),
|
||||
|
||||
@@ -53,6 +53,14 @@ describe('inferOutcome', () => {
|
||||
it('infers unknown when there is no next episode', () => {
|
||||
expect(inferOutcome(ep(), null)).toBe('unknown');
|
||||
});
|
||||
it('infers blocked when the episode has more error than retry events', () => {
|
||||
const blocked = ep({ events: [{ kind: 'error' }, { kind: 'error' }, { kind: 'retry' }] });
|
||||
expect(inferOutcome(blocked, ep({ prompt_signal: 'approval' }))).toBe('blocked');
|
||||
});
|
||||
it('does not infer blocked when every error was retried', () => {
|
||||
const recovered = ep({ events: [{ kind: 'error' }, { kind: 'retry' }] });
|
||||
expect(inferOutcome(recovered, ep({ prompt_signal: 'approval' }))).toBe('success');
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupEpisodesToTasks', () => {
|
||||
@@ -108,6 +116,18 @@ describe('buildFactorMatrix', () => {
|
||||
expect(m.decision_provenance.user_chose_from_options.success).toBe(1);
|
||||
expect(m.decision_provenance.user_chose_from_options.rework).toBe(1);
|
||||
});
|
||||
|
||||
it('includes session_turn (bucketed) and parallel_session factors', () => {
|
||||
const eps = [
|
||||
{ ...ep(), _inferredOutcome: 'success', environment: { session_turn: 3, parallel_session: false } },
|
||||
{ ...ep(), _inferredOutcome: 'rework', environment: { session_turn: 120, parallel_session: true } },
|
||||
];
|
||||
const m = buildFactorMatrix(eps);
|
||||
expect(m.session_turn.early.success).toBe(1);
|
||||
expect(m.session_turn.late.rework).toBe(1);
|
||||
expect(m.parallel_session.false.success).toBe(1);
|
||||
expect(m.parallel_session.true.rework).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('analyze', () => {
|
||||
@@ -118,4 +138,15 @@ describe('analyze', () => {
|
||||
expect(Array.isArray(result.tasks)).toBe(true);
|
||||
expect(Array.isArray(result.causalChains)).toBe(true);
|
||||
});
|
||||
|
||||
it('skips v1 episodes (no schema_version 2) from the analysis', () => {
|
||||
const v1 = { task_id: 's-old', timestamps: { started_at: '2026-05-19T09:00:00Z' }, outcome: 'success' };
|
||||
const result = analyze([
|
||||
v1,
|
||||
ep(),
|
||||
ep({ timestamps: { started_at: '2026-05-19T11:00:00Z', ended_at: '2026-05-19T11:01:00Z' } }),
|
||||
]);
|
||||
expect(result.episodeCount).toBe(2);
|
||||
expect(result.v1SkippedCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -147,16 +147,19 @@ export function extractEnvironment(allEntries, turnStartIdx) {
|
||||
}
|
||||
}
|
||||
|
||||
let post_compaction = false;
|
||||
// The transcript file accumulates duplicated context-rebuild snapshots
|
||||
// (repeated isCompactSummary entries — see feedback_environment quirk #101).
|
||||
// Counting prompts from i=0 inflates session_turn with those dupes. Count
|
||||
// from the LAST compaction before the turn: session_turn = real prompts
|
||||
// since it, which is monotonic ("turns since last compaction").
|
||||
let lastCompactIdx = -1;
|
||||
for (let i = 0; i < turnStartIdx && i < allEntries.length; i++) {
|
||||
if (allEntries[i] && allEntries[i].isCompactSummary === true) {
|
||||
post_compaction = true;
|
||||
break;
|
||||
}
|
||||
if (allEntries[i] && allEntries[i].isCompactSummary === true) lastCompactIdx = i;
|
||||
}
|
||||
const post_compaction = lastCompactIdx >= 0;
|
||||
|
||||
let session_turn = 0;
|
||||
for (let i = 0; i <= turnStartIdx && i < allEntries.length; i++) {
|
||||
for (let i = lastCompactIdx + 1; i <= turnStartIdx && i < allEntries.length; i++) {
|
||||
if (isRealUserPrompt(allEntries[i])) session_turn += 1;
|
||||
}
|
||||
|
||||
@@ -189,7 +192,11 @@ export function extractTaskSize(turn) {
|
||||
/** Classify the opening user-prompt sentiment (per spec §6 / gap-resolution 1). */
|
||||
export function classifyPromptSignal(text) {
|
||||
const t = String(text || '').toLowerCase().trim();
|
||||
if (/не то\b|не так\b|переделай|отбой|\bстоп\b|почему ты|неверно|не верно|это не /.test(t)) {
|
||||
if (
|
||||
/не то\b|не так\b|переделай|отбой|\bстоп\b|почему ты|неверно|не верно|это не |не работает|не правильн|сломал|опять|снова не|всё ещё|все ещё|все еще|верни как|откат|\brevert\b|\bundo\b|still not|doesn'?t work|does not work|\bwrong\b/.test(
|
||||
t
|
||||
)
|
||||
) {
|
||||
return 'correction';
|
||||
}
|
||||
if (/^(ок|окей|ok|спасибо|супер|отлично|готово|дальше|идеально)([,\s]|$)/.test(t)) {
|
||||
|
||||
@@ -267,6 +267,28 @@ describe('extractEnvironment', () => {
|
||||
];
|
||||
expect(extractEnvironment(entries, 2).session_turn).toBe(3);
|
||||
});
|
||||
|
||||
it('session_turn counts only prompts after the last compaction', () => {
|
||||
const entries = [
|
||||
userPrompt('old 1', '2026-05-19T08:00:00Z'),
|
||||
userPrompt('old 2', '2026-05-19T08:30:00Z'),
|
||||
{ type: 'user', isCompactSummary: true, message: { role: 'user', content: 'summary' }, timestamp: '2026-05-19T09:00:00Z' },
|
||||
userPrompt('after 1', '2026-05-19T09:30:00Z'),
|
||||
userPrompt('after 2 — turn', '2026-05-19T10:00:00Z'),
|
||||
];
|
||||
expect(extractEnvironment(entries, 4).session_turn).toBe(2);
|
||||
});
|
||||
|
||||
it('session_turn counts from the LAST compaction when several are present', () => {
|
||||
const entries = [
|
||||
{ type: 'user', isCompactSummary: true, message: { role: 'user', content: 's1' }, timestamp: '2026-05-19T08:00:00Z' },
|
||||
userPrompt('mid 1', '2026-05-19T08:30:00Z'),
|
||||
{ type: 'user', isCompactSummary: true, message: { role: 'user', content: 's2' }, timestamp: '2026-05-19T09:00:00Z' },
|
||||
userPrompt('after 1', '2026-05-19T09:30:00Z'),
|
||||
userPrompt('after 2 — turn', '2026-05-19T10:00:00Z'),
|
||||
];
|
||||
expect(extractEnvironment(entries, 4).session_turn).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractTaskSize', () => {
|
||||
@@ -298,6 +320,15 @@ describe('classifyPromptSignal', () => {
|
||||
expect(classifyPromptSignal('не то, переделай')).toBe('correction');
|
||||
expect(classifyPromptSignal('почему ты это сделал')).toBe('correction');
|
||||
});
|
||||
it('detects widened correction phrases', () => {
|
||||
expect(classifyPromptSignal('не работает экспорт')).toBe('correction');
|
||||
expect(classifyPromptSignal('сломал тесты, верни как было')).toBe('correction');
|
||||
expect(classifyPromptSignal('опять не та колонка')).toBe('correction');
|
||||
expect(classifyPromptSignal('всё ещё падает')).toBe('correction');
|
||||
expect(classifyPromptSignal('откати последнюю правку')).toBe('correction');
|
||||
expect(classifyPromptSignal('this is still not working')).toBe('correction');
|
||||
expect(classifyPromptSignal('revert that change')).toBe('correction');
|
||||
});
|
||||
it('detects approvals', () => {
|
||||
expect(classifyPromptSignal('ок, спасибо')).toBe('approval');
|
||||
expect(classifyPromptSignal('готово, дальше')).toBe('approval');
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
**перепроверять реальной командой**, не доверять снимку вслепую.
|
||||
- Обновляется по команде заказчика **«обнови эталон»**.
|
||||
|
||||
**Снимок снят:** 18.05.2026 (после эпика «drawer + project source edit» + tenant cleanup).
|
||||
**Снимок снят:** 19.05.2026 (вечер, после эпика `supplier-migration-followup` + push).
|
||||
|
||||
---
|
||||
|
||||
@@ -17,17 +17,20 @@
|
||||
|
||||
- Git-корень репозитория — папка `Документация/` (**не** `app/`).
|
||||
- Remote: `CoralMinister/lidpotok` (приватный).
|
||||
- Текущая локальная ветка: **`feat/parallel-sessions-coordination`**.
|
||||
- Локальный HEAD: `5dc9509` (CLAUDE.md v2.16 от параллельной сессии — SYSTEM-аудит «мозга» Rec1-Rec5).
|
||||
- `origin/main` HEAD: `5dc9509`. Мой push `36ea9cd..f248e27` (8 commits FF, drawer+project source эпик)
|
||||
поверх него параллельная сессия добавила 3 docs/правки `ec4069c..5dc9509`.
|
||||
- Локальная ветка: **0 впереди / 0 позади** `origin/main`.
|
||||
- Текущая локальная ветка: **`feat/supplier-migration-followup`**.
|
||||
- Локальный HEAD: `353b159` — но **верхние 2 коммита (`97388cf`, `353b159`) — `fix(observer): …`
|
||||
параллельной brain-сессии**, не мои, не запушены. Мой эпик заканчивается на `8f5a399`.
|
||||
- **`origin/main` HEAD: `8f5a399`** — мой push `83295a2..8f5a399` (10 commits FF, эпик
|
||||
supplier-migration-followup).
|
||||
- Локальная ветка: **2 впереди** `origin/main` (чужие observer-коммиты) / **0 позади**.
|
||||
- Push-паттерн проекта: `git push origin <ветка>:main`.
|
||||
- Незакоммиченное сейчас: правки `app/bootstrap/app.php` (trustProxies — временно),
|
||||
`app/playwright/refresh-session.js`, `.gitignore`, `app/dev-indices.json`;
|
||||
untracked webhook/демо-артефакты (см. §4).
|
||||
- 3 stash'а (`git stash list`) — все продукты параллельной brain-сессии (STATUS.md / episodes /
|
||||
observer-*), сохранены при rebase. Не мои — не трогать.
|
||||
- Незакоммиченное: `docs/observer/STATUS.md` + `episodes-2026-05.jsonl` (hook-артефакты
|
||||
brain governance); untracked webhook/демо-артефакты (см. §4).
|
||||
|
||||
> ⚠️ Снимок git в начале сессии бывает **устаревшим**. Истина — `git branch --show-current` + `git log -1`.
|
||||
> ⚠️ Снимок git в начале сессии бывает **устаревшим** (параллельные сессии скачут по веткам).
|
||||
> Истина — `git branch --show-current` + `git log -1 origin/main`.
|
||||
|
||||
## 2. Dev-окружение (native Windows, без Docker)
|
||||
|
||||
@@ -36,46 +39,63 @@
|
||||
| PostgreSQL 16 | служба `postgresql-x64-16` Running; БД `liderra`; dev-юзер `postgres` (superuser → RLS обходится) |
|
||||
| Redis | Memurai (служба `Memurai`) Running, порт 6379 |
|
||||
| PHP | 8.3, `C:\tools\php83\php.exe` |
|
||||
| Портал | `php artisan serve` на `127.0.0.1:8000` |
|
||||
| Очередь | `php artisan queue:work` (один воркер) |
|
||||
| Фронтенд | режим **PROD-сборка** (`app/public/hot` отсутствует → отдаётся `app/public/build/`) |
|
||||
| Портал | `php artisan serve --host=127.0.0.1 --port=8000` |
|
||||
| Очередь | `php artisan queue:work redis --tries=3 --timeout=120 --queue=default,supplier_webhooks` |
|
||||
| Шедулер | `php artisan schedule:work` — `CsvReconcileJob` каждые 30 мин, `RefreshSupplierSessionJob` hourly |
|
||||
| Cloudflare-туннель | `tools/cloudflared.exe tunnel --url http://localhost:8000` (1 процесс, random URL) |
|
||||
| Фронтенд | **PROD-сборка** (`app/public/hot` отсутствует → отдаётся `app/public/build/`) |
|
||||
|
||||
> ⚠️ После любых правок фронтенда (`app/resources/js/`) — **`npm --prefix app run build`**,
|
||||
> иначе портал отдаёт старый бандл (баг 18.05.2026). PID процессов меняются —
|
||||
> проверять `Get-CimInstance Win32_Process -Filter "Name='php.exe'"`.
|
||||
> ⚠️ После правок фронтенда (`app/resources/js/`) — **`npm --prefix app run build`**.
|
||||
> PID процессов меняются — `Get-CimInstance Win32_Process -Filter "Name='php.exe'"` для свежих.
|
||||
|
||||
## 3. Что запущено временно (webhook-тест)
|
||||
## 3. Что запущено временно (тест supplier-каналов)
|
||||
|
||||
- ngrok-туннель: `https://overtly-ascension-multiple.ngrok-free.dev` → `:8000` (`ngrok http 8000`).
|
||||
- Webhook поставщика crm.bp-gr.ru настроен на этот URL, статус «Активный».
|
||||
- Зомби-процесс `php artisan tinker _demo_reroute.php` — безвреден, можно завершить.
|
||||
- Cloudflared quick-tunnel → `:8000` (random URL, при рестарте — менять в supplier-портале).
|
||||
- Webhook поставщика crm.bp-gr.ru настроен на текущий tunnel URL, статус «Активный».
|
||||
- Supplier session в Redis (ключ `supplier:session`) — рефрешится hourly `RefreshSupplierSessionJob`
|
||||
- on-demand из `SupplierPortalClient`; на момент снимка ключа в кэше нет (пересоздастся при
|
||||
первом обращении / тике крона).
|
||||
- `CsvReconcileJob` по крону каждые 30 мин → таблица `supplier_csv_reconcile_log`.
|
||||
|
||||
## 4. Временное / демо — НЕ постоянное, под финальную очистку
|
||||
|
||||
- Webhook-тест: `.env` `SUPPLIER_LOGIN`/`SUPPLIER_PASSWORD`, `app/bootstrap/app.php` trustProxies,
|
||||
`system_settings.supplier_webhook_secret`, `tools/cloudflared.exe`, DNS-правки (`tools/.dns-backup.json`).
|
||||
- Демо-данные: **только tenant 3** (`_demo_tenantA`) — 37 проектов, 89 сделок, баланс 1000 лидов.
|
||||
Tenants 1 / 2 / 4 soft-deleted 18.05 ≈13:02 UTC (восстановление: `UPDATE tenants SET deleted_at=NULL WHERE id IN (1,4)`).
|
||||
- Демо-скрипты: `app/storage/_demo_reroute.php`, `_demo_day_setup.php`, `_demo_user.php`.
|
||||
- **Демо-данные (после re-seed 19.05):** активен **только tenant 1** (`subdomain=demo`);
|
||||
5 пользователей `admin@demo.local` + `user1-4@demo.local`, пароль **`12345678`** (форма требует ≥8 симв.);
|
||||
~137 сделок (5 DemoSeeder + 132 импортированных из `supplier_leads`); ~54 проекта (3 DemoSeeder
|
||||
- 51 канал-проект из `B1/B2/B3_…` имён лидов).
|
||||
- Демо-скрипты `app/storage/_demo_*.php` (`_demo_reroute`, `_demo_day_setup`, `_demo_user`,
|
||||
`_demo_create_projects`, `_demo_migrate_b1site`) — untracked, временные.
|
||||
- Untracked снимки в корне (`*.png`, `rt-*.yml`, `user-api-page.yml`) — артефакты recon/тестов.
|
||||
- Orphan worktree-директория `.claude/worktrees/supplier-session-fix/` (gitignored, безвредна).
|
||||
- Процедура очистки: Task 9 плана `docs/superpowers/plans/2026-05-18-webhook-real-supplier-integration.md`.
|
||||
|
||||
## 5. Ключевые факты (стабильно)
|
||||
|
||||
- Структура: git-корень `Документация/` · Laravel-приложение `app/` · фронтенд `app/resources/js/`
|
||||
· сборка фронтенда → `app/public/build/` · схема БД `db/schema.sql` (v8.23).
|
||||
· сборка фронтенда → `app/public/build/` · схема БД `db/schema.sql` (**v8.25** —
|
||||
`+supplier_manual_sync_queue`, Tier-3 очередь резерва канала миграции проектов).
|
||||
- Стек: Laravel 13 + PHP 8.3 · Vue 3 + Vuetify 3 (не Tailwind/Inertia) · PostgreSQL 16 · Redis.
|
||||
- Демо-доступ к порталу: `demo@liderra.local` / `Demo12345!` (tenant 3).
|
||||
- Поставщик лидов: `crm.bp-gr.ru` (учётка в `.env` `SUPPLIER_*`).
|
||||
- **Демо-доступ к порталу:** `admin@demo.local` / **`12345678`** (tenant 1).
|
||||
- Поставщик лидов: `crm.bp-gr.ru` (учётка в `.env` `SUPPLIER_*`); портал — Vue 2 + Element UI;
|
||||
`/admin/visit/rt` «Мои проекты» (форма add-project — Element UI внутри Vuetify v-dialog).
|
||||
- Оперативная карта проекта: `CLAUDE.md` (правится только плагином `claude-md-management`).
|
||||
- Память Claude: индекс `MEMORY.md` — подгружается каждую сессию.
|
||||
|
||||
## 6. Текущие рабочие нити (детали — в памяти Claude)
|
||||
|
||||
- Webhook-канал доказан вживую (110+ реальных лидов 18.05); запасной CSV-канал (путь 2) — дизайн ждёт «ок»; находка B1+sms.
|
||||
Память: `project_supplier_channels_2026-05-18.md`.
|
||||
- Редизайн страницы «Сделки» (вариант A, воронка 14→5) + Drawer/Project-source эпик 18.05 — в `origin/main` `f248e27`.
|
||||
Память: `project_deals_page_redesign.md` + `project_deals_drawer_and_project_source_2026-05-18.md` (новая запись).
|
||||
- Tenant cleanup 18.05: оставлен только tenant 3 (`demo@liderra.local` / `Demo12345!`).
|
||||
- **Эпик supplier-migration-followup ЗАКРЫТ** (19.05 вечер, origin/main `8f5a399`): defense-in-depth
|
||||
HTTP-200-логин в `SupplierPortalClient`, recon формы rt-проекта, rewrite `manage-project.js`
|
||||
под Element UI, live-smoke form-канала + 3-ярусного `FailoverProjectChannel`. Память:
|
||||
`project_state.md`, recon — `docs/discovery/2026-05-19-rt-project-form-locators.md`.
|
||||
- **Каналы миграции (вход crm.bp-gr.ru → Лидерра)** — webhook (#3) и CSV reconcile (#2)
|
||||
работают. Память: `project_supplier_channels_2026-05-18.md`.
|
||||
- **Экспорт проектов (Лидерра → crm.bp-gr.ru)** — `FailoverProjectChannel` 3 яруса: код закрыт
|
||||
(`ad09db6`) + live-smoke пройден (Task 5b). Известные gap'ы Tier-2: `workdays`/`regions`
|
||||
не выставляются через форму (by design). Память: `project_supplier_project_failover.md`.
|
||||
- **brain governance dashboard** — параллельная сессия, ветка `feat/brain-dashboard`
|
||||
(запушена в `83295a2`); продолжает observer-фиксы. Память: `project_brain_governance_design.md`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user