ruvnet-brain 4.5.14 → 4.5.15

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 CHANGED
@@ -7,7 +7,7 @@ Created: 2026-06-29 22:36:38 EDT
7
7
 
8
8
  # 🧠 RuvNet Brain
9
9
 
10
- ### 🧠 RuvNet Brain — [![RuvNet Brain version 4.5.14 — updated 2026-07-30 03:24 EDT](https://img.shields.io/badge/version_4.5.14-updated_2026--07--30_03:24_EDT-1E90FF?style=for-the-badge&labelColor=0757BA)](https://github.com/stuinfla/ruvnet-brain/blob/main/plugin/.claude-plugin/plugin.json)
10
+ ### 🧠 RuvNet Brain — [![RuvNet Brain version 4.5.15 — updated 2026-07-30 03:24 EDT](https://img.shields.io/badge/version_4.5.15-updated_2026--07--30_03:24_EDT-1E90FF?style=for-the-badge&labelColor=0757BA)](https://github.com/stuinfla/ruvnet-brain/blob/main/plugin/.claude-plugin/plugin.json)
11
11
 
12
12
  **A portable, source-grounded brain over Reuven Cohen's (rUv's) RuvNet stack — delivered as a Claude Code plugin that makes Claude _use_ the stack instead of fighting it.**
13
13
 
package/bin/install.mjs CHANGED
@@ -1750,6 +1750,42 @@ function atomicReplace(targetPath, writeTmp) {
1750
1750
  }
1751
1751
  }
1752
1752
 
