ruvnet-brain 4.7.20 → 4.7.22

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 (260) hide show
  1. package/README.md +2 -2
  2. package/bin/install.mjs +618 -104
  3. package/console/activity.js +122 -38
  4. package/kb/download-retry.mjs +93 -21
  5. package/kb/forge-guard-injection.mjs +277 -0
  6. package/kb/forge-update.mjs +166 -38
  7. package/kb/lifecycle-evidence-retention.mjs +151 -14
  8. package/kb/model-requirements.mjs +4 -1
  9. package/kb/refresh-run.mjs +175 -27
  10. package/kb/verify-citation.mjs +15 -2
  11. package/package.json +3 -1
  12. package/plugin/.claude-plugin/plugin.json +1 -1
  13. package/plugin/.codex-plugin/plugin.json +1 -1
  14. package/plugin/mcp/managed-cli-interface.mjs +16 -8
  15. package/plugin/scripts/adr-currency-gate.mjs +2 -1
  16. package/plugin/scripts/advisory-contract.mjs +21 -0
  17. package/plugin/scripts/advocacy-route.mjs +5 -4
  18. package/plugin/scripts/agentdb-recall-process.mjs +115 -5
  19. package/plugin/scripts/agentdb-recall-reader.mjs +44 -2
  20. package/plugin/scripts/agentdb-recall.mjs +49 -17
  21. package/plugin/scripts/automatic-update.mjs +9 -1
  22. package/plugin/scripts/brain-confirmation.mjs +2 -1
  23. package/plugin/scripts/brain-footprint.mjs +72 -11
  24. package/plugin/scripts/capability-registry.mjs +1 -1
  25. package/plugin/scripts/capacity-aware-parallel-work.mjs +2 -1
  26. package/plugin/scripts/codex-overnight-notice.mjs +33 -0
  27. package/plugin/scripts/completion-claim-evidence.mjs +241 -23
  28. package/plugin/scripts/continuation-gate.mjs +75 -11
  29. package/plugin/scripts/decision-gate.mjs +13 -63
  30. package/plugin/scripts/decision-outcomes.mjs +36 -0
  31. package/plugin/scripts/decision-recall-gate.mjs +40 -5
  32. package/plugin/scripts/decision-record.mjs +8 -3
  33. package/plugin/scripts/degradation-watch.mjs +2 -1
  34. package/plugin/scripts/developer-update-lock.mjs +172 -33
  35. package/plugin/scripts/developer-update-owner.mjs +8 -5
  36. package/plugin/scripts/developer-update-policy.mjs +4 -1
  37. package/plugin/scripts/developer-update.mjs +155 -53
  38. package/plugin/scripts/doc-currency.mjs +2 -16
  39. package/plugin/scripts/duplicate-gate.mjs +2 -1
  40. package/plugin/scripts/entrypoint.mjs +34 -0
  41. package/plugin/scripts/footprint-io.mjs +135 -8
  42. package/plugin/scripts/grok-hook-adapter.mjs +2 -1
  43. package/plugin/scripts/ground-ruvnet.sh +6 -3
  44. package/plugin/scripts/grounding-answer.mjs +2 -1
  45. package/plugin/scripts/grounding-code-projection.mjs +2 -1
  46. package/plugin/scripts/grounding-substance.mjs +2 -1
  47. package/plugin/scripts/grounding-turn-gate.mjs +2 -2
  48. package/plugin/scripts/grounding-turn-mark.mjs +2 -2
  49. package/plugin/scripts/hook-context-budget.mjs +2 -1
  50. package/plugin/scripts/hook-input.mjs +2 -2
  51. package/plugin/scripts/hook-shim.mjs +9 -2
  52. package/plugin/scripts/host-update.mjs +29 -5
  53. package/plugin/scripts/identifier-preflight.mjs +2 -1
  54. package/plugin/scripts/kb-copy-proof.mjs +57 -9
  55. package/plugin/scripts/learn-capture.mjs +2 -1
  56. package/plugin/scripts/learning-store.mjs +62 -16
  57. package/plugin/scripts/lesson-bridge.mjs +2 -2
  58. package/plugin/scripts/lesson-gate.mjs +7 -39
  59. package/plugin/scripts/lesson-promote.mjs +83 -22
  60. package/plugin/scripts/lesson-scope.mjs +51 -0
  61. package/plugin/scripts/lesson-store.mjs +1 -1
  62. package/plugin/scripts/md-stamp.mjs +2 -1
  63. package/plugin/scripts/native-turn-transcript.mjs +98 -6
  64. package/plugin/scripts/nightly-scheduler.mjs +10 -5
  65. package/plugin/scripts/package-recommender.mjs +2 -2
  66. package/plugin/scripts/project-store-resolver.mjs +19 -0
  67. package/plugin/scripts/routing-outcome-capture.mjs +2 -1
  68. package/plugin/scripts/ruflo-bin.mjs +14 -6
  69. package/plugin/scripts/ruflo-memory-hooks.mjs +157 -19
  70. package/plugin/scripts/session-snapshot-hook.mjs +25 -5
  71. package/plugin/scripts/session-start-core.mjs +7 -16
  72. package/plugin/scripts/session-start-health.mjs +98 -10
  73. package/plugin/scripts/session-start-issue-alert.mjs +63 -0
  74. package/plugin/scripts/session-start-update-plane.mjs +15 -1
  75. package/plugin/scripts/signal-watch.mjs +2 -1
  76. package/plugin/scripts/spend-guard.mjs +2 -1
  77. package/plugin/scripts/substitution-wall.mjs +2 -1
  78. package/plugin/scripts/turn-outcome-capture.mjs +7 -3
  79. package/plugin/scripts/unprompted-runtime.mjs +20 -3
  80. package/plugin/scripts/update-apply.mjs +186 -15
  81. package/plugin/skills/release-proof/scripts/release-proof.mjs +2 -1
  82. package/plugin/skills/ruvnet-brain/PLAYBOOK.md +2 -2
  83. package/plugin/skills/ruvnet-brain/SKILL.md +3 -2
  84. package/scripts/adr-072-completion.mjs +2 -1
  85. package/scripts/agentdb-context.mjs +2 -1
  86. package/scripts/agentdb-fleet-doctor.mjs +30 -8
  87. package/scripts/approved-runtime.mjs +2 -1
  88. package/scripts/architecture-review-lock.mjs +2 -1
  89. package/scripts/ascii-drift.mjs +2 -1
  90. package/scripts/brain-latency-50.mjs +2 -1
  91. package/scripts/brain-novice-50.mjs +2 -16
  92. package/scripts/brain-score.mjs +13 -2
  93. package/scripts/build-bundle.mjs +2 -1
  94. package/scripts/build-concepts.mjs +2 -1
  95. package/scripts/candidate-ci-receipt.mjs +2 -1
  96. package/scripts/candidate-host-evidence.mjs +2 -1
  97. package/scripts/card-from-source.mjs +2 -1
  98. package/scripts/ci/warm-brain-models.mjs +2 -1
  99. package/scripts/claims-verify.mjs +7 -3
  100. package/scripts/claude-controlled-terminal.mjs +2 -1
  101. package/scripts/claude-terminal-mod.mjs +2 -1
  102. package/scripts/code-release-corpus.mjs +2 -1
  103. package/scripts/codex-boot-surface-proof.mjs +208 -0
  104. package/scripts/codex-hook-trust-reconcile.mjs +67 -1
  105. package/scripts/codex-hook-trust.mjs +40 -1
  106. package/scripts/codex-hook-wrapper-history.mjs +2 -1
  107. package/scripts/codex-host-execution-proof.mjs +2 -1
  108. package/scripts/completion-claim-replay.mjs +2 -1
  109. package/scripts/console-engine.mjs +8 -3
  110. package/scripts/corpus-canary.mjs +2 -1
  111. package/scripts/corpus-candidate.mjs +2 -2
  112. package/scripts/corpus-coverage-sidecar.mjs +2 -1
  113. package/scripts/corpus-dispatch-decision.mjs +2 -1
  114. package/scripts/corpus-dispatch-receipt.mjs +2 -1
  115. package/scripts/corpus-next-seed.mjs +2 -1
  116. package/scripts/corpus-qa.mjs +2 -1
  117. package/scripts/corpus-reconcile.mjs +2 -2
  118. package/scripts/corpus-watchdog.mjs +2 -1
  119. package/scripts/count-chunks.mjs +2 -1
  120. package/scripts/customer-state-matrix.mjs +2 -1
  121. package/scripts/derive-passage-content-map.mjs +2 -1
  122. package/scripts/development-push-check.mjs +2 -1
  123. package/scripts/dispatch-receipt.mjs +2 -1
  124. package/scripts/doc-currency.mjs +2 -1
  125. package/scripts/dream-issue-gate.mjs +2 -1
  126. package/scripts/dual-host-deliberation.mjs +2 -16
  127. package/scripts/dual-host-suggest.mjs +2 -16
  128. package/scripts/eval-brain.mjs +7 -3
  129. package/scripts/execution-policy.mjs +2 -1
  130. package/scripts/execution-preflight.mjs +2 -1
  131. package/scripts/falsify.mjs +2 -1
  132. package/scripts/fix-metaharness-memretrieve.mjs +2 -1
  133. package/scripts/fix-workstream.mjs +2 -1
  134. package/scripts/full-hints.mjs +2 -1
  135. package/scripts/full-suite-gate.mjs +2 -1
  136. package/scripts/grok-host.mjs +68 -0
  137. package/scripts/hook-qualify-core.mjs +3 -1
  138. package/scripts/hook-qualify.mjs +2 -1
  139. package/scripts/hook-retirement-check.mjs +2 -1
  140. package/scripts/host-registry.mjs +2 -1
  141. package/scripts/independent-review-receipt.mjs +2 -1
  142. package/scripts/ingest-new-repos.mjs +2 -1
  143. package/scripts/ingest-repo.mjs +12 -2
  144. package/scripts/integration-evidence.mjs +2 -1
  145. package/scripts/issue-fix.mjs +7 -24
  146. package/scripts/issue-watch.mjs +116 -40
  147. package/scripts/job-heartbeat.sh +54 -19
  148. package/scripts/key-canary.mjs +9 -3
  149. package/scripts/latency-to-surface.mjs +2 -1
  150. package/scripts/learning-replay-cli.mjs +97 -19
  151. package/scripts/learning-replay-execution.mjs +2 -0
  152. package/scripts/learning-replay-fixture.mjs +3 -0
  153. package/scripts/learning-replay-proof.mjs +14 -13
  154. package/scripts/learning-replay.mjs +3 -3
  155. package/scripts/learnings.mjs +2 -1
  156. package/scripts/lesson-ratify.mjs +88 -40
  157. package/scripts/loop-checkpoint.mjs +2 -1
  158. package/scripts/metaharness-receipts.mjs +2 -1
  159. package/scripts/model-catalog.mjs +2 -1
  160. package/scripts/model-currency-evidence.mjs +6 -4
  161. package/scripts/model-currency.mjs +33 -3
  162. package/scripts/model-native-catalog.mjs +2 -1
  163. package/scripts/model-router-agent-hook.mjs +2 -1
  164. package/scripts/model-router-dispatch.mjs +2 -1
  165. package/scripts/model-router-engine.mjs +2 -1
  166. package/scripts/model-router-outcome.mjs +2 -1
  167. package/scripts/model-router-setup.mjs +2 -1
  168. package/scripts/model-routing-eval.mjs +2 -1
  169. package/scripts/model-routing-gateway.mjs +2 -1
  170. package/scripts/model-routing-launchers.mjs +4 -1
  171. package/scripts/model-terminal-gateway.mjs +2 -1
  172. package/scripts/model-terminal-launchers.mjs +2 -1
  173. package/scripts/model-weekly-analyst.mjs +2 -1
  174. package/scripts/model-weekly-cycle.mjs +2 -1
  175. package/scripts/model-weekly-qualification.mjs +2 -1
  176. package/scripts/nightly-gists.sh +26 -20
  177. package/scripts/nightly-two-run-proof.mjs +2 -1
  178. package/scripts/nightly-watchdog.mjs +158 -47
  179. package/scripts/no-silent-substitution.mjs +2 -1
  180. package/scripts/notify.sh +11 -6
  181. package/scripts/ntfy-inbox.mjs +41 -18
  182. package/scripts/onboarding-console.mjs +123 -23
  183. package/scripts/oracle/abstain-threshold-sweep.mjs +2 -1
  184. package/scripts/oracle/abstain-trace.mjs +2 -1
  185. package/scripts/oracle/doc2query-generate.mjs +2 -1
  186. package/scripts/oracle/doc2query-reach.mjs +2 -1
  187. package/scripts/oracle/judge-train.mjs +2 -1
  188. package/scripts/oracle/need-set-split.mjs +2 -1
  189. package/scripts/oracle/produce-questions.mjs +2 -16
  190. package/scripts/oracle/repo-recall.mjs +2 -2
  191. package/scripts/oracle/retrieval-accuracy.mjs +2 -2
  192. package/scripts/oracle/sona-query-adapter-eval.mjs +2 -1
  193. package/scripts/oracle/source-tree.mjs +2 -15
  194. package/scripts/oracle/source-units.mjs +2 -16
  195. package/scripts/oracle/spike-run.mjs +2 -16
  196. package/scripts/oracle/validate-labels.mjs +2 -16
  197. package/scripts/org-repo-count.mjs +2 -1
  198. package/scripts/package-cards.mjs +2 -1
  199. package/scripts/prepublication-evidence.mjs +2 -1
  200. package/scripts/private-overlay.mjs +2 -1
  201. package/scripts/product-integrity-contract.mjs +2 -1
  202. package/scripts/protected-release-invocation.mjs +2 -1
  203. package/scripts/public-verification-abandon.mjs +2 -1
  204. package/scripts/public-verification-aggregate.mjs +2 -1
  205. package/scripts/public-verification-finalizer.mjs +2 -1
  206. package/scripts/public-verification-inputs.mjs +2 -1
  207. package/scripts/public-verification-lane.mjs +2 -1
  208. package/scripts/publication-receipt.mjs +2 -1
  209. package/scripts/published-surface-probe.mjs +2 -1
  210. package/scripts/qe/card-lane-gate.mjs +2 -16
  211. package/scripts/qe/session-start-gate.mjs +2 -16
  212. package/scripts/qe/ux-suite.mjs +2 -1
  213. package/scripts/qualified-candidate-check.mjs +2 -1
  214. package/scripts/rebuild-gists-from-receipts.mjs +2 -1
  215. package/scripts/recall-latency-probe.mjs +48 -0
  216. package/scripts/recommendation-eval.mjs +2 -1
  217. package/scripts/recommendation-floor.mjs +2 -1
  218. package/scripts/recommendation-judge-score.mjs +2 -1
  219. package/scripts/recommendation-real-host-score.mjs +2 -1
  220. package/scripts/recommendation-real-host.mjs +2 -1
  221. package/scripts/refresh-capability-only-store.mjs +2 -1
  222. package/scripts/refresh-model-catalog.mjs +2 -1
  223. package/scripts/rehearse-corpus-pipeline.mjs +2 -1
  224. package/scripts/release-authority.mjs +2 -1
  225. package/scripts/release-evidence-aggregate.mjs +2 -1
  226. package/scripts/release-payload.mjs +2 -1
  227. package/scripts/release-projection.mjs +2 -1
  228. package/scripts/release-proof.mjs +2 -1
  229. package/scripts/release-qualification-contract.mjs +82 -28
  230. package/scripts/release-qualification.mjs +8 -1
  231. package/scripts/release-vector.mjs +2 -1
  232. package/scripts/restore-local-ingests.mjs +2 -1
  233. package/scripts/retrieval-canary.mjs +2 -1
  234. package/scripts/route-cheap.mjs +2 -1
  235. package/scripts/route-gold-rank.mjs +2 -1
  236. package/scripts/route-index-memory.mjs +2 -1
  237. package/scripts/route-latency-warm.mjs +2 -1
  238. package/scripts/router-utilization.mjs +2 -1
  239. package/scripts/routing-flywheel.mjs +2 -1
  240. package/scripts/run-operational-benchmark.mjs +2 -1
  241. package/scripts/run-operational-benchmark.v3.mjs +2 -1
  242. package/scripts/rvf-index-audit.mjs +2 -16
  243. package/scripts/seal-gist-receipt.mjs +2 -1
  244. package/scripts/security-guidance-codex-compat.mjs +2 -1
  245. package/scripts/selfcheck.mjs +2 -1
  246. package/scripts/signal-watch.mjs +2 -1
  247. package/scripts/source-coverage.mjs +2 -1
  248. package/scripts/stabilization-receipt.mjs +2 -1
  249. package/scripts/stamp-sweep.mjs +2 -1
  250. package/scripts/status-honesty.mjs +2 -1
  251. package/scripts/sync-commands.mjs +2 -2
  252. package/scripts/top100-benchmark.mjs +2 -1
  253. package/scripts/top100-corpus.mjs +2 -1
  254. package/scripts/user-model-hook-install.mjs +2 -1
  255. package/scripts/user-model-prompt-hook.mjs +2 -1
  256. package/scripts/verify-bundle.mjs +3 -1
  257. package/scripts/verify-model-catalog.mjs +2 -1
  258. package/scripts/version.mjs +2 -1
  259. package/scripts/wired-check.mjs +13 -5
  260. package/scripts/worktree-integrity.mjs +2 -2
