ruvnet-brain 4.0.36 → 4.0.90-dev
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 +4 -4
- package/bin/install.mjs +283 -23
- package/data/model-catalog.json +104 -15
- package/package.json +2 -1
- package/plugin/.claude-plugin/plugin.json +2 -2
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/commands/brain-console.md +72 -9
- package/plugin/commands/configure.md +67 -21
- package/plugin/commands/rvcb.md +72 -9
- package/plugin/hooks/codex-hooks.json +40 -33
- package/plugin/hooks/hook-contracts.json +14 -24
- package/plugin/hooks/hooks.json +7 -42
- package/plugin/mcp/server.mjs +23 -6
- package/plugin/scripts/adr-currency-gate.mjs +150 -0
- package/plugin/scripts/capability-registry.mjs +10 -1
- package/plugin/scripts/codex-hook-adapter.mjs +121 -19
- package/plugin/scripts/codex-hook-wrapper.mjs +61 -4
- package/plugin/scripts/continuation-gate.mjs +148 -8
- package/plugin/scripts/decision-gate.mjs +428 -0
- package/plugin/scripts/decision-outcomes.mjs +0 -0
- package/plugin/scripts/degradation-watch.mjs +271 -0
- package/plugin/scripts/ground-ruvnet.sh +51 -11
- package/plugin/scripts/hijack-ruvnet.sh +11 -3
- package/plugin/scripts/hook-registry.mjs +48 -3
- package/plugin/scripts/hook-shim.mjs +58 -3
- package/plugin/scripts/identifier-preflight.mjs +134 -0
- package/plugin/scripts/learn-capture.sh +50 -1
- package/plugin/scripts/learn-flush.mjs +5 -5
- package/plugin/scripts/lesson-bridge.mjs +343 -0
- package/plugin/scripts/lesson-hooks.sh +26 -0
- package/plugin/scripts/lesson-promote.mjs +50 -0
- package/plugin/scripts/lesson-store.mjs +6 -1
- package/plugin/scripts/mcp-readiness.mjs +107 -0
- package/plugin/scripts/protect-brain-state.sh +9 -0
- package/plugin/scripts/runtime-preferences.mjs +40 -0
- package/plugin/scripts/session-snapshot-hook.mjs +15 -6
- package/plugin/scripts/spend-guard.mjs +125 -0
- package/plugin/scripts/unprompted-runtime.mjs +12 -2
- package/plugin/scripts/update-apply.mjs +7 -2
- package/plugin/skills/ruvnet-brain/PLAYBOOK.md +20 -5
- package/plugin/skills/ruvnet-brain/SKILL.md +3 -3
- package/scripts/brain-score.mjs +252 -0
- package/scripts/brain-stamp.mjs +5 -1
- package/scripts/build-bundle.mjs +25 -1
- package/scripts/console-engine.mjs +1 -1
- package/scripts/health-repair.mjs +11 -2
- package/scripts/ingest-repo.mjs +66 -6
- package/scripts/learning-replay-cli.mjs +8 -3
- package/scripts/learning-replay-fixture.mjs +25 -6
- package/scripts/learning-replay-proof.mjs +38 -0
- package/scripts/nightly-wrapper.sh +13 -0
- package/scripts/onboarding-console.mjs +21 -1
- package/scripts/org-repo-count.mjs +119 -0
- package/scripts/repo-count-detector.mjs +62 -0
- package/scripts/restore-local-ingests.mjs +116 -0
- package/scripts/selfcheck.mjs +9 -1
- package/scripts/stabilization-receipt.mjs +11 -1
- package/scripts/sync-census.mjs +0 -0
- package/scripts/sync-commands.mjs +117 -0
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* brain-score.mjs — the ONLY sanctioned source of a score about this product.
|
|
3
|
+
*
|
|
4
|
+
* THE DEFECT THIS REPLACES, measured 2026-08-13. Asked to grade the architecture, I counted files
|
|
5
|
+
* — stores/repos, cards/stores — called the result 52/100, and reported it as an architecture
|
|
6
|
+
* grade. Six graders sat unused in this same repo. The real numbers were nothing like 52, and they
|
|
7
|
+
* disagreed with each other in ways that mattered:
|
|
8
|
+
*
|
|
9
|
+
* grounded 100/100 does it fabricate? (evals/baseline.json)
|
|
10
|
+
* routed 63/80 does it reach the right store? same file, same run
|
|
11
|
+
* panel ~56 independent multi-vendor grade (data/grade-*.json)
|
|
12
|
+
*
|
|
13
|
+
* A single number could not have carried that. My 52 did not measure quality at all; it measured
|
|
14
|
+
* CATALOGUE COVERAGE and wore a quality label. That is the specific error this file makes
|
|
15
|
+
* structurally impossible: dimensions carry a `kind`, and `composite()` REFUSES to average across
|
|
16
|
+
* kinds. A coverage percentage and a quality percentage are answers to different questions, and
|
|
17
|
+
* collapsing them tells the owner his working product is failing while his users report it works.
|
|
18
|
+
*
|
|
19
|
+
* THE SECOND DEFECT, committed while correcting the first. Having found `grounded 100/100`, I
|
|
20
|
+
* quoted it as current. It was recorded 2026-07-10 — 34 days and ~40 commits earlier. The existing
|
|
21
|
+
* claims gate passed it because it checks INTERNAL consistency (k<=n, lo<=p<=hi) and never asks
|
|
22
|
+
* WHEN. So every dimension here declares `maxAgeDays`, and a reading past it is `stale` — reported,
|
|
23
|
+
* never silently used, and never the basis of a claim.
|
|
24
|
+
*
|
|
25
|
+
* This is not new machinery. It is ADR-065's own rule — facts are GENERATED, behaviours are TESTED
|
|
26
|
+
* — applied to the one class of fact that was still being typed by hand. `orgTotalApprox: 248` was
|
|
27
|
+
* this exact bug about a repo count; it was wrong by 48 in two files at once. `sync-version.mjs`
|
|
28
|
+
* and `sync-census.mjs` fixed it for versions and counts. Scores were never brought along.
|
|
29
|
+
*/
|
|
30
|
+
import fs from 'node:fs';
|
|
31
|
+
import path from 'node:path';
|
|
32
|
+
import { fileURLToPath } from 'node:url';
|
|
33
|
+
|
|
34
|
+
const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
35
|
+
const DAY = 86_400_000;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Every dimension names the QUESTION it answers, because a number whose question is unstated is
|
|
39
|
+
* the thing that produced the 52. `kind` is load-bearing: see `composite()`.
|
|
40
|
+
*/
|
|
41
|
+
export const DIMENSIONS = [
|
|
42
|
+
{
|
|
43
|
+
key: 'grounded',
|
|
44
|
+
kind: 'quality',
|
|
45
|
+
question: 'When it answers, is the answer bound to real retrieved source rather than invented?',
|
|
46
|
+
artifact: 'evals/baseline.json',
|
|
47
|
+
maxAgeDays: 14,
|
|
48
|
+
read: (j) => ({ value: pct(j?.score?.grounded), detail: frac(j?.score?.grounded), at: j?.recorded }),
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
key: 'routed',
|
|
52
|
+
kind: 'quality',
|
|
53
|
+
question: 'Does a by-description query reach the store that actually holds the answer?',
|
|
54
|
+
artifact: 'evals/baseline.json',
|
|
55
|
+
maxAgeDays: 14,
|
|
56
|
+
read: (j) => ({ value: pct(j?.score?.routed), detail: frac(j?.score?.routed), at: j?.recorded }),
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
key: 'abstained',
|
|
60
|
+
kind: 'quality',
|
|
61
|
+
question: 'When it does NOT know, does it decline instead of producing something?',
|
|
62
|
+
artifact: 'evals/baseline.json',
|
|
63
|
+
maxAgeDays: 14,
|
|
64
|
+
read: (j) => ({ value: pct(j?.score?.abstain), detail: frac(j?.score?.abstain), at: j?.recorded }),
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
key: 'panelStrict',
|
|
68
|
+
kind: 'quality',
|
|
69
|
+
question: 'How do INDEPENDENT-vendor graders score the answers, strictly? (ADR-0002 gate)',
|
|
70
|
+
artifact: 'data/grade-*.json',
|
|
71
|
+
maxAgeDays: 30,
|
|
72
|
+
read: null, // aggregated across files — see readPanel()
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
key: 'catalogue',
|
|
76
|
+
kind: 'coverage',
|
|
77
|
+
question: 'What FRACTION of rUv\'s live repos has a store at all? (NOT a quality measure)',
|
|
78
|
+
artifact: 'data/org-repo-count.json + the live store root',
|
|
79
|
+
maxAgeDays: 7,
|
|
80
|
+
read: null, // derived — see readCatalogue()
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
key: 'routable',
|
|
84
|
+
kind: 'coverage',
|
|
85
|
+
question: 'What fraction of BUILT stores can a by-description query actually reach?',
|
|
86
|
+
artifact: 'capability-cards.md at the store root',
|
|
87
|
+
maxAgeDays: 7,
|
|
88
|
+
read: null,
|
|
89
|
+
},
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Is this store reachable by a by-description query? ALIAS-AWARE, because the router is.
|
|
94
|
+
*
|
|
95
|
+
* Pure and exported so the rule can be tested without a live store root — the previous version was
|
|
96
|
+
* an inline `cards.has(s)` buried in an async reader, which is why nobody noticed it disagreed with
|
|
97
|
+
* the router. `resolve` is injected rather than imported here so a fixture can supply its own
|
|
98
|
+
* registry; production passes `repositoryNames` from kb/card-lane.mjs, the router's own resolver.
|
|
99
|
+
*/
|
|
100
|
+
export function isRoutable(store, cards, root, resolve) {
|
|
101
|
+
return resolve(store, root).some((name) => cards.has(name));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const pct = (s) => (s && Number.isFinite(s.p) ? Math.round(s.p * 1000) / 10 : null);
|
|
105
|
+
const frac = (s) => (s && Number.isFinite(s.k) ? `${s.k}/${s.n}` : null);
|
|
106
|
+
|
|
107
|
+
function readJson(rel) {
|
|
108
|
+
try { return JSON.parse(fs.readFileSync(path.join(ROOT, rel), 'utf8')); } catch { return null; }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Aggregate the multi-vendor panel across whatever stores have actually been graded. */
|
|
112
|
+
function readPanel() {
|
|
113
|
+
const dir = path.join(ROOT, 'data');
|
|
114
|
+
let files = [];
|
|
115
|
+
try { files = fs.readdirSync(dir).filter((f) => /^grade-.*\.json$/.test(f)); } catch { /* none */ }
|
|
116
|
+
const rows = files.map((f) => ({ f, j: readJson(path.join('data', f)) })).filter((r) => r.j?.summary);
|
|
117
|
+
if (!rows.length) return { value: null, detail: null, at: null };
|
|
118
|
+
const strict = rows.map((r) => r.j.summary.avgStrict).filter(Number.isFinite);
|
|
119
|
+
if (!strict.length) return { value: null, detail: null, at: null };
|
|
120
|
+
const at = rows
|
|
121
|
+
.map((r) => { try { return fs.statSync(path.join(dir, r.f)).mtime.toISOString(); } catch { return null; } })
|
|
122
|
+
.filter(Boolean).sort().pop();
|
|
123
|
+
return {
|
|
124
|
+
value: Math.round((strict.reduce((a, b) => a + b, 0) / strict.length) * 10) / 10,
|
|
125
|
+
detail: `${strict.length} store(s) graded`,
|
|
126
|
+
at,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Coverage is DERIVED from the live store root, never from a number in a doc. Returns null rather
|
|
132
|
+
* than a guess when the denominator is unknown — a wrong denominator still renders, which is worse
|
|
133
|
+
* than an absent one.
|
|
134
|
+
*/
|
|
135
|
+
async function readCoverage() {
|
|
136
|
+
const { storeRoot, storesAt, cardsAt } = await import('../kb/store-root.mjs');
|
|
137
|
+
// ROUTABILITY IS ALIAS-AWARE, BECAUSE THE ROUTER IS.
|
|
138
|
+
//
|
|
139
|
+
// `carded` was `stores.filter((s) => cards.has(s))` — a direct name match against card HEADINGS,
|
|
140
|
+
// while the real router resolves a store through `repositoryNames()` first. So a store reachable
|
|
141
|
+
// ONLY under an alias counted as unroutable, and this metric under-reported the brain's own
|
|
142
|
+
// reachability: measured 2026-08-19, 30 stores called unroutable when 26 actually are —
|
|
143
|
+
// `metaharness` among the four, which is reachable through the `agent-harness-generator` card.
|
|
144
|
+
//
|
|
145
|
+
// That is not a rounding error, it is the same alias-blindness ADR-058 records as open in
|
|
146
|
+
// `darkStores()`, and it already cost something real: a false "dark store" reading led to a
|
|
147
|
+
// DUPLICATE `## metaharness` card being added, which collided with `agent-harness-generator` and
|
|
148
|
+
// broke alias resolution outright. A metric that cannot see aliases manufactures work that
|
|
149
|
+
// damages the thing it measures.
|
|
150
|
+
const { repositoryNames } = await import('../kb/card-lane.mjs');
|
|
151
|
+
const root = storeRoot();
|
|
152
|
+
const stores = storesAt(root);
|
|
153
|
+
const cards = new Set(cardsAt(root));
|
|
154
|
+
const org = readJson('data/org-repo-count.json');
|
|
155
|
+
const total = Number.isFinite(org?.count) && org.count > 0 ? org.count : null;
|
|
156
|
+
const carded = stores.filter((s) => isRoutable(s, cards, root, repositoryNames)).length;
|
|
157
|
+
return {
|
|
158
|
+
catalogue: {
|
|
159
|
+
value: total ? Math.round((stores.length / total) * 1000) / 10 : null,
|
|
160
|
+
detail: total ? `${stores.length}/${total} live repos` : `${stores.length} stores, org total UNKNOWN`,
|
|
161
|
+
at: org?.at ?? null,
|
|
162
|
+
},
|
|
163
|
+
routable: {
|
|
164
|
+
value: stores.length ? Math.round((carded / stores.length) * 1000) / 10 : null,
|
|
165
|
+
detail: `${carded}/${stores.length} built stores have a card`,
|
|
166
|
+
// Derived from the live store root at this instant, so it is current BY CONSTRUCTION. A
|
|
167
|
+
// read-now value reported as stale would train the reader to ignore the stale flag, which
|
|
168
|
+
// costs more than the flag is worth.
|
|
169
|
+
at: new Date().toISOString(),
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** A reading past its dimension's `maxAgeDays`, or with no timestamp at all, is not current. */
|
|
175
|
+
export function staleness(at, maxAgeDays, now = Date.now()) {
|
|
176
|
+
if (!at) return { stale: true, ageDays: null, why: 'no timestamp recorded' };
|
|
177
|
+
const ms = now - Date.parse(at);
|
|
178
|
+
if (!Number.isFinite(ms)) return { stale: true, ageDays: null, why: 'unparseable timestamp' };
|
|
179
|
+
const ageDays = Math.round((ms / DAY) * 10) / 10;
|
|
180
|
+
return { stale: ageDays > maxAgeDays, ageDays, why: ageDays > maxAgeDays ? `${ageDays}d > ${maxAgeDays}d budget` : null };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export async function brainScore({ now = Date.now() } = {}) {
|
|
184
|
+
const cov = await readCoverage();
|
|
185
|
+
const out = [];
|
|
186
|
+
for (const d of DIMENSIONS) {
|
|
187
|
+
let r;
|
|
188
|
+
if (d.key === 'panelStrict') r = readPanel();
|
|
189
|
+
else if (cov[d.key]) r = cov[d.key];
|
|
190
|
+
else r = d.read ? d.read(readJson(d.artifact)) : { value: null, detail: null, at: null };
|
|
191
|
+
out.push({
|
|
192
|
+
...d,
|
|
193
|
+
...r,
|
|
194
|
+
...staleness(r.at, d.maxAgeDays, now),
|
|
195
|
+
status: r.value === null ? 'unmeasured' : (staleness(r.at, d.maxAgeDays, now).stale ? 'stale' : 'current'),
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
return out;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* THE STRUCTURAL GUARD. Averaging a coverage percentage with a quality percentage is precisely the
|
|
203
|
+
* 52. It is refused here rather than discouraged in prose, because prose did not stop me.
|
|
204
|
+
*/
|
|
205
|
+
export function composite(dims, kind) {
|
|
206
|
+
if (!kind) return { value: null, refused: 'a score must name WHICH question it answers — pass a kind' };
|
|
207
|
+
const mine = dims.filter((d) => d.kind === kind);
|
|
208
|
+
if (!mine.length) return { value: null, refused: `no dimensions of kind "${kind}"` };
|
|
209
|
+
const usable = mine.filter((d) => d.status === 'current' && d.value !== null);
|
|
210
|
+
if (usable.length < mine.length) {
|
|
211
|
+
return {
|
|
212
|
+
value: null,
|
|
213
|
+
refused: `${mine.length - usable.length} of ${mine.length} ${kind} dimension(s) are stale or unmeasured`,
|
|
214
|
+
missing: mine.filter((d) => !usable.includes(d)).map((d) => `${d.key} (${d.status})`),
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
return { value: Math.round((usable.reduce((a, d) => a + d.value, 0) / usable.length) * 10) / 10, of: usable.length };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const isMain = (() => {
|
|
221
|
+
try { return process.argv[1] && fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url); }
|
|
222
|
+
catch { return false; }
|
|
223
|
+
})();
|
|
224
|
+
|
|
225
|
+
if (isMain) {
|
|
226
|
+
const dims = await brainScore();
|
|
227
|
+
const asJson = process.argv.includes('--json');
|
|
228
|
+
if (asJson) {
|
|
229
|
+
console.log(JSON.stringify({ recorded: new Date().toISOString(), dimensions: dims }, null, 2));
|
|
230
|
+
} else {
|
|
231
|
+
console.log('\nRUVNET-BRAIN — MEASURED, never typed. Each row names the question it answers.\n');
|
|
232
|
+
for (const kind of ['quality', 'coverage']) {
|
|
233
|
+
console.log(` ${kind.toUpperCase()}`);
|
|
234
|
+
for (const d of dims.filter((x) => x.kind === kind)) {
|
|
235
|
+
const v = d.value === null ? 'UNMEASURED' : `${d.value}`.padStart(5);
|
|
236
|
+
const age = d.ageDays === null ? '' : ` ${d.ageDays}d old`;
|
|
237
|
+
const flag = d.status === 'current' ? '' : ` <-- ${d.status.toUpperCase()}${d.why ? ` (${d.why})` : ''}`;
|
|
238
|
+
console.log(` ${d.key.padEnd(13)} ${v} ${(d.detail ?? '').padEnd(26)}${age}${flag}`);
|
|
239
|
+
console.log(` ${d.question}`);
|
|
240
|
+
}
|
|
241
|
+
const c = composite(dims, kind);
|
|
242
|
+
console.log(c.value === null ? ` => NO ${kind} COMPOSITE: ${c.refused}` : ` => ${kind} composite ${c.value} (mean of ${c.of})`);
|
|
243
|
+
console.log('');
|
|
244
|
+
}
|
|
245
|
+
console.log(' A quality number and a coverage number are NEVER averaged. That was the 52.\n');
|
|
246
|
+
}
|
|
247
|
+
const anyStale = dims.some((d) => d.status !== 'current');
|
|
248
|
+
if (process.argv.includes('--require-current') && anyStale) {
|
|
249
|
+
console.error('brain-score: refusing — at least one dimension is stale or unmeasured. Re-run the graders.');
|
|
250
|
+
process.exit(1);
|
|
251
|
+
}
|
|
252
|
+
}
|
package/scripts/brain-stamp.mjs
CHANGED
|
@@ -10,6 +10,9 @@ import { execFileSync } from 'node:child_process';
|
|
|
10
10
|
import { fileURLToPath } from 'node:url';
|
|
11
11
|
import { canonicalRvfStores, hasCanonicalRvfStore } from './rvf-generation.mjs';
|
|
12
12
|
import { getVersionTag, stripTag } from './version.mjs';
|
|
13
|
+
// The org total is DERIVED, never a literal: it was hardcoded 248 in this file and in its
|
|
14
|
+
// sibling while the account actually had 200 — one stale fact, restated twice (2026-08-12).
|
|
15
|
+
import { orgRepoCount } from './org-repo-count.mjs';
|
|
13
16
|
|
|
14
17
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
15
18
|
const arg = (f, d) => { const i = process.argv.indexOf(f); return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : d; };
|
|
@@ -70,11 +73,12 @@ for (const store of canonicalRvfStores(KB_DIR)) {
|
|
|
70
73
|
});
|
|
71
74
|
}
|
|
72
75
|
const built = repos.filter((r) => r.status === 'built');
|
|
76
|
+
const ORG = orgRepoCount();
|
|
73
77
|
const manifest = {
|
|
74
78
|
brainVersion: stripTag(BRAIN_VERSION), // FIELD = bare literal; the "v" tag is display-only (see version.mjs)
|
|
75
79
|
generated: NOW,
|
|
76
80
|
generatedHuman: new Date(NOW).toUTCString(),
|
|
77
|
-
coverage: { built: built.length, catalogued: repos.length, orgTotalApprox:
|
|
81
|
+
coverage: { built: built.length, catalogued: repos.length, orgTotalApprox: ORG.count, orgTotalSource: ORG.source, orgTotalAt: ORG.at, pending: repos.length - built.length },
|
|
78
82
|
builtRepos: built,
|
|
79
83
|
pendingRepos: repos.filter((r) => r.status === 'pending').map((r) => ({ name: r.name, tier: r.tier })),
|
|
80
84
|
};
|
package/scripts/build-bundle.mjs
CHANGED
|
@@ -19,6 +19,9 @@ import { spawnSync } from 'node:child_process';
|
|
|
19
19
|
import { fileURLToPath } from 'node:url';
|
|
20
20
|
import { getVersion, getVersionTag, stripTag } from './version.mjs';
|
|
21
21
|
import { auditRvfIndexes } from './rvf-index-audit.mjs';
|
|
22
|
+
// The org total is DERIVED, never a literal: it was hardcoded 248 in this file and in its
|
|
23
|
+
// sibling while the account actually had 200 — one stale fact, restated twice (2026-08-12).
|
|
24
|
+
import { orgRepoCount } from './org-repo-count.mjs';
|
|
22
25
|
|
|
23
26
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
24
27
|
const KB = path.join(ROOT, 'kb');
|
|
@@ -263,6 +266,26 @@ if (hasGists) for (const suf of ['ruv-gists.big.rvf', 'ruv-gists.big.rvf.idmap.j
|
|
|
263
266
|
fs.writeFileSync(path.join(OUT, 'capability-cards.md'), parts[0] + kept.join(''));
|
|
264
267
|
copied++;
|
|
265
268
|
if (filteredAny) console.log('[build-bundle] filtered private repo card(s) out of shipped capability-cards.md');
|
|
269
|
+
|
|
270
|
+
// repo-aliases.json TRAVELS WITH THE CARDS OR EVERY ALIAS IS DEAD ON ARRIVAL.
|
|
271
|
+
//
|
|
272
|
+
// card-lane.mjs reads it (REPO_ALIASES_FILE) to resolve a PRODUCT name to its installed store:
|
|
273
|
+
// `agent-harness-generator -> metaharness`, `ruvnet-brain -> brain`, `ruvector -> rvf`,
|
|
274
|
+
// `agentic-qe -> qe`. The bundle never copied it, so on every install those mappings were absent
|
|
275
|
+
// and the fast lane could not route them. Measured 2026-08-19 against the live store root:
|
|
276
|
+
// repo-aliases.json ABSENT, and `search_ruvnet("metaharness: what does Darwin mode mutate?")`
|
|
277
|
+
// answered "card router was ambiguous" — the product cannot be found by its own name.
|
|
278
|
+
//
|
|
279
|
+
// `forge-update.mjs:505` already lists this file among the ones it manages, so the updater knew
|
|
280
|
+
// about it and the builder did not — one fact, two shipping paths, only one of them told. Same
|
|
281
|
+
// shape as the reader/writer store-root split and the ingest `--out` bug fixed this week.
|
|
282
|
+
const aliasesSrc = path.join(KB, 'repo-aliases.json');
|
|
283
|
+
if (fs.existsSync(aliasesSrc)) {
|
|
284
|
+
fs.copyFileSync(aliasesSrc, path.join(OUT, 'repo-aliases.json'));
|
|
285
|
+
copied++;
|
|
286
|
+
} else {
|
|
287
|
+
console.log('[build-bundle] note: kb/repo-aliases.json absent — product-name aliases will not resolve on install');
|
|
288
|
+
}
|
|
266
289
|
} else {
|
|
267
290
|
console.log('[build-bundle] note: kb/capability-cards.md absent — the fast lane ships with no card source and will honestly fall through on every query');
|
|
268
291
|
}
|
|
@@ -395,11 +418,12 @@ cp(path.join(ROOT, 'keys', 'ruvnet-brain-signing.pub.pem'), path.join(OUT, 'keys
|
|
|
395
418
|
const builtLower = new Set(built.map((b) => b.toLowerCase()));
|
|
396
419
|
const pendingRepos = regFlat.filter((r) => !builtLower.has(r.name.toLowerCase())).map((r) => ({ name: r.name, tier: r.tier }));
|
|
397
420
|
const now = new Date();
|
|
421
|
+
const ORG = orgRepoCount();
|
|
398
422
|
const manifest = {
|
|
399
423
|
brainVersion: stripTag(BRAIN_VERSION), // FIELD = bare literal; BRAIN_VERSION stays the v-prefixed Release tag
|
|
400
424
|
generated: now.toISOString(),
|
|
401
425
|
generatedHuman: now.toUTCString(),
|
|
402
|
-
coverage: { built: builtRepos.length, catalogued: regFlat.length, orgTotalApprox:
|
|
426
|
+
coverage: { built: builtRepos.length, catalogued: regFlat.length, orgTotalApprox: ORG.count, orgTotalSource: ORG.source, orgTotalAt: ORG.at, pending: pendingRepos.length },
|
|
403
427
|
crossRepoTool: { mcp: 'forge-mcp-all.mjs', cli: 'forge-ask-all.mjs', tool: 'search_ruvnet' },
|
|
404
428
|
conceptsStore: hasConcepts ? { store: 'concepts.big.rvf', note: 'L2 synthesis + per-repo primers embedded as prose; unioned by search_ruvnet so code-implemented capabilities are retrievable as high-confidence prose.' } : null,
|
|
405
429
|
builtRepos,
|
|
@@ -126,7 +126,7 @@ export function buildHealthRecommendations({ memory = null, learning = null } =
|
|
|
126
126
|
if (Number.isFinite(age) && age > STALE_TRAIN_SECONDS) {
|
|
127
127
|
recs.push(makeRecommendation({
|
|
128
128
|
id: 'learning:train',
|
|
129
|
-
scope: '
|
|
129
|
+
scope: 'project',
|
|
130
130
|
title: 'Your learner has gone quiet',
|
|
131
131
|
rationale: 'It is installed and switched on, but it has not learned anything recently — so it is not getting smarter.',
|
|
132
132
|
severity: 'SUGGESTED',
|
|
@@ -24,7 +24,7 @@ import path from 'node:path';
|
|
|
24
24
|
import os from 'node:os';
|
|
25
25
|
import { execFileSync, spawnSync } from 'node:child_process';
|
|
26
26
|
import { findStores, diagnose } from './memory-doctor.mjs';
|
|
27
|
-
import { loadRuntimePreferences } from '../plugin/scripts/runtime-preferences.mjs';
|
|
27
|
+
import { learnerCwd, loadRuntimePreferences } from '../plugin/scripts/runtime-preferences.mjs';
|
|
28
28
|
|
|
29
29
|
const HOME = os.homedir();
|
|
30
30
|
// The SAME project root learn-flush.mjs computes, by the same rule — the two halves of the flush
|
|
@@ -195,7 +195,16 @@ function flushLearning() {
|
|
|
195
195
|
function trainLearning() {
|
|
196
196
|
if (!RUFLO) return { ok: false, log: 'ruflo is not on this machine — install it with `npm i -g ruflo@latest` to enable learning' };
|
|
197
197
|
try {
|
|
198
|
-
|
|
198
|
+
// ISSUE #136: train the SAME store the console card reads. With `cwd: HOME` the card read the
|
|
199
|
+
// project's learner and this trained the home one, so the button could never clear the card it
|
|
200
|
+
// was offered for — a remedy that cannot resolve its own finding is worse than no button.
|
|
201
|
+
// PROJECT is the same root learn-flush.mjs and learn-capture.sh resolve (#134).
|
|
202
|
+
// ISSUE #139 — now that ruflo v3.38.9 made `--train` REAL (ruvnet/ruflo#2940 was a no-op
|
|
203
|
+
// before), training the wrong store is no longer harmless: it moves that store's
|
|
204
|
+
// lastAdaptation to 0s and the card SILENTLY SELF-CLEARS while the learner the operator
|
|
205
|
+
// actually uses is untouched. #136 already moved this off `cwd: HOME`; it now shares the
|
|
206
|
+
// console's resolver so the remedy provably trains the store the card measured.
|
|
207
|
+
execFileSync(RUFLO, ['hooks', 'intelligence', '--train'], { cwd: learnerCwd({ cwd: PROJECT }), env: RUFLO_ENV, stdio: 'ignore', timeout: 600_000 });
|
|
199
208
|
} catch (e) { return { ok: false, log: `training cycle failed: ${e.message}` }; }
|
|
200
209
|
return { ok: true, log: 'ran one training cycle in the cross-project learner' };
|
|
201
210
|
}
|
package/scripts/ingest-repo.mjs
CHANGED
|
@@ -20,6 +20,7 @@ import fs from 'node:fs';
|
|
|
20
20
|
import { execFileSync } from 'node:child_process';
|
|
21
21
|
import { fileURLToPath } from 'node:url';
|
|
22
22
|
import { FULL_HINTS, KEEP_DIRS } from './full-hints.mjs';
|
|
23
|
+
import { storeRoot } from '../kb/store-root.mjs';
|
|
23
24
|
|
|
24
25
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
25
26
|
const arg = (f, d) => { const i = process.argv.indexOf(f); return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : d; };
|
|
@@ -33,7 +34,26 @@ if (!NAME) {
|
|
|
33
34
|
process.exit(2);
|
|
34
35
|
}
|
|
35
36
|
|
|
36
|
-
|
|
37
|
+
// THE WRITER NOW USES THE SAME RESOLVER AS THE READER. This was `path.join(ROOT, 'kb')` — the
|
|
38
|
+
// repo's build directory — while retrieval (kb/forge-mcp-all.mjs) reads storeRoot(), which defaults
|
|
39
|
+
// to ~/.cache/ruvnet-brain/kb. That split is why on 2026-08-12 three repos were ingested, each
|
|
40
|
+
// printed `roundtrip 3/3 PASS` and "searchable now", and NONE of them could be found by search: the
|
|
41
|
+
// ingest wrote one root and the live brain read another. This file's own header promises "the new
|
|
42
|
+
// repo is searchable immediately", and that promise was false by construction.
|
|
43
|
+
//
|
|
44
|
+
// Two adversarial audits both flagged that fixing the READER alone (kb/store-root.mjs, earlier
|
|
45
|
+
// today) made the split GUARANTEED rather than accidental for a dev checkout. This closes it at the
|
|
46
|
+
// writer, which is the half that was left.
|
|
47
|
+
//
|
|
48
|
+
// The repo's own bundle build still targets ./kb by setting KB_DIR — an explicit override of a
|
|
49
|
+
// stated default, not a second answer to the same question.
|
|
50
|
+
// TWO DIFFERENT THINGS, WHICH THE ORIGINAL `path.join(ROOT,'kb')` CONFLATED and my first fix
|
|
51
|
+
// inherited: where the BUILD SCRIPTS live (forge-refresh.mjs, always in this checkout) and where
|
|
52
|
+
// the STORES go (the root retrieval actually reads). Pointing both at storeRoot() broke immediately
|
|
53
|
+
// with `Cannot find module .../.cache/ruvnet-brain/kb/forge-refresh.mjs` — the tool is not the
|
|
54
|
+
// output. One name for two facts is the same defect as one fact in two names.
|
|
55
|
+
const KB_TOOLS = path.join(ROOT, 'kb'); // where forge-refresh.mjs lives
|
|
56
|
+
const KB = storeRoot(); // where stores go — the root the reader reads
|
|
37
57
|
const CLONES = path.join(ROOT, 'clones');
|
|
38
58
|
const kb = NAME.toLowerCase();
|
|
39
59
|
const dir = SOURCE ? path.resolve(SOURCE) : path.join(CLONES, NAME);
|
|
@@ -62,18 +82,58 @@ const url = process.env.RUVNET_CANONICAL_URL || 'https://raw.githubusercontent.c
|
|
|
62
82
|
// full-body source indexing (the 2026-07-10 depth-restore run zeroed ruvector this way).
|
|
63
83
|
const FULL = arg('--full', FULL_HINTS[kb] || '');
|
|
64
84
|
const KEEP = arg('--keep', KEEP_DIRS[kb] || '');
|
|
65
|
-
|
|
85
|
+
// `--out` is the STORE ROOT, not '.' — cwd is the tools directory now, and '.' would have written
|
|
86
|
+
// the store next to the build script, which is precisely the reader/writer split this change closes.
|
|
87
|
+
const buildArgs = ['forge-refresh.mjs', '--repo', dir, '--out', KB, '--name', kb, '--canonical-url', url];
|
|
66
88
|
if (FULL) buildArgs.push('--full', FULL);
|
|
67
89
|
if (KEEP) buildArgs.push('--keep', KEEP);
|
|
68
90
|
console.log(`[refresh bge-768] ${kb}${FULL ? ' (--full: ' + FULL.split(',').length + ' prefixes)' : ''}${KEEP ? ' (--keep: ' + KEEP + ')' : ''}`);
|
|
69
|
-
run('node', buildArgs, { cwd:
|
|
91
|
+
run('node', buildArgs, { cwd: KB_TOOLS, env });
|
|
70
92
|
console.log(`[symbols] ${kb}`);
|
|
71
93
|
try { run('node', ['scripts/build-symbols.mjs', '--name', kb], { cwd: ROOT, env }); } catch { console.log(' (symbols skipped — sparse repo)'); }
|
|
72
94
|
|
|
73
95
|
const ok = fs.existsSync(path.join(KB, `${kb}.big.rvf`))
|
|
74
96
|
&& fs.existsSync(path.join(KB, `${kb}.passages.jsonl`))
|
|
75
97
|
&& fs.existsSync(path.join(KB, `${kb}.meta.json`));
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
98
|
+
// BUILT IS NOT REACHABLE, AND THIS LINE USED TO CLAIM OTHERWISE. It printed "searchable now" on
|
|
99
|
+
// the strength of three files existing. Measured 2026-08-13 on the very next run: helix ingested
|
|
100
|
+
// cleanly, every file present, and `search_ruvnet` answered "Searched 0 RuvNet repos" — the router
|
|
101
|
+
// declined because nothing in capability-cards.md describes the repo, so a by-description query
|
|
102
|
+
// cannot reach it. 26 of 66 stores are in exactly that state. The 2026-08-12 incident (three repos
|
|
103
|
+
// ingested, all reporting success, none findable) was read as a path bug; the path was only half of
|
|
104
|
+
// it. A store with no card is BUILT AND DARK, and saying "searchable" of it is the product lying.
|
|
105
|
+
const carded = (() => {
|
|
106
|
+
try {
|
|
107
|
+
return new RegExp(`^##\\s+${kb}\\s*$`, 'im')
|
|
108
|
+
.test(fs.readFileSync(path.join(KB, 'capability-cards.md'), 'utf8'));
|
|
109
|
+
} catch { return false; }
|
|
110
|
+
})();
|
|
111
|
+
// AN ARTIFACT WITH NO COMMITTED RECIPE IS NOT DURABLE WORK — it is a local side effect with a
|
|
112
|
+
// countdown on it. Proven overnight 2026-08-13 -> 08-14: a scheduled bundle apply extracted an
|
|
113
|
+
// Aug-8 bundle over the live cache. capability-cards.md SURVIVED because it was committed and the
|
|
114
|
+
// brain was restored from the repo in one command. helix, rvQR, RuCelium and wifi-veil — ingested
|
|
115
|
+
// and VERIFIED ROUTING hours earlier — were gone without a trace, because a .rvf is a 32MB
|
|
116
|
+
// gitignored build artifact that existed in exactly one directory on one machine.
|
|
117
|
+
//
|
|
118
|
+
// The stores themselves cannot be committed. The FACT that they were ingested can, so a wipe
|
|
119
|
+
// becomes detectable and replayable instead of silent and permanent. This record is the recipe.
|
|
120
|
+
if (ok) {
|
|
121
|
+
try {
|
|
122
|
+
const ledger = path.join(ROOT, 'kb', 'local-ingests.json');
|
|
123
|
+
const prior = fs.existsSync(ledger) ? JSON.parse(fs.readFileSync(ledger, 'utf8')) : { ingests: [] };
|
|
124
|
+
const rest = (prior.ingests || []).filter((e) => e.name !== NAME);
|
|
125
|
+
rest.push({ name: NAME, org: ORG, at: new Date().toISOString(), store: kb });
|
|
126
|
+
rest.sort((a, b) => a.name.localeCompare(b.name));
|
|
127
|
+
fs.writeFileSync(ledger, `${JSON.stringify({ ingests: rest }, null, 2)}\n`);
|
|
128
|
+
console.log(`[record] kb/local-ingests.json — ${rest.length} local ingest(s) recorded. COMMIT THIS.`);
|
|
129
|
+
} catch (e) { console.log(`[record] could not record the ingest: ${e.message}`); }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
console.log(!ok
|
|
133
|
+
? `\n[FAIL] ${NAME}: expected stores missing after build.`
|
|
134
|
+
: carded
|
|
135
|
+
? `\n[done] ${NAME} ingested AND routable → answers by-description queries now (no restart).`
|
|
136
|
+
: `\n[done] ${NAME} ingested, but DARK: no '## ${kb}' section in ${path.join(KB, 'capability-cards.md')}.\n`
|
|
137
|
+
+ ` The store is valid and a by-description query CANNOT reach it. Write a card describing\n`
|
|
138
|
+
+ ` what ${NAME} is for and when to reach for it, then re-check with a real search_ruvnet query.`);
|
|
79
139
|
process.exit(ok ? 0 : 1);
|
|
@@ -132,9 +132,14 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
132
132
|
const dirs = buildFixtures(base);
|
|
133
133
|
process.once('exit', () => cleanupFixtureDaemons(dirs));
|
|
134
134
|
const record = recordInProjectA(dirs, { trap });
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
135
|
+
// Every target fixture needs a real AgentDB file before Codex starts. The global SessionStart
|
|
136
|
+
// contract otherwise injects an opt-in question and requires the model to wait for an answer,
|
|
137
|
+
// which prevents both post-task arms from producing the command this trap is meant to compare.
|
|
138
|
+
// Only the memory-search trap needs the independent cache note; post-task needs an initialized,
|
|
139
|
+
// otherwise empty store so its control differs solely on the lesson under test.
|
|
140
|
+
const seed = seedProjectBMemory(dirs, {
|
|
141
|
+
includeFixtureRecord: trap === TRAP.MEMORY_SEARCH,
|
|
142
|
+
});
|
|
138
143
|
const refresh = nightlyRefresh(dirs);
|
|
139
144
|
console.log(` record: ${record.projectCount} sources; promoted=${record.promoted}; ok=${record.ok}`);
|
|
140
145
|
console.log(` seed: ${seed.skipped || `ok=${seed.ok}`}`);
|
|
@@ -23,11 +23,16 @@ import {
|
|
|
23
23
|
const CLAUDE_BIN = process.env.RUVNET_CLAUDE_BIN
|
|
24
24
|
|| path.join(os.homedir(), '.npm-global', 'bin', 'claude');
|
|
25
25
|
const CODEX_BIN = process.env.RUVNET_CODEX_BIN || 'codex';
|
|
26
|
-
const sh = (cmd, args, options = {}) =>
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
26
|
+
const sh = (cmd, args, options = {}) => {
|
|
27
|
+
const [binary, argv] = /\.[cm]?js$/i.test(cmd)
|
|
28
|
+
? [process.execPath, [cmd, ...args]]
|
|
29
|
+
: [cmd, args];
|
|
30
|
+
return spawnSync(binary, argv, {
|
|
31
|
+
encoding: 'utf8',
|
|
32
|
+
timeout: 120_000,
|
|
33
|
+
...options,
|
|
34
|
+
});
|
|
35
|
+
};
|
|
31
36
|
const remove = (target) => {
|
|
32
37
|
try { fs.rmSync(target, { recursive: true, force: true }); } catch { /* already gone */ }
|
|
33
38
|
};
|
|
@@ -143,9 +148,23 @@ export function recordInProjectA(dirs, {
|
|
|
143
148
|
};
|
|
144
149
|
}
|
|
145
150
|
|
|
146
|
-
export function seedProjectBMemory(dirs, {
|
|
151
|
+
export function seedProjectBMemory(dirs, {
|
|
152
|
+
ruflo = RUFLO_BIN,
|
|
153
|
+
includeFixtureRecord = true,
|
|
154
|
+
} = {}) {
|
|
147
155
|
const db = path.join(dirs.projectB, '.swarm', 'memory.db');
|
|
148
156
|
const init = initMemoryDb(ruflo, db, dirs.projectB);
|
|
157
|
+
if (!includeFixtureRecord) {
|
|
158
|
+
return {
|
|
159
|
+
db,
|
|
160
|
+
key: null,
|
|
161
|
+
initExit: init.status,
|
|
162
|
+
storeExit: null,
|
|
163
|
+
exactReadExit: null,
|
|
164
|
+
ok: init.status === 0 && fs.existsSync(db),
|
|
165
|
+
skipped: 'AgentDB initialized; memory-search record not required',
|
|
166
|
+
};
|
|
167
|
+
}
|
|
149
168
|
const store = sh(ruflo, [
|
|
150
169
|
'memory', 'store', '-k', PROJECT_B_MEMORY_KEY,
|
|
151
170
|
'--value', PROJECT_B_MEMORY_VALUE, '-n', 'default', '--path', db,
|
|
@@ -454,6 +454,44 @@ export function writeArtifact(file, aggregateResult, meta = {}) {
|
|
|
454
454
|
runs,
|
|
455
455
|
};
|
|
456
456
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
457
|
+
|
|
458
|
+
// AN ENVIRONMENT THAT CANNOT MEASURE MUST NOT OVERWRITE ONE THAT DID.
|
|
459
|
+
// Measured 2026-08-19: `learning-replay` had been red on main since 2026-08-11 because the
|
|
460
|
+
// artifact was re-recorded carrying `verdict: UNKNOWN` and "3/3 run(s) could not be measured;
|
|
461
|
+
// executor error: spawnSync codex ENOENT" — written by a nightly container with no `codex` on
|
|
462
|
+
// PATH. `codex` resolves fine on the owner's machine (0.148.0), so a real PASS was replaced by
|
|
463
|
+
// "I could not look", and every later CI run correctly refused the non-PASS verdict. The gate was
|
|
464
|
+
// right; its INPUT had been destroyed by a host that could never produce one. Same distinction
|
|
465
|
+
// `restore-local-ingests.mjs` and `degradation-watch` both needed: CANNOT-MEASURE is not
|
|
466
|
+
// MEASURED-AND-FAILED. A real FAIL still overwrites — that is a measurement, and it must land.
|
|
467
|
+
// "PARTIALLY MEASURED" IS NOT "COULD NOT MEASURE" either, and the first version of this guard got
|
|
468
|
+
// that wrong within the hour — the same conflation it exists to prevent, committed inside it. Its
|
|
469
|
+
// predicate was a substring match on "could not be measured", so a run reporting
|
|
470
|
+
// "1/3 run(s) could not be measured; 1/3 passed" — a genuine mixed result carrying a live
|
|
471
|
+
// regression signal — was swallowed, leaving a PASS from 2026-08-03 standing. It suppressed
|
|
472
|
+
// exactly the finding it should have let through. A host that cannot measure produces NO
|
|
473
|
+
// measurements: every run unknown. If even one run yielded a pass or a fail, the executor plainly
|
|
474
|
+
// ran and the result is real, however unwelcome.
|
|
475
|
+
const runsTotal = Number(artifact.n ?? 0);
|
|
476
|
+
const runsUnknown = Number(artifact.unknowns ?? 0);
|
|
477
|
+
const nothingMeasured = runsTotal > 0 && runsUnknown === runsTotal;
|
|
478
|
+
const unmeasurable = artifact.verdict === 'UNKNOWN'
|
|
479
|
+
&& nothingMeasured
|
|
480
|
+
&& /ENOENT|executor error/i.test(String(artifact.why ?? ''));
|
|
481
|
+
if (unmeasurable && fs.existsSync(file)) {
|
|
482
|
+
try {
|
|
483
|
+
const prior = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
484
|
+
if (prior?.verdict && prior.verdict !== 'UNKNOWN') {
|
|
485
|
+
process.stderr.write(
|
|
486
|
+
`[learning-replay] REFUSING to overwrite a ${prior.verdict} recorded ${prior.at} with an\n`
|
|
487
|
+
+ ` unmeasurable UNKNOWN — this host cannot run the executor (${artifact.why}).\n`
|
|
488
|
+
+ ' The prior measurement stands. Re-record on a host that has the executor.\n',
|
|
489
|
+
);
|
|
490
|
+
return prior;
|
|
491
|
+
}
|
|
492
|
+
} catch { /* unreadable prior is not a reason to keep a bad one — fall through and write */ }
|
|
493
|
+
}
|
|
494
|
+
|
|
457
495
|
fs.writeFileSync(file, `${JSON.stringify(artifact, null, 2)}\n`);
|
|
458
496
|
return artifact;
|
|
459
497
|
}
|
|
@@ -83,6 +83,19 @@ RUFLO_DAEMON_AUTOSTART=0 ~/.npm-global/bin/ruflo memory distill run --path .swar
|
|
|
83
83
|
# automatically (keeps last 14), zero risk to the live DB (reads only).
|
|
84
84
|
RUFLO_DAEMON_AUTOSTART=0 ~/.npm-global/bin/ruflo memory backup --db .swarm/memory.db --keep 14 >> "$LOG" 2>&1 || true
|
|
85
85
|
|
|
86
|
+
# LESSON BRIDGE (ADR-066, 2026-08-10) — carry newly-tagged machine-wide lessons into the store the
|
|
87
|
+
# hooks actually read. Without this the bridge is a command someone has to remember, and a learning
|
|
88
|
+
# system that depends on being remembered is the thing ADR-066 was written to fix: knowledge sat in
|
|
89
|
+
# AgentDB for 18 days while the repo re-made the mistake it described.
|
|
90
|
+
#
|
|
91
|
+
# Safe to run unattended and to run when nothing changed: it is idempotent (bridged rows are replaced
|
|
92
|
+
# wholesale, native rows are never touched), it writes through the store's locked atomic writer, and
|
|
93
|
+
# it REFUSES to apply when it reads zero rows while bridged lessons are installed — so a transient
|
|
94
|
+
# read failure can never strip the store. Best-effort, never blocks the rebuild.
|
|
95
|
+
echo "===== lesson-bridge — $(date -u +%FT%TZ) =====" >> "$LOG"
|
|
96
|
+
/usr/local/bin/node plugin/scripts/lesson-bridge.mjs --apply >> "$LOG" 2>&1 \
|
|
97
|
+
|| echo "===== lesson-bridge: SKIPPED (see line above) =====" >> "$LOG"
|
|
98
|
+
|
|
86
99
|
# AgentDB drift canary (2026-07-23, P7 wiring sweep) — scripts/memdb-health.sh existed unwired since
|
|
87
100
|
# 2026-07-09/10, the exact nights idx_bridge_key/idx_bridge_ns corruption recurred 3x on this very DB
|
|
88
101
|
# while in WAL mode. Best-effort, same shape as its brain-health/key-health siblings below: never
|
|
@@ -66,6 +66,7 @@ import { loadLessons, updateLessons, ratify, demote, restore, pending, weightOf,
|
|
|
66
66
|
import {
|
|
67
67
|
openRouterCredentialStatus,
|
|
68
68
|
saveOpenRouterCredential,
|
|
69
|
+
learnerCwd,
|
|
69
70
|
} from '../plugin/scripts/runtime-preferences.mjs';
|
|
70
71
|
import { applyNightlyChoice, nightlyStatus } from './nightly-controller.mjs';
|
|
71
72
|
// One canonical answer to "which directory is this, and have I counted it already?" — shared with
|
|
@@ -2213,10 +2214,29 @@ function observeLearning() {
|
|
|
2213
2214
|
|
|
2214
2215
|
let lastTrainSeconds = null; let trajectories = 0;
|
|
2215
2216
|
try {
|
|
2217
|
+
// ISSUE #136 — THE LEARNER IS PROJECT-SCOPED, so this must ask about the SERVED project.
|
|
2218
|
+
//
|
|
2219
|
+
// `ruflo hooks intelligence --status` reports `Data Dir: <cwd>/.claude-flow/neural`. With
|
|
2220
|
+
// `cwd: SYSTEM_HOME` this measured `~/.claude-flow/neural` — a store nothing writes to on a
|
|
2221
|
+
// machine whose work happens inside project directories. Measured on one machine, one minute:
|
|
2222
|
+
// the home store held 1,216 trajectories last trained 6.9 DAYS ago while the served project held
|
|
2223
|
+
// 9,940 last trained 22 SECONDS ago. The card said "Your learner has gone quiet" about a learner
|
|
2224
|
+
// training every few seconds.
|
|
2225
|
+
//
|
|
2226
|
+
// This file already carries the verdict on this exact mistake at the refresh-child spawn: "cwd =
|
|
2227
|
+
// the SERVED project, NOT REPO … it was a real console-honesty bug". Same rule, same file,
|
|
2228
|
+
// different call site — #104's and #134's residual arriving a third time.
|
|
2229
|
+
// ISSUE #139 (@ObiWanKenobi) — `process.cwd()` was a HARDCODE in the opposite direction from
|
|
2230
|
+
// #136's `SYSTEM_HOME`. Neither asked which scope was in effect; the first was wrong by default
|
|
2231
|
+
// and the second is right only BECAUSE `project` is the default. Under
|
|
2232
|
+
// `RUVNET_LEARNING_SCOPE=user` the flush feeds ~/.claude-flow/neural while this read
|
|
2233
|
+
// <project>/.claude-flow/neural — the same false-positive card, inverted. The writer and this
|
|
2234
|
+
// reader now call ONE resolver (runtime-preferences.learnerCwd), so they agree by construction
|
|
2235
|
+
// instead of by coincidence.
|
|
2216
2236
|
const r = spawnSync(path.join(SYSTEM_HOME, '.npm-global/bin/ruflo'),
|
|
2217
2237
|
['hooks', 'intelligence', '--status'],
|
|
2218
2238
|
{
|
|
2219
|
-
cwd:
|
|
2239
|
+
cwd: learnerCwd(),
|
|
2220
2240
|
env: { ...process.env, RUFLO_DAEMON_AUTOSTART: '0' },
|
|
2221
2241
|
encoding: 'utf8',
|
|
2222
2242
|
timeout: 20_000,
|