ruvnet-brain 4.7.21 → 4.7.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +2 -2
  2. package/bin/install.mjs +126 -9
  3. package/console/activity.js +122 -38
  4. package/package.json +2 -1
  5. package/plugin/.claude-plugin/plugin.json +1 -1
  6. package/plugin/.codex-plugin/plugin.json +1 -1
  7. package/plugin/scripts/agentdb-recall-process.mjs +115 -5
  8. package/plugin/scripts/agentdb-recall-reader.mjs +44 -2
  9. package/plugin/scripts/agentdb-recall.mjs +34 -12
  10. package/plugin/scripts/completion-claim-evidence.mjs +185 -11
  11. package/plugin/scripts/continuation-gate.mjs +52 -7
  12. package/plugin/scripts/lesson-gate.mjs +7 -39
  13. package/plugin/scripts/lesson-scope.mjs +51 -0
  14. package/plugin/scripts/lesson-store.mjs +1 -1
  15. package/plugin/scripts/ruflo-bin.mjs +14 -6
  16. package/plugin/scripts/ruflo-memory-hooks.mjs +157 -19
  17. package/plugin/scripts/session-snapshot-hook.mjs +1 -1
  18. package/plugin/scripts/session-start-core.mjs +2 -1
  19. package/plugin/scripts/session-start-health.mjs +3 -2
  20. package/scripts/codex-boot-surface-proof.mjs +208 -0
  21. package/scripts/generation-census.mjs +220 -0
  22. package/scripts/issue-autofix-agents.mjs +172 -0
  23. package/scripts/issue-autofix-comment.mjs +224 -0
  24. package/scripts/issue-autofix-diff.mjs +108 -0
  25. package/scripts/issue-autofix-exec.mjs +128 -0
  26. package/scripts/issue-autofix-fix.mjs +104 -0
  27. package/scripts/issue-autofix-gate.mjs +137 -0
  28. package/scripts/issue-autofix-night.mjs +164 -0
  29. package/scripts/issue-autofix-notes.mjs +346 -0
  30. package/scripts/issue-autofix-policy.mjs +349 -0
  31. package/scripts/issue-autofix-prompts.mjs +105 -0
  32. package/scripts/issue-autofix-release-ship.mjs +226 -0
  33. package/scripts/issue-autofix-release-util.mjs +141 -0
  34. package/scripts/issue-autofix-release.mjs +272 -0
  35. package/scripts/issue-autofix-report.mjs +121 -0
  36. package/scripts/issue-autofix-runner.mjs +75 -0
  37. package/scripts/issue-autofix-sandbox.mjs +108 -0
  38. package/scripts/issue-autofix-security.mjs +346 -0
  39. package/scripts/issue-autofix-selftest.mjs +101 -0
  40. package/scripts/issue-autofix-state.mjs +61 -0
  41. package/scripts/issue-autofix.mjs +382 -0
  42. package/scripts/issue-fix-lock.mjs +40 -0
  43. package/scripts/issue-fix.mjs +33 -20
  44. package/scripts/no-silent-substitution.mjs +1 -1
  45. package/scripts/ntfy-inbox.mjs +15 -0
  46. package/scripts/onboarding-console.mjs +76 -15
  47. package/scripts/recall-latency-probe.mjs +48 -0
  48. package/scripts/release-prepush-checks.mjs +251 -0
  49. package/scripts/release-prepush.mjs +168 -0
  50. package/scripts/release-qualification-contract.mjs +41 -0
  51. package/scripts/wired-check.mjs +13 -5
@@ -64,7 +64,7 @@ import {
64
64
  } from '../kb/brain-profile.mjs';
65
65
  // Lessons: read model + the two user verbs. Every mutation goes through lesson-store's own
66
66
  // updateLessons/ratify/demote/restore — this file adds a SURFACE, never a second writer.
