ruvnet-brain 4.5.8 → 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.
- package/README.md +1 -1
- package/bin/install.mjs +50 -93
- package/bin/nightly-refresh.mjs +20 -34
- package/kb/download-retry.mjs +97 -0
- package/kb/forge-update.mjs +12 -10
- package/kb/verify-citation.mjs +10 -13
- package/package.json +2 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/scripts/advocacy-catalog.mjs +1 -0
- package/plugin/scripts/automatic-update.mjs +125 -0
- package/plugin/scripts/completion-claim-evidence.mjs +9 -4
- package/plugin/scripts/continuation-gate.mjs +34 -10
- package/plugin/scripts/continuation-objective.mjs +45 -10
- package/plugin/scripts/continuity-journal.mjs +11 -11
- package/plugin/scripts/ground-before-write.sh +27 -11
- package/plugin/scripts/ground-ruvnet.sh +3 -0
- package/plugin/scripts/grounding-code-projection.mjs +90 -0
- package/plugin/scripts/grounding-turn-evidence.mjs +2 -2
- package/plugin/scripts/grounding-turn-gate.mjs +8 -14
- package/plugin/scripts/grounding-turn-mark.mjs +106 -20
- package/plugin/scripts/hijack-ruvnet.sh +4 -4
- package/plugin/scripts/hook-input.mjs +1 -1
- package/plugin/scripts/host-update.mjs +16 -15
- package/plugin/scripts/lesson-presentation.mjs +2 -1
- package/plugin/scripts/nightly-scheduler.mjs +34 -7
- package/plugin/scripts/project-progression-hook.mjs +22 -10
- package/plugin/scripts/project-progression-producer.mjs +11 -1
- package/plugin/scripts/project-progression-store.mjs +3 -0
- package/plugin/scripts/project-transition-hook.mjs +7 -1
- package/plugin/scripts/ruvnet-gate1-pattern.mjs +33 -0
- package/plugin/scripts/session-snapshot-hook.mjs +4 -3
- package/plugin/scripts/session-start-core.mjs +3 -1
- package/plugin/scripts/turn-capture-privacy.mjs +202 -0
- package/plugin/scripts/turn-outcome-capture.mjs +28 -13
- package/plugin/scripts/user-settings.mjs +23 -1
- package/scripts/brain-grade-groundtruth.mjs +1 -0
- package/scripts/brain-score.mjs +11 -11
- package/scripts/issue-watch.mjs +21 -10
- package/scripts/model-weekly-cycle.mjs +45 -9
- package/scripts/record-lesson.mjs +13 -7
- package/scripts/release-qualification-contract.mjs +40 -1
|
@@ -44,3 +44,36 @@ export function ruvnetGate1Matches(text) {
|
|
|
44
44
|
export const RUVNET_GATE1_TERMS = RUVNET_GATE1_PATTERN
|
|
45
45
|
.split('|')
|
|
46
46
|
.map((alt) => alt.replace(/\\b/g, '').replace(/s\?$/, '').toLowerCase());
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
/** Prompt-trigger scope only; never used for retrieval or write safety. */
|
|
50
|
+
const SCOPE_ALIASES = { rvf: 'ruvector', 'ruvector-postgres': 'ruvector', 'claude-flow': 'ruflo', swarms: 'swarm' };
|
|
51
|
+
export function normalizeGroundingScope(raw) {
|
|
52
|
+
if (raw === 'all') return { ok: true, value: 'all' };
|
|
53
|
+
if (!Array.isArray(raw) || !raw.length || raw.length > RUVNET_GATE1_TERMS.length) return { ok: false, value: 'all' };
|
|
54
|
+
const terms = raw.map((x) => typeof x === 'string' ? x.toLowerCase() : '');
|
|
55
|
+
if (terms.some((x) => !RUVNET_GATE1_TERMS.includes(x) && x !== 'ruvector-postgres')) return { ok: false, value: 'all' };
|
|
56
|
+
return { ok: true, value: [...new Set(terms.map((x) => SCOPE_ALIASES[x] || x))] };
|
|
57
|
+
}
|
|
58
|
+
export function groundingScopeMatches(text, scope = 'all') {
|
|
59
|
+
const value = normalizeGroundingScope(scope).value;
|
|
60
|
+
if (value === 'all') return ruvnetGate1Matches(text);
|
|
61
|
+
const terms = value.flatMap((x) => x === 'ruvector' ? ['ruvector', 'rvf'] : x === 'ruflo' ? ['ruflo', 'claude-flow'] : [x]);
|
|
62
|
+
return terms.some((x) => new RegExp(`\\b${x === 'swarm' ? 'swarms?' : x}\\b`, 'i').test(String(text ?? '')));
|
|
63
|
+
}
|
|
64
|
+
export function groundingSubjectAllowed(subject, scope = 'all') {
|
|
65
|
+
return !ruvnetGate1Matches(subject) || groundingScopeMatches(subject, scope);
|
|
66
|
+
}
|
|
67
|
+
export function mergeGroundingScopes(a = 'all', b = 'all') {
|
|
68
|
+
const x = normalizeGroundingScope(a).value, y = normalizeGroundingScope(b).value;
|
|
69
|
+
return x === 'all' || y === 'all' ? 'all' : [...new Set([...x, ...y])];
|
|
70
|
+
}
|
|
71
|
+
// The shell body's Gate1 invokes this existing packaged module only after its full regex matches.
|
|
72
|
+
if (process.argv[1]?.replaceAll('\\', '/').endsWith('/ruvnet-gate1-pattern.mjs') && process.argv[2] === '--scope-matches') setImmediate(async () => {
|
|
73
|
+
try {
|
|
74
|
+
const { loadSettings } = await import('./user-settings.mjs');
|
|
75
|
+
const { readStdinBounded } = await import('./hook-input.mjs');
|
|
76
|
+
const text = (await readStdinBounded({ maxBytes: 32768 })).toString('utf8');
|
|
77
|
+
process.stdout.write(groundingScopeMatches(text, loadSettings().values.groundingScope) ? '1' : '0');
|
|
78
|
+
} catch { process.stdout.write('1'); } // uncertain config preserves default enforcement
|
|
79
|
+
});
|
|
@@ -166,7 +166,7 @@ export function runSessionSnapshotHook(projectDir, event, {
|
|
|
166
166
|
if (payload.hook_event_name !== event) {
|
|
167
167
|
throw new Error(`progression boundary mismatch: expected ${event}, received ${payload.hook_event_name}`);
|
|
168
168
|
}
|
|
169
|
-
const result = captureProgression({ host, payload, projectDir, recoverFrozen: Boolean(ordered),
|
|
169
|
+
const result = captureProgression({ host, payload, projectDir, env, recoverFrozen: Boolean(ordered),
|
|
170
170
|
canCommit: () => Boolean(ordered) && refreshReplayLock(resolveProjectStore({ projectDir }).projectRoot, ordered), storeFactory: (options) => makeStoreFactory(now() + budgetMs)({ ...options, env }) });
|
|
171
171
|
return { ...idle, progressionCaptured: true, receipt: result.receipt };
|
|
172
172
|
}
|
|
@@ -206,7 +206,7 @@ export function runSessionSnapshotHook(projectDir, event, {
|
|
|
206
206
|
let token = ordered;
|
|
207
207
|
const handOff = (why) => {
|
|
208
208
|
let frozen;
|
|
209
|
-
try { frozen = produce({ resolution, projectDir, payload, host, trigger: event }); } catch { frozen = null; }
|
|
209
|
+
try { frozen = produce({ resolution, projectDir, payload, host, env, trigger: event }); } catch { frozen = null; }
|
|
210
210
|
const queued = frozen?.projectProgression ? queueCapture({ projectDir: root, originProjectDir: projectDir, env, event, host,
|
|
211
211
|
payload: { session_id: payload.session_id, hook_event_name: event, projectProgression: frozen.projectProgression } }) : null;
|
|
212
212
|
const handed = queued ? spawnReplay({ projectDir: root, token, env }) : false;
|
|
@@ -247,7 +247,7 @@ export function runSessionSnapshotHook(projectDir, event, {
|
|
|
247
247
|
|
|
248
248
|
let produced;
|
|
249
249
|
try {
|
|
250
|
-
produced = produce({ resolution, projectDir, payload, host, trigger: event });
|
|
250
|
+
produced = produce({ resolution, projectDir, payload, host, env, trigger: event });
|
|
251
251
|
} catch (error) {
|
|
252
252
|
return { ...idle, replayed, skipped: `producer failed: ${error.message}` };
|
|
253
253
|
}
|
|
@@ -259,6 +259,7 @@ export function runSessionSnapshotHook(projectDir, event, {
|
|
|
259
259
|
host,
|
|
260
260
|
payload: { ...payload, hook_event_name: event, projectProgression: produced.projectProgression },
|
|
261
261
|
projectDir,
|
|
262
|
+
env,
|
|
262
263
|
storeFactory,
|
|
263
264
|
});
|
|
264
265
|
} catch (error) {
|
|
@@ -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 =
|
|
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 === '
|
|
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'),
|
package/scripts/brain-score.mjs
CHANGED
|
@@ -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`
|
|
120
|
-
*
|
|
121
|
-
*
|
|
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
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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`,
|
package/scripts/issue-watch.mjs
CHANGED
|
@@ -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 (
|
|
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 =
|
|
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
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
|