ruvnet-brain 4.5.9 → 4.5.10

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.
Files changed (40) hide show
  1. package/README.md +1 -1
  2. package/bin/install.mjs +47 -92
  3. package/bin/nightly-refresh.mjs +20 -34
  4. package/kb/download-retry.mjs +97 -0
  5. package/kb/forge-update.mjs +12 -10
  6. package/kb/verify-citation.mjs +10 -13
  7. package/package.json +2 -1
  8. package/plugin/.claude-plugin/plugin.json +1 -1
  9. package/plugin/.codex-plugin/plugin.json +1 -1
  10. package/plugin/scripts/automatic-update.mjs +125 -0
  11. package/plugin/scripts/completion-claim-evidence.mjs +9 -4
  12. package/plugin/scripts/continuation-gate.mjs +34 -10
  13. package/plugin/scripts/continuation-objective.mjs +45 -10
  14. package/plugin/scripts/continuity-journal.mjs +11 -11
  15. package/plugin/scripts/ground-ruvnet.sh +3 -0
  16. package/plugin/scripts/grounding-code-projection.mjs +6 -1
  17. package/plugin/scripts/grounding-turn-evidence.mjs +2 -2
  18. package/plugin/scripts/grounding-turn-gate.mjs +8 -14
  19. package/plugin/scripts/grounding-turn-mark.mjs +106 -20
  20. package/plugin/scripts/hijack-ruvnet.sh +4 -4
  21. package/plugin/scripts/hook-input.mjs +1 -1
  22. package/plugin/scripts/host-update.mjs +16 -15
  23. package/plugin/scripts/lesson-presentation.mjs +2 -1
  24. package/plugin/scripts/nightly-scheduler.mjs +34 -7
  25. package/plugin/scripts/project-progression-hook.mjs +22 -10
  26. package/plugin/scripts/project-progression-producer.mjs +11 -1
  27. package/plugin/scripts/project-progression-store.mjs +3 -0
  28. package/plugin/scripts/project-transition-hook.mjs +7 -1
  29. package/plugin/scripts/ruvnet-gate1-pattern.mjs +33 -0
  30. package/plugin/scripts/session-snapshot-hook.mjs +4 -3
  31. package/plugin/scripts/session-start-core.mjs +3 -1
  32. package/plugin/scripts/turn-capture-privacy.mjs +202 -0
  33. package/plugin/scripts/turn-outcome-capture.mjs +28 -13
  34. package/plugin/scripts/user-settings.mjs +23 -1
  35. package/scripts/brain-grade-groundtruth.mjs +1 -0
  36. package/scripts/brain-score.mjs +11 -11
  37. package/scripts/issue-watch.mjs +21 -10
  38. package/scripts/model-weekly-cycle.mjs +45 -9
  39. package/scripts/record-lesson.mjs +13 -7
  40. package/scripts/release-qualification-contract.mjs +31 -1