@@ -5,6 +5,7 @@ import path from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { managedGenerationIdentity } from './managed-cli-generation.mjs';
7
7
  import { loadRuntimePreferences, runtimeChildEnv } from '../scripts/runtime-preferences.mjs';
8
+ import { resolveRuflo } from '../scripts/ruflo-bin.mjs';
8
9
  import { projectDirectory } from '../scripts/project-identity.mjs';
9
10
  import { recordManagedCliObservation, recordRegistryLatestObservation } from '../scripts/capability-claim-evidence.mjs';
10
11
 
@@ -180,12 +181,19 @@ function writeStamps(executable, argv, env, binding) {
180
181
 
181
182
  export function resolveManagedExecutable(executable, env = process.env) {
182
183
  if (executable !== 'ruflo') return executable;
183
- const home = env.HOME || os.homedir();
184
- const candidates = [path.join(home, '.npm-global', 'bin', 'ruflo')];
185
- if (process.platform === 'win32') candidates.push(`${candidates[0]}.cmd`);
186
- return candidates.find((candidate) => {
187
- try { fs.accessSync(candidate, fs.constants.X_OK); return true; } catch { return false; }
188
- }) || executable;
184
+ // #420 item 5: same rules as every other Brain caller (RUFLO_BIN is authoritative, then ~/.npm-global/bin, then PATH); this used to check ~/.npm-global only and ignored RUFLO_BIN.
185
+ return resolveRuflo({ env, home: env.HOME || os.homedir() }) || executable;
186
+ }
187
+
188
+ /**
189
+ * ruflo (and claude-flow, the same @claude-flow/cli) self-updates at startup by running `npm install` in its
190
+ * working directory, which here is the user's project: measured 2026-10-22, a 1.3 GB install landed there.
191
+ * ruflo-bin.mjs promises every Brain spawn sets CLAUDE_FLOW_AUTO_UPDATE=false; this boundary is a Brain spawn
192
+ * too. RUFLO_DAEMON_AUTOSTART=0 keeps a background daemon from being left behind in that project.
193
+ */
194
+ export function managedChildEnv(executable, env) {
195
+ if (executable !== 'ruflo' && executable !== 'claude-flow') return env;
196
+ return { ...env, CLAUDE_FLOW_AUTO_UPDATE: 'false', RUFLO_DAEMON_AUTOSTART: '0' };
189
197
  }
190
198
 
191
199
  function quoteWindowsCommandArg(value) {
@@ -323,7 +331,7 @@ export async function callManagedCli(toolName, args, env = process.env, fetchImp
323
331
  if (toolName === 'ruvnet_cli_help') {
324
332
  stampKeysForHelp(executable, argv);
325
333
  const commandArgv = [...argv, '--help'];
326
- const execution = await execute(executable, commandArgv, env);
334
+ const execution = await execute(executable, commandArgv, managedChildEnv(executable, env));
327
335
  if (normalizeManagedExecution(execution).outcome === 'success') writeStamps(executable, argv, env, binding);
328
336
  recordManagedCliObservation({ toolName, executable, argv: commandArgv, execution, env });
329
337
  return resultOf(executable, commandArgv, execution);
@@ -368,7 +376,7 @@ export async function callManagedCli(toolName, args, env = process.env, fetchImp
368
376
  // host by supplying an arbitrary `host` property.
369
377
  const childEnv = (executable === 'agentic-flow' || executable === 'agentic-qe')
370
378
  ? runtimeChildEnv({ env, cwd: projectRoot })
371
- : env;
379
+ : managedChildEnv(executable, env);
372
380
  if (lifecycle && typeof lifecycle.beforeExecute === 'function') {
373
381
  await lifecycle.beforeExecute({ executable, argv, projectRoot, env: childEnv });
374
382
  }
@@ -33,6 +33,7 @@ import fs from 'node:fs';
33
33
  import { execFileSync, spawn } from 'node:child_process';
34
34
  import path from 'node:path';
35
35
  import { fileURLToPath, pathToFileURL } from 'node:url';
36
+ import { isDirectInvocation } from './entrypoint.mjs';
36
37
 
37
38
  const HERE = path.dirname(fileURLToPath(import.meta.url));
38
39
  const REPO = path.resolve(HERE, '..', '..');
@@ -128,7 +129,7 @@ An ADR describing a world the code left is worse than no ADR: the next reader tr
128
129
  }
129
130
 
130
131
  const isMain = (() => {
131
- try { return process.argv[1] && fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url); }
132
+ try { return isDirectInvocation(import.meta.url); }
132
133
  catch { return false; }
133
134
  })();
134
135
 
@@ -0,0 +1,21 @@
1
+ /**
2
+ * The delivery contract for OPTIONAL Brain advice (#489).
3
+ *
4
+ * A capability recommendation, a package recommendation or a routine stack-update notice is
5
+ * information, not a decision the user must make now. Phrased as a question ("want me to update
6
+ * it?"), it was promoted by Codex into a structured question dialog that suspended an already
7
+ * authorized task. So optional advice is worded as statements, and the model-visible context
8
+ * carries this one line saying so. Real permission gates and genuinely missing information keep
9
+ * their normal question behaviour; this applies only to optional advisory channels.
10
+ *
11
+ * ground-ruvnet.sh cannot import this module and carries the same sentence verbatim;
12
+ * tests/unit/advisory-contract.test.mjs fails if the two drift.
13
+ */
14
+ export const ADVISORY_CONTRACT = 'Optional, informational advice: mention it at most once as a statement and continue the current task. '
15
+ + 'Do not open a question or approval dialog because of it; it grants no authority to install or update anything.';
16
+
17
+ /**
18
+ * Question-shaped wording that invites a decision dialog, including the "say X to proceed, or ignore
19
+ * this" choice prompt the advocacy copy used to carry. Optional advice must match none of it.
20
+ */
21
+ export const QUESTION_SHAPED = /\?|\b(want me to|would you like|should i|shall i|do you want|can i|to proceed|or ignore this)\b/i;
@@ -61,6 +61,7 @@ import {
61
61
  ACTIONS, record, shouldStillOffer, stateHashOf, precision, pendingOffers, reconcileIgnored, loadOutcomes,
62
62
  } from './advocacy-outcomes.mjs';
63
63
  import { packageRecommenderEnabled, offerNames } from './package-recommender-flag.mjs';
64
+ import { isDirectInvocation } from './entrypoint.mjs';
64
65
  // Matcher + socket client load only when the flag is on at process start: default-off pays nothing.
65
66
  const PKG = packageRecommenderEnabled() ? { ...(await import('./package-recommender.mjs')), ...(await import('./package-recommender-client.mjs')) } : null;
66
67
 
@@ -341,9 +342,9 @@ export function buildCandidate({ prompt, match, availability }) {
341
342
  ? `It is already installed here.`
342
343
  : `Install state unknown from here — say so rather than claiming it is available.`;
343
344
  const copy = [
344
- `[RuvNet Brain — capability advocacy] If it genuinely fits, tell the user in ONE sentence: `
345
- + `"Consider ${cap.id} — ${cap.benefit}. Say 'use ${cap.id}' to proceed, or ignore this." ${avail}`,
346
- `If they accept, the safe first step is \`${cap.nextAction}\` (undo: ${cap.undo}); confirm it with `
345
+ `[RuvNet Brain — capability advocacy] If it genuinely fits, state it in ONE sentence: `
346
+ + `"${cap.id} fits here — ${cap.benefit}; 'use ${cap.id}' adopts it." ${avail}`,
347
+ `If the user later says to use it, the safe first step is \`${cap.nextAction}\` (undo: ${cap.undo}); confirm it with `
347
348
  + `search_ruvnet before you build. Say it once, do not expand it, then carry on with the actual work.`,
348
349
  ].join('\n');
349
350
  return {
@@ -429,7 +430,7 @@ export function decide({ prompt, sessionId, file, state, now = Date.now(), start
429
430
  }
430
431
 
431
432
  // ── CLI ───────────────────────────────────────────────────────────────────────────────────────────
432
- const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
433
+ const isMain = isDirectInvocation(import.meta.url);
433
434
 
434
435
  async function main() {
435
436
  const startedAt = Date.now();
@@ -2,8 +2,8 @@
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { spawn } from 'node:child_process';
5
- import { fileURLToPath } from 'node:url';
6
- import { rufloInvocation } from './ruflo-bin.mjs';
5
+ import { fileURLToPath, pathToFileURL } from 'node:url';
6
+ import { rufloInvocation, nativeNodeFor } from './ruflo-bin.mjs';
7
7
  export function searchJsonResult(stdout) {
8
8
  const s = String(stdout || '');
9
9
  // Live Ruflo prints warnings AFTER the JSON object as well as logs before it.
@@ -20,6 +20,105 @@ function killGroup(child) {
20
20
  catch { try { child.kill('SIGKILL'); } catch { /* already gone */ } }
21
21
  }
22
22
 
23
+ const readJson = (file) => { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; } };
24
+ const inside = (root, file) => { const rel = path.relative(root, file); return rel && !rel.startsWith('..') && !path.isAbsolute(rel); };
25
+
26
+ /**
27
+ * Can reads go through Ruflo's own published code instead of one CLI process per read (#429)? Only when the resolved
28
+ * `ruflo` is the real package entry AND the `@claude-flow/cli` it loads (found as bin/ruflo.js finds it) declares a
29
+ * `./memory` export that resolves to a file inside it. Otherwise null and the caller keeps the per-read CLI route.
30
+ * The three functions and result shapes are checked again inside the child, where they are actually loaded.
31
+ */
32
+ export function resolveBatchTarget(bin, { env = process.env, deadlineAt } = {}) {
33
+ try {
34
+ if (!bin || process.platform === 'win32') return null; // npm .cmd shims are not inspected here
35
+ const entry = fs.realpathSync(bin);
36
+ const pkgRoot = path.dirname(path.dirname(entry));
37
+ const pkg = readJson(path.join(pkgRoot, 'package.json'));
38
+ const declared = typeof pkg?.bin === 'string' ? pkg.bin : pkg?.bin?.ruflo;
39
+ if (pkg?.name !== 'ruflo' || typeof declared !== 'string' || fs.realpathSync(path.resolve(pkgRoot, declared)) !== entry) return null;
40
+ let cliRoot = null;
41
+ for (let dir = pkgRoot, i = 0; i < 10 && !cliRoot; i += 1, dir = path.dirname(dir)) {
42
+ const candidate = path.join(dir, 'node_modules', '@claude-flow', 'cli');
43
+ if (fs.existsSync(path.join(candidate, 'package.json'))) cliRoot = candidate;
44
+ if (path.dirname(dir) === dir) break;
45
+ }
46
+ const cli = cliRoot && readJson(path.join(cliRoot, 'package.json'));
47
+ const target = cli?.name === '@claude-flow/cli' ? (cli.exports?.['./memory']?.import ?? cli.exports?.['./memory']) : null;
48
+ if (typeof target !== 'string') return null;
49
+ const file = fs.realpathSync(path.resolve(cliRoot, target));
50
+ if (!inside(fs.realpathSync(cliRoot), file) || !fs.statSync(file).isFile()) return null;
51
+ return { node: nativeNodeFor(bin, { env, deadlineAt })?.node ?? process.execPath, moduleUrl: pathToFileURL(file).href };
52
+ } catch { return null; }
53
+ }
54
+
55
+ /**
56
+ * ONE owned child answering `list` / `get` for a store through Ruflo's public memory export. Spawned on first use,
57
+ * killed (whole process group) at the absolute deadline or on close(). Every op resolves, never rejects:
58
+ * { state: 'ok' | 'unavailable' | 'timed out' | 'fallback', rows?, value? } — 'fallback' means this route cannot
59
+ * answer (capability or shape mismatch) and the caller must use the per-read CLI route. `{success:false}` from
60
+ * Ruflo is 'unavailable', never an empty result.
61
+ */
62
+ export function openBatchSession({ bin, storePath, env, deadline, makeCwd, signal }) {
63
+ const target = resolveBatchTarget(bin, { env, deadlineAt: deadline });
64
+ if (!target || !storePath) return null;
65
+ let child = null, cwd = null, ready = null, resolveReady = null, seq = 0, closed = false, timer;
66
+ const pending = new Map();
67
+ const settleAll = (state) => { for (const finish of [...pending.values()]) finish({ state }); };
68
+ const close = (state = 'unavailable') => {
69
+ if (closed) return; closed = true; clearTimeout(timer);
70
+ settleAll(state);
71
+ if (child) { killGroup(child); try { child.stdin.destroy(); } catch { /* gone */ } }
72
+ cwd?.cleanup?.();
73
+ };
74
+ const start = () => {
75
+ if (child || closed) return;
76
+ try {
77
+ cwd = makeCwd();
78
+ child = spawn(target.node, [fileURLToPath(new URL('./agentdb-recall-reader.mjs', import.meta.url)), '--session', storePath, target.moduleUrl],
79
+ { cwd: cwd.cwd, env: { ...env, RUFLO_DAEMON_AUTOSTART: '0', CLAUDE_FLOW_AUTO_UPDATE: 'false', CLAUDE_FLOW_MEMORY_PATH: undefined },
80
+ stdio: ['pipe', 'pipe', 'ignore'], detached: process.platform !== 'win32', windowsHide: true });
81
+ } catch { child = null; close(); return; }
82
+ ready = new Promise((resolve) => {
83
+ resolveReady = resolve;
84
+ child.once('close', () => { resolve(false); if (!closed) settleAll('unavailable'); });
85
+ child.once('error', () => resolve(false));
86
+ });
87
+ let buffer = '';
88
+ child.stdout.setEncoding('utf8');
89
+ child.stdout.on('data', (chunk) => {
90
+ buffer += chunk;
91
+ for (let at = buffer.indexOf('\n'); at >= 0; at = buffer.indexOf('\n')) {
92
+ const line = buffer.slice(0, at); buffer = buffer.slice(at + 1);
93
+ let row; try { row = JSON.parse(line); } catch { continue; }
94
+ if (typeof row.ready === 'boolean') resolveReady(row.ready);
95
+ else pending.get(row.id)?.(row);
96
+ }
97
+ });
98
+ child.stdin.on('error', () => {});
99
+ timer = setTimeout(() => close('timed out'), Math.max(1, deadline - Date.now()));
100
+ signal?.addEventListener('abort', () => close('unavailable'), { once: true });
101
+ };
102
+ const ask = (request, opDeadline = deadline) => new Promise((resolve) => {
103
+ if (closed || signal?.aborted) return resolve({ state: signal?.aborted ? 'unavailable' : 'timed out' });
104
+ if (opDeadline - Date.now() <= 0) return resolve({ state: 'timed out' });
105
+ start();
106
+ if (!child) return resolve({ state: 'unavailable' });
107
+ const id = ++seq;
108
+ const opTimer = opDeadline < deadline ? setTimeout(() => finish({ state: 'timed out' }), Math.max(1, opDeadline - Date.now())) : null;
109
+ const finish = (row) => { if (!pending.delete(id)) return; clearTimeout(opTimer); const { id: _id, ...rest } = row; resolve(rest); };
110
+ pending.set(id, finish);
111
+ ready.then((ok) => {
112
+ if (!pending.has(id)) return;
113
+ if (!ok) return finish({ state: closed ? 'unavailable' : 'fallback' });
114
+ try { child.stdin.write(JSON.stringify({ id, ...request }) + '\n'); } catch { finish({ state: 'unavailable' }); }
115
+ });
116
+ });
117
+ return { storePath, close,
118
+ list: (namespace, limit, opDeadline) => ask({ op: 'list', namespace, limit }, opDeadline),
119
+ get: async (namespace, key, opDeadline) => { const r = await ask({ op: 'get', namespace, key }, opDeadline); return r.state === 'ok' ? { ...r, value: r.value.trim() } : r; } };
120
+ }
121
+
23
122
  /** One ruflo search, bounded by an absolute deadline. Resolves { rows, state } — never rejects. */
24
123
  export function searchOnce({ ruflo, store, args, deadline, env, scratch, operation = 'search', signal, invocation }) {
25
124
  return new Promise((resolve) => {
@@ -82,7 +181,18 @@ export function searchOnce({ ruflo, store, args, deadline, env, scratch, operati
82
181
  }
83
182
 
84
183
  /** Isolate synchronous read-only SQLite work inside the same cancellable process budget. */
85
- export async function nativeRead({ store, requests, namespaces, deadline, env, scratch, signal }) {
184
+ export async function nativeRead({ store, requests, namespaces, deadline, env, scratch, signal, session }) {
185
+ if (session && session.storePath === store.path) {
186
+ const rows = requests ? await Promise.all(requests.map(({ namespace, key }) => session.get(namespace, key)))
187
+ : await Promise.all(namespaces.map((namespace) => session.list(namespace, 100000)));
188
+ const states = rows.map((row) => row.state);
189
+ if (!states.includes('fallback')) {
190
+ const dead = states.find((state) => state === 'timed out') ?? (!requests && states.find((state) => state !== 'ok'));
191
+ if (dead) return { ok: false, fatal: true, state: dead };
192
+ return { ok: true, state: 'ok', value: requests ? rows.map((row) => (row.state === 'ok' ? row.value : null))
193
+ : Object.fromEntries(namespaces.map((namespace, i) => [namespace, rows[i].rows.map((row) => row.key).filter((key) => typeof key === 'string')])) };
194
+ }
195
+ }
86
196
  const result = await searchOnce({ store, deadline, env, scratch, signal, operation: 'native',
87
197
  invocation: { executable: process.execPath, args: [fileURLToPath(new URL('./agentdb-recall-reader.mjs', import.meta.url)),
88
198
  store.path, JSON.stringify({ requests, namespaces, deadline })] } });
@@ -94,7 +204,7 @@ export async function nativeRead({ store, requests, namespaces, deadline, env, s
94
204
  } catch { return { ok: false, fatal: true, state: 'unavailable' }; }
95
205
  }
96
206
 
97
- export async function exactReads({ candidates, bin, deadline, env, scratch, signal }) {
207
+ export async function exactReads({ candidates, bin, deadline, env, scratch, signal, session }) {
98
208
  const groups = new Map();
99
209
  for (const candidate of candidates) {
100
210
  const storePath = candidate.storePath;
@@ -104,7 +214,7 @@ export async function exactReads({ candidates, bin, deadline, env, scratch, sign
104
214
  const results = new Map();
105
215
  await Promise.all([...groups].map(async ([storePath, rows]) => {
106
216
  const native = await nativeRead({ store: { path: storePath }, requests: rows.map(({ namespace, key }) => ({ namespace, key })),
107
- deadline, env, scratch, signal });
217
+ deadline, env, scratch, signal, session });
108
218
  await Promise.all(rows.map(async (row, index) => {
109
219
  const result = native.ok ? { state: 'ok', value: native.value[index] ?? '' }
110
220
  : native.fatal ? { state: native.state, value: '' }
@@ -3,8 +3,46 @@
3
3
  * so a stalled CLI is retired at the recall deadline. Output contract unchanged: {ok, value}.
4
4
  */
5
5
  import { execFile } from 'node:child_process';
6
+ import readline from 'node:readline';
6
7
  import { resolveRuflo, rufloInvocationOrPlain } from './ruflo-bin.mjs';
7
8
 
9
+ /** Batch session (#429): ONE process answering line-based `list` / `get` requests by calling the installed Ruflo
10
+ * package's own public `@claude-flow/cli/memory` export — the functions its `memory list` / `memory retrieve`
11
+ * commands call. argv: --session <storePath> <moduleUrl>. Replies are single JSON lines on the real stdout. */
12
+ async function session(storePath, moduleUrl) {
13
+ const write = process.stdout.write.bind(process.stdout);
14
+ process.stdout.write = (chunk, ...rest) => process.stderr.write(chunk, ...rest); // module chatter must not corrupt the protocol
15
+ const reply = (row) => write(JSON.stringify(row) + '\n');
16
+ let mod;
17
+ try { mod = await import(moduleUrl); } catch { mod = null; }
18
+ if (!mod || typeof mod.listEntries !== 'function' || typeof mod.getEntry !== 'function' || typeof mod.resolveDbPath !== 'function') {
19
+ reply({ ready: false }); process.exit(0);
20
+ }
21
+ const dbPath = mod.resolveDbPath(storePath);
22
+ reply({ ready: true });
23
+ const run = async ({ op, namespace, key, limit }) => {
24
+ if (typeof namespace !== 'string') return { state: 'fallback' };
25
+ if (op === 'list') {
26
+ const r = await mod.listEntries({ namespace, limit, offset: 0, dbPath });
27
+ if (r?.success === false) return { state: 'unavailable' };
28
+ // Every entry must carry a string key. If Ruflo renames the field the list is UNREADABLE by this route, never
29
+ // an empty namespace: fall back to the CLI route rather than report no memory (#429).
30
+ return r?.success === true && Array.isArray(r.entries) && r.entries.every((e) => e && typeof e === 'object' && typeof e.key === 'string')
31
+ ? { state: 'ok', rows: r.entries.map((e) => ({ key: e.key, createdAt: e.createdAt })) } : { state: 'fallback' };
32
+ }
33
+ if (op === 'get' && typeof key === 'string' && key) {
34
+ const r = await mod.getEntry({ key, namespace, dbPath });
35
+ if (r?.success === false || r?.success === true && r.found === false) return { state: 'unavailable' };
36
+ return r?.success === true && typeof r.entry?.content === 'string' ? { state: 'ok', value: r.entry.content } : { state: 'fallback' };
37
+ }
38
+ return { state: 'fallback' };
39
+ };
40
+ readline.createInterface({ input: process.stdin }).on('line', (line) => {
41
+ let request; try { request = JSON.parse(line); } catch { return; }
42
+ run(request).catch(() => ({ state: 'unavailable' })).then((out) => reply({ id: request.id, ...out }));
43
+ }).on('close', () => process.exit(0));
44
+ }
45
+
8
46
  const call = (bin, args, deadline) => new Promise((resolve) => {
9
47
  const timeout = Math.max(1, deadline - Date.now());
10
48
  const inv = rufloInvocationOrPlain(bin, args, { env: process.env });
@@ -12,7 +50,8 @@ const call = (bin, args, deadline) => new Promise((resolve) => {
12
50
  (error, stdout) => resolve(error ? null : String(stdout || '')));
13
51
  });
14
52
 
15
- try {
53
+ if (process.argv[2] === '--session') await session(process.argv[3], process.argv[4]);
54
+ else try {
16
55
  const [storePath, input] = process.argv.slice(2);
17
56
  const { requests, namespaces, deadline } = JSON.parse(input);
18
57
  if (!Number.isFinite(deadline) || Date.now() >= deadline
@@ -33,7 +72,10 @@ try {
33
72
  const out = await call(bin, ['memory', 'list', '--namespace', namespace, '--limit', '100000', '--format', 'json', '--path', storePath], deadline);
34
73
  if (out === null) throw new Error('list failed');
35
74
  const at = out.indexOf('[');
36
- return [namespace, at < 0 ? [] : JSON.parse(out.slice(at)).map((row) => row.key).filter((key) => typeof key === 'string')];
75
+ const rows = at < 0 ? [] : JSON.parse(out.slice(at));
76
+ // A list whose entries lack a string key is a changed contract, not an empty namespace: fail the read.
77
+ if (!Array.isArray(rows) || rows.some((row) => typeof row?.key !== 'string')) throw new Error('list shape changed');
78
+ return [namespace, rows.map((row) => row.key)];
37
79
  }));
38
80
  value = Object.fromEntries(lists);
39
81
  }
@@ -22,10 +22,11 @@ import { safeAction } from './learn-capture.mjs';
22
22
  import { redactText } from './continuity-events.mjs';
23
23
  import { resolveTurnDb } from './turn-outcome-capture.mjs';
24
24
  import { maskExcludedPaths } from './turn-capture-privacy.mjs';
25
- import { searchOnce, nativeRead, exactReads, searchJsonResult } from './agentdb-recall-process.mjs';
25
+ import { searchOnce, nativeRead, exactReads, searchJsonResult, openBatchSession } from './agentdb-recall-process.mjs';
26
26
  import { makeLesson } from './lesson-store.mjs';
27
27
  import { supersededKeys, tombstonedKeys } from './decision-record.mjs';
28
28
  import { checkpointMs, checkpointNamespaces, newestCheckpoint } from './checkpoint-keys.mjs';
29
+ import { isDirectInvocation } from './entrypoint.mjs';
29
30
  export const STORE_FILES = Object.freeze(['memory.db']);
30
31
  export const DEFAULT_DEADLINE_MS = 1900;
31
32
  export const BLOCK_MAX_BYTES = 600;
@@ -49,8 +50,8 @@ export function recallBinding({ binding = {}, projectRoot, storePath, prompt } =
49
50
  }
50
51
 
51
52
  /** Bounded structural enumeration uses the same cancellable read-only canonical provider. */
52
- export function enumerateCanonicalKeys({ storePath, namespaces, deadline, signal, env, scratch }) {
53
- return nativeRead({ store: { path: storePath }, namespaces, deadline, signal, env, scratch });
53
+ export function enumerateCanonicalKeys({ storePath, namespaces, deadline, signal, env, scratch, session }) {
54
+ return nativeRead({ store: { path: storePath }, namespaces, deadline, signal, env, scratch, session });
54
55
  }
55
56
 
56
57
  export function applicableRecallLesson(value, projectRoot) {
@@ -115,7 +116,9 @@ function clean(value, limit) {
115
116
  return out;
116
117
  }
117
118
 
118
- const NOISE_KEY = /^(?:verify[-_]|probe[-_]|test[-_]|rnb-quality-probe|session[-_]|turn[-_]|project-progress[-_]|cevt[-_])/i;
119
+ const MISSING_KNOWLEDGE = /\b(?:cannot\s+(?:be\s+determined|determine)|can(?:'|’)?t\s+(?:be\s+)?determined?|do\s+not\s+know|don(?:'|’)?t\s+know|cannot\s+answer|no\s+(?:verified\s+|supplied\s+|historical\s+)?(?:record|memory|evidence)\s+(?:is\s+)?(?:available|found))\b/i;
120
+ const MISSING_KNOWLEDGE_QUERY = /\b(?:fail(?:ed|ing|ures?)?|missing|unavailable|unknown)\b/i;
121
+ const NOISE_KEY =/^(?:verify[-_]|probe[-_]|test[-_]|rnb-quality-probe|session[-_]|turn[-_]|project-progress[-_]|cevt[-_])/i;
119
122
  const SIGNAL_NAMESPACES = new Set(['lessons', 'patterns', 'pattern']);
120
123
  /** Turn knowledge is the actual redacted outcome, never its session/transcript wrapper. */
121
124
  function turnOutcomeClauses(value, prompt) {
@@ -231,12 +234,12 @@ export function formatBlock({ picks, status, withheld = 0 }) {
231
234
  export const SEARCH_BUDGET_SHARE = 0.8;
232
235
  export function searchDeadlineFor(started, deadline) { return Math.min(deadline, started + Math.floor((deadline - started) * SEARCH_BUDGET_SHARE)); }
233
236
 
234
- async function consequentialRecall({ prompt, root, store, bin, scratch, env, deadline, binding, signal, enumerateKeys, exclusions, rankedSearch = true }) {
237
+ async function consequentialRecall({ prompt, root, store, bin, scratch, env, deadline, binding, signal, enumerateKeys, exclusions, rankedSearch = true, session }) {
235
238
  const searchDeadline = searchDeadlineFor(Date.now(), deadline);
236
239
  const phaseBinding = recallBinding({ binding, projectRoot: root, storePath: store.path, prompt });
237
240
  const stateNamespaces = checkpointNamespaces(root);
238
241
  const namespaces = [...new Set([...stateNamespaces, 'lessons', 'continuity-events'])];
239
- const enumeration = await enumerateKeys({ storePath: store.path, namespaces, deadline, signal, env, scratch });
242
+ const enumeration = await enumerateKeys({ storePath: store.path, namespaces, deadline, signal, env, scratch, session });
240
243
  if (!enumeration?.ok || signal?.aborted || Date.now() >= deadline) return { block: formatBlock({ picks: [], status: 'unavailable structural enumeration' }), picks: [], stores: [store],
241
244
  status: { 'memory.db': 'unavailable' }, outcome: signal?.aborted ? 'unavailable' : Date.now() >= deadline ? 'timed-out' : 'unavailable', categories: { state: 'unavailable', decisions: 'unavailable', lessons: 'unavailable' } };
242
245
  const keys = Object.fromEntries(namespaces.map(namespace => [namespace, Array.isArray(enumeration.value?.[namespace]) ? enumeration.value[namespace] : []]));
@@ -276,7 +279,7 @@ async function consequentialRecall({ prompt, root, store, bin, scratch, env, dea
276
279
  const categories = { state: unorderedState ? 'unavailable' : 'ok-empty', decisions: decisionAvailability, lessons: lessonAvailability };
277
280
  const records = [];
278
281
  candidates.forEach(candidate => { candidate.storePath = store.path; });
279
- const exactResults = await exactReads({ candidates, bin, deadline, env, scratch, signal });
282
+ const exactResults = await exactReads({ candidates, bin, deadline, env, scratch, signal, session });
280
283
  // ADR-105 §8: supersession is a link on the NEWER record, computed from every decision record read, plus
281
284
  // the tombstones already in the enumeration so a superseder that was not read still retires its target.
282
285
  const superseded = tombstonedKeys(keys[path.basename(root)]);
@@ -335,6 +338,8 @@ export async function recall({ prompt, projectDir = process.cwd(), env = process
335
338
  binding = {}, signal, absoluteDeadline, consequential = false, enumerateKeys = enumerateCanonicalKeys } = {}) {
336
339
  const started = Date.now();
337
340
  const empty = { block: '', picks: [], stores: [], status: {} };
341
+ let session = null;
342
+ let specAbort = null; // hoisted so `finally` can stop a speculative search that a later throw would orphan
338
343
  try {
339
344
  if (signal?.aborted) return { ...empty, outcome: 'unavailable', reason: 'cancelled' };
340
345
  if (!agentdbFirstEnabled(env)) return { ...empty, outcome: 'disabled' };
@@ -367,7 +372,15 @@ export async function recall({ prompt, projectDir = process.cwd(), env = process
367
372
  const store = stores[0];
368
373
  const query = recallQuery(prompt);
369
374
  const scratchFor = scratch || ((storePath) => rufloCwdFor(storePath, { root: rufloScratchRoot(env) }));
370
- if (consequential) return await consequentialRecall({ prompt, root, store, bin, scratch: scratchFor, env, deadline, binding, signal, enumerateKeys, exclusions });
375
+ // ONE owned child answers every list/get of this recall through Ruflo's public memory export (#429); null
376
+ // (not the real package, no ./memory export, an injected `ruflo`) keeps the per-read CLI route.
377
+ if (store) {
378
+ session = openBatchSession({ bin, storePath: store.path, env, deadline, signal, makeCwd: () => {
379
+ const cwd = fs.mkdtempSync(path.join(scratchFor(store.path), 'run-'));
380
+ return { cwd, cleanup: () => { try { fs.rmSync(cwd, { recursive: true, force: true }); } catch { /* swept later */ } } };
381
+ } });
382
+ }
383
+ if (consequential) return await consequentialRecall({ prompt, root, store, bin, scratch: scratchFor, env, deadline, binding, signal, enumerateKeys, exclusions, session });
371
384
  // Only namespaces something below reads: curated lessons/decisions, the checkpoint namespaces (dirname, its
372
385
  // lowercase form, default) and turns for the single ranked search. Each one is a `ruflo memory list` process.
373
386
  const namespaces = [...new Set(['lessons', 'turns', 'continuity-events', ...checkpointNamespaces(root)])];
@@ -375,12 +388,21 @@ export async function recall({ prompt, projectDir = process.cwd(), env = process
375
388
  // per namespace; run concurrently they starve every other read past the deadline. So: ONE native enumeration
376
389
  // (reused for the curated pass and for pruning empty namespaces), then exact curated state/lessons/decisions,
377
390
  // then spend what remains on ranking.
378
- const presence = store ? await nativeRead({ store, namespaces, deadline, env, scratch: scratchFor, signal }) : null;
379
- if (presence?.fatal) { return { ...empty, stores, outcome: presence.state === 'timed out' ? 'timed-out' : 'unavailable',
391
+ // The batch child's start-up (loading Ruflo's memory module, ~0.4 s) and the ranked search are independent, so with a
392
+ // session the search for the first-choice namespace starts NOW instead of after enumeration (#429 probe: the
393
+ // sequence left a 1.3-1.6 s tail against the 1.9 s budget). Still ONE search: it is cancelled and replaced only if
394
+ // enumeration shows that namespace is empty.
395
+ const specNamespace = path.basename(root);
396
+ specAbort = new AbortController();
397
+ const specSignal = signal ? AbortSignal.any([signal, specAbort.signal]) : specAbort.signal;
398
+ const specArgs = ['--format', 'json', '-q', query, '-n', specNamespace, '--limit', '12'];
399
+ const spec = session && store ? searchOnce({ ruflo: bin, store, deadline: searchDeadlineFor(Date.now(), deadline), env, scratch: scratchFor, args: specArgs, signal: specSignal }) : null;
400
+ const presence = store ? await nativeRead({ store, namespaces, deadline, env, scratch: scratchFor, signal, session }) : null;
401
+ if (presence?.fatal) { specAbort.abort(); return { ...empty, stores, outcome: presence.state === 'timed out' ? 'timed-out' : 'unavailable',
380
402
  block: formatBlock({ picks: [], status: presence.state }), reason: 'namespace enumeration failed' }; }
381
403
  const sharedKeys = presence?.ok ? async () => ({ ok: true, value: presence.value }) : enumerateCanonicalKeys;
382
404
  const curated = store ? await consequentialRecall({ prompt, root, store, bin, scratch: scratchFor, env, deadline, binding, signal,
383
- enumerateKeys: sharedKeys, exclusions, rankedSearch: false }).catch(() => null) : null;
405
+ enumerateKeys: sharedKeys, exclusions, rankedSearch: false, session }).catch(() => null) : null;
384
406
  const searchDeadline = searchDeadlineFor(Date.now(), deadline);
385
407
  const activeNamespaces = presence?.ok ? namespaces.filter(namespace => presence.value[namespace]?.length) : namespaces;
386
408
  // ONE supplementary search (owner ruling 2026-10-12). Measured on a live-size store at load 17: ~10
@@ -389,16 +411,19 @@ export async function recall({ prompt, projectDir = process.cwd(), env = process
389
411
  const searchNamespace = [path.basename(root), 'default', 'turns'].find((namespace) => activeNamespaces.includes(namespace));
390
412
  const jobs = store && searchNamespace ? [{ store, namespace: searchNamespace, family: null,
391
413
  args: ['--format', 'json', '-q', query, '-n', searchNamespace, '--limit', '12'] }] : [];
414
+ const useSpec = spec && jobs[0]?.namespace === specNamespace;
415
+ if (!useSpec) specAbort.abort();
392
416
  const results = await Promise.all(jobs.map(async ({ store: jobStore, namespace, family: recordFamily, args }) => ({ namespace, family: recordFamily, storePath: jobStore.path,
393
- ...await searchOnce({ ruflo: bin, store: jobStore, deadline: searchDeadline, env, scratch: scratchFor, args, signal }) })));
417
+ ...await (useSpec ? spec : searchOnce({ ruflo: bin, store: jobStore, deadline: searchDeadline, env, scratch: scratchFor, args, signal })) })));
394
418
  let status = results.every((r) => r.state === 'ok') ? 'ok'
395
419
  : results.some((r) => r.state === 'timed out') ? 'timed out' : 'unavailable';
396
- // Overfetch a bounded six exact values so rejected turn metadata cannot hide
397
- // the next useful outcome; at most three verified excerpts are delivered.
398
- const candidates = pickRows(results, 6);
420
+ // Overfetch a bounded twelve exact values (the one search already returns twelve; the exact reads are
421
+ // one batched reader, so this adds no Ruflo startup) so rejected turn metadata and repeated "cannot
422
+ // determine" answers cannot hide the next useful outcome (#429); at most three excerpts are delivered.
423
+ const candidates = pickRows(results, 12);
399
424
  // Ruflo previews are ~60 characters. Read the actual selected values within
400
425
  // the same deadline so the block contains useful evidence rather than titles.
401
- const exactResults = await exactReads({ candidates, bin, deadline, env, scratch: scratchFor, signal });
426
+ const exactResults = await exactReads({ candidates, bin, deadline, env, scratch: scratchFor, signal, session });
402
427
  const retrieved = await Promise.all(candidates.map(async (p) => {
403
428
  const r = exactResults.get(p);
404
429
  if (r.state === 'ok' && r.value && !r.value.startsWith('[WARN]')) {
@@ -417,6 +442,12 @@ export async function recall({ prompt, projectDir = process.cwd(), env = process
417
442
  }
418
443
  return { pick: null, state: r.state === 'ok' ? 'unavailable' : r.state };
419
444
  }));
445
+ // A turn that admitted it could not answer is evidence of a failed recall, not the missing fact (#429).
446
+ // It stays available, after concrete outcomes; a query about failures keeps the ranked order.
447
+ if (!MISSING_KNOWLEDGE_QUERY.test(prompt)) {
448
+ const rank = ({ pick }) => Number(pick?.namespace === 'turns' && MISSING_KNOWLEDGE.test(pick.preview));
449
+ retrieved.sort((a, b) => rank(a) - rank(b));
450
+ }
420
451
  const seenClauses = new Set();
421
452
  const picks = retrieved.flatMap(({ pick }) => {
422
453
  if (!pick) return [];
@@ -452,6 +483,7 @@ export async function recall({ prompt, projectDir = process.cwd(), env = process
452
483
  authority: false };
453
484
  return { block: formatBlock({ picks, status, withheld: curated?.withheld || 0 }), picks, stores, status: { 'memory.db': status }, outcome, receipt };
454
485
  } catch (error) { return { ...empty, outcome: 'unavailable', reason: 'canonical recall failed: ' + clean(error.message, 180) }; }
486
+ finally { specAbort?.abort(); session?.close(); }
455
487
  }
456
488
 
457
489
  async function main() {
@@ -470,7 +502,7 @@ async function main() {
470
502
  }
471
503
 
472
504
  function isMain() {
473
- try { return Boolean(process.argv[1]) && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)); }
505
+ try { return isDirectInvocation(import.meta.url); }
474
506
  catch { return false; }
475
507
  }
476
508
  if (isMain()) main().catch(() => process.exit(0));
@@ -98,7 +98,15 @@ export function automaticInvocation(args, { source, home = os.homedir(), nodePat
98
98
  const coordinator = developerCoordinatorOwner({ home });
99
99
  if (coordinator.active) {
100
100
  if (!coordinator.ready) throw new Error(`coordinated updater is not ready: ${coordinator.reason}`);
101
- return { executable: nodePath, args: [coordinator.entry, '--apply'], source: 'developer-suite', coordinator };
101
+ // Hooks and timers get knowledge only, never the whole suite (brew, cargo, every npm install) under a session TTL
102
+ // that kills it mid-install (AuditC #10). The full suite runs only from the scheduled runner, which calls the
103
+ // coordinator directly, not through here.
104
+ // The registered snapshot moves only when an --update reconciles it, so it can predate --knowledge-only
105
+ // (4.7.20 and older refuse it: "unsupported developer update argument", exit 1). Pass the flag only to a
106
+ // coordinator whose source parses it, as runUpdate does for the updater's --result-file.
107
+ const knowledgeOnly = fs.readFileSync(coordinator.entry, 'utf8').includes("'--knowledge-only'");
108
+ return { executable: nodePath, args: [coordinator.entry, '--apply', ...(knowledgeOnly ? ['--knowledge-only'] : [])],
109
+ source: 'developer-suite', coordinator };
102
110
  }
103
111
  }
104
112
  source ??= updateSource({ home });
@@ -143,7 +143,8 @@ export function confirm({ footprint, env = process.env, home = os.homedir(), now
143
143
  const checkable = Boolean(integrity) && fs.existsSync(path.join(roots.kbDir, 'CORPUS-COVERAGE.json'));
144
144
  if (checkable && !integrity.valid) knowledgeProblems.push([`the bytes on disk fail the release-coverage check (${integrity.failures?.[0] || 'unknown'})`, UPDATE]);
145
145
  // A newer corpus is published and not yet installed (identity, not age): advisory, never a gate.
146
- const pending = newerCorpusPending({ source, corpusCheck: readJson(autoUpdatePaths(roots.brainHome).checkFile) });
146
+ const pending = newerCorpusPending({ source, corpusCheck: readJson(autoUpdatePaths(roots.brainHome).checkFile),
147
+ installedAtMs: signed ? Date.parse(signature.verifiedAt || '') : NaN });
147
148
  if (pending) {
148
149
  const lockAt = readJson(autoUpdatePaths(roots.brainHome).lockFile)?.at;
149
150
  const running = Number.isFinite(Date.parse(lockAt || '')) && now - Date.parse(lockAt) < AUTO_UPDATE_LOCK_STALE_MS;