ruvnet-brain 4.0.12 → 4.0.28
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 +5 -5
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +2 -2
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/scripts/advocacy-outcomes.mjs +808 -0
- package/plugin/scripts/anticipate.sh +80 -14
- package/plugin/scripts/capability-registry.mjs +994 -0
- package/plugin/scripts/codex-hook-wrapper.mjs +1 -0
- package/plugin/scripts/continuation-gate.mjs +129 -1
- package/plugin/scripts/gates.mjs +146 -0
- package/plugin/scripts/goal-match.mjs +398 -0
- package/plugin/scripts/hijack-ruvnet.sh +69 -1
- package/plugin/scripts/hook-registry.mjs +616 -0
- package/plugin/scripts/hook-shim.mjs +13 -2
- package/plugin/scripts/learning-enable.mjs +382 -0
- package/plugin/scripts/lesson-promote.mjs +262 -0
- package/plugin/scripts/lesson-provenance.mjs +43 -0
- package/plugin/scripts/lesson-store.mjs +67 -56
- package/plugin/scripts/memory-doctor.mjs +345 -0
- package/plugin/scripts/nightly-controller.mjs +98 -0
- package/plugin/scripts/runtime-preferences.mjs +18 -0
- package/plugin/scripts/session-start-core.mjs +3 -3
- package/plugin/scripts/unprompted-runtime.mjs +22 -7
- package/plugin/scripts/user-settings.mjs +672 -0
- package/plugin/skills/ruvnet-brain/SKILL.md +2 -2
- package/scripts/advocacy-outcomes.mjs +4 -808
- package/scripts/capability-registry.mjs +4 -876
- package/scripts/corpus-qa.mjs +44 -6
- package/scripts/doc-currency.mjs +30 -2
- package/scripts/gates.mjs +4 -146
- package/scripts/goal-match.mjs +4 -398
- package/scripts/hook-registry.mjs +4 -567
- package/scripts/issue-watch.mjs +108 -0
- package/scripts/learning-enable.mjs +4 -380
- package/scripts/lesson-promote.mjs +4 -262
- package/scripts/memory-doctor.mjs +4 -345
- package/scripts/nightly-controller.mjs +4 -66
- package/scripts/nightly-wrapper.sh +23 -1
- package/scripts/proactivity-metrics.mjs +8 -1
- package/scripts/qe/ux-suite.mjs +72 -1
- package/scripts/release-abort-stale.mjs +111 -0
- package/scripts/release-convergence-watchdog.mjs +119 -0
- package/scripts/release-transaction-provider.mjs +61 -7
- package/scripts/release-transaction.mjs +63 -17
- package/scripts/self-update.mjs +63 -10
- package/scripts/user-settings.mjs +4 -640
|
@@ -4,22 +4,52 @@ import crypto from 'node:crypto';
|
|
|
4
4
|
export const RECEIPT_PREFIX = 'release-transaction-';
|
|
5
5
|
export const TERMINAL_STATES = new Set(['channels-converged', 'aborted']);
|
|
6
6
|
|
|
7
|
+
// `aborted` WAS UNREACHABLE, AND THAT BRICKED THE RELEASE RAIL (found 2026-08-07, issue #77).
|
|
8
|
+
//
|
|
9
|
+
// `aborted` has always been in TERMINAL_STATES, but no state below listed it as a target — so it
|
|
10
|
+
// was a terminal state nothing could ever enter. Combined with `manual-intervention-required`
|
|
11
|
+
// (which has NO outgoing transitions and is NOT terminal), an interrupted release had exactly two
|
|
12
|
+
// destinations and both were permanent non-terminal dead ends.
|
|
13
|
+
//
|
|
14
|
+
// That is not theoretical. The v4.0.7 transaction (b2ac9b69…) stopped at `npm-stage-intent` and
|
|
15
|
+
// stayed there. `runReleaseTransaction` treats every non-terminal receipt from another transaction
|
|
16
|
+
// as competing, so it refused EVERY later release with
|
|
17
|
+
// `pending release b2ac9b69… blocks <new>`
|
|
18
|
+
// and there was no legal move that could clear it. npm was then hand-moved to 4.0.12, which also
|
|
19
|
+
// disqualified the provider's `settled` escape hatch (it requires receipt.version === npmLatest).
|
|
20
|
+
// So the rail deadlocked itself, hand-publishing became the only way to ship, and hand-publishing
|
|
21
|
+
// is precisely how npm and GitHub came to name different generations — the whole of #77.
|
|
22
|
+
//
|
|
23
|
+
// The fix is to give abandonment a legal move, which is what `aborted` was declared for. Any
|
|
24
|
+
// non-terminal state may now abort. This LOOSENS nothing about a live release: `aborted` is
|
|
25
|
+
// terminal, so a transaction that aborts can never resume and claim to have shipped, and the
|
|
26
|
+
// competing-transaction guard still refuses two genuinely in-flight releases.
|
|
27
|
+
const ABORTABLE = ['aborted'];
|
|
28
|
+
|
|
7
29
|
export const ALLOWED_TRANSITIONS = Object.freeze({
|
|
8
|
-
'remote-prepared': ['asset-upload-intent', 'manual-intervention-required'],
|
|
9
|
-
'asset-upload-intent': ['npm-stage-intent', 'manual-intervention-required'],
|
|
10
|
-
'npm-stage-intent': ['npm-candidate-staged', 'manual-intervention-required'],
|
|
11
|
-
'npm-candidate-staged': ['remote-materialization-intent', 'manual-intervention-required'],
|
|
12
|
-
'remote-materialization-intent': ['prepared', 'manual-intervention-required'],
|
|
13
|
-
prepared: ['github-promote-intent', 'manual-intervention-required'],
|
|
14
|
-
'github-promote-intent': ['github-promoted-nonlatest', 'manual-intervention-required'],
|
|
15
|
-
'github-promoted-nonlatest': ['npm-promote-intent', 'manual-intervention-required'],
|
|
16
|
-
'npm-promote-intent': ['npm-promoted', 'compensation-intent', 'manual-intervention-required'],
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
30
|
+
'remote-prepared': ['asset-upload-intent', 'manual-intervention-required', ...ABORTABLE],
|
|
31
|
+
'asset-upload-intent': ['npm-stage-intent', 'manual-intervention-required', ...ABORTABLE],
|
|
32
|
+
'npm-stage-intent': ['npm-candidate-staged', 'manual-intervention-required', ...ABORTABLE],
|
|
33
|
+
'npm-candidate-staged': ['remote-materialization-intent', 'manual-intervention-required', ...ABORTABLE],
|
|
34
|
+
'remote-materialization-intent': ['prepared', 'manual-intervention-required', ...ABORTABLE],
|
|
35
|
+
prepared: ['github-promote-intent', 'manual-intervention-required', ...ABORTABLE],
|
|
36
|
+
'github-promote-intent': ['github-promoted-nonlatest', 'manual-intervention-required', ...ABORTABLE],
|
|
37
|
+
'github-promoted-nonlatest': ['npm-promote-intent', 'manual-intervention-required', ...ABORTABLE],
|
|
38
|
+
'npm-promote-intent': ['npm-promoted', 'compensation-intent', 'manual-intervention-required', ...ABORTABLE],
|
|
39
|
+
// `defaults-promoted` added 2026-08-07: when GitHub is ALREADY latest at the moment npm is
|
|
40
|
+
// promoted, the reducer correctly chooses `finalize` and the finalize path moves straight to
|
|
41
|
+
// `defaults-promoted` (release-transaction.mjs:406) — there is no `github-latest-intent` to pass
|
|
42
|
+
// through, because there is nothing left to intend. The table assumed that hop was mandatory, so
|
|
43
|
+
// the 4.0.24 publish promoted BOTH channels successfully and then died on its own bookkeeping
|
|
44
|
+
// with `illegal release transition npm-promoted -> defaults-promoted`. The publication was real;
|
|
45
|
+
// only the ledger entry was refused. Both defaults genuinely are promoted in that state, so this
|
|
46
|
+
// records what happened rather than permitting anything new.
|
|
47
|
+
'npm-promoted': ['github-latest-intent', 'defaults-promoted', 'compensation-intent', 'manual-intervention-required', ...ABORTABLE],
|
|
48
|
+
'github-latest-intent': ['defaults-promoted', 'compensation-intent', 'manual-intervention-required', ...ABORTABLE],
|
|
49
|
+
'compensation-intent': ['compensated', 'manual-intervention-required', ...ABORTABLE],
|
|
50
|
+
compensated: ['github-promote-intent', 'npm-promote-intent', 'manual-intervention-required', ...ABORTABLE],
|
|
51
|
+
'defaults-promoted': ['finalize-intent', 'manual-intervention-required', ...ABORTABLE],
|
|
52
|
+
'finalize-intent': ['channels-converged', 'manual-intervention-required', ...ABORTABLE],
|
|
23
53
|
'manual-intervention-required': [],
|
|
24
54
|
'channels-converged': [],
|
|
25
55
|
aborted: [],
|
|
@@ -300,8 +330,24 @@ export async function runReleaseTransaction({ identity, assets, adapter, private
|
|
|
300
330
|
if (decision.action === 'upload-assets') {
|
|
301
331
|
await transition('asset-upload-intent', { payloadId: identity.payloadId || null });
|
|
302
332
|
await adapter.uploadAssets(draft, assets, identity);
|
|
303
|
-
|
|
304
|
-
|
|
333
|
+
// FORCE the digests, and never report a read failure as a mismatch (2026-08-07, #77).
|
|
334
|
+
//
|
|
335
|
+
// This read `observeSnapshot(identity, draft)` then `if (!observed.github?.assetsExact)`.
|
|
336
|
+
// Two distinct failures collapsed into one misleading sentence:
|
|
337
|
+
// · the snapshot's own catch returns `{ readError }`, leaving `github` UNDEFINED — so a
|
|
338
|
+
// transient API error or an OOM hashing the ~529MB bundle reported "payload mismatch",
|
|
339
|
+
// which sends you hunting a corruption that never happened. Verified against the real
|
|
340
|
+
// staged draft: all four assets (zip, .sig, .sha256, .tgz) matched the sealed identity
|
|
341
|
+
// byte-for-byte, and GitHub's own asset digest agreed — yet this line still threw.
|
|
342
|
+
// · digests are memoised by `${asset.id}:${asset.size}`, so a value computed BEFORE the
|
|
343
|
+
// upload finished could satisfy a later check from cache. Immediately after uploading is
|
|
344
|
+
// exactly when that cache must not be trusted, so this observation forces a re-read.
|
|
345
|
+
const observed = await adapter.observeSnapshot(identity, draft, { forceAssets: true });
|
|
346
|
+
if (observed.readError) {
|
|
347
|
+
throw new Error(`could not read the staged GitHub payload (this is NOT a digest mismatch): ${observed.readError}`);
|
|
348
|
+
}
|
|
349
|
+
if (!observed.github) throw new Error('staged GitHub draft not observable after upload');
|
|
350
|
+
if (!observed.github.assetsExact) throw new Error('staged GitHub payload mismatch');
|
|
305
351
|
await transition('npm-stage-intent', { github: observed.github });
|
|
306
352
|
continue;
|
|
307
353
|
}
|
package/scripts/self-update.mjs
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
import fs from 'node:fs';
|
|
17
17
|
import os from 'node:os';
|
|
18
18
|
import path from 'node:path';
|
|
19
|
-
import { execFileSync } from 'node:child_process';
|
|
19
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
20
20
|
import { fileURLToPath } from 'node:url';
|
|
21
21
|
import { FULL_HINTS, KEEP_DIRS } from './full-hints.mjs';
|
|
22
22
|
import { withSubmoduleSymlinksDetached } from './git-clone-refresh.mjs';
|
|
@@ -228,6 +228,42 @@ if (!APPLY) {
|
|
|
228
228
|
}
|
|
229
229
|
|
|
230
230
|
const NOTIFY = (t, m, p) => { try { execFileSync('sh', [path.join(ROOT, 'scripts/notify.sh'), t, m, p || 'default']); } catch { /* alerting never breaks the pipeline */ } };
|
|
231
|
+
|
|
232
|
+
// ---- child steps that can explain themselves -------------------------------------------------
|
|
233
|
+
// With stdio:'inherit' a failing child's Error carries NO output: e.stdout and e.stderr are both
|
|
234
|
+
// null and e.message is only "Command failed: <argv>" (verified 2026-08-06). That empty reason is
|
|
235
|
+
// what propagated into the [FATAL] summary, which is in turn what nightly-wrapper.sh samples for
|
|
236
|
+
// the escalation — so six identical nightly failures escalated saying nothing, while the log had
|
|
237
|
+
// the answer the whole time.
|
|
238
|
+
//
|
|
239
|
+
// Capture, then RE-EMIT verbatim so the log is byte-identical to what stdio:'inherit' produced,
|
|
240
|
+
// and keep a tail for the failure record.
|
|
241
|
+
// captureStdout=false (clone/fetch/reset/symbols): stdout stays inherited so those steps stream
|
|
242
|
+
// live and survive a kill; only stderr is buffered (small: stacks and warnings).
|
|
243
|
+
// captureStdout=true (every step that carries a corpus-QA verdict — [refresh] and [qa]): BOTH
|
|
244
|
+
// streams, because corpus-qa prints its verdict table and every '↳ <reason>' line via
|
|
245
|
+
// console.log — i.e. on STDOUT. Capturing stderr alone would have captured nothing for the
|
|
246
|
+
// exact failure this exists to explain. Measured output is ~34 lines/step, so it costs nothing.
|
|
247
|
+
const STEP_TAIL_LINES = 14;
|
|
248
|
+
function runStep(label, file, args, opts = {}, { captureStdout = false } = {}) {
|
|
249
|
+
const r = spawnSync(file, args, {
|
|
250
|
+
...opts,
|
|
251
|
+
stdio: ['inherit', captureStdout ? 'pipe' : 'inherit', 'pipe'],
|
|
252
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
253
|
+
});
|
|
254
|
+
if (r.stdout?.length) process.stdout.write(r.stdout);
|
|
255
|
+
if (r.stderr?.length) process.stderr.write(r.stderr);
|
|
256
|
+
if (r.error) { r.error.step = label; throw r.error; }
|
|
257
|
+
if (r.status !== 0) {
|
|
258
|
+
const tail = Buffer.concat([r.stdout || Buffer.alloc(0), r.stderr || Buffer.alloc(0)]).toString()
|
|
259
|
+
.split('\n').map((l) => l.trim()).filter(Boolean).slice(-STEP_TAIL_LINES).join(' | ');
|
|
260
|
+
const err = new Error(`${label} exited ${r.status}${tail ? ` — ${tail}` : ' — (child produced no output)'}`);
|
|
261
|
+
err.step = label;
|
|
262
|
+
err.status = r.status;
|
|
263
|
+
err.output = tail;
|
|
264
|
+
throw err;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
231
267
|
const failures = []; // per-repo build failures collected here; ANY failure aborts before publish (see below)
|
|
232
268
|
for (const p of todo) {
|
|
233
269
|
const dir = clonePath(p.name);
|
|
@@ -235,15 +271,15 @@ for (const p of todo) {
|
|
|
235
271
|
if (!fs.existsSync(path.join(dir, '.git'))) {
|
|
236
272
|
console.log(`[clone] ${p.name}`);
|
|
237
273
|
fs.mkdirSync(CLONE_DIR, { recursive: true });
|
|
238
|
-
|
|
274
|
+
runStep(`[clone] ${p.name}`, 'git', ['clone', '--depth', '1', `https://github.com/${p.owner || p.org || 'ruvnet'}/${p.repo || p.name}`, dir]);
|
|
239
275
|
} else {
|
|
240
276
|
// Some cached clones deduplicate large submodules with symlinks to another clone. Git refuses
|
|
241
277
|
// even a fetch when a gitlink path is a symlink ("expected submodule path ... not to be a
|
|
242
278
|
// symbolic link"). Detach only those declared submodule symlinks for fetch/reset, then restore
|
|
243
279
|
// them in finally so a network/reset failure cannot strand the cache in a half-repaired state.
|
|
244
280
|
withSubmoduleSymlinksDetached(dir, () => {
|
|
245
|
-
|
|
246
|
-
|
|
281
|
+
runStep(`[fetch] ${p.name}`, 'git', ['-C', dir, 'fetch', '--depth', '1', 'origin']);
|
|
282
|
+
runStep(`[reset] ${p.name}`, 'git', ['-C', dir, 'reset', '--hard', 'origin/HEAD']);
|
|
247
283
|
});
|
|
248
284
|
}
|
|
249
285
|
const env = { ...process.env, KB_MODEL_CACHE: MODEL_CACHE };
|
|
@@ -255,26 +291,43 @@ for (const p of todo) {
|
|
|
255
291
|
if (full) buildArgs.push('--full', full);
|
|
256
292
|
if (keep) buildArgs.push('--keep', keep);
|
|
257
293
|
console.log(`[refresh] ${kb}`);
|
|
258
|
-
|
|
294
|
+
// captureStdout: forge-refresh runs corpus-qa against its CANDIDATE dir and refuses to promote
|
|
295
|
+
// on a FAIL — so this step, not the [qa] step below, is where the six 2026-08-03..08-06 nightly
|
|
296
|
+
// failures actually died, and the verdict table naming the offending chunk is on ITS stdout.
|
|
297
|
+
// Steps produce ~34 lines at most (largest measured [refresh] section in logs/nightly.log), so
|
|
298
|
+
// buffering costs nothing; the tradeoff is that the log flushes at step end rather than live.
|
|
299
|
+
runStep(`[refresh] ${kb}`, NODE, buildArgs, { cwd: path.join(ROOT, 'kb'), env }, { captureStdout: true });
|
|
259
300
|
console.log(`[symbols] ${kb}`);
|
|
260
|
-
|
|
301
|
+
runStep(`[symbols] ${kb}`, NODE, ['scripts/build-symbols.mjs', '--name', kb], { cwd: ROOT, env });
|
|
261
302
|
// Corpus QA gate (scripts/corpus-qa.mjs): structural (passages>0, full-bodies>0 where
|
|
262
303
|
// FULL_HINTS demands them, vectors==passages, embed.json present) + deterministic
|
|
263
304
|
// self-retrieval round trip on every canonical store. Non-zero exit throws -> failures[] ->
|
|
264
305
|
// the run aborts before stamp/bundle/publish. This is the machine version of the
|
|
265
306
|
// 2026-07-10 hand-verification: a store that embeds wrong or reads wrong cannot ship.
|
|
266
307
|
console.log(`[qa] ${kb}`);
|
|
267
|
-
|
|
268
|
-
|
|
308
|
+
// captureStdout: corpus-qa's verdict table and its '↳ <reason>' lines go to stdout.
|
|
309
|
+
runStep(`[qa] ${kb}`, NODE, ['scripts/corpus-qa.mjs', '--store', kb], { cwd: ROOT, env }, { captureStdout: true });
|
|
310
|
+
} catch (e) {
|
|
311
|
+
console.error(`[FAIL] ${p.name}: ${e.message}`);
|
|
312
|
+
failures.push({ name: p.name, step: e.step || '(unknown step)', error: e.message, reason: e.output || '' });
|
|
313
|
+
}
|
|
269
314
|
}
|
|
270
315
|
|
|
271
316
|
// A per-repo build failure used to be logged and swallowed before the run re-stamped and re-bundled
|
|
272
317
|
// partial data. Fail loud: if ANY repo failed, abort before stamp/bundle. This rebuild process has no
|
|
273
318
|
// publication authority; a later protected release may consume only a fully prepared candidate.
|
|
274
319
|
if (failures.length) {
|
|
275
|
-
|
|
320
|
+
// Carry the REASON, not just the count. The wrapper samples this block's tail for the push
|
|
321
|
+
// escalation, so whatever is not printed here is not in the alert either.
|
|
322
|
+
const first = failures[0];
|
|
323
|
+
NOTIFY('🔴 Nightly brain rebuild ABORTED',
|
|
324
|
+
`${failures.length} repo build(s) failed — no candidate prepared. First: ${first.name} ${first.step} — ${first.reason || first.error}`,
|
|
325
|
+
'urgent');
|
|
276
326
|
console.error(`\n[FATAL] ${failures.length} repo build(s) failed this run — aborting before stamp/bundle. No candidate prepared:`);
|
|
277
|
-
for (const f of failures)
|
|
327
|
+
for (const f of failures) {
|
|
328
|
+
console.error(` - ${f.name} ${f.step}: ${f.error}`);
|
|
329
|
+
if (f.reason) console.error(` reason: ${f.reason}`);
|
|
330
|
+
}
|
|
278
331
|
console.error('Fix the failing repo(s) and re-run.');
|
|
279
332
|
process.exit(1);
|
|
280
333
|
}
|