@@ -105,6 +105,7 @@ export async function runSessionStart({
105
105
  || s.startsWith(KNOWLEDGE_LINE_PREFIX)
106
106
  || s.startsWith(FOOTPRINT_LINE_PREFIX)
107
107
  || s.startsWith('[RuvNet Brain — OPEN ISSUES')
108
+ || s.startsWith('[RuvNet Brain — TURN CAPTURE]')
108
109
  || /\bopen issue\(s\)/i.test(s)
109
110
  || /^\[RuvNet Brain — external signal/i.test(s)
110
111
  || (s.startsWith('Workflow ') && !/\b(Say it plainly|offer to look|ask only|run:|invoke)\b/i.test(s))
@@ -191,7 +192,8 @@ export async function runSessionStart({
191
192
  }
192
193
  const restoreProof = { name: 'restore', ms: Date.now() - restoreStart, failed: restoreFailed };
193
194
  // Turn health remains visible even when the separate progression restore fails.
194
- const turnStatus = turnRecordingStatus({ projectDir: cwd, env, home });
195
+ const turnStatus = turnRecordingStatus({ projectDir: cwd, env, home, noticeOnFirstUse: true });
196
+ if (turnStatus.notice) emit(`[RuvNet Brain — TURN CAPTURE] ${turnStatus.notice}`);
195
197
  if (turnStatus.state === 'warn') emit(`[RuvNet Brain — TURN RECORDING] ${turnStatus.line}`);
196
198
  // Opt-in, matching the pre-existing `trace()` convention below: several other tests assert
197
199
  // SessionStart's stderr is EMPTY in the clean case (hook-battery.test.mjs, hook-hardening.test.mjs
@@ -0,0 +1,202 @@
1
+ // Content privacy only: canonical project/store/source bindings are never rewritten here.
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import crypto from 'node:crypto';
5
+ import { redactText } from './continuity-events.mjs';
6
+ import { normalizeHostEvent } from './hook-input.mjs';
7
+
8
+ const normalized = (value) => String(value).replace(/\\/g, '/').replace(/\/{2,}/g, '/')
9
+ .replace(/\/$/, (match, offset, source) => source === '/' || /^[A-Za-z]:\/$/.test(source) ? match : '');
10
+ const escaped = (value) => value.replace(/[.+^${}()|[\]\\]/g, '\\$&');
11
+ const separator = '[/\\\\]';
12
+
13
+ /** Rooted prefixes or simple * / ** globs; no expansion or unbounded recipes. */
14
+ export function contentPathExcludes(value) {
15
+ if (value === undefined) return [];
16
+ if (!Array.isArray(value) || value.length > 16) throw new Error('contentPathExcludes must be an array of at most 16 rooted prefixes or globs');
17
+ return value.map((raw) => {
18
+ if (typeof raw !== 'string' || !raw || raw.length > 1024 || /[\x00-\x1f?\[\]{}!]/.test(raw)) throw new Error('invalid content path exclusion');
19
+ const pattern = normalized(raw);
20
+ if (!pattern.startsWith('/') && !/^[A-Za-z]:\//.test(pattern)) throw new Error('content path exclusions must be rooted');
21
+ const parts = pattern.split('/');
22
+ if (parts.length > 32 || parts.some((part) => part === '.' || part === '..'
23
+ || (part !== '**' && (part.match(/\*/g) || []).length > 1))
24
+ || parts.filter((part) => part === '**').length > 1) throw new Error('invalid or overly complex content path exclusion');
25
+ return pattern;
26
+ });
27
+ }
28
+
29
+ function globBody(pattern) {
30
+ const parts = pattern.split('/');
31
+ let body = '';
32
+ for (let i = 0; i < parts.length; i += 1) {
33
+ const part = parts[i];
34
+ if (i && parts[i - 1] !== '**') body += separator;
35
+ if (part === '**') {
36
+ if (i === parts.length - 1) {
37
+ if (body.endsWith(separator)) body = body.slice(0, -separator.length);
38
+ body += `(?:${separator}[^\\n]*)?`;
39
+ } else body += `(?:[^/\\\\\\n]+${separator})*`;
40
+ } else body += part.split('*').map(escaped).join('[^/\\\\\\n]*');
41
+ }
42
+ return body;
43
+ }
44
+
45
+ export function pathIsExcluded(file, patterns = [], projectDir) {
46
+ if (!patterns.length || typeof file !== 'string' || !file) return false;
47
+ const rooted = normalized(path.isAbsolute(file) || /^[A-Za-z]:[\\/]/.test(file)
48
+ ? file : `${projectDir || process.cwd()}/${file}`);
49
+ const candidate = path.posix.normalize(rooted);
50
+ const physical = (value) => {
51
+ if (/^[A-Za-z]:\//.test(value) && process.platform !== 'win32') return value;
52
+ let ancestor = value; const suffix = [];
53
+ for (let depth = 0; depth < 128; depth += 1) {
54
+ try { return path.posix.join(normalized(fs.realpathSync.native(ancestor)), ...suffix); }
55
+ catch (error) {
56
+ if (error.code !== 'ENOENT') throw new Error('content exclusion resource resolution unavailable');
57
+ const parent = path.posix.dirname(ancestor);
58
+ if (parent === ancestor) break;
59
+ suffix.unshift(path.posix.basename(ancestor)); ancestor = /^[A-Za-z]:$/.test(parent) ? `${parent}/` : parent;
60
+ }
61
+ }
62
+ throw new Error('content exclusion resource resolution unavailable');
63
+ };
64
+ const candidates = [...new Set([candidate, physical(rooted)])];
65
+ return patterns.some((pattern) => {
66
+ const flags = /^[A-Za-z]:\//.test(pattern) ? 'i' : '';
67
+ return candidates.some((candidate) => {
68
+ if (pattern.includes('*')) {
69
+ const slash = pattern.lastIndexOf('/', pattern.indexOf('*')); const base = pattern.slice(0, slash) || '/';
70
+ return [pattern, `${physical(base).replace(/\/$/, '')}${pattern.slice(slash)}`].some((variant) => new RegExp(`^${globBody(variant)}$`, flags).test(candidate));
71
+ }
72
+ return [...new Set([pattern, physical(pattern)])].some((prefix) => {
73
+ const comparable = flags ? candidate.toLowerCase() : candidate;
74
+ if (flags) prefix = prefix.toLowerCase();
75
+ return comparable === prefix || comparable.startsWith(prefix.endsWith('/') ? prefix : `${prefix}/`);
76
+ });
77
+ });
78
+ });
79
+ }
80
+
81
+ /** Conservative prose masking may remove the remainder of an unquoted path-bearing clause. */
82
+ export function maskExcludedPaths(text, patterns = [], projectDir) {
83
+ let result = String(text || '');
84
+ if (!patterns.length) return result;
85
+ const excluded = (value) => (projectDir || /^[A-Za-z]:[\\/]|^\//.test(value)) && pathIsExcluded(value, patterns, projectDir);
86
+ result = result.replace(/(["'\x60])([^"'\x60\n]+)\1/g, (match, quote, value) =>
87
+ /[\\/]/.test(value) && excluded(value) ? `${quote}[REDACTED:excluded-path]${quote}` : match);
88
+ result = result.replace(/[^\s"'\x60,;<>]+/g, (value) => /[\\/]/.test(value) && excluded(value) ? '[REDACTED:excluded-path]' : value);
89
+ for (const pattern of patterns) {
90
+ const body = globBody(pattern);
91
+ const tail = pattern.includes('*') ? '' : pattern.endsWith('/') ? `[^\\n"'\x60,;<>]*` : `(?:${separator}[^\\n"'\x60,;<>]*)?`;
92
+ const flags = /^[A-Za-z]:\//.test(pattern) ? 'gi' : 'g';
93
+ result = result.replace(new RegExp(`${body}${tail}(?=$|[\\s"'\x60,;<>.])`, flags), '[REDACTED:excluded-path]');
94
+ }
95
+ return result;
96
+ }
97
+
98
+ export function turnReferencesExcludedResource(turn, patterns = [], projectDir) {
99
+ return [...(turn.resources || []), ...(turn.files || [])].some((file) => pathIsExcluded(file, patterns, projectDir))
100
+ || [...(turn.resourceTexts || []), ...(turn.actions || []), turn.finalText || ''].some((text) => maskExcludedPaths(text, patterns, projectDir) !== text);
101
+ }
102
+
103
+ export function privateTurn(turn, patterns, projectDir) {
104
+ const excludedFiles = turn.files.filter((file) => pathIsExcluded(file, patterns, projectDir));
105
+ const redact = (text) => {
106
+ let result = maskExcludedPaths(text, patterns, projectDir);
107
+ for (const file of excludedFiles) {
108
+ for (const reference of [file, path.basename(file.replace(/\\/g, '/'))]) {
109
+ if (reference) result = result.split(reference).join('[REDACTED:excluded-path]');
110
+ }
111
+ }
112
+ return result;
113
+ };
114
+ const excludedResource = turnReferencesExcludedResource(turn, patterns, projectDir);
115
+ return { excludedResource, finalText: excludedResource ? '[REDACTED:excluded-resource-outcome]' : redact(turn.finalText), files: turn.files.filter((file) => !excludedFiles.includes(file)),
116
+ actions: excludedResource ? [] : turn.actions.map(redact) };
117
+ }
118
+
119
+ // Only derived content is filtered. IDs, provenance, project/source and evidence bindings survive.
120
+ function contentValue(value, patterns, projectDir) {
121
+ if (typeof value === 'string') return maskExcludedPaths(value, patterns, projectDir) !== value ? '[REDACTED:excluded-content]' : value;
122
+ if (Array.isArray(value)) return value.map((item) => contentValue(item, patterns, projectDir));
123
+ if (!value || typeof value !== 'object') return value;
124
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key,
125
+ ['id', 'key', 'source', 'provenance', 'authoritative', 'note', 'state', 'status', 'outcome', 'trigger', 'tool'].includes(key)
126
+ ? item : contentValue(item, patterns, projectDir)]));
127
+ }
128
+
129
+ export function privateContinuityEvent(event, patterns = [], projectDir) {
130
+ const files = Array.isArray(event.detail?.files) ? event.detail.files : [];
131
+ const turn = privateTurn({ finalText: event.summary, files, actions: [] }, patterns, projectDir);
132
+ const detail = contentValue(event.detail, patterns, projectDir);
133
+ if (Array.isArray(event.detail?.files)) detail.files = turn.files;
134
+ const privateCommand = ['command', 'description', 'task'].some((field) => typeof event.detail?.[field] === 'string' && maskExcludedPaths(event.detail[field], patterns, projectDir) !== event.detail[field]);
135
+ if (privateCommand) for (const field of ['command', 'description', 'task']) if (typeof detail?.[field] === 'string') detail[field] = '[REDACTED:excluded-resource-detail]';
136
+ return { ...event, summary: privateCommand || turn.excludedResource ? '[REDACTED:excluded-resource-summary]' : turn.finalText, ...(detail ? { detail } : {}) };
137
+ }
138
+
139
+ export function payloadReferencesExcludedResource(payload, patterns = [], projectDir) {
140
+ const normalized = normalizeHostEvent(payload) || {}; const input = normalized.tool_input || {};
141
+ return [input.file_path, input.notebook_path, input.path].some((file) => pathIsExcluded(file, patterns, projectDir))
142
+ || [input.command, input.cmd, input.description, normalized.prompt, normalized.user_prompt].some((value) => typeof value === 'string' && maskExcludedPaths(value, patterns, projectDir) !== value);
143
+ }
144
+
145
+ export function privateTransitionObservation(observation, patterns = [], projectDir, payload = {}) {
146
+ if (!patterns.length) return observation;
147
+ const filtered = { ...observation }; const privateInput = payloadReferencesExcludedResource(payload, patterns, projectDir);
148
+ if (typeof observation.selectedIntent?.text === 'string'
149
+ && (privateInput || maskExcludedPaths(observation.selectedIntent.text, patterns, projectDir) !== observation.selectedIntent.text)) delete filtered.selectedIntent;
150
+ for (const field of ['error', 'signal']) if (typeof observation[field] === 'string') filtered[field] = privateInput || maskExcludedPaths(observation[field], patterns, projectDir) !== observation[field]
151
+ ? `[REDACTED:excluded-resource-${field}]` : observation[field];
152
+ return filtered;
153
+ }
154
+
155
+ export function privateProgressionState(state, patterns = [], projectDir) {
156
+ if (!patterns.length) return state;
157
+ for (const binding of [...(state.plan || []).map((item) => item?.id), state.evidence?.transcript?.path, state.evidence?.workLedger?.file]) {
158
+ if (typeof binding === 'string' && maskExcludedPaths(binding, patterns, projectDir) !== binding) throw new Error('content exclusion conflicts with immutable progression binding');
159
+ }
160
+ const result = { ...state };
161
+ for (const field of ['currentGoal', 'nextAction', 'acceptanceContract', 'completed', 'inProgress', 'blockers', 'failures',
162
+ 'decisions', 'commands', 'untested', 'resumeConflicts']) {
163
+ if (Object.hasOwn(state, field)) result[field] = contentValue(state[field], patterns, projectDir);
164
+ }
165
+ for (const field of ['commands', 'failures']) if (Array.isArray(state[field])) result[field] = state[field].map((observation) => {
166
+ if (!observation || typeof observation !== 'object') return contentValue(observation, patterns, projectDir);
167
+ const filtered = contentValue(observation, patterns, projectDir);
168
+ if (pathIsExcluded(observation.filePath, patterns, projectDir) || String(observation.filePath || '').includes('[REDACTED:excluded-path]')
169
+ || typeof observation.command === 'string' && maskExcludedPaths(observation.command, patterns, projectDir) !== observation.command) {
170
+ if (observation.filePath) filtered.filePath = '[REDACTED:excluded-path]'; if (observation.command) filtered.command = '[REDACTED:excluded-resource-command]';
171
+ for (const output of ['stdout', 'stderr', 'result', 'error', 'signal']) if (Object.hasOwn(observation, output)) filtered[output] = '[REDACTED:excluded-resource-output]';
172
+ }
173
+ return filtered;
174
+ });
175
+ if (Array.isArray(state.observations)) result.observations = state.observations.map((observation) => privateTransitionObservation(observation, patterns, projectDir));
176
+ if (Array.isArray(state.changedFiles)) result.changedFiles = state.changedFiles.filter((file) =>
177
+ typeof file !== 'string' || !pathIsExcluded(file, patterns, projectDir));
178
+ return result;
179
+ }
180
+
181
+ export function captureFailureReason(result, status) {
182
+ if (result.error?.message) return redactText(result.error.message).slice(0, 300);
183
+ const lines = [result.stderr, result.stdout].filter((stream) => stream != null).join('\n').split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
184
+ const terminal = lines.findLast((line) => /(?:\[ERROR\]|\bfatal\b|\bError:|\bSQLITE_[A-Z_]+\b|refus(?:ing|ed))/i.test(line));
185
+ return redactText(terminal || lines.at(-1) || `ruflo exited ${status}`).slice(0, 300);
186
+ }
187
+
188
+ /** One notice per eligible canonical store; this is a cache marker, never a consent writer. */
189
+ export function firstTurnCaptureNotice({ target, brainHome, policyFile, env }) {
190
+ if (target.skipped || String(env.RUVNET_TURN_CAPTURE || '').toLowerCase() === 'off') return null;
191
+ const dir = path.join(brainHome, 'turn-capture', 'notices');
192
+ const file = path.join(dir, crypto.createHash('sha256').update(target.db).digest('hex'));
193
+ try {
194
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
195
+ const stat = fs.lstatSync(dir);
196
+ if (!stat.isDirectory() || stat.isSymbolicLink() || fs.realpathSync.native(dir) !== dir) return null;
197
+ fs.writeFileSync(file, '', { flag: 'wx', mode: 0o600 });
198
+ } catch { return null; }
199
+ return `Turn outcome capture is enabled for this project: redacted assistant outcomes, changed files and command descriptions go to its AgentDB store; never raw user prompts. `
200
+ + `The breadcrumb contains hashes and lengths only. Project/path opt-out and contentPathExcludes are read live from ${policyFile}; `
201
+ + `set projects[${JSON.stringify(target.projectRoot)}] to "off" to opt out. Content exclusions accept rooted prefixes or simple * / ** globs and do not purge historical records.`;
202
+ }
@@ -44,6 +44,7 @@ import { resolveRuflo, rufloInvocation } from './ruflo-bin.mjs';
44
44
  import { redactText, userLevelAgentdbHooks } from './continuity-events.mjs';
45
45
  import { resolveProjectStore } from './project-store-resolver.mjs';
46
46
  import { digest, journalTurn, readJournal, pendingTurnFiles, acknowledgeJournal, appendReceipt, storeData } from './turn-transport-journal.mjs';
47
+ import { contentPathExcludes, pathIsExcluded, privateTurn, maskExcludedPaths, captureFailureReason, firstTurnCaptureNotice } from './turn-capture-privacy.mjs';
47
48
 
48
49
  export const TURN_NAMESPACE = 'turns';
49
50
  export const MIN_OUTCOME_CHARS = 200;
@@ -83,6 +84,7 @@ export function claudeTurn(lines) {
83
84
  const texts = [];
84
85
  const files = new Set();
85
86
  const actions = [];
87
+ const resources = []; const resourceTexts = [];
86
88
  for (const o of recs.slice(start)) {
87
89
  if ((o?.message?.role || o?.role) !== 'assistant') continue;
88
90
  const c = o.message?.content;
@@ -92,6 +94,8 @@ export function claudeTurn(lines) {
92
94
  for (const u of c) {
93
95
  if (!u || u.type !== 'tool_use') continue;
94
96
  const inp = u.input || {};
97
+ const resource = inp.file_path || inp.notebook_path || inp.path; if (typeof resource === 'string' && resource) resources.push(resource);
98
+ if (u.name === 'Bash' && typeof inp.command === 'string') resourceTexts.push(inp.command);
95
99
  if (['Edit', 'Write', 'MultiEdit', 'NotebookEdit'].includes(u.name)) {
96
100
  const f = inp.file_path || inp.notebook_path;
97
101
  if (typeof f === 'string' && f) files.add(f);
@@ -106,7 +110,7 @@ export function claudeTurn(lines) {
106
110
  // other assistant text is kept too, newest last, so the record still says what happened.
107
111
  const last = texts.length ? texts[texts.length - 1] : '';
108
112
  const finalText = last.length >= MIN_OUTCOME_CHARS ? last : texts.join('\n').slice(-2500);
109
- return { finalText, files: [...files], actions };
113
+ return { finalText, files: [...files], actions, resources, resourceTexts };
110
114
  }
111
115
 
112
116
  function readTail(file, bytes = TRANSCRIPT_TAIL_BYTES) {
@@ -168,12 +172,15 @@ export function resolveTurnDb({ projectDir, brainHome, requestedStorePath, gitTi
168
172
  policy = JSON.parse(fs.readFileSync(file, 'utf8'));
169
173
  if (policy?.schemaVersion !== 1 || !consentMap(policy.projects)
170
174
  || (Object.hasOwn(policy, 'paths') && !consentMap(policy.paths))) throw new Error('invalid policy');
175
+ contentPathExcludes(policy.contentPathExcludes);
171
176
  } catch { return { skipped: 'turn capture policy unreadable or invalid', projectRoot: resolved.projectRoot }; }
172
177
  }
173
178
  // A path rule wins over a project rule, allowing a linked checkout/subdirectory to opt out.
174
179
  const setting = policy.paths?.[capturePath] ?? policy.projects?.[resolved.projectRoot];
175
180
  if (setting !== undefined && !['on', 'off'].includes(setting)) return { skipped: 'invalid turn capture consent', projectRoot: resolved.projectRoot };
176
181
  const db = resolved.canonicalAgentDbPath;
182
+ const exclusions = contentPathExcludes(policy.contentPathExcludes);
183
+ if ([db, capturePath].some((binding) => pathIsExcluded(binding, exclusions, capturePath))) return { skipped: 'content exclusion conflicts with canonical source/store binding', projectRoot: resolved.projectRoot };
177
184
  if (unknownOriginalPath && Object.entries(policy.paths || {}).some(([origin, choice]) => choice === 'off'
178
185
  && (origin === resolved.checkoutRoot || origin.startsWith(`${resolved.checkoutRoot}${path.sep}`)))) {
179
186
  return { db, scope: 'project', capturePath, projectRoot: resolved.projectRoot, skipped: 'capture origin cannot be verified under path opt-out' };
@@ -183,7 +190,8 @@ export function resolveTurnDb({ projectDir, brainHome, requestedStorePath, gitTi
183
190
  let exists = false;
184
191
  try { exists = fs.statSync(db).isFile(); } catch { /* absent */ }
185
192
  if (!exists && setting !== 'on') return { db, scope: 'project', capturePath, projectRoot: resolved.projectRoot, skipped: 'no project memory db; persisted opt-in required' };
186
- return { db, scope: 'project', capturePath, projectRoot: resolved.projectRoot, optedIn: setting === 'on' };
193
+ return { db, scope: 'project', capturePath, projectRoot: resolved.projectRoot, optedIn: setting === 'on',
194
+ contentPathExcludes: exclusions };
187
195
  }
188
196
 
189
197
  // Revalidation narrows the queue-to-launch window; it does not make SQLite's later open atomic.
@@ -292,9 +300,10 @@ export function captureTurnOutcome({
292
300
  try { turn = claudeTurn(readTranscript(payload.transcript_path, { maxMs: message ? 0 : settleMs })); } catch { /* unreadable */ }
293
301
  }
294
302
  if (message && (message.length >= MIN_OUTCOME_CHARS || message.length >= turn.finalText.length)) turn.finalText = message;
303
+ turn = privateTurn(turn, target.contentPathExcludes, target.capturePath);
295
304
  if (!sessionKey) report.skipped = 'no session identity in the host payload';
296
305
  else if (turn.finalText.length < MIN_OUTCOME_CHARS && !turn.files.length) {
297
- report.skipped = host === 'codex' && !message ? 'codex payload carried no last_assistant_message' : 'trivial turn';
306
+ report.skipped = turn.excludedResource ? 'turn content withheld: explicitly excluded resource' : host === 'codex' && !message ? 'codex payload carried no last_assistant_message' : 'trivial turn';
298
307
  } else {
299
308
  const fingerprint = crypto.createHash('sha256').update(JSON.stringify([turn.finalText, turn.files])).digest('hex');
300
309
  const stateFile = path.join(brainHome, 'turn-capture', 'last-turn.json');
@@ -365,7 +374,7 @@ export function runSteps({ steps = [], receipts } = {}, { run: suppliedRun = spa
365
374
  };
366
375
  const results = [];
367
376
  for (const queued of steps) {
368
- let status = 1; let error = null; let verified = false; let db = ''; let key;
377
+ let status = 1; let error = null; let verified = false; let db = ''; let key; let privacyPatterns = [];
369
378
  const kind = queued.kind === 'journal' ? 'store' : queued.kind;
370
379
  try {
371
380
  if (String(env.RUVNET_TURN_CAPTURE || '').toLowerCase() === 'off') throw new Error('RUVNET_TURN_CAPTURE=off');
@@ -373,6 +382,7 @@ export function runSteps({ steps = [], receipts } = {}, { run: suppliedRun = spa
373
382
  if (queued.kind !== 'journal' && (!queued.projectRoot || !queued.projectDir || !queued.brainHome)) throw new Error('queued step has no canonical project binding');
374
383
  const target = resolveTurnDb({ projectDir, brainHome, gitTimeoutMs: 1000 });
375
384
  if (target.skipped) throw new Error(target.skipped);
385
+ privacyPatterns = target.contentPathExcludes;
376
386
  db = target.db;
377
387
  let args; let value; let binding;
378
388
  if (kind === 'store') {
@@ -382,7 +392,8 @@ export function runSteps({ steps = [], receipts } = {}, { run: suppliedRun = spa
382
392
  if (data.legacyBrainHome && data.legacyBrainHome !== brainHome) throw new Error('queued consent settings differ from trusted caller');
383
393
  if (queued.kind !== 'journal' && queued.brainHome !== brainHome) throw new Error('queued consent settings differ from trusted caller');
384
394
  binding = queued.kind === 'journal' ? data.binding : queued;
385
- key = data.key; value = redactText(data.value);
395
+ key = data.key; value = maskExcludedPaths(redactText(data.value), target.contentPathExcludes, target.capturePath);
396
+ if (value !== redactText(data.value)) throw new Error('content exclusions changed; immutable turn retained');
386
397
  args = ['memory', 'store', '-k', key, '--value', value, '-n', TURN_NAMESPACE, '--path', db, '--no-upsert', '--provenance', 'agent_output'];
387
398
  } else if (kind === 'distill') {
388
399
  if (queued.brainHome !== brainHome) throw new Error('queued consent settings differ from trusted caller');
@@ -420,9 +431,10 @@ export function runSteps({ steps = [], receipts } = {}, { run: suppliedRun = spa
420
431
  status = Number.isInteger(r.status) ? r.status : 1;
421
432
  if (kind === 'store' && !verified) verified = read({ ruflo, db, key, run, options }) === value;
422
433
  if (verified) status = 0;
423
- if (!verified && (r.error || status !== 0)) error = redactText(r.error?.message || String(r.stderr || '').trim().split(/\r?\n/)[0] || `ruflo exited ${status}`).slice(0, 300);
434
+ if (!verified && (r.error || status !== 0)) error = captureFailureReason(r, status);
424
435
  else if (kind === 'store' && !verified) { status = 1; error = 'exact turn key/content readback failed'; }
425
436
  } catch (e) { status = 1; error = redactText(e.message).slice(0, 300); }
437
+ if (error) error = maskExcludedPaths(error, privacyPatterns, projectDir);
426
438
  const row = { at: new Date().toISOString(), kind, db: redactText(db), storeIdentity: digest(db), key, status, error,
427
439
  ...(kind === 'store' ? { verified } : {}) };
428
440
  results.push(row);
@@ -454,11 +466,14 @@ export function replayTurnQueue({ projectDir = process.cwd(), env = process.env,
454
466
  }
455
467
 
456
468
  /** Bounded recent evidence, isolated by canonical db. A continuity success cannot mask turn failures. */
457
- export function turnRecordingStatus({ projectDir = process.cwd(), env = process.env, home = os.homedir(), now = Date.now() } = {}) {
469
+ export function turnRecordingStatus({ projectDir = process.cwd(), env = process.env, home = os.homedir(), now = Date.now(), noticeOnFirstUse = false } = {}) {
458
470
  const brainHome = env.RUVNET_BRAIN_HOME || path.join(home, '.cache', 'ruvnet-brain');
459
471
  let target;
460
472
  try { target = resolveTurnDb({ projectDir, brainHome }); } catch { return { state: 'unknown', line: 'turn recording unavailable: canonical store resolution failed' }; }
461
473
  if (String(env.RUVNET_TURN_CAPTURE || '').toLowerCase() === 'off' || target.skipped) return { state: 'unknown', line: `turn recording n/a — ${target.skipped || 'RUVNET_TURN_CAPTURE=off'}` };
474
+ const deferred = String(env.RUVNET_HOOK_HOST || 'claude') === 'claude' && String(env.RUVNET_TURN_CAPTURE || '').toLowerCase() !== 'force' && userLevelAgentdbHooks({ home }).turnCapture;
475
+ const notice = noticeOnFirstUse && !deferred ? firstTurnCaptureNotice({ target, brainHome, policyFile: turnCapturePolicyFile(brainHome), env }) : null;
476
+ const status = (value) => ({ ...value, ...(notice ? { notice } : {}) });
462
477
  let rows = [];
463
478
  try {
464
479
  rows = readTail(path.join(brainHome, 'turn-capture', 'receipts.jsonl'), 128 * 1024).flatMap((line) => {
@@ -467,14 +482,14 @@ export function turnRecordingStatus({ projectDir = process.cwd(), env = process.
467
482
  } catch { /* first run */ }
468
483
  rows = [...new Map(rows.map((row) => [row.key || row.at, row])).values()];
469
484
  let pending = 0;
470
- try { pending = pendingTurnFiles(target.db, 25).length; } catch { return { state: 'warn', line: 'turn recording durable queue unsafe or unreadable' }; }
485
+ try { pending = pendingTurnFiles(target.db, 25).length; } catch { return status({ state: 'warn', line: 'turn recording durable queue unsafe or unreadable' }); }
471
486
  const historical = rows.filter((r) => r.status === 0 && r.verified === undefined);
472
487
  const failed = rows.filter((r) => r.status !== 0 || r.verified === false);
473
- if (failed.length) return { state: 'warn', line: `turn recording failing ${failed.length}/${rows.length} — ${redactText(failed.at(-1).error || 'write has no exact readback evidence').slice(0, 300)}` };
474
- if (historical.length) return { state: 'unknown', line: `turn recording unverified historical (${historical.length} receipts without exact readback evidence)` };
475
- if (pending) return { state: 'unknown', line: `turn recording pending durable replay (${pending} entries); not yet proven` };
476
- return rows.length ? { state: 'ok', line: `turn recording ✓ (${rows.length}/${rows.length} exact readbacks)` }
477
- : { state: 'unknown', line: 'turn recording not yet proven — no exact readback receipt' };
488
+ if (failed.length) return status({ state: 'warn', line: `turn recording failing ${failed.length}/${rows.length} — ${redactText(failed.at(-1).error || 'write has no exact readback evidence').slice(0, 300)}` });
489
+ if (historical.length) return status({ state: 'unknown', line: `turn recording unverified historical (${historical.length} receipts without exact readback evidence)` });
490
+ if (pending) return status({ state: 'unknown', line: `turn recording pending durable replay (${pending} entries); not yet proven` });
491
+ return status(rows.length ? { state: 'ok', line: `turn recording ✓ (${rows.length}/${rows.length} exact readbacks)` }
492
+ : { state: 'unknown', line: 'turn recording not yet proven — no exact readback receipt' });
478
493
  }
479
494
 
480
495
  if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) && process.argv[2] === '--run-steps') {
@@ -34,6 +34,7 @@
34
34
  import fs from 'node:fs';
35
35
  import os from 'node:os';
36
36
  import path from 'node:path';
37
+ import { normalizeGroundingScope } from './ruvnet-gate1-pattern.mjs';
37
38
 
38
39
  const HOME = os.homedir();
39
40
 
@@ -104,6 +105,23 @@ export const SETTINGS_SCHEMA = Object.freeze([
104
105
  downside: 'Off, answers about rUv\'s ecosystem are no longer grounded in his source and nothing warns you when they drift, the write-path grounding gate stops enforcing, and nothing is learned from this or any later session until you switch it back on. Updates and health alarms keep running while it is off, which the console states plainly rather than hiding.',
105
106
  }),
106
107
 
108
+ Object.freeze({
109
+ key: 'updateSource', label: 'Code source used by explicitly requested updates',
110
+ type: 'enum', options: Object.freeze(['latest', 'installed']), default: 'latest',
111
+ escalates: Object.freeze([]),
112
+ help: 'Latest uses the published updater; installed explicitly uses the verified installed updater.',
113
+ whyItMatters: 'This user-owned preference selects updater code, not corpus freshness or search scope. Consumers must read the canonical user settings path, never project overrides.',
114
+ downside: 'Installed mode may retain older updater code; if that updater is unavailable or invalid it must report failure rather than silently download or switch sources.',
115
+ }),
116
+
117
+ Object.freeze({
118
+ key: 'groundingScope', label: 'Products that trigger conversational grounding',
119
+ type: 'product-list', default: 'all', escalates: Object.freeze([]),
120
+ help: 'All products, or a nonempty list such as ["ruvector", "metaharness"]. RuVector includes RVF and ruvector-postgres.',
121
+ whyItMatters: 'Only narrows prompt grounding directives and corresponding Stop checks. Search still accesses the installed corpus. Independent write and managed-memory safety guards remain active.',
122
+ downside: 'Excluded product claims are not forced through Brain grounding; verify your fork against its own sources. Unknown, malformed or empty lists use all products.',
123
+ }),
124
+
107
125
  Object.freeze({
108
126
  key: 'brainProfile',
109
127
  label: 'How much of the brain is installed',
@@ -289,7 +307,11 @@ export function validate(input) {
289
307
  warnings.push({ key, reason: 'not a known setting — ignored' });
290
308
  continue;
291
309
  }
292
- if (entry.type === 'bool') {
310
+ if (entry.type === 'product-list') {
311
+ const scope = normalizeGroundingScope(raw);
312
+ if (!scope.ok) { errors.push({ key, reason: 'expected all or a nonempty list of known product terms — using all' }); continue; }
313
+ values[key] = scope.value;
314
+ } else if (entry.type === 'bool') {
293
315
  if (typeof raw !== 'boolean') { errors.push({ key, reason: `expected true or false, got ${JSON.stringify(raw)} — using the default (${entry.default})` }); continue; }
294
316
  values[key] = raw;
295
317
  } else if (entry.type === 'enum') {
@@ -87,6 +87,7 @@ const mean = (k) => valid.reduce((s, r) => s + r[k], 0) / valid.length;
87
87
  const minK = (k) => Math.min(...valid.map(r => r[k]));
88
88
  const gtFail = report.filter(r => !r.groundTruthPathExists).length;
89
89
  const summary = {
90
+ generatedAt: new Date().toISOString(),
90
91
  name: NAME, variant: VARIANT, questions: questions.length, models: MODELS,
91
92
  avgStrict: +mean('avgStrict').toFixed(2), avgRealUse: +mean('avgRealUse').toFixed(2),
92
93
  minStrict: minK('avgStrict'), minRealUse: minK('avgRealUse'),
@@ -116,9 +116,10 @@ function readJson(rel) {
116
116
  * tracked file's mtime to "now", so a panel graded weeks ago read as freshly current on any new
117
117
  * clone — the exact false-freshness failure this whole gate exists to prevent (see the file header:
118
118
  * "grounded 100/100... recorded 2026-07-10... quoted as current"). Each grader-produced file's own
119
- * `summary.generatedAt` (when present) is now preferred; checkout mtime is used only as a fallback
120
- * for older panels that predate the stamp, so this cannot regress a panel that never recorded one.
121
- * `dir` is overridable so a fixture test can exercise this without touching the real `data/` panel.
119
+ * `summary.generatedAt` dates each contributing score. Missing/invalid stamps leave the
120
+ * aggregate undated; otherwise the oldest contributor controls its currency. Checkout times
121
+ * cannot establish when a panel ran.
122
+ * `dir` is overridable so fixtures need not touch the real `data/` panel.
122
123
  */
123
124
  export function readPanel(dir = path.join(ROOT, 'data')) {
124
125
  let files = [];
@@ -127,17 +128,16 @@ export function readPanel(dir = path.join(ROOT, 'data')) {
127
128
  let j = null;
128
129
  try { j = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')); } catch { /* unreadable/absent */ }
129
130
  return { f, j };
130
- }).filter((r) => r.j?.summary);
131
+ }).filter((r) => Number.isFinite(r.j?.summary?.avgStrict));
131
132
  if (!rows.length) return { value: null, detail: null, at: null };
132
133
  const strict = rows.map((r) => r.j.summary.avgStrict).filter(Number.isFinite);
133
134
  if (!strict.length) return { value: null, detail: null, at: null };
134
- const at = rows
135
- .map((r) => {
136
- const recorded = r.j.summary.generatedAt;
137
- if (typeof recorded === 'string' && Number.isFinite(Date.parse(recorded))) return new Date(recorded).toISOString();
138
- try { return fs.statSync(path.join(dir, r.f)).mtime.toISOString(); } catch { return null; }
139
- })
140
- .filter(Boolean).sort().pop();
135
+ const stamps = rows.map((r) => {
136
+ const recorded = r.j.summary.generatedAt;
137
+ return typeof recorded === 'string' && Number.isFinite(Date.parse(recorded))
138
+ ? new Date(recorded).toISOString() : null;
139
+ });
140
+ const at = stamps.every(Boolean) ? stamps.sort()[0] : null;
141
141
  return {
142
142
  value: Math.round((strict.reduce((a, b) => a + b, 0) / strict.length) * 10) / 10,
143
143
  detail: `${strict.length} store(s) graded`,
@@ -150,16 +150,19 @@ export async function run({ dryRun = false, now = Date.now(), repo = REPO } = {}
150
150
  // lastAlertAt: state is only written when the push actually went out, so a failed push
151
151
  // retries next run instead of burying the sighting.
152
152
  const firstSighting = !state[key];
153
+ // Acknowledgment delivery is not page delivery. Retry each missing channel independently.
154
+ const needsNewPage = !state[key]?.newAlertAt;
155
+ const needsAck = !state[key]?.ackAt;
153
156
 
154
157
  results.push({ number: issue.number, title: issue.title, ageHours, ownerComment, breach, dueForAlert, firstSighting, url });
155
158
 
156
- if (firstSighting) {
159
+ if (needsNewPage || needsAck) {
157
160
  if (dryRun) {
158
161
  alertsSent.push({ number: issue.number, sent: false, kind: 'new-issue', reason: 'dry-run' });
159
162
  } else {
160
- const topic = resolveTopic();
161
- let sent = false;
162
- if (topic) sent = await pushNtfy(topic, {
163
+ const topic = needsNewPage ? resolveTopic() : null;
164
+ let sent = !needsNewPage;
165
+ if (needsNewPage && topic) sent = await pushNtfy(topic, {
163
166
  title: `New issue #${issue.number} (open ${fmtAge(ageHours)})`,
164
167
  body: `${issue.title}\n${url}`,
165
168
  priority: 'high', tags: 'new,eyes',
@@ -171,12 +174,20 @@ export async function run({ dryRun = false, now = Date.now(), repo = REPO } = {}
171
174
  // not caring — the opposite of the point). Carries BOT_MARKER so judgeIssue() can never
172
175
  // mistake it for the owner responding. Best-effort like ntfy: a comment failure must not
173
176
  // break the watch; unacked issues simply retry next run (ackAt is delivery-derived).
174
- let ackAt = null;
175
- const ackBody = `🤖 Automated acknowledgment — received and opened. The maintainer has been paged and this is being worked. The next update here will be a fix, findings, or the maintainer in person.`;
176
- const ack = spawnSync(GH_BIN, ['issue', 'comment', String(issue.number), '--repo', repo, '--body', ackBody], { encoding: 'utf8' });
177
- if (ack.status === 0) ackAt = new Date(now).toISOString();
178
- if (sent || ackAt) state[key] = { firstSeenAt: new Date(now).toISOString(), ...(sent ? { newAlertAt: new Date(now).toISOString() } : {}), ...(ackAt ? { ackAt } : {}), title: issue.title, url };
179
- alertsSent.push({ number: issue.number, sent, acked: Boolean(ackAt), kind: 'new-issue', reason: topic ? null : 'no ntfy topic configured' });
177
+ let acked = !needsAck;
178
+ if (needsAck) {
179
+ const ackBody = `${BOT_MARKER} acknowledgment — received and opened. The next update here will be a fix, findings, or the maintainer in person.`;
180
+ const ack = spawnSync(GH_BIN, ['issue', 'comment', String(issue.number), '--repo', repo, '--body', ackBody], { encoding: 'utf8' });
181
+ acked = ack.status === 0;
182
+ }
183
+ if (sent || acked) state[key] = {
184
+ ...(state[key] || {}), firstSeenAt: state[key]?.firstSeenAt || new Date(now).toISOString(),
185
+ ...(needsNewPage && sent ? { newAlertAt: new Date(now).toISOString() } : {}),
186
+ ...(needsAck && acked ? { ackAt: new Date(now).toISOString() } : {}), title: issue.title, url,
187
+ };
188
+ alertsSent.push({ number: issue.number, sent, acked, kind: 'new-issue',
189
+ pageAttempted: needsNewPage, ackAttempted: needsAck,
190
+ reason: needsNewPage && !topic ? 'no ntfy topic configured' : null });
180
191
  }
181
192
  }
182
193
 
@@ -36,16 +36,50 @@ function writeOwned(dir, token, file, value) { const committed = transaction(dir
36
36
  function release(dir, token) { transaction(dir, () => { if (json(dir, ownerFile, 4096)?.token === token) fs.unlinkSync(path.join(dir, ownerFile)); }); }
37
37
  export function runCycleStage({ script, args, timeoutMs, env, spawnHost = spawn }) {
38
38
  return new Promise((resolve, reject) => {
39
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > 900000) throw new Error('Invalid weekly stage deadline');
40
+ const deadline = performance.now() + timeoutMs, retirementMs = Math.min(1000, timeoutMs / 10);
41
+ const workDeadline = deadline - retirementMs;
39
42
  const child = spawnHost(process.execPath, [path.join(DIR, script), ...args], { env, shell: false, stdio: ['ignore', 'pipe', 'pipe'] });
40
- let stdout = '', timedOut = false, killTimer;
41
- const timer = setTimeout(() => { timedOut = true; child.kill('SIGTERM'); killTimer = setTimeout(() => child.kill('SIGKILL'), 1000); }, timeoutMs);
42
- child.stderr.on('data', () => {}); child.stdout.on('data', (d) => { stdout += d; if (stdout.length > 262144) { timedOut = true; child.kill('SIGKILL'); } });
43
- child.once('error', () => { clearTimeout(timer); clearTimeout(killTimer); reject(new Error('Weekly cycle child host unavailable')); });
44
- child.once('exit', (code) => { clearTimeout(timer); clearTimeout(killTimer);
45
- if (timedOut) return reject(new Error('Weekly cycle child exceeded bounded deadline or output limit'));
46
- let result; try { result = JSON.parse(stdout.trim().split('\n').at(-1)); } catch { return reject(new Error('Weekly cycle child receipt missing')); }
47
- resolve({ code, result });
43
+ let stdout = '', outputBytes = 0, failure, settled = false, exitObserved = false;
44
+ let timer, killTimer, retirementTimer;
45
+ const kill = (signal) => { try { child.kill(signal); } catch { /* owned child retirement remains unverified */ } };
46
+ const finish = (error, value) => {
47
+ if (settled) return; settled = true;
48
+ clearTimeout(timer); clearTimeout(killTimer); clearTimeout(retirementTimer);
49
+ // Drop only our child's referenced handles, including pipes held open after its exit.
50
+ // Signals/exit cannot prove detached descendants retired.
51
+ for (const stream of [child.stdin, child.stdout, child.stderr]) { try { stream?.destroy?.(); } catch { /* best effort */ } }
52
+ try { child.unref?.(); } catch { /* best effort */ }
53
+ if (error) {
54
+ error.stageCleanup = { ownedChildPid: child.pid ?? null, childExitObserved: exitObserved,
55
+ ownedChildRetirement: exitObserved ? 'exit-observed' : 'unverified', descendantRetirement: 'unproven' };
56
+ reject(error);
57
+ } else resolve(value);
58
+ };
59
+ const fail = (reason) => {
60
+ if (settled || failure) return; failure = new Error(reason);
61
+ // Reserve retirement inside the caller's budget; do not wait indefinitely for exit/close.
62
+ const remaining = Math.max(0, Math.min(retirementMs, deadline - performance.now()));
63
+ killTimer = setTimeout(() => kill('SIGKILL'), remaining / 2);
64
+ retirementTimer = setTimeout(() => finish(failure), remaining);
65
+ kill('SIGTERM');
66
+ };
67
+ const consume = (chunk, capture) => {
68
+ if (settled || failure) return;
69
+ if (performance.now() >= workDeadline) return fail('Weekly cycle child exceeded bounded deadline or output limit');
70
+ outputBytes += Buffer.isBuffer(chunk) ? chunk.length : Buffer.byteLength(String(chunk));
71
+ if (outputBytes > 262144) return fail('Weekly cycle child exceeded bounded deadline or output limit');
72
+ if (capture) stdout += chunk;
73
+ };
74
+ child.stderr.on('data', (chunk) => consume(chunk, false)); child.stdout.on('data', (chunk) => consume(chunk, true));
75
+ child.on('error', () => fail('Weekly cycle child host unavailable'));
76
+ child.once('exit', (code) => {
77
+ exitObserved = true; if (failure) return finish(failure); if (settled) return;
78
+ if (performance.now() >= workDeadline) { fail('Weekly cycle child exceeded bounded deadline or output limit'); return finish(failure); }
79
+ let result; try { result = JSON.parse(stdout.trim().split('\n').at(-1)); } catch { return finish(new Error('Weekly cycle child receipt missing')); }
80
+ finish(null, { code, result });
48
81
  });
82
+ timer = setTimeout(() => fail('Weekly cycle child exceeded bounded deadline or output limit'), Math.max(0, workDeadline - performance.now()));
49
83
  });
50
84
  }
51
85
  const INVENTORY_URL = 'https://openrouter.ai/api/v1/models';
@@ -172,7 +206,9 @@ export async function runWeeklyCycle({ routerDir = path.join(os.homedir(), '.cla
172
206
  delete state.pendingSemanticReceipt; delete state.pendingSemanticReleaseIds;
173
207
  state.lastQualification = qualified.result; save(STATE_FILE, state); record.status = 'complete';
174
208
  save('weekly-cycle-last-attempt.json', record); return record;
175
- } catch (error) { record.status = 'failed'; record.releaseStatus = 'unknown-or-pending'; record.reason = error.message.slice(0, 240); try { save('weekly-cycle-last-attempt.json', record); } catch { /* superseded cannot write */ } return record;
209
+ } catch (error) { record.status = 'failed'; record.releaseStatus = 'unknown-or-pending'; record.reason = error.message.slice(0, 240);
210
+ if (error.stageCleanup) record.stageCleanup = error.stageCleanup;
211
+ try { save('weekly-cycle-last-attempt.json', record); } catch { /* superseded cannot write */ } return record;
176
212
  } finally { release(routerDir, token); }
177
213
  }
178
214
  if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {