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.
Files changed (46) hide show
  1. package/README.md +5 -5
  2. package/package.json +1 -1
  3. package/plugin/.claude-plugin/plugin.json +2 -2
  4. package/plugin/.codex-plugin/plugin.json +1 -1
  5. package/plugin/scripts/advocacy-outcomes.mjs +808 -0
  6. package/plugin/scripts/anticipate.sh +80 -14
  7. package/plugin/scripts/capability-registry.mjs +994 -0
  8. package/plugin/scripts/codex-hook-wrapper.mjs +1 -0
  9. package/plugin/scripts/continuation-gate.mjs +129 -1
  10. package/plugin/scripts/gates.mjs +146 -0
  11. package/plugin/scripts/goal-match.mjs +398 -0
  12. package/plugin/scripts/hijack-ruvnet.sh +69 -1
  13. package/plugin/scripts/hook-registry.mjs +616 -0
  14. package/plugin/scripts/hook-shim.mjs +13 -2
  15. package/plugin/scripts/learning-enable.mjs +382 -0
  16. package/plugin/scripts/lesson-promote.mjs +262 -0
  17. package/plugin/scripts/lesson-provenance.mjs +43 -0
  18. package/plugin/scripts/lesson-store.mjs +67 -56
  19. package/plugin/scripts/memory-doctor.mjs +345 -0
  20. package/plugin/scripts/nightly-controller.mjs +98 -0
  21. package/plugin/scripts/runtime-preferences.mjs +18 -0
  22. package/plugin/scripts/session-start-core.mjs +3 -3
  23. package/plugin/scripts/unprompted-runtime.mjs +22 -7
  24. package/plugin/scripts/user-settings.mjs +672 -0
  25. package/plugin/skills/ruvnet-brain/SKILL.md +2 -2
  26. package/scripts/advocacy-outcomes.mjs +4 -808
  27. package/scripts/capability-registry.mjs +4 -876
  28. package/scripts/corpus-qa.mjs +44 -6
  29. package/scripts/doc-currency.mjs +30 -2
  30. package/scripts/gates.mjs +4 -146
  31. package/scripts/goal-match.mjs +4 -398
  32. package/scripts/hook-registry.mjs +4 -567
  33. package/scripts/issue-watch.mjs +108 -0
  34. package/scripts/learning-enable.mjs +4 -380
  35. package/scripts/lesson-promote.mjs +4 -262
  36. package/scripts/memory-doctor.mjs +4 -345
  37. package/scripts/nightly-controller.mjs +4 -66
  38. package/scripts/nightly-wrapper.sh +23 -1
  39. package/scripts/proactivity-metrics.mjs +8 -1
  40. package/scripts/qe/ux-suite.mjs +72 -1
  41. package/scripts/release-abort-stale.mjs +111 -0
  42. package/scripts/release-convergence-watchdog.mjs +119 -0
  43. package/scripts/release-transaction-provider.mjs +61 -7
  44. package/scripts/release-transaction.mjs +63 -17
  45. package/scripts/self-update.mjs +63 -10
  46. 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
- 'npm-promoted': ['github-latest-intent', 'compensation-intent', 'manual-intervention-required'],
18
- 'github-latest-intent': ['defaults-promoted', 'compensation-intent', 'manual-intervention-required'],
19
- 'compensation-intent': ['compensated', 'manual-intervention-required'],
20
- compensated: ['github-promote-intent', 'npm-promote-intent', 'manual-intervention-required'],
21
- 'defaults-promoted': ['finalize-intent', 'manual-intervention-required'],
22
- 'finalize-intent': ['channels-converged', 'manual-intervention-required'],
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
- const observed = await adapter.observeSnapshot(identity, draft);
304
- if (!observed.github?.assetsExact) throw new Error('staged GitHub payload mismatch');
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
  }
@@ -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
- execFileSync('git', ['clone', '--depth', '1', `https://github.com/${p.owner || p.org || 'ruvnet'}/${p.repo || p.name}`, dir], { stdio: 'inherit' });
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
- execFileSync('git', ['-C', dir, 'fetch', '--depth', '1', 'origin'], { stdio: 'inherit' });
246
- execFileSync('git', ['-C', dir, 'reset', '--hard', 'origin/HEAD'], { stdio: 'inherit' });
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
- execFileSync(NODE, buildArgs, { cwd: path.join(ROOT, 'kb'), env, stdio: 'inherit' });
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
- execFileSync(NODE, ['scripts/build-symbols.mjs', '--name', kb], { cwd: ROOT, env, stdio: 'inherit' });
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
- execFileSync(NODE, ['scripts/corpus-qa.mjs', '--store', kb], { cwd: ROOT, env, stdio: 'inherit' });
268
- } catch (e) { console.error(`[FAIL] ${p.name}: ${e.message}`); failures.push({ name: p.name, error: e.message }); }
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
- NOTIFY('🔴 Nightly brain rebuild ABORTED', `${failures.length} repo build(s) failed — no candidate prepared. See logs/nightly.log`, 'urgent');
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) console.error(` - ${f.name}: ${f.error}`);
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
  }