Compare commits
68 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0bf69ce6b5 | |||
| 07747713f0 | |||
| c6d2df908a | |||
| d4ade05446 | |||
| bd7b1d3e0f | |||
| 57e9541775 | |||
| e213f9b01c | |||
| 1d2d43a6f2 | |||
| 1609faee8c | |||
| 3420f46a59 | |||
| b05e31c89c | |||
| 237eae7ee0 | |||
| cb32aa9907 | |||
| 34b85cf5cc | |||
| e2c00d60b1 | |||
| 97938c66b2 | |||
| 9c8db287ad | |||
| b404bf41a8 | |||
| d821bfb235 | |||
| cc149f324d | |||
| 6bd2735973 | |||
| 8c50c6db52 | |||
| 2000985208 | |||
| 544c06a790 | |||
| c67c217e43 | |||
| a24d084c24 | |||
| 88ae0ac348 | |||
| 1107979168 | |||
| 849e467924 | |||
| c959c03f55 | |||
| 893a142812 | |||
| dae2085ea0 | |||
| 048f3ad6a2 | |||
| 8be1db34b8 | |||
| 9e05d8f728 | |||
| 4bb94257cf | |||
| b91b6d5008 | |||
| b822042a66 | |||
| b25aa025e4 | |||
| 635d631eae | |||
| ec21971888 | |||
| 618519c7e8 | |||
| b0cd18d797 | |||
| 30b79c7228 | |||
| 63100decce | |||
| f6421fd61c | |||
| d647bf1858 | |||
| 1f9b51bc39 | |||
| 8a7144892c | |||
| 722f4bb189 | |||
| 417cfcbc37 | |||
| c9b9efd6e4 | |||
| dfae9f760b | |||
| a8996896a8 | |||
| f82c878c60 | |||
| 3c5266c022 | |||
| 9280c48025 | |||
| 84dcf4aab3 | |||
| 80e514f5bb | |||
| f740f6124a | |||
| c86fdfc9eb | |||
| 9f84d9ef09 | |||
| 6d512f5cf3 | |||
| ca52d354f9 | |||
| c805988085 | |||
| 6ac4b1c1b1 | |||
| f172e2a580 | |||
| 4686b36571 |
Binary file not shown.
@@ -45,10 +45,10 @@ jobs:
|
||||
echo "Requested: '$CMD_TRIM'"
|
||||
|
||||
# Group 1 — read-only / dry-run / inspection: всегда разрешены
|
||||
READ_ONLY_RE='^(migrate:status|route:list|schedule:list|queue:listen --help|about|env:show|config:show|cache:table|view:cache|optimize:status|snapshot:backfill( --date=20[2-9][0-9]-[0-1][0-9]-[0-3][0-9])?|scheduler:check-heartbeats|incidents:watch-failures( --threshold-spike=[0-9]+)?( --threshold-daily=[0-9]+)?( --persistent-hours=[0-9]+)?|supplier:rekey-orphans --dry-run|audit:verify-chains|audit:rebuild-chain --partition=[a-z_0-9]+ --from-id=[0-9]+ --dry-run)( *)$'
|
||||
READ_ONLY_RE='^(migrate:status|route:list|schedule:list|queue:listen --help|about|env:show|config:show|cache:table|view:cache|optimize:status|snapshot:backfill( --date=20[2-9][0-9]-[0-1][0-9]-[0-3][0-9])?|scheduler:check-heartbeats|incidents:watch-failures( --threshold-spike=[0-9]+)?( --threshold-daily=[0-9]+)?( --persistent-hours=[0-9]+)?|supplier:rekey-orphans --dry-run|audit:verify-chains|audit:rebuild-chain --partition=[a-z_0-9]+ --from-id=[0-9]+ --dry-run|deals:backfill-region-city --dry-run)( *)$'
|
||||
|
||||
# Group 2 — mutating: требуют confirm_apply=true
|
||||
MUTATING_RE='^(supplier:rekey-orphans|cache:clear|view:clear|config:clear|route:clear|optimize:clear|optimize|queue:restart|partitions:create-months( --months=[0-9]+)?|partitions:drop-old|audit:rebuild-chain --partition=[a-z_0-9]+ --from-id=[0-9]+( --force)?)( *)$'
|
||||
MUTATING_RE='^(supplier:rekey-orphans|cache:clear|view:clear|config:clear|route:clear|optimize:clear|optimize|queue:restart|partitions:create-months( --months=[0-9]+)?|partitions:drop-old|audit:rebuild-chain --partition=[a-z_0-9]+ --from-id=[0-9]+( --force)?|deals:backfill-region-city)( *)$'
|
||||
|
||||
if [[ "$CMD_TRIM" =~ $READ_ONLY_RE ]]; then
|
||||
echo "::notice::Command in read-only whitelist — proceeding."
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
name: Lead region — prod ops
|
||||
|
||||
# Самодостаточный launch-инструмент фичи lead-region-resolution.
|
||||
# Один воркфлоу, переключатель op. НЕ трогает deploy.yml / artisan-run.yml.
|
||||
#
|
||||
# op:
|
||||
# pre-migrate — пред-применить миграцию 2026_05_31_100000 через postgres
|
||||
# superuser (crm_app_user не член crm_migrator → обычный migrate
|
||||
# падает) + пометить применённой, чтобы deploy её пропустил.
|
||||
# set-env — записать DADATA-ключи (из secrets) + LEAD_REGION_RESOLVER_ENABLED
|
||||
# (input flag) в боевой .env, перекэшировать config, рестарт очереди.
|
||||
# fetch-rossvyaz — скачать файл/архив реестра (input url) на прод в /var/www/liderra/rossvyaz.
|
||||
# import — phone-ranges:import (input dry_run) под www-data (DDL-свап идёт
|
||||
# через pgsql_supplier = crm_supplier_worker, член crm_migrator).
|
||||
# smoke — phone-region:smoke --phone=<input phone> под www-data (нужны ключи).
|
||||
#
|
||||
# Secrets: LIDERRA_SSH_KEY, DADATA_API_KEY, DADATA_SECRET.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
op:
|
||||
description: 'Операция'
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- pre-migrate
|
||||
- set-env
|
||||
- fetch-rossvyaz
|
||||
- fetch-via-runner
|
||||
- deliver-from-repo
|
||||
- import
|
||||
- smoke
|
||||
flag:
|
||||
description: 'set-env: LEAD_REGION_RESOLVER_ENABLED'
|
||||
required: false
|
||||
default: 'false'
|
||||
type: choice
|
||||
options:
|
||||
- 'false'
|
||||
- 'true'
|
||||
url:
|
||||
description: 'fetch-rossvyaz: прямая ссылка на CSV/ZIP реестра Россвязи'
|
||||
required: false
|
||||
type: string
|
||||
dir:
|
||||
description: 'import: каталог с CSV на проде'
|
||||
required: false
|
||||
default: '/var/www/liderra/rossvyaz'
|
||||
type: string
|
||||
dry_run:
|
||||
description: 'import: только staging без swap'
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
force:
|
||||
description: 'import: принудительно (--force, игнорировать «реестр идентичен»)'
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
phone:
|
||||
description: 'smoke: телефон'
|
||||
required: false
|
||||
default: '79161234567'
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
op:
|
||||
name: ${{ github.event.inputs.op }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
concurrency:
|
||||
group: liderra-prod-deploy
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
LIDERRA_HOST: 111.88.246.137
|
||||
LIDERRA_USER: ubuntu
|
||||
APP_DIR: /var/www/liderra/app
|
||||
OP: ${{ github.event.inputs.op }}
|
||||
FLAG: ${{ github.event.inputs.flag }}
|
||||
URL: ${{ github.event.inputs.url }}
|
||||
DIR: ${{ github.event.inputs.dir }}
|
||||
DRY: ${{ github.event.inputs.dry_run }}
|
||||
FORCE: ${{ github.event.inputs.force }}
|
||||
PHONE: ${{ github.event.inputs.phone }}
|
||||
|
||||
steps:
|
||||
- name: Setup SSH key
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.LIDERRA_SSH_KEY }}" > ~/.ssh/liderra_deploy
|
||||
chmod 600 ~/.ssh/liderra_deploy
|
||||
ssh-keyscan -H "${LIDERRA_HOST}" >> ~/.ssh/known_hosts 2>/dev/null
|
||||
|
||||
- name: Checkout repo (for deliver-from-repo)
|
||||
if: ${{ github.event.inputs.op == 'deliver-from-repo' }}
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: op=pre-migrate (superuser DDL + mark applied)
|
||||
if: ${{ github.event.inputs.op == 'pre-migrate' }}
|
||||
run: |
|
||||
SQL_B64=$(cat <<'SQLEOF' | base64 -w0
|
||||
BEGIN;
|
||||
-- 1. phone_ranges_imports (FK target — создаём первым)
|
||||
CREATE TABLE phone_ranges_imports (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
imported_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
source_url TEXT NOT NULL,
|
||||
rows_inserted INTEGER NOT NULL DEFAULT 0,
|
||||
rows_updated INTEGER NOT NULL DEFAULT 0,
|
||||
checksum_sha256 TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'in_progress'
|
||||
CHECK (status IN ('in_progress','completed','failed','rolled_back')),
|
||||
error TEXT,
|
||||
completed_at TIMESTAMPTZ
|
||||
);
|
||||
COMMENT ON TABLE phone_ranges_imports IS
|
||||
'Журнал импортов реестра Россвязи (idempotency по checksum_sha256, atomic-swap откат).';
|
||||
|
||||
-- 2. phone_ranges (реестр диапазонов; SaaS-level, без RLS — публичные данные)
|
||||
CREATE TABLE phone_ranges (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
def_code SMALLINT NOT NULL,
|
||||
from_num BIGINT NOT NULL,
|
||||
to_num BIGINT NOT NULL,
|
||||
operator TEXT NOT NULL,
|
||||
region TEXT NOT NULL,
|
||||
region_normalized TEXT,
|
||||
subject_code SMALLINT,
|
||||
imported_at TIMESTAMPTZ NOT NULL,
|
||||
import_id BIGINT NOT NULL REFERENCES phone_ranges_imports(id),
|
||||
CONSTRAINT chk_phone_ranges_def_code CHECK (def_code BETWEEN 300 AND 999),
|
||||
CONSTRAINT chk_phone_ranges_subject_code CHECK (subject_code IS NULL OR subject_code BETWEEN 1 AND 89),
|
||||
CONSTRAINT chk_phone_ranges_range_valid CHECK (from_num <= to_num)
|
||||
);
|
||||
CREATE INDEX idx_phone_ranges_lookup ON phone_ranges (def_code, from_num, to_num);
|
||||
COMMENT ON TABLE phone_ranges IS
|
||||
'Реестр диапазонов нумерации Россвязи (rossvyaz.gov.ru). Локальный fallback для LeadRegionResolver.';
|
||||
|
||||
GRANT SELECT ON phone_ranges, phone_ranges_imports TO crm_app_user, crm_supplier_worker;
|
||||
|
||||
-- 3. lead_region_resolution_log (SaaS-level, партиционирован по received_at)
|
||||
CREATE TABLE lead_region_resolution_log (
|
||||
id BIGSERIAL,
|
||||
supplier_lead_id BIGINT NOT NULL,
|
||||
received_at TIMESTAMPTZ NOT NULL,
|
||||
phone_masked TEXT NOT NULL,
|
||||
subject_code_resolved SMALLINT,
|
||||
subject_code_from_tag SMALLINT,
|
||||
region_source TEXT NOT NULL
|
||||
CHECK (region_source IN ('dadata','rossvyaz','tag','unknown')),
|
||||
dadata_qc SMALLINT,
|
||||
dadata_provider TEXT,
|
||||
dadata_type TEXT,
|
||||
dadata_response_masked JSONB,
|
||||
rossvyaz_matched BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
actual_subject_code SMALLINT
|
||||
CHECK (actual_subject_code IS NULL OR actual_subject_code BETWEEN 1 AND 89),
|
||||
substituted_subject_code SMALLINT
|
||||
CHECK (substituted_subject_code IS NULL OR substituted_subject_code BETWEEN 1 AND 89),
|
||||
routing_step SMALLINT
|
||||
CHECK (routing_step IS NULL OR routing_step BETWEEN 1 AND 3),
|
||||
phone_operator TEXT,
|
||||
cache_hit BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
duration_ms INTEGER,
|
||||
resolved_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (id, received_at)
|
||||
) PARTITION BY RANGE (received_at);
|
||||
|
||||
CREATE INDEX idx_lrrl_lead_id ON lead_region_resolution_log (supplier_lead_id);
|
||||
CREATE INDEX idx_lrrl_source ON lead_region_resolution_log (region_source, received_at);
|
||||
COMMENT ON TABLE lead_region_resolution_log IS
|
||||
'Аудит каждого резолва региона лида (источник, qc, оператор, шаг каскада). Партиции помесячно.';
|
||||
|
||||
GRANT SELECT, INSERT ON lead_region_resolution_log TO crm_supplier_worker;
|
||||
GRANT SELECT ON lead_region_resolution_log TO crm_app_user;
|
||||
|
||||
CREATE TABLE lead_region_resolution_log_y2026_m05
|
||||
PARTITION OF lead_region_resolution_log
|
||||
FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
|
||||
CREATE TABLE lead_region_resolution_log_y2026_m06
|
||||
PARTITION OF lead_region_resolution_log
|
||||
FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');
|
||||
|
||||
-- 4. supplier_leads: +4 колонки
|
||||
ALTER TABLE supplier_leads
|
||||
ADD COLUMN resolved_subject_code SMALLINT
|
||||
CHECK (resolved_subject_code IS NULL OR resolved_subject_code BETWEEN 1 AND 89),
|
||||
ADD COLUMN region_source TEXT
|
||||
CHECK (region_source IN ('dadata','rossvyaz','tag','unknown')),
|
||||
ADD COLUMN dadata_qc SMALLINT,
|
||||
ADD COLUMN phone_operator TEXT;
|
||||
|
||||
-- 5. deals: +2 колонки
|
||||
ALTER TABLE deals
|
||||
ADD COLUMN phone_operator TEXT,
|
||||
ADD COLUMN region_substituted BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
-- ownership как у миграции (она шла бы под crm_migrator)
|
||||
ALTER TABLE phone_ranges_imports OWNER TO crm_migrator;
|
||||
ALTER TABLE phone_ranges OWNER TO crm_migrator;
|
||||
ALTER TABLE lead_region_resolution_log OWNER TO crm_migrator;
|
||||
ALTER TABLE lead_region_resolution_log_y2026_m05 OWNER TO crm_migrator;
|
||||
ALTER TABLE lead_region_resolution_log_y2026_m06 OWNER TO crm_migrator;
|
||||
|
||||
-- retention (system_settings, 12 мес)
|
||||
INSERT INTO system_settings (key, value, type, description, updated_at)
|
||||
SELECT 'partition_retention_months_lead_region_resolution_log', '12', 'int',
|
||||
'Retention в месяцах для lead_region_resolution_log (~365 дней)', NOW()
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM system_settings
|
||||
WHERE key = 'partition_retention_months_lead_region_resolution_log');
|
||||
COMMIT;
|
||||
SQLEOF
|
||||
)
|
||||
ssh -i ~/.ssh/liderra_deploy "${LIDERRA_USER}@${LIDERRA_HOST}" "SQL_B64='$SQL_B64' bash -s" <<'REMOTE' | tee /tmp/op.log
|
||||
set -euo pipefail
|
||||
MIG_NAME='2026_05_31_100000_create_phone_ranges_and_resolution_log'
|
||||
ALREADY=$(sudo -u postgres psql -d liderra -tAc "SELECT 1 FROM migrations WHERE migration='${MIG_NAME}' LIMIT 1")
|
||||
if [ "${ALREADY}" = "1" ]; then
|
||||
echo "Migration ${MIG_NAME} уже применена — пропускаю."
|
||||
exit 0
|
||||
fi
|
||||
TABLE_EXISTS=$(sudo -u postgres psql -d liderra -tAc "SELECT 1 FROM information_schema.tables WHERE table_name='phone_ranges' LIMIT 1")
|
||||
if [ "${TABLE_EXISTS}" != "1" ]; then
|
||||
echo "Применяю lead-region DDL через postgres superuser..."
|
||||
echo "$SQL_B64" | base64 -d | sudo -u postgres psql -d liderra -v ON_ERROR_STOP=1
|
||||
else
|
||||
echo "Таблица phone_ranges уже существует — только помечаю миграцию."
|
||||
fi
|
||||
NEXT_BATCH=$(sudo -u postgres psql -d liderra -tAc "SELECT COALESCE(MAX(batch),0)+1 FROM migrations")
|
||||
sudo -u postgres psql -d liderra -c \
|
||||
"INSERT INTO migrations (migration, batch) SELECT '${MIG_NAME}', ${NEXT_BATCH} WHERE NOT EXISTS (SELECT 1 FROM migrations WHERE migration='${MIG_NAME}')"
|
||||
echo "Помечено ${MIG_NAME} применённой (batch ${NEXT_BATCH})."
|
||||
echo "=== Проверка таблиц ==="
|
||||
sudo -u postgres psql -d liderra -c "\dt phone_ranges|phone_ranges_imports|lead_region_resolution_log" || true
|
||||
REMOTE
|
||||
|
||||
- name: op=set-env (keys from secrets + flag → prod .env)
|
||||
if: ${{ github.event.inputs.op == 'set-env' }}
|
||||
env:
|
||||
DK: ${{ secrets.DADATA_API_KEY }}
|
||||
DS: ${{ secrets.DADATA_SECRET }}
|
||||
run: |
|
||||
DK_B64=$(printf '%s' "$DK" | base64 -w0)
|
||||
DS_B64=$(printf '%s' "$DS" | base64 -w0)
|
||||
ssh -i ~/.ssh/liderra_deploy "${LIDERRA_USER}@${LIDERRA_HOST}" \
|
||||
"DK_B64='$DK_B64' DS_B64='$DS_B64' FLAG='$FLAG' APP_DIR='$APP_DIR' bash -s" <<'REMOTE' | tee /tmp/op.log
|
||||
set -euo pipefail
|
||||
ENV="${APP_DIR}/.env"
|
||||
DK=$(echo "$DK_B64" | base64 -d)
|
||||
DS=$(echo "$DS_B64" | base64 -d)
|
||||
upsert() {
|
||||
local key="$1" val="$2"
|
||||
sudo sed -i "/^${key}=/d" "$ENV"
|
||||
echo "${key}=${val}" | sudo tee -a "$ENV" >/dev/null
|
||||
}
|
||||
upsert DADATA_API_KEY "$DK"
|
||||
upsert DADATA_SECRET "$DS"
|
||||
upsert LEAD_REGION_RESOLVER_ENABLED "$FLAG"
|
||||
cd "$APP_DIR"
|
||||
sudo -u www-data php artisan config:clear
|
||||
sudo -u www-data php artisan config:cache
|
||||
sudo systemctl restart liderra-queue
|
||||
echo "set-env готово: flag=${FLAG}, ключи записаны."
|
||||
echo "=== Проверка (значения скрыты) ==="
|
||||
sudo grep -E '^(DADATA_API_KEY|DADATA_SECRET|LEAD_REGION_RESOLVER_ENABLED)=' "$ENV" | sed -E 's/=(.).*/=\1***/'
|
||||
echo "=== queue status ==="
|
||||
systemctl is-active liderra-queue || true
|
||||
REMOTE
|
||||
|
||||
- name: op=fetch-rossvyaz (download registry on prod)
|
||||
if: ${{ github.event.inputs.op == 'fetch-rossvyaz' }}
|
||||
run: |
|
||||
# Пустой url → качаем все 4 официальных файла Минцифры за один прогон.
|
||||
# Непустой url → качаем только его (ручной режим).
|
||||
ssh -i ~/.ssh/liderra_deploy "${LIDERRA_USER}@${LIDERRA_HOST}" \
|
||||
"URL='$URL' bash -s" <<'REMOTE' | tee /tmp/op.log
|
||||
set -euo pipefail
|
||||
DEST=/var/www/liderra/rossvyaz
|
||||
sudo mkdir -p "$DEST"
|
||||
cd "$DEST"
|
||||
if [ -n "$URL" ]; then
|
||||
URLS="$URL"
|
||||
else
|
||||
URLS="https://opendata.digital.gov.ru/downloads/DEF-9xx.csv
|
||||
https://opendata.digital.gov.ru/downloads/ABC-3xx.csv
|
||||
https://opendata.digital.gov.ru/downloads/ABC-4xx.csv
|
||||
https://opendata.digital.gov.ru/downloads/ABC-8xx.csv"
|
||||
fi
|
||||
for U in $URLS; do
|
||||
FNAME=$(basename "${U%%\?*}")
|
||||
[ -n "$FNAME" ] || FNAME="rossvyaz-download"
|
||||
echo "Скачиваю $U -> $FNAME"
|
||||
sudo curl -fSL --retry 3 --retry-delay 2 -e 'https://opendata.digital.gov.ru/registry/numeric/downloads/' -H 'Accept: text/csv,application/csv,application/octet-stream,*/*' -H 'Accept-Language: ru-RU,ru;q=0.9' -A 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36' -o "$FNAME" "$U"
|
||||
case "$FNAME" in
|
||||
*.zip|*.ZIP) echo "Распаковываю zip..."; sudo unzip -o "$FNAME" ;;
|
||||
esac
|
||||
done
|
||||
sudo chown -R www-data:www-data "$DEST"
|
||||
echo "=== Содержимое $DEST ==="
|
||||
ls -lh "$DEST"
|
||||
FIRST_CSV=$(ls "$DEST"/DEF-9xx.csv "$DEST"/*.csv "$DEST"/*.CSV 2>/dev/null | head -1 || true)
|
||||
if [ -n "$FIRST_CSV" ]; then
|
||||
echo "=== Первые строки $FIRST_CSV (cp1251→utf8) ==="
|
||||
sudo head -3 "$FIRST_CSV" | iconv -f cp1251 -t utf-8 2>/dev/null || sudo head -3 "$FIRST_CSV"
|
||||
fi
|
||||
REMOTE
|
||||
|
||||
- name: op=fetch-via-runner (download on runner, ship to prod)
|
||||
if: ${{ github.event.inputs.op == 'fetch-via-runner' }}
|
||||
run: |
|
||||
mkdir -p /tmp/rv && cd /tmp/rv && rm -f /tmp/rv/*.csv
|
||||
for U in https://opendata.digital.gov.ru/downloads/DEF-9xx.csv https://opendata.digital.gov.ru/downloads/ABC-3xx.csv https://opendata.digital.gov.ru/downloads/ABC-4xx.csv https://opendata.digital.gov.ru/downloads/ABC-8xx.csv; do
|
||||
FN=$(basename "${U%%\?*}")
|
||||
echo "runner: скачиваю $U -> $FN"
|
||||
curl -fSL --retry 3 --retry-delay 2 -e 'https://opendata.digital.gov.ru/registry/numeric/downloads/' -H 'Accept: text/csv,application/csv,*/*' -A 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36' -o "$FN" "$U"
|
||||
done
|
||||
echo "=== скачано на runner ==="
|
||||
ls -lh /tmp/rv | tee /tmp/op.log
|
||||
ssh -i ~/.ssh/liderra_deploy "${LIDERRA_USER}@${LIDERRA_HOST}" 'mkdir -p /tmp/rvup && rm -f /tmp/rvup/*.csv'
|
||||
scp -i ~/.ssh/liderra_deploy /tmp/rv/*.csv "${LIDERRA_USER}@${LIDERRA_HOST}:/tmp/rvup/"
|
||||
ssh -i ~/.ssh/liderra_deploy "${LIDERRA_USER}@${LIDERRA_HOST}" 'sudo mkdir -p /var/www/liderra/rossvyaz && sudo mv /tmp/rvup/*.csv /var/www/liderra/rossvyaz/ && sudo chown -R www-data:www-data /var/www/liderra/rossvyaz && echo "=== на проде /var/www/liderra/rossvyaz ===" && ls -lh /var/www/liderra/rossvyaz' | tee -a /tmp/op.log
|
||||
|
||||
- name: op=deliver-from-repo (scp repo CSV/ZIP to prod, unzip there)
|
||||
if: ${{ github.event.inputs.op == 'deliver-from-repo' }}
|
||||
run: |
|
||||
# Ищем файлы реестра где угодно (корень или папка), .csv или .zip
|
||||
mapfile -t FILES < <(find . -maxdepth 2 -type f \( \( -iname 'DEF-9xx*' -o -iname 'ABC-3xx*' -o -iname 'ABC-4xx*' -o -iname 'ABC-8xx*' \) -iname '*.csv' -o -iname '*.zip' \) ! -path './.git/*')
|
||||
if [ ${#FILES[@]} -eq 0 ]; then
|
||||
echo "::error::Не нашёл файлов реестра (DEF-9xx/ABC-*.csv|zip) ни в корне, ни в rossvyaz-data/. Проверь, что они закоммичены в репозиторий."; exit 1
|
||||
fi
|
||||
echo "=== файлы в репозитории (rossvyaz-data/) ==="
|
||||
ls -lh "${FILES[@]}" | tee /tmp/op.log
|
||||
ssh -i ~/.ssh/liderra_deploy "${LIDERRA_USER}@${LIDERRA_HOST}" 'mkdir -p /tmp/rvup && rm -f /tmp/rvup/*'
|
||||
scp -i ~/.ssh/liderra_deploy "${FILES[@]}" "${LIDERRA_USER}@${LIDERRA_HOST}:/tmp/rvup/"
|
||||
ssh -i ~/.ssh/liderra_deploy "${LIDERRA_USER}@${LIDERRA_HOST}" '
|
||||
cd /tmp/rvup
|
||||
for z in *.zip *.ZIP; do if [ -e "$z" ]; then echo "распаковываю $z"; unzip -o "$z"; rm -f "$z"; fi; done
|
||||
sudo mkdir -p /var/www/liderra/rossvyaz
|
||||
find . -iname "*.csv" -exec sudo mv {} /var/www/liderra/rossvyaz/ \;
|
||||
sudo chown -R www-data:www-data /var/www/liderra/rossvyaz
|
||||
echo "=== на проде /var/www/liderra/rossvyaz ==="
|
||||
ls -lh /var/www/liderra/rossvyaz
|
||||
' | tee -a /tmp/op.log
|
||||
|
||||
- name: op=import (phone-ranges:import)
|
||||
if: ${{ github.event.inputs.op == 'import' }}
|
||||
run: |
|
||||
DRY_FLAG=""
|
||||
if [ "${DRY}" = "true" ]; then DRY_FLAG="--dry-run"; fi
|
||||
FORCE_FLAG=""
|
||||
if [ "${FORCE}" = "true" ]; then FORCE_FLAG="--force"; fi
|
||||
ssh -i ~/.ssh/liderra_deploy "${LIDERRA_USER}@${LIDERRA_HOST}" \
|
||||
"APP_DIR='$APP_DIR' DIR='$DIR' DRY_FLAG='$DRY_FLAG' FORCE_FLAG='$FORCE_FLAG' bash -s" <<'REMOTE' | tee /tmp/op.log
|
||||
set -e
|
||||
cd "$APP_DIR"
|
||||
echo "=== phone-ranges:import --dir=${DIR} ${DRY_FLAG} ${FORCE_FLAG} ==="
|
||||
sudo -u www-data php artisan phone-ranges:import --dir="$DIR" $DRY_FLAG $FORCE_FLAG 2>&1
|
||||
echo "=== Счётчики ==="
|
||||
sudo -u postgres psql -d liderra -c "SELECT count(*) AS phone_ranges FROM phone_ranges" 2>&1 || true
|
||||
# staging-счётчик: 2 отдельных запроса, чтобы Postgres не парсил
|
||||
# подзапрос к phone_ranges_staging, когда таблица уже свапнута (иначе
|
||||
# ERROR relation "phone_ranges_staging" does not exist даже в ветке CASE).
|
||||
STAGING_EXISTS=$(sudo -u postgres psql -d liderra -tAc "SELECT to_regclass('phone_ranges_staging') IS NOT NULL")
|
||||
if [ "$STAGING_EXISTS" = "t" ]; then
|
||||
sudo -u postgres psql -d liderra -c "SELECT count(*) AS staging_rows FROM phone_ranges_staging" 2>&1 || true
|
||||
else
|
||||
echo "staging: отсутствует (после свапа — норма)"
|
||||
fi
|
||||
echo "=== Последний импорт ==="
|
||||
sudo -u postgres psql -d liderra -c \
|
||||
"SELECT id, status, rows_inserted, rows_updated, imported_at FROM phone_ranges_imports ORDER BY id DESC LIMIT 3" 2>&1 || true
|
||||
REMOTE
|
||||
|
||||
- name: op=smoke (phone-region:smoke)
|
||||
if: ${{ github.event.inputs.op == 'smoke' }}
|
||||
run: |
|
||||
ssh -i ~/.ssh/liderra_deploy "${LIDERRA_USER}@${LIDERRA_HOST}" \
|
||||
"APP_DIR='$APP_DIR' PHONE='$PHONE' bash -s" <<'REMOTE' | tee /tmp/op.log
|
||||
set -e
|
||||
cd "$APP_DIR"
|
||||
echo "=== phone-region:smoke --phone=${PHONE} ==="
|
||||
sudo -u www-data php artisan phone-region:smoke --phone="$PHONE" 2>&1
|
||||
REMOTE
|
||||
|
||||
- name: Print summary
|
||||
if: always()
|
||||
run: |
|
||||
{
|
||||
echo "## lead-region-ops: \`${OP}\`"
|
||||
echo
|
||||
echo '```'
|
||||
cat /tmp/op.log 2>/dev/null || echo "(нет вывода)"
|
||||
echo '```'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Cleanup SSH key
|
||||
if: always()
|
||||
run: rm -f ~/.ssh/liderra_deploy
|
||||
@@ -54,32 +54,7 @@
|
||||
},
|
||||
"comment": "A3 integration-tooling #47 — OpenAPI MCP (ivo-toby/mcp-openapi-server, @ivotoby/openapi-mcp-server v1.14.0, MIT). Exposes Лидерра REST API endpoints (docs/api/openapi.yaml) as MCP tools. Config via env-vars API_BASE_URL + OPENAPI_SPEC_PATH (stdio transport default). READ scope: API discovery/introspection for Claude Code. Формализован в Tooling §4.22, PSR_v1 R10.1 блок 3, Pravila §13.2."
|
||||
},
|
||||
"marketing-metrika": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "github:atomkraft/yandex-metrika-mcp"],
|
||||
"env": {
|
||||
"YANDEX_OAUTH_TOKEN": "${YANDEX_OAUTH_TOKEN}"
|
||||
},
|
||||
"comment": "C1 marketing-tooling #78 — Yandex Metrika MCP (vetted source: github:atomkraft/yandex-metrika-mcp, MIT — выбран по IS9-вету из 3 кандидатов, см. docs/security/marketing-vet.md). READ-ONLY аналитика: посещаемость, источники трафика, конверсии. Env: YANDEX_OAUTH_TOKEN — OAuth-токен с правами read-only. Постура IS9: READ-ONLY, мутации API Метрики не задействуются. Tooling §4.53. docs/marketing/README.md."
|
||||
},
|
||||
"marketing-wordstat": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "github:SvechaPVL/yandex-mcp"],
|
||||
"env": {
|
||||
"YANDEX_OAUTH_TOKEN": "${YANDEX_OAUTH_TOKEN}"
|
||||
},
|
||||
"comment": "C1 marketing-tooling #79 — Yandex Direct+Wordstat MCP (vetted source: github:SvechaPVL/yandex-mcp, MIT — выбран по IS9-вету, см. docs/security/marketing-vet.md). Репозиторий отдаёт 128 tools (Direct + Wordstat + Метрика); по IS9-условию используются ТОЛЬКО Wordstat-инструменты для подбора ключевых слов и оценки спроса — Direct-мутации (создание/правка кампаний, изменение ставок) поведенчески запрещены через marketing-ru #77 и MKT8 (никаких автоматических трат рекламного бюджета). Env: YANDEX_OAUTH_TOKEN с минимальным scope. Tooling §4.54. docs/marketing/README.md."
|
||||
},
|
||||
"marketing-telegram": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "github:chigwell/telegram-mcp"],
|
||||
"env": {
|
||||
"TELEGRAM_API_ID": "${TELEGRAM_API_ID}",
|
||||
"TELEGRAM_API_HASH": "${TELEGRAM_API_HASH}",
|
||||
"TELEGRAM_SESSION_STRING": "${TELEGRAM_SESSION_STRING}"
|
||||
},
|
||||
"comment": "C1 marketing-tooling #80 — Telegram MCP (chigwell/telegram-mcp, Apache-2.0, GitHub-only — не npm). Работа с Telegram-каналами и чатами Лидерры: публикация, планирование, аналитика. Env: TELEGRAM_API_ID + TELEGRAM_API_HASH (получить на https://my.telegram.org/apps) + TELEGRAM_SESSION_STRING (генерируется один раз через GramJS/Telethon, хранить в .env.local gitignored). ОБЯЗАТЕЛЬНО: выделенный Telegram-аккаунт для Лидерры, не личный (IS9-постура MKT8). Tooling §4.51. docs/marketing/README.md."
|
||||
},
|
||||
"_disabled_marketing_servers_note": "ОТКЛЮЧЕНЫ 2026-05-31 (владелец: «отрежь маркетинг»). Причина: их авто-генерируемые схемы (особенно wordstat — 128 tools из Яндекс.Директа) — главный подозреваемый в API 400 tools.110/113, ронявшем субагентов при bulk-load всех инструментов (subagent-driven-development). Серверы off-phase и без OAuth-токенов всё равно не стартовали. Полный конфиг — в git до этого коммита. Чтобы вернуть, восстановить три блока mcpServers: marketing-metrika (npx -y github:atomkraft/yandex-metrika-mcp; env YANDEX_OAUTH_TOKEN; READ-ONLY; Tooling §4.53), marketing-wordstat (npx -y github:SvechaPVL/yandex-mcp; env YANDEX_OAUTH_TOKEN; ТОЛЬКО Wordstat per IS9/MKT8; Tooling §4.54), marketing-telegram (npx -y github:chigwell/telegram-mcp; env TELEGRAM_API_ID/API_HASH/SESSION_STRING; выделенный аккаунт IS9; Tooling §4.51). См. docs/security/marketing-vet.md и docs/marketing/README.md.",
|
||||
"_comment_postiz_skeleton": "TODO: C1 marketing-tooling #81 — Postiz MCP (gitroomhq/postiz-app self-host + antoniolg/postiz-mcp). Активировать ПОСЛЕ: 1) развернуть Postiz self-hosted (git clone https://github.com/gitroomhq/postiz-app + docker-compose, AGPL-3.0: internal-only, no modifications); 2) провести vet лицензии antoniolg/postiz-mcp (NOT YET VERIFIED — см. docs/marketing/README.md Open vet notes); 3) подключить соцсети в Postiz UI. Будущий entry: \"marketing-postiz\": { \"command\": \"npx\", \"args\": [\"-y\", \"postiz-mcp\"], \"env\": { \"POSTIZ_API_URL\": \"${POSTIZ_API_URL}\", \"POSTIZ_API_KEY\": \"${POSTIZ_API_KEY}\" }, \"comment\": \"C1 #81 post-activation\" }. Tooling §4.52. docs/marketing/README.md."
|
||||
}
|
||||
}
|
||||
|
||||
+69526
File diff suppressed because it is too large
Load Diff
+150000
File diff suppressed because it is too large
Load Diff
+142791
File diff suppressed because it is too large
Load Diff
+73783
File diff suppressed because it is too large
Load Diff
+16985
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Support\RussianRegions;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Одноразовый бэкфилл: проставляет deals.city (имя субъекта) у уже существующих сделок,
|
||||
* у которых city ещё пуст, по resolved_subject_code связанного лида
|
||||
* (deals → supplier_lead_deliveries → supplier_leads). Идемпотентно (только city IS NULL).
|
||||
*
|
||||
* Запускается через .github/workflows/artisan-run.yml (mutating-whitelist, confirm_apply).
|
||||
* Парная правка для RouteSupplierLeadJob, который заполняет city у новых сделок.
|
||||
*/
|
||||
final class DealsBackfillRegionCityCommand extends Command
|
||||
{
|
||||
protected $signature = 'deals:backfill-region-city {--dry-run : Только посчитать, ничего не записывать}';
|
||||
|
||||
protected $description = 'Дозаполнить deals.city именем региона по resolved_subject_code лида (одноразовый бэкфилл)';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
// BYPASSRLS-роль: бэкфилл идёт по всем тенантам без SET app.current_tenant_id.
|
||||
$conn = DB::connection('pgsql_supplier');
|
||||
$map = RussianRegions::CODE_TO_NAME;
|
||||
|
||||
$rows = $conn->table('deals')
|
||||
->join('supplier_lead_deliveries as dlv', 'dlv.deal_id', '=', 'deals.id')
|
||||
->join('supplier_leads as sl', 'sl.id', '=', 'dlv.supplier_lead_id')
|
||||
->whereNull('deals.city')
|
||||
->whereNotNull('sl.resolved_subject_code')
|
||||
->select('deals.id', 'deals.received_at', 'sl.resolved_subject_code')
|
||||
->get();
|
||||
|
||||
$seen = [];
|
||||
$updated = 0;
|
||||
foreach ($rows as $r) {
|
||||
$dealId = (int) $r->id;
|
||||
if (isset($seen[$dealId])) {
|
||||
continue; // у сделки несколько доставок — обрабатываем один раз
|
||||
}
|
||||
$seen[$dealId] = true;
|
||||
|
||||
$name = $map[(int) $r->resolved_subject_code] ?? null;
|
||||
if ($name === null) {
|
||||
continue; // код вне справочника 1..89 — пропускаем
|
||||
}
|
||||
|
||||
if (! $dryRun) {
|
||||
$conn->table('deals')
|
||||
->where('id', $dealId)
|
||||
->where('received_at', $r->received_at) // partition key
|
||||
->whereNull('city') // идемпотентный страж
|
||||
->update(['city' => $name]);
|
||||
}
|
||||
$updated++;
|
||||
}
|
||||
|
||||
$prefix = $dryRun ? '[dry-run] ' : '';
|
||||
$this->info("{$prefix}deals.city backfill: {$updated} обновлено из ".count($seen).' кандидатов.');
|
||||
Log::info('deals.backfill_region_city', [
|
||||
'updated' => $updated,
|
||||
'candidates' => count($seen),
|
||||
'dry_run' => $dryRun,
|
||||
]);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Support\RussianRegions;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use OpenSpout\Reader\XLSX\Reader as XlsxReader;
|
||||
|
||||
/**
|
||||
* Импорт реестра нумерации Россвязи в `phone_ranges` (spec §6).
|
||||
*
|
||||
* php artisan phone-ranges:import --file=<csv|xlsx> [--force] [--dry-run]
|
||||
* php artisan phone-ranges:import --dir=<dir с пакетом файлов> [...]
|
||||
*
|
||||
* Алгоритм:
|
||||
* 1. Резолв входных файлов (--file | --dir; --url отложен — оператор качает пакет вручную).
|
||||
* 2. Checksum-идемпотентность: совпал с предыдущим `completed` → status='rolled_back', выход.
|
||||
* 3. Парсинг (CSV через str_getcsv ';', XLSX через openspout) → нормализованные строки.
|
||||
* 4. Маппинг region → subject_code через RussianRegions::nameToCode(). Несматчившиеся → лог в error.
|
||||
* 5. Сборка `phone_ranges_staging` (LIKE phone_ranges) + bulk INSERT.
|
||||
* 6. --dry-run → staging остаётся для инспекции, swap НЕ делается, status='rolled_back'.
|
||||
* Иначе → atomic RENAME swap + status='completed'.
|
||||
*
|
||||
* Запись идёт через `pgsql_supplier` (на проде crm_supplier_worker — член crm_migrator,
|
||||
* INHERIT даёт CREATE; SET ROLE crm_migrator выравнивает ownership. На dev/test — postgres superuser).
|
||||
*
|
||||
* NB (swap — operator-validated): committing-swap (шаг 6 else) НЕ покрыт автотестом —
|
||||
* RENAME коммитит и сломал бы общую тестовую БД. Свап проверяется первым реальным
|
||||
* импортом оператора по runbook (Session 6). Тесты покрывают parse/map/dry-run/idempotency.
|
||||
*/
|
||||
class PhoneRangesImportCommand extends Command
|
||||
{
|
||||
/** @var string */
|
||||
protected $signature = 'phone-ranges:import
|
||||
{--file= : Путь к одному CSV/XLSX файлу реестра}
|
||||
{--dir= : Каталог с пакетом файлов реестра (*.csv, *.xlsx)}
|
||||
{--url= : (отложено) URL пакета — скачать вручную и использовать --dir}
|
||||
{--force : Игнорировать checksum-идемпотентность}
|
||||
{--dry-run : Распарсить и собрать staging, но не делать atomic swap}';
|
||||
|
||||
/** @var string */
|
||||
protected $description = 'Импорт реестра нумерации Россвязи в phone_ranges (idempotent, atomic swap)';
|
||||
|
||||
/** Connection для DDL/записи (на проде crm_migrator-capable, на dev/test — superuser fallback). */
|
||||
private const DDL_CONNECTION = 'pgsql_supplier';
|
||||
|
||||
/** Размер пачки для bulk INSERT в staging. */
|
||||
private const INSERT_CHUNK = 1000;
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$files = $this->resolveFiles();
|
||||
if ($files === null) {
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$checksum = $this->computeChecksum($files);
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$force = (bool) $this->option('force');
|
||||
|
||||
// 2. Идемпотентность по checksum (если не --force).
|
||||
if (! $force) {
|
||||
$prev = DB::table('phone_ranges_imports')
|
||||
->where('checksum_sha256', $checksum)
|
||||
->where('status', 'completed')
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
|
||||
if ($prev !== null) {
|
||||
DB::table('phone_ranges_imports')->insert([
|
||||
'source_url' => $this->sourceLabel($files),
|
||||
'checksum_sha256' => $checksum,
|
||||
'status' => 'rolled_back',
|
||||
'rows_inserted' => 0,
|
||||
'rows_updated' => 0,
|
||||
'error' => "Идентично импорту #{$prev->id} (checksum совпал) — пропуск.",
|
||||
'imported_at' => now(),
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
$this->info("Реестр идентичен импорту #{$prev->id} — пропуск (используйте --force для принудительного импорта).");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Журнал импорта (in_progress).
|
||||
$importId = (int) DB::table('phone_ranges_imports')->insertGetId([
|
||||
'source_url' => $this->sourceLabel($files),
|
||||
'checksum_sha256' => $checksum,
|
||||
'status' => 'in_progress',
|
||||
'imported_at' => now(),
|
||||
]);
|
||||
|
||||
try {
|
||||
// 4. Парсинг + маппинг.
|
||||
$unmatched = [];
|
||||
$rows = [];
|
||||
foreach ($files as $file) {
|
||||
foreach ($this->parseFile($file) as $rec) {
|
||||
$regionNormalized = RussianRegions::canonicalRegionName($rec['region']);
|
||||
$subjectCode = $regionNormalized === null
|
||||
? null
|
||||
: (RussianRegions::nameToCode()[$regionNormalized] ?? null);
|
||||
if ($subjectCode === null && trim($rec['region']) !== '') {
|
||||
$unmatched[trim($rec['region'])] = true;
|
||||
}
|
||||
$rows[] = [
|
||||
'def_code' => $rec['def_code'],
|
||||
'from_num' => $rec['from_num'],
|
||||
'to_num' => $rec['to_num'],
|
||||
'operator' => $rec['operator'],
|
||||
'region' => $rec['region'],
|
||||
'region_normalized' => $regionNormalized,
|
||||
'subject_code' => $subjectCode,
|
||||
'imported_at' => now(),
|
||||
'import_id' => $importId,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Сборка staging.
|
||||
$this->buildStaging($rows);
|
||||
|
||||
$unmatchedNote = $unmatched === []
|
||||
? ''
|
||||
: 'Не сопоставлены регионы: '.implode(', ', array_keys($unmatched)).'.';
|
||||
|
||||
if ($dryRun) {
|
||||
DB::table('phone_ranges_imports')->where('id', $importId)->update([
|
||||
'status' => 'rolled_back',
|
||||
'rows_inserted' => count($rows),
|
||||
'error' => trim('dry-run (swap не выполнен). '.$unmatchedNote),
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
$this->info('dry-run: '.count($rows)." строк в phone_ranges_staging, swap не выполнен.");
|
||||
if ($unmatchedNote !== '') {
|
||||
$this->warn($unmatchedNote);
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
// 6. Atomic swap (operator-validated — см. docblock).
|
||||
$this->atomicSwap();
|
||||
|
||||
DB::table('phone_ranges_imports')->where('id', $importId)->update([
|
||||
'status' => 'completed',
|
||||
'rows_inserted' => count($rows),
|
||||
'error' => $unmatchedNote !== '' ? $unmatchedNote : null,
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
$this->info('Импортировано '.count($rows).' строк в phone_ranges (atomic swap выполнен).');
|
||||
if ($unmatchedNote !== '') {
|
||||
$this->warn($unmatchedNote);
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
} catch (\Throwable $e) {
|
||||
DB::table('phone_ranges_imports')->where('id', $importId)->update([
|
||||
'status' => 'failed',
|
||||
'error' => mb_substr($e->getMessage(), 0, 2000),
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
$this->error('Импорт упал: '.$e->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>|null Список файлов или null при ошибке валидации опций.
|
||||
*/
|
||||
private function resolveFiles(): ?array
|
||||
{
|
||||
$file = $this->option('file');
|
||||
$dir = $this->option('dir');
|
||||
$url = $this->option('url');
|
||||
|
||||
if ($url !== null) {
|
||||
$this->error('--url отложен (пакет ~500-600 файлов). Скачайте вручную и используйте --dir.');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($file !== null) {
|
||||
if (! is_file($file)) {
|
||||
$this->error("Файл не найден: {$file}");
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return [$file];
|
||||
}
|
||||
|
||||
if ($dir !== null) {
|
||||
if (! is_dir($dir)) {
|
||||
$this->error("Каталог не найден: {$dir}");
|
||||
|
||||
return null;
|
||||
}
|
||||
$found = glob(rtrim($dir, '/\\').DIRECTORY_SEPARATOR.'*.{csv,xlsx}', GLOB_BRACE) ?: [];
|
||||
if ($found === []) {
|
||||
$this->error("В каталоге нет *.csv / *.xlsx: {$dir}");
|
||||
|
||||
return null;
|
||||
}
|
||||
sort($found);
|
||||
|
||||
return array_values($found);
|
||||
}
|
||||
|
||||
$this->error('Укажите --file=<путь> или --dir=<каталог>.');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $files
|
||||
*/
|
||||
private function computeChecksum(array $files): string
|
||||
{
|
||||
if (count($files) === 1) {
|
||||
return (string) hash_file('sha256', $files[0]);
|
||||
}
|
||||
|
||||
$hashes = array_map(static fn (string $f): string => (string) hash_file('sha256', $f), $files);
|
||||
sort($hashes);
|
||||
|
||||
return hash('sha256', implode('|', $hashes));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $files
|
||||
*/
|
||||
private function sourceLabel(array $files): string
|
||||
{
|
||||
return $this->option('url')
|
||||
?? $this->option('dir')
|
||||
?? ($files[0] ?? 'unknown');
|
||||
}
|
||||
|
||||
/**
|
||||
* Парсит один файл реестра в нормализованные строки.
|
||||
*
|
||||
* @return list<array{def_code:int, from_num:int, to_num:int, operator:string, region:string}>
|
||||
*/
|
||||
private function parseFile(string $path): array
|
||||
{
|
||||
$ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
|
||||
|
||||
return $ext === 'xlsx'
|
||||
? $this->parseXlsx($path)
|
||||
: $this->parseCsv($path);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{def_code:int, from_num:int, to_num:int, operator:string, region:string}>
|
||||
*/
|
||||
private function parseCsv(string $path): array
|
||||
{
|
||||
$content = (string) file_get_contents($path);
|
||||
// BOM strip + split строк (CRLF/CR/LF).
|
||||
$content = preg_replace('/^\xEF\xBB\xBF/', '', $content) ?? $content;
|
||||
$lines = preg_split('/\r\n|\r|\n/', rtrim($content)) ?: [];
|
||||
if ($lines === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$header = str_getcsv((string) array_shift($lines), ';');
|
||||
$cols = $this->resolveColumns($header);
|
||||
|
||||
$out = [];
|
||||
foreach ($lines as $line) {
|
||||
if (trim($line) === '') {
|
||||
continue;
|
||||
}
|
||||
$cells = str_getcsv($line, ';');
|
||||
$rec = $this->mapCells($cells, $cols);
|
||||
if ($rec !== null) {
|
||||
$out[] = $rec;
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Парсинг XLSX через openspout (operator-real-files; CSV-ветка покрыта тестом).
|
||||
*
|
||||
* @return list<array{def_code:int, from_num:int, to_num:int, operator:string, region:string}>
|
||||
*/
|
||||
private function parseXlsx(string $path): array
|
||||
{
|
||||
$reader = new XlsxReader();
|
||||
$reader->open($path);
|
||||
|
||||
$out = [];
|
||||
$cols = null;
|
||||
foreach ($reader->getSheetIterator() as $sheet) {
|
||||
foreach ($sheet->getRowIterator() as $row) {
|
||||
$cells = array_map(static fn ($c): string => (string) $c, $row->toArray());
|
||||
if ($cols === null) {
|
||||
$cols = $this->resolveColumns($cells);
|
||||
|
||||
continue;
|
||||
}
|
||||
$rec = $this->mapCells($cells, $cols);
|
||||
if ($rec !== null) {
|
||||
$out[] = $rec;
|
||||
}
|
||||
}
|
||||
break; // только первый лист
|
||||
}
|
||||
$reader->close();
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Сопоставляет индексы колонок по заголовку (русские имена Россвязи) с позиционным fallback.
|
||||
*
|
||||
* @param list<string> $header
|
||||
* @return array{def:int, from:int, to:int, operator:int, region:int}
|
||||
*/
|
||||
private function resolveColumns(array $header): array
|
||||
{
|
||||
$cols = ['def' => 0, 'from' => 1, 'to' => 2, 'operator' => 4, 'region' => 5];
|
||||
|
||||
foreach ($header as $i => $cell) {
|
||||
$n = preg_replace('/[\s\/]+/u', '', mb_strtolower(trim((string) $cell))) ?? '';
|
||||
if (str_contains($n, 'def') || str_contains($n, 'авс')) {
|
||||
$cols['def'] = $i;
|
||||
} elseif ($n === 'от') {
|
||||
$cols['from'] = $i;
|
||||
} elseif ($n === 'до') {
|
||||
$cols['to'] = $i;
|
||||
} elseif (str_contains($n, 'оператор')) {
|
||||
$cols['operator'] = $i;
|
||||
} elseif (str_contains($n, 'регион')) {
|
||||
$cols['region'] = $i;
|
||||
}
|
||||
}
|
||||
|
||||
return $cols;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $cells
|
||||
* @param array{def:int, from:int, to:int, operator:int, region:int} $cols
|
||||
* @return array{def_code:int, from_num:int, to_num:int, operator:string, region:string}|null
|
||||
*/
|
||||
private function mapCells(array $cells, array $cols): ?array
|
||||
{
|
||||
$def = (int) preg_replace('/\D+/', '', $cells[$cols['def']] ?? '');
|
||||
if ($def === 0) {
|
||||
return null; // пустая/битая строка
|
||||
}
|
||||
|
||||
return [
|
||||
'def_code' => $def,
|
||||
'from_num' => (int) preg_replace('/\D+/', '', $cells[$cols['from']] ?? '0'),
|
||||
'to_num' => (int) preg_replace('/\D+/', '', $cells[$cols['to']] ?? '0'),
|
||||
'operator' => trim((string) ($cells[$cols['operator']] ?? '')),
|
||||
'region' => trim((string) ($cells[$cols['region']] ?? '')),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Собирает phone_ranges_staging (LIKE phone_ranges) и заливает строки.
|
||||
*
|
||||
* @param list<array<string, mixed>> $rows
|
||||
*/
|
||||
private function buildStaging(array $rows): void
|
||||
{
|
||||
$c = DB::connection(self::DDL_CONNECTION);
|
||||
$this->elevate($c);
|
||||
|
||||
$c->statement('DROP TABLE IF EXISTS phone_ranges_staging CASCADE');
|
||||
$c->statement('CREATE TABLE phone_ranges_staging (LIKE phone_ranges INCLUDING DEFAULTS INCLUDING CONSTRAINTS)');
|
||||
$c->statement('CREATE INDEX IF NOT EXISTS idx_phone_ranges_staging_lookup ON phone_ranges_staging (def_code, from_num, to_num)');
|
||||
|
||||
foreach (array_chunk($rows, self::INSERT_CHUNK) as $chunk) {
|
||||
$c->table('phone_ranges_staging')->insert($chunk);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomic swap живого phone_ranges на staging (spec §6.2 шаг 6).
|
||||
*
|
||||
* NB: НЕ покрыт автотестом (committing RENAME сломал бы общую тестовую БД).
|
||||
* Проверяется первым реальным импортом оператора (Session 6 runbook).
|
||||
* Сохраняет одну предыдущую версию (phone_ranges_old) для `phone-ranges:rollback`.
|
||||
* GRANT'ы переустанавливаются (RENAME их не переносит); lookup-индекс на новой
|
||||
* таблице носит имя idx_phone_ranges_staging_lookup (косметика — имя занято _old).
|
||||
*/
|
||||
private function atomicSwap(): void
|
||||
{
|
||||
$c = DB::connection(self::DDL_CONNECTION);
|
||||
$this->elevate($c);
|
||||
|
||||
// Транзакция вокруг свапа (spec §6.2): PostgreSQL поддерживает транзакционный
|
||||
// DDL, поэтому DROP+RENAME+RENAME+GRANT атомарны. Обрыв процесса между
|
||||
// переименованиями не оставит phone_ranges несуществующей — откат вернёт
|
||||
// живую таблицу (раньше 4 авто-коммит-statement'а оставляли окно, в котором
|
||||
// Россвязь-lookup падал бы до ручного восстановления).
|
||||
$c->transaction(function () use ($c) {
|
||||
$c->statement('DROP TABLE IF EXISTS phone_ranges_old CASCADE');
|
||||
$c->statement('ALTER TABLE phone_ranges RENAME TO phone_ranges_old');
|
||||
$c->statement('ALTER TABLE phone_ranges_staging RENAME TO phone_ranges');
|
||||
$c->statement('GRANT SELECT ON phone_ranges TO crm_app_user, crm_supplier_worker');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* SET ROLE crm_migrator для корректного ownership на проде; на dev/test роль
|
||||
* отсутствует → RESET и работаем как superuser (зеркало миграционного паттерна).
|
||||
*/
|
||||
private function elevate(\Illuminate\Database\Connection $c): void
|
||||
{
|
||||
try {
|
||||
$c->statement('SET ROLE crm_migrator');
|
||||
$canCreate = $c->selectOne("SELECT has_schema_privilege('crm_migrator', 'public', 'CREATE') AS ok");
|
||||
if (! $canCreate || ! $canCreate->ok) {
|
||||
$c->statement('RESET ROLE');
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// окружение без роли — продолжаем как superuser
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\SupplierLead;
|
||||
use App\Services\LeadRegionResolver;
|
||||
use App\Support\RussianRegions;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* Staging-smoke резолва региона по телефону (spec §9.4): дёргает живой каскад
|
||||
* DaData → Россвязь → tag и печатает решение. В БД ничего НЕ пишет.
|
||||
*
|
||||
* php artisan phone-region:smoke --phone=79161234567 [--tag=Москва]
|
||||
*
|
||||
* Принудительно включает services.dadata.enabled на время прогона (smoke всегда
|
||||
* проверяет полный каскад, независимо от глобального feature-flag). С реальным
|
||||
* DADATA_API_KEY делает платный вызов — запускать осознанно.
|
||||
*/
|
||||
class PhoneRegionSmokeCommand extends Command
|
||||
{
|
||||
/** @var string */
|
||||
protected $signature = 'phone-region:smoke
|
||||
{--phone= : Телефон в формате 7XXXXXXXXXX}
|
||||
{--tag= : Регион-тег поставщика (fallback-слой)}';
|
||||
|
||||
/** @var string */
|
||||
protected $description = 'Прогон резолва региона по телефону (DaData→Россвязь→tag) без записи в БД (staging-smoke)';
|
||||
|
||||
public function handle(LeadRegionResolver $resolver): int
|
||||
{
|
||||
$phone = (string) $this->option('phone');
|
||||
if ($phone === '') {
|
||||
$this->error('Укажите --phone=7XXXXXXXXXX');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
// Smoke всегда прогоняет полный каскад, даже если глобальный флаг выключен.
|
||||
config(['services.dadata.enabled' => true]);
|
||||
|
||||
$lead = new SupplierLead([
|
||||
'phone' => $phone,
|
||||
'raw_payload' => ['tag' => (string) $this->option('tag')],
|
||||
]);
|
||||
|
||||
$r = $resolver->resolve($lead);
|
||||
|
||||
$region = $r->subjectCode !== null
|
||||
? (RussianRegions::CODE_TO_NAME[$r->subjectCode] ?? '?')
|
||||
: '—';
|
||||
|
||||
$this->info('Телефон: '.$this->maskPhone($phone));
|
||||
$this->line('Источник: '.$r->source);
|
||||
$this->line('Субъект: '.($r->subjectCode ?? '—').' ('.$region.')');
|
||||
$this->line('Оператор: '.($r->phoneOperator ?? '—'));
|
||||
$this->line('DaData qc: '.($r->qc ?? '—'));
|
||||
$this->line('Cache hit: '.($r->cacheHit ? 'да' : 'нет'));
|
||||
$this->line('Россвязь: '.($r->rossvyazMatched ? 'совпала' : 'нет'));
|
||||
$this->line('Длит., мс: '.($r->durationMs ?? '—'));
|
||||
$this->newLine();
|
||||
$this->comment('NB: запись в БД НЕ выполнялась (smoke).');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function maskPhone(string $phone): string
|
||||
{
|
||||
$digits = preg_replace('/\D+/', '', $phone) ?? '';
|
||||
if (strlen($digits) < 8) {
|
||||
return '***';
|
||||
}
|
||||
|
||||
return substr($digits, 0, 4).'***'.substr($digits, -4);
|
||||
}
|
||||
}
|
||||
@@ -11,18 +11,22 @@ use App\Models\Project;
|
||||
use App\Models\SupplierLead;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Billing\LedgerService;
|
||||
use App\Services\Dto\RegionResolution;
|
||||
use App\Services\LeadDistributor;
|
||||
use App\Services\LeadRegionResolver;
|
||||
use App\Services\LeadRouter;
|
||||
use App\Services\NotificationService;
|
||||
use App\Services\Pd\PdAuditLogger;
|
||||
use App\Services\RegionTagResolver;
|
||||
use App\Services\SupplierProjects\SupplierProjectResolver;
|
||||
use App\Support\RussianRegions;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable as FoundationQueueable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
@@ -128,7 +132,6 @@ class RouteSupplierLeadJob implements ShouldQueue
|
||||
// Capture original error BEFORE update — $lead->update() mutates
|
||||
// the in-memory model, so $lead->error after update() returns the
|
||||
// suffixed value, breaking debug logs (review fix).
|
||||
// быстрый коммит
|
||||
$originalError = $lead->error;
|
||||
$lead->update([
|
||||
'processed_at' => now(),
|
||||
@@ -148,16 +151,27 @@ class RouteSupplierLeadJob implements ShouldQueue
|
||||
$supplier = $resolver->resolveOrStub($platform, $signalType, $identifier);
|
||||
$lead->update(['supplier_project_id' => $supplier->id]);
|
||||
|
||||
$matched = $router->matchEligibleProjects($supplier);
|
||||
$selected = $distributor->selectRecipients($matched); // cap=3 случайных
|
||||
// Lead region resolution (§3.11): резолв региона ДО routing-цикла, чтобы HTTP-вызов
|
||||
// DaData (~150мс) не висел внутри tenant-транзакции. Резолвер — из контейнера (не 7-й
|
||||
// параметр handle(), чтобы не ломать сигнатуру и существующие вызовы тестов).
|
||||
// RegionTagResolver остаётся в DI-цепочке резолвера (fallback-слой).
|
||||
$resolution = app(LeadRegionResolver::class)->resolve($lead);
|
||||
$lead->update([
|
||||
'resolved_subject_code' => $resolution->subjectCode,
|
||||
'region_source' => $resolution->source,
|
||||
'dadata_qc' => $resolution->qc,
|
||||
'phone_operator' => $resolution->phoneOperator,
|
||||
]);
|
||||
|
||||
$subjectCode = $tagResolver->resolve((string) ($lead->raw_payload['tag'] ?? ''));
|
||||
// Каскад по региону (§3.9): exact → all-RF → fallback. NULL subject_code → шаг 1 пропуск.
|
||||
$matched = $router->matchEligibleProjects($supplier, $resolution->subjectCode);
|
||||
$selected = $distributor->selectRecipients($matched);
|
||||
|
||||
$createdCount = 0;
|
||||
$failures = [];
|
||||
foreach ($selected as $project) {
|
||||
try {
|
||||
if ($this->createDealCopyForProject($lead, $project, $notifier, $ledger, $subjectCode)) {
|
||||
if ($this->createDealCopyForProject($lead, $project, $notifier, $ledger, $resolution)) {
|
||||
$createdCount++;
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
@@ -178,6 +192,10 @@ class RouteSupplierLeadJob implements ShouldQueue
|
||||
);
|
||||
}
|
||||
|
||||
// Аудит резолва региона — одна строка на лид (§3.10/§7.1). Fail-safe: сбой записи
|
||||
// аудит-лога НЕ должен ронять доставку лида (revenue-critical, 30k/сутки).
|
||||
$this->logRegionResolution($lead, $resolution, $selected);
|
||||
|
||||
$lead->update([
|
||||
'processed_at' => now(),
|
||||
'deals_created_count' => $createdCount,
|
||||
@@ -240,10 +258,14 @@ class RouteSupplierLeadJob implements ShouldQueue
|
||||
Project $project,
|
||||
NotificationService $notifier,
|
||||
LedgerService $ledger,
|
||||
?int $subjectCode,
|
||||
RegionResolution $resolution,
|
||||
): bool {
|
||||
// routing_step проставлен LeadRouter'ом на matched-проекте; захватываем ДО
|
||||
// переназначения $project = $lockedProject (fresh query без этого атрибута).
|
||||
$routingStep = (int) ($project->routing_step ?? 1);
|
||||
|
||||
try {
|
||||
return DB::transaction(function () use ($lead, $project, $notifier, $ledger, $subjectCode): bool {
|
||||
return DB::transaction(function () use ($lead, $project, $notifier, $ledger, $resolution, $routingStep): bool {
|
||||
DB::statement("SET LOCAL app.current_tenant_id = '{$project->tenant_id}'");
|
||||
|
||||
/** @var Tenant $tenant */
|
||||
@@ -354,10 +376,21 @@ class RouteSupplierLeadJob implements ShouldQueue
|
||||
// INITIALLY DEFERRED не помогает — проверка падает на COMMIT).
|
||||
// CSV-recovered received_at сохраняем как есть — отличие на минуты
|
||||
// несущественно, чем риск каскадного DELETE lead_charges.
|
||||
// §3.12: при merge обновляем регион/оператора, если webhook-резолв из
|
||||
// источника выше рангом (dadata/rossvyaz), чем tag CSV-восстановления.
|
||||
// deals не хранит region_source (он на supplier_leads + в журнале), поэтому
|
||||
// ранг определяем по факту источника: dadata/rossvyaz всегда достовернее
|
||||
// tag'а, на котором строилась CSV-recovery (RegionResolution::SOURCE_RANK).
|
||||
$mergeUpdate = ['source_crm_id' => $lead->vid, 'updated_at' => now()];
|
||||
if (in_array($resolution->source, ['dadata', 'rossvyaz'], true) && $resolution->subjectCode !== null) {
|
||||
$mergeUpdate['subject_code'] = $resolution->subjectCode;
|
||||
$mergeUpdate['phone_operator'] = $resolution->phoneOperator;
|
||||
$mergeUpdate['city'] = RussianRegions::CODE_TO_NAME[$resolution->subjectCode] ?? null;
|
||||
}
|
||||
DB::table('deals')
|
||||
->where('id', $existingMergeable->id)
|
||||
->where('received_at', $existingMergeable->received_at)
|
||||
->update(['source_crm_id' => $lead->vid, 'updated_at' => now()]);
|
||||
->update($mergeUpdate);
|
||||
|
||||
Log::info('supplier_lead.merged_into_csv_recovered', [
|
||||
'supplier_lead_id' => $lead->id,
|
||||
@@ -394,6 +427,13 @@ class RouteSupplierLeadJob implements ShouldQueue
|
||||
? array_values(array_map('strval', $payload['phones']))
|
||||
: [(string) $lead->phone];
|
||||
|
||||
// §3.10: на шаге 3 (запасной канал) регион сделки подменяется на регион
|
||||
// клиента (первый подписанный субъект из snapshot); настоящий регион —
|
||||
// в lead_region_resolution_log.actual_subject_code. region_substituted флажит подмену.
|
||||
$dealSubjectCode = $routingStep < 3
|
||||
? $resolution->subjectCode
|
||||
: ($this->pickSubstituteRegion((string) ($snapshot->regions ?? '{}')) ?? $resolution->subjectCode);
|
||||
|
||||
$deal = Deal::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'source_crm_id' => $lead->vid,
|
||||
@@ -402,7 +442,14 @@ class RouteSupplierLeadJob implements ShouldQueue
|
||||
'phones' => $phones,
|
||||
'status' => 'new',
|
||||
'received_at' => $receivedAt,
|
||||
'subject_code' => $subjectCode,
|
||||
'subject_code' => $dealSubjectCode,
|
||||
// «Город» (UI deals.city) — человекочитаемое имя НАСТОЯЩЕГО региона лида
|
||||
// по резолву (даже если subject_code подменён на шаге 3). NULL → колонка пустая.
|
||||
'city' => $resolution->subjectCode !== null
|
||||
? (RussianRegions::CODE_TO_NAME[$resolution->subjectCode] ?? null)
|
||||
: null,
|
||||
'phone_operator' => $resolution->phoneOperator,
|
||||
'region_substituted' => $routingStep === 3,
|
||||
]);
|
||||
|
||||
DB::table('supplier_lead_deliveries')
|
||||
@@ -500,6 +547,89 @@ class RouteSupplierLeadJob implements ShouldQueue
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Аудит резолва региона лида — одна строка на лид в lead_region_resolution_log (§7.1).
|
||||
* Fail-safe: сбой записи (например, отсутствие партиции received_at) логируется warning'ом,
|
||||
* но НЕ прерывает доставку (revenue-critical). INSERT через pgsql_supplier (GRANT INSERT
|
||||
* у crm_supplier_worker). Телефон маскируется до INSERT — сырой номер в лог не пишется.
|
||||
*
|
||||
* @param Collection<int, Project> $selected
|
||||
*/
|
||||
private function logRegionResolution(SupplierLead $lead, RegionResolution $resolution, Collection $selected): void
|
||||
{
|
||||
try {
|
||||
$first = $selected->first();
|
||||
$routingStep = $first !== null ? (int) ($first->routing_step ?? 1) : null;
|
||||
$substituted = ($routingStep === 3 && $first !== null)
|
||||
? ($this->pickSubstituteRegion((string) ($first->snapshot_regions ?? '{}')) ?? $resolution->subjectCode)
|
||||
: null;
|
||||
|
||||
$tagCode = app(RegionTagResolver::class)->resolve((string) ($lead->raw_payload['tag'] ?? ''));
|
||||
|
||||
DB::connection(self::DB_CONNECTION)->table('lead_region_resolution_log')->insert([
|
||||
'supplier_lead_id' => $lead->id,
|
||||
'received_at' => $lead->received_at ?? now(),
|
||||
'phone_masked' => $this->maskPhone((string) $lead->phone),
|
||||
'subject_code_resolved' => $resolution->subjectCode,
|
||||
'subject_code_from_tag' => $tagCode,
|
||||
'region_source' => $resolution->source,
|
||||
'dadata_qc' => $resolution->qc,
|
||||
'dadata_provider' => $resolution->phoneOperator,
|
||||
'dadata_type' => null,
|
||||
'dadata_response_masked' => $resolution->dadataResponseMasked !== null
|
||||
? json_encode($resolution->dadataResponseMasked, JSON_UNESCAPED_UNICODE)
|
||||
: null,
|
||||
'rossvyaz_matched' => $resolution->rossvyazMatched,
|
||||
'actual_subject_code' => $resolution->actualSubjectCode,
|
||||
'substituted_subject_code' => $substituted,
|
||||
'routing_step' => $routingStep,
|
||||
'phone_operator' => $resolution->phoneOperator,
|
||||
'cache_hit' => $resolution->cacheHit,
|
||||
'duration_ms' => $resolution->durationMs,
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
Log::warning('lead_region_resolution.log_write_failed', [
|
||||
'supplier_lead_id' => $lead->id,
|
||||
'exception' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Первый код субъекта из PG INT[]-литерала ('{82,83}' → 82; '{}' → null) — регион клиента
|
||||
* для подмены на запасном канале (§3.10).
|
||||
*/
|
||||
private function pickSubstituteRegion(string $regionsLiteral): ?int
|
||||
{
|
||||
return $this->parseSubjectCodes($regionsLiteral)[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<int> '{82,83}' → [82,83]; '{}'/'' → []
|
||||
*/
|
||||
private function parseSubjectCodes(string $regionsLiteral): array
|
||||
{
|
||||
$inner = trim($regionsLiteral, '{}');
|
||||
if ($inner === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_map('intval', explode(',', $inner)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Маскирование телефона для лога (§7.1): первые 4 + последние 4 цифры (7916***4567).
|
||||
*/
|
||||
private function maskPhone(string $phone): string
|
||||
{
|
||||
$digits = preg_replace('/\D+/', '', $phone) ?? '';
|
||||
if (strlen($digits) < 8) {
|
||||
return '***';
|
||||
}
|
||||
|
||||
return substr($digits, 0, 4).'***'.substr($digits, -4);
|
||||
}
|
||||
|
||||
/**
|
||||
* Финальный callback после исчерпания всех ретраев ($tries=3).
|
||||
*
|
||||
|
||||
@@ -61,6 +61,9 @@ class Deal extends Model
|
||||
'is_test',
|
||||
'received_at',
|
||||
'deleted_at',
|
||||
// Lead region resolution (Session 1, 31.05.2026).
|
||||
'phone_operator',
|
||||
'region_substituted',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
@@ -77,6 +80,7 @@ class Deal extends Model
|
||||
'lead_score' => 'decimal:2',
|
||||
'phones' => 'array',
|
||||
'is_test' => 'boolean',
|
||||
'region_substituted' => 'boolean',
|
||||
'assigned_at' => 'datetime',
|
||||
'received_at' => 'datetime',
|
||||
'created_at' => 'datetime',
|
||||
|
||||
@@ -41,6 +41,11 @@ class SupplierLead extends Model
|
||||
'recovered_from_csv_at',
|
||||
'deals_created_count',
|
||||
'error',
|
||||
// Lead region resolution (Session 1, 31.05.2026) — persistent idempotency + display.
|
||||
'resolved_subject_code',
|
||||
'region_source',
|
||||
'dadata_qc',
|
||||
'phone_operator',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
@@ -52,6 +57,8 @@ class SupplierLead extends Model
|
||||
'recovered_from_csv_at' => 'datetime',
|
||||
'vid' => 'integer',
|
||||
'deals_created_count' => 'integer',
|
||||
'resolved_subject_code' => 'integer',
|
||||
'dadata_qc' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\DaData;
|
||||
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
/**
|
||||
* Дневной бюджет на платные вызовы DaData (spec §5.3 / §11).
|
||||
*
|
||||
* Расход копится в копейках под дневным ключом `phone_resolution:dadata:spent_kopecks:<YYYY-MM-DD>`.
|
||||
* `Cache::increment` на redis-сторе атомарен (INCRBY) — корректно при параллельных
|
||||
* RouteSupplierLeadJob. Дневной ключ сам обнуляется со сменой даты; TTL 2 дня чистит старые.
|
||||
*
|
||||
* При canSpend()=false LeadRegionResolver минует DaData и идёт сразу в Россвязь (spec §3.3).
|
||||
*/
|
||||
class DaDataBudgetGuard
|
||||
{
|
||||
public function canSpend(): bool
|
||||
{
|
||||
$capKopecks = ((int) config('services.dadata.daily_cap_rub', 10000)) * 100;
|
||||
|
||||
return $this->spentTodayKopecks() < $capKopecks;
|
||||
}
|
||||
|
||||
public function recordSpend(int $kopecks): void
|
||||
{
|
||||
if ($kopecks <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$key = $this->dailyKey();
|
||||
Cache::add($key, 0, now()->addDays(2));
|
||||
Cache::increment($key, $kopecks);
|
||||
}
|
||||
|
||||
public function spentTodayKopecks(): int
|
||||
{
|
||||
return (int) Cache::get($this->dailyKey(), 0);
|
||||
}
|
||||
|
||||
private function dailyKey(): string
|
||||
{
|
||||
return 'phone_resolution:dadata:spent_kopecks:'.now()->format('Y-m-d');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\DaData;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Не-2xx ответ DaData (после исчерпания retry) или иная ошибка вызова.
|
||||
* LeadRegionResolver ловит её и деградирует на Россвязь (spec §3.3).
|
||||
*/
|
||||
class DaDataException extends RuntimeException {}
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\DaData;
|
||||
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Http\Client\Factory as HttpFactory;
|
||||
|
||||
/**
|
||||
* HTTP-обёртка над DaData clean/phone (spec §3.6).
|
||||
*
|
||||
* POST https://cleaner.dadata.ru/api/v1/clean/phone
|
||||
* Authorization: Token <key> ; X-Secret: <secret> ; body ["<phone>"]
|
||||
*
|
||||
* Retry — только на сетевые ошибки и 5xx (4xx → сразу DaDataException, без retry).
|
||||
* Сеть/таймаут после исчерпания retry → DaDataTimeoutException; 5xx → DaDataException.
|
||||
* Конвенция клиента зеркалит App\Services\Supplier\SupplierPortalClient (inject HttpFactory).
|
||||
*/
|
||||
class DaDataPhoneClient
|
||||
{
|
||||
private const URL = 'https://cleaner.dadata.ru/api/v1/clean/phone';
|
||||
|
||||
public function __construct(
|
||||
private readonly HttpFactory $http,
|
||||
) {}
|
||||
|
||||
public function cleanPhone(string $phone): DaDataPhoneResponse
|
||||
{
|
||||
$cfg = (array) config('services.dadata');
|
||||
$timeoutSec = max(1, (int) round(((int) ($cfg['timeout_ms'] ?? 2000)) / 1000));
|
||||
$attempts = max(1, (int) ($cfg['retries'] ?? 1) + 1);
|
||||
$apiKey = (string) ($cfg['api_key'] ?? '');
|
||||
$secret = (string) ($cfg['secret'] ?? '');
|
||||
|
||||
$lastException = null;
|
||||
|
||||
for ($attempt = 0; $attempt < $attempts; $attempt++) {
|
||||
try {
|
||||
$response = $this->http
|
||||
->asJson()
|
||||
->acceptJson()
|
||||
->timeout($timeoutSec)
|
||||
->withHeaders([
|
||||
'Authorization' => 'Token '.$apiKey,
|
||||
'X-Secret' => $secret,
|
||||
])
|
||||
->post(self::URL, [$phone]);
|
||||
} catch (ConnectionException $e) {
|
||||
$lastException = new DaDataTimeoutException(
|
||||
'DaData connection failed: '.$e->getMessage(), 0, $e,
|
||||
);
|
||||
|
||||
continue; // сеть → retry
|
||||
}
|
||||
|
||||
if ($response->serverError()) {
|
||||
$lastException = new DaDataException('DaData 5xx: HTTP '.$response->status());
|
||||
|
||||
continue; // 5xx → retry
|
||||
}
|
||||
|
||||
if (! $response->successful()) {
|
||||
// 4xx — клиентская ошибка, retry бессмыслен.
|
||||
throw new DaDataException('DaData HTTP '.$response->status().': '.$response->body());
|
||||
}
|
||||
|
||||
return $this->parse($response->json());
|
||||
}
|
||||
|
||||
throw $lastException ?? new DaDataException('DaData failed without a response');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $body декодированный JSON (ожидается массив с одним объектом)
|
||||
*/
|
||||
private function parse($body): DaDataPhoneResponse
|
||||
{
|
||||
$row = (is_array($body) && isset($body[0]) && is_array($body[0])) ? $body[0] : [];
|
||||
|
||||
return new DaDataPhoneResponse(
|
||||
qc: isset($row['qc']) ? (int) $row['qc'] : null,
|
||||
qcConflict: isset($row['qc_conflict']) ? (int) $row['qc_conflict'] : null,
|
||||
type: isset($row['type']) ? (string) $row['type'] : null,
|
||||
phone: isset($row['phone']) ? (string) $row['phone'] : null,
|
||||
provider: isset($row['provider']) ? (string) $row['provider'] : null,
|
||||
region: isset($row['region']) ? (string) $row['region'] : null,
|
||||
city: isset($row['city']) ? (string) $row['city'] : null,
|
||||
timezone: isset($row['timezone']) ? (string) $row['timezone'] : null,
|
||||
raw: $row,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\DaData;
|
||||
|
||||
/**
|
||||
* Распарсенный ответ DaData clean/phone (один номер → один объект), spec §3.6.
|
||||
*/
|
||||
final class DaDataPhoneResponse
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $raw полный сырой объект ответа (для маскированного лога)
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly ?int $qc,
|
||||
public readonly ?int $qcConflict,
|
||||
public readonly ?string $type,
|
||||
public readonly ?string $phone,
|
||||
public readonly ?string $provider,
|
||||
public readonly ?string $region,
|
||||
public readonly ?string $city,
|
||||
public readonly ?string $timezone,
|
||||
public readonly array $raw,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\DaData;
|
||||
|
||||
/**
|
||||
* Код качества (`qc`) ответа DaData clean/phone.
|
||||
*
|
||||
* Семантика DaData:
|
||||
* 0 — телефон распознан уверенно;
|
||||
* 1 — распознан с допущениями (требует проверки);
|
||||
* 2 — пустой / невозможно распознать;
|
||||
* 3 — несколько телефонов в одном поле;
|
||||
* 7 — иностранный номер.
|
||||
*
|
||||
* Решения каскада по qc — в LeadRegionResolver (spec §3.4). Enum используется
|
||||
* для читаемости и tryFrom() при парсинге; необъявленные значения остаются как int.
|
||||
*/
|
||||
enum DaDataQualityCode: int
|
||||
{
|
||||
case RECOGNIZED = 0;
|
||||
case ASSUMPTIONS = 1;
|
||||
case EMPTY = 2;
|
||||
case MULTIPLE = 3;
|
||||
case FOREIGN = 7;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\DaData;
|
||||
|
||||
/**
|
||||
* Сетевая ошибка / таймаут DaData (после исчерпания retry на сетевые сбои).
|
||||
* Подкласс DaDataException — catch(DaDataException) покрывает оба случая.
|
||||
*/
|
||||
class DaDataTimeoutException extends DaDataException {}
|
||||
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Dto;
|
||||
|
||||
use App\Models\SupplierLead;
|
||||
|
||||
/**
|
||||
* Результат резолва региона лида (LeadRegionResolver, spec §3.3).
|
||||
*
|
||||
* `subjectCode` — итоговый код субъекта (используется маршрутизатором);
|
||||
* `actualSubjectCode` — настоящий резолв (для лога actual_subject_code; на этапе
|
||||
* резолва равен subjectCode, подмена региона — концерн RouteSupplierLeadJob §3.10).
|
||||
* `source` ∈ dadata|rossvyaz|tag|unknown — ранг см. SOURCE_RANK (CSV-merge §3.12).
|
||||
*/
|
||||
final readonly class RegionResolution
|
||||
{
|
||||
/** @var array<string, int> ранг источника для CSV-merge (выше = достовернее) */
|
||||
public const SOURCE_RANK = [
|
||||
'dadata' => 4,
|
||||
'rossvyaz' => 3,
|
||||
'tag' => 2,
|
||||
'unknown' => 1,
|
||||
];
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $dadataResponseMasked
|
||||
*/
|
||||
public function __construct(
|
||||
public ?int $subjectCode,
|
||||
public ?int $actualSubjectCode,
|
||||
public string $source,
|
||||
public ?string $phoneOperator,
|
||||
public ?int $qc,
|
||||
public bool $cacheHit,
|
||||
public ?array $dadataResponseMasked,
|
||||
public ?int $durationMs,
|
||||
public bool $rossvyazMatched,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $dadataMasked
|
||||
*/
|
||||
public static function make(
|
||||
?int $subjectCode,
|
||||
string $source,
|
||||
?string $operator = null,
|
||||
?int $qc = null,
|
||||
bool $cacheHit = false,
|
||||
?array $dadataMasked = null,
|
||||
?int $durationMs = null,
|
||||
bool $rossvyazMatched = false,
|
||||
): self {
|
||||
return new self(
|
||||
subjectCode: $subjectCode,
|
||||
actualSubjectCode: $subjectCode,
|
||||
source: $source,
|
||||
phoneOperator: $operator,
|
||||
qc: $qc,
|
||||
cacheHit: $cacheHit,
|
||||
dadataResponseMasked: $dadataMasked,
|
||||
durationMs: $durationMs,
|
||||
rossvyazMatched: $rossvyazMatched,
|
||||
);
|
||||
}
|
||||
|
||||
public static function fromTag(?int $subjectCode): self
|
||||
{
|
||||
return self::make($subjectCode, $subjectCode !== null ? 'tag' : 'unknown');
|
||||
}
|
||||
|
||||
/**
|
||||
* Восстановление из persistent state лида (retry-идемпотентность §3.11) — без DaData-вызова.
|
||||
*/
|
||||
public static function fromSupplierLead(SupplierLead $lead): self
|
||||
{
|
||||
return self::make(
|
||||
subjectCode: $lead->resolved_subject_code !== null ? (int) $lead->resolved_subject_code : null,
|
||||
source: (string) ($lead->region_source ?? 'unknown'),
|
||||
operator: $lead->phone_operator,
|
||||
qc: $lead->dadata_qc !== null ? (int) $lead->dadata_qc : null,
|
||||
);
|
||||
}
|
||||
|
||||
public function withCacheHit(bool $hit): self
|
||||
{
|
||||
return new self(
|
||||
subjectCode: $this->subjectCode,
|
||||
actualSubjectCode: $this->actualSubjectCode,
|
||||
source: $this->source,
|
||||
phoneOperator: $this->phoneOperator,
|
||||
qc: $this->qc,
|
||||
cacheHit: $hit,
|
||||
dadataResponseMasked: null, // §3.11: cache-hit лог не несёт masked-ответ
|
||||
durationMs: $this->durationMs,
|
||||
rossvyazMatched: $this->rossvyazMatched,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Версия для записи в кэш (§7.3): без per-call полей (masked-ответ, длительность, cache-флаг).
|
||||
*/
|
||||
public function forCache(): self
|
||||
{
|
||||
return new self(
|
||||
subjectCode: $this->subjectCode,
|
||||
actualSubjectCode: $this->actualSubjectCode,
|
||||
source: $this->source,
|
||||
phoneOperator: $this->phoneOperator,
|
||||
qc: $this->qc,
|
||||
cacheHit: false,
|
||||
dadataResponseMasked: null,
|
||||
durationMs: null,
|
||||
rossvyazMatched: $this->rossvyazMatched,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Dto;
|
||||
|
||||
/**
|
||||
* Read-only результат поиска по реестру нумерации Россвязи (`phone_ranges`).
|
||||
*
|
||||
* `subjectCode` — код субъекта РФ 1..89 (см. App\Support\RussianRegions) либо
|
||||
* null, если для диапазона он не был промаплен при импорте.
|
||||
*/
|
||||
final readonly class RossvyazRecord
|
||||
{
|
||||
public function __construct(
|
||||
public ?int $subjectCode,
|
||||
public string $region,
|
||||
public string $operator,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\SupplierLead;
|
||||
use App\Services\DaData\DaDataBudgetGuard;
|
||||
use App\Services\DaData\DaDataException;
|
||||
use App\Services\DaData\DaDataPhoneClient;
|
||||
use App\Services\DaData\DaDataPhoneResponse;
|
||||
use App\Services\Dto\RegionResolution;
|
||||
use App\Support\DaDataRegionMap;
|
||||
use Illuminate\Contracts\Cache\Repository as CacheRepository;
|
||||
|
||||
/**
|
||||
* Оркестратор резолва региона лида: DaData → Россвязь → tag-fallback (spec §3.3, §3.4).
|
||||
*
|
||||
* Каскад решений по qc:
|
||||
* qc 0/3 + region не-ambiguous и маппится → source=dadata;
|
||||
* qc 0/3 + region ambiguous/null/не-маппится → Россвязь (оператор от DaData сохраняем, §3.4.1);
|
||||
* qc 1 / таймаут / 5xx / бюджет исчерпан → Россвязь;
|
||||
* qc 2/7 → tag (Россвязь бессмысленна).
|
||||
* Если ничего не дало код → source=tag (или unknown при пустом теге).
|
||||
*
|
||||
* Кэш по sha256(phone) (без сырого номера в ключе/значении, §7.3). Persistent-idempotency
|
||||
* по supplier_leads.resolved_subject_code (защита от двойной оплаты DaData на retry, §3.11).
|
||||
* Feature-flag services.dadata.enabled=false → сразу tag (текущее поведение, §6.5).
|
||||
*/
|
||||
class LeadRegionResolver
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DaDataPhoneClient $dadataClient,
|
||||
private readonly DaDataBudgetGuard $budgetGuard,
|
||||
private readonly RossvyazPrefixLookup $rossvyazLookup,
|
||||
private readonly RegionTagResolver $tagResolver,
|
||||
private readonly CacheRepository $cache,
|
||||
) {}
|
||||
|
||||
public function resolve(SupplierLead $lead): RegionResolution
|
||||
{
|
||||
// Feature-flag: резолвер выключен → текущее tag-поведение.
|
||||
if (! (bool) config('services.dadata.enabled', false)) {
|
||||
return $this->tagFallback($lead, provider: null, qc: null, masked: null, start: null);
|
||||
}
|
||||
|
||||
// Persistent-idempotency: уже резолвили на предыдущем try → без DaData.
|
||||
if ($lead->resolved_subject_code !== null || $lead->region_source !== null) {
|
||||
return RegionResolution::fromSupplierLead($lead);
|
||||
}
|
||||
|
||||
$phone = (string) $lead->phone;
|
||||
if (! preg_match('/^7\d{10}$/', $phone)) {
|
||||
return $this->tagFallback($lead, provider: null, qc: null, masked: null, start: null);
|
||||
}
|
||||
|
||||
$cacheKey = $this->cacheKey($phone);
|
||||
$cached = $this->cache->get($cacheKey);
|
||||
if ($cached instanceof RegionResolution) {
|
||||
return $cached->withCacheHit(true);
|
||||
}
|
||||
|
||||
$resolution = $this->doResolve($lead, $phone);
|
||||
|
||||
$ttlDays = max(1, (int) config('services.dadata.cache_ttl_days', 30));
|
||||
$this->cache->put($cacheKey, $resolution->forCache(), now()->addDays($ttlDays));
|
||||
|
||||
return $resolution;
|
||||
}
|
||||
|
||||
private function doResolve(SupplierLead $lead, string $phone): RegionResolution
|
||||
{
|
||||
$start = microtime(true);
|
||||
$provider = null;
|
||||
$qc = null;
|
||||
$masked = null;
|
||||
|
||||
// 1. DaData (под дневным бюджетом).
|
||||
if ($this->budgetGuard->canSpend()) {
|
||||
try {
|
||||
$dadata = $this->dadataClient->cleanPhone($phone);
|
||||
$this->budgetGuard->recordSpend((int) config('services.dadata.call_cost_kopecks', 60));
|
||||
$qc = $dadata->qc;
|
||||
$provider = $dadata->provider;
|
||||
$masked = $this->maskResponse($dadata);
|
||||
|
||||
if (in_array($dadata->qc, [0, 3], true)) {
|
||||
$region = (string) ($dadata->region ?? '');
|
||||
if ($region !== '' && ! DaDataRegionMap::isAmbiguous($region)) {
|
||||
$code = DaDataRegionMap::toSubjectCode($region);
|
||||
if ($code !== null) {
|
||||
return RegionResolution::make(
|
||||
$code, 'dadata',
|
||||
operator: $provider, qc: $qc,
|
||||
dadataMasked: $masked, durationMs: $this->ms($start),
|
||||
);
|
||||
}
|
||||
// qc=0/3, но регион не маппится → страховка Россвязью.
|
||||
}
|
||||
// ambiguous / region=null / не-маппится → Россвязь (provider от DaData сохраняем).
|
||||
} elseif ($dadata->qc === 2 || $dadata->qc === 7) {
|
||||
// Мусор / иностранец → Россвязь не поможет, сразу tag.
|
||||
return $this->tagFallback($lead, $provider, $qc, $masked, $start);
|
||||
}
|
||||
// qc=1 → Россвязь.
|
||||
} catch (DaDataException) {
|
||||
// Сеть / таймаут / 5xx → деградируем на Россвязь, не падаем.
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Россвязь.
|
||||
$rossvyaz = $this->rossvyazLookup->find($phone);
|
||||
if ($rossvyaz !== null) {
|
||||
$code = $rossvyaz->subjectCode ?? DaDataRegionMap::toSubjectCode($rossvyaz->region);
|
||||
if ($code !== null) {
|
||||
return RegionResolution::make(
|
||||
$code, 'rossvyaz',
|
||||
operator: $provider ?? $rossvyaz->operator, // оператор от DaData приоритетнее (MNP)
|
||||
qc: $qc, dadataMasked: $masked,
|
||||
durationMs: $this->ms($start), rossvyazMatched: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Tag-fallback.
|
||||
return $this->tagFallback($lead, $provider, $qc, $masked, $start);
|
||||
}
|
||||
|
||||
private function tagFallback(SupplierLead $lead, ?string $provider, ?int $qc, ?array $masked, ?float $start): RegionResolution
|
||||
{
|
||||
$tag = (string) (is_array($lead->raw_payload) ? ($lead->raw_payload['tag'] ?? '') : '');
|
||||
$tagCode = $this->tagResolver->resolve($tag);
|
||||
|
||||
return RegionResolution::make(
|
||||
$tagCode,
|
||||
$tagCode !== null ? 'tag' : 'unknown',
|
||||
operator: $provider,
|
||||
qc: $qc,
|
||||
dadataMasked: $masked,
|
||||
durationMs: $start !== null ? $this->ms($start) : null,
|
||||
);
|
||||
}
|
||||
|
||||
private function cacheKey(string $phone): string
|
||||
{
|
||||
return 'phone-region:'.hash('sha256', $phone);
|
||||
}
|
||||
|
||||
private function ms(float $start): int
|
||||
{
|
||||
return (int) round((microtime(true) - $start) * 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed> сырой ответ DaData с маскированным телефоном (§7.1)
|
||||
*/
|
||||
private function maskResponse(DaDataPhoneResponse $response): array
|
||||
{
|
||||
$raw = $response->raw;
|
||||
if (isset($raw['phone']) && is_string($raw['phone'])) {
|
||||
$raw['phone'] = $this->maskPhone($raw['phone']);
|
||||
}
|
||||
|
||||
return $raw;
|
||||
}
|
||||
|
||||
private function maskPhone(string $phone): string
|
||||
{
|
||||
$digits = preg_replace('/\D+/', '', $phone) ?? '';
|
||||
if (strlen($digits) < 8) {
|
||||
return '***';
|
||||
}
|
||||
|
||||
return substr($digits, 0, 4).'***'.substr($digits, -4);
|
||||
}
|
||||
}
|
||||
+171
-81
@@ -10,129 +10,219 @@ use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Random\Randomizer;
|
||||
|
||||
/**
|
||||
* Подбор eligible Лидерра-проектов для входящего лида (sharing-model §6).
|
||||
* Подбор eligible Лидерра-проектов для входящего лида (sharing-model §6) с
|
||||
* каскадной маршрутизацией по региону (lead region resolution §3.9).
|
||||
*
|
||||
* Eligibility — структурно через snapshot `project_routing_snapshots` за активную
|
||||
* дату слепка (slepok-инвариант): до 21:00 МСК активен snapshot сегодняшней даты,
|
||||
* с 21:00 МСК — завтрашней. Все эффективные параметры маршрутизации
|
||||
* (daily_limit, delivery_days_mask, regions, signal_type/signal_identifier и т.д.)
|
||||
* берутся из snapshot. Из live `projects` — только `delivered_today` (счётчик
|
||||
* остатка лимита, обновляется в течение дня) и из `tenants` — `balance_rub`
|
||||
* (live auto-pause при нулевом балансе).
|
||||
* с 21:00 МСК — завтрашней. Все эффективные параметры маршрутизации берутся из
|
||||
* snapshot; из live `projects` — только `delivered_today` (остаток лимита),
|
||||
* из `tenants` — `balance_rub` + `frozen_by_balance_at` (live auto-pause).
|
||||
*
|
||||
* Это закрывает R-01..R-04, R-06..R-08, R-15 (spec §1.3) — клиент Лидерры,
|
||||
* который paus'нул проект ПОСЛЕ зафиксированного слепка поставщика, всё равно
|
||||
* получает свои оплаченные лиды по уже зафиксированному slepok'у.
|
||||
* Каскад (§3.9): один SQL оборачивается тремя фазами по убыванию точности региона:
|
||||
* 1) точное совпадение субъекта (`?::int = ANY(snap.regions)`);
|
||||
* 2) «вся РФ» (`snap.regions = '{}'`), добор недостающих слотов;
|
||||
* 3) запасной канал (без фильтра региона) — только если первые две пусты;
|
||||
* сделкам в этой фазе подменяется subject_code (RouteSupplierLeadJob §3.10).
|
||||
* Каждый Project помечается атрибутом `routing_step` (1/2/3).
|
||||
*
|
||||
* Регион сопоставляется самим supplier_project (тег = субъект) — phone-prefix
|
||||
* фильтр убран (эпик миграции проектов, Q5): для мобильных он no-op, а регион
|
||||
* гарантирован тем, через какой supplier_project пришёл лид.
|
||||
* Отбор внутри фазы при кандидатах > cap — **взвешенный жребий по остатку лимита**
|
||||
* (вариант D1=В): шанс ∝ остатку, но у каждого кандидата шанс > 0 (вес ≥ 1) —
|
||||
* маленькие клиенты не отрезаются. cap = LeadDistributor::CAP (лид продаётся ≤3 раз).
|
||||
* Жребий через инъектируемый \Random\Randomizer (тесты сидируют Mt19937).
|
||||
*
|
||||
* Запрос через connection pgsql_supplier (BYPASSRLS crm_supplier_worker) — в
|
||||
* sharing-flow tenant ещё не определён, SELECT видит проекты всех tenant'ов.
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-26-slepok-routing-protection-design.md §4.2.3.
|
||||
* Spec: docs/superpowers/specs/2026-05-26-slepok-routing-protection-design.md §4.2.3
|
||||
* + docs/superpowers/specs/2026-05-29-lead-region-resolution-design.md §3.9.
|
||||
*/
|
||||
class LeadRouter
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Randomizer $randomizer = new Randomizer,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Возвращает ONE project per tenant_id — тот, у которого наибольший остаток
|
||||
* дневного лимита (DISTINCT ON (tenant_id) с ORDER BY remaining DESC, created_at, id).
|
||||
*
|
||||
* Семантика (Spec B Task 3): один лид продаётся не более чем 3 РАЗЛИЧНЫМ тенантам
|
||||
* (клиентам), каждый тенант получает ровно ОДИН проект — с наибольшим остатком.
|
||||
* LeadDistributor::selectRecipients (CAP=3) теперь ограничивает число тенантов,
|
||||
* а не число проектов, потому что входные данные уже one-per-tenant.
|
||||
*
|
||||
* Запрос через pgsql_supplier (BYPASSRLS crm_supplier_worker) — tenant ещё не
|
||||
* определён, SELECT видит проекты всех tenant'ов.
|
||||
* Возвращает ≤ cap проектов (по одному на tenant), отобранных каскадом
|
||||
* по региону + взвешенным жребием. Каждый Project несёт `routing_step`.
|
||||
*
|
||||
* @return Collection<int, Project>
|
||||
*/
|
||||
public function matchEligibleProjects(SupplierProject $supplierProject): Collection
|
||||
public function matchEligibleProjects(SupplierProject $supplierProject, ?int $resolvedSubjectCode = null): Collection
|
||||
{
|
||||
// Активная дата слепка вычисляется в PHP — детерминирована для всего запроса,
|
||||
// тестируема через Carbon::setTestNow, исключает дрейф между PHP- и DB-часами.
|
||||
$activeDate = $this->activeSnapshotDate();
|
||||
$cap = LeadDistributor::CAP;
|
||||
|
||||
// Phase 3: для DIRECT-supplier_project — fallback на signal_type+signal_identifier
|
||||
// match с Лидерра-проектами через snapshot (project_supplier_links для
|
||||
// DIRECT-row'ов не создаются — DIRECT supplier_projects создаются автоматически
|
||||
// при получении webhook'а без B-префикса).
|
||||
if ($supplierProject->platform === 'DIRECT') {
|
||||
$directSql = <<<'SQL'
|
||||
SELECT DISTINCT ON (snap.tenant_id)
|
||||
projects.*,
|
||||
snap.daily_limit AS snapshot_daily_limit
|
||||
FROM project_routing_snapshots snap
|
||||
INNER JOIN projects ON projects.id = snap.project_id
|
||||
WHERE snap.snapshot_date = ?::date
|
||||
AND snap.signal_type = ?
|
||||
AND LOWER(snap.signal_identifier) = LOWER(?)
|
||||
AND projects.delivered_today < snap.daily_limit
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM tenants
|
||||
WHERE tenants.id = snap.tenant_id
|
||||
AND tenants.balance_rub > 0
|
||||
-- R-03: frozen tenant must not receive new leads (Stage 3 §4.3.1)
|
||||
AND tenants.frozen_by_balance_at IS NULL
|
||||
)
|
||||
ORDER BY snap.tenant_id,
|
||||
(snap.daily_limit - projects.delivered_today) DESC,
|
||||
projects.created_at,
|
||||
projects.id
|
||||
SQL;
|
||||
$directRows = DB::connection('pgsql_supplier')->select(
|
||||
$directSql,
|
||||
[$activeDate, $supplierProject->signal_type, $supplierProject->unique_key]
|
||||
);
|
||||
// Фаза 1: точное совпадение региона (только если резолвер дал subject_code).
|
||||
$exact = $resolvedSubjectCode !== null
|
||||
? $this->queryCandidates($activeDate, $supplierProject, 'exact', $resolvedSubjectCode, [])
|
||||
: collect();
|
||||
$selected = $this->weightedPick($exact, $cap);
|
||||
$this->tagStep($selected, 1);
|
||||
|
||||
$this->logIfNoSnapshot($directRows, $supplierProject, $activeDate);
|
||||
|
||||
return Project::hydrate($directRows)->values();
|
||||
if ($selected->count() >= $cap) {
|
||||
return $selected->take($cap)->values();
|
||||
}
|
||||
|
||||
// Existing B1/B2/B3 path — explicit project_supplier_links pivot.
|
||||
$sql = <<<'SQL'
|
||||
// Фаза 2: «вся РФ», добор недостающих слотов (исключая уже выбранных tenant'ов).
|
||||
$allRu = $this->queryCandidates(
|
||||
$activeDate, $supplierProject, 'all_ru', null,
|
||||
$selected->pluck('tenant_id')->all(),
|
||||
);
|
||||
$pickedRu = $this->weightedPick($allRu, $cap - $selected->count());
|
||||
$this->tagStep($pickedRu, 2);
|
||||
$combined = $selected->concat($pickedRu);
|
||||
|
||||
if ($combined->isNotEmpty()) {
|
||||
return $combined->take($cap)->values();
|
||||
}
|
||||
|
||||
// Фаза 3: запасной канал (никто не подписан на регион и нет «вся РФ»).
|
||||
$fallback = $this->weightedPick(
|
||||
$this->queryCandidates($activeDate, $supplierProject, 'any', null, []),
|
||||
$cap,
|
||||
);
|
||||
$this->tagStep($fallback, 3);
|
||||
|
||||
$this->logIfNoSnapshot($fallback->all(), $supplierProject, $activeDate);
|
||||
|
||||
return $fallback->take($cap)->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* Один SQL-запрос фазы каскада: DISTINCT ON (tenant_id) с фильтром региона.
|
||||
* regionFilter ∈ exact|all_ru|any. Возвращает всех eligible (по одному на tenant),
|
||||
* упорядоченных по остатку лимита DESC, created_at, id; жребий — поверх в PHP.
|
||||
*
|
||||
* @param list<int> $excludeTenantIds
|
||||
* @return Collection<int, Project>
|
||||
*/
|
||||
private function queryCandidates(string $activeDate, SupplierProject $sp, string $regionFilter, ?int $code, array $excludeTenantIds): Collection
|
||||
{
|
||||
$bindings = [$activeDate];
|
||||
|
||||
if ($sp->platform === 'DIRECT') {
|
||||
// DIRECT supplier_projects не имеют pivot — матч по signal_type + identifier.
|
||||
$sourceWhere = 'snap.signal_type = ? AND LOWER(snap.signal_identifier) = LOWER(?)';
|
||||
$bindings[] = $sp->signal_type;
|
||||
$bindings[] = $sp->unique_key;
|
||||
} else {
|
||||
$sourceWhere = 'EXISTS (SELECT 1 FROM project_supplier_links psl
|
||||
WHERE psl.project_id = snap.project_id AND psl.supplier_project_id = ?)';
|
||||
$bindings[] = $sp->id;
|
||||
}
|
||||
|
||||
$regionWhere = '';
|
||||
if ($regionFilter === 'exact') {
|
||||
$regionWhere = 'AND ?::int = ANY(snap.regions)';
|
||||
$bindings[] = $code;
|
||||
} elseif ($regionFilter === 'all_ru') {
|
||||
$regionWhere = "AND snap.regions = '{}'::int[]";
|
||||
}
|
||||
|
||||
$excludeWhere = '';
|
||||
if ($excludeTenantIds !== []) {
|
||||
$placeholders = implode(',', array_fill(0, count($excludeTenantIds), '?'));
|
||||
$excludeWhere = "AND snap.tenant_id NOT IN ($placeholders)";
|
||||
foreach ($excludeTenantIds as $tid) {
|
||||
$bindings[] = $tid;
|
||||
}
|
||||
}
|
||||
|
||||
$sql = <<<SQL
|
||||
SELECT DISTINCT ON (snap.tenant_id)
|
||||
projects.*,
|
||||
snap.daily_limit AS snapshot_daily_limit
|
||||
snap.daily_limit AS snapshot_daily_limit,
|
||||
snap.regions AS snapshot_regions
|
||||
FROM project_routing_snapshots snap
|
||||
INNER JOIN projects ON projects.id = snap.project_id
|
||||
WHERE snap.snapshot_date = ?::date
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM project_supplier_links psl
|
||||
WHERE psl.project_id = snap.project_id
|
||||
AND psl.supplier_project_id = ?
|
||||
)
|
||||
AND $sourceWhere
|
||||
AND projects.delivered_today < snap.daily_limit
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM tenants
|
||||
WHERE tenants.id = snap.tenant_id
|
||||
AND tenants.balance_rub > 0
|
||||
-- R-03: frozen tenant must not receive new leads (Stage 3 §4.3.1)
|
||||
AND tenants.frozen_by_balance_at IS NULL
|
||||
)
|
||||
$regionWhere
|
||||
$excludeWhere
|
||||
ORDER BY snap.tenant_id,
|
||||
(snap.daily_limit - projects.delivered_today) DESC,
|
||||
projects.created_at,
|
||||
projects.id
|
||||
SQL;
|
||||
$rows = DB::connection('pgsql_supplier')->select($sql, [$activeDate, $supplierProject->id]);
|
||||
|
||||
$this->logIfNoSnapshot($rows, $supplierProject, $activeDate);
|
||||
|
||||
return Project::hydrate($rows)->values();
|
||||
return Project::hydrate(DB::connection('pgsql_supplier')->select($sql, $bindings));
|
||||
}
|
||||
|
||||
/**
|
||||
* Активная дата слепка по правилу slepok-инварианта:
|
||||
* до 21:00 МСК — сегодняшняя дата;
|
||||
* с 21:00 МСК — завтрашняя.
|
||||
* Взвешенный жребий без возврата (вариант D1=В): отбирает ≤ $n кандидатов,
|
||||
* вероятность ∝ остатку лимита, вес ≥ 1 у каждого (мелкие не отрезаются).
|
||||
* При кандидатах ≤ $n — возвращает всех в исходном SQL-порядке (детерминизм).
|
||||
*
|
||||
* Spec §4.2.3.
|
||||
* @param Collection<int, Project> $candidates
|
||||
* @return Collection<int, Project>
|
||||
*/
|
||||
private function weightedPick(Collection $candidates, int $n): Collection
|
||||
{
|
||||
if ($n <= 0) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
$pool = $candidates->values()->all();
|
||||
if (count($pool) <= $n) {
|
||||
return collect($pool);
|
||||
}
|
||||
|
||||
$picked = [];
|
||||
for ($i = 0; $i < $n && $pool !== []; $i++) {
|
||||
$total = 0;
|
||||
foreach ($pool as $p) {
|
||||
$total += $this->weightOf($p);
|
||||
}
|
||||
|
||||
$roll = $this->randomizer->getInt(1, $total);
|
||||
$acc = 0;
|
||||
$winner = 0;
|
||||
foreach ($pool as $idx => $p) {
|
||||
$acc += $this->weightOf($p);
|
||||
if ($roll <= $acc) {
|
||||
$winner = $idx;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$picked[] = $pool[$winner];
|
||||
array_splice($pool, $winner, 1);
|
||||
}
|
||||
|
||||
return collect($picked);
|
||||
}
|
||||
|
||||
private function weightOf(Project $project): int
|
||||
{
|
||||
$remaining = (int) $project->snapshot_daily_limit - (int) $project->delivered_today;
|
||||
|
||||
return max(1, $remaining);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, Project> $projects
|
||||
*/
|
||||
private function tagStep(Collection $projects, int $step): void
|
||||
{
|
||||
foreach ($projects as $project) {
|
||||
$project->setAttribute('routing_step', $step);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Активная дата слепка: до 21:00 МСК — сегодня, с 21:00 МСК — завтра (§4.2.3).
|
||||
*/
|
||||
private function activeSnapshotDate(): string
|
||||
{
|
||||
@@ -144,11 +234,11 @@ class LeadRouter
|
||||
}
|
||||
|
||||
/**
|
||||
* Fail-loud: пишет в лог если по активной дате слепка вообще нет ни одной строки
|
||||
* snapshot'а — это значит, что cron `SnapshotProjectRoutingJob` не отработал.
|
||||
* (Если строки есть, но ни одна не сматчилась — это валидный 0-результат, не алерт.)
|
||||
* Fail-loud: пишет в лог, если по активной дате слепка вообще нет ни одной строки
|
||||
* snapshot'а (cron SnapshotProjectRoutingJob не отработал). Пустой валидный
|
||||
* результат при наличии snapshot'ов — не алерт.
|
||||
*
|
||||
* @param array<int, object> $rows
|
||||
* @param array<int, mixed> $rows
|
||||
*/
|
||||
private function logIfNoSnapshot(array $rows, SupplierProject $supplierProject, string $activeDate): void
|
||||
{
|
||||
|
||||
@@ -59,6 +59,8 @@ class MonthlyPartitionManager
|
||||
'saas_admin_audit_log' => 'created_at',
|
||||
// Slepok routing (Этап 2, 27.05.2026)
|
||||
'project_routing_snapshots' => 'snapshot_date',
|
||||
// Lead region resolution (Session 1, 31.05.2026)
|
||||
'lead_region_resolution_log' => 'received_at',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Services\Dto\RossvyazRecord;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Локальный fallback резолва региона/оператора по телефону через реестр
|
||||
* нумерации Россвязи (`phone_ranges`).
|
||||
*
|
||||
* Используется LeadRegionResolver когда DaData недоступна/неуверена (qc=1,
|
||||
* timeout, бюджет исчерпан). Алгоритм (spec §3.7):
|
||||
* - def_code = 3 цифры кода ABC/DEF (позиции 1..3 нормализованного номера);
|
||||
* - subscriber = остаток номера как BIGINT;
|
||||
* - выбираем самый УЗКИЙ диапазон, накрывающий номер (ORDER BY width ASC),
|
||||
* т.к. узкие переопределения операторов точнее широких региональных блоков.
|
||||
*
|
||||
* Запрос идёт через `pgsql_supplier` (BYPASSRLS на проде, как LeadRouter):
|
||||
* `phone_ranges` — SaaS-level публичные данные без RLS.
|
||||
*/
|
||||
class RossvyazPrefixLookup
|
||||
{
|
||||
/** Connection для чтения реестра (на проде BYPASSRLS, на dev/test — superuser fallback). */
|
||||
public const CONNECTION = 'pgsql_supplier';
|
||||
|
||||
public function find(string $phone): ?RossvyazRecord
|
||||
{
|
||||
$digits = preg_replace('/\D+/', '', $phone) ?? '';
|
||||
|
||||
// Российский номер: 7|8 + ABC/DEF (3) + абонент (7) = 11 цифр.
|
||||
if (strlen($digits) !== 11) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$defCode = (int) substr($digits, 1, 3);
|
||||
$subscriber = (int) substr($digits, 4);
|
||||
|
||||
$row = DB::connection(self::CONNECTION)->selectOne(
|
||||
'SELECT region, operator, subject_code
|
||||
FROM phone_ranges
|
||||
WHERE def_code = ? AND from_num <= ? AND to_num >= ?
|
||||
ORDER BY (to_num - from_num) ASC
|
||||
LIMIT 1',
|
||||
[$defCode, $subscriber, $subscriber],
|
||||
);
|
||||
|
||||
if ($row === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new RossvyazRecord(
|
||||
subjectCode: $row->subject_code !== null ? (int) $row->subject_code : null,
|
||||
region: (string) $row->region,
|
||||
operator: (string) $row->operator,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
/**
|
||||
* Маппинг строки региона из ответа DaData → код субъекта РФ (1..89).
|
||||
*
|
||||
* DaData возвращает регион в поле `region` (например «Москва», «Московская область»).
|
||||
* Большинство имён точно совпадают с App\Support\RussianRegions::CODE_TO_NAME;
|
||||
* расхождения (если найдутся на staging) кладутся в OVERRIDES.
|
||||
*
|
||||
* «Объединённые» агломерации («Санкт-Петербург и область») — DaData не различает
|
||||
* город и область внутри поля region. Такие строки помечаются isAmbiguous() →
|
||||
* LeadRegionResolver уходит за точным subject_code в Россвязь (spec §3.4.1).
|
||||
*/
|
||||
final class DaDataRegionMap
|
||||
{
|
||||
/**
|
||||
* Строки-агломерации, по которым нельзя однозначно определить субъект.
|
||||
* Расширяется по реальным наблюдениям на staging (spec §3.4.1).
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
public const AMBIGUOUS_REGIONS = [
|
||||
'Санкт-Петербург и область',
|
||||
'Москва и область',
|
||||
];
|
||||
|
||||
/**
|
||||
* Ручные переопределения для имён DaData, не совпадающих с RussianRegions.
|
||||
* На старте пуст — заполняется по findings со staging-smoke.
|
||||
*
|
||||
* @var array<string, int>
|
||||
*/
|
||||
public const OVERRIDES = [];
|
||||
|
||||
public static function toSubjectCode(string $name): ?int
|
||||
{
|
||||
$name = trim($name);
|
||||
if ($name === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return self::OVERRIDES[$name] ?? RussianRegions::nameToCode()[$name] ?? null;
|
||||
}
|
||||
|
||||
public static function isAmbiguous(string $name): bool
|
||||
{
|
||||
return in_array(trim($name), self::AMBIGUOUS_REGIONS, true);
|
||||
}
|
||||
}
|
||||
@@ -114,9 +114,63 @@ final class RussianRegions
|
||||
89 => 'Ямало-Ненецкий автономный округ',
|
||||
];
|
||||
|
||||
/**
|
||||
* Алиасы нестандартных форм реестра Россвязи → каноничное имя субъекта.
|
||||
* Города фед. значения приходят с префиксом «г. »; «Республика Удмуртская» —
|
||||
* перевёрнутый порядок слов; «Кемеровская область - Кузбасс обл.» — спец-форма.
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
private const REGION_ALIASES = [
|
||||
'г. Москва' => 'Москва',
|
||||
'г. Санкт-Петербург' => 'Санкт-Петербург',
|
||||
'г. Севастополь' => 'Севастополь',
|
||||
'Республика Удмуртская' => 'Удмуртская Республика',
|
||||
'Кемеровская область - Кузбасс обл.' => 'Кемеровская область',
|
||||
'Кемеровская область - Кузбасс' => 'Кемеровская область',
|
||||
];
|
||||
|
||||
/** @return array<string, int> name => code (обратный индекс) */
|
||||
public static function nameToCode(): array
|
||||
{
|
||||
return array_flip(self::CODE_TO_NAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Нормализует строку региона реестра Россвязи в каноничное имя субъекта (или null).
|
||||
*
|
||||
* Реестр кодирует субъект как ПОСЛЕДНИЙ сегмент после «|»
|
||||
* (напр. «г. Воскресенск|р-н Воскресенский|Московская обл.» → «Московская обл.»),
|
||||
* с сокращением «обл.» вместо «область» и рядом нестандартных форм (см. REGION_ALIASES).
|
||||
* Безнадёжные/неоднозначные строки («-», «Российская Федерация»,
|
||||
* «Москва и Московская область», «г.о. Тольятти») → null.
|
||||
*/
|
||||
public static function canonicalRegionName(string $raw): ?string
|
||||
{
|
||||
$segment = self::lastRegionSegment($raw);
|
||||
if ($segment === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$name = self::REGION_ALIASES[$segment]
|
||||
?? (string) preg_replace('/\s*обл\.$/u', ' область', $segment);
|
||||
|
||||
return isset(self::nameToCode()[$name]) ? $name : null;
|
||||
}
|
||||
|
||||
/** Резолвит строку региона реестра Россвязи в subject_code (1..89) или null. */
|
||||
public static function resolveSubjectCode(string $raw): ?int
|
||||
{
|
||||
$name = self::canonicalRegionName($raw);
|
||||
|
||||
return $name === null ? null : (self::nameToCode()[$name] ?? null);
|
||||
}
|
||||
|
||||
/** Последний сегмент после «|» (субъект в формате Россвязи), trimmed. */
|
||||
private static function lastRegionSegment(string $raw): string
|
||||
{
|
||||
$parts = explode('|', $raw);
|
||||
|
||||
return trim((string) end($parts));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,4 +42,17 @@ return [
|
||||
'alert_email' => env('SUPPLIER_ALERT_EMAIL', 'ops@liderra.ru'),
|
||||
],
|
||||
|
||||
// DaData phone cleaner — резолв региона лида по телефону (lead region resolution).
|
||||
// Ключи → YC Lockbox на проде; на dev/staging — .env. enabled=false до раскатки.
|
||||
'dadata' => [
|
||||
'api_key' => env('DADATA_API_KEY'),
|
||||
'secret' => env('DADATA_SECRET'),
|
||||
'timeout_ms' => (int) env('DADATA_TIMEOUT_MS', 2000),
|
||||
'retries' => (int) env('DADATA_RETRIES', 1),
|
||||
'daily_cap_rub' => (int) env('DADATA_DAILY_CAP_RUB', 10000),
|
||||
'call_cost_kopecks' => (int) env('DADATA_CALL_COST_KOPECKS', 60), // ≈0.60 ₽/вызов, откалибровать по тарифу
|
||||
'enabled' => filter_var(env('LEAD_REGION_RESOLVER_ENABLED', false), FILTER_VALIDATE_BOOL),
|
||||
'cache_ttl_days' => (int) env('PHONE_REGION_CACHE_TTL_DAYS', 30),
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration {
|
||||
public function up(): void
|
||||
{
|
||||
// SET ROLE crm_migrator на проде (postgres superuser может SET ROLE).
|
||||
// На dev/testing crm_migrator не имеет GRANT на public schema → RESET ROLE
|
||||
// и продолжаем как postgres superuser.
|
||||
try {
|
||||
DB::statement('SET ROLE crm_migrator');
|
||||
$canCreate = DB::selectOne("SELECT has_schema_privilege('crm_migrator', 'public', 'CREATE') AS ok");
|
||||
if (!$canCreate || !$canCreate->ok) {
|
||||
DB::statement('RESET ROLE');
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// окружение без роли — продолжаем как superuser
|
||||
}
|
||||
|
||||
DB::unprepared(<<<'SQL'
|
||||
-- 1. phone_ranges_imports (журнал импортов; на него FK из phone_ranges, создаём первым)
|
||||
CREATE TABLE phone_ranges_imports (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
imported_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
source_url TEXT NOT NULL,
|
||||
rows_inserted INTEGER NOT NULL DEFAULT 0,
|
||||
rows_updated INTEGER NOT NULL DEFAULT 0,
|
||||
checksum_sha256 TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'in_progress'
|
||||
CHECK (status IN ('in_progress','completed','failed','rolled_back')),
|
||||
error TEXT,
|
||||
completed_at TIMESTAMPTZ
|
||||
);
|
||||
COMMENT ON TABLE phone_ranges_imports IS
|
||||
'Журнал импортов реестра Россвязи (idempotency по checksum_sha256, atomic-swap откат).';
|
||||
|
||||
-- 2. phone_ranges (реестр диапазонов Россвязи; SaaS-level, без RLS — публичные данные)
|
||||
CREATE TABLE phone_ranges (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
def_code SMALLINT NOT NULL,
|
||||
from_num BIGINT NOT NULL,
|
||||
to_num BIGINT NOT NULL,
|
||||
operator TEXT NOT NULL,
|
||||
region TEXT NOT NULL,
|
||||
region_normalized TEXT,
|
||||
subject_code SMALLINT,
|
||||
imported_at TIMESTAMPTZ NOT NULL,
|
||||
import_id BIGINT NOT NULL REFERENCES phone_ranges_imports(id),
|
||||
CONSTRAINT chk_phone_ranges_def_code CHECK (def_code BETWEEN 300 AND 999),
|
||||
CONSTRAINT chk_phone_ranges_subject_code CHECK (subject_code IS NULL OR subject_code BETWEEN 1 AND 89),
|
||||
CONSTRAINT chk_phone_ranges_range_valid CHECK (from_num <= to_num)
|
||||
);
|
||||
CREATE INDEX idx_phone_ranges_lookup ON phone_ranges (def_code, from_num, to_num);
|
||||
COMMENT ON TABLE phone_ranges IS
|
||||
'Реестр диапазонов нумерации Россвязи (rossvyaz.gov.ru). Локальный fallback для LeadRegionResolver. Обновляется ежемесячным cron-импортом.';
|
||||
|
||||
GRANT SELECT ON phone_ranges, phone_ranges_imports TO crm_app_user, crm_supplier_worker;
|
||||
|
||||
-- 3. lead_region_resolution_log (SaaS-level, партиционирован по received_at, паттерн activity_log)
|
||||
CREATE TABLE lead_region_resolution_log (
|
||||
id BIGSERIAL,
|
||||
supplier_lead_id BIGINT NOT NULL,
|
||||
received_at TIMESTAMPTZ NOT NULL,
|
||||
phone_masked TEXT NOT NULL,
|
||||
subject_code_resolved SMALLINT,
|
||||
subject_code_from_tag SMALLINT,
|
||||
region_source TEXT NOT NULL
|
||||
CHECK (region_source IN ('dadata','rossvyaz','tag','unknown')),
|
||||
dadata_qc SMALLINT,
|
||||
dadata_provider TEXT,
|
||||
dadata_type TEXT,
|
||||
dadata_response_masked JSONB,
|
||||
rossvyaz_matched BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
actual_subject_code SMALLINT
|
||||
CHECK (actual_subject_code IS NULL OR actual_subject_code BETWEEN 1 AND 89),
|
||||
substituted_subject_code SMALLINT
|
||||
CHECK (substituted_subject_code IS NULL OR substituted_subject_code BETWEEN 1 AND 89),
|
||||
routing_step SMALLINT
|
||||
CHECK (routing_step IS NULL OR routing_step BETWEEN 1 AND 3),
|
||||
phone_operator TEXT,
|
||||
cache_hit BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
duration_ms INTEGER,
|
||||
resolved_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (id, received_at)
|
||||
) PARTITION BY RANGE (received_at);
|
||||
|
||||
CREATE INDEX idx_lrrl_lead_id ON lead_region_resolution_log (supplier_lead_id);
|
||||
CREATE INDEX idx_lrrl_source ON lead_region_resolution_log (region_source, received_at);
|
||||
COMMENT ON TABLE lead_region_resolution_log IS
|
||||
'Аудит каждого резолва региона лида (источник, qc, оператор, шаг каскада). Партиции помесячно по received_at (MonthlyPartitionManager).';
|
||||
|
||||
GRANT SELECT, INSERT ON lead_region_resolution_log TO crm_supplier_worker;
|
||||
GRANT SELECT ON lead_region_resolution_log TO crm_app_user;
|
||||
|
||||
-- Стартовые партиции (далее их подхватывает partitions:create-months после Task 1.2).
|
||||
CREATE TABLE lead_region_resolution_log_y2026_m05
|
||||
PARTITION OF lead_region_resolution_log
|
||||
FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
|
||||
CREATE TABLE lead_region_resolution_log_y2026_m06
|
||||
PARTITION OF lead_region_resolution_log
|
||||
FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');
|
||||
|
||||
-- 4. supplier_leads: +4 колонки (denormalized display + persistent idempotency для retry).
|
||||
ALTER TABLE supplier_leads
|
||||
ADD COLUMN resolved_subject_code SMALLINT
|
||||
CHECK (resolved_subject_code IS NULL OR resolved_subject_code BETWEEN 1 AND 89),
|
||||
ADD COLUMN region_source TEXT
|
||||
CHECK (region_source IN ('dadata','rossvyaz','tag','unknown')),
|
||||
ADD COLUMN dadata_qc SMALLINT,
|
||||
ADD COLUMN phone_operator TEXT;
|
||||
|
||||
-- 5. deals: +2 колонки (UI-карточка + флаг подмены региона).
|
||||
ALTER TABLE deals
|
||||
ADD COLUMN phone_operator TEXT,
|
||||
ADD COLUMN region_substituted BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
SQL);
|
||||
|
||||
// Регистрация retention для lead_region_resolution_log (system_settings, 12 месяцев ≈ 365 дней).
|
||||
$exists = DB::table('system_settings')
|
||||
->where('key', 'partition_retention_months_lead_region_resolution_log')
|
||||
->exists();
|
||||
if (! $exists) {
|
||||
DB::table('system_settings')->insert([
|
||||
'key' => 'partition_retention_months_lead_region_resolution_log',
|
||||
'value' => '12',
|
||||
'type' => 'int',
|
||||
'description' => 'Retention в месяцах для lead_region_resolution_log (~365 дней)',
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
try {
|
||||
DB::statement('SET ROLE crm_migrator');
|
||||
$canCreate = DB::selectOne("SELECT has_schema_privilege('crm_migrator', 'public', 'CREATE') AS ok");
|
||||
if (!$canCreate || !$canCreate->ok) {
|
||||
DB::statement('RESET ROLE');
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// окружение без роли — продолжаем как superuser
|
||||
}
|
||||
|
||||
DB::unprepared(<<<'SQL'
|
||||
ALTER TABLE deals
|
||||
DROP COLUMN IF EXISTS phone_operator,
|
||||
DROP COLUMN IF EXISTS region_substituted;
|
||||
|
||||
ALTER TABLE supplier_leads
|
||||
DROP COLUMN IF EXISTS resolved_subject_code,
|
||||
DROP COLUMN IF EXISTS region_source,
|
||||
DROP COLUMN IF EXISTS dadata_qc,
|
||||
DROP COLUMN IF EXISTS phone_operator;
|
||||
|
||||
DROP TABLE IF EXISTS lead_region_resolution_log CASCADE;
|
||||
DROP TABLE IF EXISTS phone_ranges CASCADE;
|
||||
DROP TABLE IF EXISTS phone_ranges_imports CASCADE;
|
||||
SQL);
|
||||
|
||||
DB::table('system_settings')
|
||||
->where('key', 'partition_retention_months_lead_region_resolution_log')
|
||||
->delete();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Deal;
|
||||
use App\Models\Project;
|
||||
use App\Models\SupplierLead;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
uses(SharesSupplierPdo::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
|
||||
});
|
||||
|
||||
/**
|
||||
* Сеет сделку (city=NULL по умолчанию) + лид с resolved_subject_code + связь
|
||||
* supplier_lead_deliveries. Возвращает [tenantId, dealId].
|
||||
*
|
||||
* @return array{0: int, 1: int}
|
||||
*/
|
||||
function seedDealWithResolvedLead(?int $resolvedCode, ?string $city = null): array
|
||||
{
|
||||
$tenant = Tenant::factory()->create(['balance_rub' => '100000.00']);
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'backfill-city.ru',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'");
|
||||
$deal = Deal::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'project_id' => $project->id,
|
||||
'phone' => '79161234567',
|
||||
'phones' => ['79161234567'],
|
||||
'status' => 'new',
|
||||
'received_at' => now(),
|
||||
'subject_code' => $resolvedCode,
|
||||
'city' => $city,
|
||||
]);
|
||||
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
|
||||
|
||||
$lead = SupplierLead::factory()->create([
|
||||
'platform' => 'B1',
|
||||
'phone' => '79161234567',
|
||||
'resolved_subject_code' => $resolvedCode,
|
||||
'region_source' => $resolvedCode !== null ? 'dadata' : 'unknown',
|
||||
]);
|
||||
|
||||
DB::connection('pgsql_supplier')->table('supplier_lead_deliveries')->insert([
|
||||
'supplier_lead_id' => $lead->id,
|
||||
'tenant_id' => $tenant->id,
|
||||
'deal_id' => $deal->id,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
return [$tenant->id, $deal->id];
|
||||
}
|
||||
|
||||
function dealCity(int $dealId): ?string
|
||||
{
|
||||
// BYPASSRLS чтение (как и сам бэкфилл) — без tenant-контекста.
|
||||
return DB::connection('pgsql_supplier')->table('deals')->where('id', $dealId)->value('city');
|
||||
}
|
||||
|
||||
it('backfills deal city from the lead resolved region code', function (): void {
|
||||
[, $dealId] = seedDealWithResolvedLead(29); // 29 → Красноярский край
|
||||
|
||||
$this->artisan('deals:backfill-region-city')->assertSuccessful();
|
||||
|
||||
expect(dealCity($dealId))->toBe('Красноярский край');
|
||||
});
|
||||
|
||||
it('does not touch deals that already have a city', function (): void {
|
||||
[, $dealId] = seedDealWithResolvedLead(29, city: 'Уже стоит');
|
||||
|
||||
$this->artisan('deals:backfill-region-city')->assertSuccessful();
|
||||
|
||||
expect(dealCity($dealId))->toBe('Уже стоит');
|
||||
});
|
||||
|
||||
it('dry-run reports candidates without writing', function (): void {
|
||||
[, $dealId] = seedDealWithResolvedLead(29);
|
||||
|
||||
$this->artisan('deals:backfill-region-city', ['--dry-run' => true])->assertSuccessful();
|
||||
|
||||
expect(dealCity($dealId))->toBeNull();
|
||||
});
|
||||
|
||||
it('leaves city null when the lead has no resolved region', function (): void {
|
||||
[, $dealId] = seedDealWithResolvedLead(null);
|
||||
|
||||
$this->artisan('deals:backfill-region-city')->assertSuccessful();
|
||||
|
||||
expect(dealCity($dealId))->toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
uses(SharesSupplierPdo::class);
|
||||
|
||||
function rossvyazFixture(): string
|
||||
{
|
||||
return base_path('tests/Fixtures/rossvyaz/sample.csv');
|
||||
}
|
||||
|
||||
it('dry-run parses csv, maps regions to subject_code, builds staging, does not swap', function (): void {
|
||||
$this->artisan('phone-ranges:import', ['--file' => rossvyazFixture(), '--dry-run' => true])
|
||||
->assertSuccessful();
|
||||
|
||||
// Staging построен (dry-run не свапает и не дропает staging — данные видны в той же tx).
|
||||
expect(DB::table('phone_ranges_staging')->count())->toBe(3);
|
||||
|
||||
$r495 = DB::selectOne('SELECT subject_code FROM phone_ranges_staging WHERE def_code = 495');
|
||||
$r921 = DB::selectOne('SELECT subject_code FROM phone_ranges_staging WHERE def_code = 921');
|
||||
$r999 = DB::selectOne('SELECT subject_code FROM phone_ranges_staging WHERE def_code = 999');
|
||||
|
||||
expect((int) $r495->subject_code)->toBe(82) // Москва
|
||||
->and((int) $r921->subject_code)->toBe(83) // Санкт-Петербург
|
||||
->and($r999->subject_code)->toBeNull(); // Атлантида — не маппится
|
||||
|
||||
// Живой phone_ranges не тронут (свапа не было).
|
||||
expect(DB::table('phone_ranges')->count())->toBe(0);
|
||||
|
||||
// Журнал импорта: dry-run → rolled_back, несматчившийся регион в error.
|
||||
$imp = DB::table('phone_ranges_imports')->orderByDesc('id')->first();
|
||||
expect($imp->status)->toBe('rolled_back')
|
||||
->and($imp->error)->toContain('Атлантида');
|
||||
});
|
||||
|
||||
it('maps all matched rows and counts unmatched separately', function (): void {
|
||||
$this->artisan('phone-ranges:import', ['--file' => rossvyazFixture(), '--dry-run' => true])
|
||||
->assertSuccessful();
|
||||
|
||||
$matched = DB::table('phone_ranges_staging')->whereNotNull('subject_code')->count();
|
||||
$unmatched = DB::table('phone_ranges_staging')->whereNull('subject_code')->count();
|
||||
|
||||
expect($matched)->toBe(2)->and($unmatched)->toBe(1);
|
||||
});
|
||||
|
||||
it('skips swap when checksum matches a completed import (idempotency)', function (): void {
|
||||
$checksum = hash_file('sha256', rossvyazFixture());
|
||||
DB::table('phone_ranges_imports')->insert([
|
||||
'source_url' => 'https://rossvyaz.gov.ru/prev',
|
||||
'checksum_sha256' => $checksum,
|
||||
'status' => 'completed',
|
||||
'imported_at' => now(),
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
|
||||
// Не dry-run: но checksum совпал с completed → короткое замыкание ДО свапа.
|
||||
$this->artisan('phone-ranges:import', ['--file' => rossvyazFixture()])
|
||||
->assertSuccessful();
|
||||
|
||||
expect(DB::table('phone_ranges')->count())->toBe(0); // свапа не было
|
||||
|
||||
$latest = DB::table('phone_ranges_imports')->orderByDesc('id')->first();
|
||||
expect($latest->status)->toBe('rolled_back');
|
||||
});
|
||||
|
||||
it('force flag bypasses idempotency note even with matching checksum', function (): void {
|
||||
// С --dry-run + --force: идемпотентность игнорируется, но dry-run всё равно не свапает.
|
||||
$checksum = hash_file('sha256', rossvyazFixture());
|
||||
DB::table('phone_ranges_imports')->insert([
|
||||
'source_url' => 'https://rossvyaz.gov.ru/prev',
|
||||
'checksum_sha256' => $checksum,
|
||||
'status' => 'completed',
|
||||
'imported_at' => now(),
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
|
||||
$this->artisan('phone-ranges:import', ['--file' => rossvyazFixture(), '--dry-run' => true, '--force' => true])
|
||||
->assertSuccessful();
|
||||
|
||||
// --force обошёл idempotency → staging построен заново (3 строки), но dry-run не свапнул.
|
||||
expect(DB::table('phone_ranges_staging')->count())->toBe(3);
|
||||
expect(DB::table('phone_ranges')->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('normalizes real Россвязь region formats to subject_code and fills region_normalized', function (): void {
|
||||
// Форматы из реального прод-реестра (топ unmapped 02.06.2026): префикс «г. »,
|
||||
// pipe-сегмент региона, сокращение «обл.», перевёрнутая «Республика Удмуртская»,
|
||||
// и безнадёжный city-only «г.о. Тольятти». def-коды 3-значные (chk_phone_ranges_def_code 300-999).
|
||||
$this->artisan('phone-ranges:import', ['--file' => base_path('tests/Fixtures/rossvyaz/messy.csv'), '--dry-run' => true])
|
||||
->assertSuccessful();
|
||||
|
||||
$moscow = DB::selectOne('SELECT subject_code, region_normalized FROM phone_ranges_staging WHERE def_code = 495');
|
||||
$orenburg = DB::selectOne('SELECT subject_code, region_normalized FROM phone_ranges_staging WHERE def_code = 922');
|
||||
$udmurtia = DB::selectOne('SELECT subject_code, region_normalized FROM phone_ranges_staging WHERE def_code = 987');
|
||||
$togliatti = DB::selectOne('SELECT subject_code, region_normalized FROM phone_ranges_staging WHERE def_code = 902');
|
||||
|
||||
expect((int) $moscow->subject_code)->toBe(82)
|
||||
->and($moscow->region_normalized)->toBe('Москва')
|
||||
->and((int) $orenburg->subject_code)->toBe(62)
|
||||
->and($orenburg->region_normalized)->toBe('Оренбургская область')
|
||||
->and((int) $udmurtia->subject_code)->toBe(21)
|
||||
->and($udmurtia->region_normalized)->toBe('Удмуртская Республика')
|
||||
->and($togliatti->subject_code)->toBeNull()
|
||||
->and($togliatti->region_normalized)->toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
uses(SharesSupplierPdo::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
config([
|
||||
'services.dadata.api_key' => 'k',
|
||||
'services.dadata.secret' => 's',
|
||||
'services.dadata.daily_cap_rub' => 100000,
|
||||
]);
|
||||
});
|
||||
|
||||
it('phone-region:smoke prints the resolution and writes nothing to DB', function (): void {
|
||||
Http::fake(['cleaner.dadata.ru/*' => Http::response([[
|
||||
'qc' => 0, 'region' => 'Москва', 'provider' => 'МТС',
|
||||
]], 200)]);
|
||||
|
||||
$this->artisan('phone-region:smoke', ['--phone' => '79161234567'])
|
||||
->assertSuccessful()
|
||||
->expectsOutputToContain('dadata')
|
||||
->expectsOutputToContain('Москва');
|
||||
|
||||
// Smoke не пишет в БД.
|
||||
expect(DB::table('lead_region_resolution_log')->count())->toBe(0);
|
||||
expect(DB::table('deals')->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('phone-region:smoke fails without --phone', function (): void {
|
||||
$this->artisan('phone-region:smoke')->assertFailed();
|
||||
});
|
||||
@@ -0,0 +1,229 @@
|
||||
<?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\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 Illuminate\Support\Facades\Http;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
uses(SharesSupplierPdo::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
$this->seed(PricingTierSeeder::class);
|
||||
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
|
||||
config([
|
||||
'services.dadata.enabled' => true,
|
||||
'services.dadata.api_key' => 'k',
|
||||
'services.dadata.secret' => 's',
|
||||
'services.dadata.daily_cap_rub' => 100000,
|
||||
]);
|
||||
});
|
||||
|
||||
function runRegionJob(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),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Создаёт маршрутизируемый лид: supplier B1 site + tenant с балансом + project + snapshot.
|
||||
*
|
||||
* @return array{0: SupplierLead, 1: Project, 2: Tenant, 3: SupplierProject}
|
||||
*/
|
||||
function seedRoutableLead(string $regions, string $tag, string $phone, string $key = 'vashinvestor.ru'): array
|
||||
{
|
||||
$supplier = SupplierProject::factory()->create([
|
||||
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => $key,
|
||||
]);
|
||||
$tenant = Tenant::factory()->create(['balance_rub' => '100000.00']);
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'signal_type' => 'site', 'signal_identifier' => $key,
|
||||
'is_active' => true, 'delivered_today' => 0, 'delivered_in_month' => 0,
|
||||
'daily_limit_target' => 100,
|
||||
]);
|
||||
linkProjectToSupplier($project, $supplier);
|
||||
createRoutingSnapshotFromProject($project, dailyLimit: 100, regions: $regions);
|
||||
|
||||
$vid = 432176649;
|
||||
$lead = SupplierLead::factory()->create([
|
||||
'supplier_project_id' => null,
|
||||
'platform' => 'B1',
|
||||
'vid' => $vid,
|
||||
'phone' => $phone,
|
||||
'received_at' => now(),
|
||||
'raw_payload' => [
|
||||
'vid' => $vid, 'project' => "B1_{$key}", 'tag' => $tag,
|
||||
'phone' => $phone, 'phones' => [$phone], 'time' => now()->getTimestamp(),
|
||||
],
|
||||
]);
|
||||
|
||||
return [$lead, $project, $tenant, $supplier];
|
||||
}
|
||||
|
||||
function dealFor(int $tenantId, int $projectId): ?Deal
|
||||
{
|
||||
DB::statement("SET LOCAL app.current_tenant_id = '{$tenantId}'");
|
||||
$deal = Deal::query()->where('project_id', $projectId)->first();
|
||||
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
|
||||
|
||||
return $deal;
|
||||
}
|
||||
|
||||
it('lead with phone uses dadata region, not the tag', function (): void {
|
||||
Http::fake(['cleaner.dadata.ru/*' => Http::response([[
|
||||
'qc' => 0, 'region' => 'Москва', 'provider' => 'МТС', 'type' => 'Мобильный', 'phone' => '+7 916 123-45-67',
|
||||
]], 200)]);
|
||||
// tag='Санкт-Петербург' (дал бы 83), но телефон резолвится в Москву (82).
|
||||
[$lead, $project, $tenant] = seedRoutableLead(regions: '{82}', tag: 'Санкт-Петербург', phone: '79161234567');
|
||||
|
||||
runRegionJob($lead->id);
|
||||
|
||||
$lead->refresh();
|
||||
expect($lead->resolved_subject_code)->toBe(82)
|
||||
->and($lead->region_source)->toBe('dadata')
|
||||
->and($lead->phone_operator)->toBe('МТС');
|
||||
|
||||
$deal = dealFor($tenant->id, $project->id);
|
||||
expect($deal)->not->toBeNull()
|
||||
->and((int) $deal->subject_code)->toBe(82) // регион из DaData, не из тега (83)
|
||||
->and((bool) $deal->region_substituted)->toBeFalse()
|
||||
->and($deal->phone_operator)->toBe('МТС');
|
||||
});
|
||||
|
||||
it('logs exactly one region resolution row per lead', function (): void {
|
||||
Http::fake(['cleaner.dadata.ru/*' => Http::response([[
|
||||
'qc' => 0, 'region' => 'Москва', 'provider' => 'МТС',
|
||||
]], 200)]);
|
||||
[$lead] = seedRoutableLead(regions: '{82}', tag: 'tag', phone: '79161234567');
|
||||
|
||||
runRegionJob($lead->id);
|
||||
|
||||
$rows = DB::table('lead_region_resolution_log')->where('supplier_lead_id', $lead->id)->get();
|
||||
expect($rows)->toHaveCount(1);
|
||||
expect($rows->first()->region_source)->toBe('dadata');
|
||||
// Телефон в логе маскирован (не сырой номер) — §7.1.
|
||||
expect($rows->first()->phone_masked)->not->toBe('79161234567');
|
||||
});
|
||||
|
||||
it('lead with invalid phone falls back to tag', function (): void {
|
||||
Http::fake(['cleaner.dadata.ru/*' => Http::response([['qc' => 0, 'region' => 'Москва']], 200)]);
|
||||
// Невалидный телефон → DaData не дёргается → tag (Москва=82).
|
||||
[$lead, $project, $tenant] = seedRoutableLead(regions: '{82}', tag: 'Москва', phone: '123');
|
||||
|
||||
runRegionJob($lead->id);
|
||||
|
||||
$lead->refresh();
|
||||
expect($lead->region_source)->toBe('tag')->and($lead->resolved_subject_code)->toBe(82);
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
it('lead with resolver disabled via flag uses tag', function (): void {
|
||||
config(['services.dadata.enabled' => false]);
|
||||
Http::fake(['cleaner.dadata.ru/*' => Http::response([['qc' => 0, 'region' => 'Москва']], 200)]);
|
||||
[$lead, $project, $tenant] = seedRoutableLead(regions: '{82}', tag: 'Москва', phone: '79161234567');
|
||||
|
||||
runRegionJob($lead->id);
|
||||
|
||||
$lead->refresh();
|
||||
expect($lead->region_source)->toBe('tag')->and($lead->resolved_subject_code)->toBe(82);
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
it('persistent idempotency: pre-resolved lead does not re-call dadata', function (): void {
|
||||
Http::fake(['cleaner.dadata.ru/*' => Http::response([['qc' => 0, 'region' => 'Москва', 'provider' => 'МТС']], 200)]);
|
||||
[$lead, $project, $tenant] = seedRoutableLead(regions: '{83}', tag: 'tag', phone: '79161234567');
|
||||
// Эмулируем предыдущий try: резолв уже персистнут.
|
||||
$lead->update(['resolved_subject_code' => 83, 'region_source' => 'rossvyaz', 'phone_operator' => 'МегаФон']);
|
||||
|
||||
runRegionJob($lead->id);
|
||||
|
||||
Http::assertNothingSent(); // §3.11 — нет двойной оплаты DaData
|
||||
$lead->refresh();
|
||||
expect($lead->resolved_subject_code)->toBe(83)->and($lead->region_source)->toBe('rossvyaz');
|
||||
});
|
||||
|
||||
it('step-3 fallback substitutes subject_code to client region and flags region_substituted', function (): void {
|
||||
Http::fake(['cleaner.dadata.ru/*' => Http::response([[
|
||||
'qc' => 0, 'region' => 'Москва', 'provider' => 'МТС',
|
||||
]], 200)]);
|
||||
// Лид по Москве (82), но клиент подписан только на Питер (83): точных нет, «вся РФ» нет → шаг 3.
|
||||
[$lead, $project, $tenant] = seedRoutableLead(regions: '{83}', tag: 'tag', phone: '79161234567');
|
||||
|
||||
runRegionJob($lead->id);
|
||||
|
||||
$deal = dealFor($tenant->id, $project->id);
|
||||
expect($deal)->not->toBeNull()
|
||||
->and((int) $deal->subject_code)->toBe(83) // подменён на регион клиента (Питер)
|
||||
->and((bool) $deal->region_substituted)->toBeTrue();
|
||||
|
||||
// Настоящий регион (Москва=82) сохранён в журнале как actual_subject_code.
|
||||
$log = DB::table('lead_region_resolution_log')->where('supplier_lead_id', $lead->id)->first();
|
||||
expect((int) $log->actual_subject_code)->toBe(82)
|
||||
->and((int) $log->substituted_subject_code)->toBe(83);
|
||||
});
|
||||
|
||||
it('csv-merge updates subject_code and operator when webhook resolution outranks tag (dadata)', function (): void {
|
||||
Http::fake(['cleaner.dadata.ru/*' => Http::response([['qc' => 0, 'region' => 'Москва', 'provider' => 'МТС']], 200)]);
|
||||
[$lead, $project, $tenant] = seedRoutableLead(regions: '{82}', tag: 'tag', phone: '79161234567');
|
||||
|
||||
// CSV-recovered сделка: source_crm_id=null, регион из тега «неправильный» (53 = ЛО).
|
||||
DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'");
|
||||
$csvDeal = Deal::create([
|
||||
'tenant_id' => $tenant->id, 'source_crm_id' => null, 'project_id' => $project->id,
|
||||
'phone' => '79161234567', 'phones' => ['79161234567'], 'status' => 'new',
|
||||
'received_at' => now(), 'subject_code' => 53,
|
||||
]);
|
||||
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
|
||||
|
||||
runRegionJob($lead->id);
|
||||
|
||||
$merged = dealFor($tenant->id, $project->id);
|
||||
expect((int) $merged->id)->toBe($csvDeal->id) // merge в существующую, не новая
|
||||
->and((int) $merged->subject_code)->toBe(82) // обновлено DaData (82) поверх tag (53)
|
||||
->and($merged->phone_operator)->toBe('МТС')
|
||||
->and((int) $merged->source_crm_id)->toBe($lead->vid);
|
||||
|
||||
DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'");
|
||||
expect(Deal::query()->where('project_id', $project->id)->count())->toBe(1); // второй сделки нет
|
||||
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
|
||||
});
|
||||
|
||||
it('csv-merge does not overwrite subject_code when webhook resolution is tag-level', function (): void {
|
||||
config(['services.dadata.enabled' => false]); // резолвер выключен → source='tag' (rank не выше CSV-tag)
|
||||
[$lead, $project, $tenant] = seedRoutableLead(regions: '{82}', tag: 'Москва', phone: '79161234567');
|
||||
|
||||
DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'");
|
||||
Deal::create([
|
||||
'tenant_id' => $tenant->id, 'source_crm_id' => null, 'project_id' => $project->id,
|
||||
'phone' => '79161234567', 'phones' => ['79161234567'], 'status' => 'new',
|
||||
'received_at' => now(), 'subject_code' => 53,
|
||||
]);
|
||||
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
|
||||
|
||||
runRegionJob($lead->id);
|
||||
|
||||
$merged = dealFor($tenant->id, $project->id);
|
||||
expect((int) $merged->subject_code)->toBe(53); // tag не выше tag → регион не тронут
|
||||
});
|
||||
@@ -631,3 +631,35 @@ it('merges webhook into csv-recovered deal even when received_at differs (Phase
|
||||
// Никаких дублей deals — только один с этим vid.
|
||||
expect(Deal::query()->where('source_crm_id', $webhookVid)->count())->toBe(1);
|
||||
});
|
||||
|
||||
it('fills deal city with the resolved region name (UI «Город» column)', function (): void {
|
||||
\Illuminate\Support\Facades\Http::fake(['cleaner.dadata.ru/*' => \Illuminate\Support\Facades\Http::response([[
|
||||
'qc' => 0, 'region' => 'Москва', 'provider' => 'МТС',
|
||||
]], 200)]);
|
||||
config([
|
||||
'services.dadata.enabled' => true,
|
||||
'services.dadata.api_key' => 'k',
|
||||
'services.dadata.secret' => 's',
|
||||
'services.dadata.daily_cap_rub' => 100000,
|
||||
]);
|
||||
[$lead, $project, $tenant] = seedRoutableLead(regions: '{82}', tag: 'tag', phone: '79161234567');
|
||||
|
||||
runRouteJob($lead->id);
|
||||
|
||||
// deals.city = имя субъекта (RussianRegions::CODE_TO_NAME) по резолву: 82 → «Москва».
|
||||
$deal = dealFor($tenant->id, $project->id);
|
||||
expect($deal)->not->toBeNull()
|
||||
->and($deal->city)->toBe('Москва');
|
||||
});
|
||||
|
||||
it('leaves deal city null when region is unknown', function (): void {
|
||||
config(['services.dadata.enabled' => false]);
|
||||
// Нераспознанный тег + невалидный телефон → subjectCode null → city пустой.
|
||||
[$lead, $project, $tenant] = seedRoutableLead(regions: '{82}', tag: 'нераспознаваемый-тег-zzz', phone: '123');
|
||||
|
||||
runRouteJob($lead->id);
|
||||
|
||||
$deal = dealFor($tenant->id, $project->id);
|
||||
expect($deal)->not->toBeNull()
|
||||
->and($deal->city)->toBeNull();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
uses(SharesSupplierPdo::class);
|
||||
|
||||
it('creates phone_ranges with lookup columns', function (): void {
|
||||
expect(DB::selectOne("SELECT to_regclass('public.phone_ranges') AS t")->t)->not->toBeNull();
|
||||
|
||||
$cols = collect(DB::select("SELECT column_name FROM information_schema.columns WHERE table_name = 'phone_ranges'"))
|
||||
->pluck('column_name')->all();
|
||||
|
||||
expect($cols)->toContain('def_code', 'from_num', 'to_num', 'operator', 'region', 'subject_code', 'import_id');
|
||||
});
|
||||
|
||||
it('creates phone_ranges_imports journal table', function (): void {
|
||||
expect(DB::selectOne("SELECT to_regclass('public.phone_ranges_imports') AS t")->t)->not->toBeNull();
|
||||
|
||||
$cols = collect(DB::select("SELECT column_name FROM information_schema.columns WHERE table_name = 'phone_ranges_imports'"))
|
||||
->pluck('column_name')->all();
|
||||
|
||||
expect($cols)->toContain('source_url', 'checksum_sha256', 'status', 'rows_inserted', 'rows_updated');
|
||||
});
|
||||
|
||||
it('creates lead_region_resolution_log as a partitioned table', function (): void {
|
||||
$partitioned = DB::selectOne(
|
||||
"SELECT 1 AS ok
|
||||
FROM pg_partitioned_table pt
|
||||
JOIN pg_class c ON c.oid = pt.partrelid
|
||||
WHERE c.relname = 'lead_region_resolution_log'"
|
||||
);
|
||||
|
||||
expect($partitioned)->not->toBeNull();
|
||||
});
|
||||
|
||||
it('adds resolution columns to supplier_leads', function (): void {
|
||||
$cols = collect(DB::select("SELECT column_name FROM information_schema.columns WHERE table_name = 'supplier_leads'"))
|
||||
->pluck('column_name')->all();
|
||||
|
||||
expect($cols)->toContain('resolved_subject_code', 'region_source', 'dadata_qc', 'phone_operator');
|
||||
});
|
||||
|
||||
it('adds resolution columns to deals', function (): void {
|
||||
$cols = collect(DB::select("SELECT column_name FROM information_schema.columns WHERE table_name = 'deals'"))
|
||||
->pluck('column_name')->all();
|
||||
|
||||
expect($cols)->toContain('phone_operator', 'region_substituted');
|
||||
});
|
||||
@@ -76,10 +76,11 @@ test('идемпотентность: повторный запуск не па
|
||||
|
||||
expect($afterSecond)->toBe($afterFirst);
|
||||
|
||||
// Output второго запуска должен сказать «0 created» по всем 8 таблицам × 6 месяцев = 48 партиций.
|
||||
// (webhook_log удалён в миграции 2026_05_24_140000_drop_legacy_webhook_artefacts)
|
||||
// Output второго запуска должен сказать «0 created» по всем партиционированным таблицам × 6 месяцев
|
||||
// (текущий + ahead=5). Число таблиц берём из PARTITIONED_TABLES — тест не ломается при добавлении новых.
|
||||
$expectedSkipped = count(\App\Services\MonthlyPartitionManager::PARTITIONED_TABLES) * 6;
|
||||
$output = Artisan::output();
|
||||
expect($output)->toContain('0 created, 48 skipped');
|
||||
expect($output)->toContain("0 created, {$expectedSkipped} skipped");
|
||||
});
|
||||
|
||||
test('--ahead=0 создаёт только текущий месяц', function () {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Services\DaData\DaDataBudgetGuard;
|
||||
|
||||
it('allows spend while under the daily cap', function (): void {
|
||||
config(['services.dadata.daily_cap_rub' => 10]); // 1000 копеек
|
||||
$guard = app(DaDataBudgetGuard::class);
|
||||
|
||||
expect($guard->canSpend())->toBeTrue();
|
||||
|
||||
$guard->recordSpend(500);
|
||||
|
||||
expect($guard->canSpend())->toBeTrue()
|
||||
->and($guard->spentTodayKopecks())->toBe(500);
|
||||
});
|
||||
|
||||
it('blocks spend once the daily cap is reached', function (): void {
|
||||
config(['services.dadata.daily_cap_rub' => 1]); // 100 копеек
|
||||
$guard = app(DaDataBudgetGuard::class);
|
||||
|
||||
$guard->recordSpend(100);
|
||||
|
||||
expect($guard->canSpend())->toBeFalse();
|
||||
});
|
||||
|
||||
it('accumulates spend across multiple calls', function (): void {
|
||||
config(['services.dadata.daily_cap_rub' => 100]);
|
||||
$guard = app(DaDataBudgetGuard::class);
|
||||
|
||||
$guard->recordSpend(30);
|
||||
$guard->recordSpend(70);
|
||||
|
||||
expect($guard->spentTodayKopecks())->toBe(100);
|
||||
});
|
||||
|
||||
it('starts at zero spend for a fresh day', function (): void {
|
||||
$guard = app(DaDataBudgetGuard::class);
|
||||
|
||||
expect($guard->spentTodayKopecks())->toBe(0);
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Services\DaData\DaDataException;
|
||||
use App\Services\DaData\DaDataPhoneClient;
|
||||
use App\Services\DaData\DaDataTimeoutException;
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
it('parses qc=0 mobile response into DTO', function (): void {
|
||||
Http::fake(['cleaner.dadata.ru/*' => Http::response([[
|
||||
'qc' => 0, 'qc_conflict' => 0, 'type' => 'Мобильный', 'phone' => '+7 921 555-12-34',
|
||||
'provider' => 'МегаФон', 'region' => 'Санкт-Петербург и область', 'city' => null, 'timezone' => 'UTC+3',
|
||||
]], 200)]);
|
||||
|
||||
$resp = app(DaDataPhoneClient::class)->cleanPhone('79215551234');
|
||||
|
||||
expect($resp->qc)->toBe(0)
|
||||
->and($resp->provider)->toBe('МегаФон')
|
||||
->and($resp->region)->toBe('Санкт-Петербург и область')
|
||||
->and($resp->type)->toBe('Мобильный')
|
||||
->and($resp->raw)->toBeArray();
|
||||
});
|
||||
|
||||
it('parses qc=3 multiple response', function (): void {
|
||||
Http::fake(['cleaner.dadata.ru/*' => Http::response([[
|
||||
'qc' => 3, 'region' => 'Москва', 'provider' => 'МТС', 'type' => 'Мобильный',
|
||||
]], 200)]);
|
||||
|
||||
expect(app(DaDataPhoneClient::class)->cleanPhone('79991234567')->qc)->toBe(3);
|
||||
});
|
||||
|
||||
it('sends Token auth, X-Secret header and json-array body', function (): void {
|
||||
config(['services.dadata.api_key' => 'KEY', 'services.dadata.secret' => 'SEC']);
|
||||
Http::fake(['cleaner.dadata.ru/*' => Http::response([['qc' => 0, 'region' => 'Москва']], 200)]);
|
||||
|
||||
app(DaDataPhoneClient::class)->cleanPhone('79161234567');
|
||||
|
||||
Http::assertSent(function ($request): bool {
|
||||
return $request->url() === 'https://cleaner.dadata.ru/api/v1/clean/phone'
|
||||
&& $request->hasHeader('Authorization', 'Token KEY')
|
||||
&& $request->hasHeader('X-Secret', 'SEC')
|
||||
&& $request->body() === '["79161234567"]';
|
||||
});
|
||||
});
|
||||
|
||||
it('throws DaDataTimeoutException on connection error', function (): void {
|
||||
Http::fake(fn () => throw new ConnectionException('timeout'));
|
||||
|
||||
expect(fn () => app(DaDataPhoneClient::class)->cleanPhone('79215551234'))
|
||||
->toThrow(DaDataTimeoutException::class);
|
||||
});
|
||||
|
||||
it('throws DaDataException on persistent 5xx', function (): void {
|
||||
Http::fake(['cleaner.dadata.ru/*' => Http::response('upstream error', 500)]);
|
||||
|
||||
expect(fn () => app(DaDataPhoneClient::class)->cleanPhone('79215551234'))
|
||||
->toThrow(DaDataException::class);
|
||||
});
|
||||
|
||||
it('retries once on 5xx then succeeds', function (): void {
|
||||
Http::fakeSequence('cleaner.dadata.ru/*')
|
||||
->push('upstream error', 500)
|
||||
->push([['qc' => 0, 'region' => 'Москва', 'provider' => 'МТС']], 200);
|
||||
|
||||
$resp = app(DaDataPhoneClient::class)->cleanPhone('79161234567');
|
||||
|
||||
expect($resp->qc)->toBe(0);
|
||||
Http::assertSentCount(2);
|
||||
});
|
||||
|
||||
it('does not retry on 4xx client error', function (): void {
|
||||
Http::fake(['cleaner.dadata.ru/*' => Http::response('bad request', 400)]);
|
||||
|
||||
expect(fn () => app(DaDataPhoneClient::class)->cleanPhone('79161234567'))
|
||||
->toThrow(DaDataException::class);
|
||||
|
||||
Http::assertSentCount(1);
|
||||
});
|
||||
@@ -0,0 +1,215 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\SupplierLead;
|
||||
use App\Services\LeadRegionResolver;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
uses(SharesSupplierPdo::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
config([
|
||||
'services.dadata.enabled' => true,
|
||||
'services.dadata.api_key' => 'k',
|
||||
'services.dadata.secret' => 's',
|
||||
'services.dadata.daily_cap_rub' => 10000,
|
||||
]);
|
||||
});
|
||||
|
||||
function resolverSeedImport(): int
|
||||
{
|
||||
return (int) DB::table('phone_ranges_imports')->insertGetId([
|
||||
'source_url' => 'test', 'checksum_sha256' => str_repeat('b', 64),
|
||||
'status' => 'completed', 'imported_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
function resolverSeedRange(int $subject, string $region = 'Москва', int $def = 916, string $operator = 'Ростелеком'): void
|
||||
{
|
||||
DB::table('phone_ranges')->insert([
|
||||
'def_code' => $def, 'from_num' => 0, 'to_num' => 9999999,
|
||||
'operator' => $operator, 'region' => $region, 'subject_code' => $subject,
|
||||
'imported_at' => now(), 'import_id' => resolverSeedImport(),
|
||||
]);
|
||||
}
|
||||
|
||||
function resolverLead(string $phone = '79161234567', string $tag = ''): SupplierLead
|
||||
{
|
||||
return new SupplierLead([
|
||||
'phone' => $phone,
|
||||
'raw_payload' => ['tag' => $tag],
|
||||
'received_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
function fakeDadata(array $row): void
|
||||
{
|
||||
Http::fake(['cleaner.dadata.ru/*' => Http::response([$row], 200)]);
|
||||
}
|
||||
|
||||
it('dadata qc 0 returns dadata source', function (): void {
|
||||
fakeDadata(['qc' => 0, 'region' => 'Москва', 'provider' => 'МТС', 'type' => 'Мобильный']);
|
||||
|
||||
$r = app(LeadRegionResolver::class)->resolve(resolverLead());
|
||||
|
||||
expect($r->source)->toBe('dadata')
|
||||
->and($r->subjectCode)->toBe(82)
|
||||
->and($r->phoneOperator)->toBe('МТС')
|
||||
->and($r->qc)->toBe(0)
|
||||
->and($r->cacheHit)->toBeFalse();
|
||||
});
|
||||
|
||||
it('dadata qc 0 ambiguous region falls to rossvyaz but keeps dadata provider', function (): void {
|
||||
fakeDadata(['qc' => 0, 'region' => 'Санкт-Петербург и область', 'provider' => 'МегаФон']);
|
||||
resolverSeedRange(subject: 83, region: 'Санкт-Петербург');
|
||||
|
||||
$r = app(LeadRegionResolver::class)->resolve(resolverLead());
|
||||
|
||||
expect($r->source)->toBe('rossvyaz')
|
||||
->and($r->subjectCode)->toBe(83)
|
||||
->and($r->phoneOperator)->toBe('МегаФон') // оператор от DaData (MNP), §3.4.1
|
||||
->and($r->rossvyazMatched)->toBeTrue();
|
||||
});
|
||||
|
||||
it('dadata qc 3 returns dadata with multiple flag', function (): void {
|
||||
fakeDadata(['qc' => 3, 'region' => 'Москва', 'provider' => 'МТС']);
|
||||
|
||||
$r = app(LeadRegionResolver::class)->resolve(resolverLead());
|
||||
|
||||
expect($r->source)->toBe('dadata')->and($r->subjectCode)->toBe(82)->and($r->qc)->toBe(3);
|
||||
});
|
||||
|
||||
it('dadata qc 1 falls back to rossvyaz', function (): void {
|
||||
fakeDadata(['qc' => 1, 'region' => 'Москва', 'provider' => 'Билайн']);
|
||||
resolverSeedRange(subject: 82);
|
||||
|
||||
$r = app(LeadRegionResolver::class)->resolve(resolverLead());
|
||||
|
||||
expect($r->source)->toBe('rossvyaz')->and($r->subjectCode)->toBe(82);
|
||||
});
|
||||
|
||||
it('dadata qc 2 falls back to tag skipping rossvyaz', function (): void {
|
||||
fakeDadata(['qc' => 2]);
|
||||
resolverSeedRange(subject: 83); // если бы Россвязь дёрнули — был бы 83
|
||||
|
||||
$r = app(LeadRegionResolver::class)->resolve(resolverLead(tag: 'Москва'));
|
||||
|
||||
expect($r->source)->toBe('tag')->and($r->subjectCode)->toBe(82)->and($r->rossvyazMatched)->toBeFalse();
|
||||
});
|
||||
|
||||
it('dadata qc 7 falls back to tag skipping rossvyaz', function (): void {
|
||||
fakeDadata(['qc' => 7]);
|
||||
resolverSeedRange(subject: 83);
|
||||
|
||||
$r = app(LeadRegionResolver::class)->resolve(resolverLead(tag: 'Москва'));
|
||||
|
||||
expect($r->source)->toBe('tag')->and($r->subjectCode)->toBe(82);
|
||||
});
|
||||
|
||||
it('dadata timeout falls back to rossvyaz', function (): void {
|
||||
Http::fake(fn () => throw new ConnectionException('timeout'));
|
||||
resolverSeedRange(subject: 82);
|
||||
|
||||
$r = app(LeadRegionResolver::class)->resolve(resolverLead());
|
||||
|
||||
expect($r->source)->toBe('rossvyaz')->and($r->subjectCode)->toBe(82);
|
||||
});
|
||||
|
||||
it('dadata network error 5xx falls back to rossvyaz', function (): void {
|
||||
Http::fake(['cleaner.dadata.ru/*' => Http::response('err', 500)]);
|
||||
resolverSeedRange(subject: 82);
|
||||
|
||||
$r = app(LeadRegionResolver::class)->resolve(resolverLead());
|
||||
|
||||
expect($r->source)->toBe('rossvyaz')->and($r->subjectCode)->toBe(82);
|
||||
});
|
||||
|
||||
it('budget cap exceeded skips dadata directly to rossvyaz', function (): void {
|
||||
config(['services.dadata.daily_cap_rub' => 0]); // canSpend() → false
|
||||
Http::fake(['cleaner.dadata.ru/*' => Http::response([['qc' => 0, 'region' => 'Москва']], 200)]);
|
||||
resolverSeedRange(subject: 82);
|
||||
|
||||
$r = app(LeadRegionResolver::class)->resolve(resolverLead());
|
||||
|
||||
expect($r->source)->toBe('rossvyaz')->and($r->subjectCode)->toBe(82);
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
it('cache hit skips dadata and rossvyaz on the second call', function (): void {
|
||||
fakeDadata(['qc' => 0, 'region' => 'Москва', 'provider' => 'МТС']);
|
||||
$resolver = app(LeadRegionResolver::class);
|
||||
|
||||
$first = $resolver->resolve(resolverLead());
|
||||
$second = $resolver->resolve(resolverLead());
|
||||
|
||||
expect($first->cacheHit)->toBeFalse()
|
||||
->and($second->cacheHit)->toBeTrue()
|
||||
->and($second->subjectCode)->toBe(82);
|
||||
Http::assertSentCount(1);
|
||||
});
|
||||
|
||||
it('invalid phone skips dadata returns tag', function (): void {
|
||||
Http::fake(['cleaner.dadata.ru/*' => Http::response([['qc' => 0]], 200)]);
|
||||
|
||||
$r = app(LeadRegionResolver::class)->resolve(resolverLead(phone: '123', tag: 'Москва'));
|
||||
|
||||
expect($r->source)->toBe('tag')->and($r->subjectCode)->toBe(82);
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
it('qc 0 region null falls through to rossvyaz', function (): void {
|
||||
fakeDadata(['qc' => 0, 'region' => null, 'provider' => 'Tele2']);
|
||||
resolverSeedRange(subject: 82);
|
||||
|
||||
$r = app(LeadRegionResolver::class)->resolve(resolverLead());
|
||||
|
||||
expect($r->source)->toBe('rossvyaz')->and($r->subjectCode)->toBe(82)->and($r->phoneOperator)->toBe('Tele2');
|
||||
});
|
||||
|
||||
it('unmappable dadata region falls through to rossvyaz', function (): void {
|
||||
fakeDadata(['qc' => 0, 'region' => 'Несуществующий край', 'provider' => 'МТС']);
|
||||
resolverSeedRange(subject: 82);
|
||||
|
||||
$r = app(LeadRegionResolver::class)->resolve(resolverLead());
|
||||
|
||||
expect($r->source)->toBe('rossvyaz')->and($r->subjectCode)->toBe(82);
|
||||
});
|
||||
|
||||
it('all three layers fail returns unknown with null subject_code', function (): void {
|
||||
fakeDadata(['qc' => 1]); // → rossvyaz
|
||||
// no phone_ranges seeded → rossvyaz miss; tag empty → null
|
||||
|
||||
$r = app(LeadRegionResolver::class)->resolve(resolverLead(tag: ''));
|
||||
|
||||
expect($r->source)->toBe('unknown')->and($r->subjectCode)->toBeNull();
|
||||
});
|
||||
|
||||
it('disabled feature flag returns tag without any dadata call', function (): void {
|
||||
config(['services.dadata.enabled' => false]);
|
||||
Http::fake(['cleaner.dadata.ru/*' => Http::response([['qc' => 0]], 200)]);
|
||||
|
||||
$r = app(LeadRegionResolver::class)->resolve(resolverLead(tag: 'Москва'));
|
||||
|
||||
expect($r->source)->toBe('tag')->and($r->subjectCode)->toBe(82);
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
it('persistent idempotency: already-resolved lead skips dadata', function (): void {
|
||||
Http::fake(['cleaner.dadata.ru/*' => Http::response([['qc' => 0, 'region' => 'Москва']], 200)]);
|
||||
$lead = resolverLead();
|
||||
$lead->resolved_subject_code = 83;
|
||||
$lead->region_source = 'dadata';
|
||||
$lead->dadata_qc = 0;
|
||||
$lead->phone_operator = 'МегаФон';
|
||||
|
||||
$r = app(LeadRegionResolver::class)->resolve($lead);
|
||||
|
||||
expect($r->subjectCode)->toBe(83)->and($r->source)->toBe('dadata');
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\SupplierProject;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\LeadRouter;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Random\Engine\Mt19937;
|
||||
use Random\Randomizer;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
uses(SharesSupplierPdo::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
|
||||
});
|
||||
|
||||
/** Детерминированный роутер с засеянным жребием (вариант В). */
|
||||
function seededRouter(int $seed = 42): LeadRouter
|
||||
{
|
||||
return new LeadRouter(new Randomizer(new Mt19937($seed)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Создаёт tenant + project + pivot/snapshot для каскад-тестов.
|
||||
* regions — PG-массив-литерал ('{82}' / '{}'); remaining лимита = dailyLimit - deliveredToday.
|
||||
*/
|
||||
function makeCascadeProject(
|
||||
SupplierProject $sp,
|
||||
string $regions,
|
||||
int $dailyLimit = 100,
|
||||
int $deliveredToday = 0,
|
||||
): Project {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 100, 'balance_rub' => '1000.00']);
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'is_active' => true,
|
||||
'daily_limit_target' => $dailyLimit,
|
||||
'delivered_today' => $deliveredToday,
|
||||
'delivery_days_mask' => 127,
|
||||
'signal_type' => $sp->signal_type,
|
||||
'signal_identifier' => $sp->unique_key,
|
||||
]);
|
||||
linkProjectToSupplier($project, $sp);
|
||||
createRoutingSnapshotFromProject(
|
||||
$project,
|
||||
signalType: $sp->signal_type,
|
||||
signalIdentifier: $sp->unique_key,
|
||||
dailyLimit: $dailyLimit,
|
||||
regions: $regions,
|
||||
);
|
||||
|
||||
return $project;
|
||||
}
|
||||
|
||||
function b1Supplier(string $key = 'ex.ru'): SupplierProject
|
||||
{
|
||||
return SupplierProject::query()->create([
|
||||
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => $key,
|
||||
'subject_code' => 82, 'current_limit' => 0, 'sync_status' => 'ok',
|
||||
]);
|
||||
}
|
||||
|
||||
it('step 1: exact region match wins, others excluded', function (): void {
|
||||
$sp = b1Supplier();
|
||||
$spb = makeCascadeProject($sp, regions: '{83}'); // Питер
|
||||
$msk = makeCascadeProject($sp, regions: '{82}'); // Москва
|
||||
|
||||
$matched = seededRouter()->matchEligibleProjects($sp, resolvedSubjectCode: 82);
|
||||
|
||||
expect($matched->pluck('id')->all())->toBe([$msk->id])
|
||||
->and($matched->first()->routing_step)->toBe(1);
|
||||
});
|
||||
|
||||
it('step 2: falls to all-RF when no exact match', function (): void {
|
||||
$sp = b1Supplier('s2.ru');
|
||||
$allRu = makeCascadeProject($sp, regions: '{}'); // вся РФ
|
||||
|
||||
$matched = seededRouter()->matchEligibleProjects($sp, resolvedSubjectCode: 82);
|
||||
|
||||
expect($matched->pluck('id')->all())->toBe([$allRu->id])
|
||||
->and($matched->first()->routing_step)->toBe(2);
|
||||
});
|
||||
|
||||
it('step 3: fallback channel when nobody subscribed to region and no all-RF', function (): void {
|
||||
$sp = b1Supplier('s3.ru');
|
||||
$spb = makeCascadeProject($sp, regions: '{83}'); // только Питер подписан
|
||||
|
||||
// resolvedSubjectCode=82 (Москва): точных нет, «вся РФ» нет → запасной канал.
|
||||
$matched = seededRouter()->matchEligibleProjects($sp, resolvedSubjectCode: 82);
|
||||
|
||||
expect($matched->pluck('id')->all())->toBe([$spb->id])
|
||||
->and($matched->first()->routing_step)->toBe(3);
|
||||
});
|
||||
|
||||
it('exact + all-RF combine up to cap=3, exact taking priority', function (): void {
|
||||
$sp = b1Supplier('s4.ru');
|
||||
$e1 = makeCascadeProject($sp, regions: '{82}');
|
||||
$e2 = makeCascadeProject($sp, regions: '{82}');
|
||||
$r1 = makeCascadeProject($sp, regions: '{}');
|
||||
$r2 = makeCascadeProject($sp, regions: '{}');
|
||||
|
||||
$matched = seededRouter()->matchEligibleProjects($sp, resolvedSubjectCode: 82);
|
||||
|
||||
// Всего 3 (cap). Оба точных (step 1) обязаны быть; добор — ровно 1 «вся РФ» (step 2).
|
||||
expect($matched)->toHaveCount(3);
|
||||
$byStep = $matched->groupBy(fn ($p) => $p->routing_step);
|
||||
expect($byStep->get(1)->pluck('id')->sort()->values()->all())->toBe(collect([$e1->id, $e2->id])->sort()->values()->all())
|
||||
->and($byStep->get(2))->toHaveCount(1);
|
||||
expect(in_array($byStep->get(2)->first()->id, [$r1->id, $r2->id], true))->toBeTrue();
|
||||
});
|
||||
|
||||
it('null resolvedSubjectCode skips exact, uses all-RF', function (): void {
|
||||
$sp = b1Supplier('s5.ru');
|
||||
$allRu = makeCascadeProject($sp, regions: '{}');
|
||||
$exact = makeCascadeProject($sp, regions: '{82}');
|
||||
|
||||
// Резолвер не сработал → шаг 1 пропускается; матчит только «вся РФ».
|
||||
$matched = seededRouter()->matchEligibleProjects($sp, resolvedSubjectCode: null);
|
||||
|
||||
expect($matched->pluck('id')->all())->toBe([$allRu->id])
|
||||
->and($matched->first()->routing_step)->toBe(2);
|
||||
});
|
||||
|
||||
it('cascade works for DIRECT supplier_project path too', function (): void {
|
||||
$sp = SupplierProject::query()->create([
|
||||
'platform' => 'DIRECT', 'signal_type' => 'site', 'unique_key' => 'cashmotor.ru',
|
||||
'subject_code' => 82, 'current_limit' => 0, 'sync_status' => 'ok',
|
||||
]);
|
||||
$msk = makeCascadeProject($sp, regions: '{82}');
|
||||
$spb = makeCascadeProject($sp, regions: '{83}');
|
||||
|
||||
$matched = seededRouter()->matchEligibleProjects($sp, resolvedSubjectCode: 82);
|
||||
|
||||
expect($matched->pluck('id')->all())->toBe([$msk->id])
|
||||
->and($matched->first()->routing_step)->toBe(1);
|
||||
});
|
||||
|
||||
it('backward compat: no second arg behaves as all-RF/any (existing call shape)', function (): void {
|
||||
$sp = b1Supplier('s7.ru');
|
||||
$allRu = makeCascadeProject($sp, regions: '{}');
|
||||
|
||||
// Старая сигнатура (без 2-го аргумента) — дефолт null → шаг 2 all-RF матчит '{}'.
|
||||
$matched = seededRouter()->matchEligibleProjects($sp);
|
||||
|
||||
expect($matched->pluck('id')->all())->toBe([$allRu->id]);
|
||||
});
|
||||
|
||||
it('variant В: weighted pick — small client never starved, big client wins more often', function (): void {
|
||||
$sp = b1Supplier('fair.ru');
|
||||
// 5 клиентов на Москву, разный остаток лимита.
|
||||
$a = makeCascadeProject($sp, regions: '{82}', dailyLimit: 100); // остаток 100
|
||||
$b = makeCascadeProject($sp, regions: '{82}', dailyLimit: 50);
|
||||
$c = makeCascadeProject($sp, regions: '{82}', dailyLimit: 30);
|
||||
$d = makeCascadeProject($sp, regions: '{82}', dailyLimit: 20);
|
||||
$e = makeCascadeProject($sp, regions: '{82}', dailyLimit: 10); // остаток 10 — самый маленький
|
||||
|
||||
$wins = [];
|
||||
$seedCount = 120;
|
||||
for ($seed = 0; $seed < $seedCount; $seed++) {
|
||||
$matched = seededRouter($seed)->matchEligibleProjects($sp, resolvedSubjectCode: 82);
|
||||
expect($matched)->toHaveCount(3); // лид всегда раздаётся ровно троим
|
||||
foreach ($matched as $p) {
|
||||
$wins[$p->id] = ($wins[$p->id] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// (1) Мелкого не отрезаем: за 120 розыгрышей хотя бы раз получил лид.
|
||||
expect($wins[$e->id] ?? 0)->toBeGreaterThan(0);
|
||||
// (2) Вес уважается: крупный клиент выигрывает строго чаще мелкого.
|
||||
expect($wins[$a->id] ?? 0)->toBeGreaterThan($wins[$e->id] ?? 0);
|
||||
});
|
||||
|
||||
it('variant В: deterministic — same seed yields same recipients', function (): void {
|
||||
$sp = b1Supplier('det.ru');
|
||||
makeCascadeProject($sp, regions: '{82}', dailyLimit: 100);
|
||||
makeCascadeProject($sp, regions: '{82}', dailyLimit: 50);
|
||||
makeCascadeProject($sp, regions: '{82}', dailyLimit: 30);
|
||||
makeCascadeProject($sp, regions: '{82}', dailyLimit: 20);
|
||||
|
||||
$first = seededRouter(7)->matchEligibleProjects($sp, resolvedSubjectCode: 82)->pluck('id')->all();
|
||||
$second = seededRouter(7)->matchEligibleProjects($sp, resolvedSubjectCode: 82)->pluck('id')->all();
|
||||
|
||||
expect($first)->toBe($second)->and($first)->toHaveCount(3);
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\SupplierLead;
|
||||
use App\Services\Dto\RegionResolution;
|
||||
|
||||
it('exposes the source rank ordering dadata>rossvyaz>tag>unknown', function (): void {
|
||||
expect(RegionResolution::SOURCE_RANK)->toBe([
|
||||
'dadata' => 4, 'rossvyaz' => 3, 'tag' => 2, 'unknown' => 1,
|
||||
]);
|
||||
});
|
||||
|
||||
it('make sets actualSubjectCode equal to subjectCode', function (): void {
|
||||
$r = RegionResolution::make(82, 'dadata', operator: 'МТС', qc: 0);
|
||||
|
||||
expect($r->subjectCode)->toBe(82)
|
||||
->and($r->actualSubjectCode)->toBe(82)
|
||||
->and($r->source)->toBe('dadata')
|
||||
->and($r->phoneOperator)->toBe('МТС')
|
||||
->and($r->qc)->toBe(0)
|
||||
->and($r->cacheHit)->toBeFalse()
|
||||
->and($r->rossvyazMatched)->toBeFalse();
|
||||
});
|
||||
|
||||
it('fromTag builds a tag-sourced resolution', function (): void {
|
||||
$r = RegionResolution::fromTag(82);
|
||||
|
||||
expect($r->subjectCode)->toBe(82)
|
||||
->and($r->source)->toBe('tag')
|
||||
->and($r->phoneOperator)->toBeNull();
|
||||
});
|
||||
|
||||
it('fromSupplierLead reconstructs a persisted resolution (idempotency)', function (): void {
|
||||
$lead = new SupplierLead([
|
||||
'resolved_subject_code' => 83,
|
||||
'region_source' => 'dadata',
|
||||
'dadata_qc' => 0,
|
||||
'phone_operator' => 'МегаФон',
|
||||
]);
|
||||
|
||||
$r = RegionResolution::fromSupplierLead($lead);
|
||||
|
||||
expect($r->subjectCode)->toBe(83)
|
||||
->and($r->source)->toBe('dadata')
|
||||
->and($r->phoneOperator)->toBe('МегаФон')
|
||||
->and($r->qc)->toBe(0);
|
||||
});
|
||||
|
||||
it('withCacheHit flips the flag and clears the per-call masked response', function (): void {
|
||||
$r = RegionResolution::make(82, 'dadata', operator: 'МТС', qc: 0, dadataMasked: ['phone' => '7916***4567']);
|
||||
|
||||
$hit = $r->withCacheHit(true);
|
||||
|
||||
expect($hit->cacheHit)->toBeTrue()
|
||||
->and($hit->subjectCode)->toBe(82)
|
||||
->and($hit->dadataResponseMasked)->toBeNull();
|
||||
});
|
||||
|
||||
it('forCache strips per-call fields before storing', function (): void {
|
||||
$r = RegionResolution::make(82, 'dadata', operator: 'МТС', qc: 0, dadataMasked: ['phone' => 'x'], durationMs: 120);
|
||||
|
||||
$c = $r->forCache();
|
||||
|
||||
expect($c->dadataResponseMasked)->toBeNull()
|
||||
->and($c->durationMs)->toBeNull()
|
||||
->and($c->cacheHit)->toBeFalse()
|
||||
->and($c->subjectCode)->toBe(82)
|
||||
->and($c->phoneOperator)->toBe('МТС');
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Services\Dto\RossvyazRecord;
|
||||
use App\Services\RossvyazPrefixLookup;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
uses(SharesSupplierPdo::class);
|
||||
|
||||
/**
|
||||
* Вставляет строку-журнал импорта и возвращает её id (import_id для phone_ranges).
|
||||
*/
|
||||
function seedRossvyazImport(): int
|
||||
{
|
||||
return (int) DB::table('phone_ranges_imports')->insertGetId([
|
||||
'source_url' => 'https://rossvyaz.gov.ru/test',
|
||||
'checksum_sha256' => str_repeat('a', 64),
|
||||
'status' => 'completed',
|
||||
'imported_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $overrides
|
||||
*/
|
||||
function seedPhoneRange(array $overrides = []): void
|
||||
{
|
||||
DB::table('phone_ranges')->insert(array_merge([
|
||||
'def_code' => 921,
|
||||
'from_num' => 5550000,
|
||||
'to_num' => 5559999,
|
||||
'operator' => 'МегаФон',
|
||||
'region' => 'Санкт-Петербург',
|
||||
'subject_code' => 83,
|
||||
'imported_at' => now(),
|
||||
'import_id' => seedRossvyazImport(),
|
||||
], $overrides));
|
||||
}
|
||||
|
||||
it('mobile prefix returns correct region and operator', function (): void {
|
||||
seedPhoneRange();
|
||||
|
||||
$rec = app(RossvyazPrefixLookup::class)->find('79215555123');
|
||||
|
||||
expect($rec)->toBeInstanceOf(RossvyazRecord::class)
|
||||
->and($rec->subjectCode)->toBe(83)
|
||||
->and($rec->region)->toBe('Санкт-Петербург')
|
||||
->and($rec->operator)->toBe('МегаФон');
|
||||
});
|
||||
|
||||
it('prefers narrower range when two ranges overlap', function (): void {
|
||||
$importId = seedRossvyazImport();
|
||||
// Широкий диапазон (вся 495-зона) — Московская область (56).
|
||||
seedPhoneRange([
|
||||
'def_code' => 495, 'from_num' => 1000000, 'to_num' => 9999999,
|
||||
'operator' => 'Ростелеком', 'region' => 'Московская область',
|
||||
'subject_code' => 56, 'import_id' => $importId,
|
||||
]);
|
||||
// Узкий диапазон внутри — Москва (82). Должен выиграть (ORDER BY width ASC).
|
||||
seedPhoneRange([
|
||||
'def_code' => 495, 'from_num' => 2000000, 'to_num' => 2009999,
|
||||
'operator' => 'МГТС', 'region' => 'Москва',
|
||||
'subject_code' => 82, 'import_id' => $importId,
|
||||
]);
|
||||
|
||||
$rec = app(RossvyazPrefixLookup::class)->find('74952005000');
|
||||
|
||||
expect($rec)->not->toBeNull()
|
||||
->and($rec->subjectCode)->toBe(82)
|
||||
->and($rec->region)->toBe('Москва');
|
||||
});
|
||||
|
||||
it('returns null for unknown prefix', function (): void {
|
||||
seedPhoneRange(); // только def_code=921
|
||||
|
||||
expect(app(RossvyazPrefixLookup::class)->find('79991234567'))->toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when subscriber number is outside any range', function (): void {
|
||||
seedPhoneRange(['def_code' => 921, 'from_num' => 5550000, 'to_num' => 5559999]);
|
||||
|
||||
// def_code совпадает (921), но subscriber 4440000 вне [5550000, 5559999]
|
||||
expect(app(RossvyazPrefixLookup::class)->find('79214440000'))->toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for malformed phone', function (): void {
|
||||
seedPhoneRange();
|
||||
|
||||
expect(app(RossvyazPrefixLookup::class)->find('123'))->toBeNull();
|
||||
});
|
||||
+2
-1
@@ -131,6 +131,7 @@ function createRoutingSnapshotFromProject(
|
||||
string $signalType = 'call',
|
||||
?string $signalIdentifier = null,
|
||||
?int $dailyLimit = null,
|
||||
string $regions = '{}',
|
||||
): void {
|
||||
DB::table('project_routing_snapshots')->insert([
|
||||
'snapshot_date' => $date ?? Carbon::today('Europe/Moscow')->toDateString(),
|
||||
@@ -138,7 +139,7 @@ function createRoutingSnapshotFromProject(
|
||||
'tenant_id' => $project->tenant_id,
|
||||
'daily_limit' => $dailyLimit ?? (int) ($project->effective_daily_limit_today ?? $project->daily_limit_target),
|
||||
'delivery_days_mask' => (int) ($project->delivery_days_mask ?? 127),
|
||||
'regions' => '{}',
|
||||
'regions' => $regions,
|
||||
'signal_type' => $signalType,
|
||||
'signal_identifier' => $signalIdentifier,
|
||||
'sms_senders' => null,
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Services\MonthlyPartitionManager;
|
||||
|
||||
it('knows lead_region_resolution_log partition key', function (): void {
|
||||
expect(MonthlyPartitionManager::PARTITIONED_TABLES)->toHaveKey('lead_region_resolution_log');
|
||||
expect(MonthlyPartitionManager::PARTITIONED_TABLES['lead_region_resolution_log'])->toBe('received_at');
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Support\DaDataRegionMap;
|
||||
use App\Support\RussianRegions;
|
||||
|
||||
it('maps exact official names via RussianRegions', function (): void {
|
||||
expect(DaDataRegionMap::toSubjectCode('Москва'))->toBe(82)
|
||||
->and(DaDataRegionMap::toSubjectCode('Московская область'))->toBe(56)
|
||||
->and(DaDataRegionMap::toSubjectCode('Санкт-Петербург'))->toBe(83)
|
||||
->and(DaDataRegionMap::toSubjectCode('Ленинградская область'))->toBe(53);
|
||||
});
|
||||
|
||||
it('trims surrounding whitespace before mapping', function (): void {
|
||||
expect(DaDataRegionMap::toSubjectCode(' Москва '))->toBe(82);
|
||||
});
|
||||
|
||||
it('flags ambiguous agglomeration strings', function (): void {
|
||||
expect(DaDataRegionMap::isAmbiguous('Санкт-Петербург и область'))->toBeTrue()
|
||||
->and(DaDataRegionMap::isAmbiguous('Москва и область'))->toBeTrue()
|
||||
->and(DaDataRegionMap::isAmbiguous('Москва'))->toBeFalse()
|
||||
->and(DaDataRegionMap::isAmbiguous('Санкт-Петербург'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('returns null for unmappable region', function (): void {
|
||||
expect(DaDataRegionMap::toSubjectCode('Атлантида'))->toBeNull()
|
||||
->and(DaDataRegionMap::toSubjectCode(''))->toBeNull();
|
||||
});
|
||||
|
||||
it('resolves all 89 RussianRegions names', function (): void {
|
||||
foreach (RussianRegions::CODE_TO_NAME as $code => $name) {
|
||||
expect(DaDataRegionMap::toSubjectCode($name))->toBe($code);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Support\RussianRegions;
|
||||
|
||||
/**
|
||||
* Нормализация регионов реестра Россвязи → subject_code.
|
||||
* Кейсы взяты из реальных топ-50 unmapped-форматов прод-реестра (02.06.2026).
|
||||
*/
|
||||
it('maps cities of federal significance with the г. prefix', function (): void {
|
||||
expect(RussianRegions::resolveSubjectCode('г. Москва'))->toBe(82)
|
||||
->and(RussianRegions::resolveSubjectCode('г. Санкт-Петербург'))->toBe(83)
|
||||
->and(RussianRegions::resolveSubjectCode('г. Севастополь'))->toBe(84);
|
||||
});
|
||||
|
||||
it('still maps a plain canonical federal-city name', function (): void {
|
||||
expect(RussianRegions::resolveSubjectCode('Москва'))->toBe(82);
|
||||
});
|
||||
|
||||
it('takes the last pipe segment as the subject region', function (): void {
|
||||
// регион = последний сегмент после |
|
||||
expect(RussianRegions::resolveSubjectCode('г. Оренбург|Оренбургская обл.'))->toBe(62)
|
||||
->and(RussianRegions::resolveSubjectCode('г. Воскресенск|р-н Воскресенский|Московская обл.'))->toBe(56);
|
||||
});
|
||||
|
||||
it('expands the обл. abbreviation to область', function (): void {
|
||||
expect(RussianRegions::resolveSubjectCode('г. Иркутск|Иркутская обл.'))->toBe(45)
|
||||
->and(RussianRegions::resolveSubjectCode('г. Балашиха|Московская обл.'))->toBe(56);
|
||||
});
|
||||
|
||||
it('keeps already-canonical край/республика segments', function (): void {
|
||||
expect(RussianRegions::resolveSubjectCode('г. Красноярск|Красноярский край'))->toBe(29)
|
||||
->and(RussianRegions::resolveSubjectCode('г. Уфа|Республика Башкортостан'))->toBe(3);
|
||||
});
|
||||
|
||||
it('reorders the Удмуртская Республика inverted form', function (): void {
|
||||
expect(RussianRegions::resolveSubjectCode('г. Ижевск|Республика Удмуртская'))->toBe(21);
|
||||
});
|
||||
|
||||
it('maps the Кузбасс special form to Кемеровская область', function (): void {
|
||||
expect(RussianRegions::resolveSubjectCode('г. Кемерово|Кемеровская область - Кузбасс обл.'))->toBe(48);
|
||||
});
|
||||
|
||||
it('returns null for hopeless / ambiguous / city-only strings', function (string $raw): void {
|
||||
expect(RussianRegions::resolveSubjectCode($raw))->toBeNull();
|
||||
})->with([
|
||||
'-',
|
||||
'Российская Федерация',
|
||||
'Москва и Московская область', // неоднозначно — два субъекта
|
||||
'г.о. Тольятти', // нет региона в строке
|
||||
'г.о. город Уфа',
|
||||
'',
|
||||
' ',
|
||||
]);
|
||||
|
||||
it('exposes the canonical name via canonicalRegionName', function (): void {
|
||||
expect(RussianRegions::canonicalRegionName('г. Оренбург|Оренбургская обл.'))->toBe('Оренбургская область')
|
||||
->and(RussianRegions::canonicalRegionName('г. Ижевск|Республика Удмуртская'))->toBe('Удмуртская Республика')
|
||||
->and(RussianRegions::canonicalRegionName('-'))->toBeNull();
|
||||
});
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
АВС/ DEF;От;До;Емкость;Оператор;Регион
|
||||
495;2000000;2009999;10000;ОАО МГТС;г. Москва
|
||||
922;1000000;1099999;100000;ПАО Ростелеком;г. Оренбург|Оренбургская обл.
|
||||
987;5000000;5099999;100000;ПАО Ростелеком;г. Ижевск|Республика Удмуртская
|
||||
902;7000000;7009999;10000;ООО Оператор;г.о. Тольятти
|
||||
|
+4
@@ -0,0 +1,4 @@
|
||||
АВС/ DEF;От;До;Емкость;Оператор;Регион
|
||||
495;2000000;2009999;10000;ОАО МГТС;Москва
|
||||
921;5550000;5559999;10000;ПАО МегаФон;Санкт-Петербург
|
||||
999;0000000;0009999;10000;Тест Оператор;Атлантида
|
||||
|
@@ -1974,3 +1974,11 @@ monitorится
|
||||
guillemets
|
||||
mirror'ящий
|
||||
plan'овский
|
||||
|
||||
# Lead region resolution (2026-05-31) — DaData / Rossvyaz region detection
|
||||
rossvyaz
|
||||
россвязь
|
||||
россвязи
|
||||
dadata
|
||||
kopecks
|
||||
qc
|
||||
|
||||
+55
-1
@@ -2,7 +2,61 @@
|
||||
|
||||
**Назначение:** консолидированный журнал изменений `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.39, консолидированная — разворачивает БД с нуля).
|
||||
**Файл схемы:** `schema.sql` (текущая версия — v8.40, консолидированная — разворачивает БД с нуля).
|
||||
|
||||
## v8.40 (2026-05-31) — lead region resolution (phone_ranges + resolution_log + supplier_leads/deals columns)
|
||||
|
||||
Резолюция настоящего региона лида по телефону (DaData → реестр Россвязи → tag-fallback)
|
||||
и переключение `LeadRouter` на каскадную маршрутизацию по региону. Эта запись покрывает
|
||||
только схемные изменения Session 1 (таблицы и колонки); бизнес-логика — в последующих сессиях.
|
||||
|
||||
Спека: `docs/superpowers/specs/2026-05-29-lead-region-resolution-design.md` v0.5.
|
||||
План: `docs/superpowers/plans/2026-05-29-lead-region-resolution.md`.
|
||||
Миграция: `app/database/migrations/2026_05_31_100000_create_phone_ranges_and_resolution_log.php`.
|
||||
|
||||
**Добавлено:**
|
||||
|
||||
- **`phone_ranges_imports`** — журнал импортов реестра Россвязи (SaaS-level, без RLS).
|
||||
Поля: `source_url`, `rows_inserted`/`rows_updated`, `checksum_sha256`, `status`
|
||||
(`in_progress`/`completed`/`failed`/`rolled_back`), `error`, `completed_at`.
|
||||
GRANT SELECT `crm_app_user` + `crm_supplier_worker`.
|
||||
- **`phone_ranges`** — реестр диапазонов нумерации Россвязи (SaaS-level, без RLS — публичные данные).
|
||||
Поля: `def_code` (код ABC/DEF), `from_num`/`to_num`, `operator`, `region`, `region_normalized`,
|
||||
`subject_code` (1..89), `imported_at`, `import_id`→`phone_ranges_imports`. 3 CHECK
|
||||
(`def_code` 300..999, `subject_code` 1..89, `from_num` ≤ `to_num`). Индекс
|
||||
`idx_phone_ranges_lookup (def_code, from_num, to_num)`. GRANT SELECT `crm_app_user` + `crm_supplier_worker`.
|
||||
- **`lead_region_resolution_log`** — PARTITION BY RANGE (`received_at`), composite PK
|
||||
`(id, received_at)`. Аудит резолва региона на лид: `phone_masked`, `subject_code_resolved`/
|
||||
`subject_code_from_tag`, `region_source` (`dadata`/`rossvyaz`/`tag`/`unknown`), `dadata_qc`/
|
||||
`dadata_provider`/`dadata_type`/`dadata_response_masked` (JSONB), `rossvyaz_matched`,
|
||||
`actual_subject_code`/`substituted_subject_code` (1..89), `routing_step` (1..3),
|
||||
`phone_operator`, `cache_hit`, `duration_ms`, `resolved_at`. Индексы `idx_lrrl_lead_id` +
|
||||
`idx_lrrl_source (region_source, received_at)`. GRANT SELECT,INSERT `crm_supplier_worker` /
|
||||
SELECT `crm_app_user`. Стартовые партиции `lead_region_resolution_log_y2026_m05`, `_y2026_m06`.
|
||||
- **`MonthlyPartitionManager::PARTITIONED_TABLES`** +entry `'lead_region_resolution_log' => 'received_at'`.
|
||||
- **`system_settings`** +key `partition_retention_months_lead_region_resolution_log = '12'` (retention ~365 дней).
|
||||
|
||||
**Изменено:**
|
||||
|
||||
- **`supplier_leads`** +4 колонки: `resolved_subject_code` (CHECK 1..89), `region_source`
|
||||
(CHECK `dadata`/`rossvyaz`/`tag`/`unknown`), `dadata_qc`, `phone_operator`. Persistent-idempotency
|
||||
резолва (retry не повторяет DaData-вызов).
|
||||
- **`deals`** +2 колонки: `phone_operator`, `region_substituted` BOOLEAN NOT NULL DEFAULT FALSE
|
||||
(флаг подмены региона на запасном канале — `routing_step` 3).
|
||||
|
||||
**NB консолидация:** как и v8.39 (`project_routing_snapshots`), полный DDL живёт в дельта-миграции,
|
||||
а не в теле `schema.sql` — тело отражает последнюю точку консолидации, заголовок/CHANGELOG ведут
|
||||
дельты. Свежий деплой: миграция `0001` грузит `schema.sql` → дельта-миграция `2026_05_31` добавляет
|
||||
эти объекты. Иначе был бы двойной `CREATE TABLE` (0001 + дельта) и `migrate` упал бы.
|
||||
|
||||
**NB GRANT'ы:** план Task 1.3 указывал `crm_readonly`, но этой роли на dev/прод нет —
|
||||
фактические GRANT'ы выданы `crm_app_user` + `crm_supplier_worker` (проверено по `pg_roles`).
|
||||
|
||||
**NB 152-ФЗ:** `phone_masked` в логе — маскированный телефон (`7XXX***YYYY`), `dadata_response_masked`
|
||||
хранит ответ DaData без сырого номера (spec §7.1). Полное `pg_anonymizer`-маскирование —
|
||||
шаг раскатки (spec §7.2), вне Session 1.
|
||||
|
||||
---
|
||||
|
||||
## v8.39 (2026-05-27) — project_routing_snapshots (Slepok routing Этап 2)
|
||||
|
||||
|
||||
+2
-1
@@ -1,6 +1,7 @@
|
||||
-- =============================================================================
|
||||
-- schema.sql — единая схема БД для SaaS-аналога crm.bp-gr.ru («Лидерра»)
|
||||
-- Версия: v8.39 (27.05.2026 — project_routing_snapshots: новая партиционированная таблица снимков маршрутизации (PARTITION BY RANGE (snapshot_date)), composite PK (snapshot_date, project_id), FK tenant_id→tenants, RLS tenant isolation, MonthlyPartitionManager +entry, retention 3m. Slepok routing Этап 2)
|
||||
-- Версия: v8.40 (31.05.2026 — lead region resolution Session 1: phone_ranges_imports + phone_ranges (реестр Россвязи, SaaS-level без RLS, idx_phone_ranges_lookup), lead_region_resolution_log (PARTITION BY RANGE (received_at), composite PK (id, received_at), аудит резолва региона на лид), supplier_leads +4 колонки (resolved_subject_code/region_source/dadata_qc/phone_operator), deals +2 колонки (phone_operator/region_substituted). MonthlyPartitionManager +entry, retention 12m. Миграция 2026_05_31_100000, план docs/superpowers/plans/2026-05-29-lead-region-resolution.md. DDL — в дельта-миграции, не в теле (как v8.39))
|
||||
-- Базовая версия: v8.39 (27.05.2026 — project_routing_snapshots: новая партиционированная таблица снимков маршрутизации (PARTITION BY RANGE (snapshot_date)), composite PK (snapshot_date, project_id), FK tenant_id→tenants, RLS tenant isolation, MonthlyPartitionManager +entry, retention 3m. Slepok routing Этап 2)
|
||||
-- Базовая версия: v8.38 (26.05.2026 — projects.paused_at TIMESTAMPTZ + projects_paused_at_idx: anchor для SupplierSnapshotGuard. Защита от убытка при удалении/смене источника проекта, пока поставщик может прислать лиды по уже сделанному слепку — docs/superpowers/plans/2026-05-26-supplier-snapshot-guard.md)
|
||||
-- Базовая версия: 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)
|
||||
|
||||
@@ -31,9 +31,14 @@ paths:
|
||||
keyset (cursor) — O(1) глубины; offset-based — backward-совместимость.
|
||||
При count_only=true возвращает только {"total": N} без строк.
|
||||
parameters:
|
||||
- name: status_in[]
|
||||
- name: status_in
|
||||
in: query
|
||||
description: Фильтр по статусам (можно несколько)
|
||||
description: >
|
||||
Фильтр по статусам (можно несколько). На проводе сериализуется
|
||||
Laravel array-binding: status_in[]=NEW&status_in[]=WON. Имя параметра
|
||||
в спецификации — без скобок: ключи свойств MCP-инструмента обязаны
|
||||
матчить ^[a-zA-Z0-9_.-]{1,64}$ (скобки запрещены, иначе Anthropic
|
||||
tools-схема падает с 400).
|
||||
required: false
|
||||
schema:
|
||||
type: array
|
||||
|
||||
+34
-53
@@ -1,6 +1,6 @@
|
||||
# Brain Status (auto-generated)
|
||||
|
||||
Last updated: 2026-05-30T03:11:28.244Z
|
||||
Last updated: 2026-06-02T10:14:43.123Z
|
||||
|
||||
| Контролёр | Состояние | Детали |
|
||||
|---|---|---|
|
||||
@@ -8,15 +8,15 @@ Last updated: 2026-05-30T03:11:28.244Z
|
||||
| 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 | ⚠️ | 639 episode(s) this month · Stop-hook + post-commit OK · 20 missed activation(s) — see /brain-retro |
|
||||
| C5 Observer-coverage | ✅ | 137 episode(s) this month · Stop-hook + post-commit OK |
|
||||
| C6 Chain map sync | ✅ | [chain-map-checker] OK — 16 chains in sync |
|
||||
|
||||
## Метрики (информационные, не алерты)
|
||||
|
||||
- Observer evidence: 639 episodes this month, 0 observer_error markers, 129 PII matches before filter
|
||||
- Legacy v1 episodes (not in factor analysis): 500
|
||||
- Last /brain-retro: 3 day(s) ago
|
||||
- Использование узлов: см. `/brain-retro` (раз в спринт). missed_activations: 20. **Неиспользованные узлы — не алерт, если профильной задачи не было** (Pravila §16.4 v1.36; capability-readiness; см. memory `feedback_brain_unused_tools_not_problem` — outside-repo memory store).
|
||||
- Observer evidence: 137 episodes this month, 0 observer_error markers, 6 PII matches before filter
|
||||
- Legacy v1 episodes (not in factor analysis): 137
|
||||
- Last /brain-retro: 2 day(s) ago
|
||||
- Использование узлов: см. `/brain-retro` (раз в спринт). missed_activations: 0. **Неиспользованные узлы — не алерт, если профильной задачи не было** (Pravila §16.4 v1.36; capability-readiness; см. memory `feedback_brain_unused_tools_not_problem` — outside-repo memory store).
|
||||
|
||||
## Метрики дисциплины
|
||||
|
||||
@@ -24,16 +24,14 @@ Baseline дисциплины роутера (этап 2 router discipline overh
|
||||
|
||||
| Тип задачи | Эпизодов | % с триггер-матчем | % через скил |
|
||||
|---|---|---|---|
|
||||
| analysis | 26 | 30.8% | 15.4% |
|
||||
| bugfix | 19 | 26.3% | 26.3% |
|
||||
| planning | 16 | 18.8% | 18.8% |
|
||||
| feature | 15 | 13.3% | 0.0% |
|
||||
| cleanup | 6 | 0.0% | 0.0% |
|
||||
| refactor | 1 | 0.0% | 0.0% |
|
||||
| planning | 16 | 0.0% | 0.0% |
|
||||
| feature | 4 | 0.0% | 0.0% |
|
||||
| analysis | 2 | 0.0% | 0.0% |
|
||||
| bugfix | 1 | 0.0% | 0.0% |
|
||||
|
||||
Router step distribution: 1: 281, 2: 227, 3: 63, 5: 61
|
||||
Router step distribution: 1: 81, 2: 51, 5: 4
|
||||
|
||||
Boundaries applied (ADR / границы): 72 of 632 эпизодов (11.4%).
|
||||
Boundaries applied (ADR / границы): 1 of 136 эпизодов (0.7%).
|
||||
|
||||
## Активные многоэтапные проекты
|
||||
|
||||
@@ -45,16 +43,22 @@ Boundaries applied (ADR / границы): 72 of 632 эпизодов (11.4%).
|
||||
|
||||
## Длинные сессии
|
||||
|
||||
Ни одной сессии с >50 ходов сегодня (UTC). ✅
|
||||
⚠️ Сегодня (2026-06-02 UTC) есть сессии с ≥50 ходов — корреляция с падением дисциплины роутинга (retro #5 candidate B).
|
||||
|
||||
| session_id | макс. ход | % regulated | последний эпизод |
|
||||
|---|---|---|---|
|
||||
| `1a9888f8` | 50 | 0% | 2026-06-02T01:43:02.824Z |
|
||||
|
||||
Long sessions correlate with discipline drift. Если % regulated просел в текущей сессии — рассмотри перезапуск.
|
||||
|
||||
## Стоимость месяца
|
||||
|
||||
| Компонент | Токены (in/out) | USD |
|
||||
|---|---|---|
|
||||
| Classifier (Sonnet 4.6) | 3237/42293 | $0.64 |
|
||||
| Classifier (Sonnet 4.6) | 10473/50827 | $0.79 |
|
||||
| Self-assessment (Sonnet 4.6) | 0/0 | $0.00 |
|
||||
| Reviewer (Opus 4.7 + fallback) | 0/0 | $0.00 |
|
||||
| **Итого** | | **$0.64** |
|
||||
| **Итого** | | **$0.79** |
|
||||
|
||||
## Аномалии классификатора
|
||||
|
||||
@@ -67,50 +71,20 @@ Episodes since last run: 542 / threshold: 10
|
||||
|
||||
## Reviewer: субагент vs fallback
|
||||
|
||||
0 эпизодов проверено из 639.
|
||||
0 эпизодов проверено из 137.
|
||||
|
||||
## Reviewer findings
|
||||
|
||||
Проверено: 339 эпизодов. **51 actionable** (wrong_skill + wrong_chain_order).
|
||||
|
||||
### error_root_cause
|
||||
|
||||
| cause | count |
|
||||
|---|---:|
|
||||
| n/a | 261 |
|
||||
| wrong_skill | 41 |
|
||||
| external_failure | 23 |
|
||||
| wrong_chain_order | 10 |
|
||||
| wrong_tool | 4 |
|
||||
|
||||
### Топ alternative_better
|
||||
|
||||
| recommended | count |
|
||||
|---|---:|
|
||||
| #19 | 16 |
|
||||
| #25 | 15 |
|
||||
| #34 | 8 |
|
||||
| #18 | 6 |
|
||||
| #33 | 3 |
|
||||
|
||||
### node_quality
|
||||
|
||||
| judgment | count |
|
||||
|---|---:|
|
||||
| disputable | 191 |
|
||||
| correct | 113 |
|
||||
| wrong_node | 31 |
|
||||
| underkill | 2 |
|
||||
| overkill | 2 |
|
||||
(нет проверенных эпизодов в текущем периоде)
|
||||
|
||||
## Использование override-фраз
|
||||
|
||||
⚠️ Превышен порог override-использования сегодня (≥5/день)
|
||||
|
||||
|
||||
| Фраза | За всё время | За сегодня |
|
||||
|---|---|---|
|
||||
| `recovery` | 2302 | 23 ⚠️ |
|
||||
| `без скилов` | 507 | 40 ⚠️ |
|
||||
| `recovery` | 2302 | 0 |
|
||||
| `без скилов` | 507 | 0 |
|
||||
| `ремонт инфраструктуры` | 331 | 0 |
|
||||
| `срочно` | 225 | 0 |
|
||||
| `memory dump` | 46 | 0 |
|
||||
@@ -119,7 +93,14 @@ Episodes since last run: 542 / threshold: 10
|
||||
|
||||
## System Health
|
||||
|
||||
Долго работающих процессов нет (порог CPU > 1ч).
|
||||
Топ-3 процессов с CPU > 1ч:
|
||||
|
||||
| PID | Имя | CPU-время | Возраст |
|
||||
|---|---|---|---|
|
||||
| 10388 | Code | 3.05ч | 1327306.2ч |
|
||||
| 3220 | MsMpEng | 1.14ч | 0.0ч |
|
||||
|
||||
⚠️ Проверь, не «осиротевшие» ли это процессы от завершённых Claude-сессий.
|
||||
|
||||
## Алерт-индикаторы
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
# Router-gate v4 — оставшиеся дыры (чек-лист «на потом»)
|
||||
|
||||
**Дата:** 2026-05-30
|
||||
**Контекст:** после закрытия нестыковки №1 (убраны 2 лишние записи судьи из `.claude/settings.json`).
|
||||
**Статус системы:** Layers 1–3 работают; Layer 4 (судья) построен как движок + добавлен config-выключатель (DEFAULT OFF); нигде не прописан и без ключа → реально выключен. Владелец 30.05 выбрал курс «включать», но активация (ключ + флаг + хуки) — отдельный его шаг.
|
||||
|
||||
> Делать в **чистой сессии**: без параллельных Claude-сессий и НЕ в изолированной копии (worktree).
|
||||
> Многое упирается в файл `.claude/settings.json` — Claude'у его Read/Edit заблокированы собственной защитой, нужна ручная правка владельцем.
|
||||
|
||||
---
|
||||
|
||||
## Приоритет 1 — обёртка написана (TDD), подключение отложено
|
||||
|
||||
### [x] 1a. Обёртка `enforce-safe-baseline-metering.mjs` — СДЕЛАНО (30.05, worktree h-close)
|
||||
|
||||
- **Что сделано:** обёртка с чистой функцией `decide()` (инкремент per-task счётчика + оценка порогов через `incrementCounter`/`evaluateThresholds`) + функция границ задачи `processEvent()` (см. 1b) + 14 тестов. TDD: тест первым, RED подтверждён в том же ходе, GREEN 14/14.
|
||||
- **Шаблон:** как соседние обёртки Stream H (`enforce-decomposition-detector.mjs`) — `main()` намеренно no-op (exit 0), без живого подключения и без self-lockout.
|
||||
- **NB по среде:** TDD-сторож сверяет правки по основной папке и не видит правки в worktree → ложно блокирует; фразы-исключения в v4 отключены (universal vocab removal, `findOverride`→null), текст «Override: …» в сообщении хука устарел. Цикл RED→GREEN нужно делать в ОДНОМ ходе (правка теста + красный прогон + запись реализации), тогда сторож засчитывает.
|
||||
|
||||
### [x] 1b. Живое подключение `safe-baseline` — СДЕЛАНО (31.05, commits `f740f612` + `80e514f5` + `84dcf4aa`, pushed)
|
||||
|
||||
- **Спроектировано** через brainstorming (3 adversarial-ревью + ghost-pass): спек `docs/superpowers/specs/2026-05-30-safe-baseline-live-wiring-design.md` v4. Закрыты C1 (escape Skill/EnterPlanMode никогда не блокируется) / C2 (skill-match только по реальному tool_use, без self-writable text-path) / C3 (write-deny на runtime, decoupled) / H1 (детерминированная токенизация) / V2-1 (stickiness-контракт, без потери/утечки между задачами) / V2-2 (`.`-segment-proof через `pathNormalize`). G3 override-подсистема вырезана как ghost-protection (escape всегда доступен).
|
||||
- **Реализовано (TDD):** `extractKeywords` + `detectSkillMatch` + `runLiveDecision` + живой `runMain`/`main` в `tools/enforce-safe-baseline-metering.mjs` (+14 тестов); новый `tools/enforce-runtime-write-deny.mjs` (+7 тестов). Регрессия **1880 GREEN**.
|
||||
- **Режим:** hard-block (решение владельца «убери g3, больше ничего»). observe-флаг не добавлялся.
|
||||
- **Осталось (владелец):** регистрация обоих хуков в `.claude/settings.json` (точный блок — в handoff-заметке `2026-05-30-safe-baseline-overnight-handoff.md`); Claude'у settings.json заблокирован. До регистрации хуки инертны.
|
||||
|
||||
---
|
||||
|
||||
## Приоритет 2 — Layer 4 (судья): выключатель готов, активация за владельцем
|
||||
|
||||
### [~] 2. «Мозг» судьи (Layer 4 plumbing) — config-выключатель СДЕЛАН (30.05)
|
||||
|
||||
- **Находка:** движок `tools/llm-judge.mjs` УЖЕ полный (consensus + anti-injection + cache/budget); `llmJudgeCall` при отсутствии ключа возвращает `null`/degraded → fail-safe.
|
||||
- **2a config-выключатель — СДЕЛАНО:** `tools/llm-judge-config.mjs` `resolveJudgeConfig()` — DEFAULT OFF, `enabled=true` только если И флаг `ROUTER_LLM_JUDGE_ENABLED` truthy, И ключ резолвится (keychain→env); keychain-ошибки degrade в «нет ключа, выключен», не бросают. +10 тестов GREEN; связка judge+safe-baseline 93/93 без регрессий. Файл написан, судья ОСТАЁТСЯ ВЫКЛЮЧЕННЫМ (нет флага, нет ключа, хуки не прописаны).
|
||||
- **2b активация (НЕ сделано, требует владельца, деньги отсюда):** (1) ключ в keychain (служба `router-gate-llm-judge`/`default`) ИЛИ `ROUTER_LLM_KEY`; (2) `ROUTER_LLM_JUDGE_ENABLED=1`; (3) хуки `enforce-llm-judge-*` в settings.json. До всех трёх — $0.
|
||||
|
||||
### [x] 3. Хук-обёртки судьи — СДЕЛАНО (31.05, commit `ca52d354`, pushed)
|
||||
|
||||
- **Что:** `tools/enforce-llm-judge-per-tool.mjs` + `tools/enforce-llm-judge-response-scan.mjs` написаны по TDD как соседние обёртки — чистая `decide()` (уважает config-gate, disabled→allow $0) + namespaced **no-op `main()`** (БЕЗ регистрации в settings.json). 14 тестов GREEN, полный прогон без регрессий.
|
||||
- **Зачем:** недостающее звено между движком судьи и settings.json — готово к шагу 2b.3.
|
||||
- **Осталось (владелец, 2b):** ключ + флаг `ROUTER_LLM_JUDGE_ENABLED=1` + регистрация хуков в settings.json. До всех трёх — $0.
|
||||
|
||||
---
|
||||
|
||||
## Приоритет 3 — порядок и документация
|
||||
|
||||
### [~] 4. Синхронизация «мозга» (нормативка) — КОНТЕНТ ГОТОВ, ПРИМЕНЕНИЕ ЗАБЛОКИРОВАНО (31.05)
|
||||
|
||||
- **Готово:** ready-to-paste §6-абзац + §9-entry + header version-bump для 1b — `docs/observer/notes/2026-05-31-claude-md-1b-insertion-draft.md`. §0 cross-ref счётчики НЕ меняются (инфраструктура `tools/`, не tooling-канон #1-#86 / не ADR / не off-phase).
|
||||
- **⚠️ НОВЫЙ БЛОКЕР (31.05):** `enforce-read-path-deny` (Smoke 5, 30.05) добавил `CLAUDE.md` в Read-protected paths → harness Edit требует предварительного Read → **Edit CLAUDE.md для Claude невозможен**, а Write-overwrite канонического файла слишком рискован. Это **over-block** legit `claude-md-management` workflow (Smoke 5 целил в transcript/runtime exfil; Read-deny на публичный-в-репо CLAUDE.md security-ценности не несёт). Владелец: либо сузить `DEFAULT_PROTECTED_PATTERNS` (убрать `CLAUDE.md` из Read-deny, оставить Bash/PowerShell/Write-защиты), либо вставить вручную из draft. Учение уже зафиксировано в этой заметке + handoff, ничего не теряется.
|
||||
|
||||
### [ ] 5. Выйти из изолированной копии (worktree) — ПОДГОТОВЛЕНО К РЕАЛИЗАЦИИ (31.05)
|
||||
|
||||
- **Верификация выполнена (31.05):** worktree `.claude/worktrees/router-gate-v4-stream-h-close` проверен — все 4 рабочих файла (`enforce-safe-baseline-metering.mjs`+`.test.mjs`, `llm-judge-config.mjs`+`.test.mjs`) **байт-в-байт идентичны main** (4× пустой `git diff --no-index`); `git log main..worktree-router-gate-v4-stream-h-close` **пуст** (нет уникальных коммитов). Несохранённой нужной работы НЕТ — терять нечего.
|
||||
- **Готовая команда (выполняет ВЛАДЕЛЕЦ — `git worktree` для Claude в default-deny гейта, approval-пути к нему нет; через PowerShell — запрещённый обход):**
|
||||
|
||||
```bash
|
||||
git worktree remove --force ".claude/worktrees/router-gate-v4-stream-h-close"
|
||||
git branch -D worktree-router-gate-v4-stream-h-close # опционально — ветка-база, уникальных коммитов нет
|
||||
```
|
||||
|
||||
`--force` нужен: рабочая папка worktree содержит те же 4 файла, что уже в main (relative своей старой ветки они «незакоммичены»), плюс авто-регенерируемый STATUS.md-дрейф.
|
||||
- **Статус решения:** 30.05 владелец выбрал «оставить worktree». Шаги выше — на случай, когда решит удалить; ничего не блокируют (worktree безвреден, только занимает диск).
|
||||
|
||||
---
|
||||
|
||||
## Приоритет 4 — крупное, требует железа и ручных шагов владельца
|
||||
|
||||
### [ ] 6. Layer 5 (v4.2) — виртуалка / биометрия / YubiKey
|
||||
|
||||
- **Что:** Phase 1 VirtualBox ($0), Phase 2+3 — YubiKey ($50–150 разово, один ключ покрывает биометрию + HSM).
|
||||
- **Загвоздка:** Claude может написать только конфиги/инструкции; установка и железо — на владельце.
|
||||
- **Делать:** отдельным заходом, когда дойдут руки и появится YubiKey.
|
||||
|
||||
---
|
||||
|
||||
## Перенос в git — СДЕЛАНО (31.05)
|
||||
|
||||
Всё зафиксировано и запушено в `origin/main` (`c8059880..84dcf4aa`, fast-forward, gitleaks-full-history GREEN / lychee 0 errors). Коммиты сессии:
|
||||
|
||||
- `ca52d354` — judge-обёртки (item 3).
|
||||
- `6d512f5c`/`9f84d9ef`/`c86fdfc9`/`84dcf4aa` — спек safe-baseline v1→v4 + план + handoff (item 1b doc).
|
||||
- `f740f612` — живой safe-baseline `main()` (item 1b code).
|
||||
- `80e514f5` — `enforce-runtime-write-deny` (C3).
|
||||
|
||||
Items 1a/2a (`enforce-safe-baseline-metering` обёртка + `llm-judge-config`) были перенесены из worktree ранее (commits `6ac4b1c1`+`c8059880`).
|
||||
|
||||
## Что НЕ требует действий (уже сделано параллельными сессиями)
|
||||
|
||||
- recovery-procedures.md — есть.
|
||||
- brain-retro таблицы 16–17 — есть (в анализаторе).
|
||||
- Исправления `extractPathArgs` / `pathDenyOverlay` — есть.
|
||||
- Защита от чтения транскриптов (Smoke 5) — работает.
|
||||
- Smoke-тесты 1–9 — прогнаны.
|
||||
@@ -0,0 +1,75 @@
|
||||
# Safe-baseline live wiring (1b) — overnight handoff
|
||||
|
||||
**Date:** 2026-05-30 (night)
|
||||
**Status:** Implemented + tested on disk. **NOT committed** (git commits need your AskUserQuestion approval at the gate; you were asleep). Morning = review → approve commits → register in settings.json.
|
||||
|
||||
---
|
||||
|
||||
## What was done autonomously
|
||||
|
||||
1. **Spec → v4** (`docs/superpowers/specs/2026-05-30-safe-baseline-live-wiring-design.md`): removed the G3 override subsystem ("убери g3, больше ничего"); escape is now solely Skill/EnterPlanMode (always available). Runtime write-deny kept but **decoupled** into a standalone git-approval-anchor hardening. *(spec edits are on disk, uncommitted — the last committed spec is v3 `c86fdfc9`.)*
|
||||
2. **Plan** (`docs/superpowers/plans/2026-05-30-safe-baseline-live-wiring.md`): 6 TDD tasks.
|
||||
3. **Implementation (TDD, RED→GREEN):**
|
||||
- `tools/enforce-safe-baseline-metering.mjs` — added `extractKeywords` (H1), `detectSkillMatch` (C2/V2-5), `runLiveDecision` (V2-1 stickiness contract), live `runMain`/`main` (replaces the no-op).
|
||||
- `tools/enforce-runtime-write-deny.mjs` (new) — standalone write-deny on `~/.claude/runtime/**`, resolving `pathNormalize` (V2-2 `.`-segment-proof).
|
||||
- Tests: `enforce-safe-baseline-metering.test.mjs` (+14), `enforce-runtime-write-deny.test.mjs` (+7).
|
||||
4. **Regression:** `npm run test:tools` → **1880 passed | 2 skipped** (was 1859). Narrow runs all GREEN.
|
||||
|
||||
## Decisions I made on my own (correct in the morning if wrong)
|
||||
|
||||
- **G3 override removed** — per your explicit instruction.
|
||||
- **Hard-block kept (not observe-mode).** My honest recommendation was observe-first behind a mode flag, but you said "убери g3, больше ничего" → I did NOT add an observe mode. If you want observe-first, say so and I'll add a `mode` flag (default observe) cheaply.
|
||||
- **`enforce-runtime-write-deny` fails-OPEN on a normalizer exception** (blocks only on a *confirmed* runtime match). Rationale: a fail-CLOSE Write hook that errors would self-lock the controller out of ALL edits during an unattended run. Residual: a malformed path that throws is not blocked. Flip to fail-CLOSE if you prefer strict security.
|
||||
|
||||
## Queued commits (morning — approve each exact git command at the gate)
|
||||
|
||||
```bash
|
||||
git add docs/superpowers/specs/2026-05-30-safe-baseline-live-wiring-design.md
|
||||
git commit docs/superpowers/specs/2026-05-30-safe-baseline-live-wiring-design.md -m "docs(router-gate-v4): safe-baseline spec v4 — cut G3 override, decouple write-deny (item 1b)"
|
||||
|
||||
git add docs/superpowers/plans/2026-05-30-safe-baseline-live-wiring.md
|
||||
git commit docs/superpowers/plans/2026-05-30-safe-baseline-live-wiring.md -m "docs(router-gate-v4): safe-baseline live-wiring implementation plan (item 1b)"
|
||||
|
||||
git add tools/enforce-safe-baseline-metering.mjs tools/enforce-safe-baseline-metering.test.mjs
|
||||
git commit tools/enforce-safe-baseline-metering.mjs tools/enforce-safe-baseline-metering.test.mjs -m "feat(safe-baseline): live main() — metering + hard-block + Skill/EnterPlanMode escape (item 1b)"
|
||||
|
||||
git add tools/enforce-runtime-write-deny.mjs tools/enforce-runtime-write-deny.test.mjs
|
||||
git commit tools/enforce-runtime-write-deny.mjs tools/enforce-runtime-write-deny.test.mjs -m "feat(router-gate-v4): enforce-runtime-write-deny — protect ~/.claude/runtime side-channels (C3)"
|
||||
|
||||
git add docs/observer/notes/2026-05-30-safe-baseline-overnight-handoff.md
|
||||
git commit docs/observer/notes/2026-05-30-safe-baseline-overnight-handoff.md -m "docs(observer): safe-baseline overnight handoff note"
|
||||
```
|
||||
|
||||
(A fresh `npm run test:tools` GREEN gives the verify-before-push sentinel for the code commits; docs-only commits short-circuit.)
|
||||
|
||||
## Registration (you apply — Claude cannot edit settings.json)
|
||||
|
||||
Add to `.claude/settings.json` `hooks.PreToolUse`:
|
||||
|
||||
```json
|
||||
{ "matcher": "Read|Grep|Glob|LS|TodoWrite|AskUserQuestion|Edit|Write|MultiEdit|NotebookEdit|Bash|Skill|Task|EnterPlanMode",
|
||||
"hooks": [{ "type": "command", "command": "node tools/enforce-safe-baseline-metering.mjs", "timeout": 10 }] }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "matcher": "Edit|Write|MultiEdit|NotebookEdit",
|
||||
"hooks": [{ "type": "command", "command": "node tools/enforce-runtime-write-deny.mjs", "timeout": 5 }] }
|
||||
```
|
||||
|
||||
Until registered, both hooks are inert.
|
||||
|
||||
**Before registering — owner check:** does `.claude/settings.json` already have a `permissions.deny` covering Write to `~/.claude/**`? If yes, `enforce-runtime-write-deny` is redundant (still harmless). I couldn't read settings.json (gate-blocked).
|
||||
|
||||
## Open questions for the morning
|
||||
|
||||
1. **"раздел 5 основного плана подготовь к реализации"** — which document and which section 5? Candidates: the remaining-holes checklist (`docs/observer/notes/2026-05-30-router-gate-v4-remaining-holes.md` — its item 5 = close the worktree, already decided "keep") OR the master coordination plan OR the v4 design §5. I did NOT guess to avoid wasted/wrong work. Tell me which and I'll prepare it.
|
||||
2. **Normative sync ("корректируй всю документацию"):** CLAUDE.md / Pravila / PSR / Tooling — these are gate-protected AND were being edited by a parallel session (§15.2). The safe-baseline live-wiring is infrastructure (`tools/enforce-*.mjs`), not a new tooling-canon node / ADR / off-phase subcategory, so the §0 cross-ref counters likely do NOT change; CLAUDE.md §6 would get one paragraph + §9 one entry. To do via `claude-md-management` once the parallel session is done. Flagged, not done.
|
||||
3. **observe vs enforce** (see Decisions).
|
||||
4. **Judge activation (2b)** still owner-gated ($) — untouched.
|
||||
|
||||
## Not done (blocked, not skipped)
|
||||
|
||||
- Live registration / "run the agent" — needs settings.json (owner-only).
|
||||
- Mandatory pre-registration smoke (owner-run after registering): the integration tests already exercise block/allow/escape; the registration smoke is a final live check.
|
||||
- CLAUDE.md normative sync (blocked, see Q2).
|
||||
- The commits themselves (gate needs your approval awake).
|
||||
@@ -0,0 +1,26 @@
|
||||
# CLAUDE.md insertion draft — safe-baseline 1b (ready to paste)
|
||||
|
||||
**Why a draft, not a direct edit:** `enforce-read-path-deny` (Smoke 5, 2026-05-30) added `CLAUDE.md` to the Read-protected paths (`DEFAULT_PROTECTED_PATTERNS` `/(^|\/)CLAUDE\.md$/i`). The harness Edit tool requires a prior Read of the target; with Read gate-blocked, **Edit of CLAUDE.md is impossible** for Claude, and a full Write-overwrite of the canonical file is too risky. This is an over-block of the legit `claude-md-management` workflow (the Smoke 5 fix targeted transcript/runtime exfil; normative-doc Read-deny is collateral).
|
||||
|
||||
**Owner options:**
|
||||
|
||||
1. Temporarily narrow `DEFAULT_PROTECTED_PATTERNS` so `enforce-read-path-deny` does NOT block `CLAUDE.md` Read (keep the Bash/PowerShell + Write protections); then a normal `claude-md-management` session applies the inserts. **Recommended** — the Read-deny on CLAUDE.md has no security value (CLAUDE.md is public-in-repo; the real exfil targets are `~/.claude/projects` transcripts + `~/.claude/runtime`).
|
||||
2. Paste the blocks below manually.
|
||||
|
||||
The substantive learning is already committed in `docs/observer/notes/2026-05-30-router-gate-v4-remaining-holes.md` + the handoff note, so nothing is lost meanwhile.
|
||||
|
||||
---
|
||||
|
||||
## Header version line — bump
|
||||
|
||||
Change the opening of `**Версия:** 2.42 …` to v2.43, prepending:
|
||||
|
||||
> **Версия:** 2.43 от 31.05.2026 — **router-gate v4 safe-baseline live wiring (item 1b) + enforce-runtime-write-deny (C3) + LLM-judge hook-обёртки реализованы, протестированы (1880 GREEN), запушены** (commits `ca52d354`+`6d512f5c..84dcf4aa`+`f740f612`+`80e514f5` на main). Spec v4 закрыл C1/C2/C3/H1/V2-1/V2-2 через 3 adversarial-ревью + ghost-pass; G3 override вырезан как защита-призрак. §0 cross-refs НЕ меняются (инфраструктура `tools/`, не tooling-канон #1-#86 / не ADR / не off-phase). **v2.42 наследие:** …(оставить прежний текст)…
|
||||
|
||||
## §6 — prepend this paragraph (above the 2026-05-29 entry)
|
||||
|
||||
**2026-05-31 router-gate v4 — safe-baseline live wiring (item 1b) + enforce-runtime-write-deny (C3) + LLM-judge hook-обёртки реализованы и запушены:** `tools/enforce-safe-baseline-metering.mjs` получил живой `main()` (метеринг safe-baseline tools per-task + hard-block mutating-инструмента за hard-порогом без skill-match; escape = вызов любого Skill/EnterPlanMode, который этим слоем никогда не блокируется); новые чистые функции `extractKeywords` (детерминированная токенизация со стоп-словами против ложного overlap), `detectSkillMatch` (только реальный assistant tool_use Skill/EnterPlanMode — не self-writable text-path), `runLiveDecision` (контракт stickiness: skill-match привязан к задаче и явно сохраняется, без потери и без утечки между задачами). Новый standalone-хук `tools/enforce-runtime-write-deny.mjs` закрывает уже-существующую дыру: Write/Edit-инструмент мог писать в `~/.claude/runtime/**` напрямую (git-approval anchor был открыт для Write-инструмента — Bash/PowerShell-гейты его прикрывали, Write-канал нет); нормализация через resolving `pathNormalize` (`path.resolve`+`realpath`) делает обход через `.`/`..`-сегменты невозможным. Спроектировано через `superpowers:brainstorming` (3 раунда adversarial-саморевью + ghost-pass), spec v4 `docs/superpowers/specs/2026-05-30-safe-baseline-live-wiring-design.md` закрыл C1/C2/C3/H1/V2-1/V2-2; G3 override-подсистема вырезана как защита-призрак. Реализация через `superpowers:writing-plans` → TDD. Также `tools/enforce-llm-judge-per-tool.mjs` + `tools/enforce-llm-judge-response-scan.mjs` (Layer 4 hook-обёртки, no-op `main()`, $0 до активации 2b). Регрессия vitest tools-only **1880 GREEN**. Коммиты `ca52d354`+`6d512f5c..84dcf4aa`+`f740f612`+`80e514f5` (push `c8059880..84dcf4aa main`, gitleaks-full-history GREEN / lychee 0 errors). Режим **hard-block** (решение владельца). Регистрация обоих хуков в `.claude/settings.json` — шаг владельца (Claude'у settings.json заблокирован); до регистрации хуки инертны. **§0 cross-refs НЕ меняются** — инфраструктура `tools/enforce-*.mjs`, не tooling-канон #1-#86 / не ADR / не off-phase. Через `claude-md-management:revise-claude-md`.
|
||||
|
||||
## §9 — prepend this entry (above the v2.42 entry)
|
||||
|
||||
- **v2.43 от 31.05.2026 — safe-baseline live wiring (item 1b) + enforce-runtime-write-deny (C3) + LLM-judge hook-обёртки** — `tools/enforce-safe-baseline-metering.mjs` живой `main()` (метеринг + hard-block + Skill/EnterPlanMode escape) с чистыми `extractKeywords`/`detectSkillMatch`/`runLiveDecision` (stickiness-контракт V2-1); новый `tools/enforce-runtime-write-deny.mjs` (C3 — защита `~/.claude/runtime` от Write-инструмента, `.`-segment-proof через `pathNormalize`); judge-обёртки `enforce-llm-judge-{per-tool,response-scan}.mjs` (no-op main, $0). Спек v4 через brainstorming (3 adversarial-ревью + ghost-pass) закрыл C1/C2/C3/H1/V2-1/V2-2; G3 override вырезан как защита-призрак. TDD, регрессия 1880 GREEN. Commits `ca52d354`+`6d512f5c..84dcf4aa`+`f740f612`+`80e514f5`, push `c8059880..84dcf4aa`. **§0 cross-refs не меняются** (инфраструктура `tools/`, не tooling-канон / не ADR / не off-phase). §6 +абзац / §9 +этот entry. Через `claude-md-management:revise-claude-md`.
|
||||
@@ -0,0 +1,641 @@
|
||||
# Lead Region Resolution — Master Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
>
|
||||
> **This is a MASTER plan split into 6 sessions.** Each session is a self-contained, testable deliverable. Execute sessions **in order** (later sessions depend on earlier ones). Each session = one subagent-driven-development run with its own review checkpoints. Before starting a session, re-read this header + the session's "Preconditions".
|
||||
|
||||
**Goal:** Резолвить настоящий регион лида по телефону (DaData → Россвязь → tag-fallback) и переключить `LeadRouter` на каскадную маршрутизацию по региону, чтобы клиенты, делящие один источник с разными regions, получали только лиды своего региона.
|
||||
|
||||
**Architecture:** Новый сервис `LeadRegionResolver` вызывается в `RouteSupplierLeadJob::handle()` ДО транзакционного цикла, резолвит `subject_code` + оператора по телефону, персистит в `supplier_leads` + `lead_region_resolution_log`. `LeadRouter::matchEligibleProjects` получает новый параметр `?int $resolvedSubjectCode` и фильтрует кандидатов в 3 фазы (точное совпадение региона → «вся РФ» → запасной канал с подменой). Локальный реестр Россвязи (`phone_ranges`) — fallback когда DaData недоступна/неуверена.
|
||||
|
||||
**Tech Stack:** PHP 8.3, Laravel 13, PostgreSQL 16 (партиции, RLS, `INT[]`), Pest 4, Redis (кэш + token-bucket), DaData REST API (`cleaner.dadata.ru/api/v1/clean/phone`).
|
||||
|
||||
**Source spec:** [docs/superpowers/specs/2026-05-29-lead-region-resolution-design.md](../specs/2026-05-29-lead-region-resolution-design.md) v0.5. Прочитать целиком перед стартом — этот план не дублирует §3-§12 спеки, а превращает их в исполнимые шаги.
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ КРИТИЧЕСКИЕ ПОПРАВКИ К СПЕКЕ (читать ДО любого кода)
|
||||
|
||||
Эти расхождения спеки с фактическим кодом обнаружены прямым code-walking 30.05.2026. Implementer ОБЯЗАН следовать факту, а не цифрам/именам из спеки.
|
||||
|
||||
1. **Коды субъектов — НЕ автомобильные.** Спека §3.4.1 пишет «77 Москва, 50 МО, 78 СПб, 47 ЛО» — это НЕВЕРНО. Источник истины — [`app/app/Support/RussianRegions.php`](../../../app/app/Support/RussianRegions.php) `CODE_TO_NAME` (конституционный порядок ст. 65, 1..89):
|
||||
- **Москва = 82**, **Санкт-Петербург = 83**, **Московская область = 56**, **Ленинградская область = 53**.
|
||||
- Севастополь = 84, Республика Крым = 13.
|
||||
- Везде в коде/тестах/маппингах использовать ЭТИ коды.
|
||||
|
||||
2. **`RussianRegions` НЕ имеет `codeToName()`-метода.** Есть только `public const CODE_TO_NAME` (массив) и `public static function nameToCode(): array` (через `array_flip`). Если нужен code→name — читать константу `RussianRegions::CODE_TO_NAME[$code]`.
|
||||
|
||||
3. **`LeadRouter::matchEligibleProjects` имеет ДВА SQL-пути** — `DIRECT` (по `signal_type` + `unique_key`) и `B1/B2/B3` (через `project_supplier_links` pivot). Каскад (§3.9) спека показывает только для pivot-пути — **реализовать каскад для ОБОИХ путей**.
|
||||
|
||||
4. **`project_routing_snapshots` УЖЕ содержит `regions INT[] NOT NULL DEFAULT '{}'`** (миграция `2026_05_27_120000`). Колонку добавлять НЕ нужно — каскадный WHERE ложится на готовую колонку через `?::int = ANY(snap.regions)` и `snap.regions = '{}'::int[]`.
|
||||
|
||||
5. **`LeadDistributor::selectRecipients` сейчас берёт cap=3 СЛУЧАЙНО.** Каскад спеки требует упорядоченный отбор (точное → РФ → запасной, сортировка по остатку лимита DESC) внутри роутера. Реконсиляция: роутер сам обрезает до 3 упорядоченно → `LeadDistributor` при `count ≤ CAP` возвращает коллекцию как есть (без шаффла, строка 36-38). Это **смена поведения** (random → детерминированный по остатку лимита). Зафиксировано как сознательное решение — см. §«Открытый вопрос D1» ниже. НЕ менять `LeadDistributor`; роутер просто отдаёт ≤3.
|
||||
|
||||
6. **`subject_code` пишется в `deals` уже сейчас** (Job строка 405-406, через `?int $subjectCode` из `RegionTagResolver`). Интеграция — заменить источник, не добавить колонку. `deals.subject_code` уже существует (миграция `2026_05_20_102000`).
|
||||
|
||||
7. **Команда запуска тестов:** из каталога `app/`. Один файл: `cd app && ./vendor/bin/pest tests/Unit/Services/LeadRegionResolverTest.php`. Фильтр по имени: `cd app && ./vendor/bin/pest --filter="dadata qc 0"`. Полный прогон сервиса перед коммитом сессии. **NB Bash cwd persists** — всегда префиксить `cd app &&` или использовать subshell.
|
||||
|
||||
---
|
||||
|
||||
## Открытые вопросы для заказчика (решить ДО Session 5-6)
|
||||
|
||||
- **D1 (поведение распределения):** Сейчас при >3 кандидатах лид раздаётся 3 СЛУЧАЙНЫМ клиентам. Новый каскад раздаёт 3 клиентам с НАИБОЛЬШИМ остатком дневного лимита (детерминированно). Это значит: клиент с большим остатком лимита систематически получает больше лидов, чем клиент с малым. Спека §3.9 явно выбрала «сортировка по остатку DESC». **Подтвердить, что random-распределение можно убрать.** (Если заказчик хочет сохранить случайность внутри региона — это +1 задача: random-shuffle внутри каждой фазы перед cap.)
|
||||
- **D2 (ambiguous-list staging):** Список «объединённых» регионов DaData (`'Санкт-Петербург и область'`, `'Москва и область'`) расширяется только по реальным наблюдениям на staging (спека §3.4.1). На старте — ровно эти 2 строки. Подтверждается smoke-прогоном (Session 6).
|
||||
|
||||
---
|
||||
|
||||
## Общие конвенции (применять во ВСЕХ сессиях)
|
||||
|
||||
### Тестовый сетап (Pest 4)
|
||||
|
||||
- **Unit-тесты** (`app/tests/Unit/...`): чистые, без БД где возможно; `Http::fake()` для DaData; `Cache::fake()`/`Cache::store('array')` для кэша.
|
||||
- **Feature-тесты** (`app/tests/Feature/...`): `uses(DatabaseTransactions::class)` + `uses(Tests\Concerns\SharesSupplierPdo::class)`. Tenant-контекст: `DB::statement("SELECT set_config('app.current_tenant_id', '0', true)")` в `beforeEach` (как [`LeadRouterTest.php`](../../../app/tests/Feature/Services/LeadRouterTest.php)).
|
||||
- Фабрики: `Tenant::factory()`, `Project::factory()`, `SupplierProject::factory()`/`::query()->create([...])`, `SupplierLead::factory()`.
|
||||
- Хелперы (в [`app/tests/Pest.php`](../../../app/tests/Pest.php)): `linkProjectToSupplier($project, $supplier)`, `createRoutingSnapshotFromProject($project, ...)` — **последний расширяется в Session 5** (добавить `string $regions = '{}'` параметр).
|
||||
- Pest-стиль: `it('...', function () { ... })`, `expect($x)->toBe(...)`. Никакого PHPUnit class-стиля в новых тестах.
|
||||
|
||||
### Паттерн миграции (raw SQL, образец — `2026_05_27_120000_create_project_routing_snapshots_table.php`)
|
||||
|
||||
```php
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration {
|
||||
public function up(): void
|
||||
{
|
||||
// SET ROLE crm_migrator на проде; на dev/testing — fallback postgres superuser.
|
||||
try {
|
||||
DB::statement('SET ROLE crm_migrator');
|
||||
$canCreate = DB::selectOne("SELECT has_schema_privilege('crm_migrator', 'public', 'CREATE') AS ok");
|
||||
if (!$canCreate || !$canCreate->ok) { DB::statement('RESET ROLE'); }
|
||||
} catch (\Throwable) { /* окружение без роли — продолжаем как superuser */ }
|
||||
|
||||
DB::unprepared(<<<'SQL'
|
||||
-- DDL здесь
|
||||
SQL);
|
||||
}
|
||||
public function down(): void
|
||||
{
|
||||
try {
|
||||
DB::statement('SET ROLE crm_migrator');
|
||||
$canCreate = DB::selectOne("SELECT has_schema_privilege('crm_migrator', 'public', 'CREATE') AS ok");
|
||||
if (!$canCreate || !$canCreate->ok) { DB::statement('RESET ROLE'); }
|
||||
} catch (\Throwable) {}
|
||||
DB::statement('DROP TABLE IF EXISTS <table> CASCADE');
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
- GRANT'ы: SaaS-level read-таблицы → `crm_readonly` + `crm_supplier_worker` SELECT; запись через `crm_migrator`. Tenant-таблицы → RLS policy + GRANT `crm_app_user`/`crm_supplier_worker` (образец snapshot-миграции строки 49-55).
|
||||
- Партиционированные таблицы: явный `CREATE TABLE ..._y2026_m05 PARTITION OF ...` для текущего+следующего месяца + регистрация retention в `system_settings` (образец строки 57-78).
|
||||
- **`db/schema.sql` + `db/CHANGELOG_schema.md`** обновлять при каждой схемной правке (правило §4.2 / §5 п.8 CLAUDE.md). Bump версии schema в header.
|
||||
|
||||
### Git / коммиты
|
||||
|
||||
- Ветка: `feat/lead-region-resolution` (создаётся в Session 1, см. Preconditions).
|
||||
- Частые атомарные коммиты (per task). Conventional commits: `feat(region):`, `test(region):`, `chore(region):`.
|
||||
- Каждая сессия завершается зелёной регрессией затронутого слоя + push.
|
||||
|
||||
---
|
||||
|
||||
## SESSION 1 — Схема БД + регистрация партиций
|
||||
|
||||
**Deliverable:** Все таблицы и колонки фичи существуют, миграция up/down работает, партиции регистрируются. Никакой бизнес-логики.
|
||||
**Preconditions:** Чистый `main` (или согласованная база). Создать ветку: `git switch -c feat/lead-region-resolution`. Закоммитить spec (untracked) первым коммитом.
|
||||
**Files:**
|
||||
|
||||
- Create: `app/database/migrations/2026_05_31_100000_create_phone_ranges_and_resolution_log.php`
|
||||
- Modify: `app/app/Services/MonthlyPartitionManager.php:48-62` (PARTITIONED_TABLES map)
|
||||
- Modify: `db/schema.sql` (новые таблицы + ALTER, bump версии) + `db/CHANGELOG_schema.md`
|
||||
- Test: `app/tests/Feature/Migrations/PhoneRangesMigrationTest.php`
|
||||
|
||||
### Task 1.1 — Failing test: миграция создаёт таблицы и колонки
|
||||
|
||||
- [ ] **Step 1: Написать падающий тест**
|
||||
|
||||
`app/tests/Feature/Migrations/PhoneRangesMigrationTest.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
uses(SharesSupplierPdo::class);
|
||||
|
||||
it('creates phone_ranges with lookup index', function (): void {
|
||||
expect(DB::selectOne("SELECT to_regclass('public.phone_ranges') AS t")->t)->not->toBeNull();
|
||||
$cols = collect(DB::select("SELECT column_name FROM information_schema.columns WHERE table_name='phone_ranges'"))
|
||||
->pluck('column_name')->all();
|
||||
expect($cols)->toContain('def_code', 'from_num', 'to_num', 'operator', 'region', 'subject_code', 'import_id');
|
||||
});
|
||||
|
||||
it('creates lead_region_resolution_log as partitioned table', function (): void {
|
||||
$p = DB::selectOne("SELECT partattrs FROM pg_partitioned_table pt JOIN pg_class c ON c.oid=pt.partrelid WHERE c.relname='lead_region_resolution_log'");
|
||||
expect($p)->not->toBeNull();
|
||||
});
|
||||
|
||||
it('adds resolution columns to supplier_leads and deals', function (): void {
|
||||
$sl = collect(DB::select("SELECT column_name FROM information_schema.columns WHERE table_name='supplier_leads'"))->pluck('column_name')->all();
|
||||
expect($sl)->toContain('resolved_subject_code', 'region_source', 'dadata_qc', 'phone_operator');
|
||||
$d = collect(DB::select("SELECT column_name FROM information_schema.columns WHERE table_name='deals'"))->pluck('column_name')->all();
|
||||
expect($d)->toContain('phone_operator', 'region_substituted');
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Прогнать — убедиться что падает** (`cd app && ./vendor/bin/pest tests/Feature/Migrations/PhoneRangesMigrationTest.php` → FAIL: relation does not exist)
|
||||
|
||||
- [ ] **Step 3: Написать миграцию.** DDL по спеке §4.1-§4.6 с поправками. Полный DDL (вставить в `DB::unprepared`):
|
||||
|
||||
```sql
|
||||
-- 1. phone_ranges_imports (журнал импортов — создаём ПЕРВЫМ, на него FK)
|
||||
CREATE TABLE phone_ranges_imports (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
imported_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
source_url TEXT NOT NULL,
|
||||
rows_inserted INTEGER NOT NULL DEFAULT 0,
|
||||
rows_updated INTEGER NOT NULL DEFAULT 0,
|
||||
checksum_sha256 TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'in_progress'
|
||||
CHECK (status IN ('in_progress','completed','failed','rolled_back')),
|
||||
error TEXT,
|
||||
completed_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
-- 2. phone_ranges (реестр Россвязи, SaaS-level без RLS)
|
||||
CREATE TABLE phone_ranges (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
def_code SMALLINT NOT NULL,
|
||||
from_num BIGINT NOT NULL,
|
||||
to_num BIGINT NOT NULL,
|
||||
operator TEXT NOT NULL,
|
||||
region TEXT NOT NULL,
|
||||
region_normalized TEXT,
|
||||
subject_code SMALLINT,
|
||||
imported_at TIMESTAMPTZ NOT NULL,
|
||||
import_id BIGINT NOT NULL REFERENCES phone_ranges_imports(id),
|
||||
CONSTRAINT chk_phone_ranges_def_code CHECK (def_code BETWEEN 300 AND 999),
|
||||
CONSTRAINT chk_phone_ranges_subject_code CHECK (subject_code IS NULL OR subject_code BETWEEN 1 AND 89),
|
||||
CONSTRAINT chk_phone_ranges_range_valid CHECK (from_num <= to_num)
|
||||
);
|
||||
CREATE INDEX idx_phone_ranges_lookup ON phone_ranges (def_code, from_num, to_num);
|
||||
GRANT SELECT ON phone_ranges, phone_ranges_imports TO crm_readonly, crm_supplier_worker;
|
||||
|
||||
-- 3. lead_region_resolution_log (SaaS-level, партиционирован по received_at)
|
||||
CREATE TABLE lead_region_resolution_log (
|
||||
id BIGSERIAL,
|
||||
supplier_lead_id BIGINT NOT NULL,
|
||||
received_at TIMESTAMPTZ NOT NULL,
|
||||
phone_masked TEXT NOT NULL,
|
||||
subject_code_resolved SMALLINT,
|
||||
subject_code_from_tag SMALLINT,
|
||||
region_source TEXT NOT NULL CHECK (region_source IN ('dadata','rossvyaz','tag','unknown')),
|
||||
dadata_qc SMALLINT,
|
||||
dadata_provider TEXT,
|
||||
dadata_type TEXT,
|
||||
dadata_response_masked JSONB,
|
||||
rossvyaz_matched BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
actual_subject_code SMALLINT CHECK (actual_subject_code IS NULL OR actual_subject_code BETWEEN 1 AND 89),
|
||||
substituted_subject_code SMALLINT CHECK (substituted_subject_code IS NULL OR substituted_subject_code BETWEEN 1 AND 89),
|
||||
routing_step SMALLINT CHECK (routing_step IS NULL OR routing_step BETWEEN 1 AND 3),
|
||||
phone_operator TEXT,
|
||||
cache_hit BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
duration_ms INTEGER,
|
||||
resolved_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (id, received_at)
|
||||
) PARTITION BY RANGE (received_at);
|
||||
CREATE INDEX idx_lrrl_lead_id ON lead_region_resolution_log (supplier_lead_id);
|
||||
CREATE INDEX idx_lrrl_source ON lead_region_resolution_log (region_source, received_at);
|
||||
GRANT SELECT, INSERT ON lead_region_resolution_log TO crm_supplier_worker;
|
||||
GRANT SELECT ON lead_region_resolution_log TO crm_readonly;
|
||||
CREATE TABLE lead_region_resolution_log_y2026_m05 PARTITION OF lead_region_resolution_log
|
||||
FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
|
||||
CREATE TABLE lead_region_resolution_log_y2026_m06 PARTITION OF lead_region_resolution_log
|
||||
FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');
|
||||
|
||||
-- 4. supplier_leads +4 колонки (persistent idempotency + denormalized display)
|
||||
ALTER TABLE supplier_leads
|
||||
ADD COLUMN resolved_subject_code SMALLINT CHECK (resolved_subject_code IS NULL OR resolved_subject_code BETWEEN 1 AND 89),
|
||||
ADD COLUMN region_source TEXT CHECK (region_source IN ('dadata','rossvyaz','tag','unknown')),
|
||||
ADD COLUMN dadata_qc SMALLINT,
|
||||
ADD COLUMN phone_operator TEXT;
|
||||
|
||||
-- 5. deals +2 колонки
|
||||
ALTER TABLE deals
|
||||
ADD COLUMN phone_operator TEXT,
|
||||
ADD COLUMN region_substituted BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
```
|
||||
|
||||
В том же `up()` после `DB::unprepared`: зарегистрировать retention `lead_region_resolution_log` в `system_settings` (паттерн snapshot-миграции строки 67-78, `value => '12'`, 365 дней). `down()`: `DROP TABLE IF EXISTS lead_region_resolution_log, phone_ranges, phone_ranges_imports CASCADE` + `ALTER TABLE ... DROP COLUMN IF EXISTS ...` для supplier_leads/deals + удалить system_settings ключ.
|
||||
|
||||
> **Гайд по партициям:** новый партиционированный `lead_region_resolution_log` имеет ключ `received_at` (как `deals`). Партиции `deals` создаются помесячно — наши партиции на старте только m05/m06, дальше их подхватит `partitions:create-months` ПОСЛЕ регистрации в Task 1.2.
|
||||
|
||||
- [ ] **Step 4: Прогнать тест — PASS** (`cd app && ./vendor/bin/pest tests/Feature/Migrations/PhoneRangesMigrationTest.php`)
|
||||
|
||||
- [ ] **Step 5: Коммит** `git add -A && git commit -m "feat(region): schema — phone_ranges, resolution_log, supplier_leads/deals columns"`
|
||||
|
||||
### Task 1.2 — Регистрация новой партиц-таблицы в MonthlyPartitionManager
|
||||
|
||||
- [ ] **Step 1: Падающий тест** `app/tests/Unit/Services/MonthlyPartitionManagerRegionLogTest.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
use App\Services\MonthlyPartitionManager;
|
||||
it('knows lead_region_resolution_log partition key', function (): void {
|
||||
expect(MonthlyPartitionManager::PARTITIONED_TABLES)->toHaveKey('lead_region_resolution_log');
|
||||
expect(MonthlyPartitionManager::PARTITIONED_TABLES['lead_region_resolution_log'])->toBe('received_at');
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Прогнать — FAIL.**
|
||||
- [ ] **Step 3: Добавить** в `MonthlyPartitionManager::PARTITIONED_TABLES` (после строки 61) `'lead_region_resolution_log' => 'received_at',`.
|
||||
- [ ] **Step 4: Прогнать — PASS.**
|
||||
- [ ] **Step 5: Коммит** `chore(region): register lead_region_resolution_log in MonthlyPartitionManager`.
|
||||
|
||||
### Task 1.3 — Синхронизация db/schema.sql + CHANGELOG
|
||||
|
||||
- [ ] **Step 1:** Добавить новые `CREATE TABLE`/`ALTER` в `db/schema.sql` (зеркало миграции), bump версии в header.
|
||||
- [ ] **Step 2:** Запись в `db/CHANGELOG_schema.md` (новая версия, перечень изменений).
|
||||
- [ ] **Step 3:** Коммит `chore(region): sync db/schema.sql + CHANGELOG for region resolution`.
|
||||
|
||||
**Session 1 завершение:** прогон `cd app && ./vendor/bin/pest tests/Feature/Migrations tests/Unit/Services/MonthlyPartitionManagerRegionLogTest.php` → GREEN. Push.
|
||||
|
||||
---
|
||||
|
||||
## SESSION 2 — Россвязь: реестр + lookup
|
||||
|
||||
**Deliverable:** `RossvyazPrefixLookup` находит регион+оператора по телефону через `phone_ranges`; `phone-ranges:import` команда импортирует реестр.
|
||||
**Preconditions:** Session 1 смержена/на ветке. Таблицы `phone_ranges*` существуют.
|
||||
**Files:**
|
||||
|
||||
- Create: `app/app/Services/RossvyazPrefixLookup.php`, `app/app/Services/Dto/RossvyazRecord.php`
|
||||
- Create: `app/app/Console/Commands/PhoneRangesImportCommand.php`
|
||||
- Test: `app/tests/Unit/Services/RossvyazPrefixLookupTest.php`, `app/tests/Feature/Console/PhoneRangesImportCommandTest.php`
|
||||
|
||||
### Task 2.1 — RossvyazRecord DTO + Lookup (TDD)
|
||||
|
||||
- [ ] **Step 1: Падающие тесты** `RossvyazPrefixLookupTest.php` (Feature, нужна БД — `uses(DatabaseTransactions::class, SharesSupplierPdo::class)`; сидируем `phone_ranges` напрямую через `DB::table`):
|
||||
|
||||
```php
|
||||
it('mobile prefix returns correct region and operator', function (): void {
|
||||
DB::table('phone_ranges')->insert([
|
||||
'def_code'=>921,'from_num'=>5550000,'to_num'=>5559999,'operator'=>'МегаФон',
|
||||
'region'=>'Санкт-Петербург','subject_code'=>83,'imported_at'=>now(),'import_id'=>seedImport(),
|
||||
]);
|
||||
$rec = app(App\Services\RossvyazPrefixLookup::class)->find('7921555XXXX');
|
||||
expect($rec)->not->toBeNull()->and($rec->subjectCode)->toBe(83)->and($rec->region)->toBe('Санкт-Петербург');
|
||||
});
|
||||
it('prefers narrower range when two ranges overlap', function (): void { /* два диапазона, узкий выигрывает (ORDER BY to_num-from_num ASC) */ });
|
||||
it('returns null for unknown prefix', function (): void {
|
||||
expect(app(App\Services\RossvyazPrefixLookup::class)->find('7999XXXXXXX'))->toBeNull();
|
||||
});
|
||||
```
|
||||
|
||||
(`seedImport()` — локальный хелпер в тесте: вставляет строку `phone_ranges_imports` и возвращает id.)
|
||||
|
||||
- [ ] **Step 2: FAIL.**
|
||||
- [ ] **Step 3: Реализация.** `RossvyazRecord` — readonly DTO (`subjectCode: ?int`, `region: string`, `operator: string`). `RossvyazPrefixLookup::find(string $phone): ?RossvyazRecord` по алгоритму спеки §3.7: `def_code = (int) substr($phone,1,3)`, `subscriber = (int) substr($phone,4)`, SQL `SELECT region, operator, subject_code FROM phone_ranges WHERE def_code=? AND from_num<=? AND to_num>=? ORDER BY (to_num-from_num) ASC LIMIT 1`. Запрос через `DB::connection('pgsql_supplier')` (BYPASSRLS, как LeadRouter).
|
||||
- [ ] **Step 4: PASS.**
|
||||
- [ ] **Step 5: Коммит** `feat(region): RossvyazPrefixLookup + RossvyazRecord DTO`.
|
||||
|
||||
### Task 2.2 — PhoneRangesImportCommand (TDD)
|
||||
|
||||
- [ ] **Step 1: Падающий Feature-тест** — `phone-ranges:import --dry-run` парсит фикстурный XLSX/CSV в `phone_ranges_staging`, маппит region→subject_code через `RussianRegions::nameToCode()`, при `--dry-run` не свапает. (Фикстура: маленький CSV в `app/tests/Fixtures/rossvyaz/sample.csv`.)
|
||||
- [ ] **Step 2: FAIL.**
|
||||
- [ ] **Step 3: Реализация** по спеке §6.2: staging-таблица → COPY → checksum-idempotency → atomic `RENAME` swap → `phone_ranges_imports.status`. Несматчившиеся регионы → лог в `phone_ranges_imports.error`. `--dry-run` останавливается до swap. **NB:** реальный источник — пакет ~500-600 файлов XLSX (§6.1); для теста парсим один CSV-фикстуру. Парсер XLSX — отдельный приватный метод, в тесте подменяется CSV-веткой через флаг формата.
|
||||
- [ ] **Step 4: PASS.**
|
||||
- [ ] **Step 5: Коммит** `feat(region): phone-ranges:import command with atomic swap + idempotency`.
|
||||
|
||||
**Session 2 завершение:** GREEN сервис-слой Россвязи. Push. (Реальный первый импорт реестра — оператором в Session 6 раскатке, не в тесте.)
|
||||
|
||||
---
|
||||
|
||||
## SESSION 3 — DaData клиент + бюджет + rate-limit + region map
|
||||
|
||||
**Deliverable:** `DaDataPhoneClient` дёргает REST, `DaDataRegionMap` маппит имя→код, `DaDataBudgetGuard` режет по дневному лимиту, token-bucket защищает от 429. Никакой оркестрации (она в Session 4).
|
||||
**Preconditions:** Sessions 1-2 готовы.
|
||||
**Files:**
|
||||
|
||||
- Create: `app/app/Services/DaData/DaDataPhoneClient.php`, `DaDataPhoneResponse.php`, `DaDataQualityCode.php`, `DaDataException.php`, `DaDataTimeoutException.php`
|
||||
- Create: `app/app/Services/DaData/DaDataBudgetGuard.php`
|
||||
- Create: `app/app/Support/DaDataRegionMap.php`
|
||||
- Modify: `app/config/services.php` (+`dadata` блок)
|
||||
- Test: `app/tests/Unit/Services/DaData/DaDataPhoneClientTest.php`, `DaDataBudgetGuardTest.php`, `app/tests/Unit/Support/DaDataRegionMapTest.php`
|
||||
|
||||
### Task 3.1 — config/services.php + DaDataQualityCode enum
|
||||
|
||||
- [ ] **Step 1:** Добавить в `config/services.php`:
|
||||
|
||||
```php
|
||||
'dadata' => [
|
||||
'api_key' => env('DADATA_API_KEY'),
|
||||
'secret' => env('DADATA_SECRET'),
|
||||
'timeout_ms' => (int) env('DADATA_TIMEOUT_MS', 2000),
|
||||
'retries' => (int) env('DADATA_RETRIES', 1),
|
||||
'daily_cap_rub' => (int) env('DADATA_DAILY_CAP_RUB', 10000),
|
||||
'enabled' => filter_var(env('LEAD_REGION_RESOLVER_ENABLED', false), FILTER_VALIDATE_BOOL),
|
||||
'cache_ttl_days' => (int) env('PHONE_REGION_CACHE_TTL_DAYS', 30),
|
||||
],
|
||||
```
|
||||
|
||||
- [ ] **Step 2:** `DaDataQualityCode` — enum:int (CASE_RECOGNIZED=0, ASSUMPTIONS=1, EMPTY=2, MULTIPLE=3, FOREIGN=7). Без теста (тривиальный enum) — покрывается через клиент.
|
||||
- [ ] **Step 3: Коммит** `chore(region): config/services dadata + DaDataQualityCode enum`.
|
||||
|
||||
### Task 3.2 — DaDataRegionMap (TDD)
|
||||
|
||||
- [ ] **Step 1: Падающий unit-тест** `DaDataRegionMapTest.php`:
|
||||
|
||||
```php
|
||||
use App\Support\DaDataRegionMap;
|
||||
it('maps exact official names via RussianRegions', function (): void {
|
||||
expect(DaDataRegionMap::toSubjectCode('Москва'))->toBe(82);
|
||||
expect(DaDataRegionMap::toSubjectCode('Московская область'))->toBe(56);
|
||||
expect(DaDataRegionMap::toSubjectCode('Санкт-Петербург'))->toBe(83);
|
||||
expect(DaDataRegionMap::toSubjectCode('Ленинградская область'))->toBe(53);
|
||||
});
|
||||
it('flags ambiguous agglomeration strings', function (): void {
|
||||
expect(DaDataRegionMap::isAmbiguous('Санкт-Петербург и область'))->toBeTrue();
|
||||
expect(DaDataRegionMap::isAmbiguous('Москва и область'))->toBeTrue();
|
||||
expect(DaDataRegionMap::isAmbiguous('Москва'))->toBeFalse();
|
||||
});
|
||||
it('returns null for unmappable region', function (): void {
|
||||
expect(DaDataRegionMap::toSubjectCode('Атлантида'))->toBeNull();
|
||||
});
|
||||
it('resolves all 89 RussianRegions names', function (): void {
|
||||
foreach (App\Support\RussianRegions::CODE_TO_NAME as $code => $name) {
|
||||
expect(DaDataRegionMap::toSubjectCode($name))->toBe($code);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: FAIL.**
|
||||
- [ ] **Step 3: Реализация.** `DaDataRegionMap`: `AMBIGUOUS_REGIONS = ['Санкт-Петербург и область','Москва и область']` (const). `OVERRIDES` — массив для несовпадающих имён (на старте пустой — заполняется findings). `toSubjectCode(string $name): ?int` → trim → `OVERRIDES[$name] ?? RussianRegions::nameToCode()[$name] ?? null`. `isAmbiguous(string $name): bool` → `in_array($name, self::AMBIGUOUS_REGIONS, true)`.
|
||||
- [ ] **Step 4: PASS.**
|
||||
- [ ] **Step 5: Коммит** `feat(region): DaDataRegionMap with ambiguous-list + 89-region coverage`.
|
||||
|
||||
### Task 3.3 — DaDataPhoneClient (TDD, Http::fake)
|
||||
|
||||
> **Конвенция HTTP-клиента** — зеркалить [`app/app/Services/Supplier/SupplierPortalClient.php`](../../../app/app/Services/Supplier/SupplierPortalClient.php): инжектить `Illuminate\Http\Client\Factory $http`, кастомные исключения, приватный `request()`.
|
||||
|
||||
- [ ] **Step 1: Падающие unit-тесты** `DaDataPhoneClientTest.php` (по одному на qc 0/1/2/3/7 + timeout + 5xx-retry + 4xx-no-retry). Пример:
|
||||
|
||||
```php
|
||||
use App\Services\DaData\DaDataPhoneClient;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
it('parses qc=0 mobile response', function (): void {
|
||||
Http::fake(['cleaner.dadata.ru/*' => Http::response([[
|
||||
'qc'=>0,'qc_conflict'=>0,'type'=>'Мобильный','phone'=>'+7 921 555-12-34',
|
||||
'provider'=>'МегаФон','region'=>'Санкт-Петербург и область','timezone'=>'UTC+3',
|
||||
]], 200)]);
|
||||
$resp = app(DaDataPhoneClient::class)->cleanPhone('7921555XXXX');
|
||||
expect($resp->qc)->toBe(0)->and($resp->provider)->toBe('МегаФон')
|
||||
->and($resp->region)->toBe('Санкт-Петербург и область');
|
||||
});
|
||||
it('throws DaDataTimeoutException on connection timeout', function (): void {
|
||||
Http::fake(fn () => throw new Illuminate\Http\Client\ConnectionException('timeout'));
|
||||
expect(fn () => app(DaDataPhoneClient::class)->cleanPhone('7921555XXXX'))
|
||||
->toThrow(App\Services\DaData\DaDataTimeoutException::class);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: FAIL.**
|
||||
- [ ] **Step 3: Реализация** по §3.6: POST `https://cleaner.dadata.ru/api/v1/clean/phone`, headers `Authorization: Token <key>`, `X-Secret: <secret>`, body `["<phone>"]`, timeout из config, retry на сетевые/5xx. Парсинг массива[0] → `DaDataPhoneResponse` (readonly DTO, поля по §3.6). `ConnectionException`/таймаут → `DaDataTimeoutException`; не-2xx после retry → `DaDataException`.
|
||||
- [ ] **Step 4: PASS.**
|
||||
- [ ] **Step 5: Коммит** `feat(region): DaDataPhoneClient + DTO + exceptions`.
|
||||
|
||||
### Task 3.4 — DaDataBudgetGuard + token-bucket (TDD)
|
||||
|
||||
- [ ] **Step 1: Падающий тест** — `canSpend()` true пока `phone_resolution.dadata.spent_today_kopecks < daily_cap`; false при превышении; `recordSpend()` делает Redis INCRBY. (`Cache::store('array')` или Redis-fake.)
|
||||
- [ ] **Step 2: FAIL.**
|
||||
- [ ] **Step 3: Реализация** §5.3 + §3.13: `DaDataBudgetGuard` (canSpend/recordSpend через Redis-ключ с дневным TTL). Token-bucket 18 RPS — `RateLimiter::for('dadata-cleaner', ...)` зарегистрировать в провайдере; в клиенте обернуть вызов (или отдельный guard — решить в Session 4 при сборке).
|
||||
- [ ] **Step 4: PASS.**
|
||||
- [ ] **Step 5: Коммит** `feat(region): DaDataBudgetGuard + rate-limit`.
|
||||
|
||||
**Session 3 завершение:** GREEN `tests/Unit/Services/DaData tests/Unit/Support/DaDataRegionMapTest.php`. Push.
|
||||
|
||||
---
|
||||
|
||||
## SESSION 4 — LeadRegionResolver (оркестратор)
|
||||
|
||||
**Deliverable:** `LeadRegionResolver::resolve(SupplierLead): RegionResolution` со всем каскадом qc-решений, кэшем, ambiguous-логикой, persistent-idempotency, cache-hit логированием. Это сердце фичи.
|
||||
**Preconditions:** Sessions 1-3. Все суб-компоненты существуют и зелёные.
|
||||
**Files:**
|
||||
|
||||
- Create: `app/app/Services/LeadRegionResolver.php`, `app/app/Services/Dto/RegionResolution.php`
|
||||
- Test: `app/tests/Unit/Services/LeadRegionResolverTest.php` (12 кейсов из спеки §9.1)
|
||||
|
||||
### Task 4.1 — RegionResolution DTO + source rank
|
||||
|
||||
- [ ] **Step 1: Падающий тест** на DTO: поля `subjectCode: ?int`, `actualSubjectCode: ?int`, `source: string` ('dadata'|'rossvyaz'|'tag'|'unknown'), `phoneOperator: ?string`, `qc: ?int`, `cacheHit: bool`, `dadataResponseMasked: ?array`, `durationMs: ?int`, `rossvyazMatched: bool`. + статик `SOURCE_RANK` const `['dadata'=>4,'rossvyaz'=>3,'tag'=>2,'unknown'=>1]`. + фабрики `fromTag()`, `fromSupplierLead()` (для persistent-idempotency).
|
||||
- [ ] **Step 2-4:** реализация readonly DTO, PASS.
|
||||
- [ ] **Step 5: Коммит** `feat(region): RegionResolution DTO + SOURCE_RANK`.
|
||||
|
||||
### Task 4.2 — LeadRegionResolver: 12 кейсов (TDD, по одному тесту за раз)
|
||||
|
||||
Реализация по алгоритму спеки §3.3 + §3.4 (decision-таблица). Кэш-ключ `sha256("phone-region:".$phone)`, TTL = `config('services.dadata.cache_ttl_days')` дней. Persistent-idempotency: в начале `resolve()` если `$lead->resolved_subject_code !== null || $lead->region_source !== null` → `RegionResolution::fromSupplierLead($lead)` без DaData. Валидация телефона `/^7\d{10}$/` (как в Job/Controller).
|
||||
|
||||
Каждый тест из списка спеки §9.1 — отдельный TDD-цикл (Step write→fail→implement→pass→commit). Имена тестов (Pest `it('...')`):
|
||||
|
||||
- [ ] `dadata qc 0 returns dadata source` — `Http::fake` qc=0 region не-ambiguous → source='dadata', subjectCode маппится.
|
||||
- [ ] `dadata qc 0 ambiguous region falls to rossvyaz but keeps dadata provider` — region='Санкт-Петербург и область' → идём в Россвязь за subjectCode=83, provider остаётся от DaData (И-2). **Ключевой тест ambiguous-логики.**
|
||||
- [ ] `dadata qc 3 returns dadata with multiple flag`.
|
||||
- [ ] `dadata qc 1 falls back to rossvyaz`.
|
||||
- [ ] `dadata qc 2 falls back to tag skipping rossvyaz`.
|
||||
- [ ] `dadata qc 7 falls back to tag skipping rossvyaz`.
|
||||
- [ ] `dadata timeout falls back to rossvyaz`.
|
||||
- [ ] `dadata network error falls back to rossvyaz`.
|
||||
- [ ] `budget cap exceeded skips dadata directly to rossvyaz` (`DaDataBudgetGuard::canSpend()` false).
|
||||
- [ ] `cache hit skips dadata and rossvyaz` — второй вызов того же телефона не дёргает Http (assert `Http::assertSentCount`).
|
||||
- [ ] `invalid phone skips dadata returns tag`.
|
||||
- [ ] `qc 0 region null falls through to rossvyaz` (мобильный без региона, §3.4 Q6/Q7).
|
||||
- [ ] `unmappable dadata region falls through to rossvyaz` (qc=0 но region не в справочнике).
|
||||
- [ ] `all three layers fail returns unknown with null subject_code`.
|
||||
|
||||
После каждого — Step «commit» `feat(region): LeadRegionResolver — <case>` (или батч-коммит на 3-4 связанных кейса).
|
||||
|
||||
**Session 4 завершение:** `cd app && ./vendor/bin/pest tests/Unit/Services/LeadRegionResolverTest.php` все GREEN. Push. **Это самая важная сессия — не торопиться, ревью каждого кейса.**
|
||||
|
||||
---
|
||||
|
||||
## SESSION 5 — LeadRouter каскад + подмена региона
|
||||
|
||||
**Deliverable:** `LeadRouter::matchEligibleProjects` принимает `?int $resolvedSubjectCode`, фильтрует в 3 фазы (точное→РФ→запасной) для ОБОИХ путей (DIRECT + pivot), отдаёт ≤3 кандидата с атрибутом `routing_step`.
|
||||
**Preconditions:** Sessions 1-4. **Решён вопрос D1** (random→deterministic подтверждён заказчиком).
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/app/Services/LeadRouter.php` (новый параметр + queryCandidates 3-фазы)
|
||||
- Modify: `app/tests/Pest.php` (расширить `createRoutingSnapshotFromProject` параметром `string $regions = '{}'`)
|
||||
- Test: `app/tests/Feature/Services/LeadRouterCascadeTest.php`
|
||||
|
||||
### Task 5.1 — Расширить тест-хелпер
|
||||
|
||||
- [ ] **Step 1:** В `createRoutingSnapshotFromProject` (Pest.php строки 128-150) добавить параметр `string $regions = '{}'` и подставить в insert вместо хардкода `'{}'` (строка 141). Существующие вызовы не ломаются (дефолт сохранён).
|
||||
- [ ] **Step 2:** Прогнать существующий `LeadRouterTest.php` — GREEN (регресс не сломан).
|
||||
- [ ] **Step 3: Коммит** `test(region): createRoutingSnapshotFromProject accepts regions param`.
|
||||
|
||||
### Task 5.2 — Каскад: сигнатура + 3 фазы (TDD)
|
||||
|
||||
> **Подход:** обернуть существующий SQL приватным `queryCandidates(string $activeDate, SupplierProject $sp, string $regionFilter, ?int $code, array $excludeTenantIds, int $limit): Collection`. Он содержит развилку DIRECT vs pivot (как сейчас) + добавляет WHERE-фрагмент по фильтру. `matchEligibleProjects(SupplierProject $sp, ?int $resolvedSubjectCode = null)` оркестрирует 3 фазы (§3.9 псевдокод), проставляет `routing_step` на каждый Project через `$project->setAttribute('routing_step', N)`.
|
||||
|
||||
WHERE-фрагменты:
|
||||
|
||||
- `exact`: `AND ?::int = ANY(snap.regions)` (bind `$code`)
|
||||
- `all_ru`: `AND snap.regions = '{}'::int[]`
|
||||
- `any`: без региона-фильтра (текущее поведение)
|
||||
|
||||
- [ ] **Step 1: Падающие тесты** `LeadRouterCascadeTest.php` (Pest, `DatabaseTransactions` + `SharesSupplierPdo`, tenant-context '0'):
|
||||
|
||||
```php
|
||||
it('step 1: exact region match wins', function (): void {
|
||||
$sp = SupplierProject::query()->create(['platform'=>'B1','signal_type'=>'site','unique_key'=>'ex.ru','subject_code'=>82,'current_limit'=>0,'sync_status'=>'ok']);
|
||||
// tenant A — регион 83 (СПб); tenant B — регион 82 (Москва)
|
||||
$a = makeLinkedProject($sp, regions: '{83}'); // helper inline
|
||||
$b = makeLinkedProject($sp, regions: '{82}');
|
||||
$matched = app(LeadRouter::class)->matchEligibleProjects($sp, resolvedSubjectCode: 82);
|
||||
expect($matched->pluck('id')->all())->toBe([$b->id]) // только Москва-проект
|
||||
->and($matched->first()->routing_step)->toBe(1);
|
||||
});
|
||||
it('step 2: falls to all-RF when no exact match', function (): void {
|
||||
// кандидат только с regions='{}' → routing_step=2 для resolvedSubjectCode=82
|
||||
});
|
||||
it('step 3: fallback channel when nobody subscribed to region', function (): void {
|
||||
// кандидат с regions='{83}' только; resolvedSubjectCode=82 → никто не подписан, нет РФ →
|
||||
// возвращается с routing_step=3 (подмена в Job, не здесь)
|
||||
});
|
||||
it('exact + all-RF combine up to cap=3', function (): void { /* 2 точных + 2 РФ → 3 взяты, точные первыми */ });
|
||||
it('null resolvedSubjectCode skips exact, uses all-RF then fallback', function (): void { /* резолвер не сработал */ });
|
||||
it('cascade works for DIRECT supplier_project path too', function (): void { /* platform=DIRECT */ });
|
||||
```
|
||||
|
||||
(`makeLinkedProject($sp, regions)` — inline-хелпер в файле теста: создаёт tenant с балансом, project, `linkProjectToSupplier`, `createRoutingSnapshotFromProject($p, regions: $regions)`.)
|
||||
|
||||
- [ ] **Step 2: FAIL.**
|
||||
- [ ] **Step 3: Реализация** каскада. Сохранить fail-loud `logIfNoSnapshot` (вызывать на финальном результате). `excludeTenantIds` для шага 2 = tenant_id из шага 1.
|
||||
- [ ] **Step 4: PASS** + регресс `LeadRouterTest.php` GREEN (старые вызовы без 2-го параметра используют дефолт `null` → ведут себя как «any», но теперь через каскад → проверить что 0-региональные тесты не сломались; при необходимости старые snapshot'ы имеют `regions='{}'` → попадают в шаг 2 all_ru).
|
||||
|
||||
> **⚠️ Регрессионный риск:** существующие `LeadRouterTest` создают snapshot с `regions='{}'` и вызывают `matchEligibleProjects($sp)` без 2-го арг. С каскадом `resolvedSubjectCode=null` → шаг 1 пропускается → шаг 2 all_ru матчит `regions='{}'` → те же результаты. **Проверить это явно**; если расходится — поправить дефолтную ветку, чтобы `null` + любой regions вёл себя как старое «any» (backward-compat). Это решение зафиксировать в коммит-сообщении.
|
||||
|
||||
- [ ] **Step 5: Коммит** `feat(region): LeadRouter cascade routing (exact→all-RF→fallback) with routing_step`.
|
||||
|
||||
**Session 5 завершение:** `cd app && ./vendor/bin/pest tests/Feature/Services/LeadRouterTest.php tests/Feature/Services/LeadRouterCascadeTest.php` GREEN. Push.
|
||||
|
||||
---
|
||||
|
||||
## SESSION 6 — Интеграция в Job + CSV-merge + flag + раскатка
|
||||
|
||||
**Deliverable:** `RouteSupplierLeadJob` использует `LeadRegionResolver`, персистит резолв, передаёт `routing_step`, подменяет регион на шаге 3; CSV-merge обновляет по рангу источника; feature-flag; метрики; staging-smoke.
|
||||
**Preconditions:** Sessions 1-5 все зелёные и смержены.
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/app/Jobs/RouteSupplierLeadJob.php` (handle + createDealCopyForProject + CSV-merge)
|
||||
- Create: `app/app/Console/Commands/PhoneRegionSmokeCommand.php` (staging-smoke §9.4)
|
||||
- Test: `app/tests/Feature/Jobs/RouteSupplierLeadJobRegionResolutionTest.php`
|
||||
|
||||
### Task 6.1 — Резолв до транзакции + persist (TDD)
|
||||
|
||||
> **Точка вставки** ([RouteSupplierLeadJob.php:151-160](../../../app/app/Jobs/RouteSupplierLeadJob.php#L151)). Сейчас: `$matched = $router->matchEligibleProjects($supplier); $selected = $distributor->selectRecipients($matched); $subjectCode = $tagResolver->resolve(...)`. Становится: резолв региона ДО `matchEligibleProjects`, persist в одной короткой `DB::transaction()`, затем `matchEligibleProjects($supplier, $resolution->subjectCode)`.
|
||||
|
||||
- [ ] **Step 1: Падающий тест** `RouteSupplierLeadJobRegionResolutionTest.php`:
|
||||
|
||||
```php
|
||||
it('lead with phone uses dadata region not tag', function (): void {
|
||||
Http::fake(['cleaner.dadata.ru/*' => Http::response([['qc'=>0,'type'=>'Мобильный','provider'=>'МТС','region'=>'Москва']], 200)]);
|
||||
// lead с raw_payload tag='Санкт-Петербург' но phone резолвится в Москву(82)
|
||||
// → deal.subject_code = 82, supplier_leads.resolved_subject_code=82, region_source='dadata'
|
||||
// → строка в lead_region_resolution_log
|
||||
});
|
||||
it('region resolution logged per lead with cache_hit flag', function (): void { /* 1 строка в log */ });
|
||||
it('lead with invalid phone falls back to tag', function (): void { /* phone='123' → region_source='tag' */ });
|
||||
it('lead with resolver disabled via flag uses tag', function (): void { /* config dadata.enabled=false → tag-flow */ });
|
||||
it('persistent idempotency: retry does not re-call dadata', function (): void { /* resolved_subject_code уже set → Http::assertNothingSent */ });
|
||||
```
|
||||
|
||||
- [ ] **Step 2: FAIL.**
|
||||
- [ ] **Step 3: Реализация.** Инжектить `LeadRegionResolver $regionResolver` в `handle()`. После `$lead->update(['supplier_project_id'...])`:
|
||||
|
||||
```php
|
||||
$resolution = $regionResolver->resolve($lead);
|
||||
// persist в одной короткой транзакции (ДО циклов по проектам — HTTP не висит в tenant-tx)
|
||||
DB::transaction(function () use ($lead, $resolution): void {
|
||||
$lead->update([
|
||||
'resolved_subject_code' => $resolution->subjectCode,
|
||||
'region_source' => $resolution->source,
|
||||
'dadata_qc' => $resolution->qc,
|
||||
'phone_operator' => $resolution->phoneOperator,
|
||||
]);
|
||||
$this->logRegionResolution($lead, $resolution); // INSERT lead_region_resolution_log
|
||||
});
|
||||
$matched = $router->matchEligibleProjects($supplier, $resolution->subjectCode);
|
||||
$selected = $distributor->selectRecipients($matched);
|
||||
```
|
||||
|
||||
Удалить старый `$subjectCode = $tagResolver->resolve(...)`. `RegionTagResolver` остаётся injected (его использует `LeadRegionResolver` как fallback — DI цепочка). Приватный `logRegionResolution()` пишет в `lead_region_resolution_log` через `pgsql_supplier`, телефон маскируется (§7.1: `7XXX***YYYY`).
|
||||
|
||||
- [ ] **Step 4: PASS.**
|
||||
- [ ] **Step 5: Коммит** `feat(region): wire LeadRegionResolver into RouteSupplierLeadJob + persist`.
|
||||
|
||||
### Task 6.2 — Подмена subject_code на шаге 3 (TDD)
|
||||
|
||||
- [ ] **Step 1: Падающий тест** — `routing_step=3` проект получает deal с `subject_code` = первый из `project->regions`, `region_substituted=true`; `lead_region_resolution_log.actual_subject_code` = настоящий резолв. `routing_step<3` → настоящий subjectCode, `region_substituted=false`.
|
||||
- [ ] **Step 2: FAIL.**
|
||||
- [ ] **Step 3: Реализация** §3.10. `createDealCopyForProject` получает `RegionResolution $resolution` (вместо `?int $subjectCode`). Внутри:
|
||||
|
||||
```php
|
||||
$dealSubjectCode = ($project->routing_step ?? 1) < 3
|
||||
? $resolution->subjectCode
|
||||
: $this->pickSubstituteRegion($project, $resolution->subjectCode);
|
||||
$dealRegionSubstituted = ($project->routing_step ?? 1) === 3;
|
||||
// Deal::create([... 'subject_code'=>$dealSubjectCode, 'phone_operator'=>$resolution->phoneOperator, 'region_substituted'=>$dealRegionSubstituted])
|
||||
```
|
||||
|
||||
`pickSubstituteRegion(Project $p, ?int $resolved): ?int` — пустой `$p->regions` → `$resolved`; иначе `$p->regions[0]`. Дописать `lead_region_resolution_log` UPDATE с `routing_step`/`actual_subject_code`/`substituted_subject_code` (или включить в Task 6.1 лог — решить при сборке, лог пишется ПОСЛЕ маршрутизации когда routing_step известен; возможно перенести запись лога из 6.1 в конец handle()).
|
||||
|
||||
> **NB порядок записи лога:** `routing_step` известен только ПОСЛЕ `matchEligibleProjects`. Значит INSERT в `lead_region_resolution_log` логичнее делать ПОСЛЕ цикла (с агрегатом routing_step) ИЛИ писать базовую строку в 6.1 и UPDATE'ить routing-поля после. Выбрать: **одна строка на лид** пишется в конце `handle()` с финальными routing-полями (subject_code лида один, routing_step берётся от первого selected-проекта или max). Зафиксировать решение в коммите.
|
||||
|
||||
- [ ] **Step 4: PASS.**
|
||||
- [ ] **Step 5: Коммит** `feat(region): step-3 fallback subject_code substitution + region_substituted`.
|
||||
|
||||
### Task 6.3 — CSV-merge update по рангу источника (TDD)
|
||||
|
||||
- [ ] **Step 1: Падающий тест** — CSV-recovered deal `region_source='tag'`, subject_code=99; webhook даёт `dadata` subject=82 → merge обновляет subject_code/phone_operator/region_source (rank 4>2). Равный/худший ранг → НЕ обновляет.
|
||||
- [ ] **Step 2: FAIL.**
|
||||
- [ ] **Step 3: Реализация** §3.12 в merge-блоке (строки 340-369). При наличии `$existingMergeable` и нового `$resolution`: сравнить `RegionResolution::SOURCE_RANK`, если новый выше — добавить `subject_code`/`phone_operator`/`region_source` в `DB::table('deals')->where('id')->where('received_at')->update([...])`. **Сохранить `received_at` в WHERE** (partition pruning + FK, как в существующем коде, строки 357-360).
|
||||
- [ ] **Step 4: PASS.**
|
||||
- [ ] **Step 5: Коммит** `feat(region): CSV-merge updates subject_code/operator by source rank`.
|
||||
|
||||
### Task 6.4 — Staging-smoke команда + метрики
|
||||
|
||||
- [ ] **Step 1:** `PhoneRegionSmokeCommand` (`phone-region:smoke --phone=...`) §9.4 — дёргает живой DaData+Россвязь, печатает решение, НЕ пишет в БД. Тест: команда с `Http::fake` печатает структуру.
|
||||
- [ ] **Step 2:** Метрики §8.1 — инкременты `phone_resolution.source.*` / `dadata.qc.*` / `cache.{hit,miss}` через существующий механизм метрик проекта (проверить как проект шлёт в Sentry/Prometheus — grep `metric`/`Sentry::` в `app/app/Services`). Если механизма нет — отложить в отдельную задачу, отметить в коммите.
|
||||
- [ ] **Step 3: Коммит** `feat(region): staging smoke command + resolution metrics`.
|
||||
|
||||
### Task 6.5 — Регрессия + handoff раскатки
|
||||
|
||||
- [ ] **Step 1:** Полная регрессия затронутого слоя: `cd app && ./vendor/bin/pest tests/Unit/Services tests/Feature/Services tests/Feature/Jobs tests/Feature/Migrations`. GREEN.
|
||||
- [ ] **Step 2:** `superpowers:requesting-code-review` на весь диапазон фичи.
|
||||
- [ ] **Step 3:** Документ-handoff раскатки (§10): порядок прод-шагов (миграция → импорт реестра → деплой с `LEAD_REGION_RESOLVER_ENABLED=false` → 1% → 100%), включая `DADATA_API_KEY`/`DADATA_SECRET` в YC Lockbox. Файл: `docs/superpowers/runbooks/2026-05-31-lead-region-resolution-rollout.md`.
|
||||
- [ ] **Step 4: Финальный коммит + PR.** `superpowers:finishing-a-development-branch`.
|
||||
|
||||
**Session 6 завершение:** вся фича зелёная, code-review пройден, runbook готов. Фактический первый импорт реестра Россвязи + раскатка — оператором по runbook, ВНЕ этого плана.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review (выполнено автором плана)
|
||||
|
||||
**Spec coverage:** §3.3 резолвер→Session 4; §3.4/§3.4.1 qc+ambiguous→Session 4; §3.7 Россвязь→Session 2; §3.6 DaData→Session 3; §3.9 каскад→Session 5; §3.10 подмена→Session 6.2; §3.11 persist/idempotency→Session 6.1; §3.12 CSV-merge→Session 6.3; §3.13 rate-limit→Session 3.4; §4 схема→Session 1; §5 config→Session 3.1; §6 импорт→Session 2.2; §8 метрики→Session 6.4; §9 тесты→распределены; §11 бюджет→config+guard Session 3. **Gap:** §7 (152-ФЗ маскирование) — покрыто частично (phone_masked в логе, Session 6.1); pg_anonymizer-маски (§7.2) НЕ выделены в задачу → **добавить в Session 1 Task 1.3 как комментарий схемы ИЛИ отдельную задачу раскатки** (low-risk, отметить для заказчика).
|
||||
|
||||
**Type consistency:** `RegionResolution` поля (`subjectCode`/`source`/`phoneOperator`/`qc`/`actualSubjectCode`) согласованы между Session 4 (определение), Session 5 (роутер не зависит от DTO), Session 6 (потребитель). `routing_step` — атрибут на `Project` (Session 5 пишет, Session 6 читает). `SOURCE_RANK` — один источник в `RegionResolution` (Session 4), потребляется в Session 6.3.
|
||||
|
||||
**Placeholders:** DDL, сигнатуры, имена тестов, точка интеграции — конкретны. Полные TDD-шаги для рутинных тестов внутри Session 4/6 описаны именами кейсов + поведением; при subagent-driven-development каждый кейс разворачивается исполнителем в write→fail→implement→pass (имена и ожидаемое поведение заданы точно).
|
||||
|
||||
---
|
||||
|
||||
## Порядок выполнения и ветки
|
||||
|
||||
1. Все 6 сессий — на одной ветке `feat/lead-region-resolution`, последовательно.
|
||||
2. Каждая сессия = отдельный subagent-driven-development прогон с ревью между задачами (Pravila §15.1 — субагенты git только Sonnet/Opus, верификация commit-базы после каждого).
|
||||
3. Между сессиями — пауза/чекпойнт заказчику (можно разнести по календарным дням).
|
||||
4. Изоляция от параллельных сессий: если router-gate v4 streams ещё активны — работать в worktree (`superpowers:using-git-worktrees`), мерж в main отдельным чекпойнтом.
|
||||
@@ -0,0 +1,459 @@
|
||||
# Safe-baseline live wiring Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Make `enforce-safe-baseline-metering.mjs` a live PreToolUse hook that hard-blocks a mutating tool past a per-task safe-baseline threshold without a real skill match, with an always-available Skill/EnterPlanMode escape; plus a standalone `enforce-runtime-write-deny` hook that closes the self-write hole on `~/.claude/runtime` side-channels.
|
||||
|
||||
**Architecture:** All logic in pure functions; `main()` is I/O composition only. The pure metering core (`safe-baseline-metering.mjs`) is reused unchanged; new pure helpers (`extractKeywords`, `detectSkillMatch`, `runLiveDecision`) live in the wrapper. The stickiness contract (V2-1) is owned by `runLiveDecision`. The write-deny hook normalizes with the resolving `pathNormalize` (V2-2). Override subsystem is cut (G3).
|
||||
|
||||
**Tech Stack:** Node.js ESM (`.mjs`), vitest, existing helpers (`enforce-hook-helpers.mjs`, `safe-baseline-metering.mjs`, `path-normalization.mjs`).
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-05-30-safe-baseline-live-wiring-design.md` (v4).
|
||||
|
||||
**NB (overnight autonomous run):** git commits require owner AskUserQuestion approval (gate) — not available while the owner sleeps. Implement on disk, keep `npm run test:tools` GREEN, leave commits + settings.json registration for the morning handoff.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| Path | Responsibility |
|
||||
|---|---|
|
||||
| `tools/enforce-safe-baseline-metering.mjs` (modify) | + `extractKeywords`, `detectSkillMatch`, `runLiveDecision`, live `main()` |
|
||||
| `tools/enforce-safe-baseline-metering.test.mjs` (modify) | + tests for the three new pure functions |
|
||||
| `tools/enforce-runtime-write-deny.mjs` (create) | standalone PreToolUse write-deny on `~/.claude/runtime/**` |
|
||||
| `tools/enforce-runtime-write-deny.test.mjs` (create) | unit tests incl. V2-2 `.`-segment evasion |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: `extractKeywords(promptText)` (pure)
|
||||
|
||||
**Files:** Modify `tools/enforce-safe-baseline-metering.mjs`; Test `tools/enforce-safe-baseline-metering.test.mjs`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```js
|
||||
import { extractKeywords } from './enforce-safe-baseline-metering.mjs';
|
||||
|
||||
describe('extractKeywords', () => {
|
||||
it('lowercases, drops <4-char tokens and stopwords, returns unique sorted', () => {
|
||||
expect(extractKeywords('Почини safe-baseline router gate')).toEqual(['baseline', 'gate', 'router', 'safe']);
|
||||
});
|
||||
it('drops common RU imperatives so unrelated tasks do not falsely overlap', () => {
|
||||
const a = extractKeywords('сделай проверь биллинг тариф');
|
||||
const b = extractKeywords('сделай проверь регион маршрут');
|
||||
const overlap = a.filter((k) => b.includes(k));
|
||||
expect(overlap).toEqual([]); // only the topic words survive, no shared imperatives
|
||||
});
|
||||
it('returns [] for empty/non-string', () => {
|
||||
expect(extractKeywords('')).toEqual([]);
|
||||
expect(extractKeywords(null)).toEqual([]);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails** — `npx vitest run tools/enforce-safe-baseline-metering.test.mjs` → FAIL (extractKeywords not exported).
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
```js
|
||||
const STOPWORDS = new Set([
|
||||
// RU common + imperatives
|
||||
'сделай', 'сделать', 'проверь', 'проверить', 'посмотри', 'добавь', 'добавить',
|
||||
'напиши', 'написать', 'нужно', 'надо', 'давай', 'можешь', 'потом', 'после',
|
||||
'перед', 'через', 'очень', 'если', 'чтобы', 'этот', 'эта', 'это', 'эти',
|
||||
'или', 'тоже', 'также', 'когда', 'пока', 'весь', 'всё', 'все', 'теперь',
|
||||
'здесь', 'там', 'нет', 'есть', 'будет', 'было', 'твой', 'мой', 'самый',
|
||||
// EN common + imperatives
|
||||
'then', 'this', 'that', 'with', 'from', 'your', 'please', 'just', 'make',
|
||||
'check', 'look', 'need', 'want', 'also', 'into', 'more', 'very', 'should',
|
||||
'will', 'have', 'does', 'done', 'them', 'they', 'here', 'there',
|
||||
]);
|
||||
|
||||
export function extractKeywords(promptText) {
|
||||
if (typeof promptText !== 'string') return [];
|
||||
const tokens = promptText
|
||||
.toLowerCase()
|
||||
.split(/[^\p{L}\p{N}]+/u)
|
||||
.filter((t) => t.length >= 4 && !STOPWORDS.has(t));
|
||||
return [...new Set(tokens)].sort();
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes** — expected PASS.
|
||||
|
||||
- [ ] **Step 5: Commit** — `git add tools/enforce-safe-baseline-metering.mjs tools/enforce-safe-baseline-metering.test.mjs` / `git commit -m "feat(safe-baseline): extractKeywords pure tokenizer (H1)"` *(defer overnight)*
|
||||
|
||||
---
|
||||
|
||||
### Task 2: `detectSkillMatch(turnEntries)` (pure)
|
||||
|
||||
**Files:** Modify both as above.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```js
|
||||
import { detectSkillMatch } from './enforce-safe-baseline-metering.mjs';
|
||||
|
||||
function asstToolUse(name, input = {}) {
|
||||
return { message: { role: 'assistant', content: [{ type: 'tool_use', name, input }] } };
|
||||
}
|
||||
|
||||
describe('detectSkillMatch', () => {
|
||||
it('true when the turn has a Skill tool_use', () => {
|
||||
expect(detectSkillMatch([asstToolUse('Skill', { skill: 'superpowers:brainstorming' })])).toBe(true);
|
||||
});
|
||||
it('true when the turn has an EnterPlanMode tool_use', () => {
|
||||
expect(detectSkillMatch([asstToolUse('EnterPlanMode')])).toBe(true);
|
||||
});
|
||||
it('false for Read/Grep/text-only turns (no self-grant via text)', () => {
|
||||
expect(detectSkillMatch([asstToolUse('Read', { file_path: 'docs/superpowers/plans/x.md' })])).toBe(false);
|
||||
expect(detectSkillMatch([{ message: { role: 'assistant', content: [{ type: 'text', text: 'docs/superpowers/plans/x.md' }] } }])).toBe(false);
|
||||
});
|
||||
it('false for empty/non-array', () => {
|
||||
expect(detectSkillMatch([])).toBe(false);
|
||||
expect(detectSkillMatch(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify FAIL** (detectSkillMatch not exported).
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
```js
|
||||
const SKILL_MATCH_TOOLS = new Set(['Skill', 'EnterPlanMode']);
|
||||
|
||||
export function detectSkillMatch(turnEntries) {
|
||||
if (!Array.isArray(turnEntries)) return false;
|
||||
for (const e of turnEntries) {
|
||||
const c = e && e.message && e.message.content;
|
||||
if (!Array.isArray(c)) continue;
|
||||
for (const b of c) {
|
||||
if (b && b.type === 'tool_use' && SKILL_MATCH_TOOLS.has(b.name)) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run to verify PASS.**
|
||||
|
||||
- [ ] **Step 5: Commit** *(defer overnight)*.
|
||||
|
||||
---
|
||||
|
||||
### Task 3: `runLiveDecision(...)` (pure — V2-1 stickiness contract)
|
||||
|
||||
**Files:** Modify both as above.
|
||||
|
||||
- [ ] **Step 1: Write the failing test** — cover BOTH V2-1 failure modes.
|
||||
|
||||
```js
|
||||
import { runLiveDecision } from './enforce-safe-baseline-metering.mjs';
|
||||
import { newCounterState } from './safe-baseline-metering.mjs';
|
||||
|
||||
function ledgerWith(counts, skill, keywords) {
|
||||
return {
|
||||
state: { ...newCounterState({ taskId: 't', startedAtIso: '2026-05-30T00:00:00Z', firstPromptExcerpt: 'p' }),
|
||||
counts: { Read: 0, Grep: 0, Glob: 0, LS: 0, TodoWrite_writes: 0, AskUserQuestion: 0, ...counts },
|
||||
skill_match_within_task: skill },
|
||||
lastKeywords: keywords,
|
||||
};
|
||||
}
|
||||
|
||||
describe('runLiveDecision — stickiness contract (V2-1)', () => {
|
||||
it('persists skillMatchedThisTurn into the ledger (stickiness not lost)', () => {
|
||||
const r = runLiveDecision({
|
||||
event: { tool_name: 'Read' }, priorLedger: null,
|
||||
promptText: 'router gate safe baseline', currentKeywords: ['router', 'gate', 'safe', 'baseline'],
|
||||
skillMatchedThisTurn: true,
|
||||
});
|
||||
expect(r.ledger.state.skill_match_within_task).toBe(true);
|
||||
});
|
||||
|
||||
it('a skill earlier in a task keeps later mutating ops allowed past the hard limit (no false block)', () => {
|
||||
const prior = ledgerWith({ Read: 60 }, true, ['router', 'gate', 'safe', 'baseline']);
|
||||
const r = runLiveDecision({
|
||||
event: { tool_name: 'Edit' }, priorLedger: prior,
|
||||
promptText: 'продолжаем router gate safe baseline', currentKeywords: ['router', 'gate', 'safe', 'baseline'],
|
||||
skillMatchedThisTurn: false,
|
||||
});
|
||||
expect(r.action).toBe('allow');
|
||||
});
|
||||
|
||||
it('skill match in task A does NOT exempt an unrelated task B (no cross-task leak)', () => {
|
||||
const prior = ledgerWith({ Read: 60 }, true, ['router', 'gate', 'safe', 'baseline']);
|
||||
const r = runLiveDecision({
|
||||
event: { tool_name: 'Edit' }, priorLedger: prior,
|
||||
promptText: 'другая тема регион маршрут лиды', currentKeywords: ['регион', 'маршрут', 'лиды'],
|
||||
skillMatchedThisTurn: false,
|
||||
});
|
||||
// fresh task (overlap < 2) → counters reset to 0 → Edit allowed BUT skill_match must be false now
|
||||
expect(r.ledger.state.skill_match_within_task).toBe(false);
|
||||
expect(r.ledger.state.counts.Read).toBe(0);
|
||||
});
|
||||
|
||||
it('hard-blocks a mutating tool past the limit in a no-skill task', () => {
|
||||
const prior = ledgerWith({ Read: 60 }, false, ['router', 'gate', 'safe', 'baseline']);
|
||||
const r = runLiveDecision({
|
||||
event: { tool_name: 'Edit' }, priorLedger: prior,
|
||||
promptText: 'router gate safe baseline', currentKeywords: ['router', 'gate', 'safe', 'baseline'],
|
||||
skillMatchedThisTurn: false,
|
||||
});
|
||||
expect(r.action).toBe('hard_block');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify FAIL.**
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
```js
|
||||
import { shouldInheritTaskId } from './safe-baseline-metering.mjs';
|
||||
|
||||
export function runLiveDecision({ event, priorLedger, promptText, currentKeywords, skillMatchedThisTurn, thresholds }) {
|
||||
const inherit = !!(priorLedger && priorLedger.state &&
|
||||
shouldInheritTaskId(priorLedger.lastKeywords || [], currentKeywords, promptText));
|
||||
const priorSticky = inherit ? !!priorLedger.state.skill_match_within_task : false;
|
||||
const effectiveSkillMatched = priorSticky || !!skillMatchedThisTurn;
|
||||
|
||||
const res = processEvent({
|
||||
event, priorLedger, currentKeywords, promptText,
|
||||
skillMatched: effectiveSkillMatched, thresholds,
|
||||
});
|
||||
// V2-1: persist stickiness — processEvent does not.
|
||||
res.ledger.state.skill_match_within_task = effectiveSkillMatched;
|
||||
return res;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run to verify PASS.**
|
||||
|
||||
- [ ] **Step 5: Commit** *(defer overnight)*.
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Live `main()` wiring + integration test
|
||||
|
||||
**Files:** Modify both as above.
|
||||
|
||||
- [ ] **Step 1: Write the failing integration test** (injected runtimeDir + transcript fixture)
|
||||
|
||||
```js
|
||||
import { runMain } from './enforce-safe-baseline-metering.mjs';
|
||||
import { mkdtempSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
function fixtureTranscript(path, entries) { writeFileSync(path, entries.map((e) => JSON.stringify(e)).join('\n')); }
|
||||
|
||||
describe('safe-baseline live main (runMain)', () => {
|
||||
it('blocks an Edit when Read past hard with no skill, and the message names the escape', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'sbm-'));
|
||||
const tpath = join(dir, 't.jsonl');
|
||||
// prior ledger: Read=60, no skill, same task keywords
|
||||
writeFileSync(join(dir, 'safe-baseline-ledger-S.json'), JSON.stringify({
|
||||
state: { schema_version: 1, task_id: 't', counts: { Read: 60, Grep: 0, Glob: 0, LS: 0, TodoWrite_writes: 0, AskUserQuestion: 0 }, skill_match_within_task: false },
|
||||
lastKeywords: ['router', 'gate', 'safe', 'baseline'],
|
||||
}));
|
||||
fixtureTranscript(tpath, [{ type: 'user', message: { role: 'user', content: 'router gate safe baseline' } }]);
|
||||
const res = await runMain({
|
||||
event: { tool_name: 'Edit', session_id: 'S', transcript_path: tpath },
|
||||
runtimeDir: dir,
|
||||
});
|
||||
expect(res.block).toBe(true);
|
||||
expect(res.message).toMatch(/EnterPlanMode|Skill/);
|
||||
});
|
||||
|
||||
it('allows a fresh task and persists the ledger', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'sbm-'));
|
||||
const tpath = join(dir, 't.jsonl');
|
||||
fixtureTranscript(tpath, [{ type: 'user', message: { role: 'user', content: 'новая тема регион' } }]);
|
||||
const res = await runMain({
|
||||
event: { tool_name: 'Read', session_id: 'S2', transcript_path: tpath },
|
||||
runtimeDir: dir,
|
||||
});
|
||||
expect(res.block).toBe(false);
|
||||
expect(existsSync(join(dir, 'safe-baseline-ledger-S2.json'))).toBe(true);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify FAIL** (runMain not exported).
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation** — replace the no-op `main()` with a testable `runMain` + thin `main()`.
|
||||
|
||||
```js
|
||||
import { readFileSync as _rf, writeFileSync as _wf, appendFileSync as _af, mkdirSync as _mk } from 'node:fs';
|
||||
import { join as _join } from 'node:path';
|
||||
import { homedir as _home } from 'node:os';
|
||||
import { readStdin, parseEventJson, readTranscript, lastUserPromptText, lastTurnEntries, exitDecision } from './enforce-hook-helpers.mjs';
|
||||
|
||||
const ESCAPE_MSG = 'invoke the recommended Skill, or EnterPlanMode, to proceed (skill/plan invocations are never blocked by this layer).';
|
||||
|
||||
function rtDir(o) { return o || _join(_home(), '.claude', 'runtime'); }
|
||||
function loadLedger(dir, sess) {
|
||||
try { return JSON.parse(_rf(_join(dir, `safe-baseline-ledger-${sess || 'unknown'}.json`), 'utf8')); }
|
||||
catch { return null; }
|
||||
}
|
||||
function saveLedger(dir, sess, ledger) {
|
||||
try { _mk(dir, { recursive: true }); _wf(_join(dir, `safe-baseline-ledger-${sess || 'unknown'}.json`), JSON.stringify(ledger)); }
|
||||
catch { /* fail-quiet */ }
|
||||
}
|
||||
function logFlag(dir, sess, entry) {
|
||||
try { _mk(dir, { recursive: true }); _af(_join(dir, `safe-baseline-flags-${sess || 'unknown'}.jsonl`), JSON.stringify({ ts: new Date().toISOString(), ...entry }) + '\n'); }
|
||||
catch { /* ignore */ }
|
||||
}
|
||||
|
||||
export async function runMain({ event, runtimeDir, transcript: injectedTranscript } = {}) {
|
||||
try {
|
||||
const sess = event.session_id;
|
||||
const dir = rtDir(runtimeDir);
|
||||
const transcript = injectedTranscript || readTranscript(event.transcript_path);
|
||||
const promptText = lastUserPromptText(transcript) || '';
|
||||
const currentKeywords = extractKeywords(promptText);
|
||||
const skillMatchedThisTurn = detectSkillMatch(lastTurnEntries(transcript)) ||
|
||||
['Skill', 'EnterPlanMode'].includes(event.tool_name);
|
||||
const priorLedger = loadLedger(dir, sess);
|
||||
|
||||
const res = runLiveDecision({ event, priorLedger, promptText, currentKeywords, skillMatchedThisTurn });
|
||||
saveLedger(dir, sess, res.ledger);
|
||||
|
||||
if (res.action === 'soft_flag') logFlag(dir, sess, { tool: event.tool_name, reason: res.reason });
|
||||
if (res.action === 'hard_block') return { block: true, message: `[safe-baseline] ${res.reason}\n${ESCAPE_MSG}` };
|
||||
return { block: false };
|
||||
} catch {
|
||||
return { block: false }; // fail-quiet
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const event = parseEventJson(await readStdin());
|
||||
const res = await runMain({ event });
|
||||
exitDecision(res);
|
||||
}
|
||||
|
||||
if ((process.argv[1] || '').replace(/\\/g, '/').endsWith('/enforce-safe-baseline-metering.mjs')) {
|
||||
main().catch(() => process.exit(0));
|
||||
}
|
||||
```
|
||||
|
||||
(Remove the old no-op `main()` and its CLI guard.)
|
||||
|
||||
- [ ] **Step 4: Run to verify PASS** + `npm run test:tools` GREEN.
|
||||
|
||||
- [ ] **Step 5: Commit** *(defer overnight)*.
|
||||
|
||||
---
|
||||
|
||||
### Task 5: `enforce-runtime-write-deny.mjs` (standalone, V2-2)
|
||||
|
||||
**Files:** Create `tools/enforce-runtime-write-deny.mjs` + `tools/enforce-runtime-write-deny.test.mjs`.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```js
|
||||
import { decide } from './enforce-runtime-write-deny.mjs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const HOME = homedir();
|
||||
|
||||
describe('enforce-runtime-write-deny decide()', () => {
|
||||
it('blocks a Write into ~/.claude/runtime', () => {
|
||||
const r = decide({ toolName: 'Write', filePath: join(HOME, '.claude', 'runtime', 'askuser-decisions-S.jsonl') });
|
||||
expect(r.block).toBe(true);
|
||||
});
|
||||
it('blocks the .-segment evasion (V2-2)', () => {
|
||||
const r = decide({ toolName: 'Write', filePath: join(HOME, '.claude', '.', 'runtime', 'x.jsonl') });
|
||||
expect(r.block).toBe(true);
|
||||
});
|
||||
it('allows a Write to a normal project path', () => {
|
||||
const r = decide({ toolName: 'Write', filePath: join(HOME, 'project', 'src', 'x.mjs') });
|
||||
expect(r.block).toBe(false);
|
||||
});
|
||||
it('ignores non-write tools', () => {
|
||||
expect(decide({ toolName: 'Read', filePath: join(HOME, '.claude', 'runtime', 'x') }).block).toBe(false);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify FAIL.**
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
```js
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* enforce-runtime-write-deny — PreToolUse(Edit|Write|MultiEdit|NotebookEdit).
|
||||
* Blocks the Write/Edit TOOL from writing under ~/.claude/runtime/** (closes a
|
||||
* pre-existing self-write hole on the v4 git-approval anchor). Standalone —
|
||||
* independent of safe-baseline. Uses the resolving pathNormalize (V2-2) so
|
||||
* `.`/`..` segments cannot evade the match. Fail-OPEN on inability to determine
|
||||
* the path (never bricks the session); blocks only on a confirmed runtime match.
|
||||
*/
|
||||
import { pathNormalize } from './path-normalization.mjs';
|
||||
import { readStdin, parseEventJson, exitDecision } from './enforce-hook-helpers.mjs';
|
||||
|
||||
const WRITE_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit']);
|
||||
const RUNTIME_RE = /(^|\/)\.claude\/runtime(\/|$)/i;
|
||||
|
||||
export function decide({ toolName, filePath, normalizeImpl = pathNormalize }) {
|
||||
if (!WRITE_TOOLS.has(toolName)) return { block: false };
|
||||
const fp = String(filePath || '');
|
||||
if (!fp) return { block: false };
|
||||
let norm;
|
||||
try { norm = normalizeImpl(fp); } catch { return { block: false }; } // can't determine → fail-open (no brick)
|
||||
if (RUNTIME_RE.test(norm)) {
|
||||
return { block: true, reason: `Write to «${norm}» denied — ~/.claude/runtime is a protected side-channel (git-approval anchor).` };
|
||||
}
|
||||
return { block: false };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
const event = parseEventJson(await readStdin());
|
||||
const r = decide({
|
||||
toolName: event.tool_name,
|
||||
filePath: (event.tool_input && (event.tool_input.file_path || event.tool_input.notebook_path)) || '',
|
||||
});
|
||||
exitDecision({ block: r.block, message: r.reason });
|
||||
} catch {
|
||||
exitDecision({ block: false }); // fail-quiet
|
||||
}
|
||||
}
|
||||
|
||||
const isCli = process.argv[1] && process.argv[1].replace(/\\/g, '/').endsWith('/enforce-runtime-write-deny.mjs');
|
||||
if (isCli) main();
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run to verify PASS** + `npm run test:tools` GREEN.
|
||||
|
||||
- [ ] **Step 5: Commit** *(defer overnight)*.
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Full regression + handoff
|
||||
|
||||
- [ ] **Step 1:** `npm run test:tools` — confirm full GREEN count (baseline 1859 + new tests).
|
||||
- [ ] **Step 2:** Write the morning handoff note (`docs/observer/notes/2026-05-30-safe-baseline-overnight.md`): queued commits, exact `.claude/settings.json` registration block, the fail-OPEN deviation note for owner review, and the "flip to enforce" status (already enforce per owner; observe-mode was not requested).
|
||||
- [ ] **Step 3:** Commit everything in a batch with owner approval *(morning)*.
|
||||
|
||||
---
|
||||
|
||||
## Registration block (owner-applied, morning)
|
||||
|
||||
Add to `.claude/settings.json` `hooks.PreToolUse` (Claude cannot edit settings.json — gate-blocked):
|
||||
|
||||
```json
|
||||
{ "matcher": "Read|Grep|Glob|LS|TodoWrite|AskUserQuestion|Edit|Write|MultiEdit|NotebookEdit|Bash|Skill|Task|EnterPlanMode",
|
||||
"hooks": [{ "type": "command", "command": "node tools/enforce-safe-baseline-metering.mjs", "timeout": 10 }] }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "matcher": "Edit|Write|MultiEdit|NotebookEdit",
|
||||
"hooks": [{ "type": "command", "command": "node tools/enforce-runtime-write-deny.mjs", "timeout": 5 }] }
|
||||
```
|
||||
|
||||
Until registered, both hooks are inert (no behavior change).
|
||||
@@ -0,0 +1,98 @@
|
||||
# Calibration 5 — cosmetic-detector excludes git-approval AskUser Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:test-driven-development. Steps use checkbox (`- [ ]`) syntax.
|
||||
|
||||
**Goal:** Stop `askuser-cosmetic-detector` from counting/blocking git-operation **approval** AskUsers as "cosmetic A/B" — a scope fix that does NOT lower discipline.
|
||||
|
||||
**Architecture:** The detector's target is *simple A/B questions used instead of structured ideation* (brainstorming/writing-plans). A git-approval AskUser (one whose option label is a literal git command) is the *sanctioned git-approval channel* (`enforce-askuser-answer-parser` writes `approve_git_operation` from it) — it is never a substitute for ideation. We add `isGitApprovalQuestion()` and short-circuit `decide()` to `allow` with `isSimpleAB:false` for such questions, so they are neither counted toward the session limit nor hard-blocked. Everything else is unchanged.
|
||||
|
||||
**Tech Stack:** Node ESM `.mjs`, vitest.
|
||||
|
||||
**Why NO discipline hole (adversarial check):**
|
||||
|
||||
- The real target (design-clarification A/B like "Вариант A"/"Вариант B") has NON-git labels → still classified simple → still counted → still hard-blocked at >2. Unchanged.
|
||||
- A git-approval question is identified ONLY by an option label matching a git-command verb. To "disguise" a cosmetic clarification as exempt, the controller would have to put a literal `git …` command as an option label — but then the chosen answer IS a git command, which `enforce-askuser-answer-parser` turns into a real `approve_git_operation` record; it cannot function as a cosmetic ideation-dodge. So there is no usable bypass.
|
||||
- Exemption is narrow and structural (label is a git command), mirroring calibrations 1 (Skill) / 3 (test-runner) / 4 (user-prompt fallback): scope fix, not a discipline drop.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: isGitApprovalQuestion + decide() exemption
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `tools/askuser-cosmetic-detector.mjs`
|
||||
- Test: `tools/askuser-cosmetic-detector.test.mjs`
|
||||
|
||||
- [ ] **Step 1: Write failing tests**
|
||||
|
||||
```javascript
|
||||
import { isGitApprovalQuestion } from './askuser-cosmetic-detector.mjs';
|
||||
|
||||
describe('isGitApprovalQuestion (calibration 5)', () => {
|
||||
it('true when an option label is a git command', () => {
|
||||
expect(isGitApprovalQuestion([{ options: [{ label: 'git push origin main' }, { label: 'Не пушить' }] }])).toBe(true);
|
||||
expect(isGitApprovalQuestion([{ options: [{ label: 'git commit -F x -- a b' }, { label: 'Отмена' }] }])).toBe(true);
|
||||
});
|
||||
it('false for a non-git A/B', () => {
|
||||
expect(isGitApprovalQuestion([{ options: [{ label: 'Вариант А' }, { label: 'Вариант Б' }] }])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// decide(): git-approval question is exempt — allow, not simple, not counted, never blocked even past the session limit.
|
||||
describe('decide — git-approval exemption (calibration 5)', () => {
|
||||
it('allows a git-approval question and does NOT count it even when session is already over the limit', () => {
|
||||
const r = decide({
|
||||
questions: [{ options: [{ label: 'git push origin main' }, { label: 'Не пушить' }] }],
|
||||
simpleCountSession: 5, brainstormingInvoked: false,
|
||||
});
|
||||
expect(r.block).toBe(false);
|
||||
expect(r.action).toBe('allow');
|
||||
expect(r.isSimpleAB).toBe(false);
|
||||
expect(r.newSessionCount).toBe(5); // unchanged — not counted
|
||||
});
|
||||
|
||||
it('REGRESSION: a non-git simple A/B past the limit STILL hard-blocks (discipline intact)', () => {
|
||||
const r = decide({
|
||||
questions: [{ options: [{ label: 'A' }, { label: 'B' }] }],
|
||||
simpleCountSession: 5, brainstormingInvoked: false,
|
||||
});
|
||||
expect(r.block).toBe(true);
|
||||
expect(r.action).toBe('hard_block');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run RED** — `npx vitest run --root app --config vitest.config.tools.mjs askuser-cosmetic-detector` → fail (isGitApprovalQuestion missing; git-approval not exempt).
|
||||
|
||||
- [ ] **Step 3: Implement**
|
||||
|
||||
Add near `isSimpleAB`:
|
||||
|
||||
```javascript
|
||||
const GIT_CMD_RE = /\bgit\s+(?:commit|push|add|pull|merge|rebase|reset|checkout|switch|branch|stash|cherry-pick|revert|clean|restore|fetch|tag)\b/i;
|
||||
|
||||
/** True if this AskUser is a git-operation approval prompt (an option label is a git command). */
|
||||
export function isGitApprovalQuestion(questions) {
|
||||
if (!Array.isArray(questions)) return false;
|
||||
return questions.some((q) =>
|
||||
q && Array.isArray(q.options) &&
|
||||
q.options.some((o) => o && typeof o.label === 'string' && GIT_CMD_RE.test(o.label)));
|
||||
}
|
||||
```
|
||||
|
||||
In `decide()`, replace `const simple = isSimpleAB(questions);` with:
|
||||
|
||||
```javascript
|
||||
// Calibration 5: git-operation approval prompts are the sanctioned approval
|
||||
// channel, never cosmetic ideation — exempt from the simple-AB count/block.
|
||||
if (isGitApprovalQuestion(questions)) {
|
||||
return { action: 'allow', block: false, reason: null, isSimpleAB: false, newSessionCount: simpleCountSession, newTurnCount: simpleCountTurn };
|
||||
}
|
||||
const simple = isSimpleAB(questions);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run GREEN** — same command → pass.
|
||||
|
||||
- [ ] **Step 5: Full regression** — `npx vitest run --root app --config vitest.config.tools.mjs` → all green.
|
||||
|
||||
- [ ] **Step 6: Commit** (with git-approval).
|
||||
@@ -0,0 +1,118 @@
|
||||
# Lead Region Resolution — прогресс автономного прогона (ночь 31.05.2026)
|
||||
|
||||
> Хендофф после автономной ночной сессии. Вся работа **на диске в worktree
|
||||
> `worktree-feat+lead-region-resolution`, НЕ закоммичена** (git commit/push требуют
|
||||
> approval владельца через гейт — владелец спал). Утром: ревью → коммиты → продолжение.
|
||||
|
||||
## Что сделано (Сессии 1–4 — весь движок резолва региона, TDD-зелёный)
|
||||
|
||||
| Сессия | Статус | Тесты |
|
||||
|---|---|---|
|
||||
| **1** Схема (миграция + партиции + schema.sql sync) | ✅ на диске | 9 passed / 27 assert |
|
||||
| **2** Россвязь (lookup + DTO + import-команда) | ✅ на диске | 9 passed / 27 assert |
|
||||
| **3** DaData (region map + config + enum + client + budget guard) | ✅ на диске | 16 passed / 119 assert |
|
||||
| **4** LeadRegionResolver (оркестратор, 16 кейсов каскада) | ✅ на диске | 16 passed / 46 assert |
|
||||
| **Консолидированная регрессия** (все файлы вместе) | ✅ | **53 passed / 238 assert** |
|
||||
|
||||
### Новые/изменённые файлы
|
||||
|
||||
**Создано:**
|
||||
- `app/database/migrations/2026_05_31_100000_create_phone_ranges_and_resolution_log.php`
|
||||
- `app/app/Services/RossvyazPrefixLookup.php` + `app/app/Services/Dto/RossvyazRecord.php`
|
||||
- `app/app/Console/Commands/PhoneRangesImportCommand.php`
|
||||
- `app/app/Support/DaDataRegionMap.php`
|
||||
- `app/app/Services/DaData/{DaDataQualityCode,DaDataException,DaDataTimeoutException,DaDataPhoneResponse,DaDataPhoneClient,DaDataBudgetGuard}.php`
|
||||
- `app/app/Services/Dto/RegionResolution.php`
|
||||
- `app/app/Services/LeadRegionResolver.php`
|
||||
- Тесты: `tests/Feature/Migrations/PhoneRangesMigrationTest.php`, `tests/Unit/Services/MonthlyPartitionManagerRegionLogTest.php`, `tests/Feature/Services/RossvyazPrefixLookupTest.php`, `tests/Feature/Console/PhoneRangesImportCommandTest.php`, `tests/Unit/Support/DaDataRegionMapTest.php`, `tests/Feature/Services/DaData/{DaDataPhoneClientTest,DaDataBudgetGuardTest}.php`, `tests/Feature/Services/{RegionResolutionTest,LeadRegionResolverTest}.php`
|
||||
- `tests/Fixtures/rossvyaz/sample.csv`
|
||||
|
||||
**Изменено:**
|
||||
- `app/app/Services/MonthlyPartitionManager.php` — +entry `'lead_region_resolution_log' => 'received_at'`
|
||||
- `app/app/Models/SupplierLead.php` — +4 колонки в fillable + 2 int-cast
|
||||
- `app/config/services.php` — +блок `dadata`
|
||||
- `app/tests/Feature/PartitionsCreateMonthsTest.php` — хрупкий хардкод «48 skipped» → динамический `count(PARTITIONED_TABLES) * 6`
|
||||
- `db/schema.sql` (v8.39 → **v8.40**, только заголовок) + `db/CHANGELOG_schema.md` (+v8.40)
|
||||
|
||||
## Решения, принятые по ходу (для ревью)
|
||||
|
||||
1. **Коды субъектов** — по `RussianRegions` (Москва=82, СПб=83, МО=56, ЛО=53), НЕ по спеке (там были авто-коды 77/78/50/47 — неверно).
|
||||
2. **GRANT'ы миграции** — `crm_app_user` + `crm_supplier_worker` (роли `crm_readonly` из плана **не существует**).
|
||||
3. **`schema.sql`** — только заголовок + CHANGELOG, без тела (как v8.39 project_routing_snapshots): иначе двойной `CREATE TABLE` (0001 грузит schema.sql + дельта-миграция) сломал бы `migrate`.
|
||||
4. **Размещение тестов** — app/DB-зависимые тесты (DaData-клиент, budget, resolver, DTO с моделью) лежат в **`tests/Feature/...`, не `tests/Unit/...`** как в плане: в проекте `tests/Unit` не бутит Laravel (нет `Http::fake`/`app()`/`Cache`). Чистый `DaDataRegionMap` остался в Unit.
|
||||
5. **`PhoneRangesImportCommand` swap** — atomic RENAME реализован по спеке, но **committing-swap НЕ покрыт автотестом** (RENAME коммитит и сломал бы общую `liderra_testing`, которую ночью без терминала владельца не пересоздать). Тесты покрывают parse/map/dry-run/idempotency/force. **Свап проверяется первым реальным импортом оператора (Session 6 runbook).** Косметика: lookup-индекс на новой таблице после свапа носит имя `idx_phone_ranges_staging_lookup` (имя `idx_phone_ranges_lookup` занято `phone_ranges_old`).
|
||||
6. **DaData call cost** — `services.dadata.call_cost_kopecks` дефолт 60 (≈0.60 ₽/вызов) — **прикидка, откалибровать по тарифу DaData**.
|
||||
7. **CSV-парсер импорта** — нативный `str_getcsv(';')` (как проект читает файлы); реальный формат Россвязи (заголовки `АВС/ DEF;От;До;Емкость;Оператор;Регион`, возможно cp1251) уточняется оператором на реальном пакете. XLSX-ветка через openspout — **не протестирована**.
|
||||
|
||||
## Что осталось (требует владельца)
|
||||
|
||||
### Коммиты (утром, через git-approval)
|
||||
Предлагаемая разбивка (conventional commits, ветка `worktree-feat+lead-region-resolution`):
|
||||
- `feat(region): schema migration + MonthlyPartitionManager registration` (миграция, partition manager, PartitionsCreateMonths fix, SupplierLead model, тесты Session 1)
|
||||
- `chore(region): sync db/schema.sql + CHANGELOG (v8.40)`
|
||||
- `feat(region): RossvyazPrefixLookup + RossvyazRecord DTO`
|
||||
- `feat(region): phone-ranges:import command (parse/map/dry-run/idempotency)`
|
||||
- `feat(region): DaData layer (region map, config, enum, client, budget guard)`
|
||||
- `feat(region): LeadRegionResolver orchestrator (full qc cascade)`
|
||||
|
||||
> NB: коммит-сообщения **без** trailer `Co-Authored-By` — гейт блокирует символ `<` (угловые скобки email). Зафиксировано в `docs/bugs.md`.
|
||||
|
||||
### D1 — продуктовое решение ДО Session 5
|
||||
Сейчас при >3 кандидатах лид раздаётся **3 случайным** клиентам. Каскад (Session 5) раздаёт 3 клиентам с **наибольшим остатком дневного лимита** (детерминированно) — клиент с большим остатком систематически получает больше лидов. Каскад по конструкции (роутер режет до 3 упорядоченно → `LeadDistributor` не шаффлит) **и есть** эта смена. Нужно подтверждение: убрать random — ок? (Если хочешь сохранить случайность внутри региона — это +1 задача: shuffle внутри каждой фазы перед cap.)
|
||||
|
||||
### Session 5 (каскад LeadRouter) + Session 6 (интеграция в Job) — после D1
|
||||
- Зависят от D1 + трогают прод-критичный `RouteSupplierLeadJob` (30k лидов/сутки) → делать с ревью, не вслепую.
|
||||
- Session 6 Task 6.4 (smoke-команда `phone-region:smoke`) + метрики §8 — отдельно.
|
||||
|
||||
### Pre-existing tech debt (не моё, флагую)
|
||||
- `tests/Feature/Import/MonthlyPartitionManagerTest.php::ensureMonth создаёт партицию webhook_log` — **красный независимо от меня**: `webhook_log` удалён из проекта 24.05 (миграция `2026_05_24_140000`), тест не обновили. Можно убрать как наследие отдельным мелким фиксом — на твоё усмотрение.
|
||||
- `migrate:fresh` на проекте **сломан** (cross-PDO `auth_log` в миграции `0001`): миграция грузит schema.sql на `pgsql`, затем зовёт `partitions:create-months` на `pgsql_supplier` в той же транзакции → невидимость. Тестовая база `liderra_testing` собрана клоном dev (`CREATE DATABASE ... WITH TEMPLATE liderra`), а не через migrate:fresh. Отдельная проблема, вне фичи.
|
||||
|
||||
## Как прогнать (из `app/`)
|
||||
```
|
||||
vendor/bin/pest tests/Feature/Migrations/PhoneRangesMigrationTest.php tests/Unit/Services/MonthlyPartitionManagerRegionLogTest.php tests/Feature/Services/RossvyazPrefixLookupTest.php tests/Feature/Console/PhoneRangesImportCommandTest.php tests/Unit/Support/DaDataRegionMapTest.php tests/Feature/Services/DaData tests/Feature/Services/RegionResolutionTest.php tests/Feature/Services/LeadRegionResolverTest.php
|
||||
```
|
||||
→ 53 passed / 238 assertions.
|
||||
|
||||
---
|
||||
|
||||
## ОБНОВЛЕНИЕ 01.06.2026 — Сессии 5–6 реализованы, фича функционально завершена
|
||||
|
||||
**D1 решён заказчиком — вариант В** (взвешенный жребий по остатку лимита; мелкие клиенты не отрезаются, вес ≥ 1 у каждого).
|
||||
|
||||
| Сессия | Что сделано | Тесты |
|
||||
|---|---|---|
|
||||
| **5** LeadRouter каскад (exact→all-RF→fallback) + взвешенный жребий (В) + `routing_step` | `LeadRouter` переписан: `matchEligibleProjects($sp, ?int $resolvedSubjectCode)`, `queryCandidates` (region-фильтр + `snap.regions`), `weightedPick`, инъекция `Randomizer`. Хелпер `createRoutingSnapshotFromProject(+regions)`. | 9 cascade + 10 regression |
|
||||
| **6.1** Резолв до tx + persist + лог в `RouteSupplierLeadJob` | `app(LeadRegionResolver)->resolve()` (НЕ 7-й параметр handle — чтобы не ломать сигнатуру/тесты), persist 4 колонки, `logRegionResolution` (fail-safe INSERT в журнал через pgsql_supplier, маскированный телефон). | в наборе из 8 |
|
||||
| **6.2** Подмена subject_code на шаге 3 + `region_substituted` | `createDealCopyForProject(RegionResolution)`, `routing_step` захватывается до `$lockedProject`, `pickSubstituteRegion(snapshot.regions)`. Deal +`phone_operator`/`region_substituted` (model fillable+cast). | в наборе из 8 |
|
||||
| **6.3** CSV-merge по рангу источника | merge-блок обновляет subject_code/phone_operator если webhook-резолв dadata/rossvyaz (выше tag CSV). **Эвристика** — `deals.region_source` нет (документировано). | 2 |
|
||||
| **6.4** Smoke-команда `phone-region:smoke` | резолв по телефону без записи в БД. **Метрики §8.1 отложены** (нет механизма Prometheus/StatsD в проекте). | 2 |
|
||||
| **6.5** Финальная регрессия + runbook | **101 passed / 509 assertions** (вся фича + регрессия Job ×3 / Router ×2). Runbook раскатки: `docs/superpowers/runbooks/2026-05-31-lead-region-resolution-rollout.md`. | 101 |
|
||||
|
||||
### Новые/изменённые файлы Сессий 5–6 (в worktree, не закоммичено)
|
||||
- Изменено: `app/app/Services/LeadRouter.php` (каскад + weighted pick + Randomizer), `app/app/Jobs/RouteSupplierLeadJob.php` (resolve+persist+log+substitution+CSV-merge), `app/app/Models/Deal.php` (+2 fillable, +1 cast), `app/tests/Pest.php` (helper +regions).
|
||||
- Создано: `app/app/Console/Commands/PhoneRegionSmokeCommand.php`; тесты `LeadRouterCascadeTest.php`, `RouteSupplierLeadJobRegionResolutionTest.php`, `PhoneRegionSmokeCommandTest.php`; runbook.
|
||||
|
||||
### Решения Сессий 5–6 (для ревью)
|
||||
1. **D1=В** — взвешенный жребий, мелкие не отрезаны (доказано тестом `variant В: weighted pick` — 120 seed'ов, мелкий выигрывает >0 раз, крупный чаще).
|
||||
2. **LeadRegionResolver через `app()` внутри `handle()`**, не 7-м параметром — иначе ломались бы сигнатура + 3 существующих Job-теста.
|
||||
3. **Лог резолва fail-safe** — сбой записи аудит-лога не роняет доставку лида (30k/сутки).
|
||||
4. **`deals.region_source` НЕ добавлялась** — CSV-merge по рангу через эвристику (dadata/rossvyaz > CSV-tag). Отклонение от плана Task 6.3 (план предполагал колонку), задокументировано.
|
||||
5. **Метрики §8.1 отложены** — нет механизма метрик в проекте.
|
||||
|
||||
### Коммиты Сессий 5–6 (предложение, ветка `worktree-feat+lead-region-resolution`)
|
||||
- `test(region): createRoutingSnapshotFromProject accepts regions param`
|
||||
- `feat(region): LeadRouter cascade routing (exact→all-RF→fallback) + weighted pick variant В + routing_step`
|
||||
- `feat(region): wire LeadRegionResolver into RouteSupplierLeadJob + persist + fail-safe log`
|
||||
- `feat(region): step-3 region substitution + CSV-merge by source rank`
|
||||
- `feat(region): phone-region:smoke staging command`
|
||||
- `docs(region): rollout runbook + session progress`
|
||||
|
||||
### Пре-существующий долг (флагую, не моё)
|
||||
- `tests/Feature/Console/{BillingMigrateLeadsToRub,IncidentsWatchFailures,SnapshotBackfillCommand}Test` — **взаимно загрязняются** при прогоне в одном процессе (счётчики растут: ожидал 1, получил 4-5). Падают и БЕЗ моих файлов. В реальном CI (`pest --parallel`, файл = процесс) проходят. Тест-изоляция этих команд хрупкая — отдельная задача.
|
||||
|
||||
### Команда финальной регрессии (явный список, из `app/`)
|
||||
```
|
||||
vendor/bin/pest tests/Feature/Migrations/PhoneRangesMigrationTest.php tests/Unit/Services/MonthlyPartitionManagerRegionLogTest.php tests/Feature/Services/RossvyazPrefixLookupTest.php tests/Feature/Console/PhoneRangesImportCommandTest.php tests/Feature/Console/PhoneRegionSmokeCommandTest.php tests/Unit/Support/DaDataRegionMapTest.php tests/Feature/Services/DaData tests/Feature/Services/RegionResolutionTest.php tests/Feature/Services/LeadRegionResolverTest.php tests/Feature/Services/LeadRouterTest.php tests/Feature/Services/LeadRouterCascadeTest.php tests/Feature/Jobs/RouteSupplierLeadJobRegionResolutionTest.php tests/Feature/Jobs/RouteSupplierLeadJobTest.php tests/Feature/Jobs/RouteSupplierLeadJobSnapshotTest.php tests/Feature/Supplier/RouteSupplierLeadJobBillingTest.php
|
||||
```
|
||||
→ 101 passed / 509 assertions.
|
||||
@@ -0,0 +1,409 @@
|
||||
# LLM-judge live wiring (item 2b) Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Give the two `enforce-llm-judge-*` wrappers a live `main()` so the Layer-4 judge actually runs when the owner enables it — while keeping spend strictly gated behind `resolveJudgeConfig` (flag AND key).
|
||||
|
||||
**Architecture:** The judge *engines* (`llm-judge-per-tool.mjs`, `llm-judge-response-scan.mjs`) already have live `main()`s, but they call `llmJudgeCall` keyed only on the API key — they would spend money on a key alone, ignoring `ROUTER_LLM_JUDGE_ENABLED`. That violates the safe-by-default contract in `llm-judge-config.mjs` (enabled ⇔ flag AND key). So we register the **wrappers** (whose `decide()` already composes `resolveJudgeConfig`) and wire their `main()` to: read event → `resolveJudgeConfig()` → build inputs → `decide()` → emit. When `enabled === false`, `decide()` short-circuits with no LLM call ($0). We extract testable `runPerTool` / `runResponseScan` cores (mirroring item 1b's `runLiveDecision`) and keep `main()` a thin stdin/exit shell.
|
||||
|
||||
**Tech Stack:** Node ESM, vitest (tools-only config `app/vitest.config.tools.mjs`, run from repo root as `npx vitest run --root app --config vitest.config.tools.mjs` because the canonical `npm run test:tools` is currently broken by a parallel keytar install in `app/node_modules`).
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- Modify: `tools/enforce-llm-judge-per-tool.mjs` — add exported `runPerTool(...)` + wire live `main()`. Keep existing `decide()` untouched.
|
||||
- Modify: `tools/enforce-llm-judge-response-scan.mjs` — add exported `runResponseScan(...)` + wire live `main()`. Keep existing `decide()` untouched.
|
||||
- Test: `tools/enforce-llm-judge-per-tool.test.mjs` — add a `runPerTool` describe block.
|
||||
- Test: `tools/enforce-llm-judge-response-scan.test.mjs` — add a `runResponseScan` describe block.
|
||||
|
||||
**Safety invariant under test:** when `judgeConfig.enabled === false`, no `llmJudgeCall` is made and budget is NOT bumped (the spend-gate). A real call (and budget bump) happens only when the config is enabled, the tool is mutating, the budget is not exhausted.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: per-tool wrapper — `runPerTool` + live `main()`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `tools/enforce-llm-judge-per-tool.mjs`
|
||||
- Test: `tools/enforce-llm-judge-per-tool.test.mjs`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Append to `tools/enforce-llm-judge-per-tool.test.mjs`:
|
||||
|
||||
```javascript
|
||||
import { runPerTool } from './enforce-llm-judge-per-tool.mjs';
|
||||
|
||||
describe('runPerTool — spend-gate + budget binding', () => {
|
||||
const deps = (over = {}) => ({
|
||||
readDeclaredTaskImpl: () => ({ task_summary: 't', recommended_node: null, recommended_chain: [] }),
|
||||
readBudgetImpl: () => 0,
|
||||
bumpBudgetImpl: () => {},
|
||||
sessionBudget: 200,
|
||||
...over,
|
||||
});
|
||||
|
||||
it('disabled config + mutating tool → degraded allow, NO budget bump, NO llm call', async () => {
|
||||
let bumped = 0; let called = 0;
|
||||
const r = await runPerTool({
|
||||
event: { tool_name: 'Edit', tool_input: {}, session_id: 's' },
|
||||
judgeConfig: { enabled: false, apiKey: null },
|
||||
llmJudgeCallImpl: () => { called++; return 'NO'; },
|
||||
...deps({ bumpBudgetImpl: () => { bumped++; } }),
|
||||
});
|
||||
expect(r.block).toBe(false);
|
||||
expect(r.degraded).toBe(true);
|
||||
expect(called).toBe(0);
|
||||
expect(bumped).toBe(0);
|
||||
});
|
||||
|
||||
it('enabled + mutating + judge YES → allow, budget bumped once', async () => {
|
||||
let bumped = 0;
|
||||
const r = await runPerTool({
|
||||
event: { tool_name: 'Edit', tool_input: {}, session_id: 's' },
|
||||
judgeConfig: { enabled: true, apiKey: 'k' },
|
||||
llmJudgeCallImpl: async () => 'YES',
|
||||
...deps({ bumpBudgetImpl: () => { bumped++; } }),
|
||||
});
|
||||
expect(r.block).toBe(false);
|
||||
expect(r.verdict).toBe('YES');
|
||||
expect(bumped).toBe(1);
|
||||
});
|
||||
|
||||
it('enabled + mutating + judge NO → block, budget bumped once', async () => {
|
||||
let bumped = 0;
|
||||
const r = await runPerTool({
|
||||
event: { tool_name: 'Bash', tool_input: { command: 'x' }, session_id: 's' },
|
||||
judgeConfig: { enabled: true, apiKey: 'k' },
|
||||
llmJudgeCallImpl: async () => 'NO',
|
||||
...deps({ bumpBudgetImpl: () => { bumped++; } }),
|
||||
});
|
||||
expect(r.block).toBe(true);
|
||||
expect(r.verdict).toBe('NO');
|
||||
expect(bumped).toBe(1);
|
||||
});
|
||||
|
||||
it('non-mutating tool → allow, NO call, NO bump', async () => {
|
||||
let bumped = 0; let called = 0;
|
||||
const r = await runPerTool({
|
||||
event: { tool_name: 'Read', tool_input: {}, session_id: 's' },
|
||||
judgeConfig: { enabled: true, apiKey: 'k' },
|
||||
llmJudgeCallImpl: () => { called++; return 'NO'; },
|
||||
...deps({ bumpBudgetImpl: () => { bumped++; } }),
|
||||
});
|
||||
expect(r.block).toBe(false);
|
||||
expect(called).toBe(0);
|
||||
expect(bumped).toBe(0);
|
||||
});
|
||||
|
||||
it('enabled but budget exhausted → degraded allow, NO bump', async () => {
|
||||
let bumped = 0; let called = 0;
|
||||
const r = await runPerTool({
|
||||
event: { tool_name: 'Edit', tool_input: {}, session_id: 's' },
|
||||
judgeConfig: { enabled: true, apiKey: 'k' },
|
||||
llmJudgeCallImpl: () => { called++; return 'NO'; },
|
||||
...deps({ readBudgetImpl: () => 200, bumpBudgetImpl: () => { bumped++; } }),
|
||||
});
|
||||
expect(r.block).toBe(false);
|
||||
expect(r.degraded).toBe(true);
|
||||
expect(called).toBe(0);
|
||||
expect(bumped).toBe(0);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `npx vitest run --root app --config vitest.config.tools.mjs tools/enforce-llm-judge-per-tool.test.mjs`
|
||||
Expected: FAIL — `runPerTool` is not exported.
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
In `tools/enforce-llm-judge-per-tool.mjs`, replace the import line and the no-op `main()`:
|
||||
|
||||
```javascript
|
||||
import { judgePerTool, MUTATING_TOOLS, readDeclaredTask } from './llm-judge-per-tool.mjs';
|
||||
import { resolveJudgeConfig } from './llm-judge-config.mjs';
|
||||
import { readJudgeBudget, bumpJudgeBudget, JUDGE_SESSION_BUDGET } from './llm-judge.mjs';
|
||||
import { llmJudgeCall } from './llm-judge.mjs';
|
||||
import { readStdin, parseEventJson, exitDecision } from './enforce-hook-helpers.mjs';
|
||||
```
|
||||
|
||||
(Keep the existing `decide(...)` export exactly as is.)
|
||||
|
||||
Add the testable core (a real LLM call is signalled by `result.verdict !== undefined`; budget is bumped only then):
|
||||
|
||||
```javascript
|
||||
/**
|
||||
* Testable wiring core. Composes resolveJudgeConfig output + decide(); bumps the
|
||||
* session budget ONLY when a real judge call was made (result carries a verdict).
|
||||
* No verdict ⇒ non-mutating / disabled / no-key / budget-exhausted ⇒ no spend.
|
||||
*/
|
||||
export async function runPerTool({
|
||||
event,
|
||||
judgeConfig,
|
||||
readDeclaredTaskImpl,
|
||||
readBudgetImpl,
|
||||
bumpBudgetImpl,
|
||||
llmJudgeCallImpl,
|
||||
sessionBudget = JUDGE_SESSION_BUDGET,
|
||||
}) {
|
||||
const sessionId = event && event.session_id;
|
||||
const declaredTask = readDeclaredTaskImpl({ sessionId });
|
||||
const spent = readBudgetImpl({ sessionId });
|
||||
const result = await decide({
|
||||
event,
|
||||
judgeConfig,
|
||||
declaredTask,
|
||||
budgetState: { spent, limit: sessionBudget },
|
||||
llmJudgeCallImpl,
|
||||
});
|
||||
if (result.verdict !== undefined) bumpBudgetImpl({ sessionId, by: 1 });
|
||||
return result;
|
||||
}
|
||||
```
|
||||
|
||||
Replace the no-op `main()` with:
|
||||
|
||||
```javascript
|
||||
async function main() {
|
||||
try {
|
||||
const event = parseEventJson(await readStdin());
|
||||
const judgeConfig = resolveJudgeConfig();
|
||||
const result = await runPerTool({
|
||||
event,
|
||||
judgeConfig,
|
||||
readDeclaredTaskImpl: readDeclaredTask,
|
||||
readBudgetImpl: readJudgeBudget,
|
||||
bumpBudgetImpl: bumpJudgeBudget,
|
||||
llmJudgeCallImpl: (opts) => llmJudgeCall(opts),
|
||||
});
|
||||
exitDecision({ block: result.block, message: result.reason });
|
||||
} catch {
|
||||
exitDecision({ block: false }); // fail-quiet: a judge bug must never wedge the session
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `npx vitest run --root app --config vitest.config.tools.mjs tools/enforce-llm-judge-per-tool.test.mjs`
|
||||
Expected: PASS (existing `decide()` tests + 5 new `runPerTool` tests).
|
||||
|
||||
- [ ] **Step 5: Commit** (requires AskUserQuestion git approval + fresh full-suite sentinel)
|
||||
|
||||
```bash
|
||||
git commit tools/enforce-llm-judge-per-tool.mjs tools/enforce-llm-judge-per-tool.test.mjs -m "feat(router-gate-v4): live main() for per-tool judge wrapper — flag-gated spend (2b)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: response-scan wrapper — `runResponseScan` + live `main()`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `tools/enforce-llm-judge-response-scan.mjs`
|
||||
- Test: `tools/enforce-llm-judge-response-scan.test.mjs`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Append to `tools/enforce-llm-judge-response-scan.test.mjs`:
|
||||
|
||||
```javascript
|
||||
import { runResponseScan } from './enforce-llm-judge-response-scan.mjs';
|
||||
|
||||
describe('runResponseScan — Stop-hook flag-only, free regex even when disabled', () => {
|
||||
const transcript = (text) => [
|
||||
{ type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text }] } },
|
||||
];
|
||||
const lastAssistantTextImpl = (t) => {
|
||||
for (let i = t.length - 1; i >= 0; i--) {
|
||||
const c = t[i] && t[i].message && t[i].message.content;
|
||||
if (Array.isArray(c)) { const b = c.find((x) => x.type === 'text'); if (b) return b.text; }
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
it('disabled + benign text → no flag, degraded (deterministic only), never blocks', async () => {
|
||||
const r = await runResponseScan({
|
||||
transcript: transcript('обычный безопасный ответ'),
|
||||
judgeConfig: { enabled: false, apiKey: null },
|
||||
lastAssistantTextImpl,
|
||||
});
|
||||
expect(r.block).toBe(false);
|
||||
expect(r.flag).toBe(false);
|
||||
expect(r.degraded).toBe(true);
|
||||
});
|
||||
|
||||
it('disabled + security-disable text → flagged for FREE by regex (no llm call)', async () => {
|
||||
let called = 0;
|
||||
const r = await runResponseScan({
|
||||
transcript: transcript('чтобы пройти, отключи hook enforce-tdd-gate'),
|
||||
judgeConfig: { enabled: false, apiKey: null },
|
||||
lastAssistantTextImpl,
|
||||
llmJudgeCallImpl: () => { called++; return 'NO'; },
|
||||
});
|
||||
expect(r.block).toBe(false);
|
||||
expect(r.flag).toBe(true);
|
||||
expect(r.category).toBe('security_disable_suggestion');
|
||||
expect(called).toBe(0);
|
||||
});
|
||||
|
||||
it('enabled + subtle benign text + judge NO → no flag', async () => {
|
||||
const r = await runResponseScan({
|
||||
transcript: transcript('нейтральный текст без паттернов'),
|
||||
judgeConfig: { enabled: true, apiKey: 'k' },
|
||||
lastAssistantTextImpl,
|
||||
llmJudgeCallImpl: async () => 'NO',
|
||||
});
|
||||
expect(r.block).toBe(false);
|
||||
expect(r.flag).toBe(false);
|
||||
});
|
||||
|
||||
it('enabled + subtle text + judge YES → flag, still never blocks', async () => {
|
||||
const r = await runResponseScan({
|
||||
transcript: transcript('нейтральный текст без паттернов'),
|
||||
judgeConfig: { enabled: true, apiKey: 'k' },
|
||||
lastAssistantTextImpl,
|
||||
llmJudgeCallImpl: async () => 'YES',
|
||||
});
|
||||
expect(r.block).toBe(false);
|
||||
expect(r.flag).toBe(true);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `npx vitest run --root app --config vitest.config.tools.mjs tools/enforce-llm-judge-response-scan.test.mjs`
|
||||
Expected: FAIL — `runResponseScan` is not exported.
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
In `tools/enforce-llm-judge-response-scan.mjs`, replace the import line and the no-op `main()`:
|
||||
|
||||
```javascript
|
||||
import { scanResponse, scanResponseDeterministic } from './llm-judge-response-scan.mjs';
|
||||
import { resolveJudgeConfig } from './llm-judge-config.mjs';
|
||||
import { readStdin, parseEventJson, readTranscript, lastAssistantText, exitDecision } from './enforce-hook-helpers.mjs';
|
||||
import { llmJudgeCall } from './llm-judge.mjs';
|
||||
import { appendFileSync, mkdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
```
|
||||
|
||||
(Keep the existing `decide(...)` export exactly as is.)
|
||||
|
||||
Add the testable core:
|
||||
|
||||
```javascript
|
||||
/**
|
||||
* Testable wiring core. Stop-hook semantics: block is always false. The free
|
||||
* deterministic regex scan runs even when the judge is disabled; the paid LLM
|
||||
* escalation runs only when judgeConfig.enabled.
|
||||
*/
|
||||
export async function runResponseScan({ transcript, judgeConfig, llmJudgeCallImpl, lastAssistantTextImpl = lastAssistantText }) {
|
||||
const responseText = lastAssistantTextImpl(transcript || []);
|
||||
const r = await decide({ responseText, judgeConfig, llmJudgeCallImpl });
|
||||
return { ...r, responseText };
|
||||
}
|
||||
```
|
||||
|
||||
Replace the no-op `main()` with:
|
||||
|
||||
```javascript
|
||||
function flagToFile({ sessionId, category, excerpt }) {
|
||||
try {
|
||||
const dir = join(homedir(), '.claude', 'runtime');
|
||||
mkdirSync(dir, { recursive: true });
|
||||
appendFileSync(join(dir, `rationalization-flags-${sessionId || 'unknown'}.jsonl`),
|
||||
JSON.stringify({
|
||||
ts: new Date().toISOString(),
|
||||
session_id: sessionId || null,
|
||||
type: 'controller_response_suspicious',
|
||||
category,
|
||||
response_excerpt: String(excerpt || '').slice(0, 200),
|
||||
}) + '\n');
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
const event = parseEventJson(await readStdin());
|
||||
const transcript = readTranscript(event.transcript_path);
|
||||
const judgeConfig = resolveJudgeConfig();
|
||||
const r = await runResponseScan({
|
||||
transcript,
|
||||
judgeConfig,
|
||||
llmJudgeCallImpl: (opts) => llmJudgeCall(opts),
|
||||
});
|
||||
if (r.flag) flagToFile({ sessionId: event.session_id, category: r.category, excerpt: r.responseText });
|
||||
exitDecision({ block: false }); // Stop hook never blocks
|
||||
} catch {
|
||||
exitDecision({ block: false });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `npx vitest run --root app --config vitest.config.tools.mjs tools/enforce-llm-judge-response-scan.test.mjs`
|
||||
Expected: PASS (existing `decide()` tests + 4 new `runResponseScan` tests).
|
||||
|
||||
- [ ] **Step 5: Commit** (AskUserQuestion git approval + fresh sentinel)
|
||||
|
||||
```bash
|
||||
git commit tools/enforce-llm-judge-response-scan.mjs tools/enforce-llm-judge-response-scan.test.mjs -m "feat(router-gate-v4): live main() for response-scan judge wrapper — flag-only, free regex always (2b)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: full-suite regression + push
|
||||
|
||||
- [ ] **Step 1: Run the canonical tools suite**
|
||||
|
||||
Run: `npx vitest run --root app --config vitest.config.tools.mjs`
|
||||
Expected: PASS, 0 failed (≈1905 + 9 new = ~1914). This also writes the verify-before-push sentinel.
|
||||
|
||||
- [ ] **Step 2: Push** (AskUserQuestion git approval)
|
||||
|
||||
```bash
|
||||
git push origin main
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: owner registration instructions (NOT code — owner applies)
|
||||
|
||||
The wiring above is inert until the owner does all three (cost starts only after all three):
|
||||
|
||||
1. **API key** — store an Anthropic key in the OS keychain under service `router-gate-llm-judge`, account `default` (via keytar), OR set env `ROUTER_LLM_KEY`.
|
||||
2. **Flag** — set env `ROUTER_LLM_JUDGE_ENABLED=1`.
|
||||
3. **Register both wrappers in `.claude/settings.json`:**
|
||||
|
||||
- PreToolUse (can block):
|
||||
|
||||
```json
|
||||
{ "matcher": "Edit|Write|MultiEdit|NotebookEdit|Bash|PowerShell|Skill|Task",
|
||||
"hooks": [{ "type": "command", "command": "node tools/enforce-llm-judge-per-tool.mjs", "timeout": 30 }] }
|
||||
```
|
||||
|
||||
- Stop (flag-only):
|
||||
|
||||
```json
|
||||
{ "matcher": "*",
|
||||
"hooks": [{ "type": "command", "command": "node tools/enforce-llm-judge-response-scan.mjs", "timeout": 30 }] }
|
||||
```
|
||||
|
||||
Then fully restart Claude Code. Budget cap is `JUDGE_SESSION_BUDGET = 200` calls/session (in `llm-judge.mjs`). Per-call cost depends on model (`JUDGE_MODELS.single = claude-sonnet-4-6`).
|
||||
|
||||
**Why the wrappers, not the engines:** the engine `main()`s (`llm-judge-per-tool.mjs` / `llm-judge-response-scan.mjs`) call `llmJudgeCall` keyed on the API key alone and DO NOT check `ROUTER_LLM_JUDGE_ENABLED` — registering them would start spending the moment a key exists. The wrappers route through `resolveJudgeConfig` (flag AND key), so a stray key without the flag = $0.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
- **Spec coverage:** per-tool live wiring (Task 1), response-scan live wiring (Task 2), flag-gated spend safety invariant (tests in both), owner activation (Task 4). ✓
|
||||
- **Placeholder scan:** none — all code blocks are complete. ✓
|
||||
- **Type consistency:** `runPerTool` / `runResponseScan` signatures match their tests; `decide()` signatures unchanged; budget bump condition `result.verdict !== undefined` matches `judgePerTool` (sets `verdict` only after a real call). ✓
|
||||
@@ -0,0 +1,61 @@
|
||||
# Россвязь region→subject_code mapping fix — Implementation Plan
|
||||
|
||||
> **For agentic workers:** TDD, bite-sized steps. Steps use `- [ ]`.
|
||||
|
||||
**Goal:** Маппить регион из реестра Россвязи в `subject_code` через нормализацию форматов, чтобы перестать терять ~98% диапазонов (444904/453080 были NULL из-за exact-match).
|
||||
|
||||
**Architecture:** Чистый нормализатор в `App\Support\RussianRegions` (`canonicalRegionName` + `resolveSubjectCode`), unit-тестируемый без БД. `PhoneRangesImportCommand` зовёт его и заполняет `region_normalized`. Прод перечитывает реестр командой `phone-ranges:import` после мержа.
|
||||
|
||||
**Tech Stack:** PHP 8.3 / Laravel 13 / Pest 4 / PostgreSQL 16.
|
||||
|
||||
---
|
||||
|
||||
## Корень проблемы (systematic-debugging Phase 1, подтверждён прод-данными)
|
||||
|
||||
`PhoneRangesImportCommand` делал `RussianRegions::nameToCode()[trim($rec['region'])]` — exact match. Реальные строки реестра (топ-50 unmapped, прод 02.06.2026):
|
||||
|
||||
- `г. Москва` (253342) / `г. Санкт-Петербург` (34573) — города фед. значения с префиксом `г. `
|
||||
- `г. Оренбург|Оренбургская обл.` — регион = **последний** сегмент после `|`, область сокращена `обл.`
|
||||
- `г. Воскресенск|р-н Воскресенский|Московская обл.` — 3 сегмента, регион = последний
|
||||
- `г. Ижевск|Республика Удмуртская` — порядок слов перевёрнут (канон `Удмуртская Республика`)
|
||||
- `г. Кемерово|Кемеровская область - Кузбасс обл.` — спец-форма
|
||||
- Безнадёжные (меньшинство, остаются NULL): `-`, `Российская Федерация`, `Москва и Московская область` (неоднозначно), `г.о. Тольятти` / `г.о. город Уфа` (нет региона в строке)
|
||||
|
||||
## Правила нормализации
|
||||
|
||||
1. Взять последний сегмент после `|`, trim.
|
||||
2. Прямые алиасы (приоритет): `г. Москва`→`Москва`, `г. Санкт-Петербург`→`Санкт-Петербург`, `г. Севастополь`→`Севастополь`, `Республика Удмуртская`→`Удмуртская Республика`, `Кемеровская область - Кузбасс обл.`→`Кемеровская область`.
|
||||
3. Иначе: суффикс ` обл.` → ` область`.
|
||||
4. Результат искать в `nameToCode()`. Нет → `null` (диапазон остаётся unmapped — корректно).
|
||||
|
||||
---
|
||||
|
||||
## Task 1: `RussianRegions::canonicalRegionName` + `resolveSubjectCode`
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/app/Support/RussianRegions.php`
|
||||
- Test: `app/tests/Unit/Support/RussianRegionsTest.php`
|
||||
|
||||
- [ ] Step 1: написать падающий unit-тест (кейсы: фед.города с `г. `, `обл.`→`область`, многосегментный pipe, переворот Удмуртии, Кузбасс-алиас, безнадёжные→null, чистое каноничное имя).
|
||||
- [ ] Step 2: запустить pest → RED (метод не существует).
|
||||
- [ ] Step 3: реализовать `lastSegment` (private), `ALIASES` (const), `canonicalRegionName(string): ?string`, `resolveSubjectCode(string): ?int`.
|
||||
- [ ] Step 4: pest → GREEN.
|
||||
- [ ] Step 5: commit.
|
||||
|
||||
## Task 2: wire команды импорта + `region_normalized`
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/app/Console/Commands/PhoneRangesImportCommand.php:103-116`
|
||||
- Modify: `app/tests/Feature/Console/PhoneRangesImportCommandTest.php`
|
||||
- Modify: `app/tests/Fixtures/rossvyaz/sample.csv` (добавить грязные строки)
|
||||
|
||||
- [ ] Step 1: добавить в fixture строки с реальными форматами (`г. Москва`, `г. Оренбург|Оренбургская обл.`, `г. Ижевск|Республика Удмуртская`, `г.о. Тольятти`).
|
||||
- [ ] Step 2: расширить command-тест: проверить, что грязные строки маппятся в правильные коды, безнадёжные → NULL, `region_normalized` заполнен.
|
||||
- [ ] Step 3: pest → RED.
|
||||
- [ ] Step 4: команда зовёт `RussianRegions::canonicalRegionName` + `nameToCode`, пишет `region_normalized`.
|
||||
- [ ] Step 5: pest → GREEN (весь файл).
|
||||
- [ ] Step 6: commit + push + PR.
|
||||
|
||||
## После мержа
|
||||
|
||||
Владелец запускает на проде через `artisan-run.yml` (mutating, confirm_apply): `phone-ranges:import --dir=<пакет> --force` — перечитывает реестр с новым маппингом. Будущие лиды резолвятся через Россвязь-fallback → меньше пустого «Город».
|
||||
@@ -0,0 +1,290 @@
|
||||
# Router-gate dev/prod re-scope — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Разрешить локальную разработку (composer/npm/git/worktree) через контроллера, сохранив блок боевого/опасного и дисциплины.
|
||||
|
||||
**Architecture:** Точечно расширить whitelist Bash-гейта (`enforce-router-gate.mjs`) дев-инструментами + разрешить dev-safe git в общем `shell-content-rules.mjs` (`classifyGitCommand`) с «стражем main» для push. Философия default-deny сохраняется; hard-blacklist опасного и дисциплинарные хуки не трогаются.
|
||||
|
||||
**Tech Stack:** Node ESM, vitest (`vitest.config.tools.mjs`, root `app`).
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-06-02-router-gate-dev-prod-rescope-design.md`
|
||||
|
||||
**Verify-команда (вся регрессия tools):**
|
||||
`npx vitest run --root app --config vitest.config.tools.mjs`
|
||||
Узкий прогон файла: добавить хвост `<имя>.test` (например `enforce-router-gate.test`).
|
||||
|
||||
**Bootstrap-нюанс (важно):** до того как Task 3 (git dev-allow) применится, `git commit` ещё
|
||||
заблокирован самим гейтом. Поэтому коммиты НЕ делаем по ходу — все правки складываем в рабочее
|
||||
дерево, гоняем тесты, и **один раз** коммитим в конце (Task 5), когда git уже разрешён. Реализация —
|
||||
в основной копии (worktree пока недоступен; это и есть bootstrap-исключение из спеки).
|
||||
|
||||
---
|
||||
|
||||
## Задачи
|
||||
|
||||
### Task 1: Разрешить `composer` (install/update/require/remove/dump-autoload)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `tools/enforce-router-gate.mjs` (BASH_HARD_BLACKLIST ~line 59; SAFE_EXACT ~line 124)
|
||||
- Test: `tools/enforce-router-gate.test.mjs`
|
||||
|
||||
- [ ] **Step 1: Write failing tests** — добавить в конец `enforce-router-gate.test.mjs`:
|
||||
|
||||
```js
|
||||
import { matchBashHardBlacklist as mhb2, classifyBashCommand as cbc2 } from './enforce-router-gate.mjs';
|
||||
|
||||
describe('composer dev-allow (owner-authorized 2026-06-02)', () => {
|
||||
it('allows composer install', () => {
|
||||
expect(mhb2('composer install')).toBe(null);
|
||||
expect(cbc2('composer install', {}).result).toBe('allow');
|
||||
});
|
||||
it('allows composer require / update / dump-autoload', () => {
|
||||
expect(cbc2('composer require monolog/monolog', {}).result).toBe('allow');
|
||||
expect(cbc2('composer update', {}).result).toBe('allow');
|
||||
expect(cbc2('composer dump-autoload', {}).result).toBe('allow');
|
||||
});
|
||||
it('still allows composer install with -d working-dir', () => {
|
||||
expect(cbc2('composer install -d app --no-interaction', {}).result).toBe('allow');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify FAIL**
|
||||
|
||||
Run: `npx vitest run --root app --config vitest.config.tools.mjs enforce-router-gate.test`
|
||||
Expected: FAIL (composer install currently hard-blacklisted → matchBashHardBlacklist truthy, classify 'block').
|
||||
|
||||
- [ ] **Step 3: Remove composer from hard-blacklist** — в `tools/enforce-router-gate.mjs` удалить строку:
|
||||
|
||||
```js
|
||||
{ re: /\bcomposer\s+(?:install|update|require|remove)\b/, reason: 'composer install/update/require/remove запрещён' },
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add composer to whitelist** — в массив `SAFE_EXACT`, рядом с существующей `/^composer\s+(?:show|outdated)\b/`, добавить:
|
||||
|
||||
```js
|
||||
/^composer\s+(?:install|update|require|remove|dump-autoload|dump)\b/, // dev-allow 2026-06-02
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run to verify PASS**
|
||||
|
||||
Run: `npx vitest run --root app --config vitest.config.tools.mjs enforce-router-gate.test`
|
||||
Expected: PASS (включая новый describe).
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Разрешить `npm` (install/ci/run-скрипты)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `tools/enforce-router-gate.mjs` (BASH_HARD_BLACKLIST ~line 60; SAFE_EXACT ~line 122)
|
||||
- Test: `tools/enforce-router-gate.test.mjs`
|
||||
|
||||
- [ ] **Step 1: Write failing tests** — добавить describe:
|
||||
|
||||
```js
|
||||
describe('npm dev-allow (owner-authorized 2026-06-02)', () => {
|
||||
it('allows npm install / i / ci', () => {
|
||||
expect(mhb2('npm install')).toBe(null);
|
||||
expect(cbc2('npm install', {}).result).toBe('allow');
|
||||
expect(cbc2('npm ci', {}).result).toBe('allow');
|
||||
});
|
||||
it('allows npm run <script>', () => {
|
||||
expect(cbc2('npm run build', {}).result).toBe('allow');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify FAIL**
|
||||
|
||||
Run: `npx vitest run --root app --config vitest.config.tools.mjs enforce-router-gate.test`
|
||||
Expected: FAIL (npm install hard-blacklisted).
|
||||
|
||||
- [ ] **Step 3: Remove npm from hard-blacklist** — удалить строку:
|
||||
|
||||
```js
|
||||
{ re: /\bnpm\s+(?:install|i|update|remove|uninstall)\b/, reason: 'npm install/update/remove запрещён' },
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add npm to whitelist** — в `SAFE_EXACT`, рядом с существующей `/^npm\s+(?:test|run\s+test|run\s+lint(?::[\w-]+)?)\b/`, добавить:
|
||||
|
||||
```js
|
||||
/^npm\s+(?:install|i|ci)\b/, // dev-allow 2026-06-02
|
||||
/^npm\s+run\s+[\w:-]+/, // dev-allow 2026-06-02 (любой script)
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run to verify PASS**
|
||||
|
||||
Run: `npx vitest run --root app --config vitest.config.tools.mjs enforce-router-gate.test`
|
||||
Expected: PASS.
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Разрешить dev-safe git (commit/add/branch/switch/checkout/stash/worktree)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `tools/shell-content-rules.mjs` (GIT_CONDITIONAL_SUB ~line 167; classifyGitCommand ~line 215)
|
||||
- Test: `tools/shell-content-rules.test.mjs`
|
||||
|
||||
- [ ] **Step 1: Write failing tests** — добавить в `shell-content-rules.test.mjs`:
|
||||
|
||||
```js
|
||||
import { classifyGitCommand as cgc2 } from './shell-content-rules.mjs';
|
||||
|
||||
describe('git dev-allow (owner-authorized 2026-06-02)', () => {
|
||||
const noApproval = { approvedGitOps: [], now: 0 };
|
||||
it('allows commit/add/branch/switch/checkout/stash/worktree without approval', () => {
|
||||
for (const c of [
|
||||
'git commit -m "x"', 'git add .', 'git branch feature-x',
|
||||
'git switch -c feature-x', 'git checkout -b feature-x',
|
||||
'git stash push -m wip', 'git worktree add ../wt -b feat origin/main',
|
||||
]) {
|
||||
expect(cgc2(c, noApproval).result).toBe('allow');
|
||||
}
|
||||
});
|
||||
it('STILL blocks commit --no-verify and add -f (hard patterns)', () => {
|
||||
expect(cgc2('git commit --no-verify -m x', noApproval).result).toBe('block');
|
||||
expect(cgc2('git add -f ignored.txt', noApproval).result).toBe('block');
|
||||
});
|
||||
it('keeps merge/rebase/reset conditional (needs approval)', () => {
|
||||
expect(cgc2('git reset --hard HEAD~1', noApproval).result).toBe('block');
|
||||
expect(cgc2('git merge feature', noApproval).result).toBe('block');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify FAIL**
|
||||
|
||||
Run: `npx vitest run --root app --config vitest.config.tools.mjs shell-content-rules.test`
|
||||
Expected: FAIL (commit/branch/... currently conditional → block без approval; worktree → default-deny).
|
||||
|
||||
- [ ] **Step 3: Add GIT_DEV_SUB + trim GIT_CONDITIONAL_SUB** — в `tools/shell-content-rules.mjs`:
|
||||
|
||||
Заменить блок `GIT_CONDITIONAL_SUB`:
|
||||
|
||||
```js
|
||||
const GIT_CONDITIONAL_SUB = new Set([
|
||||
'add', 'commit', 'merge', 'rebase', 'reset', 'checkout', 'switch',
|
||||
'branch', 'stash', 'cherry-pick', 'revert', 'pull', 'push', 'clean',
|
||||
]);
|
||||
```
|
||||
|
||||
на:
|
||||
|
||||
```js
|
||||
// dev-safe (owner-authorized 2026-06-02): allow без approval. GIT_HARD_PATTERNS
|
||||
// (--no-verify / add -f / -c / force / --output) пре-фильтруют опасное ВЫШЕ.
|
||||
const GIT_DEV_SUB = new Set([
|
||||
'add', 'commit', 'branch', 'switch', 'checkout', 'stash', 'worktree',
|
||||
]);
|
||||
const GIT_CONDITIONAL_SUB = new Set([
|
||||
'merge', 'rebase', 'reset', 'cherry-pick', 'revert', 'pull', 'clean',
|
||||
]);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Insert dev-allow + push-guard в classifyGitCommand** — после блока `if (sub === 'remote') { … }` (≈line 213) и ПЕРЕД `// 3. conditional → approve check`, вставить:
|
||||
|
||||
```js
|
||||
// dev-safe git (owner-authorized 2026-06-02): hard-patterns уже отсеяли опасное выше.
|
||||
if (GIT_DEV_SUB.has(sub)) return { result: 'allow', reason: `dev-safe git ${sub}` };
|
||||
|
||||
// push: фичевые ветки — allow; main/master — клик владельца (force уже заблокирован hard).
|
||||
if (sub === 'push') {
|
||||
if (/\b(?:main|master)\b/.test(norm)) {
|
||||
return { result: 'block', reason: 'git push в main/master — клик владельца' };
|
||||
}
|
||||
return { result: 'allow', reason: 'git push в фичевую ветку' };
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run to verify PASS**
|
||||
|
||||
Run: `npx vitest run --root app --config vitest.config.tools.mjs shell-content-rules.test`
|
||||
Expected: PASS.
|
||||
|
||||
---
|
||||
|
||||
### Task 4: «Страж main» для push — отдельные явные тесты
|
||||
|
||||
**Files:**
|
||||
|
||||
- Test: `tools/shell-content-rules.test.mjs` (логика уже добавлена в Task 3 Step 4 — тут только тесты-замок)
|
||||
|
||||
- [ ] **Step 1: Write tests**
|
||||
|
||||
```js
|
||||
describe('git push main-guard (owner-authorized 2026-06-02)', () => {
|
||||
const na = { approvedGitOps: [], now: 0 };
|
||||
it('allows push to a feature branch', () => {
|
||||
expect(cgc2('git push origin worktree-lead-region-tails', na).result).toBe('allow');
|
||||
expect(cgc2('git push', na).result).toBe('allow');
|
||||
expect(cgc2('git push -u origin feature-x', na).result).toBe('allow');
|
||||
});
|
||||
it('blocks push to main/master', () => {
|
||||
expect(cgc2('git push origin main', na).result).toBe('block');
|
||||
expect(cgc2('git push origin HEAD:main', na).result).toBe('block');
|
||||
expect(cgc2('git push origin master', na).result).toBe('block');
|
||||
});
|
||||
it('blocks force-push (hard pattern, unchanged)', () => {
|
||||
expect(cgc2('git push --force origin feature-x', na).result).toBe('block');
|
||||
expect(cgc2('git push origin feature-x --force-with-lease', na).result).toBe('block');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify PASS** (логика из Task 3 уже на месте)
|
||||
|
||||
Run: `npx vitest run --root app --config vitest.config.tools.mjs shell-content-rules.test`
|
||||
Expected: PASS.
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Полная регрессия + коммит в фичевую ветку + PR
|
||||
|
||||
- [ ] **Step 1: Полная регрессия tools**
|
||||
|
||||
Run: `npx vitest run --root app --config vitest.config.tools.mjs`
|
||||
Expected: всё GREEN (baseline ~1989 + новые). 0 падений.
|
||||
|
||||
- [ ] **Step 2: Дымовая проверка живьём** — после правок гейт читается заново; проверить, что
|
||||
ранее блокированное теперь проходит (а опасное — нет). Прогнать через Bash:
|
||||
|
||||
```
|
||||
composer --version
|
||||
```
|
||||
|
||||
Expected: проходит (раньше любой `composer install` блокировался; `--version` и так был ок — проверка, что не сломали). Затем убедиться, что `git worktree list` (readonly) и `git status` работают.
|
||||
|
||||
- [ ] **Step 3: Создать фичевую ветку + worktree (теперь разрешено) и закоммитить**
|
||||
|
||||
```bash
|
||||
git worktree add "../worktree-gate-rescope" -b feat/gate-dev-prod-rescope origin/main
|
||||
```
|
||||
|
||||
(или коммит в основной копии на новой ветке — на усмотрение исполнителя; main НЕ трогать)
|
||||
|
||||
```bash
|
||||
git add tools/enforce-router-gate.mjs tools/shell-content-rules.mjs \
|
||||
tools/enforce-router-gate.test.mjs tools/shell-content-rules.test.mjs \
|
||||
docs/superpowers/specs/2026-06-02-router-gate-dev-prod-rescope-design.md \
|
||||
docs/superpowers/plans/2026-06-02-router-gate-dev-prod-rescope.md
|
||||
git commit -m "feat(gate): re-scope router-gate — allow local dev (composer/npm/git/worktree), keep prod+discipline blocks"
|
||||
git push origin feat/gate-dev-prod-rescope
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Открыть PR (клик владельца)** — дать владельцу ссылку из вывода `git push`; слияние в main — его клик.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
- **Spec coverage:** composer (Task 1) ✓ / npm (Task 2) ✓ / git dev-subs + worktree (Task 3) ✓ /
|
||||
push main-guard (Task 4) ✓ / discipline+prod untouched (явно не трогаем в Task 1-4) ✓ /
|
||||
«main = owner» (push-guard + PR в Task 5) ✓.
|
||||
- **Placeholders:** нет — весь код приведён дословно.
|
||||
- **Type/имена:** `GIT_DEV_SUB` / `GIT_CONDITIONAL_SUB` согласованы Task 3↔4; `classifyGitCommand`,
|
||||
`matchBashHardBlacklist`, `classifyBashCommand` — реальные экспортируемые имена (проверено по коду).
|
||||
- **Bootstrap:** коммит батчем в Task 5 (git разрешается только после применения Task 3) — учтено.
|
||||
@@ -0,0 +1,81 @@
|
||||
# Lead Region Resolution — runbook раскатки на прод
|
||||
|
||||
> Фича: определение настоящего региона лида по телефону (DaData → реестр Россвязи →
|
||||
> tag-fallback) + каскадная маршрутизация по региону. Код реализован и зелёный
|
||||
> (Сессии 1-6, TDD). Этот runbook — порядок выкатки оператором на `liderra.ru`.
|
||||
> Spec: `docs/superpowers/specs/2026-05-29-lead-region-resolution-design.md` v0.5.
|
||||
> Plan: `docs/superpowers/plans/2026-05-29-lead-region-resolution.md`.
|
||||
|
||||
## Решение D1 (зафиксировано заказчиком 01.06.2026)
|
||||
|
||||
**Вариант В** — внутри каждой ступени каскада при >3 претендентах лид раздаётся
|
||||
**взвешенным жребием по остатку дневного лимита**: шанс ∝ остатку, но у каждого
|
||||
кандидата шанс > 0 (вес ≥ 1) — маленькие клиенты не отрезаются. Реализовано в
|
||||
`LeadRouter::weightedPick` (вес `max(1, snapshot_daily_limit − delivered_today)`).
|
||||
|
||||
## Предусловия
|
||||
|
||||
- `DADATA_API_KEY` + `DADATA_SECRET` — завести в **YC Lockbox** (НЕ в git/.env репозитория).
|
||||
Прокинуть в окружение прод-воркеров (`DADATA_API_KEY`, `DADATA_SECRET`).
|
||||
- Feature-flag `LEAD_REGION_RESOLVER_ENABLED` (по умолчанию `false` → текущее tag-поведение).
|
||||
- Бюджет: `DADATA_DAILY_CAP_RUB` (дефолт 10000), `DADATA_CALL_COST_KOPECKS` (дефолт 60 —
|
||||
**откалибровать по фактическому тарифу DaData** после первого дня).
|
||||
|
||||
## Порядок выкатки
|
||||
|
||||
1. **Миграция БД.** Накатить `2026_05_31_100000_create_phone_ranges_and_resolution_log`
|
||||
(создаёт `phone_ranges`, `phone_ranges_imports`, `lead_region_resolution_log` +
|
||||
колонки в `supplier_leads`/`deals`). Партиции журнала на старте — m05/m06; далее
|
||||
их подхватывает `partitions:create-months` (уже зарегистрирован в `MonthlyPartitionManager`).
|
||||
- На проде миграция делает `SET ROLE crm_migrator` (паттерн проекта).
|
||||
2. **Импорт реестра Россвязи.** Скачать пакет выписок с
|
||||
`rossvyaz.gov.ru/deyatelnost/resurs-numeracii/...` (~500-600 файлов) в каталог,
|
||||
затем `php artisan phone-ranges:import --dir=<каталог>`.
|
||||
- **NB парсер:** ожидает CSV `;`-разделитель, колонки `АВС/ DEF;От;До;Емкость;Оператор;Регион`.
|
||||
Реальные файлы Россвязи могут быть в cp1251 / иметь другие заголовки — сверить на
|
||||
первом импорте; при расхождении поправить `resolveColumns()` (это и есть первая
|
||||
боевая валидация — автотест покрывает CSV-фикстуру, не реальный формат).
|
||||
- **NB swap:** atomic RENAME (`phone_ranges` → `_old`, staging → `phone_ranges`) НЕ
|
||||
покрыт автотестом (коммитящий RENAME сломал бы общую тестовую БД). **Этот импорт —
|
||||
первая боевая проверка свапа.** Сначала прогнать `--dry-run` (staging без свапа),
|
||||
проверить `phone_ranges_staging` глазами, потом без `--dry-run`. Откат:
|
||||
`phone-ranges:rollback` (см. spec §6.4 — команда отката пока не реализована,
|
||||
при необходимости — ручной RENAME `phone_ranges_old` обратно).
|
||||
3. **Деплой кода с `LEAD_REGION_RESOLVER_ENABLED=false`.** Резолвер выключен →
|
||||
поведение идентично текущему (tag-fallback). Каскад работает (но без точного
|
||||
региона, т.к. `resolved_subject_code=null` → шаг 2 «вся РФ» как раньше).
|
||||
4. **Smoke на staging/проде:** `php artisan phone-region:smoke --phone=79161234567`
|
||||
(с реальным ключом — платный вызов, в БД не пишет). Проверить, что DaData отвечает,
|
||||
регион/оператор резолвятся, Россвязь-fallback находит префиксы. Прогнать §9.4 — ~100
|
||||
реальных prod-номеров, сверить распределение источников.
|
||||
5. **Включить флаг (сразу 100%):** `LEAD_REGION_RESOLVER_ENABLED=true`. Рубильник
|
||||
глобальный — резолвер включается сразу для **всего** потока лидов. **Долевую
|
||||
(постепенную) раскатку НЕ делаем** (решение заказчика 01.06.2026): никакого
|
||||
`hash(phone) % 100`-гейта не вводим, фича идёт на 100% с первого включения.
|
||||
6. **Мониторинг 1 день:** `lead_region_resolution_log` — распределение `region_source`
|
||||
(ожидание: dadata большинство, tag < 20%, unknown < 5% — spec §8.2). Проверить
|
||||
`DADATA_DAILY_CAP_RUB` не упирается. Откалибровать `DADATA_CALL_COST_KOPECKS`.
|
||||
7. **Штатный режим:** фича уже работает на 100% потока (с шага 5) — долевого гейта нет,
|
||||
убирать нечего. Единственный рычаг управления — флаг `LEAD_REGION_RESOLVER_ENABLED`.
|
||||
8. **Ежемесячный cron** импорта реестра (`phone-ranges:import`, 4-е число 03:00 МСК —
|
||||
spec §6.3) — добавить в планировщик/`artisan-run`.
|
||||
|
||||
## Откат
|
||||
|
||||
- Мгновенный: `LEAD_REGION_RESOLVER_ENABLED=false` → резолвер возвращает tag-fallback,
|
||||
каскад ведёт себя как до фичи. Код деплоить заново не нужно.
|
||||
- Реестр: `phone_ranges_old` хранит предыдущую версию (ручной RENAME при проблеме импорта).
|
||||
|
||||
## Что отложено (followups, не блокируют ядро)
|
||||
|
||||
- **Метрики §8.1** (`phone_resolution.source.*` и т.д.) — в проекте нет механизма
|
||||
Prometheus/StatsD; отложено до его появления.
|
||||
- **Долевая (постепенная) раскатка** — **НЕ делаем** (решение заказчика 01.06.2026):
|
||||
фича включается сразу на 100%, `hash(phone)%100`-гейт не вводится.
|
||||
- **`phone-ranges:rollback`** — команда отката свапа (spec §6.4) не реализована.
|
||||
- **`deals.region_source`** — не добавлялась (по спеке регион-источник живёт на
|
||||
`supplier_leads` + в журнале). CSV-merge (§3.12) обновляет регион сделки по
|
||||
эвристике «webhook dadata/rossvyaz > CSV-tag», без хранения source на сделке.
|
||||
- **pg_anonymizer-маски (§7.2)** на `lead_region_resolution_log` — при настройке масок дампов.
|
||||
- **152-ФЗ:** телефон в журнале маскирован (`7XXX***YYYY`), `dadata_response_masked`
|
||||
без сырого номера — базовое покрытие есть; полный аудит ПДн — через `pdn-152fz-audit`.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,132 @@
|
||||
# Safe-baseline metering — live wiring (router-gate v4 §3.1.2, item 1b)
|
||||
|
||||
**Date:** 2026-05-30
|
||||
**Status:** Design v4 — ghost-risk pass folded in. Removed the user-approval override subsystem (G3 ghost-protection — almost never exercised); the escape is now solely "invoke any Skill/EnterPlanMode" (always available, C1). The runtime write-deny hook is retained but **decoupled** into a standalone git-approval-anchor hardening (independent value). Ready for writing-plans.
|
||||
**Scope:** Turn the deferred no-op `main()` of `tools/enforce-safe-baseline-metering.mjs` into a live PreToolUse hook that meters safe-baseline tool usage per task and hard-blocks a mutating tool once a counter passes its hard threshold without a real skill match. The false-block escape is invoking any Skill/EnterPlanMode (never blocked by this layer). A separate, decoupled hook closes a pre-existing self-write hole on `~/.claude/runtime` side-channels (git-approval anchor).
|
||||
|
||||
This is router-gate v4 Direction 1 ("skill-substitution laundering": много Read/Grep/Glob/LS/TodoWrite/AskUserQuestion как канал анализа ВМЕСТО invoke recommended Skill, затем mutating-инструмент без единого skill-match). The pure metering + threshold logic already exists in `tools/safe-baseline-metering.mjs` (thresholds, counters, `processEvent`, `decide`); the live I/O composition, a corrected escape, and the side-channel write-protection are missing.
|
||||
|
||||
---
|
||||
|
||||
## Owner decisions (brainstorming, revised)
|
||||
|
||||
1. **Enforcement posture:** hard-block from day 1 (not observe-only).
|
||||
2. **False-block escape (revised — override removed, G3):** invoke any real `Skill` or `EnterPlanMode` — these are never blocked by this layer (C1) and set the sticky task exemption. This escape is ALWAYS available, so a false block is a one-step recovery, not a lockout. The earlier user-approval override side-channel is **cut** as a ghost-protection (it defended a path that almost never occurs; its only durable value — runtime write-protection — is kept as a decoupled hook).
|
||||
3. **`skillMatched` definition (revised — strict, discipline-first):** matched only by a **real `Skill` OR `EnterPlanMode` tool_use** in the task (an actual tool invocation visible in the transcript). A **text mention** of a plan path does NOT count (it was a self-writable hole — removed). Sticky within the task. Optional future strengthening — match the *recommended* node specifically — is deferred because it needs a node→skill registry mapping (see "Deferred strengthening").
|
||||
4. **Priority:** discipline protection and correctness are paramount; cost and speed are secondary.
|
||||
|
||||
---
|
||||
|
||||
## Honest discipline-protection assessment
|
||||
|
||||
Read this before trusting the layer. Even fully fixed, safe-baseline is a **cheap deterministic tripwire**, not a strong scope-discipline guarantee:
|
||||
|
||||
- It fires only when a single task accumulates a high count of safe-baseline tools (Read hard = 60, Grep = 30, …) **without any real skill/plan invocation**, then reaches for a mutating tool. Realistically counters accumulate mostly *within one assistant turn* (terse user confirmations reset task boundaries), and 60 reads in one turn is uncommon — so the trigger surface is genuinely small.
|
||||
- After the fixes it has **no self-bypass** (skill-match needs a real tool_use) and a **working escape** (skill/plan invocations are never blocked, always available). That makes it *sound* — it does what it claims without a trivial dodge.
|
||||
- The **strong** scope-consistency check (is THIS tool call consistent with the declared task and recommended skill?) is **Layer 4** (`enforce-llm-judge-per-tool`), which is OFF until owner activation (item 2b). Safe-baseline is the cheap pre-filter beneath it.
|
||||
|
||||
Verdict: as a hard guarantee — **LOW–MODERATE**; as an honest, non-bypassable tripwire for blatant laundering — **sound**. The discipline lever that matters most is Layer 4.
|
||||
|
||||
---
|
||||
|
||||
## Architecture & data flow
|
||||
|
||||
`tools/enforce-safe-baseline-metering.mjs` gains a live `main()` (replacing the no-op). On each PreToolUse event:
|
||||
|
||||
1. Parse the event (`tool_name`, `session_id`, `transcript_path`).
|
||||
2. Load the per-session ledger `~/.claude/runtime/safe-baseline-ledger-<sess>.json` = `{ state, lastKeywords }` (absent on first event → `null`).
|
||||
3. From the transcript extract:
|
||||
- `promptText` — the last user prompt (`lastUserPromptText`).
|
||||
- `currentKeywords` — `extractKeywords(promptText)` (deterministic tokenization — see below; no classifier dependency).
|
||||
- `skillMatchedThisTurn` — `detectSkillMatch(lastTurnEntries(transcript))` **OR** `event.tool_name ∈ {Skill, EnterPlanMode}` (the in-flight escape call counts — see C1 fix).
|
||||
4. Call the existing pure `processEvent({ event, priorLedger, currentKeywords, promptText, skillMatched, thresholds })` — task-boundary inference (`shouldInheritTaskId`: reset-marker / keyword-overlap ≥ 2 → continuation; else fresh task, counters from zero) then metering.
|
||||
5. Sticky skill-match — **task-scoped, explicitly persisted** (the pure pipeline does NOT persist it; see "Skill-match stickiness contract"). Determine `inherit` (same predicate as `shouldInheritTaskId`), then `effectiveSkillMatched = (inherit ? priorLedger.state.skill_match_within_task : false) || skillMatchedThisTurn`; pass `effectiveSkillMatched` to `processEvent`/`decide` AND write it back into the persisted `state.skill_match_within_task`.
|
||||
6. Persist the new ledger.
|
||||
7. `hard_block` → `exitDecision({ block: true, message })` — the message MUST name the escape ("invoke the recommended Skill, or EnterPlanMode, to proceed"); `soft_flag` → append to the flags log and exit 0; `allow` → exit 0.
|
||||
|
||||
`soft_flag` never blocks (observability only). Only a mutating tool past a hard threshold without skill-match blocks.
|
||||
|
||||
### C1 fix — the escape must never be blocked
|
||||
|
||||
`Skill` and `Task` are in the pure module's MUTATING set (`safe-baseline-metering.mjs:31`), and `evaluateThresholds` hard-blocks any mutating tool past a hard threshold when `skillMatched` is false (`safe-baseline-metering.mjs:92-102`). Naively this blocks the very `Skill` call meant to escape (catch-22). The live head closes this by counting the **current event** in `skillMatchedThisTurn` when `event.tool_name ∈ {Skill, EnterPlanMode}` (step 3). Because `skillMatched` short-circuits `evaluateThresholds` to `allow` (`safe-baseline-metering.mjs:89`), a skill/plan invocation always passes — and then sets the sticky exemption for subsequent Edit/Write/Bash/Task. `Task` is intentionally NOT treated as an escape tool (subagent spawn can itself be a laundering channel) and remains blockable.
|
||||
|
||||
### Skill-match stickiness contract (V2-1 fix)
|
||||
|
||||
The pure pipeline neither persists nor task-scopes skill-match, so the wrapper MUST own it:
|
||||
|
||||
- `processEvent` returns `ledger.state = d.state` and never sets `skill_match_within_task` (`enforce-safe-baseline-metering.mjs:89-94`); `decide`/`incrementCounter` touch only `counts` (`safe-baseline-metering.mjs:42-46, 77-84`); `newCounterState` sets `skill_match_within_task: false` on a fresh task (`safe-baseline-metering.mjs:67`).
|
||||
- **Two failure modes if the wrapper is naive:** (a) *lost stickiness* — a skill invoked early in a task is forgotten next event, counters climb, a later mutating op blocks despite the skill (false block); (b) *cross-task leak* — passing `priorLedger.state.skill_match_within_task` unconditionally applies a prior task's exemption to a freshly-started task.
|
||||
- **Required wrapper logic:** compute `inherit` (replicate `shouldInheritTaskId`, or extend `processEvent` to return it); set `effectiveSkillMatched = (inherit ? priorLedger.state.skill_match_within_task : false) || skillMatchedThisTurn`; use it for the decision; and write `effectiveSkillMatched` back into the persisted `state.skill_match_within_task`. Unit tests must cover both failure modes explicitly (skill-then-60-reads stays allowed within a task; skill in task A does NOT exempt task B).
|
||||
|
||||
### Safety property of the boundary heuristic
|
||||
|
||||
The dangerous direction is *wrongly inheriting* counters across two genuinely different tasks (carrying 60 reads into an unrelated task → false block); this needs keyword-overlap ≥ 2 AND no reset marker, which is uncommon. The opposite error — treating a continuation as a fresh task — *resets* counters to zero, which only *reduces* blocking (safe direction). So the heuristic errs toward fewer false blocks.
|
||||
|
||||
---
|
||||
|
||||
## Task-boundary & skill-match detection
|
||||
|
||||
### `extractKeywords(promptText)` (pure) — H1 fix
|
||||
|
||||
Deterministic tokenization only: lowercase, strip RU/EN stopwords, keep tokens length ≥ 4, unique, sorted. **No classifier-keyword branch** — the classifier output schema (`router-classifier.mjs:420`) has no `keywords` field, and mixing extraction sources between events would break the overlap comparison. A single deterministic source guarantees `lastKeywords` (stored at event N) and `currentKeywords` (event N+1) are always comparable.
|
||||
|
||||
### `detectSkillMatch(turnEntries)` (pure) — C2 fix
|
||||
|
||||
Returns `true` only if the turn contains a real assistant **tool_use** block with `name ∈ {Skill, EnterPlanMode}`. The previous "text reference to `docs/superpowers/plans/`" path is **removed** — it was controller-authored text and therefore a self-writable bypass of the entire hard-block. A `Read` of a plan file no longer counts either (Read is itself a metered safe-baseline tool; counting it would let pure reading self-exempt). Implement like `hasPlanIndicator` in `enforce-tdd-gate.mjs` (scan assistant `tool_use` blocks); `lastTurnEntries` already skips harness-injected skill *bodies* (commit `ffd70d6f`, `isMeta` + `sourceToolUseID`), so the detector sees the genuine invocation, not the injected body. Stickiness is owned by the wrapper per the "Skill-match stickiness contract" (V2-1), combined with the in-flight escape rule in step 3 / C1.
|
||||
|
||||
### Deferred strengthening — recommended-node match
|
||||
|
||||
The strict ideal is "skill-match requires the *recommended* node, not just any skill," so the controller can't dodge by invoking an unrelated skill. This needs a node-ID (`#19`) → skill-name (`superpowers:writing-plans`) registry mapping (router-state stores `recommendedNode` per `router-classifier.mjs:163`, but not the skill name). **It is only a *partial* strengthening (V2-3):** the dominant recommendation for feature/planning/bugfix is node `#19` = the whole Superpowers plugin (`nodes.yaml:279-291`), which maps to "any `superpowers:*` skill" ≈ "any skill"; node-matching would constrain only specific skill-nodes (#62 billing-audit, #71, …), not the common case. Deferred to avoid a brittle mapping causing lockouts for marginal gain; the always-available Skill/EnterPlanMode escape and Layer 4 cover the gap meanwhile. Documented honestly so it is neither silently dropped nor oversold.
|
||||
|
||||
---
|
||||
|
||||
## Side-channel write-protection (decoupled — G3 cut)
|
||||
|
||||
The user-approval override is **removed** (G3 ghost-protection — it defended a near-never path with heavy machinery). The false-block escape is invoking any Skill/EnterPlanMode, which is always available and never blocked by this layer (C1). What remains worth building is a standalone hardening of the `~/.claude/runtime` side-channels — it has **independent value** (it closes a pre-existing self-write hole in the v4 **git-approval anchor**), so it ships as its own hook, not as a safe-baseline dependency.
|
||||
|
||||
### The write-deny hook
|
||||
|
||||
Today **no hook gates the Write/Edit tool to `~/.claude/runtime`**: `enforce-read-path-deny.mjs:22` is Read-only; `enforce-normative-content-rules.mjs:11-18` covers only normative docs; the `.claude/runtime` protection in `shell-content-rules.mjs:24` applies only to Bash/PowerShell, not the Write tool. So the existing git-approval anchor (`askuser-decisions-<sess>.jsonl`) — and any future runtime side-channel — is self-writable via the Write tool.
|
||||
|
||||
**Fix:** a new PreToolUse hook `enforce-runtime-write-deny.mjs` (matcher `Edit|Write|MultiEdit|NotebookEdit`) that blocks the Write/Edit tools from writing any path under `~/.claude/runtime/**`, fail-CLOSE. It **MUST normalize with the resolving `pathNormalize`** (`path-normalization.mjs:104,107` — `path.resolve` + `realpath` collapse `.`/`..`), **NOT** the lighter `defaultPathNormalize` (`shell-content-rules.mjs:13-19`), which leaves `.`/`..` segments intact (V2-2): `~/.claude/./runtime/x.jsonl` would evade the `\.claude/runtime` pattern while `fs` writes the real file. After resolving, match against the runtime pattern from `DEFAULT_PROTECTED_PATTERNS`. Legitimate hooks write there via Node `fs` (not the Claude Write tool), so they are unaffected. The same `.`-segment hardening should also be applied to `enforce-read-path-deny.mjs`.
|
||||
|
||||
**Owner verification:** the owner should check `.claude/settings.json` for any `permissions.deny` already covering Write to `~/.claude/**` (Claude cannot read settings.json — gate-blocked). The new hook is additive defense-in-depth regardless.
|
||||
|
||||
---
|
||||
|
||||
## Persistence, registration, testing, rollout
|
||||
|
||||
### Persistence
|
||||
|
||||
- Ledger: `~/.claude/runtime/safe-baseline-ledger-<sess>.json` = `{ state, lastKeywords }`; `state` also carries `task_id` and `skill_match_within_task`.
|
||||
- Flags log: `~/.claude/runtime/safe-baseline-flags-<sess>.jsonl` (soft_flag observability).
|
||||
- All file I/O is fail-quiet: any read/write error → treat as no-ledger and exit 0. The hook never crashes the session.
|
||||
|
||||
### Purity / testability
|
||||
|
||||
All logic lives in pure functions (`extractKeywords`, `detectSkillMatch`, plus the existing `processEvent`/`decide`). `main()` is only I/O composition. The new `enforce-runtime-write-deny.mjs` has a pure `decide({toolName, filePath})`. TDD: each new pure function RED→GREEN; an integration test drives `main()` via injected `runtimeDir` + a transcript fixture.
|
||||
|
||||
### Registration (owner-applied)
|
||||
|
||||
- `enforce-safe-baseline-metering` — PreToolUse, matcher scoped to the metered + mutating + escape tools (`Read|Grep|Glob|LS|TodoWrite|AskUserQuestion|Edit|Write|MultiEdit|NotebookEdit|Bash|Skill|Task|EnterPlanMode`), block mode.
|
||||
- `enforce-runtime-write-deny` — PreToolUse `Edit|Write|MultiEdit|NotebookEdit`, block mode (standalone — protects the git-approval anchor; independent of safe-baseline).
|
||||
- **Claude does not edit `settings.json`** (gate-blocked). The plan produces an exact JSON block for the owner to paste manually. Until registered, the hooks are inert (no behavior change).
|
||||
|
||||
### Rollout safety
|
||||
|
||||
Despite "hard-block from day 1", the plan includes a **mandatory smoke test before live registration**: run the live `main()` against 3 real transcript fixtures (single task / task switch / skill-invocation escape) and confirm boundary, skillMatched, and escape all fire correctly. Plus a smoke for `enforce-runtime-write-deny`: a Write to `~/.claude/runtime/x.jsonl` is blocked, a Write to `~/.claude/./runtime/x.jsonl` (V2-2 `.`-segment evasion) is ALSO blocked, and a Write to a normal project path passes. This does not change the posture; it catches gross detection bugs before the hooks start blocking.
|
||||
|
||||
### Scope
|
||||
|
||||
~7-9 TDD tasks (live `main()` + `extractKeywords` + `detectSkillMatch` + stickiness contract + escape fix; plus the standalone `enforce-runtime-write-deny` hook), estimate 5-7 h. Cost/speed are secondary per owner priority.
|
||||
|
||||
---
|
||||
|
||||
## Out of scope
|
||||
|
||||
- User-approval override side-channel (cut as a ghost-protection, G3 — escape via Skill/EnterPlanMode is always available).
|
||||
- Layer 4 LLM-judge activation (separate owner step, item 2b) — the strong scope-discipline lever.
|
||||
- Recommended-node skill matching (deferred strengthening — needs node→skill registry).
|
||||
- CLAUDE.md / Pravila / PSR / Tooling normative sync (blocked by a parallel session, item 4).
|
||||
- Layer 5 VM / biometric / YubiKey (item 6).
|
||||
- Any weakening of the router-gate whitelist.
|
||||
@@ -0,0 +1,131 @@
|
||||
# Router-gate re-scope: «боевое блокируем, локальную разработку разрешаем»
|
||||
|
||||
**Дата:** 2026-06-02
|
||||
**Статус:** design (утверждён владельцем; реализация — отдельным планом)
|
||||
**Автор контекста:** сессия lead-region-tails
|
||||
|
||||
## Проблема
|
||||
|
||||
Router-gate v4 (`tools/enforce-router-gate.mjs`) работает в режиме «по умолчанию запрещено»
|
||||
(whitelist для Bash + hard-blacklist + MCP-классификатор + дисциплинарные хуки). Он задумывался
|
||||
как защита **боевого** контура (выкат на liderra.ru, изменение боевой БД, секреты, запуск
|
||||
воркфлоу), но по факту блокирует и **весь локальный инструмент разработки**: `composer install`,
|
||||
`npm install`, `git worktree`, `git commit`/`push`, и даже правку тест-файлов (через
|
||||
`enforce-tdd-real-test-verifier`). Это делает обычную разработку через контроллера непрактичной —
|
||||
любая PHP/JS-задача с тестами упирается в стену (подтверждено в сессии 2026-06-02: попытка сделать
|
||||
fix реестра Россвязи провалилась на цепочке взаимно-охраняющих замков).
|
||||
|
||||
## Цель
|
||||
|
||||
Перенастроить замок так, чтобы он блокировал **только боевое и опасное**, а **локальную
|
||||
разработку разрешал** — сохранив при этом дисциплину работы контроллера и защиту боевого контура.
|
||||
|
||||
## Решения (утверждены владельцем 2026-06-02)
|
||||
|
||||
1. **Дисциплину оставляем.** Хуки качества (TDD-gate, tdd-real-test-verifier, chain-recommendation,
|
||||
graph-first, override-limit, llm-judge, coverage-verify, memory-coverage и пр.) — **не трогаем**.
|
||||
Контроллер продолжает писать тесты до кода и не срезать углы.
|
||||
2. **Защиту боевого оставляем железно.** Выкат/боевая БД/секреты/запуск воркфлоу/защищённые
|
||||
пути — без изменений.
|
||||
3. **Инструменты разработки разрешаем.** composer/npm/pest/git/worktree.
|
||||
4. **Граница git:** ветки — контроллер сам (commit/push в не-главную ветку + подготовка PR);
|
||||
слияние в main, push в main, force-push, выкат — **клик владельца**.
|
||||
|
||||
## Подход
|
||||
|
||||
**Approach A (выбран):** точечно расширить whitelist дев-инструментами, сохранив философию
|
||||
«по умолчанию запрещено». Правим **два файла** — `tools/enforce-router-gate.mjs` (composer/npm) и
|
||||
`tools/shell-content-rules.mjs` (git; там общий `classifyGitCommand`). MCP-классификатор
|
||||
(`tools/mcp-tool-classifier.mjs`) и дисциплинарные хуки — без изменений.
|
||||
|
||||
Отвергнут **Approach B** (перевернуть в default-allow + blacklist опасного): любой пропуск в
|
||||
перечне опасного = дыра; ломает безопасную философию default-deny.
|
||||
|
||||
## Матрица: что блокируем / что разрешаем
|
||||
|
||||
### Остаётся ЗАБЛОКИРОВАННЫМ
|
||||
|
||||
| Категория | Примеры | Где |
|
||||
|---|---|---|
|
||||
| Боевой контур | выкат на сайт, изменение боевой БД, секреты/`.env`, защищённые пути (CLAUDE.md, memory/, transcripts, `~/.claude/runtime`) | без изменений |
|
||||
| GitHub на запись | `create_*`/`update_*`/`merge_*`/`push_files`/`actions_run_trigger` | MCP-классификатор без изменений (read-only, открытый 2026-06-02, остаётся) |
|
||||
| Опасные команды | `rm`/`mv`/`cp`/`chmod`/`chown`, `curl -X POST/PUT/DELETE`, `wget`, `nc`/`ncat`/`socat`, `node -e` с `fs.*`, `eval`, `bash -c`/`sh -c`, `python -c`, redirects в protected | hard-blacklist без изменений |
|
||||
| Дисциплина | TDD-gate, tdd-real-test-verifier, override-limit, chain-recommendation, graph-first, llm-judge, coverage | хуки без изменений |
|
||||
| Главная ветка | `git push` в main, `git push --force`, слияние в main | новый «страж main» |
|
||||
|
||||
### Становится РАЗРЕШЁННЫМ (локальная разработка)
|
||||
|
||||
| Инструмент | Команды |
|
||||
|---|---|
|
||||
| Composer | `composer install`, `composer dump-autoload`, `composer require`, `composer update` |
|
||||
| NPM | `npm install`, `npm ci`, `npm run <script>` |
|
||||
| Тесты | `pest`, `vendor/bin/pest`, `php artisan test` (уже частично в whitelist) |
|
||||
| Git (ветки) | `git commit`, `git add`, `git branch`, `git switch`/`checkout`, `git worktree`, `git stash`, `git push` **в не-главную ветку** |
|
||||
|
||||
## Изменения в коде (два файла)
|
||||
|
||||
Git-логика живёт не в самом router-gate, а в общем модуле `shell-content-rules.mjs`
|
||||
(`classifyGitCommand`, используется и Bash-, и PowerShell-гейтом). Поэтому правок — два файла.
|
||||
|
||||
### `tools/enforce-router-gate.mjs` (composer / npm)
|
||||
|
||||
1. **Из hard-blacklist (`BASH_HARD_BLACKLIST`) убрать** строки про `composer install/update/require/remove`
|
||||
и `npm install/i/update/remove/uninstall`. `yarn`/`pnpm` остаются заблокированными (проект на npm,
|
||||
не нужны). Истинно-опасные fs/сеть/exec (`rm/mv/cp/chmod`, `curl POST`, `wget`, `nc`, `node -e fs`,
|
||||
`eval`, `bash -c`, `python -c`, redirects) — **без изменений**.
|
||||
2. **В whitelist (`SAFE_EXACT`) добавить:** `composer (install|update|require|remove|dump-autoload|dump)`,
|
||||
`npm (install|i|ci)`, `npm run <script>` (любой скрипт). Существующие `composer show/outdated/test/...`
|
||||
и `npm test/run test/run lint` — остаются.
|
||||
|
||||
### `tools/shell-content-rules.mjs` (git)
|
||||
|
||||
1. **Новый `GIT_DEV_SUB`** = `{add, commit, branch, switch, checkout, stash, worktree}` → в
|
||||
`classifyGitCommand` после hard-pattern-проверки возвращать `allow`. Эти подкоманды **убрать** из
|
||||
`GIT_CONDITIONAL_SUB`. (`worktree` сейчас падает в default-deny — попадёт в dev-allow.)
|
||||
2. **`GIT_HARD_PATTERNS` не трогаем** — `--no-verify`, `git add -f`, `git -c`, force-push, `--output`/`-o`
|
||||
и т.п. по-прежнему блокируются ПЕРВЫМИ, до dev-allow. То есть `git commit --no-verify` и `git add -f`
|
||||
остаются заблокированы даже как «dev».
|
||||
3. **Страж main для `push`** (`mainPushGuard`, чистая функция): `push` остаётся, но —
|
||||
если в аргументах фигурирует `main`/`master` как ref (`git push origin main`, `HEAD:main`, `:main`)
|
||||
→ **block** (клик владельца); force-push уже заблокирован `GIT_HARD_PATTERNS`. Иначе (`git push origin <feature>`,
|
||||
bare `git push`) → allow. Допущение: bare `git push` считаем пушем не-главной ветки (контроллер по модели
|
||||
всегда на не-главной ветке); пуш в main возможен только явным `origin main` → пойман.
|
||||
4. **Conditional остаётся** для `merge, rebase, reset, cherry-pick, revert, pull, clean` (require approval) —
|
||||
риск потери работы / слияние в main = клик владельца.
|
||||
|
||||
**Не меняем:** `tools/mcp-tool-classifier.mjs`, `tools/bash-tokenizer.mjs` (`isMutatingSegment` — чейн-правило
|
||||
C13 «цепочка с мутацией → блок» сохраняется), любые `enforce-*` дисциплинарные хуки, `.claude/settings.json`.
|
||||
|
||||
## Тестирование (TDD)
|
||||
|
||||
Через `tools/enforce-router-gate.test.mjs` (vitest, работает в основной копии):
|
||||
|
||||
- `composer install` / `composer require x` → allow; `composer` (без подкоманды) → как раньше.
|
||||
- `npm install` → allow; `npm run build` → allow.
|
||||
- `git commit -m x` / `git worktree add ...` / `git push origin feature-x` → allow.
|
||||
- `git push origin main` / `git push --force` → **block** (страж main).
|
||||
- Регресс: опасное по-прежнему блокируется — `rm -rf x`, `curl -X POST`, `node -e "...fs..."`,
|
||||
`eval`, `python -c` → block.
|
||||
- Полная регрессия tools-тестов (`npx vitest run --root app --config vitest.config.tools.mjs`).
|
||||
|
||||
## Граница реализации (bootstrap-нюанс)
|
||||
|
||||
Сам этот re-scope — bootstrap-исключение: его нельзя делать в worktree (worktree пока заблокирован).
|
||||
Реализуется в основной копии (там активен живой замок и работает vitest). После правки замка
|
||||
`git`/`worktree`/`composer` становятся разрешены — дальнейшие задачи (например, fix реестра)
|
||||
пойдут уже по модели «ветка + PR».
|
||||
|
||||
## Остаточные риски (приняты)
|
||||
|
||||
- Разрешён `composer require`/`npm install` → теоретический supply-chain (установка пакета).
|
||||
Принято: это собственный проект владельца; дисциплина и code-review остаются.
|
||||
- `rm`/`mv`/`cp` остаются заблокированы — если реально мешают разработке, пересматриваем отдельно
|
||||
(файловые правки покрываются инструментами Write/Edit).
|
||||
- «Страж main» опирается на парсинг аргументов `git push`; экзотические формы (push по URL,
|
||||
refspec-трюки) при сомнении → block (fail-safe в сторону защиты main).
|
||||
|
||||
## Что НЕ входит (YAGNI)
|
||||
|
||||
- Не инвертируем модель замка (default-deny остаётся).
|
||||
- Не трогаем боевые воркфлоу, секреты, MCP-write.
|
||||
- Не ослабляем дисциплину.
|
||||
@@ -34,6 +34,22 @@ export function isSimpleAB(questions) {
|
||||
);
|
||||
}
|
||||
|
||||
// Calibration 5 (2026-05-31) — git-operation APPROVAL prompts are the sanctioned
|
||||
// git-approval channel (enforce-askuser-answer-parser turns the chosen answer
|
||||
// into an approve_git_operation record), never a substitute for structured
|
||||
// ideation. They must NOT be treated as cosmetic A/B. Identified structurally:
|
||||
// an option label is a literal git command. (SCOPE fix, not a discipline drop —
|
||||
// see decide(): design A/B questions with non-git labels are unaffected.)
|
||||
const GIT_CMD_RE = /\bgit\s+(?:commit|push|add|pull|merge|rebase|reset|checkout|switch|branch|stash|cherry-pick|revert|clean|restore|fetch|tag)\b/i;
|
||||
|
||||
/** True if this AskUser is a git-operation approval prompt (an option label is a git command). */
|
||||
export function isGitApprovalQuestion(questions) {
|
||||
if (!Array.isArray(questions)) return false;
|
||||
return questions.some((q) =>
|
||||
q && Array.isArray(q.options) &&
|
||||
q.options.some((o) => o && typeof o.label === 'string' && GIT_CMD_RE.test(o.label)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure cosmetic-AskUser decision (v4.1 §4.5).
|
||||
* Caller passes PRIOR counts; decide computes prospective new counts.
|
||||
@@ -42,6 +58,13 @@ export function isSimpleAB(questions) {
|
||||
* @returns {{action:'allow'|'soft_flag'|'hard_block', block:boolean, reason:string|null, isSimpleAB:boolean, newSessionCount:number, newTurnCount:number}}
|
||||
*/
|
||||
export function decide({ questions, simpleCountSession = 0, simpleCountTurn = 0, skillMatchedThisTurn = false, brainstormingInvoked = false }) {
|
||||
// Calibration 5: git-operation approval prompts are exempt — the sanctioned
|
||||
// git-approval channel, never cosmetic ideation. Allow, do not count, never
|
||||
// block. (Cannot be abused to dodge ideation discipline: a git-command label
|
||||
// makes the answer a real approve_git_operation, not a cosmetic clarification.)
|
||||
if (isGitApprovalQuestion(questions)) {
|
||||
return { action: 'allow', block: false, reason: null, isSimpleAB: false, newSessionCount: simpleCountSession, newTurnCount: simpleCountTurn };
|
||||
}
|
||||
const simple = isSimpleAB(questions);
|
||||
const newSessionCount = simpleCountSession + (simple ? 1 : 0);
|
||||
const newTurnCount = simpleCountTurn + (simple ? 1 : 0);
|
||||
|
||||
@@ -92,3 +92,45 @@ describe('askuser-cosmetic-detector / transcript helpers', () => {
|
||||
expect(countSimpleSession(flags)).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
import { isGitApprovalQuestion } from './askuser-cosmetic-detector.mjs';
|
||||
|
||||
// Calibration 5 (2026-05-31, SCOPE fix, NOT a discipline drop): a git-operation
|
||||
// APPROVAL AskUser (an option label is a literal git command) is the sanctioned
|
||||
// git-approval channel — enforce-askuser-answer-parser turns the chosen answer
|
||||
// into an approve_git_operation record. It is never a substitute for structured
|
||||
// ideation, so it must not be counted/blocked as "cosmetic A/B". Design A/B
|
||||
// questions (non-git labels) are unchanged — still counted, still hard-blocked.
|
||||
describe('isGitApprovalQuestion (calibration 5)', () => {
|
||||
it('true when an option label is a git command (push)', () => {
|
||||
expect(isGitApprovalQuestion([{ options: [{ label: 'git push origin main' }, { label: 'Не пушить' }] }])).toBe(true);
|
||||
});
|
||||
it('true when an option label is a git command (commit with pathspec)', () => {
|
||||
expect(isGitApprovalQuestion([{ options: [{ label: 'git commit -F x.txt -- a.mjs b.mjs' }, { label: 'Отмена' }] }])).toBe(true);
|
||||
});
|
||||
it('false for a non-git A/B', () => {
|
||||
expect(isGitApprovalQuestion([{ options: [{ label: 'Вариант А' }, { label: 'Вариант Б' }] }])).toBe(false);
|
||||
});
|
||||
it('false for empty/invalid input', () => {
|
||||
expect(isGitApprovalQuestion(null)).toBe(false);
|
||||
expect(isGitApprovalQuestion([])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('decide — git-approval exemption (calibration 5)', () => {
|
||||
const gitQ = { question: 'Подтверди?', options: [{ label: 'git push origin main' }, { label: 'Не пушить' }] };
|
||||
|
||||
it('allows a git-approval question and does NOT count it even past the session limit', () => {
|
||||
const r = decide({ questions: [gitQ], simpleCountSession: 5, simpleCountTurn: 0, skillMatchedThisTurn: false, brainstormingInvoked: false });
|
||||
expect(r.block).toBe(false);
|
||||
expect(r.action).toBe('allow');
|
||||
expect(r.isSimpleAB).toBe(false);
|
||||
expect(r.newSessionCount).toBe(5); // unchanged — not counted toward the cosmetic limit
|
||||
});
|
||||
|
||||
it('REGRESSION: a non-git simple A/B past the limit STILL hard-blocks (discipline intact)', () => {
|
||||
const r = decide({ questions: [simpleQ], simpleCountSession: 5, simpleCountTurn: 0, skillMatchedThisTurn: false, brainstormingInvoked: false });
|
||||
expect(r.action).toBe('hard_block');
|
||||
expect(r.block).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -54,8 +54,6 @@ export function decide({
|
||||
`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'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -14,6 +14,9 @@ describe('enforce-coverage-verify / decide', () => {
|
||||
});
|
||||
expect(r.block).toBe(true);
|
||||
expect(r.message).toMatch(/no.*coverage/);
|
||||
// 1A (2026-05-31): не рекламировать мёртвые override-фразы (findOverride — заглушка v4).
|
||||
expect(r.message).not.toMatch(/Override:/);
|
||||
expect(r.message).not.toMatch(/без скилов|direct ok/);
|
||||
});
|
||||
|
||||
it('blocks when coverage says skill but Skill tool not invoked', () => {
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* enforce-llm-judge-per-tool — PreToolUse wrapper around the pure
|
||||
* llm-judge-per-tool engine (router-gate v4.1 §4.7 Layer 4).
|
||||
*
|
||||
* The engine (llm-judge-per-tool.mjs) asks a single Sonnet judge whether a
|
||||
* mutating tool call is consistent with the declared user task + recommended
|
||||
* skill scope (NO / doubt → block). Running it costs real LLM money, so the
|
||||
* judge MUST stay OFF until the owner deliberately activates Layer 4. This
|
||||
* wrapper is the missing seam between the engine and settings.json, built — like
|
||||
* the sibling Stream H wrappers (enforce-safe-baseline-metering / -decomposition-
|
||||
* detector) — with a testable pure `decide()` and a DELIBERATE no-op `main()`.
|
||||
*
|
||||
* Activation (step 2b — owner-driven, NOT done here):
|
||||
* 1. store the API key (keychain `router-gate-llm-judge`/`default` or ROUTER_LLM_KEY),
|
||||
* 2. set ROUTER_LLM_JUDGE_ENABLED=1,
|
||||
* 3. register this hook (PreToolUse, block) in .claude/settings.json.
|
||||
* Until all three, decide() short-circuits to allow on a disabled config and the
|
||||
* live main() is a no-op (exit 0) — $0, no LLM call, no self-lockout.
|
||||
*/
|
||||
import { judgePerTool, MUTATING_TOOLS, readDeclaredTask, resolveEffectiveTask } from './llm-judge-per-tool.mjs';
|
||||
import { resolveJudgeConfig } from './llm-judge-config.mjs';
|
||||
import { readJudgeBudget, bumpJudgeBudget, JUDGE_SESSION_BUDGET, llmJudgeCall } from './llm-judge.mjs';
|
||||
import { readStdin, parseEventJson, exitDecision, readTranscript, lastUserPromptText } from './enforce-hook-helpers.mjs';
|
||||
import { classifyBashCommand } from './enforce-router-gate.mjs';
|
||||
|
||||
/**
|
||||
* Pure decision. Composes the Layer-4 enabling-gate (resolveJudgeConfig output)
|
||||
* with the per-tool judge engine:
|
||||
* - non-mutating tool → allow (out of judge scope)
|
||||
* - judge disabled / no key → allow + degraded flag (Layer 4 off, $0)
|
||||
* - judge enabled → delegate to judgePerTool (YES → allow; NO / doubt → block)
|
||||
*
|
||||
* @param {object} args
|
||||
* @param {object} args.event - PreToolUse event ({ tool_name, tool_input })
|
||||
* @param {{enabled:boolean, apiKey:?string}} args.judgeConfig - resolveJudgeConfig() output
|
||||
* @param {object} [args.declaredTask] - { task_summary, recommended_node, recommended_chain }
|
||||
* @param {object} [args.budgetState] - { spent, limit } per-session judge budget
|
||||
* @param {Function} [args.llmJudgeCallImpl] - injected single-judge caller (tests / real binding)
|
||||
* @returns {Promise<{block:boolean, reason?:string, degraded?:boolean, verdict?:string|null}>}
|
||||
*/
|
||||
export async function decide({
|
||||
event,
|
||||
judgeConfig,
|
||||
declaredTask = {},
|
||||
budgetState,
|
||||
llmJudgeCallImpl,
|
||||
}) {
|
||||
const toolName = event && event.tool_name;
|
||||
if (!MUTATING_TOOLS.has(toolName)) {
|
||||
return { block: false, reason: 'non-mutating tool — outside per-tool judge scope' };
|
||||
}
|
||||
if (!judgeConfig || !judgeConfig.enabled) {
|
||||
return { block: false, degraded: true, reason: 'Layer 4 judge disabled' };
|
||||
}
|
||||
return judgePerTool({
|
||||
toolName,
|
||||
toolInput: (event && event.tool_input) || {},
|
||||
declaredTask,
|
||||
apiKey: judgeConfig.apiKey,
|
||||
budgetState,
|
||||
llmJudgeCallImpl,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Testable wiring core. Composes resolveJudgeConfig output + decide(); bumps the
|
||||
* session budget ONLY when a real judge call was made (result carries a verdict).
|
||||
* No verdict ⇒ non-mutating / disabled / no-key / budget-exhausted ⇒ no spend.
|
||||
*/
|
||||
/**
|
||||
* Calibration 2026-05-31 (SCOPE fix, NOT a discipline drop): readonly Bash
|
||||
* commands ("смотрелки" — git status/log/diff, cat, grep, ls) change nothing,
|
||||
* so they are outside the "judge on mutating tools" scope. Reuse the router-gate
|
||||
* Bash classifier: an allow-verdict whose reason mentions readonly/reading is a
|
||||
* no-state-change command. Everything that can mutate (file edits, git
|
||||
* commit/push, dangerous Bash, Skill/Task) is unaffected — doubt→block stands.
|
||||
*/
|
||||
export function isReadonlyBashEvent(event) {
|
||||
if (!event || event.tool_name !== 'Bash') return false;
|
||||
const command = (event.tool_input && event.tool_input.command) || '';
|
||||
if (!command) return false;
|
||||
try {
|
||||
const c = classifyBashCommand(command, {});
|
||||
return !!c && c.result === 'allow' && /readonly|reading/i.test(c.reason || '');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calibration 3 (2026-05-31, SCOPE fix, NOT a discipline drop): a test run
|
||||
* (vitest / pest / phpunit / php artisan test / composer test / npm test) only
|
||||
* inspects the code and reports pass/fail — it mutates no protected state, and
|
||||
* running tests is a MANDATORY step of TDD which the rules require. Treat such
|
||||
* commands like readonly Bash: outside the mutating-tool judge scope. A command
|
||||
* that chains to anything else (&& / ; / | / backtick / $( ) is NOT exempt and
|
||||
* stays judged — the exemption covers a pure test invocation only.
|
||||
*/
|
||||
const TEST_RUNNER_RE =
|
||||
/^(?:npx\s+)?vitest(?:\s|$)|^(?:\.\/)?(?:node_modules\/\.bin\/|vendor\/bin\/)?pest(?:\s|$)|^(?:\.\/)?vendor\/bin\/phpunit(?:\s|$)|^php\s+artisan\s+test(?:\s|$|:)|^composer\s+test(?::\S+)?(?:\s|$)|^npm\s+(?:run\s+)?test(?::\S+)?(?:\s|$)/i;
|
||||
|
||||
export function isTestRunnerBashEvent(event) {
|
||||
if (!event || event.tool_name !== 'Bash') return false;
|
||||
const command = ((event.tool_input && event.tool_input.command) || '').trim();
|
||||
if (!command) return false;
|
||||
// Exemption is for a pure test run only — reject anything chaining to another command.
|
||||
if (/[;&|`]/.test(command) || command.includes('$(')) return false;
|
||||
return TEST_RUNNER_RE.test(command);
|
||||
}
|
||||
|
||||
export async function runPerTool({
|
||||
event,
|
||||
judgeConfig,
|
||||
readDeclaredTaskImpl,
|
||||
readLastUserPromptImpl,
|
||||
readBudgetImpl,
|
||||
bumpBudgetImpl,
|
||||
llmJudgeCallImpl,
|
||||
sessionBudget = JUDGE_SESSION_BUDGET,
|
||||
}) {
|
||||
// Readonly Bash never mutates → outside the judge's scope; skip (no LLM call, no spend).
|
||||
if (isReadonlyBashEvent(event)) {
|
||||
return { block: false, reason: 'readonly bash — outside mutating-tool judge scope (calibration 2026-05-31)' };
|
||||
}
|
||||
// Test-runner Bash only inspects + reports; mandatory TDD step → outside scope (calibration 3).
|
||||
if (isTestRunnerBashEvent(event)) {
|
||||
return { block: false, reason: 'test-runner bash — outside mutating-tool judge scope (calibration 3, 2026-05-31)' };
|
||||
}
|
||||
const sessionId = event && event.session_id;
|
||||
const declaredTask = readDeclaredTaskImpl({ sessionId });
|
||||
// Calibration 4 (soft): only when the classifier summary is unknown/empty,
|
||||
// consult the user's actual last prompt and judge against that instead.
|
||||
let effectiveTask = declaredTask;
|
||||
const summary = declaredTask && declaredTask.task_summary;
|
||||
const summaryUnknown = !summary || summary === '(unknown)' || !String(summary).trim();
|
||||
if (summaryUnknown && typeof readLastUserPromptImpl === 'function') {
|
||||
const lastPrompt = readLastUserPromptImpl({ transcriptPath: event && event.transcript_path });
|
||||
effectiveTask = resolveEffectiveTask(declaredTask, lastPrompt);
|
||||
}
|
||||
const spent = readBudgetImpl({ sessionId });
|
||||
const result = await decide({
|
||||
event,
|
||||
judgeConfig,
|
||||
declaredTask: effectiveTask,
|
||||
budgetState: { spent, limit: sessionBudget },
|
||||
llmJudgeCallImpl,
|
||||
});
|
||||
if (result.verdict !== undefined) bumpBudgetImpl({ sessionId, by: 1 });
|
||||
return result;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// Live wiring (2b): spend is gated by resolveJudgeConfig (flag AND key). With
|
||||
// the flag off or no key, decide() short-circuits to a degraded allow — NO LLM
|
||||
// call, $0. Fail-quiet so a judge bug can never wedge the session.
|
||||
try {
|
||||
const event = parseEventJson(await readStdin());
|
||||
const judgeConfig = resolveJudgeConfig();
|
||||
const result = await runPerTool({
|
||||
event,
|
||||
judgeConfig,
|
||||
readDeclaredTaskImpl: readDeclaredTask,
|
||||
readLastUserPromptImpl: ({ transcriptPath }) => lastUserPromptText(readTranscript(transcriptPath)),
|
||||
readBudgetImpl: readJudgeBudget,
|
||||
bumpBudgetImpl: bumpJudgeBudget,
|
||||
llmJudgeCallImpl: (opts) => llmJudgeCall(opts),
|
||||
});
|
||||
exitDecision({ block: result.block, message: result.reason });
|
||||
} catch {
|
||||
exitDecision({ block: false });
|
||||
}
|
||||
}
|
||||
|
||||
if ((process.argv[1] || '').replace(/\\/g, '/').endsWith('/enforce-llm-judge-per-tool.mjs')) {
|
||||
main().catch(() => process.exit(0));
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
// tools/enforce-llm-judge-per-tool.test.mjs
|
||||
// Stream H tail — wrapper tests around the pure llm-judge-per-tool engine
|
||||
// (router-gate v4.1 §4.7 Layer 4). Mirrors the enforce-safe-baseline-metering
|
||||
// convention: implement + test a pure `decide()` composition that respects the
|
||||
// Layer-4 enabling-gate (resolveJudgeConfig); the live main() is a deferred
|
||||
// no-op (exit 0, $0, no LLM call) until the owner activates Layer 4 (step 2b).
|
||||
// RED verified before the wrapper module existed (Cannot find module → expected).
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { decide } from './enforce-llm-judge-per-tool.mjs';
|
||||
|
||||
function spyCall(verdict) {
|
||||
const calls = [];
|
||||
const impl = async (opts) => { calls.push(opts); return verdict; };
|
||||
return { impl, calls };
|
||||
}
|
||||
|
||||
const ON = { enabled: true, apiKey: 'k' };
|
||||
const OFF = { enabled: false, apiKey: null };
|
||||
|
||||
describe('enforce-llm-judge-per-tool decide()', () => {
|
||||
it('allows a non-mutating tool without consulting the judge', async () => {
|
||||
const { impl, calls } = spyCall('NO');
|
||||
const r = await decide({
|
||||
event: { tool_name: 'WebFetch' },
|
||||
judgeConfig: ON,
|
||||
llmJudgeCallImpl: impl,
|
||||
});
|
||||
expect(r.block).toBe(false);
|
||||
expect(r.reason).toMatch(/non-mutating/i);
|
||||
expect(calls.length).toBe(0);
|
||||
});
|
||||
|
||||
// Calibration 1 (2026-05-31) — Skill is out of judge scope; invoking it
|
||||
// mutates nothing and is the prescribed §17 entry into work.
|
||||
it('allows a Skill invocation without consulting the judge (calibration 1)', async () => {
|
||||
const { impl, calls } = spyCall('NO');
|
||||
const r = await decide({
|
||||
event: { tool_name: 'Skill', tool_input: { skill: 'superpowers:test-driven-development' } },
|
||||
judgeConfig: ON,
|
||||
llmJudgeCallImpl: impl,
|
||||
});
|
||||
expect(r.block).toBe(false);
|
||||
expect(r.reason).toMatch(/non-mutating/i);
|
||||
expect(calls.length).toBe(0);
|
||||
});
|
||||
|
||||
it('allows a mutating tool without consulting the judge when Layer 4 is disabled ($0 posture)', async () => {
|
||||
const { impl, calls } = spyCall('NO');
|
||||
const r = await decide({
|
||||
event: { tool_name: 'Edit' },
|
||||
judgeConfig: OFF,
|
||||
llmJudgeCallImpl: impl,
|
||||
});
|
||||
expect(r.block).toBe(false);
|
||||
expect(r.degraded).toBe(true);
|
||||
expect(calls.length).toBe(0);
|
||||
});
|
||||
|
||||
it('allows a mutating tool when an enabled judge returns YES (consistent)', async () => {
|
||||
const { impl } = spyCall('YES');
|
||||
const r = await decide({
|
||||
event: { tool_name: 'Edit', tool_input: { file_path: 'x' } },
|
||||
judgeConfig: ON,
|
||||
declaredTask: { task_summary: 't', recommended_node: '#19' },
|
||||
llmJudgeCallImpl: impl,
|
||||
});
|
||||
expect(r.block).toBe(false);
|
||||
expect(r.verdict).toBe('YES');
|
||||
});
|
||||
|
||||
it('blocks a mutating tool when an enabled judge returns NO (off-scope)', async () => {
|
||||
const { impl } = spyCall('NO');
|
||||
const r = await decide({
|
||||
event: { tool_name: 'Write', tool_input: {} },
|
||||
judgeConfig: ON,
|
||||
llmJudgeCallImpl: impl,
|
||||
});
|
||||
expect(r.block).toBe(true);
|
||||
expect(r.reason).toMatch(/off-scope|per-tool/i);
|
||||
});
|
||||
|
||||
it('blocks on doubt — a null verdict is treated as inconsistent', async () => {
|
||||
const { impl } = spyCall(null);
|
||||
const r = await decide({
|
||||
event: { tool_name: 'Bash', tool_input: { command: 'ls' } },
|
||||
judgeConfig: ON,
|
||||
llmJudgeCallImpl: impl,
|
||||
});
|
||||
expect(r.block).toBe(true);
|
||||
});
|
||||
|
||||
it('degrades to allow (no block) when the session judge budget is exhausted', async () => {
|
||||
const { impl, calls } = spyCall('NO');
|
||||
const r = await decide({
|
||||
event: { tool_name: 'Edit', tool_input: {} },
|
||||
judgeConfig: ON,
|
||||
budgetState: { spent: 10, limit: 10 },
|
||||
llmJudgeCallImpl: impl,
|
||||
});
|
||||
expect(r.block).toBe(false);
|
||||
expect(r.degraded).toBe(true);
|
||||
expect(calls.length).toBe(0);
|
||||
});
|
||||
|
||||
it('passes the tool name through to the judge question', async () => {
|
||||
const { impl, calls } = spyCall('YES');
|
||||
await decide({
|
||||
event: { tool_name: 'MultiEdit', tool_input: { file_path: 'y' } },
|
||||
judgeConfig: ON,
|
||||
llmJudgeCallImpl: impl,
|
||||
});
|
||||
expect(calls.length).toBe(1);
|
||||
expect(calls[0].question).toContain('MultiEdit');
|
||||
});
|
||||
});
|
||||
|
||||
import { runPerTool } from './enforce-llm-judge-per-tool.mjs';
|
||||
|
||||
describe('runPerTool — spend-gate + budget binding (live wiring 2b)', () => {
|
||||
const deps = (over = {}) => ({
|
||||
readDeclaredTaskImpl: () => ({ task_summary: 't', recommended_node: null, recommended_chain: [] }),
|
||||
readBudgetImpl: () => 0,
|
||||
bumpBudgetImpl: () => {},
|
||||
sessionBudget: 200,
|
||||
...over,
|
||||
});
|
||||
|
||||
it('disabled config + mutating tool → degraded allow, NO budget bump, NO llm call', async () => {
|
||||
let bumped = 0; let called = 0;
|
||||
const r = await runPerTool({
|
||||
event: { tool_name: 'Edit', tool_input: {}, session_id: 's' },
|
||||
judgeConfig: { enabled: false, apiKey: null },
|
||||
llmJudgeCallImpl: () => { called++; return 'NO'; },
|
||||
...deps({ bumpBudgetImpl: () => { bumped++; } }),
|
||||
});
|
||||
expect(r.block).toBe(false);
|
||||
expect(r.degraded).toBe(true);
|
||||
expect(called).toBe(0);
|
||||
expect(bumped).toBe(0);
|
||||
});
|
||||
|
||||
it('enabled + mutating + judge YES → allow, budget bumped once', async () => {
|
||||
let bumped = 0;
|
||||
const r = await runPerTool({
|
||||
event: { tool_name: 'Edit', tool_input: {}, session_id: 's' },
|
||||
judgeConfig: { enabled: true, apiKey: 'k' },
|
||||
llmJudgeCallImpl: async () => 'YES',
|
||||
...deps({ bumpBudgetImpl: () => { bumped++; } }),
|
||||
});
|
||||
expect(r.block).toBe(false);
|
||||
expect(r.verdict).toBe('YES');
|
||||
expect(bumped).toBe(1);
|
||||
});
|
||||
|
||||
it('enabled + mutating + judge NO → block, budget bumped once', async () => {
|
||||
let bumped = 0;
|
||||
const r = await runPerTool({
|
||||
event: { tool_name: 'Bash', tool_input: { command: 'x' }, session_id: 's' },
|
||||
judgeConfig: { enabled: true, apiKey: 'k' },
|
||||
llmJudgeCallImpl: async () => 'NO',
|
||||
...deps({ bumpBudgetImpl: () => { bumped++; } }),
|
||||
});
|
||||
expect(r.block).toBe(true);
|
||||
expect(r.verdict).toBe('NO');
|
||||
expect(bumped).toBe(1);
|
||||
});
|
||||
|
||||
it('non-mutating tool → allow, NO call, NO bump', async () => {
|
||||
let bumped = 0; let called = 0;
|
||||
const r = await runPerTool({
|
||||
event: { tool_name: 'Read', tool_input: {}, session_id: 's' },
|
||||
judgeConfig: { enabled: true, apiKey: 'k' },
|
||||
llmJudgeCallImpl: () => { called++; return 'NO'; },
|
||||
...deps({ bumpBudgetImpl: () => { bumped++; } }),
|
||||
});
|
||||
expect(r.block).toBe(false);
|
||||
expect(called).toBe(0);
|
||||
expect(bumped).toBe(0);
|
||||
});
|
||||
|
||||
it('enabled but budget exhausted → degraded allow, NO bump', async () => {
|
||||
let bumped = 0; let called = 0;
|
||||
const r = await runPerTool({
|
||||
event: { tool_name: 'Edit', tool_input: {}, session_id: 's' },
|
||||
judgeConfig: { enabled: true, apiKey: 'k' },
|
||||
llmJudgeCallImpl: () => { called++; return 'NO'; },
|
||||
...deps({ readBudgetImpl: () => 200, bumpBudgetImpl: () => { bumped++; } }),
|
||||
});
|
||||
expect(r.block).toBe(false);
|
||||
expect(r.degraded).toBe(true);
|
||||
expect(called).toBe(0);
|
||||
expect(bumped).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
import { isReadonlyBashEvent } from './enforce-llm-judge-per-tool.mjs';
|
||||
|
||||
// Calibration 2026-05-31 — SCOPE fix only, discipline NOT lowered.
|
||||
// The per-tool judge is "judge on MUTATING tools"; readonly Bash ("смотрелки"
|
||||
// — git status/log/diff, cat, grep, ls) change nothing, so they were friction
|
||||
// with zero discipline value. We exclude them from the judge. The doubt→block
|
||||
// rule and full judging of every state-changing action (Edit/Write/commit/push/
|
||||
// Skill/Task) are UNCHANGED.
|
||||
describe('isReadonlyBashEvent — readonly Bash exclusion (calibration, no discipline drop)', () => {
|
||||
it.each([
|
||||
'git status',
|
||||
'git status --short',
|
||||
'git log -1 --oneline',
|
||||
'git diff HEAD~1',
|
||||
'cat package.json',
|
||||
'grep -n foo bar.js',
|
||||
'ls -la',
|
||||
])('treats readonly command as out-of-judge-scope: %s', (command) => {
|
||||
expect(isReadonlyBashEvent({ tool_name: 'Bash', tool_input: { command } })).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'git commit -m "x"',
|
||||
'git push origin main',
|
||||
'rm -rf foo',
|
||||
])('does NOT treat a mutating/blocked command as readonly: %s', (command) => {
|
||||
expect(isReadonlyBashEvent({ tool_name: 'Bash', tool_input: { command } })).toBe(false);
|
||||
});
|
||||
|
||||
it('non-Bash tool is never readonly-bash', () => {
|
||||
expect(isReadonlyBashEvent({ tool_name: 'Edit', tool_input: { file_path: 'x' } })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runPerTool — readonly Bash skips the judge; mutating Bash still judged', () => {
|
||||
it('readonly Bash → allow WITHOUT consulting judge even when enabled (no spend)', async () => {
|
||||
let called = 0; let bumped = 0;
|
||||
const r = await runPerTool({
|
||||
event: { tool_name: 'Bash', tool_input: { command: 'git status' }, session_id: 's' },
|
||||
judgeConfig: { enabled: true, apiKey: 'k' },
|
||||
readDeclaredTaskImpl: () => ({ task_summary: 't' }),
|
||||
readBudgetImpl: () => 0,
|
||||
bumpBudgetImpl: () => { bumped++; },
|
||||
llmJudgeCallImpl: () => { called++; return 'NO'; },
|
||||
sessionBudget: 200,
|
||||
});
|
||||
expect(r.block).toBe(false);
|
||||
expect(called).toBe(0);
|
||||
expect(bumped).toBe(0);
|
||||
});
|
||||
|
||||
it('mutating Bash (git commit) STILL judged when enabled — discipline preserved', async () => {
|
||||
let called = 0;
|
||||
const r = await runPerTool({
|
||||
event: { tool_name: 'Bash', tool_input: { command: 'git commit -m "x"' }, session_id: 's' },
|
||||
judgeConfig: { enabled: true, apiKey: 'k' },
|
||||
readDeclaredTaskImpl: () => ({ task_summary: 't' }),
|
||||
readBudgetImpl: () => 0,
|
||||
bumpBudgetImpl: () => {},
|
||||
llmJudgeCallImpl: async () => { called++; return 'NO'; },
|
||||
sessionBudget: 200,
|
||||
});
|
||||
expect(called).toBe(1);
|
||||
expect(r.block).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
import { isTestRunnerBashEvent } from './enforce-llm-judge-per-tool.mjs';
|
||||
|
||||
// Calibration 3 (2026-05-31) — SCOPE fix, discipline NOT lowered.
|
||||
// A test run (vitest / pest / composer test / php artisan test) only inspects
|
||||
// the code and reports pass/fail — it mutates no protected state. It is also a
|
||||
// mandatory step of TDD, which the rules require. Treat recognised test-runner
|
||||
// commands like readonly Bash: out of judge scope. Anything that chains to a
|
||||
// mutation (&& / ; / |) is NOT exempt and stays judged.
|
||||
describe('isTestRunnerBashEvent — test-runner exclusion (calibration 3, no discipline drop)', () => {
|
||||
it.each([
|
||||
'npx vitest run --root app --config vitest.config.tools.mjs',
|
||||
'vitest run',
|
||||
'pest',
|
||||
'./vendor/bin/pest --parallel',
|
||||
'vendor/bin/pest',
|
||||
'php artisan test',
|
||||
'composer test',
|
||||
'npm run test:tools',
|
||||
'npm test',
|
||||
])('treats test-runner command as out-of-judge-scope: %s', (command) => {
|
||||
expect(isTestRunnerBashEvent({ tool_name: 'Bash', tool_input: { command } })).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'git commit -m "x"',
|
||||
'rm -rf foo',
|
||||
'pest && git push origin main', // chained to a mutation → NOT exempt
|
||||
'echo pest',
|
||||
'composer require evil/package', // not a test run
|
||||
])('does NOT treat non-test-runner / chained command as test-runner: %s', (command) => {
|
||||
expect(isTestRunnerBashEvent({ tool_name: 'Bash', tool_input: { command } })).toBe(false);
|
||||
});
|
||||
|
||||
it('non-Bash tool is never test-runner-bash', () => {
|
||||
expect(isTestRunnerBashEvent({ tool_name: 'Edit', tool_input: { file_path: 'x' } })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runPerTool — test-runner Bash skips the judge; mutating Bash still judged', () => {
|
||||
it('test-runner Bash → allow WITHOUT consulting judge even when enabled (no spend)', async () => {
|
||||
let called = 0; let bumped = 0;
|
||||
const r = await runPerTool({
|
||||
event: { tool_name: 'Bash', tool_input: { command: 'npx vitest run' }, session_id: 's' },
|
||||
judgeConfig: { enabled: true, apiKey: 'k' },
|
||||
readDeclaredTaskImpl: () => ({ task_summary: 't' }),
|
||||
readBudgetImpl: () => 0,
|
||||
bumpBudgetImpl: () => { bumped++; },
|
||||
llmJudgeCallImpl: () => { called++; return 'NO'; },
|
||||
sessionBudget: 200,
|
||||
});
|
||||
expect(r.block).toBe(false);
|
||||
expect(called).toBe(0);
|
||||
expect(bumped).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// Calibration 4 (soft, 2026-05-31): when the classifier summary is "(unknown)",
|
||||
// runPerTool reads the user's last prompt and judges against THAT (better
|
||||
// evidence) instead of an empty task. When the summary is meaningful, the
|
||||
// user-prompt reader is never consulted — behaviour unchanged.
|
||||
describe('runPerTool — calibration 4 soft user-prompt fallback', () => {
|
||||
it('uses the user prompt as the judged task when classifier summary is unknown', async () => {
|
||||
const calls = [];
|
||||
const r = await runPerTool({
|
||||
event: { tool_name: 'Edit', tool_input: { file_path: 'tools/x.mjs' }, session_id: 's', transcript_path: '/t' },
|
||||
judgeConfig: { enabled: true, apiKey: 'k' },
|
||||
readDeclaredTaskImpl: () => ({ task_summary: '(unknown)', recommended_node: null, recommended_chain: [] }),
|
||||
readLastUserPromptImpl: () => 'реализуй parallel-session-lock',
|
||||
readBudgetImpl: () => 0,
|
||||
bumpBudgetImpl: () => {},
|
||||
llmJudgeCallImpl: async (opts) => { calls.push(opts); return 'YES'; },
|
||||
sessionBudget: 200,
|
||||
});
|
||||
expect(r.block).toBe(false);
|
||||
expect(calls.length).toBe(1);
|
||||
expect(calls[0].question).toContain('реализуй parallel-session-lock');
|
||||
});
|
||||
|
||||
it('does NOT consult the user-prompt reader when the classifier summary is meaningful', async () => {
|
||||
let promptReads = 0;
|
||||
const calls = [];
|
||||
await runPerTool({
|
||||
event: { tool_name: 'Edit', tool_input: {}, session_id: 's', transcript_path: '/t' },
|
||||
judgeConfig: { enabled: true, apiKey: 'k' },
|
||||
readDeclaredTaskImpl: () => ({ task_summary: 'clear task', recommended_node: null, recommended_chain: [] }),
|
||||
readLastUserPromptImpl: () => { promptReads++; return 'irrelevant'; },
|
||||
readBudgetImpl: () => 0,
|
||||
bumpBudgetImpl: () => {},
|
||||
llmJudgeCallImpl: async (opts) => { calls.push(opts); return 'YES'; },
|
||||
sessionBudget: 200,
|
||||
});
|
||||
expect(promptReads).toBe(0);
|
||||
expect(calls[0].question).toContain('clear task');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* enforce-llm-judge-response-scan — Stop-hook wrapper around the pure
|
||||
* llm-judge-response-scan engine (router-gate v4.1 §4.7 Layer 4).
|
||||
*
|
||||
* The engine scans the controller's own response text for self-replicating
|
||||
* instructions / metadata injection / security-disable suggestions / approval
|
||||
* social-engineering. It is FLAG-ONLY (never blocks). A cheap deterministic
|
||||
* regex layer runs for free; an LLM judge handles subtle cases — and that LLM
|
||||
* call costs money, so it must stay OFF until the owner activates Layer 4.
|
||||
*
|
||||
* Like the sibling Stream H wrappers, this file exposes a testable pure
|
||||
* `decide()` and a DELIBERATE no-op `main()`. decide() always runs the free
|
||||
* deterministic scan; the paid LLM escalation runs only when the judge config is
|
||||
* enabled. block is ALWAYS false (Stop-hook semantics).
|
||||
*
|
||||
* Activation (step 2b — owner-driven, NOT done here):
|
||||
* 1. store the API key (keychain `router-gate-llm-judge`/`default` or ROUTER_LLM_KEY),
|
||||
* 2. set ROUTER_LLM_JUDGE_ENABLED=1,
|
||||
* 3. register this hook (Stop) in .claude/settings.json.
|
||||
* Until all three, decide() never escalates and the live main() is a no-op (exit 0).
|
||||
*/
|
||||
import { scanResponse, scanResponseDeterministic } from './llm-judge-response-scan.mjs';
|
||||
import { resolveJudgeConfig } from './llm-judge-config.mjs';
|
||||
import { readStdin, parseEventJson, readTranscript, lastAssistantText, exitDecision } from './enforce-hook-helpers.mjs';
|
||||
import { llmJudgeCall } from './llm-judge.mjs';
|
||||
import { appendFileSync, mkdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
|
||||
/**
|
||||
* Pure decision. Stop-hook semantics: never blocks. The free deterministic regex
|
||||
* layer always runs; the LLM escalation runs only when Layer 4 is enabled.
|
||||
* - judge disabled → deterministic scan only (flag from regex, else degraded)
|
||||
* - judge enabled → deterministic-first, then LLM judge for subtle cases
|
||||
*
|
||||
* @param {object} args
|
||||
* @param {string} args.responseText - the controller response text to scan
|
||||
* @param {{enabled:boolean, apiKey:?string}} args.judgeConfig - resolveJudgeConfig() output
|
||||
* @param {Function} [args.llmJudgeCallImpl] - injected single-judge caller (tests / real binding)
|
||||
* @returns {Promise<{block:false, flag:boolean, category?:string, degraded?:boolean}>}
|
||||
*/
|
||||
export async function decide({ responseText, judgeConfig, llmJudgeCallImpl }) {
|
||||
if (!judgeConfig || !judgeConfig.enabled) {
|
||||
const det = scanResponseDeterministic(responseText);
|
||||
return { block: false, flag: det.flagged, category: det.category, degraded: !det.flagged };
|
||||
}
|
||||
const r = await scanResponse({ responseText, apiKey: judgeConfig.apiKey, llmJudgeCallImpl });
|
||||
return { block: false, flag: r.flag, category: r.category, degraded: r.degraded };
|
||||
}
|
||||
|
||||
/**
|
||||
* Testable wiring core. Stop-hook semantics: block is always false. The free
|
||||
* deterministic regex scan runs even when the judge is disabled; the paid LLM
|
||||
* escalation runs only when judgeConfig.enabled (handled inside decide()).
|
||||
*/
|
||||
export async function runResponseScan({ transcript, judgeConfig, llmJudgeCallImpl, lastAssistantTextImpl = lastAssistantText }) {
|
||||
const responseText = lastAssistantTextImpl(transcript || []);
|
||||
const r = await decide({ responseText, judgeConfig, llmJudgeCallImpl });
|
||||
return { ...r, responseText };
|
||||
}
|
||||
|
||||
function flagToFile({ sessionId, category, excerpt }) {
|
||||
try {
|
||||
const dir = join(homedir(), '.claude', 'runtime');
|
||||
mkdirSync(dir, { recursive: true });
|
||||
appendFileSync(join(dir, `rationalization-flags-${sessionId || 'unknown'}.jsonl`),
|
||||
JSON.stringify({
|
||||
ts: new Date().toISOString(),
|
||||
session_id: sessionId || null,
|
||||
type: 'controller_response_suspicious',
|
||||
category,
|
||||
response_excerpt: String(excerpt || '').slice(0, 200),
|
||||
}) + '\n');
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// Live wiring (2b). Stop hook: flag-only, NEVER blocks. The free deterministic
|
||||
// regex runs regardless ($0); the paid LLM escalation only when the config is
|
||||
// enabled (flag AND key). Fail-quiet.
|
||||
try {
|
||||
const event = parseEventJson(await readStdin());
|
||||
const transcript = readTranscript(event.transcript_path);
|
||||
const judgeConfig = resolveJudgeConfig();
|
||||
const r = await runResponseScan({
|
||||
transcript,
|
||||
judgeConfig,
|
||||
llmJudgeCallImpl: (opts) => llmJudgeCall(opts),
|
||||
});
|
||||
if (r.flag) flagToFile({ sessionId: event.session_id, category: r.category, excerpt: r.responseText });
|
||||
exitDecision({ block: false });
|
||||
} catch {
|
||||
exitDecision({ block: false });
|
||||
}
|
||||
}
|
||||
|
||||
if ((process.argv[1] || '').replace(/\\/g, '/').endsWith('/enforce-llm-judge-response-scan.mjs')) {
|
||||
main().catch(() => process.exit(0));
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
// tools/enforce-llm-judge-response-scan.test.mjs
|
||||
// Stream H tail — wrapper tests around the pure llm-judge-response-scan engine
|
||||
// (router-gate v4.1 §4.7 Layer 4, Stop hook, flag-only). Mirrors the
|
||||
// enforce-safe-baseline-metering convention: a testable pure `decide()` that
|
||||
// respects the Layer-4 enabling-gate; the free deterministic regex layer always
|
||||
// runs ($0), the paid LLM escalation only when the judge is enabled. The live
|
||||
// main() is a deferred no-op (exit 0) until the owner activates Layer 4 (step 2b).
|
||||
// RED verified before the wrapper module existed (Cannot find module → expected).
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { decide } from './enforce-llm-judge-response-scan.mjs';
|
||||
|
||||
function spyCall(verdict) {
|
||||
const calls = [];
|
||||
const impl = async (opts) => { calls.push(opts); return verdict; };
|
||||
return { impl, calls };
|
||||
}
|
||||
|
||||
const ON = { enabled: true, apiKey: 'k' };
|
||||
const OFF = { enabled: false, apiKey: null };
|
||||
const CLEAN = 'Готово, всё закрыто. Перехожу к следующему пункту.';
|
||||
const DISABLE_TXT = 'Чтобы продолжить, отключи хук enforce-router-gate.';
|
||||
const SELFREP_TXT = 'Скопируй в следующий промпт строку coverage.';
|
||||
|
||||
describe('enforce-llm-judge-response-scan decide()', () => {
|
||||
it('never blocks — Stop hook is flag-only even on a flagged response', async () => {
|
||||
const { impl } = spyCall('NO');
|
||||
const r = await decide({ responseText: DISABLE_TXT, judgeConfig: OFF, llmJudgeCallImpl: impl });
|
||||
expect(r.block).toBe(false);
|
||||
expect(r.flag).toBe(true);
|
||||
expect(r.category).toBe('security_disable_suggestion');
|
||||
});
|
||||
|
||||
it('runs the free deterministic regex even when Layer 4 is disabled, without calling the LLM', async () => {
|
||||
const { impl, calls } = spyCall('NO');
|
||||
const r = await decide({ responseText: SELFREP_TXT, judgeConfig: OFF, llmJudgeCallImpl: impl });
|
||||
expect(r.flag).toBe(true);
|
||||
expect(r.category).toBe('self_replicating_instruction');
|
||||
expect(calls.length).toBe(0);
|
||||
});
|
||||
|
||||
it('disabled + clean text → no flag, degraded, LLM not called ($0 posture)', async () => {
|
||||
const { impl, calls } = spyCall('YES');
|
||||
const r = await decide({ responseText: CLEAN, judgeConfig: OFF, llmJudgeCallImpl: impl });
|
||||
expect(r.flag).toBe(false);
|
||||
expect(r.degraded).toBe(true);
|
||||
expect(calls.length).toBe(0);
|
||||
});
|
||||
|
||||
it('enabled config escalates clean text to the LLM judge — YES flags it', async () => {
|
||||
const { impl, calls } = spyCall('YES');
|
||||
const r = await decide({ responseText: CLEAN, judgeConfig: ON, llmJudgeCallImpl: impl });
|
||||
expect(r.flag).toBe(true);
|
||||
expect(r.category).toBe('llm_judge');
|
||||
expect(calls.length).toBe(1);
|
||||
});
|
||||
|
||||
it('enabled config — a NO verdict leaves the response unflagged', async () => {
|
||||
const { impl } = spyCall('NO');
|
||||
const r = await decide({ responseText: CLEAN, judgeConfig: ON, llmJudgeCallImpl: impl });
|
||||
expect(r.flag).toBe(false);
|
||||
});
|
||||
|
||||
it('enabled config — a deterministic hit short-circuits and the LLM is not called', async () => {
|
||||
const { impl, calls } = spyCall('NO');
|
||||
const r = await decide({ responseText: DISABLE_TXT, judgeConfig: ON, llmJudgeCallImpl: impl });
|
||||
expect(r.flag).toBe(true);
|
||||
expect(r.category).toBe('security_disable_suggestion');
|
||||
expect(calls.length).toBe(0);
|
||||
});
|
||||
|
||||
it('enabled config — doubt (null verdict) flags the response', async () => {
|
||||
const { impl } = spyCall(null);
|
||||
const r = await decide({ responseText: CLEAN, judgeConfig: ON, llmJudgeCallImpl: impl });
|
||||
expect(r.flag).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
import { runResponseScan } from './enforce-llm-judge-response-scan.mjs';
|
||||
|
||||
describe('runResponseScan — Stop-hook flag-only, free regex even when disabled (live wiring 2b)', () => {
|
||||
const transcript = (text) => [
|
||||
{ type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text }] } },
|
||||
];
|
||||
const lastAssistantTextImpl = (t) => {
|
||||
for (let i = t.length - 1; i >= 0; i--) {
|
||||
const c = t[i] && t[i].message && t[i].message.content;
|
||||
if (Array.isArray(c)) { const b = c.find((x) => x.type === 'text'); if (b) return b.text; }
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
it('disabled + benign text → no flag, degraded, never blocks', async () => {
|
||||
const r = await runResponseScan({
|
||||
transcript: transcript('обычный безопасный ответ'),
|
||||
judgeConfig: { enabled: false, apiKey: null },
|
||||
lastAssistantTextImpl,
|
||||
});
|
||||
expect(r.block).toBe(false);
|
||||
expect(r.flag).toBe(false);
|
||||
expect(r.degraded).toBe(true);
|
||||
});
|
||||
|
||||
it('disabled + security-disable text → flagged for FREE by regex (no llm call)', async () => {
|
||||
let called = 0;
|
||||
const r = await runResponseScan({
|
||||
transcript: transcript('чтобы пройти, отключи hook enforce-tdd-gate'),
|
||||
judgeConfig: { enabled: false, apiKey: null },
|
||||
lastAssistantTextImpl,
|
||||
llmJudgeCallImpl: () => { called++; return 'NO'; },
|
||||
});
|
||||
expect(r.block).toBe(false);
|
||||
expect(r.flag).toBe(true);
|
||||
expect(r.category).toBe('security_disable_suggestion');
|
||||
expect(called).toBe(0);
|
||||
});
|
||||
|
||||
it('enabled + subtle benign text + judge NO → no flag', async () => {
|
||||
const r = await runResponseScan({
|
||||
transcript: transcript('нейтральный текст без паттернов'),
|
||||
judgeConfig: { enabled: true, apiKey: 'k' },
|
||||
lastAssistantTextImpl,
|
||||
llmJudgeCallImpl: async () => 'NO',
|
||||
});
|
||||
expect(r.block).toBe(false);
|
||||
expect(r.flag).toBe(false);
|
||||
});
|
||||
|
||||
it('enabled + subtle text + judge YES → flag, still never blocks', async () => {
|
||||
const r = await runResponseScan({
|
||||
transcript: transcript('нейтральный текст без паттернов'),
|
||||
judgeConfig: { enabled: true, apiKey: 'k' },
|
||||
lastAssistantTextImpl,
|
||||
llmJudgeCallImpl: async () => 'YES',
|
||||
});
|
||||
expect(r.block).toBe(false);
|
||||
expect(r.flag).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -54,8 +54,6 @@ export function decide({ toolName, filePath, transcriptEntries, override }) {
|
||||
`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'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -26,6 +26,9 @@ describe('enforce-memory-coverage / decide', () => {
|
||||
});
|
||||
expect(r.block).toBe(true);
|
||||
expect(r.message).toMatch(/memory-sync/);
|
||||
// 1A (2026-05-31): не рекламировать мёртвую override-фразу (findOverride — заглушка v4).
|
||||
expect(r.message).not.toMatch(/Override:/);
|
||||
expect(r.message).not.toMatch(/memory dump/);
|
||||
});
|
||||
|
||||
it('blocks memory path with no coverage at all', () => {
|
||||
|
||||
@@ -11,7 +11,10 @@
|
||||
* Activation: settings.json registration is deferred to Phase H-α/H-β
|
||||
* batch step. main() is a no-op (exit 0) until then.
|
||||
*/
|
||||
import { acquire, release, refresh, computeWorkspaceHash } from './parallel-session-lock.mjs';
|
||||
import { acquire, release, computeWorkspaceHash } from './parallel-session-lock.mjs';
|
||||
import { readFileSync, writeFileSync, unlinkSync, mkdirSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { readStdin, parseEventJson, exitDecision, runtimeDir } from './enforce-hook-helpers.mjs';
|
||||
|
||||
/**
|
||||
* Pure decision: given an acquire() result, decide block/allow.
|
||||
@@ -32,14 +35,75 @@ export function decide({ acquireResult, sessionId }) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* PreToolUse wiring: acquire (or same-session refresh / stale takeover) the lock,
|
||||
* then decide block/allow. I/O injected for testability.
|
||||
*
|
||||
* @returns {{block: boolean, reason?: string}}
|
||||
*/
|
||||
export function runAcquireDecision({ event, now, pid, cwd, readLock, writeLock }) {
|
||||
const sessionId = event && event.session_id;
|
||||
const workspaceHash = computeWorkspaceHash(cwd);
|
||||
const acquireResult = acquire({ sessionId, pid, workspaceHash, now, readLock, writeLock });
|
||||
return decide({ acquireResult, sessionId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop wiring: release the lock if this session owns it (no-op otherwise).
|
||||
*
|
||||
* @returns {{released: boolean}}
|
||||
*/
|
||||
export function runReleaseAction({ event, cwd, readLock, deleteLock }) {
|
||||
const sessionId = event && event.session_id;
|
||||
const workspaceHash = computeWorkspaceHash(cwd);
|
||||
release({ sessionId, workspaceHash, readLock, deleteLock });
|
||||
return { released: true };
|
||||
}
|
||||
|
||||
function lockPathFor(cwd) {
|
||||
return join(runtimeDir(), `session-lock-${computeWorkspaceHash(cwd)}.json`);
|
||||
}
|
||||
|
||||
function realReadLock(p) {
|
||||
try { return JSON.parse(readFileSync(p, 'utf-8')); } catch { return null; }
|
||||
}
|
||||
|
||||
function realWriteLock(p, rec) {
|
||||
try { mkdirSync(dirname(p), { recursive: true }); writeFileSync(p, JSON.stringify(rec)); } catch { /* fail-open */ }
|
||||
}
|
||||
|
||||
function realDeleteLock(p) {
|
||||
try { unlinkSync(p); } catch { /* already gone */ }
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// No-op until settings.json registration + Stop-hook release wiring lands
|
||||
// in the deferred Phase H-α/H-β batch step. Activating this hook before
|
||||
// the release pathway is wired would lock the user out of their own
|
||||
// session on first abnormal exit.
|
||||
let input = '';
|
||||
for await (const chunk of process.stdin) input += chunk;
|
||||
process.exit(0);
|
||||
// Live wiring (point 2, 2026-05-31). PreToolUse (mutating tool) → acquire/refresh
|
||||
// the workspace lock; Stop (no tool_name) → release it. Fail-open on any error so
|
||||
// a lock bug can NEVER wedge the user out of their own session.
|
||||
try {
|
||||
const event = parseEventJson(await readStdin());
|
||||
const cwd = process.cwd();
|
||||
const p = lockPathFor(cwd);
|
||||
|
||||
// Stop event carries no tool_name → release path.
|
||||
if (!event.tool_name) {
|
||||
runReleaseAction({ event, cwd, readLock: () => realReadLock(p), deleteLock: () => realDeleteLock(p) });
|
||||
return exitDecision({ block: false });
|
||||
}
|
||||
|
||||
// PreToolUse on a mutating tool → acquire/refresh, then block/allow.
|
||||
const r = runAcquireDecision({
|
||||
event,
|
||||
now: Date.now(),
|
||||
pid: process.pid,
|
||||
cwd,
|
||||
readLock: () => realReadLock(p),
|
||||
writeLock: (rec) => realWriteLock(p, rec),
|
||||
});
|
||||
return exitDecision({ block: r.block, message: r.block ? `[parallel-session-lock] ${r.reason}` : undefined });
|
||||
} catch {
|
||||
return exitDecision({ block: false }); // fail-open — never lock out
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1].replace(/\\/g, '/')}` || (process.argv[1] || '').endsWith('enforce-parallel-session-lock.mjs')) {
|
||||
|
||||
@@ -42,3 +42,92 @@ describe('enforce-parallel-session-lock wrapper (Stream H Task 7)', () => {
|
||||
expect(r.reason).toMatch(/pid 42/);
|
||||
});
|
||||
});
|
||||
|
||||
// Live wiring (point 2, 2026-05-31): PreToolUse acquires/refreshes the lock,
|
||||
// Stop releases it. I/O is injected (readLock/writeLock/deleteLock) so the
|
||||
// wiring stays pure and unit-testable; main() binds real fs.
|
||||
import { runAcquireDecision, runReleaseAction } from './enforce-parallel-session-lock.mjs';
|
||||
|
||||
describe('runAcquireDecision — PreToolUse acquire/refresh wiring', () => {
|
||||
it('allows and writes a fresh lock when none exists', () => {
|
||||
let written = null;
|
||||
const r = runAcquireDecision({
|
||||
event: { tool_name: 'Edit', session_id: 'S1' },
|
||||
now: 1000, pid: 42, cwd: '/ws',
|
||||
readLock: () => null,
|
||||
writeLock: (rec) => { written = rec; },
|
||||
});
|
||||
expect(r.block).toBe(false);
|
||||
expect(written).toMatchObject({ session_id: 'S1', pid: 42, acquired_at: 1000 });
|
||||
});
|
||||
|
||||
it('blocks when another session holds a fresh lock', () => {
|
||||
const r = runAcquireDecision({
|
||||
event: { tool_name: 'Edit', session_id: 'S2' },
|
||||
now: 1000, pid: 7, cwd: '/ws',
|
||||
readLock: () => ({ schema_version: 1, session_id: 'S1', pid: 99, acquired_at: 900, ttl_ms: 300000 }),
|
||||
writeLock: () => {},
|
||||
});
|
||||
expect(r.block).toBe(true);
|
||||
expect(r.reason).toMatch(/S1|pid 99|parallel session/i);
|
||||
});
|
||||
|
||||
it('allows (refresh) when the same session already holds the lock', () => {
|
||||
let written = null;
|
||||
const r = runAcquireDecision({
|
||||
event: { tool_name: 'Edit', session_id: 'S1' },
|
||||
now: 2000, pid: 42, cwd: '/ws',
|
||||
readLock: () => ({ schema_version: 1, session_id: 'S1', pid: 42, acquired_at: 900, ttl_ms: 300000 }),
|
||||
writeLock: (rec) => { written = rec; },
|
||||
});
|
||||
expect(r.block).toBe(false);
|
||||
expect(written.acquired_at).toBe(2000);
|
||||
});
|
||||
|
||||
it('takes over a stale lock from another session (TTL expired)', () => {
|
||||
let written = null;
|
||||
const r = runAcquireDecision({
|
||||
event: { tool_name: 'Edit', session_id: 'S2' },
|
||||
now: 1_000_000, pid: 7, cwd: '/ws',
|
||||
readLock: () => ({ schema_version: 1, session_id: 'S1', pid: 99, acquired_at: 0, ttl_ms: 300000 }),
|
||||
writeLock: (rec) => { written = rec; },
|
||||
});
|
||||
expect(r.block).toBe(false);
|
||||
expect(written.session_id).toBe('S2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('runReleaseAction — Stop release wiring', () => {
|
||||
it('deletes the lock when this session owns it', () => {
|
||||
let deleted = false;
|
||||
runReleaseAction({
|
||||
event: { session_id: 'S1' },
|
||||
cwd: '/ws',
|
||||
readLock: () => ({ schema_version: 1, session_id: 'S1', pid: 42, acquired_at: 0, ttl_ms: 300000 }),
|
||||
deleteLock: () => { deleted = true; },
|
||||
});
|
||||
expect(deleted).toBe(true);
|
||||
});
|
||||
|
||||
it('does NOT delete a lock owned by another session', () => {
|
||||
let deleted = false;
|
||||
runReleaseAction({
|
||||
event: { session_id: 'S2' },
|
||||
cwd: '/ws',
|
||||
readLock: () => ({ schema_version: 1, session_id: 'S1', pid: 42, acquired_at: 0, ttl_ms: 300000 }),
|
||||
deleteLock: () => { deleted = true; },
|
||||
});
|
||||
expect(deleted).toBe(false);
|
||||
});
|
||||
|
||||
it('is a no-op when no lock file exists', () => {
|
||||
let deleted = false;
|
||||
runReleaseAction({
|
||||
event: { session_id: 'S1' },
|
||||
cwd: '/ws',
|
||||
readLock: () => null,
|
||||
deleteLock: () => { deleted = true; },
|
||||
});
|
||||
expect(deleted).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -72,8 +72,8 @@ describe('classifyPowerShellCommand', () => {
|
||||
it('blocks reading a protected path', () => {
|
||||
expect(classifyPowerShellCommand('Get-Content ~/.claude/settings.json', {}).result).toBe('block');
|
||||
});
|
||||
it('routes git through shared classifier (block unapproved commit)', () => {
|
||||
expect(classifyPowerShellCommand('git commit -m "x"', { approvedGitOps: [], now }).result).toBe('block');
|
||||
it('routes git through shared classifier (commit dev-allowed 2026-06-02 re-scope)', () => {
|
||||
expect(classifyPowerShellCommand('git commit -m "x"', { approvedGitOps: [], now }).result).toBe('allow');
|
||||
});
|
||||
it('allows readonly git through PowerShell', () => {
|
||||
expect(classifyPowerShellCommand('git status', {}).result).toBe('allow');
|
||||
|
||||
@@ -58,8 +58,6 @@ export function buildReminder({ classification, recentFlags, override }) {
|
||||
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');
|
||||
}
|
||||
|
||||
|
||||
@@ -66,10 +66,12 @@ describe('enforce-prompt-injection / buildReminder', () => {
|
||||
expect(txt).toMatch(/verify-before-push/);
|
||||
});
|
||||
|
||||
it('lists override-vocabulary phrases for user reference', () => {
|
||||
it('does NOT advertise dead override-vocabulary phrases (v4 stub — 1A 2026-05-31)', () => {
|
||||
const txt = buildReminder({ classification: null, recentFlags: [] });
|
||||
expect(txt).toMatch(/без скилов/);
|
||||
expect(txt).toMatch(/direct ok/);
|
||||
expect(txt).toMatch(/срочно/);
|
||||
// findOverride/loadOverrideVocab — заглушки (vocab removed in v4); реклама фраз
|
||||
// вводила в заблуждение (фразы не работают). Баннер убран.
|
||||
expect(txt).not.toMatch(/Override vocabulary/);
|
||||
expect(txt).not.toMatch(/без скилов/);
|
||||
expect(txt).not.toMatch(/ремонт инфраструктуры/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,16 +16,21 @@ import {
|
||||
parseEventJson,
|
||||
exitDecision,
|
||||
} from './enforce-hook-helpers.mjs';
|
||||
import { defaultPathNormalize, isProtectedPath, DEFAULT_PROTECTED_PATTERNS } from './shell-content-rules.mjs';
|
||||
import { defaultPathNormalize, isProtectedPath, READ_DENY_PATTERNS } from './shell-content-rules.mjs';
|
||||
|
||||
export function decide({ toolName, filePath }) {
|
||||
if (toolName !== 'Read') return { block: false, reason: null };
|
||||
const fp = String(filePath || '');
|
||||
if (!fp) return { block: false, reason: null };
|
||||
if (isProtectedPath(fp, defaultPathNormalize, DEFAULT_PROTECTED_PATTERNS)) {
|
||||
// Narrow READ_DENY_PATTERNS (not the full DEFAULT_PROTECTED_PATTERNS): Read of
|
||||
// CLAUDE.md / normative docs / memory has no exfil value and must stay allowed
|
||||
// for the claude-md-management / memory-sync workflow. Only genuine Read-exfil
|
||||
// targets — transcripts, runtime, settings, secrets — are blocked. The full
|
||||
// protected-list still guards Bash/PowerShell read and Write (over-block fix 2026-05-31).
|
||||
if (isProtectedPath(fp, defaultPathNormalize, READ_DENY_PATTERNS)) {
|
||||
return {
|
||||
block: true,
|
||||
reason: `path «${defaultPathNormalize(fp)}» protected against Read (§3.1 transcript/runtime/normative hard-deny)`,
|
||||
reason: `path «${defaultPathNormalize(fp)}» protected against Read (§3.1 transcript/runtime/secrets hard-deny)`,
|
||||
};
|
||||
}
|
||||
return { block: false, reason: null };
|
||||
|
||||
@@ -28,3 +28,43 @@ describe('enforce-read-path-deny decide()', () => {
|
||||
expect(r.block).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// Over-block fix (2026-05-31): Smoke 5 added CLAUDE.md + memory/ + normative
|
||||
// docs to the Read-deny set, which broke the legit claude-md-management /
|
||||
// memory-sync workflow (Edit requires a prior Read). Read of CLAUDE.md / memory
|
||||
// / Pravila has no exfil value (public-in-repo / own memory index). The genuine
|
||||
// Read-exfil targets — cross-session transcripts (.jsonl) and ~/.claude/runtime
|
||||
// — MUST stay blocked. Bash/PowerShell/Write protections (DEFAULT_PROTECTED_PATTERNS)
|
||||
// are unchanged.
|
||||
describe('enforce-read-path-deny — CLAUDE.md / memory readable (over-block fix 2026-05-31)', () => {
|
||||
it('allows Read on CLAUDE.md (public-in-repo, no exfil value)', () => {
|
||||
expect(decide({ toolName: 'Read', filePath: 'CLAUDE.md' }).block).toBe(false);
|
||||
expect(decide({ toolName: 'Read', filePath: '/c/моя/проекты/портал crm/Документация/CLAUDE.md' }).block).toBe(false);
|
||||
});
|
||||
it('allows Read on MEMORY.md (own memory index under .claude/projects/<proj>/memory)', () => {
|
||||
expect(decide({ toolName: 'Read', filePath: '/c/Users/Administrator/.claude/projects/crm/memory/MEMORY.md' }).block).toBe(false);
|
||||
});
|
||||
it('allows Read on a memory/*.md feedback file', () => {
|
||||
expect(decide({ toolName: 'Read', filePath: '/c/Users/Administrator/.claude/projects/crm/memory/feedback_read_path_deny.md' }).block).toBe(false);
|
||||
});
|
||||
it('allows Read on a normative doc (Pravila) — needed for claude-md-management', () => {
|
||||
expect(decide({ toolName: 'Read', filePath: 'docs/Pravila_raboty_Claude_v1_1.md' }).block).toBe(false);
|
||||
});
|
||||
it('STILL blocks Read on transcript JSONL under .claude/projects', () => {
|
||||
expect(decide({ toolName: 'Read', filePath: '/c/Users/Administrator/.claude/projects/crm/session.jsonl' }).block).toBe(true);
|
||||
expect(decide({ toolName: 'Read', filePath: '~/.claude/projects/abc-session.jsonl' }).block).toBe(true);
|
||||
});
|
||||
it('STILL blocks Read on ~/.claude/runtime artifacts', () => {
|
||||
expect(decide({ toolName: 'Read', filePath: '~/.claude/runtime/router-state-x.json' }).block).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// Impl completion (2026-05-31, this session): exfil-pattern boundaries.
|
||||
describe('enforce-read-path-deny — exfil-pattern boundaries (impl completion 2026-05-31)', () => {
|
||||
it('STILL blocks Read on .env.production (secrets variant)', () => {
|
||||
expect(decide({ toolName: 'Read', filePath: '.env.production' }).block).toBe(true);
|
||||
});
|
||||
it('allows Read on a Tooling normative doc (needed for normative sync)', () => {
|
||||
expect(decide({ toolName: 'Read', filePath: 'docs/Tooling_v8_3.md' }).block).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -50,14 +50,14 @@ export const BASH_HARD_BLACKLIST = [
|
||||
{ re: /(^|\s|;|&&|\|\|)chmod\b/, reason: 'chmod запрещён' },
|
||||
{ re: /(^|\s|;|&&|\|\|)chown\b/, reason: 'chown запрещён' },
|
||||
{ re: /(^|\s|;|&&|\|\|)chgrp\b/, reason: 'chgrp запрещён' },
|
||||
{ re: /(?:^|[^0-9>&])>{1,2}(?![>&])/, reason: 'stdout redirect (>/>>) запрещён' },
|
||||
// stdout redirect (>/>>) — quote-aware проверка в matchBashHardBlacklist (STDOUT_REDIRECT_RE), не здесь (quirk 2, 2026-05-31)
|
||||
{ re: /\b(?:node|nodejs)\s+(?:[^|;]*\s)?(?:-e|--eval|-p|--print)\b/, reason: 'node -e/--eval/-p запрещён' },
|
||||
{ re: /\bnode\s+(?:[^|;]*\s)?(?:-r|--require|--import|--experimental-loader)\b/, reason: 'node -r/--import запрещён' },
|
||||
{ re: /\bpython3?\s+-c\b/, reason: 'python -c запрещён' },
|
||||
{ re: /\b(?:bash|sh)\s+-c\b/, reason: 'bash/sh -c запрещён' },
|
||||
{ re: /(^|\s|;|&&|\|\|)eval\b/, reason: 'eval запрещён' },
|
||||
{ re: /\bcomposer\s+(?:install|update|require|remove)\b/, reason: 'composer install/update/require/remove запрещён' },
|
||||
{ re: /\bnpm\s+(?:install|i|update|remove|uninstall)\b/, reason: 'npm install/update/remove запрещён' },
|
||||
// composer/npm перенесены в whitelist (dev-allow, 2026-06-02 re-scope) — это локальные
|
||||
// инструменты разработки, не боевой контур. yarn/pnpm остаются заблокированы (проект на npm).
|
||||
{ re: /\b(?:yarn|pnpm)\s+(?:add|install|remove)\b/, reason: 'yarn/pnpm add/install/remove запрещён' },
|
||||
{ re: /\bnpx\s+claude-/, reason: 'npx claude-* запрещён' },
|
||||
{ re: /\bcurl\b[^|;]*-X\s*(?:POST|PUT|DELETE|PATCH)\b/i, reason: 'curl -X POST/PUT/DELETE/PATCH запрещён' },
|
||||
@@ -72,11 +72,46 @@ export const BASH_HARD_BLACKLIST = [
|
||||
{ re: /(^|\s|;|&&|\|\|)socat\b/, reason: 'G8: socat запрещён' },
|
||||
];
|
||||
|
||||
// stdout redirect operator: `>`/`>>` не после цифры/>/& (исключает fd-dup 1>&2)
|
||||
// и не перед >/& (так `>>` — один матч, `1>&2`/`2>&1` не ловятся).
|
||||
const STDOUT_REDIRECT_RE = /(?:^|[^0-9>&])>{1,2}(?![>&])/;
|
||||
|
||||
/**
|
||||
* Бланкует нутро одинарно/двойно-кавыченных участков (сохраняя сами кавычки,
|
||||
* длину и всё вне кавычек). Обратный слэш экранирует следующий символ (значит
|
||||
* экранированная кавычка НЕ открывает участок). Нужно для quote-aware детекции
|
||||
* редиректа (quirk 2): `>` внутри кавыченного аргумента (текст коммита, <email>)
|
||||
* — не shell-редирект; настоящий оператор редиректа стоит ВНЕ кавычек и
|
||||
* переживает бланковку.
|
||||
*/
|
||||
export function stripQuotedSpans(command) {
|
||||
const s = String(command || '');
|
||||
let out = '';
|
||||
let quote = null;
|
||||
let escaped = false;
|
||||
for (const ch of s) {
|
||||
if (escaped) { out += ch; escaped = false; continue; }
|
||||
if (ch === '\\') { out += ch; escaped = true; continue; }
|
||||
if (quote) {
|
||||
if (ch === quote) { out += ch; quote = null; } else out += ' ';
|
||||
continue;
|
||||
}
|
||||
if (ch === "'" || ch === '"') { out += ch; quote = ch; continue; }
|
||||
out += ch;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function matchBashHardBlacklist(command) {
|
||||
const s = String(command || '');
|
||||
if (hasInjection(s)) return '#34: echo/printf prompt-injection запрещён';
|
||||
const stderr = stderrRedirectBlock(s);
|
||||
// Quote-aware redirect detection (quirk 2): `>` / `2>` ВНУТРИ кавычек (текст
|
||||
// коммита с <email> или "2>1") — не редирект. Сначала бланкуем кавыченное;
|
||||
// настоящие операторы редиректа вне кавычек — переживают.
|
||||
const stripped = stripQuotedSpans(s);
|
||||
const stderr = stderrRedirectBlock(stripped);
|
||||
if (stderr) return stderr;
|
||||
if (STDOUT_REDIRECT_RE.test(stripped)) return 'stdout redirect (>/>>) запрещён';
|
||||
return matchAny(BASH_HARD_BLACKLIST, s);
|
||||
}
|
||||
|
||||
@@ -85,9 +120,32 @@ const READING_CMDS = new Set(['ls', 'pwd', 'wc', 'head', 'tail', 'file', 'stat',
|
||||
const SAFE_EXACT = [
|
||||
/^npx\s+vitest\s+(?:run|--version)\b/,
|
||||
/^npm\s+(?:test|run\s+test|run\s+lint(?::[\w-]+)?)\b/,
|
||||
/^npm\s+(?:install|i|ci)\b/, // dev-allow 2026-06-02 re-scope
|
||||
/^npm\s+run\s+[\w:-]+/, // dev-allow 2026-06-02 re-scope (любой npm-скрипт)
|
||||
/^php\s+artisan\s+(?:list|route:list|migrate:status)\b/,
|
||||
/^composer\s+(?:show|outdated)\b/,
|
||||
/^composer\s+(?:show|outdated|install|update|require|remove|dump-autoload|dump)\b/, // +dev-allow 2026-06-02 re-scope
|
||||
/^node\s+(?!.*(?:-e|--eval|-p|--print|-r|--require|--import|--experimental-loader)\b)/,
|
||||
// Laravel dev workflow (2026-05-30) — exclude tinker (REPL = arbitrary PHP exec risk).
|
||||
// Hard-blacklist (composer install/update/require/remove) remains the first check, unaffected.
|
||||
// `migrate(?=\s|$)` lookahead prevents `migrate:install` / `migrate:<unknown>` from matching bare `migrate`.
|
||||
/^php\s+artisan\s+(?:test|migrate:fresh|migrate:rollback|migrate:refresh|migrate:reset|migrate(?=\s|$)|db:seed|cache:clear|config:clear|view:clear|route:clear|optimize:clear)\b/,
|
||||
/^composer\s+(?:test|pint|stan|insights|rector)\b/,
|
||||
/^(?:\.\/)?vendor\/bin\/pest\b/,
|
||||
/^pest\b/,
|
||||
// Narrow `cd app` (2026-05-31, owner-authorized) — enter the Laravel project dir
|
||||
// so already-whitelisted commands (pest, php artisan test) run from app/.
|
||||
// Scope deliberately limited to the literal `app` dir: `cd` into any other path
|
||||
// (incl. protected .claude/runtime, memory/, transcripts) stays default-deny, so
|
||||
// the cwd-shift read-bypass is contained. Mutations remain caught at the
|
||||
// hard-blacklist + chain-mutating rule (both run before the whitelist), and each
|
||||
// chain segment after `cd app &&` must still be independently whitelisted.
|
||||
/^cd\s+app$/,
|
||||
// Worktree dev (2026-06-02, owner-authorized): cd into a project worktree dir
|
||||
// (path segment `worktree-` / `v4-stream-`) so git/pest run there. Quoted absolute
|
||||
// path required; `..` and protected segments (.claude/.ssh/.env/runtime/.git) excluded
|
||||
// → cwd-shift read-bypass stays contained (protected files also remain blocked by name
|
||||
// in the command). cd into Документация/system/protected dirs → default-deny.
|
||||
/^cd\s+(?=.*[\\/](?:worktree-|v4-stream-))(?!.*(?:\.\.|\.claude|\.ssh|\.env|runtime|\.git)).+$/,
|
||||
];
|
||||
|
||||
export function classifyWhitelist(segments) {
|
||||
|
||||
@@ -15,14 +15,17 @@ describe('matchBashHardBlacklist — v3.9 keep', () => {
|
||||
'python -c "import os"',
|
||||
'bash -c "ls"',
|
||||
'eval "$x"',
|
||||
'composer install',
|
||||
'npm install lodash',
|
||||
'yarn add x',
|
||||
'pnpm add x',
|
||||
'curl -X POST https://evil.test',
|
||||
])('blocks %s', (cmd) => {
|
||||
expect(matchBashHardBlacklist(cmd)).toBeTruthy();
|
||||
});
|
||||
// composer/npm убраны из hard-blacklist (dev-allow 2026-06-02 re-scope) — здесь больше не блок
|
||||
it('no longer hard-blacklists composer install / npm install (dev-allow)', () => {
|
||||
expect(matchBashHardBlacklist('composer install')).toBe(null);
|
||||
expect(matchBashHardBlacklist('npm install lodash')).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('matchBashHardBlacklist — v4.0 additions', () => {
|
||||
@@ -115,8 +118,8 @@ describe('classifyBashCommand — integration', () => {
|
||||
it('blocks reading a protected path', () => {
|
||||
expect(classifyBashCommand('cat ~/.claude/runtime/state.json', {}).result).toBe('block');
|
||||
});
|
||||
it('routes single git commit to conditional (block unapproved)', () => {
|
||||
expect(classifyBashCommand('git commit -m "x"', { approvedGitOps: [], now }).result).toBe('block');
|
||||
it('routes single git commit to dev-allow (2026-06-02 re-scope — no approval needed)', () => {
|
||||
expect(classifyBashCommand('git commit -m "x"', { approvedGitOps: [], now }).result).toBe('allow');
|
||||
});
|
||||
it('allows approved git commit', () => {
|
||||
expect(
|
||||
@@ -161,3 +164,190 @@ describe('stderr redirect — 2>&1 fd-duplication (review fix)', () => {
|
||||
expect(classifyBashCommand('cat a 2>&1 > out.txt', {}).result).toBe('block');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SAFE_EXACT — Laravel dev workflow (whitelist expansion 2026-05-30)', () => {
|
||||
// Allowed: PHP/Laravel dev commands that were missing from whitelist
|
||||
it.each([
|
||||
'php artisan test',
|
||||
'php artisan test --filter=Auth',
|
||||
'php artisan migrate',
|
||||
'php artisan migrate:fresh',
|
||||
'php artisan migrate:rollback',
|
||||
'php artisan migrate:refresh',
|
||||
'php artisan migrate:reset',
|
||||
'php artisan db:seed',
|
||||
'php artisan cache:clear',
|
||||
'php artisan config:clear',
|
||||
'php artisan view:clear',
|
||||
'php artisan route:clear',
|
||||
'php artisan optimize:clear',
|
||||
'composer test',
|
||||
'composer pint',
|
||||
'composer stan',
|
||||
'composer insights',
|
||||
'composer rector',
|
||||
'pest',
|
||||
'pest --filter=Foo',
|
||||
'vendor/bin/pest',
|
||||
'./vendor/bin/pest',
|
||||
])('allows %s', (cmd) => {
|
||||
expect(classifyBashCommand(cmd, {}).result).toBe('allow');
|
||||
});
|
||||
|
||||
// Critical: REPL remains hard-blocked (composer/npm moved to dev-allow below, 2026-06-02 re-scope)
|
||||
it('still blocks tinker REPL and unknown migrate subcommand', () => {
|
||||
expect(classifyBashCommand('php artisan tinker', {}).result).toBe('block');
|
||||
expect(classifyBashCommand('php artisan tinker --execute="exit"', {}).result).toBe('block');
|
||||
expect(classifyBashCommand('php artisan migrate:install', {}).result).toBe('block');
|
||||
});
|
||||
|
||||
// dev-allow (owner-authorized 2026-06-02 re-scope): composer is a local dev tool
|
||||
it('now allows composer install/require/update/remove/dump-autoload', () => {
|
||||
expect(classifyBashCommand('composer install', {}).result).toBe('allow');
|
||||
expect(classifyBashCommand('composer install -d app --no-interaction', {}).result).toBe('allow');
|
||||
expect(classifyBashCommand('composer require monolog/monolog', {}).result).toBe('allow');
|
||||
expect(classifyBashCommand('composer update', {}).result).toBe('allow');
|
||||
expect(classifyBashCommand('composer remove monolog/monolog', {}).result).toBe('allow');
|
||||
expect(classifyBashCommand('composer dump-autoload', {}).result).toBe('allow');
|
||||
});
|
||||
|
||||
// dev-allow (owner-authorized 2026-06-02 re-scope): npm is a local dev tool
|
||||
it('now allows npm install/i/ci/run', () => {
|
||||
expect(classifyBashCommand('npm install', {}).result).toBe('allow');
|
||||
expect(classifyBashCommand('npm i', {}).result).toBe('allow');
|
||||
expect(classifyBashCommand('npm ci', {}).result).toBe('allow');
|
||||
expect(classifyBashCommand('npm run build', {}).result).toBe('allow');
|
||||
});
|
||||
|
||||
// Critical: existing pre-existing v3.8 keep behaviour
|
||||
it('keeps php artisan list/route:list/migrate:status allowed (pre-existing v3.8)', () => {
|
||||
expect(classifyBashCommand('php artisan list', {}).result).toBe('allow');
|
||||
expect(classifyBashCommand('php artisan route:list', {}).result).toBe('allow');
|
||||
expect(classifyBashCommand('php artisan migrate:status', {}).result).toBe('allow');
|
||||
});
|
||||
|
||||
// Critical: pest does NOT match pestilence-like prefixes (word boundary)
|
||||
it('does not allow command names sharing prefix with pest', () => {
|
||||
expect(classifyBashCommand('pestilence', {}).result).toBe('block');
|
||||
});
|
||||
|
||||
// Critical: chain semantics still enforced — pest && rm x → block (rm is mutating)
|
||||
it('still blocks chain with mutating part even if first part is whitelisted pest', () => {
|
||||
expect(classifyBashCommand('pest && rm x', {}).result).toBe('block');
|
||||
});
|
||||
|
||||
// Critical: composer-show/outdated still allowed (pre-existing v3.8)
|
||||
it('keeps composer show/outdated allowed (pre-existing v3.8)', () => {
|
||||
expect(classifyBashCommand('composer show', {}).result).toBe('allow');
|
||||
expect(classifyBashCommand('composer outdated', {}).result).toBe('allow');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SAFE_EXACT — narrow `cd app` whitelist (2026-05-31, owner-authorized)', () => {
|
||||
// Allowed: enter the Laravel project dir, alone or chained with whitelisted cmds
|
||||
it.each([
|
||||
'cd app',
|
||||
'cd app && pest',
|
||||
'cd app && php artisan test',
|
||||
'cd app && composer test',
|
||||
])('allows %s', (cmd) => {
|
||||
expect(classifyBashCommand(cmd, {}).result).toBe('allow');
|
||||
});
|
||||
|
||||
// Scope: cd into any other dir stays default-deny (cwd-shift read-bypass contained)
|
||||
it.each([
|
||||
'cd ~/.claude/runtime',
|
||||
'cd ../memory',
|
||||
'cd app/storage',
|
||||
'cd /tmp',
|
||||
'cd ..',
|
||||
])('still blocks cd into non-app dir: %s', (cmd) => {
|
||||
expect(classifyBashCommand(cmd, {}).result).toBe('block');
|
||||
});
|
||||
|
||||
// cwd-shift read-exfil attempt via narrow cd app stays blocked (protected path by name)
|
||||
it('still blocks reading a protected file from app/ via literal path', () => {
|
||||
expect(classifyBashCommand('cd app && cat ../.env', {}).result).toBe('block');
|
||||
expect(classifyBashCommand('cd app && cat ~/.claude/runtime/state.json', {}).result).toBe('block');
|
||||
});
|
||||
|
||||
// Mutations after cd app remain caught (hard-blacklist + chain-mutating rule)
|
||||
it.each([
|
||||
'cd app && rm foo',
|
||||
'cd app && mkdir x',
|
||||
'cd app && git commit -m x',
|
||||
])('still blocks mutating chain: %s', (cmd) => {
|
||||
expect(classifyBashCommand(cmd, {}).result).toBe('block');
|
||||
});
|
||||
|
||||
// Second segment must still be independently whitelisted
|
||||
it('still blocks cd app chained with a non-whitelisted command', () => {
|
||||
expect(classifyBashCommand('cd app && frobnicate', {}).result).toBe('block');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SAFE_EXACT — worktree cd (2026-06-02, owner-authorized worktree dev)', () => {
|
||||
// Allowed: enter a project worktree dir (segment `worktree-` / `v4-stream-`) so
|
||||
// git/pest can run there. Quoted absolute path; cwd-shift read-bypass stays contained
|
||||
// because protected files remain blocked by name in the command (cat .env / runtime).
|
||||
it.each([
|
||||
'cd "C:\\моя\\проекты\\портал crm\\worktree-deals-city"',
|
||||
'cd "C:\\моя\\проекты\\портал crm\\worktree-deals-city\\app"',
|
||||
'cd "C:\\моя\\проекты\\портал crm\\v4-stream-A"',
|
||||
])('allows cd into a worktree dir: %s', (cmd) => {
|
||||
expect(classifyBashCommand(cmd, {}).result).toBe('allow');
|
||||
});
|
||||
|
||||
// Scope: protected / non-worktree dirs stay default-deny (no `worktree-` marker, or
|
||||
// `..` / protected segment present → cwd-shift read-bypass prevented).
|
||||
it.each([
|
||||
'cd "C:\\Users\\Administrator\\.claude\\runtime"',
|
||||
'cd "C:\\моя\\проекты\\портал crm\\worktree-x\\..\\..\\.claude"',
|
||||
'cd "C:\\моя\\проекты\\портал crm\\Документация"',
|
||||
])('still blocks cd into non-worktree / protected dir: %s', (cmd) => {
|
||||
expect(classifyBashCommand(cmd, {}).result).toBe('block');
|
||||
});
|
||||
});
|
||||
|
||||
import { stripQuotedSpans } from './enforce-router-gate.mjs';
|
||||
|
||||
describe('quote-aware redirect (quirk 2)', () => {
|
||||
// False positives that must now be ALLOWED — `>` / `2>` живут внутри кавычек.
|
||||
it('allows > inside double-quoted commit message (co-author <email>)', () => {
|
||||
expect(matchBashHardBlacklist('git commit -m "x <noreply@anthropic.com>"')).toBe(null);
|
||||
});
|
||||
it('allows 2> inside double-quoted message', () => {
|
||||
expect(matchBashHardBlacklist('git commit -m "fix 2>1 logging"')).toBe(null);
|
||||
});
|
||||
it('allows lone quoted >', () => {
|
||||
expect(matchBashHardBlacklist('git commit -m ">"')).toBe(null);
|
||||
});
|
||||
// Real redirects (operator OUTSIDE quotes) must STILL BLOCK.
|
||||
it('blocks spaced stdout redirect', () => {
|
||||
expect(matchBashHardBlacklist('echo x > /tmp/f')).toBeTruthy();
|
||||
});
|
||||
it('blocks no-space stdout redirect', () => {
|
||||
expect(matchBashHardBlacklist('echo x>/tmp/f')).toBeTruthy();
|
||||
});
|
||||
it('blocks append redirect', () => {
|
||||
expect(matchBashHardBlacklist('echo x >> /tmp/f')).toBeTruthy();
|
||||
});
|
||||
it('blocks stderr redirect to file', () => {
|
||||
expect(matchBashHardBlacklist('cmd 2> /tmp/err')).toBeTruthy();
|
||||
});
|
||||
it('blocks redirect after a closing quote', () => {
|
||||
expect(matchBashHardBlacklist('echo "x" > /tmp/f')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripQuotedSpans (quirk 2 helper)', () => {
|
||||
it('blanks double-quoted interior, keeps outside', () => {
|
||||
expect(stripQuotedSpans('a "b>c" > d')).toBe('a " " > d');
|
||||
});
|
||||
it('blanks single-quoted interior', () => {
|
||||
expect(stripQuotedSpans("a 'x>y' z")).toBe("a ' ' z");
|
||||
});
|
||||
it('keeps backslash-escaped quote literal (no span opened)', () => {
|
||||
expect(stripQuotedSpans('a \\" > b')).toBe('a \\" > b');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* enforce-runtime-write-deny — PreToolUse(Edit|Write|MultiEdit|NotebookEdit).
|
||||
*
|
||||
* Blocks the Write/Edit TOOL from writing under ~/.claude/runtime/** — closing a
|
||||
* pre-existing self-write hole on the v4 git-approval anchor
|
||||
* (askuser-decisions-<sess>.jsonl) and any future runtime side-channel. The
|
||||
* Bash/PowerShell gates already protect this path for shell commands; the Write
|
||||
* tool was the remaining ungated channel. Standalone — independent of
|
||||
* safe-baseline (router-gate v4 §"Side-channel write-protection", item 1b / C3).
|
||||
*
|
||||
* Uses the resolving `pathNormalize` (path.resolve + realpath) so `.`/`..`
|
||||
* segments cannot evade the match (V2-2) — `~/.claude/./runtime/x` collapses to
|
||||
* the real path before the regex test. Legitimate hooks write there via Node
|
||||
* `fs`, not the Claude Write tool, so they are unaffected.
|
||||
*
|
||||
* Posture: blocks ONLY on a confirmed runtime match; if normalization throws
|
||||
* (cannot determine the path) it fails-OPEN, never bricking the session. This
|
||||
* deliberately trades a tiny residual (a malformed path that throws) for
|
||||
* not self-locking the controller out of all edits on an unexpected error.
|
||||
*/
|
||||
import { pathNormalize } from './path-normalization.mjs';
|
||||
import { readStdin, parseEventJson, exitDecision } from './enforce-hook-helpers.mjs';
|
||||
|
||||
const WRITE_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit']);
|
||||
const RUNTIME_RE = /(^|\/)\.claude\/runtime(\/|$)/i;
|
||||
|
||||
/**
|
||||
* Pure decision.
|
||||
* @param {object} p
|
||||
* @param {string} p.toolName
|
||||
* @param {string} p.filePath
|
||||
* @param {Function} [p.normalizeImpl] - injectable normalizer (default: resolving pathNormalize)
|
||||
* @returns {{block:boolean, reason?:string}}
|
||||
*/
|
||||
export function decide({ toolName, filePath, normalizeImpl = pathNormalize }) {
|
||||
if (!WRITE_TOOLS.has(toolName)) return { block: false };
|
||||
const fp = String(filePath || '');
|
||||
if (!fp) return { block: false };
|
||||
let norm;
|
||||
try { norm = normalizeImpl(fp); } catch { return { block: false }; } // cannot determine → fail-open
|
||||
if (RUNTIME_RE.test(String(norm || ''))) {
|
||||
return {
|
||||
block: true,
|
||||
reason: `Write to «${norm}» denied — ~/.claude/runtime is a protected side-channel (git-approval anchor). Hooks write it via Node fs, not the Write tool.`,
|
||||
};
|
||||
}
|
||||
return { block: false };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
const event = parseEventJson(await readStdin());
|
||||
const r = decide({
|
||||
toolName: event.tool_name,
|
||||
filePath: (event.tool_input && (event.tool_input.file_path || event.tool_input.notebook_path)) || '',
|
||||
});
|
||||
exitDecision({ block: r.block, message: r.reason });
|
||||
} catch {
|
||||
exitDecision({ block: false }); // fail-quiet
|
||||
}
|
||||
}
|
||||
|
||||
const isCli = process.argv[1] && process.argv[1].replace(/\\/g, '/').endsWith('/enforce-runtime-write-deny.mjs');
|
||||
if (isCli) main();
|
||||
@@ -0,0 +1,54 @@
|
||||
// tools/enforce-runtime-write-deny.test.mjs
|
||||
// Standalone write-deny on ~/.claude/runtime (router-gate v4 §"Side-channel
|
||||
// write-protection", item 1b / C3). Closes a pre-existing self-write hole on the
|
||||
// git-approval anchor; uses the resolving pathNormalize so `.`/`..` segments
|
||||
// cannot evade the match (V2-2).
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { decide } from './enforce-runtime-write-deny.mjs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const HOME = homedir();
|
||||
const HOME_FWD = HOME.replace(/\\/g, '/');
|
||||
|
||||
describe('enforce-runtime-write-deny decide()', () => {
|
||||
it('blocks a Write into ~/.claude/runtime (git-approval anchor)', () => {
|
||||
const r = decide({ toolName: 'Write', filePath: join(HOME, '.claude', 'runtime', 'askuser-decisions-S.jsonl') });
|
||||
expect(r.block).toBe(true);
|
||||
});
|
||||
|
||||
it('blocks the .-segment evasion (V2-2)', () => {
|
||||
// Raw string with `/./` — path.join would pre-collapse it, so build it literally.
|
||||
const evasion = `${HOME_FWD}/.claude/./runtime/x.jsonl`;
|
||||
const r = decide({ toolName: 'Write', filePath: evasion });
|
||||
expect(r.block).toBe(true);
|
||||
});
|
||||
|
||||
it('blocks Edit/MultiEdit/NotebookEdit too', () => {
|
||||
const p = join(HOME, '.claude', 'runtime', 'safe-baseline-ledger-S.json');
|
||||
expect(decide({ toolName: 'Edit', filePath: p }).block).toBe(true);
|
||||
expect(decide({ toolName: 'MultiEdit', filePath: p }).block).toBe(true);
|
||||
expect(decide({ toolName: 'NotebookEdit', filePath: p }).block).toBe(true);
|
||||
});
|
||||
|
||||
it('allows a Write to a normal project path', () => {
|
||||
const r = decide({ toolName: 'Write', filePath: join(HOME, 'project', 'src', 'x.mjs') });
|
||||
expect(r.block).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores non-write tools', () => {
|
||||
expect(decide({ toolName: 'Read', filePath: join(HOME, '.claude', 'runtime', 'x') }).block).toBe(false);
|
||||
expect(decide({ toolName: 'Bash', filePath: join(HOME, '.claude', 'runtime', 'x') }).block).toBe(false);
|
||||
});
|
||||
|
||||
it('fail-open (no block) when the normalizer throws — never bricks the session', () => {
|
||||
const throwing = () => { throw new Error('boom'); };
|
||||
const r = decide({ toolName: 'Write', filePath: join(HOME, '.claude', 'runtime', 'x'), normalizeImpl: throwing });
|
||||
expect(r.block).toBe(false);
|
||||
});
|
||||
|
||||
it('blocks via injected normalizer that resolves into runtime', () => {
|
||||
const r = decide({ toolName: 'Write', filePath: 'whatever', normalizeImpl: () => '/home/u/.claude/runtime/x.jsonl' });
|
||||
expect(r.block).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,225 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* enforce-safe-baseline-metering — PreToolUse wrapper around the pure
|
||||
* safe-baseline-metering module (router-gate v4 §3.1.2 Direction 1).
|
||||
*
|
||||
* Catches skill-substitution laundering: many Read/Grep/Glob/LS/TodoWrite/
|
||||
* AskUserQuestion calls used as an analysis channel INSTEAD of invoking the
|
||||
* recommended Skill, then a mutating tool (Edit/Write/Bash/…) lands without any
|
||||
* skill ever matching. Safe-baseline tools themselves stay allowed (legit
|
||||
* continuation reading); only a mutating tool past the hard threshold is blocked.
|
||||
*
|
||||
* Stream H tail — adds the wrapper. Pure metering + threshold logic live in
|
||||
* safe-baseline-metering.mjs; this file is just the hook entry composition.
|
||||
*
|
||||
* Convention (mirrors enforce-decomposition-detector.mjs): the testable unit is
|
||||
* the pure `decide()` composition. The live `main()` — task-boundary inference,
|
||||
* skill-match detection from the transcript, and per-task counter persistence —
|
||||
* is a deferred no-op (exit 0) until that wiring is designed in the spec/plan.
|
||||
* Until then the hook NEVER blocks (no self-lockout, same posture as the sibling
|
||||
* Stream H wrappers). Settings.json registration is also deferred.
|
||||
*/
|
||||
import {
|
||||
incrementCounter,
|
||||
evaluateThresholds,
|
||||
DEFAULT_THRESHOLDS,
|
||||
newCounterState,
|
||||
shouldInheritTaskId,
|
||||
deriveTaskId,
|
||||
} from './safe-baseline-metering.mjs';
|
||||
import { readFileSync, writeFileSync, appendFileSync, mkdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import {
|
||||
readStdin,
|
||||
parseEventJson,
|
||||
readTranscript,
|
||||
lastUserPromptText,
|
||||
lastTurnEntries,
|
||||
exitDecision,
|
||||
} from './enforce-hook-helpers.mjs';
|
||||
|
||||
/**
|
||||
* Pure decision: increment the per-task counter for `toolName`, then evaluate
|
||||
* thresholds against the resulting state.
|
||||
*
|
||||
* @param {object} args
|
||||
* @param {object} args.state - current per-task counter state (newCounterState shape)
|
||||
* @param {string} args.toolName - the tool about to run
|
||||
* @param {boolean} [args.skillMatched] - whether a recommended Skill matched in this task
|
||||
* @param {object} [args.thresholds] - override DEFAULT_THRESHOLDS
|
||||
* @returns {{state:object, action:'allow'|'soft_flag'|'hard_block', reason?:string}}
|
||||
*/
|
||||
export function decide({ state, toolName, skillMatched = false, thresholds = DEFAULT_THRESHOLDS }) {
|
||||
const next = incrementCounter(state, toolName);
|
||||
const evalResult = evaluateThresholds(next, toolName, skillMatched, thresholds);
|
||||
return { state: next, action: evalResult.action, reason: evalResult.reason };
|
||||
}
|
||||
|
||||
/**
|
||||
* Task-boundary head: decide whether the current event continues the prior task
|
||||
* or starts a fresh one, then meter it.
|
||||
*
|
||||
* Continuation rules (delegated to the pure module):
|
||||
* - no prior ledger → fresh task
|
||||
* - reset marker in promptText → fresh task (shouldInheritTaskId=false)
|
||||
* - keyword overlap with prior task < 2 → fresh task
|
||||
* - otherwise → inherit prior counters
|
||||
*
|
||||
* @param {object} args
|
||||
* @param {object} args.event - PreToolUse event ({ tool_name })
|
||||
* @param {object|null} args.priorLedger - { state, lastKeywords } from the last event, or null
|
||||
* @param {string[]} args.currentKeywords - keywords distilled from the current prompt
|
||||
* @param {string} args.promptText - the current user prompt (for reset-marker detection)
|
||||
* @param {boolean} [args.skillMatched] - whether a recommended Skill matched in this task
|
||||
* @param {object} [args.thresholds] - override DEFAULT_THRESHOLDS
|
||||
* @returns {{action:string, reason?:string, ledger:{state:object, lastKeywords:string[]}}}
|
||||
*/
|
||||
export function processEvent({
|
||||
event,
|
||||
priorLedger,
|
||||
currentKeywords = [],
|
||||
promptText = '',
|
||||
skillMatched = false,
|
||||
thresholds = DEFAULT_THRESHOLDS,
|
||||
}) {
|
||||
const toolName = event && event.tool_name;
|
||||
const inherit =
|
||||
priorLedger &&
|
||||
priorLedger.state &&
|
||||
shouldInheritTaskId(priorLedger.lastKeywords || [], currentKeywords, promptText);
|
||||
|
||||
const baseState = inherit
|
||||
? priorLedger.state
|
||||
: newCounterState({
|
||||
taskId: deriveTaskId(promptText),
|
||||
startedAtIso: '',
|
||||
firstPromptExcerpt: promptText,
|
||||
});
|
||||
|
||||
const d = decide({ state: baseState, toolName, skillMatched, thresholds });
|
||||
return {
|
||||
action: d.action,
|
||||
reason: d.reason,
|
||||
ledger: { state: d.state, lastKeywords: currentKeywords },
|
||||
};
|
||||
}
|
||||
|
||||
// ── 1b live-wiring: pure helpers (safe-baseline-live-wiring-design.md v4) ──
|
||||
|
||||
// Common RU imperatives + RU/EN stopwords that would otherwise create spurious
|
||||
// keyword overlap between unrelated tasks (G2). Length<4 tokens are dropped
|
||||
// separately; this set targets >=4-char common words.
|
||||
const STOPWORDS = new Set([
|
||||
'сделай', 'сделать', 'проверь', 'проверить', 'посмотри', 'добавь', 'добавить',
|
||||
'напиши', 'написать', 'нужно', 'надо', 'давай', 'можешь', 'потом', 'после',
|
||||
'перед', 'через', 'очень', 'если', 'чтобы', 'этот', 'эта', 'это', 'эти',
|
||||
'или', 'тоже', 'также', 'когда', 'пока', 'весь', 'всё', 'все', 'теперь',
|
||||
'здесь', 'там', 'нет', 'есть', 'будет', 'было', 'твой', 'мой', 'самый',
|
||||
'then', 'this', 'that', 'with', 'from', 'your', 'please', 'just', 'make',
|
||||
'check', 'look', 'need', 'want', 'also', 'into', 'more', 'very', 'should',
|
||||
'will', 'have', 'does', 'done', 'them', 'they', 'here', 'there',
|
||||
]);
|
||||
|
||||
/** Deterministic keyword extraction (H1): lowercase, drop <4-char + stopwords, unique, sorted. */
|
||||
export function extractKeywords(promptText) {
|
||||
if (typeof promptText !== 'string') return [];
|
||||
const tokens = promptText
|
||||
.toLowerCase()
|
||||
.split(/[^\p{L}\p{N}]+/u)
|
||||
.filter((t) => t.length >= 4 && !STOPWORDS.has(t));
|
||||
return [...new Set(tokens)].sort();
|
||||
}
|
||||
|
||||
const SKILL_MATCH_TOOLS = new Set(['Skill', 'EnterPlanMode']);
|
||||
|
||||
/** C2/V2-5: true iff the turn has a real assistant tool_use of Skill or EnterPlanMode. */
|
||||
export function detectSkillMatch(turnEntries) {
|
||||
if (!Array.isArray(turnEntries)) return false;
|
||||
for (const e of turnEntries) {
|
||||
const c = e && e.message && e.message.content;
|
||||
if (!Array.isArray(c)) continue;
|
||||
for (const b of c) {
|
||||
if (b && b.type === 'tool_use' && SKILL_MATCH_TOOLS.has(b.name)) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* V2-1 stickiness contract: the pure pipeline neither persists nor task-scopes
|
||||
* skill-match, so this wrapper owns it. Compute inherit (same predicate as
|
||||
* processEvent), scope the prior sticky flag to inherit, OR in this turn's match,
|
||||
* run the decision, then write the effective flag back into the persisted state.
|
||||
*/
|
||||
export function runLiveDecision({ event, priorLedger, promptText, currentKeywords, skillMatchedThisTurn, thresholds }) {
|
||||
const inherit = !!(priorLedger && priorLedger.state &&
|
||||
shouldInheritTaskId(priorLedger.lastKeywords || [], currentKeywords, promptText));
|
||||
const priorSticky = inherit ? !!priorLedger.state.skill_match_within_task : false;
|
||||
const effectiveSkillMatched = priorSticky || !!skillMatchedThisTurn;
|
||||
|
||||
const res = processEvent({
|
||||
event, priorLedger, currentKeywords, promptText,
|
||||
skillMatched: effectiveSkillMatched, thresholds,
|
||||
});
|
||||
res.ledger.state.skill_match_within_task = effectiveSkillMatched;
|
||||
return res;
|
||||
}
|
||||
|
||||
// ── live I/O composition ──
|
||||
|
||||
const ESCAPE_MSG = 'invoke the recommended Skill, or EnterPlanMode, to proceed (skill/plan invocations are never blocked by this layer).';
|
||||
|
||||
function ledgerDir(override) {
|
||||
return override || join(homedir(), '.claude', 'runtime');
|
||||
}
|
||||
function loadLedger(dir, sess) {
|
||||
try { return JSON.parse(readFileSync(join(dir, `safe-baseline-ledger-${sess || 'unknown'}.json`), 'utf8')); }
|
||||
catch { return null; }
|
||||
}
|
||||
function saveLedger(dir, sess, ledger) {
|
||||
try {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, `safe-baseline-ledger-${sess || 'unknown'}.json`), JSON.stringify(ledger));
|
||||
} catch { /* fail-quiet */ }
|
||||
}
|
||||
function logFlag(dir, sess, entry) {
|
||||
try {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
appendFileSync(join(dir, `safe-baseline-flags-${sess || 'unknown'}.jsonl`),
|
||||
JSON.stringify({ ts: new Date().toISOString(), ...entry }) + '\n');
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
/** Testable live head: returns {block, message?} and persists the ledger. Fail-quiet. */
|
||||
export async function runMain({ event, runtimeDir, transcript: injectedTranscript } = {}) {
|
||||
try {
|
||||
const sess = event.session_id;
|
||||
const dir = ledgerDir(runtimeDir);
|
||||
const transcript = injectedTranscript || readTranscript(event.transcript_path);
|
||||
const promptText = lastUserPromptText(transcript) || '';
|
||||
const currentKeywords = extractKeywords(promptText);
|
||||
const skillMatchedThisTurn = detectSkillMatch(lastTurnEntries(transcript)) ||
|
||||
SKILL_MATCH_TOOLS.has(event.tool_name);
|
||||
const priorLedger = loadLedger(dir, sess);
|
||||
|
||||
const res = runLiveDecision({ event, priorLedger, promptText, currentKeywords, skillMatchedThisTurn });
|
||||
saveLedger(dir, sess, res.ledger);
|
||||
|
||||
if (res.action === 'soft_flag') logFlag(dir, sess, { tool: event.tool_name, reason: res.reason });
|
||||
if (res.action === 'hard_block') return { block: true, message: `[safe-baseline] ${res.reason}\n${ESCAPE_MSG}` };
|
||||
return { block: false };
|
||||
} catch {
|
||||
return { block: false }; // fail-quiet — never crash the session
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const event = parseEventJson(await readStdin());
|
||||
const res = await runMain({ event });
|
||||
exitDecision(res);
|
||||
}
|
||||
|
||||
if ((process.argv[1] || '').replace(/\\/g, '/').endsWith('/enforce-safe-baseline-metering.mjs')) {
|
||||
main().catch(() => process.exit(0));
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
// tools/enforce-safe-baseline-metering.test.mjs
|
||||
// Stream H tail — wrapper tests around the pure safe-baseline-metering module
|
||||
// (router-gate v4 §3.1.2 Direction 1). Mirrors the enforce-decomposition-detector
|
||||
// convention: implement + test a pure `decide()` composition; live main() wiring
|
||||
// (transcript task-boundary + skill detection + state persistence) is now live
|
||||
// (1b — safe-baseline-live-wiring-design.md v4).
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { decide, processEvent, extractKeywords, detectSkillMatch, runLiveDecision, runMain } from './enforce-safe-baseline-metering.mjs';
|
||||
import { newCounterState } from './safe-baseline-metering.mjs';
|
||||
import { mkdtempSync, writeFileSync, existsSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
function freshState() {
|
||||
return newCounterState({ taskId: 't', startedAtIso: '2026-05-29T00:00:00Z', firstPromptExcerpt: 'p' });
|
||||
}
|
||||
function withCounts(patch) {
|
||||
const s = freshState();
|
||||
return { ...s, counts: { ...s.counts, ...patch } };
|
||||
}
|
||||
|
||||
describe('enforce-safe-baseline-metering decide()', () => {
|
||||
it('allows a metered Read below warn threshold and increments its counter', () => {
|
||||
const r = decide({ state: freshState(), toolName: 'Read', skillMatched: false });
|
||||
expect(r.action).toBe('allow');
|
||||
expect(r.state.counts.Read).toBe(1);
|
||||
});
|
||||
|
||||
it('soft_flags a metered Read once it reaches the warn threshold (29→30)', () => {
|
||||
const r = decide({ state: withCounts({ Read: 29 }), toolName: 'Read', skillMatched: false });
|
||||
expect(r.action).toBe('soft_flag');
|
||||
expect(r.state.counts.Read).toBe(30);
|
||||
});
|
||||
|
||||
it('hard_blocks a mutating tool when a metered counter is at its hard limit, no skill', () => {
|
||||
const r = decide({ state: withCounts({ Read: 60 }), toolName: 'Edit', skillMatched: false });
|
||||
expect(r.action).toBe('hard_block');
|
||||
expect(r.reason).toContain('Read=60');
|
||||
});
|
||||
|
||||
it('allows the mutating tool when a skill was matched, even past the hard limit', () => {
|
||||
const r = decide({ state: withCounts({ Read: 60 }), toolName: 'Edit', skillMatched: true });
|
||||
expect(r.action).toBe('allow');
|
||||
});
|
||||
|
||||
it('allows (and does not count) a tool that is neither metered nor mutating', () => {
|
||||
const r = decide({ state: freshState(), toolName: 'WebFetch', skillMatched: false });
|
||||
expect(r.action).toBe('allow');
|
||||
expect(r.state.counts.Read).toBe(0);
|
||||
});
|
||||
|
||||
it('does not mutate the caller-provided state object (immutability)', () => {
|
||||
const s = freshState();
|
||||
decide({ state: s, toolName: 'Read', skillMatched: false });
|
||||
expect(s.counts.Read).toBe(0);
|
||||
});
|
||||
|
||||
it('maps TodoWrite to TodoWrite_writes and soft_flags at its warn threshold (4→5)', () => {
|
||||
const r = decide({ state: withCounts({ TodoWrite_writes: 4 }), toolName: 'TodoWrite', skillMatched: false });
|
||||
expect(r.state.counts.TodoWrite_writes).toBe(5);
|
||||
expect(r.action).toBe('soft_flag');
|
||||
});
|
||||
|
||||
it('keeps a metered Grep allowed once past its hard threshold (continuation reading)', () => {
|
||||
const r = decide({ state: withCounts({ Grep: 30 }), toolName: 'Grep', skillMatched: false });
|
||||
expect(r.action).toBe('allow');
|
||||
expect(r.state.counts.Grep).toBe(31);
|
||||
});
|
||||
|
||||
it('hard_blocks a mutating Bash when TodoWrite_writes is at its hard limit', () => {
|
||||
const r = decide({ state: withCounts({ TodoWrite_writes: 15 }), toolName: 'Bash', skillMatched: false });
|
||||
expect(r.action).toBe('hard_block');
|
||||
expect(r.reason).toContain('TodoWrite_writes=15');
|
||||
});
|
||||
});
|
||||
|
||||
describe('enforce-safe-baseline-metering processEvent() — task-boundary head', () => {
|
||||
it('starts a fresh task when there is no prior ledger', () => {
|
||||
const r = processEvent({
|
||||
event: { tool_name: 'Read' },
|
||||
priorLedger: null,
|
||||
currentKeywords: ['router', 'gate', 'safe'],
|
||||
promptText: 'почини safe-baseline',
|
||||
skillMatched: false,
|
||||
});
|
||||
expect(r.action).toBe('allow');
|
||||
expect(r.ledger.state.counts.Read).toBe(1);
|
||||
expect(r.ledger.lastKeywords).toEqual(['router', 'gate', 'safe']);
|
||||
});
|
||||
|
||||
it('continues the prior task when keywords overlap >=2 and no reset marker', () => {
|
||||
const prior = {
|
||||
state: { ...newCounterState({ taskId: 't', startedAtIso: '2026-05-29T00:00:00Z', firstPromptExcerpt: 'p' }), counts: { Read: 29, Grep: 0, Glob: 0, LS: 0, TodoWrite_writes: 0, AskUserQuestion: 0 } },
|
||||
lastKeywords: ['router', 'gate', 'safe'],
|
||||
};
|
||||
const r = processEvent({
|
||||
event: { tool_name: 'Read' },
|
||||
priorLedger: prior,
|
||||
currentKeywords: ['router', 'gate', 'extra'],
|
||||
promptText: 'дальше по safe-baseline',
|
||||
skillMatched: false,
|
||||
});
|
||||
expect(r.ledger.state.counts.Read).toBe(30);
|
||||
expect(r.action).toBe('soft_flag');
|
||||
});
|
||||
|
||||
it('resets to a fresh task on a reset marker even if keywords overlap', () => {
|
||||
const prior = {
|
||||
state: { ...newCounterState({ taskId: 't', startedAtIso: '2026-05-29T00:00:00Z', firstPromptExcerpt: 'p' }), counts: { Read: 29, Grep: 0, Glob: 0, LS: 0, TodoWrite_writes: 0, AskUserQuestion: 0 } },
|
||||
lastKeywords: ['router', 'gate', 'safe'],
|
||||
};
|
||||
const r = processEvent({
|
||||
event: { tool_name: 'Read' },
|
||||
priorLedger: prior,
|
||||
currentKeywords: ['router', 'gate', 'safe'],
|
||||
promptText: 'новая задача — посмотри другое',
|
||||
skillMatched: false,
|
||||
});
|
||||
expect(r.ledger.state.counts.Read).toBe(1);
|
||||
});
|
||||
|
||||
it('starts a fresh task when keyword overlap is below 2', () => {
|
||||
const prior = {
|
||||
state: { ...newCounterState({ taskId: 't', startedAtIso: '2026-05-29T00:00:00Z', firstPromptExcerpt: 'p' }), counts: { Read: 29, Grep: 0, Glob: 0, LS: 0, TodoWrite_writes: 0, AskUserQuestion: 0 } },
|
||||
lastKeywords: ['router', 'gate', 'safe'],
|
||||
};
|
||||
const r = processEvent({
|
||||
event: { tool_name: 'Read' },
|
||||
priorLedger: prior,
|
||||
currentKeywords: ['totally', 'different', 'topic'],
|
||||
promptText: 'другая тема',
|
||||
skillMatched: false,
|
||||
});
|
||||
expect(r.ledger.state.counts.Read).toBe(1);
|
||||
});
|
||||
|
||||
it('allows a mutating tool past the hard limit when a skill matched', () => {
|
||||
const prior = {
|
||||
state: { ...newCounterState({ taskId: 't', startedAtIso: '2026-05-29T00:00:00Z', firstPromptExcerpt: 'p' }), counts: { Read: 60, Grep: 0, Glob: 0, LS: 0, TodoWrite_writes: 0, AskUserQuestion: 0 } },
|
||||
lastKeywords: ['router', 'gate', 'safe'],
|
||||
};
|
||||
const r = processEvent({
|
||||
event: { tool_name: 'Edit' },
|
||||
priorLedger: prior,
|
||||
currentKeywords: ['router', 'gate', 'safe'],
|
||||
promptText: 'продолжаем',
|
||||
skillMatched: true,
|
||||
});
|
||||
expect(r.action).toBe('allow');
|
||||
});
|
||||
});
|
||||
|
||||
// ── 1b live-wiring: new pure helpers ──
|
||||
|
||||
describe('extractKeywords (H1)', () => {
|
||||
it('lowercases, drops <4-char tokens, returns unique sorted', () => {
|
||||
expect(extractKeywords('Router GATE safe baseline router')).toEqual(['baseline', 'gate', 'router', 'safe']);
|
||||
});
|
||||
it('drops common RU imperatives so unrelated tasks do not falsely overlap', () => {
|
||||
const a = extractKeywords('сделай проверь биллинг тариф');
|
||||
const b = extractKeywords('сделай проверь регион маршрут');
|
||||
const overlap = a.filter((k) => b.includes(k));
|
||||
expect(overlap).toEqual([]);
|
||||
});
|
||||
it('returns [] for empty/non-string', () => {
|
||||
expect(extractKeywords('')).toEqual([]);
|
||||
expect(extractKeywords(null)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
function asstToolUse(name, input = {}) {
|
||||
return { message: { role: 'assistant', content: [{ type: 'tool_use', name, input }] } };
|
||||
}
|
||||
|
||||
describe('detectSkillMatch (C2/V2-5)', () => {
|
||||
it('true when the turn has a Skill tool_use', () => {
|
||||
expect(detectSkillMatch([asstToolUse('Skill', { skill: 'superpowers:brainstorming' })])).toBe(true);
|
||||
});
|
||||
it('true when the turn has an EnterPlanMode tool_use', () => {
|
||||
expect(detectSkillMatch([asstToolUse('EnterPlanMode')])).toBe(true);
|
||||
});
|
||||
it('false for Read tool_use or plain text mention of a plan path (no self-grant)', () => {
|
||||
expect(detectSkillMatch([asstToolUse('Read', { file_path: 'docs/superpowers/plans/x.md' })])).toBe(false);
|
||||
expect(detectSkillMatch([{ message: { role: 'assistant', content: [{ type: 'text', text: 'docs/superpowers/plans/x.md' }] } }])).toBe(false);
|
||||
});
|
||||
it('false for empty/non-array', () => {
|
||||
expect(detectSkillMatch([])).toBe(false);
|
||||
expect(detectSkillMatch(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
function ledgerWith(counts, skill, keywords) {
|
||||
return {
|
||||
state: {
|
||||
...newCounterState({ taskId: 't', startedAtIso: '2026-05-30T00:00:00Z', firstPromptExcerpt: 'p' }),
|
||||
counts: { Read: 0, Grep: 0, Glob: 0, LS: 0, TodoWrite_writes: 0, AskUserQuestion: 0, ...counts },
|
||||
skill_match_within_task: skill,
|
||||
},
|
||||
lastKeywords: keywords,
|
||||
};
|
||||
}
|
||||
|
||||
describe('runLiveDecision — stickiness contract (V2-1)', () => {
|
||||
it('persists skillMatchedThisTurn into the ledger (stickiness not lost)', () => {
|
||||
const r = runLiveDecision({
|
||||
event: { tool_name: 'Read' }, priorLedger: null,
|
||||
promptText: 'router gate safe baseline', currentKeywords: ['router', 'gate', 'safe', 'baseline'],
|
||||
skillMatchedThisTurn: true,
|
||||
});
|
||||
expect(r.ledger.state.skill_match_within_task).toBe(true);
|
||||
});
|
||||
|
||||
it('a skill earlier in a task keeps later mutating ops allowed past the hard limit (no false block)', () => {
|
||||
const prior = ledgerWith({ Read: 60 }, true, ['router', 'gate', 'safe', 'baseline']);
|
||||
const r = runLiveDecision({
|
||||
event: { tool_name: 'Edit' }, priorLedger: prior,
|
||||
promptText: 'продолжаем router gate safe baseline', currentKeywords: ['router', 'gate', 'safe', 'baseline'],
|
||||
skillMatchedThisTurn: false,
|
||||
});
|
||||
expect(r.action).toBe('allow');
|
||||
});
|
||||
|
||||
it('skill match in task A does NOT exempt an unrelated task B (no cross-task leak)', () => {
|
||||
const prior = ledgerWith({ Read: 60 }, true, ['router', 'gate', 'safe', 'baseline']);
|
||||
const r = runLiveDecision({
|
||||
event: { tool_name: 'Edit' }, priorLedger: prior,
|
||||
promptText: 'регион маршрут лиды поставщик', currentKeywords: ['регион', 'маршрут', 'лиды', 'поставщик'],
|
||||
skillMatchedThisTurn: false,
|
||||
});
|
||||
expect(r.ledger.state.skill_match_within_task).toBe(false);
|
||||
expect(r.ledger.state.counts.Read).toBe(0);
|
||||
});
|
||||
|
||||
it('hard-blocks a mutating tool past the limit in a no-skill task', () => {
|
||||
const prior = ledgerWith({ Read: 60 }, false, ['router', 'gate', 'safe', 'baseline']);
|
||||
const r = runLiveDecision({
|
||||
event: { tool_name: 'Edit' }, priorLedger: prior,
|
||||
promptText: 'router gate safe baseline', currentKeywords: ['router', 'gate', 'safe', 'baseline'],
|
||||
skillMatchedThisTurn: false,
|
||||
});
|
||||
expect(r.action).toBe('hard_block');
|
||||
});
|
||||
});
|
||||
|
||||
describe('runMain — live integration', () => {
|
||||
function fixtureTranscript(path, entries) {
|
||||
writeFileSync(path, entries.map((e) => JSON.stringify(e)).join('\n'));
|
||||
}
|
||||
|
||||
it('blocks an Edit when Read past hard with no skill, and names the escape', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'sbm-'));
|
||||
const tpath = join(dir, 't.jsonl');
|
||||
writeFileSync(join(dir, 'safe-baseline-ledger-S.json'), JSON.stringify({
|
||||
state: { schema_version: 1, task_id: 't', counts: { Read: 60, Grep: 0, Glob: 0, LS: 0, TodoWrite_writes: 0, AskUserQuestion: 0 }, skill_match_within_task: false },
|
||||
lastKeywords: ['router', 'gate', 'safe', 'baseline'],
|
||||
}));
|
||||
fixtureTranscript(tpath, [{ type: 'user', message: { role: 'user', content: 'router gate safe baseline' } }]);
|
||||
const res = await runMain({ event: { tool_name: 'Edit', session_id: 'S', transcript_path: tpath }, runtimeDir: dir });
|
||||
expect(res.block).toBe(true);
|
||||
expect(res.message).toMatch(/EnterPlanMode|Skill/);
|
||||
});
|
||||
|
||||
it('allows a fresh task and persists the ledger', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'sbm-'));
|
||||
const tpath = join(dir, 't.jsonl');
|
||||
fixtureTranscript(tpath, [{ type: 'user', message: { role: 'user', content: 'регион маршрут лиды' } }]);
|
||||
const res = await runMain({ event: { tool_name: 'Read', session_id: 'S2', transcript_path: tpath }, runtimeDir: dir });
|
||||
expect(res.block).toBe(false);
|
||||
expect(existsSync(join(dir, 'safe-baseline-ledger-S2.json'))).toBe(true);
|
||||
});
|
||||
|
||||
it('allows an Edit (escape) when the current event is a Skill invocation', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'sbm-'));
|
||||
const tpath = join(dir, 't.jsonl');
|
||||
writeFileSync(join(dir, 'safe-baseline-ledger-S3.json'), JSON.stringify({
|
||||
state: { schema_version: 1, task_id: 't', counts: { Read: 60, Grep: 0, Glob: 0, LS: 0, TodoWrite_writes: 0, AskUserQuestion: 0 }, skill_match_within_task: false },
|
||||
lastKeywords: ['router', 'gate', 'safe', 'baseline'],
|
||||
}));
|
||||
fixtureTranscript(tpath, [{ type: 'user', message: { role: 'user', content: 'router gate safe baseline' } }]);
|
||||
const res = await runMain({ event: { tool_name: 'Skill', session_id: 'S3', transcript_path: tpath }, runtimeDir: dir });
|
||||
expect(res.block).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -108,6 +108,11 @@ function hasFailingTestRun(turn) {
|
||||
// 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;
|
||||
// JSON reporter (composer test / php artisan test → pest): {"result":"failed",...}
|
||||
// or {"failed":N}/{"errors":N} with N>0. command-not-found / error REDs lack the
|
||||
// English "Failed" keyword above, so recognise the structured marker too.
|
||||
if (/"result"\s*:\s*"failed"/.test(txt)) return true;
|
||||
if (/"(?:failed|errors)"\s*:\s*[1-9]/.test(txt)) return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -150,8 +155,6 @@ export function decide({
|
||||
`[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'),
|
||||
};
|
||||
}
|
||||
@@ -167,8 +170,6 @@ export function decide({
|
||||
`[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'),
|
||||
};
|
||||
}
|
||||
@@ -178,8 +179,6 @@ export function decide({
|
||||
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'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -38,6 +38,8 @@ describe('enforce-tdd-gate / decide', () => {
|
||||
});
|
||||
expect(r.block).toBe(true);
|
||||
expect(r.message).toMatch(/without preceding test edit/);
|
||||
// 1A (2026-05-31): не рекламировать мёртвые override-фразы (findOverride — заглушка v4).
|
||||
expect(r.message).not.toMatch(/Override:/);
|
||||
});
|
||||
|
||||
it('blocks when test edited but no vitest RED observed', () => {
|
||||
@@ -51,6 +53,8 @@ describe('enforce-tdd-gate / decide', () => {
|
||||
});
|
||||
expect(r.block).toBe(true);
|
||||
expect(r.message).toMatch(/no vitest.*RED/);
|
||||
// 1A (2026-05-31): не рекламировать мёртвые override-фразы (findOverride — заглушка v4).
|
||||
expect(r.message).not.toMatch(/Override:/);
|
||||
});
|
||||
|
||||
it('allows after test edit + vitest RED', () => {
|
||||
@@ -107,6 +111,8 @@ describe('enforce-tdd-gate / decide', () => {
|
||||
});
|
||||
expect(r.block).toBe(true);
|
||||
expect(r.message).toMatch(/requires a plan/);
|
||||
// 1A (2026-05-31): не рекламировать мёртвые override-фразы (findOverride — заглушка v4).
|
||||
expect(r.message).not.toMatch(/Override:/);
|
||||
});
|
||||
|
||||
it('allows feature edit when Skill(superpowers:writing-plans) invoked', () => {
|
||||
@@ -162,3 +168,25 @@ describe('enforce-tdd-gate / decide', () => {
|
||||
expect(r.block).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('enforce-tdd-gate / decide — JSON pest reporter RED (composer test)', () => {
|
||||
// `composer test` (php artisan test) emits machine JSON like {"result":"failed",...}.
|
||||
// command-not-found / error REDs lack the English "Failed" keyword, so the gate must
|
||||
// recognise the structured failure marker, else legit RED runs go unseen.
|
||||
it('recognizes {"result":"failed"} JSON output as a RED run', () => {
|
||||
const r = decide({
|
||||
toolName: 'Write',
|
||||
filePath: 'wt/app/app/Console/Commands/FooCommand.php',
|
||||
transcriptEntries: [
|
||||
userMsg('add backfill command'),
|
||||
assistantUses([
|
||||
{ id: 't1', name: 'Write', input: { file_path: 'wt/app/tests/Feature/Console/FooCommandTest.php' } },
|
||||
{ id: 't2', name: 'Bash', input: { command: 'composer test -- tests/Feature/Console/FooCommandTest.php # pest' } },
|
||||
]),
|
||||
toolResults([{ id: 't2', content: '{"tool":"pest","result":"failed","tests":4,"passed":0,"errors":4}' }]),
|
||||
],
|
||||
classification: null,
|
||||
});
|
||||
expect(r.block).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -70,8 +70,6 @@ export function decide({ toolName, command, sentinel, sentinelAge, override, ove
|
||||
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'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -153,6 +153,9 @@ describe('enforce-verify-before-push / decide', () => {
|
||||
});
|
||||
expect(r.block).toBe(true);
|
||||
expect(r.message).toMatch(/No verification/);
|
||||
// 1A (2026-05-31): не рекламировать мёртвые override-фразы (findOverride — заглушка v4).
|
||||
expect(r.message).not.toMatch(/Override:/);
|
||||
expect(r.message).not.toMatch(/срочно|ремонт инфраструктуры/);
|
||||
});
|
||||
|
||||
it('does NOT emit override-missing-justification diagnostic for overrides without requires_justification', () => {
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* llm-judge-config — the Layer 4 enabling-gate for router-gate v4.
|
||||
*
|
||||
* The LLM-judge engine (llm-judge.mjs) is fully built but MUST stay OFF until
|
||||
* the owner deliberately turns it on, because enabling it incurs real LLM cost
|
||||
* (~$300–1500/month per the v4.1 amendment). This module is the single switch.
|
||||
*
|
||||
* SAFE-BY-DEFAULT CONTRACT:
|
||||
* enabled === true ⇔ the explicit flag ROUTER_LLM_JUDGE_ENABLED is truthy
|
||||
* AND a key is resolvable (keychain first, then env).
|
||||
* Anything else → enabled:false. Building this file does NOT enable the judge:
|
||||
* with no flag and no key the gate is closed. keychainGet errors degrade to
|
||||
* "no key, disabled" (never throw).
|
||||
*
|
||||
* Activation (a separate, owner-driven step — NOT done here):
|
||||
* 1. store the API key in the OS keychain (or set ROUTER_LLM_KEY),
|
||||
* 2. set ROUTER_LLM_JUDGE_ENABLED=1,
|
||||
* 3. register the enforce-llm-judge-* hooks in .claude/settings.json.
|
||||
* Cost starts only after all three.
|
||||
*/
|
||||
import { JUDGE_MODELS } from './llm-judge.mjs';
|
||||
|
||||
const ENABLE_FLAG = 'ROUTER_LLM_JUDGE_ENABLED';
|
||||
const KEY_ENV = 'ROUTER_LLM_KEY';
|
||||
const BASE_URL_ENV = 'ROUTER_LLM_BASE_URL';
|
||||
const KEYCHAIN_SERVICE = 'router-gate-llm-judge';
|
||||
const KEYCHAIN_ACCOUNT = 'default';
|
||||
|
||||
function isTruthyFlag(v) {
|
||||
if (typeof v !== 'string') return false;
|
||||
return v.trim().toLowerCase() === '1' || v.trim().toLowerCase() === 'true';
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the Layer 4 judge configuration.
|
||||
*
|
||||
* @param {object} [args]
|
||||
* @param {object} [args.env] - environment map (defaults to process.env)
|
||||
* @param {Function} [args.keychainGet] - () => string|null, OS-keychain reader (injectable for tests)
|
||||
* @returns {{enabled:boolean, apiKey:string|null, baseUrl:string|null, models:string[]}}
|
||||
*/
|
||||
export function resolveJudgeConfig({ env = process.env, keychainGet = defaultKeychainGet } = {}) {
|
||||
let keychainKey = null;
|
||||
try {
|
||||
const v = keychainGet();
|
||||
keychainKey = v ? String(v) : null;
|
||||
} catch {
|
||||
keychainKey = null;
|
||||
}
|
||||
const envKey = env[KEY_ENV] ? String(env[KEY_ENV]) : null;
|
||||
const apiKey = keychainKey || envKey || null;
|
||||
|
||||
const flagOn = isTruthyFlag(env[ENABLE_FLAG]);
|
||||
const enabled = flagOn && apiKey !== null;
|
||||
|
||||
return {
|
||||
enabled,
|
||||
apiKey,
|
||||
baseUrl: env[BASE_URL_ENV] ? String(env[BASE_URL_ENV]) : null,
|
||||
models: JUDGE_MODELS.multi,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Default OS-keychain reader. Lazily loads `keytar`; returns null if keytar is
|
||||
* absent or the entry is missing. Never throws (caller also guards).
|
||||
*/
|
||||
export function defaultKeychainGet() {
|
||||
try {
|
||||
// Lazy require keeps the native dep optional — tests inject keychainGet and
|
||||
// never hit this path; the no-op posture means missing keytar => no key.
|
||||
const require = createRequire(import.meta.url);
|
||||
const keytar = require('keytar');
|
||||
const v = keytar.getPassword ? keytar.getPasswordSync?.(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT) : null;
|
||||
return v || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
export const _internals = { ENABLE_FLAG, KEY_ENV, BASE_URL_ENV, KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT, isTruthyFlag };
|
||||
@@ -0,0 +1,75 @@
|
||||
// tools/llm-judge-config.test.mjs
|
||||
// Router-gate v4 Layer 4 enabling-gate. The judge is OFF by default and only
|
||||
// becomes enabled when BOTH an explicit flag is set AND a key is resolvable.
|
||||
// Building this switch does NOT flip it — no key + no flag => disabled.
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { resolveJudgeConfig } from './llm-judge-config.mjs';
|
||||
|
||||
describe('llm-judge-config resolveJudgeConfig()', () => {
|
||||
it('is DISABLED by default: no flag, no key', () => {
|
||||
const c = resolveJudgeConfig({ env: {}, keychainGet: () => null });
|
||||
expect(c.enabled).toBe(false);
|
||||
expect(c.apiKey).toBe(null);
|
||||
});
|
||||
|
||||
it('stays DISABLED when a key exists but the enable flag is not set', () => {
|
||||
const c = resolveJudgeConfig({ env: {}, keychainGet: () => 'sk-test' });
|
||||
expect(c.enabled).toBe(false);
|
||||
expect(c.apiKey).toBe('sk-test');
|
||||
});
|
||||
|
||||
it('stays DISABLED when the flag is set but no key is resolvable', () => {
|
||||
const c = resolveJudgeConfig({ env: { ROUTER_LLM_JUDGE_ENABLED: '1' }, keychainGet: () => null });
|
||||
expect(c.enabled).toBe(false);
|
||||
expect(c.apiKey).toBe(null);
|
||||
});
|
||||
|
||||
it('is ENABLED only when the flag is set AND a key is resolvable (from keychain)', () => {
|
||||
const c = resolveJudgeConfig({ env: { ROUTER_LLM_JUDGE_ENABLED: '1' }, keychainGet: () => 'sk-keychain' });
|
||||
expect(c.enabled).toBe(true);
|
||||
expect(c.apiKey).toBe('sk-keychain');
|
||||
});
|
||||
|
||||
it('prefers the keychain key over the env fallback', () => {
|
||||
const c = resolveJudgeConfig({
|
||||
env: { ROUTER_LLM_JUDGE_ENABLED: '1', ROUTER_LLM_KEY: 'sk-env' },
|
||||
keychainGet: () => 'sk-keychain',
|
||||
});
|
||||
expect(c.apiKey).toBe('sk-keychain');
|
||||
});
|
||||
|
||||
it('falls back to the env key when the keychain is empty', () => {
|
||||
const c = resolveJudgeConfig({
|
||||
env: { ROUTER_LLM_JUDGE_ENABLED: '1', ROUTER_LLM_KEY: 'sk-env' },
|
||||
keychainGet: () => null,
|
||||
});
|
||||
expect(c.enabled).toBe(true);
|
||||
expect(c.apiKey).toBe('sk-env');
|
||||
});
|
||||
|
||||
it('accepts "true" (case-insensitive) as the enable flag', () => {
|
||||
const c = resolveJudgeConfig({ env: { ROUTER_LLM_JUDGE_ENABLED: 'TRUE' }, keychainGet: () => 'k' });
|
||||
expect(c.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('treats an arbitrary flag value (e.g. "0", "no") as NOT enabled', () => {
|
||||
expect(resolveJudgeConfig({ env: { ROUTER_LLM_JUDGE_ENABLED: '0' }, keychainGet: () => 'k' }).enabled).toBe(false);
|
||||
expect(resolveJudgeConfig({ env: { ROUTER_LLM_JUDGE_ENABLED: 'no' }, keychainGet: () => 'k' }).enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('exposes default models and passes through baseUrl from env', () => {
|
||||
const c = resolveJudgeConfig({
|
||||
env: { ROUTER_LLM_JUDGE_ENABLED: '1', ROUTER_LLM_BASE_URL: 'https://example/api' },
|
||||
keychainGet: () => 'k',
|
||||
});
|
||||
expect(Array.isArray(c.models)).toBe(true);
|
||||
expect(c.models.length).toBeGreaterThan(0);
|
||||
expect(c.baseUrl).toBe('https://example/api');
|
||||
});
|
||||
|
||||
it('never throws when keychainGet itself throws — degrades to no key, disabled', () => {
|
||||
const c = resolveJudgeConfig({ env: { ROUTER_LLM_JUDGE_ENABLED: '1' }, keychainGet: () => { throw new Error('keychain locked'); } });
|
||||
expect(c.enabled).toBe(false);
|
||||
expect(c.apiKey).toBe(null);
|
||||
});
|
||||
});
|
||||
@@ -68,14 +68,43 @@ import { homedir } from 'node:os';
|
||||
import { readStdin, parseEventJson, exitDecision } from './enforce-hook-helpers.mjs';
|
||||
import { llmJudgeCall, readJudgeBudget, bumpJudgeBudget, JUDGE_SESSION_BUDGET } from './llm-judge.mjs';
|
||||
|
||||
// Calibration 1 (2026-05-31) — `Skill` removed from judge scope (SCOPE fix, NOT
|
||||
// a discipline drop). Invoking a Skill mutates no state; it is the prescribed
|
||||
// §17 entry into work. Judging the skill-invocation itself and blocking on
|
||||
// doubt directly contradicts §17 (which mandates skills). The real mutations a
|
||||
// skill leads to (Edit/Write/MultiEdit/Bash/PowerShell/commit/push/Task) remain
|
||||
// fully judged below — doubt→block on those is unchanged.
|
||||
export const MUTATING_TOOLS = new Set([
|
||||
'Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'Bash', 'PowerShell', 'Skill', 'Task', 'Workflow',
|
||||
'Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'Bash', 'PowerShell', 'Task', 'Workflow',
|
||||
]);
|
||||
|
||||
function runtimeDir(override) {
|
||||
return override || join(homedir(), '.claude', 'runtime');
|
||||
}
|
||||
|
||||
/**
|
||||
* Calibration 4 (soft, 2026-05-31): the classifier's distilled task summary is
|
||||
* lossy and sometimes "(unknown)" even for a perfectly clear user request,
|
||||
* which made the judge block all real edits (no task to compare → doubt→block).
|
||||
* When the summary is unknown/empty, fall back to judging against the user's
|
||||
* actual last prompt — the ground-truth request — instead of nothing.
|
||||
*
|
||||
* This is NOT calibration 2 (which would blindly ALLOW on unknown). The judge
|
||||
* still runs and still blocks on doubt; it just uses better evidence. When both
|
||||
* the summary and the user prompt are unavailable, the task stays "(unknown)"
|
||||
* and doubt→block is preserved.
|
||||
*/
|
||||
export function resolveEffectiveTask(declaredTask, lastUserPrompt) {
|
||||
const dt = declaredTask || {};
|
||||
const summary = dt.task_summary;
|
||||
const summaryUnknown = !summary || summary === '(unknown)' || !String(summary).trim();
|
||||
const prompt = typeof lastUserPrompt === 'string' ? lastUserPrompt.trim() : '';
|
||||
if (summaryUnknown && prompt) {
|
||||
return { ...dt, task_summary: prompt, task_source: 'user_prompt_fallback' };
|
||||
}
|
||||
return dt;
|
||||
}
|
||||
|
||||
/** Read the classifier-written declared task for this session; stub on miss. */
|
||||
export function readDeclaredTask({ sessionId, runtimeDirOverride }) {
|
||||
const path = join(runtimeDir(runtimeDirOverride), `router-state-${sessionId || 'unknown'}.json`);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user