Files
houseplan-card/scripts/review-result-gate.mjs
T
Claudeandclaude[bot] d327ec3d93 feat(process): a failed show verdict re-routes to ask without a fresh budget (#726)
A non-green show verdict that found "something to decide" went down the same
path as "fix the code": S6 with a limit of 2. Promoting the task to track:ask
was left to the agent's memory, with no named criterion and no trace, and the
exhausted budget only surfaced on the next S7 - after a fix nobody would read.

The structured verdict now carries `route` (fix | reclassify) and an optional
`criterion` (one of the six show criteria of PROCESS.md section 5). The trust
boundary reads a missing route as fix, rejects one outside the dictionary and
rejects reclassify on a green verdict. `reviewRoute` in process-track.mjs is
the single decision: on a code review of an unconfirmed show it moves the task
to track:ask and S3-spec; on an owner-confirmed show it adds `blocked` and asks
the owner; anywhere else reclassify degrades to fix with a note. The verdict
that spends the last cycle sets review-4 at once; the stage budget is shared
across tracks, so promotion changes the limit (4), not the count.

The "Решение по вердикту" step makes one `process-track.mjs route` call (from
dev, like the track step) and only executes its output: comment from a file,
labels from add/remove lists, status via status-label.mjs as before. The track
step also emits `confirmed` and a `route_note` for the review prompt; the
review document anchor gains a route tail that the old reader still parses;
wait-verdict reports the two new pipeline comments. The guard's own
spent >= limit check stays as the safety net.

Issue: #726
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-01 03:06:49 +00:00

156 lines
8.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* Граница доверия между стадией модели и привилегированной публикацией (#556).
*
* `model_review` — единственная недоверенная стадия конвейера: там исполняется
* чужой код с Read/Write/Bash. Всё, что она может передать дальше, — один
* artifact. Публикация разбора, перестановка метки и слияние в `dev` идут в
* другой job, на чистом checkout `dev`, и обязаны принимать этот artifact как
* недоверенный ввод: полный набор файлов, сходящиеся контрольные суммы и
* совпадение КАЖДОГО поля паспорта с тем, что посчитала детерминированная
* стадия `prepare`. Подменённый, неполный, устаревший или чужой результат
* отвергается fail-closed.
*
* Проверка вынесена из inline-shell в функцию именно ради враждебных фикстур:
* в YAML её нельзя прогнать ни одним отрицательным случаем.
*
* node scripts/review-result-gate.mjs --dir=<путь> # поля ожидания из env
*/
import { createHash } from 'node:crypto';
import { readFileSync, readdirSync } from 'node:fs';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
/** Ровно эти файлы и ни одного больше: лишний файл — это уже чужой artifact. */
export const REQUIRED_FILES = ['manifest.sha256', 'prepared.json', 'review-document.md', 'verdict.json'];
/** Паспорт материала. Каждое поле сверяется с outputs стадии `prepare`. */
export const PASSPORT_FIELDS = [
'run_id', 'run_attempt', 'issue', 'stage', 'cycle', 'branch',
'material_sha', 'material_tree', 'material_specs', 'material_issue_body',
'validate_result', 'validate_url', 'rebase_note', 'validated_note',
'spec_body_changed', 'spec_body_doc', 'spec_body_recorded',
];
export const VERDICTS = ['green', 'yellow', 'red'];
/**
* Маршрут вердикта (#726): `fix` — вернуть автору по находкам; `reclassify` —
* трек `show` выбран неверно, задача не проходит критерий §5. Смысл
* `criterion` судит `reviewRoute` (`process-track.mjs`), а не граница.
*/
export const ROUTES = ['fix', 'reclassify'];
export const sha256 = (text) => createHash('sha256').update(text).digest('hex');
/**
* Маршрут структурного вердикта: поля нет (вердикты до #726, модель его не
* заполнила) — `fix`, прежний путь; значение вне словаря — `null`.
*/
export function verdictRoute(verdict) {
if (verdict?.route == null) return 'fix';
return ROUTES.includes(verdict.route) ? verdict.route : null;
}
/** Причины отказа самого `verdict.json`; пустой массив — вердикт цел. */
export function verdictProblems(verdict) {
const problems = [];
if (!VERDICTS.includes(verdict?.verdict)) problems.push(`verdict вне словаря: ${JSON.stringify(verdict?.verdict)}`);
if (typeof verdict?.high !== 'number') problems.push('verdict.high не число');
if (typeof verdict?.medium !== 'number') problems.push('verdict.medium не число');
if (typeof verdict?.summary !== 'string') problems.push('verdict.summary не строка');
const route = verdictRoute(verdict);
if (route === null) {
problems.push(`route вне словаря: ${JSON.stringify(verdict?.route)}`);
} else if (route === 'reclassify' && verdict?.verdict === 'green') {
// Противоречивый результат: «вперёд» и «трек неверен» сразу. Fail-closed,
// молчаливый green здесь хуже остановки (#726, порядок владельца #707).
problems.push('reclassify при зелёном вердикте — результат противоречив');
}
return problems;
}
/** Разбор строки `sha256sum`: «<hex> <имя>». */
export function parseManifest(text) {
const rows = [];
for (const line of String(text).split('\n')) {
if (!line.trim()) continue;
const match = /^([0-9a-f]{64})\s[\s*](.+)$/.exec(line);
if (!match) return null;
rows.push({ hash: match[1], name: match[2].trim() });
}
return rows;
}
/**
* @param {object} p
* @param {string[]} p.files имена файлов в каталоге artifact
* @param {(name:string)=>string} p.read содержимое файла
* @param {Record<string,string>} p.expected паспорт, посчитанный `prepare`
* @returns {string[]} причины отказа; пустой массив — принять
*/
export function reviewResultProblems({ files, read, expected }) {
const problems = [];
const actual = [...files].sort();
const wanted = [...REQUIRED_FILES].sort();
if (actual.join(',') !== wanted.join(',')) {
return [`набор файлов не тот: ожидалось ${wanted.join(', ')}, получено ${actual.join(', ') || '(пусто)'}`];
}
const manifest = parseManifest(read('manifest.sha256'));
if (!manifest) return ['manifest.sha256 не разобран'];
const covered = manifest.map((row) => row.name).sort();
const mustCover = REQUIRED_FILES.filter((name) => name !== 'manifest.sha256').sort();
if (covered.join(',') !== mustCover.join(',')) {
problems.push(`manifest покрывает не те файлы: ${covered.join(', ') || '(пусто)'}`);
}
for (const row of manifest) {
if (!REQUIRED_FILES.includes(row.name)) continue;
const digest = sha256(read(row.name));
if (digest !== row.hash) problems.push(`контрольная сумма не сходится: ${row.name}`);
}
if (!String(read('review-document.md')).trim()) problems.push('документ ревью пуст');
let prepared;
try {
prepared = JSON.parse(read('prepared.json'));
} catch (error) {
return [...problems, `prepared.json не разобран: ${error.message}`];
}
if (prepared?.schema !== 1) problems.push('prepared.json: не та схема');
for (const field of PASSPORT_FIELDS) {
const want = expected[field] ?? '';
const got = prepared?.[field] ?? '';
if (String(got) !== String(want)) {
problems.push(`паспорт не совпал: ${field} = «${got}», ожидалось «${want}»`);
}
}
let verdict;
try {
verdict = JSON.parse(read('verdict.json'));
} catch (error) {
return [...problems, `verdict.json не разобран: ${error.message}`];
}
return [...problems, ...verdictProblems(verdict)];
}
const invokedDirectly = process.argv[1]
&& resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url));
if (invokedDirectly) {
const dir = process.argv.find((a) => a.startsWith('--dir='))?.slice(6);
if (!dir) { console.error('usage: review-result-gate.mjs --dir=<путь>'); process.exit(2); }
const env = (name) => process.env[name.toUpperCase()] ?? '';
const expected = Object.fromEntries(PASSPORT_FIELDS.map((field) => [field, env(field)]));
expected.run_id = process.env.GITHUB_RUN_ID ?? '';
expected.run_attempt = process.env.GITHUB_RUN_ATTEMPT ?? '';
const problems = reviewResultProblems({
files: readdirSync(dir, { withFileTypes: true }).filter((e) => e.isFile()).map((e) => e.name),
read: (name) => readFileSync(resolve(dir, name), 'utf8'),
expected,
});
if (problems.length) {
for (const problem of problems) console.error(`::error::результат модели отвергнут — ${problem}`);
process.exit(1);
}
console.log('результат модели полон, суммы сходятся, паспорт совпал');
}