fix(process): free the reviewer prompt budget, rules stay in REVIEWER.md (#750)

The code-review prompt sat at exactly 1 400 of its 1 400 words (#634), so
any new line turned the budget test red, and #707/#726 already had to route
their notes through job outputs. Part of the text was dead or a retelling
of the reviewer digest, which #634 says the prompt must not repeat:

- the mutants fragment of the track line: since #709 `mutants` is always
  false, so «прогнаны Validate» was unreachable;
- «Отсутствие мутантов по диффу — не находка» in the show line: a rule of
  every track, already in REVIEWER.md «Трек show» and §10.4;
- three retellings — the repeated round, the gate scope and the severity
  paragraph — now one-line references to the REVIEWER.md sections. The ban
  on a separate issue for an in-scope Medium stays in the prompt: the model
  files issues itself, and that mistake is expensive.

What only the prompt said moves into REVIEWER.md with links to the canon:
the spec delta is the diff of the issue body, a doubt about locality means
a full review with a stated reason, the three smoke-select answers
(docs/TESTING.md), and geometry without invariants in the report is an
unrun gate.

The prompt is now 1 130 words; the 1 400 threshold stays, the difference
is headroom. A new test ties every «docs/process/REVIEWER.md, «X»»
reference in the prompt to an existing `## X` section.

Issue: #750
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
This commit is contained in:
Claude
2026-10-01 14:27:08 +00:00
committed by claude[bot]
parent 653d94ef09
commit de3e1f00e1
3 changed files with 59 additions and 40 deletions
+36 -1
View File
@@ -194,7 +194,42 @@ test('#634 промпт ревьюера: конспект вместо пере
'Затем верни JSON по схеме',
]) assert.ok(flat.includes(required), `промпт потерял: ${required}`);
// Правила живут в каноне; промпт, снова набравший пересказ, — возврат к 60–90 k
// контекста до первого git diff (аудит 22.09). До #634 — 1 642 слова.
// контекста до первого git diff (аудит 22.09). До #634 — 1 642 слова, к #750 —
// ровно 1 400, после #750 — 1 130: порог прежний, разница — запас.
const words = prompt.split(/\s+/).filter(Boolean).length;
assert.ok(words <= 1400, `промпт ревьюера ${words} слов > 1400`);
// #750 AC1: мёртвая строка мутантов (с #709 `mutants` всегда false) и правило
// всех треков, которое REVIEWER.md уже несёт, в промпт не возвращаются.
assert.doesNotMatch(prompt, /needs\.prepare\.outputs\.mutants/, 'промпт читает мёртвый выход mutants');
assert.doesNotMatch(flat, /Отсутствие мутантов/, 'правило «отсутствие мутантов — не находка» — в REVIEWER.md, не в промпте');
});
// #750 AC2: промпт вместо пересказа ссылается на разделы конспекта — ссылка
// без раздела оставила бы ревьюера без правила, которое промпт больше не несёт.
test('#750 AC2: ссылки промпта на REVIEWER.md ведут в существующие разделы; перенесённое на месте', () => {
const workflow = read('.github/workflows/_process.yml');
const prompt = workflow.slice(workflow.indexOf(' prompt: |\n'), workflow.indexOf(' claude_args: |'));
const flat = prompt.replace(/\s+/g, ' ');
const reviewer = read('docs/process/REVIEWER.md');
const sections = new Set(headings(reviewer).filter((h) => h.level === 2).map((h) => h.text));
const cited = [...flat.matchAll(/docs\/process\/REVIEWER\.md, «([^»]+)»/g)].map((m) => m[1]);
for (const name of ['Трек show', 'Повторный раунд', 'Объём гейтов', 'Находки и вердикт']) {
assert.ok(cited.includes(name), `промпт не ссылается на «${name}»`);
}
for (const name of cited) assert.ok(sections.has(name), `REVIEWER.md: нет раздела «## ${name}», на который ссылается промпт`);
// Запрет на отдельный issue для Medium в скоупе остаётся в промпте: модель
// заводит issue сама, и ошибка дорогая.
assert.ok(flat.includes('отдельный issue НЕ заводится (#202)'), 'промпт потерял запрет на issue для Medium в скоупе');
const bullets = (anchor) => topLevelBullets(sectionText(reviewer, anchor) ?? '');
const gates = bullets('объём-гейтов');
const uncertain = gates.find((bullet) => bullet.includes('НЕОПРЕДЕЛЁННОСТЬ'));
assert.ok(uncertain, '«Объём гейтов»: нет трёх ответов smoke-select с «НЕОПРЕДЕЛЁННОСТЬ»');
assert.match(uncertain, /связь не доказана/);
assert.ok(markdownLinks(uncertain).some((link) => link.file.endsWith('TESTING.md')), 'ответы smoke-select ссылаются на docs/TESTING.md');
assert.ok(gates.some((bullet) => /инвариант/i.test(bullet) && bullet.includes('непрогнанный гейт')),
'«Объём гейтов»: нет строки «геометрия без инвариантов в отчёте — непрогнанный гейт»');
const delta = norm(bullets('повторный-раунд').join('\n'));
assert.ok(delta.includes(norm('дифф тела issue')), '«Повторный раунд»: дельта ТЗ — дифф тела issue');
assert.ok(delta.includes(norm('Сомнение в локальности — полный разбор')), '«Повторный раунд»: сомнение — полный разбор');
});