ruvnet-brain 4.7.21 → 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.
@@ -54,6 +54,7 @@ import { readSettledTranscript } from './turn-outcome-capture.mjs';
54
54
  import { continuationProjectIdentity, authorizedContinuationObjective, authorizedPromiseItems, assistantCommitmentOwned, setAssistantCommitmentState } from './continuation-objective.mjs';
55
55
  import {
56
56
  auditCompletionClaims, readClaudeTurn, extractCommitments, promiseClosingClaim,
57
+ promiseEvidence, completionCorrectionText, outcomeAdvisoryText, completionCategory,
57
58
  PROMISE_KIND, PROMISE_CAP_OPEN,
58
59
  } from './completion-claim-evidence.mjs';
59
60
  import { writeAtomic } from './user-settings.mjs';
@@ -376,6 +377,7 @@ const HOST = process.env.RUVNET_HOOK_HOST === 'codex' ? 'codex' : 'claude';
376
377
  * nativeTurnLines reader, #423) are parsed; a Codex rollout that cannot be read or has no current-turn
377
378
  * boundary stays UNKNOWN (completion-claim-evidence.mjs header). Fails open: a throw is NONE.
378
379
  */
380
+ let completionTurnKey = null;
379
381
  const completion = (() => {
380
382
  const message = String(hookInput.last_assistant_message || '');
381
383
  if (!message || !hookInput.session_id) return { verdict: 'NONE', claims: [] };
@@ -384,7 +386,15 @@ const completion = (() => {
384
386
  // Claude always sends a JSONL transcript; one we cannot read is OUR failure, and a Stop hook
385
387
  // fails open on its own machinery (ADR-074 failure semantics) — never a correction request.
386
388
  // Missing native evidence stays UNKNOWN; it cannot silently validate a completion claim.
387
- return auditCompletionClaims(message, { turn, host: HOST });
389
+ const audit = auditCompletionClaims(message, { turn, host: HOST });
390
+ // Turn identity for correction de-duplication (#425): the host's native id (Claude prompt_id, Codex
391
+ // turn_id), else the transcript record that opened this turn (uuid or position, never its text: a repeated
392
+ // "continue" is a new turn). Never a clock.
393
+ const native = HOST === 'codex' ? hookInput.turn_id : hookInput.prompt_id;
394
+ const opener = typeof native === 'string' && native.trim() ? `native:${native}`
395
+ : turn?.boundaryFound && turn.openerId ? `opener:${turn.openerId}` : '';
396
+ if (opener) completionTurnKey = crypto.createHash('sha256').update(JSON.stringify([hookInput.session_id, opener])).digest('hex').slice(0, 24);
397
+ return audit;
388
398
  } catch { return { verdict: 'NONE', claims: [] }; }
389
399
  })();
390
400
 
@@ -416,6 +426,12 @@ function promiseBookkeeping() {
416
426
  changed = true;
417
427
  }
418
428
  }
429
+ // #369: observed evidence moves a promise to `evidenced` (NOT done, never forced again); same session only.
430
+ for (const item of led.items) {
431
+ if (!assistantCommitmentOwned(item, hookInput.session_id, projectIdentity) || !(item.state === undefined || item.state === 'active')) continue;
432
+ const closureEvidence = promiseEvidence(completion, hookInput.last_assistant_message, item);
433
+ if (closureEvidence) { Object.assign(item, { state: 'evidenced', stateAt: at, closureEvidence }); changed = true; }
434
+ }
419
435
  const owned = led.items.filter((i) => assistantCommitmentOwned(i, hookInput.session_id, projectIdentity));
420
436
  const openKeys = new Set(owned.map((i) => i.key));
421
437
  let activeCount = owned.filter((i) => i.state === undefined || i.state === 'active').length;
@@ -428,6 +444,7 @@ function promiseBookkeeping() {
428
444
  led.items.push({ schemaVersion: 1, kind: PROMISE_KIND, text: promise.text, key: promise.key, done: false, state: 'active', at,
429
445
  projectId: pid, worktreeIds: [projectIdentity.worktreeId], sessionIds: [hookInput.session_id],
430
446
  capturedFrom: { sessionId: hookInput.session_id },
447
+ ...(promise.evidenceCommands ? { evidenceCommands: promise.evidenceCommands } : {}),
431
448
  authorization: { kind: 'owner-mandate', reference: 'i-will-is-a-contract-2026-09-15' } });
432
449
  changed = true;
433
450
  }
@@ -727,14 +744,12 @@ if (observations.length && STOP_NUDGES_FULL) console.error(JSON.stringify({ kind
727
744
  // re-running checks or rewording cannot satisfy it. The verdict stays UNKNOWN (never PASS: it closes
728
745
  // no promise and grants no completion authority); under `full` the limitation is reported, not enforced.
729
746
  const scopeOnlyUnknown = completion.verdict === 'UNKNOWN' && completion.namesCheck === true
730
- && completion.disclosesGaps === true && (completion.verification?.checks?.length || 0) > 0;
747
+ && completion.disclosesGaps === true
748
+ && ((completion.verification?.checks?.length || 0) + (completion.verification?.maskedChecks?.length || 0)) > 0;
731
749
  if (scopeOnlyUnknown && STOP_NUDGES_FULL) console.error(JSON.stringify({ kind: 'completion-scope-advisory',
732
- authority: false, verdict: 'UNKNOWN', text: `"${completion.claims[0].text.slice(0, 160)}": a check ran after the last change`
733
- + ' and the answer names it and discloses what is not verified. This gate cannot judge whether that task outcome'
734
- + ' is complete (it only qualifies check-scoped statements), so completion stays UNKNOWN. Not blocking: more checks'
735
- + ' or rewording cannot change this verdict.' }));
750
+ authority: false, verdict: 'UNKNOWN', outcomes: completion.outcomes, text: outcomeAdvisoryText(completion) }));
736
751
  const completionWork = ['FAIL', 'UNKNOWN'].includes(completion.verdict) && !scopeOnlyUnknown ? [{
737
- text: `You claimed "${completion.claims[0].text.slice(0, 160)}" is done; ${completion.problems.join('; ')}. Run the real consumer path, or restate it as UNVERIFIED.`,
752
+ text: completionCorrectionText(completion),
738
753
  at: new Date(nowMs).toISOString(), derived: true, kind: 'completion-claim-integrity',
739
754
  }] : [];
