ruvnet-brain 4.5.3 → 4.5.5

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 (56) hide show
  1. package/README.md +2 -2
  2. package/bin/install.mjs +148 -31
  3. package/config/model-router/catalog.template.json +126 -52
  4. package/config/model-router/policy.default.mjs +94 -75
  5. package/config/model-router/qualification-contract.json +124 -0
  6. package/config/model-router/routing-eval-cases.json +275 -0
  7. package/config/model-router/routing-policy.template.json +76 -0
  8. package/config/model-router/weekly-analyst-instruction.md +60 -0
  9. package/data/model-catalog.json +44 -49
  10. package/package.json +3 -1
  11. package/plugin/.claude-plugin/plugin.json +1 -1
  12. package/plugin/.codex-plugin/plugin.json +1 -1
  13. package/plugin/hooks/codex-hooks.json +40 -3
  14. package/plugin/hooks/hook-contracts.json +218 -19
  15. package/plugin/hooks/hooks.json +51 -2
  16. package/plugin/scripts/agentdb-recall.mjs +101 -30
  17. package/plugin/scripts/codex-hook-adapter.mjs +18 -9
  18. package/plugin/scripts/continuity-hook-policy.mjs +9 -0
  19. package/plugin/scripts/continuity-journal.mjs +33 -33
  20. package/plugin/scripts/ground-ruvnet.sh +5 -5
  21. package/plugin/scripts/hook-shim.mjs +4 -30
  22. package/plugin/scripts/project-capture-queue.mjs +333 -0
  23. package/plugin/scripts/project-progression-contract.mjs +1 -1
  24. package/plugin/scripts/project-progression-hook.mjs +3 -3
  25. package/plugin/scripts/project-progression-producer.mjs +53 -28
  26. package/plugin/scripts/project-progression-session-start.mjs +44 -4
  27. package/plugin/scripts/project-progression-store.mjs +14 -0
  28. package/plugin/scripts/project-transition-hook.mjs +204 -0
  29. package/plugin/scripts/session-snapshot-hook.mjs +44 -272
  30. package/plugin/scripts/session-start-budget.mjs +2 -2
  31. package/plugin/scripts/turn-outcome-capture.mjs +125 -47
  32. package/plugin/scripts/turn-transport-journal.mjs +106 -0
  33. package/scripts/codex-hook-trust-reconcile.mjs +247 -0
  34. package/scripts/codex-routed.sh +3 -36
  35. package/scripts/goldie-weekly.sh +8 -64
  36. package/scripts/metaharness-router.mjs +7 -1
  37. package/scripts/model-analyst-sandbox.mjs +54 -0
  38. package/scripts/model-currency-evidence.mjs +139 -0
  39. package/scripts/model-currency.mjs +230 -0
  40. package/scripts/model-native-catalog.mjs +111 -0
  41. package/scripts/model-native-qualification.mjs +251 -0
  42. package/scripts/model-router-agent-hook.mjs +136 -0
  43. package/scripts/model-router-dispatch.mjs +161 -0
  44. package/scripts/model-router-engine.mjs +155 -104
  45. package/scripts/model-routing-eval.mjs +108 -0
  46. package/scripts/model-routing-gateway.mjs +420 -0
  47. package/scripts/model-routing-launchers.mjs +174 -0
  48. package/scripts/model-routing-policy-promotion.mjs +203 -0
  49. package/scripts/model-weekly-analyst.mjs +299 -0
  50. package/scripts/model-weekly-assessment.mjs +91 -0
  51. package/scripts/model-weekly-cycle.mjs +183 -0
  52. package/scripts/model-weekly-qualification.mjs +362 -0
  53. package/scripts/native-subscription-usage.mjs +57 -0
  54. package/scripts/release-qualification-contract.mjs +54 -0
  55. package/scripts/security-guidance-codex-compat.mjs +142 -0
  56. package/scripts/user-model-prompt-hook.mjs +69 -0
@@ -31,9 +31,8 @@
31
31
  *
32
32
  * LATENCY: Stop runs synchronously in the host's turn, and one `ruflo memory store` costs ~0.7s
33
33
  * (measured). So the writes run in ONE detached worker (this file, `--run-steps`), store then
34
- * distill in order; the hook itself only reads, fingerprints and spawns. A breadcrumb line is
35
- * containing only key, hash and length is appended BEFORE the spawn; exact readback receipts make a lost
36
- * write is visible rather than silent. Advisory always: nothing here throws to the caller.
34
+ * distill in order; the hook itself only reads, fingerprints and spawns. A project-local journal containing redacted content and canonical binding is fsynced BEFORE
35
+ * spawning; fresh sessions replay stable keys without upsert and require exact readback receipts. Advisory always: nothing here throws to the caller.
37
36
  */
38
37
  import fs from 'node:fs';
39
38
  import os from 'node:os';
@@ -44,6 +43,7 @@ import { fileURLToPath } from 'node:url';
44
43
  import { resolveRuflo, rufloInvocation } from './ruflo-bin.mjs';
45
44
  import { redactText, userLevelAgentdbHooks } from './continuity-events.mjs';
46
45
  import { resolveProjectStore } from './project-store-resolver.mjs';
46
+ import { digest, journalTurn, readJournal, pendingTurnFiles, acknowledgeJournal, appendReceipt, storeData } from './turn-transport-journal.mjs';
47
47
 
48
48
  export const TURN_NAMESPACE = 'turns';
49
49
  export const MIN_OUTCOME_CHARS = 200;
@@ -154,8 +154,9 @@ const consentMap = (value) => value !== null && typeof value === 'object'
154
154
  && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype
155
155
  && Object.values(value).every((setting) => setting === 'on' || setting === 'off');
156
156
 
