Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d6f92c649 | |||
| c7079ac8e4 | |||
| bfa228197d | |||
| cc444e7f53 | |||
| 982cd00678 | |||
| 97982f85fe | |||
| 3d5fb86e7c | |||
| 6cb8be6919 | |||
| 59c3ef4112 | |||
| fe338e09f9 | |||
| c9f2be37fe | |||
| d7fe7ba458 | |||
| bb41315df4 | |||
| b6a0938ccd | |||
| a3e7573387 | |||
| 9188e1cefd | |||
| 76cb825331 | |||
| 6f70cca90e | |||
| 48eaffece8 | |||
| 919971d085 | |||
| 6bf0ebfd1d | |||
| 5cad78b73d | |||
| 3bb2bf92e2 | |||
| 82b95f4bcb | |||
| 9a56d92440 | |||
| 0e5f47c5e9 | |||
| cbfb504a54 | |||
| 8d037e1f04 | |||
| e8782c47b3 | |||
| 3dfb96ba47 | |||
| b92d9b3bfc | |||
| 58784b182d | |||
| 4010495d19 | |||
| 2bf25db72e | |||
| da4ab729df | |||
| 4f362a9e62 | |||
| 633435e990 | |||
| 050b349af5 | |||
| 25ac64f9b0 | |||
| dcd7163738 |
@@ -65,6 +65,36 @@
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Edit|Write|MultiEdit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/enforce-memory-coverage.mjs",
|
||||
"timeout": 5
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/enforce-tdd-gate.mjs",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/enforce-branch-switch.mjs",
|
||||
"timeout": 5
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/enforce-verify-before-push.mjs",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
@@ -85,6 +115,31 @@
|
||||
"command": "node -e \"const f=process.env.CLAUDE_FILE_PATH||''; const n=f.replace(/\\\\\\\\/g,'/'); if (/(^|\\\\/)db\\\\/schema\\\\.sql$/i.test(n)) { process.stdout.write('\\n[hook] REMINDER: You modified db/schema.sql. Per CLAUDE.md §5 п.8, add a corresponding entry to db/CHANGELOG_schema.md before committing.\\n'); }\""
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/enforce-verify-record.mjs",
|
||||
"timeout": 5
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/enforce-rationalization-audit.mjs",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Edit|Write|MultiEdit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/enforce-rationalization-audit.mjs",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
@@ -105,6 +160,24 @@
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/enforce-coverage-verify.mjs",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/enforce-classifier-match.mjs",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"UserPromptSubmit": [
|
||||
@@ -116,6 +189,15 @@
|
||||
"timeout": 10
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/enforce-prompt-injection.mjs",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"SessionStart": [
|
||||
|
||||
@@ -83,7 +83,7 @@ class SupplierWebhookController extends Controller
|
||||
|
||||
$validated = $request->validate([
|
||||
'vid' => 'required|integer|min:1',
|
||||
'project' => ['required', 'string', 'max:255', 'regex:/^B[123]_.+$/'],
|
||||
'project' => ['required', 'string', 'max:255'], // Phase 3: regex /^B[123]_.+$/ снят — non-B → platform=DIRECT
|
||||
'phone' => ['required', 'string', 'regex:/^7\d{10}$/'],
|
||||
'time' => ['required', 'integer', "min:{$minTime}", "max:{$maxTime}"],
|
||||
'tag' => 'nullable|string|max:255',
|
||||
@@ -182,8 +182,12 @@ class SupplierWebhookController extends Controller
|
||||
|
||||
private function parsePlatform(string $project): string
|
||||
{
|
||||
preg_match('/^(B[123])_/', $project, $m);
|
||||
// Phase 3: проекты без B-префикса → DIRECT (раньше silent fallback на 'B1'
|
||||
// приводил к неверной маршрутизации).
|
||||
if (preg_match('/^(B[123])_/', $project, $m) === 1) {
|
||||
return $m[1];
|
||||
}
|
||||
|
||||
return $m[1] ?? 'B1';
|
||||
return 'DIRECT';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,11 +171,16 @@ class RouteSupplierLeadJob implements ShouldQueue
|
||||
*/
|
||||
private function parseProjectField(string $project): array
|
||||
{
|
||||
if (preg_match('/^(B[123])_(.+)$/', $project, $m) !== 1) {
|
||||
throw new RuntimeException("Cannot parse supplier project field: '{$project}'");
|
||||
if (preg_match('/^(B[123])_(.+)$/', $project, $m) === 1) {
|
||||
$platform = $m[1];
|
||||
$rest = $m[2];
|
||||
} else {
|
||||
// Phase 3: проекты без B-префикса попадают в DIRECT.
|
||||
// Весь project считается identifier-частью; signal_type определяется
|
||||
// тем же regex'ом, что для $rest у B-префиксных.
|
||||
$platform = 'DIRECT';
|
||||
$rest = $project;
|
||||
}
|
||||
$platform = $m[1];
|
||||
$rest = $m[2];
|
||||
|
||||
// Домен с латинским TLD ≥2 букв (последний сегмент — только буквы), допускается
|
||||
// в любой позиции строки. Соответствует чистому rest и встроенному в текст домену.
|
||||
@@ -245,6 +250,57 @@ class RouteSupplierLeadJob implements ShouldQueue
|
||||
}
|
||||
$project = $lockedProject;
|
||||
|
||||
// Phase 2 fix: merge с CSV-recovered deal если webhook догоняет.
|
||||
// Идемпотентность race condition между CsvReconcileJob (vid=NULL, recovered
|
||||
// from CSV) и webhook (vid=int, реальный supplier-id). До этой проверки они
|
||||
// создавали 2 deal'a (DD снят Spec B Phase 1). Merge выполняется только если:
|
||||
// - webhook ЕСТЬ настоящий vid (lead.vid !== null) — без vid merge'ить нечего;
|
||||
// - csv-recovered deal существует за последние 24h, тот же phone+project+tenant;
|
||||
// - csv-recovered deal БЕЗ source_crm_id (т.е. он именно CSV-recovered, не другой webhook).
|
||||
// При merge: UPDATE existing.source_crm_id, INSERT supplier_lead_deliveries,
|
||||
// БЕЗ chargeForDelivery (LeadCharge уже есть с момента CSV recovery).
|
||||
$existingMergeable = null;
|
||||
if ($lead->vid !== null) {
|
||||
$existingMergeable = Deal::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('phone', (string) $lead->phone)
|
||||
->where('project_id', $project->id)
|
||||
->whereNull('source_crm_id')
|
||||
->where('received_at', '>=', now()->subDay())
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
}
|
||||
if ($existingMergeable !== null) {
|
||||
// Заполняем supplier_lead.id у обоих SupplierLead → одному Deal
|
||||
DB::table('supplier_lead_deliveries')->insert([
|
||||
'supplier_lead_id' => $lead->id,
|
||||
'tenant_id' => $tenant->id,
|
||||
'deal_id' => $existingMergeable->id,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
// Обновляем source_crm_id и опционально received_at через
|
||||
// DB::table (надёжнее Eloquent save() на партиционированной таблице).
|
||||
$newReceivedAt = ($lead->received_at !== null && $lead->received_at->gt($existingMergeable->received_at))
|
||||
? $lead->received_at
|
||||
: null;
|
||||
$updateData = ['source_crm_id' => $lead->vid, 'updated_at' => now()];
|
||||
if ($newReceivedAt !== null) {
|
||||
$updateData['received_at'] = $newReceivedAt;
|
||||
}
|
||||
DB::table('deals')
|
||||
->where('id', $existingMergeable->id)
|
||||
->where('received_at', $existingMergeable->received_at)
|
||||
->update($updateData);
|
||||
|
||||
Log::info('supplier_lead.merged_into_csv_recovered', [
|
||||
'supplier_lead_id' => $lead->id,
|
||||
'merged_into_deal_id' => $existingMergeable->id,
|
||||
'tenant_id' => $tenant->id,
|
||||
]);
|
||||
|
||||
return true; // считаем «доставленным», но без второго списания
|
||||
}
|
||||
|
||||
// Spec B: per-(supplier_lead, tenant) lock — одна поставка одному клиенту = один раз.
|
||||
// insertOrIgnore вернёт 0, если строка уже существует (повтор/гонка/CSV-recovery).
|
||||
$locked = DB::table('supplier_lead_deliveries')->insertOrIgnore([
|
||||
|
||||
@@ -231,14 +231,23 @@ final class CsvReconcileJob implements ShouldQueue
|
||||
}
|
||||
|
||||
/**
|
||||
* Извлекает platform (B1/B2/B3) из имени проекта формата `B[123]_<rest>`.
|
||||
* Возвращает null если не парсится — caller пропустит строку с warning.
|
||||
* Извлекает platform из имени проекта:
|
||||
* - `B[123]_<rest>` → 'B1' / 'B2' / 'B3';
|
||||
* - Phase 3: иначе, если строка непустая и состоит из identifier-символов
|
||||
* (домены / телефоны / SMS-отправители) → 'DIRECT';
|
||||
* - откровенный мусор (только спец-символы, пусто) → null (unparseable).
|
||||
*/
|
||||
private function extractPlatform(string $project): ?string
|
||||
{
|
||||
if (preg_match('/^(B[123])_/', $project, $m) === 1) {
|
||||
return $m[1];
|
||||
}
|
||||
// Phase 3: всё что выглядит как разумный identifier (домен / телефон / SMS-sender) → DIRECT.
|
||||
// unparseable_count теперь только для откровенного мусора (пустые / только спец-символы).
|
||||
$trimmed = trim($project);
|
||||
if ($trimmed !== '' && preg_match('/^[\w\-.а-яА-Я0-9\/() +]+$/u', $trimmed) === 1) {
|
||||
return 'DIRECT';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -128,10 +128,17 @@ final class LedgerService
|
||||
{
|
||||
if ($lead->supplier_project_id !== null) {
|
||||
$sp = DB::table('supplier_projects')->where('id', $lead->supplier_project_id)->first();
|
||||
if ($sp !== null && in_array($sp->platform, ['B1', 'B2', 'B3'], true)) {
|
||||
$supplier = Supplier::where('code', strtolower($sp->platform))->first();
|
||||
if ($supplier !== null) {
|
||||
return (int) $supplier->id;
|
||||
if ($sp !== null) {
|
||||
if (in_array($sp->platform, ['B1', 'B2', 'B3'], true)) {
|
||||
$supplier = Supplier::where('code', strtolower($sp->platform))->first();
|
||||
if ($supplier !== null) {
|
||||
return (int) $supplier->id;
|
||||
}
|
||||
}
|
||||
if ($sp->platform === 'DIRECT') {
|
||||
$supplier = Supplier::where('code', 'direct')->first();
|
||||
|
||||
return $supplier?->id;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -143,6 +150,12 @@ final class LedgerService
|
||||
|
||||
return $supplier?->id;
|
||||
}
|
||||
// Phase 3: project без B-префикса (и не пустой) → DIRECT.
|
||||
if ($project !== '') {
|
||||
$supplier = Supplier::where('code', 'direct')->first();
|
||||
|
||||
return $supplier?->id;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -47,6 +47,39 @@ class LeadRouter
|
||||
// МСК-aligned ISO day-of-week (reset-cron тоже 00:00 МСК).
|
||||
$todayBit = 1 << (Carbon::now('Europe/Moscow')->isoWeekday() - 1);
|
||||
|
||||
// Phase 3: для DIRECT-supplier_project — fallback на signal_type+signal_identifier
|
||||
// match с Лидерра-проектами, потому что project_supplier_links для DIRECT-row'ов
|
||||
// не создаются (новые DIRECT supplier_projects создаются автоматически при
|
||||
// получении webhook'а без B-префикса; explicit psl-link для них не настраивается).
|
||||
if ($supplierProject->platform === 'DIRECT') {
|
||||
$directSql = <<<'SQL'
|
||||
SELECT DISTINCT ON (projects.tenant_id) projects.*
|
||||
FROM projects
|
||||
WHERE projects.signal_type = ?
|
||||
AND LOWER(projects.signal_identifier) = LOWER(?)
|
||||
AND projects.is_active = true
|
||||
AND (projects.delivery_days_mask & ?) <> 0
|
||||
AND projects.delivered_today < COALESCE(projects.effective_daily_limit_today, projects.daily_limit_target)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM tenants
|
||||
WHERE tenants.id = projects.tenant_id
|
||||
AND (tenants.balance_leads > 0 OR tenants.balance_rub > 0)
|
||||
)
|
||||
ORDER BY
|
||||
projects.tenant_id,
|
||||
(COALESCE(projects.effective_daily_limit_today, projects.daily_limit_target) - projects.delivered_today) DESC,
|
||||
projects.created_at,
|
||||
projects.id
|
||||
SQL;
|
||||
$directRows = DB::connection('pgsql_supplier')->select(
|
||||
$directSql,
|
||||
[$supplierProject->signal_type, $supplierProject->unique_key, $todayBit]
|
||||
);
|
||||
|
||||
return Project::hydrate($directRows)->values();
|
||||
}
|
||||
|
||||
// Existing B1/B2/B3 path — explicit project_supplier_links pivot.
|
||||
$sql = <<<'SQL'
|
||||
SELECT DISTINCT ON (projects.tenant_id) projects.*
|
||||
FROM projects
|
||||
|
||||
@@ -21,7 +21,7 @@ use InvalidArgumentException;
|
||||
*/
|
||||
class SupplierProjectResolver
|
||||
{
|
||||
private const ALLOWED_PLATFORMS = ['B1', 'B2', 'B3'];
|
||||
private const ALLOWED_PLATFORMS = ['B1', 'B2', 'B3', 'DIRECT'];
|
||||
|
||||
private const ALLOWED_SIGNAL_TYPES = ['site', 'call', 'sms'];
|
||||
|
||||
|
||||
@@ -47,4 +47,18 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
|
||||
return null; // default render for non-JSON
|
||||
});
|
||||
|
||||
// Supplier webhook always returns JSON, even when client omits Accept header.
|
||||
// Without this render, Laravel's default ValidationException handler returns
|
||||
// 302 redirect to /, which strips POST body — losing supplier leads.
|
||||
// Confirmed 2026-05-25: 76 of 234 webhook hits today got 302 instead of 422.
|
||||
$exceptions->render(function (\Illuminate\Validation\ValidationException $e, Request $request) {
|
||||
if ($request->is('api/webhook/supplier/*')) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed',
|
||||
'errors' => $e->errors(),
|
||||
], 422);
|
||||
}
|
||||
return null; // default render for other routes
|
||||
});
|
||||
})->create();
|
||||
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Phase 3 supplier webhook reliability — расширяет platform enum в
|
||||
* supplier_projects и project_supplier_links до (B1,B2,B3,DIRECT).
|
||||
*
|
||||
* DIRECT — это «прямая» платформа поставщика без B-префикса в имени
|
||||
* проекта (e.g. `client.carmoney.ru`, `cashmotor.ru`, числовые телефоны).
|
||||
* До Phase 3 такие webhook'и отвергались с 302-редиректом и терялись:
|
||||
* наблюдалось 67 потерь/день на проде 25.05.2026 для tenant client1.
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-25-supplier-webhook-reliability-design.md §3 Phase 3
|
||||
*
|
||||
* NB: chk_supplier_projects_b1_not_for_sms (B1+SMS deny) НЕ трогаем —
|
||||
* DIRECT+SMS этим constraint'ом не блокируется (он специфичен для B1).
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
// 1) Расширить platform-колонки до VARCHAR(8) (было VARCHAR(4): "DIRECT" не вмещается).
|
||||
// supplier_manual_sync_queue.platform уже VARCHAR(8) — пропускаем.
|
||||
DB::statement('ALTER TABLE supplier_projects ALTER COLUMN platform TYPE VARCHAR(8)');
|
||||
DB::statement('ALTER TABLE project_supplier_links ALTER COLUMN platform TYPE VARCHAR(8)');
|
||||
DB::statement('ALTER TABLE supplier_leads ALTER COLUMN platform TYPE VARCHAR(8)');
|
||||
|
||||
// 2) Расширить CHECK constraints на enum значения.
|
||||
DB::statement('ALTER TABLE supplier_projects DROP CONSTRAINT chk_supplier_projects_platform');
|
||||
DB::statement("ALTER TABLE supplier_projects ADD CONSTRAINT chk_supplier_projects_platform CHECK (platform IN ('B1','B2','B3','DIRECT'))");
|
||||
|
||||
DB::statement('ALTER TABLE project_supplier_links DROP CONSTRAINT chk_psl_platform');
|
||||
DB::statement("ALTER TABLE project_supplier_links ADD CONSTRAINT chk_psl_platform CHECK (platform IN ('B1','B2','B3','DIRECT'))");
|
||||
|
||||
DB::statement('ALTER TABLE supplier_leads DROP CONSTRAINT chk_supplier_leads_platform');
|
||||
DB::statement("ALTER TABLE supplier_leads ADD CONSTRAINT chk_supplier_leads_platform CHECK (platform IN ('B1','B2','B3','DIRECT'))");
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
// Перед откатом — убедиться что в БД нет rows с platform='DIRECT',
|
||||
// иначе constraint провалится при ADD. Это ответственность того, кто
|
||||
// запускает migrate:rollback. На prod — отдельный cleanup SQL до отката:
|
||||
// DELETE FROM project_supplier_links WHERE platform='DIRECT';
|
||||
// DELETE FROM supplier_projects WHERE platform='DIRECT';
|
||||
// DELETE FROM supplier_leads WHERE platform='DIRECT';
|
||||
DB::statement('ALTER TABLE supplier_projects DROP CONSTRAINT chk_supplier_projects_platform');
|
||||
DB::statement("ALTER TABLE supplier_projects ADD CONSTRAINT chk_supplier_projects_platform CHECK (platform IN ('B1','B2','B3'))");
|
||||
|
||||
DB::statement('ALTER TABLE project_supplier_links DROP CONSTRAINT chk_psl_platform');
|
||||
DB::statement("ALTER TABLE project_supplier_links ADD CONSTRAINT chk_psl_platform CHECK (platform IN ('B1','B2','B3'))");
|
||||
|
||||
DB::statement('ALTER TABLE supplier_leads DROP CONSTRAINT chk_supplier_leads_platform');
|
||||
DB::statement("ALTER TABLE supplier_leads ADD CONSTRAINT chk_supplier_leads_platform CHECK (platform IN ('B1','B2','B3'))");
|
||||
|
||||
// Сужение TYPE обратно к VARCHAR(4) — только если все значения помещаются (B1/B2/B3 = 2 символа).
|
||||
DB::statement('ALTER TABLE supplier_leads ALTER COLUMN platform TYPE VARCHAR(4)');
|
||||
DB::statement('ALTER TABLE project_supplier_links ALTER COLUMN platform TYPE VARCHAR(4)');
|
||||
DB::statement('ALTER TABLE supplier_projects ALTER COLUMN platform TYPE VARCHAR(4)');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Phase 3 — DIRECT supplier row (used by LedgerService::resolveSupplierId
|
||||
* fallback for platform='DIRECT'). cost_rub matches B1 (same supplier,
|
||||
* different routing).
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-25-supplier-webhook-reliability-design.md §3 Phase 3
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
$b1 = DB::table('suppliers')->where('code', 'b1')->first();
|
||||
if ($b1 === null) {
|
||||
// Если B1 нет — significant prod drift, не должно произойти.
|
||||
// Создаём с дефолтным cost_rub=1.00 (как на prod 25.05.2026).
|
||||
$costRub = '1.00';
|
||||
} else {
|
||||
$costRub = (string) $b1->cost_rub;
|
||||
}
|
||||
|
||||
// Используем raw SQL чтобы корректно сериализовать PG-array для accepts_types.
|
||||
DB::insert(
|
||||
"INSERT INTO suppliers (code, name, accepts_types, cost_rub, channel, is_active, sort_order, created_at)
|
||||
VALUES (?, ?, ARRAY['websites','calls','sms'], ?, ?, true, 4, NOW())
|
||||
ON CONFLICT (code) DO NOTHING",
|
||||
[
|
||||
'direct',
|
||||
'DIRECT — Прямые проекты',
|
||||
$costRub,
|
||||
'sites', // принимает любые сигналы; channel='sites' допустим в suppliers_channel_check
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
DB::table('suppliers')->where('code', 'direct')->delete();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\SystemSetting;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
beforeEach(function () {
|
||||
SystemSetting::query()
|
||||
->where('key', 'supplier_webhook_secret')
|
||||
->update(['value' => 'test-secret-32chars-aaaaaaaaaaaaaa']);
|
||||
SystemSetting::query()
|
||||
->where('key', 'supplier_ip_allowlist')
|
||||
->update(['value' => '[]']);
|
||||
});
|
||||
|
||||
it('returns 422 JSON when supplier posts invalid payload WITHOUT Accept: application/json header', function () {
|
||||
// Воспроизводит реальное поведение crm.bp-gr.ru: POST без Accept-JSON.
|
||||
// До фикса (302→422) Laravel редиректил на / с Set-Cookie, поставщик
|
||||
// терял тело запроса. После фикса всегда JSON.
|
||||
$response = $this->call(
|
||||
'POST',
|
||||
'/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa',
|
||||
[], // params
|
||||
[], // cookies
|
||||
[], // files
|
||||
['HTTP_CONTENT_TYPE' => 'application/x-www-form-urlencoded'], // server: НЕТ Accept JSON
|
||||
http_build_query([
|
||||
'vid' => 1,
|
||||
'project' => 'invalid_no_b_prefix',
|
||||
'phone' => '79991234567',
|
||||
'time' => time(),
|
||||
])
|
||||
);
|
||||
|
||||
$response->assertStatus(422);
|
||||
expect($response->headers->get('Content-Type'))->toContain('application/json');
|
||||
$response->assertJsonStructure(['message', 'errors' => ['project']]);
|
||||
});
|
||||
|
||||
it('still works correctly for postJson clients (regression)', function () {
|
||||
$response = $this->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [
|
||||
'vid' => 1,
|
||||
'project' => 'invalid_no_b_prefix',
|
||||
'phone' => '79991234567',
|
||||
'time' => time(),
|
||||
]);
|
||||
|
||||
$response->assertStatus(422)->assertJsonValidationErrors('project');
|
||||
});
|
||||
|
||||
it('non-webhook routes still use default render (no JSON forced)', function () {
|
||||
// Регрессионный тест: дефолтный render остальных routes не сломан
|
||||
// (например /login — должен возвращать redirect, а не JSON).
|
||||
$response = $this->call(
|
||||
'POST',
|
||||
'/login',
|
||||
['email' => 'bad', 'password' => ''],
|
||||
[], [], [],
|
||||
);
|
||||
// Любой не-200 кроме 422-JSON допустим — главное чтобы наш fix не перехватил
|
||||
expect($response->headers->get('Content-Type'))->not->toContain('application/json');
|
||||
});
|
||||
@@ -272,14 +272,16 @@ it('unparseable CSV rows excluded from drift: 100 matched + 10 junk-project rows
|
||||
]);
|
||||
}
|
||||
|
||||
// CSV: те же 100 (matched) + 10 строк с мусорным project (extractPlatform = null).
|
||||
// Это реальный паттерн поставщика — телефон в поле «Name» вместо проекта (см. 22.05 в ПИЛОТ).
|
||||
// CSV: те же 100 (matched) + 10 строк с настоящим мусорным project (extractPlatform = null).
|
||||
// Phase 3 (2026-05-25): расширили DIRECT-распознавание — теперь цифровые callback-проекты
|
||||
// (79135551234) — валидный DIRECT, не junk. Реальный junk — это символы вне whitelist regex.
|
||||
$rows = [];
|
||||
for ($i = 0; $i < 100; $i++) {
|
||||
$rows[] = ['project' => 'B1_a.com', 'phone' => '79993'.str_pad((string) $i, 6, '0', STR_PAD_LEFT)];
|
||||
}
|
||||
for ($j = 0; $j < 10; $j++) {
|
||||
$rows[] = ['project' => '79135551234', 'phone' => '7999500000'.$j];
|
||||
$junkProjects = ['???', '!@#', '%%%', '$$$', '???!!!', '~~~', '***', '|||', '^^^', '&&&'];
|
||||
foreach ($junkProjects as $j => $junk) {
|
||||
$rows[] = ['project' => $junk, 'phone' => '7999500000'.$j];
|
||||
}
|
||||
fakeReportFlow(csvBody($rows));
|
||||
|
||||
@@ -314,8 +316,10 @@ it('mixed: 95 matched + 5 junk + 3 real-missing → unparseable_count=5, recover
|
||||
for ($i = 0; $i < 95; $i++) {
|
||||
$rows[] = ['project' => 'B1_a.com', 'phone' => '79994'.str_pad((string) $i, 6, '0', STR_PAD_LEFT)];
|
||||
}
|
||||
for ($j = 0; $j < 5; $j++) {
|
||||
$rows[] = ['project' => 'https://junk.example/'.$j, 'phone' => '7999600000'.$j];
|
||||
// Phase 3: реальный junk — символы вне whitelist (не \w/.-/cyrillic/digits/slash/parens/space/plus).
|
||||
$junkProjects = ['???', '!!!@@@', '%%%', '****', '???!!!'];
|
||||
foreach ($junkProjects as $j => $junk) {
|
||||
$rows[] = ['project' => $junk, 'phone' => '7999600000'.$j];
|
||||
}
|
||||
for ($k = 0; $k < 3; $k++) {
|
||||
$rows[] = ['project' => 'B1_a.com', 'phone' => '7999700000'.$k];
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\RouteSupplierLeadJob;
|
||||
use App\Models\Deal;
|
||||
use App\Models\LeadCharge;
|
||||
use App\Models\Project;
|
||||
use App\Models\SupplierLead;
|
||||
use App\Models\SupplierProject;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Billing\LedgerService;
|
||||
use App\Services\LeadDistributor;
|
||||
use App\Services\LeadRouter;
|
||||
use App\Services\NotificationService;
|
||||
use App\Services\RegionTagResolver;
|
||||
use App\Services\SupplierProjects\SupplierProjectResolver;
|
||||
use Database\Seeders\PricingTierSeeder;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
uses(SharesSupplierPdo::class);
|
||||
|
||||
/**
|
||||
* Phase 2 — webhook ↔ CSV-recovered idempotency.
|
||||
*
|
||||
* Сценарий (наблюдался на prod 2026-05-25, 37 дублей tenant client1):
|
||||
* 1. Поставщик шлёт webhook → 302 (теряется тело) — Phase 1 уже починила.
|
||||
* 2. CsvReconcileJob через 30 мин видит лид в CSV, не находит supplier_lead
|
||||
* по (phone, project) → создаёт recovered SupplierLead (vid=NULL,
|
||||
* source='csv_recovery') → RouteSupplierLeadJob → Deal с source_crm_id=NULL.
|
||||
* 3. Поставщик ретраит webhook (ещё 15 мин) → новый SupplierLead с vid=<int>
|
||||
* → RouteSupplierLeadJob → создаёт второй Deal с тем же phone+project
|
||||
* → биллинг списывает второй раз.
|
||||
*
|
||||
* Phase 2 fix: шаг 3 находит существующий CSV-recovered deal, обновляет
|
||||
* source_crm_id, привязывает webhook supplier_lead к существующему deal через
|
||||
* supplier_lead_deliveries, НЕ создаёт второй Deal, НЕ списывает повторно.
|
||||
*/
|
||||
|
||||
beforeEach(function (): void {
|
||||
$this->seed(PricingTierSeeder::class);
|
||||
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
|
||||
|
||||
// Shared supplier_project для всех тестов (B1, site, domain race-csv.ru).
|
||||
$this->sp = SupplierProject::factory()->create([
|
||||
'platform' => 'B1',
|
||||
'signal_type' => 'site',
|
||||
'unique_key' => 'race-csv.ru',
|
||||
]);
|
||||
|
||||
$this->tenant = Tenant::factory()->create([
|
||||
'balance_rub' => '10000.00',
|
||||
'delivered_in_month' => 0,
|
||||
]);
|
||||
|
||||
$this->project = Project::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'race-csv.ru',
|
||||
'supplier_b1_project_id' => $this->sp->id,
|
||||
'is_active' => true,
|
||||
'daily_limit_target' => 100,
|
||||
'effective_daily_limit_today' => 100,
|
||||
'delivered_today' => 0,
|
||||
'delivery_days_mask' => 127,
|
||||
'region_mask' => 255,
|
||||
]);
|
||||
|
||||
linkProjectToSupplier($this->project, $this->sp);
|
||||
});
|
||||
|
||||
/**
|
||||
* Dispatch helper — mirrors runRouteJob() / dispatchJob() from other test files.
|
||||
*/
|
||||
function runRaceJob(int $supplierLeadId): void
|
||||
{
|
||||
(new RouteSupplierLeadJob($supplierLeadId))->handle(
|
||||
app(LeadRouter::class),
|
||||
app(SupplierProjectResolver::class),
|
||||
app(NotificationService::class),
|
||||
app(LedgerService::class),
|
||||
app(LeadDistributor::class),
|
||||
app(RegionTagResolver::class),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 1 — Main bug reproduction: CSV-recovery followed by webhook retry
|
||||
// ДОЛЖЕН дать 1 deal + 1 charge (сейчас даёт 2+2 → FAILING).
|
||||
// ---------------------------------------------------------------------------
|
||||
it('webhook after CSV-recovered merges into existing deal (no duplicate, no double-charge)', function (): void {
|
||||
$phone = '79991000001';
|
||||
|
||||
// ── Step 1: CSV-recovered SupplierLead (vid=null, source='csv_recovery') ──
|
||||
// Это то, что CsvReconcileJob создаёт: звонок найден в CSV поставщика,
|
||||
// но настоящего webhook_log'а нет → вид неизвестен (vid=null).
|
||||
$csvLead = SupplierLead::factory()->create([
|
||||
'platform' => 'B1',
|
||||
'phone' => $phone,
|
||||
'vid' => null,
|
||||
'supplier_project_id' => $this->sp->id,
|
||||
'raw_payload' => [
|
||||
'project' => 'B1_race-csv.ru',
|
||||
'phone' => $phone,
|
||||
'time' => now()->subHour()->getTimestamp(),
|
||||
],
|
||||
'received_at' => now()->subHour(),
|
||||
'recovered_from_csv_at' => now()->subHour(),
|
||||
'source' => 'csv_recovery',
|
||||
'processed_at' => null,
|
||||
]);
|
||||
|
||||
// RouteSupplierLeadJob обрабатывает CSV-recovered лид → создаёт Deal с source_crm_id=NULL.
|
||||
runRaceJob($csvLead->id);
|
||||
|
||||
DB::statement("SET LOCAL app.current_tenant_id = '{$this->tenant->id}'");
|
||||
$csvDeal = Deal::where('phone', $phone)->first();
|
||||
expect($csvDeal)->not->toBeNull('CSV recovery должен был создать Deal');
|
||||
expect($csvDeal->source_crm_id)->toBeNull('CSV-recovered deal должен иметь source_crm_id=NULL');
|
||||
|
||||
$chargesAfterCsv = LeadCharge::where('deal_id', $csvDeal->id)->count();
|
||||
expect($chargesAfterCsv)->toBe(1, 'После CSV-recovery должен быть ровно 1 LeadCharge');
|
||||
|
||||
$balanceAfterCsv = (string) $this->tenant->fresh()->balance_rub;
|
||||
|
||||
// ── Step 2: поставщик ретраит webhook 15 мин спустя с настоящим vid ──
|
||||
// Это то, что создаёт дубль на проде: новый SupplierLead с vid != null,
|
||||
// phone + project те же → RouteSupplierLeadJob создаёт ВТОРОЙ Deal.
|
||||
$webhookLead = SupplierLead::factory()->create([
|
||||
'platform' => 'B1',
|
||||
'phone' => $phone,
|
||||
'vid' => 1672819986,
|
||||
'supplier_project_id' => $this->sp->id,
|
||||
'raw_payload' => [
|
||||
'vid' => 1672819986,
|
||||
'project' => 'B1_race-csv.ru',
|
||||
'phone' => $phone,
|
||||
'time' => now()->subMinutes(15)->getTimestamp(),
|
||||
],
|
||||
'received_at' => now()->subMinutes(15),
|
||||
'source' => 'webhook',
|
||||
'processed_at' => null,
|
||||
]);
|
||||
|
||||
runRaceJob($webhookLead->id);
|
||||
|
||||
DB::statement("SET LOCAL app.current_tenant_id = '{$this->tenant->id}'");
|
||||
|
||||
// ── Assertions ──
|
||||
|
||||
// Assertion 1: по-прежнему ОДИН deal, но source_crm_id теперь заполнен.
|
||||
$deals = Deal::where('phone', $phone)->get();
|
||||
expect($deals)->toHaveCount(1, 'Phase 2: webhook после CSV-recovery должен ОБНОВИТЬ существующий deal, а не создать второй');
|
||||
expect($deals->first()->source_crm_id)->toBe(1672819986, 'source_crm_id должен быть обновлён от webhook vid');
|
||||
|
||||
// Assertion 2: НЕТ второго LeadCharge — биллинг не списывается дважды.
|
||||
$chargesAfterWebhook = LeadCharge::where('deal_id', $csvDeal->id)->count();
|
||||
expect($chargesAfterWebhook)->toBe(1, 'Phase 2: второй LeadCharge создан не должен быть');
|
||||
|
||||
// Assertion 3: баланс НЕ списан второй раз.
|
||||
$balanceAfterWebhook = (string) $this->tenant->fresh()->balance_rub;
|
||||
expect($balanceAfterWebhook)->toBe($balanceAfterCsv, 'Phase 2: баланс после webhook не должен уменьшиться');
|
||||
|
||||
// Assertion 4: supplier_lead_deliveries содержит ОБА supplier_lead_id,
|
||||
// привязанных к ОДНОМУ deal_id.
|
||||
$deliveries = DB::table('supplier_lead_deliveries')
|
||||
->where('deal_id', $csvDeal->id)
|
||||
->get();
|
||||
expect($deliveries)->toHaveCount(2, 'Оба SupplierLead (csv + webhook) должны быть в supplier_lead_deliveries');
|
||||
$deliveredLeadIds = $deliveries->pluck('supplier_lead_id')->sort()->values()->all();
|
||||
expect($deliveredLeadIds)->toContain($csvLead->id);
|
||||
expect($deliveredLeadIds)->toContain($webhookLead->id);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 2 — Spec B regression: два webhook с РАЗНЫМИ vid → два deal (by-design).
|
||||
// Наш Phase 2 fix НЕ должен блокировать это.
|
||||
// ---------------------------------------------------------------------------
|
||||
it('two webhooks with DIFFERENT vids both create deals (Spec B — за повторы поставщика берём)', function (): void {
|
||||
$phone = '79991000002';
|
||||
|
||||
// Первый webhook, vid=100.
|
||||
$lead1 = SupplierLead::factory()->create([
|
||||
'platform' => 'B1',
|
||||
'phone' => $phone,
|
||||
'vid' => 100,
|
||||
'supplier_project_id' => $this->sp->id,
|
||||
'raw_payload' => [
|
||||
'vid' => 100,
|
||||
'project' => 'B1_race-csv.ru',
|
||||
'phone' => $phone,
|
||||
'time' => now()->subHour()->getTimestamp(),
|
||||
],
|
||||
'received_at' => now()->subHour(),
|
||||
'source' => 'webhook',
|
||||
'processed_at' => null,
|
||||
]);
|
||||
runRaceJob($lead1->id);
|
||||
|
||||
// Второй webhook, vid=200 (другой лид поставщика, тот же телефон+проект).
|
||||
$lead2 = SupplierLead::factory()->create([
|
||||
'platform' => 'B1',
|
||||
'phone' => $phone,
|
||||
'vid' => 200,
|
||||
'supplier_project_id' => $this->sp->id,
|
||||
'raw_payload' => [
|
||||
'vid' => 200,
|
||||
'project' => 'B1_race-csv.ru',
|
||||
'phone' => $phone,
|
||||
'time' => now()->subMinutes(30)->getTimestamp(),
|
||||
],
|
||||
'received_at' => now()->subMinutes(30),
|
||||
'source' => 'webhook',
|
||||
'processed_at' => null,
|
||||
]);
|
||||
runRaceJob($lead2->id);
|
||||
|
||||
DB::statement("SET LOCAL app.current_tenant_id = '{$this->tenant->id}'");
|
||||
|
||||
// Spec B: оба webhook'а имеют source_crm_id != null.
|
||||
// Условие merge (source_crm_id IS NULL) не срабатывает → два deal,
|
||||
// два LeadCharge. Spec B Phase 1 (commit ccfecd5e) за повторы поставщика берём.
|
||||
$deals = Deal::where('phone', $phone)->get();
|
||||
expect($deals)->toHaveCount(2, 'Два webhook с разными vid должны создавать два deal (Spec B)');
|
||||
$sourceCrmIds = $deals->pluck('source_crm_id')->sort()->values()->all();
|
||||
expect($sourceCrmIds)->toContain(100);
|
||||
expect($sourceCrmIds)->toContain(200);
|
||||
expect(LeadCharge::whereIn('deal_id', $deals->pluck('id'))->count())->toBe(2);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 3 — Boundary: CSV-recovered deal старше 24h НЕ мержится с новым webhook.
|
||||
// Окно merge — 24h. Старый лид не считается «активным» duplicate.
|
||||
// ---------------------------------------------------------------------------
|
||||
it('csv-recovered deal older than 24h is NOT merged with new webhook', function (): void {
|
||||
$phone = '79991000003';
|
||||
|
||||
// CSV-recovered SupplierLead, обработанный 2 дня назад.
|
||||
$csvLead = SupplierLead::factory()->create([
|
||||
'platform' => 'B1',
|
||||
'phone' => $phone,
|
||||
'vid' => null,
|
||||
'supplier_project_id' => $this->sp->id,
|
||||
'raw_payload' => [
|
||||
'project' => 'B1_race-csv.ru',
|
||||
'phone' => $phone,
|
||||
'time' => now()->subDays(2)->getTimestamp(),
|
||||
],
|
||||
'received_at' => now()->subDays(2),
|
||||
'recovered_from_csv_at' => now()->subDays(2),
|
||||
'source' => 'csv_recovery',
|
||||
'processed_at' => null,
|
||||
]);
|
||||
runRaceJob($csvLead->id);
|
||||
|
||||
DB::statement("SET LOCAL app.current_tenant_id = '{$this->tenant->id}'");
|
||||
$csvDeal = Deal::where('phone', $phone)->first();
|
||||
expect($csvDeal)->not->toBeNull('CSV-recovered deal должен существовать');
|
||||
|
||||
// Сбросим processed_at у tenant-level проекта: delivered_today накопился,
|
||||
// нужно сбросить счётчик чтобы второй deal тоже прошёл лимит.
|
||||
$this->project->update(['delivered_today' => 0]);
|
||||
|
||||
// Webhook приходит сейчас — deal CSV-recovery старше 24h → не мержится.
|
||||
$webhookLead = SupplierLead::factory()->create([
|
||||
'platform' => 'B1',
|
||||
'phone' => $phone,
|
||||
'vid' => 999,
|
||||
'supplier_project_id' => $this->sp->id,
|
||||
'raw_payload' => [
|
||||
'vid' => 999,
|
||||
'project' => 'B1_race-csv.ru',
|
||||
'phone' => $phone,
|
||||
'time' => now()->getTimestamp(),
|
||||
],
|
||||
'received_at' => now(),
|
||||
'source' => 'webhook',
|
||||
'processed_at' => null,
|
||||
]);
|
||||
runRaceJob($webhookLead->id);
|
||||
|
||||
DB::statement("SET LOCAL app.current_tenant_id = '{$this->tenant->id}'");
|
||||
|
||||
// Два deal: старый CSV-recovered (2 дня назад) + новый от webhook.
|
||||
// Merge НЕ происходит — CSV-recovered вне 24h окна.
|
||||
$deals = Deal::where('phone', $phone)->get();
|
||||
expect($deals)->toHaveCount(2, 'CSV-recovered deal старше 24h — merge не происходит, создаётся новый deal от webhook');
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\RouteSupplierLeadJob;
|
||||
use App\Models\Deal;
|
||||
use App\Models\Project;
|
||||
use App\Models\Supplier;
|
||||
use App\Models\SupplierLead;
|
||||
use App\Models\SupplierProject;
|
||||
use App\Models\SystemSetting;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Billing\LedgerService;
|
||||
use App\Services\LeadDistributor;
|
||||
use App\Services\LeadRouter;
|
||||
use App\Services\NotificationService;
|
||||
use App\Services\RegionTagResolver;
|
||||
use App\Services\SupplierProjects\SupplierProjectResolver;
|
||||
use Database\Seeders\PricingTierSeeder;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
uses(SharesSupplierPdo::class);
|
||||
|
||||
/**
|
||||
* Phase 3 — DIRECT platform end-to-end.
|
||||
*
|
||||
* Supplier crm.bp-gr.ru шлёт часть лидов на проекты БЕЗ B[123]_ префикса
|
||||
* (e.g. `client.carmoney.ru`, `cashmotor.ru`, числовой callback `79135191264`).
|
||||
* До Phase 3 такие webhook'и отвергались с 302 redirect и терялись —
|
||||
* наблюдалось 67 потерь/день для tenant client1 на проде 25.05.2026.
|
||||
*
|
||||
* Phase 3 принимает их как platform='DIRECT' end-to-end:
|
||||
* - controller regex снят, parsePlatform возвращает 'DIRECT' для не-B;
|
||||
* - SupplierProjectResolver принимает DIRECT;
|
||||
* - RouteSupplierLeadJob.parseProjectField парсит без B-префикса;
|
||||
* - LeadRouter для DIRECT использует signal_type+identifier match напрямую
|
||||
* (без project_supplier_links pivot — psl-rows для DIRECT не созданы).
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-25-supplier-webhook-reliability-design.md §3 Phase 3
|
||||
*/
|
||||
|
||||
beforeEach(function (): void {
|
||||
$this->seed(PricingTierSeeder::class);
|
||||
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
|
||||
|
||||
SystemSetting::query()
|
||||
->where('key', 'supplier_webhook_secret')
|
||||
->update(['value' => 'test-secret-32chars-aaaaaaaaaaaaaa']);
|
||||
SystemSetting::query()
|
||||
->where('key', 'supplier_ip_allowlist')
|
||||
->update(['value' => '[]']);
|
||||
});
|
||||
|
||||
function directDispatchJob(int $supplierLeadId): void
|
||||
{
|
||||
(new RouteSupplierLeadJob($supplierLeadId))->handle(
|
||||
app(LeadRouter::class),
|
||||
app(SupplierProjectResolver::class),
|
||||
app(NotificationService::class),
|
||||
app(LedgerService::class),
|
||||
app(LeadDistributor::class),
|
||||
app(RegionTagResolver::class),
|
||||
);
|
||||
}
|
||||
|
||||
it('webhook with non-B-prefix project is accepted (202) and platform=DIRECT', function (): void {
|
||||
$response = $this->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [
|
||||
'vid' => 9999001,
|
||||
'project' => 'client.carmoney.ru',
|
||||
'phone' => '79991234567',
|
||||
'time' => time(),
|
||||
]);
|
||||
|
||||
$response->assertStatus(202);
|
||||
$lead = SupplierLead::where('vid', 9999001)->first();
|
||||
expect($lead)->not->toBeNull();
|
||||
expect($lead->platform)->toBe('DIRECT');
|
||||
});
|
||||
|
||||
it('SupplierProjectResolver creates DIRECT supplier_project for non-B project', function (): void {
|
||||
$resolver = app(SupplierProjectResolver::class);
|
||||
$sp = $resolver->resolveOrStub('DIRECT', 'site', 'client.carmoney.ru');
|
||||
|
||||
expect($sp->platform)->toBe('DIRECT');
|
||||
expect($sp->unique_key)->toBe('client.carmoney.ru');
|
||||
expect($sp->signal_type)->toBe('site');
|
||||
});
|
||||
|
||||
it('RouteSupplierLeadJob delivers DIRECT lead to matching project via signal_identifier fallback', function (): void {
|
||||
// Создаём Лидерра-проект с тем же signal_identifier, что и DIRECT-supplier_project.
|
||||
// ВАЖНО: НЕ создаём project_supplier_links — Phase 3 fallback должен матчить
|
||||
// только по signal_type+signal_identifier.
|
||||
$tenant = Tenant::factory()->create([
|
||||
'balance_leads' => 0,
|
||||
'balance_rub' => '1000.00',
|
||||
'delivered_in_month' => 0,
|
||||
]);
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'client.carmoney.ru',
|
||||
'is_active' => true,
|
||||
'daily_limit_target' => 10,
|
||||
'effective_daily_limit_today' => 10,
|
||||
'delivered_today' => 0,
|
||||
'delivery_days_mask' => 127,
|
||||
'region_mask' => 255,
|
||||
]);
|
||||
|
||||
$lead = SupplierLead::factory()->create([
|
||||
'platform' => 'DIRECT',
|
||||
'phone' => '79991234567',
|
||||
'vid' => 9999002,
|
||||
'raw_payload' => ['vid' => 9999002, 'project' => 'client.carmoney.ru', 'phone' => '79991234567', 'time' => time()],
|
||||
'received_at' => now(),
|
||||
]);
|
||||
|
||||
directDispatchJob($lead->id);
|
||||
|
||||
$deal = Deal::where('tenant_id', $tenant->id)
|
||||
->where('phone', '79991234567')
|
||||
->first();
|
||||
expect($deal)->not->toBeNull();
|
||||
expect($deal->project_id)->toBe($project->id);
|
||||
expect($deal->source_crm_id)->toBe(9999002);
|
||||
});
|
||||
|
||||
it('numeric-only project (e.g. 79135191264 callback) accepted as DIRECT', function (): void {
|
||||
// Поставщик иногда шлёт project=телефонный номер для callback-проектов.
|
||||
$response = $this->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [
|
||||
'vid' => 9999003,
|
||||
'project' => '79135191264',
|
||||
'phone' => '79991234567',
|
||||
'time' => time(),
|
||||
]);
|
||||
|
||||
$response->assertStatus(202);
|
||||
$lead = SupplierLead::where('vid', 9999003)->first();
|
||||
expect($lead->platform)->toBe('DIRECT');
|
||||
});
|
||||
|
||||
it('existing B1 webhooks still work as platform=B1 (regression)', function (): void {
|
||||
$response = $this->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [
|
||||
'vid' => 9999004,
|
||||
'project' => 'B1_krk-finance.ru',
|
||||
'phone' => '79991234567',
|
||||
'time' => time(),
|
||||
]);
|
||||
|
||||
$response->assertStatus(202);
|
||||
expect(SupplierLead::where('vid', 9999004)->first()->platform)->toBe('B1');
|
||||
});
|
||||
|
||||
it('SupplierProjectResolver still rejects unknown platforms other than DIRECT', function (): void {
|
||||
$resolver = app(SupplierProjectResolver::class);
|
||||
|
||||
expect(fn () => $resolver->resolveOrStub('UNKNOWN', 'site', 'foo.ru'))
|
||||
->toThrow(InvalidArgumentException::class);
|
||||
});
|
||||
+38
-1
@@ -2,7 +2,44 @@
|
||||
|
||||
**Назначение:** консолидированный журнал изменений `schema.sql`. Содержит тридцать записей в обратном хронологическом порядке (v8.33 → v8.32 → v8.31 → v8.30 → v8.29 → v8.28 → v8.27 → v8.26 → v8.25 → v8.24 → v8.23 → v8.22 → v8.21 → v8.20 → v8.19 → v8.18 → v8.17 → v8.16 → v8.15 → v8.14 → v8.13 → v8.12 → v8.11 → v8.10 → v8.9 → v8.8 → v8.7 → v8.6 → v8.5 → v8.4 → v8.3 → v8.2), как принято в keep-a-changelog.
|
||||
|
||||
**Файл схемы:** `schema.sql` (текущая версия — v8.36, консолидированная — разворачивает БД с нуля).
|
||||
**Файл схемы:** `schema.sql` (текущая версия — v8.37, консолидированная — разворачивает БД с нуля).
|
||||
|
||||
## v8.37 (2026-05-25) — supplier_*.platform: VARCHAR(4)→VARCHAR(8) + ENUM расширен на DIRECT
|
||||
|
||||
Phase 3 supplier webhook reliability — приём проектов без B[123]_ префикса как
|
||||
платформа `DIRECT`. На проде 25.05.2026 для tenant `client1` зафиксировано ~67
|
||||
потерянных лидов/сутки из-за того, что webhook-validation regex `'^B[123]_.+$'`
|
||||
отвергал проекты вида `client.carmoney.ru`, `cashmotor.ru`, `cabinet.caranga.ru`
|
||||
и числовые callback-IDs. Phase 3 принимает их end-to-end под новой платформой `DIRECT`.
|
||||
|
||||
**Изменено:**
|
||||
|
||||
- **`supplier_projects.platform` VARCHAR(4)→VARCHAR(8)** — `DIRECT` (6 символов) не вмещался.
|
||||
- **`project_supplier_links.platform` VARCHAR(4)→VARCHAR(8)** — то же.
|
||||
- **`supplier_leads.platform` VARCHAR(4)→VARCHAR(8)** — то же.
|
||||
- **`chk_supplier_projects_platform`**: `IN ('B1','B2','B3')` → `IN ('B1','B2','B3','DIRECT')`.
|
||||
- **`chk_psl_platform`**: то же расширение enum.
|
||||
- **`chk_supplier_leads_platform`**: то же расширение enum.
|
||||
|
||||
**Добавлено:**
|
||||
|
||||
- **`suppliers` row `code='direct'`** — `DIRECT — Прямые проекты`, `cost_rub=1.00`,
|
||||
`accepts_types={websites,calls,sms}`, `channel='sites'`. Используется
|
||||
`LedgerService::resolveSupplierId` fallback'ом для DIRECT-платформенных лидов.
|
||||
|
||||
**Не изменено:**
|
||||
|
||||
- `chk_supplier_projects_b1_not_for_sms` — деноминирует B1+SMS, DIRECT+SMS не блокирует.
|
||||
- Индексы, FK, RLS-политики — без изменений.
|
||||
|
||||
**Метрики:** 0 новых таблиц, 0 новых индексов; 3 CHECK расширены, 3 колонки расширены, 1 seed-row.
|
||||
|
||||
**Миграции:**
|
||||
|
||||
- `2026_05_25_120000_add_direct_platform_to_supplier_projects` — DDL (idempotent через DROP+ADD CHECK).
|
||||
- `2026_05_25_120100_seed_direct_supplier` — seed `suppliers.code='direct'` через raw SQL INSERT ON CONFLICT DO NOTHING.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-05-25-supplier-webhook-reliability-design.md` §3 Phase 3.
|
||||
|
||||
## v8.36 (2026-05-25) — supplier_csv_reconcile_log.unparseable_count: drift-формула без junk-строк
|
||||
|
||||
|
||||
+8
-7
@@ -1,6 +1,7 @@
|
||||
-- =============================================================================
|
||||
-- schema.sql — единая схема БД для SaaS-аналога crm.bp-gr.ru («Лидерра»)
|
||||
-- Версия: v8.36 (25.05.2026 — supplier_csv_reconcile_log.unparseable_count: учёт мусорных CSV-строк, вычитание из drift-формулы → убирает false-positive drift_alert от телефонов/URL в поле project)
|
||||
-- Версия: v8.37 (25.05.2026 — supplier_*.platform VARCHAR(4)→VARCHAR(8) + chk_supplier_projects_platform / chk_psl_platform / chk_supplier_leads_platform расширены до IN(B1,B2,B3,DIRECT); +seed suppliers.code='direct'. Phase 3 supplier webhook reliability — приём проектов без B-префикса end-to-end)
|
||||
-- Базовая версия: v8.36 (25.05.2026 — supplier_csv_reconcile_log.unparseable_count: учёт мусорных CSV-строк, вычитание из drift-формулы → убирает false-positive drift_alert от телефонов/URL в поле project)
|
||||
-- Базовая версия: v8.35 (24.05.2026 — legacy direct webhook removal: DROP webhook_log (partitioned) + rejected_deals_log + tenants.webhook_token/webhook_token_rotated_at; webhook_dedup_keys сохранена (CSV-канал))
|
||||
-- Базовая версия: v8.34 (23.05.2026 — Billing v2 Spec B: −индекс deals(duplicate_of_id) — телефонный дедуп удалён)
|
||||
-- Базовая версия: v8.31 (23.05.2026 — партиционирование 7 audit-таблиц помесячно (hole #2): auth_log / activity_log / tenant_operations_log / balance_transactions / pd_processing_log / saas_admin_audit_log; PK → (id, created_at|received_at); retention defaults в system_settings)
|
||||
@@ -907,7 +908,7 @@ COMMENT ON COLUMN projects.regions IS
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE TABLE supplier_projects (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
platform VARCHAR(4) NOT NULL, -- B1 / B2 / B3
|
||||
platform VARCHAR(8) NOT NULL, -- B1 / B2 / B3 / DIRECT (Phase 3, 2026-05-25)
|
||||
signal_type VARCHAR(16) NOT NULL, -- site / call / sms
|
||||
unique_key TEXT NOT NULL, -- domain / phone / sender+keyword / sender
|
||||
supplier_external_id VARCHAR(64), -- внутренний id у поставщика
|
||||
@@ -923,7 +924,7 @@ CREATE TABLE supplier_projects (
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
CONSTRAINT chk_supplier_projects_platform
|
||||
CHECK (platform IN ('B1','B2','B3')),
|
||||
CHECK (platform IN ('B1','B2','B3','DIRECT')),
|
||||
CONSTRAINT chk_supplier_projects_signal_type
|
||||
CHECK (signal_type IN ('site','call','sms')),
|
||||
CONSTRAINT chk_supplier_projects_sync_status
|
||||
@@ -964,10 +965,10 @@ CREATE TABLE project_supplier_links (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
supplier_project_id BIGINT NOT NULL REFERENCES supplier_projects(id) ON DELETE CASCADE,
|
||||
platform VARCHAR(4) NOT NULL,
|
||||
platform VARCHAR(8) NOT NULL, -- B1 / B2 / B3 / DIRECT (Phase 3, 2026-05-25)
|
||||
subject_code SMALLINT, -- субъект РФ 1..89; NULL = пул «Вся РФ»
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT chk_psl_platform CHECK (platform IN ('B1','B2','B3')),
|
||||
CONSTRAINT chk_psl_platform CHECK (platform IN ('B1','B2','B3','DIRECT')),
|
||||
CONSTRAINT uq_psl_project_supplier UNIQUE (project_id, supplier_project_id)
|
||||
);
|
||||
CREATE INDEX idx_psl_supplier_project ON project_supplier_links(supplier_project_id);
|
||||
@@ -1979,7 +1980,7 @@ CREATE INDEX idx_failed_webhook_jobs_log ON failed_webhook_jobs(webhook_log_id);
|
||||
CREATE TABLE supplier_leads (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
supplier_project_id BIGINT REFERENCES supplier_projects(id) ON DELETE SET NULL,
|
||||
platform VARCHAR(4) NOT NULL,
|
||||
platform VARCHAR(8) NOT NULL, -- B1 / B2 / B3 / DIRECT (Phase 3, 2026-05-25)
|
||||
raw_payload JSONB NOT NULL,
|
||||
vid BIGINT, -- nullable: NULL у CSV-recovered лидов (Путь 2)
|
||||
phone VARCHAR(20) NOT NULL,
|
||||
@@ -1993,7 +1994,7 @@ CREATE TABLE supplier_leads (
|
||||
error TEXT,
|
||||
|
||||
CONSTRAINT chk_supplier_leads_platform
|
||||
CHECK (platform IN ('B1','B2','B3')),
|
||||
CHECK (platform IN ('B1','B2','B3','DIRECT')),
|
||||
CONSTRAINT chk_supplier_leads_source
|
||||
CHECK (source IN ('webhook','csv_recovery')),
|
||||
CONSTRAINT chk_supplier_leads_deals_count_nonneg
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"2026-05": {
|
||||
"WIN_USER_PATH": 53,
|
||||
"IPV4": 1
|
||||
"WIN_USER_PATH": 57,
|
||||
"IPV4": 1,
|
||||
"RU_PHONE": 1
|
||||
}
|
||||
}
|
||||
|
||||
+38
-12
@@ -1,6 +1,6 @@
|
||||
# Brain Status (auto-generated)
|
||||
|
||||
Last updated: 2026-05-25T07:30:23.475Z
|
||||
Last updated: 2026-05-25T14:59:12.388Z
|
||||
|
||||
| Контролёр | Состояние | Детали |
|
||||
|---|---|---|
|
||||
@@ -8,15 +8,15 @@ Last updated: 2026-05-25T07:30:23.475Z
|
||||
| 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 | ⚠️ | 135 episode(s) this month · .git/hooks/post-commit not installed (run: npx lefthook install --force) · 17 missed activation(s) — see /brain-retro |
|
||||
| C5 Observer-coverage | ⚠️ | 414 episode(s) this month · Stop-hook + post-commit OK · 21 missed activation(s) — see /brain-retro |
|
||||
| C6 Chain map sync | ✅ | [chain-map-checker] OK — 16 chains in sync |
|
||||
|
||||
## Метрики (информационные, не алерты)
|
||||
|
||||
- Observer evidence: 135 episodes this month, 0 observer_error markers, 6 PII matches before filter
|
||||
- Legacy v1 episodes (not in factor analysis): 11
|
||||
- Observer evidence: 414 episodes this month, 0 observer_error markers, 59 PII matches before filter
|
||||
- Legacy v1 episodes (not in factor analysis): 275
|
||||
- Last /brain-retro: 1 day(s) ago
|
||||
- Использование узлов: см. `/brain-retro` (раз в спринт). missed_activations: 17. **Неиспользованные узлы — не алерт, если профильной задачи не было** (Pravila §16.4 v1.36; capability-readiness; см. memory `feedback_brain_unused_tools_not_problem` — outside-repo memory store).
|
||||
- Использование узлов: см. `/brain-retro` (раз в спринт). missed_activations: 21. **Неиспользованные узлы — не алерт, если профильной задачи не было** (Pravila §16.4 v1.36; capability-readiness; см. memory `feedback_brain_unused_tools_not_problem` — outside-repo memory store).
|
||||
|
||||
## Метрики дисциплины
|
||||
|
||||
@@ -24,17 +24,17 @@ Baseline дисциплины роутера (этап 2 router discipline overh
|
||||
|
||||
| Тип задачи | Эпизодов | % с триггер-матчем | % через скил |
|
||||
|---|---|---|---|
|
||||
| bugfix | 7 | 28.6% | 42.9% |
|
||||
| feature | 5 | 0.0% | 0.0% |
|
||||
| analysis | 4 | 0.0% | 25.0% |
|
||||
| planning | 2 | 0.0% | 0.0% |
|
||||
| analysis | 19 | 42.1% | 21.1% |
|
||||
| monitoring | 16 | 0.0% | 0.0% |
|
||||
| feature | 14 | 14.3% | 0.0% |
|
||||
| bugfix | 11 | 36.4% | 45.5% |
|
||||
| planning | 10 | 20.0% | 20.0% |
|
||||
| refactor | 1 | 0.0% | 0.0% |
|
||||
| cleanup | 1 | 0.0% | 0.0% |
|
||||
| monitoring | 1 | 0.0% | 0.0% |
|
||||
|
||||
Router step distribution: 1: 55, 2: 45, 3: 12, 5: 18
|
||||
Router step distribution: 1: 166, 2: 143, 3: 54, 5: 46
|
||||
|
||||
Boundaries applied (ADR / границы): 13 of 130 эпизодов (10.0%).
|
||||
Boundaries applied (ADR / границы): 64 of 409 эпизодов (15.6%).
|
||||
|
||||
## Активные многоэтапные проекты
|
||||
|
||||
@@ -44,6 +44,32 @@ Boundaries applied (ADR / границы): 13 of 130 эпизодов (10.0%).
|
||||
- Этап 3 (принуждение — хук на routing) — Phase A+B (классификатор + 3 хука: router-prehook/tool-gate/stop-gate в `.claude/settings.json`) ✅ + влит в main 2026-05-24. Гейт работает в режиме **`warn-only`** (только stderr-предупреждения, никакой блокировки). Bug-fix `bec69aa5`: `deriveRouterStep` в `tools/discipline-metrics.mjs` — шаг роутера теперь выводится из наблюдаемых признаков (был захардкоженной константой 1). **Follow-up 3 fixes 2026-05-24** (после ANTHROPIC_API_KEY + рестарта CC выявлены при инспекции state): (a) UTF-8 stdin helper `tools/router-stdin-helper.mjs` через `StringDecoder` + подключение к 3 хукам (русский в state-файл и Anthropic API без mojibake); (b) `tools/observer-state-enricher.mjs` — pure helper для чтения `router-state-<session>.json`; (c) `parseTranscript` обогащение `primary_rationale` 4 полями (`recommended_node` override + `recommended_chain` + `chain_progress` + `chain_completed`). 538 tools-тестов GREEN. Plan: `docs/superpowers/plans/2026-05-24-router-stage3-three-fixes.md`. CHECKPOINT B: дать warn-only накопить реальные наблюдения с **починенным** сторожем (план говорит «минимум 24 часа»), затем Task 9 — переключение в `enforce` + 2 новых метрики (domain-hit-rate / chain-completion). Plan: `docs/superpowers/plans/2026-05-24-router-overhaul-stage-3-enforcement.md`.
|
||||
- Этап 4 (уборка устаревших правил, deprecation `observer-classification-map.json` → удаление) — не начат.
|
||||
|
||||
## Стоимость месяца
|
||||
|
||||
| Компонент | Токены (in/out) | USD |
|
||||
|---|---|---|
|
||||
| Classifier (Sonnet 4.6) | 0/0 | $0.00 |
|
||||
| Self-assessment (Sonnet 4.6) | 0/0 | $0.00 |
|
||||
| Reviewer (Opus 4.7 + fallback) | 0/0 | $0.00 |
|
||||
| **Итого** | | **$0.00** |
|
||||
|
||||
|
||||
## Аномалии классификатора
|
||||
|
||||
Аномалий нет.
|
||||
|
||||
|
||||
## Авто-ретроспектива
|
||||
|
||||
Last self-retrospect: never
|
||||
Episodes since last run: 0 / threshold: 10
|
||||
|
||||
|
||||
## Reviewer: субагент vs fallback
|
||||
|
||||
0 эпизодов проверено из 414.
|
||||
|
||||
|
||||
## Алерт-индикаторы
|
||||
|
||||
✅ — норма ・ ⚠️ — внимание ・ 🔴 — действие требуется ・ ⚪ — не запускалось
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,72 @@
|
||||
# Enforce hard rules — implementation plan
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-05-25-enforce-hard-rules-design.md`
|
||||
**Branch:** `feat/enforce-hard-rules`
|
||||
**Estimate:** 4-8 hours autonomous (overnight)
|
||||
|
||||
## Tasks (in commit order — each commit standalone testable)
|
||||
|
||||
### T1 — Shared hook helpers + override vocab
|
||||
**Files:** `tools/enforce-hook-helpers.mjs`, `tools/enforce-hook-helpers.test.mjs`, `tools/enforce-override-vocab.json`
|
||||
**Helpers:** readStdinJson, readTranscript, getCoverageFromLastAssistant, hasOverridePhrase, loadVocab, sentinelPath, writeSentinel, readSentinel, expectedBranchPath, getExpectedBranch, setExpectedBranch, readRationalizationFlags, appendRationalizationFlag.
|
||||
**Override vocab content:** initial 6 phrases per spec §9.
|
||||
**Coverage:** skill:superpowers:test-driven-development
|
||||
|
||||
### T2 — Rule #5 memory-sync coverage (PreToolUse)
|
||||
**File:** `tools/enforce-memory-coverage.mjs` + test.
|
||||
Simplest rule, easy validation. RED test: prod-code edit with TDD coverage → block. GREEN: memory edit with memory-sync coverage → allow.
|
||||
|
||||
### T3 — Rule #7 branch-switch detection (PreToolUse Bash)
|
||||
**File:** `tools/enforce-branch-switch.mjs` + test.
|
||||
Reads expected-branch file, runs `git branch --show-current`, compares.
|
||||
|
||||
### T4 — Rule #4 verify-before-push (PreToolUse + PostToolUse Bash)
|
||||
**Files:** `tools/enforce-verify-before-push.mjs` (PreToolUse) + `tools/enforce-verify-record.mjs` (PostToolUse to write sentinel) + tests.
|
||||
PostToolUse runs after Bash with vitest/pest pattern. If exit 0 + stdout has PASS marker → write sentinel.
|
||||
PreToolUse on git commit/push checks sentinel age + exists.
|
||||
|
||||
### T5 — Rule #2 coverage-verify (Stop)
|
||||
**File:** `tools/enforce-coverage-verify.mjs` + test.
|
||||
Parses last assistant message for coverage line, checks against transcript tool_use history.
|
||||
|
||||
### T6 — Rule #1 mandatory re-classification injection (UserPromptSubmit)
|
||||
**File:** `tools/enforce-prompt-injection.mjs` + test.
|
||||
Reads classifier output from router-state-*.json, injects mandatory coverage list via stdout JSON.
|
||||
|
||||
### T7 — Rule #3 + Rule #6 TDD + writing-plans gate (PreToolUse Edit/Write/MultiEdit)
|
||||
**File:** `tools/enforce-tdd-gate.mjs` + test.
|
||||
Path-match, transcript-scan for test-edit + vitest-fail-output, OR plan-file-exists.
|
||||
|
||||
### T8 — Rule #8 classifier-mismatch (Stop)
|
||||
**File:** `tools/enforce-classifier-match.mjs` + test.
|
||||
Reads classifier output, checks turn for matching Skill/Task tool_use, gates on confidence threshold.
|
||||
|
||||
### T9 — Rule #10 rationalization flags (PostToolUse Bash + Edit/Write)
|
||||
**File:** `tools/enforce-rationalization-audit.mjs` + test.
|
||||
Scan transcript for rationalization phrases / weak tests; append flag JSONL.
|
||||
|
||||
### T10 — Atomic wire-up
|
||||
**File:** `.claude/settings.json` — add all hooks to PreToolUse/PostToolUse/UserPromptSubmit/Stop.
|
||||
**Critical:** this must be the LAST commit. Pre-wire commits keep hooks inert.
|
||||
|
||||
### T11 — Smoke + push
|
||||
Manual smoke each hook with synthetic stdin. Then `git push origin feat/enforce-hard-rules:main` via FF (or merge-commit if main moved).
|
||||
|
||||
### T12 — Memory + state sync
|
||||
Create `memory/project_enforce_hard_rules.md`, update MEMORY.md index, project_state.md, reference_github.md.
|
||||
|
||||
## Risks identified, mitigations
|
||||
|
||||
- **R1:** Parallel session edits `.claude/settings.json` while I'm working. **Mitigation:** Read settings.json fresh right before T10. Use `git stash` for any concurrent local changes if needed.
|
||||
- **R2:** A rule blocks my own work mid-task. **Mitigation:** Rules inert until T10. If T10 wire-up succeeds and immediately blocks me on T11 push, override-vocab is in place (`recovery` phrase).
|
||||
- **R3:** Hook scripts crash → all subsequent tool calls hang. **Mitigation:** Every hook wraps logic in try/catch, exits 0 with empty {} on internal error (fail-quiet). NEVER exit 2 unless intentional violation found.
|
||||
- **R4:** Override-vocab phrase appears coincidentally in user's normal speech. **Mitigation:** Phrases chosen to be unusual (включают «без скилов» which is unlikely normal).
|
||||
- **R5:** PreToolUse latency on Bash slows every command. **Mitigation:** Hook target deltay <100ms by reading minimum (cached classifier-state, sentinel file, no transcript-parse unless rule triggers).
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- All 10 rules implemented with unit tests
|
||||
- All hooks wired in settings.json
|
||||
- Manual smoke per hook: fake-stdin → expected exit code + stderr
|
||||
- Push to origin/main (or PR if main is unstable)
|
||||
- Memory + project_state synced
|
||||
@@ -0,0 +1,355 @@
|
||||
# Phase 1: Always JSON 422 for webhook validation errors
|
||||
|
||||
> **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:** Webhook `/api/webhook/supplier/*` ВСЕГДА возвращает JSON 422 на ValidationException, никогда не редиректит на `/`. Закрывает ~76 потерянных лидов сутки в логах nginx.
|
||||
|
||||
**Architecture:** Один `withExceptions()` render-callback в `bootstrap/app.php`: для запросов матчащих `api/webhook/supplier/*` отдаём `response()->json(['message','errors'], 422)`. Для остальных — `return null` (дефолт). Существующие тесты остаются valid, добавляется один новый тест с `Accept: text/html` (имитация реального поставщика).
|
||||
|
||||
**Tech Stack:** Laravel 13 / Pest 4 / PHP 8.3
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-05-25-supplier-webhook-reliability-design.md` §3 Phase 1
|
||||
**Ветка:** `feat/supplier-webhook-fixes` (создана)
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
**Создать:**
|
||||
- `app/tests/Feature/Http/Webhook/SupplierWebhookValidationFormatTest.php` — единственный новый тест, фиксирующий формат ответа для не-JSON Accept
|
||||
|
||||
**Изменить:**
|
||||
- `app/bootstrap/app.php` — добавить `$exceptions->render(...)` для ValidationException
|
||||
|
||||
**Не трогать:**
|
||||
- `SupplierWebhookController.php` — логика валидации не меняется
|
||||
- Существующие `SupplierWebhookTest.php` — все `postJson()` тесты продолжают работать
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Failing test — webhook returns 422 JSON for non-JSON-Accept clients
|
||||
|
||||
**Files:**
|
||||
- Create: `app/tests/Feature/Http/Webhook/SupplierWebhookValidationFormatTest.php`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\SystemSetting;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
beforeEach(function () {
|
||||
SystemSetting::query()
|
||||
->where('key', 'supplier_webhook_secret')
|
||||
->update(['value' => 'test-secret-32chars-aaaaaaaaaaaaaa']);
|
||||
SystemSetting::query()
|
||||
->where('key', 'supplier_ip_allowlist')
|
||||
->update(['value' => '[]']);
|
||||
});
|
||||
|
||||
it('returns 422 JSON when supplier posts invalid payload WITHOUT Accept: application/json header', function () {
|
||||
// Воспроизводит реальное поведение crm.bp-gr.ru: POST без Accept-JSON.
|
||||
// До фикса (302→422) Laravel редиректил на / с Set-Cookie, поставщик
|
||||
// терял тело запроса. После фикса всегда JSON.
|
||||
$response = $this->call(
|
||||
'POST',
|
||||
'/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa',
|
||||
[], // params
|
||||
[], // cookies
|
||||
[], // files
|
||||
['HTTP_CONTENT_TYPE' => 'application/x-www-form-urlencoded'], // server: НЕТ Accept JSON
|
||||
http_build_query([
|
||||
'vid' => 1,
|
||||
'project' => 'invalid_no_b_prefix',
|
||||
'phone' => '79991234567',
|
||||
'time' => time(),
|
||||
])
|
||||
);
|
||||
|
||||
$response->assertStatus(422);
|
||||
expect($response->headers->get('Content-Type'))->toContain('application/json');
|
||||
$response->assertJsonStructure(['message', 'errors' => ['project']]);
|
||||
});
|
||||
|
||||
it('still works correctly for postJson clients (regression)', function () {
|
||||
$response = $this->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [
|
||||
'vid' => 1,
|
||||
'project' => 'invalid_no_b_prefix',
|
||||
'phone' => '79991234567',
|
||||
'time' => time(),
|
||||
]);
|
||||
|
||||
$response->assertStatus(422)->assertJsonValidationErrors('project');
|
||||
});
|
||||
|
||||
it('non-webhook routes still use default render (no JSON forced)', function () {
|
||||
// Регрессионный тест: дефолтный render остальных routes не сломан
|
||||
// (например /login — должен возвращать redirect, а не JSON).
|
||||
$response = $this->call(
|
||||
'POST',
|
||||
'/login',
|
||||
['email' => 'bad', 'password' => ''],
|
||||
[], [], [],
|
||||
);
|
||||
// Любой не-200 кроме 422-JSON допустим — главное чтобы наш fix не перехватил
|
||||
expect($response->headers->get('Content-Type'))->not->toContain('application/json');
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
```
|
||||
cd app && ./vendor/bin/pest tests/Feature/Http/Webhook/SupplierWebhookValidationFormatTest.php
|
||||
```
|
||||
|
||||
Expected: тест #1 (non-JSON Accept) FAIL с status=302 (или Content-Type=text/html), потому что ValidationException рендерится через redirect.
|
||||
|
||||
- [ ] **Step 3: Commit failing test**
|
||||
|
||||
```bash
|
||||
git add app/tests/Feature/Http/Webhook/SupplierWebhookValidationFormatTest.php
|
||||
git commit -m "test(supplier-webhook): assert JSON 422 for non-JSON Accept clients (failing)
|
||||
|
||||
Reproduces 302-redirect bug observed on prod 2026-05-25 — when supplier
|
||||
crm.bp-gr.ru POSTs without Accept: application/json, Laravel renders
|
||||
ValidationException as redirect to /, losing body. Test calls webhook
|
||||
without Accept header and asserts JSON 422 response. Will fail until
|
||||
bootstrap/app.php has render(ValidationException) for api/webhook/supplier/*."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Implement bootstrap render — force JSON 422 for webhook routes
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/bootstrap/app.php` (lines 35-48 — withExceptions block)
|
||||
|
||||
- [ ] **Step 1: Add ValidationException render in bootstrap/app.php**
|
||||
|
||||
В `withExceptions` callback (после существующего `QueryException` render) добавить новый render для `ValidationException`:
|
||||
|
||||
```php
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
$exceptions->render(function (QueryException $e, Request $request) {
|
||||
// ... existing code, не менять ...
|
||||
});
|
||||
|
||||
// Supplier webhook always returns JSON, even when client omits Accept header.
|
||||
// Without this render, Laravel's default ValidationException handler returns
|
||||
// 302 redirect to /, which strips POST body — losing supplier leads.
|
||||
// Confirmed 2026-05-25: 76 of 234 webhook hits today got 302 instead of 422.
|
||||
$exceptions->render(function (\Illuminate\Validation\ValidationException $e, Request $request) {
|
||||
if ($request->is('api/webhook/supplier/*')) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed',
|
||||
'errors' => $e->errors(),
|
||||
], 422);
|
||||
}
|
||||
return null; // default render for other routes
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
NB: `use Illuminate\Validation\ValidationException;` — не нужен, используем FQN inline чтобы не трогать existing imports section.
|
||||
|
||||
- [ ] **Step 2: Run new test to verify it passes**
|
||||
|
||||
```
|
||||
cd app && ./vendor/bin/pest tests/Feature/Http/Webhook/SupplierWebhookValidationFormatTest.php
|
||||
```
|
||||
|
||||
Expected: все 3 теста PASS.
|
||||
|
||||
- [ ] **Step 3: Run full webhook test suite (regression)**
|
||||
|
||||
```
|
||||
cd app && ./vendor/bin/pest tests/Feature/Http/Webhook/SupplierWebhookTest.php tests/Feature/Http/Webhook/SupplierWebhookValidationFormatTest.php
|
||||
```
|
||||
|
||||
Expected: все тесты (≥14 в обоих файлах) PASS. Особенно проверить что `'rejects invalid project format (no B[123]_ prefix) with 422'` (line 95 в SupplierWebhookTest.php) продолжает PASS — он использует `postJson()`, поэтому новый render для него не сработает (default handler уже даёт 422 для JSON Accept), но мы не должны его сломать.
|
||||
|
||||
- [ ] **Step 4: Commit implementation**
|
||||
|
||||
```bash
|
||||
git add app/bootstrap/app.php
|
||||
git commit -m "fix(supplier-webhook): always return JSON 422 on ValidationException
|
||||
|
||||
Adds withExceptions render callback for ValidationException that forces
|
||||
JSON 422 response when request matches api/webhook/supplier/* — regardless
|
||||
of Accept header. Default Laravel behavior is 302 redirect for non-JSON
|
||||
clients, which strips POST body.
|
||||
|
||||
Observed on prod 2026-05-25: 76 of 234 supplier webhook hits got 302 (Location: /),
|
||||
mostly for non-B-prefix projects (client.carmoney.ru, cabinet.caranga.ru,
|
||||
cashmotor.ru). Supplier doesn't follow 302 redirects on POST, so the
|
||||
lead body is lost. This fix ensures supplier always sees a meaningful
|
||||
422 with errors[] instead of a redirect.
|
||||
|
||||
Other routes unaffected (render returns null for non-webhook URLs)."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Reproduce on staging-clone or local — manual smoke
|
||||
|
||||
**Files:**
|
||||
- Test: manual curl (no file)
|
||||
|
||||
- [ ] **Step 1: Run dev server locally (if available) or skip to Task 4**
|
||||
|
||||
Если на машине поднят `php artisan serve --port=8000`:
|
||||
```bash
|
||||
cd app && php artisan serve --port=8000 &
|
||||
sleep 2
|
||||
```
|
||||
|
||||
- [ ] **Step 2: POST without Accept header — assert 422 JSON**
|
||||
|
||||
```bash
|
||||
curl -sk -X POST \
|
||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||
-d 'vid=1&project=invalid_no_b_prefix&phone=79991234567&time='$(date +%s) \
|
||||
http://localhost:8000/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa \
|
||||
-w "\nSTATUS: %{http_code}\nCT: %{content_type}\n"
|
||||
```
|
||||
|
||||
Expected: `STATUS: 422`, `CT: application/json`, тело содержит `"errors":{"project":...}`.
|
||||
|
||||
- [ ] **Step 3: POST with Accept: application/json — same result (regression)**
|
||||
|
||||
```bash
|
||||
curl -sk -X POST \
|
||||
-H "Accept: application/json" -H "Content-Type: application/json" \
|
||||
-d '{"vid":1,"project":"invalid_no_b_prefix","phone":"79991234567","time":'$(date +%s)'}' \
|
||||
http://localhost:8000/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa \
|
||||
-w "\nSTATUS: %{http_code}\n"
|
||||
```
|
||||
|
||||
Expected: `STATUS: 422`, JSON body.
|
||||
|
||||
- [ ] **Step 4: Stop server (если запускал)**
|
||||
|
||||
```bash
|
||||
pkill -f 'artisan serve' || true
|
||||
```
|
||||
|
||||
Если dev-сервер не поднимается на этой машине — пропустить Task 3, прод-smoke в Task 5 покроет.
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Regression — quick mode
|
||||
|
||||
**Files:**
|
||||
- None
|
||||
|
||||
- [ ] **Step 1: Run /regression quick**
|
||||
|
||||
```
|
||||
/regression quick
|
||||
```
|
||||
|
||||
Expected: GREEN — lint, format, type-check ОК. Если pre-commit hook падает (memory `feedback_environment.md` #111 — gitleaks висит на heavy diff), использовать `LEFTHOOK=0` при коммите.
|
||||
|
||||
- [ ] **Step 2: If quick GREEN, proceed to /regression full**
|
||||
|
||||
```
|
||||
/regression full
|
||||
```
|
||||
|
||||
Expected: Pest 742+ pass / 0 fail, Vitest 736+ pass, Vite build OK, lychee 0 broken, gitleaks 0. Допустимы pre-existing skipped.
|
||||
|
||||
Если найдены регрессии — НЕ переходить к деплою. Зафиксировать в отдельном fixup-commit либо вернуться к Task 2.
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Deploy to liderra.ru (prod)
|
||||
|
||||
**Files:**
|
||||
- None — деплой через ssh + redeploy.sh
|
||||
|
||||
- [ ] **Step 1: Pre-deploy validation via prod-deploy-validator agent**
|
||||
|
||||
Через Task tool:
|
||||
```
|
||||
subagent_type: prod-deploy-validator
|
||||
prompt: проверь готовность боевого liderra.ru к выкату ветки feat/supplier-webhook-fixes на коммит после Phase 1 (bootstrap/app.php изменён). Что меняется: webhook /api/webhook/supplier/* теперь всегда отвечает JSON 422 на validation errors. Миграций БД нет. Очередь queue:restart нужен? проверь 8 pre-flight.
|
||||
```
|
||||
|
||||
Expected: вердикт GO. Если NO-GO — устранить причину (квирки 104-108) и повторить.
|
||||
|
||||
- [ ] **Step 2: Merge feature branch fixup to main**
|
||||
|
||||
После одобрения Phase 1 changes:
|
||||
```bash
|
||||
cd "c:/моя/проекты/портал crm/Документация"
|
||||
git checkout main
|
||||
git merge --ff-only feat/supplier-webhook-fixes
|
||||
git push origin main
|
||||
```
|
||||
|
||||
NB: ОДНОВРЕМЕННО другие phases ещё не закоммичены, поэтому FF-merge содержит только Phase 1.
|
||||
|
||||
- [ ] **Step 3: Run redeploy.sh on prod**
|
||||
|
||||
```bash
|
||||
ssh liderra "cd /var/www/liderra/app && sudo -u www-data ./redeploy.sh 2>&1 | tail -50"
|
||||
```
|
||||
|
||||
Expected: успешный pull + composer install + `optimize:clear` + `optimize` + queue:restart. Errors → revert (git revert + redeploy).
|
||||
|
||||
- [ ] **Step 4: Prod smoke — webhook returns 422 not 302**
|
||||
|
||||
```bash
|
||||
ssh liderra 'curl -sk -X POST \
|
||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||
-d "vid=1&project=invalid&phone=79991234567&time="$(date +%s) \
|
||||
https://liderra.ru/api/webhook/supplier/8c1c07ddb0768763661b357198e0625832f74ad0915d91b1 \
|
||||
-w "\nSTATUS: %{http_code}\nCT: %{content_type}\n"'
|
||||
```
|
||||
|
||||
Expected: `STATUS: 422`, `CT: application/json`. **Если 302 — деплой не применился, откатывать.**
|
||||
|
||||
- [ ] **Step 5: Wait 30 min, check nginx access.log**
|
||||
|
||||
```bash
|
||||
ssh liderra "sudo grep '/api/webhook/supplier' /var/log/nginx/access.log | tail -50 | awk '{print \$9}' | sort | uniq -c"
|
||||
```
|
||||
|
||||
Expected: только 202, 422, 429, 404. **0 × 302, 0 × 301** для запросов на webhook URL.
|
||||
|
||||
- [ ] **Step 6: Update ПИЛОТ.md + memory**
|
||||
|
||||
Через прямой Edit, отметка «Phase 1 deployed 25.05.2026 HH:MM МСК, webhook always JSON». Memory update — `project_billing_v2.md` или новый `project_supplier_webhook_fixes.md`.
|
||||
|
||||
```bash
|
||||
# Update ПИЛОТ.md as needed manually
|
||||
git add ПИЛОТ.md
|
||||
git commit -m "docs(пилот): Phase 1 supplier webhook JSON-422 deployed"
|
||||
git push origin main
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Done criteria для Phase 1
|
||||
|
||||
- [ ] Все тесты в `SupplierWebhookTest.php` + `SupplierWebhookValidationFormatTest.php` PASS
|
||||
- [ ] /regression full GREEN
|
||||
- [ ] Прод-smoke: curl без Accept → 422 JSON
|
||||
- [ ] За 30 мин после деплоя в nginx access.log — 0 × 302 на webhook URL
|
||||
- [ ] Phase 2 plan starts only after Phase 1 deployed AND observed clean for ≥30 min
|
||||
|
||||
---
|
||||
|
||||
## Откат (если что-то пошло не так)
|
||||
|
||||
```bash
|
||||
ssh liderra "cd /var/www/liderra/app && git revert --no-edit HEAD && sudo -u www-data ./redeploy.sh 2>&1 | tail -20"
|
||||
```
|
||||
|
||||
Изменение касается только обработки исключений — откат без миграций, мгновенный.
|
||||
@@ -0,0 +1,475 @@
|
||||
# Phase 2: Idempotent dedup webhook ↔ CSV-recovered
|
||||
|
||||
> **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:** Webhook, поступивший после CSV-recovered deal по `(tenant_id, phone, project_id)` в окне 24h, **обновляет** существующий deal (`source_crm_id`, `received_at`), не создаёт второй. Без двойного списания биллингом. Закрывает 37 дублей сутки.
|
||||
|
||||
**Architecture:** В `RouteSupplierLeadJob::createDealCopyForProject` под уже существующей `DB::transaction + lockForUpdate(Tenant)+lockForUpdate(Project)` добавляется проверка «есть ли csv-recovered deal по `(tenant_id, phone, project_id, received_at ≥ now()-24h, source_crm_id IS NULL)`». Если есть — `UPDATE existing.source_crm_id = lead.vid` + `INSERT supplier_lead_deliveries` (привязка webhook к existing deal), **БЕЗ** `chargeForDelivery`. Возврат специального статуса `MERGED` (не считается в `$createdCount`, не failure).
|
||||
|
||||
**Tech Stack:** Laravel 13 / Pest 4 / PHP 8.3 / PostgreSQL 16 / bcmath / RLS
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-05-25-supplier-webhook-reliability-design.md` §3 Phase 2
|
||||
**Предусловие:** Phase 1 deployed и наблюдаем clean ≥30 мин.
|
||||
**Ветка:** `feat/supplier-webhook-fixes` (продолжение)
|
||||
|
||||
---
|
||||
|
||||
## Открытый вопрос (OQ-1 из спеки) — резолвится в Task 1
|
||||
|
||||
`LedgerService::chargeForDelivery` (app/app/Services/Billing/LedgerService.php:47-117) — **НЕ идемпотентен**: каждый вызов делает INSERT LeadCharge, BalanceTransaction, supplier_lead_costs + decrement balance_rub. Поэтому критично НЕ вызывать его второй раз для merged deal.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
**Создать:**
|
||||
- `app/tests/Feature/Supplier/CsvWebhookRaceTest.php` — TDD-тесты для merge сценария
|
||||
|
||||
**Изменить:**
|
||||
- `app/app/Jobs/RouteSupplierLeadJob.php` — добавить блок поиска csv-recovered deal в `createDealCopyForProject`
|
||||
|
||||
**Не трогать:**
|
||||
- `LedgerService.php` — не меняем, идемпотентность достигается через ранний return ДО его вызова
|
||||
- `supplier_lead_deliveries` schema — не меняем (текущая `(supplier_lead_id, tenant_id)` UNIQUE остаётся; добавляем дополнительный row для merge case)
|
||||
- `CsvReconcileJob.php` — не меняем (он создаёт SupplierLead с vid=NULL, как и было)
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Verify LedgerService is NOT idempotent (read-only confirmation)
|
||||
|
||||
**Files:**
|
||||
- Read: `app/app/Services/Billing/LedgerService.php`
|
||||
|
||||
- [ ] **Step 1: Confirm there is NO check for existing lead_charges with same deal_id**
|
||||
|
||||
Открыть [app/app/Services/Billing/LedgerService.php:47-117](../../../app/app/Services/Billing/LedgerService.php#L47-L117). Подтвердить:
|
||||
- Нет `LeadCharge::where('deal_id', $deal->id)->exists()` guard.
|
||||
- Нет SELECT перед INSERT.
|
||||
- Метод просто делает INSERT, increment, INSERT, INSERT.
|
||||
|
||||
Если идемпотентность ЕСТЬ — пересмотреть план Phase 2 (может быть проще, без MERGED статуса). Если НЕТ (ожидаемо) — продолжаем по плану.
|
||||
|
||||
- [ ] **Step 2: Document in commit message**
|
||||
|
||||
Зафиксировать наблюдение в первом коммите Task 2. Никакой правки в LedgerService не делаем — guard добавляется в caller (RouteSupplierLeadJob).
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Failing test — webhook after CSV-recovered merges, doesn't duplicate or double-charge
|
||||
|
||||
**Files:**
|
||||
- Create: `app/tests/Feature/Supplier/CsvWebhookRaceTest.php`
|
||||
|
||||
- [ ] **Step 1: Write failing tests**
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\RouteSupplierLeadJob;
|
||||
use App\Models\Deal;
|
||||
use App\Models\LeadCharge;
|
||||
use App\Models\Project;
|
||||
use App\Models\SupplierLead;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
/**
|
||||
* Phase 2 — webhook ↔ CSV-recovered idempotency.
|
||||
*
|
||||
* Сценарий (наблюдался на prod 2026-05-25):
|
||||
* 1. Поставщик шлёт webhook → 302 (теряется тело) — Phase 1 уже починила.
|
||||
* 2. CsvReconcileJob через 30 мин видит лид в CSV, не находит supplier_lead
|
||||
* по (phone, project) → создаёт recovered SupplierLead (vid=NULL,
|
||||
* source='csv_recovery') → RouteSupplierLeadJob → Deal с source_crm_id=NULL.
|
||||
* 3. Поставщик ретраит webhook (ещё 15 мин) → новый SupplierLead с vid=<int>
|
||||
* → RouteSupplierLeadJob → создаёт второй Deal с тем же phone+project
|
||||
* → биллинг списывает второй раз.
|
||||
*
|
||||
* Phase 2 fix: шаг 3 находит существующий CSV-recovered deal, обновляет
|
||||
* source_crm_id, привязывает webhook supplier_lead к существующему deal через
|
||||
* supplier_lead_deliveries, НЕ создаёт второй Deal, НЕ списывает повторно.
|
||||
*/
|
||||
|
||||
beforeEach(function () {
|
||||
$this->tenant = Tenant::factory()->create([
|
||||
'balance_rub' => '1000.00',
|
||||
'delivered_in_month' => 0,
|
||||
]);
|
||||
$this->project = Project::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'krk-finance.ru',
|
||||
'is_active' => true,
|
||||
'daily_limit_target' => 100,
|
||||
'delivered_today' => 0,
|
||||
]);
|
||||
// ... настроить supplier_projects + project_supplier_links для платформы B1
|
||||
// identifier krk-finance.ru — детали зависят от фабрик
|
||||
});
|
||||
|
||||
it('webhook after CSV-recovered merges into existing deal (no duplicate, no double-charge)', function () {
|
||||
// Step 1: simulate CSV-recovered SupplierLead (vid=null)
|
||||
$csvLead = SupplierLead::create([
|
||||
'platform' => 'B1',
|
||||
'phone' => '79991234567',
|
||||
'vid' => null,
|
||||
'raw_payload' => ['project' => 'B1_krk-finance.ru', 'phone' => '79991234567', 'time' => time()],
|
||||
'received_at' => now()->subHour(),
|
||||
'recovered_from_csv_at' => now()->subHour(),
|
||||
'source' => 'csv_recovery',
|
||||
]);
|
||||
(new RouteSupplierLeadJob($csvLead->id))->handle(
|
||||
app(\App\Services\LeadRouter::class),
|
||||
app(\App\Services\SupplierProjects\SupplierProjectResolver::class),
|
||||
app(\App\Services\NotificationService::class),
|
||||
app(\App\Services\Billing\LedgerService::class),
|
||||
app(\App\Services\LeadDistributor::class),
|
||||
app(\App\Services\RegionTagResolver::class),
|
||||
);
|
||||
|
||||
$csvDeal = Deal::where('phone', '79991234567')->first();
|
||||
expect($csvDeal)->not->toBeNull();
|
||||
expect($csvDeal->source_crm_id)->toBeNull();
|
||||
$chargesAfterCsv = LeadCharge::where('deal_id', $csvDeal->id)->count();
|
||||
expect($chargesAfterCsv)->toBe(1); // одна charge от CSV-recovered
|
||||
|
||||
$balanceAfterCsv = (string) $this->tenant->fresh()->balance_rub;
|
||||
|
||||
// Step 2: simulate webhook arriving 15 min later with real vid
|
||||
$webhookLead = SupplierLead::create([
|
||||
'platform' => 'B1',
|
||||
'phone' => '79991234567',
|
||||
'vid' => 1672819986,
|
||||
'raw_payload' => ['project' => 'B1_krk-finance.ru', 'phone' => '79991234567', 'time' => time()],
|
||||
'received_at' => now()->subMinutes(15),
|
||||
'source' => 'webhook',
|
||||
]);
|
||||
(new RouteSupplierLeadJob($webhookLead->id))->handle(
|
||||
app(\App\Services\LeadRouter::class),
|
||||
app(\App\Services\SupplierProjects\SupplierProjectResolver::class),
|
||||
app(\App\Services\NotificationService::class),
|
||||
app(\App\Services\Billing\LedgerService::class),
|
||||
app(\App\Services\LeadDistributor::class),
|
||||
app(\App\Services\RegionTagResolver::class),
|
||||
);
|
||||
|
||||
// Assertion 1: still ONE deal, but source_crm_id теперь заполнен
|
||||
$deals = Deal::where('phone', '79991234567')->get();
|
||||
expect($deals)->toHaveCount(1);
|
||||
expect($deals->first()->source_crm_id)->toBe(1672819986);
|
||||
|
||||
// Assertion 2: НЕТ второго LeadCharge (idempotency биллинга)
|
||||
$chargesAfterWebhook = LeadCharge::where('deal_id', $csvDeal->id)->count();
|
||||
expect($chargesAfterWebhook)->toBe(1); // всё ещё ОДИН charge
|
||||
|
||||
// Assertion 3: balance НЕ списан второй раз
|
||||
$balanceAfterWebhook = (string) $this->tenant->fresh()->balance_rub;
|
||||
expect($balanceAfterWebhook)->toBe($balanceAfterCsv);
|
||||
|
||||
// Assertion 4: supplier_lead_deliveries содержит ОБА supplier_lead_id,
|
||||
// привязанные к ОДНОМУ deal.id
|
||||
$deliveries = DB::table('supplier_lead_deliveries')
|
||||
->where('deal_id', $csvDeal->id)
|
||||
->get();
|
||||
expect($deliveries)->toHaveCount(2);
|
||||
expect($deliveries->pluck('supplier_lead_id')->all())
|
||||
->toContain($csvLead->id, $webhookLead->id);
|
||||
});
|
||||
|
||||
it('two webhooks with DIFFERENT vids both create deals (Spec B — за повторы поставщика берём)', function () {
|
||||
// Регрессионный тест: если поставщик намеренно шлёт два webhook'а с РАЗНЫМИ
|
||||
// vid'ами на тот же phone+project — это два разных лида, оба должны быть
|
||||
// приняты. Спек B Phase 1 (commit ccfecd5e) специально снял DD для этого
|
||||
// кейса. Наш Phase 2 fix НЕ должен этому препятствовать.
|
||||
$lead1 = SupplierLead::create([
|
||||
'platform' => 'B1', 'phone' => '79991234567', 'vid' => 100,
|
||||
'raw_payload' => ['project' => 'B1_krk-finance.ru', 'phone' => '79991234567', 'time' => time()],
|
||||
'received_at' => now()->subHour(), 'source' => 'webhook',
|
||||
]);
|
||||
(new RouteSupplierLeadJob($lead1->id))->handle(/* ... */);
|
||||
|
||||
$lead2 = SupplierLead::create([
|
||||
'platform' => 'B1', 'phone' => '79991234567', 'vid' => 200,
|
||||
'raw_payload' => ['project' => 'B1_krk-finance.ru', 'phone' => '79991234567', 'time' => time()],
|
||||
'received_at' => now()->subMinutes(30), 'source' => 'webhook',
|
||||
]);
|
||||
(new RouteSupplierLeadJob($lead2->id))->handle(/* ... */);
|
||||
|
||||
// Assertion: ОБА webhook'а имеют source_crm_id (не NULL), поэтому merge
|
||||
// не происходит — это два разных лида у поставщика, два разных deal.
|
||||
$deals = Deal::where('phone', '79991234567')->get();
|
||||
expect($deals)->toHaveCount(2);
|
||||
expect($deals->pluck('source_crm_id')->all())->toContain(100, 200);
|
||||
expect(LeadCharge::whereIn('deal_id', $deals->pluck('id'))->count())->toBe(2);
|
||||
});
|
||||
|
||||
it('csv-recovered deal older than 24h is NOT merged with new webhook', function () {
|
||||
// Окно merge — 24h. Если CSV-recovered deal старше — не считается duplicate.
|
||||
$csvLead = SupplierLead::create([
|
||||
'platform' => 'B1', 'phone' => '79991234567', 'vid' => null,
|
||||
'raw_payload' => ['project' => 'B1_krk-finance.ru', 'phone' => '79991234567', 'time' => now()->subDays(2)->getTimestamp()],
|
||||
'received_at' => now()->subDays(2),
|
||||
'recovered_from_csv_at' => now()->subDays(2),
|
||||
'source' => 'csv_recovery',
|
||||
]);
|
||||
(new RouteSupplierLeadJob($csvLead->id))->handle(/* ... */);
|
||||
|
||||
$webhookLead = SupplierLead::create([
|
||||
'platform' => 'B1', 'phone' => '79991234567', 'vid' => 999,
|
||||
'raw_payload' => ['project' => 'B1_krk-finance.ru', 'phone' => '79991234567', 'time' => time()],
|
||||
'received_at' => now(), 'source' => 'webhook',
|
||||
]);
|
||||
(new RouteSupplierLeadJob($webhookLead->id))->handle(/* ... */);
|
||||
|
||||
// Assertion: TWO deals (старый CSV-recovered + новый webhook), не merge
|
||||
$deals = Deal::where('phone', '79991234567')->get();
|
||||
expect($deals)->toHaveCount(2);
|
||||
});
|
||||
```
|
||||
|
||||
NB: код тестов написан как **набросок**. При имплементации:
|
||||
- Заменить `(new RouteSupplierLeadJob(...))->handle(/* ... */)` на правильную диспатч-схему (Bus::dispatchSync или вручную с DI). Посмотреть в [app/tests/Feature/Supplier/RouteSupplierLeadJobBillingTest.php](../../../app/tests/Feature/Supplier/RouteSupplierLeadJobBillingTest.php) для примера.
|
||||
- Настроить supplier_projects + project_supplier_links фабрики правильно. Посмотреть в существующих тестах.
|
||||
|
||||
- [ ] **Step 2: Run tests, expect FAIL**
|
||||
|
||||
```
|
||||
cd app && ./vendor/bin/pest tests/Feature/Supplier/CsvWebhookRaceTest.php
|
||||
```
|
||||
|
||||
Expected: тест #1 FAIL (deals.count == 2 а не 1; charges.count == 2 а не 1). Это подтверждает баг.
|
||||
|
||||
- [ ] **Step 3: Commit failing tests**
|
||||
|
||||
```bash
|
||||
git add app/tests/Feature/Supplier/CsvWebhookRaceTest.php
|
||||
git commit -m "test(supplier): assert webhook-after-csv-recovered merges into existing deal (failing)
|
||||
|
||||
Reproduces 37 duplicate deals observed on prod 2026-05-25 for tenant client1.
|
||||
After Spec B Phase 1 (commit ccfecd5e) removed DuplicateDetector, the race
|
||||
between CsvReconcileJob (creates SupplierLead vid=null) and later webhook
|
||||
retry (vid=int) results in two separate Deals because supplier_lead_deliveries
|
||||
locks on supplier_lead_id (which differs between csv-recovery and webhook),
|
||||
not on (phone, project_id).
|
||||
|
||||
Failing now — implementation comes in next commit."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Implement merge logic in RouteSupplierLeadJob::createDealCopyForProject
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/app/Jobs/RouteSupplierLeadJob.php:207-330`
|
||||
|
||||
- [ ] **Step 1: Add early merge check ДО supplier_lead_deliveries insertOrIgnore**
|
||||
|
||||
В `createDealCopyForProject`, **после** `$lockedProject = ... lockForUpdate(); ... if (delivered_today >= limit) return false;`, **до** `$locked = DB::table('supplier_lead_deliveries')->insertOrIgnore(...)`:
|
||||
|
||||
```php
|
||||
// Phase 2 fix: merge с CSV-recovered deal если webhook догоняет.
|
||||
// Идемпотентность race condition между CsvReconcileJob (vid=NULL, recovered
|
||||
// from CSV) и webhook (vid=int, реальный supplier-id). До этой проверки они
|
||||
// создавали 2 deal'a (DD снят Spec B Phase 1). Merge выполняется только если:
|
||||
// - webhook ЕСТЬ настоящий vid (lead.vid !== null) — без vid merge'ить нечего;
|
||||
// - csv-recovered deal существует за последние 24h, тот же phone+project+tenant;
|
||||
// - csv-recovered deal БЕЗ source_crm_id (т.е. он именно CSV-recovered, не другой webhook).
|
||||
// При merge: UPDATE existing.source_crm_id, INSERT supplier_lead_deliveries,
|
||||
// БЕЗ chargeForDelivery (LeadCharge уже есть с момента CSV recovery).
|
||||
$existingMergeable = null;
|
||||
if ($lead->vid !== null) {
|
||||
$existingMergeable = Deal::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('phone', (string) $lead->phone)
|
||||
->where('project_id', $project->id)
|
||||
->whereNull('source_crm_id')
|
||||
->where('received_at', '>=', now()->subDay())
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
}
|
||||
if ($existingMergeable !== null) {
|
||||
// Заполняем supplier_lead.id у обоих SupplierLead → одному Deal
|
||||
DB::table('supplier_lead_deliveries')->insert([
|
||||
'supplier_lead_id' => $lead->id,
|
||||
'tenant_id' => $tenant->id,
|
||||
'deal_id' => $existingMergeable->id,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
$existingMergeable->source_crm_id = $lead->vid;
|
||||
if ($lead->received_at !== null && $lead->received_at->gt($existingMergeable->received_at)) {
|
||||
$existingMergeable->received_at = $lead->received_at;
|
||||
}
|
||||
$existingMergeable->save();
|
||||
|
||||
Log::info('supplier_lead.merged_into_csv_recovered', [
|
||||
'supplier_lead_id' => $lead->id,
|
||||
'merged_into_deal_id' => $existingMergeable->id,
|
||||
'tenant_id' => $tenant->id,
|
||||
]);
|
||||
|
||||
return true; // считаем «доставленным», но без второго списания
|
||||
}
|
||||
|
||||
// Spec B: per-(supplier_lead, tenant) lock — existing code ниже без изменений
|
||||
$locked = DB::table('supplier_lead_deliveries')->insertOrIgnore([
|
||||
// ... existing ...
|
||||
]);
|
||||
```
|
||||
|
||||
NB:
|
||||
- `lockForUpdate()` на existingMergeable защищает от двойного merge при параллельных queue workers.
|
||||
- Условие `whereNull('source_crm_id')` — критично: оно отличает CSV-recovered (vid=NULL → source_crm_id=NULL) от настоящих webhook deals (source_crm_id=vid). Без этого условия мы бы мерджили на любой повтор поставщика, что **сломало бы Spec B**.
|
||||
- Insert в `supplier_lead_deliveries` — простой `->insert()`, не `->insertOrIgnore()`. Потому что `(supplier_lead_id, tenant_id)` уникален, и для webhook-after-csv это новая комбинация (другой supplier_lead_id чем у csv-recovered).
|
||||
|
||||
- [ ] **Step 2: Run tests, expect PASS**
|
||||
|
||||
```
|
||||
cd app && ./vendor/bin/pest tests/Feature/Supplier/CsvWebhookRaceTest.php
|
||||
```
|
||||
|
||||
Expected: все 3 теста PASS.
|
||||
|
||||
- [ ] **Step 3: Run full supplier test suite (regression)**
|
||||
|
||||
```
|
||||
cd app && ./vendor/bin/pest tests/Feature/Supplier/ tests/Feature/Jobs/RouteSupplierLeadJobTest.php
|
||||
```
|
||||
|
||||
Expected: все existing тесты PASS. Особенно:
|
||||
- `SupplierLeadDeliveryGuardTest` (текущий lock-механизм)
|
||||
- `RouteSupplierLeadJobBillingTest` (биллинг)
|
||||
- `RouteSupplierLeadJobTest`
|
||||
- `CsvReconcileJobTest`
|
||||
|
||||
Если что-то сломалось — это знак что existingMergeable условие слишком широкое. Сузить и повторить.
|
||||
|
||||
- [ ] **Step 4: Commit implementation**
|
||||
|
||||
```bash
|
||||
git add app/app/Jobs/RouteSupplierLeadJob.php
|
||||
git commit -m "fix(supplier): merge webhook into csv-recovered deal, no double-charge
|
||||
|
||||
Adds early merge check in RouteSupplierLeadJob::createDealCopyForProject:
|
||||
when lead.vid IS NOT NULL and an existing deal with NULL source_crm_id
|
||||
exists for (tenant, phone, project_id) within last 24h, UPDATE that
|
||||
deal's source_crm_id instead of creating a second Deal. INSERT into
|
||||
supplier_lead_deliveries links the new supplier_lead.id to the existing
|
||||
deal.id. LedgerService::chargeForDelivery is NOT called — the original
|
||||
charge happened when the csv-recovery created the deal.
|
||||
|
||||
Closes 37 duplicate deals observed on prod for tenant client1 25.05.2026.
|
||||
Spec B Phase 1 (commit ccfecd5e) removed DuplicateDetector — this fix
|
||||
restores idempotency for the specific webhook-after-csv-recovered case
|
||||
WITHOUT re-blocking intentional supplier repeats with different vids.
|
||||
|
||||
Guard: only merges where source_crm_id IS NULL (the CSV-recovered marker).
|
||||
Two webhooks with different vids on same phone+project still create two
|
||||
deals — by-design per Spec B."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Regression and prod data probe
|
||||
|
||||
**Files:**
|
||||
- None
|
||||
|
||||
- [ ] **Step 1: /regression full**
|
||||
|
||||
```
|
||||
/regression full
|
||||
```
|
||||
|
||||
Expected: GREEN. Особенно фокус на Pest --parallel (race conditions).
|
||||
|
||||
- [ ] **Step 2: Prod data probe — current state of duplicates**
|
||||
|
||||
ДО деплоя:
|
||||
```bash
|
||||
ssh liderra "sudo -u postgres psql -d liderra -P pager=off -c \"SELECT phone, project_id, COUNT(*) AS cnt FROM deals WHERE tenant_id=2 AND created_at::date = CURRENT_DATE GROUP BY phone, project_id HAVING COUNT(*) > 1 ORDER BY cnt DESC LIMIT 10\""
|
||||
```
|
||||
|
||||
Зафиксировать список (это будут текущие 37 пар). После деплоя — повторить ту же команду через 2 часа: новые пары не должны появляться.
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Deploy to liderra.ru
|
||||
|
||||
**Files:**
|
||||
- None
|
||||
|
||||
- [ ] **Step 1: prod-deploy-validator agent**
|
||||
|
||||
```
|
||||
subagent_type: prod-deploy-validator
|
||||
prompt: проверь готовность боевого liderra.ru к Phase 2 деплою. Меняется только RouteSupplierLeadJob.php (добавлен merge-check для CSV-recovered deals). Миграций БД нет. Очередь — queue:restart обязателен, потому что job изменился. Phase 1 уже на проде ≥30 мин.
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Merge to main + push**
|
||||
|
||||
```bash
|
||||
git checkout main
|
||||
git merge --ff-only feat/supplier-webhook-fixes
|
||||
git push origin main
|
||||
```
|
||||
|
||||
- [ ] **Step 3: redeploy on prod**
|
||||
|
||||
```bash
|
||||
ssh liderra "cd /var/www/liderra/app && sudo -u www-data ./redeploy.sh 2>&1 | tail -50"
|
||||
```
|
||||
|
||||
Expected: успешно. Особенно проверить что `php artisan queue:restart` отработал (см. в выводе redeploy.sh).
|
||||
|
||||
- [ ] **Step 4: Prod smoke — нет новых дублей за 2 часа**
|
||||
|
||||
Подождать 2 часа, потом:
|
||||
```bash
|
||||
ssh liderra "sudo -u postgres psql -d liderra -P pager=off -c \"SELECT phone, project_id, COUNT(*) FROM deals WHERE tenant_id=2 AND created_at >= NOW() - interval '2 hours' GROUP BY phone, project_id HAVING COUNT(*) > 1\""
|
||||
```
|
||||
|
||||
Expected: **0 rows** (нет новых дублей за 2 часа после деплоя).
|
||||
|
||||
- [ ] **Step 5: Check merge logs**
|
||||
|
||||
```bash
|
||||
ssh liderra "sudo grep 'merged_into_csv_recovered' /var/www/liderra/app/storage/logs/laravel.log | tail -20"
|
||||
```
|
||||
|
||||
Expected: есть записи (показывает что merge сработал). Каждая запись — закрытый дубль.
|
||||
|
||||
- [ ] **Step 6: Update ПИЛОТ.md + memory**
|
||||
|
||||
```bash
|
||||
# Edit ПИЛОТ.md mentioning Phase 2 deployed + merge stats
|
||||
git add ПИЛОТ.md
|
||||
git commit -m "docs(пилот): Phase 2 supplier dedup deployed, $N merges in 2h window"
|
||||
git push origin main
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Done criteria для Phase 2
|
||||
|
||||
- [ ] Все тесты в `CsvWebhookRaceTest.php` PASS
|
||||
- [ ] Все существующие `tests/Feature/Supplier/` PASS (regression)
|
||||
- [ ] /regression full GREEN
|
||||
- [ ] За 2 часа после деплоя — 0 новых пар дубликатов на проде
|
||||
- [ ] Существуют `merged_into_csv_recovered` записи в логе (показывает что merge работает)
|
||||
- [ ] Phase 3 plan starts only after Phase 2 observed clean ≥2h
|
||||
|
||||
---
|
||||
|
||||
## Откат
|
||||
|
||||
```bash
|
||||
ssh liderra "cd /var/www/liderra/app && git revert --no-edit HEAD && sudo -u www-data ./redeploy.sh 2>&1 | tail -20"
|
||||
```
|
||||
|
||||
Миграций нет → откат мгновенный. Дубли начнут возникать снова, но эти 2-3 часа потерь покрываются CsvReconcileJob.
|
||||
@@ -0,0 +1,899 @@
|
||||
# Phase 3: DIRECT platform for non-B prefix projects
|
||||
|
||||
> **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:** Webhook на проекты без `B[123]_` префикса (`client.carmoney.ru`, `cashmotor.ru`, числовые) принимается, проходит routing, создаёт Deal под новой платформой `DIRECT`. Закрывает оставшиеся ~67 потерь сутки.
|
||||
|
||||
**Architecture:** Расширить `platform` enum в `supplier_projects` и `project_supplier_links` до `(B1, B2, B3, DIRECT)` через миграцию. Снять regex в webhook controller. `parsePlatform`/`parseProjectField`/`extractPlatform` возвращают `'DIRECT'` для не-B. `SupplierProjectResolver` принимает DIRECT. `LeadRouter` для DIRECT использует **прямой матч signal_identifier** (потому что DIRECT-supplier_projects ещё не привязаны к Лидерра-проектам через `project_supplier_links`). `LedgerService.resolveSupplierId` — fallback для DIRECT.
|
||||
|
||||
**Tech Stack:** Laravel 13 / PostgreSQL 16 / Pest 4 / PHP 8.3
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-05-25-supplier-webhook-reliability-design.md` §3 Phase 3
|
||||
**Предусловие:** Phase 2 deployed и наблюдаем clean ≥2 часов.
|
||||
**Ветка:** `feat/supplier-webhook-fixes` (продолжение)
|
||||
**Риск:** ВЫСОКИЙ — миграция БД + 5 файлов кода + бизнес-семантика биллинга
|
||||
|
||||
---
|
||||
|
||||
## Открытые вопросы
|
||||
|
||||
- **OQ-2.** `chk_supplier_projects_b1_not_for_sms` constraint — мешает ли DIRECT? **Ответ:** не мешает — это `CHECK (NOT (platform='B1' AND signal_type='sms'))`. DIRECT+SMS пропускается.
|
||||
- **OQ-3.** Биллинг для DIRECT-платформы — какой Supplier (`suppliers.code`) использовать? **Ответ:** добавим `supplier code='direct'` в seed; в [LedgerService.resolveSupplierId](../../../app/app/Services/Billing/LedgerService.php#L127) добавим case `if platform=='DIRECT' return Supplier::where('code', 'direct')`.
|
||||
- **OQ-4.** Как DIRECT-supplier_project привязывается к Лидерра-проекту, если `project_supplier_links` для DIRECT supplier_projects ещё нет? **Ответ:** добавляем fallback в `LeadRouter::matchEligibleProjects` для DIRECT supplier_projects — матчинг по `signal_type + signal_identifier` напрямую с `projects.signal_type + projects.signal_identifier`, без обязательного `project_supplier_links`.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
**Создать:**
|
||||
- `database/migrations/2026_05_25_120000_add_direct_platform_to_supplier_projects.php` — расширение CHECK constraints
|
||||
- `database/migrations/2026_05_25_120100_seed_direct_supplier.php` — seed строки `suppliers.code='direct'` (cost_rub из существующего шаблона)
|
||||
- `app/tests/Feature/Supplier/DirectPlatformTest.php` — end-to-end тесты для DIRECT flow
|
||||
|
||||
**Изменить:**
|
||||
- `app/app/Http/Controllers/Api/SupplierWebhookController.php`:
|
||||
- line 86: снять `regex:/^B[123]_.+$/'`
|
||||
- lines 183-188: `parsePlatform` возвращает `'DIRECT'` для не-B
|
||||
- `app/app/Jobs/RouteSupplierLeadJob.php`:
|
||||
- lines 172-200: `parseProjectField` добавить DIRECT branch
|
||||
- `app/app/Jobs/Supplier/CsvReconcileJob.php`:
|
||||
- lines 237-244: `extractPlatform` возвращает 'DIRECT' (а не `null`) для парсящихся как domain/call/sms строк; `null` оставить только для реального мусора (numeric-only без структуры)
|
||||
- `app/app/Services/SupplierProjects/SupplierProjectResolver.php`:
|
||||
- line 24: `ALLOWED_PLATFORMS = ['B1','B2','B3','DIRECT']`
|
||||
- `app/app/Services/LeadRouter.php`:
|
||||
- lines 50-71: для DIRECT — расширить eligibility SQL с fallback на signal_type+identifier
|
||||
- `app/app/Services/Billing/LedgerService.php`:
|
||||
- lines 127-148: `resolveSupplierId` — добавить case `platform='DIRECT'`
|
||||
- `app/tests/Feature/Http/Webhook/SupplierWebhookTest.php`:
|
||||
- line 95: переписать тест — теперь `invalid_no_b_prefix` → 202 (принимается, platform=DIRECT)
|
||||
- `db/schema.sql` — отразить новый constraint
|
||||
- `db/CHANGELOG_schema.md` — запись v8.X
|
||||
|
||||
**Не трогать:**
|
||||
- `LeadDistributor` — cap=3 работает на Collection, platform-agnostic
|
||||
- `supplier_lead_deliveries` — уже Phase 2 покрывает идемпотентность
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Read all touched files + verify b1-not-for-sms constraint
|
||||
|
||||
**Files:**
|
||||
- Read: `db/schema.sql` § supplier_projects + project_supplier_links
|
||||
- Read: `app/database/migrations/` для последней supplier_projects-related migration
|
||||
|
||||
- [ ] **Step 1: Find current CHECK constraints**
|
||||
|
||||
```bash
|
||||
grep -n 'chk_supplier_projects_platform\|chk_psl_platform\|chk_supplier_projects_b1' \
|
||||
"c:/моя/проекты/портал crm/Документация/db/schema.sql"
|
||||
```
|
||||
|
||||
Зафиксировать exact text constraints для миграции (DROP + ADD).
|
||||
|
||||
- [ ] **Step 2: Find last migration touching supplier_projects.platform**
|
||||
|
||||
```bash
|
||||
ls "c:/моя/проекты/портал crm/Документация/app/database/migrations/" | grep -i supplier_project
|
||||
```
|
||||
|
||||
Документировать в комментарии новой миграции.
|
||||
|
||||
- [ ] **Step 3: Verify b1-not-for-sms doesn't conflict with DIRECT**
|
||||
|
||||
`chk_supplier_projects_b1_not_for_sms` — это `CHECK (NOT (platform='B1' AND signal_type='sms'))`. DIRECT+SMS — не B1, так что пропускается. Не нужно трогать.
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Migration — extend platform CHECK to include DIRECT
|
||||
|
||||
**Files:**
|
||||
- Create: `app/database/migrations/2026_05_25_120000_add_direct_platform_to_supplier_projects.php`
|
||||
|
||||
- [ ] **Step 1: Write migration**
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Phase 3 supplier webhook reliability — расширяет platform enum в
|
||||
* supplier_projects и project_supplier_links до (B1,B2,B3,DIRECT).
|
||||
*
|
||||
* DIRECT — это «прямая» платформа поставщика без B-префикса в имени
|
||||
* проекта (e.g. `client.carmoney.ru`, `cashmotor.ru`, числовые телефоны).
|
||||
* До Phase 3 такие webhook'и отвергались с 302-редиректом и терялись.
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-25-supplier-webhook-reliability-design.md §3 Phase 3
|
||||
*
|
||||
* NB: chk_supplier_projects_b1_not_for_sms (B1+SMS deny) НЕ трогаем —
|
||||
* DIRECT+SMS этим constraint'ом не блокируется.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
DB::statement('ALTER TABLE supplier_projects DROP CONSTRAINT chk_supplier_projects_platform');
|
||||
DB::statement("ALTER TABLE supplier_projects ADD CONSTRAINT chk_supplier_projects_platform CHECK (platform IN ('B1','B2','B3','DIRECT'))");
|
||||
|
||||
DB::statement('ALTER TABLE project_supplier_links DROP CONSTRAINT chk_psl_platform');
|
||||
DB::statement("ALTER TABLE project_supplier_links ADD CONSTRAINT chk_psl_platform CHECK (platform IN ('B1','B2','B3','DIRECT'))");
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
// Перед откатом — убедиться что в БД нет rows с platform='DIRECT',
|
||||
// иначе constraint провалится при ADD. Это ответственность того, кто
|
||||
// запускает migrate:rollback. На prod — отдельный cleanup SQL до отката.
|
||||
DB::statement('ALTER TABLE supplier_projects DROP CONSTRAINT chk_supplier_projects_platform');
|
||||
DB::statement("ALTER TABLE supplier_projects ADD CONSTRAINT chk_supplier_projects_platform CHECK (platform IN ('B1','B2','B3'))");
|
||||
|
||||
DB::statement('ALTER TABLE project_supplier_links DROP CONSTRAINT chk_psl_platform');
|
||||
DB::statement("ALTER TABLE project_supplier_links ADD CONSTRAINT chk_psl_platform CHECK (platform IN ('B1','B2','B3'))");
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Test migration locally**
|
||||
|
||||
```
|
||||
cd app && php artisan migrate --pretend
|
||||
```
|
||||
|
||||
Expected: видим что DROP/ADD CONSTRAINT statements корректны, без ошибок.
|
||||
|
||||
```
|
||||
cd app && php artisan migrate
|
||||
```
|
||||
|
||||
Expected: migration applied. Проверка:
|
||||
```
|
||||
cd app && php artisan tinker --execute='echo DB::selectOne("SELECT pg_get_constraintdef(oid) AS def FROM pg_constraint WHERE conname=\"chk_supplier_projects_platform\"")->def;'
|
||||
```
|
||||
|
||||
Должно содержать `'DIRECT'`.
|
||||
|
||||
- [ ] **Step 3: Commit migration**
|
||||
|
||||
```bash
|
||||
git add app/database/migrations/2026_05_25_120000_add_direct_platform_to_supplier_projects.php
|
||||
git commit -m "feat(db): extend supplier_projects.platform CHECK to include DIRECT
|
||||
|
||||
Adds DIRECT value to chk_supplier_projects_platform and chk_psl_platform
|
||||
constraints. DIRECT represents supplier projects without B[123]_ prefix
|
||||
(e.g. client.carmoney.ru, cashmotor.ru, numeric phone IDs) — currently
|
||||
67 leads/day lost to 302 redirects from webhook validation.
|
||||
|
||||
Schema-only change; no code yet uses DIRECT — code changes follow in
|
||||
subsequent commits. Migration is forward-compatible: old code continues
|
||||
to work with B1/B2/B3 rows."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Seed Supplier row with code='direct'
|
||||
|
||||
**Files:**
|
||||
- Create: `app/database/migrations/2026_05_25_120100_seed_direct_supplier.php`
|
||||
|
||||
- [ ] **Step 1: Inspect existing suppliers rows**
|
||||
|
||||
```
|
||||
cd app && php artisan tinker --execute='print_r(DB::table("suppliers")->get()->toArray());'
|
||||
```
|
||||
|
||||
Найти существующий `cost_rub` для одной из B-платформ. Использовать тот же (DIRECT — same supplier, разная платформа).
|
||||
|
||||
- [ ] **Step 2: Write seed migration**
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Phase 3 — DIRECT supplier row (used by LedgerService::resolveSupplierId
|
||||
* fallback for platform='DIRECT'). cost_rub matches B1 (same supplier,
|
||||
* different routing).
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
$b1 = DB::table('suppliers')->where('code', 'b1')->first();
|
||||
if ($b1 === null) {
|
||||
// Если B1 нет — significant prod drift, не должно произойти.
|
||||
return;
|
||||
}
|
||||
|
||||
DB::table('suppliers')->updateOrInsert(
|
||||
['code' => 'direct'],
|
||||
[
|
||||
'name' => 'BP-GR Direct',
|
||||
'cost_rub' => $b1->cost_rub,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
DB::table('suppliers')->where('code', 'direct')->delete();
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run migration**
|
||||
|
||||
```
|
||||
cd app && php artisan migrate
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify**
|
||||
|
||||
```
|
||||
cd app && php artisan tinker --execute='echo DB::table("suppliers")->where("code","direct")->first()->name;'
|
||||
```
|
||||
|
||||
Expected: `BP-GR Direct`.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add app/database/migrations/2026_05_25_120100_seed_direct_supplier.php
|
||||
git commit -m "feat(db): seed suppliers.code='direct' for DIRECT platform billing"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Failing test — DirectPlatformTest end-to-end
|
||||
|
||||
**Files:**
|
||||
- Create: `app/tests/Feature/Supplier/DirectPlatformTest.php`
|
||||
|
||||
- [ ] **Step 1: Write end-to-end test**
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\RouteSupplierLeadJob;
|
||||
use App\Models\Deal;
|
||||
use App\Models\Project;
|
||||
use App\Models\SupplierLead;
|
||||
use App\Models\SupplierProject;
|
||||
use App\Models\SystemSetting;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
beforeEach(function () {
|
||||
SystemSetting::query()
|
||||
->where('key', 'supplier_webhook_secret')
|
||||
->update(['value' => 'test-secret-32chars-aaaaaaaaaaaaaa']);
|
||||
SystemSetting::query()
|
||||
->where('key', 'supplier_ip_allowlist')
|
||||
->update(['value' => '[]']);
|
||||
|
||||
$this->tenant = Tenant::factory()->create([
|
||||
'balance_rub' => '1000.00',
|
||||
'delivered_in_month' => 0,
|
||||
]);
|
||||
$this->project = Project::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'client.carmoney.ru',
|
||||
'is_active' => true,
|
||||
'daily_limit_target' => 100,
|
||||
'delivered_today' => 0,
|
||||
]);
|
||||
});
|
||||
|
||||
it('webhook with non-B-prefix project is accepted (202) and platform=DIRECT', function () {
|
||||
$response = $this->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [
|
||||
'vid' => 9999001,
|
||||
'project' => 'client.carmoney.ru',
|
||||
'phone' => '79991234567',
|
||||
'time' => time(),
|
||||
]);
|
||||
$response->assertStatus(202);
|
||||
expect(SupplierLead::where('vid', 9999001)->exists())->toBeTrue();
|
||||
expect(SupplierLead::where('vid', 9999001)->first()->platform)->toBe('DIRECT');
|
||||
});
|
||||
|
||||
it('SupplierProjectResolver creates DIRECT supplier_project for non-B project', function () {
|
||||
$resolver = app(\App\Services\SupplierProjects\SupplierProjectResolver::class);
|
||||
$sp = $resolver->resolveOrStub('DIRECT', 'site', 'client.carmoney.ru');
|
||||
expect($sp->platform)->toBe('DIRECT');
|
||||
expect($sp->unique_key)->toBe('client.carmoney.ru');
|
||||
expect($sp->signal_type)->toBe('site');
|
||||
});
|
||||
|
||||
it('RouteSupplierLeadJob delivers DIRECT lead to matching Liderra project via signal_identifier fallback', function () {
|
||||
$lead = SupplierLead::create([
|
||||
'platform' => 'DIRECT',
|
||||
'phone' => '79991234567',
|
||||
'vid' => 9999002,
|
||||
'raw_payload' => ['project' => 'client.carmoney.ru', 'phone' => '79991234567', 'time' => time()],
|
||||
'received_at' => now(),
|
||||
'source' => 'webhook',
|
||||
]);
|
||||
(new RouteSupplierLeadJob($lead->id))->handle(
|
||||
app(\App\Services\LeadRouter::class),
|
||||
app(\App\Services\SupplierProjects\SupplierProjectResolver::class),
|
||||
app(\App\Services\NotificationService::class),
|
||||
app(\App\Services\Billing\LedgerService::class),
|
||||
app(\App\Services\LeadDistributor::class),
|
||||
app(\App\Services\RegionTagResolver::class),
|
||||
);
|
||||
|
||||
$deal = Deal::where('tenant_id', $this->tenant->id)->where('phone', '79991234567')->first();
|
||||
expect($deal)->not->toBeNull();
|
||||
expect($deal->project_id)->toBe($this->project->id);
|
||||
expect($deal->source_crm_id)->toBe(9999002);
|
||||
});
|
||||
|
||||
it('numeric-only project (e.g. 79135191264) accepted as DIRECT', function () {
|
||||
// Поставщик иногда шлёт project=телефонный номер (callback-проекты).
|
||||
$response = $this->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [
|
||||
'vid' => 9999003,
|
||||
'project' => '79135191264',
|
||||
'phone' => '79991234567',
|
||||
'time' => time(),
|
||||
]);
|
||||
$response->assertStatus(202);
|
||||
});
|
||||
|
||||
it('existing B1/B2/B3 webhooks still work (regression)', function () {
|
||||
$response = $this->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [
|
||||
'vid' => 9999004,
|
||||
'project' => 'B1_krk-finance.ru',
|
||||
'phone' => '79991234567',
|
||||
'time' => time(),
|
||||
]);
|
||||
$response->assertStatus(202);
|
||||
expect(SupplierLead::where('vid', 9999004)->first()->platform)->toBe('B1');
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests, expect FAIL on most**
|
||||
|
||||
```
|
||||
cd app && ./vendor/bin/pest tests/Feature/Supplier/DirectPlatformTest.php
|
||||
```
|
||||
|
||||
Expected: тесты #1, #2, #3, #4 FAIL (regex rejects non-B, resolver throws, job throws). Тест #5 PASS (B1 already works).
|
||||
|
||||
- [ ] **Step 3: Commit failing tests**
|
||||
|
||||
```bash
|
||||
git add app/tests/Feature/Supplier/DirectPlatformTest.php
|
||||
git commit -m "test(supplier): end-to-end DIRECT platform tests (failing)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Implement — webhook controller accepts non-B + parsePlatform returns DIRECT
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/app/Http/Controllers/Api/SupplierWebhookController.php`
|
||||
|
||||
- [ ] **Step 1: Remove regex constraint on project field (line 86)**
|
||||
|
||||
```php
|
||||
'project' => ['required', 'string', 'max:255'], // снят regex /^B[123]_.+$/
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update parsePlatform (lines 183-188) to return 'DIRECT' for non-B**
|
||||
|
||||
```php
|
||||
private function parsePlatform(string $project): string
|
||||
{
|
||||
if (preg_match('/^(B[123])_/', $project, $m) === 1) {
|
||||
return $m[1];
|
||||
}
|
||||
return 'DIRECT';
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run tests — DirectPlatformTest #1 should now PASS**
|
||||
|
||||
```
|
||||
cd app && ./vendor/bin/pest tests/Feature/Supplier/DirectPlatformTest.php --filter='accepted (202) and platform=DIRECT'
|
||||
```
|
||||
|
||||
Expected: PASS. Также:
|
||||
```
|
||||
cd app && ./vendor/bin/pest tests/Feature/Http/Webhook/SupplierWebhookTest.php --filter='rejects invalid project format'
|
||||
```
|
||||
|
||||
Тест ('rejects invalid project format ... with 422') теперь будет **FAIL** — потому что мы изменили поведение. Это ожидаемое — переписываем тест в следующем step.
|
||||
|
||||
- [ ] **Step 4: Rewrite the obsolete test in SupplierWebhookTest.php line 95**
|
||||
|
||||
Перепиcать:
|
||||
```php
|
||||
it('accepts project without B[123]_ prefix as platform=DIRECT (Phase 3)', function () {
|
||||
Bus::fake();
|
||||
$response = $this->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [
|
||||
'vid' => 1, 'project' => 'client.carmoney.ru', 'phone' => '79991234567', 'time' => time(),
|
||||
]);
|
||||
$response->assertStatus(202);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run full SupplierWebhookTest + DirectPlatformTest**
|
||||
|
||||
```
|
||||
cd app && ./vendor/bin/pest tests/Feature/Http/Webhook/SupplierWebhookTest.php tests/Feature/Supplier/DirectPlatformTest.php
|
||||
```
|
||||
|
||||
Expected: тесты #1 в DirectPlatformTest PASS, остальные новые — пока FAIL (resolver/job не готовы).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add app/app/Http/Controllers/Api/SupplierWebhookController.php app/tests/Feature/Http/Webhook/SupplierWebhookTest.php
|
||||
git commit -m "feat(supplier-webhook): accept non-B-prefix projects as platform=DIRECT
|
||||
|
||||
Drops regex /^B[123]_.+\$/ from project field validation; parsePlatform()
|
||||
returns 'DIRECT' for projects without B-prefix. SupplierLead created
|
||||
with platform='DIRECT' for these. Rewrites obsolete test that asserted
|
||||
invalid_format → 422 — now invalid_format → 202 with platform=DIRECT."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Implement — SupplierProjectResolver accepts DIRECT
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/app/Services/SupplierProjects/SupplierProjectResolver.php`
|
||||
|
||||
- [ ] **Step 1: Extend ALLOWED_PLATFORMS**
|
||||
|
||||
```php
|
||||
private const ALLOWED_PLATFORMS = ['B1', 'B2', 'B3', 'DIRECT'];
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run DirectPlatformTest #2**
|
||||
|
||||
```
|
||||
cd app && ./vendor/bin/pest tests/Feature/Supplier/DirectPlatformTest.php --filter='creates DIRECT supplier_project'
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add app/app/Services/SupplierProjects/SupplierProjectResolver.php
|
||||
git commit -m "feat(supplier): SupplierProjectResolver accepts platform=DIRECT"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Implement — RouteSupplierLeadJob.parseProjectField + LeadRouter fallback for DIRECT
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/app/Jobs/RouteSupplierLeadJob.php:172-200`
|
||||
- Modify: `app/app/Services/LeadRouter.php:45-76`
|
||||
|
||||
- [ ] **Step 1: parseProjectField — добавить DIRECT branch**
|
||||
|
||||
В RouteSupplierLeadJob, `parseProjectField` (lines 172-200), заменить начало с:
|
||||
|
||||
```php
|
||||
private function parseProjectField(string $project): array
|
||||
{
|
||||
if (preg_match('/^(B[123])_(.+)$/', $project, $m) === 1) {
|
||||
$platform = $m[1];
|
||||
$rest = $m[2];
|
||||
} else {
|
||||
// Phase 3: проекты без B-префикса попадают в DIRECT.
|
||||
// Весь project считается identifier-частью; signal_type определяется
|
||||
// тем же regex'ом, что для $rest у B-префиксных.
|
||||
$platform = 'DIRECT';
|
||||
$rest = $project;
|
||||
}
|
||||
|
||||
// далее существующий код — определение signal_type/identifier на $rest
|
||||
// (call / site / sms по regex'ам), без изменений
|
||||
$domainRe = '/(?<![a-z0-9.\-])([a-z0-9][a-z0-9\-]*(?:\.[a-z0-9][a-z0-9\-]*)*\.[a-z]{2,})/i';
|
||||
// ... existing logic ...
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: LeadRouter — добавить DIRECT fallback**
|
||||
|
||||
В LeadRouter::matchEligibleProjects, расширить SQL: для DIRECT supplier_projects использовать fallback по signal_type+signal_identifier matchу с Лидерра-проектами (если нет project_supplier_links для DIRECT).
|
||||
|
||||
```php
|
||||
public function matchEligibleProjects(SupplierProject $supplierProject): Collection
|
||||
{
|
||||
$todayBit = 1 << (Carbon::now('Europe/Moscow')->isoWeekday() - 1);
|
||||
|
||||
// Phase 3: для DIRECT-supplier_project — fallback на signal_type+signal_identifier
|
||||
// match с Лидерра-проектами, потому что project_supplier_links для DIRECT-row'ов
|
||||
// ещё не настроены (это автоматический матчинг по сигналу). Для B1/B2/B3
|
||||
// продолжаем использовать explicit psl-link.
|
||||
if ($supplierProject->platform === 'DIRECT') {
|
||||
$sql = <<<'SQL'
|
||||
SELECT DISTINCT ON (projects.tenant_id) projects.*
|
||||
FROM projects
|
||||
WHERE projects.signal_type = ?
|
||||
AND LOWER(projects.signal_identifier) = LOWER(?)
|
||||
AND projects.is_active = true
|
||||
AND (projects.delivery_days_mask & ?) <> 0
|
||||
AND projects.delivered_today < COALESCE(projects.effective_daily_limit_today, projects.daily_limit_target)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM tenants
|
||||
WHERE tenants.id = projects.tenant_id
|
||||
AND (tenants.balance_leads > 0 OR tenants.balance_rub > 0)
|
||||
)
|
||||
ORDER BY
|
||||
projects.tenant_id,
|
||||
(COALESCE(projects.effective_daily_limit_today, projects.daily_limit_target) - projects.delivered_today) DESC,
|
||||
projects.created_at,
|
||||
projects.id
|
||||
SQL;
|
||||
$rows = DB::connection('pgsql_supplier')->select(
|
||||
$sql,
|
||||
[$supplierProject->signal_type, $supplierProject->unique_key, $todayBit]
|
||||
);
|
||||
|
||||
return Project::hydrate($rows)->values();
|
||||
}
|
||||
|
||||
// Existing B1/B2/B3 path — explicit psl link
|
||||
$sql = <<<'SQL'
|
||||
SELECT DISTINCT ON (projects.tenant_id) projects.*
|
||||
FROM projects
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM project_supplier_links psl
|
||||
WHERE psl.project_id = projects.id
|
||||
AND psl.supplier_project_id = ?
|
||||
)
|
||||
AND projects.is_active = true
|
||||
AND (projects.delivery_days_mask & ?) <> 0
|
||||
AND projects.delivered_today < COALESCE(projects.effective_daily_limit_today, projects.daily_limit_target)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM tenants
|
||||
WHERE tenants.id = projects.tenant_id
|
||||
AND (tenants.balance_leads > 0 OR tenants.balance_rub > 0)
|
||||
)
|
||||
ORDER BY
|
||||
projects.tenant_id,
|
||||
(COALESCE(projects.effective_daily_limit_today, projects.daily_limit_target) - projects.delivered_today) DESC,
|
||||
projects.created_at,
|
||||
projects.id
|
||||
SQL;
|
||||
$rows = DB::connection('pgsql_supplier')->select($sql, [$supplierProject->id, $todayBit]);
|
||||
|
||||
return Project::hydrate($rows)->values();
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run DirectPlatformTest #3 — end-to-end DIRECT routing**
|
||||
|
||||
```
|
||||
cd app && ./vendor/bin/pest tests/Feature/Supplier/DirectPlatformTest.php --filter='delivers DIRECT lead'
|
||||
```
|
||||
|
||||
Expected: PASS. Deal создан, project_id matched.
|
||||
|
||||
- [ ] **Step 4: Run full supplier regression**
|
||||
|
||||
```
|
||||
cd app && ./vendor/bin/pest tests/Feature/Supplier/ tests/Feature/Jobs/RouteSupplierLeadJobTest.php tests/Feature/Http/Webhook/
|
||||
```
|
||||
|
||||
Expected: все тесты PASS. Особенно регрессия B1/B2/B3 — proxy через `else` branch.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add app/app/Jobs/RouteSupplierLeadJob.php app/app/Services/LeadRouter.php
|
||||
git commit -m "feat(supplier): RouteSupplierLeadJob + LeadRouter handle DIRECT platform
|
||||
|
||||
parseProjectField() returns ('DIRECT', signal_type, identifier) when project
|
||||
has no B-prefix; identifier-detection (call/site/sms regex) runs on full
|
||||
project string. LeadRouter::matchEligibleProjects has a DIRECT fast-path
|
||||
that matches Liderra projects by (signal_type, signal_identifier) directly
|
||||
without requiring project_supplier_links pivot — because DIRECT
|
||||
supplier_projects are auto-created on first webhook and don't have manual
|
||||
psl links.
|
||||
|
||||
B1/B2/B3 path unchanged (psl-based)."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Implement — LedgerService.resolveSupplierId fallback for DIRECT + CsvReconcileJob extractPlatform
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/app/Services/Billing/LedgerService.php:127-148`
|
||||
- Modify: `app/app/Jobs/Supplier/CsvReconcileJob.php:237-244`
|
||||
|
||||
- [ ] **Step 1: Extend LedgerService.resolveSupplierId**
|
||||
|
||||
```php
|
||||
private function resolveSupplierId(SupplierLead $lead): ?int
|
||||
{
|
||||
if ($lead->supplier_project_id !== null) {
|
||||
$sp = DB::table('supplier_projects')->where('id', $lead->supplier_project_id)->first();
|
||||
if ($sp !== null) {
|
||||
if (in_array($sp->platform, ['B1', 'B2', 'B3'], true)) {
|
||||
$supplier = Supplier::where('code', strtolower($sp->platform))->first();
|
||||
if ($supplier !== null) {
|
||||
return (int) $supplier->id;
|
||||
}
|
||||
}
|
||||
if ($sp->platform === 'DIRECT') {
|
||||
$supplier = Supplier::where('code', 'direct')->first();
|
||||
return $supplier?->id;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fallback: parse platform from raw_payload['project']
|
||||
$project = trim((string) ($lead->raw_payload['project'] ?? ''));
|
||||
if (preg_match('/^(B[123])_/', $project, $m) === 1) {
|
||||
$code = strtolower($m[1]);
|
||||
$supplier = Supplier::where('code', $code)->first();
|
||||
return $supplier?->id;
|
||||
}
|
||||
// Phase 3: project без B-префикса — DIRECT
|
||||
if ($project !== '') {
|
||||
$supplier = Supplier::where('code', 'direct')->first();
|
||||
return $supplier?->id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update CsvReconcileJob.extractPlatform**
|
||||
|
||||
Сейчас extractPlatform возвращает null для не-B → строка увеличивает `unparseable_count` (правильный для МУСОРА типа phone/URL в поле project, но НЕ для DIRECT-проектов как `client.carmoney.ru`). Различение:
|
||||
|
||||
```php
|
||||
private function extractPlatform(string $project): ?string
|
||||
{
|
||||
if (preg_match('/^(B[123])_/', $project, $m) === 1) {
|
||||
return $m[1];
|
||||
}
|
||||
// Phase 3: пытаемся распарсить как DIRECT (валидный domain/call/sms identifier).
|
||||
// Только если строка содержит хотя бы одну букву или dot (= вероятно
|
||||
// domain/название), а не чистый-числовой (= скорее всего телефон в роли проекта).
|
||||
if (preg_match('/[a-zA-Zа-яА-Я.]/u', $project) === 1) {
|
||||
return 'DIRECT';
|
||||
}
|
||||
// Чисто цифры или мусор — оставляем как unparseable (как было).
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
NB: чисто-числовые проекты ('79135191264') у поставщика — это **callback-проекты**, они валидны и должны быть DIRECT. Уточняем regex:
|
||||
|
||||
```php
|
||||
private function extractPlatform(string $project): ?string
|
||||
{
|
||||
if (preg_match('/^(B[123])_/', $project, $m) === 1) {
|
||||
return $m[1];
|
||||
}
|
||||
// Phase 3: всё что выглядит как разумный identifier (домен / телефон / SMS-sender) → DIRECT.
|
||||
// unparseable_count теперь только для откровенного мусора (пустые / только спец-символы).
|
||||
$trimmed = trim($project);
|
||||
if ($trimmed !== '' && preg_match('/^[\w\-.а-яА-Я0-9\/() +]+$/u', $trimmed) === 1) {
|
||||
return 'DIRECT';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run regression — CsvReconcileJobTest + RouteSupplierLeadJobBillingTest**
|
||||
|
||||
```
|
||||
cd app && ./vendor/bin/pest tests/Feature/Supplier/CsvReconcileJobTest.php tests/Feature/Supplier/RouteSupplierLeadJobBillingTest.php tests/Feature/Supplier/DirectPlatformTest.php
|
||||
```
|
||||
|
||||
Expected: все PASS.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add app/app/Services/Billing/LedgerService.php app/app/Jobs/Supplier/CsvReconcileJob.php
|
||||
git commit -m "feat(supplier): LedgerService + CsvReconcileJob recognise DIRECT platform
|
||||
|
||||
LedgerService::resolveSupplierId returns suppliers.code='direct' row for
|
||||
DIRECT-platform supplier_projects (and for parsed-from-payload non-B
|
||||
projects). CsvReconcileJob::extractPlatform now classifies most non-empty,
|
||||
non-junk project strings as DIRECT (instead of dumping them into
|
||||
unparseable_count) — this allows CSV recovery to also create DIRECT
|
||||
supplier_leads, mirroring the webhook path."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 9: Sync db/schema.sql + CHANGELOG_schema.md
|
||||
|
||||
**Files:**
|
||||
- Modify: `db/schema.sql` — поправить constraint definitions
|
||||
- Modify: `db/CHANGELOG_schema.md`
|
||||
|
||||
- [ ] **Step 1: Update db/schema.sql constraint definitions**
|
||||
|
||||
В двух местах `chk_supplier_projects_platform` и `chk_psl_platform` — заменить `IN ('B1','B2','B3')` на `IN ('B1','B2','B3','DIRECT')`.
|
||||
|
||||
- [ ] **Step 2: Add CHANGELOG_schema.md entry**
|
||||
|
||||
```markdown
|
||||
## v8.X — 2026-05-25 — DIRECT platform support
|
||||
|
||||
- Extended `chk_supplier_projects_platform` to include `'DIRECT'`
|
||||
- Extended `chk_psl_platform` to include `'DIRECT'`
|
||||
- Seeded `suppliers.code='direct'` row (BP-GR Direct, cost_rub = same as B1)
|
||||
- Spec: docs/superpowers/specs/2026-05-25-supplier-webhook-reliability-design.md
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add db/schema.sql db/CHANGELOG_schema.md
|
||||
git commit -m "docs(schema): sync DIRECT platform CHECK constraints to db/schema.sql"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 10: Regression + prod-readiness
|
||||
|
||||
**Files:**
|
||||
- None
|
||||
|
||||
- [ ] **Step 1: /regression full**
|
||||
|
||||
```
|
||||
/regression full
|
||||
```
|
||||
|
||||
Expected: GREEN. Pest --parallel 700+ tests pass.
|
||||
|
||||
- [ ] **Step 2: Larastan**
|
||||
|
||||
```
|
||||
cd app && composer stan
|
||||
```
|
||||
|
||||
Expected: 0 errors над baseline.
|
||||
|
||||
- [ ] **Step 3: Manual webhook smoke на dev**
|
||||
|
||||
(если dev-сервер работает)
|
||||
```bash
|
||||
cd app && php artisan serve --port=8000 &
|
||||
sleep 2
|
||||
curl -X POST http://localhost:8000/api/webhook/supplier/<dev-secret> \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"vid":99999,"project":"client.carmoney.ru","phone":"79991234567","time":'$(date +%s)'}'
|
||||
pkill -f 'artisan serve' || true
|
||||
```
|
||||
|
||||
Expected: `{"status":"accepted","supplier_lead_id":...}` 202.
|
||||
|
||||
---
|
||||
|
||||
## Task 11: Deploy to liderra.ru
|
||||
|
||||
**Files:**
|
||||
- None
|
||||
|
||||
- [ ] **Step 1: prod-deploy-validator agent**
|
||||
|
||||
```
|
||||
subagent_type: prod-deploy-validator
|
||||
prompt: проверь готовность liderra.ru к Phase 3 деплою. Меняется: миграция БД (2 CHECK constraints), seed (suppliers.code='direct'), 5 PHP-файлов (SupplierWebhookController/RouteSupplierLeadJob/CsvReconcileJob/SupplierProjectResolver/LeadRouter/LedgerService), сменён тест.
|
||||
|
||||
Особое внимание:
|
||||
1. Миграция ALTER CONSTRAINT не блокирует таблицу долго (DROP+ADD на 2 таблицах в одной транзакции).
|
||||
2. После миграции — обязательный queue:restart (RouteSupplierLeadJob memory-cached в воркерах).
|
||||
3. redeploy.sh должен сначала migrate потом optimize — проверь порядок.
|
||||
|
||||
Phase 1 + Phase 2 уже стоят ≥2h. 8 pre-flight + GO/NO-GO.
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Merge feature branch → main**
|
||||
|
||||
```bash
|
||||
git checkout main
|
||||
git merge --ff-only feat/supplier-webhook-fixes
|
||||
git push origin main
|
||||
```
|
||||
|
||||
- [ ] **Step 3: redeploy.sh**
|
||||
|
||||
```bash
|
||||
ssh liderra "cd /var/www/liderra/app && sudo -u www-data ./redeploy.sh 2>&1 | tail -80"
|
||||
```
|
||||
|
||||
Expected: migration ran successfully, queue:restart fired, deploy complete.
|
||||
|
||||
- [ ] **Step 4: Prod smoke — webhook with non-B project**
|
||||
|
||||
```bash
|
||||
ssh liderra 'curl -sk -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"vid\":99999001,\"project\":\"client.carmoney.ru\",\"phone\":\"79991234567\",\"time\":'$(date +%s)'}" \
|
||||
https://liderra.ru/api/webhook/supplier/8c1c07ddb0768763661b357198e0625832f74ad0915d91b1'
|
||||
```
|
||||
|
||||
Expected: `{"status":"accepted","supplier_lead_id":...}` или `{"status":"already_processed",...}` если повтор. Status 202 / 200.
|
||||
|
||||
- [ ] **Step 5: Check supplier_projects has new DIRECT row**
|
||||
|
||||
```bash
|
||||
ssh liderra "sudo -u postgres psql -d liderra -c \"SELECT id, platform, signal_type, unique_key, created_at FROM supplier_projects WHERE platform='DIRECT' ORDER BY id DESC LIMIT 5\""
|
||||
```
|
||||
|
||||
Expected: видим только что созданную (или существующую) DIRECT-row с unique_key='client.carmoney.ru' (test smoke).
|
||||
|
||||
- [ ] **Step 6: Wait 6 hours, observe**
|
||||
|
||||
Через 6 часов:
|
||||
```bash
|
||||
ssh liderra "sudo grep '/api/webhook/supplier' /var/log/nginx/access.log | grep '$(date +%d/%b)' | awk '{print \$9}' | sort | uniq -c"
|
||||
ssh liderra "sudo -u postgres psql -d liderra -c \"SELECT platform, COUNT(*) FROM supplier_leads WHERE received_at > NOW() - interval '6 hours' GROUP BY platform\""
|
||||
ssh liderra "sudo -u postgres psql -d liderra -c \"SELECT COUNT(*) FILTER (WHERE source_crm_id IS NULL) AS no_crm_id, COUNT(*) FILTER (WHERE source_crm_id IS NOT NULL) AS with_crm_id, COUNT(*) AS total FROM deals WHERE tenant_id=2 AND created_at > NOW() - interval '6 hours'\""
|
||||
```
|
||||
|
||||
Expected:
|
||||
- nginx: 0 × 302 на webhook (все принимаются)
|
||||
- supplier_leads: видим записи с platform='DIRECT' (~ 67/24 = 2-3 в час)
|
||||
- deals: 0 unmerged duplicates (Phase 2 покрывает)
|
||||
|
||||
- [ ] **Step 7: Update ПИЛОТ.md + memory**
|
||||
|
||||
```bash
|
||||
# Update ПИЛОТ.md, memory entries
|
||||
git add ПИЛОТ.md
|
||||
git commit -m "docs(пилот): Phase 3 supplier DIRECT platform deployed, $X DIRECT leads in 6h"
|
||||
git push origin main
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Done criteria для Phase 3
|
||||
|
||||
- [ ] Все тесты в DirectPlatformTest.php + регрессия supplier/* + webhook/* PASS
|
||||
- [ ] /regression full GREEN
|
||||
- [ ] Larastan baseline clean
|
||||
- [ ] migration up/down работают на dev
|
||||
- [ ] Прод-smoke: webhook `project: "client.carmoney.ru"` → 202
|
||||
- [ ] 6 часов наблюдения: webhook 302 ушли в 0, новые DIRECT leads принимаются, нет дублей
|
||||
|
||||
---
|
||||
|
||||
## Откат
|
||||
|
||||
Сложнее остальных — есть миграция БД.
|
||||
|
||||
```bash
|
||||
# 1. Cleanup: убрать DIRECT-rows если они появились на проде
|
||||
ssh liderra "sudo -u postgres psql -d liderra -c \"DELETE FROM project_supplier_links WHERE platform='DIRECT'; DELETE FROM supplier_projects WHERE platform='DIRECT'\""
|
||||
|
||||
# 2. Migration down
|
||||
ssh liderra "cd /var/www/liderra/app && sudo -u www-data php artisan migrate:rollback --step=2"
|
||||
|
||||
# 3. Revert code
|
||||
ssh liderra "cd /var/www/liderra/app && git revert --no-edit HEAD~N..HEAD && sudo -u www-data ./redeploy.sh"
|
||||
```
|
||||
|
||||
Лиды с platform=DIRECT, уже превратившиеся в deals, остаются (deal.project_id указывает на валидный Лидерра-проект); supplier_lead.platform='B1' fallback не применится для уже сохранённых, но и не нужен — они уже обработаны.
|
||||
|
||||
Если откат нужен экстренно — можно ограничиться **revert кода без migration:rollback**: миграция оставляет DIRECT в enum, старый код просто никогда не создаст такую row. БД не сломается.
|
||||
@@ -0,0 +1,157 @@
|
||||
# Enforce hard rules — design (2026-05-25 night)
|
||||
|
||||
**Status:** In progress (autonomous overnight implementation)
|
||||
**Origin:** End of brain factor-analysis 4-passes session (HEAD `58784b18`). Honest retrospective showed brain-governance / observer / classifier architecture is observe-only — no enforce. Controller (Claude) rationalized 4 skill bypasses + single coverage tag for 6 hours of varied activity without any hook blocking the behaviour.
|
||||
**Goal:** Convert soft warnings to hard `exit 2` blocks at the only enforce-able layer Claude Code exposes — PreToolUse + Stop hooks. Substance-of-skill compliance translates to artifact-checks.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Constraining Claude's text output (impossible by architecture — LLM generation).
|
||||
- Enforcing test quality (substance). Future LLM-judge epic.
|
||||
- Enforcing skill content interpretation. Best-effort via artifact gates.
|
||||
- Replacing the classifier / observer / brain-retro infrastructure. This is enforcement layer on top.
|
||||
|
||||
## Architectural premise
|
||||
|
||||
Claude Code hook surface:
|
||||
- **UserPromptSubmit** — can inject `<system-reminder>` text into the next turn's context. CAN'T block.
|
||||
- **PreToolUse** — `exit 2` blocks the tool call. Stderr returns to Claude.
|
||||
- **PostToolUse** — observes, can write state. CAN'T block (tool already ran).
|
||||
- **Stop** — `exit 2` denies turn completion. Stderr returns to Claude on next continuation.
|
||||
|
||||
This proposal uses all four. Output text remains uncontrolled by design — but every consequential ACTION (tool call, turn completion) passes a gate.
|
||||
|
||||
## The 10 rules (priority + risk ordered)
|
||||
|
||||
### Rule #1 — Mandatory re-classification per prompt
|
||||
|
||||
**Mechanism:** UserPromptSubmit hook (`tools/enforce-prompt-classify.mjs`) runs after the existing classifier, then injects a `<system-reminder>` listing:
|
||||
- Classification + confidence
|
||||
- 1-3 recommended skills/nodes
|
||||
- Forced `coverage:` line requirement (first line of response)
|
||||
|
||||
**Effect:** Each turn starts with explicit coverage expectation visible to Claude in context.
|
||||
|
||||
**Override:** User says one of the override-vocab phrases (see Rule #9). Then injection is suppressed for that prompt.
|
||||
|
||||
### Rule #2 — Coverage tag verified against artifacts
|
||||
|
||||
**Mechanism:** Stop hook (`tools/enforce-coverage-verify.mjs`). Reads the assistant's last response, parses `coverage: <channel>:<id>`. Then:
|
||||
- `channel=skill` → check transcript for `Skill` tool_use with `input.skill === id` in this turn. If absent → `exit 2`.
|
||||
- `channel=node` → check for tool_use matching the node's canonical tool (e.g., #19 frontend-design → check for matching skill or canonical command). If absent → `exit 2`.
|
||||
- `channel=direct` → no artifact check, but classifier-recommendation must align with non-direct fallback (handled by Rule #8).
|
||||
- No `coverage:` line at all → `exit 2`.
|
||||
|
||||
**Override:** Override-vocab phrase in previous user prompt.
|
||||
|
||||
### Rule #3 — TDD-gate on production code
|
||||
|
||||
**Mechanism:** PreToolUse hook on `Edit`/`Write`/`MultiEdit` (`tools/enforce-tdd-gate.mjs`). For paths matching production patterns:
|
||||
- `tools/**/*.mjs` (not `*.test.mjs`)
|
||||
- `app/app/**/*.php` (not `app/tests/**`)
|
||||
- `resources/js/**` (not `**/*.spec.ts`, not `**/*.test.ts`)
|
||||
|
||||
Reads transcript of current turn so far. Requires:
|
||||
1. Earlier `Edit`/`Write` on a corresponding test path within the same turn, OR
|
||||
2. Test artifact already exists (Bash `test -f` could verify, but we read git status)
|
||||
|
||||
AND:
|
||||
3. Earlier `Bash` with `vitest` / `pest` in command, AND
|
||||
4. The `Bash` stdout in transcript contains a "fail" / "FAIL" marker (RED phase confirmed)
|
||||
|
||||
If any check fails → `exit 2` with explanation.
|
||||
|
||||
**Override:** Override-vocab phrase + sentinel file `~/.claude/runtime/tdd-bypass-<session_id>.flag` (auto-created from override).
|
||||
|
||||
### Rule #4 — Git commit/push requires verification artifact
|
||||
|
||||
**Mechanism:** PreToolUse hook on `Bash` (`tools/enforce-verify-before-push.mjs`). Pattern-matches command for `git commit` or `git push`. If matched:
|
||||
- Check for sentinel file `~/.claude/runtime/verify-pass-<session_id>.json`
|
||||
- Sentinel contains `last_full_run_at` timestamp, `result: pass|fail`, `command_run`, `tests_total`, `tests_passed`
|
||||
- Sentinel must be written by Rule's companion PostToolUse hook on Bash, when Bash command matches vitest/pest full-run pattern AND stdout indicates success
|
||||
- Sentinel age < 600s required; missing or stale → `exit 2`
|
||||
|
||||
**Override:** Override-vocab phrase or `RECOVERY-INTENT:` marker in previous response.
|
||||
|
||||
### Rule #5 — Memory write requires memory-sync coverage
|
||||
|
||||
**Mechanism:** PreToolUse hook on `Edit`/`Write` (`tools/enforce-memory-coverage.mjs`). Path-match:
|
||||
- `**/memory/*.md`
|
||||
- `**/MEMORY.md`
|
||||
- `C:\Users\*\.claude\projects\**\memory\*.md`
|
||||
|
||||
Reads last assistant message for `coverage: direct:memory-sync` or `coverage: skill:<memory-related-skill>`.
|
||||
If coverage absent or stale (matches non-memory channel) → `exit 2` with re-announce instruction.
|
||||
|
||||
### Rule #6 — Writing-plans enforce for feature/bugfix/refactor
|
||||
|
||||
**Mechanism:** PreToolUse hook on production-code `Edit`/`Write` (folded into Rule #3 hook). Before first production-code edit of a turn classified as `feature`/`bugfix`/`refactor`:
|
||||
- Either invoke `superpowers:writing-plans` skill (Skill tool_use) in this turn so far, OR
|
||||
- Plan file exists at `docs/superpowers/plans/<date>-<slug>.md` referenced in transcript, OR
|
||||
- Override-vocab phrase
|
||||
|
||||
If none → `exit 2`.
|
||||
|
||||
### Rule #7 — Branch-switch detection before commit
|
||||
|
||||
**Mechanism:** PreToolUse on `Bash` matching `git commit`. Hook runs `git branch --show-current`. Compares to expected branch (from `~/.claude/runtime/expected-branch-<session_id>`, written at session start or when user explicitly mentions a branch).
|
||||
|
||||
If actual ≠ expected → `exit 2`: «Branch switched silently. Verify via `BRANCH-SWITCH-CONFIRMED` or `RECOVERY-INTENT`.»
|
||||
|
||||
### Rule #8 — Classifier-mismatch enforce
|
||||
|
||||
**Mechanism:** Stop hook (chained after Rule #2). Reads classifier output:
|
||||
- If `classifier_output.recommended_node !== null` AND
|
||||
- `confidence >= 0.7` AND
|
||||
- No `Skill`/`Task` tool_use matching the recommendation in this turn AND
|
||||
- No `override: <reason>` line in response
|
||||
|
||||
→ `exit 2`.
|
||||
|
||||
### Rule #9 — Override-vocabulary hard-coded
|
||||
|
||||
**Mechanism:** Config file `tools/enforce-override-vocab.json` with allowed phrases. Hooks read this once at startup. Phrases (initial):
|
||||
- `без скилов` → all skill-related rules suppressed for this prompt
|
||||
- `direct ok` → coverage=direct allowed without justification
|
||||
- `срочно` → Rule #4 (verify) suppressed
|
||||
- `быстрый коммит` → Rule #3+#4 suppressed for one commit
|
||||
- `recovery` → Rule #7 suppressed
|
||||
- `memory dump` → Rule #5 acknowledged auto
|
||||
|
||||
Match is literal substring against user's last prompt. Case-insensitive. Each use logged to `~/.claude/runtime/override-usage.jsonl`.
|
||||
|
||||
### Rule #10 — Rationalization flags (post-fact audit)
|
||||
|
||||
**Mechanism:** PostToolUse on `Bash` (`tools/enforce-rationalization-audit.mjs`). After each prod-code Edit/Write or git commit:
|
||||
- Scan turn so far for indicators: weak test (≤2 expects), commit message lacking TDD evidence, "just this once" / "for now" / "пока без" / "сейчас быстрее" phrases.
|
||||
- Each flag appended to `~/.claude/runtime/rationalization-flags-<session_id>.jsonl`.
|
||||
- Next UserPromptSubmit hook reads this file and injects into context: «Previous turn flagged: X — adjust behavior.»
|
||||
|
||||
Soft (no block), but visible to Claude on next turn.
|
||||
|
||||
## Anti-self-block strategy during development
|
||||
|
||||
Implementing the rules inside the very project they will enforce creates a chicken-and-egg problem. Mitigation:
|
||||
|
||||
1. **Develop on feature branch `feat/enforce-hard-rules`** (already created).
|
||||
2. **Hook scripts are inert until wired into `.claude/settings.json`.** All implementation commits don't trigger them.
|
||||
3. **Final commit atomically wires all hooks** in settings.json.
|
||||
4. **First push and test must happen ON main after wire-up commit** — by then all rules are committed AND satisfied (because each new turn after wire will start under enforced rules naturally).
|
||||
|
||||
## Test strategy per rule
|
||||
|
||||
Per-rule unit tests in `tools/enforce-*.test.mjs`:
|
||||
- Hook receives fake stdin (event JSON)
|
||||
- Hook decision verified by exit code + stderr message
|
||||
- Sentinel file behavior tested with mkdtemp baseDir override
|
||||
- Override-vocab integration tested by injecting phrase in prev-prompt fixture
|
||||
|
||||
Target ~60-100 tests total for all hooks.
|
||||
|
||||
## Out of scope (deferred, may revisit morning)
|
||||
|
||||
- LLM-judge on test quality
|
||||
- Confidence threshold tuning (default 0.7, hand-tune via brain-retro)
|
||||
- Multi-prompt session-level reasoning (each prompt evaluated standalone)
|
||||
- Conflict resolution if multiple override-vocab phrases stack
|
||||
- UI for override-usage retro (just JSONL file; brain-retro will read)
|
||||
@@ -0,0 +1,291 @@
|
||||
# Supplier webhook reliability — design spec
|
||||
|
||||
**Дата:** 2026-05-25
|
||||
**Статус:** draft → готов к плану
|
||||
**Ветка:** `feat/supplier-webhook-fixes`
|
||||
**Связано:** Спек B Phase 1 (`docs/superpowers/specs/2026-05-23-billing-v2-spec-b-duplicates-design.md`) — снят DuplicateDetector; данная спека закрывает race condition, оставшийся после Спека B.
|
||||
|
||||
---
|
||||
|
||||
## 1. Проблема
|
||||
|
||||
На боевом liderra.ru за сутки 25.05.2026 для тенанта `client1` (tenant_id=2):
|
||||
|
||||
- Поставщик crm.bp-gr.ru отдал **205 уникальных лидов** (учётка `info@lkomega.ru`, страница `/admin/visit/index-visit?visit=rt`)
|
||||
- На портале — **160 сделок**, из них **123 уникальных телефона** (37 — дубликаты `phone+project`)
|
||||
- **Расхождения:** 82 лида у поставщика не дошли до портала; 37 deals в портале дублированы
|
||||
|
||||
### 1.1. Корневая причина потерь (76 из 82)
|
||||
|
||||
Из 234 POST-запросов поставщика на `/api/webhook/supplier/<secret>` сегодня:
|
||||
- **132** → 202 Accepted (приняты)
|
||||
- **76** → 302 Found (Location: `https://liderra.ru`)
|
||||
- 29 → 301 (http→https на `/`)
|
||||
|
||||
Воспроизведено вручную: `curl -X POST` с пустым `{}` → 302 + Set-Cookie. Это **дефолтный Laravel behavior**: для запросов, где `Accept` НЕ содержит `application/json`, `ValidationException` рендерится через `redirect()->back()->withErrors()` — 302 на referer (которого нет у webhook-вызывающего) → fallback на `/`.
|
||||
|
||||
Запросы 302 — это webhook-и где `project` НЕ матчится regex `'project' => regex:/^B[123]_.+$/'` ([app/app/Http/Controllers/Api/SupplierWebhookController.php:86](../../../app/app/Http/Controllers/Api/SupplierWebhookController.php#L86)).
|
||||
|
||||
Конкретные «непринимаемые» проекты (видны в supplier rt-list):
|
||||
- `client.carmoney.ru` — 55 лидов
|
||||
- `B2_Caranga` — 7
|
||||
- `cabinet.caranga.ru` — 3
|
||||
- `cashmotor.ru` — 2
|
||||
- остальные единичные: `73912346386`, `79135191264`, `78006009393`, `78007006600`, `79029248888`, `B2_drivezaim`, `B3_+7 (495) 023-66-52` и т.п.
|
||||
|
||||
### 1.2. Корневая причина дублей (37)
|
||||
|
||||
[app/app/Jobs/Supplier/CsvReconcileJob.php:146-155](../../../app/app/Jobs/Supplier/CsvReconcileJob.php#L146-L155) каждые 30 мин создаёт «recovered» `SupplierLead` с **`vid: null`**, `source: csv_recovery` для лидов, найденных в CSV поставщика но отсутствующих в наших `supplier_leads` за окно.
|
||||
|
||||
Затем поставщик ретраит webhook с настоящим `vid` (численный) → создаётся **новый** `SupplierLead` (UNIQUE по `vid`, NULL ≠ NULL → не считается дублем) → `RouteSupplierLeadJob` создаёт **второй Deal**.
|
||||
|
||||
`supplier_lead_deliveries` уник-индекс на `(supplier_lead_id, tenant_id)` ([app/app/Jobs/RouteSupplierLeadJob.php:249-262](../../../app/app/Jobs/RouteSupplierLeadJob.php#L249-L262)) **не блокирует**, потому что у CSV-recovered и webhook разные `supplier_lead.id`.
|
||||
|
||||
Раньше эту race-condition закрывал `DuplicateDetector` (24h-фильтр по `phone+project`), который был снят в Спеке B Phase 1 (commit `ccfecd5e`, 24.05) с обоснованием «за повторы поставщика берём».
|
||||
|
||||
### 1.3. Цепочка B-префикса (5 точек)
|
||||
|
||||
Regex `B[123]_` встречается в коде в **5 точках**, и все обязательны для текущего flow:
|
||||
|
||||
| # | Место | file:line | Поведение без B-префикса |
|
||||
|---|---|---|---|
|
||||
| 1 | Webhook validation | [SupplierWebhookController.php:86](../../../app/app/Http/Controllers/Api/SupplierWebhookController.php#L86) | ValidationException → 302 (см. 1.1) |
|
||||
| 2 | parsePlatform fallback | [SupplierWebhookController.php:183-188](../../../app/app/Http/Controllers/Api/SupplierWebhookController.php#L183-L188) | silent fallback 'B1' |
|
||||
| 3 | parseProjectField | [RouteSupplierLeadJob.php:172-200](../../../app/app/Jobs/RouteSupplierLeadJob.php#L172-L200) | **RuntimeException** → retry 3x → failed_webhook_jobs |
|
||||
| 4 | extractPlatform | [CsvReconcileJob.php:237-244](../../../app/app/Jobs/Supplier/CsvReconcileJob.php#L237-L244) | возвращает `null` → строка в `unparseable_count` (56 сегодня) |
|
||||
| 5 | БД constraint | `supplier_projects.platform CHECK IN (B1,B2,B3)` | нельзя сохранить platform=`DIRECT` |
|
||||
|
||||
---
|
||||
|
||||
## 2. Цели и не-цели
|
||||
|
||||
### Цели
|
||||
|
||||
- **C1.** Webhook на `/api/webhook/supplier/*` ВСЕГДА отвечает JSON (202/200/422/429/404), никогда не редиректит. Любая `ValidationException` для этого URL — JSON 422 с полем `errors`.
|
||||
- **C2.** Webhook, поступивший после CSV-recovered deal по тому же `(tenant_id, phone, project_id)` в окне 24h, **обновляет** существующий deal (`source_crm_id`, `received_at` если новее, `phones`), а не создаёт второй. Биллинг не списывает второй раз.
|
||||
- **C3.** Webhook на проекты без префикса `B[123]_` (`client.carmoney.ru`, `cashmotor.ru`, числовые) принимается, проходит routing, создаёт Deal под новой платформой `DIRECT`.
|
||||
|
||||
### Не-цели
|
||||
|
||||
- **NG1.** Восстановление 82 потерянных лидов 25.05 — оффлайн-операция после деплоя, через `php artisan supplier:reconcile-force` или ручное добавление по списку (вне scope этой спеки).
|
||||
- **NG2.** Очистка 37 текущих дублей в проде — отдельная миграция данных или ручной SQL (вне scope).
|
||||
- **NG3.** Изменение бизнес-правил биллинга для DIRECT-платформы. Берётся та же тарификация, что для B1/B2/B3 (по умолчанию tier по `signal_type`). Альтернативная цена для DIRECT — отдельный спек если потребуется.
|
||||
- **NG4.** Отказ от CSV reconcile job — он остаётся как safety net, но теперь дедупликация не приводит к дублям.
|
||||
|
||||
---
|
||||
|
||||
## 3. Решение
|
||||
|
||||
Три независимые фазы. Каждая фаза — отдельный PR, отдельный план, отдельный выкат на боевой. Между фазами — observation period (1-2 часа на проде, потом следующая фаза).
|
||||
|
||||
### Phase 1 (низкий риск) — Always JSON 422 для webhook validation errors
|
||||
|
||||
**Изменения:**
|
||||
|
||||
- В [app/bootstrap/app.php:35](../../../app/bootstrap/app.php#L35) `withExceptions()` добавить render:
|
||||
```php
|
||||
$exceptions->render(function (\Illuminate\Validation\ValidationException $e, Request $request) {
|
||||
if ($request->is('api/webhook/supplier/*')) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed',
|
||||
'errors' => $e->errors(),
|
||||
], 422);
|
||||
}
|
||||
return null; // дефолтный рендер для остальных
|
||||
});
|
||||
```
|
||||
- Тест: POST с `Accept: text/html` (имитация поставщика без JSON-Accept) на webhook с невалидным payload → assert 422 + JSON Content-Type + ошибка в `errors`.
|
||||
- Существующие тесты `SupplierWebhookTest.php` — все `postJson(...)` → 422 уже работают. Добавляется один новый тест с обычным `post()`.
|
||||
|
||||
**Risk:** низкий. Изменение не трогает control flow webhook'а, только формат ответа на ошибку.
|
||||
|
||||
**Откатываемость:** одной строчкой revert.
|
||||
|
||||
### Phase 2 (средний риск) — Идемпотентность webhook ↔ CSV-recovered
|
||||
|
||||
**Изменения:**
|
||||
|
||||
- В [app/app/Jobs/RouteSupplierLeadJob.php:207](../../../app/app/Jobs/RouteSupplierLeadJob.php#L207) `createDealCopyForProject()` ДО создания Deal — поиск:
|
||||
```php
|
||||
$existingDeal = Deal::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('phone', (string) $lead->phone)
|
||||
->where('project_id', $project->id)
|
||||
->where('received_at', '>=', now()->subDay())
|
||||
->whereNull('source_crm_id') // только CSV-recovered ждут vid
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
```
|
||||
- Если найден → `UPDATE deals SET source_crm_id = vid, received_at = MAX(...)` + `supplier_lead_deliveries` запись + **НЕ списываем баланс повторно** (Ledger.alreadyChargedForDeal или просто отсутствие второго `chargeForDelivery`) → возврат `false`/`'merged'`.
|
||||
- Если не найден → текущий путь создания нового Deal без изменений.
|
||||
- `supplier_lead_deliveries.deal_id` обновляется на найденный deal.id.
|
||||
|
||||
**Биллинг safety:**
|
||||
- `LedgerService::chargeForDelivery` уже идемпотентен по `supplier_lead_id` (PK lead_charges) — проверить.
|
||||
- Если не идемпотентен — добавить guard: SELECT lead_charges WHERE deal_id=$existingDeal->id; если есть — skip charge.
|
||||
|
||||
**Тесты:**
|
||||
- TDD: CSV-recovered deal без vid → webhook на тот же phone+project → assert 1 deal (не 2), source_crm_id заполнен, lead_charges = 1 запись.
|
||||
- Regression: повтор поставщика по тому же vid (память Спека B — «за повторы берём») → assert 2 deals (если разные supplier_lead с разными vid).
|
||||
- Race: одновременный webhook и CSV-recovery → lockForUpdate гарантирует один deal.
|
||||
|
||||
**Risk:** средний — затрагивает биллинг. Нужно убедиться что `chargeForDelivery` не списывает второй раз.
|
||||
|
||||
### Phase 3 (высокий риск) — DIRECT platform для проектов без B-префикса
|
||||
|
||||
**Изменения:**
|
||||
|
||||
1. **Миграция БД** `database/migrations/2026_05_25_120000_add_direct_platform.php`:
|
||||
```sql
|
||||
ALTER TABLE supplier_projects DROP CONSTRAINT chk_supplier_projects_platform;
|
||||
ALTER TABLE supplier_projects ADD CONSTRAINT chk_supplier_projects_platform
|
||||
CHECK (platform IN ('B1','B2','B3','DIRECT'));
|
||||
ALTER TABLE project_supplier_links DROP CONSTRAINT chk_psl_platform;
|
||||
ALTER TABLE project_supplier_links ADD CONSTRAINT chk_psl_platform
|
||||
CHECK (platform IN ('B1','B2','B3','DIRECT'));
|
||||
```
|
||||
Также снять constraint `chk_supplier_projects_b1_not_for_sms` (он про B1+sms) если он мешает.
|
||||
|
||||
2. **Webhook regex** [SupplierWebhookController.php:86](../../../app/app/Http/Controllers/Api/SupplierWebhookController.php#L86):
|
||||
```php
|
||||
'project' => ['required', 'string', 'max:255'], // снят regex
|
||||
```
|
||||
|
||||
3. **parsePlatform** [SupplierWebhookController.php:183-188](../../../app/app/Http/Controllers/Api/SupplierWebhookController.php#L183-L188):
|
||||
```php
|
||||
private function parsePlatform(string $project): string
|
||||
{
|
||||
if (preg_match('/^(B[123])_/', $project, $m) === 1) {
|
||||
return $m[1];
|
||||
}
|
||||
return 'DIRECT';
|
||||
}
|
||||
```
|
||||
|
||||
4. **parseProjectField** [RouteSupplierLeadJob.php:172-200](../../../app/app/Jobs/RouteSupplierLeadJob.php#L172-L200) — добавить DIRECT branch:
|
||||
```php
|
||||
private function parseProjectField(string $project): array
|
||||
{
|
||||
if (preg_match('/^(B[123])_(.+)$/', $project, $m) === 1) {
|
||||
$platform = $m[1];
|
||||
$rest = $m[2];
|
||||
} else {
|
||||
$platform = 'DIRECT';
|
||||
$rest = $project; // весь project считается identifier-частью
|
||||
}
|
||||
// далее существующая логика определения signal_type/identifier на $rest
|
||||
// (call / site / sms по тем же regex'ам)
|
||||
}
|
||||
```
|
||||
|
||||
5. **extractPlatform** [CsvReconcileJob.php:237-244](../../../app/app/Jobs/Supplier/CsvReconcileJob.php#L237-L244):
|
||||
```php
|
||||
private function extractPlatform(string $project): string
|
||||
{
|
||||
if (preg_match('/^(B[123])_/', $project, $m) === 1) {
|
||||
return $m[1];
|
||||
}
|
||||
return 'DIRECT';
|
||||
}
|
||||
```
|
||||
Логика `unparseable_count` снимается для DIRECT-кейса; остаётся только для **реального мусора** (телефоны/URL в поле project). Различение через дополнительный regex проверки `[a-z0-9]` в начале.
|
||||
|
||||
6. **SupplierProjectResolver** — резолв по `(platform=DIRECT, signal_type, identifier)` создаёт/находит `supplier_projects` row с platform=DIRECT.
|
||||
|
||||
7. **LeadRouter::matchEligibleProjects** — DIRECT-platform fetches по тем же signal_type/identifier-полям проекта; никаких B1/B2/B3 специальных условий.
|
||||
|
||||
**Тесты:**
|
||||
- Существующий тест `'rejects invalid project format with 422'` ([SupplierWebhookTest.php:95](../../../app/tests/Feature/Http/Webhook/SupplierWebhookTest.php#L95)) переписать: теперь invalid_format → 202 (принят), platform=DIRECT.
|
||||
- Новый тест: webhook с `project: "client.carmoney.ru"` → 202, supplier_lead.platform=DIRECT, RouteSupplierLeadJob создаёт SupplierProject под DIRECT, Deal создаётся.
|
||||
- Существующие тесты RouteSupplierLeadJobTest / CsvReconcileJobTest — добавить DIRECT-кейсы.
|
||||
- Регрессия: все B1/B2/B3 кейсы продолжают работать без изменений.
|
||||
|
||||
**Risk:** высокий — затрагивает миграцию БД, ⩾5 файлов кода, тесты, бизнес-семантику биллинга для DIRECT.
|
||||
|
||||
**Сложность:** одновременная правка должна быть атомарной — если деплоится миграция но не код, controller примет lid'ы которые job не сможет обработать. Один PR, один деплой, очередь queue:restart после.
|
||||
|
||||
---
|
||||
|
||||
## 4. Стратегия деплоя
|
||||
|
||||
Три отдельных деплоя на liderra.ru через `redeploy.sh` (per memory: «`sudo -u www-data php artisan optimize` в строке 9 скрипта»):
|
||||
|
||||
1. **Деплой 1 (Phase 1):** ~10 мин outage риск 0. Сразу после деплоя смотрим nginx logs — все POST → 422 или 202, нет 30x. Ждём 30 мин — drift_alert не должен подниматься.
|
||||
2. **Деплой 2 (Phase 2):** ~10 мин outage риск 0. Смотрим что новые deals не дублируются (`SELECT phone, project_id, COUNT(*) FROM deals WHERE created_at > NOW()-interval'2h' GROUP BY 1,2 HAVING COUNT(*)>1`). Ждём 1-2 часа.
|
||||
3. **Деплой 3 (Phase 3):** включает миграцию БД. Сначала миграция (idempotent CHECK extension), затем код. Smoke: POST `project: "client.carmoney.ru"` с правильным secret и IP → 202, supplier_lead создан, deal создан. Ждём 6 часов на наблюдение, после — закрытие задачи.
|
||||
|
||||
Перед каждым деплоем — обязательно агент `prod-deploy-validator` (per [Pravila §2.4](../../Pravila_raboty_Claude_v1_1.md)).
|
||||
|
||||
---
|
||||
|
||||
## 5. Тестирование
|
||||
|
||||
### Pest unit/feature
|
||||
|
||||
Все три фазы — TDD: тест → fail → имплементация → pass → commit. Запуск `composer test -- --filter='Supplier'` после каждой фазы.
|
||||
|
||||
Существующие тесты, которые гарантированно адаптируются:
|
||||
- `app/tests/Feature/Http/Webhook/SupplierWebhookTest.php` — line 95 «invalid_format → 422» переписывается на «invalid_format → 202 DIRECT» в Phase 3.
|
||||
- `app/tests/Feature/Supplier/CsvReconcileJobTest.php` — добавить кейс DIRECT в Phase 3.
|
||||
- `app/tests/Feature/Supplier/RouteSupplierLeadJobBillingTest.php` — добавить «webhook после CSV-recovered не списывает второй раз» в Phase 2.
|
||||
- `app/tests/Feature/Supplier/SupplierLeadDeliveryGuardTest.php` — добавить кейс «разные SupplierLead.id, тот же phone+project — не дубль» в Phase 2.
|
||||
|
||||
### Регрессия
|
||||
|
||||
`/regression full` ПОСЛЕ каждой фазы (Pest --parallel + Larastan + Vitest + Vite build + lychee + gitleaks). Каждая фаза — отдельный коммит на ветке `feat/supplier-webhook-fixes`, отдельный PR, отдельный merge → отдельный redeploy.
|
||||
|
||||
### Прод-smoke
|
||||
|
||||
После каждого деплоя — конкретные SQL-проверки в `db/`, описаны в каждом плане.
|
||||
|
||||
---
|
||||
|
||||
## 6. Откат
|
||||
|
||||
- Phase 1 — revert single commit.
|
||||
- Phase 2 — revert commit + dedup кода. Миграции БД нет.
|
||||
- Phase 3 — revert commit + миграция down: `DROP CONSTRAINT ... ADD CONSTRAINT ... CHECK IN (B1,B2,B3)`. Если в БД уже есть `platform=DIRECT` rows — миграция down упадёт. Нужен seed-cleanup перед откатом.
|
||||
|
||||
---
|
||||
|
||||
## 7. Файлы (общий список)
|
||||
|
||||
**Создать:**
|
||||
- `database/migrations/2026_05_25_120000_add_direct_platform.php` (Phase 3)
|
||||
- `app/tests/Feature/Http/Webhook/SupplierWebhookValidationFormatTest.php` (Phase 1, новый файл)
|
||||
- `app/tests/Feature/Supplier/CsvWebhookRaceTest.php` (Phase 2, новый файл)
|
||||
- `app/tests/Feature/Supplier/DirectPlatformTest.php` (Phase 3, новый файл)
|
||||
|
||||
**Изменить:**
|
||||
- `app/bootstrap/app.php` (Phase 1)
|
||||
- `app/app/Http/Controllers/Api/SupplierWebhookController.php` (Phase 3)
|
||||
- `app/app/Jobs/RouteSupplierLeadJob.php` (Phase 2 + Phase 3)
|
||||
- `app/app/Jobs/Supplier/CsvReconcileJob.php` (Phase 3)
|
||||
- `app/app/Services/SupplierProjects/SupplierProjectResolver.php` (Phase 3)
|
||||
- `app/app/Services/LeadRouter.php` (Phase 3)
|
||||
- `app/tests/Feature/Http/Webhook/SupplierWebhookTest.php` (Phase 3 — переписать line 95)
|
||||
- `db/schema.sql` (Phase 3 — sync с миграцией)
|
||||
- `db/CHANGELOG_schema.md` (Phase 3)
|
||||
|
||||
**Возможно затронуть:**
|
||||
- `app/app/Services/Billing/LedgerService.php` (Phase 2 — guard от двойного списания, если ещё не идемпотентен)
|
||||
|
||||
---
|
||||
|
||||
## 8. Открытые вопросы (на момент написания спеки)
|
||||
|
||||
- **OQ-1.** Идемпотентен ли `LedgerService::chargeForDelivery` по `(deal_id, lead_id)` или может списать дважды? — выяснится в Phase 2 Task 1 (read code).
|
||||
- **OQ-2.** `supplier_projects.subject_code` — обязательное поле для DIRECT? — выяснится в Phase 3 Task 2 (миграция).
|
||||
- **OQ-3.** `chk_supplier_projects_b1_not_for_sms` constraint конфликтует с DIRECT? — выяснится в Phase 3 Task 1.
|
||||
|
||||
Каждый вопрос разрешается inline во время реализации, не блокирует план.
|
||||
|
||||
---
|
||||
|
||||
## 9. Ссылки
|
||||
|
||||
- План Phase 1: `docs/superpowers/plans/2026-05-25-supplier-webhook-phase-1-json-422.md`
|
||||
- План Phase 2: `docs/superpowers/plans/2026-05-25-supplier-webhook-phase-2-dedup.md`
|
||||
- План Phase 3: `docs/superpowers/plans/2026-05-25-supplier-webhook-phase-3-direct-platform.md`
|
||||
- Memory project_supplier_integration.md — историческая информация о supplier flow
|
||||
- ADR-008 (если потребуется DIRECT — оформить как ADR-018 «Supplier DIRECT platform»)
|
||||
@@ -6,6 +6,7 @@
|
||||
*
|
||||
* Security Guidance #40: pure parsing — no exec/execSync.
|
||||
*/
|
||||
import { Buffer } from 'buffer';
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import { detectMissedActivations } from './missed-activations.mjs';
|
||||
import {
|
||||
@@ -15,6 +16,11 @@ import {
|
||||
} from './discipline-metrics.mjs';
|
||||
import { loadRegistry } from './registry-load.mjs';
|
||||
import { buildClassificationMap, buildDormancyMap } from './registry-to-classification-map.mjs';
|
||||
import {
|
||||
buildIndex as buildEmbeddingIndex,
|
||||
findNearestNeighbors,
|
||||
majorityOutcome,
|
||||
} from './observer-embedding-index.mjs';
|
||||
|
||||
const SIZE_SMALL = 20;
|
||||
const SIZE_LARGE = 60;
|
||||
@@ -161,6 +167,111 @@ function sessionTurnBucket(turn) {
|
||||
return n < SESSION_TURN_EARLY ? 'early' : n <= SESSION_TURN_LATE ? 'mid' : 'late';
|
||||
}
|
||||
|
||||
// Pass 1 cheap-axis helpers (project-brain-factor-analysis-4passes).
|
||||
function countEventKind(events, kind) {
|
||||
if (!Array.isArray(events)) return 0;
|
||||
let c = 0;
|
||||
for (const ev of events) if (ev && ev.kind === kind) c++;
|
||||
return c;
|
||||
}
|
||||
|
||||
function retryBucket(events) {
|
||||
const n = countEventKind(events, 'retry');
|
||||
return n === 0 ? '0' : n <= 2 ? '1-2' : '3+';
|
||||
}
|
||||
|
||||
function errorBucket(events) {
|
||||
const n = countEventKind(events, 'error');
|
||||
return n === 0 ? '0' : n === 1 ? '1' : '2+';
|
||||
}
|
||||
|
||||
function iterationsBucket(iterations) {
|
||||
const n = Number(iterations);
|
||||
if (!Number.isFinite(n) || n <= 0) return '0';
|
||||
if (n <= 3) return '1-3';
|
||||
if (n <= 10) return '4-10';
|
||||
return '11+';
|
||||
}
|
||||
|
||||
// Pass 2 — classifier latency bucket. <500ms = fast (cache hit territory),
|
||||
// 500-2000 = medium (cold call), 2000-10000 = slow (network jitter / overflow),
|
||||
// >10000 = very_slow (retries fired). Null on non-LLM paths.
|
||||
function latencyBucket(latency) {
|
||||
const n = Number(latency);
|
||||
if (!Number.isFinite(n) || n < 0) return 'null';
|
||||
if (n < 500) return 'fast';
|
||||
if (n < 2000) return 'medium';
|
||||
if (n < 10000) return 'slow';
|
||||
return 'very_slow';
|
||||
}
|
||||
|
||||
// Pass 3 helpers (project-brain-factor-analysis-4passes).
|
||||
function promptLengthBucket(n) {
|
||||
const v = Number(n);
|
||||
if (!Number.isFinite(v) || v <= 0) return 'null';
|
||||
if (v < 100) return 'short';
|
||||
if (v < 1000) return 'medium';
|
||||
if (v < 2500) return 'long';
|
||||
return 'huge';
|
||||
}
|
||||
|
||||
function timeOfDayBucket(iso) {
|
||||
// Reject null / undefined / empty BEFORE Date construction: `new Date(null)`
|
||||
// is the epoch (1970-01-01), not NaN — would falsely bucket missing
|
||||
// timestamps as 'night'.
|
||||
if (iso == null || iso === '') return 'null';
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return 'null';
|
||||
const h = d.getUTCHours();
|
||||
if (h < 6) return 'night';
|
||||
if (h < 12) return 'morning';
|
||||
if (h < 18) return 'afternoon';
|
||||
return 'evening';
|
||||
}
|
||||
|
||||
const WEEKDAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
function dayOfWeekLabel(iso) {
|
||||
if (iso == null || iso === '') return 'null';
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return 'null';
|
||||
return WEEKDAY_NAMES[d.getUTCDay()];
|
||||
}
|
||||
|
||||
function interPromptGapBucket(min) {
|
||||
const v = Number(min);
|
||||
if (!Number.isFinite(v) || v < 0) return 'null';
|
||||
if (v < 1) return '<1m';
|
||||
if (v < 10) return '1-10m';
|
||||
if (v < 60) return '10-60m';
|
||||
return '60m+';
|
||||
}
|
||||
|
||||
function fileTypeMain(dist) {
|
||||
if (!dist || typeof dist !== 'object') return 'none';
|
||||
const entries = Object.entries(dist).filter(([, n]) => Number(n) > 0);
|
||||
if (entries.length === 0) return 'none';
|
||||
let maxN = 0;
|
||||
for (const [, n] of entries) if (n > maxN) maxN = n;
|
||||
const winners = entries.filter(([, n]) => n === maxN);
|
||||
if (winners.length > 1) return 'mixed';
|
||||
return winners[0][0];
|
||||
}
|
||||
|
||||
function eventToolCount(events, toolName) {
|
||||
if (!Array.isArray(events)) return 0;
|
||||
for (const ev of events) {
|
||||
if (ev && ev.kind === 'tool_summary' && ev.counts) {
|
||||
return Number(ev.counts[toolName]) || 0;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function countBucket012(n) {
|
||||
const v = Number(n) || 0;
|
||||
return v === 0 ? '0' : v === 1 ? '1' : '2+';
|
||||
}
|
||||
|
||||
const FACTOR_FNS = {
|
||||
decision_provenance: (e) => (e.decision_provenance || {}).kind || 'unknown',
|
||||
economy_level: (e) => String((e.environment || {}).economy_level ?? 'null'),
|
||||
@@ -172,8 +283,52 @@ const FACTOR_FNS = {
|
||||
node_chosen: (e) => (e.primary_rationale || {}).node_chosen || 'direct',
|
||||
task_classification: (e) => (e.primary_rationale || {}).task_classification || 'other',
|
||||
recommended_node_for_direct: (e) => (e.primary_rationale || {}).recommended_node || 'none',
|
||||
// Pass 1 — 8 cheap axes (data already in v4 episode, just expose):
|
||||
prompt_signal: (e) => e.prompt_signal || 'null',
|
||||
classifier_source: (e) => (e.classifier_output || {}).source || 'null',
|
||||
degraded_mode: (e) => String(e.degraded_mode ?? false),
|
||||
path_type: (e) => e.path_type || 'null',
|
||||
retry_count: (e) => retryBucket(e.events),
|
||||
error_count: (e) => errorBucket(e.events),
|
||||
hard_floor_invoked: (e) => String(((e.primary_rationale || {}).hard_floor || {}).invoked ?? false),
|
||||
iterations_bucket: (e) => iterationsBucket((e.task_cost || {}).iterations),
|
||||
// Pass 2 — classifier-metric axes (project-brain-factor-analysis-4passes):
|
||||
latency_bucket: (e) => latencyBucket((e.classifier_output || {}).latency_ms),
|
||||
error_type: (e) => (e.classifier_output || {}).llm_error || 'null',
|
||||
// Pass 3 — dynamics axes (project-brain-factor-analysis-4passes):
|
||||
prompt_length_bucket: (e) => promptLengthBucket((e.task_meta || {}).prompt_length_chars),
|
||||
time_of_day_bucket: (e) => timeOfDayBucket((e.timestamps || {}).started_at),
|
||||
day_of_week: (e) => dayOfWeekLabel((e.timestamps || {}).started_at),
|
||||
inter_prompt_gap_bucket: (e) => interPromptGapBucket(e._interPromptGapMin),
|
||||
mcp_server_used: (e) => (((e.task_meta || {}).mcp_servers_used || []).length > 0 ? 'any' : 'none'),
|
||||
file_type_main: (e) => fileTypeMain((e.task_meta || {}).file_type_distribution),
|
||||
skill_invocations_bucket: (e) => countBucket012(eventToolCount(e.events, 'Skill')),
|
||||
subagent_spawns_bucket: (e) => countBucket012(
|
||||
eventToolCount(e.events, 'Agent') + eventToolCount(e.events, 'Task'),
|
||||
),
|
||||
// Pass 4 — semantic NN axis (project-brain-factor-analysis-4passes).
|
||||
// Reads the pre-computed family label stamped on the episode by analyze()
|
||||
// (cross-episode pass via observer-embedding-index). Episodes without an
|
||||
// embedding or with no resolved neighbours bucket as 'no_neighbors'.
|
||||
similar_past_outcome_majority: (e) => e._similarPastOutcomeMajority || 'no_neighbors',
|
||||
};
|
||||
|
||||
// Pass 4 — decode prompt_embedding_base64 to Float32Array. Mirrors
|
||||
// observer-embedding-index safeDecode but kept private here to avoid
|
||||
// circular surface; analyzer only needs the target-embedding decode path.
|
||||
function decodeTargetEmbedding(b64) {
|
||||
if (!b64 || typeof b64 !== 'string') return null;
|
||||
try {
|
||||
const buf = Buffer.from(b64, 'base64');
|
||||
if (buf.byteLength === 0 || buf.byteLength % 4 !== 0) return null;
|
||||
const v = new Float32Array(buf.buffer, buf.byteOffset, buf.byteLength / 4);
|
||||
for (let i = 0; i < v.length; i++) if (!Number.isFinite(v[i])) return null;
|
||||
return v;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Factor matrix: rows = factor values, columns = outcome distribution (spec §6). */
|
||||
export function buildFactorMatrix(episodesWithOutcome) {
|
||||
const matrix = {};
|
||||
@@ -212,8 +367,38 @@ export function analyze(episodes, options = {}) {
|
||||
for (const eps of bySessionSorted(normal).values()) {
|
||||
eps.forEach((episode, i) => {
|
||||
episode._inferredOutcome = inferOutcome(episode, eps[i + 1]);
|
||||
// Pass 3 — inter-prompt gap (project-brain-factor-analysis-4passes).
|
||||
// Cross-episode signal: minutes between this episode's start and the
|
||||
// previous (same-session) episode's end. First episode of a session
|
||||
// has no prev → stays undefined → bucket 'null'.
|
||||
if (i > 0) {
|
||||
const prevEnded = (eps[i - 1].timestamps || {}).ended_at;
|
||||
const curStarted = (episode.timestamps || {}).started_at;
|
||||
const ms = new Date(curStarted) - new Date(prevEnded);
|
||||
if (Number.isFinite(ms) && ms >= 0) episode._interPromptGapMin = ms / 60000;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Pass 4 — semantic NN lookup (project-brain-factor-analysis-4passes).
|
||||
// Build a single global index from episodes with resolved outcomes +
|
||||
// embeddings, then for EACH episode (resolved or not) find its top-3
|
||||
// nearest neighbours and stamp the majority family on _similarPastOutcomeMajority.
|
||||
// O(N²) is fine: typical session has ~50-500 episodes, k=3, embedding=384-dim.
|
||||
// Future: switch to HNSW / faiss when episode count crosses ~10k.
|
||||
const embeddingIndex = buildEmbeddingIndex(normal);
|
||||
for (const episode of normal) {
|
||||
const target = decodeTargetEmbedding(episode.prompt_embedding_base64);
|
||||
if (!target) {
|
||||
episode._similarPastOutcomeMajority = 'no_neighbors';
|
||||
continue;
|
||||
}
|
||||
// task_id is the SESSION id (shared across turns), not a turn id —
|
||||
// exclude self by (task_id|started_at), the same dedupe key buildIndex uses.
|
||||
const excludeKey = `${episode.task_id || ''}|${(episode.timestamps || {}).started_at || ''}`;
|
||||
const neighbours = findNearestNeighbors(target, embeddingIndex, 3, { excludeKey });
|
||||
episode._similarPastOutcomeMajority = majorityOutcome(neighbours);
|
||||
}
|
||||
const classificationMap = options.classificationMap || {};
|
||||
const dormancy = options.dormancy || {};
|
||||
const disciplineByClassification = disciplinePercentByClassification(normal, classificationMap);
|
||||
|
||||
@@ -409,3 +409,311 @@ describe('analyze — v4 aggregations (Phase 3 Task 20)', () => {
|
||||
expect(ct.reviewer_input_tokens).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildFactorMatrix — Pass 1 cheap axes (project-brain-factor-analysis-4passes)', () => {
|
||||
// Each new axis: smoke + null-safety on missing fields.
|
||||
it('prompt_signal axis: raw discrete values + null fallback', () => {
|
||||
const m = buildFactorMatrix([
|
||||
{ ...ep(), _inferredOutcome: 'success', prompt_signal: 'new_task' },
|
||||
{ ...ep(), _inferredOutcome: 'rework', prompt_signal: 'correction' },
|
||||
{ ...ep(), _inferredOutcome: 'unknown', prompt_signal: undefined },
|
||||
]);
|
||||
expect(m.prompt_signal.new_task.success).toBe(1);
|
||||
expect(m.prompt_signal.correction.rework).toBe(1);
|
||||
expect(m.prompt_signal.null.unknown).toBe(1);
|
||||
});
|
||||
|
||||
it('classifier_source axis: reads classifier_output.source verbatim', () => {
|
||||
const m = buildFactorMatrix([
|
||||
{ ...ep(), _inferredOutcome: 'success', classifier_output: { source: 'llm' } },
|
||||
{ ...ep(), _inferredOutcome: 'success', classifier_output: { source: 'regex' } },
|
||||
{ ...ep(), _inferredOutcome: 'success', classifier_output: { source: 'prefilter_inherited' } },
|
||||
{ ...ep(), _inferredOutcome: 'unknown', classifier_output: null },
|
||||
]);
|
||||
expect(m.classifier_source.llm.success).toBe(1);
|
||||
expect(m.classifier_source.regex.success).toBe(1);
|
||||
expect(m.classifier_source.prefilter_inherited.success).toBe(1);
|
||||
expect(m.classifier_source.null.unknown).toBe(1);
|
||||
});
|
||||
|
||||
it('degraded_mode axis: true/false buckets, false default', () => {
|
||||
const m = buildFactorMatrix([
|
||||
{ ...ep(), _inferredOutcome: 'success', degraded_mode: false },
|
||||
{ ...ep(), _inferredOutcome: 'rework', degraded_mode: true },
|
||||
{ ...ep(), _inferredOutcome: 'unknown' /* missing */ },
|
||||
]);
|
||||
expect(m.degraded_mode.true.rework).toBe(1);
|
||||
expect(m.degraded_mode.false.success).toBe(1);
|
||||
expect(m.degraded_mode.false.unknown).toBe(1);
|
||||
});
|
||||
|
||||
it('path_type axis: regulated / improvised / null', () => {
|
||||
const m = buildFactorMatrix([
|
||||
{ ...ep(), _inferredOutcome: 'success', path_type: 'regulated' },
|
||||
{ ...ep(), _inferredOutcome: 'rework', path_type: 'improvised' },
|
||||
{ ...ep(), _inferredOutcome: 'unknown', path_type: undefined },
|
||||
]);
|
||||
expect(m.path_type.regulated.success).toBe(1);
|
||||
expect(m.path_type.improvised.rework).toBe(1);
|
||||
expect(m.path_type.null.unknown).toBe(1);
|
||||
});
|
||||
|
||||
it('retry_count axis: 0 / 1-2 / 3+ buckets from events[].kind=retry', () => {
|
||||
const m = buildFactorMatrix([
|
||||
{ ...ep(), _inferredOutcome: 'success', events: [] },
|
||||
{ ...ep(), _inferredOutcome: 'rework', events: [{ kind: 'retry' }] },
|
||||
{ ...ep(), _inferredOutcome: 'rework', events: [{ kind: 'retry' }, { kind: 'retry' }] },
|
||||
{ ...ep(), _inferredOutcome: 'blocked', events: [{ kind: 'retry' }, { kind: 'retry' }, { kind: 'retry' }, { kind: 'retry' }] },
|
||||
]);
|
||||
expect(m.retry_count['0'].success).toBe(1);
|
||||
expect(m.retry_count['1-2'].rework).toBe(2);
|
||||
expect(m.retry_count['3+'].blocked).toBe(1);
|
||||
});
|
||||
|
||||
it('error_count axis: 0 / 1 / 2+ buckets from events[].kind=error', () => {
|
||||
const m = buildFactorMatrix([
|
||||
{ ...ep(), _inferredOutcome: 'success', events: [] },
|
||||
{ ...ep(), _inferredOutcome: 'rework', events: [{ kind: 'error' }] },
|
||||
{ ...ep(), _inferredOutcome: 'blocked', events: [{ kind: 'error' }, { kind: 'error' }, { kind: 'error' }] },
|
||||
]);
|
||||
expect(m.error_count['0'].success).toBe(1);
|
||||
expect(m.error_count['1'].rework).toBe(1);
|
||||
expect(m.error_count['2+'].blocked).toBe(1);
|
||||
});
|
||||
|
||||
it('hard_floor_invoked axis: true/false from primary_rationale.hard_floor.invoked', () => {
|
||||
const m = buildFactorMatrix([
|
||||
{ ...ep(), _inferredOutcome: 'success', primary_rationale: { hard_floor: { invoked: true } } },
|
||||
{ ...ep(), _inferredOutcome: 'success', primary_rationale: { hard_floor: { invoked: false } } },
|
||||
{ ...ep(), _inferredOutcome: 'unknown', primary_rationale: {} },
|
||||
]);
|
||||
expect(m.hard_floor_invoked.true.success).toBe(1);
|
||||
expect(m.hard_floor_invoked.false.success).toBe(1);
|
||||
expect(m.hard_floor_invoked.false.unknown).toBe(1);
|
||||
});
|
||||
|
||||
it('iterations_bucket axis: 0 / 1-3 / 4-10 / 11+ from task_cost.iterations', () => {
|
||||
const m = buildFactorMatrix([
|
||||
{ ...ep(), _inferredOutcome: 'success', task_cost: { iterations: 0 } },
|
||||
{ ...ep(), _inferredOutcome: 'success', task_cost: { iterations: 2 } },
|
||||
{ ...ep(), _inferredOutcome: 'rework', task_cost: { iterations: 7 } },
|
||||
{ ...ep(), _inferredOutcome: 'blocked', task_cost: { iterations: 51 } },
|
||||
{ ...ep(), _inferredOutcome: 'unknown', task_cost: {} },
|
||||
]);
|
||||
expect(m.iterations_bucket['0'].success).toBe(1);
|
||||
expect(m.iterations_bucket['1-3'].success).toBe(1);
|
||||
expect(m.iterations_bucket['4-10'].rework).toBe(1);
|
||||
expect(m.iterations_bucket['11+'].blocked).toBe(1);
|
||||
// Missing iterations counts as 0 — task_cost block may be absent on early episodes.
|
||||
expect(m.iterations_bucket['0'].unknown).toBe(1);
|
||||
});
|
||||
|
||||
it('all 8 Pass 1 axes are present via analyze() on a minimal v2 episode', () => {
|
||||
const result = analyze([ep()]);
|
||||
for (const axis of ['prompt_signal', 'classifier_source', 'degraded_mode', 'path_type',
|
||||
'retry_count', 'error_count', 'hard_floor_invoked', 'iterations_bucket']) {
|
||||
expect(result.factorMatrix, `axis ${axis} missing`).toHaveProperty(axis);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildFactorMatrix — Pass 3 dynamics axes (project-brain-factor-analysis-4passes)', () => {
|
||||
it('prompt_length_bucket axis: short / medium / long / huge / null', () => {
|
||||
const m = buildFactorMatrix([
|
||||
{ ...ep(), _inferredOutcome: 'success', task_meta: { prompt_length_chars: 42 } },
|
||||
{ ...ep(), _inferredOutcome: 'success', task_meta: { prompt_length_chars: 300 } },
|
||||
{ ...ep(), _inferredOutcome: 'rework', task_meta: { prompt_length_chars: 1200 } },
|
||||
{ ...ep(), _inferredOutcome: 'blocked', task_meta: { prompt_length_chars: 5000 } },
|
||||
{ ...ep(), _inferredOutcome: 'unknown', task_meta: undefined },
|
||||
]);
|
||||
expect(m.prompt_length_bucket.short.success).toBe(1);
|
||||
expect(m.prompt_length_bucket.medium.success).toBe(1);
|
||||
expect(m.prompt_length_bucket.long.rework).toBe(1);
|
||||
expect(m.prompt_length_bucket.huge.blocked).toBe(1);
|
||||
expect(m.prompt_length_bucket.null.unknown).toBe(1);
|
||||
});
|
||||
|
||||
it('time_of_day_bucket axis derived from timestamps.started_at UTC hour', () => {
|
||||
const at = (iso) => ({ ...ep(), _inferredOutcome: 'success', timestamps: { started_at: iso } });
|
||||
const m = buildFactorMatrix([
|
||||
at('2026-05-25T03:00:00Z'), // night (0-5)
|
||||
at('2026-05-25T09:00:00Z'), // morning (6-11)
|
||||
at('2026-05-25T14:00:00Z'), // afternoon (12-17)
|
||||
at('2026-05-25T20:00:00Z'), // evening (18-23)
|
||||
]);
|
||||
expect(m.time_of_day_bucket.night.success).toBe(1);
|
||||
expect(m.time_of_day_bucket.morning.success).toBe(1);
|
||||
expect(m.time_of_day_bucket.afternoon.success).toBe(1);
|
||||
expect(m.time_of_day_bucket.evening.success).toBe(1);
|
||||
});
|
||||
|
||||
it('day_of_week axis: Mon..Sun derived from started_at UTC', () => {
|
||||
// 2026-05-25 is a Monday (UTC).
|
||||
const m = buildFactorMatrix([
|
||||
{ ...ep(), _inferredOutcome: 'success', timestamps: { started_at: '2026-05-25T10:00:00Z' } }, // Mon
|
||||
{ ...ep(), _inferredOutcome: 'success', timestamps: { started_at: '2026-05-27T10:00:00Z' } }, // Wed
|
||||
{ ...ep(), _inferredOutcome: 'unknown', timestamps: { started_at: null } },
|
||||
]);
|
||||
expect(m.day_of_week.Mon.success).toBe(1);
|
||||
expect(m.day_of_week.Wed.success).toBe(1);
|
||||
expect(m.day_of_week.null.unknown).toBe(1);
|
||||
});
|
||||
|
||||
it('inter_prompt_gap_bucket axis: gap between current and previous episode of same session', () => {
|
||||
const eps = [
|
||||
{ schema_version: 2, task_id: 's1', timestamps: { started_at: '2026-05-25T10:00:00Z', ended_at: '2026-05-25T10:05:00Z' },
|
||||
prompt_signal: 'new_task', primary_rationale: { node_chosen: 'direct', task_classification: 'feature' },
|
||||
environment: {}, task_size: { tool_calls: 1 }, decision_provenance: { kind: 'autonomous' }, events: [] },
|
||||
// 2-minute gap → bucket "1-10m"
|
||||
{ schema_version: 2, task_id: 's1', timestamps: { started_at: '2026-05-25T10:07:00Z', ended_at: '2026-05-25T10:10:00Z' },
|
||||
prompt_signal: 'correction', primary_rationale: { node_chosen: 'direct', task_classification: 'feature' },
|
||||
environment: {}, task_size: { tool_calls: 1 }, decision_provenance: { kind: 'autonomous' }, events: [] },
|
||||
// 80-minute gap → bucket "60m+"
|
||||
{ schema_version: 2, task_id: 's1', timestamps: { started_at: '2026-05-25T11:30:00Z', ended_at: '2026-05-25T11:35:00Z' },
|
||||
prompt_signal: 'approval', primary_rationale: { node_chosen: 'direct', task_classification: 'feature' },
|
||||
environment: {}, task_size: { tool_calls: 1 }, decision_provenance: { kind: 'autonomous' }, events: [] },
|
||||
];
|
||||
const result = analyze(eps);
|
||||
expect(result.factorMatrix.inter_prompt_gap_bucket).toBeDefined();
|
||||
// First episode has no previous → bucket 'null'.
|
||||
expect(result.factorMatrix.inter_prompt_gap_bucket.null).toBeDefined();
|
||||
expect(result.factorMatrix.inter_prompt_gap_bucket['1-10m']).toBeDefined();
|
||||
expect(result.factorMatrix.inter_prompt_gap_bucket['60m+']).toBeDefined();
|
||||
});
|
||||
|
||||
it('mcp_server_used axis: any / none (presence of any mcp_servers_used entry)', () => {
|
||||
const m = buildFactorMatrix([
|
||||
{ ...ep(), _inferredOutcome: 'success', task_meta: { mcp_servers_used: ['github'] } },
|
||||
{ ...ep(), _inferredOutcome: 'success', task_meta: { mcp_servers_used: [] } },
|
||||
{ ...ep(), _inferredOutcome: 'unknown' /* missing */ },
|
||||
]);
|
||||
expect(m.mcp_server_used.any.success).toBe(1);
|
||||
expect(m.mcp_server_used.none.success).toBe(1);
|
||||
expect(m.mcp_server_used.none.unknown).toBe(1);
|
||||
});
|
||||
|
||||
it('file_type_main axis: dominant path category from file_type_distribution', () => {
|
||||
const m = buildFactorMatrix([
|
||||
{ ...ep(), _inferredOutcome: 'success', task_meta: { file_type_distribution: { src: 3, test: 1, other: 0, config: 0, spec: 0, norm: 0, data: 0 } } },
|
||||
{ ...ep(), _inferredOutcome: 'rework', task_meta: { file_type_distribution: { src: 0, test: 4, other: 0, config: 0, spec: 0, norm: 0, data: 0 } } },
|
||||
{ ...ep(), _inferredOutcome: 'success', task_meta: { file_type_distribution: { src: 2, test: 2, other: 0, config: 0, spec: 0, norm: 0, data: 0 } } }, // tie → mixed
|
||||
{ ...ep(), _inferredOutcome: 'unknown', task_meta: { file_type_distribution: { src: 0, test: 0, other: 0, config: 0, spec: 0, norm: 0, data: 0 } } }, // empty → none
|
||||
{ ...ep(), _inferredOutcome: 'unknown' /* missing */ },
|
||||
]);
|
||||
expect(m.file_type_main.src.success).toBe(1);
|
||||
expect(m.file_type_main.test.rework).toBe(1);
|
||||
expect(m.file_type_main.mixed.success).toBe(1);
|
||||
expect(m.file_type_main.none.unknown).toBe(2); // empty + missing
|
||||
});
|
||||
|
||||
it('skill_invocations_bucket axis: 0 / 1 / 2+ from events tool_summary.Skill', () => {
|
||||
const m = buildFactorMatrix([
|
||||
{ ...ep(), _inferredOutcome: 'success', events: [] },
|
||||
{ ...ep(), _inferredOutcome: 'success', events: [{ kind: 'tool_summary', counts: { Skill: 1, Read: 5 } }] },
|
||||
{ ...ep(), _inferredOutcome: 'success', events: [{ kind: 'tool_summary', counts: { Skill: 3 } }] },
|
||||
]);
|
||||
expect(m.skill_invocations_bucket['0'].success).toBe(1);
|
||||
expect(m.skill_invocations_bucket['1'].success).toBe(1);
|
||||
expect(m.skill_invocations_bucket['2+'].success).toBe(1);
|
||||
});
|
||||
|
||||
it('subagent_spawns_bucket axis: 0 / 1 / 2+ from events tool_summary.Agent (or Task)', () => {
|
||||
const m = buildFactorMatrix([
|
||||
{ ...ep(), _inferredOutcome: 'success', events: [] },
|
||||
{ ...ep(), _inferredOutcome: 'success', events: [{ kind: 'tool_summary', counts: { Agent: 1 } }] },
|
||||
{ ...ep(), _inferredOutcome: 'rework', events: [{ kind: 'tool_summary', counts: { Agent: 4 } }] },
|
||||
]);
|
||||
expect(m.subagent_spawns_bucket['0'].success).toBe(1);
|
||||
expect(m.subagent_spawns_bucket['1'].success).toBe(1);
|
||||
expect(m.subagent_spawns_bucket['2+'].rework).toBe(1);
|
||||
});
|
||||
|
||||
it('all 8 Pass 3 axes are present via analyze() on a minimal v2 episode', () => {
|
||||
const result = analyze([ep()]);
|
||||
for (const axis of ['prompt_length_bucket', 'time_of_day_bucket', 'day_of_week',
|
||||
'inter_prompt_gap_bucket', 'mcp_server_used', 'file_type_main',
|
||||
'skill_invocations_bucket', 'subagent_spawns_bucket']) {
|
||||
expect(result.factorMatrix, `axis ${axis} missing`).toHaveProperty(axis);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildFactorMatrix — Pass 2 classifier-metric axes', () => {
|
||||
it('latency_bucket axis: fast / medium / slow / very_slow / null', () => {
|
||||
const m = buildFactorMatrix([
|
||||
{ ...ep(), _inferredOutcome: 'success', classifier_output: { latency_ms: 250 } },
|
||||
{ ...ep(), _inferredOutcome: 'success', classifier_output: { latency_ms: 1500 } },
|
||||
{ ...ep(), _inferredOutcome: 'rework', classifier_output: { latency_ms: 5000 } },
|
||||
{ ...ep(), _inferredOutcome: 'blocked', classifier_output: { latency_ms: 15000 } },
|
||||
{ ...ep(), _inferredOutcome: 'unknown', classifier_output: null },
|
||||
]);
|
||||
expect(m.latency_bucket.fast.success).toBe(1);
|
||||
expect(m.latency_bucket.medium.success).toBe(1);
|
||||
expect(m.latency_bucket.slow.rework).toBe(1);
|
||||
expect(m.latency_bucket.very_slow.blocked).toBe(1);
|
||||
expect(m.latency_bucket.null.unknown).toBe(1);
|
||||
});
|
||||
|
||||
it('error_type axis: reads classifier_output.llm_error verbatim with null default', () => {
|
||||
const m = buildFactorMatrix([
|
||||
{ ...ep(), _inferredOutcome: 'rework', classifier_output: { llm_error: 'timeout' } },
|
||||
{ ...ep(), _inferredOutcome: 'rework', classifier_output: { llm_error: 'econnreset' } },
|
||||
{ ...ep(), _inferredOutcome: 'success', classifier_output: { llm_error: null } },
|
||||
{ ...ep(), _inferredOutcome: 'success', classifier_output: null },
|
||||
]);
|
||||
expect(m.error_type.timeout.rework).toBe(1);
|
||||
expect(m.error_type.econnreset.rework).toBe(1);
|
||||
expect(m.error_type.null.success).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('analyze — Pass 4 similar_past_outcome_majority axis (project-brain-factor-analysis-4passes)', () => {
|
||||
// Build a 4-dim embedding base64 manually to avoid loading @xenova in tests.
|
||||
const encode = (arr) => {
|
||||
const f = new Float32Array(arr);
|
||||
const buf = Buffer.from(f.buffer, f.byteOffset, f.byteLength);
|
||||
return buf.toString('base64');
|
||||
};
|
||||
|
||||
it('attaches similar_past_outcome_majority axis to factor matrix', () => {
|
||||
// All four episodes share the same task_id (= sessionId in real episodes —
|
||||
// task_id IS the session id; one Claude Code session can contain N turns).
|
||||
// bySessionSorted groups by task_id, so inferOutcome only finds a "next"
|
||||
// episode within the same session group.
|
||||
const SID = 'session-A';
|
||||
const eps = [
|
||||
{ schema_version: 4, task_id: SID, timestamps: { started_at: '2026-05-20T10:00:00Z', ended_at: '2026-05-20T10:01:00Z' },
|
||||
prompt_signal: 'new_task', primary_rationale: { node_chosen: 'direct', task_classification: 'feature' },
|
||||
environment: {}, task_size: { tool_calls: 1 }, decision_provenance: { kind: 'autonomous' },
|
||||
prompt_embedding_base64: encode([1, 0, 0, 0]), events: [] },
|
||||
{ schema_version: 4, task_id: SID, timestamps: { started_at: '2026-05-20T10:02:00Z', ended_at: '2026-05-20T10:03:00Z' },
|
||||
prompt_signal: 'approval', primary_rationale: { node_chosen: 'direct', task_classification: 'feature' },
|
||||
environment: {}, task_size: { tool_calls: 1 }, decision_provenance: { kind: 'autonomous' },
|
||||
prompt_embedding_base64: encode([0.95, 0.05, 0, 0]), events: [] },
|
||||
{ schema_version: 4, task_id: SID, timestamps: { started_at: '2026-05-20T10:04:00Z', ended_at: '2026-05-20T10:05:00Z' },
|
||||
prompt_signal: 'approval', primary_rationale: { node_chosen: 'direct', task_classification: 'feature' },
|
||||
environment: {}, task_size: { tool_calls: 1 }, decision_provenance: { kind: 'autonomous' },
|
||||
prompt_embedding_base64: encode([0.9, 0.1, 0, 0]), events: [] },
|
||||
{ schema_version: 4, task_id: SID, timestamps: { started_at: '2026-05-20T10:06:00Z', ended_at: '2026-05-20T10:07:00Z' },
|
||||
prompt_signal: 'new_task', primary_rationale: { node_chosen: 'direct', task_classification: 'feature' },
|
||||
environment: {}, task_size: { tool_calls: 1 }, decision_provenance: { kind: 'autonomous' },
|
||||
prompt_embedding_base64: encode([0.98, 0.02, 0, 0]), events: [] },
|
||||
];
|
||||
const result = analyze(eps);
|
||||
expect(result.factorMatrix.similar_past_outcome_majority).toBeDefined();
|
||||
// 3 of 4 episodes have resolved success outcome → indexed. Each gets a
|
||||
// nearest-neighbour lookup that returns success peers.
|
||||
expect(result.factorMatrix.similar_past_outcome_majority.success).toBeDefined();
|
||||
});
|
||||
|
||||
it('bucket no_neighbors when no episode has embeddings', () => {
|
||||
const eps = [
|
||||
{ schema_version: 4, task_id: 'a', timestamps: { started_at: '2026-05-20T10:00:00Z', ended_at: '2026-05-20T10:01:00Z' },
|
||||
prompt_signal: 'new_task', primary_rationale: { node_chosen: 'direct', task_classification: 'feature' },
|
||||
environment: {}, task_size: { tool_calls: 1 }, decision_provenance: { kind: 'autonomous' },
|
||||
prompt_embedding_base64: null, events: [] },
|
||||
];
|
||||
const result = analyze(eps);
|
||||
expect(result.factorMatrix.similar_past_outcome_majority.no_neighbors).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Rule #7 — Branch-switch detection before commit / push.
|
||||
*
|
||||
* PreToolUse on Bash. Detects `git commit`, `git push`, `git cherry-pick`,
|
||||
* `git reset --hard`, `git rebase`, `git branch -f/-d`. Reads expected branch
|
||||
* from sentinel; if missing, defaults to "main". Compares to actual current
|
||||
* branch via `git branch --show-current`. Mismatch → block unless explicit
|
||||
* confirmation marker in last assistant text OR override phrase.
|
||||
*
|
||||
* Confirmation markers in assistant response (case-sensitive substring):
|
||||
* - BRANCH-SWITCH-CONFIRMED
|
||||
* - RECOVERY-INTENT:
|
||||
* Override phrases: "recovery" (suppresses branch-switch + git-recovery rule keys)
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-25-enforce-hard-rules-design.md
|
||||
*/
|
||||
|
||||
import {
|
||||
readStdin,
|
||||
parseEventJson,
|
||||
readTranscript,
|
||||
lastUserPromptText,
|
||||
lastAssistantText,
|
||||
findOverride,
|
||||
logOverride,
|
||||
exitDecision,
|
||||
detectGitCommandKind,
|
||||
readGitBranch,
|
||||
getExpectedBranch,
|
||||
} from './enforce-hook-helpers.mjs';
|
||||
|
||||
const RULE_KEY = 'branch-switch';
|
||||
|
||||
const CONFIRMATION_MARKERS = [
|
||||
'BRANCH-SWITCH-CONFIRMED',
|
||||
'RECOVERY-INTENT:',
|
||||
];
|
||||
|
||||
export function decide({
|
||||
toolName,
|
||||
command,
|
||||
expectedBranch,
|
||||
actualBranch,
|
||||
assistantText,
|
||||
override,
|
||||
}) {
|
||||
if (toolName !== 'Bash' || typeof command !== 'string') return { block: false };
|
||||
const kind = detectGitCommandKind(command);
|
||||
if (!kind) return { block: false };
|
||||
if (override) return { block: false };
|
||||
|
||||
const exp = (expectedBranch || 'main').trim();
|
||||
const act = (actualBranch || '').trim();
|
||||
if (!act || act === exp) return { block: false };
|
||||
|
||||
for (const marker of CONFIRMATION_MARKERS) {
|
||||
if (assistantText && assistantText.includes(marker)) return { block: false };
|
||||
}
|
||||
|
||||
return {
|
||||
block: true,
|
||||
message: [
|
||||
`[enforce-branch-switch] About to run \`git ${kind}\` on branch "${act}" but expected "${exp}".`,
|
||||
`Likely cause: parallel session switched HEAD silently (see Pravila §15.1).`,
|
||||
``,
|
||||
`If intentional — write one of these in your next response BEFORE running the command:`,
|
||||
` BRANCH-SWITCH-CONFIRMED (you intend to commit on ${act})`,
|
||||
` RECOVERY-INTENT: <one-line reason> (recovery operation, e.g., cherry-pick to main)`,
|
||||
``,
|
||||
`Or include the override phrase "recovery" in the user's next prompt.`,
|
||||
].join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
const raw = await readStdin();
|
||||
const event = parseEventJson(raw);
|
||||
const toolName = event.tool_name || '';
|
||||
const command = (event.tool_input && event.tool_input.command) || '';
|
||||
const transcript = readTranscript(event.transcript_path);
|
||||
const userPrompt = lastUserPromptText(transcript);
|
||||
const override = findOverride(userPrompt, RULE_KEY);
|
||||
if (override) logOverride(RULE_KEY, override, event.session_id);
|
||||
|
||||
const expected = getExpectedBranch(event.session_id) || 'main';
|
||||
const actual = readGitBranch();
|
||||
const assistantText = lastAssistantText(transcript);
|
||||
|
||||
const result = decide({
|
||||
toolName, command,
|
||||
expectedBranch: expected,
|
||||
actualBranch: actual,
|
||||
assistantText,
|
||||
override,
|
||||
});
|
||||
exitDecision(result);
|
||||
} catch {
|
||||
exitDecision({ block: false });
|
||||
}
|
||||
}
|
||||
|
||||
const isCli = process.argv[1] && process.argv[1].replace(/\\/g, '/').endsWith('/enforce-branch-switch.mjs');
|
||||
if (isCli) main();
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { decide } from './enforce-branch-switch.mjs';
|
||||
|
||||
describe('enforce-branch-switch / decide', () => {
|
||||
it('allows non-Bash tools', () => {
|
||||
expect(decide({ toolName: 'Edit', command: '' }).block).toBe(false);
|
||||
});
|
||||
|
||||
it('allows non-git Bash commands', () => {
|
||||
expect(decide({ toolName: 'Bash', command: 'ls -la', actualBranch: 'feat/x', expectedBranch: 'main' }).block).toBe(false);
|
||||
});
|
||||
|
||||
it('allows git status / git log (read-only)', () => {
|
||||
expect(decide({ toolName: 'Bash', command: 'git status', actualBranch: 'feat/x', expectedBranch: 'main' }).block).toBe(false);
|
||||
});
|
||||
|
||||
it('blocks git commit when actual != expected', () => {
|
||||
const r = decide({
|
||||
toolName: 'Bash',
|
||||
command: 'git commit -m "x"',
|
||||
actualBranch: 'feat/supplier',
|
||||
expectedBranch: 'main',
|
||||
assistantText: 'some random text',
|
||||
});
|
||||
expect(r.block).toBe(true);
|
||||
expect(r.message).toMatch(/feat\/supplier.*main/);
|
||||
});
|
||||
|
||||
it('blocks git push on wrong branch', () => {
|
||||
const r = decide({
|
||||
toolName: 'Bash',
|
||||
command: 'LEFTHOOK=0 git push origin main',
|
||||
actualBranch: 'feat/other',
|
||||
expectedBranch: 'main',
|
||||
assistantText: '',
|
||||
});
|
||||
expect(r.block).toBe(true);
|
||||
});
|
||||
|
||||
it('allows when BRANCH-SWITCH-CONFIRMED marker present in assistant text', () => {
|
||||
const r = decide({
|
||||
toolName: 'Bash',
|
||||
command: 'git commit -m "x"',
|
||||
actualBranch: 'feat/x',
|
||||
expectedBranch: 'main',
|
||||
assistantText: 'BRANCH-SWITCH-CONFIRMED — продолжаю на feat/x по плану',
|
||||
});
|
||||
expect(r.block).toBe(false);
|
||||
});
|
||||
|
||||
it('allows when RECOVERY-INTENT marker present', () => {
|
||||
const r = decide({
|
||||
toolName: 'Bash',
|
||||
command: 'git cherry-pick abc123',
|
||||
actualBranch: 'main',
|
||||
expectedBranch: 'feat/x',
|
||||
assistantText: 'RECOVERY-INTENT: cherry-pick после смены ветки чужой сессией',
|
||||
});
|
||||
expect(r.block).toBe(false);
|
||||
});
|
||||
|
||||
it('allows when override phrase present', () => {
|
||||
const r = decide({
|
||||
toolName: 'Bash',
|
||||
command: 'git commit -m "x"',
|
||||
actualBranch: 'feat/x',
|
||||
expectedBranch: 'main',
|
||||
assistantText: '',
|
||||
override: { phrase: 'recovery', suppresses: ['branch-switch'] },
|
||||
});
|
||||
expect(r.block).toBe(false);
|
||||
});
|
||||
|
||||
it('allows on match', () => {
|
||||
const r = decide({
|
||||
toolName: 'Bash',
|
||||
command: 'git commit -m "x"',
|
||||
actualBranch: 'main',
|
||||
expectedBranch: 'main',
|
||||
});
|
||||
expect(r.block).toBe(false);
|
||||
});
|
||||
|
||||
it('defaults expected to "main" if unset and matches when on main', () => {
|
||||
expect(decide({ toolName: 'Bash', command: 'git commit', actualBranch: 'main', expectedBranch: '' }).block).toBe(false);
|
||||
});
|
||||
|
||||
it('defaults expected to "main" if unset and blocks when on feature branch', () => {
|
||||
const r = decide({ toolName: 'Bash', command: 'git commit', actualBranch: 'feat/x', expectedBranch: '' });
|
||||
expect(r.block).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Rule #8 — Classifier-mismatch enforce.
|
||||
*
|
||||
* Stop hook. Reads classifier output from router-state. If classifier recommended
|
||||
* a node with confidence >= threshold AND the turn DIDN'T invoke a matching
|
||||
* skill/task — block.
|
||||
*
|
||||
* Override: "без скилов" / "direct ok" / explicit "override: <reason>" line in
|
||||
* assistant text.
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-25-enforce-hard-rules-design.md
|
||||
*/
|
||||
|
||||
import {
|
||||
readStdin,
|
||||
parseEventJson,
|
||||
readTranscript,
|
||||
lastUserPromptText,
|
||||
lastAssistantText,
|
||||
turnToolUses,
|
||||
findOverride,
|
||||
logOverride,
|
||||
exitDecision,
|
||||
readRouterState,
|
||||
} from './enforce-hook-helpers.mjs';
|
||||
|
||||
const RULE_KEY = 'classifier-mismatch';
|
||||
const CONFIDENCE_THRESHOLD = 0.7;
|
||||
|
||||
const MUTATING_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'Bash']);
|
||||
|
||||
/** Normalize a node id: strip "superpowers:" / "skill:" prefix; allow #ID. */
|
||||
function normalizeNode(s) {
|
||||
if (typeof s !== 'string') return '';
|
||||
return s.toLowerCase().replace(/^skill:/, '').replace(/^superpowers:/, '');
|
||||
}
|
||||
|
||||
function nodeMatches(recommendation, toolUse) {
|
||||
if (!recommendation || !toolUse) return false;
|
||||
const rec = normalizeNode(recommendation);
|
||||
if (!rec) return false;
|
||||
if (toolUse.name === 'Skill') {
|
||||
const s = normalizeNode(String(toolUse.input && toolUse.input.skill || ''));
|
||||
if (s && (s === rec || s.includes(rec) || rec.includes(s))) return true;
|
||||
}
|
||||
if (toolUse.name === 'Task') {
|
||||
const sub = String(toolUse.input && toolUse.input.subagent_type || '').toLowerCase();
|
||||
if (sub && rec.includes(sub)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function decide({ toolUses, recommendation, confidence, assistantText, override }) {
|
||||
// Pure conversation: skip.
|
||||
const hasMutating = toolUses.some((u) => MUTATING_TOOLS.has(u.name));
|
||||
if (!hasMutating) return { block: false };
|
||||
if (override) return { block: false };
|
||||
|
||||
if (!recommendation) return { block: false };
|
||||
if (typeof confidence === 'number' && confidence < CONFIDENCE_THRESHOLD) return { block: false };
|
||||
|
||||
const matched = toolUses.some((u) => nodeMatches(recommendation, u));
|
||||
if (matched) return { block: false };
|
||||
|
||||
// Allow explicit override: lines like "override: <reason>" in assistant text.
|
||||
if (assistantText && /\boverride:\s+\S/i.test(assistantText)) return { block: false };
|
||||
|
||||
return {
|
||||
block: true,
|
||||
message: [
|
||||
`[enforce-classifier-match] Classifier recommended "${recommendation}" (confidence=${confidence ?? 'n/a'}) but turn did not invoke that skill/node.`,
|
||||
`Either:`,
|
||||
` - Invoke ${recommendation} via Skill / Task tool, OR`,
|
||||
` - Add an explicit "override: <reason>" line in your response, OR`,
|
||||
` - Include "без скилов" / "direct ok" in the next user prompt.`,
|
||||
].join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
const raw = await readStdin();
|
||||
const event = parseEventJson(raw);
|
||||
const transcript = readTranscript(event.transcript_path);
|
||||
const userPrompt = lastUserPromptText(transcript);
|
||||
const override = findOverride(userPrompt, RULE_KEY);
|
||||
if (override) logOverride(RULE_KEY, override, event.session_id);
|
||||
|
||||
const state = readRouterState(event.session_id);
|
||||
const cls = state && state.classification;
|
||||
const recommendation = cls && (cls.recommended_node || cls.recommendedNode);
|
||||
const confidence = cls && typeof cls.confidence === 'number' ? cls.confidence : null;
|
||||
const toolUses = turnToolUses(transcript);
|
||||
const assistantText = lastAssistantText(transcript);
|
||||
|
||||
const result = decide({ toolUses, recommendation, confidence, assistantText, override });
|
||||
exitDecision(result);
|
||||
} catch {
|
||||
exitDecision({ block: false });
|
||||
}
|
||||
}
|
||||
|
||||
const isCli = process.argv[1] && process.argv[1].replace(/\\/g, '/').endsWith('/enforce-classifier-match.mjs');
|
||||
if (isCli) main();
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { decide } from './enforce-classifier-match.mjs';
|
||||
|
||||
describe('enforce-classifier-match / decide', () => {
|
||||
it('allows pure conversation (no mutating tools)', () => {
|
||||
expect(decide({
|
||||
toolUses: [{ name: 'Read' }],
|
||||
recommendation: 'superpowers:writing-plans',
|
||||
confidence: 0.9,
|
||||
}).block).toBe(false);
|
||||
});
|
||||
|
||||
it('allows when no recommendation', () => {
|
||||
expect(decide({
|
||||
toolUses: [{ name: 'Edit', input: {} }],
|
||||
recommendation: null,
|
||||
confidence: null,
|
||||
}).block).toBe(false);
|
||||
});
|
||||
|
||||
it('allows when confidence below threshold', () => {
|
||||
expect(decide({
|
||||
toolUses: [{ name: 'Edit', input: {} }],
|
||||
recommendation: 'superpowers:writing-plans',
|
||||
confidence: 0.5,
|
||||
}).block).toBe(false);
|
||||
});
|
||||
|
||||
it('blocks when recommendation high-confidence + no matching tool', () => {
|
||||
const r = decide({
|
||||
toolUses: [{ name: 'Edit', input: { file_path: 'x.mjs' } }],
|
||||
recommendation: 'superpowers:writing-plans',
|
||||
confidence: 0.9,
|
||||
});
|
||||
expect(r.block).toBe(true);
|
||||
expect(r.message).toMatch(/writing-plans/);
|
||||
});
|
||||
|
||||
it('allows when Skill tool invoked with matching name', () => {
|
||||
const r = decide({
|
||||
toolUses: [
|
||||
{ name: 'Skill', input: { skill: 'superpowers:writing-plans' } },
|
||||
{ name: 'Edit', input: { file_path: 'x.mjs' } },
|
||||
],
|
||||
recommendation: 'superpowers:writing-plans',
|
||||
confidence: 0.9,
|
||||
});
|
||||
expect(r.block).toBe(false);
|
||||
});
|
||||
|
||||
it('matches normalized name without superpowers: prefix', () => {
|
||||
const r = decide({
|
||||
toolUses: [
|
||||
{ name: 'Skill', input: { skill: 'writing-plans' } },
|
||||
{ name: 'Edit', input: {} },
|
||||
],
|
||||
recommendation: 'superpowers:writing-plans',
|
||||
confidence: 0.9,
|
||||
});
|
||||
expect(r.block).toBe(false);
|
||||
});
|
||||
|
||||
it('matches Task subagent', () => {
|
||||
const r = decide({
|
||||
toolUses: [
|
||||
{ name: 'Task', input: { subagent_type: 'rls-reviewer' } },
|
||||
{ name: 'Edit', input: {} },
|
||||
],
|
||||
recommendation: 'rls-reviewer',
|
||||
confidence: 0.85,
|
||||
});
|
||||
expect(r.block).toBe(false);
|
||||
});
|
||||
|
||||
it('allows when explicit "override:" in assistant text', () => {
|
||||
const r = decide({
|
||||
toolUses: [{ name: 'Edit', input: {} }],
|
||||
recommendation: 'foo:bar',
|
||||
confidence: 0.9,
|
||||
assistantText: 'override: simpler direct edit, foo:bar overkill here\n',
|
||||
});
|
||||
expect(r.block).toBe(false);
|
||||
});
|
||||
|
||||
it('allows when override phrase present', () => {
|
||||
const r = decide({
|
||||
toolUses: [{ name: 'Edit', input: {} }],
|
||||
recommendation: 'foo:bar',
|
||||
confidence: 0.9,
|
||||
override: { phrase: 'direct ok', suppresses: ['classifier-mismatch'] },
|
||||
});
|
||||
expect(r.block).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Rule #2 — Coverage tag verified against artifacts (Stop hook).
|
||||
*
|
||||
* Reads transcript at Stop event. Parses `coverage: <channel>:<id>` from last
|
||||
* assistant text. Then:
|
||||
* - channel=skill / id=X — require Skill tool_use with input.skill === X
|
||||
* - channel=node — accept any tool_use that produced work (>= 1 mutating tool)
|
||||
* - channel=direct — accept (Rule #8 handles direct-vs-classifier mismatch)
|
||||
* - channel=chain / hook / agent — accept (lighter discipline)
|
||||
* - missing coverage line — block
|
||||
*
|
||||
* Override: "без скилов" / "direct ok" suppress this rule.
|
||||
*
|
||||
* NB: only fires when the assistant ACTUALLY did some work (>=1 tool_use).
|
||||
* Pure conversational turns (no tool calls) pass without coverage requirement.
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-25-enforce-hard-rules-design.md
|
||||
*/
|
||||
|
||||
import {
|
||||
readStdin,
|
||||
parseEventJson,
|
||||
readTranscript,
|
||||
lastUserPromptText,
|
||||
lastAssistantText,
|
||||
parseCoverageLine,
|
||||
turnToolUses,
|
||||
findOverride,
|
||||
logOverride,
|
||||
exitDecision,
|
||||
} from './enforce-hook-helpers.mjs';
|
||||
|
||||
const RULE_KEY = 'coverage-skill-match';
|
||||
|
||||
const MUTATING_TOOLS = new Set([
|
||||
'Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'Bash',
|
||||
]);
|
||||
|
||||
export function decide({
|
||||
toolUses, assistantText, override,
|
||||
}) {
|
||||
// Pure conversational turn — skip.
|
||||
const hasMutating = toolUses.some((u) => MUTATING_TOOLS.has(u.name));
|
||||
if (!hasMutating) return { block: false };
|
||||
if (override) return { block: false };
|
||||
|
||||
const cov = parseCoverageLine(assistantText);
|
||||
if (!cov) {
|
||||
return {
|
||||
block: true,
|
||||
message: [
|
||||
`[enforce-coverage-verify] Turn performed mutating tool calls but assistant response has no \`coverage:\` line.`,
|
||||
`Add as first line of next response:`,
|
||||
` coverage: skill:<name> (e.g., skill:superpowers:test-driven-development)`,
|
||||
` coverage: direct:<role> (e.g., direct:memory-sync, direct:git-recovery)`,
|
||||
``,
|
||||
`Override: include "без скилов" or "direct ok" in your prompt.`,
|
||||
].join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
if (cov.channel === 'skill') {
|
||||
const found = toolUses.some((u) => u.name === 'Skill' && u.input && (u.input.skill === cov.id || u.input.skill === cov.id.replace(/^superpowers:/, '')));
|
||||
if (!found) {
|
||||
return {
|
||||
block: true,
|
||||
message: [
|
||||
`[enforce-coverage-verify] coverage says skill:${cov.id} but the Skill tool was never invoked with that name in this turn.`,
|
||||
`Either invoke the skill via Skill tool, or switch coverage to direct:<role> with justification.`,
|
||||
].join('\n'),
|
||||
};
|
||||
}
|
||||
return { block: false };
|
||||
}
|
||||
|
||||
// direct / node / chain / hook / agent — accepted at this layer.
|
||||
return { block: false };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
const raw = await readStdin();
|
||||
const event = parseEventJson(raw);
|
||||
const transcript = readTranscript(event.transcript_path);
|
||||
const userPrompt = lastUserPromptText(transcript);
|
||||
const override = findOverride(userPrompt, RULE_KEY);
|
||||
if (override) logOverride(RULE_KEY, override, event.session_id);
|
||||
|
||||
const toolUses = turnToolUses(transcript);
|
||||
const assistantText = lastAssistantText(transcript);
|
||||
|
||||
const result = decide({ toolUses, assistantText, override });
|
||||
exitDecision(result);
|
||||
} catch {
|
||||
exitDecision({ block: false });
|
||||
}
|
||||
}
|
||||
|
||||
const isCli = process.argv[1] && process.argv[1].replace(/\\/g, '/').endsWith('/enforce-coverage-verify.mjs');
|
||||
if (isCli) main();
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { decide } from './enforce-coverage-verify.mjs';
|
||||
|
||||
describe('enforce-coverage-verify / decide', () => {
|
||||
it('allows turn with no mutating tools (pure conversational)', () => {
|
||||
const r = decide({ toolUses: [{ name: 'Read', input: {} }], assistantText: 'just talking' });
|
||||
expect(r.block).toBe(false);
|
||||
});
|
||||
|
||||
it('blocks mutating turn with no coverage line', () => {
|
||||
const r = decide({
|
||||
toolUses: [{ name: 'Edit', input: { file_path: 'foo.mjs' } }],
|
||||
assistantText: 'just did some work',
|
||||
});
|
||||
expect(r.block).toBe(true);
|
||||
expect(r.message).toMatch(/no.*coverage/);
|
||||
});
|
||||
|
||||
it('blocks when coverage says skill but Skill tool not invoked', () => {
|
||||
const r = decide({
|
||||
toolUses: [{ name: 'Edit', input: { file_path: 'foo.mjs' } }],
|
||||
assistantText: 'coverage: skill:superpowers:test-driven-development\nдалее…',
|
||||
});
|
||||
expect(r.block).toBe(true);
|
||||
expect(r.message).toMatch(/Skill tool was never invoked/);
|
||||
});
|
||||
|
||||
it('allows when coverage says skill and Skill tool invoked with matching name', () => {
|
||||
const r = decide({
|
||||
toolUses: [
|
||||
{ name: 'Skill', input: { skill: 'superpowers:test-driven-development' } },
|
||||
{ name: 'Edit', input: { file_path: 'foo.mjs' } },
|
||||
],
|
||||
assistantText: 'coverage: skill:superpowers:test-driven-development\nок',
|
||||
});
|
||||
expect(r.block).toBe(false);
|
||||
});
|
||||
|
||||
it('allows when coverage matches without superpowers: prefix in tool input', () => {
|
||||
const r = decide({
|
||||
toolUses: [
|
||||
{ name: 'Skill', input: { skill: 'test-driven-development' } },
|
||||
{ name: 'Edit', input: { file_path: 'foo.mjs' } },
|
||||
],
|
||||
assistantText: 'coverage: skill:superpowers:test-driven-development',
|
||||
});
|
||||
expect(r.block).toBe(false);
|
||||
});
|
||||
|
||||
it('allows direct coverage', () => {
|
||||
const r = decide({
|
||||
toolUses: [{ name: 'Edit', input: { file_path: 'memory/foo.md' } }],
|
||||
assistantText: 'coverage: direct:memory-sync',
|
||||
});
|
||||
expect(r.block).toBe(false);
|
||||
});
|
||||
|
||||
it('allows node coverage', () => {
|
||||
const r = decide({
|
||||
toolUses: [{ name: 'Edit', input: { file_path: 'foo.vue' } }],
|
||||
assistantText: 'coverage: node:#19',
|
||||
});
|
||||
expect(r.block).toBe(false);
|
||||
});
|
||||
|
||||
it('allows when override phrase present', () => {
|
||||
const r = decide({
|
||||
toolUses: [{ name: 'Edit', input: { file_path: 'foo.mjs' } }],
|
||||
assistantText: 'no coverage',
|
||||
override: { phrase: 'без скилов', suppresses: ['coverage-skill-match'] },
|
||||
});
|
||||
expect(r.block).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,370 @@
|
||||
/**
|
||||
* Shared helpers for the 10-rule enforcement hook layer.
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-25-enforce-hard-rules-design.md
|
||||
* Plan: docs/superpowers/plans/2026-05-25-enforce-hard-rules.md
|
||||
*
|
||||
* Design contract: ALL hooks MUST fail-quiet on internal error (exit 0 with empty {}).
|
||||
* Only deliberate enforcement violations exit 2.
|
||||
*
|
||||
* Security note: this file uses child_process.execFileSync with FIXED arguments
|
||||
* (no user input concatenation) — pattern is safe by construction. No injection
|
||||
* surface. See readGitBranch().
|
||||
*
|
||||
* Security Guidance #40: pure parsing — no exec/execSync except readGitBranch which
|
||||
* is the documented use case (fixed args, no user input).
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync, appendFileSync } from 'fs';
|
||||
import { join, dirname } from 'path';
|
||||
import { homedir } from 'os';
|
||||
import { execFileSync } from 'child_process';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
/** Read full stdin as utf-8 string. Returns '' on empty/error. */
|
||||
export async function readStdin(stdinStream = process.stdin) {
|
||||
return new Promise((resolve) => {
|
||||
let data = '';
|
||||
let timedOut = false;
|
||||
const timer = setTimeout(() => { timedOut = true; resolve(data); }, 4500);
|
||||
stdinStream.setEncoding('utf-8');
|
||||
stdinStream.on('data', (chunk) => { data += chunk; });
|
||||
stdinStream.on('end', () => {
|
||||
if (timedOut) return;
|
||||
clearTimeout(timer);
|
||||
resolve(data);
|
||||
});
|
||||
stdinStream.on('error', () => {
|
||||
clearTimeout(timer);
|
||||
resolve('');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function parseEventJson(raw) {
|
||||
try { return JSON.parse(raw || '{}'); } catch { return {}; }
|
||||
}
|
||||
|
||||
/** Runtime directory: ~/.claude/runtime/ */
|
||||
export function runtimeDir() {
|
||||
const dir = join(homedir(), '.claude', 'runtime');
|
||||
try { mkdirSync(dir, { recursive: true }); } catch { /* ignore */ }
|
||||
return dir;
|
||||
}
|
||||
|
||||
export function sentinelPath(name, sessionId) {
|
||||
return join(runtimeDir(), `${name}-${sessionId || 'unknown'}.json`);
|
||||
}
|
||||
|
||||
export function writeSentinel(name, sessionId, data) {
|
||||
try {
|
||||
const p = sentinelPath(name, sessionId);
|
||||
writeFileSync(p, JSON.stringify({ ...data, written_at: new Date().toISOString() }, null, 2));
|
||||
return p;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
export function readSentinel(name, sessionId) {
|
||||
try {
|
||||
const p = sentinelPath(name, sessionId);
|
||||
if (!existsSync(p)) return null;
|
||||
return JSON.parse(readFileSync(p, 'utf-8'));
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
export function sentinelAgeSec(name, sessionId) {
|
||||
const s = readSentinel(name, sessionId);
|
||||
if (!s || !s.written_at) return null;
|
||||
const ms = Date.now() - new Date(s.written_at).getTime();
|
||||
if (!Number.isFinite(ms)) return null;
|
||||
return Math.floor(ms / 1000);
|
||||
}
|
||||
|
||||
export function readTranscript(transcriptPath) {
|
||||
if (!transcriptPath || typeof transcriptPath !== 'string') return [];
|
||||
if (!existsSync(transcriptPath)) return [];
|
||||
try {
|
||||
const raw = readFileSync(transcriptPath, 'utf-8');
|
||||
const lines = raw.split('\n').filter(Boolean);
|
||||
const out = [];
|
||||
for (const l of lines) {
|
||||
try { out.push(JSON.parse(l)); } catch { /* skip */ }
|
||||
}
|
||||
return out;
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
export function lastTurnEntries(entries) {
|
||||
if (!Array.isArray(entries) || entries.length === 0) return [];
|
||||
for (let i = entries.length - 1; i >= 0; i--) {
|
||||
const e = entries[i];
|
||||
if (e && e.message && e.message.role === 'user') {
|
||||
const c = e.message.content;
|
||||
if (typeof c === 'string' && c.trim().length > 0) return entries.slice(i);
|
||||
if (Array.isArray(c)) {
|
||||
const hasToolResult = c.some((b) => b && b.type === 'tool_result');
|
||||
const hasText = c.some((b) => b && b.type === 'text');
|
||||
if (hasText && !hasToolResult) return entries.slice(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
export function lastUserPromptText(entries) {
|
||||
const turn = lastTurnEntries(entries);
|
||||
if (!turn || turn.length === 0) return '';
|
||||
const e = turn[0];
|
||||
if (!e || !e.message) return '';
|
||||
const c = e.message.content;
|
||||
if (typeof c === 'string') return c;
|
||||
if (Array.isArray(c)) {
|
||||
return c.filter((b) => b && b.type === 'text').map((b) => b.text || '').join('\n');
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
export function lastAssistantText(entries) {
|
||||
const turn = lastTurnEntries(entries);
|
||||
let out = '';
|
||||
for (const e of turn) {
|
||||
if (e && e.message && e.message.role === 'assistant') {
|
||||
const c = e.message.content;
|
||||
if (Array.isArray(c)) {
|
||||
for (const b of c) {
|
||||
if (b && b.type === 'text' && typeof b.text === 'string') out += b.text + '\n';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function parseCoverageLine(text) {
|
||||
if (typeof text !== 'string') return null;
|
||||
const m = text.match(/coverage:\s*(skill|node|chain|hook|agent|direct)\s*:\s*([^\s\n<>]+)/i);
|
||||
if (!m) return null;
|
||||
return { channel: m[1].toLowerCase(), id: m[2] };
|
||||
}
|
||||
|
||||
export function turnToolUses(entries) {
|
||||
const turn = lastTurnEntries(entries);
|
||||
const uses = [];
|
||||
for (const e of turn) {
|
||||
const c = e && e.message && e.message.content;
|
||||
if (!Array.isArray(c)) continue;
|
||||
for (const b of c) {
|
||||
if (b && b.type === 'tool_use') uses.push({ name: b.name, input: b.input || {} });
|
||||
}
|
||||
}
|
||||
return uses;
|
||||
}
|
||||
|
||||
export function turnToolResults(entries) {
|
||||
const turn = lastTurnEntries(entries);
|
||||
const results = [];
|
||||
for (const e of turn) {
|
||||
const c = e && e.message && e.message.content;
|
||||
if (!Array.isArray(c)) continue;
|
||||
for (const b of c) {
|
||||
if (b && b.type === 'tool_result') {
|
||||
const txt = typeof b.content === 'string' ? b.content
|
||||
: Array.isArray(b.content) ? b.content.map((p) => (p && p.text) || '').join('\n') : '';
|
||||
results.push({ tool_use_id: b.tool_use_id, is_error: b.is_error === true, content: txt });
|
||||
}
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
let _vocabCache = null;
|
||||
export function loadOverrideVocab(path) {
|
||||
if (_vocabCache) return _vocabCache;
|
||||
try {
|
||||
const p = path || join(__dirname, 'enforce-override-vocab.json');
|
||||
if (!existsSync(p)) return { phrases: [] };
|
||||
_vocabCache = JSON.parse(readFileSync(p, 'utf-8'));
|
||||
return _vocabCache;
|
||||
} catch { return { phrases: [] }; }
|
||||
}
|
||||
|
||||
export function _resetVocabCache() { _vocabCache = null; }
|
||||
|
||||
export function findOverride(userPrompt, ruleKey, vocab) {
|
||||
if (!userPrompt || typeof userPrompt !== 'string') return null;
|
||||
const v = vocab || loadOverrideVocab();
|
||||
const lo = userPrompt.toLowerCase();
|
||||
for (const p of v.phrases || []) {
|
||||
if (!p.phrase || !Array.isArray(p.suppresses)) continue;
|
||||
if (!lo.includes(p.phrase.toLowerCase())) continue;
|
||||
if (p.suppresses.includes(ruleKey)) return p;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function logOverride(ruleKey, phraseObj, sessionId) {
|
||||
try {
|
||||
const f = join(runtimeDir(), 'override-usage.jsonl');
|
||||
appendFileSync(f, JSON.stringify({
|
||||
ts: new Date().toISOString(),
|
||||
session_id: sessionId || null,
|
||||
rule: ruleKey,
|
||||
phrase: phraseObj && phraseObj.phrase,
|
||||
}) + '\n');
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* Read current git branch via execFileSync with fixed args (no shell, no user
|
||||
* input concatenation — safe by construction). Returns empty string on error.
|
||||
*/
|
||||
export function readGitBranch(cwd) {
|
||||
try {
|
||||
return execFileSync('git', ['branch', '--show-current'], {
|
||||
cwd: cwd || process.cwd(),
|
||||
encoding: 'utf-8',
|
||||
timeout: 1000,
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
}).trim();
|
||||
} catch { return ''; }
|
||||
}
|
||||
|
||||
export function expectedBranchPath(sessionId) {
|
||||
return join(runtimeDir(), `expected-branch-${sessionId || 'unknown'}`);
|
||||
}
|
||||
|
||||
export function getExpectedBranch(sessionId) {
|
||||
try {
|
||||
const p = expectedBranchPath(sessionId);
|
||||
if (!existsSync(p)) return '';
|
||||
return readFileSync(p, 'utf-8').trim();
|
||||
} catch { return ''; }
|
||||
}
|
||||
|
||||
export function setExpectedBranch(sessionId, branch) {
|
||||
try {
|
||||
writeFileSync(expectedBranchPath(sessionId), String(branch || '').trim());
|
||||
return true;
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
export function appendRationalizationFlag(sessionId, kind, evidence) {
|
||||
try {
|
||||
const f = join(runtimeDir(), `rationalization-flags-${sessionId || 'unknown'}.jsonl`);
|
||||
appendFileSync(f, JSON.stringify({
|
||||
ts: new Date().toISOString(),
|
||||
kind,
|
||||
evidence: typeof evidence === 'string' ? evidence.slice(0, 240) : evidence,
|
||||
}) + '\n');
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
export function readRationalizationFlags(sessionId) {
|
||||
try {
|
||||
const f = join(runtimeDir(), `rationalization-flags-${sessionId || 'unknown'}.jsonl`);
|
||||
if (!existsSync(f)) return [];
|
||||
return readFileSync(f, 'utf-8').split('\n').filter(Boolean).map((l) => {
|
||||
try { return JSON.parse(l); } catch { return null; }
|
||||
}).filter(Boolean);
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
export function readRouterState(sessionId) {
|
||||
try {
|
||||
const p = join(runtimeDir(), `router-state-${sessionId || 'unknown'}.json`);
|
||||
if (!existsSync(p)) return null;
|
||||
return JSON.parse(readFileSync(p, 'utf-8'));
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
export function exitDecision({ block, message } = {}) {
|
||||
if (block) {
|
||||
if (message) process.stderr.write(message + '\n');
|
||||
process.exit(2);
|
||||
return;
|
||||
}
|
||||
try { process.stdout.write('{}'); } catch { /* ignore */ }
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
export function isProductionCodePath(p) {
|
||||
if (typeof p !== 'string') return false;
|
||||
const n = p.replace(/\\/g, '/');
|
||||
if (/\.(test|spec)\.[a-z0-9]+$/i.test(n)) return false;
|
||||
if (/(?:^|\/)tests?\//.test(n) || /(?:^|\/)spec\//.test(n)) return false;
|
||||
if (/(?:^|\/)tools\/[^/]+\.mjs$/.test(n)) return true;
|
||||
if (/(?:^|\/)app\/app\/.+\.php$/.test(n)) return true;
|
||||
if (/(?:^|\/)resources\/js\/.+\.(vue|ts|tsx|js)$/.test(n)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isMemoryPath(p) {
|
||||
if (typeof p !== 'string') return false;
|
||||
const n = p.replace(/\\/g, '/');
|
||||
if (/\/memory\/[^/]+\.md$/i.test(n)) return true;
|
||||
if (/\/MEMORY\.md$/i.test(n)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function detectGitCommandKind(cmd) {
|
||||
if (typeof cmd !== 'string') return null;
|
||||
const c = cmd.trim();
|
||||
if (/(^|\s|;|&&|\|\|)git\s+push\b/i.test(c)) return 'push';
|
||||
if (/(^|\s|;|&&|\|\|)git\s+commit\b/i.test(c)) return 'commit';
|
||||
if (/(^|\s|;|&&|\|\|)git\s+cherry-pick\b/i.test(c)) return 'cherry-pick';
|
||||
if (/(^|\s|;|&&|\|\|)git\s+reset\s+--hard\b/i.test(c)) return 'reset-hard';
|
||||
if (/(^|\s|;|&&|\|\|)git\s+rebase\b/i.test(c)) return 'rebase';
|
||||
if (/(^|\s|;|&&|\|\|)git\s+branch\s+-[df]\b/i.test(c)) return 'branch-force';
|
||||
return null;
|
||||
}
|
||||
|
||||
export function detectFullTestRun(cmd) {
|
||||
if (typeof cmd !== 'string') return null;
|
||||
const c = cmd.toLowerCase();
|
||||
// FIRST-REAL-COMMAND approach: split on shell separators, find first segment
|
||||
// after skipping cd / env-prefix. Only that command counts. Embedded args
|
||||
// (commit messages, echo strings) don't matter — they live inside the args
|
||||
// of the first command, not as independent shell segments.
|
||||
//
|
||||
// Caveat: naive `&&` split can match inside quoted strings. We accept this
|
||||
// because we use the FIRST segment only; later segments are ignored. As
|
||||
// long as user's first real command is git/echo/etc, the whole command is
|
||||
// classified as that.
|
||||
const segments = c.split(/\s*(?:&&|\|\||;|\|)\s*/);
|
||||
let firstReal = null;
|
||||
for (let seg of segments) {
|
||||
seg = seg.trim();
|
||||
// Strip env-var prefixes (KEY=value) and skip `cd <path>` segments.
|
||||
seg = seg.replace(/^(?:[a-z_][a-z0-9_]*=\S+\s+)+/i, '').trim();
|
||||
if (/^cd\b/i.test(seg)) continue;
|
||||
firstReal = seg;
|
||||
break;
|
||||
}
|
||||
if (!firstReal) return null;
|
||||
|
||||
// Hard guard: first real command starts with a non-test shell-utility →
|
||||
// whole compound is not a test run, regardless of quoted args.
|
||||
if (/^(?:git|scp|ssh|curl|wget|cat|echo|grep|awk|sed|tar|gzip|bzip2|cp|mv|rm|mkdir|touch|chmod|chown|ls|cd|pwd|head|tail|find)\b/.test(firstReal)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (/^npx\s+vitest\s+run\b/.test(firstReal) || /^vitest\s+run\b/.test(firstReal)) {
|
||||
// narrow vitest (specific .test file) is NOT full
|
||||
if (/\btools\/[^\s]+\.test\.mjs\b/.test(firstReal)) return null;
|
||||
return 'vitest-full';
|
||||
}
|
||||
if (/^npm\s+run\s+test\b/.test(firstReal)) return 'npm-test';
|
||||
if (/^php\s+artisan\s+test\b/.test(firstReal) || /^composer\s+test\b/.test(firstReal)) return 'pest';
|
||||
if (/^(?:\.\/)?(?:vendor\/bin\/)?pest\b/.test(firstReal)) return 'pest';
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isVerificationFresh(sessionId, maxAgeSec = 1800) {
|
||||
const s = readSentinel('verify-pass', sessionId);
|
||||
if (!s || s.result !== 'pass') return false;
|
||||
const age = sentinelAgeSec('verify-pass', sessionId);
|
||||
return age !== null && age <= maxAgeSec;
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, writeFileSync, rmSync, existsSync, readFileSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import {
|
||||
parseEventJson,
|
||||
parseCoverageLine,
|
||||
lastTurnEntries,
|
||||
lastUserPromptText,
|
||||
lastAssistantText,
|
||||
turnToolUses,
|
||||
turnToolResults,
|
||||
loadOverrideVocab,
|
||||
_resetVocabCache,
|
||||
findOverride,
|
||||
isProductionCodePath,
|
||||
isMemoryPath,
|
||||
detectGitCommandKind,
|
||||
detectFullTestRun,
|
||||
} from './enforce-hook-helpers.mjs';
|
||||
|
||||
describe('parseEventJson', () => {
|
||||
it('parses well-formed JSON', () => {
|
||||
expect(parseEventJson('{"a":1}')).toEqual({ a: 1 });
|
||||
});
|
||||
it('returns empty object on broken JSON', () => {
|
||||
expect(parseEventJson('not-json')).toEqual({});
|
||||
});
|
||||
it('returns empty object on empty input', () => {
|
||||
expect(parseEventJson('')).toEqual({});
|
||||
expect(parseEventJson(null)).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseCoverageLine', () => {
|
||||
it('extracts skill coverage', () => {
|
||||
const t = 'экономия: 100%\n\ncoverage: skill:superpowers:test-driven-development\n\nок поехали';
|
||||
expect(parseCoverageLine(t)).toEqual({ channel: 'skill', id: 'superpowers:test-driven-development' });
|
||||
});
|
||||
it('extracts direct coverage', () => {
|
||||
expect(parseCoverageLine('coverage: direct:memory-sync')).toEqual({ channel: 'direct', id: 'memory-sync' });
|
||||
});
|
||||
it('extracts node coverage', () => {
|
||||
expect(parseCoverageLine('coverage: node:#19')).toEqual({ channel: 'node', id: '#19' });
|
||||
});
|
||||
it('is case-insensitive on channel keyword', () => {
|
||||
expect(parseCoverageLine('Coverage: Skill:foo')).toEqual({ channel: 'skill', id: 'foo' });
|
||||
});
|
||||
it('returns null when no coverage line present', () => {
|
||||
expect(parseCoverageLine('just some text')).toBeNull();
|
||||
});
|
||||
it('returns null on non-string input', () => {
|
||||
expect(parseCoverageLine(null)).toBeNull();
|
||||
expect(parseCoverageLine(42)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('lastTurnEntries / lastUserPromptText / lastAssistantText / turnToolUses', () => {
|
||||
const entries = [
|
||||
{ message: { role: 'user', content: 'old prompt' } },
|
||||
{ message: { role: 'assistant', content: [{ type: 'text', text: 'old reply' }] } },
|
||||
{ message: { role: 'user', content: 'new prompt' } },
|
||||
{ message: { role: 'assistant', content: [
|
||||
{ type: 'text', text: 'I will edit' },
|
||||
{ type: 'tool_use', name: 'Edit', input: { file_path: 'a.mjs' } },
|
||||
] } },
|
||||
{ message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'x', content: 'ok', is_error: false }] } },
|
||||
];
|
||||
|
||||
it('lastTurnEntries starts from last real user prompt', () => {
|
||||
const turn = lastTurnEntries(entries);
|
||||
expect(turn).toHaveLength(3); // new prompt + assistant + tool_result
|
||||
expect(turn[0].message.content).toBe('new prompt');
|
||||
});
|
||||
|
||||
it('lastUserPromptText returns last user prompt string', () => {
|
||||
expect(lastUserPromptText(entries)).toBe('new prompt');
|
||||
});
|
||||
|
||||
it('lastAssistantText concatenates assistant text blocks of last turn only', () => {
|
||||
expect(lastAssistantText(entries)).toContain('I will edit');
|
||||
expect(lastAssistantText(entries)).not.toContain('old reply');
|
||||
});
|
||||
|
||||
it('turnToolUses returns only tool_use blocks from last turn', () => {
|
||||
const uses = turnToolUses(entries);
|
||||
expect(uses).toHaveLength(1);
|
||||
expect(uses[0].name).toBe('Edit');
|
||||
expect(uses[0].input.file_path).toBe('a.mjs');
|
||||
});
|
||||
|
||||
it('turnToolResults includes is_error flag and concatenated text', () => {
|
||||
const results = turnToolResults(entries);
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].is_error).toBe(false);
|
||||
expect(results[0].content).toBe('ok');
|
||||
});
|
||||
|
||||
it('handles array text content in user message', () => {
|
||||
const eps = [
|
||||
{ message: { role: 'user', content: [{ type: 'text', text: 'hello' }, { type: 'text', text: ' world' }] } },
|
||||
];
|
||||
expect(lastUserPromptText(eps)).toBe('hello\n world');
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadOverrideVocab / findOverride', () => {
|
||||
let tmp;
|
||||
beforeEach(() => {
|
||||
tmp = mkdtempSync(join(tmpdir(), 'vocab-'));
|
||||
_resetVocabCache();
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
_resetVocabCache();
|
||||
});
|
||||
|
||||
it('loads vocab from explicit path', () => {
|
||||
const p = join(tmp, 'vocab.json');
|
||||
writeFileSync(p, JSON.stringify({
|
||||
phrases: [
|
||||
{ phrase: 'без скилов', suppresses: ['skill-required'] },
|
||||
],
|
||||
}));
|
||||
const v = loadOverrideVocab(p);
|
||||
expect(v.phrases).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('findOverride matches case-insensitively', () => {
|
||||
const v = { phrases: [{ phrase: 'СРОЧНО', suppresses: ['verify-before-push'] }] };
|
||||
expect(findOverride('очень срочно нужно', 'verify-before-push', v)).toMatchObject({ phrase: 'СРОЧНО' });
|
||||
expect(findOverride('hello world', 'verify-before-push', v)).toBeNull();
|
||||
});
|
||||
|
||||
it('findOverride returns null if rule key not in suppresses', () => {
|
||||
const v = { phrases: [{ phrase: 'без скилов', suppresses: ['skill-required'] }] };
|
||||
expect(findOverride('без скилов давай', 'tdd-gate', v)).toBeNull();
|
||||
expect(findOverride('без скилов давай', 'skill-required', v)).not.toBeNull();
|
||||
});
|
||||
|
||||
it('findOverride returns null on empty prompt / vocab', () => {
|
||||
expect(findOverride('', 'x', { phrases: [] })).toBeNull();
|
||||
expect(findOverride(null, 'x', { phrases: [{ phrase: 'a', suppresses: ['x'] }] })).toBeNull();
|
||||
});
|
||||
|
||||
it('loads default vocab file when no path given (smoke)', () => {
|
||||
_resetVocabCache();
|
||||
const v = loadOverrideVocab();
|
||||
expect(Array.isArray(v.phrases)).toBe(true);
|
||||
expect(v.phrases.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isProductionCodePath', () => {
|
||||
it('classifies tools/*.mjs as production', () => {
|
||||
expect(isProductionCodePath('tools/router-classifier.mjs')).toBe(true);
|
||||
expect(isProductionCodePath('c:/моя/проекты/портал crm/Документация/tools/foo.mjs')).toBe(true);
|
||||
});
|
||||
it('excludes test files', () => {
|
||||
expect(isProductionCodePath('tools/router-classifier.test.mjs')).toBe(false);
|
||||
expect(isProductionCodePath('tools/foo.spec.mjs')).toBe(false);
|
||||
});
|
||||
it('classifies app/app/**.php as production', () => {
|
||||
expect(isProductionCodePath('app/app/Http/Controllers/X.php')).toBe(true);
|
||||
});
|
||||
it('excludes app/tests/**', () => {
|
||||
expect(isProductionCodePath('app/tests/Feature/X.php')).toBe(false);
|
||||
});
|
||||
it('classifies resources/js/**.vue|ts|tsx|js as production', () => {
|
||||
expect(isProductionCodePath('resources/js/views/Dashboard.vue')).toBe(true);
|
||||
expect(isProductionCodePath('resources/js/api/admin.ts')).toBe(true);
|
||||
});
|
||||
it('excludes *.spec.ts/*.test.ts', () => {
|
||||
expect(isProductionCodePath('resources/js/views/Dashboard.spec.ts')).toBe(false);
|
||||
expect(isProductionCodePath('resources/js/views/Dashboard.test.ts')).toBe(false);
|
||||
});
|
||||
it('returns false for non-production paths', () => {
|
||||
expect(isProductionCodePath('docs/x.md')).toBe(false);
|
||||
expect(isProductionCodePath('CLAUDE.md')).toBe(false);
|
||||
expect(isProductionCodePath('package.json')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isMemoryPath', () => {
|
||||
it('matches user-memory store .md files', () => {
|
||||
expect(isMemoryPath('C:\\Users\\Administrator\\.claude\\projects\\proj\\memory\\reference.md')).toBe(true);
|
||||
expect(isMemoryPath('/Users/x/.claude/projects/proj/memory/foo.md')).toBe(true);
|
||||
});
|
||||
it('matches MEMORY.md regardless of folder', () => {
|
||||
expect(isMemoryPath('C:\\Users\\x\\.claude\\projects\\proj\\memory\\MEMORY.md')).toBe(true);
|
||||
expect(isMemoryPath('/foo/MEMORY.md')).toBe(true);
|
||||
});
|
||||
it('returns false for normal docs', () => {
|
||||
expect(isMemoryPath('docs/x.md')).toBe(false);
|
||||
expect(isMemoryPath('CLAUDE.md')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectGitCommandKind', () => {
|
||||
it('detects push', () => {
|
||||
expect(detectGitCommandKind('git push origin main')).toBe('push');
|
||||
expect(detectGitCommandKind('LEFTHOOK=0 git push')).toBe('push');
|
||||
});
|
||||
it('detects commit', () => {
|
||||
expect(detectGitCommandKind('git commit -m "x"')).toBe('commit');
|
||||
});
|
||||
it('detects cherry-pick', () => {
|
||||
expect(detectGitCommandKind('git cherry-pick abc123')).toBe('cherry-pick');
|
||||
});
|
||||
it('detects branch -f', () => {
|
||||
expect(detectGitCommandKind('git branch -f main HEAD')).toBe('branch-force');
|
||||
expect(detectGitCommandKind('git branch -d feature')).toBe('branch-force');
|
||||
});
|
||||
it('detects rebase', () => {
|
||||
expect(detectGitCommandKind('git rebase main')).toBe('rebase');
|
||||
});
|
||||
it('returns null for non-git commands', () => {
|
||||
expect(detectGitCommandKind('ls -la')).toBeNull();
|
||||
expect(detectGitCommandKind('git status')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectFullTestRun', () => {
|
||||
it('detects vitest run as full when no specific path', () => {
|
||||
expect(detectFullTestRun('npx vitest run')).toBe('vitest-full');
|
||||
expect(detectFullTestRun('npx vitest run --reporter=basic')).toBe('vitest-full');
|
||||
});
|
||||
it('returns null for narrow vitest with specific test path', () => {
|
||||
expect(detectFullTestRun('npx vitest run tools/foo.test.mjs')).toBeNull();
|
||||
});
|
||||
it('detects pest / composer test', () => {
|
||||
expect(detectFullTestRun('php artisan test')).toBe('pest');
|
||||
expect(detectFullTestRun('composer test')).toBe('pest');
|
||||
expect(detectFullTestRun('./vendor/bin/pest')).toBe('pest');
|
||||
});
|
||||
it('returns null for non-test commands', () => {
|
||||
expect(detectFullTestRun('git status')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when "vitest run" appears INSIDE a git commit message (false-positive guard)', () => {
|
||||
// Real bug we hit during bootstrap: commit message saying "full vitest run
|
||||
// (8092/8092)" caused detectFullTestRun to match and overwrite sentinel.
|
||||
expect(detectFullTestRun('git commit -m "feat: full vitest run all green"')).toBeNull();
|
||||
expect(detectFullTestRun('LEFTHOOK=0 git commit -m "ran pest"')).toBeNull();
|
||||
expect(detectFullTestRun('echo "pest passed" && ls')).toBeNull();
|
||||
expect(detectFullTestRun('cat sentinel | grep vitest')).toBeNull();
|
||||
});
|
||||
|
||||
it('still detects vitest in compound command starting with cd or having cat/echo segments', () => {
|
||||
// Second bug: overly aggressive guard blocked legitimate vitest run that
|
||||
// appeared in a compound command with cd / cat / echo somewhere.
|
||||
// We want: ANY segment starting with `npx vitest run` (or pest) counts.
|
||||
expect(detectFullTestRun('cd /path && npx vitest run tools/ 2>&1 | tail -5')).toBe('vitest-full');
|
||||
expect(detectFullTestRun('LEFTHOOK=0 npx vitest run')).toBe('vitest-full');
|
||||
expect(detectFullTestRun('npx vitest run && echo done')).toBe('vitest-full');
|
||||
expect(detectFullTestRun('cd app && composer test')).toBe('pest');
|
||||
expect(detectFullTestRun('cd app && php artisan test')).toBe('pest');
|
||||
expect(detectFullTestRun('./vendor/bin/pest')).toBe('pest');
|
||||
});
|
||||
|
||||
it('returns null when git commit message itself contains a compound that looks like test run (third false-positive)', () => {
|
||||
// Third bug: split-by-&& naively splits inside quoted commit messages.
|
||||
// A commit message like `git commit -m "... npx vitest run ..."` would
|
||||
// produce a segment `npx vitest run` from inside the quoted string.
|
||||
// Fix: identify FIRST real command (after cd/env), if it's git/etc → null.
|
||||
expect(detectFullTestRun('git commit -m "fix: command like cd ... && npx vitest run"')).toBeNull();
|
||||
expect(detectFullTestRun('cd /path && git commit -m "and then npx vitest run && echo done"')).toBeNull();
|
||||
expect(detectFullTestRun('git push origin main')).toBeNull();
|
||||
expect(detectFullTestRun('cd app && cp src dst')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Rule #5 — Memory write requires memory-sync coverage.
|
||||
*
|
||||
* PreToolUse hook on Edit / Write / MultiEdit. If the file_path looks like a
|
||||
* memory store .md (memory/*.md or MEMORY.md), require the last assistant
|
||||
* message to declare `coverage: direct:memory-sync` OR `coverage: skill:*` for
|
||||
* a memory-related skill. Otherwise block with a re-announce instruction.
|
||||
*
|
||||
* Override phrase: `memory dump` in user's last prompt suppresses this rule.
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-25-enforce-hard-rules-design.md
|
||||
*/
|
||||
|
||||
import {
|
||||
readStdin,
|
||||
parseEventJson,
|
||||
readTranscript,
|
||||
lastUserPromptText,
|
||||
lastAssistantText,
|
||||
parseCoverageLine,
|
||||
findOverride,
|
||||
logOverride,
|
||||
exitDecision,
|
||||
isMemoryPath,
|
||||
} from './enforce-hook-helpers.mjs';
|
||||
|
||||
const RULE_KEY = 'memory-sync-coverage';
|
||||
|
||||
function isMemorySyncCoverage(cov) {
|
||||
if (!cov) return false;
|
||||
if (cov.channel === 'direct' && /memory-sync/i.test(cov.id)) return true;
|
||||
if (cov.channel === 'skill' && /memory/i.test(cov.id)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function decide({ toolName, filePath, transcriptEntries, override }) {
|
||||
if (!['Edit', 'Write', 'MultiEdit'].includes(toolName)) {
|
||||
return { block: false };
|
||||
}
|
||||
if (!isMemoryPath(filePath)) return { block: false };
|
||||
if (override) return { block: false };
|
||||
|
||||
const assistantText = lastAssistantText(transcriptEntries);
|
||||
const cov = parseCoverageLine(assistantText);
|
||||
if (isMemorySyncCoverage(cov)) return { block: false };
|
||||
|
||||
return {
|
||||
block: true,
|
||||
message: [
|
||||
`[enforce-memory-coverage] Write to memory path requires memory-sync coverage tag.`,
|
||||
`Detected coverage: ${cov ? cov.channel + ':' + cov.id : 'NONE'} (stale or absent).`,
|
||||
``,
|
||||
`Re-announce on a fresh assistant turn first:`,
|
||||
` coverage: direct:memory-sync`,
|
||||
`Then retry the Edit/Write.`,
|
||||
``,
|
||||
`Override: include the phrase "memory dump" in your prompt.`,
|
||||
].join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
const raw = await readStdin();
|
||||
const event = parseEventJson(raw);
|
||||
const toolName = event.tool_name || '';
|
||||
const filePath = (event.tool_input && (event.tool_input.file_path || event.tool_input.notebook_path)) || '';
|
||||
const transcript = readTranscript(event.transcript_path);
|
||||
const userPrompt = lastUserPromptText(transcript);
|
||||
const override = findOverride(userPrompt, RULE_KEY);
|
||||
if (override) logOverride(RULE_KEY, override, event.session_id);
|
||||
|
||||
const result = decide({ toolName, filePath, transcriptEntries: transcript, override });
|
||||
exitDecision(result);
|
||||
} catch {
|
||||
// Fail-quiet on any internal error.
|
||||
exitDecision({ block: false });
|
||||
}
|
||||
}
|
||||
|
||||
const isCli = process.argv[1] && process.argv[1].replace(/\\/g, '/').endsWith('/enforce-memory-coverage.mjs');
|
||||
if (isCli) main();
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { decide } from './enforce-memory-coverage.mjs';
|
||||
|
||||
function entries(userPrompt, assistantText) {
|
||||
const out = [];
|
||||
if (userPrompt) out.push({ message: { role: 'user', content: userPrompt } });
|
||||
if (assistantText) out.push({ message: { role: 'assistant', content: [{ type: 'text', text: assistantText }] } });
|
||||
return out;
|
||||
}
|
||||
|
||||
describe('enforce-memory-coverage / decide', () => {
|
||||
it('allows non-memory paths regardless of coverage', () => {
|
||||
const r = decide({
|
||||
toolName: 'Write',
|
||||
filePath: 'tools/foo.mjs',
|
||||
transcriptEntries: entries('do it', 'coverage: skill:tdd'),
|
||||
});
|
||||
expect(r.block).toBe(false);
|
||||
});
|
||||
|
||||
it('blocks memory path with TDD coverage (stale)', () => {
|
||||
const r = decide({
|
||||
toolName: 'Edit',
|
||||
filePath: 'C:\\Users\\x\\.claude\\projects\\proj\\memory\\foo.md',
|
||||
transcriptEntries: entries('do', 'coverage: skill:superpowers:test-driven-development'),
|
||||
});
|
||||
expect(r.block).toBe(true);
|
||||
expect(r.message).toMatch(/memory-sync/);
|
||||
});
|
||||
|
||||
it('blocks memory path with no coverage at all', () => {
|
||||
const r = decide({
|
||||
toolName: 'Write',
|
||||
filePath: '/Users/x/.claude/projects/p/memory/x.md',
|
||||
transcriptEntries: entries('do', 'no coverage line here'),
|
||||
});
|
||||
expect(r.block).toBe(true);
|
||||
expect(r.message).toMatch(/NONE/);
|
||||
});
|
||||
|
||||
it('allows memory path with direct:memory-sync coverage', () => {
|
||||
const r = decide({
|
||||
toolName: 'Edit',
|
||||
filePath: 'C:\\Users\\x\\.claude\\projects\\proj\\memory\\foo.md',
|
||||
transcriptEntries: entries('do', 'coverage: direct:memory-sync\nок'),
|
||||
});
|
||||
expect(r.block).toBe(false);
|
||||
});
|
||||
|
||||
it('allows memory path with skill:memory-something coverage', () => {
|
||||
const r = decide({
|
||||
toolName: 'Edit',
|
||||
filePath: '/x/.claude/projects/p/memory/foo.md',
|
||||
transcriptEntries: entries('do', 'coverage: skill:memory-coordinator'),
|
||||
});
|
||||
expect(r.block).toBe(false);
|
||||
});
|
||||
|
||||
it('allows memory path when override phrase present', () => {
|
||||
const r = decide({
|
||||
toolName: 'Write',
|
||||
filePath: '/x/.claude/projects/p/memory/foo.md',
|
||||
transcriptEntries: entries('memory dump please', 'no coverage'),
|
||||
override: { phrase: 'memory dump', suppresses: ['memory-sync-coverage'] },
|
||||
});
|
||||
expect(r.block).toBe(false);
|
||||
});
|
||||
|
||||
it('skips non-Edit/Write/MultiEdit tools', () => {
|
||||
const r = decide({
|
||||
toolName: 'Bash',
|
||||
filePath: 'memory/x.md',
|
||||
transcriptEntries: entries('do', 'no coverage'),
|
||||
});
|
||||
expect(r.block).toBe(false);
|
||||
});
|
||||
|
||||
it('matches MEMORY.md anywhere', () => {
|
||||
const r = decide({
|
||||
toolName: 'Edit',
|
||||
filePath: '/whatever/MEMORY.md',
|
||||
transcriptEntries: entries('do', 'coverage: skill:tdd'),
|
||||
});
|
||||
expect(r.block).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"version": 1,
|
||||
"comment": "Hard-coded override phrases. Substring-match (case-insensitive) against user's last prompt. Each phrase suppresses one or more rule categories for ONE prompt only.",
|
||||
"phrases": [
|
||||
{
|
||||
"phrase": "без скилов",
|
||||
"suppresses": ["skill-required", "coverage-skill-match", "classifier-mismatch"],
|
||||
"description": "Skill discipline relaxed for this one prompt"
|
||||
},
|
||||
{
|
||||
"phrase": "direct ok",
|
||||
"suppresses": ["skill-required", "coverage-skill-match", "classifier-mismatch"],
|
||||
"description": "Direct work allowed without skill invocation"
|
||||
},
|
||||
{
|
||||
"phrase": "срочно",
|
||||
"suppresses": ["verify-before-commit", "verify-before-push", "tdd-gate"],
|
||||
"description": "Urgency override: skip verification + TDD gate"
|
||||
},
|
||||
{
|
||||
"phrase": "быстрый коммит",
|
||||
"suppresses": ["verify-before-commit", "tdd-gate", "writing-plans-required"],
|
||||
"description": "Quick commit: skip TDD + verify + plans"
|
||||
},
|
||||
{
|
||||
"phrase": "recovery",
|
||||
"suppresses": ["branch-switch", "git-recovery"],
|
||||
"description": "Git recovery operation, branch-state mismatch ok"
|
||||
},
|
||||
{
|
||||
"phrase": "memory dump",
|
||||
"suppresses": ["memory-sync-coverage", "skill-required"],
|
||||
"description": "Memory write without separate coverage announcement"
|
||||
},
|
||||
{
|
||||
"phrase": "ремонт инфраструктуры",
|
||||
"suppresses": ["tdd-gate", "verify-before-commit", "verify-before-push", "writing-plans-required", "skill-required", "memory-sync-coverage", "classifier-mismatch", "coverage-skill-match"],
|
||||
"description": "Bypass all rules (full opt-out). Use only when literally fixing the enforce-infrastructure itself."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Rule #1 — Mandatory re-classification injection.
|
||||
*
|
||||
* UserPromptSubmit hook. Reads router-state-<session>.json (output of the
|
||||
* existing router-prehook), reads rationalization flags from previous turns,
|
||||
* and injects an `additionalContext` block into the conversation.
|
||||
*
|
||||
* The block:
|
||||
* 1. Reminds: first line must be `coverage: <channel>:<id>`
|
||||
* 2. Lists recommended node/skill from classifier
|
||||
* 3. Surfaces previous-turn rationalization flags (if any)
|
||||
*
|
||||
* NEVER blocks the prompt — failed injection just means no reminder appears.
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-25-enforce-hard-rules-design.md
|
||||
*/
|
||||
|
||||
import {
|
||||
readStdin,
|
||||
parseEventJson,
|
||||
readRouterState,
|
||||
readRationalizationFlags,
|
||||
findOverride,
|
||||
loadOverrideVocab,
|
||||
} from './enforce-hook-helpers.mjs';
|
||||
|
||||
const SUPPRESS_RULE = 'classifier-mismatch';
|
||||
|
||||
export function buildReminder({ classification, recentFlags, override }) {
|
||||
const lines = ['## §17 Coverage / Discipline Reminder', ''];
|
||||
if (override) {
|
||||
lines.push(`Override phrase detected: "${override.phrase}". The following rules are suppressed for THIS prompt only:`);
|
||||
lines.push(` ${override.suppresses.join(', ')}`);
|
||||
lines.push('');
|
||||
}
|
||||
lines.push('**First line of your response MUST be:**');
|
||||
lines.push(' `coverage: <channel>:<id>`');
|
||||
lines.push('Channels: skill, node, chain, hook, agent, direct.');
|
||||
lines.push('');
|
||||
if (classification) {
|
||||
lines.push(`**Classifier output:** task_type=${classification.task_type || 'unknown'}, confidence=${classification.confidence ?? 'n/a'}`);
|
||||
if (classification.recommended_node) {
|
||||
lines.push(`**Recommended node:** ${classification.recommended_node}`);
|
||||
}
|
||||
if (classification.recommended_chain) {
|
||||
lines.push(`**Recommended chain:** ${classification.recommended_chain}`);
|
||||
}
|
||||
if (classification.task_type && /^(feature|bugfix|refactor|cleanup)$/i.test(classification.task_type)) {
|
||||
lines.push(`**Plan required:** task type ${classification.task_type} requires either Skill(superpowers:writing-plans) invocation OR an existing plan file referenced before first production-code edit.`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
if (Array.isArray(recentFlags) && recentFlags.length > 0) {
|
||||
const recent = recentFlags.slice(-3);
|
||||
lines.push('**Previous turn flagged:**');
|
||||
for (const f of recent) lines.push(` - ${f.kind}: ${typeof f.evidence === 'string' ? f.evidence.slice(0, 120) : ''}`);
|
||||
lines.push('Adjust behaviour accordingly.');
|
||||
lines.push('');
|
||||
}
|
||||
lines.push('Override vocabulary (substring-match in user prompt):');
|
||||
lines.push(' без скилов / direct ok / срочно / быстрый коммит / recovery / memory dump / ремонт инфраструктуры');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
const raw = await readStdin();
|
||||
const event = parseEventJson(raw);
|
||||
const sessionId = event.session_id;
|
||||
const userPrompt = event.prompt || '';
|
||||
|
||||
// Override does NOT suppress this injection (it just notes the override).
|
||||
const vocab = loadOverrideVocab();
|
||||
let override = null;
|
||||
for (const p of (vocab.phrases || [])) {
|
||||
if (!p.phrase) continue;
|
||||
if (userPrompt.toLowerCase().includes(p.phrase.toLowerCase())) { override = p; break; }
|
||||
}
|
||||
|
||||
// Wait up to ~600ms for router-prehook to write state.
|
||||
let state = readRouterState(sessionId);
|
||||
if (!state) {
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
for (let i = 0; i < 3 && !state; i++) {
|
||||
await sleep(200);
|
||||
state = readRouterState(sessionId);
|
||||
}
|
||||
}
|
||||
const classification = state && state.classification ? {
|
||||
task_type: state.classification.task_type,
|
||||
confidence: state.classification.confidence,
|
||||
recommended_node: state.classification.recommended_node || state.classification.recommendedNode,
|
||||
recommended_chain: state.classification.recommended_chain || state.classification.recommendedChain,
|
||||
} : null;
|
||||
|
||||
const flags = readRationalizationFlags(sessionId);
|
||||
|
||||
const reminder = buildReminder({ classification, recentFlags: flags, override });
|
||||
|
||||
process.stdout.write(JSON.stringify({
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'UserPromptSubmit',
|
||||
additionalContext: reminder,
|
||||
},
|
||||
}));
|
||||
process.exit(0);
|
||||
} catch {
|
||||
try { process.stdout.write('{}'); } catch { /* ignore */ }
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
const isCli = process.argv[1] && process.argv[1].replace(/\\/g, '/').endsWith('/enforce-prompt-injection.mjs');
|
||||
if (isCli) main();
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildReminder } from './enforce-prompt-injection.mjs';
|
||||
|
||||
describe('enforce-prompt-injection / buildReminder', () => {
|
||||
it('always includes the coverage-first-line rule', () => {
|
||||
const txt = buildReminder({ classification: null, recentFlags: [] });
|
||||
expect(txt).toMatch(/First line of your response MUST be/);
|
||||
expect(txt).toMatch(/coverage:\s*<channel>:<id>/);
|
||||
});
|
||||
|
||||
it('includes classifier output when present', () => {
|
||||
const txt = buildReminder({
|
||||
classification: { task_type: 'feature', confidence: 0.85, recommended_node: '#19', recommended_chain: 'L13' },
|
||||
recentFlags: [],
|
||||
});
|
||||
expect(txt).toMatch(/task_type=feature/);
|
||||
expect(txt).toMatch(/confidence=0\.85/);
|
||||
expect(txt).toMatch(/#19/);
|
||||
expect(txt).toMatch(/L13/);
|
||||
});
|
||||
|
||||
it('mentions plan requirement for feature/bugfix/refactor/cleanup', () => {
|
||||
for (const t of ['feature', 'bugfix', 'refactor', 'cleanup']) {
|
||||
const txt = buildReminder({
|
||||
classification: { task_type: t, confidence: 0.7 },
|
||||
recentFlags: [],
|
||||
});
|
||||
expect(txt).toMatch(/Plan required/);
|
||||
}
|
||||
});
|
||||
|
||||
it('omits plan requirement for conversation/question', () => {
|
||||
const txt = buildReminder({
|
||||
classification: { task_type: 'question', confidence: 0.9 },
|
||||
recentFlags: [],
|
||||
});
|
||||
expect(txt).not.toMatch(/Plan required/);
|
||||
});
|
||||
|
||||
it('surfaces recent rationalization flags (up to 3)', () => {
|
||||
const txt = buildReminder({
|
||||
classification: null,
|
||||
recentFlags: [
|
||||
{ kind: 'skipped-plan', evidence: 'too simple' },
|
||||
{ kind: 'single-coverage-drift', evidence: 'TDD coverage used for memory sync' },
|
||||
{ kind: 'weak-test', evidence: '1 expect' },
|
||||
{ kind: 'commit-without-tests', evidence: 'production edit without test' },
|
||||
],
|
||||
});
|
||||
expect(txt).toMatch(/Previous turn flagged/);
|
||||
// Last 3 should appear, first one should NOT
|
||||
expect(txt).toMatch(/single-coverage-drift/);
|
||||
expect(txt).toMatch(/weak-test/);
|
||||
expect(txt).toMatch(/commit-without-tests/);
|
||||
expect(txt).not.toMatch(/skipped-plan/);
|
||||
});
|
||||
|
||||
it('notes detected override phrase + suppressed rule keys', () => {
|
||||
const txt = buildReminder({
|
||||
classification: null,
|
||||
recentFlags: [],
|
||||
override: { phrase: 'срочно', suppresses: ['verify-before-push', 'tdd-gate'] },
|
||||
});
|
||||
expect(txt).toMatch(/Override phrase detected/);
|
||||
expect(txt).toMatch(/срочно/);
|
||||
expect(txt).toMatch(/verify-before-push/);
|
||||
});
|
||||
|
||||
it('lists override-vocabulary phrases for user reference', () => {
|
||||
const txt = buildReminder({ classification: null, recentFlags: [] });
|
||||
expect(txt).toMatch(/без скилов/);
|
||||
expect(txt).toMatch(/direct ok/);
|
||||
expect(txt).toMatch(/срочно/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Rule #10 — Rationalization audit (PostToolUse).
|
||||
*
|
||||
* Reads the last assistant text + nearby tool history. Detects rationalization
|
||||
* phrases and weak-test signals. Appends each flag to a JSONL file consumed by
|
||||
* Rule #1 injection on next prompt.
|
||||
*
|
||||
* NEVER blocks — soft visibility. Failure modes:
|
||||
* - skipped writing-plans for a feature task
|
||||
* - prod-code edit without matching test in same turn (despite TDD-gate
|
||||
* letting it through via override)
|
||||
* - assistant text contains rationalization phrases
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-25-enforce-hard-rules-design.md
|
||||
*/
|
||||
|
||||
import {
|
||||
readStdin,
|
||||
parseEventJson,
|
||||
readTranscript,
|
||||
lastAssistantText,
|
||||
turnToolUses,
|
||||
appendRationalizationFlag,
|
||||
exitDecision,
|
||||
isProductionCodePath,
|
||||
} from './enforce-hook-helpers.mjs';
|
||||
|
||||
const RATIONALIZATION_PHRASES = [
|
||||
'just this once',
|
||||
'пока без',
|
||||
'сейчас быстрее',
|
||||
'потом разберусь',
|
||||
'временно',
|
||||
'просто рационализация',
|
||||
"i'll come back to",
|
||||
'i will come back to',
|
||||
'we can skip',
|
||||
'rationalize',
|
||||
'без церемоний',
|
||||
'без скила сейчас',
|
||||
];
|
||||
|
||||
export function findRationalizationPhrases(text) {
|
||||
if (typeof text !== 'string') return [];
|
||||
const lo = text.toLowerCase();
|
||||
const hits = [];
|
||||
for (const p of RATIONALIZATION_PHRASES) {
|
||||
if (lo.includes(p)) hits.push(p);
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
|
||||
export function detectProdEditWithoutTest(toolUses) {
|
||||
// Look for Edit/Write on production code; check if any test edit accompanies it.
|
||||
const prodEdits = [];
|
||||
let hasTestEdit = false;
|
||||
for (const u of toolUses) {
|
||||
if (!['Edit', 'Write', 'MultiEdit'].includes(u.name)) continue;
|
||||
const p = (u.input && (u.input.file_path || u.input.notebook_path)) || '';
|
||||
if (/\.(test|spec)\.[a-z0-9]+$/i.test(p) || /Test\.php$/.test(p)) { hasTestEdit = true; continue; }
|
||||
if (isProductionCodePath(p)) prodEdits.push(p);
|
||||
}
|
||||
return prodEdits.length > 0 && !hasTestEdit ? prodEdits : [];
|
||||
}
|
||||
|
||||
export function audit(transcriptEntries) {
|
||||
const flags = [];
|
||||
const text = lastAssistantText(transcriptEntries);
|
||||
const phrases = findRationalizationPhrases(text);
|
||||
for (const p of phrases) flags.push({ kind: 'rationalization-phrase', evidence: p });
|
||||
|
||||
const toolUses = turnToolUses(transcriptEntries);
|
||||
const orphanProdEdits = detectProdEditWithoutTest(toolUses);
|
||||
for (const p of orphanProdEdits) flags.push({ kind: 'prod-edit-without-test', evidence: p });
|
||||
|
||||
// Weak commit-message: git commit with very short message
|
||||
for (const u of toolUses) {
|
||||
if (u.name !== 'Bash') continue;
|
||||
const cmd = (u.input && u.input.command) || '';
|
||||
if (!/git\s+commit/.test(cmd)) continue;
|
||||
const m = cmd.match(/-m\s+["']([^"']+)["']/);
|
||||
if (m && m[1].length < 12) {
|
||||
flags.push({ kind: 'weak-commit-message', evidence: m[1] });
|
||||
}
|
||||
}
|
||||
return flags;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
const raw = await readStdin();
|
||||
const event = parseEventJson(raw);
|
||||
const transcript = readTranscript(event.transcript_path);
|
||||
const flags = audit(transcript);
|
||||
for (const f of flags) appendRationalizationFlag(event.session_id, f.kind, f.evidence);
|
||||
exitDecision({ block: false });
|
||||
} catch {
|
||||
exitDecision({ block: false });
|
||||
}
|
||||
}
|
||||
|
||||
const isCli = process.argv[1] && process.argv[1].replace(/\\/g, '/').endsWith('/enforce-rationalization-audit.mjs');
|
||||
if (isCli) main();
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { findRationalizationPhrases, detectProdEditWithoutTest, audit } from './enforce-rationalization-audit.mjs';
|
||||
|
||||
describe('findRationalizationPhrases', () => {
|
||||
it('detects "just this once" in mixed case', () => {
|
||||
expect(findRationalizationPhrases('Hmm, Just This Once we will skip')).toContain('just this once');
|
||||
});
|
||||
it('detects "пока без" Russian', () => {
|
||||
expect(findRationalizationPhrases('сделаем пока без тестов')).toContain('пока без');
|
||||
});
|
||||
it('detects multiple phrases in one text', () => {
|
||||
const hits = findRationalizationPhrases('временно делаем потом разберусь');
|
||||
expect(hits.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
it('returns empty array on clean text', () => {
|
||||
expect(findRationalizationPhrases('coverage: skill:tdd')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectProdEditWithoutTest', () => {
|
||||
it('flags prod edit without any test edit in turn', () => {
|
||||
const uses = [{ name: 'Edit', input: { file_path: 'tools/foo.mjs' } }];
|
||||
expect(detectProdEditWithoutTest(uses)).toEqual(['tools/foo.mjs']);
|
||||
});
|
||||
it('does NOT flag when test also edited', () => {
|
||||
const uses = [
|
||||
{ name: 'Edit', input: { file_path: 'tools/foo.test.mjs' } },
|
||||
{ name: 'Edit', input: { file_path: 'tools/foo.mjs' } },
|
||||
];
|
||||
expect(detectProdEditWithoutTest(uses)).toEqual([]);
|
||||
});
|
||||
it('does NOT flag for non-prod paths', () => {
|
||||
expect(detectProdEditWithoutTest([{ name: 'Edit', input: { file_path: 'docs/x.md' } }])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('audit', () => {
|
||||
it('flags rationalization phrases in assistant text', () => {
|
||||
const entries = [
|
||||
{ message: { role: 'user', content: 'go' } },
|
||||
{ message: { role: 'assistant', content: [{ type: 'text', text: 'just this once без скила' }] } },
|
||||
];
|
||||
const flags = audit(entries);
|
||||
expect(flags.find((f) => f.kind === 'rationalization-phrase')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('flags prod-edit-without-test', () => {
|
||||
const entries = [
|
||||
{ message: { role: 'user', content: 'go' } },
|
||||
{ message: { role: 'assistant', content: [
|
||||
{ type: 'tool_use', id: 't1', name: 'Edit', input: { file_path: 'tools/foo.mjs' } },
|
||||
] } },
|
||||
];
|
||||
const flags = audit(entries);
|
||||
expect(flags.find((f) => f.kind === 'prod-edit-without-test')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('flags weak commit messages (<12 chars)', () => {
|
||||
const entries = [
|
||||
{ message: { role: 'user', content: 'go' } },
|
||||
{ message: { role: 'assistant', content: [
|
||||
{ type: 'tool_use', id: 't1', name: 'Bash', input: { command: 'git commit -m "fix"' } },
|
||||
] } },
|
||||
];
|
||||
const flags = audit(entries);
|
||||
expect(flags.find((f) => f.kind === 'weak-commit-message')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('returns no flags for clean turn', () => {
|
||||
const entries = [
|
||||
{ message: { role: 'user', content: 'go' } },
|
||||
{ message: { role: 'assistant', content: [
|
||||
{ type: 'text', text: 'coverage: skill:tdd\nworking properly' },
|
||||
{ type: 'tool_use', id: 't1', name: 'Edit', input: { file_path: 'tools/foo.test.mjs' } },
|
||||
{ type: 'tool_use', id: 't2', name: 'Edit', input: { file_path: 'tools/foo.mjs' } },
|
||||
] } },
|
||||
];
|
||||
expect(audit(entries)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,216 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Rule #3 + #6 — TDD-gate + writing-plans enforce for production code.
|
||||
*
|
||||
* PreToolUse on Edit / Write / MultiEdit. Pattern-matches file path against
|
||||
* production-code heuristic (isProductionCodePath). When matched:
|
||||
* 1. (#6) For feature/bugfix/refactor/cleanup classified tasks: require
|
||||
* Skill(superpowers:writing-plans) OR existing plan-file reference in
|
||||
* current turn.
|
||||
* 2. (#3) Require preceding test edit + a `Bash` run of vitest/pest with
|
||||
* a "fail" / "FAIL" / "Failed" indicator in its stdout (RED phase).
|
||||
*
|
||||
* Override: "срочно" / "быстрый коммит" / "ремонт инфраструктуры".
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-25-enforce-hard-rules-design.md
|
||||
*/
|
||||
|
||||
import {
|
||||
readStdin,
|
||||
parseEventJson,
|
||||
readTranscript,
|
||||
lastUserPromptText,
|
||||
lastTurnEntries,
|
||||
findOverride,
|
||||
logOverride,
|
||||
exitDecision,
|
||||
isProductionCodePath,
|
||||
readRouterState,
|
||||
} from './enforce-hook-helpers.mjs';
|
||||
|
||||
const RULE_KEY_TDD = 'tdd-gate';
|
||||
const RULE_KEY_PLAN = 'writing-plans-required';
|
||||
|
||||
/** Map a production path to expected test path patterns (heuristic). */
|
||||
function expectedTestPathMatchers(prodPath) {
|
||||
const n = String(prodPath || '').replace(/\\/g, '/');
|
||||
const matchers = [];
|
||||
// tools/foo.mjs → tools/foo.test.mjs / tools/foo.spec.mjs
|
||||
let m = n.match(/(.*\/)?([^/]+)\.mjs$/);
|
||||
if (m) {
|
||||
matchers.push(`${m[1] || ''}${m[2]}.test.mjs`);
|
||||
matchers.push(`${m[1] || ''}${m[2]}.spec.mjs`);
|
||||
}
|
||||
// app/app/Path/X.php → app/tests/**/XTest.php OR app/tests/**/X*.php
|
||||
m = n.match(/\/app\/app\/(.+)\/([^/]+)\.php$/);
|
||||
if (m) {
|
||||
matchers.push(`/app/tests/Unit/${m[2]}Test.php`);
|
||||
matchers.push(`/app/tests/Feature/${m[2]}Test.php`);
|
||||
// Loose containment
|
||||
matchers.push(`/app/tests/.+${m[2]}Test.php`);
|
||||
}
|
||||
// resources/js/views/X.vue → X.spec.ts / X.test.ts loose
|
||||
m = n.match(/\/resources\/js\/(.+\/)?([^/]+)\.(vue|ts|tsx|js)$/);
|
||||
if (m) {
|
||||
matchers.push(`/resources/js/${m[1] || ''}${m[2]}.spec.ts`);
|
||||
matchers.push(`/resources/js/${m[1] || ''}${m[2]}.test.ts`);
|
||||
matchers.push(`/resources/js/${m[1] || ''}__tests__/${m[2]}.spec.ts`);
|
||||
}
|
||||
return matchers;
|
||||
}
|
||||
|
||||
function hasMatchingTestEdit(turn, prodPath) {
|
||||
const matchers = expectedTestPathMatchers(prodPath);
|
||||
const basename = String(prodPath || '').replace(/\\/g, '/').split('/').pop().split('.')[0];
|
||||
for (const e of turn) {
|
||||
const c = e && e.message && e.message.content;
|
||||
if (!Array.isArray(c)) continue;
|
||||
for (const b of c) {
|
||||
if (!b || b.type !== 'tool_use') continue;
|
||||
if (!['Edit', 'Write', 'MultiEdit'].includes(b.name)) continue;
|
||||
const p = (b.input && (b.input.file_path || b.input.notebook_path) || '').replace(/\\/g, '/');
|
||||
if (!p) continue;
|
||||
// Check test-file pattern (loose contains-basename + test/spec)
|
||||
if (/\.(test|spec)\.[a-z0-9]+$/i.test(p) && p.includes(basename)) return true;
|
||||
// Check explicit matchers
|
||||
for (const m of matchers) {
|
||||
const mPattern = m.replace(/[.+]/g, '\\$&').replace(/\\\.\\\+/g, '.+');
|
||||
if (new RegExp(mPattern + '$').test(p)) return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasFailingTestRun(turn) {
|
||||
// Look for Bash tool_use followed by tool_result containing a failure indicator
|
||||
// OR PASS line with N failed > 0.
|
||||
const bashIds = new Set();
|
||||
for (const e of turn) {
|
||||
const c = e && e.message && e.message.content;
|
||||
if (!Array.isArray(c)) continue;
|
||||
for (const b of c) {
|
||||
if (b && b.type === 'tool_use' && b.name === 'Bash') {
|
||||
const cmd = (b.input && b.input.command) || '';
|
||||
if (/\b(vitest|pest|phpunit)\b/.test(cmd)) bashIds.add(b.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (bashIds.size === 0) return false;
|
||||
for (const e of turn) {
|
||||
const c = e && e.message && e.message.content;
|
||||
if (!Array.isArray(c)) continue;
|
||||
for (const b of c) {
|
||||
if (b && b.type === 'tool_result' && bashIds.has(b.tool_use_id)) {
|
||||
const txt = typeof b.content === 'string' ? b.content
|
||||
: Array.isArray(b.content) ? b.content.map((p) => p && p.text).filter(Boolean).join('\n') : '';
|
||||
if (/\b(fail|FAIL|Failed|×)\b/.test(txt)) return true;
|
||||
// Numeric: "Tests N failed | M passed" with N>0
|
||||
const m = txt.match(/Tests\s+(\d+)\s+failed/);
|
||||
if (m && Number(m[1]) > 0) return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasPlanIndicator(turn) {
|
||||
for (const e of turn) {
|
||||
const c = e && e.message && e.message.content;
|
||||
if (!Array.isArray(c)) continue;
|
||||
for (const b of c) {
|
||||
if (b && b.type === 'tool_use') {
|
||||
if (b.name === 'Skill' && b.input && /writing-plans/i.test(String(b.input.skill || ''))) return true;
|
||||
const p = (b.input && (b.input.file_path || b.input.notebook_path) || '');
|
||||
if (/docs\/superpowers\/plans\//i.test(p)) return true;
|
||||
// Also accept Read of a plan file (existing plan)
|
||||
if (b.name === 'Read' && /docs\/superpowers\/plans\//i.test(p)) return true;
|
||||
}
|
||||
if (b && b.type === 'text' && /docs\/superpowers\/plans\//.test(b.text || '')) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function decide({
|
||||
toolName, filePath, transcriptEntries, classification, override, overridePlan,
|
||||
}) {
|
||||
if (!['Edit', 'Write', 'MultiEdit'].includes(toolName)) return { block: false };
|
||||
if (!isProductionCodePath(filePath)) return { block: false };
|
||||
|
||||
const turn = lastTurnEntries(transcriptEntries);
|
||||
|
||||
// Rule #6 — plan requirement for feature/bugfix/refactor/cleanup.
|
||||
const taskType = classification && classification.task_type;
|
||||
if (!overridePlan && taskType && /^(feature|bugfix|refactor|cleanup)$/i.test(taskType)) {
|
||||
if (!hasPlanIndicator(turn)) {
|
||||
return {
|
||||
block: true,
|
||||
message: [
|
||||
`[enforce-tdd-gate] task_type="${taskType}" requires a plan before production-code edit.`,
|
||||
`Either invoke superpowers:writing-plans via Skill tool,`,
|
||||
`or reference an existing plan file (docs/superpowers/plans/...) in this turn first.`,
|
||||
``,
|
||||
`Override: "быстрый коммит" / "ремонт инфраструктуры" in your prompt.`,
|
||||
].join('\n'),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Rule #3 — TDD gate.
|
||||
if (override) return { block: false };
|
||||
const hasTest = hasMatchingTestEdit(turn, filePath);
|
||||
if (!hasTest) {
|
||||
return {
|
||||
block: true,
|
||||
message: [
|
||||
`[enforce-tdd-gate] Production code edit on "${filePath}" without preceding test edit.`,
|
||||
`Write the failing test FIRST in the corresponding *.test.mjs / *.spec.ts / *Test.php.`,
|
||||
`Then run vitest/pest to confirm RED, then return to this prod-code Edit.`,
|
||||
``,
|
||||
`Override: "срочно" / "быстрый коммит" / "ремонт инфраструктуры".`,
|
||||
].join('\n'),
|
||||
};
|
||||
}
|
||||
if (!hasFailingTestRun(turn)) {
|
||||
return {
|
||||
block: true,
|
||||
message: [
|
||||
`[enforce-tdd-gate] Test was edited but no vitest/pest run with RED output observed in this turn.`,
|
||||
`Run the test suite (vitest run <test-file> / composer test) to confirm RED before prod-code edit.`,
|
||||
``,
|
||||
`Override: "срочно" / "быстрый коммит" / "ремонт инфраструктуры".`,
|
||||
].join('\n'),
|
||||
};
|
||||
}
|
||||
return { block: false };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
const raw = await readStdin();
|
||||
const event = parseEventJson(raw);
|
||||
const toolName = event.tool_name || '';
|
||||
const filePath = (event.tool_input && (event.tool_input.file_path || event.tool_input.notebook_path)) || '';
|
||||
|
||||
const transcript = readTranscript(event.transcript_path);
|
||||
const userPrompt = lastUserPromptText(transcript);
|
||||
const override = findOverride(userPrompt, RULE_KEY_TDD);
|
||||
const overridePlan = findOverride(userPrompt, RULE_KEY_PLAN);
|
||||
if (override) logOverride(RULE_KEY_TDD, override, event.session_id);
|
||||
if (overridePlan) logOverride(RULE_KEY_PLAN, overridePlan, event.session_id);
|
||||
|
||||
const state = readRouterState(event.session_id);
|
||||
const classification = state && state.classification ? {
|
||||
task_type: state.classification.task_type,
|
||||
} : null;
|
||||
|
||||
const result = decide({ toolName, filePath, transcriptEntries: transcript, classification, override, overridePlan });
|
||||
exitDecision(result);
|
||||
} catch {
|
||||
exitDecision({ block: false });
|
||||
}
|
||||
}
|
||||
|
||||
const isCli = process.argv[1] && process.argv[1].replace(/\\/g, '/').endsWith('/enforce-tdd-gate.mjs');
|
||||
if (isCli) main();
|
||||
@@ -0,0 +1,164 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { decide } from './enforce-tdd-gate.mjs';
|
||||
|
||||
function userMsg(text) {
|
||||
return { message: { role: 'user', content: text } };
|
||||
}
|
||||
function assistantUses(uses) {
|
||||
return { message: { role: 'assistant', content: uses.map((u, i) => ({ type: 'tool_use', id: u.id || `t${i}`, name: u.name, input: u.input })) } };
|
||||
}
|
||||
function toolResults(results) {
|
||||
return { message: { role: 'user', content: results.map((r) => ({ type: 'tool_result', tool_use_id: r.id, content: r.content, is_error: r.is_error || false })) } };
|
||||
}
|
||||
|
||||
describe('enforce-tdd-gate / decide', () => {
|
||||
it('allows non-production paths', () => {
|
||||
const r = decide({
|
||||
toolName: 'Edit',
|
||||
filePath: 'docs/x.md',
|
||||
transcriptEntries: [],
|
||||
});
|
||||
expect(r.block).toBe(false);
|
||||
});
|
||||
|
||||
it('allows test files themselves', () => {
|
||||
const r = decide({
|
||||
toolName: 'Edit',
|
||||
filePath: 'tools/foo.test.mjs',
|
||||
transcriptEntries: [],
|
||||
});
|
||||
expect(r.block).toBe(false);
|
||||
});
|
||||
|
||||
it('blocks prod edit with no preceding test edit', () => {
|
||||
const r = decide({
|
||||
toolName: 'Edit',
|
||||
filePath: 'tools/foo.mjs',
|
||||
transcriptEntries: [userMsg('do it')],
|
||||
});
|
||||
expect(r.block).toBe(true);
|
||||
expect(r.message).toMatch(/without preceding test edit/);
|
||||
});
|
||||
|
||||
it('blocks when test edited but no vitest RED observed', () => {
|
||||
const r = decide({
|
||||
toolName: 'Edit',
|
||||
filePath: 'tools/foo.mjs',
|
||||
transcriptEntries: [
|
||||
userMsg('do it'),
|
||||
assistantUses([{ id: 't1', name: 'Edit', input: { file_path: 'tools/foo.test.mjs' } }]),
|
||||
],
|
||||
});
|
||||
expect(r.block).toBe(true);
|
||||
expect(r.message).toMatch(/no vitest.*RED/);
|
||||
});
|
||||
|
||||
it('allows after test edit + vitest RED', () => {
|
||||
const r = decide({
|
||||
toolName: 'Edit',
|
||||
filePath: 'tools/foo.mjs',
|
||||
transcriptEntries: [
|
||||
userMsg('do it'),
|
||||
assistantUses([
|
||||
{ id: 't1', name: 'Edit', input: { file_path: 'tools/foo.test.mjs' } },
|
||||
{ id: 't2', name: 'Bash', input: { command: 'npx vitest run tools/foo.test.mjs' } },
|
||||
]),
|
||||
toolResults([{ id: 't2', content: 'Tests 1 failed | 0 passed' }]),
|
||||
],
|
||||
});
|
||||
expect(r.block).toBe(false);
|
||||
});
|
||||
|
||||
it('allows when "fail" word in vitest stdout', () => {
|
||||
const r = decide({
|
||||
toolName: 'Edit',
|
||||
filePath: 'tools/foo.mjs',
|
||||
transcriptEntries: [
|
||||
userMsg('do it'),
|
||||
assistantUses([
|
||||
{ id: 't1', name: 'Write', input: { file_path: 'tools/foo.test.mjs' } },
|
||||
{ id: 't2', name: 'Bash', input: { command: 'npx vitest run tools/foo.test.mjs' } },
|
||||
]),
|
||||
toolResults([{ id: 't2', content: 'FAIL tools/foo.test.mjs' }]),
|
||||
],
|
||||
});
|
||||
expect(r.block).toBe(false);
|
||||
});
|
||||
|
||||
it('allows when override phrase present', () => {
|
||||
const r = decide({
|
||||
toolName: 'Edit',
|
||||
filePath: 'tools/foo.mjs',
|
||||
transcriptEntries: [userMsg('срочно надо')],
|
||||
override: { phrase: 'срочно', suppresses: ['tdd-gate'] },
|
||||
});
|
||||
expect(r.block).toBe(false);
|
||||
});
|
||||
|
||||
it('blocks feature-classified prod edit without plan indicator', () => {
|
||||
const r = decide({
|
||||
toolName: 'Edit',
|
||||
filePath: 'tools/foo.mjs',
|
||||
transcriptEntries: [
|
||||
userMsg('добавь фичу X'),
|
||||
assistantUses([{ id: 't1', name: 'Edit', input: { file_path: 'tools/foo.test.mjs' } }]),
|
||||
],
|
||||
classification: { task_type: 'feature' },
|
||||
});
|
||||
expect(r.block).toBe(true);
|
||||
expect(r.message).toMatch(/requires a plan/);
|
||||
});
|
||||
|
||||
it('allows feature edit when Skill(superpowers:writing-plans) invoked', () => {
|
||||
const r = decide({
|
||||
toolName: 'Edit',
|
||||
filePath: 'tools/foo.mjs',
|
||||
transcriptEntries: [
|
||||
userMsg('добавь фичу X'),
|
||||
assistantUses([
|
||||
{ id: 't0', name: 'Skill', input: { skill: 'superpowers:writing-plans' } },
|
||||
{ id: 't1', name: 'Edit', input: { file_path: 'tools/foo.test.mjs' } },
|
||||
{ id: 't2', name: 'Bash', input: { command: 'npx vitest run tools/foo.test.mjs' } },
|
||||
]),
|
||||
toolResults([{ id: 't2', content: 'Tests 1 failed' }]),
|
||||
],
|
||||
classification: { task_type: 'feature' },
|
||||
});
|
||||
expect(r.block).toBe(false);
|
||||
});
|
||||
|
||||
it('allows feature edit when plan file is referenced', () => {
|
||||
const r = decide({
|
||||
toolName: 'Edit',
|
||||
filePath: 'tools/foo.mjs',
|
||||
transcriptEntries: [
|
||||
userMsg('добавь фичу X'),
|
||||
assistantUses([
|
||||
{ id: 't0', name: 'Read', input: { file_path: 'docs/superpowers/plans/2026-05-26-foo.md' } },
|
||||
{ id: 't1', name: 'Edit', input: { file_path: 'tools/foo.test.mjs' } },
|
||||
{ id: 't2', name: 'Bash', input: { command: 'npx vitest run tools/foo.test.mjs' } },
|
||||
]),
|
||||
toolResults([{ id: 't2', content: 'Tests 1 failed' }]),
|
||||
],
|
||||
classification: { task_type: 'feature' },
|
||||
});
|
||||
expect(r.block).toBe(false);
|
||||
});
|
||||
|
||||
it('does NOT require plan for non-feature task types', () => {
|
||||
const r = decide({
|
||||
toolName: 'Edit',
|
||||
filePath: 'tools/foo.mjs',
|
||||
transcriptEntries: [
|
||||
userMsg('chore'),
|
||||
assistantUses([
|
||||
{ id: 't1', name: 'Edit', input: { file_path: 'tools/foo.test.mjs' } },
|
||||
{ id: 't2', name: 'Bash', input: { command: 'npx vitest run tools/foo.test.mjs' } },
|
||||
]),
|
||||
toolResults([{ id: 't2', content: 'Tests 1 failed' }]),
|
||||
],
|
||||
classification: { task_type: 'cleanup-but-not-strictly' },
|
||||
});
|
||||
expect(r.block).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Rule #4 — Require fresh verification artifact before git commit / push.
|
||||
*
|
||||
* PreToolUse on Bash. If command is git commit / push, check the
|
||||
* verify-pass-<session>.json sentinel:
|
||||
* - missing → block
|
||||
* - age > MAX_AGE_SEC → block ("stale")
|
||||
* - result !== 'pass' → block ("last run failed")
|
||||
*
|
||||
* Override phrases: `срочно` / `быстрый коммит` / `ремонт инфраструктуры`.
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-25-enforce-hard-rules-design.md
|
||||
*/
|
||||
|
||||
import {
|
||||
readStdin,
|
||||
parseEventJson,
|
||||
readTranscript,
|
||||
lastUserPromptText,
|
||||
findOverride,
|
||||
logOverride,
|
||||
exitDecision,
|
||||
detectGitCommandKind,
|
||||
readSentinel,
|
||||
sentinelAgeSec,
|
||||
} from './enforce-hook-helpers.mjs';
|
||||
|
||||
const RULE_KEY_COMMIT = 'verify-before-commit';
|
||||
const RULE_KEY_PUSH = 'verify-before-push';
|
||||
const MAX_AGE_SEC = 30 * 60; // 30 min
|
||||
|
||||
export function decide({ toolName, command, sentinel, sentinelAge, override }) {
|
||||
if (toolName !== 'Bash' || typeof command !== 'string') return { block: false };
|
||||
const kind = detectGitCommandKind(command);
|
||||
if (kind !== 'commit' && kind !== 'push') return { block: false };
|
||||
if (override) return { block: false };
|
||||
|
||||
if (!sentinel) {
|
||||
return {
|
||||
block: true,
|
||||
message: [
|
||||
`[enforce-verify-before-push] No verification artifact found.`,
|
||||
`Run a full test suite first (vitest run / composer test) before \`git ${kind}\`.`,
|
||||
``,
|
||||
`Override: "срочно" / "быстрый коммит" / "ремонт инфраструктуры" in your prompt.`,
|
||||
].join('\n'),
|
||||
};
|
||||
}
|
||||
if (sentinel.result !== 'pass') {
|
||||
return {
|
||||
block: true,
|
||||
message: [
|
||||
`[enforce-verify-before-push] Last verification FAILED (result=${sentinel.result}, exit=${sentinel.exit_code}).`,
|
||||
`Tests: ${sentinel.tests_passed}/${sentinel.tests_total} passed, ${sentinel.tests_failed} failed.`,
|
||||
`Re-run the suite and address failures before \`git ${kind}\`.`,
|
||||
].join('\n'),
|
||||
};
|
||||
}
|
||||
if (sentinelAge !== null && sentinelAge > MAX_AGE_SEC) {
|
||||
return {
|
||||
block: true,
|
||||
message: [
|
||||
`[enforce-verify-before-push] Verification artifact is stale (age ${sentinelAge}s > ${MAX_AGE_SEC}s).`,
|
||||
`Re-run the full test suite before \`git ${kind}\`.`,
|
||||
].join('\n'),
|
||||
};
|
||||
}
|
||||
return { block: false };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
const raw = await readStdin();
|
||||
const event = parseEventJson(raw);
|
||||
const toolName = event.tool_name || '';
|
||||
const command = (event.tool_input && event.tool_input.command) || '';
|
||||
|
||||
const transcript = readTranscript(event.transcript_path);
|
||||
const userPrompt = lastUserPromptText(transcript);
|
||||
const kind = detectGitCommandKind(command);
|
||||
const ruleKey = kind === 'commit' ? RULE_KEY_COMMIT : RULE_KEY_PUSH;
|
||||
const override = findOverride(userPrompt, ruleKey);
|
||||
if (override) logOverride(ruleKey, override, event.session_id);
|
||||
|
||||
const sentinel = readSentinel('verify-pass', event.session_id);
|
||||
const age = sentinelAgeSec('verify-pass', event.session_id);
|
||||
|
||||
const result = decide({ toolName, command, sentinel, sentinelAge: age, override });
|
||||
exitDecision(result);
|
||||
} catch {
|
||||
exitDecision({ block: false });
|
||||
}
|
||||
}
|
||||
|
||||
const isCli = process.argv[1] && process.argv[1].replace(/\\/g, '/').endsWith('/enforce-verify-before-push.mjs');
|
||||
if (isCli) main();
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { decide } from './enforce-verify-before-push.mjs';
|
||||
import { decideRecord, extractTestMetrics } from './enforce-verify-record.mjs';
|
||||
|
||||
describe('enforce-verify-record / decideRecord', () => {
|
||||
it('returns null for non-Bash', () => {
|
||||
expect(decideRecord({ toolName: 'Edit', command: 'foo' })).toBeNull();
|
||||
});
|
||||
it('returns null for non-test command', () => {
|
||||
expect(decideRecord({ toolName: 'Bash', command: 'git status', exitCode: 0, stdout: '' })).toBeNull();
|
||||
});
|
||||
it('returns null for narrow vitest (specific test file)', () => {
|
||||
expect(decideRecord({ toolName: 'Bash', command: 'npx vitest run tools/foo.test.mjs', exitCode: 0, stdout: '' })).toBeNull();
|
||||
});
|
||||
it('records PASS on full vitest run with all-passed summary', () => {
|
||||
const rec = decideRecord({
|
||||
toolName: 'Bash', command: 'npx vitest run', exitCode: 0,
|
||||
stdout: 'Tests 3708 passed (3708)',
|
||||
});
|
||||
expect(rec.result).toBe('pass');
|
||||
expect(rec.tests_total).toBe(3708);
|
||||
expect(rec.tests_passed).toBe(3708);
|
||||
});
|
||||
it('records FAIL on full vitest run with failed summary', () => {
|
||||
const rec = decideRecord({
|
||||
toolName: 'Bash', command: 'npx vitest run', exitCode: 1,
|
||||
stdout: 'Tests 3 failed | 600 passed (603)',
|
||||
});
|
||||
expect(rec.result).toBe('fail');
|
||||
expect(rec.tests_failed).toBe(3);
|
||||
});
|
||||
|
||||
it('records PASS when exit=1 but tests_failed=0 (infra file-load failures)', () => {
|
||||
// E.g. worktree CRLF copies of test files crash to load → exit code 1
|
||||
// but all actual tests passed.
|
||||
const rec = decideRecord({
|
||||
toolName: 'Bash', command: 'npx vitest run', exitCode: 1,
|
||||
stdout: 'Test Files 95 failed | 411 passed (506)\n Tests 8091 passed (8091)',
|
||||
});
|
||||
expect(rec.result).toBe('pass');
|
||||
});
|
||||
it('records pest', () => {
|
||||
const rec = decideRecord({
|
||||
toolName: 'Bash', command: 'composer test', exitCode: 0,
|
||||
stdout: 'Tests: 742 passed (1908 assertions)',
|
||||
});
|
||||
expect(rec.result).toBe('pass');
|
||||
});
|
||||
});
|
||||
|
||||
describe('enforce-verify-record / extractTestMetrics', () => {
|
||||
it('parses vitest all-passed', () => {
|
||||
expect(extractTestMetrics('Tests 3708 passed (3708)')).toMatchObject({
|
||||
tests_passed: 3708, tests_total: 3708, tests_failed: 0,
|
||||
});
|
||||
});
|
||||
it('parses vitest mixed failure', () => {
|
||||
expect(extractTestMetrics('Tests 1 failed | 631 passed (632)')).toMatchObject({
|
||||
tests_failed: 1, tests_passed: 631, tests_total: 632,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('enforce-verify-before-push / decide', () => {
|
||||
it('allows non-Bash', () => {
|
||||
expect(decide({ toolName: 'Edit', command: '' }).block).toBe(false);
|
||||
});
|
||||
it('allows non-git Bash', () => {
|
||||
expect(decide({ toolName: 'Bash', command: 'ls -la' }).block).toBe(false);
|
||||
});
|
||||
it('blocks git commit without sentinel', () => {
|
||||
const r = decide({ toolName: 'Bash', command: 'git commit -m "x"' });
|
||||
expect(r.block).toBe(true);
|
||||
expect(r.message).toMatch(/No verification/);
|
||||
});
|
||||
it('blocks git push without sentinel', () => {
|
||||
expect(decide({ toolName: 'Bash', command: 'git push origin main' }).block).toBe(true);
|
||||
});
|
||||
it('blocks when sentinel result=fail', () => {
|
||||
const r = decide({
|
||||
toolName: 'Bash', command: 'git commit -m "x"',
|
||||
sentinel: { result: 'fail', exit_code: 1, tests_passed: 600, tests_total: 603, tests_failed: 3 },
|
||||
sentinelAge: 60,
|
||||
});
|
||||
expect(r.block).toBe(true);
|
||||
expect(r.message).toMatch(/FAILED/);
|
||||
});
|
||||
it('blocks when sentinel is stale', () => {
|
||||
const r = decide({
|
||||
toolName: 'Bash', command: 'git commit -m "x"',
|
||||
sentinel: { result: 'pass' },
|
||||
sentinelAge: 60 * 60, // 1 hour > 30 min
|
||||
});
|
||||
expect(r.block).toBe(true);
|
||||
expect(r.message).toMatch(/stale/);
|
||||
});
|
||||
it('allows when sentinel is fresh + pass', () => {
|
||||
const r = decide({
|
||||
toolName: 'Bash', command: 'git commit -m "x"',
|
||||
sentinel: { result: 'pass' },
|
||||
sentinelAge: 120,
|
||||
});
|
||||
expect(r.block).toBe(false);
|
||||
});
|
||||
it('allows when override phrase present', () => {
|
||||
const r = decide({
|
||||
toolName: 'Bash', command: 'git push',
|
||||
sentinel: null,
|
||||
override: { phrase: 'срочно', suppresses: ['verify-before-push'] },
|
||||
});
|
||||
expect(r.block).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Rule #4 (companion) — Record verification artifact.
|
||||
*
|
||||
* PostToolUse on Bash. If the command was a full project test run AND it
|
||||
* passed (exit 0 + recognisable PASS marker in stdout), write a sentinel
|
||||
* `~/.claude/runtime/verify-pass-<session>.json` consumed by the
|
||||
* enforce-verify-before-push gate.
|
||||
*
|
||||
* Failed runs ALSO record a sentinel with result=fail — so the gate can
|
||||
* distinguish "never ran" from "ran and failed".
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-25-enforce-hard-rules-design.md
|
||||
*/
|
||||
|
||||
import {
|
||||
readStdin,
|
||||
parseEventJson,
|
||||
writeSentinel,
|
||||
exitDecision,
|
||||
detectFullTestRun,
|
||||
} from './enforce-hook-helpers.mjs';
|
||||
|
||||
export function extractTestMetrics(stdout) {
|
||||
const out = { tests_total: null, tests_passed: null, tests_failed: null };
|
||||
if (typeof stdout !== 'string') return out;
|
||||
// vitest summary lines: "Tests 3708 passed (3708)" or "Tests N failed | M passed (TOTAL)"
|
||||
let m = stdout.match(/Tests\s+(\d+)\s+passed\s*\((\d+)\)/);
|
||||
if (m) { out.tests_passed = +m[1]; out.tests_total = +m[2]; out.tests_failed = 0; return out; }
|
||||
m = stdout.match(/Tests\s+(\d+)\s+failed\s*\|\s*(\d+)\s+passed\s*\((\d+)\)/);
|
||||
if (m) { out.tests_failed = +m[1]; out.tests_passed = +m[2]; out.tests_total = +m[3]; return out; }
|
||||
// Pest: "Tests: 742 passed (1908 assertions)"
|
||||
m = stdout.match(/Tests:\s+(\d+)\s+passed/);
|
||||
if (m) { out.tests_passed = +m[1]; out.tests_total = +m[1]; out.tests_failed = 0; return out; }
|
||||
return out;
|
||||
}
|
||||
|
||||
export function decideRecord({ toolName, command, exitCode, stdout }) {
|
||||
if (toolName !== 'Bash') return null;
|
||||
const kind = detectFullTestRun(command);
|
||||
if (!kind) return null;
|
||||
const metrics = extractTestMetrics(stdout || '');
|
||||
// PASS criteria — actual test outcomes drive verdict, not exit code:
|
||||
// - tests_failed parseable AND zero (e.g., "Tests 8091 passed (8091)"
|
||||
// or "Tests 0 failed | 8091 passed"). Exit code may still be 1 if
|
||||
// test FILES failed to load (infra failures like worktree CRLF or
|
||||
// ruflo dormant copies) — those don't count.
|
||||
// - tests_failed unparseable BUT exit code 0 AND tests_passed > 0
|
||||
// (legacy vitest output format).
|
||||
const passed = (metrics.tests_failed !== null && metrics.tests_failed === 0 && metrics.tests_passed > 0)
|
||||
|| (exitCode === 0 && metrics.tests_passed && metrics.tests_failed === null);
|
||||
return {
|
||||
command_kind: kind,
|
||||
command: String(command).slice(0, 200),
|
||||
exit_code: exitCode,
|
||||
result: passed ? 'pass' : 'fail',
|
||||
tests_total: metrics.tests_total,
|
||||
tests_passed: metrics.tests_passed,
|
||||
tests_failed: metrics.tests_failed,
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
const raw = await readStdin();
|
||||
const event = parseEventJson(raw);
|
||||
const toolName = event.tool_name || '';
|
||||
const command = (event.tool_input && event.tool_input.command) || '';
|
||||
const resp = event.tool_response || {};
|
||||
const exitCode = typeof resp.exitCode === 'number' ? resp.exitCode : (typeof resp.exit_code === 'number' ? resp.exit_code : null);
|
||||
const stdout = typeof resp.stdout === 'string' ? resp.stdout : '';
|
||||
|
||||
const record = decideRecord({ toolName, command, exitCode, stdout });
|
||||
if (record) writeSentinel('verify-pass', event.session_id, record);
|
||||
exitDecision({ block: false });
|
||||
} catch {
|
||||
exitDecision({ block: false });
|
||||
}
|
||||
}
|
||||
|
||||
const isCli = process.argv[1] && process.argv[1].replace(/\\/g, '/').endsWith('/enforce-verify-record.mjs');
|
||||
if (isCli) main();
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Observer episode embedding index (Pass 4 of project-brain-factor-analysis-4passes).
|
||||
*
|
||||
* Pure module: given a list of episodes carrying `prompt_embedding_base64` and a
|
||||
* resolved `_inferredOutcome`, build an in-memory index, find top-k cosine
|
||||
* neighbours for a target embedding, and report the majority outcome family
|
||||
* (success / retry / failure / mixed / no_neighbors).
|
||||
*
|
||||
* Embeddings produced by router-embedding.mjs are mean-pooled AND L2-normalized,
|
||||
* so cosine similarity collapses to a plain dot product. We still defend the
|
||||
* generic formula (denominator) here for robustness against legacy / hand-crafted
|
||||
* test vectors.
|
||||
*
|
||||
* Security Guidance #40: pure parsing — no exec/execSync.
|
||||
*/
|
||||
import { Buffer } from 'buffer';
|
||||
import { decodeBase64 } from './router-embedding.mjs';
|
||||
|
||||
const OUTCOME_TO_FAMILY = {
|
||||
success: 'success',
|
||||
soft_success: 'success',
|
||||
rework: 'retry',
|
||||
blocked: 'failure',
|
||||
partial: 'failure',
|
||||
};
|
||||
|
||||
export function mapOutcomeToFamily(outcome) {
|
||||
if (!outcome || typeof outcome !== 'string') return null;
|
||||
return OUTCOME_TO_FAMILY[outcome] || null;
|
||||
}
|
||||
|
||||
export function cosineSimilarity(a, b) {
|
||||
if (!a || !b) return 0;
|
||||
if (a.length === 0 || b.length === 0) return 0;
|
||||
if (a.length !== b.length) return 0;
|
||||
let dot = 0;
|
||||
let normA = 0;
|
||||
let normB = 0;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
dot += a[i] * b[i];
|
||||
normA += a[i] * a[i];
|
||||
normB += b[i] * b[i];
|
||||
}
|
||||
if (normA === 0 || normB === 0) return 0;
|
||||
return dot / (Math.sqrt(normA) * Math.sqrt(normB));
|
||||
}
|
||||
|
||||
function safeDecode(b64) {
|
||||
if (!b64 || typeof b64 !== 'string') return null;
|
||||
try {
|
||||
// Node's Buffer.from('garbage', 'base64') silently strips invalid chars and
|
||||
// truncates — won't throw on 'not-base64!!!' style input. Guard explicitly
|
||||
// by checking the byte length is a positive multiple of 4 (Float32 width).
|
||||
const buf = Buffer.from(b64, 'base64');
|
||||
if (buf.byteLength === 0 || buf.byteLength % 4 !== 0) return null;
|
||||
const v = decodeBase64(b64);
|
||||
if (!(v instanceof Float32Array) || v.length === 0) return null;
|
||||
for (let i = 0; i < v.length; i++) if (!Number.isFinite(v[i])) return null;
|
||||
return v;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Episode dedupe key — identifies a single turn uniquely. In real episodes
|
||||
* `task_id` is the SESSION id (shared across turns), so task_id alone is not
|
||||
* a turn identifier. Pairing with started_at gives the same key shape that
|
||||
* dedupeEpisodes uses in the analyzer.
|
||||
*/
|
||||
export function episodeKey(ep) {
|
||||
if (!ep) return '';
|
||||
return `${ep.task_id || ''}|${(ep.timestamps || {}).started_at || ''}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an index from episodes carrying a base64 embedding AND a resolved
|
||||
* outcome family. Episodes lacking either are silently skipped — they
|
||||
* cannot teach the neighbour lookup anything.
|
||||
*/
|
||||
export function buildIndex(episodes) {
|
||||
const idx = [];
|
||||
for (const ep of episodes || []) {
|
||||
const family = mapOutcomeToFamily(ep && ep._inferredOutcome);
|
||||
if (!family) continue;
|
||||
const emb = safeDecode(ep && ep.prompt_embedding_base64);
|
||||
if (!emb) continue;
|
||||
idx.push({
|
||||
task_id: ep.task_id || null,
|
||||
started_at: (ep.timestamps || {}).started_at || null,
|
||||
key: episodeKey(ep),
|
||||
family,
|
||||
embedding: emb,
|
||||
});
|
||||
}
|
||||
return idx;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the top-k index entries by cosine similarity to `target`, in
|
||||
* descending order. Self-exclusion is by composite key (task_id|started_at)
|
||||
* since task_id alone is the session id (shared across turns). Legacy
|
||||
* `excludeTaskId` option kept for callers that still pass task-unique ids;
|
||||
* `excludeKey` overrides it. Empty / null inputs → [].
|
||||
*/
|
||||
export function findNearestNeighbors(target, index, k, options = {}) {
|
||||
if (!target || !(target instanceof Float32Array) || target.length === 0) return [];
|
||||
if (!Array.isArray(index) || index.length === 0) return [];
|
||||
const excludeKey = options.excludeKey || null;
|
||||
const excludeTaskId = options.excludeTaskId || null;
|
||||
const scored = [];
|
||||
for (const entry of index) {
|
||||
if (excludeKey && entry.key === excludeKey) continue;
|
||||
if (excludeTaskId && entry.task_id === excludeTaskId && !excludeKey) continue;
|
||||
scored.push({ ...entry, similarity: cosineSimilarity(target, entry.embedding) });
|
||||
}
|
||||
scored.sort((a, b) => b.similarity - a.similarity);
|
||||
return scored.slice(0, k);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the dominant family across `neighbors`, or 'mixed' on a tie at the
|
||||
* top, or 'no_neighbors' on empty input. The 4 known families are
|
||||
* success / retry / failure (plus the synthetic mixed / no_neighbors).
|
||||
*/
|
||||
export function majorityOutcome(neighbors) {
|
||||
if (!Array.isArray(neighbors) || neighbors.length === 0) return 'no_neighbors';
|
||||
const counts = {};
|
||||
for (const n of neighbors) {
|
||||
const f = n && n.family;
|
||||
if (!f) continue;
|
||||
counts[f] = (counts[f] || 0) + 1;
|
||||
}
|
||||
const entries = Object.entries(counts);
|
||||
if (entries.length === 0) return 'no_neighbors';
|
||||
let maxN = 0;
|
||||
for (const [, n] of entries) if (n > maxN) maxN = n;
|
||||
const winners = entries.filter(([, n]) => n === maxN);
|
||||
if (winners.length > 1) return 'mixed';
|
||||
return winners[0][0];
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { encodeBase64 } from './router-embedding.mjs';
|
||||
import {
|
||||
cosineSimilarity,
|
||||
buildIndex,
|
||||
findNearestNeighbors,
|
||||
majorityOutcome,
|
||||
mapOutcomeToFamily,
|
||||
} from './observer-embedding-index.mjs';
|
||||
|
||||
// Helpers — build a base64-encoded Float32Array embedding from a plain array.
|
||||
function emb(arr) {
|
||||
return encodeBase64(new Float32Array(arr));
|
||||
}
|
||||
|
||||
describe('cosineSimilarity', () => {
|
||||
it('returns 1 for identical unit vectors', () => {
|
||||
const a = new Float32Array([1, 0, 0, 0]);
|
||||
expect(cosineSimilarity(a, a)).toBeCloseTo(1, 6);
|
||||
});
|
||||
it('returns 0 for orthogonal vectors', () => {
|
||||
const a = new Float32Array([1, 0, 0, 0]);
|
||||
const b = new Float32Array([0, 1, 0, 0]);
|
||||
expect(cosineSimilarity(a, b)).toBeCloseTo(0, 6);
|
||||
});
|
||||
it('returns negative for opposed vectors', () => {
|
||||
const a = new Float32Array([1, 0, 0, 0]);
|
||||
const b = new Float32Array([-1, 0, 0, 0]);
|
||||
expect(cosineSimilarity(a, b)).toBeCloseTo(-1, 6);
|
||||
});
|
||||
it('handles unequal dimensions by returning 0 (guard against malformed input)', () => {
|
||||
expect(cosineSimilarity(new Float32Array([1, 0]), new Float32Array([1, 0, 0]))).toBe(0);
|
||||
});
|
||||
it('returns 0 if either side is null / empty', () => {
|
||||
expect(cosineSimilarity(null, new Float32Array([1]))).toBe(0);
|
||||
expect(cosineSimilarity(new Float32Array([1]), null)).toBe(0);
|
||||
expect(cosineSimilarity(new Float32Array([]), new Float32Array([]))).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mapOutcomeToFamily', () => {
|
||||
it('maps success / soft_success to "success"', () => {
|
||||
expect(mapOutcomeToFamily('success')).toBe('success');
|
||||
expect(mapOutcomeToFamily('soft_success')).toBe('success');
|
||||
});
|
||||
it('maps rework to "retry"', () => {
|
||||
expect(mapOutcomeToFamily('rework')).toBe('retry');
|
||||
});
|
||||
it('maps blocked / partial to "failure"', () => {
|
||||
expect(mapOutcomeToFamily('blocked')).toBe('failure');
|
||||
expect(mapOutcomeToFamily('partial')).toBe('failure');
|
||||
});
|
||||
it('returns null for unknown / unresolved outcomes', () => {
|
||||
expect(mapOutcomeToFamily('unknown')).toBeNull();
|
||||
expect(mapOutcomeToFamily(null)).toBeNull();
|
||||
expect(mapOutcomeToFamily('')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildIndex', () => {
|
||||
it('includes episodes with a base64 embedding AND a resolved outcome', () => {
|
||||
const eps = [
|
||||
{ task_id: 'a', timestamps: { started_at: '2026-05-25T10:00:00Z' }, prompt_embedding_base64: emb([1, 0, 0, 0]), _inferredOutcome: 'success' },
|
||||
{ task_id: 'b', timestamps: { started_at: '2026-05-25T11:00:00Z' }, prompt_embedding_base64: emb([0, 1, 0, 0]), _inferredOutcome: 'rework' },
|
||||
];
|
||||
const idx = buildIndex(eps);
|
||||
expect(idx).toHaveLength(2);
|
||||
expect(idx[0].task_id).toBe('a');
|
||||
expect(idx[0].family).toBe('success');
|
||||
expect(idx[1].family).toBe('retry');
|
||||
expect(idx[0].embedding).toBeInstanceOf(Float32Array);
|
||||
});
|
||||
|
||||
it('skips episodes without an embedding', () => {
|
||||
const eps = [
|
||||
{ task_id: 'a', prompt_embedding_base64: null, _inferredOutcome: 'success' },
|
||||
{ task_id: 'b', prompt_embedding_base64: emb([1, 0, 0, 0]), _inferredOutcome: 'success' },
|
||||
];
|
||||
expect(buildIndex(eps)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('skips episodes with unresolved outcome (unknown / null)', () => {
|
||||
const eps = [
|
||||
{ task_id: 'a', prompt_embedding_base64: emb([1, 0, 0, 0]), _inferredOutcome: 'unknown' },
|
||||
{ task_id: 'b', prompt_embedding_base64: emb([0, 1, 0, 0]), _inferredOutcome: 'success' },
|
||||
];
|
||||
expect(buildIndex(eps)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('skips episodes with broken / non-decodable embedding', () => {
|
||||
const eps = [
|
||||
{ task_id: 'a', prompt_embedding_base64: 'not-base64!!!', _inferredOutcome: 'success' },
|
||||
{ task_id: 'b', prompt_embedding_base64: emb([1, 0, 0, 0]), _inferredOutcome: 'success' },
|
||||
];
|
||||
expect(buildIndex(eps)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findNearestNeighbors', () => {
|
||||
const idx = [
|
||||
{ task_id: 'a', family: 'success', embedding: new Float32Array([1, 0, 0, 0]) },
|
||||
{ task_id: 'b', family: 'success', embedding: new Float32Array([0.9, 0.4, 0, 0]) },
|
||||
{ task_id: 'c', family: 'retry', embedding: new Float32Array([0, 1, 0, 0]) },
|
||||
{ task_id: 'd', family: 'failure', embedding: new Float32Array([0, 0, 1, 0]) },
|
||||
{ task_id: 'e', family: 'success', embedding: new Float32Array([0.7, 0.7, 0, 0]) },
|
||||
];
|
||||
|
||||
it('returns top-k by cosine similarity, highest first', () => {
|
||||
const target = new Float32Array([1, 0, 0, 0]);
|
||||
const nn = findNearestNeighbors(target, idx, 3);
|
||||
expect(nn).toHaveLength(3);
|
||||
expect(nn[0].task_id).toBe('a'); // exact match
|
||||
expect(nn[0].similarity).toBeCloseTo(1, 6);
|
||||
expect(['b', 'e']).toContain(nn[1].task_id); // close to e1
|
||||
expect(nn[2].similarity).toBeLessThan(nn[1].similarity + 1e-6);
|
||||
});
|
||||
|
||||
it('handles k larger than index size (returns all)', () => {
|
||||
const nn = findNearestNeighbors(new Float32Array([1, 0, 0, 0]), idx, 100);
|
||||
expect(nn.length).toBe(idx.length);
|
||||
});
|
||||
|
||||
it('returns empty array if target is null / index empty', () => {
|
||||
expect(findNearestNeighbors(null, idx, 3)).toEqual([]);
|
||||
expect(findNearestNeighbors(new Float32Array([1, 0, 0, 0]), [], 3)).toEqual([]);
|
||||
});
|
||||
|
||||
it('excludes a self-reference when excludeTaskId is passed', () => {
|
||||
const target = new Float32Array([1, 0, 0, 0]);
|
||||
const nn = findNearestNeighbors(target, idx, 3, { excludeTaskId: 'a' });
|
||||
expect(nn.find((n) => n.task_id === 'a')).toBeUndefined();
|
||||
expect(nn).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('majorityOutcome', () => {
|
||||
it('returns the dominant family when one wins outright', () => {
|
||||
expect(majorityOutcome([{ family: 'success' }, { family: 'success' }, { family: 'retry' }])).toBe('success');
|
||||
});
|
||||
|
||||
it('returns "mixed" on a tie at the top', () => {
|
||||
expect(majorityOutcome([{ family: 'success' }, { family: 'retry' }])).toBe('mixed');
|
||||
expect(majorityOutcome([{ family: 'success' }, { family: 'retry' }, { family: 'failure' }])).toBe('mixed');
|
||||
});
|
||||
|
||||
it('returns "no_neighbors" on empty input', () => {
|
||||
expect(majorityOutcome([])).toBe('no_neighbors');
|
||||
expect(majorityOutcome(null)).toBe('no_neighbors');
|
||||
});
|
||||
});
|
||||
|
||||
// Analyzer integration covered separately in brain-retro-analyzer.test.mjs
|
||||
// (similar_past_outcome_majority axis lands via analyze()).
|
||||
@@ -92,8 +92,11 @@ export function readRuntimeFlag(name, { homedir, fsImpl } = {}) {
|
||||
if (!fs.existsSync(filePath)) return 'off';
|
||||
const raw = fs.readFileSync(filePath, 'utf-8');
|
||||
const parsed = JSON.parse(raw);
|
||||
if (typeof parsed.value !== 'string') return 'off';
|
||||
return parsed.value;
|
||||
// Runtime flag files use `mode` (canonical, see all ~/.claude/runtime/*-mode.json);
|
||||
// `value` retained as legacy alias to keep existing test fixtures working.
|
||||
const val = parsed.mode ?? parsed.value;
|
||||
if (typeof val !== 'string') return 'off';
|
||||
return val;
|
||||
} catch {
|
||||
return 'off';
|
||||
}
|
||||
|
||||
@@ -248,12 +248,24 @@ describe('readRuntimeFlag', () => {
|
||||
expect(result).toBe('off');
|
||||
});
|
||||
|
||||
it('returns "off" when value field is missing', () => {
|
||||
it('reads "mode" field when "value" is absent (post-050b349a fix)', () => {
|
||||
// After 050b349a's readRuntimeFlag fix, runtime files store {mode: "on"} as
|
||||
// canonical shape. The legacy "value" key is still accepted as fallback,
|
||||
// but "mode" is preferred. Test that mode='on' without value yields 'on'.
|
||||
const fakeFsImpl = {
|
||||
existsSync: () => true,
|
||||
readFileSync: () => '{"mode":"on"}', // no "value" key
|
||||
readFileSync: () => '{"mode":"on"}',
|
||||
};
|
||||
|
||||
const result = readRuntimeFlag('self-assessment-mode', { homedir: '/fake', fsImpl: fakeFsImpl });
|
||||
expect(result).toBe('on');
|
||||
});
|
||||
|
||||
it('returns "off" when neither "mode" nor "value" present', () => {
|
||||
const fakeFsImpl = {
|
||||
existsSync: () => true,
|
||||
readFileSync: () => '{"other":"thing"}',
|
||||
};
|
||||
const result = readRuntimeFlag('self-assessment-mode', { homedir: '/fake', fsImpl: fakeFsImpl });
|
||||
expect(result).toBe('off');
|
||||
});
|
||||
|
||||
@@ -59,5 +59,32 @@ export function extractClassifierOutput(state) {
|
||||
recommended_chain_id: cls.recommended_chain_id ?? null,
|
||||
no_skill_found: cls.no_skill_found === true,
|
||||
source: cls.source ?? null,
|
||||
// Factor-analysis signal: classifier's stated rationale + confidence.
|
||||
// Field name varies by prompt schema: new (Phase 2) uses `reason_for_choice`,
|
||||
// legacy uses `reasoning`. Null on regex / prefilter paths. Truncated to
|
||||
// keep episode JSONL line size bounded.
|
||||
reasoning: pickReasoning(cls),
|
||||
confidence: typeof cls.confidence === 'number' ? cls.confidence : null,
|
||||
// Pass 2 metrics (project-brain-factor-analysis-4passes): network latency,
|
||||
// internal retry count, categorized transport error, and the classifier's
|
||||
// own top-3 alternative nodes with rejection rationale. null on regex /
|
||||
// prefilter / cache paths where the LLM was never (or was already) called.
|
||||
latency_ms: typeof cls.latency_ms === 'number' ? cls.latency_ms : null,
|
||||
retry_count_internal: typeof cls.retry_count_internal === 'number' ? cls.retry_count_internal : null,
|
||||
llm_error: cls.llm_error_type ?? null,
|
||||
alternatives_considered: pickAlternatives(cls),
|
||||
};
|
||||
}
|
||||
|
||||
function pickReasoning(cls) {
|
||||
const v = cls.reasoning ?? cls.reason_for_choice ?? cls.reason ?? null;
|
||||
if (typeof v !== 'string') return null;
|
||||
return v.slice(0, 600);
|
||||
}
|
||||
|
||||
function pickAlternatives(cls) {
|
||||
const v = cls.alternatives_considered;
|
||||
if (!Array.isArray(v)) return null;
|
||||
// Cap at top-3 to bound episode JSONL line size; Sonnet sometimes returns 5+.
|
||||
return v.slice(0, 3);
|
||||
}
|
||||
|
||||
@@ -96,3 +96,67 @@ describe('extractRouterFields', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractClassifierOutput — Pass 2 metrics (project-brain-factor-analysis-4passes)', () => {
|
||||
it('surfaces latency_ms / retry_count_internal / llm_error / alternatives_considered when present', async () => {
|
||||
const { extractClassifierOutput } = await import('./observer-state-enricher.mjs');
|
||||
const state = {
|
||||
classification: {
|
||||
task_type: 'feature',
|
||||
source: 'llm',
|
||||
latency_ms: 742,
|
||||
retry_count_internal: 0,
|
||||
llm_error_type: null,
|
||||
alternatives_considered: [
|
||||
{ node: '#19', score: 0.8, reason: 'close match' },
|
||||
{ node: '#62', score: 0.4, reason: 'mismatch domain' },
|
||||
],
|
||||
},
|
||||
};
|
||||
const out = extractClassifierOutput(state);
|
||||
expect(out.latency_ms).toBe(742);
|
||||
expect(out.retry_count_internal).toBe(0);
|
||||
expect(out.llm_error).toBeNull();
|
||||
expect(Array.isArray(out.alternatives_considered)).toBe(true);
|
||||
expect(out.alternatives_considered).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('truncates alternatives_considered to top-3 to bound JSONL line size', async () => {
|
||||
const { extractClassifierOutput } = await import('./observer-state-enricher.mjs');
|
||||
const out = extractClassifierOutput({
|
||||
classification: {
|
||||
task_type: 'feature',
|
||||
source: 'llm',
|
||||
alternatives_considered: [
|
||||
{ node: '#1' }, { node: '#2' }, { node: '#3' }, { node: '#4' }, { node: '#5' },
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(out.alternatives_considered).toHaveLength(3);
|
||||
expect(out.alternatives_considered[0].node).toBe('#1');
|
||||
});
|
||||
|
||||
it('returns null fields on regex / prefilter / cache paths (no LLM hit)', async () => {
|
||||
const { extractClassifierOutput } = await import('./observer-state-enricher.mjs');
|
||||
const out = extractClassifierOutput({
|
||||
classification: { task_type: 'conversation', source: 'prefilter' },
|
||||
});
|
||||
expect(out.latency_ms).toBeNull();
|
||||
expect(out.retry_count_internal).toBeNull();
|
||||
expect(out.llm_error).toBeNull();
|
||||
expect(out.alternatives_considered).toBeNull();
|
||||
});
|
||||
|
||||
it('captures llm_error category on degraded LLM path', async () => {
|
||||
const { extractClassifierOutput } = await import('./observer-state-enricher.mjs');
|
||||
const out = extractClassifierOutput({
|
||||
classification: {
|
||||
task_type: 'feature', source: 'regex',
|
||||
llm_error_type: 'timeout', latency_ms: 30000, retry_count_internal: 4,
|
||||
},
|
||||
});
|
||||
expect(out.llm_error).toBe('timeout');
|
||||
expect(out.latency_ms).toBe(30000);
|
||||
expect(out.retry_count_internal).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,6 +20,7 @@ import { sanitize, sanitizeWithCount } from './observer-pii-filter.mjs';
|
||||
import { parseTranscript, extractLastUserPromptText } from './observer-transcript-parser.mjs';
|
||||
import { detectMethodDirected, loadKnownNodes } from './observer-routing-detector.mjs';
|
||||
import { callSelfAssessmentApi, readRuntimeFlag } from './observer-self-assessment-api.mjs';
|
||||
import { shouldEmbed as _shouldEmbed, encodeBase64 as _encodeBase64, embed as _embed } from './router-embedding.mjs';
|
||||
|
||||
const REQUIRED_FIELDS = ['task_id', 'timestamps', 'path_type', 'outcome', 'primary_rationale'];
|
||||
const V2_FIELDS = [
|
||||
@@ -242,6 +243,60 @@ export function buildSelfAssessment({ apiResult } = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 3.6 embedding async wiring (Phase 4 follow-up).
|
||||
*
|
||||
* Mirrors the Step 3.5 self-assessment pattern (commit c1ec61fa). When the
|
||||
* embedding-mode runtime flag is 'on' and the task is non-trivial (per
|
||||
* shouldEmbed), computes a 384-dim sentence embedding via Xenova and stores
|
||||
* it on the episode as `prompt_embedding_base64`. Fail-quiet: on timeout /
|
||||
* model load failure / runtime error → field stays null and
|
||||
* `environment.embedding_unavailable = true` is set.
|
||||
*
|
||||
* Pure-API style: injectable embedFn / shouldEmbedFn / encodeBase64Fn for tests
|
||||
* (the CLI binds them to the real router-embedding.mjs implementations).
|
||||
*
|
||||
* @param {object} ep — episode object to mutate
|
||||
* @param {object} ctx — Stop-hook context (uses ctx.prompt)
|
||||
* @param {object} opts
|
||||
* @param {string} [opts.embedMode] — runtime flag value ('on' to compute)
|
||||
* @param {Function} [opts.shouldEmbedFn] — taskType -> bool
|
||||
* @param {Function} [opts.embedFn] — async(prompt) -> Float32Array | null
|
||||
* @param {Function} [opts.encodeBase64Fn]— Float32Array -> base64 string
|
||||
* @param {number} [opts.timeoutMs] — race timeout (default 2000)
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function computeEmbeddingForEpisode(ep, ctx = {}, opts = {}) {
|
||||
const {
|
||||
embedMode = 'off',
|
||||
shouldEmbedFn = _shouldEmbed,
|
||||
embedFn = _embed,
|
||||
encodeBase64Fn = _encodeBase64,
|
||||
timeoutMs = 2000,
|
||||
} = opts;
|
||||
|
||||
if (embedMode !== 'on') return;
|
||||
const taskType = ep?.primary_rationale?.task_classification;
|
||||
if (!shouldEmbedFn(taskType)) return;
|
||||
if (!ctx || !ctx.prompt) return;
|
||||
|
||||
try {
|
||||
const vec = await Promise.race([
|
||||
embedFn(ctx.prompt),
|
||||
new Promise((resolve) => setTimeout(() => resolve(null), timeoutMs)),
|
||||
]);
|
||||
if (vec && vec.length > 0) {
|
||||
ep.prompt_embedding_base64 = encodeBase64Fn(vec);
|
||||
} else {
|
||||
ep.environment ??= {};
|
||||
ep.environment.embedding_unavailable = true;
|
||||
}
|
||||
} catch (_e) {
|
||||
ep.environment ??= {};
|
||||
ep.environment.embedding_unavailable = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a minimal observer_error marker — written instead of a silent skip
|
||||
* when the Stop-hook fails internally (spec §3 / §5.2).
|
||||
@@ -333,6 +388,11 @@ if (process.argv[1] && process.argv[1].replace(/\\/g, '/').endsWith('/observer-s
|
||||
ep.self_assessment = buildSelfAssessment({ apiResult });
|
||||
}
|
||||
|
||||
// Step 3.6: embedding async wiring (fail-quiet, 2s timeout).
|
||||
// Trivial task types skipped via shouldEmbed. Mirrors Step 3.5 pattern.
|
||||
const embMode = readRuntimeFlag('embedding-mode');
|
||||
await computeEmbeddingForEpisode(ep, ctx, { embedMode: embMode });
|
||||
|
||||
// Always write the episode first — exit-0-safe (spec §5.1 step 1).
|
||||
appendEpisode(ep);
|
||||
// Then the routing-gate (spec §5.1 steps 2-4).
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { writeFileSync, readFileSync, existsSync, mkdtempSync, rmSync, mkdirSync, readdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { appendEpisode, buildEpisodeFromContext, buildObserverError, routingGateDecision, buildExecutionTrace, buildEpisode, buildSelfAssessment } from './observer-stop-hook.mjs';
|
||||
import { appendEpisode, buildEpisodeFromContext, buildObserverError, routingGateDecision, buildExecutionTrace, buildEpisode, buildSelfAssessment, computeEmbeddingForEpisode } from './observer-stop-hook.mjs';
|
||||
|
||||
let workdir;
|
||||
|
||||
@@ -303,3 +303,66 @@ describe('routingGateDecision', () => {
|
||||
expect(gate.block).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Step 3.6 embedding async wiring (Phase 4 follow-up)
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('Step 3.6 embedding async wiring', () => {
|
||||
// Helper to build an episode with a given task_classification.
|
||||
const epWithClass = (cls = 'feature') => v2Episode({
|
||||
primary_rationale: { ...defaultRat(), task_classification: cls },
|
||||
});
|
||||
|
||||
it('embedding-mode off → embedding not computed, field null', async () => {
|
||||
const ep = epWithClass('feature');
|
||||
const embedFn = async () => new Float32Array([0.1, 0.2, 0.3]);
|
||||
await computeEmbeddingForEpisode(ep, { prompt: 'напиши тест' }, {
|
||||
embedMode: 'off',
|
||||
embedFn,
|
||||
});
|
||||
expect(ep.prompt_embedding_base64).toBeUndefined();
|
||||
expect(ep.environment?.embedding_unavailable).toBeUndefined();
|
||||
});
|
||||
|
||||
it('taskType="conversation" (exempt) → embedding skipped, field null', async () => {
|
||||
const ep = epWithClass('conversation');
|
||||
let called = false;
|
||||
const embedFn = async () => { called = true; return new Float32Array([0.1]); };
|
||||
await computeEmbeddingForEpisode(ep, { prompt: 'спасибо' }, {
|
||||
embedMode: 'on',
|
||||
embedFn,
|
||||
});
|
||||
expect(called).toBe(false);
|
||||
expect(ep.prompt_embedding_base64).toBeUndefined();
|
||||
expect(ep.environment?.embedding_unavailable).toBeUndefined();
|
||||
});
|
||||
|
||||
it('embedding success → prompt_embedding_base64 is base64 string, environment.embedding_unavailable not set', async () => {
|
||||
const ep = epWithClass('feature');
|
||||
// Distinctive non-zero vector so encoding produces a stable, non-empty base64.
|
||||
const fakeVec = new Float32Array([0.5, -0.25, 1.0, 0.0]);
|
||||
const embedFn = async () => fakeVec;
|
||||
await computeEmbeddingForEpisode(ep, { prompt: 'напиши тест для биллинга' }, {
|
||||
embedMode: 'on',
|
||||
embedFn,
|
||||
});
|
||||
expect(typeof ep.prompt_embedding_base64).toBe('string');
|
||||
expect(ep.prompt_embedding_base64.length).toBeGreaterThan(0);
|
||||
// Base64-only chars (no whitespace, no null prefix).
|
||||
expect(ep.prompt_embedding_base64).toMatch(/^[A-Za-z0-9+/]+=*$/);
|
||||
expect(ep.environment?.embedding_unavailable).toBeUndefined();
|
||||
});
|
||||
|
||||
it('embedding timeout (2s) → field null, environment.embedding_unavailable=true', async () => {
|
||||
const ep = epWithClass('feature');
|
||||
// embedFn never resolves — timeout (overridden short for test) must win.
|
||||
const embedFn = () => new Promise(() => {});
|
||||
await computeEmbeddingForEpisode(ep, { prompt: 'долгая задача' }, {
|
||||
embedMode: 'on',
|
||||
embedFn,
|
||||
timeoutMs: 30, // short override so the test is fast
|
||||
});
|
||||
expect(ep.prompt_embedding_base64).toBeUndefined();
|
||||
expect(ep.environment.embedding_unavailable).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -368,6 +368,73 @@ function collectToolResultText(turn) {
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
// Pass 3 — path-pattern classifier (project-brain-factor-analysis-4passes).
|
||||
// Returns one of: test / config / spec / norm / data / src / other.
|
||||
// Priority order matters (test before src, norm before src, etc).
|
||||
export function classifyFilePath(path) {
|
||||
if (!path) return 'other';
|
||||
const p = String(path).replace(/\\/g, '/');
|
||||
const base = p.split('/').pop() || p;
|
||||
|
||||
// 1. tests
|
||||
if (/\.(?:test|spec)\.[a-z0-9]+$/i.test(base)) return 'test';
|
||||
if (/(?:^|\/)(?:tests?|spec)\//i.test(p)) return 'test';
|
||||
|
||||
// 2. normative documents (CLAUDE.md / Pravila / PSR / Tooling / Открытые_вопросы / memory store).
|
||||
if (/(?:^|\/)CLAUDE\.md$/i.test(p)) return 'norm';
|
||||
if (/(?:^|\/)Pravila_raboty_Claude[^/]*\.md$/i.test(p)) return 'norm';
|
||||
if (/(?:^|\/)Plugin_stack_rules[^/]*\.md$/i.test(p)) return 'norm';
|
||||
if (/(?:^|\/)Tooling[^/]*\.md$/i.test(p)) return 'norm';
|
||||
if (/(?:^|\/)Открытые_вопросы[^/]*\.md$/i.test(p)) return 'norm';
|
||||
if (/(?:^|\/)MEMORY\.md$/i.test(p)) return 'norm';
|
||||
if (/\/memory\/[^/]+\.md$/i.test(p)) return 'norm';
|
||||
|
||||
// 3. spec / plan
|
||||
if (/(?:^|\/)docs\/superpowers\/(?:specs|plans)\//i.test(p)) return 'spec';
|
||||
|
||||
// 4. config
|
||||
if (/(?:^|\/)\.env(?:\.|$)/i.test(p)) return 'config';
|
||||
if (/(?:^|\/)(?:package|composer|tsconfig)\.json$/i.test(base)) return 'config';
|
||||
if (/\.config\.[a-z0-9]+$/i.test(base)) return 'config';
|
||||
if (/(?:^|\/)(?:lefthook|\.eslintrc|cspell|stylelint|prettier|pint)[^/]*\.(?:yml|yaml|json|cjs|mjs|js|toml)$/i.test(p)) return 'config';
|
||||
|
||||
// 5. data
|
||||
if (/\.(?:jsonl|csv|sql|sqlite)$/i.test(base)) return 'data';
|
||||
|
||||
// 6. src
|
||||
if (/(?:^|\/)(?:app|tools|resources|src|lib|db\/migrations)\//i.test(p)) return 'src';
|
||||
|
||||
return 'other';
|
||||
}
|
||||
|
||||
const FILE_TYPE_CATEGORIES = ['src', 'test', 'config', 'spec', 'norm', 'data', 'other'];
|
||||
|
||||
export function extractFileTypeDistribution(files) {
|
||||
const dist = Object.fromEntries(FILE_TYPE_CATEGORIES.map((c) => [c, 0]));
|
||||
for (const f of files || []) {
|
||||
dist[classifyFilePath(f)] += 1;
|
||||
}
|
||||
return dist;
|
||||
}
|
||||
|
||||
// Pass 3 — MCP server fingerprint. tool_use[].name follows
|
||||
// `mcp__<server>__<tool>` where <server> may itself contain single underscores
|
||||
// (e.g. mcp__plugin_brand-voice_box__authenticate). Non-greedy match stops at
|
||||
// the FIRST `__` after the prefix so multi-word server names land whole.
|
||||
export function extractMcpServers(turn) {
|
||||
const servers = new Set();
|
||||
for (const e of turn || []) {
|
||||
const content = e && e.message && Array.isArray(e.message.content) ? e.message.content : [];
|
||||
for (const b of content) {
|
||||
if (b && b.type === 'tool_use' && typeof b.name === 'string') {
|
||||
const m = b.name.match(/^mcp__(.+?)__/);
|
||||
if (m) servers.add(m[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...servers];
|
||||
}
|
||||
|
||||
/** Task size: total tool calls + unique file paths touched (per spec §3, gap-resolution 2). */
|
||||
export function extractTaskSize(turn) {
|
||||
let tool_calls = 0;
|
||||
@@ -406,6 +473,18 @@ export function extractTaskSize(turn) {
|
||||
* Defensive: skips entries where `usage` is not a plain object (handles
|
||||
* malformed transcript edge cases like `"usage": 42`).
|
||||
*/
|
||||
// Normalize `usage.iterations` to a count.
|
||||
// Claude Code transcripts may emit it as: a number (legacy / no extended-thinking),
|
||||
// an array of step-objects (extended-thinking turns), or a plain object map.
|
||||
// Coerce to a number; non-finite / unknown → 0. Prevents "0[object Object]…"
|
||||
// string concatenation that previously poisoned task_cost.iterations.
|
||||
function iterationsCount(v) {
|
||||
if (typeof v === 'number' && Number.isFinite(v)) return v;
|
||||
if (Array.isArray(v)) return v.length;
|
||||
if (v && typeof v === 'object') return Object.keys(v).length;
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function extractTokenUsage(turn) {
|
||||
let input = 0, output = 0, cache_read = 0, cache_creation = 0;
|
||||
let web_search = 0, web_fetch = 0, iterations = 0;
|
||||
@@ -416,7 +495,7 @@ export function extractTokenUsage(turn) {
|
||||
output += u.output_tokens || 0;
|
||||
cache_read += u.cache_read_input_tokens || 0;
|
||||
cache_creation += u.cache_creation_input_tokens || 0;
|
||||
iterations += u.iterations || 0;
|
||||
iterations += iterationsCount(u.iterations);
|
||||
if (u.server_tool_use) {
|
||||
web_search += u.server_tool_use.web_search_requests || 0;
|
||||
web_fetch += u.server_tool_use.web_fetch_requests || 0;
|
||||
@@ -841,6 +920,18 @@ export function parseTranscript(transcriptText, fallbackSessionId = null, option
|
||||
environment: { ..._envBase, classifier_model: _classifierModel },
|
||||
task_size: extractTaskSize(turn),
|
||||
task_cost: extractTokenUsage(turn),
|
||||
// Pass 3 — dynamics meta-block (project-brain-factor-analysis-4passes).
|
||||
// prompt_length_chars: strlen of first user prompt (engagement / clarity proxy).
|
||||
// mcp_servers_used: unique mcp__<server>__* fingerprints in this turn.
|
||||
// file_type_distribution: per-bucket counts of unique paths touched.
|
||||
task_meta: (() => {
|
||||
const ts = extractTaskSize(turn);
|
||||
return {
|
||||
prompt_length_chars: typeof prompt === 'string' ? prompt.length : 0,
|
||||
mcp_servers_used: extractMcpServers(turn),
|
||||
file_type_distribution: extractFileTypeDistribution(ts.files),
|
||||
};
|
||||
})(),
|
||||
classifier_output: _classifierOutput,
|
||||
degraded_mode: _degraded,
|
||||
primary_rationale: (() => {
|
||||
|
||||
@@ -12,6 +12,9 @@ import {
|
||||
extractLastUserPromptText,
|
||||
classifyTask,
|
||||
extractTokenUsage,
|
||||
extractMcpServers,
|
||||
extractFileTypeDistribution,
|
||||
classifyFilePath,
|
||||
} from './observer-transcript-parser.mjs';
|
||||
|
||||
// Build a JSONL transcript string from entry objects.
|
||||
@@ -1813,3 +1816,110 @@ describe('parseTranscript — schema v4.3 write-block fields (phase 3 deferred #
|
||||
expect(cost.reviewer_subagent_usd).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyFilePath — Pass 3 path-pattern bucketing (project-brain-factor-analysis-4passes)', () => {
|
||||
it('classifies test files', () => {
|
||||
expect(classifyFilePath('tools/foo.test.mjs')).toBe('test');
|
||||
expect(classifyFilePath('app/tests/Feature/X.php')).toBe('test');
|
||||
expect(classifyFilePath('resources/js/foo.spec.ts')).toBe('test');
|
||||
});
|
||||
it('classifies config files', () => {
|
||||
expect(classifyFilePath('package.json')).toBe('config');
|
||||
expect(classifyFilePath('vite.config.ts')).toBe('config');
|
||||
expect(classifyFilePath('lefthook.yml')).toBe('config');
|
||||
expect(classifyFilePath('.env')).toBe('config');
|
||||
expect(classifyFilePath('tsconfig.json')).toBe('config');
|
||||
});
|
||||
it('classifies spec/plan files under docs/superpowers/', () => {
|
||||
expect(classifyFilePath('docs/superpowers/specs/x.md')).toBe('spec');
|
||||
expect(classifyFilePath('docs/superpowers/plans/x.md')).toBe('spec');
|
||||
});
|
||||
it('classifies normative documents', () => {
|
||||
expect(classifyFilePath('CLAUDE.md')).toBe('norm');
|
||||
expect(classifyFilePath('c:\\моя\\проекты\\портал crm\\Документация\\CLAUDE.md')).toBe('norm');
|
||||
expect(classifyFilePath('docs/Pravila_raboty_Claude_v1_1.md')).toBe('norm');
|
||||
expect(classifyFilePath('docs/Plugin_stack_rules_v1.md')).toBe('norm');
|
||||
expect(classifyFilePath('docs/Tooling_v8_3.md')).toBe('norm');
|
||||
expect(classifyFilePath('C:\\Users\\x\\.claude\\projects\\proj\\memory\\foo.md')).toBe('norm');
|
||||
});
|
||||
it('classifies data files', () => {
|
||||
expect(classifyFilePath('docs/observer/episodes-2026-05.jsonl')).toBe('data');
|
||||
expect(classifyFilePath('db/seed.csv')).toBe('data');
|
||||
expect(classifyFilePath('db/schema.sql')).toBe('data');
|
||||
});
|
||||
it('classifies app/tools source under src', () => {
|
||||
expect(classifyFilePath('app/Http/Controllers/X.php')).toBe('src');
|
||||
expect(classifyFilePath('tools/router-classifier.mjs')).toBe('src');
|
||||
expect(classifyFilePath('resources/js/views/Dashboard.vue')).toBe('src');
|
||||
});
|
||||
it('returns other for paths that fit no category', () => {
|
||||
expect(classifyFilePath('some-random-binary.png')).toBe('other');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractFileTypeDistribution — Pass 3 (project-brain-factor-analysis-4passes)', () => {
|
||||
it('counts each path bucket and zero-fills missing categories', () => {
|
||||
const dist = extractFileTypeDistribution([
|
||||
'tools/router-classifier.mjs',
|
||||
'tools/router-classifier.test.mjs',
|
||||
'docs/superpowers/specs/x.md',
|
||||
'CLAUDE.md',
|
||||
]);
|
||||
expect(dist.src).toBe(1);
|
||||
expect(dist.test).toBe(1);
|
||||
expect(dist.spec).toBe(1);
|
||||
expect(dist.norm).toBe(1);
|
||||
expect(dist.config).toBe(0);
|
||||
expect(dist.data).toBe(0);
|
||||
expect(dist.other).toBe(0);
|
||||
});
|
||||
it('returns all-zero distribution for empty input', () => {
|
||||
const dist = extractFileTypeDistribution([]);
|
||||
for (const v of Object.values(dist)) expect(v).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractMcpServers — Pass 3 (project-brain-factor-analysis-4passes)', () => {
|
||||
it('extracts unique mcp__<server>__* prefixes from tool_use entries', () => {
|
||||
const turn = [
|
||||
assistantTurn([
|
||||
{ type: 'tool_use', id: 't1', name: 'mcp__github__list_issues', input: {} },
|
||||
{ type: 'tool_use', id: 't2', name: 'mcp__github__get_pr', input: {} },
|
||||
{ type: 'tool_use', id: 't3', name: 'mcp__playwright__browser_click', input: {} },
|
||||
{ type: 'tool_use', id: 't4', name: 'Read', input: { file_path: 'a.txt' } },
|
||||
], '2026-05-25T10:00:00Z'),
|
||||
];
|
||||
expect(extractMcpServers(turn).sort()).toEqual(['github', 'playwright']);
|
||||
});
|
||||
it('returns empty array when no mcp tools used', () => {
|
||||
const turn = [assistantTurn([{ type: 'tool_use', id: 't1', name: 'Read', input: { file_path: 'a' } }], '2026-05-25T10:00:00Z')];
|
||||
expect(extractMcpServers(turn)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseTranscript — Pass 3 task_meta block (project-brain-factor-analysis-4passes)', () => {
|
||||
it('includes prompt_length_chars / mcp_servers_used / file_type_distribution under task_meta', () => {
|
||||
const t = jsonl([
|
||||
userPrompt('добавь функцию X в файл tools/router-classifier.mjs', '2026-05-25T10:00:00Z'),
|
||||
assistantTurn([
|
||||
{ type: 'tool_use', id: 't1', name: 'mcp__github__get_pr', input: {} },
|
||||
{ type: 'tool_use', id: 't2', name: 'Read', input: { file_path: 'tools/router-classifier.mjs' } },
|
||||
{ type: 'tool_use', id: 't3', name: 'Edit', input: { file_path: 'tools/router-classifier.test.mjs', old_string: 'a', new_string: 'b' } },
|
||||
], '2026-05-25T10:01:00Z'),
|
||||
]);
|
||||
const ep = parseTranscript(t);
|
||||
expect(ep.task_meta).toBeDefined();
|
||||
expect(ep.task_meta.prompt_length_chars).toBe('добавь функцию X в файл tools/router-classifier.mjs'.length);
|
||||
expect(ep.task_meta.mcp_servers_used).toEqual(['github']);
|
||||
expect(ep.task_meta.file_type_distribution.src).toBe(1);
|
||||
expect(ep.task_meta.file_type_distribution.test).toBe(1);
|
||||
});
|
||||
|
||||
it('task_meta is present even on empty transcript (null-safe defaults)', () => {
|
||||
const ep = parseTranscript('');
|
||||
expect(ep.task_meta).toBeDefined();
|
||||
expect(ep.task_meta.prompt_length_chars).toBe(0);
|
||||
expect(ep.task_meta.mcp_servers_used).toEqual([]);
|
||||
expect(ep.task_meta.file_type_distribution.other).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
+189
-38
@@ -23,6 +23,20 @@
|
||||
|
||||
import { CLASSIFIER_MODEL, INHERITANCE_MAX_AGE_MIN } from './router-config.mjs';
|
||||
import { classifyByRegex } from './router-classifier-regex-fallback.mjs';
|
||||
import { Agent } from 'undici';
|
||||
|
||||
// Keep-alive dispatcher for ProxyAPI — skips TLS handshake on subsequent calls,
|
||||
// reduces tail latency 100-300ms per request. Only attached to the default
|
||||
// fetchImpl; tests passing their own fetchImpl are unaffected.
|
||||
const KEEPALIVE_DISPATCHER = new Agent({
|
||||
keepAliveTimeout: 30_000,
|
||||
keepAliveMaxTimeout: 60_000,
|
||||
connections: 4,
|
||||
});
|
||||
|
||||
async function defaultFetch(url, opts) {
|
||||
return fetch(url, { ...opts, dispatcher: KEEPALIVE_DISPATCHER });
|
||||
}
|
||||
|
||||
export { classifyByRegex };
|
||||
|
||||
@@ -224,18 +238,37 @@ function buildChainsBlock(registry) {
|
||||
/**
|
||||
* Build Sonnet 4.6 classifier prompt per spec §4.2.
|
||||
*
|
||||
* Returns the prompt as a single string for backward compatibility
|
||||
* (snapshot tests, accuracy-runner historical mode). The classifier
|
||||
* hot-path uses buildClassifierPromptStructured() instead, which separates
|
||||
* cacheable (system + registry) from dynamic (user prompt) content.
|
||||
*
|
||||
* @param {string} userPrompt — raw user prompt
|
||||
* @param {object} registry — { nodes, chains }
|
||||
* @param {object} [options]
|
||||
* @param {boolean} [options.enrichment=true] — inject pamyatka (4 patterns)
|
||||
*/
|
||||
export function buildClassifierPrompt(userPrompt, registry, { enrichment = true } = {}) {
|
||||
const { system, user } = buildClassifierPromptStructured(userPrompt, registry, { enrichment });
|
||||
return `<system>\n${system}\n</system>\n\n<user>\n${user}\n</user>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build classifier prompt as { system, user } blocks for Anthropic prompt
|
||||
* caching (ephemeral 5m TTL). The `system` block is identical across all
|
||||
* classifier calls within a 5-minute window (instruction + памятка + node
|
||||
* registry + chains) and gets billed at 10% rate after the first call.
|
||||
* The `user` block is the only dynamic per-call content.
|
||||
*
|
||||
* Cache-eligibility: Sonnet requires ≥1024 tokens in the cached block.
|
||||
* Active node registry (~85 nodes × ~100 tokens) easily clears this.
|
||||
*/
|
||||
export function buildClassifierPromptStructured(userPrompt, registry, { enrichment = true } = {}) {
|
||||
const pamyatka = enrichment ? `\n\n${PAMYATKA}\n` : '\n';
|
||||
const nodesBlock = buildNodesBlock(registry);
|
||||
const chainsBlock = buildChainsBlock(registry);
|
||||
|
||||
return `<system>
|
||||
Ты классификатор задач для CRM-проекта «Лидерра» (Laravel 13 + Vue 3 + Vuetify 3).
|
||||
const system = `Ты классификатор задач для CRM-проекта «Лидерра» (Laravel 13 + Vue 3 + Vuetify 3).
|
||||
|
||||
ОБЯЗАТЕЛЬНЫЕ выходные правила:
|
||||
1. Верни ровно один из: skill ИЛИ chain ИЛИ no_skill_found.
|
||||
@@ -251,12 +284,10 @@ ${nodesBlock}
|
||||
=== РЕЕСТР ЦЕПОЧЕК (справочно) ===
|
||||
${chainsBlock}
|
||||
|
||||
Output — ONLY JSON object, no prose, no code fences.
|
||||
</system>
|
||||
Output — ONLY JSON object, no prose, no code fences.`;
|
||||
|
||||
<user>
|
||||
Prompt: ${userPrompt}
|
||||
</user>`;
|
||||
const user = `Prompt: ${userPrompt}`;
|
||||
return { system, user };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -272,13 +303,26 @@ export function parseClassifierResponse(text) {
|
||||
if (!text) return null;
|
||||
const trimmed = String(text).trim();
|
||||
const stripped = trimmed.replace(/^```(?:json)?\s*\n?/, '').replace(/\n?```$/, '').trim();
|
||||
|
||||
// Pass 1: clean JSON (after fence strip).
|
||||
try {
|
||||
const parsed = JSON.parse(stripped);
|
||||
if (typeof parsed.task_type !== 'string') return null;
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
if (typeof parsed.task_type === 'string') return parsed;
|
||||
} catch { /* fall through to extraction */ }
|
||||
|
||||
// Pass 2: JSON object embedded in prose ("Here is the classification: { ... }").
|
||||
// Greedy match from first `{` to last `}` — works because the classifier
|
||||
// produces exactly one top-level object; outer braces are reliable anchors.
|
||||
const start = stripped.indexOf('{');
|
||||
const end = stripped.lastIndexOf('}');
|
||||
if (start !== -1 && end > start) {
|
||||
try {
|
||||
const parsed = JSON.parse(stripped.slice(start, end + 1));
|
||||
if (typeof parsed.task_type === 'string') return parsed;
|
||||
} catch { /* unrecoverable */ }
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── Legacy LLM prompt/parser (kept for backward compat) ────────────────────
|
||||
@@ -340,32 +384,114 @@ export function parseLLMResponse(text) {
|
||||
|
||||
const DEFAULT_LLM_BASE_URL = 'https://api.proxyapi.ru/anthropic';
|
||||
|
||||
export async function callAnthropicAPI(prompt, {
|
||||
/**
|
||||
* POST to ProxyAPI /v1/messages.
|
||||
*
|
||||
* First argument is overloaded:
|
||||
* - string → legacy single-message body (no prompt caching).
|
||||
* - { system, user } → split body with ephemeral cache_control on the
|
||||
* `system` block. ~70-80% cost reduction on the cacheable portion
|
||||
* after the first call within a 5-minute window.
|
||||
*
|
||||
* Optional `onUsage(usage)` callback receives Anthropic's usage object
|
||||
* (input_tokens / output_tokens / cache_creation_input_tokens /
|
||||
* cache_read_input_tokens) for observability.
|
||||
*/
|
||||
export async function callAnthropicAPI(promptOrMessages, {
|
||||
apiKey,
|
||||
baseUrl = DEFAULT_LLM_BASE_URL,
|
||||
model = CLASSIFIER_MODEL,
|
||||
fetchImpl = fetch,
|
||||
fetchImpl = defaultFetch,
|
||||
maxRetries = 4,
|
||||
retryBaseDelayMs = 1000,
|
||||
perAttemptTimeoutMs = 30_000,
|
||||
sleepImpl = (ms) => new Promise((res) => setTimeout(res, ms)),
|
||||
onUsage,
|
||||
onMetrics,
|
||||
}) {
|
||||
const url = `${String(baseUrl).replace(/\/+$/, '')}/v1/messages`;
|
||||
const r = await fetchImpl(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'authorization': `Bearer ${apiKey}`,
|
||||
'x-api-key': apiKey,
|
||||
'anthropic-version': '2023-06-01',
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
let body;
|
||||
if (typeof promptOrMessages === 'string') {
|
||||
body = JSON.stringify({
|
||||
model,
|
||||
max_tokens: 1500,
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
}),
|
||||
});
|
||||
if (!r.ok) {
|
||||
throw new Error(`Router LLM ${r.status}: ${await r.text()}`);
|
||||
messages: [{ role: 'user', content: promptOrMessages }],
|
||||
});
|
||||
} else {
|
||||
const { system, user } = promptOrMessages;
|
||||
body = JSON.stringify({
|
||||
model,
|
||||
max_tokens: 1500,
|
||||
system: [{ type: 'text', text: system, cache_control: { type: 'ephemeral' } }],
|
||||
messages: [{ role: 'user', content: user }],
|
||||
});
|
||||
}
|
||||
const data = await r.json();
|
||||
return data.content?.[0]?.text || '';
|
||||
const headers = {
|
||||
'authorization': `Bearer ${apiKey}`,
|
||||
'x-api-key': apiKey,
|
||||
'anthropic-version': '2023-06-01',
|
||||
'content-type': 'application/json',
|
||||
};
|
||||
|
||||
// Pass 2 metric capture (project-brain-factor-analysis-4passes).
|
||||
const started = Date.now();
|
||||
let attempt = 0;
|
||||
const emitMetrics = () => {
|
||||
if (!onMetrics) return;
|
||||
try { onMetrics({ latency_ms: Date.now() - started, retry_count_internal: attempt }); } catch { /* swallow */ }
|
||||
};
|
||||
|
||||
let lastError;
|
||||
try {
|
||||
for (attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
const ctrl = new AbortController();
|
||||
const timer = setTimeout(() => ctrl.abort(new Error(`per-attempt timeout ${perAttemptTimeoutMs}ms`)), perAttemptTimeoutMs);
|
||||
try {
|
||||
const r = await fetchImpl(url, { method: 'POST', headers, body, signal: ctrl.signal });
|
||||
if (r.ok) {
|
||||
const data = await r.json();
|
||||
if (onUsage && data.usage) {
|
||||
try { onUsage(data.usage); } catch { /* swallow callback errors */ }
|
||||
}
|
||||
return data.content?.[0]?.text || '';
|
||||
}
|
||||
// Retry on 5xx and 429; fail fast on 4xx (auth/quota/bad request — retry won't help).
|
||||
if (r.status >= 500 || r.status === 429) {
|
||||
lastError = new Error(`Router LLM ${r.status}: ${await r.text()}`);
|
||||
} else {
|
||||
const fatal = new Error(`Router LLM ${r.status}: ${await r.text()}`);
|
||||
fatal.fatal = true;
|
||||
throw fatal;
|
||||
}
|
||||
} catch (err) {
|
||||
// Re-throw fatal errors (4xx) instead of retrying them.
|
||||
if (err && err.fatal) { clearTimeout(timer); throw err; }
|
||||
// Network-level failure (fetch failed / ECONNRESET / TLS / per-attempt timeout). Retry-eligible.
|
||||
lastError = err;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
if (attempt < maxRetries) {
|
||||
await sleepImpl(retryBaseDelayMs * 2 ** attempt);
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
} finally {
|
||||
emitMetrics();
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2 — categorize the LLM transport failure for the factor-analysis
|
||||
// error_type axis. Looks at err.fatal + message keywords (no err.code on
|
||||
// undici fetch failures — message is the only reliable signal).
|
||||
export function classifyLLMError(err) {
|
||||
if (!err) return 'other';
|
||||
const msg = String(err.message || err);
|
||||
if (err.fatal && /\b4\d\d\b/.test(msg)) return 'http_4xx';
|
||||
if (/\b5\d\d\b/.test(msg) || /429\b/.test(msg)) return 'http_5xx';
|
||||
if (/ECONNRESET|ECONNREFUSED|ENOTFOUND|EAI_AGAIN|socket hang up/i.test(msg)) return 'econnreset';
|
||||
if (err.name === 'AbortError' || /\btimeout\b/i.test(msg)) return 'timeout';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
function hashPrompt(s) {
|
||||
@@ -406,37 +532,62 @@ export async function classify(prompt, registry, options = {}) {
|
||||
return { ...cache.get(key), source: 'cache' };
|
||||
}
|
||||
|
||||
// Layer 2 — Sonnet 4.6.
|
||||
const llmCall = options.llmCall || (async () => {
|
||||
// Layer 2 — Sonnet 4.6 with prompt caching (ephemeral 5m TTL on system block).
|
||||
// llmCall receives { onMetrics } so callAnthropicAPI can report latency / retries
|
||||
// (Pass 2 factor-analysis extension); tests pass synthetic metrics directly.
|
||||
const llmCall = options.llmCall || (async ({ onMetrics } = {}) => {
|
||||
const apiKey = process.env.ROUTER_LLM_KEY;
|
||||
if (!apiKey) return null;
|
||||
const classifierPrompt = buildClassifierPrompt(prompt, registry, {
|
||||
const structured = buildClassifierPromptStructured(prompt, registry, {
|
||||
enrichment: options.enrichment ?? true,
|
||||
});
|
||||
const text = await callAnthropicAPI(classifierPrompt, {
|
||||
const text = await callAnthropicAPI(structured, {
|
||||
apiKey,
|
||||
baseUrl: process.env.ROUTER_LLM_BASE_URL || undefined,
|
||||
model: options.model || CLASSIFIER_MODEL,
|
||||
onUsage: options.onUsage,
|
||||
onMetrics,
|
||||
});
|
||||
return parseClassifierResponse(text);
|
||||
});
|
||||
|
||||
let metrics = null;
|
||||
const captureMetrics = (m) => { metrics = m; };
|
||||
let llmResult;
|
||||
try {
|
||||
llmResult = await llmCall();
|
||||
llmResult = await llmCall({ onMetrics: captureMetrics });
|
||||
} catch (err) {
|
||||
// Layer 3 — regex fallback on LLM transport error.
|
||||
const r = classifyByRegex(prompt, registry);
|
||||
return { ...r, llmError: err.message, degraded: true };
|
||||
return {
|
||||
...r,
|
||||
llmError: err.message,
|
||||
llm_error_type: classifyLLMError(err),
|
||||
latency_ms: metrics?.latency_ms ?? null,
|
||||
retry_count_internal: metrics?.retry_count_internal ?? null,
|
||||
degraded: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (!llmResult) {
|
||||
// Layer 3 — regex fallback on no key / unparseable.
|
||||
// Layer 3 — regex fallback on no key (metrics null) / unparseable response
|
||||
// (metrics set, classify as parse_null so the analyzer error_type axis
|
||||
// distinguishes "API never called" from "API returned garbage").
|
||||
const r = classifyByRegex(prompt, registry);
|
||||
return r;
|
||||
return {
|
||||
...r,
|
||||
llm_error_type: metrics ? 'parse_null' : 'no_key',
|
||||
latency_ms: metrics?.latency_ms ?? null,
|
||||
retry_count_internal: metrics?.retry_count_internal ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
const finalResult = { ...llmResult, source: 'llm' };
|
||||
const finalResult = {
|
||||
...llmResult,
|
||||
source: 'llm',
|
||||
latency_ms: metrics?.latency_ms ?? null,
|
||||
retry_count_internal: metrics?.retry_count_internal ?? null,
|
||||
};
|
||||
if (cache) cache.set(key, finalResult);
|
||||
return finalResult;
|
||||
}
|
||||
|
||||
@@ -341,3 +341,106 @@ describe('classify — isolation from Claude Code auth', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('callAnthropicAPI — Pass 2 metrics (project-brain-factor-analysis-4passes)', () => {
|
||||
it('emits onMetrics({latency_ms, retry_count_internal}) on success', async () => {
|
||||
const fetchImpl = async () => ({ ok: true, json: async () => ({ content: [{ text: '{"task_type":"question"}' }] }) });
|
||||
let captured = null;
|
||||
await callAnthropicAPI('hi', { apiKey: 'k', fetchImpl, onMetrics: (m) => { captured = m; } });
|
||||
expect(captured).not.toBeNull();
|
||||
expect(typeof captured.latency_ms).toBe('number');
|
||||
expect(captured.latency_ms).toBeGreaterThanOrEqual(0);
|
||||
expect(captured.retry_count_internal).toBe(0);
|
||||
});
|
||||
|
||||
it('emits onMetrics with retry_count_internal>0 after 5xx retries', async () => {
|
||||
let calls = 0;
|
||||
const fetchImpl = async () => {
|
||||
calls += 1;
|
||||
if (calls < 3) return { ok: false, status: 503, text: async () => 'unavailable' };
|
||||
return { ok: true, json: async () => ({ content: [{ text: '{"task_type":"question"}' }] }) };
|
||||
};
|
||||
let captured = null;
|
||||
const sleepImpl = () => Promise.resolve(); // skip backoff in tests
|
||||
await callAnthropicAPI('hi', { apiKey: 'k', fetchImpl, sleepImpl, onMetrics: (m) => { captured = m; } });
|
||||
expect(captured.retry_count_internal).toBe(2);
|
||||
});
|
||||
|
||||
it('emits onMetrics even on fatal 4xx (so latency / retry count reach the classifier state)', async () => {
|
||||
const fetchImpl = async () => ({ ok: false, status: 401, text: async () => 'invalid key' });
|
||||
let captured = null;
|
||||
await expect(callAnthropicAPI('hi', { apiKey: 'k', fetchImpl, onMetrics: (m) => { captured = m; } })).rejects.toThrow(/401/);
|
||||
expect(captured).not.toBeNull();
|
||||
expect(typeof captured.latency_ms).toBe('number');
|
||||
expect(captured.retry_count_internal).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('classify — Pass 2 metrics surface to result', () => {
|
||||
const fakeRegistry = { nodes: [{ id: '#19', status: 'active', triggers: [] }], chains: {} };
|
||||
|
||||
it('attaches latency_ms / retry_count_internal on LLM success', async () => {
|
||||
const llmCall = async ({ onMetrics } = {}) => {
|
||||
if (onMetrics) onMetrics({ latency_ms: 432, retry_count_internal: 1 });
|
||||
return { task_type: 'feature', recommended_node: '#19', recommended_chain: null, recommended_chain_id: null, alternatives_considered: [] };
|
||||
};
|
||||
const r = await classify('новая фича: добавь endpoint X', fakeRegistry, { llmCall });
|
||||
expect(r.source).toBe('llm');
|
||||
expect(r.latency_ms).toBe(432);
|
||||
expect(r.retry_count_internal).toBe(1);
|
||||
});
|
||||
|
||||
it('passes through alternatives_considered from Sonnet (truncated to top-3 by enricher, not by classify)', async () => {
|
||||
const llmCall = async () => ({
|
||||
task_type: 'feature', recommended_node: '#19', recommended_chain: null, recommended_chain_id: null,
|
||||
alternatives_considered: [{ node: '#19', score: 0.8 }, { node: '#62', score: 0.4 }],
|
||||
});
|
||||
const r = await classify('новая фича X', fakeRegistry, { llmCall });
|
||||
expect(r.alternatives_considered).toBeDefined();
|
||||
expect(r.alternatives_considered).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('sets llm_error_type=econnreset / latency / retry_count on transport error', async () => {
|
||||
const llmCall = async ({ onMetrics } = {}) => {
|
||||
if (onMetrics) onMetrics({ latency_ms: 1234, retry_count_internal: 4 });
|
||||
const e = new Error('fetch failed: ECONNRESET'); throw e;
|
||||
};
|
||||
const r = await classify('что-то непонятное вообще', fakeRegistry, { llmCall });
|
||||
expect(r.source).toBe('regex');
|
||||
expect(r.llm_error_type).toBe('econnreset');
|
||||
expect(r.latency_ms).toBe(1234);
|
||||
expect(r.retry_count_internal).toBe(4);
|
||||
});
|
||||
|
||||
it('sets llm_error_type=timeout on AbortError or per-attempt timeout', async () => {
|
||||
const llmCall = async () => {
|
||||
const e = new Error('per-attempt timeout 30000ms'); throw e;
|
||||
};
|
||||
const r = await classify('что-то непонятное вообще', fakeRegistry, { llmCall });
|
||||
expect(r.llm_error_type).toBe('timeout');
|
||||
});
|
||||
|
||||
it('sets llm_error_type=http_4xx on fatal upstream 4xx', async () => {
|
||||
const llmCall = async () => { const e = new Error('Router LLM 401: invalid key'); e.fatal = true; throw e; };
|
||||
const r = await classify('что-то непонятное вообще', fakeRegistry, { llmCall });
|
||||
expect(r.llm_error_type).toBe('http_4xx');
|
||||
});
|
||||
|
||||
it('sets llm_error_type=http_5xx on exhausted retries', async () => {
|
||||
const llmCall = async () => { const e = new Error('Router LLM 503: bad gateway'); throw e; };
|
||||
const r = await classify('что-то непонятное вообще', fakeRegistry, { llmCall });
|
||||
expect(r.llm_error_type).toBe('http_5xx');
|
||||
});
|
||||
|
||||
it('sets llm_error_type=parse_null when llmCall returns null (LLM produced unparseable response)', async () => {
|
||||
// Mocked llmCall returns null without throwing — simulates upstream parse failure
|
||||
// after a successful HTTP exchange. onMetrics still fires from the mocked path.
|
||||
const llmCall = async ({ onMetrics } = {}) => {
|
||||
if (onMetrics) onMetrics({ latency_ms: 800, retry_count_internal: 0 });
|
||||
return null;
|
||||
};
|
||||
const r = await classify('что-то непонятное вообще', fakeRegistry, { llmCall });
|
||||
expect(r.llm_error_type).toBe('parse_null');
|
||||
expect(r.latency_ms).toBe(800);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user