740
755
  // DECISION RECORDS (ADR-105 §8, ADR-106): a turn that CREATED an ADR in an adopted project must also
@@ -880,6 +895,36 @@ try {
880
895
  if (touched) fs.writeFileSync(FORCES, JSON.stringify(counts));
881
896
  if (retired.length) { save(led); forceable = forceable.filter((i) => !retired.includes(i)); }
882
897
  } catch { /* bookkeeping only: never break a turn */ }
898
+ /**
899
+ * A completion correction is delivered at most once per native session + user turn + audit category (#425,
900
+ * sparkling). The marker is an exclusive-create file (the same primitive as the cooldown lock), so two racing
901
+ * Stop hooks cannot both deliver. Rewording or elapsed time does not rearm it; a new user turn does. With no
902
+ * turn identity the correction degrades to a non-blocking advisory carrying the ORIGINAL verdict (never PASS).
903
+ * This only ever removes a blocking item.
904
+ */
905
+ try {
906
+ const item = forceable.find((i) => i.kind === 'completion-claim-integrity');
907
+ if (item) {
908
+ const category = completionCategory(completion);
909
+ let deliver = false;
910
+ if (!completionTurnKey) {
911
+ console.error(JSON.stringify({ kind: 'completion-correction-advisory', authority: false, verdict: completion.verdict,
912
+ category, reason: 'turn identity unavailable; a correction cannot be de-duplicated, so it is not enforced', text: item.text }));
913
+ } else {
914
+ const id = crypto.createHash('sha256').update(JSON.stringify([hookInput.session_id, completionTurnKey, category])).digest('hex').slice(0, 24);
915
+ const dir = `${LEDGER}.completion-delivered`;
916
+ try {
917
+ fs.mkdirSync(dir, { recursive: true });
918
+ fs.writeFileSync(path.join(dir, id), new Date(nowMs).toISOString(), { flag: 'wx' });
919
+ deliver = true;
920
+ for (const f of fs.readdirSync(dir)) { // markers are only needed for the turn; keep a week
921
+ try { if (nowMs - fs.statSync(path.join(dir, f)).mtimeMs > 7 * 86_400_000) fs.rmSync(path.join(dir, f), { force: true }); } catch { /* raced */ }
922
+ }
923
+ } catch { /* already delivered this turn/category, or cannot persist: do not block */ }
924
+ }
925
+ if (!deliver) forceable = forceable.filter((i) => i !== item);
926
+ }
927
+ } catch { forceable = forceable.filter((i) => i.kind !== 'completion-claim-integrity'); }
883
928
  if (!forceable.length) process.exit(EXIT_ALLOW);