1753
+ function legacyBrainWrapperOwnership(wrapperPath, home) {
1754
+ const source = path.join(REPO_ROOT, 'plugin', 'scripts', 'codex-hook-wrapper.mjs');
1755
+ try {
1756
+ const canonicalHome = fs.realpathSync(home);
1757
+ if (path.resolve(wrapperPath) !== path.resolve(home, '.cache', 'ruvnet-brain', 'codex-hook.mjs')) {
1758
+ return { state: 'conflict', reason: 'bridge is outside the expected Brain namespace' };
1759
+ }
1760
+ for (const relative of ['.cache', path.join('.cache', 'ruvnet-brain')]) {
1761
+ const ancestor = path.join(home, relative);
1762
+ let parent;
1763
+ try { parent = fs.lstatSync(ancestor); } catch (error) {
1764
+ if (error.code === 'ENOENT') break;
1765
+ throw error;
1766
+ }
1767
+ if (!parent.isDirectory() || parent.isSymbolicLink()
1768
+ || fs.realpathSync(ancestor) !== path.join(canonicalHome, relative)
1769
+ || (typeof process.getuid === 'function' && parent.uid !== process.getuid())) {
1770
+ return { state: 'conflict', reason: 'bridge namespace is indirect or belongs to another owner' };
1771
+ }
1772
+ }
1773
+ } catch { return { state: 'conflict', reason: 'bridge namespace could not be verified' }; }
1774
+ let stat;
1775
+ try { stat = fs.lstatSync(wrapperPath); } catch (error) {
1776
+ if (error.code === 'ENOENT') return { state: 'absent' };
1777
+ return { state: 'conflict', reason: 'existing bridge could not be inspected' };
1778
+ }
1779
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1
1780
+ || (typeof process.getuid === 'function' && stat.uid !== process.getuid())) {
1781
+ return { state: 'conflict', reason: 'existing bridge is not an unshared regular file' };
1782
+ }
1783
+ try {
1784
+ if (fs.readFileSync(wrapperPath).equals(fs.readFileSync(source))) return { state: 'verified' };
1785
+ } catch { /* Missing proof is not ownership. */ }
1786
+ return { state: 'conflict', reason: 'existing bridge differs from the shipped Brain source' };
1787
+ }
1788
+
1753
1789
  export function wireCodexHost({
1754
1790
  codexDir = codexHomeDir(),
1755
1791
  configPath = path.join(codexDir, 'config.toml'),
@@ -1763,6 +1799,14 @@ export function wireCodexHost({
1763
1799
  // No Codex on this machine is not a warning. Say nothing and change nothing.
1764
1800
  if (!host) return { host: false, action: 'no-host' };
1765
1801
 
1802
+ // Check ownership before retiring registrations or changing any installed bytes.
1803
+ const wrapperOwnership = legacyBrainWrapperOwnership(hookWrapperPath, path.dirname(codexDir));
1804
+ if (wrapperOwnership.state === 'conflict') {
1805
+ if (announce) warn(`Codex hook bridge preserved: ${wrapperOwnership.reason}`);
1806
+ return { host: true, action: 'hook-wrapper-ownership-conflict', hookWrapperInstalled: false,
1807
+ error: wrapperOwnership.reason };
1808
+ }
1809
+
1766
1810
  if (announce) {
1767
1811
  step('Wiring the Codex host', 'Codex reads MCP servers from ~/.codex/config.toml — the brain was never registered there');
1768
1812
  }
@@ -1819,12 +1863,12 @@ export function wireCodexHost({
1819
1863
  // the versioned cache so a plugin refresh cannot leave Codex pointing at a missing executable.
1820
1864
  // The bridge resolves the active immutable generation on every fire and fails open on every
1821
1865
  // non-contract error; only the two continuity registrations use it.
1822
- retireManagedHookRegistrations({ home: path.dirname(codexDir), codexDir, wrapperPath: hookWrapperPath });
1823
1866
  const hookWrapperSource = path.join(REPO_ROOT, 'plugin', 'scripts', 'codex-hook-wrapper.mjs');
1824
1867
  if (!fs.existsSync(hookWrapperSource)) {
1825
1868
  if (announce) warn(`Codex hook bridge source missing from this bundle: ${hookWrapperSource}`);
1826
1869
  return { host: true, action: 'hook-wrapper-source-missing' };
1827
1870
  }
1871
+ let hookRetirement;
1828
1872
  try {
1829
1873
  fs.mkdirSync(path.dirname(hookWrapperPath), { recursive: true });
1830
1874
  const wrapperStat = (() => { try { return fs.lstatSync(hookWrapperPath); } catch (error) {
@@ -1833,11 +1877,17 @@ export function wireCodexHost({
1833
1877
  if (wrapperStat?.isSymbolicLink()) {
1834
1878
  throw new Error('installer-owned hook bridge path is a symlink; refusing to overwrite it');
1835
1879
  }
1880
+ const currentOwnership = legacyBrainWrapperOwnership(hookWrapperPath, path.dirname(codexDir));
1881
+ if (currentOwnership.state === 'conflict') throw new Error(currentOwnership.reason);
1882
+ hookRetirement = retireManagedHookRegistrations({ home: path.dirname(codexDir), codexDir, wrapperPath: hookWrapperPath });
1836
1883
  atomicReplace(hookWrapperPath, (tmp) => fs.copyFileSync(hookWrapperSource, tmp));
1837
1884
  } catch (error) {
1838
1885
  if (announce) warn(`could not install the Codex hook bridge at ${hookWrapperPath}: ${error.message}`);
1839
1886
  return { host: true, action: 'hook-wrapper-install-failed', error: error.message };
1840
1887
  }
1888
+ if (announce && hookRetirement.conflicts.length) {
1889
+ warn(`Preserved ${hookRetirement.conflicts.length} customized or unverified hook entries.`);
1890
+ }
1841
1891
 
1842
1892
  let before = '';
1843
1893
  try { before = fs.readFileSync(configPath, 'utf8'); } catch { /* first run — no config yet */ }
@@ -1848,7 +1898,7 @@ export function wireCodexHost({
1848
1898
  info(` to hand it to us instead, delete that ${c.bold('[mcp_servers.ruvnet-brain]')} block and re-run this installer`);
1849
1899
  }
1850
1900
  return { host: true, action, serverPath, managedCliPath, runtimePreferencesPath,
1851
- hookWrapperPath, hookWrapperInstalled: true };
1901
+ hookWrapperPath, hookWrapperInstalled: true, hookOwnershipConflicts: hookRetirement.conflicts.length };
1852
1902
  }
1853
1903
  if (text !== before) {
1854
1904
  fs.mkdirSync(path.dirname(configPath), { recursive: true });
@@ -1860,7 +1910,8 @@ export function wireCodexHost({
1860
1910
  info(` ${c.dim('only our marked block is written; every other section is byte-preserved')}`);
1861
1911
  }
1862
1912
  return { host: true, action, serverPath, managedCliPath, runtimePreferencesPath,
1863
- hookWrapperPath, hookWrapperInstalled: true, changed: text !== before };
1913
+ hookWrapperPath, hookWrapperInstalled: true, hookOwnershipConflicts: hookRetirement.conflicts.length,
1914
+ changed: text !== before };
1864
1915
  }
1865
1916
 
1866
1917
  const CODEX_PLUGIN_ID = 'ruvnet-brain@ruvnet-brain';
@@ -2326,23 +2377,62 @@ export function codexLifecycleGuidance(status) {
2326
2377
  }
2327
2378
  }
2328
2379
 
2329
- // Remove only explicit Brain ownership or the exact installer-owned durable bridge.
2380
+ // Remove only byte-verified Brain bridges with an exact shipped hook definition.
2330
2381
  // Foreign commands, mixed command strings, unknown shapes, and their surrounding settings survive.
2331
2382
  export function retireManagedHookRegistrations({ home = os.homedir(), codexDir = codexHomeDir(),
2332
2383
  files = [path.join(home, '.claude', 'settings.json'), path.join(home, '.claude', 'settings.local.json'),
2333
2384
  path.join(codexDir, 'hooks.json')], wrapperPath = codexHookWrapperPath(codexDir) } = {}) {
2334
2385
  let removed = 0;
2335
2386
  const changedFiles = [];
2336
- const owns = (hook) => {
2337
- if (hook?.pluginId === 'ruvnet-brain@ruvnet-brain') return true;
2387
+ const conflicts = [];
2388
+ const verifiedWrapper = legacyBrainWrapperOwnership(wrapperPath, path.dirname(codexDir)).state === 'verified';
2389
+ const tokensOf = (command) => (command.match(/"[^"]*"|'[^']*'|[^\s]+/g) || [])
2390
+ .map((token) => token.replace(/^["']|["']$/g, ''));
2391
+ const registry = JSON.parse(fs.readFileSync(path.join(REPO_ROOT, 'plugin', 'hooks', 'codex-hooks.json'), 'utf8'));
2392
+ const definitions = Object.entries(registry.hooks).flatMap(([event, groups]) => groups.flatMap(group =>
2393
+ group.hooks.flatMap(hook => {
2394
+ const values = typeof hook.command === 'string' ? tokensOf(hook.command) : [];
2395
+ return values[0] === 'node' && values[1] === '-e' && /^\d+$/.test(values[3] || '')
2396
+ ? [{ event, matcher: String(group.matcher ?? '*'), hook, args: values.slice(4) }] : [];
2397
+ })));
2398
+ const owns = (hook, event, group) => {
2338
2399
  const command = hook?.command;
2339
- if (typeof command !== 'string' || /[;&|`\n\r]/.test(command)) return false;
2340
- const tokens = command.match(/"[^"]*"|'[^']*'|[^\s]+/g) || [];
2341
- const values = tokens.map((token) => token.replace(/^["']|["']$/g, ''));
2342
- return values.length >= 2 && /(?:^|[/\\])node(?:\.exe)?$/.test(values[0])
2343
- && values[1] === wrapperPath;
2400
+ if (!verifiedWrapper || typeof command !== 'string' || hook.type !== 'command'
2401
+ || (hook.pluginId != null && hook.pluginId !== 'ruvnet-brain@ruvnet-brain')
2402
+ || Object.keys(group).some(key => !['matcher', 'hooks'].includes(key))) return false;
2403
+ return definitions.some(definition => {
2404
+ if (definition.event !== event || definition.matcher !== String(group.matcher ?? '*')) return false;
2405
+ if (Object.keys(hook).some(key => !['command', 'pluginId', ...Object.keys(definition.hook)].includes(key))) return false;
2406
+ if (Object.keys(definition.hook).some(key => key !== 'command' && hook[key] !== definition.hook[key])) return false;
2407
+ if (Object.keys(hook).some(key => !['command', 'pluginId'].includes(key)
2408
+ && hook[key] !== definition.hook[key])) return false;
2409
+ if (command === definition.hook.command) return true;
2410
+ if (/[;&|`$\n\r]/.test(command)) return false;
2411
+ // Match literal installer forms, not a shell-like split: adjacent quoted tokens
2412
+ // can execute a different script even when a permissive splitter looks identical.
2413
+ if (/["']/.test(wrapperPath) || (process.platform !== 'win32' && wrapperPath.includes('\\'))
2414
+ || (process.platform === 'win32' && /[%!^]/.test(command))
2415
+ || definition.args.some(arg => !/^[\w-]+$/.test(arg))) return false;
2416
+ const scripts = [`"${wrapperPath}"`];
2417
+ if (process.platform !== 'win32') scripts.push(`'${wrapperPath}'`);
2418
+ if (/^[\w/.:~-]+$/.test(wrapperPath)) scripts.push(wrapperPath);
2419
+ const nodes = ['node', `"${process.execPath}"`];
2420
+ if (process.platform !== 'win32') nodes.push(`'${process.execPath}'`);
2421
+ if (/^[\w/.:~-]+$/.test(process.execPath)) nodes.push(process.execPath);
2422
+ return nodes.some(node => scripts.some(script =>
2423
+ command === `${node} ${script} ${definition.args.join(' ')}`));
2424
+ });
2344
2425
  };
2345
2426
  for (const file of files) {
2427
+ let stat;
2428
+ try { stat = fs.lstatSync(file); } catch (error) {
2429
+ if (error.code === 'ENOENT') continue;
2430
+ throw error;
2431
+ }
2432
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) {
2433
+ conflicts.push({ file, reason: 'shared or indirect settings preserved' });
2434
+ continue;
2435
+ }
2346
2436
  let before;
2347
2437
  try { before = fs.readFileSync(file, 'utf8'); } catch (error) {
2348
2438
  if (error.code === 'ENOENT') continue;
@@ -2355,7 +2445,16 @@ export function retireManagedHookRegistrations({ home = os.homedir(), codexDir =
2355
2445
  if (!Array.isArray(groups)) continue;
2356
2446
  document.hooks[event] = groups.flatMap((group) => {
2357
2447
  if (!Array.isArray(group?.hooks)) return [group];
2358
- const hooks = group.hooks.filter((hook) => { if (!owns(hook)) return true; count++; return false; });
2448
+ const hooks = group.hooks.filter((hook) => {
2449
+ if (!owns(hook, event, group)) {
2450
+ if (hook?.pluginId === 'ruvnet-brain@ruvnet-brain'
2451
+ || (typeof hook?.command === 'string' && hook.command.includes(wrapperPath))) {
2452
+ conflicts.push({ file, event, reason: 'unverified or customized hook preserved' });
2453
+ }
2454
+ return true;
2455
+ }
2456
+ count++; return false;
2457
+ });
2359
2458
  return hooks.length || group.hooks.length === 0 ? [{ ...group, hooks }] : [];
2360
2459
  });
2361
2460
  if (groups.length && document.hooks[event].length === 0) delete document.hooks[event];
@@ -2365,7 +2464,7 @@ export function retireManagedHookRegistrations({ home = os.homedir(), codexDir =
2365
2464
  removed += count;
2366
2465
  changedFiles.push(file);
2367
2466
  }
2368
- return { removed, changedFiles };
2467
+ return { removed, changedFiles, conflicts };
2369
2468
  }
2370
2469
 
2371
2470
  export function automaticHookRetirementStatus(root = REPO_ROOT, { scope = 'source' } = {}) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ruvnet-brain",
3
- "version": "4.5.14",
3
+ "version": "4.5.15",
4
4
  "description": "One-command installer for RuvNet Brain — a portable, source-grounded brain over rUv's RuvNet building blocks, delivered as a Claude Code plugin so Claude uses the stack instead of fighting it.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ruvnet-brain",
3
3
  "description": "RuvNet brain transplant for Claude Code — grounds every RuvNet decision in real source across 77 rUv repositories, prefers Ruflo / RuVector-RVF / AgentDB over training-prior defaults (pgvector, Pinecone, hand-rolled cosine), and can pull in any RuvNet repo on demand. Ships a UserPromptSubmit retrieve-and-inject grounding hook and a PreToolUse write gate that refuses ungrounded rUv-product code until search_ruvnet has been consulted (ADR-0012 / ADR-067).",
4
- "version": "4.5.14",
4
+ "version": "4.5.15",
5
5
  "author": {
6
6
  "name": "Stuart Kerr"
7
7
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ruvnet-brain",
3
- "version": "4.5.14",
3
+ "version": "4.5.15",
4
4
  "description": "Source-grounded RuvNet knowledge, lifecycle enforcement, and learning for Codex.",
5
5
  "author": {
6
6
  "name": "Stuart Kerr"
@@ -213,7 +213,11 @@ export function auditCompletionClaims(message, { turn = null, host = 'claude' }
213
213
  if (!namesCheck) problems.push('the answer does not name the check that proves it');
214
214
  if (!disclosesGaps) problems.push('the answer does not disclose what is NOT verified');
215
215
  const transcriptOk = turn ? verification.checks.length > 0 : false;
216
- const verdict = transcriptOk && namesCheck && disclosesGaps ? 'PASS' : !turn && namesCheck && disclosesGaps ? 'UNKNOWN' : 'FAIL';
216
+ const scopedCheck = claims.every(claim => /^(?:The\s+)?(?:targeted|selected|unit|syntax)\s+(?:unit\s+)?(?:checks?|tests?)\s+(?:is|are)\s+(?:now\s+)?(?:passing|green|verified)[.!]?$/i.test(claim.text));
217
+ // A transcript command is an observed check, not proof of the owner's whole task.
218
+ if (transcriptOk && namesCheck && disclosesGaps && !scopedCheck) problems.push('observed checks cover only their executed scope; whole-task completion is UNKNOWN');
219
+ const verdict = transcriptOk && namesCheck && disclosesGaps && scopedCheck ? 'OBSERVED_CHECK'
220
+ : namesCheck && disclosesGaps ? 'UNKNOWN' : 'FAIL';
217
221
  return { verdict, claims, verification, namesCheck, disclosesGaps, problems };
218
222
  }
219
223
 
@@ -272,16 +272,12 @@ if (has('--complete-objective')) {
272
272
  }
273
273
  const led = load();
274
274
  if (led.objective) {
275
- // DERIVED from the required argument checked above, not asserted (ADR-0024 / status-honesty.mjs):
276
- // this line is unreachable with blank/missing evidence — the guard exits 2 first. That proves an
277
- // explicit, human/model-invoked CLI action supplied named evidence; it does not verify the
278
- // evidence is TRUE, which is exactly why continuation-objective.mjs's own header already
279
- // documents this ledger as non-authoritative ("neither prove user provenance nor establish task
280
- // completion") — a nudge-suppression preference with the reason attached for audit, not a
281
- // verified fact standing in for a real completion check.
282
- led.objective.state = evidence.trim() ? 'completed' : led.objective.state;
283
- led.objective.completedAt = new Date().toISOString();
284
- led.objective.completionEvidence = evidence;
275
+ // Prose is a request, never verified completion or authority to suppress truth audits.
276
+ led.objective.completionRequestedAt = new Date().toISOString();
277
+ led.objective.completionRequest = evidence.trim();
278
+ save(led);
279
+ console.error('completion unverified: prose cannot complete this objective; retain unfinished work');
280
+ process.exit(2);
285
281
  }
286
282
  save(led);
287
283
  console.log(led.objective ? `objective completed: ${evidence}` : 'no objective to complete');
@@ -315,8 +311,8 @@ if (has('--help') || has('-h')) {
315
311
  ' --set-commitment-state <blocked|deferred|superseded|disputed> --item "<exact text>"',
316
312
  ' --session-id "<capturing session>" --reason "<why>" [--replacement "<reference>"]',
317
313
  ' retain a noncompleted assistant item; user work unchanged',
318
- ' --complete-objective "<evidence>" close the current objective as state: completed, with the',
319
- ' completion evidence recorded — leaves other ledger items',
314
+ ' --complete-objective "<evidence>" request closure; prose alone never marks completed',
315
+ ' unverified requests retain the unfinished objective;',
320
316
  ' untouched',
321
317
  ' --cancel-objective "<reason>" close the current objective as state: cancelled, with the',
322
318
  ' reason recorded — leaves other ledger items untouched',
@@ -372,7 +368,7 @@ const completion = (() => {
372
368
  const turn = HOST === 'claude' ? readClaudeTurn(hookInput.transcript_path) : null;
373
369
  // Claude always sends a JSONL transcript; one we cannot read is OUR failure, and a Stop hook
374
370
  // fails open on its own machinery (ADR-074 failure semantics) — never a correction request.
375
- if (HOST === 'claude' && !turn) return { verdict: 'NONE', claims: [], unreadable: true };
371
+ // Missing native evidence stays UNKNOWN; it cannot silently validate a completion claim.
376
372
  return auditCompletionClaims(message, { turn, host: HOST });
377
373
  } catch { return { verdict: 'NONE', claims: [] }; }
378
374
  })();
@@ -427,8 +423,7 @@ promiseBookkeeping();
427
423
  * emitted on a stop that is already a continuation.
428
424
  */
429
425
  if (hookInput.stop_hook_active) process.exit(EXIT_ALLOW);
430
- // Terminal objectives remain terminal even if legacy/global ledger rows or observations stay open.
431
- if (['cancelled', 'completed', 'blocked'].includes(led.objective?.state)) process.exit(EXIT_ALLOW);
426
+ // A terminal preference suppresses only its own nudge, never independent truth audits.
432
427
  const objective = authorizedContinuationObjective(led.objective, hookInput, projectIdentity);
433
428
 
434
429
  // LOOP-SAFETY 1b (GPT-5.6-Sol review) — an empty-but-parseable `{}` is NOT a real Stop payload; a genuine
@@ -665,7 +660,7 @@ if (observations.length) console.error(JSON.stringify({ kind: 'continuation-advi
665
660
  authority: false, items: observations.map(({ text, at }) => ({ text, at })) }));
666
661
  // ONE correction per turn: a single item however many claims the answer made; the stop_hook_active
667
662
  // exit and the cooldown lock below bound it to one request per stop episode.
668
- const completionWork = completion.verdict === 'FAIL' ? [{
663
+ const completionWork = ['FAIL', 'UNKNOWN'].includes(completion.verdict) ? [{
669
664
  text: `You claimed "${completion.claims[0].text.slice(0, 160)}" is done; ${completion.problems.join('; ')}. Run the real consumer path, or restate it as UNVERIFIED.`,
670
665
  at: new Date(nowMs).toISOString(), derived: true, kind: 'completion-claim-integrity',
671
666
  }] : [];
@@ -733,7 +728,9 @@ const ageLabel = (i) => {
733
728
  * This replaces a read-lastForcedAt-then-write that failed OPEN on a write error and let two hooks race.
734
729
  */
735
730
  const COOLDOWN_MS = Number(process.env.RUVNET_CONTINUATION_COOLDOWN_MS ?? 20000);
736
- const LOCK = LEDGER + '.cooldown';
731
+ const episode = crypto.createHash('sha256').update(JSON.stringify([projectIdentity.projectId, projectIdentity.worktreeId,
732
+ hookInput.session_id, led.objective?.id ?? null, hookInput.turn_id ?? String(hookInput.last_assistant_message || '')])).digest('hex');
733
+ const LOCK = LEDGER + '.cooldown.' + episode;
737
734
  function claimCooldown(now, windowMs) {
738
735
  try {
739
736
  const prev = Date.parse(fs.readFileSync(LOCK, 'utf8'));
@@ -771,8 +768,8 @@ const header = capabilityClaims.length
771
768
  ? ['Your proposed final answer contains a RuvNet capability claim that is contradicted or not provable.',
772
769
  'Do NOT deliver it unchanged — continue now and correct the claim from the sealed live-host inventory.']
773
770
  : completionClaims.length
774
- ? ['Your proposed final answer claims work is done without end-to-end evidence from this turn.',
775
- 'Do NOT deliver it unchanged — run the real consumer path now, or restate the claim as UNVERIFIED.']
771
+ ? ['Your proposed final answer claims completion beyond the available verified scope.',
772
+ 'Correct only that claim: report the observed check scope and what remains UNVERIFIED.']
776
773
  : committed.length && observed.length
777
774
  ? [`You have unfinished work you committed to, and ${repoLabel} has open work of its own.`,
778
775
  'Do NOT end the turn — continue now.']
@@ -836,12 +833,4 @@ const lines = [
836
833
  : []),
837
834
  ];
838
835
 
839
- process.stdout.write(JSON.stringify({
840
- hookSpecificOutput: {
841
- hookEventName: 'Stop', // must name the firing event or the envelope is discarded
842
- additionalContext: lines.join('\n'),
843
- },
844
- }));
845
-
846
- // Exit 0 regardless. This gate informs at the boundary; it never breaks the turn.
847
- process.exit(EXIT_ALLOW);
836
+ process.stdout.write(JSON.stringify({ decision: 'block', reason: lines.join('\n') }));
@@ -24,7 +24,7 @@ export function authorizedContinuationObjective(objective, input, identity) {
24
24
  if (!identity || input?.hook_event_name !== 'Stop' || !text(input.session_id)
25
25
  || input.interrupted || input.cancelled || input.stop_hook_active) return null;
26
26
  if (objective?.schemaVersion !== 1 || objective.kind !== 'continuation-preferences'
27
- || objective.authoritative !== false || objective.state !== 'active'
27
+ || objective.authoritative !== false || !['active', 'completed'].includes(objective.state)
28
28
  || !text(objective.id) || !text(objective.text) || !Number.isFinite(Date.parse(objective.at))
29
29
  || objective.authorization?.kind !== 'user' || !text(objective.authorization.reference)
30
30
  || objective.projectId !== identity.projectId
@@ -185,6 +185,16 @@ const TABLE = {
185
185
  };
186
186
 
187
187
  const hookId = process.argv[2];
188
+ // Native Claude supplies this exact registered plugin root; do not guess a host from absence.
189
+ if (['session-snapshot', 'continuation-gate'].includes(hookId)
190
+ && process.env.RUVNET_HOOK_HOST === undefined && process.env.CLAUDE_PLUGIN_ROOT) {
191
+ try {
192
+ const ownPluginRoot = fs.realpathSync(path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'));
193
+ if (fs.realpathSync(process.env.CLAUDE_PLUGIN_ROOT) === ownPluginRoot) process.env.RUVNET_HOOK_HOST = 'claude';
194
+ } catch { /* Missing or mismatched native registration remains unknown. */ }
195
+ }
196
+
197
+
188
198
  const entry = TABLE[hookId];
189
199
  if (!entry) {
190
200
  process.stderr.write(`[hook-shim] unknown hook id: ${JSON.stringify(hookId)} — known: ${Object.keys(TABLE).join(', ')}\n`);
@@ -155,8 +155,8 @@ export const RELEASE_REQUIREMENTS = Object.freeze({
155
155
  "tests/unit/model-routing-defence.test.mjs"
156
156
  ],
157
157
  "platformFiles": {
158
- "linux": ["tests/unit/model-terminal-canonical-entry.test.mjs", "tests/unit/codex-managed-terminal.test.mjs", "tests/unit/grok-subscription-host.test.mjs", "tests/unit/model-managed-parent-context-posix.test.mjs"],
159
- "macos": ["tests/unit/model-terminal-canonical-entry.test.mjs", "tests/unit/codex-managed-terminal.test.mjs", "tests/unit/grok-subscription-host.test.mjs", "tests/unit/model-managed-parent-context-posix.test.mjs"]
158
+ "linux": ["tests/unit/model-terminal-canonical-entry.test.mjs", "tests/unit/codex-managed-terminal.test.mjs", "tests/unit/grok-subscription-host.test.mjs", "tests/unit/model-managed-parent-context-posix.test.mjs", "tests/unit/automatic-hook-retirement-posix.test.mjs"],
159
+ "macos": ["tests/unit/model-terminal-canonical-entry.test.mjs", "tests/unit/codex-managed-terminal.test.mjs", "tests/unit/grok-subscription-host.test.mjs", "tests/unit/model-managed-parent-context-posix.test.mjs", "tests/unit/automatic-hook-retirement-posix.test.mjs"]
160
160
  }
161
161
  },
162
162
  {
@@ -243,14 +243,25 @@ export const RELEASE_REQUIREMENTS = Object.freeze({
243
243
  ]
244
244
  },
245
245
  {
246
- "id": "owner-capture-and-off-controls",
247
- "reason": "Brain OFF suppresses capacity execution; promise opt-out suppresses only new capture and preserves existing closure and capability truth",
248
- "files": [
249
- "tests/unit/capacity-aware-parallel-work.test.mjs",
250
- "tests/unit/continuation-gate-capability-truth.test.mjs",
251
- "tests/unit/continuation-gate-completion-claims.test.mjs"
252
- ]
253
- },
246
+ "id": "owner-capture-and-off-controls",
247
+ "reason": "Brain OFF suppresses capacity execution; promise opt-out suppresses only new capture and preserves existing closure and capability truth",
248
+ "files": [
249
+ "tests/unit/capacity-aware-parallel-work.test.mjs",
250
+ "tests/unit/continuation-gate-capability-truth.test.mjs",
251
+ "tests/unit/continuation-gate-completion-claims.test.mjs",
252
+ "tests/unit/continuation-gate-objective-close.test.mjs",
253
+ "tests/unit/continuation-objective.test.mjs",
254
+ "tests/unit/continuation-gate.test.mjs"
255
+ ],
256
+ "platformFiles": {
257
+ "linux": [
258
+ "tests/unit/hook-shim.test.mjs"
259
+ ],
260
+ "macos": [
261
+ "tests/unit/hook-shim.test.mjs"
262
+ ]
263
+ }
264
+ },
254
265
  {
255
266
  "id": "active-managed-generation",
256
267
  "reason": "Active code selection binds help authorization and execution to one immutable leased generation across promotion",