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.
@@ -0,0 +1,208 @@
1
+ // Proof that the Codex boot surface a release changed is the surface a FRESH Codex session will load (issue #391,
2
+ // ADR-0070, ADR-0051). A receipt that names `scripts/hook-shim.mjs`, `mcp/server.mjs` or `skills/` as changed used to stay
3
+ // restartScope:'unproven' forever, because only the two hook-declaration files could be cleared. Each path is proven by
4
+ // the strongest evidence that can be gathered without a Codex relaunch, and any path with no prover stays unproven,
5
+ // NAMED in the result:
6
+ // hooks/codex-hooks.json, hooks/hooks.json the fresh-declarations proof (scripts/codex-fresh-host-proof.mjs)
7
+ // skills/ that proof plus a byte-identical skills/ tree
8
+ // scripts/hook-shim.mjs byte-identical AND a real run of the INSTALLED shim with an unknown hook
9
+ // id (it exits 0 doing nothing and lists its roles; every registered Codex
10
+ // role must be listed)
11
+ // mcp/server.mjs byte-identical AND a real stdio initialize + tools/list that must
12
+ // include search_ruvnet
13
+ // The result ALWAYS reports windows that are already open as `unproven` and a host relaunch as `not-observed`. It is
14
+ // read-only: both real processes run against an isolated temporary HOME and brain home (the MCP server leases its
15
+ // generation while running), under a hard deadline, and the temp tree is removed. It never touches a receipt.
16
+ import fs from 'node:fs';
17
+ import os from 'node:os';
18
+ import path from 'node:path';
19
+ import { spawn } from 'node:child_process';
20
+ import { shellDiff } from '../plugin/scripts/host-shell-boundary.mjs';
21
+
22
+ export const BOOT_REASON_PREFIX = 'boot-level declarations changed: ';
23
+ export const PROVABLE_PATHS = Object.freeze(['hooks/codex-hooks.json', 'hooks/hooks.json', 'skills/',
24
+ 'scripts/hook-shim.mjs', 'mcp/server.mjs']);
25
+ const MAX_CAPTURE = 64 * 1024;
26
+
27
+ /** The changed paths a machine-generated sessionSafetyReason names, or [] when it names none. */
28
+ export function parseChangedPaths(reason) {
29
+ if (typeof reason !== 'string' || !reason.startsWith(BOOT_REASON_PREFIX)) return [];
30
+ return reason.slice(BOOT_REASON_PREFIX.length).split(', ').map((entry) => entry.trim()).filter(Boolean);
31
+ }
32
+
33
+ /** The hook roles a Codex hooks file registers: the id after the timeout in each `... <timeoutMs> <role> [event]` command. */
34
+ export function registeredCodexRoles(hooksFile) {
35
+ const parsed = JSON.parse(fs.readFileSync(hooksFile, 'utf8'));
36
+ const roles = new Set();
37
+ for (const groups of Object.values(parsed.hooks || {})) {
38
+ for (const group of groups) {
39
+ for (const handler of group.hooks || []) {
40
+ const match = /"\s+\d+\s+([a-z][a-z0-9-]*)(?:\s+\S+)?\s*$/.exec(String(handler.command || ''));
41
+ if (match) roles.add(match[1]);
42
+ }
43
+ }
44
+ }
45
+ return [...roles];
46
+ }
47
+
48
+ // Windows cannot start node (or resolve DLLs, temp, shells) without these; copied only when present, never a secret.
49
+ const WINDOWS_NEEDS = ['SystemRoot', 'windir', 'ComSpec', 'PATHEXT'];
50
+ const IS_WIN = process.platform === 'win32';
51
+
52
+ /** SIGKILL the probe's whole process group (it is spawned detached, so it leads one); the child alone on Windows. */
53
+ function killTree(child) {
54
+ try {
55
+ if (!IS_WIN && Number.isInteger(child?.pid) && child.pid > 1) process.kill(-child.pid, 'SIGKILL');
56
+ else child?.kill('SIGKILL');
57
+ } catch { try { child?.kill('SIGKILL'); } catch { /* gone */ } }
58
+ }
59
+
60
+ export function isolatedEnv(tmp, extra = {}, source = process.env) {
61
+ const needs = {};
62
+ for (const name of WINDOWS_NEEDS) if (source[name]) needs[name] = source[name];
63
+ const env = { ...needs, PATH: source.PATH || source.Path || '', HOME: tmp, USERPROFILE: tmp, TMPDIR: tmp,
64
+ RUVNET_BRAIN_HOME: path.join(tmp, 'brain'), RUVNET_BRAIN_STATE_DIR: path.join(tmp, 'state'),
65
+ XDG_CACHE_HOME: path.join(tmp, 'xdg'), XDG_CONFIG_HOME: path.join(tmp, 'xdgc'), CODEX_HOME: path.join(tmp, 'codex'),
66
+ RUVNET_BRAIN_KB: path.join(tmp, 'kb'), RUVNET_NIGHTLY: '', ...extra };
67
+ if (IS_WIN) { env.TEMP = tmp; env.TMP = tmp; } // USERPROFILE is already the isolated tmp; TEMP/TMP must not point at the real profile
68
+ return env;
69
+ }
70
+
71
+ function runBounded(args, { cwd, env, timeoutMs }) {
72
+ return new Promise((resolve) => {
73
+ let out = ''; let err = ''; let timedOut = false; let settled = false;
74
+ const child = spawn(process.execPath, args, { cwd, env, detached: !IS_WIN, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] });
75
+ const finish = (result) => { if (settled) return; settled = true; clearTimeout(timer); resolve({ stdout: out, stderr: err, timedOut, ...result }); };
76
+ const timer = setTimeout(() => { timedOut = true; killTree(child); }, timeoutMs);
77
+ child.stdout.on('data', (b) => { if (out.length < MAX_CAPTURE) out += b; });
78
+ child.stderr.on('data', (b) => { if (err.length < MAX_CAPTURE) err += b; });
79
+ child.once('error', (error) => finish({ code: null, signal: null, spawnError: error.message }));
80
+ child.once('close', (code, signal) => { killTree(child); finish({ code, signal }); });
81
+ });
82
+ }
83
+
84
+ /** Run the INSTALLED shim with an id it does not know. Proven only by exit 0 plus a role list covering every registered role. */
85
+ export async function runShimProbe({ installedRoot, roles, tmp, timeoutMs }) {
86
+ const shim = path.join(installedRoot, 'scripts', 'hook-shim.mjs');
87
+ const started = Date.now();
88
+ const run = await runBounded([shim, 'boot-surface-proof-unknown-hook'], { cwd: tmp, env: isolatedEnv(tmp), timeoutMs });
89
+ const elapsedMs = Date.now() - started;
90
+ if (run.timedOut) return { ok: false, elapsedMs, reason: `the installed hook shim did not exit within ${timeoutMs} ms and was killed` };
91
+ if (run.spawnError) return { ok: false, elapsedMs, reason: `the installed hook shim could not start: ${run.spawnError}` };
92
+ if (run.code !== 0) return { ok: false, elapsedMs, reason: `the installed hook shim exited ${run.code ?? run.signal} for an unknown hook id (expected 0)` };
93
+ const listed = /known:\s*([^\n]*)/.exec(run.stderr)?.[1]?.split(',').map((s) => s.trim()).filter(Boolean) || [];
94
+ const missing = roles.filter((role) => !listed.includes(role));
95
+ if (!listed.length) return { ok: false, elapsedMs, reason: 'the installed hook shim listed no hook roles' };
96
+ if (missing.length) return { ok: false, elapsedMs, listed, reason: `the installed hook shim does not list registered Codex role(s): ${missing.join(', ')}` };
97
+ return { ok: true, elapsedMs, rolesListed: listed.length, rolesRegistered: roles.length };
98
+ }
99
+
100
+ const STUB_WORKER = "require('node:fs').writeFileSync(process.env.RNB_PROOF_MARKER,String(process.pid));process.exit(0);\n";
101
+
102
+ /** Real stdio MCP handshake against the INSTALLED server: initialize, tools/list. Never starts the real search worker. */
103
+ export async function runMcpProbe({ installedRoot, tmp, timeoutMs }) {
104
+ const server = path.join(installedRoot, 'mcp', 'server.mjs');
105
+ const stub = path.join(tmp, 'stub-worker.cjs'); const marker = path.join(tmp, 'worker-started');
106
+ fs.writeFileSync(stub, STUB_WORKER);
107
+ const env = isolatedEnv(tmp, { RUVNET_BRAIN_CHILD_MCP: stub, RNB_PROOF_MARKER: marker });
108
+ const started = Date.now();
109
+ return await new Promise((resolve) => {
110
+ let buffer = ''; let settled = false; let timedOut = false;
111
+ const replies = new Map(); let stderr = '';
112
+ const child = spawn(process.execPath, [server], { cwd: tmp, env, detached: !IS_WIN, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] });
113
+ const kill = () => killTree(child);
114
+ const deadline = setTimeout(() => { timedOut = true; kill(); }, timeoutMs);
115
+ const send = (message) => { try { child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', ...message })}\n`); } catch { /* exit handler reports */ } };
116
+ const done = (result) => { if (settled) return; settled = true; clearTimeout(deadline); resolve({ elapsedMs: Date.now() - started, ...result }); };
117
+ child.stdout.on('data', (chunk) => {
118
+ buffer += chunk;
119
+ let at;
120
+ while ((at = buffer.indexOf('\n')) >= 0) {
121
+ const line = buffer.slice(0, at); buffer = buffer.slice(at + 1);
122
+ let message; try { message = JSON.parse(line); } catch { continue; }
123
+ if (message.id !== undefined) replies.set(message.id, message);
124
+ if (message.id === 1) { send({ method: 'notifications/initialized' }); send({ id: 2, method: 'tools/list', params: {} }); }
125
+ if (message.id === 2) {
126
+ // initialize launches the warm search worker in the background (plugin/mcp/server.mjs `void ensureChild()`); the
127
+ // stub worker records that it was launched, so the result reports the fact instead of assuming it.
128
+ setTimeout(() => { try { child.stdin.end(); } catch { /* gone */ } }, 400);
129
+ }
130
+ }
131
+ });
132
+ child.stderr.on('data', (b) => { if (stderr.length < MAX_CAPTURE) stderr += b; });
133
+ child.once('error', (error) => done({ ok: false, reason: `the installed MCP server could not start: ${error.message}` }));
134
+ child.once('close', (code, signal) => {
135
+ killTree(child);
136
+ const leaseLeft = fs.existsSync(path.join(tmp, 'brain', 'leases')) && fs.readdirSync(path.join(tmp, 'brain', 'leases')).length > 0;
137
+ const initialize = replies.get(1); const list = replies.get(2);
138
+ const workerStarted = fs.existsSync(marker);
139
+ const base = { exit: code ?? signal, leaseLeftBehind: leaseLeft, initializeStartsWarmWorker: workerStarted };
140
+ if (timedOut) return done({ ok: false, ...base, reason: `the installed MCP server did not finish an initialize + tools/list within ${timeoutMs} ms and was killed` });
141
+ if (!initialize?.result) return done({ ok: false, ...base, reason: 'the installed MCP server did not answer initialize' });
142
+ const names = Array.isArray(list?.result?.tools) ? list.result.tools.map((tool) => tool?.name) : null;
143
+ if (!names) return done({ ok: false, ...base, reason: 'the installed MCP server did not answer tools/list' });
144
+ if (!names.includes('search_ruvnet')) return done({ ok: false, ...base, tools: names, reason: 'the installed MCP server tools/list does not include search_ruvnet' });
145
+ if (leaseLeft) return done({ ok: false, ...base, tools: names, reason: 'the installed MCP server left its lease file behind after exit' });
146
+ return done({ ok: true, ...base, tools: names.length });
147
+ });
148
+ send({ id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'boot-surface-proof', version: '1' } } });
149
+ });
150
+ }
151
+
152
+ /**
153
+ * @param {object} o
154
+ * @param {string[]} o.changedPaths paths the convergence receipt names as changed at boot level
155
+ * @param {string} o.releasedPluginRoot the release being converged to (its plugin/ directory)
156
+ * @param {string} o.installedPluginRoot the plugin tree Codex loads (its plugin cache for the active version)
157
+ * @param {object} [o.declarations] a probeFreshCodexDeclarations() result; required for the declaration paths
158
+ */
159
+ export async function proveCodexBootSurface({ changedPaths, releasedPluginRoot, installedPluginRoot, declarations = null, timeoutMs = 15_000 } = {}) {
160
+ const proven = []; const unproven = [];
161
+ const result = (extra = {}) => ({ ok: !extra.error && changedPaths?.length > 0 && unproven.length === 0,
162
+ state: !extra.error && changedPaths?.length > 0 && unproven.length === 0 ? 'boot-surface-ready' : 'boot-surface-unproven',
163
+ scope: 'fresh-codex-boot-surface', changed: changedPaths || [], proven, unproven,
164
+ reason: extra.error || (unproven.length ? unproven.map((u) => `${u.path}: ${u.reason}`).join('; ') : null),
165
+ existingWindows: 'unproven', hostRelaunch: 'not-observed', inferenceRequests: 0, ...extra.detail });
166
+ if (!Array.isArray(changedPaths) || !changedPaths.length) return result({ error: 'no changed boot-level paths are recorded, so there is nothing to prove' });
167
+ if (!releasedPluginRoot || !installedPluginRoot) return result({ error: 'the released or installed plugin root is unknown' });
168
+ let diff;
169
+ try { diff = new Set(shellDiff(installedPluginRoot, releasedPluginRoot)); }
170
+ catch (error) { return result({ error: `the installed and released plugin trees could not be compared: ${error.message}` }); }
171
+ const identical = (rel) => !diff.has(rel);
172
+ const declarationsReady = declarations?.ok === true && declarations.state === 'fresh-declarations-ready';
173
+ const wants = (rel) => changedPaths.includes(rel);
174
+ let tmp = null; let shim = null; let mcp = null;
175
+ try {
176
+ if (wants('scripts/hook-shim.mjs') || wants('mcp/server.mjs')) {
177
+ tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'rnb-boot-proof-'));
178
+ for (const dir of ['brain', 'state', 'xdg', 'xdgc', 'codex', 'kb']) fs.mkdirSync(path.join(tmp, dir), { recursive: true });
179
+ }
180
+ for (const rel of changedPaths) {
181
+ if (!PROVABLE_PATHS.includes(rel)) { unproven.push({ path: rel, reason: 'no prover exists for this path; it stays unproven' }); continue; }
182
+ if (rel === 'hooks/codex-hooks.json' || rel === 'hooks/hooks.json') {
183
+ if (declarationsReady) proven.push({ path: rel, evidence: 'fresh-declarations-ready' });
184
+ else unproven.push({ path: rel, reason: `fresh Codex hook declarations are not proven${declarations?.reason ? ` (${declarations.reason})` : ''}` });
185
+ } else if (rel === 'skills/') {
186
+ if (!identical(rel)) unproven.push({ path: rel, reason: 'the installed skills/ tree is not byte-identical to the release' });
187
+ else if (!declarationsReady) unproven.push({ path: rel, reason: `fresh Codex declarations are not proven${declarations?.reason ? ` (${declarations.reason})` : ''}` });
188
+ else proven.push({ path: rel, evidence: 'byte-identical skills/ tree plus fresh-declarations-ready' });
189
+ } else if (rel === 'scripts/hook-shim.mjs') {
190
+ if (!identical(rel)) { unproven.push({ path: rel, reason: 'the installed hook shim is not byte-identical to the release' }); continue; }
191
+ let roles;
192
+ try { roles = registeredCodexRoles(path.join(installedPluginRoot, 'hooks', 'codex-hooks.json')); }
193
+ catch (error) { unproven.push({ path: rel, reason: `the registered Codex roles could not be read: ${error.message}` }); continue; }
194
+ shim = await runShimProbe({ installedRoot: installedPluginRoot, roles, tmp, timeoutMs });
195
+ if (shim.ok) proven.push({ path: rel, evidence: `byte-identical; real run exited 0 and listed all ${shim.rolesRegistered} registered Codex roles` });
196
+ else unproven.push({ path: rel, reason: shim.reason });
197
+ } else if (rel === 'mcp/server.mjs') {
198
+ if (!identical(rel)) { unproven.push({ path: rel, reason: 'the installed MCP server is not byte-identical to the release' }); continue; }
199
+ mcp = await runMcpProbe({ installedRoot: installedPluginRoot, tmp, timeoutMs });
200
+ if (mcp.ok) proven.push({ path: rel, evidence: `byte-identical; real stdio initialize + tools/list returned ${mcp.tools} tools including search_ruvnet` });
201
+ else unproven.push({ path: rel, reason: mcp.reason });
202
+ }
203
+ }
204
+ } finally {
205
+ if (tmp) { try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* best effort */ } }
206
+ }
207
+ return result({ detail: { ...(shim ? { shim } : {}), ...(mcp ? { mcp } : {}) } });
208
+ }
@@ -64,7 +64,7 @@ import {
64
64
  } from '../kb/brain-profile.mjs';
