ruvnet-brain 4.7.21 → 4.7.23

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 (51) hide show
  1. package/README.md +2 -2
  2. package/bin/install.mjs +126 -9
  3. package/console/activity.js +122 -38
  4. package/package.json +2 -1
  5. package/plugin/.claude-plugin/plugin.json +1 -1
  6. package/plugin/.codex-plugin/plugin.json +1 -1
  7. package/plugin/scripts/agentdb-recall-process.mjs +115 -5
  8. package/plugin/scripts/agentdb-recall-reader.mjs +44 -2
  9. package/plugin/scripts/agentdb-recall.mjs +34 -12
  10. package/plugin/scripts/completion-claim-evidence.mjs +185 -11
  11. package/plugin/scripts/continuation-gate.mjs +52 -7
  12. package/plugin/scripts/lesson-gate.mjs +7 -39
  13. package/plugin/scripts/lesson-scope.mjs +51 -0
  14. package/plugin/scripts/lesson-store.mjs +1 -1
  15. package/plugin/scripts/ruflo-bin.mjs +14 -6
  16. package/plugin/scripts/ruflo-memory-hooks.mjs +157 -19
  17. package/plugin/scripts/session-snapshot-hook.mjs +1 -1
  18. package/plugin/scripts/session-start-core.mjs +2 -1
  19. package/plugin/scripts/session-start-health.mjs +3 -2
  20. package/scripts/codex-boot-surface-proof.mjs +208 -0
  21. package/scripts/generation-census.mjs +220 -0
  22. package/scripts/issue-autofix-agents.mjs +172 -0
  23. package/scripts/issue-autofix-comment.mjs +224 -0
  24. package/scripts/issue-autofix-diff.mjs +108 -0
  25. package/scripts/issue-autofix-exec.mjs +128 -0
  26. package/scripts/issue-autofix-fix.mjs +104 -0
  27. package/scripts/issue-autofix-gate.mjs +137 -0
  28. package/scripts/issue-autofix-night.mjs +164 -0
  29. package/scripts/issue-autofix-notes.mjs +346 -0
  30. package/scripts/issue-autofix-policy.mjs +349 -0
  31. package/scripts/issue-autofix-prompts.mjs +105 -0
  32. package/scripts/issue-autofix-release-ship.mjs +226 -0
  33. package/scripts/issue-autofix-release-util.mjs +141 -0
  34. package/scripts/issue-autofix-release.mjs +272 -0
  35. package/scripts/issue-autofix-report.mjs +121 -0
  36. package/scripts/issue-autofix-runner.mjs +75 -0
  37. package/scripts/issue-autofix-sandbox.mjs +108 -0
  38. package/scripts/issue-autofix-security.mjs +346 -0
  39. package/scripts/issue-autofix-selftest.mjs +101 -0
  40. package/scripts/issue-autofix-state.mjs +61 -0
  41. package/scripts/issue-autofix.mjs +382 -0
  42. package/scripts/issue-fix-lock.mjs +40 -0
  43. package/scripts/issue-fix.mjs +33 -20
  44. package/scripts/no-silent-substitution.mjs +1 -1
  45. package/scripts/ntfy-inbox.mjs +15 -0
  46. package/scripts/onboarding-console.mjs +76 -15
  47. package/scripts/recall-latency-probe.mjs +48 -0
  48. package/scripts/release-prepush-checks.mjs +251 -0
  49. package/scripts/release-prepush.mjs +168 -0
  50. package/scripts/release-qualification-contract.mjs +41 -0
  51. package/scripts/wired-check.mjs +13 -5
@@ -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,7 +22,7 @@ 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';
@@ -50,8 +50,8 @@ export function recallBinding({ binding = {}, projectRoot, storePath, prompt } =
50
50
  }
51
51
 
52
52
  /** Bounded structural enumeration uses the same cancellable read-only canonical provider. */
53
- export function enumerateCanonicalKeys({ storePath, namespaces, deadline, signal, env, scratch }) {
54
- 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 });
55
55
  }
56
56
 