884
929
  if (deferralState && forceable.some((i) => i.kind === 'no-deferral')) {
885
930
  try { for (const [k, v] of Object.entries(deferralState.counts)) if (nowMs - Date.parse(v?.at) > 7 * 86_400_000) delete deferralState.counts[k];
@@ -80,6 +80,8 @@ import {
80
80
  CONFIG_ROOT,
81
81
  loadLessons,
82
82
  lessonsFor,
83
+ projectNameFor,
84
+ lessonApplies,
83
85
  ENFORCEMENT,
84
86
  STATUS,
85
87
  ORIGIN,
@@ -263,42 +265,8 @@ const gateState = event ? readGateState() : {};
263
265
  * the fastest way to make someone uninstall a nudge is to nudge them about something that has
264
266
  * nothing to do with what they are doing.
265
267
  */
266
- const HERE = (() => {
267
- let d = process.cwd();
268
- for (let i = 0; i < 12; i++) {
269
- if (fs.existsSync(path.join(d, '.git'))) break;
270
- const up = path.dirname(d);
271
- if (up === d) { d = process.cwd(); break; }
272
- d = up;
273
- }
274
- return path.basename(d);
275
- })();
276
- /**
277
- * A "suffix match" for project names, bounded so it cannot fire on a bare shared tail. `a` matches
278
- * `b` when `b` is a whole path/name SEGMENT suffix of `a` — the character immediately before it must
279
- * be a separator, never a mid-word letter. Without this, "Sentry" matched inside "WhitSentry" and any
280
- * project name that merely happens to end another's, which is the exact cross-project leak the
281
- * 2026-07-22 project-scope fix (this same function, below) was written to close.
282
- */
283
- const segmentSuffixMatch = (a, b) => {
284
- if (a === b) return true;
285
- if (a.length <= b.length || !a.endsWith(b)) return false;
286
- return /[-_/]/.test(a[a.length - b.length - 1]);
287
- };
288
-
289
- /** Does this lesson belong to the project we are standing in? Match is loose on purpose — stored
290
- * names carry prefixes like `Code-` that the directory name does not — but bounded to a real
291
- * delimiter so it cannot match a bare, accidental shared suffix between unrelated projects. */
292
- const isHome = (l) => {
293
- const ps = Array.isArray(l.projects) ? l.projects : [];
294
- if (!ps.length) return true; // unscoped: applies anywhere, by declaration
295
- return ps.some((p) => {
296
- const n = String(p).replace(/^Code-/, '');
297
- return n === HERE || String(p) === HERE
298
- || segmentSuffixMatch(HERE, n) || segmentSuffixMatch(n, HERE);
299
- });
300
- };
301
- const isUniversal = (l) => Array.isArray(l.projects) && l.projects.length >= 2;
268
+ const HERE = projectNameFor(process.cwd());
269
+ const applies = (l) => lessonApplies(l, HERE);
302
270
 
303
271
  // Apply the mutate-machine predicate defined above. `mutate-machine` is requested for EVERY Bash
304
272
  // call (plugin/scripts/lesson-hooks.sh:98) — it is the ONLY trigger the dispatcher fires unconditionally
@@ -313,10 +281,10 @@ const effectiveTriggers = command === null
313
281
  const seen = new Set();
314
282
  const candidates = [];
315
283
  for (const t of effectiveTriggers) {
316
- for (const l of lessonsFor(t, lessons.filter((l) => isHome(l) || isUniversal(l)), { limit: 3 })) {
284
+ for (const l of lessonsFor(t, lessons.filter(applies), { limit: 3 })) {
317
285
  if (seen.has(l.id)) continue;
318
- // Away from home, only a lesson with cross-project evidence may speak.
319
- if (!isHome(l) && !isUniversal(l)) continue;
286
+ // Away from home, only a lesson with cross-project evidence may speak (lessonApplies: one rule, shared with the Console).
287
+ if (!applies(l)) continue;
320
288
  seen.add(l.id); candidates.push(l);
321
289
  }
322
290
  }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Where a lesson applies: the ONE project-identity and applicability rule, shared by plugin/scripts/lesson-gate.mjs and
3
+ * the Console (scripts/onboarding-console.mjs). Re-exported from lesson-store.mjs. Pure apart from the git/fs reads
4
+ * projectNameFor makes, which are bounded.
5
+ */
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { resolveProjectStore } from './project-store-resolver.mjs';
9
+
10
+ /**
11
+ * THE project identity lessons are scoped by: the folder name of the CANONICAL project root (the primary checkout,
12
+ * the same root resolveProjectStore uses for the store), so a linked worktree `feature-x` is the project it belongs to,
13
+ * not its own name. Falls back to the nearest folder holding a `.git`, then to the folder itself, when the resolver
14
+ * refuses (the name is only a lesson-scope label; a refusal must not silence the gate).
15
+ */
16
+ export function projectNameFor(cwd = process.cwd()) {
17
+ try { return path.basename(resolveProjectStore({ projectDir: cwd, gitTimeoutMs: 500 }).projectRoot); } catch { /* fall through */ }
18
+ let d = cwd;
19
+ for (let i = 0; i < 12; i++) {
20
+ if (fs.existsSync(path.join(d, '.git'))) return path.basename(d);
21
+ const up = path.dirname(d);
22
+ if (up === d) break;
23
+ d = up;
24
+ }
25
+ return path.basename(cwd);
26
+ }
27
+
28
+ /**
29
+ * `a` matches `b` when `b` is a whole path/name SEGMENT suffix of `a`: the character before it must be a separator,
30
+ * never a mid-word letter ("Sentry" must not match inside "WhitSentry").
31
+ */
32
+ export function segmentSuffixMatch(a, b) {
33
+ if (a === b) return true;
34
+ if (a.length <= b.length || !a.endsWith(b)) return false;
35
+ return /[-_/]/.test(a[a.length - b.length - 1]);
36
+ }
37
+
38
+ /**
39
+ * THE applicability rule (lesson-gate and the Console share it): a lesson applies to a project when it is unscoped,
40
+ * scoped to that project (stored names may carry a `Code-` prefix the folder lacks), or proven in two or more
41
+ * projects (cross-project rediscovery is what makes it universal, ADR-029).
42
+ */
43
+ export function lessonApplies(lesson, projectName) {
44
+ const ps = Array.isArray(lesson?.projects) ? lesson.projects : [];
45
+ if (!ps.length || ps.length >= 2) return true;
46
+ const here = String(projectName);
47
+ return ps.some((p) => {
48
+ const n = String(p).replace(/^Code-/, '');
49
+ return n === here || String(p) === here || segmentSuffixMatch(here, n) || segmentSuffixMatch(n, here);
50
+ });
51
+ }
@@ -24,7 +24,7 @@ import fs from 'node:fs';
24
24
  import os from 'node:os';
25
25
  import path from 'node:path';
26
26
  import { SOURCE_CLASS, STATUS, isUntouchedOwnerSeedRow } from './lesson-provenance.mjs';
27
-
27
+ export { projectNameFor, segmentSuffixMatch, lessonApplies } from './lesson-scope.mjs';
28
28
  export { BUNDLED_OWNER_SEED_IDS, SOURCE_CLASS, STATUS, isUntouchedOwnerSeedRow } from './lesson-provenance.mjs';
29
29
 
30
30
  /** Resolve fixture/plugin configuration without mutating the child process account HOME. */
@@ -109,7 +109,7 @@ export function rufloInvocationOrPlain(binary, args, opts) {
109
109
  * @param {{ env?: Record<string,string|undefined>, candidates?: string[] }} [opts]
110
110
  * @returns {{ node: string, entry: string }|null}
111
111
  */
112
- export function nativeNodeFor(binary, { env = process.env, candidates } = {}) {
112
+ export function nativeNodeFor(binary, { env = process.env, candidates, deadlineAt = Infinity } = {}) {
113
113
  let entry;
114
114
  try { entry = fs.realpathSync(binary); } catch { return null; }
115
115
  const addon = findAddon(path.dirname(path.dirname(entry)));
@@ -123,21 +123,29 @@ export function nativeNodeFor(binary, { env = process.env, candidates } = {}) {
123
123
  let chosen = null;
124
124
  // Bounded: this runs inside hooks that have their own deadline (SessionStart is stopped 1.5 s early), so a
125
125
  // wedged candidate must cost seconds at most and then leave the invocation exactly as it was.
126
+ // A caller's absolute deadline caps the probes too (#501); a probe cut short by it proves nothing, so it is not cached.
126
127
  const started = Date.now();
128
+ let cut = false;
127
129
  for (let i = 0; i < list.length; i += 1) {
128
- if (Date.now() - started > PROBE_BUDGET_MS) break;
129
- const r = spawnSync(list[i], ['-e', probe], { stdio: 'ignore', timeout: PROBE_TIMEOUT_MS, windowsHide: true });
130
+ const budgetLeft = PROBE_BUDGET_MS - (Date.now() - started);
131
+ const deadlineLeft = deadlineAt - Date.now();
132
+ if (Math.min(budgetLeft, deadlineLeft) <= 0) { cut = deadlineLeft <= 0; break; }
133
+ const probeTimeout = Math.max(1, Math.floor(Math.min(PROBE_TIMEOUT_MS, budgetLeft, deadlineLeft)));
134
+ const deadlineLimited = deadlineLeft < Math.min(PROBE_TIMEOUT_MS, budgetLeft);
135
+ const r = spawnSync(list[i], ['-e', probe], { stdio: 'ignore', timeout: probeTimeout, windowsHide: true });
130
136
  if (r.status === 0) { chosen = i === 0 ? null : { node: list[i], entry }; break; }
137
+ // This probe's own timeout came from the deadline, not from PROBE_TIMEOUT_MS: it proves nothing, so do not cache.
138
+ if (r.error?.code === 'ETIMEDOUT' && deadlineLimited) { cut = true; break; }
131
139
  }
132
- nativeNodeCache.set(cacheKey, chosen);
140
+ if (!cut) nativeNodeCache.set(cacheKey, chosen);
133
141
  return chosen;
134
142
  }
135
143
 
136
144
  // npm's Windows .cmd shim cannot be spawned without a shell. Resolve the declared entry
137
145
  // inside that same managed installation and pass argv directly to Node instead.
138
- export function rufloInvocation(binary, args, { platform = process.platform, env = process.env, nodeCandidates } = {}) {
146
+ export function rufloInvocation(binary, args, { platform = process.platform, env = process.env, nodeCandidates, deadlineAt } = {}) {
139
147
  if (platform !== 'win32') {
140
- const native = nativeNodeFor(binary, { env, candidates: nodeCandidates });
148
+ const native = nativeNodeFor(binary, { env, candidates: nodeCandidates, deadlineAt });
141
149
  return native ? { executable: native.node, args: [native.entry, ...args] } : { executable: binary, args };
142
150
  }
143
151
  if (!/\.cmd$/i.test(binary)) return { executable: binary, args };
@@ -16,11 +16,12 @@
16
16
  import fs from 'node:fs';
17
17
  import crypto from 'node:crypto';
18
18
  import path from 'node:path';
19
- import { execFile, execFileSync, spawnSync } from 'node:child_process';
19
+ import { execFileSync, spawn, spawnSync } from 'node:child_process';
20
20
  import os from 'node:os';
21
21
  import { resolveRuflo, rufloInvocationOrPlain, rufloRunDir, rufloScratchRoot } from './ruflo-bin.mjs';
22
22
  import { resolveProjectStore } from './project-store-resolver.mjs';
23
23
  import { checkpointNamespaces, newestOfFamily } from './checkpoint-keys.mjs';
24
+ import { openBatchSession } from './agentdb-recall-process.mjs';
24
25
 
25
26
  export const CURATED_PREFIX = 'project-state-current-';
26
27
  export const AUTO_PREFIX = 'project-state-auto-';
@@ -29,16 +30,30 @@ export const AUTO_PREFIX = 'project-state-auto-';
29
30
  * The SAME store and namespace turn capture uses (review M2): the canonical project's `.swarm/memory.db`,
30
31
  * which a linked git worktree shares with its main checkout. Every git call is bounded by the deadline.
31
32
  */
32
- export function projectStore(projectDir, { deadlineAt = Date.now() + 3000 } = {}) {
33
- let root = projectDir; let db = path.join(projectDir, '.swarm', 'memory.db');
33
+ export function projectStore(projectDir, { deadlineAt = Date.now() + 3000, resolve = resolveProjectStore } = {}) {
34
+ let resolved;
34
35
  try {
35
36
  const gitTimeoutMs = Math.max(1, Math.min(1000, Math.floor(deadlineAt - Date.now())));
36
- const resolved = resolveProjectStore({ projectDir, gitTimeoutMs, deadlineAt });
37
- root = resolved.projectRoot; db = resolved.canonicalAgentDbPath;
38
- } catch { /* unresolvable (or out of budget): treated as not adopted below only if no store exists here */ }
37
+ resolved = resolve({ projectDir, gitTimeoutMs, deadlineAt });
38
+ } catch (error) {
39
+ // A refusal (invalid Git marker, identity timeout, symlink/hard-link escape) is never permission to use
40
+ // the raw local `.swarm/memory.db` instead (#503). A valid non-Git project resolves without throwing.
41
+ const reason = refusalReason(error);
42
+ return { root: projectDir, db: null, adopted: false, namespace: path.basename(projectDir), refused: reason,
43
+ // A local store is only "withheld" when one exists; a project that never adopted stays quiet.
44
+ withheld: fs.existsSync(path.join(projectDir, '.swarm', 'memory.db')),
45
+ timedOut: /deadline exceeded|timed out/i.test(String(error?.message || '')) };
46
+ }
47
+ const root = resolved.projectRoot; const db = resolved.canonicalAgentDbPath;
39
48
  return { root, db, adopted: fs.existsSync(db), namespace: path.basename(root) };
40
49
  }
41
50
 
51
+ /** The resolver's own short reason; never a path, never a stack (they can carry private locations). */
52
+ function refusalReason(error) {
53
+ const text = String(error?.message || '').split('\n')[0].slice(0, 120);
54
+ return text && !/[\\/]/.test(text) ? text : 'canonical project store could not be resolved';
55
+ }
56
+
42
57
  /**
43
58
  * The spawn options every ruflo call here uses. `--path` pins the store, so the working directory only receives
44
59
  * what ruflo writes beside itself (`ruvector.db`, `.swarm/hnsw.*`, verified on 3.56.9). Run it in a fresh private
@@ -51,16 +66,101 @@ export function rufloSpawnOptions(db, env = process.env) {
51
66
  return { cwd, env: { ...env, RUFLO_DAEMON_AUTOSTART: '0', CLAUDE_FLOW_AUTO_UPDATE: 'false' }, cleanup: () => { try { fs.rmSync(cwd, { recursive: true, force: true }); } catch { /* swept later as a stale run- dir */ } } };
52
67
  }
53
68
 
54
- /** One `ruflo memory <args>` call, bounded by the caller's deadline. Resolves, never rejects. */
55
- export function rufloMemory(args, { db, env = process.env, bin = resolveRuflo({ env }), deadlineAt = Date.now() + 3000 } = {}) {
56
- const timeout = Math.max(1, Math.floor(deadlineAt - Date.now()));
69
+ /** SIGKILL the child's whole process group (it is spawned detached, so it leads one); the child alone if it does not. */
70
+ function killGroup(child) {
71
+ try {
72
+ if (process.platform !== 'win32' && Number.isInteger(child?.pid) && child.pid > 1) process.kill(-child.pid, 'SIGKILL');
73
+ else child?.kill('SIGKILL');
74
+ } catch { try { child?.kill('SIGKILL'); } catch { /* already gone */ } }
75
+ }
76
+
77
+ const WATCHDOG_GRACE_MS = 2000;
78
+ // Runs in `node -e`: argv = [deadlineMs (0 = none), file, ...args]. Exit code is the command's; 127 when it cannot start.
79
+ const WATCHDOG = `const {spawn}=require('node:child_process');
80
+ const ms=Number(process.argv[1]);const file=process.argv[2];const args=process.argv.slice(3);
81
+ const killAll=()=>{try{process.kill(-process.pid,'SIGKILL')}catch{process.exit(137)}};
82
+ if(ms>0)setTimeout(killAll,ms);
83
+ process.stdin.on('end',killAll);process.stdin.on('close',killAll);process.stdin.on('error',killAll);process.stdin.resume();
84
+ const c=spawn(file,args,{stdio:['ignore','inherit','inherit']});
85
+ c.on('error',(e)=>{process.stderr.write(String(e.message)+'\\n');process.exit(127)});
86
+ c.on('close',(code)=>process.exit(code===null?1:code));`;
87
+
88
+ /**
89
+ * execFile-shaped runner that really starts the child as its OWN process group. Node's execFile silently drops the
90
+ * `detached` option (verified: the child stayed in the caller's group), so a group kill needs spawn itself.
91
+ * Calls back once with (error, stdout, stderr); `error.killed` is true only when WE killed it (timeout/overflow).
92
+ */
93
+ export function spawnDetached(file, args, opts = {}, cb = () => {}) {
94
+ // POSIX: the real command runs under a tiny watchdog that leads the new process group. The watchdog (a) SIGKILLs its
95
+ // whole group at timeout + grace on its own clock and (b) does so the moment its stdin (a pipe held by THIS process)
96
+ // reaches EOF, which is what happens when this process is SIGKILLed. So a hook killed by its host cannot leave a
97
+ // ruflo child or grandchild behind, and a child can never outlive its own deadline. Windows has no process groups
98
+ // here: it keeps the previous behaviour (direct spawn, no watchdog, child-only kill), so the bound there is the parent's timer alone.
99
+ const guarded = process.platform !== 'win32';
100
+ const child = guarded
101
+ ? spawn(process.execPath, ['-e', WATCHDOG, String(opts.timeout > 0 ? opts.timeout + WATCHDOG_GRACE_MS : 0), file, ...args],
102
+ { cwd: opts.cwd, env: opts.env, detached: true, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] })
103
+ : spawn(file, args, { cwd: opts.cwd, env: opts.env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] });
104
+ if (guarded) child.stdin.on('error', () => { /* watchdog gone */ });
105
+ let out = ''; let err = ''; let bytes = 0; let killed = false; let done = false;
106
+ const finish = (error) => { if (done) return; done = true; clearTimeout(timer); cb(error, out, err); };
107
+ const take = (chunk, into) => {
108
+ bytes += chunk.length;
109
+ if (bytes > (opts.maxBuffer || Infinity)) { killed = true; killGroup(child); return into; }
110
+ return into + chunk;
111
+ };
112
+ child.stdout.setEncoding('utf8'); child.stderr.setEncoding('utf8');
113
+ child.stdout.on('data', (c) => { out = take(c, out); });
114
+ child.stderr.on('data', (c) => { err = take(c, err); });
115
+ const timer = opts.timeout > 0 ? setTimeout(() => { killed = true; killGroup(child); }, opts.timeout) : null;
116
+ child.on('error', (e) => finish(e));
117
+ child.on('close', (code, signal) => finish(code === 0 && !killed ? null : Object.assign(new Error(`exited ${code ?? signal}`), { code, signal, killed })));
118
+ return child;
119
+ }
120
+
121
+ /**
122
+ * One `ruflo memory <args>` call, bounded by the caller's ABSOLUTE deadline and cancelled by its signal. Resolves,
123
+ * never rejects. The child's timeout is what is left AFTER interpreter preparation, and nothing is launched once
124
+ * the deadline has passed or the signal has fired (#501). The signal is NOT handed to execFile (Node would send
125
+ * SIGTERM and call back before the child exits): on abort the child is SIGKILLed here and the result resolves
126
+ * only once it has closed.
127
+ */
128
+ export function rufloMemory(args, { db, env = process.env, bin = resolveRuflo({ env }), deadlineAt = Date.now() + 3000, signal,
129
+ run = spawnDetached, invoke = rufloInvocationOrPlain } = {}) {
57
130
  return new Promise((resolve) => {
58
- if (!bin) return resolve({ ok: false, stdout: '', error: 'global Ruflo unavailable' });
131
+ const refuse = (error) => resolve({ ok: false, stdout: '', error });
132
+ if (!bin) return refuse('global Ruflo unavailable');
133
+ if (signal?.aborted) return refuse('cancelled');
134
+ if (deadlineAt - Date.now() <= 0) return refuse('deadline exceeded before launch');
59
135
  const spawnAt = rufloSpawnOptions(db, env);
60
- const inv = rufloInvocationOrPlain(bin, ['memory', ...args, '--path', db], { env: spawnAt.env });
61
- execFile(inv.executable, inv.args, { cwd: spawnAt.cwd, env: spawnAt.env, timeout, killSignal: 'SIGKILL', encoding: 'utf8', maxBuffer: 8 * 1024 * 1024 },
62
- // The ledger gets a fixed reason, never execFile's message (it embeds the whole argv: value, db path).
63
- (error, stdout) => { spawnAt.cleanup(); resolve({ ok: !error, stdout: String(stdout || ''), error: error ? (error.killed ? 'timed out' : `ruflo memory ${args[0]} exited ${error.code ?? 'abnormally'}`) : null }); });
136
+ const inv = invoke(bin, ['memory', ...args, '--path', db], { env: spawnAt.env, deadlineAt });
137
+ const timeout = Math.floor(deadlineAt - Date.now());
138
+ if (signal?.aborted || timeout < 1) { spawnAt.cleanup(); return refuse(signal?.aborted ? 'cancelled' : 'deadline exceeded before launch'); }
139
+ let child; let onAbort; let timer; let timedOut = false;
140
+ // The ledger gets a fixed reason, never execFile's message (it embeds the whole argv: value, db path).
141
+ // The default runner starts it as its own process group (spawnDetached): abort and timeout kill the GROUP, so a grandchild (`sh -c 'sleep …'`) cannot
142
+ // outlive the call and keep running after its working directory is deleted under it.
143
+ child = run(inv.executable, inv.args, { cwd: spawnAt.cwd, env: spawnAt.env, timeout, killSignal: 'SIGKILL', encoding: 'utf8', maxBuffer: 8 * 1024 * 1024 },
144
+ (error, stdout) => {
145
+ clearTimeout(timer);
146
+ if (onAbort) signal.removeEventListener('abort', onAbort);
147
+ spawnAt.cleanup();
148
+ const reason = !error ? null : signal?.aborted ? 'cancelled'
149
+ : error.killed || timedOut ? 'timed out' : `ruflo memory ${args[0]} exited ${error.code ?? 'abnormally'}`;
150
+ resolve({ ok: !error, stdout: String(stdout || ''), error: reason });
151
+ });
152
+ // Backstop for an injected runner: kills the whole group a hair before any child-only timeout.
153
+ timer = setTimeout(() => { timedOut = true; killGroup(child); }, Math.max(1, timeout - 5));
154
+ if (signal) {
155
+ // Kill the child's group AND destroy its pipes. execFile's callback waits for stdout/stderr to close, so a grandchild
156
+ // that inherited them (`sh -c 'sleep …'`) held the result until the deadline (#501: measured 5006 ms).
157
+ // The group kill ends the grandchild; destroying the pipes releases the wait even if one escaped the group.
158
+ onAbort = () => {
159
+ killGroup(child);
160
+ for (const s of [child?.stdout, child?.stderr, child?.stdin]) { try { s?.destroy(); } catch { /* closed */ } }
161
+ };
162
+ if (signal.aborted) onAbort(); else signal.addEventListener('abort', onAbort, { once: true });
163
+ }
64
164
  });
65
165
  }
66
166
 
@@ -72,10 +172,21 @@ function parseJson(text) {
72
172
  const entriesOf = (json) => (Array.isArray(json) ? json : json?.entries || json?.results || []);
73
173
 
74
174
  async function listKeys(namespace, options, limit = 1000) {
175
+ if (options.session) {
176
+ // Ruflo's own list function in the one owned child (#429). 'fallback' = that route cannot answer: use the CLI.
177
+ const r = await options.session.list(namespace, limit, options.deadlineAt);
178
+ if (r.state === 'ok') return r.rows.filter((e) => typeof e?.key === 'string');
179
+ if (r.state !== 'fallback') return null;
180
+ }
75
181
  const r = await rufloMemory(['list', '-n', namespace, '--limit', String(limit), '--format', 'json'], options);
76
182
  return r.ok ? entriesOf(parseJson(r.stdout)).filter((e) => typeof e?.key === 'string') : null;
77
183
  }
78
184
  const retrieve = async (key, namespace, options) => {
185
+ if (options.session) {
186
+ const r = await options.session.get(namespace, key, options.deadlineAt);
187
+ if (r.state === 'ok') return r.value;
188
+ if (r.state !== 'fallback') return null;
189
+ }
79
190
  const r = await rufloMemory(['retrieve', '--key', key, '-n', namespace, '--value-only'], options);
80
191
  return r.ok ? r.stdout.trim() : null;
81
192
  };
@@ -93,6 +204,7 @@ function gitFacts(root, deadlineAt) {
93
204
  export async function checkpoint({ projectDir, event, payload = {}, env = process.env, bin, deadlineAt = Date.now() + 7000, now = Date.now } = {}) {
94
205
  if (String(env.RUVNET_TURN_CAPTURE || '').toLowerCase() === 'off') return { stored: false, skipped: 'RUVNET_TURN_CAPTURE=off' };
95
206
  const store = projectStore(projectDir, { deadlineAt });
207
+ if (store.refused && store.withheld) return { stored: false, skipped: `canonical project store refused: ${store.refused}` };
96
208
  if (!store.adopted) return { stored: false, skipped: 'project has not adopted a .swarm/memory.db' };
97
209
  // A short session hash keeps two sessions' same-millisecond checkpoints apart; --no-upsert makes a
98
210
  // collision a refusal (ledger row) instead of a silent overwrite (review m1).
@@ -137,10 +249,23 @@ export function needsFallback(facts, best) {
137
249
  }
138
250
 
139
251
  /** SessionStart: newest curated checkpoint (+ newer auto checkpoint) and lesson keys, by exact key. */
140
- export async function restore({ cwd, env = process.env, bin, deadlineAt = Date.now() + 3000 } = {}) {
141
- const store = projectStore(cwd, { deadlineAt });
252
+ export async function restore({ cwd, env = process.env, bin, deadlineAt = Date.now() + 3000, signal, resolve, run, invoke } = {}) {
253
+ const store = projectStore(cwd, { deadlineAt, resolve });
254
+ if (store.refused && store.withheld) {
255
+ // A timeout proves nothing about the store: 'unknown'. A real refusal of a store that exists is 'refused'.
256
+ return { status: store.timedOut ? 'unknown' : 'refused',
257
+ context: `[RuvNet Brain — PROJECT CONTINUITY UNKNOWN] the canonical project store ${store.timedOut ? 'could not be resolved in time' : 'was refused'} (${store.refused}); nothing restored.` };
258
+ }
142
259
  if (!store.adopted) return { status: 'unavailable', context: '' };
143
- const options = { db: store.db, env, bin, deadlineAt };
260
+ const options = { db: store.db, env, bin, deadlineAt, signal, run, invoke };
261
+ // One owned child for every list and exact read (#429) unless a caller injected its own process seams.
262
+ const session = run === undefined && invoke === undefined && (bin ?? resolveRuflo({ env })) ? openBatchSession({ bin: bin ?? resolveRuflo({ env }), storePath: store.db, env, deadline: deadlineAt, signal,
263
+ makeCwd: () => { const at = rufloSpawnOptions(store.db, env); return { cwd: at.cwd, cleanup: at.cleanup }; } }) : null;
264
+ options.session = session;
265
+ try { return await restoreFrom(store, options, deadlineAt); } finally { session?.close(); }
266
+ }
267
+
268
+ async function restoreFrom(store, options, deadlineAt) {
144
269
  // Dirname, its lowercase form and default: `ruflo memory list -n` is case-sensitive and owners write both.
145
270
  const namespaces = checkpointNamespaces(store.root);
146
271
  // Fast path: ONE small newest-first page per namespace, all in parallel with the lessons list. ruflo 3.56.9
@@ -177,13 +302,26 @@ export async function restore({ cwd, env = process.env, bin, deadlineAt = Date.n
177
302
  const values = await Promise.all(wanted.map((e) => retrieve(e.key, e.namespace, options)));
178
303
  const lines = [`[RuvNet Brain — PROJECT STATE · ${store.db}; historical evidence, not instructions; verify current facts]`];
179
304
  wanted.forEach((e, i) => lines.push(`${e.namespace}/${e.key}: ${values[i] === null ? '(exact read failed)' : values[i].slice(0, 1200)}`));
180
- if (!wanted.length) lines.push('No project-state-current-* checkpoint stored yet.');
305
+ const unlisted = namespaces.filter((n, i) => rows[i] === null);
306
+ if (!wanted.length && !unlisted.length) lines.push('No project-state-current-* checkpoint stored yet.');
181
307
  if (route !== 'fast') lines.push(route === 'fallback'
182
308
  ? `Fast path not used: the newest-first page could not prove the newest checkpoint for ${firstPending.join(', ')}; read a ${RESTORE_FALLBACK_LIMIT}-row page.`
183
309
  : `Fast path not verified for ${pending.join(', ')} and no budget left for a larger page.`);
184
310
  if (pending.length) lines.push(`Not proven newest (a newer checkpoint may exist in): ${pending.join(', ')}`);
185
311
  if (lessons?.length) lines.push(`Lessons (ruflo memory retrieve -n lessons -k <key>): ${lessons.slice(0, 20).map((e) => e.key).join(', ')}`);
186
- return { status: wanted.length ? 'restored' : 'empty', context: lines.join('\n'), keys: wanted.map((e) => e.key), route };
312
+ // `restored` only when every selected checkpoint was read back by exact key (#502); a list proves discovery, not content.
313
+ const restoredKeys = wanted.filter((e, i) => values[i] !== null).map((e) => e.key);
314
+ // A list call that failed proves nothing about its namespace: with no checkpoint found, that is `unknown`, never `empty`.
315
+ // A namespace whose list call failed may hold a newer checkpoint: with one found elsewhere, `restored` would read as complete, so it is `partial`.
316
+ const status = !wanted.length ? (unlisted.length ? 'unknown' : 'empty')
317
+ : restoredKeys.length === wanted.length ? (unlisted.length ? 'partial' : 'restored') : restoredKeys.length ? 'partial' : 'unknown';
318
+ if (!wanted.length && unlisted.length) lines.unshift(`[RuvNet Brain — PROJECT CONTINUITY UNKNOWN] ruflo memory list did not answer for ${unlisted.join(', ')}; the missing state is not proof that no checkpoint exists.`);
319
+ else if (status === 'partial' || status === 'unknown') {
320
+ const why = [restoredKeys.length < wanted.length ? 'an exact checkpoint read failed' : null,
321
+ unlisted.length ? `ruflo memory list did not answer for ${unlisted.join(', ')} (a newer checkpoint may exist there)` : null].filter(Boolean).join('; ');
322
+ lines.unshift(`[RuvNet Brain — PROJECT CONTINUITY INCOMPLETE] ${why}; do not treat the missing state as restored.`);
323
+ }
324
+ return { status, context: lines.join('\n'), keys: wanted.map((e) => e.key), restoredKeys, route };
187
325
  }
188
326
 
189
327
  /**
@@ -62,7 +62,7 @@ async function checkpointNow({ projectDir, event, payload, env, deadlineAt, brai
62
62
  if (consent.skipped) return { stored: false, skipped: consent.skipped };
63
63
  const { checkpoint } = await import('./ruflo-memory-hooks.mjs');
64
64
  const saved = await checkpoint({ projectDir, event, payload, env, deadlineAt });
65
- if (saved.stored === false && saved.key || saved.stored && saved.verified !== true) {
65
+ if (saved.stored === false && saved.key || saved.stored && saved.verified !== true || /refused/.test(saved.skipped || '')) {
66
66
  recordHookFailure({ hook: 'checkpoint', event, error: saved.skipped || 'exact checkpoint readback failed' }, { brainHome });
67
67
  }
68
68
  return saved;
@@ -214,7 +214,8 @@ export async function runSessionStart({
214
214
  const continuity = await Promise.race([Promise.resolve().then(() => restoreContinuity({ env, cwd,
215
215
  deadlineAt: restoreDeadlineAt, signal: restoreController.signal })), expired]);
216
216
  if (Date.now() >= restoreDeadlineAt) throw new Error('restore deadline exceeded');
217
- restoreFailed = continuity?.status === 'unknown' || continuity?.brief?.status === 'unknown';
217
+ const bad = ['unknown', 'partial', 'refused'];
218
+ restoreFailed = bad.includes(continuity?.status) || bad.includes(continuity?.brief?.status);
218
219
  if (continuity?.context) emit(continuity.context);
219
220
  } catch (error) {
220
221
  restoreController.abort();
@@ -264,8 +264,9 @@ export const knowledgeCurrency = ({ env = process.env, home, now = Date.now(), w
264
264
  + `the last refresh (${latest.action || 'unknown'}) ${age(history.latest.at)} passed its knowledge update phase, `
265
265
  + `only the host plugin sync after it did not finish: ${hostOnly.why}. `
266
266
  // A restart writes no refresh receipt, so this line stays until the next refresh records the sync.
267
- + (restart ? 'Fix: restart Claude Code or Codex (`claude --continue` keeps a Claude conversation), then record it: npx ruvnet-brain@latest --update.'
268
- : 'Fix: npx ruvnet-brain@latest --update (verify: npx ruvnet-brain --doctor).');
267
+ // The host sync alone is what did not finish, so the fix is the host-sync-only repair, not the full knowledge update (#391).
268
+ + (restart ? 'Fix: restart Claude Code or Codex (`claude --continue` keeps a Claude conversation), then record it: npx ruvnet-brain@latest --update --host-sync-only.'
269
+ : 'Fix: npx ruvnet-brain@latest --update --host-sync-only (verify: npx ruvnet-brain --doctor).');
269
270
  }
270
271
  if (!failing && fresh) return coordinatedSuiteLine({ brainHome, now, builtMs, age });
271
272
  const kit = agenticKitUpdates({ home, facts });