docs(process): ролевые конспекты, замер входа, Snapshot генерируется, TESTING.md разделён

Вход агента до первого файла кода стоил ≈ 26 700 слов (аудит 22.09).

- docs/process/AUTHOR.md и REVIEWER.md — выжимки PROCESS.md: каждый пункт
  ссылается на раздел канона, ключевые формулировки дословные;
  test/process-digests.test.mjs сверяет якоря, ссылки и правила.
- scripts/entry-cost.mjs — маршрут чтения по роли и бюджет (автор ≤ 12 000
  слов, AC1); AGENTS.md «Read this first» называет те же маршруты.
- docs/STATUS.md: блок Snapshot генерирует scripts/status-snapshot.mjs
  (версии — release-contract, счётчики — inventory, теги — git); feature
  surface и ранние milestones перенесены дословно в docs/STATUS-FEATURES.md.
- docs/TESTING.md — действующая инструкция (684 строки, AC3); ручные
  чек-листы и приложения по issue перенесены дословно в docs/testing-notes/
  с индексом и тестом на полноту.
- Промпт ревьюера в process.yml читает конспект вместо пересказа правил;
  машинные требования (строка вердикта, REVIEW_DOC, запрет fetch, таблица
  «чем краснеет», разделы повторного раунда) сохранены и закреплены тестом.
- PROCESS.md: правила не менялись; добавлены ссылка на конспекты в шапке и
  уточнение в §10.4, что ревьюер конвейера читает конспект.
- 7 мутантов в реестре.

