ruvnet-brain 4.6.3 → 4.7.0
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/bin/install.mjs +6 -50
- package/config/practical-rule-catalog.json +22 -22
- package/console/app.js +1 -1
- package/console/architecture.html +2 -2
- package/package.json +2 -2
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/commands/checkpoint.md +22 -40
- package/plugin/commands/rnb-brief.md +18 -15
- package/plugin/hooks/codex-hooks.json +1 -58
- package/plugin/hooks/hook-contracts.json +19 -220
- package/plugin/hooks/hooks.json +1 -70
- package/plugin/mcp/managed-cli-interface.mjs +0 -56
- package/plugin/scripts/agentdb-recall-reader.mjs +29 -9
- package/plugin/scripts/agentdb-recall.mjs +27 -21
- package/plugin/scripts/brain-footprint.mjs +1 -1
- package/plugin/scripts/codex-hook-wrapper.mjs +13 -3
- package/plugin/scripts/completion-claim-evidence.mjs +1 -1
- package/plugin/scripts/continuation-gate.mjs +78 -1
- package/plugin/scripts/continuity-events.mjs +90 -82
- package/plugin/scripts/continuity-hook-policy.mjs +5 -14
- package/plugin/scripts/decision-gate.mjs +3 -3
- package/plugin/scripts/decision-recall-gate.mjs +141 -0
- package/plugin/scripts/decision-record.mjs +324 -0
- package/plugin/scripts/deferral-evidence.mjs +134 -0
- package/plugin/scripts/hook-failures.mjs +61 -0
- package/plugin/scripts/hook-shim.mjs +71 -6
- package/plugin/scripts/learn-capture.mjs +88 -37
- package/plugin/scripts/learning-store.mjs +70 -60
- package/plugin/scripts/node-sqlite.mjs +1 -1
- package/plugin/scripts/ruflo-bin.mjs +71 -1
- package/plugin/scripts/ruflo-memory-hooks.mjs +132 -0
- package/plugin/scripts/runtime-preferences.mjs +1 -1
- package/plugin/scripts/session-snapshot-hook.mjs +85 -332
- package/plugin/scripts/session-start-core.mjs +7 -9
- package/plugin/scripts/signal-watch.mjs +1 -1
- package/plugin/scripts/substitution-wall.mjs +222 -0
- package/plugin/scripts/turn-outcome-capture.mjs +74 -88
- package/scripts/codex-host-execution-proof.mjs +1 -1
- package/scripts/console-engine.mjs +0 -32
- package/scripts/deferral-replay.mjs +47 -0
- package/scripts/health-repair.mjs +1 -81
- package/scripts/hook-qualify-core.mjs +2 -2
- package/scripts/model-managed-workflow-service.mjs +9 -10
- package/scripts/model-routing-controller.mjs +9 -8
- package/scripts/no-silent-substitution.mjs +65 -1
- package/scripts/onboarding-console.mjs +8 -23
- package/scripts/product-integrity-contract.mjs +1 -1
- package/scripts/release-qualification-contract.mjs +9 -27
- package/scripts/remedy-registry.mjs +0 -18
- package/scripts/wired-check.mjs +1 -5
- package/plugin/scripts/continuity-brief.mjs +0 -357
- package/plugin/scripts/continuity-journal.mjs +0 -553
- package/plugin/scripts/learn-flush.mjs +0 -96
- package/plugin/scripts/learning-observation.mjs +0 -51
- package/plugin/scripts/learning-queue.mjs +0 -135
- package/plugin/scripts/learning-worker-supervisor.mjs +0 -79
- package/plugin/scripts/project-capture-queue.mjs +0 -357
- package/plugin/scripts/project-memory-enrollment.mjs +0 -203
- package/plugin/scripts/project-progression-checkpoint.mjs +0 -145
- package/plugin/scripts/project-progression-contract.mjs +0 -496
- package/plugin/scripts/project-progression-hook.mjs +0 -233
- package/plugin/scripts/project-progression-outbox.mjs +0 -225
- package/plugin/scripts/project-progression-producer.mjs +0 -309
- package/plugin/scripts/project-progression-reader.mjs +0 -304
- package/plugin/scripts/project-progression-session-start.mjs +0 -320
- package/plugin/scripts/project-progression-sources.mjs +0 -246
- package/plugin/scripts/project-progression-store.mjs +0 -723
- package/plugin/scripts/project-progression-suspension.mjs +0 -60
- package/plugin/scripts/project-transition-hook.mjs +0 -246
- package/plugin/scripts/session-snapshot-contract.mjs +0 -105
- package/plugin/scripts/turn-transport-journal.mjs +0 -108
- package/scripts/qa/progression-validation-benchmark.mjs +0 -66
- package/scripts/session-snapshot-contract.mjs +0 -1
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
// Operator containment only (#390 B). No store, checkpoint, event, turn or search is disabled here.
|
|
2
|
-
import fs from 'node:fs';
|
|
3
|
-
import os from 'node:os';
|
|
4
|
-
import path from 'node:path';
|
|
5
|
-
import { fileURLToPath } from 'node:url';
|
|
6
|
-
import { randomUUID } from 'node:crypto';
|
|
7
|
-
|
|
8
|
-
const suspendedResults = new WeakSet();
|
|
9
|
-
export function progressionSuspensionFile(env = process.env) {
|
|
10
|
-
return path.join(env.RUVNET_BRAIN_STATE_DIR || path.join(env.HOME || os.homedir(), '.config', 'ruvnet-brain'), 'brain-progression-suspension');
|
|
11
|
-
}
|
|
12
|
-
export function operatorProgressionSuspension(env = process.env) {
|
|
13
|
-
if (env.RUVNET_BRAIN_PROGRESSION_SUSPENDED === '1') return { source: 'environment' };
|
|
14
|
-
if (env.RUVNET_BRAIN_PROGRESSION_SUSPENDED && env.RUVNET_BRAIN_PROGRESSION_SUSPENDED !== '0') {
|
|
15
|
-
throw new Error('invalid automatic progression suspension environment setting');
|
|
16
|
-
}
|
|
17
|
-
let value;
|
|
18
|
-
try { value = JSON.parse(fs.readFileSync(progressionSuspensionFile(env), 'utf8')); }
|
|
19
|
-
catch (error) { if (error.code === 'ENOENT') return null; throw new Error('automatic progression suspension policy unreadable or invalid'); }
|
|
20
|
-
if (value?.schemaVersion !== 1 || typeof value.suspended !== 'boolean') {
|
|
21
|
-
throw new Error('automatic progression suspension policy unreadable or invalid');
|
|
22
|
-
}
|
|
23
|
-
return value.suspended ? { source: 'operator-state' } : null;
|
|
24
|
-
}
|
|
25
|
-
export function automaticProgressionSuspensionResult(env = process.env, fields = {}) {
|
|
26
|
-
const control = operatorProgressionSuspension(env);
|
|
27
|
-
if (!control) return null;
|
|
28
|
-
const result = Object.freeze({ ...fields, progressionSuspended: true, progressionCaptured: false,
|
|
29
|
-
receipt: null, skipped: 'operator suspended automatic project progression', suspensionSource: control.source });
|
|
30
|
-
suspendedResults.add(result);
|
|
31
|
-
return result;
|
|
32
|
-
}
|
|
33
|
-
// A look-alike public flag is never authority to bypass an unexpected capture failure.
|
|
34
|
-
export const isAutomaticProgressionSuspension = (result) => Boolean(result && suspendedResults.has(result));
|
|
35
|
-
|
|
36
|
-
/** Explicit operator configuration: replacing this control file never touches evidence or queues. */
|
|
37
|
-
export function setOperatorProgressionSuspension(suspended, { env = process.env } = {}) {
|
|
38
|
-
if (typeof suspended !== 'boolean') throw new TypeError('suspended must be a boolean');
|
|
39
|
-
const file = progressionSuspensionFile(env);
|
|
40
|
-
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
41
|
-
const temporary = `${file}.${process.pid}.${randomUUID()}.tmp`;
|
|
42
|
-
try {
|
|
43
|
-
fs.writeFileSync(temporary, JSON.stringify({ schemaVersion: 1, suspended }), { flag: 'wx', mode: 0o600 });
|
|
44
|
-
fs.renameSync(temporary, file);
|
|
45
|
-
} finally { try { fs.unlinkSync(temporary); } catch { /* renamed or not created */ } }
|
|
46
|
-
return operatorProgressionSuspension(env);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
50
|
-
try {
|
|
51
|
-
const argv = process.argv.slice(2);
|
|
52
|
-
if (argv.length !== 1 || !['--suspend', '--resume', '--status'].includes(argv[0])) {
|
|
53
|
-
throw new Error('usage: project-progression-suspension.mjs --suspend | --resume | --status');
|
|
54
|
-
}
|
|
55
|
-
if (argv[0] !== '--status') setOperatorProgressionSuspension(argv[0] === '--suspend');
|
|
56
|
-
const control = operatorProgressionSuspension();
|
|
57
|
-
console.log(JSON.stringify({ automaticProgression: control ? 'operator-suspended' : 'enabled', source: control?.source || null,
|
|
58
|
-
evidencePreserved: true, explicitCheckpointsAvailable: true }));
|
|
59
|
-
} catch (error) { console.error(error.message); process.exitCode = 1; }
|
|
60
|
-
}
|
|
@@ -1,246 +0,0 @@
|
|
|
1
|
-
/** Observed transitions are context, never instructions or completion claims. */
|
|
2
|
-
import fs from 'node:fs';
|
|
3
|
-
import path from 'node:path';
|
|
4
|
-
import crypto from 'node:crypto';
|
|
5
|
-
import os from 'node:os';
|
|
6
|
-
import { developmentHooksSuspended } from './development-maintenance.mjs';
|
|
7
|
-
import { resolveTurnDb } from './turn-outcome-capture.mjs';
|
|
8
|
-
import { privateTransitionObservation } from './turn-capture-privacy.mjs';
|
|
9
|
-
import { automaticProgressionSuspensionResult } from './project-progression-suspension.mjs';
|
|
10
|
-
import { execFileSync } from 'node:child_process';
|
|
11
|
-
import { fileURLToPath } from 'node:url';
|
|
12
|
-
import { redactText, normalizeToolOutcome } from './continuity-events.mjs';
|
|
13
|
-
import { conditionNotice } from './continuity-journal.mjs';
|
|
14
|
-
import { normalizeHostEvent } from './hook-input.mjs';
|
|
15
|
-
import { resolveProjectStore } from './project-store-resolver.mjs';
|
|
16
|
-
import { withProgressionReader } from './project-progression-reader.mjs';
|
|
17
|
-
import { redactProgression, restoreProjectProgression, digestCanonical, validateProgressionSnapshot } from './project-progression-contract.mjs';
|
|
18
|
-
import { runSessionSnapshotHook, effectiveBudgetMs, queueCapture, replayOutboxDetached, runOutboxReplay } from './session-snapshot-hook.mjs';
|
|
19
|
-
|
|
20
|
-
const EVENTS = new Set(['UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'PostToolUseFailure', 'SubagentStop']);
|
|
21
|
-
const TOPICS = [
|
|
22
|
-
[/\b(agentdb|memory|recall|capture|checkpoint)\b/i, 'project memory'],
|
|
23
|
-
[/\b(test|tests|vitest|pytest|qa|check)\b/i, 'validation'],
|
|
24
|
-
[/\b(release|publish|deploy|deployment)\b/i, 'release'],
|
|
25
|
-
[/\b(doc|docs|documentation|readme)\b/i, 'documentation'],
|
|
26
|
-
[/\b(security|authentication|permission|secret)\b/i, 'security'],
|
|
27
|
-
[/\b(runtime|hook|hooks|process|session)\b/i, 'runtime integration'],
|
|
28
|
-
[/\b(ui|interface|page|browser)\b/i, 'user interface'],
|
|
29
|
-
];
|
|
30
|
-
const ACTIONS = ['fix', 'repair', 'implement', 'add', 'remove', 'update', 'audit', 'review', 'verify', 'test', 'explain', 'build'];
|
|
31
|
-
const MEANINGFUL_TOOLS = /(?:^|__)(?:Write|Edit|MultiEdit|NotebookEdit|apply_patch|Bash|exec_command|write_stdin|agent_spawn|Task|Agent)$/i;
|
|
32
|
-
|
|
33
|
-
// Classification deliberately returns dictionary words only. Redaction alone cannot
|
|
34
|
-
// distinguish a password from an ordinary word in arbitrary free text.
|
|
35
|
-
export function semanticIntent(text) {
|
|
36
|
-
const safe = String(text ?? '').slice(0, 8192);
|
|
37
|
-
const action = ACTIONS.find((word) => new RegExp(`\\b${word}\\b`, 'i').test(safe)) || 'discuss';
|
|
38
|
-
const topics = TOPICS.filter(([pattern]) => pattern.test(safe)).map(([, label]) => label);
|
|
39
|
-
return { action, subjects: topics.length ? topics : ['project work'] };
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/** Selected user task clause, not a raw prompt archive and never authority to resume. */
|
|
43
|
-
export function selectedUserIntent(value) {
|
|
44
|
-
if (typeof value !== 'string') return null;
|
|
45
|
-
const bounded = value.slice(0, 64 * 1024);
|
|
46
|
-
const redacted = redactText(bounded);
|
|
47
|
-
const prose = redacted.replace(/```[\s\S]*?(?:```|$)|~~~[\s\S]*?(?:~~~|$)/g, '');
|
|
48
|
-
const lines = prose.split(/\r?\n/).filter((line) => line.trim()
|
|
49
|
-
&& !/^\s*(?:>|\{|\[|(?:tool|stdout|stderr|output|log|trace|result)\s*:|\d{4}-\d\d-\d\d)/i.test(line));
|
|
50
|
-
const selected = lines.find((line) => /\b(?:fix|repair|implement|add|remove|update|audit|review|verify|test|explain|build|why|how|what)\b/i.test(line));
|
|
51
|
-
if (!selected) return null;
|
|
52
|
-
const clause = selected.trim().split(/(?<=[.!?])\s/)[0].replace(/\s+/g, ' ');
|
|
53
|
-
if (/\[REDACTED|password|passwd|credential|private key|authorization|bearer|api.?key|secret|(?:access|auth)[-_ ]?token/i.test(clause)) return null;
|
|
54
|
-
let excerpt = '';
|
|
55
|
-
for (const character of clause) {
|
|
56
|
-
if (Buffer.byteLength(excerpt + character, 'utf8') > 240) break;
|
|
57
|
-
excerpt += character;
|
|
58
|
-
}
|
|
59
|
-
if (!excerpt) return null;
|
|
60
|
-
return { text: excerpt, source: 'user-prompt-excerpt', authoritative: false,
|
|
61
|
-
redacted: bounded !== redacted, truncated: excerpt !== clause || bounded !== value };
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
export function normalizeTransition(payload, event, { now = () => new Date().toISOString(), eventId = () => crypto.randomUUID(), host } = {}) {
|
|
65
|
-
const input = normalizeHostEvent(payload);
|
|
66
|
-
if (!EVENTS.has(event)) return { skipped: 'unsupported transition' };
|
|
67
|
-
if (!input || typeof input.session_id !== 'string' || !input.session_id) return { skipped: 'no session identity' };
|
|
68
|
-
const common = { id: eventId(), occurredAt: now(), trigger: event, source: 'host-observation', authoritative: false };
|
|
69
|
-
if (event === 'UserPromptSubmit') {
|
|
70
|
-
const text = input.prompt ?? input.user_prompt;
|
|
71
|
-
if (typeof text !== 'string' || !text.trim()) return { skipped: 'no user intent supplied' };
|
|
72
|
-
const selectedIntent = selectedUserIntent(text);
|
|
73
|
-
return { ...common, kind: 'user-goal-observation', intent: semanticIntent(text),
|
|
74
|
-
...(selectedIntent ? { selectedIntent } : {}), outcome: 'requested' };
|
|
75
|
-
}
|
|
76
|
-
if (event === 'SubagentStop') return { ...common, kind: 'child-observation', outcome: 'child-stopped', parentGoalChanged: false };
|
|
77
|
-
if (!MEANINGFUL_TOOLS.test(String(input.tool_name ?? ''))) return { skipped: 'non-material tool observation' };
|
|
78
|
-
const toolInput = input.tool_input ?? {};
|
|
79
|
-
const response = input.tool_response && typeof input.tool_response === 'object' ? input.tool_response : {};
|
|
80
|
-
const normalized = normalizeToolOutcome({ ...input, content: input.tool_response,
|
|
81
|
-
...(event === 'PostToolUseFailure' ? { is_error: true } : {}) });
|
|
82
|
-
const exitCode = normalized.exitCode;
|
|
83
|
-
const error = typeof response.error === 'string' && response.error ? redactText(response.error).slice(0, 4096) : undefined;
|
|
84
|
-
const signal = typeof response.signal === 'string' && response.signal ? redactText(response.signal).slice(0, 100) : undefined;
|
|
85
|
-
// Measured native Claude Bash PostToolUse has no exit code. Its completion envelope
|
|
86
|
-
// is host-reported success, never an inferred exit-code zero. Other hosts stay unknown.
|
|
87
|
-
const claudeBashCompletion = host === 'claude' && input.tool_name === 'Bash' && event === 'PostToolUse'
|
|
88
|
-
&& typeof response.stdout === 'string' && typeof response.stderr === 'string'
|
|
89
|
-
&& response.interrupted === false && typeof response.isImage === 'boolean'
|
|
90
|
-
&& typeof response.noOutputExpected === 'boolean';
|
|
91
|
-
const outcome = event === 'PreToolUse' ? 'pending'
|
|
92
|
-
: normalized.outcome === 'fail' ? 'failure'
|
|
93
|
-
: ['interrupted', 'pending'].includes(normalized.outcome) ? normalized.outcome
|
|
94
|
-
: normalized.outcome === 'pass' || normalized.successfulToolResult ? 'success'
|
|
95
|
-
: !normalized.uncertain && (response.outcome === 'success' || claudeBashCompletion) ? 'success' : 'unknown';
|
|
96
|
-
return { ...common, kind: 'tool-observation', tool: String(input.tool_name).split('__').at(-1).slice(0, 100),
|
|
97
|
-
intent: semanticIntent(toolInput.description ?? toolInput.command ?? toolInput.cmd ?? toolInput.file_path), outcome,
|
|
98
|
-
...(outcome === 'success' && claudeBashCompletion ? { outcomeEvidence: 'claude-bash-completion' } : {}),
|
|
99
|
-
...(event !== 'PreToolUse' && Number.isSafeInteger(exitCode) ? { exitCode } : event !== 'PreToolUse' && (response.exit_code === null || response.exitCode === null) ? { exitCode: null } : {}),
|
|
100
|
-
...(event !== 'PreToolUse' && error ? { error } : {}),
|
|
101
|
-
...(event !== 'PreToolUse' && signal ? { signal } : {}) };
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
export function readTransitionHistory(resolution, { deadlineAt = Infinity } = {}) {
|
|
105
|
-
const remaining = () => { if (Date.now() >= deadlineAt) throw new Error('transition history deadline exceeded'); };
|
|
106
|
-
remaining();
|
|
107
|
-
const result = withProgressionReader(resolution.canonicalAgentDbPath, (reader) => {
|
|
108
|
-
const keys = reader.listKeys('project-progression');
|
|
109
|
-
// Complete ancestry under the shared deadline; never a lifetime row cap or latest-N window.
|
|
110
|
-
remaining();
|
|
111
|
-
return keys.map((key) => { remaining(); return JSON.parse(reader.readContent('project-progression', key)); });
|
|
112
|
-
});
|
|
113
|
-
if (!result.ok) throw new Error('canonical transition history unavailable');
|
|
114
|
-
return result.value;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
export function buildTransitionProgression({ resolution, observation, snapshots = [], sessionIdentity, host, sourceIdentity: originalSourceIdentity }) {
|
|
118
|
-
const restored = restoreProjectProgression(snapshots, { expectedProjectIdentity: resolution.projectIdentity });
|
|
119
|
-
if (snapshots.length && !restored.ok) throw new Error('nonempty transition journal has no coherent ancestry');
|
|
120
|
-
return buildRestoredTransitionProgression({ resolution, observation, snapshots, sessionIdentity, host, sourceIdentity: originalSourceIdentity }, restored);
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
// Only the validated result computed in this module can reach this private builder.
|
|
124
|
-
function buildRestoredTransitionProgression({ resolution, observation, snapshots, sessionIdentity, host, sourceIdentity: originalSourceIdentity }, restored) {
|
|
125
|
-
const heads = snapshots.filter((snapshot) => restored.heads.includes(snapshot.eventKey));
|
|
126
|
-
const prior = restored.state;
|
|
127
|
-
const empty = Object.fromEntries(['plan', 'completed', 'inProgress', 'blockers', 'failures', 'decisions', 'changedFiles', 'commands', 'proofArtifacts', 'untested', 'resumeConflicts'].map((key) => [key, []]));
|
|
128
|
-
const state = prior ? structuredClone(prior) : { ...empty, currentGoal: null, nextAction: null, acceptanceContract: null, activeProcess: null, activeStep: null };
|
|
129
|
-
delete state.sourceIdentity;
|
|
130
|
-
delete state.journalHeads;
|
|
131
|
-
state.activeStep = observation.trigger;
|
|
132
|
-
state.observations = [...(Array.isArray(state.observations) ? state.observations : []), observation];
|
|
133
|
-
if (observation.kind === 'tool-observation') {
|
|
134
|
-
state.commands = [...state.commands, observation];
|
|
135
|
-
if (['failure', 'interrupted'].includes(observation.outcome)) state.failures = [...state.failures, observation];
|
|
136
|
-
}
|
|
137
|
-
state.evidence = { ...(state.evidence ?? {}), transition: { originalEventId: observation.id, originalOccurredAt: observation.occurredAt,
|
|
138
|
-
sourceMeasurement: 'head-only; tree digests not measured at this boundary', authoritative: false } };
|
|
139
|
-
// Source identity was measured at observation time, never reconstructed by a later drainer.
|
|
140
|
-
const sourceIdentity = originalSourceIdentity || observeTransitionSource(resolution);
|
|
141
|
-
return redactProgression({ canonicalAgentDbPath: resolution.canonicalAgentDbPath, sourceIdentity,
|
|
142
|
-
sequence: Math.max(0, ...heads.map((item) => item.sequence)) + 1, occurredAt: observation.occurredAt,
|
|
143
|
-
parentEventKeys: restored.heads, dedupId: `${host}:${sessionIdentity}:${observation.id}`, completeProjectState: state }).value;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
export function observeTransitionSource(resolution, projectDir = resolution.checkoutRoot) {
|
|
147
|
-
let head = 'unmeasured';
|
|
148
|
-
try { head = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: resolution.checkoutRoot, encoding: 'utf8', timeout: 300, stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { /* explicitly unmeasured */ }
|
|
149
|
-
return { checkoutPath: resolution.checkoutRoot, capturePath: fs.realpathSync.native(projectDir),
|
|
150
|
-
worktreeId: crypto.createHash('sha256').update(resolution.checkoutRoot).digest('hex'), branch: 'unmeasured', head,
|
|
151
|
-
trackedDigest: 'unmeasured-at-transition', untrackedDigest: 'unmeasured-at-transition', dirtyTreeDigest: 'unmeasured-at-transition' };
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
/** Called only by the fenced queue drainer; current heads merge, original observations stay fixed. */
|
|
155
|
-
export function captureNormalizedTransition(job, { readHistory = readTransitionHistory, capture = runSessionSnapshotHook,
|
|
156
|
-
budgetMs = 6500, makeStoreFactory, now = Date.now, env = process.env,
|
|
157
|
-
deadlineAt: inheritedDeadlineAt = Infinity, signal } = {}) {
|
|
158
|
-
const suspended = automaticProgressionSuspensionResult(env);
|
|
159
|
-
if (suspended) return suspended;
|
|
160
|
-
const deadlineAt = Math.min(inheritedDeadlineAt, now() + budgetMs);
|
|
161
|
-
const checkDeadline = () => { if (signal?.aborted || now() >= deadlineAt) throw new Error('restore deadline exceeded'); };
|
|
162
|
-
checkDeadline();
|
|
163
|
-
const resolution = resolveProjectStore({ projectDir: job.originProjectDir, gitTimeoutMs: Math.max(1, Math.min(500, budgetMs)), deadlineAt });
|
|
164
|
-
const normalized = job.payload.normalizedTransition;
|
|
165
|
-
if (!normalized || normalized.observation?.authoritative !== false || !normalized.observation?.id
|
|
166
|
-
|| normalized.sourceIdentity?.checkoutPath !== resolution.checkoutRoot) throw new Error('invalid normalized transition binding');
|
|
167
|
-
const policy = resolveTurnDb({ projectDir: job.originProjectDir, brainHome: env.RUVNET_BRAIN_HOME || path.join(env.HOME || os.homedir(), '.cache', 'ruvnet-brain'), deadlineAt, signal });
|
|
168
|
-
checkDeadline();
|
|
169
|
-
if (policy.skipped) throw new Error(policy.skipped);
|
|
170
|
-
if (digestCanonical(privateTransitionObservation(normalized.observation, policy.contentPathExcludes, job.originProjectDir)) !== digestCanonical(normalized.observation)) throw new Error('content exclusions changed; immutable transition retained');
|
|
171
|
-
const snapshots = readHistory(resolution, { deadlineAt });
|
|
172
|
-
checkDeadline();
|
|
173
|
-
const restored = restoreProjectProgression(snapshots, { expectedProjectIdentity: resolution.projectIdentity });
|
|
174
|
-
if (snapshots.length && !restored.ok) {
|
|
175
|
-
throw new Error('nonempty transition journal has no coherent ancestry');
|
|
176
|
-
}
|
|
177
|
-
// A failed first store already fsynced a snapshot to the outbox. If startup/replay committed
|
|
178
|
-
// that snapshot before returning to its observation job, verify the same event rather than
|
|
179
|
-
// turn one observed action into a second event at a new sequence.
|
|
180
|
-
const dedupId = `${job.host}:${job.payload.session_id}:${normalized.observation.id}`;
|
|
181
|
-
const committed = snapshots.find((snapshot) => snapshot.dedupId === dedupId
|
|
182
|
-
&& validateProgressionSnapshot(snapshot, { expectedProjectIdentity: resolution.projectIdentity }).ok
|
|
183
|
-
&& digestCanonical(snapshot.sourceIdentity) === digestCanonical(normalized.sourceIdentity)
|
|
184
|
-
&& snapshot.completeProjectState.observations?.some((value) => digestCanonical(value) === digestCanonical(normalized.observation)));
|
|
185
|
-
if (committed) return { progressionCaptured: true, eventId: normalized.observation.id,
|
|
186
|
-
receipt: { eventKey: committed.eventKey, payloadDigest: committed.payloadDigest,
|
|
187
|
-
readbackDigest: committed.payloadDigest, readPath: 'canonical progression reader' } };
|
|
188
|
-
const progression = buildRestoredTransitionProgression({ resolution, observation: normalized.observation,
|
|
189
|
-
snapshots, sessionIdentity: job.payload.session_id, host: job.host,
|
|
190
|
-
sourceIdentity: normalized.sourceIdentity }, restored);
|
|
191
|
-
checkDeadline();
|
|
192
|
-
const result = capture(job.originProjectDir, job.event, { host: job.host, env, budgetMs: Math.max(0, deadlineAt - now()), deadlineAt, signal,
|
|
193
|
-
...(makeStoreFactory ? { makeStoreFactory } : {}), writeMetadata: false,
|
|
194
|
-
rawInput: JSON.stringify({ session_id: job.payload.session_id, hook_event_name: job.event, projectProgression: progression }),
|
|
195
|
-
captureTurn: () => ({ recorded: false, skipped: 'transition boundary' }),
|
|
196
|
-
captureEvents: () => ({ recorded: 0, skipped: 'transition boundary' }) });
|
|
197
|
-
return { ...result, eventId: normalized.observation.id };
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
export function runProjectTransitionHook(projectDir, event, { payload = {}, host = process.env.RUVNET_HOOK_HOST || 'claude',
|
|
201
|
-
readHistory = readTransitionHistory, capture = runSessionSnapshotHook, env = process.env } = {}) {
|
|
202
|
-
if (developmentHooksSuspended(projectDir)) return { state: 'skipped', reason: 'development hooks suspended' };
|
|
203
|
-
const suspended = automaticProgressionSuspensionResult(env, { state: 'suspended', reason: 'automatic project progression is operator-suspended' });
|
|
204
|
-
if (suspended) return suspended;
|
|
205
|
-
payload = normalizeHostEvent(payload);
|
|
206
|
-
let observation = normalizeTransition(payload, event, { host });
|
|
207
|
-
if (observation.skipped) return { state: 'skipped', reason: observation.skipped };
|
|
208
|
-
const brainHome = env.RUVNET_BRAIN_HOME || path.join(os.homedir(), '.cache', 'ruvnet-brain');
|
|
209
|
-
const consent = resolveTurnDb({ projectDir, brainHome, gitTimeoutMs: 500 });
|
|
210
|
-
if (consent.skipped) return { state: 'skipped', reason: consent.skipped };
|
|
211
|
-
observation = privateTransitionObservation(observation, consent.contentPathExcludes, projectDir, payload);
|
|
212
|
-
const resolution = resolveProjectStore({ projectDir, gitTimeoutMs: 500 });
|
|
213
|
-
if (!fs.existsSync(resolution.canonicalAgentDbPath)) return { state: 'skipped', reason: 'no adopted canonical store' };
|
|
214
|
-
const transportEvent = event === 'PostToolUseFailure' ? 'PostToolUse' : event;
|
|
215
|
-
// Fsync the selected observation BEFORE any history enumeration/merge. A deadline, corruption,
|
|
216
|
-
// or long-lived project must leave it pending rather than erase it or fabricate root ancestry.
|
|
217
|
-
const queued = queueCapture({ projectDir: resolution.projectRoot, originProjectDir: projectDir, env, event: transportEvent, host,
|
|
218
|
-
payload: { session_id: payload.session_id, hook_event_name: transportEvent,
|
|
219
|
-
normalizedTransition: { observation, sourceIdentity: observeTransitionSource(resolution, projectDir) } } });
|
|
220
|
-
if (!queued) return { state: 'degraded', reason: 'normalized observation queue unwritable', eventId: observation.id };
|
|
221
|
-
let result = null;
|
|
222
|
-
runOutboxReplay({ projectDir: resolution.projectRoot, env, budgetMs: Math.min(6500, effectiveBudgetMs(env)),
|
|
223
|
-
captureNormalized: (job, options) => captureNormalizedTransition(job, { ...options, env, readHistory, capture }),
|
|
224
|
-
onCaptured: (captured) => { if (captured?.eventId === observation.id) result = captured; } });
|
|
225
|
-
if (!result?.receipt) replayOutboxDetached({ projectDir: resolution.projectRoot, env });
|
|
226
|
-
return { state: result?.progressionCaptured && result.receipt ? 'committed' : 'pending', eventId: observation.id, result };
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
/** One pending-readback condition across prompt/tool boundaries and both CLI entrypoints. */
|
|
230
|
-
export function transitionPendingNotice(projectDir, payload, message) {
|
|
231
|
-
const { projectRoot } = resolveProjectStore({ projectDir, gitTimeoutMs: 500 });
|
|
232
|
-
return conditionNotice({ swarm: path.join(projectRoot, '.swarm'),
|
|
233
|
-
session: normalizeHostEvent(payload)?.session_id, condition: 'project-transition-pending-readback', message });
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
237
|
-
try {
|
|
238
|
-
const payload = JSON.parse(fs.readFileSync(0, 'utf8') || '{}');
|
|
239
|
-
const projectDir = payload.cwd || process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
240
|
-
const result = runProjectTransitionHook(projectDir, process.argv[2], { payload });
|
|
241
|
-
if (result.state === 'pending') {
|
|
242
|
-
const message = transitionPendingNotice(projectDir, payload, 'Project memory transition is pending; exact AgentDB readback was not verified at this boundary.');
|
|
243
|
-
if (message) process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: process.argv[2], additionalContext: message } }));
|
|
244
|
-
}
|
|
245
|
-
} catch { process.stdout.write(JSON.stringify({ systemMessage: 'Project memory transition capture degraded; exact readback was not verified.' })); }
|
|
246
|
-
}
|
|
@@ -1,105 +0,0 @@
|
|
|
1
|
-
import fs from 'node:fs';
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
|
|
4
|
-
export const SNAPSHOT_SCHEMA = 'ruvnet-brain.session-snapshot';
|
|
5
|
-
export const SNAPSHOT_VERSION = 1;
|
|
6
|
-
export const SNAPSHOT_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
|
7
|
-
|
|
8
|
-
const EVENTS = new Set(['PreCompact', 'PostCompact', 'SessionEnd']);
|
|
9
|
-
|
|
10
|
-
export function createSessionSnapshot({ event, capturedAt = new Date().toISOString() }) {
|
|
11
|
-
if (!EVENTS.has(event)) throw new Error(`unsupported snapshot event: ${event}`);
|
|
12
|
-
if (!Number.isFinite(Date.parse(capturedAt))) throw new Error('capturedAt must be an ISO-8601 timestamp');
|
|
13
|
-
return {
|
|
14
|
-
schema: SNAPSHOT_SCHEMA,
|
|
15
|
-
version: SNAPSHOT_VERSION,
|
|
16
|
-
capturedAt,
|
|
17
|
-
boundary: { event },
|
|
18
|
-
privacy: { rawTranscriptStored: false, credentialValuesStored: false },
|
|
19
|
-
};
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export function validateSessionSnapshot(value) {
|
|
23
|
-
return Boolean(value)
|
|
24
|
-
&& typeof value === 'object'
|
|
25
|
-
&& !Array.isArray(value)
|
|
26
|
-
&& value.schema === SNAPSHOT_SCHEMA
|
|
27
|
-
&& value.version === SNAPSHOT_VERSION
|
|
28
|
-
&& EVENTS.has(value.boundary?.event)
|
|
29
|
-
&& Number.isFinite(Date.parse(value.capturedAt))
|
|
30
|
-
&& value.privacy?.rawTranscriptStored === false
|
|
31
|
-
&& value.privacy?.credentialValuesStored === false;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function freshness(capturedAt, now) {
|
|
35
|
-
const age = now - Date.parse(capturedAt);
|
|
36
|
-
return age >= 0 && age <= SNAPSHOT_MAX_AGE_MS;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function canonical(projectDir, now) {
|
|
40
|
-
const file = path.join(projectDir, '.swarm', 'agentdb-sessions.jsonl');
|
|
41
|
-
if (!fs.existsSync(file)) return { values: [], malformed: false };
|
|
42
|
-
try {
|
|
43
|
-
const stat = fs.lstatSync(file);
|
|
44
|
-
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 4 * 1024 * 1024) {
|
|
45
|
-
return { values: [], malformed: true };
|
|
46
|
-
}
|
|
47
|
-
const lines = fs.readFileSync(file, 'utf8').trim().split('\n').filter(Boolean).slice(-128);
|
|
48
|
-
const values = [];
|
|
49
|
-
let malformed = false;
|
|
50
|
-
for (const line of lines) {
|
|
51
|
-
try {
|
|
52
|
-
const value = JSON.parse(line);
|
|
53
|
-
if (validateSessionSnapshot(value)) values.push({ kind: 'canonical', fresh: freshness(value.capturedAt, now), capturedAt: value.capturedAt });
|
|
54
|
-
else malformed = true;
|
|
55
|
-
} catch { malformed = true; }
|
|
56
|
-
}
|
|
57
|
-
return { values, malformed };
|
|
58
|
-
} catch { return { values: [], malformed: true }; }
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function legacy(projectDir, now) {
|
|
62
|
-
const values = [];
|
|
63
|
-
let malformed = false;
|
|
64
|
-
for (const root of ['.claude', '.claude-flow']) {
|
|
65
|
-
const directory = path.join(projectDir, root, 'sessions');
|
|
66
|
-
if (!fs.existsSync(directory)) continue;
|
|
67
|
-
let names;
|
|
68
|
-
try {
|
|
69
|
-
const stat = fs.lstatSync(directory);
|
|
70
|
-
// A malformed ROOT (a stray file or symlink where `sessions/` should be a directory) must not
|
|
71
|
-
// discard every OTHER root's evidence. This used to `return` here, so a bad `.claude/sessions`
|
|
72
|
-
// entry made a genuinely fresh `.claude-flow/sessions` legacy session invisible to the whole
|
|
73
|
-
// scan — `continue` so the loop still inspects the remaining roots, same as the readdirSync
|
|
74
|
-
// catch below already does for an unreadable directory.
|
|
75
|
-
if (!stat.isDirectory() || stat.isSymbolicLink()) { malformed = true; continue; }
|
|
76
|
-
names = fs.readdirSync(directory).filter((name) => /^session-.*\.json$/.test(name)).slice(-64);
|
|
77
|
-
} catch { malformed = true; continue; }
|
|
78
|
-
for (const name of names) {
|
|
79
|
-
try {
|
|
80
|
-
const file = path.join(directory, name);
|
|
81
|
-
const stat = fs.lstatSync(file);
|
|
82
|
-
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 1024 * 1024) { malformed = true; continue; }
|
|
83
|
-
const value = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
84
|
-
const capturedAt = value.endedAt || value.startedAt;
|
|
85
|
-
if (typeof value.id !== 'string' || !value.id || !value.context || !value.metrics || !Number.isFinite(Date.parse(capturedAt))) {
|
|
86
|
-
malformed = true;
|
|
87
|
-
continue;
|
|
88
|
-
}
|
|
89
|
-
values.push({ kind: 'legacy', fresh: freshness(capturedAt, now), capturedAt });
|
|
90
|
-
} catch { malformed = true; }
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
return { values, malformed };
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
export function inspectSessionSnapshots(projectDir, { now = Date.now() } = {}) {
|
|
97
|
-
const results = [canonical(projectDir, now), legacy(projectDir, now)];
|
|
98
|
-
const values = results.flatMap((result) => result.values);
|
|
99
|
-
const priority = { canonical: 0, legacy: 1 };
|
|
100
|
-
const fresh = values.filter((value) => value.fresh).sort((a, b) => priority[a.kind] - priority[b.kind] || Date.parse(b.capturedAt) - Date.parse(a.capturedAt))[0];
|
|
101
|
-
if (fresh) return fresh;
|
|
102
|
-
if (values.length) return values.sort((a, b) => priority[a.kind] - priority[b.kind] || Date.parse(b.capturedAt) - Date.parse(a.capturedAt))[0];
|
|
103
|
-
if (results.some((result) => result.malformed)) return { kind: 'malformed', fresh: false };
|
|
104
|
-
return { kind: 'absent', fresh: false };
|
|
105
|
-
}
|
|
@@ -1,108 +0,0 @@
|
|
|
1
|
-
import fs from 'node:fs';
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
import crypto from 'node:crypto';
|
|
4
|
-
|
|
5
|
-
export const digest = (value) => crypto.createHash('sha256').update(value).digest('hex');
|
|
6
|
-
export const turnQueueDirectory = (db) => path.join(path.dirname(db), 'turn-outbox');
|
|
7
|
-
function regular(file, directory = false) {
|
|
8
|
-
const stat = fs.lstatSync(file);
|
|
9
|
-
if (stat.isSymbolicLink() || (!directory && (!stat.isFile() || stat.nlink !== 1))
|
|
10
|
-
|| (directory && (!stat.isDirectory() || fs.realpathSync.native(file) !== file))) throw new Error('unsafe turn journal path');
|
|
11
|
-
}
|
|
12
|
-
// Node's Windows directory handles do not consistently support fsync. Never weaken
|
|
13
|
-
// file fsync or hide unexpected I/O errors; a missing namespace flush is reported.
|
|
14
|
-
export function syncTurnDirectory(dir, { platform = process.platform, io = fs } = {}) {
|
|
15
|
-
let fd;
|
|
16
|
-
try {
|
|
17
|
-
fd = io.openSync(dir, 'r'); io.fsyncSync(fd);
|
|
18
|
-
return { platform, directoryFsync: 'completed' };
|
|
19
|
-
} catch (error) {
|
|
20
|
-
if (platform !== 'win32' || !['EISDIR', 'EPERM', 'EACCES', 'EINVAL', 'ENOTSUP', 'EBADF'].includes(error.code)) throw error;
|
|
21
|
-
return { platform, directoryFsync: 'unavailable', reason: error.code,
|
|
22
|
-
limitation: 'file fsync remains required; directory-entry persistence across power loss is unproven' };
|
|
23
|
-
} finally { if (fd !== undefined) io.closeSync(fd); }
|
|
24
|
-
}
|
|
25
|
-
// Legacy recipes are data extraction inputs only, never executable commands.
|
|
26
|
-
export function storeData(step) {
|
|
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
|
-
const fields = {}; const aliases = { '-k': 'key', '--key': 'key', '--value': 'value', '-n': 'namespace', '--namespace': 'namespace', '--path': 'db', '--tags': 'tags', '--provenance': 'provenance' };
|
|
29
|
-
let strict = false; let native = false; let immutable = false;
|
|
30
|
-
for (let i = 2; i < step.args.length; i++) {
|
|
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; }
|
|
34
|
-
if (flag === '--no-upsert') { if (strict) throw new Error('duplicate turn store flag'); strict = true; continue; }
|
|
35
|
-
const field = aliases[flag];
|
|
36
|
-
if (!field || Object.hasOwn(fields, field) || typeof step.args[i + 1] !== 'string') throw new Error('unknown or duplicate turn store flag');
|
|
37
|
-
fields[field] = step.args[++i];
|
|
38
|
-
}
|
|
39
|
-
if (!fields.key || typeof fields.value !== 'string' || fields.namespace !== 'turns' || !path.isAbsolute(fields.db || '')) throw new Error('invalid turn store namespace or data');
|
|
40
|
-
return { key: fields.key, value: fields.value, db: fields.db };
|
|
41
|
-
}
|
|
42
|
-
const bindingOf = (step) => ({ projectRoot: step.projectRoot, projectDir: step.projectDir, rootIdentity: step.rootIdentity });
|
|
43
|
-
function validateBinding(binding) {
|
|
44
|
-
if (!binding || Object.keys(binding).some((key) => !['projectRoot', 'projectDir', 'rootIdentity'].includes(key))
|
|
45
|
-
|| !path.isAbsolute(binding.projectRoot || '') || !path.isAbsolute(binding.projectDir || '')
|
|
46
|
-
|| typeof binding.rootIdentity !== 'string' || !/^\d+:\d+$/.test(binding.rootIdentity)) throw new Error('invalid turn canonical binding');
|
|
47
|
-
}
|
|
48
|
-
export function journalTurn(step, db, key, { platform = process.platform, io = fs, onDurability } = {}) {
|
|
49
|
-
const dir = turnQueueDirectory(db);
|
|
50
|
-
fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); regular(dir, true);
|
|
51
|
-
const file = path.join(dir, `${digest(key)}.json`);
|
|
52
|
-
const data = storeData(step);
|
|
53
|
-
if (data.db !== db || data.key !== key) throw new Error('turn journal identity mismatch');
|
|
54
|
-
const binding = bindingOf(step); validateBinding(binding);
|
|
55
|
-
const record = { schemaVersion: 2, key, value: data.value, contentDigest: digest(data.value), consentScope: 'canonical-project-and-path', binding };
|
|
56
|
-
let created = false;
|
|
57
|
-
try {
|
|
58
|
-
const fd = io.openSync(file, 'wx', 0o600);
|
|
59
|
-
created = true;
|
|
60
|
-
try { io.writeFileSync(fd, JSON.stringify(record)); io.fsyncSync(fd); } finally { io.closeSync(fd); }
|
|
61
|
-
} catch (error) {
|
|
62
|
-
if (error.code !== 'EEXIST') throw error;
|
|
63
|
-
const previous = readJournal(file, db);
|
|
64
|
-
if (previous.key !== key || previous.contentDigest !== record.contentDigest) throw new Error('turn journal identity collision');
|
|
65
|
-
// Reflush an existing complete entry: its creator may have died before fsync.
|
|
66
|
-
const fd = io.openSync(file, 'r+');
|
|
67
|
-
try { io.fsyncSync(fd); } finally { io.closeSync(fd); }
|
|
68
|
-
}
|
|
69
|
-
const evidence = { fileFsync: 'completed', entry: created ? 'created-exclusively' : 'existing-exact-match-reflushed', ...syncTurnDirectory(dir, { platform, io }) };
|
|
70
|
-
if (onDurability) onDurability(evidence);
|
|
71
|
-
return file;
|
|
72
|
-
}
|
|
73
|
-
export function readJournal(file, db) {
|
|
74
|
-
const dir = turnQueueDirectory(db); regular(dir, true);
|
|
75
|
-
if (path.dirname(file) !== dir) throw new Error('foreign turn journal rejected');
|
|
76
|
-
regular(file);
|
|
77
|
-
const record = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
78
|
-
let normalized = record;
|
|
79
|
-
if (record.schemaVersion === 1) {
|
|
80
|
-
const data = storeData(record.step);
|
|
81
|
-
if (!record.step.args.includes('--no-upsert')) throw new Error('legacy turn journal requires strict insert');
|
|
82
|
-
if (data.db !== db || data.key !== record.key) throw new Error('legacy turn journal identity mismatch');
|
|
83
|
-
// Legacy job-selected consent settings are never consumed; caller policy governs replay.
|
|
84
|
-
normalized = { schemaVersion: 2, key: data.key, value: data.value, contentDigest: record.contentDigest,
|
|
85
|
-
consentScope: record.consentScope, binding: bindingOf(record.step), legacyBrainHome: record.step.brainHome };
|
|
86
|
-
} else if (Object.keys(record).some((key) => !['schemaVersion', 'key', 'value', 'contentDigest', 'consentScope', 'binding'].includes(key))) throw new Error('unknown turn journal field');
|
|
87
|
-
validateBinding(normalized.binding);
|
|
88
|
-
if (normalized.schemaVersion !== 2 || normalized.consentScope !== 'canonical-project-and-path'
|
|
89
|
-
|| typeof normalized.key !== 'string' || !normalized.key.startsWith('turn-') || normalized.key.length > 250
|
|
90
|
-
|| typeof normalized.value !== 'string' || normalized.value.length > 4000
|
|
91
|
-
|| digest(normalized.value) !== normalized.contentDigest
|
|
92
|
-
|| path.basename(file) !== `${digest(normalized.key)}.json`) throw new Error('invalid turn journal');
|
|
93
|
-
return normalized;
|
|
94
|
-
}
|
|
95
|
-
export function pendingTurnFiles(db, limit = 10) {
|
|
96
|
-
const dir = turnQueueDirectory(db);
|
|
97
|
-
try { regular(dir, true); } catch (error) { if (error.code === 'ENOENT') return []; throw error; }
|
|
98
|
-
return fs.readdirSync(dir).filter((name) => /^[a-f0-9]{64}\.json$/.test(name)).sort().slice(0, Math.min(25, Math.max(0, limit))).map((name) => path.join(dir, name));
|
|
99
|
-
}
|
|
100
|
-
export function acknowledgeJournal(file, db, options = {}) {
|
|
101
|
-
readJournal(file, db); fs.unlinkSync(file); return syncTurnDirectory(path.dirname(file), options);
|
|
102
|
-
}
|
|
103
|
-
export function appendReceipt(file, row) {
|
|
104
|
-
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
105
|
-
if (fs.existsSync(file)) regular(file);
|
|
106
|
-
const fd = fs.openSync(file, 'a', 0o600);
|
|
107
|
-
try { fs.writeFileSync(fd, `${JSON.stringify(row)}\n`); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
|
|
108
|
-
}
|
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
/** Source-bound synthetic validation comparison; deliberately excludes database/host I/O. */
|
|
2
|
-
import fs from 'node:fs';
|
|
3
|
-
import path from 'node:path';
|
|
4
|
-
import crypto from 'node:crypto';
|
|
5
|
-
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
6
|
-
import { performance } from 'node:perf_hooks';
|
|
7
|
-
import { createProgressionSnapshot, restoreProjectProgression, digestCanonical } from '../../plugin/scripts/project-progression-contract.mjs';
|
|
8
|
-
|
|
9
|
-
const flag = (name) => process.argv[process.argv.indexOf(name) + 1];
|
|
10
|
-
if (!process.argv.includes('--baseline-module') || !process.argv.includes('--output')) {
|
|
11
|
-
throw new Error('Require --baseline-module <unchanged contract module> --output <receipt.json>');
|
|
12
|
-
}
|
|
13
|
-
const baselinePath = path.resolve(flag('--baseline-module'));
|
|
14
|
-
const baseline = await import(pathToFileURL(baselinePath).href);
|
|
15
|
-
const currentPath = fileURLToPath(new URL('../../plugin/scripts/project-progression-contract.mjs', import.meta.url));
|
|
16
|
-
const hash = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
|
17
|
-
const sizes = (process.argv.includes('--sizes') ? flag('--sizes') : '100,400,1000').split(',').map(Number);
|
|
18
|
-
if (!sizes.every(n => Number.isSafeInteger(n) && n > 0 && n <= 10_000)) throw new Error('sizes must be positive integers <= 10000');
|
|
19
|
-
const projectIdentity = { id: 'synthetic-validation', canonicalAgentDbPath: '/synthetic/project/.swarm/memory.db' };
|
|
20
|
-
const sourceIdentity = { checkoutPath: '/synthetic/project', worktreeId: 'primary', branch: 'main', head: 'a'.repeat(40),
|
|
21
|
-
trackedDigest: 'b'.repeat(64), untrackedDigest: 'c'.repeat(64), dirtyTreeDigest: 'd'.repeat(64) };
|
|
22
|
-
const empty = Object.fromEntries(['plan', 'completed', 'inProgress', 'blockers', 'failures', 'decisions',
|
|
23
|
-
'changedFiles', 'commands', 'proofArtifacts', 'untested', 'resumeConflicts'].map(k => [k, []]));
|
|
24
|
-
const receipt = { scope: 'Synthetic CPU-only complete-history validation; no native capture/restore deadline claim',
|
|
25
|
-
node: process.version, platform: process.platform, arch: process.arch,
|
|
26
|
-
sources: { baseline: { path: baselinePath, sha256: hash(baselinePath) }, candidate: { path: currentPath, sha256: hash(currentPath) } },
|
|
27
|
-
workloads: [] };
|
|
28
|
-
for (const size of sizes) {
|
|
29
|
-
const snapshots = [], observations = [];
|
|
30
|
-
let parents = [];
|
|
31
|
-
for (let sequence = 1; sequence <= size; sequence++) {
|
|
32
|
-
observations.push({ id: `event-${sequence}`, occurredAt: '2026-10-05T00:00:00.000Z', trigger: 'PostToolUse',
|
|
33
|
-
kind: 'tool-observation', source: 'host-observation', authoritative: false, outcome: 'success', exitCode: 0,
|
|
34
|
-
intent: { action: 'verify', subjects: ['project memory'] }, tool: 'exec_command' });
|
|
35
|
-
const snapshot = createProgressionSnapshot({ projectIdentity, sourceIdentity,
|
|
36
|
-
hostIdentity: { host: 'codex', adapterVersion: 'synthetic-benchmark' }, sessionIdentity: 'synthetic',
|
|
37
|
-
sequence, parentEventKeys: parents, occurredAt: '2026-10-05T00:00:00.000Z', trigger: 'PostToolUse', dedupId: `event-${sequence}`,
|
|
38
|
-
completeProjectState: { ...empty, currentGoal: 'Preserve complete history', nextAction: 'Validate all evidence',
|
|
39
|
-
acceptanceContract: { required: ['exact equality'] }, activeProcess: 'verification', activeStep: 'PostToolUse',
|
|
40
|
-
observations: [...observations], commands: [...observations] } });
|
|
41
|
-
snapshots.push(snapshot); parents = [snapshot.eventKey];
|
|
42
|
-
}
|
|
43
|
-
const run = (restore) => {
|
|
44
|
-
const started = performance.now();
|
|
45
|
-
const value = restore(snapshots, { expectedProjectIdentity: projectIdentity });
|
|
46
|
-
return { ms: performance.now() - started, value };
|
|
47
|
-
};
|
|
48
|
-
const original = run(baseline.restoreProjectProgression);
|
|
49
|
-
const candidate = run(restoreProjectProgression);
|
|
50
|
-
if (JSON.stringify(original.value) !== JSON.stringify(candidate.value)) throw new Error('full restored result differs from baseline');
|
|
51
|
-
// Check a historical mutation whose digest was not recomputed, not just the current head.
|
|
52
|
-
const oldGoal = snapshots[0].completeProjectState.currentGoal;
|
|
53
|
-
snapshots[0].completeProjectState.currentGoal = 'tampered historical state';
|
|
54
|
-
const mutatedOriginal = baseline.restoreProjectProgression(snapshots, { expectedProjectIdentity: projectIdentity });
|
|
55
|
-
const mutatedCandidate = restoreProjectProgression(snapshots, { expectedProjectIdentity: projectIdentity });
|
|
56
|
-
if (JSON.stringify(mutatedOriginal) !== JSON.stringify(mutatedCandidate) || mutatedCandidate.ok) {
|
|
57
|
-
throw new Error('historical mutation negative semantics differ');
|
|
58
|
-
}
|
|
59
|
-
snapshots[0].completeProjectState.currentGoal = oldGoal;
|
|
60
|
-
receipt.workloads.push({ snapshots: size, bytes: snapshots.reduce((n, s) => n + Buffer.byteLength(JSON.stringify(s)), 0),
|
|
61
|
-
baselineMs: original.ms, candidateMs: candidate.ms, resultDigest: digestCanonical(candidate.value),
|
|
62
|
-
fullResultEqual: true, historicalMutationRejected: true, observations: candidate.value.state.observations.length,
|
|
63
|
-
commands: candidate.value.state.commands.length, peakRssKiB: process.resourceUsage().maxRSS });
|
|
64
|
-
fs.writeFileSync(path.resolve(flag('--output')), JSON.stringify(receipt, null, 2));
|
|
65
|
-
process.stdout.write(`${JSON.stringify(receipt.workloads.at(-1))}\n`);
|
|
66
|
-
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export * from '../plugin/scripts/session-snapshot-contract.mjs';
|