67
- import { loadLessons, updateLessons, ratify, demote, restore, pending, weightOf, TRIGGERS, ENFORCEMENT, ORIGIN, SOURCE_CLASS, STATUS } from './lesson-store.mjs';
67
+ import { loadLessons, lessonApplies, projectNameFor, STORE_PATH as LESSON_STORE_PATH, updateLessons, ratify, demote, restore, pending, weightOf, TRIGGERS, ENFORCEMENT, ORIGIN, SOURCE_CLASS, STATUS } from './lesson-store.mjs';
68
68
  import {
69
69
  openRouterCredentialStatus,
70
70
  saveOpenRouterCredential,
@@ -1401,6 +1401,8 @@ function setLesson(body) {
1401
1401
  // --refresh-cache child. A request never scans — not inline on a first-ever run, and not via
1402
1402
  // setImmediate either (deferring synchronous work still blocks the loop when it runs). A machine with
1403
1403
  // no fleet scan yet is reported `warming`, never as a fabricated zero.
1404
+ // #500: bump when the meaning of the cached fleet changes. 2 = one entry per resolved store; 3 = + stable `id`.
1405
+ const ACTIVITY_CACHE_SCHEMA = 3; // 3 = each project also carries `id`, the store's dev:inode identity
1404
1406
  let ACTIVITY_MACHINE_CACHE = null;
1405
1407
  const CONSOLE_CACHE_PATH = path.join(CONSOLE_ROOT, '.cache/ruvnet-brain/console-cache.json');
1406
1408
 
@@ -1862,7 +1864,9 @@ function loadConsoleCache() {
1862
1864
  let restored = false;
1863
1865
  try {
1864
1866
  const j = JSON.parse(fs.readFileSync(CONSOLE_CACHE_PATH, 'utf8'));
1865
- if (j.activity && j.activity.at) { ACTIVITY_MACHINE_CACHE = j.activity; restored = true; }
1867
+ // #500: a cache written before the fleet scan deduped by store identity can hold one database
1868
+ // counted twice (a nested docs/adr/.swarm beside its parent). Never trust it — re-measure.
1869
+ if (j.activity && j.activity.at && j.activity.schema === ACTIVITY_CACHE_SCHEMA) { ACTIVITY_MACHINE_CACHE = j.activity; restored = true; }
1866
1870
  if (j.trust && j.trust.at) { TRUST_CACHE = j.trust; restored = true; }
1867
1871
  } catch { /* no cache yet — first ever boot */ }
1868
1872
  return restored;
@@ -1896,7 +1900,7 @@ function saveConsoleCache() {
1896
1900
  function adoptFleetFromDisk() {
1897
1901
  try {
1898
1902
  const j = readJSON(CONSOLE_CACHE_PATH);
1899
- if (j && j.activity && j.activity.at && (!ACTIVITY_MACHINE_CACHE || j.activity.at > ACTIVITY_MACHINE_CACHE.at)) {
1903
+ if (j && j.activity && j.activity.at && j.activity.schema === ACTIVITY_CACHE_SCHEMA && (!ACTIVITY_MACHINE_CACHE || j.activity.at > ACTIVITY_MACHINE_CACHE.at)) {
1900
1904
  ACTIVITY_MACHINE_CACHE = j.activity;
1901
1905
  }
1902
1906
  } catch { /* no cache yet — the caller kicks a child and reports `warming` */ }
@@ -1912,8 +1916,10 @@ function refreshFleetCache() {
1912
1916
  // pathIdentity, not path.resolve: resolve() normalises `.`/`..` and nothing else, so a project
1913
1917
  // reached through a symlink OR through the other capitalisation of a case-insensitive volume
1914
1918
  // was two distinct strings for one directory, and its memories were summed twice (#107).
1915
- const resolved = pathIdentity(s.project) ?? path.resolve(s.project);
1916
- if (seen.has(resolved)) continue; // a project visible under two roots counts once
1919
+ // #500: dedupe on the DATABASE, which is what holds the memories. Two directories (a repo and its
1920
+ // nested docs/adr/.swarm, or a linked worktree) can resolve to one store; project paths differ.
1921
+ const resolved = pathIdentity(s.db) ?? path.resolve(s.db);
1922
+ if (seen.has(resolved)) continue; // a store visible twice counts once
1917
1923
  seen.add(resolved);
1918
1924
  const n = Number(robustRead(s.db, "SELECT COUNT(*) FROM memory_entries WHERE status='active'").value || 0);
1919
1925
  if (n > 0) {
@@ -1922,17 +1928,25 @@ function refreshFleetCache() {
1922
1928
  const lastTouched = Number(robustRead(s.db, 'SELECT MAX(updated_at) FROM memory_entries').value || 0);
1923
1929
  // rel = the root-relative path — the SAME key reconcile:<id> recommendations use (wiringSurvey
1924
1930
  // computes projName the same way, relative to whichever root the project was found under).
1925
- projects.push({ name: path.basename(s.project), rel: path.relative(root, s.project), memories: n, lastTouched });
1931
+ // Name from the RESOLVED project root. A linked worktree whose main checkout lies outside this
1932
+ // scan root would give a '..'-prefixed rel, but reconcile:<id> keys (wiringSurvey) are relative to
1933
+ // the directory found under the root — so fall back to the found directory in that case.
1934
+ const inRoot = path.relative(root, s.project);
1935
+ const rel = (inRoot === '' || inRoot.startsWith('..') || path.isAbsolute(inRoot)) ? path.relative(root, s.found || s.project) : inRoot;
1936
+ // `id` = the store's own dev:inode identity (pathIdentity follows symlinks and sees hard links), so a
1937
+ // client can tell WHICH project is "you" without comparing folder names (two repos can share one).
1938
+ projects.push({ id: resolved, name: path.basename(s.project), rel, memories: n, lastTouched });
1926
1939
  total += n;
1927
1940
  }
1928
1941
  }
1929
1942
  }
1930
1943
  projects.sort((a, b) => b.memories - a.memories);
1931
- ACTIVITY_MACHINE_CACHE = { at: Date.now(), projects, totalMemories: total };
1944
+ ACTIVITY_MACHINE_CACHE = { schema: ACTIVITY_CACHE_SCHEMA, at: Date.now(), projects, totalMemories: total };
1932
1945
  saveConsoleCache();
1933
1946
  }
1934
- function findMemoryStores(root) {
1947
+ export function findMemoryStores(root) {
1935
1948
  const out = [];
1949
+ const seenDb = new Set();
1936
1950
  const walk = (dir, depth) => {
1937
1951
  if (depth > 3) return;
1938
1952
  let ents; try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
@@ -1944,8 +1958,12 @@ function findMemoryStores(root) {
1944
1958
  // Fleet activity is project memory, so resolve the canonical store rather than selecting a
1945
1959
  // coordination database by size. The latter is diagnostic-only and can contain unrelated rows.
1946
1960
  try {
1947
- const resolved = resolveProjectStore({ projectDir: dir }).canonicalAgentDbPath;
1948
- if (fs.existsSync(resolved)) out.push({ project: dir, db: resolved });
1961
+ const store = resolveProjectStore({ projectDir: dir });
1962
+ const resolved = store.canonicalAgentDbPath;
1963
+ // #500: record the RESOLVED project root and one entry per database. A nested docs/adr/.swarm
1964
+ // resolves to its parent's store; recording `dir` listed that one database twice.
1965
+ const id = pathIdentity(resolved) ?? path.resolve(resolved);
1966
+ if (fs.existsSync(resolved) && !seenDb.has(id)) { seenDb.add(id); out.push({ project: store.projectRoot, found: dir, db: resolved }); }
1949
1967
  } catch { /* an unresolvable directory is not a project store */ }
1950
1968
  continue;
1951
1969
  }
@@ -1956,6 +1974,38 @@ function findMemoryStores(root) {
1956
1974
  walk(root, 0);
1957
1975
  return out;
1958
1976
  }
1977
+ // #499: approved lessons are the ratified rules in the lesson store (the SAME active rule as
1978
+ // gatherLessons().counts.active). `available:false` means the store could not be read — unknown, never 0.
1979
+ // count = machine-wide (every ratified/active, non-demoted lesson)
1980
+ // applicable = the subset that applies to THIS project, by the SAME rule plugin/scripts/lesson-gate.mjs
1981
+ // uses (lesson-store.lessonApplies): unscoped, or scoped to this project, or proven in >=2 projects.
1982
+ // items = per-lesson id/title/scope/enforcement/status for the applicable ones, bounded (the metadata
1983
+ // the reporter asked to keep), `itemsTruncated` says when the bound cut the list.
1984
+ const APPROVED_ITEMS_MAX = 25;
1985
+ function approvedLessonsFigure(here) {
1986
+ try {
1987
+ // loadLessons() swallows a parse failure and returns [] — which would read as "0 approved". A file
1988
+ // that exists but does not parse is UNKNOWN; only an absent file is a true zero.
1989
+ if (fs.existsSync(LESSON_STORE_PATH)) JSON.parse(fs.readFileSync(LESSON_STORE_PATH, 'utf8'));
1990
+ const approved = loadLessons().filter((l) => (l.status === STATUS.RATIFIED || l.status === STATUS.ACTIVE) && !l.demoted);
1991
+ const mine = approved.filter((l) => lessonApplies(l, here)); // the gate's own rule (lesson-store.lessonApplies)
1992
+ const items = mine.slice(0, APPROVED_ITEMS_MAX).map((l) => {
1993
+ const ps = Array.isArray(l.projects) ? l.projects : [];
1994
+ const text = String(l.statement || '');
1995
+ return { id: l.id, title: text.length > 140 ? `${text.slice(0, 139)}…` : text,
1996
+ scope: ps.length ? ps.join(', ') : 'all projects', enforcement: l.enforcement ?? null,
1997
+ ratified: l.status === STATUS.RATIFIED || l.status === STATUS.ACTIVE, status: l.status };
1998
+ });
1999
+ return { available: true, count: approved.length, applicable: mine.length, items, itemsTruncated: mine.length > items.length };
2000
+ } catch { return { available: false, count: null, applicable: null, items: [], itemsTruncated: false }; }
2001
+ }
2002
+ // #499: a count that could not be read is null, not 0 — "0" would claim the store is empty.
2003
+ function readCount(db, sql) {
2004
+ const r = robustRead(db, sql);
2005
+ if (!r.ok) return null;
2006
+ const n = Number(r.value ?? 0);
2007
+ return Number.isFinite(n) ? n : null;
2008
+ }
1959
2009
  function gatherActivity(cwd) {
1960
2010
  let project;
1961
2011
  let db;
@@ -1965,19 +2015,27 @@ function gatherActivity(cwd) {
1965
2015
  db = resolved.canonicalAgentDbPath;
1966
2016
  } catch {
1967
2017
  const measuredAt = new Date().toISOString();
1968
- return { generatedAt: measuredAt, project: path.basename(cwd), hasStore: false, continuity: 'unavailable', ...freshnessOf(measuredAt) };
2018
+ return { generatedAt: measuredAt, project: path.basename(cwd), hasStore: false, continuity: 'unavailable', approvedLessons: approvedLessonsFigure(projectNameFor(cwd)), ...freshnessOf(measuredAt) };
1969
2019
  }
1970
2020
  if (!fs.existsSync(db)) {
1971
2021
  // No store to read — the existence check above IS the entire measurement, so it IS the
1972
2022
  // observation instant. Stamped here, not with a value taken before the check ran.
1973
2023
  const measuredAt = new Date().toISOString();
1974
- return { generatedAt: measuredAt, project: path.basename(project), hasStore: false, ...freshnessOf(measuredAt) };
2024
+ return { generatedAt: measuredAt, project: path.basename(project), hasStore: false, approvedLessons: approvedLessonsFigure(path.basename(project)), ...freshnessOf(measuredAt) };
1975
2025
  }
1976
- const out = { project: path.basename(project), hasStore: true };
2026
+ // projectId = the same dev:inode identity the fleet entries carry, so the client matches on identity, not the folder name.
2027
+ const out = { project: path.basename(project), projectId: pathIdentity(db) ?? path.resolve(db), hasStore: true, approvedLessons: approvedLessonsFigure(path.basename(project)) };
1977
2028
  const rows = (sql) => robustReadJSON(db, sql).rows;
2029
+ const agentdbLessons = readCount(db, "SELECT COUNT(*) FROM memory_entries WHERE namespace='lessons' AND status='active'");
1978
2030
  out.totals = {
1979
- memories: Number(robustRead(db, "SELECT COUNT(*) FROM memory_entries WHERE status='active'").value || 0),
1980
- lessons: Number(robustRead(db, "SELECT COUNT(*) FROM memory_entries WHERE namespace='lessons' AND status='active'").value || 0),
2031
+ memories: readCount(db, "SELECT COUNT(*) FROM memory_entries WHERE status='active'"),
2032
+ agentdbLessons,
2033
+ lessons: agentdbLessons, // legacy alias of agentdbLessons
2034
+ // #499: the learning pipeline's OWN measurements (null = unreadable, shown as Unknown, never 0). Distillation
2035
+ // writes patterns/episodes, never `lessons` rows; observations are the namespace it mines.
2036
+ observations: readCount(db, "SELECT COUNT(*) FROM memory_entries WHERE namespace='learning-observations' AND status='active'"),
2037
+ patterns: readCount(db, 'SELECT COUNT(*) FROM reasoning_patterns'),
2038
+ episodes: readCount(db, 'SELECT COUNT(*) FROM episodes'),
1981
2039
  };
1982
2040
  out.lessons = rows("SELECT key, access_count, date(created_at/1000,'unixepoch') AS learned, substr(content,1,600) AS excerpt FROM memory_entries WHERE namespace='lessons' AND status='active' ORDER BY created_at DESC");
1983
2041
  out.recent = rows("SELECT key, namespace, type, datetime(updated_at/1000,'unixepoch','localtime') AS at FROM memory_entries WHERE status='active' ORDER BY updated_at DESC LIMIT 18");
@@ -3550,6 +3608,9 @@ export {
3550
3608
  saveBrainProfile,
3551
3609
  setLesson,
3552
3610
  gatherLessons,
3611
+ gatherActivity,
3612
+ refreshFleetCache,
3613
+ loadConsoleCache,
3553
3614
  gatherRouterEngine,
3554
3615
  autoEligibleIds,
3555
3616
  gatherConfig,
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Measures the real hook paths against a real store (#429): prompt-time recall() and SessionStart restore().
4
+ * node scripts/recall-latency-probe.mjs --project <repo> [--runs N]
5
+ * Prints per-run milliseconds, outcome and `uptime` (machine load). Exit 1 unless EVERY prompt recall is
6
+ * ok-with-results / ok-empty within 1900 ms and EVERY restore is 'restored' within 3500 ms. The thresholds are
7
+ * the product's budgets, not tuned to the machine.
8
+ */
9
+ import { execFileSync } from 'node:child_process';
10
+ import path from 'node:path';
11
+ import { recall } from '../plugin/scripts/agentdb-recall.mjs';
12
+ import { restore } from '../plugin/scripts/ruflo-memory-hooks.mjs';
13
+
14
+ export const RECALL_LIMIT_MS = 1900;
15
+ export const RESTORE_LIMIT_MS = 3500;
16
+ const PROMPT = 'Where are we on the recall latency work? Catch me up on the last decision.';
17
+
18
+ const argValue = (name) => { const at = process.argv.indexOf(name); return at >= 0 ? process.argv[at + 1] : undefined; };
19
+ const uptime = () => { try { return execFileSync('uptime', { encoding: 'utf8' }).trim(); } catch { return 'uptime unavailable'; } };
20
+
21
+ async function main() {
22
+ const project = argValue('--project');
23
+ const runs = Number(argValue('--runs') ?? 5);
24
+ if (!project || !Number.isInteger(runs) || runs < 1) {
25
+ console.error('usage: recall-latency-probe.mjs --project <repo> [--runs N]');
26
+ process.exit(2);
27
+ }
28
+ const dir = path.resolve(project);
29
+ let failures = 0;
30
+ for (let run = 1; run <= runs; run += 1) {
31
+ const t0 = Date.now();
32
+ const r = await recall({ prompt: PROMPT, projectDir: dir });
33
+ const recallMs = Date.now() - t0;
34
+ const recallOk = ['ok-with-results', 'ok-empty'].includes(r.outcome) && recallMs <= RECALL_LIMIT_MS;
35
+ const t1 = Date.now();
36
+ const s = await restore({ cwd: dir });
37
+ const restoreMs = Date.now() - t1;
38
+ const restoreOk = s.status === 'restored' && restoreMs <= RESTORE_LIMIT_MS;
39
+ if (!recallOk) failures += 1;
40
+ if (!restoreOk) failures += 1;
41
+ console.log(`run ${run}: recall ${recallMs} ms ${r.outcome}${r.status?.['memory.db'] && r.outcome !== 'ok-with-results' ? ` [${r.status['memory.db']}]` : ''}${r.reason ? ` (${r.reason})` : ''} ${recallOk ? 'PASS' : 'FAIL'}`
42
+ + ` | restore ${restoreMs} ms ${s.status} ${restoreOk ? 'PASS' : 'FAIL'} | ${uptime()}`);
43
+ }
44
+ console.log(failures ? `FAIL: ${failures} of ${runs * 2} measurements outside budget (recall <= ${RECALL_LIMIT_MS} ms, restore <= ${RESTORE_LIMIT_MS} ms)`
45
+ : `PASS: all ${runs * 2} measurements within budget`);
46
+ process.exit(failures ? 1 : 0);
47
+ }
48
+ await main();
@@ -0,0 +1,251 @@
1
+ // scripts/release-prepush-checks.mjs — the individual checks behind `npm run release:prepush`.
2
+ //
3
+ // Each check is `async (ctx) => ({ ok, detail, warnings? })`. Every effect (git, gh, node, network)
4
+ // goes through `ctx.deps`, so the unit tests drive the checks with no real git, gh or network.
5
+ // The rules here restate nothing: the commands come from package.json, the census rules from
6
+ // scripts/claims-verify.mjs, the test inventory from scripts/release-qualification.mjs.
7
+
8
+ import path from 'node:path';
9
+
10
+ export const RELEASE_BRANCH = /^release\/(\d+\.\d+\.\d+)$/;
11
+ export const MAX_ADDED_BYTES = 5 * 1024 * 1024;
12
+ export const QUALIFICATION_SUITES = ['source', 'integration'];
13
+ export const QUALIFICATION_PLATFORMS = ['linux', 'macos', 'windows'];
14
+ export const ANCHOR_TEST_DIRS = ['tests/mesh/', 'tests/mutation/', 'tests/regression/'];
15
+ const CHANGED_TEST = /^tests\/(?:unit|integration|mesh|mutation|regression)\/.+\.test\.mjs$/;
16
+ export const RECENT_PRERELEASE_MS = 3 * 60 * 60 * 1000;
17
+ export const SEED_LINE = /\[corpus-next-seed\] (published-generation|committed-bootstrap) (\S+)/g;
18
+
19
+ // The preflight's first-push gates, by the npm script that defines each (never a restated flag), plus
20
+ // the two the preflight runs directly (.github/workflows/release-candidate-preflight.yml).
21
+ export const STATIC_GATES = [
22
+ { name: 'node-check-release', argv: ['--check', 'scripts/release.mjs'] },
23
+ { name: 'version-check', script: 'version:check' },
24
+ { name: 'release-authority', script: 'release:authority' },
25
+ { name: 'architecture-review-lock', argv: ['scripts/architecture-review-lock.mjs'] },
26
+ { name: 'convergence-check', script: 'convergence:check' },
27
+ { name: 'hooks-check', script: 'hooks:check' },
28
+ { name: 'single-source-check', script: 'single-source:check' },
29
+ { name: 'wired-check', script: 'wired:check' },
30
+ { name: 'substitution-check', script: 'substitution:check' },
31
+ ];
32
+
33
+ /**
34
+ * Stray-artifact rules for files ADDED on the branch. Every pattern is anchored to a path segment or a
35
+ * basename suffix: a loose `agentdb*` or `*.rvf*` would match legitimate tracked files such as
36
+ * kb/l2/agentdb.md or plugin/scripts/package-cards.rvf.
37
+ */
38
+ export const STRAY_PATTERNS = [
39
+ { name: 'ruflo/agentdb working store (.swarm/)', re: /(^|\/)\.swarm\// },
40
+ { name: 'database file', re: /\.db(-wal|-shm)?$/ },
41
+ { name: 'local Claude settings', re: /(^|\/)\.claude\/settings\.local[^/]*$/ },
42
+ { name: 'backup file (*.bak*)', re: /(^|\/)[^/]*\.bak(?:$|[-._\d])[^/]*$/ },
43
+ { name: 'opencode workspace', re: /(^|\/)\.opencode\// },
44
+ { name: 'VS Code workspace', re: /(^|\/)\.vscode\// },
45
+ ];
46
+
47
+ export function strayFindings(added, { rootFilesAtMain, sizes }) {
48
+ const findings = [];
49
+ for (const file of added) {
50
+ for (const rule of STRAY_PATTERNS) if (rule.re.test(file)) findings.push(`${file}: ${rule.name}`);
51
+ if (!file.includes('/') && !rootFilesAtMain.has(file)) findings.push(`${file}: new root-level file (absent at origin/main)`);
52
+ const size = sizes.get(file);
53
+ if (size > MAX_ADDED_BYTES) findings.push(`${file}: ${(size / 1048576).toFixed(1)} MB added (limit 5 MB)`);
54
+ }
55
+ return findings;
56
+ }
57
+
58
+ const lines = (text) => String(text || '').split('\n').map((line) => line.trim()).filter(Boolean);
59
+ const fail = (detail, extra = {}) => ({ ok: false, detail, ...extra });
60
+ const pass = (detail, extra = {}) => ({ ok: true, detail, ...extra });
61
+
62
+ async function git(ctx, args) {
63
+ const result = await ctx.deps.exec('git', args, { cwd: ctx.root });
64
+ if (result.status !== 0) throw new Error(`git ${args.join(' ')}: ${String(result.stderr || result.stdout).trim()}`);
65
+ return result.stdout;
66
+ }
67
+
68
+ /** (a) release/X.Y.Z branch, X.Y.Z = package.json version, no tracked changes. */
69
+ export async function branchIdentity(ctx) {
70
+ const branch = (await git(ctx, ['rev-parse', '--abbrev-ref', 'HEAD'])).trim();
71
+ const version = JSON.parse(await git(ctx, ['show', 'HEAD:package.json'])).version;
72
+ const dirty = lines(await git(ctx, ['status', '--porcelain', '--untracked-files=no']));
73
+ const problems = [];
74
+ const match = RELEASE_BRANCH.exec(branch);
75
+ if (!match) problems.push(`HEAD is on "${branch}", not a release/X.Y.Z branch`);
76
+ else if (match[1] !== version) problems.push(`branch says ${match[1]} but package.json says ${version}`);
77
+ if (dirty.length) problems.push(`${dirty.length} tracked change(s) not committed: ${dirty.slice(0, 5).join(', ')}`);
78
+ return problems.length ? fail(problems.join('; ')) : pass(`${branch} = package.json ${version}; tracked tree clean`);
79
+ }
80
+
81
+ /** (b) one static gate; its exit status is the verdict, its last output lines the detail. */
82
+ export function staticGate(gate) {
83
+ return async (ctx) => {
84
+ let argv = gate.argv;
85
+ if (gate.script) {
86
+ const command = ctx.packageScripts[gate.script];
87
+ if (!command) return fail(`package.json has no "${gate.script}" script`);
88
+ const node = /^node\s+(.+)$/.exec(command.trim());
89
+ argv = node && !/[|&;<>$`]/.test(node[1]) ? node[1].split(/\s+/) : null;
90
+ if (!argv) {
91
+ const npm = await ctx.deps.exec('npm', ['run', '--silent', gate.script], { cwd: ctx.root });
92
+ return npm.status === 0 ? pass(`npm run ${gate.script}`) : fail(tail(npm));
93
+ }
94
+ }
95
+ const result = await ctx.deps.exec(process.execPath, argv, { cwd: ctx.root });
96
+ return result.status === 0 ? pass(`node ${argv.join(' ')}`) : fail(`node ${argv.join(' ')} exited ${result.status}: ${tail(result)}`);
97
+ };
98
+ }
99
+ const tail = (result) => lines(`${result.stdout || ''}\n${result.stderr || ''}`).slice(-6).join(' | ').slice(0, 1200);
100
+
101
+ /** (c) the qualification inventory builds for every suite and platform, and every file is tracked at HEAD. */
102
+ export async function qualificationInventory(ctx) {
103
+ const tracked = new Set(lines(await git(ctx, ['ls-tree', '-r', '--name-only', 'HEAD'])));
104
+ const { qualificationPlan } = await ctx.deps.importModule(path.join(ctx.root, 'scripts/release-qualification.mjs'));
105
+ const problems = [];
106
+ let files = 0;
107
+ for (const suite of QUALIFICATION_SUITES) for (const platform of QUALIFICATION_PLATFORMS) {
108
+ try {
109
+ const plan = qualificationPlan(suite, platform);
110
+ files += plan.files.length;
111
+ for (const file of plan.files) if (!tracked.has(file)) problems.push(`${suite}/${platform}: ${file} is not tracked at HEAD`);
112
+ } catch (error) { problems.push(`${suite}/${platform}: ${error.message}`); }
113
+ }
114
+ return problems.length ? fail(problems.join('; ')) : pass(`6 plans, ${files} listed files, all tracked at HEAD`);
115
+ }
116
+
117
+ /** (d) nothing stray was added on the branch. */
118
+ export async function strayArtifacts(ctx) {
119
+ const base = (await git(ctx, ['merge-base', 'origin/main', 'HEAD'])).trim();
120
+ const added = lines(await git(ctx, ['diff', '--name-only', '--no-renames', '--diff-filter=A', base, 'HEAD']));
121
+ const rootFilesAtMain = new Set(lines(await git(ctx, ['ls-tree', '--name-only', 'origin/main'])));
122
+ const sizes = new Map();
123
+ for (const row of lines(await git(ctx, ['ls-tree', '-r', '-l', 'HEAD']))) {
124
+ const m = /^\S+ blob \S+\s+(\d+)\t(.+)$/.exec(row);
125
+ if (m) sizes.set(m[2], Number(m[1]));
126
+ }
127
+ const findings = strayFindings(added, { rootFilesAtMain, sizes });
128
+ return findings.length ? fail(findings.join('; ')) : pass(`${added.length} added file(s) since ${base.slice(0, 8)}; none stray`);
129
+ }
130
+
131
+ /** The surface rules of scripts/claims-verify.mjs (qualifyCensusArtifacts), with old and new numbers named. */
132
+ export function surfaceCensusProblems(file, text, census, { SURFACE_CLAIM_RULES }) {
133
+ const expected = { ...census, builtStores: census.publicStores };
134
+ const problems = [];
135
+ const want = census.chunks.toLocaleString('en-US');
136
+ if (!text.includes(want)) problems.push(`${file}: chunk count ${want} absent`);
137
+ for (const rule of SURFACE_CLAIM_RULES) for (const match of text.matchAll(rule.re)) for (const group of rule.groups) {
138
+ const found = Number(match[group.i].replace(/,/g, ''));
139
+ if (found !== expected[rule.key]) {
140
+ problems.push(`${file}: ${rule.name} ${found.toLocaleString('en-US')} -> ${expected[rule.key].toLocaleString('en-US')}`);
141
+ }
142
+ }
143
+ return problems;
144
+ }
145
+
146
+ async function surfacesAgainst(ctx, rev, census) {
147
+ const claims = await ctx.deps.importModule(path.join(ctx.root, 'scripts/claims-verify.mjs'));
148
+ const problems = [];
149
+ for (const file of claims.CHUNK_SURFACES) {
150
+ const text = await git(ctx, ['show', `${rev}:${file}`]);
151
+ problems.push(...surfaceCensusProblems(file, text, census, claims));
152
+ }
153
+ return { problems, surfaces: claims.CHUNK_SURFACES.length };
154
+ }
155
+
156
+ /** Which generation is the newest NOW (or the one named for a replay). */
157
+ async function targetGeneration(ctx) {
158
+ if (ctx.generation) return { tag: ctx.generation, bytes: null, origin: 'named' };
159
+ const seed = await ctx.deps.resolveSeed();
160
+ return { tag: seed.tag, bytes: seed.bytes ?? null, origin: seed.origin };
161
+ }
162
+
163
+ async function corpusWarnings(ctx) {
164
+ const warnings = [];
165
+ const now = ctx.deps.now();
166
+ const releases = await ctx.deps.exec('gh', ['release', 'list', '--repo', ctx.repo, '--limit', '20', '--json', 'tagName,isPrerelease,publishedAt'], { cwd: ctx.root });
167
+ if (releases.status !== 0) warnings.push('could not list releases to look for a fresh corpus prerelease');
168
+ else for (const row of JSON.parse(releases.stdout || '[]')) {
169
+ const age = now - Date.parse(row.publishedAt);
170
+ if (row.isPrerelease && /^corpus-sha256-/.test(row.tagName) && age < RECENT_PRERELEASE_MS) {
171
+ warnings.push(`${row.tagName} is a prerelease published ${Math.round(age / 60000)} min ago — a newer generation may promote soon`);
172
+ }
173
+ }
174
+ const runs = await ctx.deps.exec('gh', ['run', 'list', '--repo', ctx.repo, '--workflow', 'protected-release.yml', '--limit', '10', '--json', 'databaseId,displayTitle,status'], { cwd: ctx.root });
175
+ if (runs.status !== 0) warnings.push('could not list protected-release runs to look for a corpus publish in flight');
176
+ else for (const run of JSON.parse(runs.stdout || '[]')) {
177
+ if (run.status !== 'completed' && /^protected-release corpus\b/.test(run.displayTitle || '')) {
178
+ warnings.push(`corpus publish run ${run.databaseId} is ${run.status} — the newest generation may change under this release`);
179
+ }
180
+ }
181
+ return warnings;
182
+ }
183
+
184
+ /** (e) every committed claim surface quotes the census of the newest generation. */
185
+ export async function censusCheck(ctx) {
186
+ const generation = await targetGeneration(ctx);
187
+ const [census, warnings] = await Promise.all([
188
+ ctx.deps.census({ tag: generation.tag, bytes: generation.bytes }),
189
+ corpusWarnings(ctx).catch((error) => [`corpus activity check failed: ${error.message}`]),
190
+ ]);
191
+ const { problems, surfaces } = await surfacesAgainst(ctx, 'HEAD', census);
192
+ const summary = `${generation.tag} (${generation.origin}): ${census.publicStores} public stores, ${census.chunks.toLocaleString('en-US')} chunks`;
193
+ return problems.length
194
+ ? fail(`${summary}; stale surfaces: ${problems.join('; ')} — restamp with scripts/sync-census.mjs`, { warnings })
195
+ : pass(`${summary}; all ${surfaces} surfaces agree`, { warnings });
196
+ }
197
+
198
+ /** (f) text-anchored mesh/mutation/regression tests for changed source, plus changed tests. */
199
+ export async function anchoredTests(ctx) {
200
+ const base = (await git(ctx, ['merge-base', 'origin/main', 'HEAD'])).trim();
201
+ const changed = lines(await git(ctx, ['diff', '--name-only', '--no-renames', base, 'HEAD']));
202
+ const tracked = lines(await git(ctx, ['ls-tree', '-r', '--name-only', 'HEAD']));
203
+ const trackedSet = new Set(tracked);
204
+ const sources = changed.filter((file) => !file.startsWith('tests/') && trackedSet.has(file));
205
+ const selected = new Set(changed.filter((file) => CHANGED_TEST.test(file) && trackedSet.has(file)));
206
+ for (const file of tracked) {
207
+ if (!file.endsWith('.test.mjs') || !ANCHOR_TEST_DIRS.some((dir) => file.startsWith(dir))) continue;
208
+ const text = await ctx.deps.readFile(path.join(ctx.root, file));
209
+ if (sources.some((source) => text.includes(source))) selected.add(file);
210
+ }
211
+ const files = [...selected].sort();
212
+ if (!files.length) return pass(`no anchored or changed tests for ${sources.length} changed source file(s)`, { files });
213
+ const result = await ctx.deps.exec(process.execPath, [path.join(ctx.root, 'node_modules/vitest/vitest.mjs'), 'run', ...files], { cwd: ctx.root });
214
+ return result.status === 0 ? pass(`${files.length} test file(s) passed: ${files.join(', ')}`, { files })
215
+ : fail(`vitest exited ${result.status} on ${files.join(', ')}: ${tail(result)}`, { files });
216
+ }
217
+
218
+ /** (g) pre-dispatch: the generation the preflight sealed for SHA must still be the newest. */
219
+ export async function sealedGeneration(ctx) {
220
+ const sha = ctx.sha;
221
+ if (!/^[0-9a-f]{40}$/.test(String(sha || ''))) return fail('--pre-dispatch needs --sha <full 40-character commit>');
222
+ const gh = async (args) => {
223
+ const result = await ctx.deps.exec('gh', args, { cwd: ctx.root });
224
+ if (result.status !== 0) throw new Error(`gh ${args.slice(0, 2).join(' ')}: ${tail(result)}`);
225
+ return result.stdout;
226
+ };
227
+ const runs = JSON.parse(await gh(['run', 'list', '--repo', ctx.repo, '--workflow', 'release-candidate-preflight.yml', '--commit', sha,
228
+ '--limit', '20', '--json', 'databaseId,conclusion,status,event,headBranch,createdAt']));
229
+ const green = runs.filter((run) => run.event === 'push' && run.conclusion === 'success' && RELEASE_BRANCH.test(run.headBranch || ''))
230
+ .sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt))[0];
231
+ if (!green) return fail(`no successful release-candidate-preflight push run for ${sha.slice(0, 8)} on a release/* branch`);
232
+ const newest = await targetGeneration(ctx);
233
+ let sealed = null;
234
+ const jobs = JSON.parse(await gh(['run', 'view', String(green.databaseId), '--repo', ctx.repo, '--json', 'jobs'])).jobs || [];
235
+ const qe = jobs.find((job) => /(^|\/ )release-qe$/.test(job.name || ''));
236
+ if (qe) {
237
+ const log = await ctx.deps.exec('gh', ['run', 'view', '--repo', ctx.repo, '--job', String(qe.databaseId), '--log'], { cwd: ctx.root });
238
+ if (log.status === 0) sealed = [...String(log.stdout).matchAll(SEED_LINE)].map((m) => m[2]).pop() || null;
239
+ }
240
+ if (sealed) {
241
+ return sealed === newest.tag
242
+ ? pass(`preflight run ${green.databaseId} sealed ${sealed}, still the newest generation`)
243
+ : fail(`preflight run ${green.databaseId} sealed ${sealed}, but the newest generation is now ${newest.tag} — restamp the census, push, and re-run preflight`);
244
+ }
245
+ // Fallback when the job log is gone or reworded: the surfaces at SHA must quote the newest census.
246
+ const census = await ctx.deps.census({ tag: newest.tag, bytes: newest.bytes });
247
+ const { problems } = await surfacesAgainst(ctx, sha, census);
248
+ return problems.length
249
+ ? fail(`no sealed-generation line in preflight run ${green.databaseId}; surfaces at ${sha.slice(0, 8)} disagree with ${newest.tag}: ${problems.join('; ')}`)
250
+ : pass(`no sealed-generation line in preflight run ${green.databaseId}; surfaces at ${sha.slice(0, 8)} equal the census of ${newest.tag} (fallback)`);
251
+ }
@@ -0,0 +1,168 @@
1
+ #!/usr/bin/env node
2
+ // scripts/release-prepush.mjs — run, before the FIRST push of a release/X.Y.Z branch, every check
3
+ // release-candidate-preflight would otherwise fail on in its first minutes (CONTRIBUTING.md,
4
+ // "Releasing code"). An explicit command on purpose: tests/unit/development-push-boundary.test.mjs
5
+ // keeps ordinary pushes free of release gates, so this is never wired into the git pre-push hook.
6
+ //
7
+ // npm run release:prepush [-- --receipt <file>] [--generation <corpus tag>]
8
+ // npm run release:prepush -- --pre-dispatch --sha <SHA> [--receipt <file>]
9
+ //
10
+ // Runs ALL checks, collects every failure, prints each check's wall time and exits 1 on any failure.
11
+ // --pre-dispatch runs only the sealed-generation check: the corpus generation the preflight sealed for
12
+ // SHA must still be the newest one before protected-release.yml is dispatched.
13
+ // Programmatic: runPrepush({ root, sha, preDispatch, generation, repo, deps }) -> { ok, checks }.
14
+
15
+ import fs from 'node:fs';
16
+ import os from 'node:os';
17
+ import path from 'node:path';
18
+ import { spawn } from 'node:child_process';
19
+ import { fileURLToPath, pathToFileURL } from 'node:url';
20
+ import { rangeCensus } from './generation-census.mjs';
21
+ import {
22
+ STATIC_GATES, anchoredTests, branchIdentity, censusCheck, qualificationInventory, sealedGeneration, staticGate, strayArtifacts,
23
+ } from './release-prepush-checks.mjs';
24
+ import { isDirectInvocation } from '../plugin/scripts/entrypoint.mjs';
25
+
26
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
27
+ const EXEC_TIMEOUT_MS = 10 * 60 * 1000;
28
+
29
+ /** Promise-returning spawn: never blocks the event loop, so checks really run side by side. */
30
+ export function exec(command, args, { cwd, timeoutMs = EXEC_TIMEOUT_MS, env = process.env } = {}) {
31
+ return new Promise((resolve) => {
32
+ const child = spawn(command, args, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] });
33
+ const out = [], err = [];
34
+ child.stdout.on('data', (chunk) => out.push(chunk));
35
+ child.stderr.on('data', (chunk) => err.push(chunk));
36
+ const timer = setTimeout(() => child.kill('SIGKILL'), timeoutMs);
37
+ const done = (status, error) => {
38
+ clearTimeout(timer);
39
+ resolve({ status, stdout: Buffer.concat(out).toString('utf8'),
40
+ stderr: Buffer.concat(err).toString('utf8') + (error ? `\n${error.message}` : '') });
41
+ };
42
+ child.on('error', (error) => done(127, error));
43
+ child.on('close', (code, signal) => done(code ?? (signal ? 128 : 1)));
44
+ });
45
+ }
46
+
47
+ function repoFromRemote(url) {
48
+ const m = /github\.com[:/]([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/.exec(String(url || '').trim());
49
+ return m ? `${m[1]}/${m[2]}` : null;
50
+ }
51
+
52
+ /** The real effects. Tests replace any of them. */
53
+ export function defaultDeps({ root = ROOT, repo } = {}) {
54
+ let token;
55
+ const ghToken = async () => {
56
+ if (token === undefined) {
57
+ const result = await exec('gh', ['auth', 'token'], { cwd: root, timeoutMs: 15_000 });
58
+ token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN || (result.status === 0 ? result.stdout.trim() : null) || null;
59
+ }
60
+ return token;
61
+ };
62
+ return {
63
+ exec,
64
+ now: () => Date.now(),
65
+ readFile: async (file) => fs.promises.readFile(file, 'utf8'),
66
+ importModule: (file) => import(pathToFileURL(file).href),
67
+ // The exact CI invocation (ci.yml release-qe), as a child process; its stdout is the descriptor.
68
+ resolveSeed: async () => {
69
+ const result = await exec(process.execPath, [path.join(root, 'scripts/corpus-next-seed.mjs'), '--repo', repo, '--require-coverage'], { cwd: root });
70
+ if (result.status !== 0) throw new Error(`corpus-next-seed exited ${result.status}: ${result.stderr.trim().split('\n').slice(-3).join(' | ')}`);
71
+ return JSON.parse(result.stdout);
72
+ },
73
+ census: async ({ tag, bytes }) => rangeCensus({ repo, tag, bytes, token: await ghToken(),
74
+ privateStoresFile: path.join(root, 'kb/PRIVATE-STORES.json'), cacheDir: os.tmpdir() }),
75
+ };
76
+ }
77
+
78
+ export async function runPrepush({ root = ROOT, sha = null, preDispatch = false, generation = null, repo = null, deps = {} } = {}) {
79
+ const resolvedRoot = path.resolve(root);
80
+ const probe = { ...defaultDeps({ root: resolvedRoot, repo: 'unresolved/unresolved' }), ...deps };
81
+ let resolvedRepo = repo || process.env.GITHUB_REPOSITORY || null;
82
+ if (!resolvedRepo) {
83
+ const remote = await probe.exec('git', ['remote', 'get-url', 'origin'], { cwd: resolvedRoot });
84
+ resolvedRepo = remote.status === 0 ? repoFromRemote(remote.stdout) : null;
85
+ }
86
+ const merged = { ...defaultDeps({ root: resolvedRoot, repo: resolvedRepo }), ...deps };
87
+ let packageScripts = {};
88
+ try { packageScripts = JSON.parse(await merged.readFile(path.join(resolvedRoot, 'package.json'))).scripts || {}; } catch { /* the gates name it */ }
89
+ const ctx = { root: resolvedRoot, repo: resolvedRepo, sha, generation, deps: merged, packageScripts };
90
+
91
+ const plan = preDispatch
92
+ ? [['sealed-generation', sealedGeneration]]
93
+ : [
94
+ ['branch-identity', branchIdentity],
95
+ ...STATIC_GATES.map((gate) => [gate.name, staticGate(gate)]),
96
+ ['qualification-inventory', qualificationInventory],
97
+ ['stray-artifacts', strayArtifacts],
98
+ ['census', censusCheck],
99
+ ['anchored-tests', anchoredTests],
100
+ ];
101
+ const checks = await Promise.all(plan.map(async ([name, check]) => {
102
+ const started = Date.now();
103
+ let outcome;
104
+ try {
105
+ outcome = resolvedRepo || !['census', 'sealed-generation'].includes(name)
106
+ ? await check(ctx)
107
+ : { ok: false, detail: 'cannot tell which GitHub repository this is (no GITHUB_REPOSITORY, no github.com origin)' };
108
+ } catch (error) {
109
+ outcome = { ok: false, detail: `check threw: ${error.message}` };
110
+ }
111
+ return { name, ok: outcome.ok === true, ms: Date.now() - started, detail: outcome.detail,
112
+ ...(outcome.warnings?.length ? { warnings: outcome.warnings } : {}), ...(outcome.files ? { files: outcome.files } : {}) };
113
+ }));
114
+ return { ok: checks.every((check) => check.ok), checks };
115
+ }
116
+
117
+ function parseArgs(argv) {
118
+ const opts = { preDispatch: false, sha: null, generation: null, receipt: null, repo: null };
119
+ for (let i = 0; i < argv.length; i += 1) {
120
+ const flag = argv[i];
121
+ const value = () => {
122
+ const next = argv[i + 1];
123
+ if (!next || next.startsWith('--')) throw new Error(`${flag} needs a value`);
124
+ i += 1;
125
+ return next;
126
+ };
127
+ if (flag === '--pre-dispatch') opts.preDispatch = true;
128
+ else if (flag === '--sha') opts.sha = value();
129
+ else if (flag === '--generation') opts.generation = value();
130
+ else if (flag === '--receipt') opts.receipt = value();
131
+ else if (flag === '--repo') opts.repo = value();
132
+ else throw new Error(`unknown argument ${flag}`);
133
+ }
134
+ if (opts.sha && !opts.preDispatch) throw new Error('--sha is only meaningful with --pre-dispatch');
135
+ return opts;
136
+ }
137
+
138
+ export async function main(argv = process.argv.slice(2), { stdout = process.stdout, stderr = process.stderr, deps = {}, root = ROOT } = {}) {
139
+ let opts;
140
+ try { opts = parseArgs(argv); } catch (error) {
141
+ stderr.write(`[release:prepush] ${error.message}\nUsage: npm run release:prepush [-- --receipt FILE] [--generation TAG] | -- --pre-dispatch --sha SHA\n`);
142
+ return 2;
143
+ }
144
+ const started = Date.now();
145
+ const result = await runPrepush({ root, sha: opts.sha, preDispatch: opts.preDispatch, generation: opts.generation, repo: opts.repo, deps });
146
+ const totalMs = Date.now() - started;
147
+ for (const check of result.checks) {
148
+ stdout.write(`${check.ok ? 'PASS' : 'FAIL'} ${check.name.padEnd(26)} ${(check.ms / 1000).toFixed(1).padStart(6)} s ${check.detail}\n`);
149
+ for (const warning of check.warnings || []) stdout.write(`WARN ${check.name.padEnd(26)} ${warning}\n`);
150
+ }
151
+ const failed = result.checks.filter((check) => !check.ok);
152
+ stdout.write(`\n[release:prepush] ${result.ok ? 'PASS' : `FAIL (${failed.length}: ${failed.map((c) => c.name).join(', ')})`} in ${(totalMs / 1000).toFixed(1)} s\n`);
153
+ if (opts.receipt) {
154
+ const head = await (deps.exec || exec)('git', ['rev-parse', 'HEAD'], { cwd: root });
155
+ const receipt = { schemaVersion: 1, kind: 'ruvnet-brain-release-prepush', mode: opts.preDispatch ? 'pre-dispatch' : 'pre-push',
156
+ head: head.stdout.trim() || null, sha: opts.sha, generation: opts.generation, ok: result.ok,
157
+ finishedAt: new Date().toISOString(), totalMs, checks: result.checks };
158
+ fs.writeFileSync(path.resolve(opts.receipt), `${JSON.stringify(receipt, null, 2)}\n`);
159
+ }
160
+ return result.ok ? 0 : 1;
161
+ }
162
+
163
+ if (isDirectInvocation(import.meta.url)) {
164
+ main().then((code) => { process.exitCode = code; }).catch((error) => {
165
+ console.error(`[release:prepush] ${error?.stack || error}`);
166
+ process.exitCode = 1;
167
+ });
168
+ }