57
57
  export function applicableRecallLesson(value, projectRoot) {
@@ -234,12 +234,12 @@ export function formatBlock({ picks, status, withheld = 0 }) {
234
234
  export const SEARCH_BUDGET_SHARE = 0.8;
235
235
  export function searchDeadlineFor(started, deadline) { return Math.min(deadline, started + Math.floor((deadline - started) * SEARCH_BUDGET_SHARE)); }
236
236
 
237
- 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 }) {
238
238
  const searchDeadline = searchDeadlineFor(Date.now(), deadline);
239
239
  const phaseBinding = recallBinding({ binding, projectRoot: root, storePath: store.path, prompt });
240
240
  const stateNamespaces = checkpointNamespaces(root);
241
241
  const namespaces = [...new Set([...stateNamespaces, 'lessons', 'continuity-events'])];
242
- 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 });
243
243
  if (!enumeration?.ok || signal?.aborted || Date.now() >= deadline) return { block: formatBlock({ picks: [], status: 'unavailable structural enumeration' }), picks: [], stores: [store],
244
244
  status: { 'memory.db': 'unavailable' }, outcome: signal?.aborted ? 'unavailable' : Date.now() >= deadline ? 'timed-out' : 'unavailable', categories: { state: 'unavailable', decisions: 'unavailable', lessons: 'unavailable' } };
245
245
  const keys = Object.fromEntries(namespaces.map(namespace => [namespace, Array.isArray(enumeration.value?.[namespace]) ? enumeration.value[namespace] : []]));