65
65
  // Lessons: read model + the two user verbs. Every mutation goes through lesson-store's own
66
66
  // updateLessons/ratify/demote/restore — this file adds a SURFACE, never a second writer.
67
- import { loadLessons, updateLessons, ratify, demote, restore, pending, weightOf, TRIGGERS, ENFORCEMENT, ORIGIN, SOURCE_CLASS, STATUS } from './lesson-store.mjs';
67
+ import { loadLessons, lessonApplies, projectNameFor, STORE_PATH as LESSON_STORE_PATH, updateLessons, ratify, demote, restore, pending, weightOf, TRIGGERS, ENFORCEMENT, ORIGIN, SOURCE_CLASS, STATUS } from './lesson-store.mjs';
68
68
  import {
69
69
  openRouterCredentialStatus,
70
70
  saveOpenRouterCredential,
@@ -1401,6 +1401,8 @@ function setLesson(body) {
1401
1401
  // --refresh-cache child. A request never scans — not inline on a first-ever run, and not via
1402
1402
  // setImmediate either (deferring synchronous work still blocks the loop when it runs). A machine with
1403
1403
  // no fleet scan yet is reported `warming`, never as a fabricated zero.
1404
+ // #500: bump when the meaning of the cached fleet changes. 2 = one entry per resolved store; 3 = + stable `id`.
1405
+ const ACTIVITY_CACHE_SCHEMA = 3; // 3 = each project also carries `id`, the store's dev:inode identity
1404
1406
  let ACTIVITY_MACHINE_CACHE = null;
1405
1407
  const CONSOLE_CACHE_PATH = path.join(CONSOLE_ROOT, '.cache/ruvnet-brain/console-cache.json');
1406
1408
 
@@ -1862,7 +1864,9 @@ function loadConsoleCache() {
1862
1864
  let restored = false;
1863
1865
  try {
1864
1866
  const j = JSON.parse(fs.readFileSync(CONSOLE_CACHE_PATH, 'utf8'));
1865
- if (j.activity && j.activity.at) { ACTIVITY_MACHINE_CACHE = j.activity; restored = true; }
1867
+ // #500: a cache written before the fleet scan deduped by store identity can hold one database
1868
+ // counted twice (a nested docs/adr/.swarm beside its parent). Never trust it — re-measure.
1869
+ if (j.activity && j.activity.at && j.activity.schema === ACTIVITY_CACHE_SCHEMA) { ACTIVITY_MACHINE_CACHE = j.activity; restored = true; }
1866
1870
  if (j.trust && j.trust.at) { TRUST_CACHE = j.trust; restored = true; }
1867
1871
  } catch { /* no cache yet — first ever boot */ }
1868
1872
  return restored;
@@ -1896,7 +1900,7 @@ function saveConsoleCache() {
1896
1900
  function adoptFleetFromDisk() {
1897
1901
  try {
1898
1902
  const j = readJSON(CONSOLE_CACHE_PATH);
1899
- if (j && j.activity && j.activity.at && (!ACTIVITY_MACHINE_CACHE || j.activity.at > ACTIVITY_MACHINE_CACHE.at)) {
1903
+ if (j && j.activity && j.activity.at && j.activity.schema === ACTIVITY_CACHE_SCHEMA && (!ACTIVITY_MACHINE_CACHE || j.activity.at > ACTIVITY_MACHINE_CACHE.at)) {
1900
1904
  ACTIVITY_MACHINE_CACHE = j.activity;
1901
1905
  }
1902
1906
  } catch { /* no cache yet — the caller kicks a child and reports `warming` */ }
@@ -1912,8 +1916,10 @@ function refreshFleetCache() {
1912
1916
  // pathIdentity, not path.resolve: resolve() normalises `.`/`..` and nothing else, so a project
1913
1917
  // reached through a symlink OR through the other capitalisation of a case-insensitive volume
1914
1918
  // was two distinct strings for one directory, and its memories were summed twice (#107).
1915
- const resolved = pathIdentity(s.project) ?? path.resolve(s.project);
1916
- if (seen.has(resolved)) continue; // a project visible under two roots counts once
1919
+ // #500: dedupe on the DATABASE, which is what holds the memories. Two directories (a repo and its
1920
+ // nested docs/adr/.swarm, or a linked worktree) can resolve to one store; project paths differ.
1921
+ const resolved = pathIdentity(s.db) ?? path.resolve(s.db);
1922
+ if (seen.has(resolved)) continue; // a store visible twice counts once
1917
1923
  seen.add(resolved);
1918
1924
  const n = Number(robustRead(s.db, "SELECT COUNT(*) FROM memory_entries WHERE status='active'").value || 0);
1919
1925
  if (n > 0) {
@@ -1922,17 +1928,25 @@ function refreshFleetCache() {
1922
1928
  const lastTouched = Number(robustRead(s.db, 'SELECT MAX(updated_at) FROM memory_entries').value || 0);
1923
1929
  // rel = the root-relative path — the SAME key reconcile:<id> recommendations use (wiringSurvey
1924
1930
  // computes projName the same way, relative to whichever root the project was found under).
1925
- projects.push({ name: path.basename(s.project), rel: path.relative(root, s.project), memories: n, lastTouched });
1931
+ // Name from the RESOLVED project root. A linked worktree whose main checkout lies outside this
1932
+ // scan root would give a '..'-prefixed rel, but reconcile:<id> keys (wiringSurvey) are relative to
1933
+ // the directory found under the root — so fall back to the found directory in that case.
1934
+ const inRoot = path.relative(root, s.project);
1935
+ const rel = (inRoot === '' || inRoot.startsWith('..') || path.isAbsolute(inRoot)) ? path.relative(root, s.found || s.project) : inRoot;
1936
+ // `id` = the store's own dev:inode identity (pathIdentity follows symlinks and sees hard links), so a
1937
+ // client can tell WHICH project is "you" without comparing folder names (two repos can share one).
1938
+ projects.push({ id: resolved, name: path.basename(s.project), rel, memories: n, lastTouched });
1926
1939
  total += n;
1927
1940
  }
1928
1941
  }
1929
1942
  }
1930
1943
  projects.sort((a, b) => b.memories - a.memories);
1931
- ACTIVITY_MACHINE_CACHE = { at: Date.now(), projects, totalMemories: total };
1944
+ ACTIVITY_MACHINE_CACHE = { schema: ACTIVITY_CACHE_SCHEMA, at: Date.now(), projects, totalMemories: total };
1932
1945
  saveConsoleCache();
1933
1946
  }
1934
- function findMemoryStores(root) {
1947
+ export function findMemoryStores(root) {
1935
1948
  const out = [];
1949
+ const seenDb = new Set();
1936
1950
  const walk = (dir, depth) => {
1937
1951
  if (depth > 3) return;
1938
1952
  let ents; try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
@@ -1944,8 +1958,12 @@ function findMemoryStores(root) {
1944
1958
  // Fleet activity is project memory, so resolve the canonical store rather than selecting a
1945
1959
  // coordination database by size. The latter is diagnostic-only and can contain unrelated rows.
1946
1960
  try {
1947
- const resolved = resolveProjectStore({ projectDir: dir }).canonicalAgentDbPath;
1948
- if (fs.existsSync(resolved)) out.push({ project: dir, db: resolved });
1961
+ const store = resolveProjectStore({ projectDir: dir });
1962
+ const resolved = store.canonicalAgentDbPath;
1963
+ // #500: record the RESOLVED project root and one entry per database. A nested docs/adr/.swarm
1964
+ // resolves to its parent's store; recording `dir` listed that one database twice.
1965
+ const id = pathIdentity(resolved) ?? path.resolve(resolved);
1966
+ if (fs.existsSync(resolved) && !seenDb.has(id)) { seenDb.add(id); out.push({ project: store.projectRoot, found: dir, db: resolved }); }
1949
1967
  } catch { /* an unresolvable directory is not a project store */ }
1950
1968
  continue;
1951
1969
  }
@@ -1956,6 +1974,38 @@ function findMemoryStores(root) {
1956
1974
  walk(root, 0);
1957
1975
  return out;
1958
1976
  }
1977
+ // #499: approved lessons are the ratified rules in the lesson store (the SAME active rule as
1978
+ // gatherLessons().counts.active). `available:false` means the store could not be read — unknown, never 0.
1979
+ // count = machine-wide (every ratified/active, non-demoted lesson)
1980
+ // applicable = the subset that applies to THIS project, by the SAME rule plugin/scripts/lesson-gate.mjs
1981
+ // uses (lesson-store.lessonApplies): unscoped, or scoped to this project, or proven in >=2 projects.
1982
+ // items = per-lesson id/title/scope/enforcement/status for the applicable ones, bounded (the metadata
1983
+ // the reporter asked to keep), `itemsTruncated` says when the bound cut the list.
1984
+ const APPROVED_ITEMS_MAX = 25;
1985
+ function approvedLessonsFigure(here) {
1986
+ try {
1987
+ // loadLessons() swallows a parse failure and returns [] — which would read as "0 approved". A file
1988
+ // that exists but does not parse is UNKNOWN; only an absent file is a true zero.
1989
+ if (fs.existsSync(LESSON_STORE_PATH)) JSON.parse(fs.readFileSync(LESSON_STORE_PATH, 'utf8'));
1990
+ const approved = loadLessons().filter((l) => (l.status === STATUS.RATIFIED || l.status === STATUS.ACTIVE) && !l.demoted);
1991
+ const mine = approved.filter((l) => lessonApplies(l, here)); // the gate's own rule (lesson-store.lessonApplies)
1992
+ const items = mine.slice(0, APPROVED_ITEMS_MAX).map((l) => {
1993
+ const ps = Array.isArray(l.projects) ? l.projects : [];
1994
+ const text = String(l.statement || '');
1995
+ return { id: l.id, title: text.length > 140 ? `${text.slice(0, 139)}…` : text,
1996
+ scope: ps.length ? ps.join(', ') : 'all projects', enforcement: l.enforcement ?? null,
1997
+ ratified: l.status === STATUS.RATIFIED || l.status === STATUS.ACTIVE, status: l.status };
1998
+ });
1999
+ return { available: true, count: approved.length, applicable: mine.length, items, itemsTruncated: mine.length > items.length };
2000
+ } catch { return { available: false, count: null, applicable: null, items: [], itemsTruncated: false }; }
2001
+ }
2002
+ // #499: a count that could not be read is null, not 0 — "0" would claim the store is empty.
2003
+ function readCount(db, sql) {
2004
+ const r = robustRead(db, sql);
2005
+ if (!r.ok) return null;
2006
+ const n = Number(r.value ?? 0);
2007
+ return Number.isFinite(n) ? n : null;
2008
+ }
1959
2009
  function gatherActivity(cwd) {
1960
2010
  let project;
1961
2011
  let db;
@@ -1965,19 +2015,27 @@ function gatherActivity(cwd) {
1965
2015
  db = resolved.canonicalAgentDbPath;
1966
2016
  } catch {
1967
2017
  const measuredAt = new Date().toISOString();
1968
- return { generatedAt: measuredAt, project: path.basename(cwd), hasStore: false, continuity: 'unavailable', ...freshnessOf(measuredAt) };
2018
+ return { generatedAt: measuredAt, project: path.basename(cwd), hasStore: false, continuity: 'unavailable', approvedLessons: approvedLessonsFigure(projectNameFor(cwd)), ...freshnessOf(measuredAt) };
1969
2019
  }
1970
2020
  if (!fs.existsSync(db)) {
1971
2021
  // No store to read — the existence check above IS the entire measurement, so it IS the
1972
2022
  // observation instant. Stamped here, not with a value taken before the check ran.
1973
2023
  const measuredAt = new Date().toISOString();
1974
- return { generatedAt: measuredAt, project: path.basename(project), hasStore: false, ...freshnessOf(measuredAt) };
2024
+ return { generatedAt: measuredAt, project: path.basename(project), hasStore: false, approvedLessons: approvedLessonsFigure(path.basename(project)), ...freshnessOf(measuredAt) };
1975
2025
  }
1976
- const out = { project: path.basename(project), hasStore: true };
2026
+ // projectId = the same dev:inode identity the fleet entries carry, so the client matches on identity, not the folder name.
2027
+ const out = { project: path.basename(project), projectId: pathIdentity(db) ?? path.resolve(db), hasStore: true, approvedLessons: approvedLessonsFigure(path.basename(project)) };
1977
2028
  const rows = (sql) => robustReadJSON(db, sql).rows;
2029
+ const agentdbLessons = readCount(db, "SELECT COUNT(*) FROM memory_entries WHERE namespace='lessons' AND status='active'");
1978
2030
  out.totals = {
1979
- memories: Number(robustRead(db, "SELECT COUNT(*) FROM memory_entries WHERE status='active'").value || 0),
1980
- lessons: Number(robustRead(db, "SELECT COUNT(*) FROM memory_entries WHERE namespace='lessons' AND status='active'").value || 0),
2031
+ memories: readCount(db, "SELECT COUNT(*) FROM memory_entries WHERE status='active'"),
2032
+ agentdbLessons,
2033
+ lessons: agentdbLessons, // legacy alias of agentdbLessons
2034
+ // #499: the learning pipeline's OWN measurements (null = unreadable, shown as Unknown, never 0). Distillation
2035
+ // writes patterns/episodes, never `lessons` rows; observations are the namespace it mines.
2036
+ observations: readCount(db, "SELECT COUNT(*) FROM memory_entries WHERE namespace='learning-observations' AND status='active'"),
2037
+ patterns: readCount(db, 'SELECT COUNT(*) FROM reasoning_patterns'),
2038
+ episodes: readCount(db, 'SELECT COUNT(*) FROM episodes'),
1981
2039
  };
1982
2040
  out.lessons = rows("SELECT key, access_count, date(created_at/1000,'unixepoch') AS learned, substr(content,1,600) AS excerpt FROM memory_entries WHERE namespace='lessons' AND status='active' ORDER BY created_at DESC");
1983
2041
  out.recent = rows("SELECT key, namespace, type, datetime(updated_at/1000,'unixepoch','localtime') AS at FROM memory_entries WHERE status='active' ORDER BY updated_at DESC LIMIT 18");
@@ -3550,6 +3608,9 @@ export {
3550
3608
  saveBrainProfile,
3551
3609
  setLesson,
3552
3610
  gatherLessons,
3611
+ gatherActivity,
3612
+ refreshFleetCache,
3613
+ loadConsoleCache,
3553
3614
  gatherRouterEngine,
3554
3615
  autoEligibleIds,
3555
3616
  gatherConfig,
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Measures the real hook paths against a real store (#429): prompt-time recall() and SessionStart restore().
4
+ * node scripts/recall-latency-probe.mjs --project <repo> [--runs N]
5
+ * Prints per-run milliseconds, outcome and `uptime` (machine load). Exit 1 unless EVERY prompt recall is
6
+ * ok-with-results / ok-empty within 1900 ms and EVERY restore is 'restored' within 3500 ms. The thresholds are
7
+ * the product's budgets, not tuned to the machine.
8
+ */
9
+ import { execFileSync } from 'node:child_process';
10
+ import path from 'node:path';
11
+ import { recall } from '../plugin/scripts/agentdb-recall.mjs';
12
+ import { restore } from '../plugin/scripts/ruflo-memory-hooks.mjs';
13
+
14
+ export const RECALL_LIMIT_MS = 1900;
15
+ export const RESTORE_LIMIT_MS = 3500;
16
+ const PROMPT = 'Where are we on the recall latency work? Catch me up on the last decision.';
17
+
18
+ const argValue = (name) => { const at = process.argv.indexOf(name); return at >= 0 ? process.argv[at + 1] : undefined; };
19
+ const uptime = () => { try { return execFileSync('uptime', { encoding: 'utf8' }).trim(); } catch { return 'uptime unavailable'; } };
20
+
21
+ async function main() {
22
+ const project = argValue('--project');
23
+ const runs = Number(argValue('--runs') ?? 5);
24
+ if (!project || !Number.isInteger(runs) || runs < 1) {
25
+ console.error('usage: recall-latency-probe.mjs --project <repo> [--runs N]');
26
+ process.exit(2);
27
+ }
28
+ const dir = path.resolve(project);
29
+ let failures = 0;
30
+ for (let run = 1; run <= runs; run += 1) {
31
+ const t0 = Date.now();
32
+ const r = await recall({ prompt: PROMPT, projectDir: dir });
33
+ const recallMs = Date.now() - t0;
34
+ const recallOk = ['ok-with-results', 'ok-empty'].includes(r.outcome) && recallMs <= RECALL_LIMIT_MS;
35
+ const t1 = Date.now();
36
+ const s = await restore({ cwd: dir });
37
+ const restoreMs = Date.now() - t1;
38
+ const restoreOk = s.status === 'restored' && restoreMs <= RESTORE_LIMIT_MS;
39
+ if (!recallOk) failures += 1;
40
+ if (!restoreOk) failures += 1;
41
+ console.log(`run ${run}: recall ${recallMs} ms ${r.outcome}${r.status?.['memory.db'] && r.outcome !== 'ok-with-results' ? ` [${r.status['memory.db']}]` : ''}${r.reason ? ` (${r.reason})` : ''} ${recallOk ? 'PASS' : 'FAIL'}`
42
+ + ` | restore ${restoreMs} ms ${s.status} ${restoreOk ? 'PASS' : 'FAIL'} | ${uptime()}`);
43
+ }
44
+ console.log(failures ? `FAIL: ${failures} of ${runs * 2} measurements outside budget (recall <= ${RECALL_LIMIT_MS} ms, restore <= ${RESTORE_LIMIT_MS} ms)`
45
+ : `PASS: all ${runs * 2} measurements within budget`);
46
+ process.exit(failures ? 1 : 0);
47
+ }
48
+ await main();
@@ -345,6 +345,15 @@ export const RELEASE_REQUIREMENTS = Object.freeze({
345
345
  "reason": "Complete outbox tails replay and accepted pending journal events survive capacity pressure without silent deletion",
346
346
  "files": [
347
347
  ]
348
+ },
349
+ {
350
+ "id": "open-issue-fixes-4-7-22",
351
+ "reason": "Reporter-filed defects stay fixed: completion evidence never binds a masked or negated check and a masked check is an advisory, not a false 'no check ran' stop; the boot surface of a recovered Codex host is proven per changed path; Console fleet and lesson counts match by store identity and never show unknown as zero; one project-scope rule is shared by the lesson gate and the Console; a refused or unreadable memory restore is reported as such and an aborted Ruflo call leaves no child process",
352
+ "files": [],
353
+ "platformFiles": {
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
+ "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
+ }
348
357
  }
349
358
  ],
350
359
  "integration": [
@@ -441,6 +450,15 @@ export const RELEASE_REQUIREMENTS = Object.freeze({
441
450
  "reason": "POSIX non-root permission refusal, capacity-pressure reporting and recovery preserve all accepted pending continuity events",
442
451
  "files": [
443
452
  ]
453
+ },
454
+ {
455
+ "id": "open-issue-fixes-4-7-22-integration",
456
+ "reason": "A named release over an existing install moves, skips truthfully or warns on a downgrade instead of reporting a false lookup failure; the Ruflo memory hooks honour the absolute deadline and abort, kill the whole child process group, and never report a refused or partly unreadable restore as restored",
457
+ "files": [],
458
+ "platformFiles": {
459
+ "linux": ["tests/integration/stale-install-trap.test.mjs", "tests/integration/ruflo-memory-hooks.test.mjs"],
460
+ "macos": ["tests/integration/stale-install-trap.test.mjs", "tests/integration/ruflo-memory-hooks.test.mjs"]
461
+ }
444
462
  }
445
463
  ]
446
464
  });
@@ -226,6 +226,8 @@ const STANDALONE = [
226
226
  + 'starts themselves (--url http://127.0.0.1:.../page.html, via Playwright) — needs a live '
227
227
  + 'rendered page, not a static diff, so it is a dev-loop tool like gen-console-images.mjs, not a '
228
228
  + 'CI step'],
229
+ ['recall-latency-probe', 'human-run measurement (#429): times prompt recall and SessionStart restore against a real '
230
+ + 'project store and exits 1 outside the budgets; it reads the owner\'s live store and loads the machine, so no scheduler runs it'],
229
231
  ['dev-plugin-link', 'developer convenience CLI — its own header: "Users never run it." Hot-links '
230
232
  + 'the cached plugin install to the working tree so hook edits apply without a CC restart'],
231
233