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
@@ -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)|claimed|claims|said|says|reported|told|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;
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) || TRIVIAL_PROMISE.test(action.trim())) continue;
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
- } catch { /* the ledger is advisory — never break a turn over it */ }
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?.kind !== PROMISE_KIND || item.done || item.projectId !== pid) continue;
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 openKeys = new Set(led.items.filter((i) => i?.kind === PROMISE_KIND && !i.done && i.projectId === pid).map((i) => i.key));
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) || openKeys.size >= PROMISE_CAP_OPEN) continue;
411
+ if (openKeys.has(promise.key) || activeCount >= PROMISE_CAP_OPEN) continue;
392
412
  openKeys.add(promise.key);
393
- led.items.push({ schemaVersion: 1, kind: PROMISE_KIND, text: promise.text, key: promise.key, done: false, at,
394
- projectId: pid, worktreeIds: [projectIdentity.worktreeId], sessionIds: ['*'],
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. If you genuinely cannot do it, say so plainly and why.']
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
- // Promises the assistant made in a final answer ("I'll do X next"), captured by continuation-gate
43
- // into the SAME ledger (owner mandate 2026-09-15: "I will" is a contract). Same scoping discipline
44
- // as the objective above — project AND worktree must match, a session wildcard only as the literal
45
- // '*' — and the same loop guards. An item that is done, malformed, or foreign is never returned.
46
- export function authorizedPromiseItems(items, input, identity) {
47
- if (!identity || input?.hook_event_name !== 'Stop' || !text(input.session_id)
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
- && Array.isArray(item.sessionIds) && (item.sessionIds.includes('*') || item.sessionIds.includes(input.session_id)));
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 fresh = [];
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
- attempts += 1;
378
- const result = store({ ruflo, db: journal.db, key: rec.key, value });
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: String(result.output || '').trim().slice(-300) };
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
- if (process.argv[1] === fileURLToPath(import.meta.url)) {
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, readMarker } from './grounding-turn-mark.mjs';
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
- let markerStat;
214
- try { markerStat = fs.statSync(marker); } catch { process.exit(EXIT_ALLOW); }
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({