@@ -279,7 +279,7 @@ async function consequentialRecall({ prompt, root, store, bin, scratch, env, dea
279
279
  const categories = { state: unorderedState ? 'unavailable' : 'ok-empty', decisions: decisionAvailability, lessons: lessonAvailability };
280
280
  const records = [];
281
281
  candidates.forEach(candidate => { candidate.storePath = store.path; });
282
- const exactResults = await exactReads({ candidates, bin, deadline, env, scratch, signal });
282
+ const exactResults = await exactReads({ candidates, bin, deadline, env, scratch, signal, session });
283
283
  // ADR-105 §8: supersession is a link on the NEWER record, computed from every decision record read, plus
284
284
  // the tombstones already in the enumeration so a superseder that was not read still retires its target.
285
285
  const superseded = tombstonedKeys(keys[path.basename(root)]);
@@ -338,6 +338,8 @@ export async function recall({ prompt, projectDir = process.cwd(), env = process
338
338
  binding = {}, signal, absoluteDeadline, consequential = false, enumerateKeys = enumerateCanonicalKeys } = {}) {
339
339
  const started = Date.now();
340
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
341
343
  try {
342
344
  if (signal?.aborted) return { ...empty, outcome: 'unavailable', reason: 'cancelled' };
343
345
  if (!agentdbFirstEnabled(env)) return { ...empty, outcome: 'disabled' };
@@ -370,7 +372,15 @@ export async function recall({ prompt, projectDir = process.cwd(), env = process
370
372
  const store = stores[0];
371
373
  const query = recallQuery(prompt);
372
374
  const scratchFor = scratch || ((storePath) => rufloCwdFor(storePath, { root: rufloScratchRoot(env) }));
373
- 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 });
374
384
  // Only namespaces something below reads: curated lessons/decisions, the checkpoint namespaces (dirname, its
375
385
  // lowercase form, default) and turns for the single ranked search. Each one is a `ruflo memory list` process.
376
386
  const namespaces = [...new Set(['lessons', 'turns', 'continuity-events', ...checkpointNamespaces(root)])];
@@ -378,12 +388,21 @@ export async function recall({ prompt, projectDir = process.cwd(), env = process
378
388
  // per namespace; run concurrently they starve every other read past the deadline. So: ONE native enumeration
379
389
  // (reused for the curated pass and for pruning empty namespaces), then exact curated state/lessons/decisions,
380
390
  // then spend what remains on ranking.
381
- const presence = store ? await nativeRead({ store, namespaces, deadline, env, scratch: scratchFor, signal }) : null;
382
- 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',
383
402
  block: formatBlock({ picks: [], status: presence.state }), reason: 'namespace enumeration failed' }; }
384
403
  const sharedKeys = presence?.ok ? async () => ({ ok: true, value: presence.value }) : enumerateCanonicalKeys;
385
404
  const curated = store ? await consequentialRecall({ prompt, root, store, bin, scratch: scratchFor, env, deadline, binding, signal,
386
- enumerateKeys: sharedKeys, exclusions, rankedSearch: false }).catch(() => null) : null;
405
+ enumerateKeys: sharedKeys, exclusions, rankedSearch: false, session }).catch(() => null) : null;
387
406
  const searchDeadline = searchDeadlineFor(Date.now(), deadline);
388
407
  const activeNamespaces = presence?.ok ? namespaces.filter(namespace => presence.value[namespace]?.length) : namespaces;
389
408
  // ONE supplementary search (owner ruling 2026-10-12). Measured on a live-size store at load 17: ~10
@@ -392,8 +411,10 @@ export async function recall({ prompt, projectDir = process.cwd(), env = process
392
411
  const searchNamespace = [path.basename(root), 'default', 'turns'].find((namespace) => activeNamespaces.includes(namespace));
393
412
  const jobs = store && searchNamespace ? [{ store, namespace: searchNamespace, family: null,
394
413
  args: ['--format', 'json', '-q', query, '-n', searchNamespace, '--limit', '12'] }] : [];
414
+ const useSpec = spec && jobs[0]?.namespace === specNamespace;
415
+ if (!useSpec) specAbort.abort();
395
416
  const results = await Promise.all(jobs.map(async ({ store: jobStore, namespace, family: recordFamily, args }) => ({ namespace, family: recordFamily, storePath: jobStore.path,
396
- ...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 })) })));
397
418
  let status = results.every((r) => r.state === 'ok') ? 'ok'
398
419
  : results.some((r) => r.state === 'timed out') ? 'timed out' : 'unavailable';
399
420
  // Overfetch a bounded twelve exact values (the one search already returns twelve; the exact reads are
@@ -402,7 +423,7 @@ export async function recall({ prompt, projectDir = process.cwd(), env = process
402
423
  const candidates = pickRows(results, 12);
403
424
  // Ruflo previews are ~60 characters. Read the actual selected values within
404
425
  // the same deadline so the block contains useful evidence rather than titles.
405
- const exactResults = await exactReads({ candidates, bin, deadline, env, scratch: scratchFor, signal });
426
+ const exactResults = await exactReads({ candidates, bin, deadline, env, scratch: scratchFor, signal, session });
406
427
  const retrieved = await Promise.all(candidates.map(async (p) => {
407
428
  const r = exactResults.get(p);
408
429
  if (r.state === 'ok' && r.value && !r.value.startsWith('[WARN]')) {
@@ -462,6 +483,7 @@ export async function recall({ prompt, projectDir = process.cwd(), env = process
462
483
  authority: false };
463
484
  return { block: formatBlock({ picks, status, withheld: curated?.withheld || 0 }), picks, stores, status: { 'memory.db': status }, outcome, receipt };
464
485
  } catch (error) { return { ...empty, outcome: 'unavailable', reason: 'canonical recall failed: ' + clean(error.message, 180) }; }
486
+ finally { specAbort?.abort(); session?.close(); }
465
487
  }
466
488
 
467
489
  async function main() {
@@ -106,6 +106,54 @@ function segmentMutates(segment) {
106
106
  return /(?:^|[^0-9&>])>{1,2}\s*(?!&|\/dev\/null)(['"]?)([^\s'"]+)/.test(segment)
107
107
  && !TMP_PATH.test((/>{1,2}\s*['"]?([^\s'"]+)/.exec(segment) || [])[1] || '');
108
108
  }
109
+ /**
110
+ * #369/#425: the exit status a transcript reports is the WHOLE command's. It is the status of one check only
111
+ * when the command is one simple command, or an `&&` chain (which reaches its end only if every part passed).
112
+ * Returns the reason a command is NOT such a shape (pipe, `||`, `;`, `&`, newline, substitution, subshell,
113
+ * redirection other than 2>&1 / /dev/null), or null. When unsure it names a reason: never binds by default.
114
+ */
115
+ export function compoundReason(command) {
116
+ const s = String(command || '');
117
+ // A leading `!` (also after `&&`, after optional VAR=x prefixes) inverts the exit status: a failing check reports success.
118
+ if (/(?:^|&&)\s*(?:[A-Za-z_]\w*=\S*\s+)*!\s/.test(s)) return 'negated exit status (`!`)';
119
+ let q = '';
120
+ for (let i = 0; i < s.length; i++) {
121
+ const c = s[i];
122
+ const n = s[i + 1];
123
+ if (q === "'") { if (c === "'") q = ''; continue; }
124
+ if (c === '\\') { i++; continue; }
125
+ if (q === '"') {
126
+ if (c === '"') q = '';
127
+ else if (c === '`' || (c === '$' && n === '(')) return 'command substitution';
128
+ continue;
129
+ }
130
+ if (c === "'" || c === '"') { q = c; continue; }
131
+ if (c === '|') return n === '|' ? '`||` fallback' : 'pipe';
132
+ if (c === ';') return '`;` sequence';
133
+ if (c === '\n' || c === '\r') return 'multiple lines';
134
+ if (c === '`' || c === '(' || c === ')' || (c === '$' && n === '(')) return 'subshell or substitution';
135
+ if (c === '&') {
136
+ if (n === '&') { i++; continue; }
137
+ if (/^&>/.test(s.slice(i))) return 'redirection';
138
+ return 'background `&`';
139
+ }
140
+ if (c === '>' || c === '<') {
141
+ const m = /^(?:>&\d|>>?\s*\/dev\/null)/.exec(s.slice(i));
142
+ if (m) { i += m[0].length - 1; continue; }
143
+ return 'redirection';
144
+ }
145
+ }
146
+ return null;
147
+ }
148
+ const SAFE_REDIRECT = /\s*\d?>>?\s*\/dev\/null|\s*\d?>&\d/g;
149
+ /** The command a claim would name: safe redirects (2>&1, >/dev/null) are not part of it. */
150
+ const withoutSafeRedirects = (seg) => seg.replace(SAFE_REDIRECT, '').trim();
151
+ /** For an unbound segment: the leading command a claim could have named (cut at the first operator). */
152
+ function headsOf(seg) {
153
+ const lead = seg.replace(/^[\s(]*(?:\$\()?/, '');
154
+ const cut = (t) => t.split(/[|;&<>()`$\n]/)[0].trim();
155
+ return [...new Set([cut(seg), cut(lead)].filter((h) => h.length >= 4))];
156
+ }
109
157
  const segmentChecks = (segment) => !TRIVIAL.test(segment) && !segmentMutates(segment);
110
158
  function checkName(segment) {
111
159
  const words = segment.replace(/^(?:[A-Z_][A-Z0-9_]*=\S+\s+)+/, '').split(/\s+/);
@@ -133,7 +181,10 @@ export function currentTurnRecords(lines) {
133
181
  const isToolResult = Array.isArray(c) && c.some((x) => x?.type === 'tool_result');
134
182
  if (o?.type === 'user' && (o.message?.role || 'user') === 'user' && !isToolResult && !o.isMeta && textOf(c).trim()) start = i;
135
183
  });
136
- return { boundaryFound: start >= 0, prompt: start >= 0 ? textOf(recs[start].message?.content) : '', recs: recs.slice(start + 1) };
184
+ // `openerId` identifies the turn by its opening record (uuid, else its position among the main-thread records),
185
+ // never by the prompt text: a repeated "continue" is a different turn with the same words.
186
+ const openerId = start < 0 ? '' : (typeof recs[start].uuid === 'string' && recs[start].uuid ? `uuid:${recs[start].uuid}` : `pos:${start}`);
187
+ return { boundaryFound: start >= 0, prompt: start >= 0 ? textOf(recs[start].message?.content) : '', openerId, recs: recs.slice(start + 1) };
137
188
  }
138
189
 
139
190
  // Codex tools that neither change state nor run a check; every other unrecognised tool is a change (fail
@@ -147,7 +198,7 @@ const CODEX_NEUTRAL = /^(?:update_plan|view_image|sleep|list_agents|wait_agent|w
147
198
  /** Ordered events for the current turn of a Claude JSONL (or normalized Codex) transcript. */
148
199
  export function claudeTurnEvents(lines, { host = 'claude' } = {}) {
149
200
  const codex = host === 'codex';
150
- const { boundaryFound, recs: turnRecs } = currentTurnRecords(lines);
201
+ const { boundaryFound, prompt: turnPrompt, openerId, recs: turnRecs } = currentTurnRecords(lines);
151
202
  const results = new Map();
152
203
  for (const o of turnRecs) {
153
204
  const c = o?.message?.content;
@@ -173,9 +224,14 @@ export function claudeTurnEvents(lines, { host = 'claude' } = {}) {
173
224
  if (['Edit', 'Write', 'MultiEdit', 'NotebookEdit'].includes(u.name) || (codex && u.name === 'apply_patch')) {
174
225
  events.push({ kind: 'change', what: `${u.name} ${input.file_path || input.notebook_path || ''}`.trim() });
175
226
  } else if (u.name === 'Bash') {
227
+ const unbound = compoundReason(input.command);
176
228
  for (const seg of segments(input.command)) {
177
229
  if (segmentMutates(seg)) events.push({ kind: 'change', what: seg.slice(0, 120) });
178
- else if (segmentChecks(seg)) events.push({ kind: 'check', what: seg.slice(0, 120), name: checkName(seg), ...result });
230
+ else if (segmentChecks(seg)) {
231
+ const cmd = unbound ? seg : withoutSafeRedirects(seg);
232
+ events.push({ kind: 'check', what: cmd.slice(0, 120), command: cmd.slice(0, 400), name: checkName(seg), ...result,
233
+ ...(unbound ? { unbound: `the command uses ${unbound}, so its exit status is not this check's`, heads: headsOf(seg) } : {}) });
234
+ }
179
235
  }
180
236
  } else if (u.name === 'Agent' || u.name === 'Task') {
181
237
  if (!READ_ONLY_AGENTS.test(String(input.subagent_type || ''))) events.push({ kind: 'change', what: `${u.name} ${input.subagent_type || 'agent'}` });
@@ -187,7 +243,7 @@ export function claudeTurnEvents(lines, { host = 'claude' } = {}) {
187
243
  }
188
244
  }
189
245
  }
190
- return { boundaryFound, events };
246
+ return { boundaryFound, prompt: turnPrompt, openerId, events };
191
247
  }
192
248
 
193
249
  /** Verification that ran AFTER the last state change this turn, with a present, non-error result. */
@@ -195,8 +251,12 @@ export function postChangeVerification(turn) {
195
251
  const events = turn?.events || [];
196
252
  let lastChange = -1;
197
253
  events.forEach((e, i) => { if (e.kind === 'change') lastChange = i; });
198
- const checks = events.slice(lastChange + 1).filter((e) => e.kind === 'check' && e.present && !e.error);
199
- return { lastChange: lastChange >= 0 ? events[lastChange].what : null, checks,
254
+ const after = events.slice(lastChange + 1).filter((e) => e.kind === 'check' && e.present && !e.error);
255
+ // `checks` is STRICT (binds a claim or a promise). `maskedChecks` ran with output and no error, but the command
256
+ // is a pipe / `||` / `;` / file redirect, so its exit status is not attributable: counted only as "a check ran".
257
+ const checks = after.filter((e) => !e.unbound);
258
+ const maskedChecks = after.filter((e) => e.unbound);
259
+ return { lastChange: lastChange >= 0 ? events[lastChange].what : null, checks, maskedChecks,
200
260
  staleChecks: events.slice(0, Math.max(0, lastChange)).filter((e) => e.kind === 'check').length };
201
261
  }
202
262
 
@@ -214,16 +274,19 @@ export function readClaudeTurn(transcriptPath, { read = readSettledTranscript, h
214
274
  */
215
275
  export function auditCompletionClaims(message, { turn = null, host = 'claude' } = {}) {
216
276
  const claims = extractCompletionClaims(message);
217
- if (!claims.length) return { verdict: 'NONE', claims };
277
+ if (!claims.length) return { verdict: 'NONE', claims, outcomes: [] };
218
278
  const text = String(message || '');
219
279
  const verification = turn ? postChangeVerification(turn) : null;
220
- const names = verification?.checks.map((c) => String(c.name || '').toLowerCase()).filter((n) => n.length >= 3) || [];
280
+ // Naming a check that ran is not binding it: masked checks count here so an inline mention is not 'unnamed'; transcriptOk below still uses strict checks only.
281
+ const names = verification ? [...verification.checks, ...verification.maskedChecks].map((c) => String(c.name || '').toLowerCase()).filter((n) => n.length >= 3) : [];
221
282
  const namesCheck = NAMED_CHECK.test(text) || names.some((n) => text.toLowerCase().includes(n));
222
283
  const disclosesGaps = DISCLOSES_GAPS.test(strippedProse(text).replace(/```[\s\S]*?```/g, ''))
223
284
  || DISCLOSES_GAPS.test(text);
224
285
  const problems = [];
225
286
  if (!turn) problems.push(`this host's (${host}) transcript is unavailable or not parsed, so whether a check ran after the change is UNKNOWN`);
226
- else if (!verification.checks.length) {
287
+ else if (!verification.checks.length && verification.maskedChecks.length) {
288
+ problems.push('a check ran after the last change, but its command uses a pipe, `||`, `;` or a file redirect, so its exit status is not attributable to it; completion is UNKNOWN');
289
+ } else if (!verification.checks.length) {
227
290
  problems.push(verification.lastChange
228
291
  ? `no end-to-end check ran this turn after the last change (${verification.lastChange})`
229
292
  : 'no end-to-end check ran this turn');
@@ -236,7 +299,81 @@ export function auditCompletionClaims(message, { turn = null, host = 'claude' }
236
299
  if (transcriptOk && namesCheck && disclosesGaps && !scopedCheck) problems.push('observed checks cover only their executed scope; whole-task completion is UNKNOWN');
237
300
  const verdict = transcriptOk && namesCheck && disclosesGaps && scopedCheck ? 'OBSERVED_CHECK'
238
301
  : namesCheck && disclosesGaps ? 'UNKNOWN' : 'FAIL';
239
- return { verdict, claims, verification, namesCheck, disclosesGaps, problems };
302
+ return { verdict, claims, verification, namesCheck, disclosesGaps, problems, outcomes: claimOutcomes(claims, text, turn) };
303
+ }
304
+
305
+ // ── per-claim outcomes (#425) ───────────────────────────────────────────────────────────────────
306
+ // Evidence per claimed outcome, NOT completion authority: `authority:false` always, the verdict above is
307
+ // computed without it, and no state here is PASS. A claim is `observed-check` only when its own sentence
308
+ // (or a line-start `Verified:` line) NAMES the command of a check that ran after the last change, produced
309
+ // output and did not error. Anything else is reported as what it is: stale, failed, or unbound.
310
+ const VERIFIED_LINE = /(?:^|\n)[ \t]*(?:[-*•][ \t]*|\*\*)*verified\b[^\n:—–]{0,4}?(?:\*\*)?[ \t]*[:—–][ \t]*([^\n]+)/gi;
311
+ // Env-var prefixes are part of the command (`SKIP_HOOKS=1 npm run x` is not `npm run x`): never stripped.
312
+ const squash = (value) => String(value || '').replace(/\s+/g, ' ').trim().toLowerCase();
313
+ /** `hay` contains `cmd` as a whole command: not preceded by an env-var assignment or a word character. */
314
+ export function containsCommand(hay, cmd) {
315
+ const h = String(hay || ''); const c = String(cmd || '');
316
+ if (!c) return false;
317
+ for (let at = h.indexOf(c); at >= 0; at = h.indexOf(c, at + 1)) {
318
+ const before = h.slice(0, at);
319
+ if (/[\w./:=-]$/.test(before) || /^[\w:-]/.test(h.slice(at + c.length))) continue;
320
+ if (/(?:^|[\s`'"(])[A-Za-z_][A-Za-z0-9_]*=\S*\s+$/.test(before)) continue;
321
+ return true;
322
+ }
323
+ return false;
324
+ }
325
+
326
+ function rawSentenceOf(message, claim) {
327
+ for (const raw of String(message || '').split(/(?<=[.!?])\s+|\n+/)) if (sentences(raw).includes(claim.text)) return raw;
328
+ return claim.text;
329
+ }
330
+
331
+ export function claimOutcomes(claims, message, turn) {
332
+ const events = turn?.events || [];
333
+ let lastChange = -1;
334
+ events.forEach((e, i) => { if (e.kind === 'change') lastChange = i; });
335
+ const checks = events.map((e, i) => ({ e, i })).filter(({ e }) => e.kind === 'check' && String(e.command || '').length >= 4);
336
+ const verifiedLines = [...String(message || '').matchAll(VERIFIED_LINE)].map((m) => squash(m[1])).join('\n');
337
+ return (claims || []).map((claim) => {
338
+ const own = squash(rawSentenceOf(message, claim));
339
+ const namedAll = checks.filter(({ e }) => { const c = squash(e.command); const hit = (h) => containsCommand(own, h) || containsCommand(verifiedLines, h);
340
+ return hit(c) || (e.unbound && (e.heads || []).some((h) => hit(squash(h)))); });
341
+ const named = namedAll.filter(({ e }) => !e.unbound);
342
+ const good = named.filter(({ e, i }) => i > lastChange && e.present && !e.error);
343
+ const state = good.length ? 'observed-check'
344
+ : named.some(({ e, i }) => i > lastChange && !(e.present && !e.error)) ? 'failed-check'
345
+ : named.length ? 'stale' : 'unbound';
346
+ const reason = state === 'unbound' ? namedAll.find(({ e }) => e.unbound)?.e.unbound : undefined;
347
+ return { claim: claim.text.slice(0, 200), state, ...(reason ? { reason } : {}),
348
+ checks: (state === 'observed-check' ? good : named).slice(-3).map(({ e }) => ({ command: e.command, name: e.name })), authority: false };
349
+ });
350
+ }
351
+
352
+ /**
353
+ * #425 (sparkling): the audit category of a correction, independent of wording. A correction is delivered
354
+ * at most once per session + user turn + category, so rewording the answer or waiting cannot rearm it.
355
+ */
356
+ export function completionCategory(completion) {
357
+ const ids = (completion?.problems || []).map((p) => /unavailable or not parsed/.test(p) ? 'transcript-unavailable'
358
+ : /no end-to-end check ran/.test(p) ? 'no-check' : /not attributable/.test(p) ? 'check-unattributed' : /does not name the check/.test(p) ? 'check-unnamed'
359
+ : /does not disclose/.test(p) ? 'gaps-undisclosed' : /observed checks cover only/.test(p) ? 'scope' : 'other');
360
+ return `${completion?.verdict || 'NONE'}:${[...new Set(ids)].sort().join('+')}`;
361
+ }
362
+
363
+ /** Correction copy for a FAIL/UNKNOWN verdict. When checks DID run, ask for an outcome-by-outcome report. */
364
+ export function completionCorrectionText(completion) {
365
+ const lead = `You claimed "${completion.claims[0].text.slice(0, 160)}" is done; ${completion.problems.join('; ')}.`;
366
+ return (completion.verification?.checks?.length || 0) + (completion.verification?.maskedChecks?.length || 0) > 0
367
+ ? `${lead} A check ran, but it does not cover every claim: report outcome by outcome, naming the check that ran for each claim or restating it as UNVERIFIED.`
368
+ : `${lead} Run the real consumer path, or restate it as UNVERIFIED.`;
369
+ }
370
+
371
+ /** Non-blocking advisory text: which claims have an observed check and which do not. */
372
+ export function outcomeAdvisoryText(completion) {
373
+ const lines = (completion.outcomes || []).map((o) => `- ${o.state}: "${o.claim.slice(0, 100)}"${o.checks.length ? ` (${o.checks.map((c) => c.command).join('; ')})` : ''}`);
374
+ const masked = !(completion.verification?.checks?.length) && (completion.verification?.maskedChecks?.length || 0) > 0;
375
+ return `A check ran after the last change${masked ? ' (its command uses a pipe, `||`, `;` or a redirect, so its exit status is not attributable to it)' : ''} and the answer names it and discloses what is not verified. Per claimed outcome:\n${lines.join('\n')}\n`
376
+ + 'This is evidence per outcome, not completion authority: completion stays UNKNOWN. Not blocking: more checks or rewording cannot change it.';
240
377
  }
241
378
 
242
379
  // ── promises (first-person commitments) ─────────────────────────────────────────────────────────
@@ -277,7 +414,24 @@ export function extractCommitments(message) {
277
414
  out.push({ text, key });
278
415
  }
279
416
  }
280
- return out.slice(0, PROMISE_CAP_PER_TURN);
417
+ return out.slice(0, PROMISE_CAP_PER_TURN).map((p) => {
418
+ const evidenceCommands = promiseCommands(message, p.key);
419
+ return evidenceCommands.length ? { ...p, evidenceCommands } : p;
420
+ });
421
+ }
422
+
423
+ // #369: inline-code commands in the promise's own sentence are what the promise says it will run. They are
424
+ // recorded at capture (max 2) so a later observed check can be matched to them; prose alone never is.
425
+ const COMMAND_SPAN = /`([^`\n]{4,160})`/g;
426
+ function promiseCommands(message, key) {
427
+ for (const raw of String(message || '').split(/(?<=[.!?])\s+|\n+/)) {
428
+ const prose = strippedProse(raw).replace(/^\s*(?:\d+[.)]|[-*•])\s+/, '').trim();
429
+ const action = PROMISE_LEAD.exec(prose)?.[1] ?? prose;
430
+ if (normalizePromise(action) !== key) continue;
431
+ const found = [...raw.matchAll(COMMAND_SPAN)].map((m) => squash(m[1])).filter((c) => /\s/.test(c) || c.startsWith('./'));
432
+ return [...new Set(found)].slice(0, 2);
433
+ }
434
+ return [];
281
435
  }
282
436
 
283
437
  const STOP = new Set(['the', 'a', 'an', 'and', 'or', 'to', 'of', 'in', 'on', 'for', 'with', 'it', 'is', 'are',
@@ -304,6 +458,10 @@ const unquoted = (value) => String(value).replace(/"([^"\n]{0,300})"|“([^”\n
304
458
  export function promiseClosingClaim(completion, answer, promiseText) {
305
459
  // DORMANT: auditCompletionClaims no longer returns PASS (only OBSERVED_CHECK/UNKNOWN/FAIL/NONE), so this never closes a promise today.
306
460
  if (completion?.verdict !== 'PASS') return null;
461
+ return matchingClaim(completion, answer, promiseText);
462
+ }
463
+
464
+ function matchingClaim(completion, answer, promiseText) {
307
465
  const direct = completion.claims.find((c) => claimClosesPromise(c.text, promiseText));
308
466
  if (direct) return direct;
309
467
  for (const raw of String(answer || '').split(/(?<=[.!?])\s+|\n+/)) {
@@ -313,3 +471,19 @@ export function promiseClosingClaim(completion, answer, promiseText) {
313
471
  }
314
472
  return null;
315
473
  }
474
+
475
+ /**
476
+ * #369: the evidence that moves a promise to the NON-completed state `evidenced`, or null. Never `done`, never
477
+ * PASS, never authority. ALL must hold: a command the promise itself named ran after the last change with
478
+ * output and no error; the answer names that command and states what is not verified; a claim sentence
479
+ * matches the promise; the verdict is not FAIL. (Same-session ownership is checked by the caller.)
480
+ */
481
+ export function promiseEvidence(completion, answer, item) {
482
+ const commands = Array.isArray(item?.evidenceCommands) ? item.evidenceCommands.map(squash) : [];
483
+ if (!commands.length || !completion?.claims?.length || !['OBSERVED_CHECK', 'UNKNOWN'].includes(completion.verdict)
484
+ || completion.disclosesGaps !== true) return null;
485
+ const said = squash(answer);
486
+ const check = (completion.verification?.checks || []).find((c) => !c.unbound && commands.includes(squash(c.command)) && containsCommand(said, squash(c.command)));
487
+ const claim = check && matchingClaim(completion, answer, item.text);
488
+ return claim ? { command: squash(check.command), check: check.what, claim: claim.text, auditVerdict: completion.verdict, authority: false } : null;
489
+ }