diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6e24f87..620a482 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,6 +23,8 @@ jobs: run: node --check skills/delegate-kit/scripts/agent-run - name: Writer caps and detach refusal run: bash skills/delegate-kit/tests/caps.sh + - name: Routing policy + run: bash skills/delegate-kit/tests/route.sh - name: SKILL.md stays a policy, not a manual run: | size=$(wc -c < skills/delegate-kit/SKILL.md) diff --git a/skills/delegate-kit/references/review.md b/skills/delegate-kit/references/review.md index 5436067..310c691 100644 --- a/skills/delegate-kit/references/review.md +++ b/skills/delegate-kit/references/review.md @@ -4,7 +4,7 @@ How many reviewers a diff deserves, which angle each one takes, and how their fi ## Independence is the first slot, not the whole review -One reviewer from the other family than the author buys **independence**: two families share fewer blind spots than one. No preset, panel or quota pressure moves slot A off the other family. The one thing that does is availability: when that family's CLI is not installed, `route` places slot A as a fresh read-only worker of the author's family, marks it `independent: false`, and the report names which one ran. A fresh context is still a real review; the other family is the stronger one. +One reviewer from the other family than the author buys **independence**: two families share fewer blind spots than one. No preset, panel or quota pressure moves slot A off the other family. Two things do. Availability: when that family's CLI is not installed, `route` places slot A as a fresh read-only worker of the author's family, marks it `independent: false`, and the report names which one ran. And the user: an explicit `--backend` on `route --role reviewer` pins slot A (the alternation runs from it), and a same-family pin is reported as `independent: false` too. A fresh context is still a real review; the other family is the stronger one. A second reviewer with the same brief buys almost nothing: the obvious findings come back twice and the subtle ones stay missed, because both reviewers looked from the same angle. What a second slot should buy is a second **lens**. So a panel is composed as lenses first, families second: diff --git a/skills/delegate-kit/scripts/agent-run b/skills/delegate-kit/scripts/agent-run index 873bdc1..ae0a119 100755 --- a/skills/delegate-kit/scripts/agent-run +++ b/skills/delegate-kit/scripts/agent-run @@ -134,7 +134,8 @@ const die = (msg, code = 1) => { const nowIso = () => new Date().toISOString(); const newId = () => `${new Date().toISOString().replace(/[-:T]/g, "").slice(0, 14)}-${Math.random().toString(36).slice(2, 6)}`; const readJson = (p, fallback = null) => { try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch { return fallback; } }; -const writeJson = (p, v) => fs.writeFileSync(p, JSON.stringify(v, null, 2) + "\n"); +// tmp + rename: a concurrent reader sees the old file or the new one, never a truncated one +const writeJson = (p, v) => { const tmp = `${p}.${process.pid}.tmp`; fs.writeFileSync(tmp, JSON.stringify(v, null, 2) + "\n"); fs.renameSync(tmp, p); }; const runDir = (id) => path.join(RUNS_DIR, id); const metaOf = (id) => readJson(path.join(runDir(id), "meta.json")); const saveMeta = (m) => writeJson(path.join(runDir(m.id), "meta.json"), m); @@ -273,13 +274,37 @@ function acquireCapsMutex(timeoutMs = 15_000) { const deadline = Date.now() + timeoutMs; for (;;) { try { fs.writeFileSync(CAPS_MUTEX, String(process.pid), { flag: "wx" }); capsMutexHeld = true; return; } catch (e) { if (e.code !== "EEXIST") throw e; } - const holder = Number(fs.readFileSync(CAPS_MUTEX, "utf8").trim() || 0); + let holder; try { holder = Number(fs.readFileSync(CAPS_MUTEX, "utf8").trim() || 0); } catch (e) { if (e.code === "ENOENT") continue; throw e; } if (!holder || !alive(holder)) { try { fs.rmSync(CAPS_MUTEX, { force: true }); } catch {} continue; } if (Date.now() > deadline) die(`another agent-run (pid ${holder}) has held ${CAPS_MUTEX} for ${timeoutMs / 1000}s; if it is stuck, kill it or remove the file`); Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50); } } -function releaseCapsMutex() { if (!capsMutexHeld) return; capsMutexHeld = false; try { fs.rmSync(CAPS_MUTEX, { force: true }); } catch {} } +/* + * Второй мьютекс — репозитория, общий с `agent-wt lock`: каталог + * /delegate-kit.caps.lock, mkdir атомарен. Без него внешний и + * нативный писатель одного репозитория считали слоты под разными замками и + * оба проходили при cap 1. agent-run берёт сначала машинный, потом + * репозиторный; agent-wt только репозиторный — цикла нет. + */ +let repoMutexHeld = null; +function acquireRepoMutex(cwd, timeoutMs = 15_000) { + const r = sh("git", ["rev-parse", "--git-common-dir"], { cwd }); + if (r.status !== 0) return; + const dir = path.join(path.resolve(cwd, r.stdout.trim()), "delegate-kit.caps.lock"); + const deadline = Date.now() + timeoutMs; + for (;;) { + try { fs.mkdirSync(dir); fs.writeFileSync(path.join(dir, "pid"), String(process.pid)); repoMutexHeld = dir; return; } catch (e) { if (e.code !== "EEXIST") throw e; } + let holder = 0; try { holder = Number(fs.readFileSync(path.join(dir, "pid"), "utf8").trim() || 0); } catch {} + if (!holder || !alive(holder)) { try { fs.rmSync(dir, { recursive: true, force: true }); } catch {} continue; } + if (Date.now() > deadline) die(`another agent-run or agent-wt (pid ${holder}) has held ${dir} for ${timeoutMs / 1000}s; if it is stuck, kill it or remove the directory`); + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50); + } +} +function releaseCapsMutex() { + if (repoMutexHeld) { const d = repoMutexHeld; repoMutexHeld = null; try { fs.rmSync(d, { recursive: true, force: true }); } catch {} } + if (!capsMutexHeld) return; capsMutexHeld = false; try { fs.rmSync(CAPS_MUTEX, { force: true }); } catch {} +} // Refuse before anything is spawned. Native locks (`agent-wt lock`) of the repository of // cwd count as workers and as writers; external runs count machine-wide. @@ -704,12 +729,15 @@ function suggestDepth(stats, kind) { // Slot A is always the other family than the author: that is where independence comes from and // nothing moves it. Slot B may sit on the author's family — independence is already paid for, and // the second slot buys a second lens, not a second opinion from the same angle. Slot C alternates. -function reviewComposition(depth, author, kind) { - const other = otherBackend(author); +// Slot A is the other family than the author unless the user pins it with --backend: an +// explicit same-family reviewer is the user's call, reported as independent: false. Before +// it was honoured, `route --role reviewer --backend X` was silently overridden here. +function reviewComposition(depth, author, kind, pinned = null) { + const first = pinned || otherBackend(author); const lenses = kind === "refactor" ? ["correctness", "standards", "spec"] : ["correctness", "spec", "standards"]; const n = depth === "single" ? 1 : depth === "panel" ? 2 : 3; - const families = [other, author, other]; - return lenses.slice(0, n).map((lens, i) => ({ slot: "ABC"[i], lens, backend: families[i], independent: families[i] !== author })); + const families = [first, otherBackend(first), first]; + return lenses.slice(0, n).map((lens, i) => ({ slot: "ABC"[i], lens, backend: families[i], independent: families[i] !== author, pinned: Boolean(pinned) && i === 0 })); } function cmdRoute(argv) { @@ -722,10 +750,13 @@ function cmdRoute(argv) { let depth = argv.depth && argv.depth !== true ? String(argv.depth) : (suggested ? suggested.depth : "single"); if (!DEPTHS.includes(depth)) die(`--depth must be ${DEPTHS.join("|")} (got ${depth})`); const author = base.author; - const reviewers = reviewComposition(depth, author, kind).map((r) => { - const rr = resolveRoute({ ...argv, role: "reviewer", backend: r.backend, _composed: true, depth: undefined, diff: undefined }); - const note = rr.why.find((w) => w.includes("falls back")); - return { ...r, backend: rr.backend, independent: rr.backend !== author, model: rr.model, effort: rr.effort, dispatch: rr.dispatch, invoke: rr.invoke, ...(note ? { note } : {}) }; + const reviewers = reviewComposition(depth, author, kind, argv.backend && argv.backend !== true ? String(argv.backend) : null).map((r) => { + // a slot the user pinned with --backend keeps it even when that CLI is missing; composed slots may fall back + const rr = resolveRoute({ ...argv, role: "reviewer", backend: r.backend, _composed: !r.pinned, depth: undefined, diff: undefined }); + // keep what the caller must act on: a fallback that happened, or a pinned CLI that is missing + const note = rr.why.find((w) => w.includes("falls back") || w.includes("not installed")); + const { pinned: _p, ...slot } = r; + return { ...slot, backend: rr.backend, independent: rr.backend !== author, model: rr.model, effort: rr.effort, dispatch: rr.dispatch, invoke: rr.invoke, ...(note ? { note } : {}) }; }); const lead = depth === "led" ? (() => { const l = resolveRoute({ ...argv, role: "review-lead", backend: undefined, depth: undefined, diff: undefined }); return { backend: l.backend, model: l.model, effort: l.effort, dispatch: l.dispatch, invoke: l.invoke }; })() : null; const sessions = reviewers.length + (lead ? 2 : 0); @@ -873,7 +904,7 @@ async function cmdRun(opts, resumeOf = null) { if (opts._supervise) SUPERVISED = { id: opts.id, role, backend, model, effort, cwd, write }; // The initial supervised start runs under the mutex its parent still holds; every other // start (foreground, detach parent, quota fallback) takes it itself. - if (!opts._supervise || opts._fallbackFrom) acquireCapsMutex(); + if (!opts._supervise || opts._fallbackFrom) { acquireCapsMutex(); if (write) acquireRepoMutex(cwd); } checkCaps(caps, write, cwd); if (write) { const conflict = writeLockConflict(cwd, null); if (conflict) die(conflict); } diff --git a/skills/delegate-kit/scripts/agent-wt b/skills/delegate-kit/scripts/agent-wt index 6d238cf..0ffdeab 100755 --- a/skills/delegate-kit/scripts/agent-wt +++ b/skills/delegate-kit/scripts/agent-wt @@ -39,6 +39,10 @@ base_ref_file() { local p; p=$(wt_path "$1"); [ -d "$p" ] || return 1; echo "$(c lock_info() { local lf; lf=$(lock_file "$1" 2>/dev/null) || { echo "no-lock"; return; } + lock_state "$lf" +} +lock_state() { # state of one lock file: unlocked | locked(...) | stale-lock(...) + local lf=$1 [ -f "$lf" ] || { echo "unlocked"; return; } local kind pid; kind=$(jq -r '.kind // "process"' "$lf"); pid=$(jq -r '.pid // empty' "$lf") # A native lock has no process behind it: the parent holds it on behalf of its own @@ -124,12 +128,15 @@ acquire_caps_mutex() { done die "another agent-wt (pid $pid) has held $d for 15s; if it is stuck, kill it or remove the directory" } +# Every linked worktree of this checkout, not only the ones under wt_base_dir: an +# `agent-run --cwd` writer may sit in any worktree, and its lock lives in the same place. locked_worktrees() { - local base; base=$(wt_base_dir); [ -d "$base" ] || return 0 - for p in "$base"/*/; do - [ -d "$p" ] || continue - local n; n=$(basename "$p") - [[ "$(lock_info "$n")" == locked* ]] && echo "$n" + local common; common="$(git rev-parse --git-common-dir)"; common="$(cd "$common" && pwd)" + [ -d "$common/worktrees" ] || return 0 + local lf + for lf in "$common"/worktrees/*/delegate-kit.lock; do + [ -f "$lf" ] || continue + [[ "$(lock_state "$lf")" == locked* ]] && basename "$(dirname "$lf")" done return 0 } diff --git a/skills/delegate-kit/tests/caps.sh b/skills/delegate-kit/tests/caps.sh index 036c514..466aef1 100755 --- a/skills/delegate-kit/tests/caps.sh +++ b/skills/delegate-kit/tests/caps.sh @@ -133,13 +133,26 @@ for w in w1 w2 w3 w4 w5 w6 w7 w8; do "$WT" lock "$w" --max-writers 1 >/dev/null ok "занят один worktree" "$("$WT" list | jq '[.[] | select(.lock | startswith("locked"))] | length')" "1" ok "мьютекс отпущен" "$([ -e "$BASE/repo/.git/delegate-kit.caps.lock" ] && echo held || echo free)" "free" -echo "── брошенный мьютекс с мёртвым pid не блокирует" +echo "── брошенные мьютексы с мёртвым pid не блокируют" +for w in w1 w2 w3 w4 w5 w6 w7 w8; do "$WT" release "$w" >/dev/null 2>&1; done mkdir -p "$BASE/repo/.git/delegate-kit.caps.lock"; echo 999999 > "$BASE/repo/.git/delegate-kit.caps.lock/pid" -"$WT" lock w8 --max-writers 8 >/dev/null; ok "lock прошёл" "$?" "0" +"$WT" lock w8 --max-writers 8 >/dev/null; ok "agent-wt снял труп репозиторного мьютекса" "$?" "0" echo 999999 > "$DELEGATE_KIT_HOME/caps.lock" -run "$WTS/w8" --max-writers 1 -ok "agent-run снял труп мьютекса и дошёл до потолка" "$(has "$ERR" "concurrent writers reached")" "yes" -ok "мьютекс agent-run отпущен" "$([ -e "$DELEGATE_KIT_HOME/caps.lock" ] && echo held || echo free)" "free" +mkdir -p "$BASE/repo/.git/delegate-kit.caps.lock"; echo 999999 > "$BASE/repo/.git/delegate-kit.caps.lock/pid" +run "$WTS/w7" --max-writers 1 +ok "agent-run снял оба трупа и дошёл до потолка" "$(has "$ERR" "concurrent writers reached")" "yes" +ok "машинный мьютекс отпущен" "$([ -e "$DELEGATE_KIT_HOME/caps.lock" ] && echo held || echo free)" "free" +ok "репозиторный мьютекс отпущен" "$([ -e "$BASE/repo/.git/delegate-kit.caps.lock" ] && echo held || echo free)" "free" + +echo "── agent-wt видит process-lock внешнего писателя в любом linked worktree" +"$WT" release w8 >/dev/null +git -C "$BASE/repo" worktree add -q -b dk/elsewhere "$BASE/elsewhere" >/dev/null 2>&1 +jq -n --arg pid "$$" '{id:"ext-1",role:"implementer",kind:"process",pid:($pid|tonumber),cwd:"x"}' > "$BASE/repo/.git/worktrees/elsewhere/delegate-kit.lock" +ERR=$("$WT" lock w1 --max-writers 1 2>&1 >/dev/null); RC=$? +ok "отказ: чужой worktree занят живым процессом" "$RC" "1" +ok "он назван" "$(has "$ERR" "(elsewhere)")" "yes" +jq -n '{id:"ext-2",role:"implementer",kind:"process",pid:999999,cwd:"x"}' > "$BASE/repo/.git/worktrees/elsewhere/delegate-kit.lock" +"$WT" lock w1 --max-writers 1 >/dev/null; ok "мёртвый process-lock не считается" "$?" "0" echo; echo "Пройдено: $PASS, провалено: $FAIL" exit $((FAIL > 0)) diff --git a/skills/delegate-kit/tests/route.sh b/skills/delegate-kit/tests/route.sh new file mode 100755 index 0000000..2347159 --- /dev/null +++ b/skills/delegate-kit/tests/route.sh @@ -0,0 +1,101 @@ +#!/bin/bash +# Стенд движка политики: `agent-run route` и `preset` — кто, на каком семействе, +# нативно или внешне, сколько ревьюеров и с какими линзами. +# +# Модели не вызываются: route детерминирован при --parent, PATH с заглушками CLI +# (каталог bin/ ниже) и DELEGATE_KIT_HOME с config.json. Пороги глубины проверяются +# на синтетических diff'ах ровно на границе, потому что именно границы решают, +# предложит ли координатор панель. +# +# ./route.sh +set -u +AR="$(cd "$(dirname "$0")/../scripts" && pwd)/agent-run" +NODE=$(command -v node) +BASE="${TMPDIR:-/tmp}/dk-route-test.$$" +trap 'rm -rf "$BASE"' EXIT +export DELEGATE_KIT_HOME="$BASE/state" +unset DELEGATE_KIT_PRESET DELEGATE_KIT_PARENT DELEGATE_KIT_MAX_WRITERS DELEGATE_KIT_MAX_WORKERS +mkdir -p "$DELEGATE_KIT_HOME" "$BASE/both" "$BASE/codex-only" "$BASE/none" +for c in claude codex; do printf '#!/bin/sh\n' > "$BASE/both/$c"; chmod +x "$BASE/both/$c"; done +cp "$BASE/both/codex" "$BASE/codex-only/codex" +PASS=0; FAIL=0 +ok(){ if [ "$2" = "$3" ]; then echo " ✔ $1"; PASS=$((PASS+1)); else echo " ✘ $1: ожидалось [$3], получено [$2]"; FAIL=$((FAIL+1)); fi; } +# route CLIS args… — jq-выражение последним аргументом; CLIS = both|codex-only|none +route(){ local clis=$1; shift; local q=${*: -1}; set -- "${@:1:$#-1}"; PATH="$BASE/$clis" "$NODE" "$AR" route "$@" 2>/dev/null | jq -r "$q"; } +mkdiff(){ # path lines-per-file file… + local out=$1 n=$2; shift 2; : > "$out" + for f in "$@"; do { printf 'diff --git a/%s b/%s\n--- a/%s\n+++ b/%s\n@@ -1,1 +1,3 @@\n' "$f" "$f" "$f" "$f"; seq 1 "$n" | sed 's/^/+line /'; } >> "$out"; done +} + +echo "── пресет auto, родитель Claude, оба CLI" +ok "planner → claude fable, нативно" "$(route both --role planner --parent claude '[.backend,.model,.dispatch,.invoke.subagent_type]|join(" ")')" "claude fable native dk-planner" +ok "implementer → codex sol, внешне" "$(route both --role implementer --parent claude '[.backend,.model,.effort,.dispatch]|join(" ")')" "codex gpt-5.6-sol high external" +ok "researcher → claude sonnet medium" "$(route both --role researcher --parent claude '[.backend,.model,.effort]|join(" ")')" "claude sonnet medium" +ok "reviewer после codex-автора → claude, независим, нативно" "$(route both --role reviewer --parent claude '.reviewers[0]|[.backend,(.independent|tostring),.dispatch]|join(" ")')" "claude true native" +ok "verifier — третья сторона к ревьюеру: обратно на codex" "$(route both --role verifier --parent claude '.backend + " " + (.why[0]|contains("third party")|tostring)')" "codex true" +ok "review-lead следует за planner'ом" "$(route both --role review-lead --parent claude '[.backend,.model]|join(" ")')" "claude fable" + +echo "── пресеты и приоритет" +ok "main-claude: implementer нативно на claude" "$(route both --role implementer --parent claude --preset main-claude '[.backend,.dispatch]|join(" ")')" "claude native" +ok "main-claude: ревьюер следует за автором → codex" "$(route both --role reviewer --parent claude --preset main-claude '.author + " " + .reviewers[0].backend')" "claude codex" +ok "main-codex под Codex-родителем: всё нативно" "$(route both --role planner --parent codex --preset main-codex '[.backend,.dispatch]|join(" ")')" "codex native" +ok "флаг сильнее env" "$(DELEGATE_KIT_PRESET=main-codex route both --role planner --parent claude --preset main-claude '.preset')" "main-claude" +ok "env сильнее config" "$($NODE "$AR" preset main-claude >/dev/null; DELEGATE_KIT_PRESET=main-codex route both --role planner --parent claude '.preset')" "main-codex" +ok "config сильнее auto" "$(route both --role researcher --parent claude '.preset + " " + .backend')" "main-claude claude" +rm -f "$DELEGATE_KIT_HOME/config.json" +ok "алиас main-gpt" "$(route both --role planner --parent claude --preset main-gpt '.preset')" "main-codex" +echo '{"roles":{"planner":{"claude":["opus","max"]}}}' > "$DELEGATE_KIT_HOME/config.json" +ok "config переопределяет модель роли" "$(route both --role planner --parent claude '.model + " " + .effort')" "opus max" +rm -f "$DELEGATE_KIT_HOME/config.json" + +echo "── автор и независимость" +ok "--author-backend self: ревьюер на другом семействе" "$(route both --role reviewer --parent claude --author-backend self '.author + " " + .reviewers[0].backend + " " + (.reviewers[0].independent|tostring)')" "claude codex true" +ok "--kind ui под auto: implementer на claude" "$(route both --role implementer --parent claude --kind ui '.backend')" "claude" +ok "--kind ui: ревьюер следует за UI-автором → codex" "$(route both --role reviewer --parent claude --kind ui '.author + " " + .reviewers[0].backend')" "claude codex" + +echo "── отсутствие CLI другого семейства" +ok "ревьюер падает на нативного, independent=false" "$(route none --role reviewer --parent claude --author-backend self '.reviewers[0]|[.backend,.dispatch,(.independent|tostring)]|join(" ")')" "claude native false" +ok "об этом сказано в note" "$(route none --role reviewer --parent claude --author-backend self '.reviewers[0].note|contains("not installed")')" "true" +ok "verifier без codex: same-family" "$(route none --role verifier --parent claude --author-backend self '.independence')" "same-family" +ok "implementer без codex: внешний с предупреждением, не падает" "$(route none --role implementer --parent claude '.dispatch + " " + (.why|map(select(contains("not installed")))|length|tostring)')" "external 1" +ok "явный --backend занимает слот A (раньше композиция его затирала)" "$(route both --role reviewer --parent claude --author-backend self --backend claude '.reviewers[0].backend + " " + (.reviewers[0].independent|tostring)')" "claude false" +ok "явный --backend без CLI не переезжает на другое семейство" "$(route codex-only --role reviewer --parent codex --author-backend self --backend claude '.reviewers[0].backend + " " + .reviewers[0].dispatch')" "claude external" +ok "закреплённый слот без CLI несёт note про отсутствие" "$(route codex-only --role reviewer --parent codex --author-backend self --backend claude '.reviewers[0].note|contains("not installed")')" "true" +ok "панель от закреплённого A чередуется дальше" "$(route both --role reviewer --parent claude --author-backend self --backend claude --depth panel '[.reviewers[].backend]|join(" ")')" "claude codex" + +echo "── глубина ревью по diff" +D="$BASE/diffs"; mkdir -p "$D" +mkdiff "$D/399.diff" 399 src/a.ts; mkdiff "$D/400.diff" 400 src/a.ts +mkdiff "$D/10files.diff" 5 $(for i in $(seq 1 10); do echo "src/f$i.ts"; done) +mkdiff "$D/2mods.diff" 5 packages/a/x.ts packages/b/y.ts +mkdiff "$D/risk.diff" 3 src/auth/login.ts +mkdiff "$D/riskline.diff" 3 src/plain.ts; printf '+const password = "x";\n' >> "$D/riskline.diff" +mkdiff "$D/led.diff" 50 $(for m in 0 1 2; do for i in $(seq 1 9); do echo "packages/p$m/f$i.ts"; done; done) +mkdiff "$D/lock.diff" 2000 package-lock.json +R="--role reviewer --parent claude --author-backend self" +ok "399 строк → single, без вопроса" "$(route both $R --diff "$D/399.diff" '.depth + " " + (.ask_user!=null|tostring)')" "single false" +ok "400 строк → panel, вопрос пользователю" "$(route both $R --diff "$D/400.diff" '.depth + " " + (.ask_user!=null|tostring) + " " + (.reviewers|length|tostring)')" "panel true 2" +ok "10 файлов → panel" "$(route both $R --diff "$D/10files.diff" '.depth')" "panel" +ok "2 модуля → panel" "$(route both $R --diff "$D/2mods.diff" '.depth + " " + (.diff.modules|join(","))')" "panel packages/a,packages/b" +ok "risk zone по пути → panel даже на 3 строках" "$(route both $R --diff "$D/risk.diff" '.depth + " " + .diff.risk_zones[0]')" "panel src/auth/login.ts" +ok "risk zone по строке diff" "$(route both $R --diff "$D/riskline.diff" '.diff.risk_zones[0]')" "src/plain.ts" +ok "27 файлов, 3 модуля, 1350 строк → led" "$(route both $R --diff "$D/led.diff" '.depth + " " + (.diff.files|tostring) + " " + (.diff.modules|length|tostring)')" "led 27 3" +ok "lockfile — шум, не строки" "$(route both $R --diff "$D/lock.diff" '.depth + " " + (.diff.lines|tostring) + " " + .diff.noise_files[0]')" "single 0 package-lock.json" +ok "--kind mechanical: large → single" "$(route both $R --diff "$D/led.diff" --kind mechanical '.depth')" "single" +ok "--depth явно = «да», без вопроса" "$(route both $R --diff "$D/399.diff" --depth panel '.depth + " " + (.suggested.overridden|tostring) + " " + (.ask_user!=null|tostring)')" "panel true false" + +echo "── состав панели" +ok "panel: A correctness на другом семействе, B spec на семействе автора" "$(route both $R --diff "$D/400.diff" '[.reviewers[]|.lens+":"+.backend+":"+(.independent|tostring)]|join(" ")')" "correctness:codex:true spec:claude:false" +ok "led: A/B/C чередуются, lead на семействе planner'а" "$(route both $R --diff "$D/led.diff" '([.reviewers[]|.lens+":"+.backend]|join(" ")) + " lead:" + .lead.backend + ":" + .lead.model')" "correctness:codex spec:claude standards:codex lead:claude:fable" +ok "led: 5 сессий в cost_note" "$(route both $R --diff "$D/led.diff" '.cost_note|startswith("5 read-only")')" "true" +ok "--kind refactor: линзы correctness+standards" "$(route both $R --diff "$D/400.diff" --kind refactor '[.reviewers[].lens]|join(" ")')" "correctness standards" +ok "merge_rules только при панели" "$(route both $R --diff "$D/399.diff" '.merge_rules==null')" "true" + +echo "── отказы" +ok "модель чужого семейства на run" "$(PATH="$BASE/both" "$NODE" "$AR" run --role planner --backend claude --model gpt-5.6-sol --prompt x --parent claude 2>&1 | grep -c 'is a codex model but --backend is claude')" "1" +ok "--depth вне списка" "$(PATH="$BASE/both" "$NODE" "$AR" route $R --diff "$D/399.diff" --depth deep 2>&1 | grep -c 'depth must be')" "1" +ok "--diff для не-ревьюера" "$(PATH="$BASE/both" "$NODE" "$AR" route --role planner --parent claude --diff "$D/399.diff" 2>&1 | grep -c 'apply to --role reviewer')" "1" +ok "неизвестный пресет" "$(PATH="$BASE/both" "$NODE" "$AR" route --role planner --parent claude --preset main-gemini 2>&1 | grep -ci 'preset')" "1" + +echo; echo "Пройдено: $PASS, провалено: $FAIL" +exit $((FAIL > 0))