ruvnet-brain 4.7.22 → 4.7.24
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.
- package/README.md +1 -1
- package/bin/install.mjs +59 -1
- package/kb/lifecycle-evidence-retention.mjs +1 -1
- package/kb/update-storage-transaction.mjs +161 -1
- package/package.json +2 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/scripts/brain-footprint.mjs +5 -1
- package/plugin/scripts/learn-capture.mjs +26 -3
- package/scripts/generation-census.mjs +220 -0
- package/scripts/issue-autofix-agents.mjs +172 -0
- package/scripts/issue-autofix-comment.mjs +224 -0
- package/scripts/issue-autofix-diff.mjs +108 -0
- package/scripts/issue-autofix-exec.mjs +128 -0
- package/scripts/issue-autofix-fix.mjs +104 -0
- package/scripts/issue-autofix-gate.mjs +137 -0
- package/scripts/issue-autofix-night.mjs +164 -0
- package/scripts/issue-autofix-notes.mjs +346 -0
- package/scripts/issue-autofix-policy.mjs +349 -0
- package/scripts/issue-autofix-prompts.mjs +105 -0
- package/scripts/issue-autofix-release-ship.mjs +226 -0
- package/scripts/issue-autofix-release-util.mjs +141 -0
- package/scripts/issue-autofix-release.mjs +272 -0
- package/scripts/issue-autofix-report.mjs +121 -0
- package/scripts/issue-autofix-runner.mjs +75 -0
- package/scripts/issue-autofix-sandbox.mjs +108 -0
- package/scripts/issue-autofix-security.mjs +346 -0
- package/scripts/issue-autofix-selftest.mjs +101 -0
- package/scripts/issue-autofix-state.mjs +61 -0
- package/scripts/issue-autofix.mjs +382 -0
- package/scripts/issue-fix-lock.mjs +40 -0
- package/scripts/issue-fix.mjs +33 -20
- package/scripts/no-silent-substitution.mjs +1 -1
- package/scripts/ntfy-inbox.mjs +15 -0
- package/scripts/onboarding-console.mjs +6 -1
- package/scripts/release-prepush-checks.mjs +251 -0
- package/scripts/release-prepush.mjs +168 -0
- package/scripts/release-qualification-contract.mjs +32 -0
- package/scripts/wired-check.mjs +11 -5
|
@@ -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
|
+
}
|
|
@@ -354,6 +354,24 @@ export const RELEASE_REQUIREMENTS = Object.freeze({
|
|
|
354
354
|
"linux": ["tests/unit/continuation-gate-codex-transcript.test.mjs", "tests/unit/codex-boot-surface-proof.test.mjs", "tests/unit/console-activity-fleet-dedupe.test.mjs", "tests/unit/console-activity-identity.test.mjs", "tests/unit/console-activity-lessons.test.mjs", "tests/unit/lesson-gate.test.mjs", "tests/unit/ruflo-memory-session.test.mjs"],
|
|
355
355
|
"macos": ["tests/unit/continuation-gate-codex-transcript.test.mjs", "tests/unit/codex-boot-surface-proof.test.mjs", "tests/unit/console-activity-fleet-dedupe.test.mjs", "tests/unit/console-activity-identity.test.mjs", "tests/unit/console-activity-lessons.test.mjs", "tests/unit/lesson-gate.test.mjs", "tests/unit/ruflo-memory-session.test.mjs"]
|
|
356
356
|
}
|
|
357
|
+
},
|
|
358
|
+
{
|
|
359
|
+
"id": "open-issue-fixes-4-7-24",
|
|
360
|
+
"reason": "A storage transaction stuck at RECOVERY_REQUIRED has a supported way out: a read-only preview, then an explicit keep-live disposition bound to the live tree's exact sha256 that moves every other tree intact and records a terminal DISPOSED receipt (#335); a detached learning capture is acknowledged only after its exact readback, so a refused store is retried instead of being coalesced away for ten minutes (#377). Windows is not verified",
|
|
361
|
+
"files": [],
|
|
362
|
+
"platformFiles": {
|
|
363
|
+
"linux": ["tests/unit/update-recovery-disposition.test.mjs", "tests/unit/learn-capture-ack-after-readback.test.mjs"],
|
|
364
|
+
"macos": ["tests/unit/update-recovery-disposition.test.mjs", "tests/unit/learn-capture-ack-after-readback.test.mjs"]
|
|
365
|
+
}
|
|
366
|
+
},
|
|
367
|
+
{
|
|
368
|
+
"id": "issue-autofix-bounded-writer",
|
|
369
|
+
"reason": "The unattended issue fixer (ADR-107) stays inside its authority: kill switch, single-writer lock, deny-by-default editable paths, the static malware and secret scan, the manifest/lockfile/pack/audit night gate, the red-team judge, restricted tool-less LLM stages, the macOS sandbox for model-written code (macOS only), never-weakened tests, independent verifier, resumable never-doubled release steps, no force or merge, owner-marker-prefixed comments, personal notes that are never boilerplate or repeated, and a registry and LaunchAgent that match. It runs on the owner's macOS launchd, so it is qualified on macOS and Linux only, the same scope as the ntfy inbox reader",
|
|
370
|
+
"files": [],
|
|
371
|
+
"platformFiles": {
|
|
372
|
+
"linux": ["tests/unit/issue-autofix-policy.test.mjs", "tests/unit/issue-autofix-agents.test.mjs", "tests/unit/issue-autofix-release.test.mjs", "tests/unit/issue-autofix-release-ship.test.mjs", "tests/unit/issue-autofix-comment.test.mjs", "tests/unit/issue-autofix-deploy.test.mjs", "tests/unit/issue-autofix-security.test.mjs", "tests/unit/issue-autofix-notes.test.mjs", "tests/unit/issue-autofix-gate.test.mjs", "tests/unit/issue-autofix-night.test.mjs"],
|
|
373
|
+
"macos": ["tests/unit/issue-autofix-policy.test.mjs", "tests/unit/issue-autofix-agents.test.mjs", "tests/unit/issue-autofix-release.test.mjs", "tests/unit/issue-autofix-release-ship.test.mjs", "tests/unit/issue-autofix-comment.test.mjs", "tests/unit/issue-autofix-deploy.test.mjs", "tests/unit/issue-autofix-security.test.mjs", "tests/unit/issue-autofix-notes.test.mjs", "tests/unit/issue-autofix-gate.test.mjs", "tests/unit/issue-autofix-night.test.mjs", "tests/unit/issue-autofix-sandbox.test.mjs"]
|
|
374
|
+
}
|
|
357
375
|
}
|
|
358
376
|
],
|
|
359
377
|
"integration": [
|
|
@@ -376,6 +394,11 @@ export const RELEASE_REQUIREMENTS = Object.freeze({
|
|
|
376
394
|
"reason": "Retired 2026-10-12: the learning queue, flush worker and Console queue recovery were deleted (owner ruling: hooks only call rUv's memory tools); learning writes are covered by canonical-learning-capture",
|
|
377
395
|
"files": []
|
|
378
396
|
},
|
|
397
|
+
{
|
|
398
|
+
"id": "release-prepush-shift-left",
|
|
399
|
+
"reason": "The explicit release pre-push gate collects every failure with per-check timing, reads the newest corpus generation's public census over HTTP Range (refusing zip64, unknown compression and CRC mismatch), names each stale claim surface with its old and new number, refuses stray added artifacts with anchored patterns, selects text-anchored tests for changed source, and before dispatch requires the preflight's sealed generation to still be the newest",
|
|
400
|
+
"files": ["tests/unit/release-prepush.test.mjs", "tests/unit/generation-census.test.mjs"]
|
|
401
|
+
},
|
|
379
402
|
{
|
|
380
403
|
"id": "canonical-progression-store",
|
|
381
404
|
"reason": "Actual adopted canonical storage, exact readback, and concurrent session restoration retain consent and complete history",
|
|
@@ -459,6 +482,15 @@ export const RELEASE_REQUIREMENTS = Object.freeze({
|
|
|
459
482
|
"linux": ["tests/integration/stale-install-trap.test.mjs", "tests/integration/ruflo-memory-hooks.test.mjs"],
|
|
460
483
|
"macos": ["tests/integration/stale-install-trap.test.mjs", "tests/integration/ruflo-memory-hooks.test.mjs"]
|
|
461
484
|
}
|
|
485
|
+
},
|
|
486
|
+
{
|
|
487
|
+
"id": "issue-autofix-simulated-end-to-end",
|
|
488
|
+
"reason": "The whole autofix night (select, AIDefence screen, plan, build, scan, sandboxed test validity, red-team, verify, one batched release, push, pull request, fast-forward, dispatch, terminal check, one personal note per person) runs against a file:// origin with a fake gh and fake claude; hostile fixes (obfuscated payload, new dependency, off-allowlist edit, red-team flag, new audit advisory) never ship, one failing fix never blocks the others, a dropped connection or the kill switch pauses and the next run resumes without a second push or dispatch, and only a red after main moved halts. macOS and Linux only (the job is the owner's launchd tool)",
|
|
489
|
+
"files": [],
|
|
490
|
+
"platformFiles": {
|
|
491
|
+
"linux": ["tests/integration/issue-autofix-simulate.test.mjs", "tests/integration/issue-autofix-simulate-night.test.mjs"],
|
|
492
|
+
"macos": ["tests/integration/issue-autofix-simulate.test.mjs", "tests/integration/issue-autofix-simulate-night.test.mjs"]
|
|
493
|
+
}
|
|
462
494
|
}
|
|
463
495
|
]
|
|
464
496
|
});
|
package/scripts/wired-check.mjs
CHANGED
|
@@ -172,6 +172,10 @@ const STANDALONE = [
|
|
|
172
172
|
// plist's own ProgramArguments on 2026-07-23, not assumed from the header comment alone:
|
|
173
173
|
['clear-claude-tmp', 'launchd, every 3h (out-of-repo scheduler) — confirmed live: '
|
|
174
174
|
+ 'com.stuartkerr.clear-claude-tmp.plist is loaded and its ProgramArguments invoke this exact file'],
|
|
175
|
+
['issue-autofix', 'launchd, daily 10:30 (out-of-repo scheduler, ADR-107): deploy/com.ruvnet.issue-autofix.plist '
|
|
176
|
+
+ 'ProgramArguments invoke scripts/issue-fix.mjs --mode autofix through scripts/job-heartbeat.sh, and issue-fix.mjs '
|
|
177
|
+
+ 'dynamically imports this file for that mode. Off unless the repository variable ISSUE_AUTOFIX is exactly on; '
|
|
178
|
+
+ 'the owner arms it after the dry-run simulation and a supervised live dry-run'],
|
|
175
179
|
['nightly-gists', 'manual re-embed of the developer checkout gists store (the launchd job '
|
|
176
180
|
+ 'com.ruvnet.brain-gists was retired 2026-10-26: no installed brain reads that store)'],
|
|
177
181
|
['nightly-wrapper', 'author-run maintenance harness, deliberately unscheduled; refuses primary, nested, and dirty worktrees'],
|
|
@@ -344,11 +348,13 @@ const HELD = {
|
|
|
344
348
|
+ 'ADR-028 and 4.0-READINESS §4 both name this the single largest 4.0 gap. HELD because the fix is a '
|
|
345
349
|
+ 'real decision — one in-session consumer that speaks unprompted, with a one-action permanent silence '
|
|
346
350
|
+ '— not a line of wiring.',
|
|
347
|
-
'issue-fix': 'the GitHub-issue
|
|
348
|
-
+ 'issue-watch.yml
|
|
349
|
-
+ '
|
|
350
|
-
+ '
|
|
351
|
-
+ '
|
|
351
|
+
'issue-fix': 'the GitHub-issue fixer (modes unattended, supervised, autofix). Verified 2026-10-29: there is no '
|
|
352
|
+
+ 'issue-watch.yml — the watcher runs from launchd — and the launchd jobs com.ruvnet.issue-fix '
|
|
353
|
+
+ '(--mode unattended, read-only triage) and com.ruvnet.issue-autofix (--mode autofix, ADR-107) run this '
|
|
354
|
+
+ 'file through scripts/job-heartbeat.sh from deploy/*.plist. A .plist is not a scanned caller extension, so '
|
|
355
|
+
+ 'the gate cannot see that invocation; it stays HELD for that reason, not because it is unwired. The '
|
|
356
|
+
+ 'autofix mode is armed only by the owner (repository variable ISSUE_AUTOFIX=on plus the LaunchAgent), '
|
|
357
|
+
+ 'a cost-and-consent decision, not a gap to silently close.',
|
|
352
358
|
};
|
|
353
359
|
|
|
354
360
|
/** Where first-party executables live. v1 knew only the first line of this table. */
|