ruvnet-brain 4.7.21 → 4.7.22

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 CHANGED
@@ -9,7 +9,7 @@ Created: 2026-06-29 22:36:38 EDT
9
9
 
10
10
  ### Discover the stack. Read the source. Build with confidence.
11
11
 
12
- [![RuvNet Brain version 4.6.2 — README updated 2026-10-10 EDT](https://img.shields.io/badge/version_4.7.21-updated_2026--10--10_EDT-1E90FF?style=for-the-badge&labelColor=0757BA)](plugin/.claude-plugin/plugin.json)
12
+ [![RuvNet Brain version 4.6.2 — README updated 2026-10-10 EDT](https://img.shields.io/badge/version_4.7.22-updated_2026--10--10_EDT-1E90FF?style=for-the-badge&labelColor=0757BA)](plugin/.claude-plugin/plugin.json)
13
13
 
14
14
  **A source-grounded companion for Claude Code and Codex that helps you find and use Reuven Cohen's (rUv's) RuvNet ecosystem.**
15
15
 
@@ -255,7 +255,7 @@ uses the protected release workflow; a consumer update downloads an available ge
255
255
  does not rebuild upstream knowledge. Check `--doctor`, `SOURCE.json` and refresh receipts
256
256
  before treating an installation as current.
257
257
 
258
- The **4.6.0 published-corpus baseline** is **205 public stores · 166,081 public source chunks**.
258
+ The **4.6.0 published-corpus baseline** is **206 public stores · 171,869 public source chunks**.
259
259
  Those are bundle census values, not a claim that every upstream repository is fresh today.
260
260
  Later corpus publication may advance the knowledge generation independently of code changes.
261
261
 
package/bin/install.mjs CHANGED
@@ -85,6 +85,7 @@ import {
85
85
  import { shellDiff as pluginShellDiff } from '../plugin/scripts/host-shell-boundary.mjs';
86
86
  import { codexPluginContentChanged, codexTrustChanges, codexUsesContentTrust, CODEX_TRUST_ACTION } from '../scripts/codex-hook-trust.mjs';
87
87
  import { probeFreshCodexDeclarations } from '../scripts/codex-fresh-host-proof.mjs';
88
+ import { proveCodexBootSurface, parseChangedPaths } from '../scripts/codex-boot-surface-proof.mjs';
88
89
  import { refreshSharedCodexAppServer } from '../scripts/codex-hook-trust-reconcile.mjs';
89
90
  import { recordCodexOvernightNotice } from '../plugin/scripts/codex-overnight-notice.mjs';
90
91
  import { repairSecurityGuidance } from '../scripts/security-guidance-codex-compat.mjs';
@@ -3344,8 +3345,15 @@ async function doctorRun({ json }) {
3344
3345
  const binary = codexAdministrativeProbeBinary();
3345
3346
  const fresh = await probeFreshCodexDeclarations({ binary, codexHome: codexHomeDir(), cwd: process.cwd(),
3346
3347
  releasedPluginRoot: path.join(REPO_ROOT, 'plugin'), expectedVersion: PACKAGE_VERSION });
3347
- hostConvergence = reconcileFreshCodexDeclarations(recorded, fresh);
3348
- if (fresh.ok) info('Codex fresh-declarations-ready: current trusted hook declarations only; hook bodies and MCP execution were not tested. Existing open windows remain unproven.');
3348
+ // Issue #391: a receipt that names the hook shim, MCP server or skills/ as changed is proven path by path
3349
+ // (scripts/codex-boot-surface-proof.mjs). Doctor only READS: the receipt on disk is never rewritten here.
3350
+ const changedPaths = parseChangedPaths(codex.sessionSafetyReason);
3351
+ const needsBootProof = changedPaths.some((file) => !['hooks/codex-hooks.json', 'hooks/hooks.json'].includes(file));
3352
+ const bootSurface = needsBootProof ? await proveCodexBootSurfaceHere(changedPaths, fresh) : null;
3353
+ hostConvergence = reconcileFreshCodexDeclarations(recorded, fresh, PACKAGE_VERSION, bootSurface);
3354
+ if (bootSurface?.ok) info(`Codex boot-surface-ready: ${bootSurface.proven.map((p) => p.path).join(', ')} proven for NEW sessions (real shim run, real MCP initialize + tools/list). Existing open windows remain unproven; a host relaunch was not observed.`);
3355
+ else if (bootSurface) warn(`Codex boot surface remains unproven: ${bootSurface.reason}`);
3356
+ else if (fresh.ok) info('Codex fresh-declarations-ready: current trusted hook declarations only; hook bodies and MCP execution were not tested. Existing open windows remain unproven.');
3349
3357
  else warn(`Codex fresh declarations remain unproven: ${fresh.reason}`);
3350
3358
  }
3351
3359
  if (hostConvergence.healthy) {
@@ -4241,9 +4249,15 @@ export function syncHostsAfterUpdate(cacheDir = resolvedKbDir(), {
4241
4249
  */
4242
4250
  export async function convergeHostsAfterUpdate(cacheDir = resolvedKbDir(), { refreshHost = refreshCodexSharedHost,
4243
4251
  unattended = process.env.RUVNET_NIGHTLY === '1', recordNotice = recordCodexOvernightNotice,
4244
- noticeHome = () => footprintRoots().brainHome, ...options } = {}) {
4245
- const convergence = syncHostsAfterUpdate(cacheDir, options);
4252
+ noticeHome = () => footprintRoots().brainHome, proveBootSurface = proveCodexBootSurfaceDefault, ...options } = {}) {
4253
+ const brainHome = options.brainHome || process.env.RUVNET_BRAIN_HOME || path.join(os.homedir(), '.cache', 'ruvnet-brain');
4254
+ const receiptPath = path.join(brainHome, 'host-convergence.json');
4255
+ const readReceipt = () => { try { return JSON.parse(fs.readFileSync(receiptPath, 'utf8')); } catch { return null; } };
4256
+ const prior = readReceipt();
4257
+ let convergence = syncHostsAfterUpdate(cacheDir, options);
4246
4258
  if (!convergence.results?.codex) return convergence;
4259
+ convergence = await recordCodexBootSurface(convergence, { prior, receiptPath, readReceipt, proveBootSurface,
4260
+ hostLockPath: options.hostLockPath || path.join(brainHome, 'host-convergence.lock') });
4247
4261
  // Owner decision (4.7.21 review r2): the unattended nightly never reloads the shared Codex app-server; a reload
4248
4262
  // pauses Brain hooks in sessions already open until someone re-reviews them in /hooks. It leaves a notice the next
4249
4263
  // SessionStart shows once instead. An interactive --update still reloads.
@@ -4260,6 +4274,57 @@ export async function convergeHostsAfterUpdate(cacheDir = resolvedKbDir(), { ref
4260
4274
  return { ...convergence, hostRefresh };
4261
4275
  }
4262
4276
 
4277
+ /**
4278
+ * Issue #391: when Codex still carries a boot-level restart request (a fresh one from this sync, or the previous
4279
+ * receipt's that a repeat sync would otherwise silently drop), prove each changed path and write a NEW receipt:
4280
+ * restartScope 'open-sessions' only when every path is proven, otherwise the request stays 'unproven' with the reason.
4281
+ * A historical receipt is never edited in place; the new one is the atomic replacement the sync itself writes.
4282
+ */
4283
+ async function recordCodexBootSurface(convergence, { prior, receiptPath, readReceipt, proveBootSurface, hostLockPath }) {
4284
+ if (!convergence.ok && convergence.restartOnly !== true) return convergence;
4285
+ const current = readReceipt();
4286
+ const codex = current?.hosts?.codex;
4287
+ if (!current || current.desiredVersion !== PACKAGE_VERSION || codex?.state !== 'ready') return convergence;
4288
+ const priorCodex = prior?.desiredVersion === PACKAGE_VERSION ? prior.hosts?.codex : null;
4289
+ const fresh = codex.restartRequired === true && codex.restartScope === 'unproven';
4290
+ const carried = codex.restartRequired !== true && priorCodex?.state === 'ready' && priorCodex.restartRequired === true
4291
+ && priorCodex.restartScope === 'unproven' && convergence.results.codex.action === 'unchanged';
4292
+ if (!fresh && !carried) return convergence;
4293
+ const reason = fresh ? codex.sessionSafetyReason : priorCodex.sessionSafetyReason;
4294
+ const changedPaths = parseChangedPaths(reason);
4295
+ let proof;
4296
+ try { proof = await proveBootSurface({ changedPaths }); }
4297
+ catch (error) { proof = { ok: false, state: 'boot-surface-unproven', changed: changedPaths, proven: [], unproven: [], reason: error?.message || String(error),
4298
+ existingWindows: 'unproven', hostRelaunch: 'not-observed' }; }
4299
+ const ready = proof?.ok === true && proof.state === 'boot-surface-ready';
4300
+ const next = { ...current, verifiedAt: new Date().toISOString(), hosts: { ...current.hosts, codex: { ...codex,
4301
+ restartRequired: true, sessionSafety: 'restart-required', sessionSafetyReason: reason,
4302
+ restartScope: ready ? 'open-sessions' : 'unproven',
4303
+ bootSurface: { state: proof?.state || 'boot-surface-unproven', changed: proof?.changed || changedPaths,
4304
+ proven: (proof?.proven || []).map((p) => p.path), unproven: proof?.unproven || [], reason: proof?.reason || null,
4305
+ existingWindows: 'unproven', hostRelaunch: 'not-observed' } } } };
4306
+ // The receipt is the sync's own file, so this read-modify-write takes the same lock the sync holds (the proof
4307
+ // above can be slow, so the lock is held only for the write). A receipt that changed while we proved, or a lock
4308
+ // we cannot take, means a newer sync owns it: record nothing and leave the prior, still-truthful receipt.
4309
+ const writeLock = acquireHostConvergenceLock(hostLockPath);
4310
+ if (!writeLock.acquired) return { ...convergence, bootSurface: proof, bootSurfaceNotRecorded: `host synchronization lock unavailable: ${writeLock.error}` };
4311
+ try {
4312
+ const latest = readReceipt();
4313
+ if (latest?.verifiedAt !== current.verifiedAt || latest?.desiredVersion !== current.desiredVersion) {
4314
+ return { ...convergence, bootSurface: proof, bootSurfaceNotRecorded: 'host-convergence receipt changed while the boot surface was proven' };
4315
+ }
4316
+ const tmp = `${receiptPath}.tmp-${process.pid}`;
4317
+ fs.writeFileSync(tmp, JSON.stringify(next, null, ' ')); // two-space indent, same bytes as before
4318
+ fs.renameSync(tmp, receiptPath);
4319
+ } finally { writeLock.release(); }
4320
+ const classified = classifyHostConvergence(next);
4321
+ if (classified.healthy) classified.state = 'boot-surface-ready';
4322
+ const { restartOnly, ...rest } = convergence;
4323
+ return { ...rest, ok: classified.healthy === true, convergence: classified, bootSurface: proof,
4324
+ ...(!classified.healthy && hostRestartIsOnlyGap(next) ? { restartOnly: true } : {}),
4325
+ error: classified.healthy ? null : `host convergence is ${classified.state}: ${classified.action}` };
4326
+ }
4327
+
4263
4328
  /**
4264
4329
  * True when a host restart is the ONLY gap in a convergence receipt: every restart-flagged host is ready at the
4265
4330
  * expected version, and with those hosts read as "already-open windows only" everything else (other hosts, the
@@ -4327,7 +4392,7 @@ export function hostSynchronizationFailureMessage(convergence, active, expectedV
4327
4392
  }
4328
4393
 
4329
4394
  /** Doctor projection only. Fresh hook metadata cannot clear an executable or MCP boot gap. */
4330
- export function reconcileFreshCodexDeclarations(recorded, proof, expectedVersion = PACKAGE_VERSION) {
4395
+ export function reconcileFreshCodexDeclarations(recorded, proof, expectedVersion = PACKAGE_VERSION, bootSurface = null) {
4331
4396
  const original = classifyHostConvergence(recorded, expectedVersion);
4332
4397
  const codex = recorded?.hosts?.codex;
4333
4398
  if (!proof?.ok || proof.state !== 'fresh-declarations-ready' || proof.expectedVersion !== expectedVersion
@@ -4338,15 +4403,44 @@ export function reconcileFreshCodexDeclarations(recorded, proof, expectedVersion
4338
4403
  const prefix = 'boot-level declarations changed: ';
4339
4404
  const reason = codex.sessionSafetyReason;
4340
4405
  const changed = typeof reason === 'string' && reason.startsWith(prefix) ? reason.slice(prefix.length).split(', ') : [];
4341
- if (!changed.length || changed.some((file) => !['hooks/codex-hooks.json', 'hooks/hooks.json'].includes(file))) {
4342
- return { ...original, freshDeclarations: proof, notice: 'Fresh Codex hook declarations are ready; executable/MCP boot readiness and existing open windows remain unproven.' };
4406
+ const hookFilesOnly = changed.length > 0 && changed.every((file) => ['hooks/codex-hooks.json', 'hooks/hooks.json'].includes(file));
4407
+ // #391: every changed path proven by the boot-surface proof (same list as the receipt names) clears the unproven
4408
+ // scope. The proof's own existingWindows:'unproven' and hostRelaunch:'not-observed' stay on the result.
4409
+ const bootProven = !hookFilesOnly && bootSurface?.ok === true && bootSurface.state === 'boot-surface-ready'
4410
+ && bootSurface.changed?.length === changed.length && changed.every((file) => bootSurface.changed.includes(file));
4411
+ if (!hookFilesOnly && !bootProven) {
4412
+ return { ...original, freshDeclarations: proof, ...(bootSurface ? { bootSurface } : {}),
4413
+ notice: bootSurface?.reason ? `Fresh Codex hook declarations are ready; boot surface unproven — ${bootSurface.reason}. Existing open windows remain unproven.`
4414
+ : 'Fresh Codex hook declarations are ready; executable/MCP boot readiness and existing open windows remain unproven.' };
4343
4415
  }
4344
4416
  const projected = classifyHostConvergence({ ...recorded, hosts: { ...recorded.hosts,
4345
4417
  codex: { ...codex, restartScope: 'open-sessions' } } }, expectedVersion);
4418
+ if (bootProven) {
4419
+ return { ...projected, ...(projected.healthy ? { state: 'boot-surface-ready' } : {}), freshDeclarations: proof, bootSurface,
4420
+ notice: 'Fresh Codex boot surface is proven for new sessions (hook shim run, MCP initialize + tools/list); existing open windows remain unproven and a host relaunch was not observed.' };
4421
+ }
4346
4422
  return { ...projected, ...(projected.healthy ? { state: 'fresh-declarations-ready' } : {}), freshDeclarations: proof,
4347
4423
  notice: 'Fresh Codex hook declarations are ready; hook bodies and MCP execution were not tested. Existing open windows remain unproven.' };
4348
4424
  }
4349
4425
 
4426
+ /** Prove the changed Codex boot paths against the plugin tree Codex loads. Read-only; `declarations` is a fresh-declarations probe. */
4427
+ async function proveCodexBootSurfaceHere(changedPaths, declarations) {
4428
+ const codexHome = codexHomeDir();
4429
+ let installedPluginRoot = declarations?.pluginRoot || null;
4430
+ if (!installedPluginRoot) { try { installedPluginRoot = codexInstalledPluginRoot({ codexHome }); } catch { installedPluginRoot = null; } }
4431
+ return await proveCodexBootSurface({ changedPaths, declarations, installedPluginRoot,
4432
+ releasedPluginRoot: path.join(REPO_ROOT, 'plugin') });
4433
+ }
4434
+
4435
+ async function proveCodexBootSurfaceDefault({ changedPaths }) {
4436
+ let declarations;
4437
+ try {
4438
+ declarations = await probeFreshCodexDeclarations({ binary: codexAdministrativeProbeBinary(), codexHome: codexHomeDir(), cwd: process.cwd(),
4439
+ releasedPluginRoot: path.join(REPO_ROOT, 'plugin'), expectedVersion: PACKAGE_VERSION });
4440
+ } catch (error) { declarations = { ok: false, reason: error?.message || String(error) }; }
4441
+ return await proveCodexBootSurfaceHere(changedPaths, declarations);
4442
+ }
4443
+
4350
4444
  const HOST_LABELS = { claude: 'Claude Code', codex: 'Codex' };
4351
4445
  /** The one accurate line for converged hosts whose already-open windows booted the old declarations. */
4352
4446
  export function openSessionsNotice(hosts, version) {
@@ -6931,6 +7025,7 @@ the installer reports that boot-level declarations changed.
6931
7025
  let installedTag = null;
6932
7026
  let latestTag = null;
6933
7027
  let resolvedRelease = null; // reused below so the release is resolved at most once
7028
+ let namedTag = null, namedMove = false, namedDowngrade = false; // --version / --pin over an existing install (#504)
6934
7029
  if (alreadyInstalled && !FLAG_FORCE) {
6935
7030
  installedTag = installedBrainVersion(cacheDir); // 'unknown' when SOURCE.json has no releaseTag
6936
7031
  try {
@@ -6973,10 +7068,28 @@ the installer reports that boot-level declarations changed.
6973
7068
  ahead = Boolean(a && b && cmpTag(a, b) > 0);
6974
7069
  staleSkip = Boolean(b && (a === null || (a !== b && !ahead)));
6975
7070
  }
7071
+ // A NAMED release (--version / --pin) runs no lookup, so latestTag stays null and the block below
7072
+ // used to print "the release lookup failed" for a lookup that never happened, installing nothing
7073
+ // (#504). The operator named the target: same tag = truthful skip; different or unknown = take the
7074
+ // same signature-verified download path. Older than installed is allowed (explicit request), loudly.
7075
+ const named = resolvedRelease && (resolvedRelease.source === 'pinned' || resolvedRelease.source === 'forced')
7076
+ ? (resolvedRelease.tag_name || resolvedRelease.tag) : null;
7077
+ if (named) {
7078
+ const nrm = (v) => (v == null || v === 'unknown' ? null : String(v).replace(/^v/, ''));
7079
+ namedTag = named;
7080
+ if (isCorpusReleaseTag(named)) namedMove = installedCorpusTag(cacheDir) !== named;
7081
+ else {
7082
+ namedMove = nrm(installedTag) !== nrm(named);
7083
+ namedDowngrade = Boolean(nrm(installedTag) && cmpTag(nrm(installedTag), nrm(named)) > 0);
7084
+ }
7085
+ }
6976
7086
  }
6977
7087
 
6978
- if (alreadyInstalled && !FLAG_FORCE && !staleSkip) {
6979
- if (latestTag && ahead) {
7088
+ if (alreadyInstalled && !FLAG_FORCE && !staleSkip && !namedMove) {
7089
+ if (namedTag) {
7090
+ step('Brain already at the named release — skipping the download', `installed ${installedTag} matches ${namedTag}`);
7091
+ ok(`found ${namedTag} at ${cacheDir} — nothing to download`);
7092
+ } else if (latestTag && ahead) {
6980
7093
  // Never silently imply equality when the user is AHEAD — that would be a small lie, and it is
6981
7094
  // the one that hides a downgrade. Skipping is right; misdescribing why is not.
6982
7095
  step('Brain already current — skipping the download', `installed ${installedTag} is NEWER than the latest release (${latestTag})`);
@@ -6991,6 +7104,10 @@ the installer reports that boot-level declarations changed.
6991
7104
  info(`When you have a connection: ${c.bold('npx ruvnet-brain --update')} ${c.dim('(or --force to refetch now)')}`);
6992
7105
  }
6993
7106
  } else {
7107
+ if (namedMove) {
7108
+ if (namedDowngrade) warn(`${namedTag} is OLDER than the installed ${installedTag} — downgrading because you named it explicitly.`);
7109
+ step('Moving to the named release', `installed ${installedTag || 'unknown'} → named ${namedTag}`);
7110
+ }
6994
7111
  if (staleSkip) {
6995
7112
  step('Brain is out of date — fetching the current release', `installed ${installedTag || 'unknown'} → latest ${latestTag}`);
6996
7113
  info(`${c.dim('(the old installer skipped this whenever any brain was present, which is why stale installs never moved)')}`);
@@ -10,6 +10,81 @@
10
10
  (function () {
11
11
  'use strict';
12
12
 
13
+ /* #499: every figure this card shows, as display text. A value that could not be read (null /
14
+ undefined / non-numeric) is the word "Unknown" — never 0, which would claim the store is empty.
15
+ Pure and published on the global before the DOM guard below so a test can load this file without a
16
+ page. nMem/nLes/nMachine are numeric (unknown -> 0) for the animation maths only; never display them. */
17
+ function activityFigures(d) {
18
+ function known(v) { return typeof v === 'number' && isFinite(v); }
19
+ function show(v) { return known(v) ? Number(v).toLocaleString('en-US') : 'Unknown'; }
20
+ function num(v) { return known(v) ? v : 0; }
21
+ d = d || {};
22
+ var t = d.totals || {};
23
+ var ag = known(t.agentdbLessons) ? t.agentdbLessons : t.lessons;
24
+ var ap = d.approvedLessons && d.approvedLessons.available === true ? d.approvedLessons.count : null;
25
+ var mach = d.machine && d.machine.totalMemories;
26
+ var apx = d.approvedLessons && d.approvedLessons.available === true ? d.approvedLessons.applicable : null;
27
+ return {
28
+ memories: show(t.memories), agentdbLessons: show(ag), lessons: show(ag),
29
+ approvedLessons: show(ap), approvedMachine: show(ap), approvedApplicable: show(apx), machine: show(mach),
30
+ observations: show(t.observations), patterns: show(t.patterns), episodes: show(t.episodes),
31
+ approvedAvailable: known(ap), agentdbKnown: known(ag),
32
+ nMem: num(t.memories), nLes: num(ag), nMachine: num(mach)
33
+ };
34
+ }
35
+ (typeof globalThis !== 'undefined' ? globalThis : this).activityFigures = activityFigures;
36
+
37
+ function escText(v) { return String(v == null ? '' : v).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); }
38
+
39
+ /* #499: the approved lessons that apply to this project, with the approval / scope / enforcement the owner
40
+ set — rendered from the server's own bounded list. Pure; an unreadable lesson store says Unknown. */
41
+ function approvedLessonsHTML(d) {
42
+ var a = d && d.approvedLessons;
43
+ if (!a || a.available !== true) return '<p class="ab-fallback">Approved lessons: Unknown (the lesson store could not be read).</p>';
44
+ var items = Array.isArray(a.items) ? a.items : [];
45
+ var F = activityFigures(d);
46
+ var head = '<div class="ab-dl-title"><span class="ab-g">✦</span>' + F.approvedApplicable + ' approved lessons apply to this project · ' + F.approvedMachine + ' approved machine-wide</div>';
47
+ if (!items.length) return head + '<p class="ab-fallback">None of your approved lessons are scoped to this project.</p>';
48
+ return head + items.map(function (l) {
49
+ return '<div class="ab-lrow"><b>' + escText(l.id) + '</b> ' + escText(l.title) +
50
+ ' <span class="ab-lmeta">scope: ' + escText(l.scope) + ' · enforcement: ' + escText(l.enforcement == null ? 'unknown' : l.enforcement) +
51
+ ' · ' + (l.ratified ? 'ratified by you' : escText(l.status || 'not ratified')) + '</span></div>';
52
+ }).join('') + (a.itemsTruncated ? '<p class="ab-fallback">…showing the first ' + items.length + ' of ' + F.approvedApplicable + '.</p>' : '');
53
+ }
54
+ (typeof globalThis !== 'undefined' ? globalThis : this).activityApprovedHTML = approvedLessonsHTML;
55
+
56
+ /* #500: which machine-wide row is "you". By STORE IDENTITY (the server's projectId against each fleet entry's
57
+ id), never by folder name — two unrelated repos can both be called "app". No identity on either side means
58
+ no match (the row is then synthesised from this project's own totals), not a guess from the name. */
59
+ function activityUniverse(d) {
60
+ d = d || {};
61
+ var machine = d.machine || {};
62
+ var projects = (Array.isArray(machine.projects) ? machine.projects.slice() : [])
63
+ .sort(function (a, b) { return (b.memories || 0) - (a.memories || 0); });
64
+ var isMe = function (p) { return !!d.projectId && p.id === d.projectId; };
65
+ var found = projects.filter(isMe)[0] || null;
66
+ var others = projects.filter(function (p) { return !isMe(p); });
67
+ return { projects: projects, me: found, others: others.slice(0, 12), more: Math.max(0, others.length - 12) };
68
+ }
69
+ (typeof globalThis !== 'undefined' ? globalThis : this).activityUniverse = activityUniverse;
70
+
71
+ /* #499: the rolling narration, from each pipeline stage's OWN measurement. Distillation turns observations
72
+ into patterns and episodes; it does not write lessons — lessons are ratified by the owner. */
73
+ function activityNarration(d) {
74
+ var F = activityFigures(d);
75
+ var lines = [
76
+ 'Your AI captures everything as it works — ' + F.memories + ' memories in this project so far.',
77
+ 'Learning pipeline in this project: ' + F.observations + ' workflow observations, ' + F.patterns + ' structural patterns, ' + F.episodes + ' episodes.',
78
+ 'Observations are distilled in the background (session end or compaction, when learning is on) into patterns and episodes — not into lessons.',
79
+ F.approvedApplicable + ' approved lessons apply here (' + F.approvedMachine + ' machine-wide). You ratify lessons; nothing is auto-approved.'
80
+ ];
81
+ if (F.agentdbKnown) lines.push(F.agentdbLessons + ' AgentDB lesson records in this project.');
82
+ else lines.push('AgentDB lesson records: Unknown.');
83
+ lines.push('Click any number above to see exactly what it knows.');
84
+ return lines;
85
+ }
86
+ (typeof globalThis !== 'undefined' ? globalThis : this).activityNarration = activityNarration;
87
+
13
88
  var CARD = document.getElementById('card-activity');
14
89
  var BODY = document.getElementById('body-activity');
15
90
  if (!CARD || !BODY) return;
@@ -148,6 +223,8 @@
148
223
  '#card-activity .ab-glyph.ab-pulse{animation:ab-pulse-once 1.1s ease-out 1;display:inline-block}',
149
224
  '#card-activity .ab-caret{display:inline-block;width:7px;color:var(--amber-text)}',
150
225
  /* graceful degrade */
226
+ '#card-activity .ab-lrow{font-size:13px;color:var(--ink-2);margin:6px 2px;line-height:1.4}',
227
+ '#card-activity .ab-lmeta{display:block;font-size:12px;color:var(--muted)}',
151
228
  '#card-activity .ab-fallback{margin-top:16px;font-size:13.5px;color:var(--ink-2);font-style:italic}',
152
229
  '@media (prefers-reduced-motion:reduce){',
153
230
  ' #card-activity .ab-glyph.ab-pulse{animation:none}',
@@ -194,6 +271,14 @@
194
271
  setChips('<span class="chip tone-grey">unavailable</span>');
195
272
  }
196
273
 
274
+ /* #499: no readable project store, but the approved-lesson count is a separate, readable fact. */
275
+ function degradeWithApprovals(d) {
276
+ var F = activityFigures(d);
277
+ BODY.innerHTML = '<p class="ab-fallback">This project\'s memory store is not readable right now (memories: ' + F.memories + '). ' +
278
+ '<b>' + F.approvedMachine + '</b> approved lessons are in force on this machine' + (F.approvedApplicable !== 'Unknown' ? ' (<b>' + F.approvedApplicable + '</b> apply to this project)' : '') + '.</p>' + approvedLessonsHTML(d);
279
+ setChips('<span class="chip tone-grey">memories unknown</span><span class="chip tone-green">' + F.approvedMachine + ' approved lessons (machine-wide)</span>');
280
+ }
281
+
197
282
  /* ---------------- key → plain-English sentence mapper ---------------- */
198
283
  var TOOL_LABEL = { memory: 'AgentDB', autocapture: 'Autocapture', hooks: 'Hooks intel', metaharness: 'MetaHarness' };
199
284
  var LESSON_TITLES = {
@@ -267,6 +352,7 @@
267
352
  fetch('/api/activity', { headers: { Accept: 'application/json' } })
268
353
  .then(function (r) { if (!r.ok) throw new Error('http ' + r.status); return r.json(); })
269
354
  .then(function (d) {
355
+ if (d && d.hasStore === false && d.approvedLessons && d.approvedLessons.available === true) { degradeWithApprovals(d); return; }
270
356
  if (!d || d.hasStore === false || !d.totals) { degrade(); return; }
271
357
  render(d);
272
358
  })
@@ -282,8 +368,9 @@
282
368
  var projects = (Array.isArray(machine.projects) ? machine.projects.slice() : [])
283
369
  .sort(function (a, b) { return (b.memories || 0) - (a.memories || 0); });
284
370
  var projectName = d.project || 'this project';
285
- var nMem = totals.memories || 0, nLes = totals.lessons || 0;
286
- var nProj = projects.length, nMachine = machine.totalMemories || 0;
371
+ var F = activityFigures(d);
372
+ var nMem = F.nMem, nLes = F.nLes;
373
+ var nProj = projects.length;
287
374
 
288
375
  var LESSONS = lessonsRaw.map(function (l) {
289
376
  return { key: l.key, learned: l.learned, recalled: l.access_count || 0, segs: parseExcerpt(l.excerpt) };
@@ -293,8 +380,10 @@
293
380
 
294
381
  /* summary chips */
295
382
  setChips(
296
- '<span class="chip tone-amber">' + fmt(nMem) + ' memories</span>' +
297
- '<span class="chip tone-green">' + fmt(nLes) + ' lessons</span>'
383
+ '<span class="chip tone-amber">' + F.memories + ' memories</span>' +
384
+ '<span class="chip tone-green">' + F.approvedApplicable + ' approved lessons · this project</span>' +
385
+ '<span class="chip tone-grey">' + F.approvedMachine + ' approved machine-wide</span>' +
386
+ '<span class="chip tone-grey">' + F.agentdbLessons + ' AgentDB lesson records</span>'
298
387
  );
299
388
 
300
389
  /* ---------------- panel skeleton ---------------- */
@@ -302,16 +391,18 @@
302
391
  '<div class="ab-stats">' +
303
392
  '<div class="ab-stat ab-door" id="ab-stat-memories" role="button" tabindex="0" aria-expanded="false" aria-controls="ab-doors">' +
304
393
  '<span class="ab-hint"></span><span class="ab-tchev">▸</span>' +
305
- '<div class="ab-n">' + fmt(nMem) + '</div><div class="ab-l">memories · this project</div></div>' +
394
+ '<div class="ab-n">' + F.memories + '</div><div class="ab-l">memories · this project</div></div>' +
306
395
  '<div class="ab-stat ab-door" id="ab-stat-lessons" role="button" tabindex="0" aria-expanded="false" aria-controls="ab-doors">' +
307
396
  '<span class="ab-hint"></span><span class="ab-tchev">▸</span>' +
308
- '<div class="ab-n">' + fmt(nLes) + '</div><div class="ab-l">distilled lessons</div></div>' +
397
+ '<div class="ab-n">' + F.approvedApplicable + '</div><div class="ab-l">approved lessons · apply to this project</div>' +
398
+ '<div class="ab-l">' + F.approvedMachine + ' approved machine-wide</div>' +
399
+ '<div class="ab-l">' + F.agentdbLessons + ' AgentDB lesson records · this project</div></div>' +
309
400
  '<div class="ab-stat ab-door" id="ab-stat-projects" role="button" tabindex="0" aria-expanded="false" aria-controls="ab-doors">' +
310
401
  '<span class="ab-hint"></span><span class="ab-tchev">▸</span>' +
311
402
  '<div class="ab-n">' + fmt(nProj) + '</div><div class="ab-l">projects machine-wide</div></div>' +
312
403
  '<div class="ab-stat ab-door" id="ab-stat-machine" role="button" tabindex="0" aria-expanded="false" aria-controls="ab-doors">' +
313
404
  '<span class="ab-hint"></span><span class="ab-tchev">▸</span>' +
314
- '<div class="ab-n">' + fmt(nMachine) + '</div><div class="ab-l">memories machine-wide</div></div>' +
405
+ '<div class="ab-n">' + F.machine + '</div><div class="ab-l">memories machine-wide</div></div>' +
315
406
  '</div>' +
316
407
  '<div class="ab-doors" id="ab-doors">' +
317
408
  '<div class="ab-doors-clip"><div class="ab-door-box">' +
@@ -324,17 +415,17 @@
324
415
  '<div class="ab-door-view" id="ab-view-universe"></div>' +
325
416
  '</div></div>' +
326
417
  '</div>' +
327
- '<div class="ab-flow" id="ab-flow" aria-label="' + fmt(nMem) + ' captured memories distill down to ' + fmt(nLes) + ' kept lessons">' +
418
+ '<div class="ab-flow" id="ab-flow" aria-label="' + F.memories + ' captured memories, of which ' + F.lessons + ' are kept lesson records">' +
328
419
  '<canvas id="ab-flowCanvas"></canvas>' +
329
- '<div class="ab-flow-hit ab-left" id="ab-flowHitLeft" role="button" tabindex="0" aria-label="see the ' + fmt(nMem) + ' memories by kind" title="see the ' + fmt(nMem) + ' memories by kind"></div>' +
330
- '<div class="ab-flow-hit ab-right" id="ab-flowHitRight" role="button" tabindex="0" aria-label="open the ' + fmt(nLes) + ' distilled lessons" title="open the ' + fmt(nLes) + ' distilled lessons"></div>' +
331
- '<div class="ab-flow-label ab-left"><span class="mono">' + fmt(nMem) + '</span> captured</div>' +
332
- '<div class="ab-flow-label ab-gate">distill</div>' +
333
- '<div class="ab-flow-label ab-right"><span class="mono">' + fmt(nLes) + '</span> lessons — the keepers</div>' +
420
+ '<div class="ab-flow-hit ab-left" id="ab-flowHitLeft" role="button" tabindex="0" aria-label="see the ' + F.memories + ' memories by kind" title="see the ' + F.memories + ' memories by kind"></div>' +
421
+ '<div class="ab-flow-hit ab-right" id="ab-flowHitRight" role="button" tabindex="0" aria-label="open the ' + F.lessons + ' lesson records" title="open the ' + F.lessons + ' lesson records"></div>' +
422
+ '<div class="ab-flow-label ab-left"><span class="mono">' + F.memories + '</span> captured</div>' +
423
+ '<div class="ab-flow-label ab-gate">curated</div>' +
424
+ '<div class="ab-flow-label ab-right"><span class="mono">' + F.lessons + '</span> lesson records — the keepers</div>' +
334
425
  '<div class="ab-flow-narr"><span class="ab-fn-text" id="ab-fnText"></span><span class="ab-fn-steps" id="ab-fnSteps" aria-hidden="true"></span></div>' +
335
426
  '</div>' +
336
- '<div class="ab-vocab"><b>memories</b> = everything your AI captures automatically&ensp;·&ensp;<b>lessons</b> = what survives distillation (task → what failed → what works)</div>' +
337
- (nLes ? '' : '<div class="ab-empty-note">0 lessons recorded yet. Lessons are captured manually: run <code>record-lesson</code> in this project to keep one. There is no automatic distillation into lessons yet.</div>') +
427
+ '<div class="ab-vocab"><b>memories</b> = everything your AI captures automatically&ensp;·&ensp;<b>lesson records</b> = the ones kept in the AgentDB lessons namespace (task → what failed → what works)&ensp;·&ensp;<b>approved lessons</b> = the ones you ratified. Background distillation makes patterns and episodes, not lessons.</div>' +
428
+ ((nLes || !F.agentdbKnown) ? '' : '<div class="ab-empty-note">No lesson records in this project\'s AgentDB yet. Lessons are ratified by you; background distillation (session end or compaction, when learning is on) produces patterns and episodes, not lesson records. <code>record-lesson</code> keeps one now. Approved lessons are counted separately above.</div>') +
338
429
  '<div class="ab-roster" id="ab-roster">' +
339
430
  '<span class="ab-who">written by</span>' +
340
431
  '<span class="ab-rchip" data-tool="memory">AgentDB<span class="ab-cs">via ruflo memory</span><span class="ab-k">✦</span></span>' +
@@ -404,11 +495,13 @@
404
495
  });
405
496
 
406
497
  /* ---------------- doors ---------------- */
407
- document.getElementById('ab-view-lessons').innerHTML = LESSONS.length
498
+ document.getElementById('ab-view-lessons').innerHTML = approvedLessonsHTML(d) + (LESSONS.length
408
499
  ? LESSONS.map(function (L) {
409
500
  return '<div class="ab-dl-title"><span class="ab-g">✦</span>' + esc(lessonTitle(L.key)) + '</div>' + lessonCardHTML(L);
410
501
  }).join('')
411
- : '<p class="ab-fallback">0 lessons recorded yet. Lessons are captured manually: run <code>record-lesson</code> in this project to keep one. There is no automatic distillation into lessons yet.</p>';
502
+ : (F.agentdbKnown
503
+ ? '<p class="ab-fallback">No lesson records in this project\'s AgentDB yet. Lessons are ratified by you; background distillation produces patterns and episodes, not lesson records. <code>record-lesson</code> keeps one now.</p>'
504
+ : '<p class="ab-fallback">AgentDB lesson records: Unknown (the store could not be read).</p>'));
412
505
 
413
506
  (function () {
414
507
  var GLOSS = {
@@ -418,7 +511,7 @@
418
511
  'adr-patterns': 'architecture decision patterns',
419
512
  'adr-edges': 'links between decisions',
420
513
  trajectories: 'reasoning paths kept for replay',
421
- lessons: 'distilled lessons — the keepers',
514
+ lessons: 'lesson records — the keepers',
422
515
  'metaharness-audit': 'MetaHarness audit runs',
423
516
  feedback: 'edit verifications, scored',
424
517
  pattern: 'learned edit patterns',
@@ -436,7 +529,7 @@
436
529
  '<span class="ab-kc">' + fmt(b.n) + '</span>' +
437
530
  '<span class="ab-kg">' + esc(GLOSS[b.namespace] || '') + '</span></div>';
438
531
  }).join('') +
439
- '<div class="ab-door-foot">' + breakdown.length + ' namespaces · <span class="mono">' + fmt(nMem) + '</span> rows — live from <span class="mono">.swarm/memory.db</span> · bars log-scaled</div>';
532
+ '<div class="ab-door-foot">' + breakdown.length + ' namespaces · <span class="mono">' + F.memories + '</span> rows — live from <span class="mono">.swarm/memory.db</span> · bars log-scaled</div>';
440
533
  })();
441
534
 
442
535
  (function () {
@@ -444,9 +537,12 @@
444
537
  document.getElementById('ab-view-universe').innerHTML = '<p class="ab-fallback">no machine-wide data available</p>';
445
538
  return;
446
539
  }
447
- var others = projects.filter(function (p) { return p.name !== projectName; }).slice(0, 12);
448
- var meRow = projects.filter(function (p) { return p.name === projectName; })[0] || { name: projectName, memories: nMem };
449
- var more = Math.max(0, projects.length - others.length - 1);
540
+ /* #500: "you are here" is the project whose STORE IDENTITY matches, never whose folder name does —
541
+ two unrelated repos can share a name. No identity (older payload) means no match, not a name guess. */
542
+ var U = activityUniverse(d);
543
+ var others = U.others;
544
+ var meRow = U.me || { name: projectName, memories: nMem };
545
+ var more = U.more;
450
546
  var top = Math.log10(Math.max(projects[0].memories || 1, 10));
451
547
  var base = Math.min(2.7, top - 0.5);
452
548
  var wU = function (n) { return Math.round(100 * Math.max(0.10, (Math.log10(Math.max(n, 1)) - base) / (top - base))); };
@@ -459,12 +555,12 @@
459
555
  others.map(function (p) { return rowHTML(p.name, p.memories, false); }).join('') +
460
556
  rowHTML(meRow.name, meRow.memories, true) +
461
557
  (more ? '<div class="ab-umore">…and ' + more + ' more projects</div>' : '') +
462
- '<div class="ab-door-foot"><span class="mono">' + fmt(nMachine) + '</span> memories machine-wide — every one persists across sessions and reboots</div>';
558
+ '<div class="ab-door-foot"><span class="mono">' + F.machine + '</span> memories machine-wide — every one persists across sessions and reboots</div>';
463
559
  })();
464
560
 
465
561
  var DOOR_META = {
466
- lessons: [fmt(nLes) + ' distilled lessons', 'what survived distillation — every card is a real memory'],
467
- memories: [fmt(nMem) + ' memories in this project', 'by namespace — what your AI writes down as you work'],
562
+ lessons: [F.lessons + ' lesson records', 'kept in this project\'s AgentDB lessons namespace — every card is a real memory'],
563
+ memories: [F.memories + ' memories in this project', 'by namespace — what your AI writes down as you work'],
468
564
  universe: [fmt(nProj) + ' projects machine-wide', "everywhere this machine's AI remembers"]
469
565
  };
470
566
  var VIEW_OF = { 'ab-stat-memories': 'memories', 'ab-stat-lessons': 'lessons', 'ab-stat-projects': 'universe', 'ab-stat-machine': 'universe' };
@@ -721,19 +817,7 @@
721
817
 
722
818
  /* ---------------- rolling narration — the takeaway in plain English ---------------- */
723
819
  (function () {
724
- var LINES = nLes > 0 ? [
725
- 'Your AI captures everything as it works — ' + fmt(nMem) + ' memories in this project so far.',
726
- "Most of that stays raw. Only what's proven survives distillation.",
727
- fmt(nLes) + ' lessons have earned permanence — the task, what failed, what actually works.',
728
- "These survive compaction, restarts, and reboots. Your AI doesn't start over tomorrow.",
729
- 'Click any number above to see exactly what it knows.'
730
- ] : [
731
- 'Your AI captures everything as it works — ' + fmt(nMem) + ' memories in this project so far.',
732
- "Most of that stays raw. Only what's proven survives distillation.",
733
- '0 lessons distilled yet. Distillation is manual for now; there is no auto-distill.',
734
- 'Capture one any time: run record-lesson in this project.',
735
- 'Click any number above to see exactly what it knows.'
736
- ];
820
+ var LINES = activityNarration(d);
737
821
  var txt = document.getElementById('ab-fnText');
738
822
  var steps = document.getElementById('ab-fnSteps');
739
823
  var idx = 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ruvnet-brain",
3
- "version": "4.7.21",
3
+ "version": "4.7.22",
4
4
  "description": "One-command installer for RuvNet Brain — a portable, source-grounded brain over rUv's RuvNet building blocks, delivered as a Claude Code plugin so Claude uses the stack instead of fighting it.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ruvnet-brain",
3
3
  "description": "RuvNet brain transplant for Claude Code — grounds every RuvNet decision in real source across 77 rUv repositories, prefers Ruflo / RuVector-RVF / AgentDB over training-prior defaults (pgvector, Pinecone, hand-rolled cosine), and can pull in any RuvNet repo on demand. Ships a UserPromptSubmit retrieve-and-inject grounding hook and a PreToolUse write gate that refuses ungrounded rUv-product code until search_ruvnet has been consulted (ADR-0012 / ADR-067).",
4
- "version": "4.7.21",
4
+ "version": "4.7.22",
5
5
  "author": {
6
6
  "name": "Stuart Kerr"
7
7
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ruvnet-brain",
3
- "version": "4.7.21",
3
+ "version": "4.7.22",
4
4
  "description": "Source-grounded RuvNet knowledge, lifecycle enforcement, and learning for Codex.",
5
5
  "author": {
6
6
  "name": "Stuart Kerr"