ruvnet-brain 4.6.2 → 4.6.4
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 +1 -1
- package/package.json +2 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/hooks/codex-hooks.json +1 -31
- package/plugin/hooks/hook-contracts.json +16 -96
- package/plugin/hooks/hooks.json +1 -31
- package/plugin/scripts/agentdb-recall-process.mjs +118 -0
- package/plugin/scripts/agentdb-recall-reader.mjs +22 -0
- package/plugin/scripts/agentdb-recall.mjs +99 -104
- package/plugin/scripts/continuation-gate.mjs +78 -1
- package/plugin/scripts/continuity-brief.mjs +4 -10
- package/plugin/scripts/continuity-events.mjs +6 -85
- package/plugin/scripts/continuity-hook-policy.mjs +3 -3
- package/plugin/scripts/continuity-journal.mjs +22 -16
- package/plugin/scripts/decision-gate.mjs +2 -2
- package/plugin/scripts/decision-recall-gate.mjs +141 -0
- package/plugin/scripts/decision-record.mjs +353 -0
- package/plugin/scripts/deferral-evidence.mjs +134 -0
- package/plugin/scripts/hook-failures.mjs +45 -0
- package/plugin/scripts/hook-shim.mjs +70 -4
- package/plugin/scripts/learn-capture.mjs +67 -34
- package/plugin/scripts/native-turn-transcript.mjs +51 -0
- package/plugin/scripts/project-capture-queue.mjs +2 -0
- package/plugin/scripts/project-memory-enrollment.mjs +203 -0
- package/plugin/scripts/project-progression-reader.mjs +4 -4
- package/plugin/scripts/project-progression-session-start.mjs +7 -27
- package/plugin/scripts/project-progression-store.mjs +18 -6
- package/plugin/scripts/project-store-resolver.mjs +44 -6
- package/plugin/scripts/project-transition-hook.mjs +31 -10
- package/plugin/scripts/session-snapshot-hook.mjs +18 -2
- package/plugin/scripts/session-start-core.mjs +15 -1
- package/plugin/scripts/turn-outcome-capture.mjs +38 -32
- package/plugin/scripts/turn-transport-journal.mjs +3 -1
- package/scripts/deferral-replay.mjs +47 -0
|
@@ -15,7 +15,7 @@ import { normalizeHostEvent } from './hook-input.mjs';
|
|
|
15
15
|
import { resolveProjectStore } from './project-store-resolver.mjs';
|
|
16
16
|
import { withProgressionReader } from './project-progression-reader.mjs';
|
|
17
17
|
import { redactProgression, restoreProjectProgression, digestCanonical, validateProgressionSnapshot } from './project-progression-contract.mjs';
|
|
18
|
-
import { runSessionSnapshotHook,
|
|
18
|
+
import { runSessionSnapshotHook, queueCapture, replayOutboxDetached } from './session-snapshot-hook.mjs';
|
|
19
19
|
|
|
20
20
|
const EVENTS = new Set(['UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'PostToolUseFailure', 'SubagentStop']);
|
|
21
21
|
const TOPICS = [
|
|
@@ -197,8 +197,23 @@ export function captureNormalizedTransition(job, { readHistory = readTransitionH
|
|
|
197
197
|
return { ...result, eventId: normalized.observation.id };
|
|
198
198
|
}
|
|
199
199
|
|
|
200
|
+
/** Queued work older than this is no longer in flight; the boundary says so (continuity-journal STUCK_AFTER_MS). */
|
|
201
|
+
export const QUEUE_STUCK_AFTER_MS = 10 * 60_000;
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Prompt and tool boundaries (UserPromptSubmit, PreToolUse, PostToolUse, PostToolUseFailure,
|
|
205
|
+
* SubagentStop) do only bounded, local work: consent and identity, fsync the observation into the
|
|
206
|
+
* durable queue, hand the queue to the detached replay worker, return. No native store write runs
|
|
207
|
+
* inline. Measured 2026-10-11: each inline commit cost one ~3 s native write per boundary, queued
|
|
208
|
+
* debt made every later boundary spend its full 6.5 s budget, and under load the host killed the
|
|
209
|
+
* hook at its 10 s timeout and discarded its output. A queued-and-handed-off observation is the
|
|
210
|
+
* NORMAL state and says nothing; only an unwritable queue or work stuck past
|
|
211
|
+
* QUEUE_STUCK_AFTER_MS is reported. Exact readback is unchanged: the worker commits each item only
|
|
212
|
+
* by exact readback (captureNormalizedTransition), and Stop/PreCompact/SessionEnd keep their own
|
|
213
|
+
* bounded synchronous budgets.
|
|
214
|
+
*/
|
|
200
215
|
export function runProjectTransitionHook(projectDir, event, { payload = {}, host = process.env.RUVNET_HOOK_HOST || 'claude',
|
|
201
|
-
|
|
216
|
+
env = process.env, spawnReplay = replayOutboxDetached, now = Date.now } = {}) {
|
|
202
217
|
if (developmentHooksSuspended(projectDir)) return { state: 'skipped', reason: 'development hooks suspended' };
|
|
203
218
|
const suspended = automaticProgressionSuspensionResult(env, { state: 'suspended', reason: 'automatic project progression is operator-suspended' });
|
|
204
219
|
if (suspended) return suspended;
|
|
@@ -218,12 +233,16 @@ export function runProjectTransitionHook(projectDir, event, { payload = {}, host
|
|
|
218
233
|
payload: { session_id: payload.session_id, hook_event_name: transportEvent,
|
|
219
234
|
normalizedTransition: { observation, sourceIdentity: observeTransitionSource(resolution, projectDir) } } });
|
|
220
235
|
if (!queued) return { state: 'degraded', reason: 'normalized observation queue unwritable', eventId: observation.id };
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
236
|
+
const handed = spawnReplay({ projectDir: resolution.projectRoot, env });
|
|
237
|
+
let oldest = Infinity;
|
|
238
|
+
try {
|
|
239
|
+
for (const name of fs.readdirSync(path.join(resolution.projectRoot, '.swarm'))) {
|
|
240
|
+
if (!/^\.progression-capture-(?:queue|claimed)-.*\.json$/.test(name)) continue;
|
|
241
|
+
oldest = Math.min(oldest, fs.statSync(path.join(resolution.projectRoot, '.swarm', name)).mtimeMs);
|
|
242
|
+
}
|
|
243
|
+
} catch { /* the observation itself is already durable */ }
|
|
244
|
+
if (now() - oldest > QUEUE_STUCK_AFTER_MS) return { state: 'stuck', eventId: observation.id, handed, oldestQueuedAt: new Date(oldest).toISOString() };
|
|
245
|
+
return { state: 'queued', eventId: observation.id, handed };
|
|
227
246
|
}
|
|
228
247
|
|
|
229
248
|
/** One pending-readback condition across prompt/tool boundaries and both CLI entrypoints. */
|
|
@@ -238,8 +257,10 @@ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.me
|
|
|
238
257
|
const payload = JSON.parse(fs.readFileSync(0, 'utf8') || '{}');
|
|
239
258
|
const projectDir = payload.cwd || process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
240
259
|
const result = runProjectTransitionHook(projectDir, process.argv[2], { payload });
|
|
241
|
-
if (result.state === '
|
|
242
|
-
const message = transitionPendingNotice(projectDir, payload,
|
|
260
|
+
if (result.state === 'stuck' || result.state === 'degraded') {
|
|
261
|
+
const message = transitionPendingNotice(projectDir, payload, result.state === 'stuck'
|
|
262
|
+
? `Project memory capture is stuck: queued work since ${result.oldestQueuedAt} has not been committed by exact readback.`
|
|
263
|
+
: 'Project memory capture degraded: the observation queue is unwritable.');
|
|
243
264
|
if (message) process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: process.argv[2], additionalContext: message } }));
|
|
244
265
|
}
|
|
245
266
|
} catch { process.stdout.write(JSON.stringify({ systemMessage: 'Project memory transition capture degraded; exact readback was not verified.' })); }
|
|
@@ -15,6 +15,7 @@ import { projectDirectory } from './project-identity.mjs';
|
|
|
15
15
|
import { buildProjectProgression } from './project-progression-producer.mjs';
|
|
16
16
|
import { ProjectProgressionStore } from './project-progression-store.mjs';
|
|
17
17
|
import { resolveProjectStore } from './project-store-resolver.mjs';
|
|
18
|
+
import { ensureProjectMemory } from './project-memory-enrollment.mjs';
|
|
18
19
|
import { resolveTurnDb, captureTurnOutcome } from './turn-outcome-capture.mjs';
|
|
19
20
|
import { captureContinuityEvents, stopNotice } from './continuity-journal.mjs';
|
|
20
21
|
import { automaticProgressionSuspensionResult } from './project-progression-suspension.mjs';
|
|
@@ -129,6 +130,7 @@ export function runSessionSnapshotHook(projectDir, event, {
|
|
|
129
130
|
spawnReplay = replayOutboxDetached,
|
|
130
131
|
ordered = null,
|
|
131
132
|
captureEvents = captureContinuityEvents,
|
|
133
|
+
enrollMemory = ensureProjectMemory,
|
|
132
134
|
deadlineAt: inheritedDeadlineAt = Infinity,
|
|
133
135
|
signal,
|
|
134
136
|
} = {}) {
|
|
@@ -136,6 +138,11 @@ export function runSessionSnapshotHook(projectDir, event, {
|
|
|
136
138
|
const checkDeadline = () => { if (signal?.aborted || now() >= deadlineAt) throw new Error('restore deadline exceeded'); };
|
|
137
139
|
const suspended = (reason) => ({ metadataWritten: false, progressionCaptured: false, receipt: null, skipped: reason,
|
|
138
140
|
turn: { event, recorded: false, skipped: reason }, continuity: { event, recorded: 0, launched: false, skipped: reason } });
|
|
141
|
+
let enrollmentPayload = {};
|
|
142
|
+
try { enrollmentPayload = rawInput ? JSON.parse(rawInput) : {}; } catch { /* malformed payload */ }
|
|
143
|
+
const enrollment = enrollMemory({ projectDir, env, event, payload: enrollmentPayload, host, deadlineAt });
|
|
144
|
+
if (enrollment.state === 'pending') return { ...suspended('native project memory enrollment pending'), enrollment };
|
|
145
|
+
if (enrollment.state === 'disabled' || enrollment.state === 'unavailable') return { ...suspended(enrollment.reason), enrollment };
|
|
139
146
|
// Consent is checked before metadata, transcript inspection or any durable capture queue.
|
|
140
147
|
try {
|
|
141
148
|
checkDeadline();
|
|
@@ -316,8 +323,13 @@ if (process.argv[1] && path.resolve(process.argv[1]).endsWith('session-snapshot-
|
|
|
316
323
|
const payload = JSON.parse(fs.readFileSync(0, 'utf8') || '{}');
|
|
317
324
|
const projectDir = payload.cwd || projectDirectory();
|
|
318
325
|
const result = runProjectTransitionHook(projectDir, process.argv[2], { payload });
|
|
319
|
-
|
|
320
|
-
|
|
326
|
+
// ADR-106: learning capture rides this boundary instead of a second PostToolUse registration.
|
|
327
|
+
if (process.argv[2] === 'PostToolUse') { try { (await import('./learn-capture.mjs')).captureLearning(payload); } catch { /* advisory */ } }
|
|
328
|
+
// A queued, handed-off observation is normal and silent (ADR-106); only stuck or degraded speaks.
|
|
329
|
+
if (result.state === 'stuck' || result.state === 'degraded') {
|
|
330
|
+
const message = transitionPendingNotice(projectDir, payload, result.state === 'stuck'
|
|
331
|
+
? `Project memory capture is stuck: queued work since ${result.oldestQueuedAt} has not been committed by exact readback.`
|
|
332
|
+
: 'Project memory capture degraded: the observation queue is unwritable.');
|
|
321
333
|
if (message) process.stdout.write(JSON.stringify({ systemMessage: message }));
|
|
322
334
|
}
|
|
323
335
|
} catch { process.stdout.write(JSON.stringify({ systemMessage: 'Project memory transition capture degraded; exact readback was not verified.' })); }
|
|
@@ -333,6 +345,10 @@ if (process.argv[1] && path.resolve(process.argv[1]).endsWith('session-snapshot-
|
|
|
333
345
|
if (typeof cwd === 'string' && path.isAbsolute(cwd)) originProjectDir = cwd;
|
|
334
346
|
} catch { /* malformed input keeps the host's project fallback */ }
|
|
335
347
|
const result = runSessionSnapshotHook(originProjectDir, process.argv[2] || 'SessionEnd', { rawInput });
|
|
348
|
+
// ADR-106: learning recovery rides SessionEnd instead of a second SessionEnd registration.
|
|
349
|
+
if ((process.argv[2] || 'SessionEnd') === 'SessionEnd') {
|
|
350
|
+
import('./learn-capture.mjs').then((m) => m.scheduleLearningRecovery({ cwd: originProjectDir })).catch(() => {});
|
|
351
|
+
}
|
|
336
352
|
// FAIL LOUDLY, NEVER SILENTLY — AND ONCE. When recording is stuck (events pending past STUCK_AFTER_MS,
|
|
337
353
|
// a quarantined conflict, a corrupt outbox line, a cap drop) Claude Code shows this systemMessage, at
|
|
338
354
|
// most once per session per condition (stopNotice; it used to repeat at every turn). "Not applicable"
|
|
@@ -26,6 +26,7 @@ import fs from 'node:fs';
|
|
|
26
26
|
import os from 'node:os';
|
|
27
27
|
import path from 'node:path';
|
|
28
28
|
import { fileURLToPath } from 'node:url';
|
|
29
|
+
import { enrollmentPlan } from './project-memory-enrollment.mjs';
|
|
29
30
|
import { restoreWithBrief } from './continuity-brief.mjs';
|
|
30
31
|
import { turnRecordingStatus } from './turn-outcome-capture.mjs';
|
|
31
32
|
import {
|
|
@@ -33,6 +34,7 @@ import {
|
|
|
33
34
|
} from './session-start-fsutil.mjs';
|
|
34
35
|
import { maintainerIssueEntitlement, surfaceIssuePointer } from './session-start-issue-alert.mjs';
|
|
35
36
|
import { surfaceSignals } from './session-start-signals.mjs';
|
|
37
|
+
import { failureLine, FAILURE_LINE_PREFIX } from './hook-failures.mjs';
|
|
36
38
|
import { brainState, health, knowledgeCurrency, mcpReadiness, KNOWLEDGE_LINE_PREFIX } from './session-start-health.mjs';
|
|
37
39
|
import { stableSpine, heartbeat, knowledgeAutoUpdate, footprintCheck } from './session-start-update-plane.mjs';
|
|
38
40
|
import { FOOTPRINT_LINE_PREFIX } from './brain-confirmation.mjs';
|
|
@@ -97,6 +99,7 @@ export async function runSessionStart({
|
|
|
97
99
|
probeConsoleAlias = probeCodexConsoleAlias,
|
|
98
100
|
startedAt = Date.now(),
|
|
99
101
|
deadlineAt = Infinity,
|
|
102
|
+
scheduleRecovery = true,
|
|
100
103
|
} = {}) {
|
|
101
104
|
const hookDeadlineAt = Math.min(deadlineAt, sessionStartDeadlineAt({ host: env.RUVNET_HOOK_HOST || 'claude', env, startedAt }));
|
|
102
105
|
const lines = [];
|
|
@@ -136,7 +139,8 @@ export async function runSessionStart({
|
|
|
136
139
|
|| s.startsWith('[RuvNet Brain — PROJECT CONTINUITY UNKNOWN]')
|
|
137
140
|
|| s.startsWith('[RuvNet Brain — PROJECT CONTINUITY RESTORED]')
|
|
138
141
|
|| s.startsWith('[RuvNet Brain — COME UP TO SPEED')
|
|
139
|
-
|| s.startsWith('[RuvNet Brain — MAINTAINER ONLY:')
|
|
142
|
+
|| s.startsWith('[RuvNet Brain — MAINTAINER ONLY:')
|
|
143
|
+
|| s.startsWith(FAILURE_LINE_PREFIX);
|
|
140
144
|
};
|
|
141
145
|
// An alarm's HEADER line always matches isSafeStatus on its own dedicated prefix (above); its
|
|
142
146
|
// explanatory BODY lines do not, and without this they were silently dropped — a real gap this
|
|
@@ -169,6 +173,8 @@ export async function runSessionStart({
|
|
|
169
173
|
const home = env.HOME || env.USERPROFILE || os.homedir();
|
|
170
174
|
const stateDir = env.RUVNET_BRAIN_HOME || path.join(home, '.cache', 'ruvnet-brain');
|
|
171
175
|
resetInjectionDedupe(stateDir, Date.now());
|
|
176
|
+
// ADR-106: advisory hooks fail open, so a failed one is reported here, once, never per fire.
|
|
177
|
+
try { const failed = failureLine(stateDir); if (failed) emit(failed); } catch { /* the summary must not cost the session */ }
|
|
172
178
|
const hookDir = path.dirname(fileURLToPath(import.meta.url));
|
|
173
179
|
const now = Date.now();
|
|
174
180
|
const consoleInvoke = env.RUVNET_HOOK_HOST === 'codex' ? '$ruvnet-brain:rnbc' : '/rnbc';
|
|
@@ -189,6 +195,11 @@ export async function runSessionStart({
|
|
|
189
195
|
}
|
|
190
196
|
};
|
|
191
197
|
|
|
198
|
+
// Report only. SessionStart never creates or bootstraps a store (ADR-105 rule 6); enrollment starts at the first
|
|
199
|
+
// consented captured boundary, so a session that merely started leaves no store behind.
|
|
200
|
+
let enrollment;
|
|
201
|
+
try { enrollment = enrollmentPlan({ projectDir: cwd, env, deadlineAt: hookDeadlineAt }); } catch (error) { enrollment = { state: 'unavailable', reason: error.message }; }
|
|
202
|
+
if (enrollment.state === 'pending' || enrollment.state === 'unavailable') emit(`[RuvNet Brain — TURN CAPTURE] Project memory enrollment ${enrollment.state}; restoration is not yet proven.`);
|
|
192
203
|
const restoreStart = Date.now();
|
|
193
204
|
const restoreDeadlineAt = Math.min(hookDeadlineAt, restoreStart + STAGE_BUDGETS_MS.restore);
|
|
194
205
|
const restoreController = new AbortController();
|
|
@@ -443,6 +454,9 @@ export async function runSessionStart({
|
|
|
443
454
|
stdout.write(output);
|
|
444
455
|
trace('body-finished');
|
|
445
456
|
recordProof({ stages: tracer.table(), restore: restoreProof, bodyFailed, bannerFallback: !bannerEmitted });
|
|
457
|
+
// ADR-106: learning recovery rides SessionStart instead of a second SessionStart registration.
|
|
458
|
+
// learningContext() inside owns consent and brain-off; the detached worker is never awaited.
|
|
459
|
+
if (scheduleRecovery) { try { (await import('./learn-capture.mjs')).scheduleLearningRecovery({ cwd, env }); } catch { /* advisory */ } }
|
|
446
460
|
return { ok: true, outputBytes: Buffer.byteLength(output, 'utf8'), platform };
|
|
447
461
|
}
|
|
448
462
|
|
|
@@ -23,11 +23,9 @@
|
|
|
23
23
|
* • Content = assistant outcome text, files changed, Bash descriptions. NEVER raw user text (a
|
|
24
24
|
* 2026-07-13 measurement: prompt echoes made 87% of a store noise). Trivial turns are skipped.
|
|
25
25
|
*
|
|
26
|
-
* CODEX:
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* unknown, and the rollout records observed locally (custom_tool_call / function_call with free-form
|
|
30
|
-
* inputs) give no stable file-change shape. Codex records therefore carry the outcome text only.
|
|
26
|
+
* CODEX: native response_item JSONL is normalized mechanically through native-turn-transcript.
|
|
27
|
+
* SessionEnd and PreCompact recover a closing outcome when Stop payload text is absent. Tool IDs
|
|
28
|
+
* correlate observable gate outcomes; opaque custom orchestration inputs remain unclassified.
|
|
31
29
|
*
|
|
32
30
|
* LATENCY: Stop runs synchronously in the host's turn, and one `ruflo memory store` costs ~0.7s
|
|
33
31
|
* (measured). So the writes run in ONE detached worker (this file, `--run-steps`), store then
|
|
@@ -40,9 +38,11 @@ import path from 'node:path';
|
|
|
40
38
|
import crypto from 'node:crypto';
|
|
41
39
|
import { spawn, spawnSync } from 'node:child_process';
|
|
42
40
|
import { fileURLToPath } from 'node:url';
|
|
41
|
+
import { nativeTurnLines } from './native-turn-transcript.mjs';
|
|
43
42
|
import { resolveRuflo, rufloInvocation } from './ruflo-bin.mjs';
|
|
44
|
-
import {
|
|
45
|
-
import {
|
|
43
|
+
import { rufloCwdFor, rufloScratchRoot } from './project-progression-store.mjs';
|
|
44
|
+
import { redactText } from './continuity-events.mjs';
|
|
45
|
+
import { resolveProjectStore, globalEnrollmentAllowed } from './project-store-resolver.mjs';
|
|
46
46
|
import { digest, journalTurn, readJournal, pendingTurnFiles, acknowledgeJournal, appendReceipt, storeData } from './turn-transport-journal.mjs';
|
|
47
47
|
import { contentPathExcludes, pathIsExcluded, privateTurn, maskExcludedPaths, captureFailureReason, firstTurnCaptureNotice } from './turn-capture-privacy.mjs';
|
|
48
48
|
|
|
@@ -162,7 +162,7 @@ const consentMap = (value) => value !== null && typeof value === 'object'
|
|
|
162
162
|
&& !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype
|
|
163
163
|
&& Object.values(value).every((setting) => setting === 'on' || setting === 'off');
|
|
164
164
|
|
|
165
|
-
export function resolveTurnDb({ projectDir, brainHome, requestedStorePath, gitTimeoutMs = 1000, unknownOriginalPath = false, deadlineAt = Infinity, signal } = {}) {
|
|
165
|
+
export function resolveTurnDb({ projectDir, brainHome, requestedStorePath, gitTimeoutMs = 1000, unknownOriginalPath = false, deadlineAt = Infinity, signal, enrollmentRoots = {} } = {}) {
|
|
166
166
|
if (signal?.aborted || Date.now() >= deadlineAt) throw new Error('restore deadline exceeded');
|
|
167
167
|
const resolved = resolveProjectStore({ projectDir, requestedStorePath, gitTimeoutMs, deadlineAt });
|
|
168
168
|
if (signal?.aborted || Date.now() >= deadlineAt) throw new Error('restore deadline exceeded');
|
|
@@ -173,12 +173,14 @@ export function resolveTurnDb({ projectDir, brainHome, requestedStorePath, gitTi
|
|
|
173
173
|
try {
|
|
174
174
|
policy = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
175
175
|
if (policy?.schemaVersion !== 1 || !consentMap(policy.projects)
|
|
176
|
+
|| (Object.hasOwn(policy, 'default') && !['on', 'off'].includes(policy.default))
|
|
176
177
|
|| (Object.hasOwn(policy, 'paths') && !consentMap(policy.paths))) throw new Error('invalid policy');
|
|
177
178
|
contentPathExcludes(policy.contentPathExcludes);
|
|
178
179
|
} catch { return { skipped: 'turn capture policy unreadable or invalid', projectRoot: resolved.projectRoot }; }
|
|
179
180
|
}
|
|
180
181
|
// A path rule wins over a project rule, allowing a linked checkout/subdirectory to opt out.
|
|
181
|
-
const
|
|
182
|
+
const explicitSetting = policy.paths?.[capturePath] ?? policy.projects?.[resolved.projectRoot];
|
|
183
|
+
const setting = explicitSetting ?? policy.default;
|
|
182
184
|
if (setting !== undefined && !['on', 'off'].includes(setting)) return { skipped: 'invalid turn capture consent', projectRoot: resolved.projectRoot };
|
|
183
185
|
const db = resolved.canonicalAgentDbPath;
|
|
184
186
|
const exclusions = contentPathExcludes(policy.contentPathExcludes);
|
|
@@ -191,6 +193,9 @@ export function resolveTurnDb({ projectDir, brainHome, requestedStorePath, gitTi
|
|
|
191
193
|
if (setting === 'off') return { db, scope: 'project', capturePath, projectRoot: resolved.projectRoot, skipped: 'persisted turn capture opt-out' };
|
|
192
194
|
let exists = false;
|
|
193
195
|
try { exists = fs.statSync(db).isFile(); } catch { /* absent */ }
|
|
196
|
+
if (!exists && setting === 'on' && explicitSetting !== 'on' && !globalEnrollmentAllowed({ resolution: resolved, brainHome, ...enrollmentRoots })) {
|
|
197
|
+
return { db, scope: 'project', capturePath, projectRoot: resolved.projectRoot, skipped: 'global capture consent requires a safe Git project root; explicit project/path opt-in required' };
|
|
198
|
+
}
|
|
194
199
|
if (!exists && setting !== 'on') return { db, scope: 'project', capturePath, projectRoot: resolved.projectRoot, skipped: 'no project memory db; persisted opt-in required' };
|
|
195
200
|
return { db, scope: 'project', capturePath, projectRoot: resolved.projectRoot, optedIn: setting === 'on',
|
|
196
201
|
contentPathExcludes: exclusions };
|
|
@@ -282,24 +287,17 @@ export function captureTurnOutcome({
|
|
|
282
287
|
const project = projectName(projectRoot);
|
|
283
288
|
const receipts = path.join(brainHome, 'turn-capture', 'receipts.jsonl');
|
|
284
289
|
|
|
285
|
-
// ONE WRITER PER TURN (ADR-
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
|
|
289
|
-
// (RUVNET_TURN_CAPTURE=force keeps both). Codex turns are not seen by that Claude-only hook.
|
|
290
|
-
const deferTo = event === 'Stop' && host === 'claude' && String(env.RUVNET_TURN_CAPTURE || '').toLowerCase() !== 'force'
|
|
291
|
-
&& userLevelAgentdbHooks({ home, event, projectDir, env }).turnCapture;
|
|
292
|
-
if (deferTo) {
|
|
293
|
-
report.skipped = 'deferred: an enabled user-level Stop writer explicitly targets the canonical turn store; actual native delivery is unproven';
|
|
294
|
-
report.deferredToUserLevel = true;
|
|
295
|
-
} else if (event === 'Stop') {
|
|
290
|
+
// ONE WRITER PER TURN (ADR-105 §7): the product is the only writer of project turn history. The
|
|
291
|
+
// former deferral to a user-level ~/.claude hook (ADR-100 §3) could never fire — its probe never
|
|
292
|
+
// reported a writer — and cost a git call on every Stop, so it was removed (ADR-106).
|
|
293
|
+
if (['Stop', 'SessionEnd', 'PreCompact'].includes(event)) {
|
|
296
294
|
const sessionKey = String(payload.session_id || payload.transcript_path || '');
|
|
297
295
|
const message = typeof payload.last_assistant_message === 'string' ? payload.last_assistant_message.trim() : '';
|
|
298
296
|
let turn = { finalText: '', files: [], actions: [] };
|
|
299
|
-
if (
|
|
297
|
+
if (typeof payload.transcript_path === 'string' && payload.transcript_path) {
|
|
300
298
|
// With the closing message in hand, the transcript is read immediately (tool calls are already
|
|
301
299
|
// flushed); without it, wait for the closing message to land, bounded.
|
|
302
|
-
try { turn = claudeTurn(readTranscript(payload.transcript_path, { maxMs: message ? 0 : settleMs })); } catch { /* unreadable */ }
|
|
300
|
+
try { turn = claudeTurn(nativeTurnLines(readTranscript(payload.transcript_path, { maxMs: message || event !== 'Stop' ? 0 : settleMs }), host)); } catch { /* unreadable */ }
|
|
303
301
|
}
|
|
304
302
|
if (message && (message.length >= MIN_OUTCOME_CHARS || message.length >= turn.finalText.length)) turn.finalText = message;
|
|
305
303
|
turn = privateTurn(turn, target.contentPathExcludes, target.capturePath);
|
|
@@ -323,12 +321,12 @@ export function captureTurnOutcome({
|
|
|
323
321
|
catch (error) { return { ...report, skipped: `durable turn queue invalid: ${redactText(error.message)}` }; }
|
|
324
322
|
}
|
|
325
323
|
steps.push({ ...binding, kind: 'store', ruflo, args: ['memory', 'store', '-k', key, '--value', value, '-n', TURN_NAMESPACE,
|
|
326
|
-
'--path', db, '--no-upsert', '--tags', `project=${project},host=${host}`, '--provenance', 'agent_output'] });
|
|
324
|
+
'--path', db, '--no-upsert', '--require-native', '--append-only', '--tags', `project=${project},host=${host}`, '--provenance', 'agent_output'] });
|
|
327
325
|
// This synchronous boundary proves only queuing; the worker's exact receipt proves recording.
|
|
328
326
|
Object.assign(report, { queued: true, key, value });
|
|
329
327
|
}
|
|
330
328
|
}
|
|
331
|
-
} else report.skipped = `turn
|
|
329
|
+
} else report.skipped = `no turn outcome capture at ${event}`;
|
|
332
330
|
|
|
333
331
|
if (event === 'SessionEnd' || event === 'PreCompact') {
|
|
334
332
|
if (!fs.existsSync(db)) report.distill = { queued: false, skipped: 'no memory db to distill yet' };
|
|
@@ -376,7 +374,7 @@ export function runSteps({ steps = [], receipts } = {}, { run: suppliedRun = spa
|
|
|
376
374
|
};
|
|
377
375
|
const results = [];
|
|
378
376
|
for (const queued of steps) {
|
|
379
|
-
let status = 1; let error = null; let verified = false; let db = ''; let key; let privacyPatterns = [];
|
|
377
|
+
let status = 1; let error = null; let skipped = null; let verified = false; let db = ''; let key; let privacyPatterns = [];
|
|
380
378
|
const kind = queued.kind === 'journal' ? 'store' : queued.kind;
|
|
381
379
|
try {
|
|
382
380
|
if (String(env.RUVNET_TURN_CAPTURE || '').toLowerCase() === 'off') throw new Error('RUVNET_TURN_CAPTURE=off');
|
|
@@ -396,7 +394,7 @@ export function runSteps({ steps = [], receipts } = {}, { run: suppliedRun = spa
|
|
|
396
394
|
binding = queued.kind === 'journal' ? data.binding : queued;
|
|
397
395
|
key = data.key; value = maskExcludedPaths(redactText(data.value), target.contentPathExcludes, target.capturePath);
|
|
398
396
|
if (value !== redactText(data.value)) throw new Error('content exclusions changed; immutable turn retained');
|
|
399
|
-
args = ['memory', 'store', '-k', key, '--value', value, '-n', TURN_NAMESPACE, '--path', db, '--no-upsert', '--provenance', 'agent_output'];
|
|
397
|
+
args = ['memory', 'store', '-k', key, '--value', value, '-n', TURN_NAMESPACE, '--path', db, '--no-upsert', '--require-native', '--append-only', '--provenance', 'agent_output'];
|
|
400
398
|
} else if (kind === 'distill') {
|
|
401
399
|
if (queued.brainHome !== brainHome) throw new Error('queued consent settings differ from trusted caller');
|
|
402
400
|
const recipe = queued.args;
|
|
@@ -424,21 +422,30 @@ export function runSteps({ steps = [], receipts } = {}, { run: suppliedRun = spa
|
|
|
424
422
|
const invocation = rufloInvocation(ruflo, args);
|
|
425
423
|
const remaining = deadlineMs === undefined ? STEP_TIMEOUT_MS : deadlineMs - Date.now();
|
|
426
424
|
if (remaining <= 0) throw new Error('turn replay deadline exhausted');
|
|
427
|
-
|
|
428
|
-
|
|
425
|
+
// Measured 2026-10-10 with global Ruflo 3.56.3: cwd=<project>/.swarm litters ruvector.db/.claude-flow there, and
|
|
426
|
+
// CLAUDE_FLOW_MEMORY_PATH=<.swarm> makes Ruflo create a second agentdb-memory.db beside the canonical store.
|
|
427
|
+
// The explicit --path already binds the store, so run from private scratch with neither.
|
|
428
|
+
const options = { encoding: 'utf8', timeout: Math.min(STEP_TIMEOUT_MS, remaining), windowsHide: true,
|
|
429
|
+
cwd: rufloCwdFor(db, { root: rufloScratchRoot({ ...env, RUVNET_BRAIN_HOME: brainHome }) }),
|
|
430
|
+
maxBuffer: 1024 * 1024, env: { ...env, ...RUFLO_ENV } };
|
|
431
|
+
delete options.env.CLAUDE_FLOW_MEMORY_PATH;
|
|
429
432
|
const existing = kind === 'store' && queued.journalFile ? read({ ruflo, db, key, run, options }) : null;
|
|
430
433
|
if (existing !== null && existing !== value) throw new Error('existing turn key content differs; no upsert permitted');
|
|
431
434
|
verified = kind === 'store' && existing === value;
|
|
432
435
|
const r = verified ? { status: 0 } : run(invocation.executable, invocation.args, options);
|
|
433
436
|
status = Number.isInteger(r.status) ? r.status : 1;
|
|
434
|
-
if (kind === 'store' && !verified) verified = read({ ruflo, db, key, run, options }) === value;
|
|
437
|
+
if (kind === 'store' && !verified && status === 0) verified = read({ ruflo, db, key, run, options }) === value;
|
|
435
438
|
if (verified) status = 0;
|
|
439
|
+
if (kind === 'distill' && status === 0) {
|
|
440
|
+
const match = /(?:^|\n)\s*skipped:\s*([^\n]+)/i.exec(String(r.stdout || '').replace(/\x1b\[[0-9;]*m/g, ''));
|
|
441
|
+
if (match) skipped = redactText(match[1]).slice(0, 300);
|
|
442
|
+
}
|
|
436
443
|
if (!verified && (r.error || status !== 0)) error = captureFailureReason(r, status);
|
|
437
444
|
else if (kind === 'store' && !verified) { status = 1; error = 'exact turn key/content readback failed'; }
|
|
438
445
|
} catch (e) { status = 1; error = redactText(e.message).slice(0, 300); }
|
|
439
446
|
if (error) error = maskExcludedPaths(error, privacyPatterns, projectDir);
|
|
440
447
|
const row = { at: new Date().toISOString(), kind, db: redactText(db), storeIdentity: digest(db), key, status, error,
|
|
441
|
-
...(kind === 'store' ? { verified } : {}) };
|
|
448
|
+
...(kind === 'store' ? { verified } : {}), ...(skipped ? { skipped } : {}) };
|
|
442
449
|
results.push(row);
|
|
443
450
|
if (receipts) {
|
|
444
451
|
try { appendReceipt(receipts, row); if (verified && queued.journalFile) acknowledgeJournal(queued.journalFile, db); }
|
|
@@ -473,8 +480,7 @@ export function turnRecordingStatus({ projectDir = process.cwd(), env = process.
|
|
|
473
480
|
let target;
|
|
474
481
|
try { target = resolveTurnDb({ projectDir, brainHome }); } catch { return { state: 'unknown', line: 'turn recording unavailable: canonical store resolution failed' }; }
|
|
475
482
|
if (String(env.RUVNET_TURN_CAPTURE || '').toLowerCase() === 'off' || target.skipped) return { state: 'unknown', line: `turn recording n/a — ${target.skipped || 'RUVNET_TURN_CAPTURE=off'}` };
|
|
476
|
-
const
|
|
477
|
-
const notice = noticeOnFirstUse && !deferred ? firstTurnCaptureNotice({ target, brainHome, policyFile: turnCapturePolicyFile(brainHome), env }) : null;
|
|
483
|
+
const notice = noticeOnFirstUse ? firstTurnCaptureNotice({ target, brainHome, policyFile: turnCapturePolicyFile(brainHome), env }) : null;
|
|
478
484
|
const status = (value) => ({ ...value, ...(notice ? { notice } : {}) });
|
|
479
485
|
let rows = [];
|
|
480
486
|
try {
|
|
@@ -26,9 +26,11 @@ export function syncTurnDirectory(dir, { platform = process.platform, io = fs }
|
|
|
26
26
|
export function storeData(step) {
|
|
27
27
|
if (step?.kind !== 'store' || !Array.isArray(step.args) || step.args[0] !== 'memory' || step.args[1] !== 'store') throw new Error('invalid turn store operation');
|
|
28
28
|
const fields = {}; const aliases = { '-k': 'key', '--key': 'key', '--value': 'value', '-n': 'namespace', '--namespace': 'namespace', '--path': 'db', '--tags': 'tags', '--provenance': 'provenance' };
|
|
29
|
-
let strict = false;
|
|
29
|
+
let strict = false; let native = false; let immutable = false;
|
|
30
30
|
for (let i = 2; i < step.args.length; i++) {
|
|
31
31
|
const flag = step.args[i];
|
|
32
|
+
if (flag === '--append-only') { if (immutable) throw new Error('duplicate turn store flag'); immutable = true; continue; }
|
|
33
|
+
if (flag === '--require-native') { if (native) throw new Error('duplicate turn store flag'); native = true; continue; }
|
|
32
34
|
if (flag === '--no-upsert') { if (strict) throw new Error('duplicate turn store flag'); strict = true; continue; }
|
|
33
35
|
const field = aliases[flag];
|
|
34
36
|
if (!field || Object.hasOwn(fields, field) || typeof step.args[i + 1] !== 'string') throw new Error('unknown or duplicate turn store flag');
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* deferral-replay.mjs — measure continuation-gate's `no-deferral` check (ADR-106) on REAL Claude Code
|
|
4
|
+
* transcripts, read-only. Same turn model as completion-claim-replay.mjs: a turn runs from one genuine
|
|
5
|
+
* user message to the next and its final assistant text stands in for `last_assistant_message`;
|
|
6
|
+
* authorization is read from the owner prompts up to and including that turn.
|
|
7
|
+
*
|
|
8
|
+
* node scripts/deferral-replay.mjs [--root ~/.claude/projects] [--limit 60] [--skip 0] [--out sample.jsonl]
|
|
9
|
+
*/
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import os from 'node:os';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { transcripts, turnsOf, finalText } from './completion-claim-replay.mjs';
|
|
14
|
+
import { auditDeferral, ownerPrompts } from '../plugin/scripts/deferral-evidence.mjs';
|
|
15
|
+
|
|
16
|
+
const argv = process.argv.slice(2);
|
|
17
|
+
const opt = (flag, fallback) => { const i = argv.indexOf(flag); return i >= 0 && argv[i + 1] ? argv[i + 1] : fallback; };
|
|
18
|
+
const root = path.resolve(opt('--root', path.join(os.homedir(), '.claude', 'projects')));
|
|
19
|
+
const limit = Number(opt('--limit', '60'));
|
|
20
|
+
const skip = Number(opt('--skip', '0')); // held-out evaluation: skip the newest N transcripts
|
|
21
|
+
const out = opt('--out', null);
|
|
22
|
+
const report = { root, transcripts: 0, turns: 0, deferralPhrase: 0, blocked: 0, unauthorized: 0, forced: 0, byKind: {} };
|
|
23
|
+
const samples = [];
|
|
24
|
+
for (const file of transcripts(root, skip + limit).slice(skip)) {
|
|
25
|
+
report.transcripts += 1;
|
|
26
|
+
const lines = fs.readFileSync(file, 'utf8').split('\n').filter(Boolean);
|
|
27
|
+
const prompts = [];
|
|
28
|
+
for (const turn of turnsOf(lines)) {
|
|
29
|
+
report.turns += 1;
|
|
30
|
+
prompts.push(...ownerPrompts(turn.slice(0, 1)));
|
|
31
|
+
const message = finalText(turn);
|
|
32
|
+
if (!message) continue;
|
|
33
|
+
const v = auditDeferral(message, { prompts });
|
|
34
|
+
if (v.verdict === 'NONE') continue;
|
|
35
|
+
report.deferralPhrase += 1;
|
|
36
|
+
if (v.verdict === 'BLOCKER') report.blocked += 1;
|
|
37
|
+
if (v.verdict === 'UNAUTHORIZED') report.unauthorized += 1;
|
|
38
|
+
if (v.verdict === 'DEFERRAL') {
|
|
39
|
+
report.forced += 1;
|
|
40
|
+
report.byKind[v.deferral.kind] = (report.byKind[v.deferral.kind] || 0) + 1;
|
|
41
|
+
samples.push({ file: path.basename(file), phrase: v.deferral.phrase, authorization: v.authorization, tail: message.slice(-500) });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
report.forcedRate = report.turns ? Number((report.forced / report.turns).toFixed(4)) : 0;
|
|
46
|
+
if (out) fs.writeFileSync(out, samples.map((s) => JSON.stringify(s)).join('\n') + '\n');
|
|
47
|
+
console.log(JSON.stringify(report, null, 2));
|