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 +2 -2
- package/bin/install.mjs +126 -9
- package/console/activity.js +122 -38
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/scripts/agentdb-recall-process.mjs +115 -5
- package/plugin/scripts/agentdb-recall-reader.mjs +44 -2
- package/plugin/scripts/agentdb-recall.mjs +34 -12
- package/plugin/scripts/completion-claim-evidence.mjs +185 -11
- package/plugin/scripts/continuation-gate.mjs +52 -7
- package/plugin/scripts/lesson-gate.mjs +7 -39
- package/plugin/scripts/lesson-scope.mjs +51 -0
- package/plugin/scripts/lesson-store.mjs +1 -1
- package/plugin/scripts/ruflo-bin.mjs +14 -6
- package/plugin/scripts/ruflo-memory-hooks.mjs +157 -19
- package/plugin/scripts/session-snapshot-hook.mjs +1 -1
- package/plugin/scripts/session-start-core.mjs +2 -1
- package/plugin/scripts/session-start-health.mjs +3 -2
- package/scripts/codex-boot-surface-proof.mjs +208 -0
- package/scripts/onboarding-console.mjs +76 -15
- package/scripts/recall-latency-probe.mjs +48 -0
- package/scripts/release-qualification-contract.mjs +18 -0
- package/scripts/wired-check.mjs +2 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
1916
|
-
|
|
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
|
-
|
|
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
|
|
1948
|
-
|
|
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
|
-
|
|
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:
|
|
1980
|
-
|
|
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
|
});
|
package/scripts/wired-check.mjs
CHANGED
|
@@ -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
|
|