157
- export function resolveTurnDb({ projectDir, brainHome, requestedStorePath, gitTimeoutMs = 1000 } = {}) {
157
+ export function resolveTurnDb({ projectDir, brainHome, requestedStorePath, gitTimeoutMs = 1000, unknownOriginalPath = false } = {}) {
158
158
  const resolved = resolveProjectStore({ projectDir, requestedStorePath, gitTimeoutMs });
159
+ const capturePath = fs.realpathSync.native(projectDir);
159
160
  let policy = {};
160
161
  const file = brainHome && turnCapturePolicyFile(brainHome);
161
162
  if (file && fs.existsSync(file)) {
@@ -166,15 +167,19 @@ export function resolveTurnDb({ projectDir, brainHome, requestedStorePath, gitTi
166
167
  } catch { return { skipped: 'turn capture policy unreadable or invalid', projectRoot: resolved.projectRoot }; }
167
168
  }
168
169
  // A path rule wins over a project rule, allowing a linked checkout/subdirectory to opt out.
169
- const setting = policy.paths?.[fs.realpathSync.native(projectDir)] ?? policy.projects?.[resolved.projectRoot];
170
+ const setting = policy.paths?.[capturePath] ?? policy.projects?.[resolved.projectRoot];
170
171
  if (setting !== undefined && !['on', 'off'].includes(setting)) return { skipped: 'invalid turn capture consent', projectRoot: resolved.projectRoot };
171
172
  const db = resolved.canonicalAgentDbPath;
173
+ if (unknownOriginalPath && Object.entries(policy.paths || {}).some(([origin, choice]) => choice === 'off'
174
+ && (origin === resolved.checkoutRoot || origin.startsWith(`${resolved.checkoutRoot}${path.sep}`)))) {
175
+ return { db, scope: 'project', capturePath, projectRoot: resolved.projectRoot, skipped: 'capture origin cannot be verified under path opt-out' };
176
+ }
172
177
  assertTurnStoreFiles(db);
173
- if (setting === 'off') return { db, scope: 'project', projectRoot: resolved.projectRoot, skipped: 'persisted turn capture opt-out' };
178
+ if (setting === 'off') return { db, scope: 'project', capturePath, projectRoot: resolved.projectRoot, skipped: 'persisted turn capture opt-out' };
174
179
  let exists = false;
175
180
  try { exists = fs.statSync(db).isFile(); } catch { /* absent */ }
176
- if (!exists && setting !== 'on') return { db, scope: 'project', projectRoot: resolved.projectRoot, skipped: 'no project memory db; persisted opt-in required' };
177
- return { db, scope: 'project', projectRoot: resolved.projectRoot, optedIn: setting === 'on' };
181
+ if (!exists && setting !== 'on') return { db, scope: 'project', capturePath, projectRoot: resolved.projectRoot, skipped: 'no project memory db; persisted opt-in required' };
182
+ return { db, scope: 'project', capturePath, projectRoot: resolved.projectRoot, optedIn: setting === 'on' };
178
183
  }
179
184
 
180
185
  // Revalidation narrows the queue-to-launch window; it does not make SQLite's later open atomic.
@@ -197,9 +202,9 @@ function assertTurnStoreFiles(db) {
197
202
  const projectName = (projectDir) => redactText(path.basename(path.resolve(projectDir))).replace(/[^A-Za-z0-9._-]/g, '_') || 'project';
198
203
 
199
204
  /** Detached worker launch: the hook returns immediately; the worker runs the steps in order. */
200
- export function launchDetached(steps, { receipts }) {
201
- const child = spawn(process.execPath, [fileURLToPath(import.meta.url), '--run-steps', JSON.stringify({ steps, receipts })], {
202
- cwd: os.tmpdir(), detached: true, stdio: 'ignore', windowsHide: true, env: { ...process.env, ...RUFLO_ENV },
205
+ export function launchDetached(steps, { receipts, brainHome, projectDir, env = process.env }) {
206
+ const child = spawn(process.execPath, [fileURLToPath(import.meta.url), '--run-steps', JSON.stringify({ steps: steps.map((step) => step.kind === 'store' && step.journalFile ? { kind: 'journal', journalFile: step.journalFile, db: step.args[step.args.indexOf('--path') + 1] } : step), receipts })], {
207
+ cwd: os.tmpdir(), detached: true, stdio: 'ignore', windowsHide: true, env: { ...env, ...RUFLO_ENV, ...(brainHome ? { RUVNET_BRAIN_HOME: brainHome } : {}), ...(projectDir ? { RUVNET_TURN_PROJECT_DIR: projectDir } : {}) },
203
208
  });
204
209
  child.unref();
205
210
  return { launched: true, pid: child.pid };
@@ -257,7 +262,8 @@ export function captureTurnOutcome({
257
262
  Object.assign(report, { db, scope });
258
263
  if (target.skipped) return { ...report, skipped: target.skipped, distill: { queued: false, skipped: target.skipped } };
259
264
  const steps = [];
260
- const binding = { projectRoot, projectDir: fs.realpathSync.native(projectDir), brainHome };
265
+ const rootStat = fs.statSync(projectRoot);
266
+ const binding = { projectRoot, projectDir: fs.realpathSync.native(projectDir), brainHome, rootIdentity: `${rootStat.dev}:${rootStat.ino}` };
261
267
  let dedupe;
262
268
  const project = projectName(projectRoot);
263
269
  const receipts = path.join(brainHome, 'turn-capture', 'receipts.jsonl');
@@ -292,11 +298,17 @@ export function captureTurnOutcome({
292
298
  if (sameTurnSeen(stateFile, identity, fingerprint, receipts)) report.skipped = 'same turn outcome already queued or verified';
293
299
  else {
294
300
  const at = now();
295
- const key = `turn-${project}-${at.getTime()}-${crypto.randomBytes(6).toString('hex')}`;
301
+ const key = `turn-${project}-${digest(`${identity}:${fingerprint}`).slice(0, 40)}`;
296
302
  dedupe = { stateFile, identity, fingerprint, key };
297
- const value = buildTurnRecord({ turn, project, host, session: payload.session_id, at });
303
+ let value = buildTurnRecord({ turn, project, host, session: payload.session_id, at });
304
+ // Retry uses the original durable content (including its timestamp), never a new value.
305
+ const pendingFile = path.join(path.dirname(db), 'turn-outbox', `${digest(key)}.json`);
306
+ if (fs.existsSync(pendingFile)) {
307
+ try { const pending = readJournal(pendingFile, db); value = pending.value; }
308
+ catch (error) { return { ...report, skipped: `durable turn queue invalid: ${redactText(error.message)}` }; }
309
+ }
298
310
  steps.push({ ...binding, kind: 'store', ruflo, args: ['memory', 'store', '-k', key, '--value', value, '-n', TURN_NAMESPACE,
299
- '--path', db, '--tags', `project=${project},host=${host}`, '--provenance', 'agent_output'] });
311
+ '--path', db, '--no-upsert', '--tags', `project=${project},host=${host}`, '--provenance', 'agent_output'] });
300
312
  // This synchronous boundary proves only queuing; the worker's exact receipt proves recording.
301
313
  Object.assign(report, { queued: true, key, value });
302
314
  }
@@ -316,10 +328,12 @@ export function captureTurnOutcome({
316
328
  // Only explicit persisted consent permits creating a project store directory.
317
329
  if (target.optedIn) fs.mkdirSync(path.dirname(db), { recursive: true, mode: 0o700 });
318
330
  if (report.queued) {
331
+ const step = steps.find((item) => item.kind === 'store');
332
+ step.journalFile = journalTurn(step, db, report.key, { onDurability: (evidence) => { report.durability = evidence; } });
319
333
  fs.appendFileSync(path.join(path.dirname(db), 'agentdb-turns.jsonl'),
320
334
  `${JSON.stringify({ ts: Date.now(), key: report.key, hash: crypto.createHash('sha256').update(report.value).digest('hex'), len: report.value.length })}\n`, { mode: 0o600 });
321
335
  }
322
- report.launch = launch(steps, { receipts });
336
+ report.launch = launch(steps, { receipts, brainHome, projectDir: binding.projectDir, env });
323
337
  if (dedupe) markTurnQueued(dedupe.stateFile, dedupe.identity, dedupe.fingerprint, dedupe.key);
324
338
  } catch (error) {
325
339
  return { ...report, queued: false, recorded: false, distill: { queued: false, skipped: `launch failed: ${redactText(error.message)}` }, skipped: `launch failed: ${redactText(error.message)}` };
@@ -335,48 +349,106 @@ function readBack({ ruflo, db, key, run, options }) {
335
349
  }
336
350
 
337
351
  /** The detached worker: bounded steps, safe error evidence, exact content readback. */
338
- export function runSteps({ steps = [], receipts } = {}, { run = spawnSync, read = readBack, env = process.env } = {}) {
352
+ export function runSteps({ steps = [], receipts } = {}, { run: suppliedRun = spawnSync, read = readBack,
353
+ env = process.env, deadlineMs, home = os.homedir(),
354
+ projectDir = env.RUVNET_TURN_PROJECT_DIR || process.cwd(),
355
+ brainHome = env.RUVNET_BRAIN_HOME || path.join(home, '.cache', 'ruvnet-brain') } = {}) {
356
+ const run = (binary, args, options) => {
357
+ if (deadlineMs === undefined) return suppliedRun(binary, args, options);
358
+ const remaining = deadlineMs - Date.now();
359
+ if (remaining <= 0) return { status: 1, error: new Error('turn replay deadline exhausted') };
360
+ return suppliedRun(binary, args, { ...options, timeout: Math.min(options.timeout, remaining) });
361
+ };
339
362
  const results = [];
340
- for (const step of steps) {
341
- let status = null; let error = null; let verified = false;
342
- const args = [...step.args];
343
- const db = args[args.indexOf(step.kind === 'store' ? '--path' : '--db') + 1];
344
- const key = step.kind === 'store' ? args[args.indexOf('-k') + 1] : undefined;
345
- const valueIndex = args.indexOf('--value') + 1;
346
- if (step.kind === 'store') args[valueIndex] = redactText(args[valueIndex]);
363
+ for (const queued of steps) {
364
+ let status = 1; let error = null; let verified = false; let db = ''; let key;
365
+ const kind = queued.kind === 'journal' ? 'store' : queued.kind;
347
366
  try {
348
- if (!step.projectRoot || !step.projectDir || !step.brainHome) throw new Error('queued step has no canonical project binding');
349
- const target = resolveTurnDb({ projectDir: step.projectDir, brainHome: step.brainHome, requestedStorePath: db, gitTimeoutMs: 1000 });
350
- if (target.skipped) throw new Error(target.skipped);
351
- if (target.projectRoot !== step.projectRoot || target.db !== db) throw new Error('queued canonical project/store identity changed');
352
367
  if (String(env.RUVNET_TURN_CAPTURE || '').toLowerCase() === 'off') throw new Error('RUVNET_TURN_CAPTURE=off');
353
- const invocation = rufloInvocation(step.ruflo, args);
354
- // Contain Ruflo's auxiliary writes in the canonical store's own directory.
355
- const home = path.dirname(db);
356
- const options = { encoding: 'utf8', timeout: STEP_TIMEOUT_MS, cwd: home, windowsHide: true,
357
- maxBuffer: 1024 * 1024, env: { ...env, ...RUFLO_ENV, CLAUDE_FLOW_MEMORY_PATH: home } };
358
- const r = run(invocation.executable, invocation.args, options);
368
+ if (kind === 'store' && queued.kind !== 'journal') { const data = storeData(queued); key = data.key; db = data.db; }
369
+ if (queued.kind !== 'journal' && (!queued.projectRoot || !queued.projectDir || !queued.brainHome)) throw new Error('queued step has no canonical project binding');
370
+ const target = resolveTurnDb({ projectDir, brainHome, gitTimeoutMs: 1000 });
371
+ if (target.skipped) throw new Error(target.skipped);
372
+ db = target.db;
373
+ let args; let value; let binding;
374
+ if (kind === 'store') {
375
+ const data = queued.kind === 'journal' ? readJournal(queued.journalFile, db) : storeData(queued);
376
+ if (queued.kind === 'journal' && queued.db !== db) throw new Error('foreign turn queue store rejected');
377
+ if (queued.kind !== 'journal' && data.db !== db) throw new Error('foreign turn store rejected');
378
+ if (data.legacyBrainHome && data.legacyBrainHome !== brainHome) throw new Error('queued consent settings differ from trusted caller');
379
+ if (queued.kind !== 'journal' && queued.brainHome !== brainHome) throw new Error('queued consent settings differ from trusted caller');
380
+ binding = queued.kind === 'journal' ? data.binding : queued;
381
+ key = data.key; value = redactText(data.value);
382
+ args = ['memory', 'store', '-k', key, '--value', value, '-n', TURN_NAMESPACE, '--path', db, '--no-upsert', '--provenance', 'agent_output'];
383
+ } else if (kind === 'distill') {
384
+ if (queued.brainHome !== brainHome) throw new Error('queued consent settings differ from trusted caller');
385
+ const recipe = queued.args;
386
+ if (!Array.isArray(recipe) || recipe.slice(0, 3).join(' ') !== 'memory distill run') throw new Error('invalid turn distill operation');
387
+ const seen = new Set();
388
+ for (let i = 3; i < recipe.length; i += 2) {
389
+ const flag = recipe[i]; const value = recipe[i + 1];
390
+ if (seen.has(flag) || !['--db', '--namespace', '--max-entries'].includes(flag) || typeof value !== 'string') throw new Error('invalid turn distill flags');
391
+ seen.add(flag);
392
+ if (flag === '--db' && value !== db || flag === '--namespace' && value !== TURN_NAMESPACE || flag === '--max-entries' && value !== '500') throw new Error('invalid turn distill scope');
393
+ }
394
+ if (!seen.has('--db')) throw new Error('missing turn distill database');
395
+ binding = queued;
396
+ args = ['memory', 'distill', 'run', '--db', db, '--namespace', TURN_NAMESPACE, '--max-entries', '500'];
397
+ } else throw new Error('invalid turn worker operation');
398
+ if (!binding.projectRoot || !binding.projectDir) throw new Error('queued step has no canonical project binding');
399
+ const rootStat = fs.statSync(target.projectRoot);
400
+ if (binding.rootIdentity && binding.rootIdentity !== `${rootStat.dev}:${rootStat.ino}`) throw new Error('queued canonical project identity changed');
401
+ if (binding.projectRoot !== target.projectRoot) throw new Error('queued canonical project/store identity changed');
402
+ const origin = resolveTurnDb({ projectDir: binding.projectDir, brainHome, requestedStorePath: db, gitTimeoutMs: 1000 });
403
+ if (origin.skipped) throw new Error(origin.skipped);
404
+ if (origin.projectRoot !== target.projectRoot || origin.db !== db) throw new Error('queued canonical capture origin changed');
405
+ const ruflo = resolveRuflo({ env, home });
406
+ if (!ruflo) throw new Error('ruflo not found for durable replay');
407
+ const invocation = rufloInvocation(ruflo, args);
408
+ const remaining = deadlineMs === undefined ? STEP_TIMEOUT_MS : deadlineMs - Date.now();
409
+ if (remaining <= 0) throw new Error('turn replay deadline exhausted');
410
+ const options = { encoding: 'utf8', timeout: Math.min(STEP_TIMEOUT_MS, remaining), cwd: path.dirname(db), windowsHide: true,
411
+ maxBuffer: 1024 * 1024, env: { ...env, ...RUFLO_ENV, CLAUDE_FLOW_MEMORY_PATH: path.dirname(db) } };
412
+ const existing = kind === 'store' && queued.journalFile ? read({ ruflo, db, key, run, options }) : null;
413
+ if (existing !== null && existing !== value) throw new Error('existing turn key content differs; no upsert permitted');
414
+ verified = kind === 'store' && existing === value;
415
+ const r = verified ? { status: 0 } : run(invocation.executable, invocation.args, options);
359
416
  status = Number.isInteger(r.status) ? r.status : 1;
360
- if (r.error || status !== 0) error = redactText(r.error?.message || String(r.stderr || '').trim().split(/\r?\n/)[0] || `ruflo exited ${status}`).slice(0, 300);
361
- else if (step.kind === 'store') {
362
- verified = read({ ruflo: step.ruflo, db, key, run, options }) === args[valueIndex];
363
- if (!verified) { status = 1; error = 'exact turn key/content readback failed'; }
364
- }
417
+ if (kind === 'store' && !verified) verified = read({ ruflo, db, key, run, options }) === value;
418
+ if (verified) status = 0;
419
+ if (!verified && (r.error || status !== 0)) error = redactText(r.error?.message || String(r.stderr || '').trim().split(/\r?\n/)[0] || `ruflo exited ${status}`).slice(0, 300);
420
+ else if (kind === 'store' && !verified) { status = 1; error = 'exact turn key/content readback failed'; }
365
421
  } catch (e) { status = 1; error = redactText(e.message).slice(0, 300); }
366
- const row = { at: new Date().toISOString(), kind: step.kind, db: redactText(db),
367
- storeIdentity: crypto.createHash('sha256').update(db).digest('hex'), key, status, error,
368
- ...(step.kind === 'store' ? { verified } : {}) };
422
+ const row = { at: new Date().toISOString(), kind, db: redactText(db), storeIdentity: digest(db), key, status, error,
423
+ ...(kind === 'store' ? { verified } : {}) };
369
424
  results.push(row);
370
425
  if (receipts) {
371
- try {
372
- fs.mkdirSync(path.dirname(receipts), { recursive: true, mode: 0o700 });
373
- fs.appendFileSync(receipts, `${JSON.stringify(row)}\n`, { mode: 0o600 });
374
- } catch { /* receipts are best effort */ }
426
+ try { appendReceipt(receipts, row); if (verified && queued.journalFile) acknowledgeJournal(queued.journalFile, db); }
427
+ catch { /* failed receipt persistence retains the journal for exact-key retry */ }
375
428
  }
376
429
  }
377
430
  return results;
378
431
  }
379
432
 
433
+ /** Replay only bounded canonical project entries; consent suspends rather than deletes them. */
434
+ export function replayTurnQueue({ projectDir = process.cwd(), env = process.env, home = os.homedir(),
435
+ brainHome = env.RUVNET_BRAIN_HOME || path.join(home, '.cache', 'ruvnet-brain'), limit = 10,
436
+ launch = launchDetached, synchronous = false, runner, deadlineMs } = {}) {
437
+ if (String(env.RUVNET_TURN_CAPTURE || '').toLowerCase() === 'off') return { queued: false, skipped: 'RUVNET_TURN_CAPTURE=off' };
438
+ try {
439
+ const target = resolveTurnDb({ projectDir, brainHome });
440
+ if (target.skipped) return { queued: false, skipped: target.skipped };
441
+ const steps = pendingTurnFiles(target.db, limit).map((journalFile) => ({ kind: 'journal', journalFile, db: target.db }));
442
+ if (!steps.length) return { queued: false, count: 0 };
443
+ if (synchronous) {
444
+ const results = runSteps({ steps, receipts: path.join(brainHome, 'turn-capture', 'receipts.jsonl') }, { env, home, projectDir, brainHome, ...(runner ? { run: runner } : {}), deadlineMs });
445
+ const pending = pendingTurnFiles(target.db, 25).length;
446
+ return { queued: false, count: steps.length, pending, failed: results.filter((row) => row.status !== 0).length, verified: results.filter((row) => row.verified === true).length, results };
447
+ }
448
+ return { queued: true, count: steps.length, pending: steps.length, launch: launch(steps, { receipts: path.join(brainHome, 'turn-capture', 'receipts.jsonl'), projectDir, brainHome, env }) };
449
+ } catch (error) { return { queued: false, skipped: redactText(error.message) }; }
450
+ }
451
+
380
452
  /** Bounded recent evidence, isolated by canonical db. A continuity success cannot mask turn failures. */
381
453
  export function turnRecordingStatus({ projectDir = process.cwd(), env = process.env, home = os.homedir(), now = Date.now() } = {}) {
382
454
  const brainHome = env.RUVNET_BRAIN_HOME || path.join(home, '.cache', 'ruvnet-brain');
@@ -389,8 +461,14 @@ export function turnRecordingStatus({ projectDir = process.cwd(), env = process.
389
461
  try { return [JSON.parse(line)]; } catch { return []; }
390
462
  }).filter((r) => r.kind === 'store' && (r.storeIdentity === crypto.createHash('sha256').update(target.db).digest('hex') || r.db === target.db) && now - Date.parse(r.at) < 7 * 86_400_000).slice(-20);
391
463
  } catch { /* first run */ }
392
- const failed = rows.filter((r) => r.status !== 0 || r.verified !== true);
464
+ rows = [...new Map(rows.map((row) => [row.key || row.at, row])).values()];
465
+ let pending = 0;
466
+ try { pending = pendingTurnFiles(target.db, 25).length; } catch { return { state: 'warn', line: 'turn recording durable queue unsafe or unreadable' }; }
467
+ const historical = rows.filter((r) => r.status === 0 && r.verified === undefined);
468
+ const failed = rows.filter((r) => r.status !== 0 || r.verified === false);
393
469
  if (failed.length) return { state: 'warn', line: `turn recording failing ${failed.length}/${rows.length} — ${redactText(failed.at(-1).error || 'write has no exact readback evidence').slice(0, 300)}` };
470
+ if (historical.length) return { state: 'unknown', line: `turn recording unverified historical (${historical.length} receipts without exact readback evidence)` };
471
+ if (pending) return { state: 'unknown', line: `turn recording pending durable replay (${pending} entries); not yet proven` };
394
472
  return rows.length ? { state: 'ok', line: `turn recording ✓ (${rows.length}/${rows.length} exact readbacks)` }
395
473
  : { state: 'unknown', line: 'turn recording not yet proven — no exact readback receipt' };
396
474
  }
@@ -0,0 +1,106 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+
5
+ export const digest = (value) => crypto.createHash('sha256').update(value).digest('hex');
6
+ export const turnQueueDirectory = (db) => path.join(path.dirname(db), 'turn-outbox');
7
+ function regular(file, directory = false) {
8
+ const stat = fs.lstatSync(file);
9
+ if (stat.isSymbolicLink() || (!directory && (!stat.isFile() || stat.nlink !== 1))
10
+ || (directory && (!stat.isDirectory() || fs.realpathSync.native(file) !== file))) throw new Error('unsafe turn journal path');
11
+ }
12
+ // Node's Windows directory handles do not consistently support fsync. Never weaken
13
+ // file fsync or hide unexpected I/O errors; a missing namespace flush is reported.
14
+ export function syncTurnDirectory(dir, { platform = process.platform, io = fs } = {}) {
15
+ let fd;
16
+ try {
17
+ fd = io.openSync(dir, 'r'); io.fsyncSync(fd);
18
+ return { platform, directoryFsync: 'completed' };
19
+ } catch (error) {
20
+ if (platform !== 'win32' || !['EISDIR', 'EPERM', 'EACCES', 'EINVAL', 'ENOTSUP', 'EBADF'].includes(error.code)) throw error;
21
+ return { platform, directoryFsync: 'unavailable', reason: error.code,
22
+ limitation: 'file fsync remains required; directory-entry persistence across power loss is unproven' };
23
+ } finally { if (fd !== undefined) io.closeSync(fd); }
24
+ }
25
+ // Legacy recipes are data extraction inputs only, never executable commands.
26
+ export function storeData(step) {
27
+ if (step?.kind !== 'store' || !Array.isArray(step.args) || step.args[0] !== 'memory' || step.args[1] !== 'store') throw new Error('invalid turn store operation');
28
+ const fields = {}; const aliases = { '-k': 'key', '--key': 'key', '--value': 'value', '-n': 'namespace', '--namespace': 'namespace', '--path': 'db', '--tags': 'tags', '--provenance': 'provenance' };
29
+ let strict = false;
30
+ for (let i = 2; i < step.args.length; i++) {
31
+ const flag = step.args[i];
32
+ if (flag === '--no-upsert') { if (strict) throw new Error('duplicate turn store flag'); strict = true; continue; }
33
+ const field = aliases[flag];
34
+ if (!field || Object.hasOwn(fields, field) || typeof step.args[i + 1] !== 'string') throw new Error('unknown or duplicate turn store flag');
35
+ fields[field] = step.args[++i];
36
+ }
37
+ if (!fields.key || typeof fields.value !== 'string' || fields.namespace !== 'turns' || !path.isAbsolute(fields.db || '')) throw new Error('invalid turn store namespace or data');
38
+ return { key: fields.key, value: fields.value, db: fields.db };
39
+ }
40
+ const bindingOf = (step) => ({ projectRoot: step.projectRoot, projectDir: step.projectDir, rootIdentity: step.rootIdentity });
41
+ function validateBinding(binding) {
42
+ if (!binding || Object.keys(binding).some((key) => !['projectRoot', 'projectDir', 'rootIdentity'].includes(key))
43
+ || !path.isAbsolute(binding.projectRoot || '') || !path.isAbsolute(binding.projectDir || '')
44
+ || typeof binding.rootIdentity !== 'string' || !/^\d+:\d+$/.test(binding.rootIdentity)) throw new Error('invalid turn canonical binding');
45
+ }
46
+ export function journalTurn(step, db, key, { platform = process.platform, io = fs, onDurability } = {}) {
47
+ const dir = turnQueueDirectory(db);
48
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); regular(dir, true);
49
+ const file = path.join(dir, `${digest(key)}.json`);
50
+ const data = storeData(step);
51
+ if (data.db !== db || data.key !== key) throw new Error('turn journal identity mismatch');
52
+ const binding = bindingOf(step); validateBinding(binding);
53
+ const record = { schemaVersion: 2, key, value: data.value, contentDigest: digest(data.value), consentScope: 'canonical-project-and-path', binding };
54
+ let created = false;
55
+ try {
56
+ const fd = io.openSync(file, 'wx', 0o600);
57
+ created = true;
58
+ try { io.writeFileSync(fd, JSON.stringify(record)); io.fsyncSync(fd); } finally { io.closeSync(fd); }
59
+ } catch (error) {
60
+ if (error.code !== 'EEXIST') throw error;
61
+ const previous = readJournal(file, db);
62
+ if (previous.key !== key || previous.contentDigest !== record.contentDigest) throw new Error('turn journal identity collision');
63
+ // Reflush an existing complete entry: its creator may have died before fsync.
64
+ const fd = io.openSync(file, 'r+');
65
+ try { io.fsyncSync(fd); } finally { io.closeSync(fd); }
66
+ }
67
+ const evidence = { fileFsync: 'completed', entry: created ? 'created-exclusively' : 'existing-exact-match-reflushed', ...syncTurnDirectory(dir, { platform, io }) };
68
+ if (onDurability) onDurability(evidence);
69
+ return file;
70
+ }
71
+ export function readJournal(file, db) {
72
+ const dir = turnQueueDirectory(db); regular(dir, true);
73
+ if (path.dirname(file) !== dir) throw new Error('foreign turn journal rejected');
74
+ regular(file);
75
+ const record = JSON.parse(fs.readFileSync(file, 'utf8'));
76
+ let normalized = record;
77
+ if (record.schemaVersion === 1) {
78
+ const data = storeData(record.step);
79
+ if (!record.step.args.includes('--no-upsert')) throw new Error('legacy turn journal requires strict insert');
80
+ if (data.db !== db || data.key !== record.key) throw new Error('legacy turn journal identity mismatch');
81
+ // Legacy job-selected consent settings are never consumed; caller policy governs replay.
82
+ normalized = { schemaVersion: 2, key: data.key, value: data.value, contentDigest: record.contentDigest,
83
+ consentScope: record.consentScope, binding: bindingOf(record.step), legacyBrainHome: record.step.brainHome };
84
+ } else if (Object.keys(record).some((key) => !['schemaVersion', 'key', 'value', 'contentDigest', 'consentScope', 'binding'].includes(key))) throw new Error('unknown turn journal field');
85
+ validateBinding(normalized.binding);
86
+ if (normalized.schemaVersion !== 2 || normalized.consentScope !== 'canonical-project-and-path'
87
+ || typeof normalized.key !== 'string' || !normalized.key.startsWith('turn-') || normalized.key.length > 250
88
+ || typeof normalized.value !== 'string' || normalized.value.length > 4000
89
+ || digest(normalized.value) !== normalized.contentDigest
90
+ || path.basename(file) !== `${digest(normalized.key)}.json`) throw new Error('invalid turn journal');
91
+ return normalized;
92
+ }
93
+ export function pendingTurnFiles(db, limit = 10) {
94
+ const dir = turnQueueDirectory(db);
95
+ try { regular(dir, true); } catch (error) { if (error.code === 'ENOENT') return []; throw error; }
96
+ return fs.readdirSync(dir).filter((name) => /^[a-f0-9]{64}\.json$/.test(name)).sort().slice(0, Math.min(25, Math.max(0, limit))).map((name) => path.join(dir, name));
97
+ }
98
+ export function acknowledgeJournal(file, db, options = {}) {
99
+ readJournal(file, db); fs.unlinkSync(file); return syncTurnDirectory(path.dirname(file), options);
100
+ }
101
+ export function appendReceipt(file, row) {
102
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
103
+ if (fs.existsSync(file)) regular(file);
104
+ const fd = fs.openSync(file, 'a', 0o600);
105
+ try { fs.writeFileSync(fd, `${JSON.stringify(row)}\n`); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
106
+ }
@@ -0,0 +1,247 @@
1
+ // Metadata-only repair. releaseIdentity is an independently verified release input owned by the
2
+ // installer, NOT a hash/boolean synthesized from the installed file. No artifact discovery/inference.
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import crypto from 'node:crypto';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { spawn, spawnSync } from 'node:child_process';
8
+ import { codexHookIdentities } from './codex-hook-trust.mjs';
9
+
10
+ /** Authenticate exact published package bytes before reading one archive member. No extraction/install. */
11
+ export async function obtainVerifiedPublishedHookIdentity({ version, fetch: fetcher = globalThis.fetch,
12
+ timeoutMs = 10000, maxTarballBytes = 32 * 1024 * 1024, tarBinary = '/usr/bin/tar' } = {}) {
13
+ if (!/^\d+\.\d+\.\d+$/.test(version || '') || typeof fetcher !== 'function') return blocked('Exact released version and HTTPS fetch required');
14
+ const deadline = Date.now() + timeoutMs;
15
+ const get = async (url, limit) => {
16
+ const remaining = deadline - Date.now(); if (remaining <= 0) throw new Error('Published artifact fetch deadline exceeded');
17
+ const response = await fetcher(url, { redirect: 'error', signal: AbortSignal.timeout(remaining) });
18
+ if (!response.ok || !response.body) throw new Error('Published artifact HTTPS fetch failed');
19
+ if (Number(response.headers.get('content-length')) > limit) throw new Error('Published artifact exceeds size bound');
20
+ const reader = response.body.getReader(); const chunks = []; let length = 0;
21
+ try {
22
+ while (true) {
23
+ if (Date.now() >= deadline) throw new Error('Published artifact fetch deadline exceeded');
24
+ const { done, value } = await reader.read(); if (done) break;
25
+ length += value.byteLength; if (length > limit) throw new Error('Published artifact exceeds size bound');
26
+ chunks.push(Buffer.from(value));
27
+ }
28
+ } finally { await reader.cancel().catch(() => {}); }
29
+ return Buffer.concat(chunks);
30
+ };
31
+ try {
32
+ const metadataUrl = `https://registry.npmjs.org/ruvnet-brain/${version}`;
33
+ const metadata = JSON.parse((await get(metadataUrl, 2 * 1024 * 1024)).toString('utf8'));
34
+ const tarballUrl = `https://registry.npmjs.org/ruvnet-brain/-/ruvnet-brain-${version}.tgz`;
35
+ if (metadata.name !== 'ruvnet-brain' || metadata.version !== version || metadata.dist?.tarball !== tarballUrl
36
+ || !/^sha512-[A-Za-z0-9+/]{86}==$/.test(metadata.dist?.integrity || '')) throw new Error('Published version/path/integrity mismatch');
37
+ const archive = await get(tarballUrl, maxTarballBytes);
38
+ const integrity = `sha512-${crypto.createHash('sha512').update(archive).digest('base64')}`;
39
+ if (integrity !== metadata.dist.integrity) throw new Error('Published archive integrity mismatch');
40
+ const remaining = deadline - Date.now(); if (remaining <= 0) throw new Error('Published artifact deadline exceeded');
41
+ // stdout-only exact member: no archive paths are ever written to disk or traversed.
42
+ const result = spawnSync(tarBinary, ['-xzOf', '-', 'package/plugin/hooks/codex-hooks.json'],
43
+ { input: archive, timeout: Math.min(remaining, 3000), maxBuffer: 256 * 1024 });
44
+ if (result.error || result.status !== 0 || !result.stdout?.length) throw new Error('Platform tar cannot read exact published hooks entry');
45
+ if (!codexHookIdentities(JSON.parse(result.stdout)).size) throw new Error('Published native hooks are invalid');
46
+ return { state: 'verified', releaseIdentity: { version, integrity, hooksSha256: digest(result.stdout) },
47
+ metadataUrl, tarballUrl, archiveBytes: archive.length };
48
+ } catch (error) { return blocked(error.message); }
49
+ }
50
+
51
+ const activeMetadataChildren = new Map();
52
+ async function shutdownMetadataChild(child) {
53
+ const entry = activeMetadataChildren.get(child); if (!entry) return;
54
+ if (entry.closing) return entry.closing;
55
+ entry.closing = (async () => {
56
+ const wait = async () => {
57
+ let timer;
58
+ try { await Promise.race([entry.exited, new Promise((resolve) => { timer = setTimeout(resolve, 350); })]); }
59
+ finally { clearTimeout(timer); }
60
+ };
61
+ if (child.pid && child.exitCode === null && child.signalCode === null) {
62
+ child.kill('SIGTERM'); await wait();
63
+ if (child.exitCode === null && child.signalCode === null) { child.kill('SIGKILL'); await wait(); }
64
+ }
65
+ if (child.pid && child.exitCode === null && child.signalCode === null) throw new Error('Native metadata child exit unverified');
66
+ activeMetadataChildren.delete(child);
67
+ })();
68
+ return entry.closing;
69
+ }
70
+
71
+ const OWNER = 'ruvnet-brain@ruvnet-brain';
72
+ const digest = (bytes) => crypto.createHash('sha256').update(bytes).digest('hex');
73
+ const blocked = (reason) => ({ state: 'blocked', changed: false, reason });
74
+ function privateBackup(configPath, bytes, backupDir) {
75
+ fs.mkdirSync(backupDir, { recursive: true, mode: 0o700 });
76
+ if (fs.lstatSync(backupDir).isSymbolicLink()) throw new Error('Backup directory symlink refused');
77
+ const file = path.join(backupDir, `rnb-hook-trust-${new Date().toISOString().replaceAll(':', '-')}-${crypto.randomUUID()}.toml`);
78
+ const fd = fs.openSync(file, 'wx', 0o600);
79
+ try { fs.writeFileSync(fd, bytes); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
80
+ const dir = fs.openSync(backupDir, 'r'); try { fs.fsyncSync(dir); } finally { fs.closeSync(dir); }
81
+ return file;
82
+ }
83
+
84
+ export function resolveNativeBinary(binary, { env = process.env, platform = process.platform } = {}) {
85
+ if (typeof binary !== 'string' || !binary) throw new Error('Native executable required');
86
+ const candidates = path.isAbsolute(binary) ? [binary] : /^[A-Za-z0-9._-]+$/.test(binary)
87
+ ? String(env.PATH || '').split(platform === 'win32' ? ';' : path.delimiter).filter(Boolean).flatMap((dir) => {
88
+ const suffixes = platform === 'win32' && !path.extname(binary) ? String(env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';') : [''];
89
+ return suffixes.map((suffix) => path.resolve(dir, binary + suffix));
90
+ }) : [];
91
+ for (const candidate of candidates) {
92
+ try {
93
+ fs.accessSync(candidate, platform === 'win32' ? fs.constants.F_OK : fs.constants.X_OK);
94
+ if (!fs.statSync(candidate).isFile()) continue;
95
+ const resolved = fs.realpathSync(candidate);
96
+ if (resolved === fs.realpathSync(fileURLToPath(import.meta.url))) throw new Error('Recursive native gateway refused');
97
+ return resolved;
98
+ } catch (error) { if (error.message === 'Recursive native gateway refused') throw error; }
99
+ }
100
+ throw new Error('Native executable unavailable on configured PATH');
101
+ }
102
+
103
+ /** Only initialize/config/read/config/batchWrite/hooks/list are ever sent by this client. */
104
+ async function nativeClient(binary, cwd, deadlineAt, configPath) {
105
+ binary = resolveNativeBinary(binary);
106
+ const env = { ...process.env, CODEX_HOME: path.dirname(configPath) };
107
+ for (const key of ['OPENAI_API_KEY', 'OPENAI_BASE_URL', 'ANTHROPIC_API_KEY', 'CODEX_API_KEY']) delete env[key];
108
+ const child = spawn(binary, ['app-server', '--listen', 'stdio://'], { cwd, env, stdio: ['pipe', 'pipe', 'ignore'] });
109
+ const exited = new Promise((resolve) => { child.once('exit', resolve); child.once('error', () => { if (!child.pid) resolve(); }); });
110
+ activeMetadataChildren.set(child, { exited });
111
+ let nextId = 1; let buffer = ''; const pending = new Map();
112
+ const rejectAll = (message) => { for (const item of pending.values()) { clearTimeout(item.timer); item.reject(new Error(message)); } pending.clear(); };
113
+ child.on('error', () => rejectAll('Native metadata process failed'));
114
+ child.stdin.on('error', () => rejectAll('Native metadata input failed'));
115
+ child.on('exit', () => rejectAll('Native metadata process exited'));
116
+ child.stdout.on('data', (chunk) => {
117
+ buffer += chunk;
118
+ if (Buffer.byteLength(buffer) > 8 * 1024 * 1024) { rejectAll('Native metadata output exceeds bound'); child.kill(); return; }
119
+ while (buffer.includes('\n')) {
120
+ const end = buffer.indexOf('\n'); const line = buffer.slice(0, end); buffer = buffer.slice(end + 1);
121
+ let row; try { row = JSON.parse(line); } catch { continue; }
122
+ const item = pending.get(row.id); if (!item) continue;
123
+ pending.delete(row.id); clearTimeout(item.timer);
124
+ // Do not include native error details: they may contain private config values.
125
+ if (row.error) item.reject(new Error(`Native ${item.method} rejected`)); else item.resolve(row.result);
126
+ }
127
+ });
128
+ const rpc = (method, params) => new Promise((resolve, reject) => {
129
+ const remaining = deadlineAt - Date.now(); if (remaining <= 0) { reject(new Error('Native metadata deadline exceeded')); return; }
130
+ const id = nextId++; const timer = setTimeout(() => { pending.delete(id); reject(new Error(`Native ${method} timed out`)); }, remaining);
131
+ pending.set(id, { resolve, reject, timer, method });
132
+ child.stdin.write(`${JSON.stringify({ id, method, params })}\n`, (error) => { if (error) rejectAll('Native metadata input failed'); });
133
+ });
134
+ const close = async () => { rejectAll('Native metadata client closed'); child.stdin.destroy(); await shutdownMetadataChild(child); };
135
+ try {
136
+ await rpc('initialize', { clientInfo: { name: 'rnb_verified_hook_trust', version: '1' }, capabilities: { experimentalApi: true } });
137
+ child.stdin.write(`${JSON.stringify({ method: 'initialized' })}\n`);
138
+ return { rpc, close };
139
+ } catch (error) { await close(); throw error; }
140
+ }
141
+ const rowsFor = (response) => {
142
+ if (!Array.isArray(response?.data)) throw new Error('Native hook registry unavailable');
143
+ return response.data.flatMap((r) => r.hooks || []).filter((r) => r.pluginId === OWNER);
144
+ };
145
+ function assertRegistry(rows, identities) {
146
+ if (rows.length !== identities.size || new Set(rows.map((r) => r.key)).size !== identities.size
147
+ || rows.some((row) => identities.get(row.key) !== row.currentHash)) throw new Error('Native registry differs from verified released artifact');
148
+ }
149
+
150
+ function otherSettings(config, keys) {
151
+ const clone = structuredClone(config);
152
+ for (const key of keys) {
153
+ const state = clone?.hooks?.state?.[key];
154
+ if (state && typeof state === 'object') {
155
+ delete state.trusted_hash;
156
+ if (!Object.keys(state).length) delete clone.hooks.state[key];
157
+ }
158
+ }
159
+ if (clone?.hooks?.state && !Object.keys(clone.hooks.state).length) delete clone.hooks.state;
160
+ if (clone?.hooks && !Object.keys(clone.hooks).length) delete clone.hooks;
161
+ const canonical = (v) => Array.isArray(v) ? v.map(canonical) : v && typeof v === 'object'
162
+ ? Object.fromEntries(Object.keys(v).sort().map((k) => [k, canonical(v[k])])) : v;
163
+ return JSON.stringify(canonical(clone));
164
+ }
165
+
166
+ /** Caller must authenticate releaseIdentity against its verified tarball/protected-release receipt.
167
+ * The helper checks installed bytes/version and native identities; it cannot authenticate caller data.
168
+ * rpc injection owns its own client lifetime. Default client never starts a thread, turn or account call.
169
+ */
170
+ export async function reconcileVerifiedCodexHookTrust({ releaseIdentity, installedVersion, hooksPath,
171
+ configPath, nativeBinary, cwd = process.cwd(), backupDir = path.join(path.dirname(configPath || ''), 'backups'),
172
+ rpc: suppliedRpc, timeoutMs = 10000 } = {}) {
173
+ let client; let wrote = false; let writeAttempted = false; let backupPath;
174
+ try {
175
+ if (!releaseIdentity || !/^\d+\.\d+\.\d+$/.test(releaseIdentity.version || '')
176
+ || installedVersion !== releaseIdentity.version || !/^[a-f0-9]{64}$/.test(releaseIdentity.hooksSha256 || '')
177
+ || !/^sha512-[A-Za-z0-9+/]{86}==$/.test(releaseIdentity.integrity || '')) return blocked('Verified released artifact identity required');
178
+ if (![hooksPath, configPath, backupDir].every((p) => typeof p === 'string' && path.isAbsolute(p))) return blocked('Absolute owned paths required');
179
+ if (fs.lstatSync(hooksPath).isSymbolicLink() || fs.lstatSync(configPath).isSymbolicLink()) return blocked('Source/config symlink refused');
180
+ const bytes = fs.readFileSync(hooksPath);
181
+ if (digest(bytes) !== releaseIdentity.hooksSha256) return blocked('Installed hooks differ from verified release');
182
+ const identities = codexHookIdentities(JSON.parse(bytes));
183
+ if (!identities.size) return blocked('Verified artifact has no native hooks');
184
+ if (!suppliedRpc) client = await nativeClient(nativeBinary, cwd, Date.now() + Math.min(timeoutMs, 20000), configPath);
185
+ const rpc = suppliedRpc || client.rpc;
186
+ const before = rowsFor(await rpc('hooks/list', { cwds: [cwd] })); assertRegistry(before, identities);
187
+ const disabled = before.filter((r) => r.enabled === false).map((r) => r.key).sort();
188
+ if (before.some((r) => !['trusted', 'modified', 'untrusted', 'managed'].includes(r.trustStatus))) return blocked('Unknown native trust state');
189
+ const pending = before.filter((r) => r.enabled === true && ['modified', 'untrusted'].includes(r.trustStatus));
190
+ if (!pending.length) return { state: 'unchanged', changed: false, disabledPreserved: disabled };
191
+ const read = await rpc('config/read', { includeLayers: true });
192
+ const users = (read.layers || []).filter((r) => r.name?.type === 'user' && r.name.file === configPath);
193
+ if (users.length !== 1 || typeof users[0].version !== 'string' || !users[0].version) return blocked('Current user config version unavailable');
194
+ // Config stays memory-only except the private backup. No logging of effective config values.
195
+ const prior = fs.readFileSync(configPath);
196
+ backupPath = privateBackup(configPath, prior, backupDir);
197
+ const edits = pending.map((r) => ({ keyPath: `hooks.state.${JSON.stringify(r.key)}.trusted_hash`,
198
+ value: identities.get(r.key), mergeStrategy: 'replace' }));
199
+ writeAttempted = true;
200
+ await rpc('config/batchWrite', { filePath: configPath, expectedVersion: users[0].version, reloadUserConfig: true, edits });
201
+ wrote = true;
202
+ const afterRead = await rpc('config/read', { includeLayers: true });
203
+ const afterUsers = (afterRead.layers || []).filter((r) => r.name?.type === 'user' && r.name.file === configPath);
204
+ if (afterUsers.length !== 1 || otherSettings(users[0].config, pending.map((r) => r.key))
205
+ !== otherSettings(afterUsers[0].config, pending.map((r) => r.key))) throw new Error('Unrelated config changed; no whole-file rollback attempted');
206
+ const after = rowsFor(await rpc('hooks/list', { cwds: [cwd] })); assertRegistry(after, identities);
207
+ if (JSON.stringify(after.filter((r) => r.enabled === false).map((r) => r.key).sort()) !== JSON.stringify(disabled)
208
+ || after.some((r) => r.enabled === true && !['trusted', 'managed'].includes(r.trustStatus))) throw new Error('Native trust repair verification incomplete');
209
+ return { state: 'registry-verified', changed: true, editedTrustHashes: edits.length,
210
+ enabledBrainTrusted: after.filter((r) => r.enabled === true).length, disabledPreserved: disabled,
211
+ artifactHooksSha256: releaseIdentity.hooksSha256, backupPath, otherSemanticSettingsUnchanged: true,
212
+ priorConfigSha256: digest(prior), afterConfigSha256: digest(fs.readFileSync(configPath)), inferenceRequests: 0,
213
+ executionAcceptance: 'not-run' };
214
+ } catch (error) {
215
+ // Never restore an entire backup over concurrent writers. A successful write followed by failed
216
+ // verification is degraded, not "unchanged"; the installer must expose this outcome.
217
+ return { state: wrote || writeAttempted ? 'degraded' : 'blocked', changed: wrote ? true : writeAttempted ? 'unknown' : false, reason: error.message, ...(backupPath ? { backupPath } : {}) };
218
+ } finally { await client?.close(); }
219
+ }
220
+
221
+ // Explicit CLI only; importing the helper never starts a process or reads stdin.
222
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
223
+ && process.argv[2] === '--reconcile-installed') {
224
+ const started = Date.now(); let emitted = false; let reconcileStarted = false;
225
+ const emit = (receipt) => { if (!emitted) { emitted = true; process.stdout.write(JSON.stringify(receipt) + '\n'); } };
226
+ const guard = setTimeout(async () => {
227
+ await Promise.allSettled([...activeMetadataChildren.keys()].map(shutdownMetadataChild));
228
+ emit({ state: 'degraded', changed: 'unknown', reason: 'CLI deadline exceeded; verify native trust state', inferenceRequests: 0 });
229
+ process.exit(1);
230
+ }, 29000);
231
+ (async () => {
232
+ let input = ''; for await (const chunk of process.stdin) {
233
+ input += chunk; if (Buffer.byteLength(input) > 16384) throw new Error('CLI input exceeds bound');
234
+ }
235
+ const args = JSON.parse(input);
236
+ if (!args || typeof args !== 'object' || Array.isArray(args)
237
+ || Object.keys(args).some((k) => !['installedVersion', 'hooksPath', 'configPath', 'nativeBinary', 'cwd'].includes(k))) throw new Error('Invalid CLI options');
238
+ const proof = await obtainVerifiedPublishedHookIdentity({ version: args.installedVersion, timeoutMs: 10000 });
239
+ if (proof.state !== 'verified') { emit(proof); return; }
240
+ reconcileStarted = true;
241
+ const result = await reconcileVerifiedCodexHookTrust({ ...args, releaseIdentity: proof.releaseIdentity,
242
+ timeoutMs: Math.max(1, Math.min(20000, 28000 - (Date.now() - started))) });
243
+ emit(result);
244
+ })().catch(() => emit(reconcileStarted
245
+ ? { state: 'degraded', changed: 'unknown', reason: 'Native reconciliation or shutdown failed; verify trust state', inferenceRequests: 0 }
246
+ : blocked('CLI request or published artifact proof failed'))).finally(() => clearTimeout(guard));
247
+ }