ruvnet-brain 4.3.40 → 4.4.1
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 +179 -36
- package/kb/brain-profile.mjs +17 -2
- package/kb/forge-update.mjs +6 -2
- package/kb/lifecycle-evidence-retention.mjs +12 -9
- package/kb/refresh-run.mjs +17 -1
- package/kb/update-storage-transaction.mjs +33 -5
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/hooks/codex-hooks.json +2 -2
- package/plugin/hooks/hooks.json +1 -1
- package/plugin/scripts/capability-registry.mjs +3 -3
- package/plugin/scripts/codex-hook-wrapper.mjs +7 -2
- package/plugin/scripts/design-wall.sh +1 -0
- package/plugin/scripts/ground-before-write.sh +1 -0
- package/plugin/scripts/ground-ruvnet.sh +3 -3
- package/plugin/scripts/grounding-answer.mjs +129 -0
- package/plugin/scripts/grounding-stamp.sh +32 -31
- package/plugin/scripts/grounding-turn-evidence.mjs +146 -5
- package/plugin/scripts/grounding-turn-gate.mjs +25 -6
- package/plugin/scripts/hook-shim.mjs +3 -0
- package/plugin/scripts/kling-preflight.sh +1 -0
- package/plugin/scripts/learn-capture.sh +1 -0
- package/plugin/scripts/project-progression-reader.mjs +10 -0
- package/plugin/scripts/project-progression-sources.mjs +16 -4
- package/plugin/scripts/project-progression-store.mjs +201 -6
- package/plugin/scripts/protect-brain-state.sh +1 -0
- package/plugin/scripts/route-dispatch.sh +1 -0
- package/plugin/scripts/session-snapshot-hook.mjs +383 -37
- package/plugin/scripts/session-start-health.mjs +24 -3
- package/plugin/scripts/session-start-update-plane.mjs +1 -1
- package/plugin/scripts/update-apply.mjs +22 -2
- package/scripts/console-instances.mjs +203 -0
- package/scripts/console-runtime-identity.mjs +2 -0
- package/scripts/corpus-canary.mjs +130 -18
- package/scripts/customer-seams.mjs +84 -0
- package/scripts/customer-state-matrix.mjs +363 -0
- package/scripts/full-suite-gate.mjs +162 -0
- package/scripts/grounding-turn-replay.mjs +11 -3
- package/scripts/hook-qualify-core.mjs +346 -0
- package/scripts/hook-qualify-hosts.mjs +115 -0
- package/scripts/hook-qualify.mjs +101 -0
- package/scripts/host-cli.mjs +115 -0
- package/scripts/qe/agentic-qe-4.3.mjs +0 -1
- package/scripts/route-gold-rank.mjs +156 -0
- package/scripts/route-index-memory.mjs +51 -0
- package/scripts/route-latency-warm.mjs +123 -0
- package/scripts/wired-check.mjs +17 -3
|
@@ -32,6 +32,7 @@ import os from 'node:os';
|
|
|
32
32
|
import path from 'node:path';
|
|
33
33
|
import { fileURLToPath } from 'node:url';
|
|
34
34
|
import { RUVNET_GATE1_TERMS } from './ruvnet-gate1-pattern.mjs';
|
|
35
|
+
import { brainAnsweredResponse } from './grounding-answer.mjs';
|
|
35
36
|
import { extractClaims } from './capability-claim-evidence.mjs';
|
|
36
37
|
import { currentTurnRecords, strippedProse } from './completion-claim-evidence.mjs';
|
|
37
38
|
|
|
@@ -134,12 +135,22 @@ const textOf = (c) => (typeof c === 'string' ? c : Array.isArray(c)
|
|
|
134
135
|
const NOT_A_SOURCE = /^(?:Edit|Write|MultiEdit|NotebookEdit|TodoWrite|ToolSearch|AskUserQuestion|ExitPlanMode|SendMessage|TaskStop|Monitor|Skill|Artifact.*|EnterWorktree|ExitWorktree)$/;
|
|
135
136
|
const MCP_MUTATING = /__(?:create|update|delete|remove|publish|deploy|push|write|send|set|merge|upload|patch|put|post|add|rename|move|approve|promote|rollback|cancel|buy|store|edit|import|reset|stop|terminate|spawn|execute)[a-z_-]*$/i;
|
|
136
137
|
|
|
137
|
-
/**
|
|
138
|
-
|
|
138
|
+
/**
|
|
139
|
+
* Did the brain actually answer? The ONE predicate (grounding-answer.mjs), shared with
|
|
140
|
+
* grounding-stamp.sh: the ANSWER TEXT (never the echoed retrieval.query) must begin with the brain's
|
|
141
|
+
* own header — heavy-lane banner, fast-lane card, optionally after the degraded paragraph — or the
|
|
142
|
+
* response is the host's oversize notice for its own saved file. `notAfterMs`: the transcript time of
|
|
143
|
+
* this tool result; a saved file modified later is not the tool's output.
|
|
144
|
+
*/
|
|
145
|
+
export function brainAnswered(r, { home = os.homedir(), notAfterMs = null } = {}) {
|
|
146
|
+
return brainAnsweredResponse(r, { home, notAfterMs });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function sourceOf(name, input = {}, result = '', { resultAtMs = null } = {}) {
|
|
139
150
|
const n = String(name || '');
|
|
140
151
|
const r = String(result || '');
|
|
141
152
|
if (/(?:^|__)search_ruvnet$/.test(n)) {
|
|
142
|
-
const ok =
|
|
153
|
+
const ok = brainAnswered(r, { notAfterMs: resultAtMs == null ? null : resultAtMs + 5000 });
|
|
143
154
|
const paths = [...r.matchAll(/^path : (\S+)/gm)].map((m) => m[1]).slice(0, 20);
|
|
144
155
|
return { kind: 'search_ruvnet', ref: String(input.query || ''), strength: ok ? 'strong' : 'failed', ok, text: [input.query, ...paths].join(' ') };
|
|
145
156
|
}
|
|
@@ -171,7 +182,8 @@ export function turnSources(lines) {
|
|
|
171
182
|
const results = new Map();
|
|
172
183
|
for (const o of recs) {
|
|
173
184
|
const c = o?.message?.content;
|
|
174
|
-
|
|
185
|
+
const at = Date.parse(o?.timestamp || '');
|
|
186
|
+
if (Array.isArray(c)) for (const r of c) if (r?.type === 'tool_result' && r.tool_use_id) results.set(r.tool_use_id, { text: textOf(r.content), at: Number.isFinite(at) ? at : null });
|
|
175
187
|
}
|
|
176
188
|
const sources = [];
|
|
177
189
|
for (const o of recs) {
|
|
@@ -179,7 +191,8 @@ export function turnSources(lines) {
|
|
|
179
191
|
if (o?.type !== 'assistant' || !Array.isArray(c)) continue;
|
|
180
192
|
for (const u of c) {
|
|
181
193
|
if (u?.type !== 'tool_use') continue;
|
|
182
|
-
const
|
|
194
|
+
const res = results.get(u.id) || { text: '', at: null };
|
|
195
|
+
const s = sourceOf(u.name, u.input || {}, res.text, { resultAtMs: res.at });
|
|
183
196
|
if (s) sources.push({ ...s, order: sources.length });
|
|
184
197
|
}
|
|
185
198
|
}
|
|
@@ -189,6 +202,134 @@ export function turnSources(lines) {
|
|
|
189
202
|
/** Did a search_ruvnet call this turn return a real grounded answer? (Gate 1, from the transcript.) */
|
|
190
203
|
export const searchedThisTurn = (sources) => sources.some((s) => s.kind === 'search_ruvnet' && s.ok);
|
|
191
204
|
|
|
205
|
+
// ── does the ANSWER assert a rUv capability? (Gate 1's trigger at Stop, 4.4.0) ───────────────────
|
|
206
|
+
// THE FALSE ALARM (measured 2026-09-30 on this repo's own transcripts): Gate 1 arms on any prompt
|
|
207
|
+
// matching RUVNET_GATE1_PATTERN, and in this repository nearly every prompt does (`ruvnet-brain`,
|
|
208
|
+
// "rUv", "swarm"). Of 183 real deliveries of "no successful search_ruvnet call", 172 were on turns
|
|
209
|
+
// whose answer asserted nothing about a rUv tool — release status, git/CI checks, disk and backup
|
|
210
|
+
// answers, memory writes. The directive is "search BEFORE asserting what a RuvNet tool can/cannot do",
|
|
211
|
+
// so the Stop check now demands a search only when the final answer actually asserts that.
|
|
212
|
+
// Deterministic, local (ADR-G004: no model evaluates a gate). Subjects are rUv PRODUCTS (grounded in
|
|
213
|
+
// ruvector ADR-029 and ruflo docs/index.md); `RuvNet Brain` / `ruvnet-brain` is THIS product and is
|
|
214
|
+
// grounded by reading this repo, never by search_ruvnet.
|
|
215
|
+
const RUV_PRODUCT = String.raw`(?:@(?:ruvector|claude-flow|metaharness|ruvnet)\/[a-z0-9-]+|ruflo|ruvector(?:-core)?|rvf(?:-[a-z]+)?|agentdb|agenticow|rulake|ruview|rupixel|ruv-fann|agentic[- ]flow|agentic[- ]qe|synthlang|qudag|safla|metaharness|cve-bench|claude[- ]flow|ruv-swarm|aidefence|aimds|ruvllm|sona|agent[- ]booster|rvlite|ospipe|reasoningbank|flow-nexus|ruvnet(?!\s+brain)|r[uU]v)`;
|
|
216
|
+
// "rUv's/Ruflo's (own) <up to 3 words>" or the bare product, never inside a path, filename or a
|
|
217
|
+
// longer identifier (`ruvnet-brain`, `ruflo-core.mjs`, `kb/ruvector`).
|
|
218
|
+
const RUV_SUBJECT = String.raw`(?<![\w/.@-])${RUV_PRODUCT}(?![\w-]|[./][\w])(?:(?:'|’)s)?(?:\s+own)?(?:\s+(?!(?:can|does|is|are|has|have|the|a|an|and|or|but|to|of|for|with|by|in|on|at|from|if|when|after|before|that|this|these|those|which|who|two|three|it|they|we|you|i)\b)[\w.@/-]+){0,4}?`;
|
|
219
|
+
// Third-person verbs only: a CLI noun phrase (`ruflo memory store`, `ruvector search`) must never read
|
|
220
|
+
// as subject + verb. Base forms ("turn", "ship", "store") count only right after a plural product noun
|
|
221
|
+
// ("rUv's tools turn …") — a lookbehind, so a rejected noun never consumes the real verb after it.
|
|
222
|
+
// Not \b at the end: `support-ticket` is no verb.
|
|
223
|
+
const PLURAL_NOUN = 'tools|packages|crates|plugins|libraries|hooks|agents|skills|servers|workers|daemons|commands|apis|clis|sdks|bindings|routers|gates|controllers';
|
|
224
|
+
const VERBS = 'support|provide|expose|ship|offer|export|implement|include|allow|enable|accept|return|store|require|need|handle|route|record|persist|index|cache|spawn|create|generate|compute|sort|classif|scan|detect|block|prevent|replace|wrap|call|launch|keep|clamp|turn|give|make|run|use|take|let|write|default';
|
|
225
|
+
// "will not" is a capability claim only with a capability verb: "AgentDB will not open X" is, while
|
|
226
|
+
// "Ruflo will not be touched by this patch" / "will not need a rebuild" report OUR change (4.4.0 nit).
|
|
227
|
+
const WILL_NOT_VERBS = 'run|work|support|open|load|accept|start|install|handle|read|write|store|return|expose|allow|connect|recogni[sz]e|parse|build|compile|sync|scale|persist|import|export';
|
|
228
|
+
// "now" makes a recency claim only with a capability verb ("Ruflo now supports X"); "AgentDB now records
|
|
229
|
+
// every turn" describes behaviour this repo just wired, and is not a claim about the product.
|
|
230
|
+
const NOW_VERBS = /\bnow\s+(?:supports|ships|works|exposes|provides|includes|offers|accepts|allows|enables|runs|requires|has|handles|can(?:not|'t|’t)?|does(?:n't|n’t|\s+not)?)\b/i;
|
|
231
|
+
const CAPABILITY_VERB = String.raw`(?:(?:won't|won’t|will\s+not)\s+(?:${WILL_NOT_VERBS})\b|can(?:not|'t|’t)?\s+\w+|does(?:n't|n’t|\s+not)\s+\w+|do(?:n't|n’t|\s+not)\s+\w+|has(?:n't|n’t|\s+not)\s+\w+|has\s+(?:a|an|no|its|built-in|native)\b|(?:is|are)\s+(?:able|unable|capable|designed|built|meant|backed|limited|not\s+(?:able|available|supported))\b|only\s+(?:supports?|works|runs|accepts)|comes\s+with|works\s+(?:with|by|on|only)|(?:${VERBS})(?:e?s|ies)|(?<=\b(?:${PLURAL_NOUN})\s+)(?:${VERBS}))(?![\w-])`;
|
|
232
|
+
// What rUv's own docs/research/source SAY is a capability claim too, in any tense.
|
|
233
|
+
const DOC_VERB = String.raw`(?:says?|said|found|finds|shows?|showed|marks?|marked|documents?|documented|recommends?|prescribes?|states?|reports?|measured|took|warns?)\b`;
|
|
234
|
+
const DOC_NOUN = String.raw`(?:research|benchmark|readme|docs?|documentation|release\s+notes|notes|code|source|adr|guidance|skill|campaign|issue|gist)`;
|
|
235
|
+
const RUV_CLAIM = new RegExp(`(${RUV_SUBJECT})\\s+(?:\\([^)]{0,80}\\)\\s+)?(?:now\\s+|also\\s+|already\\s+|actually\\s+|really\\s+|still\\s+|always\\s+|never\\s+|only\\s+)?${CAPABILITY_VERB}`, 'gi');
|
|
236
|
+
const RUV_DOC_CLAIM = new RegExp(`(?<![\\w/.@-])${RUV_PRODUCT}(?:(?:'|’)s)?(?:\\s+own)?(?:\\s+[\\w.@/-]+){0,3}?\\s+${DOC_NOUN}\\b[^.;]{0,40}?\\b${DOC_VERB}`, 'gi');
|
|
237
|
+
// "Ruflo is the orchestration layer and it has no hooks API": the pronoun refers back, in the same
|
|
238
|
+
// sentence — only to a product that OPENS the sentence as its subject (a product inside a list or a
|
|
239
|
+
// parenthetical is not what "they" means: "N-API builds (ruvector, rvf, …), so they don't depend…").
|
|
240
|
+
// The opening subject phrase of a sentence: "The RuVector router package (`ruvector-router`)",
|
|
241
|
+
// "RuVector's router", "Ruflo". Words stop at a copula; a parenthetical right after it is allowed.
|
|
242
|
+
// OUR change to a product ("the ruflo upgrade", "the AgentDB write", "the ruflo fix") is not the product.
|
|
243
|
+
const CHANGE_NOUN = 'upgrade|update|fix|change|patch|install|installation|write|read|call|run|release|build|migration|restart|bump|version|commit|test|tests|check|ci|job|workflow|step|lock|config|setting|settings|issue|bug|error|failure|outage|incident|pr|branch|diff|rollout|deploy|deployment';
|
|
244
|
+
const OPENING_SUBJECT = String.raw`^(?:the\s+)?(${RUV_PRODUCT})(?![\w-]|[./][\w])(?:(?:'|’)s)?(?:\s+(?!(?:is|are|was|were|has|and|or|but|it|they|${CHANGE_NOUN})\b)[\w.@/-]+){0,4}?\s+(?:\([^)]{0,80}\)\s+)?`;
|
|
245
|
+
const RUV_COREF_CLAIM = new RegExp(`${OPENING_SUBJECT}(?:(?:is|are)\\s+(?:a|an|the)\\b|has\\b|provides?\\b|ships?\\b|${CAPABILITY_VERB})[^.;!?]{0,200}?\\b(?:and|but|so|which|because)\\s+(?:it|they)\\s+(?:also\\s+|now\\s+|still\\s+|only\\s+)?${CAPABILITY_VERB}`, 'gi');
|
|
246
|
+
// A DEFINITION is a capability claim too: "The RuVector router package (…) is a vector database …"
|
|
247
|
+
// asserts what the product is and does (4.4.1, a live miss). Only as the sentence's opening subject,
|
|
248
|
+
// and only "is a/an": "your ruflo is a version behind" (a status) does not open the sentence.
|
|
249
|
+
const RUV_DEFINE_CLAIM = new RegExp(`${OPENING_SUBJECT}(?:is|are)\\s+(?:a|an)\\s+(?!(?:version|bit|few|lot|little|couple|day|week|month|commit|no-op|success|failure|regression|bug|fix|one-line|change|problem|mistake|non-issue|win|loss)s?\\b)\\w`, 'gi');
|
|
250
|
+
// "this is agentic-qe's own static/heuristic estimate": a copula naming what the PRODUCT's output is.
|
|
251
|
+
const RUV_COPULA_OWN_CLAIM = new RegExp(`\\b(?:this|that|it)\\s+is\\s+(${RUV_PRODUCT})(?![\\w-]|[./][\\w])(?:'|’)s\\s+own\\s+(?!(?:ci|tests?|build|pipeline|run|job|workflow|checks?|bug|failure|issue|repo|release|fault|problem|mistake|error)\\b)[\\w/-]+`, 'gi');
|
|
252
|
+
// "is confirmed honored by the installed ruflo": the product as the passive AGENT of a behaviour.
|
|
253
|
+
const RUV_PASSIVE_CLAIM = new RegExp(`\\b(?:is|are)\\s+(?:\\w+\\s+){0,2}?(?:honou?red|supported|handled|enforced|rejected|ignored|accepted|respected|read|parsed)\\s+by\\s+(?:the\\s+)?(?:installed\\s+|global\\s+|current\\s+)?(${RUV_PRODUCT})(?![\\w-]|[./][\\w])`, 'gi');
|
|
254
|
+
// Not an assertion: a question, a hedge, a plan or hypothetical. Narrower than HEDGE above on purpose,
|
|
255
|
+
// and narrower still since the 4.4.0 review (S2): "now", "if" and "will not" no longer silence a whole
|
|
256
|
+
// sentence — "Ruflo now supports Windows natively", "RuVector cannot run on Windows, so if you need it
|
|
257
|
+
// use WSL" and "X will not run on Y" are claims. They are judged per claim in isAssertion instead.
|
|
258
|
+
const NOT_RUV_ASSERTION = /\?|\b(?:might|may|maybe|perhaps|probably|possibly|potentially|likely|unlikely|apparently|seems?|i\s+think|i\s+believe|i\s+suspect|not\s+sure|unsure|unverified|unconfirmed|not\s+verified|would|could|should|i(?:'ll|’ll)|we(?:'ll|’ll)|will(?!\s+not\b)|plan(?:ned)?\s+to|going\s+to|once)\b/i;
|
|
259
|
+
const FIRST_PERSON = /\b(?:I|we|me|my|our|us)\b/;
|
|
260
|
+
|
|
261
|
+
function isAssertion(s, m) {
|
|
262
|
+
// "what rUv already ships" / "whether ruflo supports X" is a noun clause, not an assertion.
|
|
263
|
+
if (/\b(?:what|whatever|whether)\b[^.,;:!?]{0,40}$/i.test(s.slice(0, m.index))) return false;
|
|
264
|
+
// A condition in the claim's OWN clause makes it hypothetical ("Ruv can't use Brain if every update
|
|
265
|
+
// breaks"); a condition in a later clause does not ("cannot run on Windows, so if you need it…").
|
|
266
|
+
const start = Math.max(0, ...[...s.slice(0, m.index).matchAll(/[,;:—–]|\b(?:so|but)\b/g)].map((x) => x.index + x[0].length));
|
|
267
|
+
const after = s.slice(m.index + m[0].length);
|
|
268
|
+
const stop = after.search(/[,;:—–]|\b(?:so|but)\b/);
|
|
269
|
+
if (/\b(?:if|unless)\b/i.test(s.slice(start, m.index + m[0].length + (stop < 0 ? after.length : stop)))) return false;
|
|
270
|
+
// "now" is a recency claim about the product — unless it reports OUR change ("AgentDB now records
|
|
271
|
+
// every turn … with no reliance on me remembering", a measured false alarm).
|
|
272
|
+
if (/\bnow\b/i.test(s) && FIRST_PERSON.test(s)) return false;
|
|
273
|
+
if (/\bnow\s+\w/i.test(m[0]) && !NOW_VERBS.test(m[0])) return false;
|
|
274
|
+
// "returns 10 results", "takes about 3 s": a measurement this turn, not a capability.
|
|
275
|
+
return !/^\s*(?:about\s+|around\s+|only\s+|~|≈)?\d/.test(s.slice(m.index + m[0].length));
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// "## What AgentDB actually is" followed by "It's a SQLite database file …": the heading names the subject
|
|
279
|
+
// and the section's sentence-initial "It" refers to it (4.4.1, a measured known miss).
|
|
280
|
+
const DEFINING_HEADING = new RegExp(`^\\s*#{1,6}\\s+(?:what|how)\\s+(?:the\\s+)?(${RUV_PRODUCT})(?![\\w-])\\s+(?:(?:actually|really|even)\\s+)?(?:is|are|does|works?)\\b`, 'i'); // the product ITSELF, not "the ruflo fix"
|
|
281
|
+
function bindHeadingPronouns(message) {
|
|
282
|
+
let subject = null;
|
|
283
|
+
return String(message || '').split('\n').map((line) => {
|
|
284
|
+
if (/^\s*#{1,6}\s/.test(line)) { subject = DEFINING_HEADING.exec(line)?.[1] ?? null; return line; }
|
|
285
|
+
if (!subject) return line;
|
|
286
|
+
return line.replace(/(^|[.!?]\s+)(?:\*\*)?It(?:'|’)s\b/g, `$1${subject} is`)
|
|
287
|
+
.replace(/(^|[.!?]\s+)(?:\*\*)?It\s+(?=(?:is|has|does|can|stores|runs|uses|keeps)\b)/g, `$1${subject} `);
|
|
288
|
+
}).join('\n');
|
|
289
|
+
}
|
|
290
|
+
// A table row whose FIRST cell is a product and whose other cells DESCRIBE it ("| AgentDB | Append-only
|
|
291
|
+
// audit trail | Concurrent-write safe KB |") asserts what the product is. Only descriptive cells count:
|
|
292
|
+
// letters only (no digits, hashes, dates or versions) and no status vocabulary — a status table about a
|
|
293
|
+
// product ("| ruflo | 3.41.2 | PASS |", "| ruflo | No version at all … |") never qualifies.
|
|
294
|
+
const STATUS_WORD = /\b(?:no|not|none|n\/a|missing|stale|behind|current|unverified|verified|pass(?:ed)?|fail(?:ed)?|yes|done|version|commit|ok|error|broken|pending|todo|skipped|live|shipped|absent|present|installed|upgraded|restarted|healthy|unhealthy|running|reachable|unreachable|updated|configured|enabled|disabled|failing|passing|green|red|removed|added|fixed|deployed|published|merged|stopped|started|restored|reset|rebuilt)\b/i;
|
|
295
|
+
function productRowClaims(message) {
|
|
296
|
+
const claims = [];
|
|
297
|
+
for (const line of String(message || '').split('\n')) {
|
|
298
|
+
if (!/^\s*\|/.test(line) || /^\s*\|[\s:|-]+\|\s*$/.test(line)) continue;
|
|
299
|
+
const cells = line.split('|').slice(1, -1).map((c) => c.replace(/\*\*|__|`/g, '').trim());
|
|
300
|
+
const first = new RegExp(`^(${RUV_PRODUCT})$`, 'i').exec(cells[0] || '');
|
|
301
|
+
if (!first || cells.length < 2) continue;
|
|
302
|
+
const described = cells.slice(1).filter((c) => /^[A-Za-z][A-Za-z +/-]{3,80}$/.test(c) && c.split(/\s+/).length >= 2 && !STATUS_WORD.test(c));
|
|
303
|
+
if (described.length === cells.length - 1) claims.push({ text: line.trim(), match: `${first[1]} | ${described[0]}`, subject: first[1].toLowerCase() });
|
|
304
|
+
}
|
|
305
|
+
return claims;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** The final answer's sentences (and table cells) that assert what a rUv product does. Never throws. */
|
|
309
|
+
export function ruvCapabilityClaims(rawMessage) {
|
|
310
|
+
const rows = productRowClaims(String(rawMessage || '').replace(/```[\s\S]*?```/g, '\n'));
|
|
311
|
+
const text = bindHeadingPronouns(rawMessage)
|
|
312
|
+
.replace(/```[\s\S]*?```/g, '\n') // command output and code are not prose claims
|
|
313
|
+
.replace(/^\s*>.*$/gm, ' ') // quoted material
|
|
314
|
+
.replace(/"[^"\n]{0,300}"|“[^”\n]{0,300}”/g, '\n') // quoted speech: someone else's words (a break: it may carry the full stop)
|
|
315
|
+
.replace(/`([^`\n]{1,80})`/g, '$1') // inline code keeps its identifier
|
|
316
|
+
.replace(/^\s*#{1,6}\s.*$/gm, ' ') // headings name a topic
|
|
317
|
+
.replace(/\*\*|__/g, '')
|
|
318
|
+
.replace(/\|/g, '\n'); // table cells judged one by one
|
|
319
|
+
const out = rows.slice(0, 8);
|
|
320
|
+
for (const raw of text.split(/(?<=[.!?;])\s+|\n+|\s+[—–]\s+/)) {
|
|
321
|
+
if (out.length >= 8) break;
|
|
322
|
+
const s = raw.replace(/^[\s\-*•#>\d.)]+/, '').trim();
|
|
323
|
+
if (!s || s.length > 400 || NOT_RUV_ASSERTION.test(s)) continue;
|
|
324
|
+
const m = [...s.matchAll(RUV_CLAIM), ...s.matchAll(RUV_DOC_CLAIM), ...s.matchAll(RUV_COREF_CLAIM), ...s.matchAll(RUV_DEFINE_CLAIM),
|
|
325
|
+
...s.matchAll(RUV_COPULA_OWN_CLAIM), ...s.matchAll(RUV_PASSIVE_CLAIM)]
|
|
326
|
+
.find((x) => isAssertion(s, x));
|
|
327
|
+
if (m) out.push({ text: s, match: m[0], subject: (m[1] || m[0]).trim().split(/\s+/)[0].replace(/(?:'|’)s$/, '').toLowerCase() });
|
|
328
|
+
if (out.length >= 8) break;
|
|
329
|
+
}
|
|
330
|
+
return out;
|
|
331
|
+
}
|
|
332
|
+
|
|
192
333
|
// ── Stop-time audit ────────────────────────────────────────────────────────────────────────────────
|
|
193
334
|
const HEDGE = /\?|\b(?:might|may|maybe|perhaps|probably|possibly|likely|unlikely|apparently|seems?|i\s+think|i\s+believe|i\s+suspect|i\s+(?:could|did)\s*n[o']?t\s+(?:confirm|verify|check)|not\s+sure|unsure|unverified|unconfirmed|not\s+verified|assum(?:e|ed|ing)|if|unless|whether|would|should|once|when)\b/i;
|
|
194
335
|
const NEGATIVE = /\b(?:no\s+[\w-]+\s+(?:can|could|will)|cannot|can(?:'|’)t|can\s+not|is\s*n(?:'|’)?t\s+(?:possible|supported|able)|not\s+possible|impossible|does\s*n(?:'|’)?t\s+(?:support|allow|expose|provide|exist|let|offer)|does\s+not\s+(?:support|allow|expose|provide|exist|let|offer)|there(?:'|’)?s\s+no\s+(?:way|api|hook|setting|option)|there\s+is\s+no\s+(?:way|api|hook|setting|option)|has\s+no\s+(?:way|api|hook|setting|option)|only\s+(?:supports?|allows?|exposes?))\b/i;
|
|
@@ -72,6 +72,15 @@
|
|
|
72
72
|
* At most ONE correction per stop episode: both checks compose into one message, and
|
|
73
73
|
* stop_hook_active silences the continued stop.
|
|
74
74
|
*
|
|
75
|
+
* 4.4.0 — GATE 1 FIRES ON A CLAIM, NOT ON A TOPIC. Gate 1 arms on any prompt that names the rUv
|
|
76
|
+
* stack, and in this repository that is nearly every prompt. Replayed through this decide() on 183
|
|
77
|
+
* real deliveries of the correction (the owner's sessions, 2026-09-12..10-01): 172 were on answers
|
|
78
|
+
* that asserted nothing about a rUv tool (release status, git/CI, disk/backup, memory writes). Now a
|
|
79
|
+
* search is demanded only when the final answer asserts a rUv capability (ruvCapabilityClaims);
|
|
80
|
+
* measured on a held-out set of 70 real Stop points: false positives 68/68 -> 0/68, and 2 borderline
|
|
81
|
+
* claims (copula, parenthetical) are missed — tests/unit/grounding-turn-false-alarm.test.mjs.
|
|
82
|
+
* A LONG turn (the transcript tail cannot see its start) falls back to the stamps, never to a pass.
|
|
83
|
+
*
|
|
75
84
|
* FAILS OPEN ALWAYS. Exit 0 unconditionally — a gate that breaks a turn's completion because a
|
|
76
85
|
* cache directory was unreadable would be disabled within a day.
|
|
77
86
|
*/
|
|
@@ -84,7 +93,7 @@ import { markerPathFor, readMarker } from './grounding-turn-mark.mjs';
|
|
|
84
93
|
import { readSettledTranscript } from './turn-outcome-capture.mjs';
|
|
85
94
|
import {
|
|
86
95
|
architectureShadow, auditAssertions, correctionText, describeSources, loadVocabulary, logShadow,
|
|
87
|
-
relayShadow, searchedThisTurn, turnSources,
|
|
96
|
+
relayShadow, ruvCapabilityClaims, searchedThisTurn, turnSources,
|
|
88
97
|
} from './grounding-turn-evidence.mjs';
|
|
89
98
|
|
|
90
99
|
const HOME = os.homedir();
|
|
@@ -145,6 +154,11 @@ export function decide({ hookInput, marker, markerMs, env = process.env, read =
|
|
|
145
154
|
if (host === 'claude' && typeof tp === 'string' && /\.jsonl$/i.test(tp)) {
|
|
146
155
|
try { turn = turnSources(read(tp, { maxMs: 0 })); } catch { turn = null; }
|
|
147
156
|
}
|
|
157
|
+
// The transcript is read as a bounded TAIL. When the turn's opening prompt is not inside it
|
|
158
|
+
// (a long turn), the tail is a suffix of the turn and cannot prove a search did NOT happen
|
|
159
|
+
// earlier — so it is not evidence either way. Fall back to the stamp evidence (the same path
|
|
160
|
+
// Codex uses), never to a silent pass: `return null` here let every long turn skip the gate.
|
|
161
|
+
if (turn && !turn.boundaryFound) turn = null;
|
|
148
162
|
const sources = turn ? turn.sources : null;
|
|
149
163
|
const message = String(hookInput.last_assistant_message || '');
|
|
150
164
|
|
|
@@ -158,15 +172,20 @@ export function decide({ hookInput, marker, markerMs, env = process.env, read =
|
|
|
158
172
|
for (const row of shadow) logShadow({ ...row, at: new Date().toISOString(), session: hookInput.session_id, host });
|
|
159
173
|
}
|
|
160
174
|
|
|
161
|
-
|
|
162
|
-
|
|
175
|
+
// Gate 1 demands a search only when the answer ASSERTS what a rUv product does (the directive's
|
|
176
|
+
// own words). A status report, git/CI check or memory write on a rUv-named repo asserts nothing.
|
|
177
|
+
const ruvClaims = marker.gate1 === false ? [] : ruvCapabilityClaims(message);
|
|
178
|
+
const grounded = !ruvClaims.length
|
|
179
|
+
|| (sources ? searchedThisTurn(sources) : wasGroundedSince(markerMs, newestGroundingStampMs()));
|
|
163
180
|
if (assertion) {
|
|
164
|
-
return correctionText(assertion) + (grounded ? '' : '\nThis turn also
|
|
181
|
+
return correctionText(assertion) + (grounded ? '' : '\nThis turn also asserted what a rUv tool does and no successful search_ruvnet call was recorded: call it with the product term(s).');
|
|
165
182
|
}
|
|
166
183
|
if (grounded) return null;
|
|
167
184
|
return [
|
|
168
|
-
|
|
169
|
-
|
|
185
|
+
`You asserted "${ruvClaims[0].text.slice(0, 200)}" about ${ruvClaims[0].subject}`
|
|
186
|
+
+ (ruvClaims.length > 1 ? ` (and ${ruvClaims.length - 1} more rUv capability claim(s))` : '') + ', and',
|
|
187
|
+
'ground-ruvnet\'s directive requires calling the search_ruvnet MCP tool before asserting what any',
|
|
188
|
+
'RuvNet tool can/cannot do — but no successful',
|
|
170
189
|
sources ? `search_ruvnet call is in this turn's transcript (read this turn: ${describeSources(sources).join('; ')}).`
|
|
171
190
|
: 'search_ruvnet call was recorded this turn (checked against the grounding-stamp evidence).',
|
|
172
191
|
'',
|
|
@@ -344,6 +344,9 @@ function runHook(file, activeVersion = '') {
|
|
|
344
344
|
// invocation if the user flips the switch while the hook is mid-run.
|
|
345
345
|
const env = {
|
|
346
346
|
...process.env,
|
|
347
|
+
// The node running THIS shim, for bash bodies that need node (grounding-stamp.sh's verdict) on a
|
|
348
|
+
// machine where `node` is not on the PATH the host hands its hooks. Additive: older bodies ignore it.
|
|
349
|
+
RUVNET_NODE_BIN: process.execPath,
|
|
347
350
|
...(activeVersion ? { RUVNET_BRAIN_ACTIVE_VERSION: activeVersion } : {}),
|
|
348
351
|
...(BRAIN_OFF && entry.offBehavior === 'partial' ? { RUVNET_BRAIN_OFF: '1' } : {}),
|
|
349
352
|
};
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
set -uo pipefail
|
|
33
33
|
|
|
34
34
|
INPUT=""
|
|
35
|
+
_l="" # set -u: a read that times out before any byte leaves _l unset ("unbound variable" on stderr)
|
|
35
36
|
# BOUNDED READ (2026-07-27, ADR-055 F20): an unqualified `read` never returns on a stdin that is
|
|
36
37
|
# opened and never closed — measured across the mesh, 18 of 37 registered commands sat until the
|
|
37
38
|
# harness killed them. Real Claude Code writes and closes, so this costs no normal turn; that is
|
|
@@ -44,6 +44,7 @@ esac
|
|
|
44
44
|
# payload's delivery time; the size cap is ~30x the largest real payload. The trailing `[ -n "$_l" ]`
|
|
45
45
|
# keeps the final unterminated line, which is what the original `||` clause was for.
|
|
46
46
|
INPUT=""
|
|
47
|
+
_l="" # set -u: a read that times out before any byte leaves _l unset ("unbound variable" on stderr)
|
|
47
48
|
while IFS= read -r -t 2 _l; do
|
|
48
49
|
INPUT+="$_l"
|
|
49
50
|
[ ${#INPUT} -ge 65536 ] && break
|
|
@@ -207,11 +207,13 @@ export function openProgressionReader(dbPath) {
|
|
|
207
207
|
};
|
|
208
208
|
let listStatement;
|
|
209
209
|
let readStatement;
|
|
210
|
+
let allStatement;
|
|
210
211
|
try {
|
|
211
212
|
// Prepared eagerly so an unexpected schema (or a WAL image this process cannot read) is
|
|
212
213
|
// reported as UNAVAILABLE now, before the caller has committed to the fast path.
|
|
213
214
|
listStatement = prepare(`SELECT key FROM memory_entries WHERE ${ACTIVE_ROW_SQL} AND namespace = ? ORDER BY key`);
|
|
214
215
|
readStatement = prepare(`SELECT content FROM memory_entries WHERE ${ACTIVE_ROW_SQL} AND namespace = ? AND key = ?`);
|
|
216
|
+
allStatement = prepare(`SELECT namespace, key, content FROM memory_entries WHERE ${ACTIVE_ROW_SQL} ORDER BY namespace, key`);
|
|
215
217
|
} catch (error) {
|
|
216
218
|
try { database.close(); } catch { /* the open failure is the news */ }
|
|
217
219
|
throw error;
|
|
@@ -252,6 +254,14 @@ export function openProgressionReader(dbPath) {
|
|
|
252
254
|
return content;
|
|
253
255
|
},
|
|
254
256
|
|
|
257
|
+
/** Every active (namespace, key, content) row — bounded, never truncated (past the bound is an error). */
|
|
258
|
+
allRows({ maxRows = 100_000 } = {}) {
|
|
259
|
+
const rows = query(allStatement, []);
|
|
260
|
+
if (rows.length > maxRows) throw new ProgressionReaderUnavailable(`store holds more than ${maxRows} active rows`);
|
|
261
|
+
return rows.map((row) => ({ namespace: String(row.namespace ?? ''), key: String(row.key ?? ''),
|
|
262
|
+
content: typeof row.content === 'string' ? row.content : null }));
|
|
263
|
+
},
|
|
264
|
+
|
|
255
265
|
close() {
|
|
256
266
|
try { database.close(); } catch { /* closing a spent read handle is never news */ }
|
|
257
267
|
},
|
|
@@ -35,7 +35,19 @@ function git(cwd, args) {
|
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
/**
|
|
38
|
-
* The
|
|
38
|
+
* The Brain's OWN state that may sit inside the customer's project: the AgentDB store and its sidecars
|
|
39
|
+
* (`.swarm/` — memory.db, -wal/-shm, the outbox and queue files) and what ruflo leaves in a cwd
|
|
40
|
+
* (`.claude-flow/`, `ruvector.db`). It is not the customer's source. Every capture writes `.swarm/`, so
|
|
41
|
+
* counting it made every later boundary look like a changed tree: the no-op path was never taken and
|
|
42
|
+
* memory.db grew at every boundary. Excluded by pathspec, so it does not depend on the user's gitignore
|
|
43
|
+
* (it only looked fine on machines whose global gitignore lists `.swarm/`).
|
|
44
|
+
*/
|
|
45
|
+
export const BRAIN_STATE_PATHSPEC_EXCLUDES = Object.freeze([':(exclude).swarm', ':(exclude).claude-flow', ':(exclude)ruvector.db']);
|
|
46
|
+
const SOURCE_PATHSPEC = ['--', '.', ...BRAIN_STATE_PATHSPEC_EXCLUDES];
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The source identity, with each digest defined EXACTLY (each one EXCLUDING the Brain's own state,
|
|
50
|
+
* BRAIN_STATE_PATHSPEC_EXCLUDES above):
|
|
39
51
|
* trackedDigest sha256 of `git ls-files -s` (mode + blob oid + stage + path for every tracked file)
|
|
40
52
|
* untrackedDigest sha256 of one `<content-sha256> <path>` line per untracked, non-ignored file —
|
|
41
53
|
* the NAMES alone would call two different working trees identical
|
|
@@ -63,9 +75,9 @@ export function readSourceIdentity({ checkoutRoot, kind = 'git' } = {}) {
|
|
|
63
75
|
|
|
64
76
|
const headBefore = git(checkoutRoot, ['rev-parse', 'HEAD'])?.trim() || 'unborn';
|
|
65
77
|
const branch = git(checkoutRoot, ['rev-parse', '--abbrev-ref', 'HEAD'])?.trim() || 'detached';
|
|
66
|
-
const tracked = git(checkoutRoot, ['ls-files', '-s']);
|
|
67
|
-
const untrackedList = git(checkoutRoot, ['ls-files', '--others', '--exclude-standard']);
|
|
68
|
-
const diff = git(checkoutRoot, ['diff', 'HEAD']);
|
|
78
|
+
const tracked = git(checkoutRoot, ['ls-files', '-s', ...SOURCE_PATHSPEC]);
|
|
79
|
+
const untrackedList = git(checkoutRoot, ['ls-files', '--others', '--exclude-standard', ...SOURCE_PATHSPEC]);
|
|
80
|
+
const diff = git(checkoutRoot, ['diff', 'HEAD', ...SOURCE_PATHSPEC]);
|
|
69
81
|
const headAfter = git(checkoutRoot, ['rev-parse', 'HEAD'])?.trim() || 'unborn';
|
|
70
82
|
|
|
71
83
|
const untrackedLines = String(untrackedList ?? '').split('\n').filter(Boolean).map((relative) => {
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import crypto from 'node:crypto';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
2
5
|
import path from 'node:path';
|
|
3
6
|
import { ProgressionOutbox } from './project-progression-outbox.mjs';
|
|
4
7
|
import {
|
|
@@ -132,6 +135,190 @@ function sortRejected(rows) {
|
|
|
132
135
|
|| left.reasons.join('|').localeCompare(right.reasons.join('|')));
|
|
133
136
|
}
|
|
134
137
|
|
|
138
|
+
/**
|
|
139
|
+
* The working directory ruflo runs in. ruflo writes into its cwd on every invocation even when --path
|
|
140
|
+
* names the store (measured 2026-10-01, ruflo 3.49.0): `.claude/`, `.claude-flow/`, `ruvector.db`, and
|
|
141
|
+
* `<cwd>/.swarm/` holding hnsw.metadata.json — the stored snapshot CONTENT — which it also LOADS when it
|
|
142
|
+
* finds one there (ruflo/v3/@claude-flow/cli/src/memory/memory-initializer.ts: getMemoryRoot() = cwd).
|
|
143
|
+
* So the cwd must be:
|
|
144
|
+
* - never the project tree (it changed the customer's working tree and broke no-op capture detection),
|
|
145
|
+
* never inside `.swarm` (nested `.swarm/.swarm`);
|
|
146
|
+
* - PER PROJECT (one shared dir would pool every project's snapshot text and let one project load
|
|
147
|
+
* another's metadata);
|
|
148
|
+
* - private and ours: under the Brain's own home (never a shared /tmp name another user could pre-create
|
|
149
|
+
* or symlink), every directory we create checked to be a real directory, owned by us, mode 0700.
|
|
150
|
+
* Every ruflo call carries --path, so the store it reads and writes is unaffected by the cwd.
|
|
151
|
+
*/
|
|
152
|
+
export function rufloScratchRoot(env = process.env) {
|
|
153
|
+
if (env.RUVNET_RUFLO_CWD_ROOT) return path.resolve(env.RUVNET_RUFLO_CWD_ROOT);
|
|
154
|
+
return path.join(env.RUVNET_BRAIN_HOME || path.join(os.homedir(), '.cache', 'ruvnet-brain'), 'ruflo-cwd');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Create-or-verify one private directory: a real directory (not a link), owned by us, mode 0700. */
|
|
158
|
+
export function ensurePrivateDir(dir) {
|
|
159
|
+
try { fs.mkdirSync(dir, { mode: 0o700 }); } catch (error) { if (error?.code !== 'EEXIST') throw error; }
|
|
160
|
+
const stat = fs.lstatSync(dir);
|
|
161
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
162
|
+
throw new Error(`ruflo scratch directory ${dir} is not a real directory (symlink or file); refusing to use it`);
|
|
163
|
+
}
|
|
164
|
+
if (process.platform !== 'win32') {
|
|
165
|
+
if (stat.uid !== process.getuid()) {
|
|
166
|
+
throw new Error(`ruflo scratch directory ${dir} is owned by uid ${stat.uid}, not ${process.getuid()}; refusing to use it`);
|
|
167
|
+
}
|
|
168
|
+
if ((stat.mode & 0o777) !== 0o700) {
|
|
169
|
+
fs.chmodSync(dir, 0o700);
|
|
170
|
+
if ((fs.lstatSync(dir).mode & 0o777) !== 0o700) throw new Error(`ruflo scratch directory ${dir} could not be made private (0700)`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return dir;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function rufloCwdFor(storePath, { root = rufloScratchRoot() } = {}) {
|
|
177
|
+
const projectKey = crypto.createHash('sha256').update(path.resolve(storePath)).digest('hex').slice(0, 32);
|
|
178
|
+
fs.mkdirSync(path.dirname(root), { recursive: true });
|
|
179
|
+
ensurePrivateDir(root);
|
|
180
|
+
return ensurePrivateDir(path.join(root, projectKey));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* 4.3.40 ran ruflo with cwd `<project>/.swarm`, so upgraded projects still hold ruflo's cwd artifacts
|
|
185
|
+
* INSIDE the store directory — including `.swarm/.swarm/hnsw.metadata.json`, a copy of snapshot content.
|
|
186
|
+
* Removes exactly those, and only when every path in them is one ruflo is known to write there; anything
|
|
187
|
+
* unexpected (or any symlink) leaves that artifact untouched and is REPORTED. The store itself
|
|
188
|
+
* (memory.db, -wal/-shm, schema.sql), the outbox and queue files are never candidates.
|
|
189
|
+
*
|
|
190
|
+
* The allowlist is measured, not guessed: real ruflo 3.49.0 run with cwd=<dir> and --path elsewhere
|
|
191
|
+
* writes .claude/{memory.db,.proven-config-version,proven-config.json} (init/store),
|
|
192
|
+
* .claude-flow/harness-active-policy.json and .swarm/{hnsw.index,hnsw.metadata.json} and ruvector.db
|
|
193
|
+
* (store), .claude-flow/policy/state.json (retrieve). The owner's real 4.3.40 projects also hold
|
|
194
|
+
* .swarm/.swarm/agentdb-memory.db(-wal,-shm): ruflo's AgentDB bridge opens <cwd>/.swarm/agentdb-memory.db
|
|
195
|
+
* (ruflo/v3/@claude-flow/cli/src/memory/memory-bridge.ts getAgentDbPath), and the native bindings drop
|
|
196
|
+
* ruvector.db into whatever cwd they run in (ruflo/scripts/smoke-memory-no-stray-db.mjs, ADR-125 Phase 7).
|
|
197
|
+
*/
|
|
198
|
+
const SQLITE = (name) => [name, `${name}-wal`, `${name}-shm`, `${name}-journal`];
|
|
199
|
+
const LEGACY_CWD_ARTIFACTS = Object.freeze({
|
|
200
|
+
'.swarm': new Set(['hnsw.index', 'hnsw.metadata.json', ...SQLITE('agentdb-memory.db')]),
|
|
201
|
+
'.claude': new Set(['.proven-config-version', 'proven-config.json', ...SQLITE('memory.db')]),
|
|
202
|
+
'.claude-flow': new Set(['harness-active-policy.json', 'policy/', 'policy/state.json']),
|
|
203
|
+
'ruvector.db': null, // a regular file
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
/** Every path under `dir`, relative, directories with a trailing slash; symlinks reported, never followed. */
|
|
207
|
+
function relativeEntries(dir) {
|
|
208
|
+
const out = [];
|
|
209
|
+
const walk = (current, prefix) => {
|
|
210
|
+
for (const name of fs.readdirSync(current)) {
|
|
211
|
+
const full = path.join(current, name);
|
|
212
|
+
const stat = fs.lstatSync(full);
|
|
213
|
+
const rel = prefix + name;
|
|
214
|
+
if (stat.isSymbolicLink()) out.push({ rel, link: true });
|
|
215
|
+
else if (stat.isDirectory()) { out.push({ rel: `${rel}/` }); walk(full, `${rel}/`); }
|
|
216
|
+
else out.push({ rel, file: stat.isFile() });
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
walk(dir, '');
|
|
220
|
+
return out;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* With `dryRun`, reports what WOULD be removed and touches nothing (--doctor). Returns
|
|
225
|
+
* { removed: [paths], refused: [{ path, reason }] }; a refusal must be shown to the user, never dropped.
|
|
226
|
+
*/
|
|
227
|
+
const IN_USE_MS = 10 * 60_000;
|
|
228
|
+
// The files that carry DATA: the database, its WAL and its rollback journal. Never -shm: it is the WAL
|
|
229
|
+
// index, and every reader — including this proof's own read-only open — rewrites it. Counting it made
|
|
230
|
+
// every real 4.3.40 store look "changed while it was being checked" (and "in use" on the next run).
|
|
231
|
+
const dataFiles = (dbPath) => {
|
|
232
|
+
const base = path.basename(dbPath);
|
|
233
|
+
return [base, `${base}-wal`, `${base}-journal`];
|
|
234
|
+
};
|
|
235
|
+
const sqliteFingerprint = (dbPath) => dataFiles(dbPath).map((name) => {
|
|
236
|
+
try { const st = fs.statSync(path.join(path.dirname(dbPath), name)); return `${name}:${st.size}:${st.mtimeMs}`; }
|
|
237
|
+
catch { return `${name}:absent`; }
|
|
238
|
+
});
|
|
239
|
+
const sameFiles = (a, b) => a.join('|') === b.join('|');
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Is every active (namespace, key, content) row of a stray nested AgentDB present, byte-identical, in
|
|
243
|
+
* the canonical store? Read-only through the progression reader (node:sqlite, readOnly — no write, no
|
|
244
|
+
* checkpoint). Anything it cannot prove — recently written (in use), unreadable, a row missing or
|
|
245
|
+
* different — keeps the file and says why.
|
|
246
|
+
*/
|
|
247
|
+
export function proveMirrored(nestedDb, canonicalDb, { now = Date.now(), maxRows = 100_000 } = {}) {
|
|
248
|
+
const fingerprint = sqliteFingerprint(nestedDb);
|
|
249
|
+
const newest = Math.max(...dataFiles(nestedDb).map((name) => {
|
|
250
|
+
try { return fs.statSync(path.join(path.dirname(nestedDb), name)).mtimeMs; } catch { return 0; }
|
|
251
|
+
}));
|
|
252
|
+
if (now - newest < IN_USE_MS) return { ok: false, reason: `kept: agentdb-memory.db was written ${Math.round((now - newest) / 1000)}s ago (in use)` };
|
|
253
|
+
const nested = withProgressionReader(nestedDb, (reader) => reader.allRows({ maxRows }));
|
|
254
|
+
if (!nested.ok) return { ok: false, reason: `kept: agentdb-memory.db could not be read to prove it is mirrored (${nested.reason})` };
|
|
255
|
+
const canonical = withProgressionReader(canonicalDb, (reader) => reader.allRows({ maxRows }));
|
|
256
|
+
if (!canonical.ok) return { ok: false, reason: `kept: memory.db could not be read to prove agentdb-memory.db is mirrored (${canonical.reason})` };
|
|
257
|
+
const have = new Map(canonical.value.map((row) => [`${row.namespace}\u0000${row.key}`, row.content]));
|
|
258
|
+
const missing = nested.value.filter((row) => row.content === null || have.get(`${row.namespace}\u0000${row.key}`) !== row.content);
|
|
259
|
+
if (missing.length) return { ok: false, reason: `kept: ${missing.length} of ${nested.value.length} rows not in memory.db`, missing: missing.length };
|
|
260
|
+
return { ok: true, rows: nested.value.length, fingerprint };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export function cleanLegacyRufloDebris(storeDir, { dryRun = false, now = Date.now() } = {}) {
|
|
264
|
+
const removed = [];
|
|
265
|
+
const refused = [];
|
|
266
|
+
for (const [name, allowed] of Object.entries(LEGACY_CWD_ARTIFACTS)) {
|
|
267
|
+
const entry = path.join(storeDir, name);
|
|
268
|
+
let stat;
|
|
269
|
+
try { stat = fs.lstatSync(entry); } catch { continue; } // absent: nothing to do
|
|
270
|
+
if (stat.isSymbolicLink()) { refused.push({ path: entry, reason: 'symbolic link' }); continue; }
|
|
271
|
+
if (allowed === null) {
|
|
272
|
+
if (!stat.isFile()) { refused.push({ path: entry, reason: 'not a regular file' }); continue; }
|
|
273
|
+
} else {
|
|
274
|
+
if (!stat.isDirectory()) { refused.push({ path: entry, reason: 'not a directory' }); continue; }
|
|
275
|
+
const unknown = relativeEntries(entry).filter((item) => item.link || item.file === false || !allowed.has(item.rel))
|
|
276
|
+
.map((item) => (item.link ? `${item.rel} (symbolic link)` : item.rel));
|
|
277
|
+
if (unknown.length) { refused.push({ path: entry, reason: `unexpected entries: ${unknown.join(', ')}` }); continue; }
|
|
278
|
+
}
|
|
279
|
+
// A nested AgentDB is a real store (owner's projects: 1-41 rows, still written by open 4.3.40
|
|
280
|
+
// sessions). It goes only when every row is PROVEN present, identically, in the canonical memory.db.
|
|
281
|
+
const nestedDb = path.join(entry, 'agentdb-memory.db');
|
|
282
|
+
const mirror = name === '.swarm' && fs.existsSync(nestedDb) ? proveMirrored(nestedDb, path.join(storeDir, 'memory.db'), { now }) : null;
|
|
283
|
+
if (mirror && !mirror.ok) { refused.push({ path: entry, reason: mirror.reason, kept: true }); continue; }
|
|
284
|
+
if (!dryRun) {
|
|
285
|
+
// The proof is only valid for the bytes it read: anything written since means the file is in use.
|
|
286
|
+
if (mirror && !sameFiles(mirror.fingerprint, sqliteFingerprint(nestedDb))) {
|
|
287
|
+
refused.push({ path: entry, reason: 'kept: agentdb-memory.db changed while it was being checked (in use)', kept: true });
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
fs.rmSync(entry, { recursive: true, force: true });
|
|
291
|
+
}
|
|
292
|
+
removed.push(entry);
|
|
293
|
+
}
|
|
294
|
+
return { removed, refused };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// What ruflo leaves in a cwd (measured, ruflo 3.49.0): `.swarm/` (hnsw.index, hnsw.metadata.json — a
|
|
298
|
+
// copy of every stored value), `.claude/`, `.claude-flow/`, `ruvector.db`. None of it is read back by the
|
|
299
|
+
// product: every call carries --path, and the store of record is that file.
|
|
300
|
+
const RUFLO_CWD_ARTIFACTS = Object.freeze(['.swarm', '.claude', '.claude-flow', 'ruvector.db']);
|
|
301
|
+
const STALE_RUN_MS = 3_600_000;
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* One ruflo invocation's working directory: a fresh private `run-*` directory inside the project's
|
|
305
|
+
* scratch dir, removed again by the caller after the call. ruflo copies every snapshot it touches into
|
|
306
|
+
* `<cwd>/.swarm/hnsw.metadata.json`; with one cwd per call that copy never accumulates (4.4.0 kept one
|
|
307
|
+
* per project that grew without bound and outlived the project). Also clears what 4.4.0 left directly
|
|
308
|
+
* in the project scratch dir and any `run-*` older than an hour (a call killed before its cleanup).
|
|
309
|
+
*/
|
|
310
|
+
export function rufloRunDir(storePath, { root = rufloScratchRoot(), now = Date.now() } = {}) {
|
|
311
|
+
const projectScratch = rufloCwdFor(storePath, { root });
|
|
312
|
+
for (const name of fs.readdirSync(projectScratch)) {
|
|
313
|
+
const entry = path.join(projectScratch, name);
|
|
314
|
+
let stat;
|
|
315
|
+
try { stat = fs.lstatSync(entry); } catch { continue; }
|
|
316
|
+
const stale = name.startsWith('run-') && stat.isDirectory() && now - stat.mtimeMs > STALE_RUN_MS;
|
|
317
|
+
if (RUFLO_CWD_ARTIFACTS.includes(name) || stale) fs.rmSync(entry, { recursive: true, force: true });
|
|
318
|
+
}
|
|
319
|
+
return fs.mkdtempSync(path.join(projectScratch, 'run-'));
|
|
320
|
+
}
|
|
321
|
+
|
|
135
322
|
export class ProjectProgressionStore {
|
|
136
323
|
constructor({
|
|
137
324
|
projectDir,
|
|
@@ -146,6 +333,9 @@ export class ProjectProgressionStore {
|
|
|
146
333
|
} = {}) {
|
|
147
334
|
if (!rufloBinary) throw new Error(RUFLO_MISSING);
|
|
148
335
|
this.resolution = resolveProjectStore({ projectDir, requestedStorePath });
|
|
336
|
+
// Best effort: a cleanup that cannot run must never stop a capture or a restore.
|
|
337
|
+
try { this.legacyDebris = cleanLegacyRufloDebris(path.dirname(this.resolution.canonicalAgentDbPath)); }
|
|
338
|
+
catch (error) { this.legacyDebris = { removed: [], refused: [{ path: null, reason: error.message }] }; }
|
|
149
339
|
this.rufloBinary = rufloBinary;
|
|
150
340
|
this.runner = runner;
|
|
151
341
|
this.clock = clock;
|
|
@@ -164,12 +354,17 @@ export class ProjectProgressionStore {
|
|
|
164
354
|
}
|
|
165
355
|
|
|
166
356
|
run(args) {
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
357
|
+
const cwd = rufloRunDir(this.resolution.canonicalAgentDbPath);
|
|
358
|
+
try {
|
|
359
|
+
return this.runner(this.rufloBinary, args, {
|
|
360
|
+
cwd,
|
|
361
|
+
encoding: 'utf8',
|
|
362
|
+
timeout: 120_000,
|
|
363
|
+
env: { ...process.env, RUFLO_DAEMON_AUTOSTART: '0' },
|
|
364
|
+
});
|
|
365
|
+
} finally {
|
|
366
|
+
fs.rmSync(cwd, { recursive: true, force: true });
|
|
367
|
+
}
|
|
173
368
|
}
|
|
174
369
|
|
|
175
370
|
validateSnapshot(snapshot) {
|
|
@@ -41,6 +41,7 @@ INPUT=""
|
|
|
41
41
|
# exactly why a hook that CAN hang forever survives unnoticed. -t bounds the wait, and the string
|
|
42
42
|
# is truncated AFTER the loop because a hook payload is one line with no newline, so `read` hands
|
|
43
43
|
# the whole thing back at once and a per-iteration cap never fires.
|
|
44
|
+
_l="" # set -u: a read that times out before any byte leaves _l unset ("unbound variable" on stderr)
|
|
44
45
|
while IFS= read -r -t 2 _l; do
|
|
45
46
|
INPUT+="$_l"
|
|
46
47
|
[ ${#INPUT} -ge 65536 ] && break
|
|
@@ -44,6 +44,7 @@ INPUT=""
|
|
|
44
44
|
# exactly why a hook that CAN hang forever survives unnoticed. -t bounds the wait, and the string
|
|
45
45
|
# is truncated AFTER the loop because a hook payload is one line with no newline, so `read` hands
|
|
46
46
|
# the whole thing back at once and a per-iteration cap never fires.
|
|
47
|
+
_line="" # set -u: a read that times out before any byte leaves _line unset ("unbound variable" on stderr)
|
|
47
48
|
while IFS= read -r -t 2 _line; do
|
|
48
49
|
INPUT+="$_line"
|
|
49
50
|
[ ${#INPUT} -ge 65536 ] && break
|