Issue: #634
User-Visible: no
This commit is contained in:
Claude
2026-09-24 02:33:08 +00:00
committed by claude[bot]
parent fe92ce067a
commit 7dc7597260
30 changed files with 5590 additions and 4352 deletions
-1
View File
@@ -70,7 +70,6 @@ export const NOT_AN_INPUT = [
['scripts/install-hooks.mjs', 'установка git-хуков при npm ci'],
['scripts/golden-accept.mjs', 'приёмка эталонов человеком, после прогона (#344)'],
['scripts/golden-container.mjs', 'локальная съёмка в пиновом образе (#334), ручной запуск'],
['scripts/inventory.mjs', 'отчёт для аудита, не гейт'],
['scripts/benchmark-wall-segment-model.mjs', 'ручной бенчмарк (npm run benchmark:wall-model)'],
['scripts/wall-strip-containment.mjs', 'ручной гейт внешних бэкапов планов (docs/WALL-THICKNESS.md)'],
['scripts/sh3d-convert/cli.mjs', 'CLI конвертера для человека'],
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env node
// #634: стоимость входа агента — сколько слов он читает до первого файла кода.
//
// Аудит 22.09 намерил ≈ 26 700 слов обязательного входа (SCOPE, AGENTS, полный
// PROCESS, STATUS) — 43–77 k токенов до первой строки кода. Ролевые конспекты
// `docs/process/AUTHOR.md` и `REVIEWER.md` заменяют на входе полный канон, а
// этот скрипт держит цену входа измеримой: маршрут по роли — один список здесь,
// `AGENTS.md` («Read this first») называет те же файлы в том же порядке, и тест
// `test/entry-cost.test.mjs` сверяет оба и краснеет на превышении бюджета.
//
// node scripts/entry-cost.mjs таблица по всем маршрутам
// node scripts/entry-cost.mjs --check exit 1, если маршрут с бюджетом его превысил
// node scripts/entry-cost.mjs --json
//
// Слово — как у `wc -w`: непустой отрезок между пробельными символами. Токены
// не считаются: их число зависит от токенизатора, а слова — нет.
// Вне замера сознательно: `CODEX-RUNBOOK.md` и `CLAUDE.md` папки владельца —
// они не в репозитории, и CI их не видит; тело issue — своё у каждой задачи.
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { isMainModule } from './spawn-portable.mjs';
const ROOT = fileURLToPath(new URL('..', import.meta.url));
/** Маршруты входа по роли. `budget: null` — только замер, без порога. */
export const ROUTES = Object.freeze({
author: {
budget: 12000, // AC1 #634
files: ['docs/SCOPE.md', 'AGENTS.md', 'docs/process/AUTHOR.md', 'docs/STATUS.md'],
},
reviewer: {
budget: 9000,
files: ['docs/SCOPE.md', 'AGENTS.md', 'docs/process/REVIEWER.md'],
},
// Тот, кто правит конвейер, гейты или сам процесс, читает канон целиком.
canon: {
budget: null,
files: ['docs/SCOPE.md', 'AGENTS.md', 'PROCESS.md', 'docs/STATUS.md'],
},
});
export const countWords = (text) => text.split(/\s+/).filter(Boolean).length;
/** Замер одного маршрута: слова по файлам, сумма и вердикт бюджета. */
export function measureRoute(route, read = (rel) => readFileSync(join(ROOT, rel), 'utf8')) {
const files = route.files.map((file) => ({ file, words: countWords(read(file)) }));
const total = files.reduce((sum, row) => sum + row.words, 0);
const over = route.budget != null && total > route.budget;
return { files, total, budget: route.budget, over };
}
export function measureAll(routes = ROUTES, read) {
return Object.fromEntries(Object.entries(routes).map(([name, route]) => [name, measureRoute(route, read)]));
}
export function renderTable(results) {
const lines = [];
for (const [name, result] of Object.entries(results)) {
const limit = result.budget == null ? 'без бюджета' : `бюджет ${result.budget}`;
const mark = result.over ? ' ПРЕВЫШЕН' : '';
lines.push(`${name}: ${result.total} слов (${limit})${mark}`);
for (const row of result.files) lines.push(` ${String(row.words).padStart(6)} ${row.file}`);
}
return lines.join('\n');
}
if (isMainModule(import.meta.url)) {
const results = measureAll();
if (process.argv.includes('--json')) console.log(JSON.stringify(results, null, 2));
else console.log(renderTable(results));
if (process.argv.includes('--check')) {
const failed = Object.entries(results).filter(([, result]) => result.over).map(([name]) => name);
if (failed.length) {
console.error(`entry-cost: превышен бюджет входа: ${failed.join(', ')}`);
process.exit(1);
}
}
}
+41 -28
View File
@@ -6,37 +6,50 @@
// underestimates the coverage that exists (review R5-2). The counts live here
// now, one command away, and STATUS.md describes the layers instead.
import { readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { METRIC_NAMES, collectMetrics, readBaseline } from './monolith-metrics.mjs';
import { isMainModule } from './spawn-portable.mjs';
const count = (dir, match, re) =>
readdirSync(dir)
const count = (root, dir, match, re) =>
readdirSync(join(root, dir))
.filter((f) => match.test(f))
.reduce((n, f) => n + (readFileSync(`${dir}/${f}`, 'utf8').match(re) || []).length, 0);
.reduce((n, f) => n + (readFileSync(join(root, dir, f), 'utf8').match(re) || []).length, 0);
const files = (dir, match) => readdirSync(dir).filter((f) => match.test(f)).length;
const files = (root, dir, match) => readdirSync(join(root, dir)).filter((f) => match.test(f)).length;
// Count both node:test spellings and indented pytest methods. Pure backend
// includes validation plus trail helper/recorder tests; only `test_ha_*` needs
// the Home Assistant harness (AUD-159B7-03).
const rows = [
['Node unit (frontend + tooling)', count('test', /\.test\.mjs$/, /^\s*(test|it)\(/gm)],
['pure backend (pytest, no HA)', count('tests_backend', /^test_(?!ha_).*\.py$/, /^\s*(?:async\s+)?def test_/gm)],
['HA-harness backend (CI, py3.13)', count('tests_backend', /^test_ha_.*\.py$/, /^\s*(?:async\s+)?def test_/gm)],
['browser smokes (headless chromium)', files('demo', /^smoke_.*\.mjs$/)],
];
const w = Math.max(...rows.map(([n]) => n.length));
for (const [name, n] of rows) console.log(`${name.padEnd(w)} ${n}`);
// #624: связность монолита — те же шесть чисел и тот же модуль, что у гейта
// `npm run lint:unused` («одно число — один источник»); рядом — база, чтобы
// движение было видно без git blame. bundleBytes есть только после сборки.
const { metrics } = collectMetrics(process.cwd());
const baseline = readBaseline(process.cwd()) || {};
console.log('\nmonolith (scripts/monolith-metrics.mjs; база scripts/monolith-baseline.json)');
const mw = Math.max(...METRIC_NAMES.map((n) => n.length));
for (const name of METRIC_NAMES) {
const now = metrics[name];
const base = baseline[name];
const delta = now != null && base != null && now !== base ? ` (${now > base ? '+' : ''}${now - base} к базе ${base})` : '';
console.log(`${name.padEnd(mw)} ${now ?? 'n/a — npm run build'}${delta}`);
/**
* Счётчики тестов по слоям. Один источник для этого отчёта и для таблицы
* Snapshot в docs/STATUS.md (`scripts/status-snapshot.mjs`, #634).
*
* Count both node:test spellings and indented pytest methods. Pure backend
* includes validation plus trail helper/recorder tests; only `test_ha_*` needs
* the Home Assistant harness (AUD-159B7-03).
*/
export function testInventory(root = process.cwd()) {
return [
{ id: 'node', label: 'Node unit (frontend + tooling)', count: count(root, 'test', /\.test\.mjs$/, /^\s*(test|it)\(/gm) },
{ id: 'backend-pure', label: 'pure backend (pytest, no HA)', count: count(root, 'tests_backend', /^test_(?!ha_).*\.py$/, /^\s*(?:async\s+)?def test_/gm) },
{ id: 'backend-ha', label: 'HA-harness backend (CI, py3.13)', count: count(root, 'tests_backend', /^test_ha_.*\.py$/, /^\s*(?:async\s+)?def test_/gm) },
{ id: 'smokes', label: 'browser smokes (headless chromium)', count: files(root, 'demo', /^smoke_.*\.mjs$/) },
];
}
if (isMainModule(import.meta.url)) {
const rows = testInventory().map(({ label, count: n }) => [label, n]);
const w = Math.max(...rows.map(([n]) => n.length));
for (const [name, n] of rows) console.log(`${name.padEnd(w)} ${n}`);
// #624: связность монолита — те же шесть чисел и тот же модуль, что у гейта
// `npm run lint:unused` («одно число — один источник»); рядом — база, чтобы
// движение было видно без git blame. bundleBytes есть только после сборки.
const { metrics } = collectMetrics(process.cwd());
const baseline = readBaseline(process.cwd()) || {};
console.log('\nmonolith (scripts/monolith-metrics.mjs; база scripts/monolith-baseline.json)');
const mw = Math.max(...METRIC_NAMES.map((n) => n.length));
for (const name of METRIC_NAMES) {
const now = metrics[name];
const base = baseline[name];
const delta = now != null && base != null && now !== base ? ` (${now > base ? '+' : ''}${now - base} к базе ${base})` : '';
console.log(`${name.padEnd(mw)} ${now ?? 'n/a — npm run build'}${delta}`);
}
}
+59
View File
@@ -0,0 +1,59 @@
// #634: якоря заголовков Markdown так, как их строит GitHub, и ссылки из текста.
//
// Ролевые конспекты процесса и индекс приложений TESTING.md ссылаются на
// разделы по якорю; тесты проверяют, что якорь существует. Правило GitHub:
// нижний регистр, выбросить всё, кроме букв, цифр, `_`, пробела и дефиса,
// каждый пробел — дефис (без схлопывания: «2.6 В разработке — реализация» →
// `26-в-разработке--реализация`), повтор — суффикс `-1`, `-2`.
// `scripts/check-docs.mjs` схлопывает дефисы для публичных документов — там
// это исторический контракт, здесь нужен настоящий якорь GitHub.
export const githubSlug = (heading) => heading
.trim()
.toLowerCase()
.replace(/<[^>]+>/g, '')
.replace(/[^\p{L}\p{M}\p{N}\p{Pc} -]/gu, '')
.replace(/ /g, '-');
/** Заголовки документа вне fenced-блоков: [{ level, text, anchor, line }]. */
export function headings(text) {
const seen = new Map();
const out = [];
let fenced = false;
text.replace(/\r\n?/g, '\n').split('\n').forEach((line, index) => {
if (/^\s*```/.test(line)) { fenced = !fenced; return; }
if (fenced) return;
const match = /^(#{1,6})\s+(.+?)\s*#*\s*$/.exec(line);
if (!match) return;
const base = githubSlug(match[2]);
const count = seen.get(base) || 0;
seen.set(base, count + 1);
out.push({ level: match[1].length, text: match[2], anchor: count ? `${base}-${count}` : base, line: index });
});
return out;
}
/** Текст раздела по якорю: от заголовка до следующего того же или старшего уровня. */
export function sectionText(text, anchor) {
const lines = text.replace(/\r\n?/g, '\n').split('\n');
const all = headings(text);
const at = all.findIndex((heading) => heading.anchor === anchor);
if (at < 0) return null;
const next = all.slice(at + 1).find((heading) => heading.level <= all[at].level);
return lines.slice(all[at].line, next ? next.line : lines.length).join('\n');
}
/** Относительные ссылки `[текст](путь#якорь)`; внешние URL пропускаются. */
export function markdownLinks(text) {
return [...text.matchAll(/\[([^\]]*)\]\(([^)\s]+)\)/g)]
.map((match) => match[2])
.filter((target) => !/^[a-z]+:/i.test(target))
.map((target) => {
const hash = target.indexOf('#');
return {
target,
file: hash >= 0 ? target.slice(0, hash) : target,
anchor: hash >= 0 ? decodeURIComponent(target.slice(hash + 1)) : '',
};
});
}
+80
View File
@@ -8627,6 +8627,86 @@ const MUTANT_DEFINITIONS = [
replace: " current = [line]; // mutant: last physical line instead of the paragraph\n }\n for (const paragraph of paragraphs) {",
}],
},
// #634: ролевые конспекты PROCESS.md, цена входа агента, генерируемый
// Snapshot и индекс приложений TESTING.md. Каждая защита — от тихого
// расхождения выжимки с каноном или тихого роста входа.
{
id: 'process-digest-dead-anchor',
guard: 'node --test --test-name-pattern="#634 конспект: каждая ссылка" test/process-digests.test.mjs',
because: 'a digest link to a PROCESS.md heading that does not exist sends the agent nowhere; '
+ 'the digest must fail as soon as an anchor goes stale (#634 AC2)',
patches: [{
file: 'docs/process/AUTHOR.md',
find: '[§5.1](../../PROCESS.md#51-короткий-трек-метка-trivial)',
replace: '[§5.1](../../PROCESS.md#51-короткий-трек)',
}],
},
{
id: 'process-digest-bullet-without-canon-link',
guard: 'node --test --test-name-pattern="#634 конспект: каждый пункт" test/process-digests.test.mjs',
because: 'every digest item cites its canon section; an item without a link is a rule the '
+ 'digest invented or a paraphrase nobody can check (#634 AC2)',
patches: [{
file: 'docs/process/AUTHOR.md',
find: ' нечего ([§5.1](../../PROCESS.md#51-короткий-трек-метка-trivial)).',
replace: ' нечего.',
}],
},
{
id: 'process-digest-key-rule-dropped',
guard: 'node --test --test-name-pattern="#634 конспект: ключевые правила" test/process-digests.test.mjs',
because: 'the empty third column is a Medium finding (PROCESS §2.7, #435); a reviewer digest '
+ 'that softens it must fail, not pass as a shorter paraphrase (#634)',
patches: [{
file: 'docs/process/REVIEWER.md',
find: ' результатом прогона. Пустой третий столбец — находка Medium, а не\n примечание.',
replace: ' результатом прогона. Пустой третий столбец желательно заполнить.',
}],
},
{
id: 'entry-cost-author-route-over-budget',
guard: 'node --test --test-name-pattern="#634 entry-cost: вход автора" test/entry-cost.test.mjs',
because: 'AC1 #634: the author entry route stays within 12 000 words; a digest that grows '
+ 'back into the canon must redden the measurement, not the next audit',
patches: [{
file: 'docs/process/AUTHOR.md',
find: '## Запрещено\n',
replace: `## Запрещено\n\n${'слово '.repeat(2000)}\n`,
}],
},
{
id: 'entry-cost-budget-never-over',
guard: 'node --test --test-name-pattern="#634 entry-cost: превышение" test/entry-cost.test.mjs',
because: 'the budget verdict is the whole point of the measurement; a comparison that never '
+ 'reports «over» turns --check into a word counter (#634 AC1)',
patches: [{
file: 'scripts/entry-cost.mjs',
find: ' const over = route.budget != null && total > route.budget;',
replace: ' const over = false; // mutant: budget never exceeded',
}],
},
{
id: 'status-snapshot-hides-version-mismatch',
guard: 'node --test --test-name-pattern="#634 status-snapshot: рассинхрон" test/status-snapshot.test.mjs',
because: 'a snapshot that reports the majority version and drops the odd source repeats the '
+ 'hand-written row it replaced: it looks current while the tree disagrees (#634)',
patches: [{
file: 'scripts/status-snapshot.mjs',
find: " const version = versions.mismatches.length\n",
replace: " const version = false // mutant: mismatches hidden\n",
}],
},
{
id: 'testing-notes-index-drops-section',
guard: 'node --test --test-name-pattern="#634 индекс приложений" test/testing-notes-index.test.mjs',
because: 'AC3 #634: appendices moved out of TESTING.md stay reachable only through the index; '
+ 'a section missing from it is text nobody will find again',
patches: [{
file: 'docs/testing-notes/README.md',
find: '- [Open passage (#157)](geometry.md#open-passage-157)\n',
replace: '',
}],
},
// #624: гейт мёртвого кода и связности монолита — три защиты, три мутанта.
{
id: 'unused-gate-allows-everything',
+138
View File
@@ -0,0 +1,138 @@
#!/usr/bin/env node
// #634: таблица «Snapshot» в docs/STATUS.md — генерируется, а не пишется руками.
//
// Раньше строка версии и счётчики в STATUS.md правились вручную и отставали от
// дерева за дни (review R5-2 уже убрал оттуда числа тестов по той же причине).
// Теперь всё, что вычислимо из дерева и git, считает этот скрипт из тех же
// источников, что и гейты: версии — `parseVersionSources` релизного контракта
// (`scripts/release-contract.mjs`), счётчики — `testInventory`
// (`scripts/inventory.mjs`), теги — `git tag`. Метки issue сюда не входят:
// api.github.com из песочницы агента недостижим, а статус живёт в метках.
//
// node scripts/status-snapshot.mjs напечатать блок
// node scripts/status-snapshot.mjs --write заменить блок в docs/STATUS.md
// node scripts/status-snapshot.mjs --check exit 1, если блок в файле устарел
// [--date=YYYY-MM-DD] [--root=<клон>]
//
// `--check` не стоит в CI сознательно: на shallow-checkout тегов нет, а
// счётчик тестов меняется с каждым новым тестом — гейт краснел бы на каждой
// задаче. Блок — снимок на дату в первой строке; свежий — одна команда.
import { readFileSync, writeFileSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { parseVersionSources } from './release-contract.mjs';
import { testInventory } from './inventory.mjs';
import { isMainModule } from './spawn-portable.mjs';
export const BEGIN = '<!-- status-snapshot:begin — generated by `node scripts/status-snapshot.mjs --write`; do not edit by hand -->';
export const END = '<!-- status-snapshot:end -->';
export const STATUS_FILE = 'docs/STATUS.md';
const STABLE_TAG = /^v\d+\.\d+\.\d+$/;
const PRERELEASE_TAG = /^v\d+\.\d+\.\d+-[0-9A-Za-z.-]+$/;
const git = (root, args) => {
const run = spawnSync('git', args, { cwd: root, encoding: 'utf8' });
return run.status === 0 ? run.stdout.trim() : null;
};
/** Сводка версий: одно значение во всех источниках либо список расхождений. */
export function summarizeVersions(sources) {
const entries = Object.entries(sources);
const values = [...new Set(entries.map(([, value]) => value))];
if (values.length === 1 && values[0]) return { version: values[0], sources: entries.length, mismatches: [] };
const counts = new Map();
for (const [, value] of entries) counts.set(value, (counts.get(value) || 0) + 1);
const majority = [...counts.entries()].sort((a, b) => b[1] - a[1])[0][0];
return {
version: majority,
sources: entries.length,
mismatches: entries.filter(([, value]) => value !== majority).map(([name, value]) => `${name}=${JSON.stringify(value)}`),
};
}
/** Последние по дате создания стабильный и пре-релизный теги (порядок `git tag --sort=-creatordate`). */
export function latestTags(tagsNewestFirst) {
return {
stable: tagsNewestFirst.find((tag) => STABLE_TAG.test(tag)) || null,
prerelease: tagsNewestFirst.find((tag) => PRERELEASE_TAG.test(tag)) || null,
};
}
/** Файлы, из которых `parseVersionSources` читает версию, — те же, что у релизного контракта. */
export const VERSION_FILES = Object.freeze({
packageJson: 'package.json',
packageLock: 'package-lock.json',
manifest: 'custom_components/houseplan/manifest.json',
constSource: 'custom_components/houseplan/const.py',
cardSource: 'src/houseplan-card.ts',
editorRuntimeSource: 'src/houseplan-editor-runtime.ts',
});
/** Собрать входы из дерева и git. Всё, что недоступно, — null, а не догадка. */
export function collectSnapshot(root, { date = new Date().toISOString().slice(0, 10) } = {}) {
const read = (rel) => readFileSync(join(root, rel), 'utf8');
const versions = summarizeVersions(parseVersionSources(Object.fromEntries(
Object.entries(VERSION_FILES).map(([key, rel]) => [key, read(rel)]))));
const tagList = git(root, ['tag', '--sort=-creatordate']);
const tags = latestTags(tagList ? tagList.split('\n').filter(Boolean) : []);
// Расстояние «N коммитов после тега» сюда не входит: закоммиченный блок
// устаревал бы на собственном коммите, и `--check` не имел бы смысла.
return { date, versions, tags, tests: testInventory(root) };
}
const code = (value) => (value ? `\`${value}\`` : 'n/a — tags are not available in this checkout');
/** Отрисовать блок. Чистая функция: вход — результат collectSnapshot. */
export function renderSnapshot({ date, versions, tags, tests }) {
const version = versions.mismatches.length
? `**${versions.version}** — NOT synchronized: ${versions.mismatches.join(', ')}`
: `**${versions.version}** in all ${versions.sources} version sources (\`scripts/release-contract.mjs\`)`;
const counts = tests.map((row) => `${row.label.replace(/\s*\(.*\)$/, '')} ${row.count}`).join(' · ');
return [
BEGIN,
'| Item | State |',
'|---|---|',
`| Generated | ${date} — rerun \`node scripts/status-snapshot.mjs\` for the current tree |`,
`| Version | ${version} |`,
`| Latest stable tag | ${code(tags.stable)} |`,
`| Latest prerelease tag | ${code(tags.prerelease)} |`,
`| Tests | ${counts} (\`npm run inventory\`) |`,
END,
].join('\n');
}
/** Заменить блок между маркерами; маркеров нет или их больше одной пары — ошибка. */
export function replaceBlock(text, block) {
const start = text.indexOf(BEGIN);
const end = text.indexOf(END);
if (start < 0 || end < start || text.indexOf(BEGIN, start + 1) >= 0 || text.indexOf(END, end + 1) >= 0)
throw new Error(`${STATUS_FILE}: expected exactly one ${BEGIN} … ${END} block`);
return text.slice(0, start) + block + text.slice(end + END.length);
}
/** Дата из существующего блока — чтобы `--check` сравнивал содержание, а не день запуска. */
export function blockDate(text) {
return /\| Generated \| (\d{4}-\d{2}-\d{2}) /.exec(text)?.[1] || null;
}
if (isMainModule(import.meta.url)) {
const arg = (name) => process.argv.find((a) => a.startsWith(`--${name}=`))?.slice(name.length + 3);
const root = resolve(arg('root') || fileURLToPath(new URL('..', import.meta.url)));
const file = join(root, STATUS_FILE);
const current = process.argv.includes('--write') || process.argv.includes('--check') ? readFileSync(file, 'utf8') : '';
const date = arg('date') || (process.argv.includes('--check') ? blockDate(current) : undefined);
const block = renderSnapshot(collectSnapshot(root, date ? { date } : {}));
if (process.argv.includes('--write')) {
writeFileSync(file, replaceBlock(current, block));
console.log(`${STATUS_FILE}: snapshot block updated`);
} else if (process.argv.includes('--check')) {
if (replaceBlock(current, block) !== current) {
console.error(`${STATUS_FILE}: snapshot block is stale — run node scripts/status-snapshot.mjs --write`);
process.exit(1);
}
console.log(`${STATUS_FILE}: snapshot block is current`);
} else {
console.log(block);
}
}