ruvnet-brain 4.5.9 → 4.5.11
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 +48 -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 +3 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- 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-ruvnet.sh +3 -0
- package/plugin/scripts/grounding-code-projection.mjs +6 -1
- 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/claude-controlled-terminal.mjs +265 -0
- package/scripts/issue-watch.mjs +21 -10
- package/scripts/model-terminal-launchers.mjs +19 -9
- package/scripts/model-weekly-cycle.mjs +45 -9
- package/scripts/record-lesson.mjs +13 -7
- package/scripts/release-qualification-contract.mjs +35 -4
- package/scripts/user-model-hook-install.mjs +145 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// Owner-only automatic update policy. Project settings and inherited package-manager paths
|
|
2
|
+
// cannot choose executable code or relax the signed corpus updater's compatibility checks.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import crypto from 'node:crypto';
|
|
7
|
+
import { spawnSync } from 'node:child_process';
|
|
8
|
+
import { SETTINGS_VERSION, validate, saveSettings } from './user-settings.mjs';
|
|
9
|
+
|
|
10
|
+
export const ownerSettingsPath = (home = os.homedir()) => path.join(home, '.config', 'ruvnet-brain', 'settings.json');
|
|
11
|
+
export function updateSource({ home = os.homedir(), read = fs.readFileSync, validatePolicy = validate } = {}) {
|
|
12
|
+
const file = ownerSettingsPath(home);
|
|
13
|
+
let descriptor; let captured;
|
|
14
|
+
try {
|
|
15
|
+
let before;
|
|
16
|
+
try { before = fs.lstatSync(file); }
|
|
17
|
+
catch (error) { if (error.code === 'ENOENT') return 'latest'; throw error; }
|
|
18
|
+
if (!before.isFile() || before.isSymbolicLink()) throw new Error('owner settings must be a regular file, not a symlink');
|
|
19
|
+
descriptor = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
|
|
20
|
+
const stat = fs.fstatSync(descriptor);
|
|
21
|
+
if (!stat.isFile() || stat.dev !== before.dev || stat.ino !== before.ino
|
|
22
|
+
|| (typeof process.getuid === 'function' && (stat.uid !== process.getuid() || (stat.mode & 0o022)))) {
|
|
23
|
+
throw new Error('owner settings identity or permissions are unsafe');
|
|
24
|
+
}
|
|
25
|
+
captured = JSON.parse(read(descriptor, 'utf8'));
|
|
26
|
+
} catch (error) { throw new Error(`owner update settings are unreadable or invalid: ${error.message}`); }
|
|
27
|
+
finally { if (descriptor !== undefined) fs.closeSync(descriptor); }
|
|
28
|
+
if (!captured || Array.isArray(captured) || captured.version !== SETTINGS_VERSION
|
|
29
|
+
|| !captured.settings || Array.isArray(captured.settings) || typeof captured.settings !== 'object') {
|
|
30
|
+
throw new Error('automatic update source is unproven: owner settings envelope is invalid or from another version');
|
|
31
|
+
}
|
|
32
|
+
const state = validatePolicy(captured.settings);
|
|
33
|
+
if (!state.ok) throw new Error('automatic update source is unproven: owner settings are invalid');
|
|
34
|
+
const source = state.values.updateSource;
|
|
35
|
+
if (!['latest', 'installed'].includes(source)) throw new Error('automatic update source is not supported by this runtime');
|
|
36
|
+
return source;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function saveUpdateSource(source, { home = os.homedir() } = {}) {
|
|
40
|
+
if (!['latest', 'installed'].includes(source)) throw new Error('--update-source must be latest or installed');
|
|
41
|
+
const result = saveSettings({ updateSource: source }, { file: ownerSettingsPath(home) });
|
|
42
|
+
if (!result.ok || result.values.updateSource !== source) throw new Error(result.log || 'update source was not persisted');
|
|
43
|
+
return result;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function automaticPath({ nodePath = process.execPath, home = os.homedir(), platform = process.platform, env = process.env } = {}) {
|
|
47
|
+
const paths = [path.dirname(nodePath), path.join(home, '.npm-global', 'bin'), path.join(home, '.local', 'bin')];
|
|
48
|
+
if (platform === 'win32') {
|
|
49
|
+
const system = env.SystemRoot || env.SYSTEMROOT;
|
|
50
|
+
if (system && path.win32.isAbsolute(system)) paths.push(system, path.join(system, 'System32'));
|
|
51
|
+
} else paths.push('/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin', '/usr/sbin', '/sbin');
|
|
52
|
+
return [...new Set(paths)].join(path.delimiter);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function stableNode({ executable = process.execPath, explicit, realpath = fs.realpathSync, run = spawnSync } = {}) {
|
|
56
|
+
let candidate = explicit || executable;
|
|
57
|
+
if (!explicit) {
|
|
58
|
+
const cellar = /^(.*)[\\/]Cellar[\\/]([^\\/]+)[\\/][^\\/]+[\\/]bin[\\/]node$/.exec(executable);
|
|
59
|
+
if (cellar) candidate = path.join(cellar[1], 'opt', cellar[2], 'bin', 'node');
|
|
60
|
+
else if (/[\\/](?:\.nvm[\\/]versions|\.npm[\\/]_npx|\.volta[\\/]tools[\\/]image|\.fnm[\\/]node-versions)[\\/]/.test(executable)) {
|
|
61
|
+
throw new Error('automatic updates need a stable Node path; pass --nightly-node /absolute/stable/node');
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (!path.isAbsolute(candidate)) throw new Error('nightly Node path must be absolute');
|
|
65
|
+
try {
|
|
66
|
+
if (realpath(candidate) !== realpath(executable)) throw new Error('selected Node does not resolve to the running installer Node');
|
|
67
|
+
} catch (error) { throw new Error(`stable Node is unavailable or mismatched: ${error.message}`); }
|
|
68
|
+
const version = run(candidate, ['--version'], { encoding: 'utf8', timeout: 5000,
|
|
69
|
+
env: { PATH: automaticPath({ nodePath: candidate }), SystemRoot: process.env.SystemRoot || '' } });
|
|
70
|
+
if (version.error || version.status !== 0 || !/^v(\d+)\./.test(String(version.stdout).trim())
|
|
71
|
+
|| Number(/^v(\d+)\./.exec(String(version.stdout).trim())[1]) < 18) throw new Error('automatic updates require supported Node 18 or newer');
|
|
72
|
+
return candidate;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function installedUpdater({ home = os.homedir(), platform = process.platform } = {}) {
|
|
76
|
+
const root = path.join(home, '.npm-global', ...(platform === 'win32' ? [] : ['lib']), 'node_modules', 'ruvnet-brain');
|
|
77
|
+
const manifestPath = path.join(root, 'package.json');
|
|
78
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
79
|
+
const declared = manifest.bin?.['ruvnet-brain'];
|
|
80
|
+
if (manifest.name !== 'ruvnet-brain' || typeof declared !== 'string' || !declared
|
|
81
|
+
|| path.isAbsolute(declared) || path.win32.isAbsolute(declared) || declared.split(/[\\/]/).includes('..')) {
|
|
82
|
+
throw new Error('installed update source has no safe declared Brain entrypoint');
|
|
83
|
+
}
|
|
84
|
+
const actualRoot = fs.realpathSync(root);
|
|
85
|
+
const entry = fs.realpathSync(path.resolve(root, declared));
|
|
86
|
+
const relative = path.relative(actualRoot, entry);
|
|
87
|
+
if (!relative || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative) || !fs.statSync(entry).isFile()) {
|
|
88
|
+
throw new Error('installed Brain entrypoint escapes its owner installation');
|
|
89
|
+
}
|
|
90
|
+
const sha256 = crypto.createHash('sha256').update(fs.readFileSync(entry)).digest('hex');
|
|
91
|
+
return { entry, version: manifest.version, sha256 };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function automaticInvocation(args, { source = updateSource(), home = os.homedir(), nodePath = process.execPath,
|
|
95
|
+
packageTarget = 'ruvnet-brain@latest' } = {}) {
|
|
96
|
+
if (source === 'installed') {
|
|
97
|
+
if (packageTarget !== 'ruvnet-brain@latest') throw new Error('installed mode cannot select a proof tarball');
|
|
98
|
+
const installed = installedUpdater({ home });
|
|
99
|
+
return { executable: nodePath, args: [installed.entry, ...args], source, installed };
|
|
100
|
+
}
|
|
101
|
+
if (source !== 'latest') throw new Error('invalid automatic update source');
|
|
102
|
+
const name = process.platform === 'win32' ? 'npx.cmd' : 'npx';
|
|
103
|
+
const ownerNpx = path.join(home, '.npm-global', 'bin', name);
|
|
104
|
+
const adjacent = fs.existsSync(ownerNpx) ? ownerNpx : path.join(path.dirname(nodePath), name);
|
|
105
|
+
if (process.platform === 'win32') {
|
|
106
|
+
try {
|
|
107
|
+
const shim = fs.existsSync(adjacent) ? adjacent : automaticPath({ nodePath, home }).split(path.delimiter)
|
|
108
|
+
.map(dir => path.join(dir, name)).find(candidate => fs.existsSync(candidate));
|
|
109
|
+
if (!shim) throw new Error('managed npm npx shim is missing');
|
|
110
|
+
const root = fs.realpathSync(path.join(path.dirname(fs.realpathSync(shim)), 'node_modules', 'npm'));
|
|
111
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
|
|
112
|
+
const declared = manifest.bin?.npx;
|
|
113
|
+
if (manifest.name !== 'npm' || typeof declared !== 'string' || !declared || path.isAbsolute(declared)
|
|
114
|
+
|| path.win32.isAbsolute(declared) || declared.split(/[\\/]/).includes('..')) throw new Error('unsafe npm npx entry');
|
|
115
|
+
const entry = fs.realpathSync(path.resolve(root, declared));
|
|
116
|
+
const relative = path.relative(root, entry);
|
|
117
|
+
if (!relative || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative) || !fs.statSync(entry).isFile()) {
|
|
118
|
+
throw new Error('managed npm npx entry escapes its package');
|
|
119
|
+
}
|
|
120
|
+
return { executable: nodePath, args: [entry, '--yes', packageTarget, ...args], source };
|
|
121
|
+
} catch (error) { throw new Error(`could not resolve managed npm: ${error.message}`); }
|
|
122
|
+
}
|
|
123
|
+
return { executable: fs.existsSync(adjacent) ? adjacent : name,
|
|
124
|
+
args: ['--yes', packageTarget, ...args], source };
|
|
125
|
+
}
|
|
@@ -48,12 +48,14 @@ function sentences(message) {
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
// A sentence that is itself a disclosure, a negation, a hedge or a condition is not a claim.
|
|
51
|
-
const NOT_A_CLAIM = /\b(?:not|never|no|nothing|none|cannot|can't|won't|isn't|aren't|wasn't|weren't|hasn't|haven't|didn't|doesn't|don't|without|unverified|untested|unproven|unknown|yet|if|unless|until|once|when|whether|should|would|could|might|may|maybe|probably|likely|expected|supposed|hopefully|assuming|pending|remaining|remains|todo|tbd|before|after|previously|earlier|already|yesterday|ago|last\s+(?:week|night|time|session|release)|
|
|
51
|
+
const NOT_A_CLAIM = /\b(?:not|never|no|nothing|none|cannot|can't|won't|isn't|aren't|wasn't|weren't|hasn't|haven't|didn't|doesn't|don't|without|unverified|untested|unproven|unknown|yet|if|unless|until|once|when|whether|should|would|could|might|may|maybe|probably|likely|expected|supposed|hopefully|assuming|pending|remaining|remains|todo|tbd|before|after|previously|earlier|already|yesterday|ago|last\s+(?:week|night|time|session|release)|neither|nor|as\s+soon\s+as|tell\s+me|I(?:'ll|’ll)|we(?:'ll|’ll)|will\s+(?!now\b)|(?:was|were)\s+working|damage\s+is\s+done)\b|\?/i;
|
|
52
|
+
const HEARSAY = /\b(?:claimed|claims|said|says|reported|told)\b/i;
|
|
53
|
+
const FIRST_PERSON_ASSERTION = /^(?:[-*•]\s*|#+\s*)*(?:I|we)(?:'ve|’ve|\s+have)?\s+(?:now\s+|just\s+|successfully\s+|also\s+|fully\s+)*(?:fixed|shipped|deployed|resolved|completed|implemented|landed|published|released|verified|finished|reported|wired\s+up)\b/i;
|
|
52
54
|
const CLAIM_PATTERNS = [
|
|
53
55
|
// "X is fixed", "the gate is now live", "tests are passing", "it's done"
|
|
54
56
|
/\b(?:is|are|was|were|has\s+been|have\s+been|(?:it|that|this|what|everything|there)(?:'s|’s)|now)\s+(?:all\s+|fully\s+|now\s+|finally\s+|successfully\s+|actually\s+)*(?:fixed|done|complete|completed|resolved|shipped|deployed|live(?!\s+(?:in|inside|under|with|alongside))|implemented|landed|published|merged|released|verified|green|passing|operational|in\s+place|wired(?:\s+up)?|working(?!\s+(?:on|with|through|in|as|tree|copy|dir)))\b/i,
|
|
55
57
|
// "I fixed", "I've shipped", "we deployed"
|
|
56
|
-
/\b(?:I|we)(?:'ve|’ve|\s+have)?\s+(?:now\s+|just\s+|successfully\s+|also\s+|fully\s+)*(?:fixed|shipped|deployed|resolved|completed|implemented|landed|published|released|verified|finished|wired\s+up)\b/i,
|
|
58
|
+
/\b(?:I|we)(?:'ve|’ve|\s+have)?\s+(?:now\s+|just\s+|successfully\s+|also\s+|fully\s+)*(?:fixed|shipped|deployed|resolved|completed|implemented|landed|published|released|verified|finished|reported|wired\s+up)\b/i,
|
|
57
59
|
// "Done." "Fixed:" "Shipped —" "✅ Deployed" at the start of a line
|
|
58
60
|
/^(?:[-*•]\s*|#+\s*|\*\*|✅\s*)*(?:done|fixed|shipped|deployed|resolved|complete|completed|implemented|all\s+done|all\s+set|all\s+green)\b\s*(?:[.!:—–-]|\*\*|$)/i,
|
|
59
61
|
// "now works", "it will now block …", "works end to end"
|
|
@@ -64,7 +66,7 @@ const CLAIM_PATTERNS = [
|
|
|
64
66
|
export function extractCompletionClaims(message) {
|
|
65
67
|
const out = [];
|
|
66
68
|
for (const sentence of sentences(message)) {
|
|
67
|
-
if (NOT_A_CLAIM.test(sentence)) continue;
|
|
69
|
+
if (NOT_A_CLAIM.test(sentence) || (HEARSAY.test(sentence) && !FIRST_PERSON_ASSERTION.test(sentence))) continue;
|
|
68
70
|
if (CLAIM_PATTERNS.some((re) => re.test(sentence))) out.push({ class: 'completion', text: sentence });
|
|
69
71
|
}
|
|
70
72
|
return out;
|
|
@@ -219,6 +221,8 @@ export function auditCompletionClaims(message, { turn = null, host = 'claude' }
|
|
|
219
221
|
const PROMISE_LEAD = /\b(?:I(?:'ll|’ll|\s+will)|I(?:'m|’m|\s+am)\s+going\s+to|next,?\s+I(?:'ll|’ll|\s+will)|I\s+commit\s+to)\s+(?:now\s+|next\s+|then\s+|also\s+|immediately\s+|first\s+)*([a-z][^.!?\n]{6,158})/i;
|
|
220
222
|
const PROMISE_REJECT = /\?|:\*\*|\b(?:if|unless|once|when|whenever|as\s+soon\s+as|after|the\s+moment|until|whether|shortly|regularly|periodically|every\s+\d+|check\s+(?:back|again|on\s+it)|keep\s+(?:watching|an\s+eye)|maybe|might|could|would|probably|perhaps|possibly|option|alternatively|either|or\s+I\s+(?:can|could)|want\s+me|should\s+I|shall\s+I|let\s+you\s+know|wait|await|stand\s+by|hold\s+off|leave\s+(?:it|that|this)\s+to\s+you|happy\s+to|glad\s+to|be\s+honest|note\s+that|keep\s+(?:that|this)\s+in\s+mind|try\s+to|need\s+your|your\s+(?:approval|go-ahead|call|decision|confirmation))\b/i;
|
|
221
223
|
const TRIVIAL_PROMISE = /^(?:not|never|be|say|mention|note|admit|point|flag|stop|stay|leave|keep|summari[sz]e|explain|answer|respond|reply|report|remember|give|tell|send|paste|bring|hand|show|walk|notify|post|update\s+you|come\s+back|get\s+back|handle|only|stand|work)\b/i;
|
|
224
|
+
const OWNER_DEPENDENT_OFFER = /\b(?:say\s+the\s+word|(?:just|simply)\s+(?:ask|say)|ask\s+me|let\s+me\s+know|give\s+me\s+the\s+(?:word|go|nod|ok)|on\s+your\s+(?:word|go|ok|say)|up\s+to\s+you)\b/i;
|
|
225
|
+
const STANDING_APPROVAL = /^(?:ask|confirm|check\s+with\s+you|clear\s+(?:it|this|that)\s+with\s+you|run\s+.+\s+by\s+you)\b.*\b(?:before|first)\b/i;
|
|
222
226
|
|
|
223
227
|
export const normalizePromise = (value) => String(value || '').toLowerCase().replace(/[`*_"“”'’]/g, '')
|
|
224
228
|
.replace(/[^a-z0-9./:+-]+/g, ' ').trim().slice(0, 160);
|
|
@@ -237,7 +241,8 @@ export function extractCommitments(message) {
|
|
|
237
241
|
const candidates = item ? [item[1]] : sentences(line).map((s) => PROMISE_LEAD.exec(s)?.[1]).filter(Boolean);
|
|
238
242
|
const whole = item ? item[1] : line;
|
|
239
243
|
for (const action of candidates) {
|
|
240
|
-
if (PROMISE_REJECT.test(whole) ||
|
|
244
|
+
if (PROMISE_REJECT.test(whole) || OWNER_DEPENDENT_OFFER.test(whole)
|
|
245
|
+
|| STANDING_APPROVAL.test(action.trim()) || TRIVIAL_PROMISE.test(action.trim())) continue;
|
|
241
246
|
const text = action.replace(/\s+/g, ' ').trim().replace(/[,;:]$/, '');
|
|
242
247
|
const key = normalizePromise(text);
|
|
243
248
|
if (key.length < 6 || seen.has(key)) continue;
|
|
@@ -47,7 +47,7 @@ import {
|
|
|
47
47
|
buildCapabilityInventoryReceipt,
|
|
48
48
|
} from './capability-inventory-receipt.mjs';
|
|
49
49
|
import { auditCurrentCapabilityEvidence } from './capability-claim-evidence.mjs';
|
|
50
|
-
import { continuationProjectIdentity, authorizedContinuationObjective, authorizedPromiseItems } from './continuation-objective.mjs';
|
|
50
|
+
import { continuationProjectIdentity, authorizedContinuationObjective, authorizedPromiseItems, assistantCommitmentOwned, setAssistantCommitmentState } from './continuation-objective.mjs';
|
|
51
51
|
import {
|
|
52
52
|
auditCompletionClaims, readClaudeTurn, extractCommitments, claimClosesPromise,
|
|
53
53
|
PROMISE_KIND, PROMISE_CAP_OPEN,
|
|
@@ -178,7 +178,8 @@ function save(led) {
|
|
|
178
178
|
try {
|
|
179
179
|
fs.mkdirSync(path.dirname(LEDGER), { recursive: true });
|
|
180
180
|
fs.writeFileSync(LEDGER, JSON.stringify({ ...led, updated: new Date().toISOString() }, null, 2) + '\n');
|
|
181
|
-
|
|
181
|
+
return true;
|
|
182
|
+
} catch { return false; /* the ledger is advisory — never break a turn over it */ }
|
|
182
183
|
}
|
|
183
184
|
|
|
184
185
|
// ── commands ─────────────────────────────────────────────────────────────────────────────────────
|
|
@@ -217,6 +218,20 @@ if (has('--commit-to')) {
|
|
|
217
218
|
process.exit(0);
|
|
218
219
|
}
|
|
219
220
|
|
|
221
|
+
if (has('--set-commitment-state')) {
|
|
222
|
+
try {
|
|
223
|
+
const sessionId = arg('--session-id');
|
|
224
|
+
const nativeSession = process.env.RUVNET_BRAIN_SESSION_ID || process.env.RUVNET_HOOK_SESSION_ID;
|
|
225
|
+
if (nativeSession && nativeSession !== sessionId) throw new Error('supplied session id differs from native session context');
|
|
226
|
+
const led = load();
|
|
227
|
+
setAssistantCommitmentState(led, { itemText: arg('--item'), state: arg('--set-commitment-state'),
|
|
228
|
+
sessionId, reason: arg('--reason'), replacementReference: arg('--replacement'),
|
|
229
|
+
identity: continuationProjectIdentity(process.cwd()) });
|
|
230
|
+
if (!save(led)) throw new Error('assistant commitment state could not be recorded');
|
|
231
|
+
console.log('assistant commitment state recorded; user objective unchanged'); process.exit(0);
|
|
232
|
+
} catch (error) { console.error(`refused: ${error.message}`); process.exit(2); }
|
|
233
|
+
}
|
|
234
|
+
|
|
220
235
|
if (has('--done')) {
|
|
221
236
|
const led = load();
|
|
222
237
|
const needle = arg('--done');
|
|
@@ -297,6 +312,9 @@ if (has('--help') || has('-h')) {
|
|
|
297
312
|
' --commit-to "<text>" record work the model agreed to do; opens/reopens the',
|
|
298
313
|
' objective as state: active',
|
|
299
314
|
' --done "<exact item text>" mark a plain ledger item done (exact text match only)',
|
|
315
|
+
' --set-commitment-state <blocked|deferred|superseded|disputed> --item "<exact text>"',
|
|
316
|
+
' --session-id "<capturing session>" --reason "<why>" [--replacement "<reference>"]',
|
|
317
|
+
' retain a noncompleted assistant item; user work unchanged',
|
|
300
318
|
' --complete-objective "<evidence>" close the current objective as state: completed, with the',
|
|
301
319
|
' completion evidence recorded — leaves other ledger items',
|
|
302
320
|
' untouched',
|
|
@@ -368,30 +386,33 @@ const completion = (() => {
|
|
|
368
386
|
* normalized text; project + worktree scoped exactly like --commit-to's objective. Fails open.
|
|
369
387
|
*/
|
|
370
388
|
function promiseBookkeeping() {
|
|
371
|
-
if (HOST !== 'claude' || !hookInput.session_id) return;
|
|
389
|
+
if (HOST !== 'claude' || !hookInput.session_id || hookInput.session_id === '*') return;
|
|
372
390
|
try {
|
|
373
391
|
const pid = projectIdentity.projectId;
|
|
374
392
|
const at = new Date(nowMs).toISOString();
|
|
375
393
|
let changed = false;
|
|
376
394
|
if (completion.verdict === 'PASS') {
|
|
377
395
|
for (const item of led.items) {
|
|
378
|
-
if (item
|
|
396
|
+
if (!assistantCommitmentOwned(item, hookInput.session_id, projectIdentity)) continue;
|
|
379
397
|
const claim = completion.claims.find((c) => claimClosesPromise(c.text, item.text));
|
|
380
398
|
if (!claim) continue;
|
|
381
|
-
Object.assign(item, { done: true, doneAt: at, completionEvidence: { claim: claim.text,
|
|
399
|
+
Object.assign(item, { done: true, state: 'completed', doneAt: at, completionEvidence: { claim: claim.text,
|
|
382
400
|
checks: completion.verification.checks.slice(-5).map((c) => c.what),
|
|
383
401
|
transcript: String(hookInput.transcript_path || ''), sessionId: hookInput.session_id } });
|
|
384
402
|
changed = true;
|
|
385
403
|
}
|
|
386
404
|
}
|
|
387
|
-
const
|
|
405
|
+
const owned = led.items.filter((i) => assistantCommitmentOwned(i, hookInput.session_id, projectIdentity));
|
|
406
|
+
const openKeys = new Set(owned.map((i) => i.key));
|
|
407
|
+
let activeCount = owned.filter((i) => i.state === undefined || i.state === 'active').length;
|
|
388
408
|
// Owner preference suppresses only new capture; verified closure above and integrity audits stay active.
|
|
389
409
|
const newPromises = process.env.RUVNET_PROMISE_CAPTURE === 'off' ? [] : extractCommitments(hookInput.last_assistant_message);
|
|
390
410
|
for (const promise of newPromises) {
|
|
391
|
-
if (openKeys.has(promise.key) ||
|
|
411
|
+
if (openKeys.has(promise.key) || activeCount >= PROMISE_CAP_OPEN) continue;
|
|
392
412
|
openKeys.add(promise.key);
|
|
393
|
-
|
|
394
|
-
|
|
413
|
+
activeCount += 1;
|
|
414
|
+
led.items.push({ schemaVersion: 1, kind: PROMISE_KIND, text: promise.text, key: promise.key, done: false, state: 'active', at,
|
|
415
|
+
projectId: pid, worktreeIds: [projectIdentity.worktreeId], sessionIds: [hookInput.session_id],
|
|
395
416
|
capturedFrom: { sessionId: hookInput.session_id },
|
|
396
417
|
authorization: { kind: 'owner-mandate', reference: 'i-will-is-a-contract-2026-09-15' } });
|
|
397
418
|
changed = true;
|
|
@@ -786,7 +807,10 @@ const lines = [
|
|
|
786
807
|
? ['Name the check you ran (a "Verified:" line with the command or artifact) and say what is NOT verified.']
|
|
787
808
|
: promises.length
|
|
788
809
|
? ['A promise closes only when a later answer claims it done with a check run after the last change —',
|
|
789
|
-
'never by saying so, and never by --done.
|
|
810
|
+
'never by saying so, and never by --done. For blocked, deferred, superseded or disputed assistant',
|
|
811
|
+
'commitments, use --set-commitment-state with --item (exact text), --session-id (capturing session)',
|
|
812
|
+
'and --reason; superseded also requires --replacement. This retains the item, never completes it,',
|
|
813
|
+
'and does not cancel or complete authorized user work.']
|
|
790
814
|
: committed.length
|
|
791
815
|
? ['Record objective completion only with actual completion evidence; legacy --done does not complete an objective.']
|
|
792
816
|
: ['These clear by being done, not by being marked: merge or fix the PR, get the build green,',
|
|
@@ -39,17 +39,52 @@ export function authorizedContinuationObjective(objective, input, identity) {
|
|
|
39
39
|
return objective;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|| input.interrupted || input.cancelled || input.stop_hook_active) return [];
|
|
49
|
-
return (Array.isArray(items) ? items : []).filter((item) => item?.kind === 'assistant-commitment'
|
|
50
|
-
&& item.schemaVersion === 1 && item.done !== true && text(item.text) && Number.isFinite(Date.parse(item.at))
|
|
42
|
+
// Automatic assistant commitments never confer project-wide user authority. Retained legacy
|
|
43
|
+
// wildcards belong only to their proven capturing session; unknown ownership stays historical.
|
|
44
|
+
export function assistantCommitmentOwned(item, sessionId, identity) {
|
|
45
|
+
return Boolean(identity && text(sessionId) && sessionId !== '*'
|
|
46
|
+
&& item?.kind === 'assistant-commitment' && item.schemaVersion === 1
|
|
47
|
+
&& item.done !== true && text(item.text) && Number.isFinite(Date.parse(item.at))
|
|
51
48
|
&& item.authorization?.kind === 'owner-mandate' && text(item.authorization.reference)
|
|
52
49
|
&& item.projectId === identity.projectId
|
|
53
50
|
&& Array.isArray(item.worktreeIds) && item.worktreeIds.includes(identity.worktreeId)
|
|
54
|
-
&&
|
|
51
|
+
&& item.capturedFrom?.sessionId === sessionId
|
|
52
|
+
&& Array.isArray(item.sessionIds)
|
|
53
|
+
&& (item.sessionIds.includes(sessionId) || item.sessionIds.includes('*')));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const noncompletedStates = new Set(['blocked', 'deferred', 'superseded', 'disputed']);
|
|
57
|
+
|
|
58
|
+
/** Explicit owner-session preference, not verified completion or authority to cancel user work. */
|
|
59
|
+
export function setAssistantCommitmentState(ledger, { itemText, state, sessionId, reason,
|
|
60
|
+
replacementReference, identity, at = new Date().toISOString() } = {}) {
|
|
61
|
+
if (!noncompletedStates.has(state) || !text(reason) || reason.length > 4000
|
|
62
|
+
|| !text(sessionId) || sessionId === '*' || !Number.isFinite(Date.parse(at))) {
|
|
63
|
+
throw new Error('commitment state requires a supported noncompleted state, exact session id and reason');
|
|
64
|
+
}
|
|
65
|
+
if (state === 'superseded' && (!text(replacementReference) || replacementReference === itemText)) {
|
|
66
|
+
throw new Error('superseded requires a distinct replacement reference');
|
|
67
|
+
}
|
|
68
|
+
const matches = (Array.isArray(ledger.items) ? ledger.items : []).filter((item) => item.text === itemText
|
|
69
|
+
&& assistantCommitmentOwned(item, sessionId, identity));
|
|
70
|
+
if (matches.length !== 1) throw new Error('exact assistant commitment is not uniquely owned by this session and worktree');
|
|
71
|
+
const item = matches[0];
|
|
72
|
+
if (item.stateHistory !== undefined && !Array.isArray(item.stateHistory)) {
|
|
73
|
+
throw new Error('existing commitment state history is malformed; retain it for explicit recovery');
|
|
74
|
+
}
|
|
75
|
+
const transition = { from: item.state || 'active', to: state, at, reason: reason.trim(),
|
|
76
|
+
provenance: { kind: 'explicit-session-cli', sessionId },
|
|
77
|
+
...(text(replacementReference) ? { replacementReference: replacementReference.trim() } : {}) };
|
|
78
|
+
item.stateHistory = [...(Array.isArray(item.stateHistory) ? item.stateHistory : []), transition];
|
|
79
|
+
item.state = state;
|
|
80
|
+
item.stateChangedAt = at;
|
|
81
|
+
return item;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// A state change suppresses only this assistant's nudge. The independent user objective is untouched.
|
|
85
|
+
export function authorizedPromiseItems(items, input, identity) {
|
|
86
|
+
if (!identity || input?.hook_event_name !== 'Stop' || !text(input.session_id)
|
|
87
|
+
|| input.interrupted || input.cancelled || input.stop_hook_active) return [];
|
|
88
|
+
return (Array.isArray(items) ? items : []).filter((item) => assistantCommitmentOwned(item, input.session_id, identity)
|
|
89
|
+
&& (item.state === undefined || item.state === 'active'));
|
|
55
90
|
}
|
|
@@ -22,7 +22,8 @@ import { withProgressionReader } from './project-progression-reader.mjs';
|
|
|
22
22
|
import { rufloRunDir } from './project-progression-store.mjs';
|
|
23
23
|
import { resolveRuflo, rufloInvocation } from './ruflo-bin.mjs';
|
|
24
24
|
import { resolveProjectStore } from './project-store-resolver.mjs';
|
|
25
|
-
import { resolveTurnDb, readSettledTranscript } from './turn-outcome-capture.mjs';
|
|
25
|
+
import { resolveTurnDb, readSettledTranscript, claudeTurn } from './turn-outcome-capture.mjs';
|
|
26
|
+
import { privateContinuityEvent, maskExcludedPaths, captureFailureReason, turnReferencesExcludedResource } from './turn-capture-privacy.mjs';
|
|
26
27
|
import {
|
|
27
28
|
CONTINUITY_NAMESPACE, INITIAL_LOOKBACK_MS, collectCommits, collectReleases, collectTurnEvents, eventIdOf, eventKey,
|
|
28
29
|
} from './continuity-events.mjs';
|
|
@@ -162,9 +163,8 @@ export class ContinuityJournal {
|
|
|
162
163
|
record(events) {
|
|
163
164
|
const consent = this.captureConsent();
|
|
164
165
|
if (consent.skipped) throw new Error(`event capture suspended: ${consent.skipped}`);
|
|
165
|
-
const known = this.knownIds();
|
|
166
|
-
const
|
|
167
|
-
for (const event of events) {
|
|
166
|
+
const known = this.knownIds(); const fresh = [];
|
|
167
|
+
for (const original of events) { const event = privateContinuityEvent(original, consent.contentPathExcludes, consent.capturePath);
|
|
168
168
|
const id = `${event.kind}:${event.id}`;
|
|
169
169
|
if (known.has(id)) continue;
|
|
170
170
|
known.add(id);
|
|
@@ -364,7 +364,6 @@ export function drain(journal, {
|
|
|
364
364
|
if (!ruflo) return { committed, failed, remaining: journal.pending().length, skipped: 'ruflo not found' };
|
|
365
365
|
if (!storeReady(journal.db)) return { committed, failed, remaining: journal.pending().length, skipped: 'store not initialized' };
|
|
366
366
|
for (const rec of journal.pending()) {
|
|
367
|
-
const value = JSON.stringify(rec.event);
|
|
368
367
|
let done = false;
|
|
369
368
|
let attempts = 0;
|
|
370
369
|
let last = null;
|
|
@@ -374,11 +373,12 @@ export function drain(journal, {
|
|
|
374
373
|
catch (error) { consent = { skipped: `capture consent unavailable: ${error.message}` }; }
|
|
375
374
|
if (consent.skipped) return { committed, failed, remaining: journal.pending().length, skipped: consent.skipped };
|
|
376
375
|
if (now() >= deadline) break;
|
|
377
|
-
|
|
378
|
-
|
|
376
|
+
const value = JSON.stringify(privateContinuityEvent(rec.event, consent.contentPathExcludes, consent.capturePath));
|
|
377
|
+
if (value !== JSON.stringify(rec.event)) return { committed, failed, remaining: journal.pending().length, skipped: 'content exclusions changed; immutable continuity retained' };
|
|
378
|
+
attempts += 1; const result = store({ ruflo, db: journal.db, key: rec.key, value });
|
|
379
379
|
const back = readBack({ ruflo, db: journal.db, key: rec.key });
|
|
380
380
|
const content = typeof back.content === 'string' ? back.content.trim() : '';
|
|
381
|
-
if (content && (content === value || sameEvent(content, rec.event))) {
|
|
381
|
+
if (content && (content === value || value === JSON.stringify(rec.event) && sameEvent(content, rec.event))) {
|
|
382
382
|
journal.appendRecords([{ type: 'commit', key: rec.key, digest: rec.digest, committedAt: new Date(now()).toISOString(), readPath: back.readPath,
|
|
383
383
|
alreadyStored: result.status !== 0 || content !== value, ...(attempts > 1 ? { attempts, lastError: last?.reason } : {}) }]);
|
|
384
384
|
committed += 1;
|
|
@@ -389,7 +389,7 @@ export function drain(journal, {
|
|
|
389
389
|
failed += 1;
|
|
390
390
|
done = true;
|
|
391
391
|
} else {
|
|
392
|
-
last = { reason: WAL_REFUSAL.test(result.output || '') ? 'wal-contention' : `store exited ${result.status}`, error:
|
|
392
|
+
last = { reason: WAL_REFUSAL.test(result.output || '') ? 'wal-contention' : `store exited ${result.status}`, error: maskExcludedPaths(captureFailureReason({ stderr: result.output }, result.status), consent.contentPathExcludes) };
|
|
393
393
|
const wait = backoff[Math.min(attempt, backoff.length - 1)];
|
|
394
394
|
if (attempt >= backoff.length || now() + wait >= deadline) break;
|
|
395
395
|
sleep(wait);
|
|
@@ -474,8 +474,8 @@ export function captureContinuityEvents({
|
|
|
474
474
|
if (host === 'claude' && typeof payload.transcript_path === 'string' && payload.transcript_path) {
|
|
475
475
|
try { lines = readTranscript(payload.transcript_path); } catch { /* unreadable transcript: git events still count */ }
|
|
476
476
|
}
|
|
477
|
-
const last = typeof payload.last_assistant_message === 'string' ? payload.last_assistant_message : '';
|
|
478
|
-
events.push(...collectTurnEvents({ lines, lastAssistantMessage: last, host, session, project, env, at: now() }));
|
|
477
|
+
const last = typeof payload.last_assistant_message === 'string' ? payload.last_assistant_message : ''; const policy = journal.captureConsent();
|
|
478
|
+
if (!policy.skipped && !(lines && turnReferencesExcludedResource(claudeTurn(lines), policy.contentPathExcludes, projectDir))) events.push(...collectTurnEvents({ lines, lastAssistantMessage: last, host, session, project, env, at: now() }));
|
|
479
479
|
}
|
|
480
480
|
try { report.recorded = journal.record(events).length; } catch (error) { return { ...report, skipped: `outbox write failed: ${error.message}` }; }
|
|
481
481
|
try { if (journal.needsCompaction()) journal.compact(); } catch { /* bounded next time */ }
|
|
@@ -446,6 +446,9 @@ fi
|
|
|
446
446
|
RUVNET=0
|
|
447
447
|
if printf '%s' "$TEXT" | grep -qiE '\bruvnet\b|\bruflo\b|\bruvector\b|\brvf\b|\bagentdb\b|\bagenticow\b|\brulake\b|\bruview\b|\brupixel\b|\bruv-fann\b|\bagentic-flow\b|\bsynthlang\b|\bdspy\b|\bqudag\b|\bsafla\b|\bmetaharness\b|\bcve-bench\b|\bsparc\b|\bswarms?\b|\bclaude-flow\b|\brUv\b'; then
|
|
448
448
|
RUVNET=1
|
|
449
|
+
# Only Gate1 is optional by product. Errors/no node retain default-all.
|
|
450
|
+
_SCOPE=$(printf '%s' "$TEXT" | "${RUVNET_NODE_BIN:-node}" "${BASH_SOURCE[0]%/*}/ruvnet-gate1-pattern.mjs" --scope-matches 2>/dev/null) || _SCOPE=1
|
|
451
|
+
[ "$_SCOPE" = "0" ] && RUVNET=0
|
|
449
452
|
fi
|
|
450
453
|
|
|
451
454
|
# ── Gate 2: is the task reaching for a CLASSICAL DEFAULT that rUv already replaced? ──────────────
|
|
@@ -79,7 +79,12 @@ export function projectGroundingInput(raw) {
|
|
|
79
79
|
return projection;
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
|
|
82
|
+
function isMain() {
|
|
83
|
+
try { return Boolean(process.argv[1]) && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)); }
|
|
84
|
+
catch { return false; }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (isMain()) {
|
|
83
88
|
try { process.stdout.write(projectGroundingInput(fs.readFileSync(0, 'utf8'))); }
|
|
84
89
|
catch { process.exitCode = 1; } // A failed exemption leaves the original guard in force.
|
|
85
90
|
}
|
|
@@ -414,9 +414,9 @@ export function answerSubjects(message) {
|
|
|
414
414
|
return [...out];
|
|
415
415
|
}
|
|
416
416
|
|
|
417
|
-
export function auditAssertions({ message, subjects = [], vocab = [], sources = null, stampTerms = [] }) {
|
|
417
|
+
export function auditAssertions({ message, subjects = [], vocab = [], sources = null, stampTerms = [], subjectAllowed = () => true }) {
|
|
418
418
|
const tools = [...new Set([...vocab, ...subjects, ...answerSubjects(message)])].filter((t) => t.length >= 3);
|
|
419
|
-
const claims = capabilityClaims(message, tools);
|
|
419
|
+
const claims = capabilityClaims(message, tools).filter((claim) => subjectAllowed(claim.subject));
|
|
420
420
|
const findings = [];
|
|
421
421
|
const unknown = [];
|
|
422
422
|
for (const claim of claims) {
|
|
@@ -88,8 +88,9 @@ import fs from 'node:fs';
|
|
|
88
88
|
import os from 'node:os';
|
|
89
89
|
import path from 'node:path';
|
|
90
90
|
import { fileURLToPath } from 'node:url';
|
|
91
|
+
import { groundingSubjectAllowed } from './ruvnet-gate1-pattern.mjs';
|
|
91
92
|
import { readStopHookInput } from './hook-input.mjs';
|
|
92
|
-
import { markerPathFor,
|
|
93
|
+
import { markerPathFor, consumeMarker } from './grounding-turn-mark.mjs';
|
|
93
94
|
import { readSettledTranscript } from './turn-outcome-capture.mjs';
|
|
94
95
|
import {
|
|
95
96
|
architectureShadow, auditAssertions, correctionText, describeSources, loadVocabulary, logShadow,
|
|
@@ -166,7 +167,8 @@ export function decide({ hookInput, marker, markerMs, env = process.env, read =
|
|
|
166
167
|
if (marker.assert && message) {
|
|
167
168
|
const vocab = loadVocabulary({ env });
|
|
168
169
|
const audit = auditAssertions({ message, subjects: marker.subjects, vocab, sources,
|
|
169
|
-
stampTerms: sources ? [] : stampTermsSince(markerMs)
|
|
170
|
+
stampTerms: sources ? [] : stampTermsSince(markerMs),
|
|
171
|
+
subjectAllowed: (subject) => groundingSubjectAllowed(subject, marker.groundingScope) });
|
|
170
172
|
if (audit.findings.length) assertion = audit.findings;
|
|
171
173
|
const shadow = [architectureShadow({ architecture: marker.architecture, message }), relayShadow({ message, sources })].filter(Boolean);
|
|
172
174
|
for (const row of shadow) logShadow({ ...row, at: new Date().toISOString(), session: hookInput.session_id, host });
|
|
@@ -174,7 +176,7 @@ export function decide({ hookInput, marker, markerMs, env = process.env, read =
|
|
|
174
176
|
|
|
175
177
|
// Gate 1 demands a search only when the answer ASSERTS what a rUv product does (the directive's
|
|
176
178
|
// own words). A status report, git/CI check or memory write on a rUv-named repo asserts nothing.
|
|
177
|
-
const ruvClaims = marker.gate1 === false ? [] : ruvCapabilityClaims(message);
|
|
179
|
+
const ruvClaims = marker.gate1 === false ? [] : ruvCapabilityClaims(message).filter((claim) => groundingSubjectAllowed(claim.subject, marker.groundingScope));
|
|
178
180
|
const grounded = !ruvClaims.length
|
|
179
181
|
|| (sources ? searchedThisTurn(sources) : wasGroundedSince(markerMs, newestGroundingStampMs()));
|
|
180
182
|
if (assertion) {
|
|
@@ -210,17 +212,9 @@ async function main() {
|
|
|
210
212
|
const marker = markerPathFor(hookInput.session_id);
|
|
211
213
|
if (!marker) process.exit(EXIT_ALLOW);
|
|
212
214
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
// Consume the marker unconditionally: whether this fires or not, it must never pressure a LATER,
|
|
217
|
-
// unrelated turn (same reasoning as continuation-gate.mjs's cooldown lock, applied here as a
|
|
218
|
-
// single-use marker instead of a timed window, because "did this turn ground itself" has no
|
|
219
|
-
// meaningful reading beyond the one turn it was written for).
|
|
220
|
-
const armed = readMarker(marker) || { gate1: true, subjects: [] };
|
|
221
|
-
try { fs.unlinkSync(marker); } catch { /* a marker that vanished between stat and unlink already told us what we needed */ }
|
|
222
|
-
|
|
223
|
-
const text = decide({ hookInput, marker: armed, markerMs: markerStat.mtimeMs });
|
|
215
|
+
const episode = consumeMarker(marker);
|
|
216
|
+
if (!episode) process.exit(EXIT_ALLOW);
|
|
217
|
+
const text = decide({ hookInput, marker: episode.marker, markerMs: episode.markerMs });
|
|
224
218
|
if (!text) process.exit(EXIT_ALLOW);
|
|
225
219
|
|
|
226
220
|
process.stdout.write(JSON.stringify({
|