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.
- package/README.md +2 -2
- package/bin/install.mjs +126 -9
- package/console/activity.js +122 -38
- package/package.json +2 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/scripts/agentdb-recall-process.mjs +115 -5
- package/plugin/scripts/agentdb-recall-reader.mjs +44 -2
- package/plugin/scripts/agentdb-recall.mjs +34 -12
- package/plugin/scripts/completion-claim-evidence.mjs +185 -11
- package/plugin/scripts/continuation-gate.mjs +52 -7
- package/plugin/scripts/lesson-gate.mjs +7 -39
- package/plugin/scripts/lesson-scope.mjs +51 -0
- package/plugin/scripts/lesson-store.mjs +1 -1
- package/plugin/scripts/ruflo-bin.mjs +14 -6
- package/plugin/scripts/ruflo-memory-hooks.mjs +157 -19
- package/plugin/scripts/session-snapshot-hook.mjs +1 -1
- package/plugin/scripts/session-start-core.mjs +2 -1
- package/plugin/scripts/session-start-health.mjs +3 -2
- package/scripts/codex-boot-surface-proof.mjs +208 -0
- package/scripts/generation-census.mjs +220 -0
- package/scripts/issue-autofix-agents.mjs +172 -0
- package/scripts/issue-autofix-comment.mjs +224 -0
- package/scripts/issue-autofix-diff.mjs +108 -0
- package/scripts/issue-autofix-exec.mjs +128 -0
- package/scripts/issue-autofix-fix.mjs +104 -0
- package/scripts/issue-autofix-gate.mjs +137 -0
- package/scripts/issue-autofix-night.mjs +164 -0
- package/scripts/issue-autofix-notes.mjs +346 -0
- package/scripts/issue-autofix-policy.mjs +349 -0
- package/scripts/issue-autofix-prompts.mjs +105 -0
- package/scripts/issue-autofix-release-ship.mjs +226 -0
- package/scripts/issue-autofix-release-util.mjs +141 -0
- package/scripts/issue-autofix-release.mjs +272 -0
- package/scripts/issue-autofix-report.mjs +121 -0
- package/scripts/issue-autofix-runner.mjs +75 -0
- package/scripts/issue-autofix-sandbox.mjs +108 -0
- package/scripts/issue-autofix-security.mjs +346 -0
- package/scripts/issue-autofix-selftest.mjs +101 -0
- package/scripts/issue-autofix-state.mjs +61 -0
- package/scripts/issue-autofix.mjs +382 -0
- package/scripts/issue-fix-lock.mjs +40 -0
- package/scripts/issue-fix.mjs +33 -20
- package/scripts/no-silent-substitution.mjs +1 -1
- package/scripts/ntfy-inbox.mjs +15 -0
- package/scripts/onboarding-console.mjs +76 -15
- package/scripts/recall-latency-probe.mjs +48 -0
- package/scripts/release-prepush-checks.mjs +251 -0
- package/scripts/release-prepush.mjs +168 -0
- package/scripts/release-qualification-contract.mjs +41 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
382
|
-
|
|
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
|
-
|
|
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))
|
|
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
|
|
199
|
-
|
|
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
|
-
|
|
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
|
+
}
|