ruvnet-brain 4.5.5 → 4.5.7
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 +24 -6
- package/config/model-router/claude-terminal-mod/.claude-plugin/plugin.json +1 -0
- package/config/model-router/claude-terminal-mod/README.md +22 -0
- package/config/model-router/claude-terminal-mod/hooks/hooks.json +1 -0
- package/config/model-router/claude-terminal-mod/hooks/policy.default.mjs +2 -0
- package/config/model-router/claude-terminal-mod/hooks/register.js +66 -0
- package/config/model-router/claude-terminal-mod/hooks/routing.js +55 -0
- package/config/model-router/claude-terminal-mod/hooks/runtime.js +2 -0
- package/config/model-router/claude-terminal-mod/tests/native.test.ts +86 -0
- package/config/model-router/policy.default.mjs +4 -1
- package/kb/forge-update.mjs +50 -9
- package/package.json +4 -3
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/commands/configure.md +13 -1
- package/plugin/hooks/hook-contracts.json +1 -1
- package/plugin/mcp/managed-cli-generation.mjs +91 -0
- package/plugin/mcp/managed-cli-interface.mjs +45 -16
- package/plugin/mcp/server.mjs +3 -2
- package/plugin/scripts/capability-claim-evidence.mjs +9 -2
- package/plugin/scripts/continuation-gate.mjs +3 -1
- package/plugin/scripts/continuity-journal.mjs +11 -11
- package/plugin/scripts/hook-shim.mjs +2 -1
- package/plugin/scripts/kb-copy-proof.mjs +4 -3
- package/plugin/scripts/nightly-scheduler.mjs +6 -2
- package/plugin/scripts/project-capture-queue.mjs +23 -8
- package/plugin/scripts/project-progression-hook.mjs +16 -7
- package/plugin/scripts/project-progression-outbox.mjs +116 -19
- package/plugin/scripts/project-progression-producer.mjs +12 -1
- package/plugin/scripts/project-progression-session-start.mjs +10 -0
- package/plugin/scripts/project-progression-store.mjs +53 -1
- package/plugin/scripts/project-progression-suspension.mjs +60 -0
- package/plugin/scripts/project-transition-hook.mjs +44 -16
- package/plugin/scripts/session-snapshot-hook.mjs +16 -8
- package/plugin/scripts/turn-outcome-capture.mjs +7 -3
- package/scripts/claude-terminal-mod.mjs +89 -0
- package/scripts/model-routing-gateway.mjs +47 -14
- package/scripts/model-routing-launchers.mjs +36 -1
- package/scripts/model-terminal-gateway.mjs +310 -0
- package/scripts/model-terminal-launchers.mjs +300 -0
- package/scripts/release-qualification-contract.mjs +37 -6
- package/scripts/release-qualification.mjs +9 -6
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// DISTINCT-FROM hook-shim.mjs: managed execution fails closed; advisory hook fallback cannot
|
|
2
|
+
// authorize a command. The protocol shell owns declarations, each request owns one generation.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
7
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
8
|
+
|
|
9
|
+
const LOCAL_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
10
|
+
const digest = (value) => createHash('sha256').update(value).digest('hex');
|
|
11
|
+
const contained = (root, file) => file.startsWith(`${root}${path.sep}`);
|
|
12
|
+
|
|
13
|
+
export function managedGenerationIdentity(root, { version, generation = 'source' } = {}) {
|
|
14
|
+
root = fs.realpathSync(root);
|
|
15
|
+
const manifestPath = path.join(root, '.claude-plugin', 'plugin.json');
|
|
16
|
+
const handlerPath = path.join(root, 'mcp', 'managed-cli-interface.mjs');
|
|
17
|
+
for (const file of [manifestPath, handlerPath]) {
|
|
18
|
+
if (!fs.lstatSync(file).isFile() || !contained(root, fs.realpathSync(file))) {
|
|
19
|
+
throw new Error('managed generation resource is not a contained regular file');
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
const manifestBytes = fs.readFileSync(manifestPath);
|
|
23
|
+
const manifest = JSON.parse(manifestBytes);
|
|
24
|
+
if (manifest.name !== 'ruvnet-brain' || typeof manifest.version !== 'string'
|
|
25
|
+
|| !/^\d+\.\d+\.\d+(?:[-+][a-zA-Z0-9.+-]+)?$/.test(manifest.version)
|
|
26
|
+
|| (version !== undefined && manifest.version !== version)) {
|
|
27
|
+
throw new Error('managed generation manifest name/version mismatch');
|
|
28
|
+
}
|
|
29
|
+
const sourceDigest = digest(Buffer.concat([manifestBytes, fs.readFileSync(handlerPath)]));
|
|
30
|
+
return Object.freeze({ root, handlerPath, version: manifest.version, generation, sourceDigest,
|
|
31
|
+
binding: digest(JSON.stringify([root, manifest.version, generation, sourceDigest])) });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function selectGeneration(env, fallbackRoot) {
|
|
35
|
+
const home = env.HOME || os.homedir();
|
|
36
|
+
const brainHome = env.RUVNET_BRAIN_HOME || path.join(home, '.cache', 'ruvnet-brain');
|
|
37
|
+
let activeBytes;
|
|
38
|
+
try { activeBytes = fs.readFileSync(path.join(brainHome, 'active.json'), 'utf8'); }
|
|
39
|
+
catch (error) {
|
|
40
|
+
if (error.code !== 'ENOENT') throw error;
|
|
41
|
+
// A copied persistent shell must never manufacture an active generation from its resources.
|
|
42
|
+
if (path.resolve(fallbackRoot) === path.join(home, '.claude', 'ruvnet-brain')) {
|
|
43
|
+
throw new Error('managed active generation unavailable');
|
|
44
|
+
}
|
|
45
|
+
return { ...managedGenerationIdentity(fallbackRoot), brainHome, leased: false };
|
|
46
|
+
}
|
|
47
|
+
const active = JSON.parse(activeBytes);
|
|
48
|
+
if (typeof active.codeRoot !== 'string' || typeof active.version !== 'string'
|
|
49
|
+
|| !Number.isSafeInteger(active.generation) || active.generation < 0) {
|
|
50
|
+
throw new Error('managed active generation descriptor invalid');
|
|
51
|
+
}
|
|
52
|
+
const versions = fs.realpathSync(path.join(brainHome, 'versions'));
|
|
53
|
+
const root = fs.realpathSync(path.resolve(brainHome, active.codeRoot));
|
|
54
|
+
if (!contained(versions, root) || path.basename(root) !== active.version) {
|
|
55
|
+
throw new Error('managed active generation escapes its version tree');
|
|
56
|
+
}
|
|
57
|
+
return { ...managedGenerationIdentity(root, active), brainHome, leased: true };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function createManagedCliDispatcher({ fallbackRoot = LOCAL_ROOT,
|
|
61
|
+
importModule = (url) => import(url) } = {}) {
|
|
62
|
+
const pins = new Map();
|
|
63
|
+
return async function dispatchManagedCli(toolName, args, env = process.env,
|
|
64
|
+
fetchImpl = globalThis.fetch, lifecycle = {}) {
|
|
65
|
+
let lease;
|
|
66
|
+
try {
|
|
67
|
+
const selected = selectGeneration(env, fallbackRoot);
|
|
68
|
+
// ESM caches by URL. A changed file at the same immutable path must never be blessed as
|
|
69
|
+
// new code while execution still uses the old cached module (nor can query strings fix dependencies).
|
|
70
|
+
const pinned = pins.get(selected.root);
|
|
71
|
+
if (pinned && pinned !== selected.sourceDigest) throw new Error('managed immutable generation changed; restart required');
|
|
72
|
+
pins.set(selected.root, selected.sourceDigest);
|
|
73
|
+
if (selected.leased) {
|
|
74
|
+
const dir = path.join(selected.brainHome, 'leases');
|
|
75
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
76
|
+
lease = path.join(dir, `mcp-managed-${process.pid}-${randomUUID()}.json`);
|
|
77
|
+
fs.writeFileSync(lease, JSON.stringify({ pid: process.pid, version: selected.version, at: new Date().toISOString() }), { flag: 'wx', mode: 0o600 });
|
|
78
|
+
}
|
|
79
|
+
const handler = await importModule(pathToFileURL(selected.handlerPath).href);
|
|
80
|
+
if (typeof handler.callManagedCli !== 'function') throw new Error('managed generation handler unavailable');
|
|
81
|
+
return await handler.callManagedCli(toolName, args, env, fetchImpl,
|
|
82
|
+
{ ...lifecycle, generationBinding: selected.binding });
|
|
83
|
+
} catch (error) {
|
|
84
|
+
return { content: [{ type: 'text', text: `managed generation unavailable: ${error.message}` }], isError: true };
|
|
85
|
+
} finally {
|
|
86
|
+
if (lease) { try { fs.unlinkSync(lease); } catch { /* lease expiry preserves interrupted calls */ } }
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export const dispatchManagedCli = createManagedCliDispatcher();
|
|
@@ -2,11 +2,14 @@ import { spawn } from 'node:child_process';
|
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { managedGenerationIdentity } from './managed-cli-generation.mjs';
|
|
5
7
|
import { loadRuntimePreferences, runtimeChildEnv } from '../scripts/runtime-preferences.mjs';
|
|
6
8
|
import { projectDirectory } from '../scripts/project-identity.mjs';
|
|
7
9
|
import { recordManagedCliObservation, recordRegistryLatestObservation } from '../scripts/capability-claim-evidence.mjs';
|
|
8
10
|
import { runSessionSnapshotHook } from '../scripts/session-snapshot-hook.mjs';
|
|
9
11
|
import { resolveProjectStore } from '../scripts/project-store-resolver.mjs';
|
|
12
|
+
import { isAutomaticProgressionSuspension } from '../scripts/project-progression-suspension.mjs';
|
|
10
13
|
|
|
11
14
|
export const MANAGED_EXECUTABLES = Object.freeze([
|
|
12
15
|
'ruflo',
|
|
@@ -34,6 +37,12 @@ const MAX_ARG_BYTES = 8192;
|
|
|
34
37
|
const DEFAULT_FRESH_MS = 24 * 60 * 60 * 1000;
|
|
35
38
|
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
36
39
|
const MAX_OUTPUT_BYTES = 1024 * 1024;
|
|
40
|
+
// Bind direct calls to the module loaded at process start, too. Missing shell resources do not
|
|
41
|
+
// prevent declarations from loading; the active dispatcher supplies the execution binding.
|
|
42
|
+
const LOCAL_BINDING = (() => {
|
|
43
|
+
try { return managedGenerationIdentity(path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')).binding; }
|
|
44
|
+
catch { return null; }
|
|
45
|
+
})();
|
|
37
46
|
|
|
38
47
|
const executableSchema = {
|
|
39
48
|
type: 'string',
|
|
@@ -139,27 +148,32 @@ function stateDir(env) {
|
|
|
139
148
|
return env.RUVNET_BRAIN_HELP_READ_DIR || path.join(brainHome, 'help-read');
|
|
140
149
|
}
|
|
141
150
|
|
|
142
|
-
function freshStamp(executable, argv, env, now = Date.now()) {
|
|
151
|
+
function freshStamp(executable, argv, env, binding, now = Date.now()) {
|
|
143
152
|
const stamp = path.join(stateDir(env), helpKey(executable, argv));
|
|
144
|
-
let stat;
|
|
153
|
+
let stat; let readAt;
|
|
145
154
|
try {
|
|
146
|
-
stat = fs.
|
|
155
|
+
stat = fs.lstatSync(stamp);
|
|
156
|
+
if (!stat.isFile()) return false;
|
|
157
|
+
const receipt = JSON.parse(fs.readFileSync(stamp, 'utf8'));
|
|
158
|
+
if (receipt.schemaVersion !== 1 || receipt.generation !== binding
|
|
159
|
+
|| !Number.isFinite(receipt.readAt) || receipt.readAt > now) return false;
|
|
160
|
+
readAt = receipt.readAt;
|
|
147
161
|
} catch {
|
|
148
162
|
return false;
|
|
149
163
|
}
|
|
150
164
|
const configured = Number(env.RUVNET_BRAIN_HELP_MAX_AGE_MS);
|
|
151
165
|
const maxAge = Number.isFinite(configured) && configured > 0 ? configured : DEFAULT_FRESH_MS;
|
|
152
|
-
return now - stat.mtimeMs < maxAge;
|
|
166
|
+
return now - stat.mtimeMs < maxAge && now - readAt < maxAge;
|
|
153
167
|
}
|
|
154
168
|
|
|
155
|
-
function writeStamps(executable, argv, env) {
|
|
169
|
+
function writeStamps(executable, argv, env, binding) {
|
|
156
170
|
const dir = stateDir(env);
|
|
157
171
|
fs.mkdirSync(dir, { recursive: true });
|
|
158
172
|
for (const key of stampKeysForHelp(executable, argv)) {
|
|
159
173
|
const stamp = path.join(dir, key);
|
|
160
174
|
const temporary = path.join(dir, `.${key}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`);
|
|
161
175
|
try {
|
|
162
|
-
fs.writeFileSync(temporary,
|
|
176
|
+
fs.writeFileSync(temporary, JSON.stringify({ schemaVersion: 1, generation: binding, readAt: Date.now() }), { flag: 'wx', mode: 0o600 });
|
|
163
177
|
fs.renameSync(temporary, stamp);
|
|
164
178
|
} finally {
|
|
165
179
|
try { fs.unlinkSync(temporary); } catch { /* renamed or never created */ }
|
|
@@ -253,9 +267,10 @@ export function normalizeManagedExecution(execution) {
|
|
|
253
267
|
const output = [execution?.stdout, execution?.stderr].filter(Boolean)
|
|
254
268
|
.join(execution?.stdout && execution?.stderr ? '\n' : '');
|
|
255
269
|
const contradictoryFailure = /(?:^|\n)\s*(?:❌|\[ERROR\])|invalid pragma command|key not found/i.test(output);
|
|
270
|
+
if (execution?.error) return { outcome: 'failure', output, contradictoryFailure };
|
|
256
271
|
if (execution?.signal) return { outcome: 'interrupted', output, signal: execution.signal, contradictoryFailure };
|
|
257
|
-
if (
|
|
258
|
-
return { outcome:
|
|
272
|
+
if (contradictoryFailure || execution?.code === null || execution?.code === undefined) {
|
|
273
|
+
return { outcome: contradictoryFailure ? 'failure' : 'unknown', output, contradictoryFailure };
|
|
259
274
|
}
|
|
260
275
|
return { outcome: execution.code === 0 ? 'success' : 'failure', output, contradictoryFailure };
|
|
261
276
|
}
|
|
@@ -290,27 +305,34 @@ function managedProgressionCapture({ executable, argv, projectRoot, env, event,
|
|
|
290
305
|
},
|
|
291
306
|
} : {}),
|
|
292
307
|
};
|
|
293
|
-
|
|
308
|
+
const result = runSessionSnapshotHook(projectRoot, event, { rawInput: JSON.stringify(payload), host, env });
|
|
309
|
+
return isAutomaticProgressionSuspension(result) ? result : { adopted: true, ...result };
|
|
294
310
|
}
|
|
295
311
|
|
|
296
312
|
function resultOf(executable, argv, result) {
|
|
297
313
|
const output = [result.stdout, result.stderr].filter(Boolean).join(result.stdout && result.stderr ? '\n' : '');
|
|
298
314
|
const normalized = normalizeManagedExecution(result);
|
|
315
|
+
const terminal = { outcome: normalized.outcome, code: result.code ?? null, signal: result.signal ?? null,
|
|
316
|
+
error: result.error ?? null, stdout: result.stdout ?? '', stderr: result.stderr ?? '' };
|
|
299
317
|
if (normalized.outcome !== 'success') {
|
|
300
|
-
const reason = result.error || (
|
|
318
|
+
const reason = result.error || (result.signal ? `signal ${result.signal}` : result.code != null && result.code !== 0 ? `exit ${result.code}` : normalized.contradictoryFailure ? 'fatal output without successful completion' : 'no terminal exit status');
|
|
301
319
|
return {
|
|
302
|
-
content: [{ type: 'text', text: output
|
|
320
|
+
content: [{ type: 'text', text: [output, `${executable} ${argv.join(' ')} failed: ${reason}`].filter(Boolean).join('\n') }],
|
|
321
|
+
structuredContent: terminal,
|
|
303
322
|
isError: true,
|
|
304
323
|
};
|
|
305
324
|
}
|
|
306
325
|
return {
|
|
307
326
|
content: [{ type: 'text', text: output || `${executable} ${argv.join(' ')} completed successfully` }],
|
|
327
|
+
structuredContent: terminal,
|
|
308
328
|
isError: false,
|
|
309
329
|
};
|
|
310
330
|
}
|
|
311
331
|
|
|
312
332
|
export async function callManagedCli(toolName, args, env = process.env, fetchImpl = globalThis.fetch, lifecycle = {}) {
|
|
313
333
|
try {
|
|
334
|
+
const binding = lifecycle.generationBinding || LOCAL_BINDING;
|
|
335
|
+
if (!binding) throw new Error('managed generation identity unavailable');
|
|
314
336
|
const executable = assertExecutable(args?.executable);
|
|
315
337
|
const argv = literalArgv(args?.argv ?? []);
|
|
316
338
|
const projectRoot = env.RUVNET_BRAIN_PROJECT_DIR || projectDirectory({ env });
|
|
@@ -339,13 +361,13 @@ export async function callManagedCli(toolName, args, env = process.env, fetchImp
|
|
|
339
361
|
stampKeysForHelp(executable, argv);
|
|
340
362
|
const commandArgv = [...argv, '--help'];
|
|
341
363
|
const execution = await execute(executable, commandArgv, env);
|
|
342
|
-
if (execution.
|
|
364
|
+
if (normalizeManagedExecution(execution).outcome === 'success') writeStamps(executable, argv, env, binding);
|
|
343
365
|
recordManagedCliObservation({ toolName, executable, argv: commandArgv, execution, env });
|
|
344
366
|
return resultOf(executable, commandArgv, execution);
|
|
345
367
|
}
|
|
346
368
|
|
|
347
369
|
if (toolName === 'ruvnet_cli_run') {
|
|
348
|
-
if (!freshStamp(executable, argv, env)) {
|
|
370
|
+
if (!freshStamp(executable, argv, env, binding)) {
|
|
349
371
|
const key = helpKey(executable, argv).replaceAll('.', ' ');
|
|
350
372
|
return {
|
|
351
373
|
content: [{ type: 'text', text: `Read the interface first with ruvnet_cli_help for: ${key}` }],
|
|
@@ -386,7 +408,8 @@ export async function callManagedCli(toolName, args, env = process.env, fetchImp
|
|
|
386
408
|
: env;
|
|
387
409
|
const capture = typeof lifecycle.capture === 'function' ? lifecycle.capture : managedProgressionCapture;
|
|
388
410
|
const beforeCapture = await capture({ executable, argv, projectRoot, env: childEnv, event: 'PreToolUse' });
|
|
389
|
-
if (beforeCapture?.error || (beforeCapture?.adopted && beforeCapture.progressionCaptured !== true
|
|
411
|
+
if (beforeCapture?.error || (beforeCapture?.adopted && beforeCapture.progressionCaptured !== true
|
|
412
|
+
&& !isAutomaticProgressionSuspension(beforeCapture))) {
|
|
390
413
|
const reason = beforeCapture.error || beforeCapture.skipped || 'pre-execution progression was not durably captured';
|
|
391
414
|
return { content: [{ type: 'text', text: `managed execution refused: ${reason}` }], isError: true };
|
|
392
415
|
}
|
|
@@ -400,13 +423,19 @@ export async function callManagedCli(toolName, args, env = process.env, fetchImp
|
|
|
400
423
|
await lifecycle.afterExecute({ executable, argv, projectRoot, env: childEnv, execution, normalized });
|
|
401
424
|
}
|
|
402
425
|
recordManagedCliObservation({ toolName, executable, argv, execution, env });
|
|
403
|
-
if (afterCapture?.adopted && afterCapture.progressionCaptured !== true
|
|
426
|
+
if (afterCapture?.adopted && afterCapture.progressionCaptured !== true
|
|
427
|
+
&& !isAutomaticProgressionSuspension(afterCapture)) {
|
|
404
428
|
return {
|
|
405
429
|
content: [{ type: 'text', text: `managed command ${normalized.outcome}; continuity result was not durably captured: ${afterCapture.skipped || 'unknown reason'}` }],
|
|
406
430
|
isError: true,
|
|
407
431
|
};
|
|
408
432
|
}
|
|
409
|
-
|
|
433
|
+
const result = resultOf(executable, argv, execution);
|
|
434
|
+
if (isAutomaticProgressionSuspension(beforeCapture) || isAutomaticProgressionSuspension(afterCapture)) {
|
|
435
|
+
result.content.push({ type: 'text', text: 'Automatic project progression is operator-suspended; suspended captures produced no progression receipt. Ordinary AgentDB memory and explicit checkpoints remain available.' });
|
|
436
|
+
result.structuredContent.continuity = 'operator-suspended';
|
|
437
|
+
}
|
|
438
|
+
return result;
|
|
410
439
|
}
|
|
411
440
|
|
|
412
441
|
return {
|
package/plugin/mcp/server.mjs
CHANGED
|
@@ -35,7 +35,8 @@ import os from 'node:os';
|
|
|
35
35
|
import readline from 'node:readline';
|
|
36
36
|
import { fileURLToPath } from 'node:url';
|
|
37
37
|
import { writeOwn as writeOwnReadiness } from '../scripts/mcp-readiness.mjs';
|
|
38
|
-
import {
|
|
38
|
+
import { MANAGED_CLI_TOOLS } from './managed-cli-interface.mjs';
|
|
39
|
+
import { dispatchManagedCli } from './managed-cli-generation.mjs';
|
|
39
40
|
import { unmountedNotice } from '../scripts/brain-location.mjs';
|
|
40
41
|
|
|
41
42
|
const BRAIN_HOME = process.env.RUVNET_BRAIN_HOME || path.join(os.homedir(), '.cache', 'ruvnet-brain');
|
|
@@ -362,7 +363,7 @@ async function handleClient(msg) {
|
|
|
362
363
|
case 'tools/call': {
|
|
363
364
|
if (params?.name === 'ruvnet_cli_help' || params?.name === 'ruvnet_cli_run'
|
|
364
365
|
|| params?.name === 'ruvnet_registry_latest') {
|
|
365
|
-
return clientOk(id, await
|
|
366
|
+
return clientOk(id, await dispatchManagedCli(params.name, params.arguments || {}));
|
|
366
367
|
}
|
|
367
368
|
if (params?.name !== 'search_ruvnet') return clientErr(id, -32602, `unknown tool: ${params?.name}`);
|
|
368
369
|
refreshLease();
|
|
@@ -3,6 +3,7 @@ import crypto from 'node:crypto';
|
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import os from 'node:os';
|
|
5
5
|
import path from 'node:path';
|
|
6
|
+
import { redactText } from './continuity-events.mjs';
|
|
6
7
|
|
|
7
8
|
const HEX40 = /^[a-f0-9]{40}$/;
|
|
8
9
|
const HEX64 = /^[a-f0-9]{64}$/;
|
|
@@ -157,7 +158,13 @@ export function recordManagedCliObservation({ toolName, executable, argv, execut
|
|
|
157
158
|
observedAt = new Date().toISOString() } = {}) {
|
|
158
159
|
try {
|
|
159
160
|
const output = [execution?.stdout, execution?.stderr].filter(Boolean).join('\n');
|
|
160
|
-
const
|
|
161
|
+
const contradictoryFailure = /(?:^|\n)\s*(?:❌|\[ERROR\])|invalid pragma command|key not found/i.test(output);
|
|
162
|
+
const reachable = execution?.code === 0 && !execution?.error && !execution?.signal && !contradictoryFailure;
|
|
163
|
+
const outcome = execution?.error ? 'failure' : execution?.signal ? 'interrupted' : contradictoryFailure ? 'failure'
|
|
164
|
+
: Number.isSafeInteger(execution?.code) ? execution.code === 0 ? 'success' : 'failure' : 'unknown';
|
|
165
|
+
const terminal = { outcome, exitCode: Number.isSafeInteger(execution?.code) ? execution.code : null,
|
|
166
|
+
error: typeof execution?.error === 'string' ? redactText(execution.error).slice(0, 4096) : null,
|
|
167
|
+
signal: typeof execution?.signal === 'string' ? redactText(execution.signal).slice(0, 100) : null };
|
|
161
168
|
const version = VERSION.exec(output)?.[1] || null;
|
|
162
169
|
let observationClass = null;
|
|
163
170
|
let fields = {};
|
|
@@ -169,7 +176,7 @@ export function recordManagedCliObservation({ toolName, executable, argv, execut
|
|
|
169
176
|
observationClass = 'health';
|
|
170
177
|
const negative = /\b(?:critical|degraded|error|failed|failure|unhealthy)\b/i.test(output);
|
|
171
178
|
const positive = /\b(?:all checks passed|healthy|overall\s*[:=-]?\s*(?:ok|pass)|status\s*[:=-]\s*(?:ok|pass|healthy))\b/i.test(output);
|
|
172
|
-
fields = { healthVerdict: negative ? 'FAIL' : reachable && positive ? 'PASS' : 'UNKNOWN', reachable };
|
|
179
|
+
fields = { healthVerdict: negative || ['failure', 'interrupted'].includes(outcome) ? 'FAIL' : reachable && positive ? 'PASS' : 'UNKNOWN', reachable, terminal };
|
|
173
180
|
}
|
|
174
181
|
if (!observationClass) return null;
|
|
175
182
|
const payload = {
|
|
@@ -385,7 +385,9 @@ function promiseBookkeeping() {
|
|
|
385
385
|
}
|
|
386
386
|
}
|
|
387
387
|
const openKeys = new Set(led.items.filter((i) => i?.kind === PROMISE_KIND && !i.done && i.projectId === pid).map((i) => i.key));
|
|
388
|
-
|
|
388
|
+
// Owner preference suppresses only new capture; verified closure above and integrity audits stay active.
|
|
389
|
+
const newPromises = process.env.RUVNET_PROMISE_CAPTURE === 'off' ? [] : extractCommitments(hookInput.last_assistant_message);
|
|
390
|
+
for (const promise of newPromises) {
|
|
389
391
|
if (openKeys.has(promise.key) || openKeys.size >= PROMISE_CAP_OPEN) continue;
|
|
390
392
|
openKeys.add(promise.key);
|
|
391
393
|
led.items.push({ schemaVersion: 1, kind: PROMISE_KIND, text: promise.text, key: promise.key, done: false, at,
|
|
@@ -303,24 +303,24 @@ export function recordingLine(status, now = Date.now()) {
|
|
|
303
303
|
return `AgentDB: recording ✓ (last write ${ago(now - status.lastCommitAt)} ago, ${status.eventsToday} event(s) today, outbox ${status.pending} pending)`;
|
|
304
304
|
}
|
|
305
305
|
|
|
306
|
-
/**
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
*/
|
|
310
|
-
export function stopNotice({ journal, status, session }) {
|
|
311
|
-
if (!status?.stuck || !status.problem) return '';
|
|
312
|
-
const file = path.join(journal.swarm, NOTICE_STATE);
|
|
306
|
+
/** Shared notices: once per session/condition, bounded to the last twenty sessions in .swarm. */
|
|
307
|
+
export function conditionNotice({ swarm, session, condition, message, now = Date.now }) {
|
|
308
|
+
const file = path.join(swarm, NOTICE_STATE);
|
|
313
309
|
let state = {};
|
|
314
310
|
try { state = JSON.parse(fs.readFileSync(file, 'utf8')) || {}; } catch { /* first notice */ }
|
|
315
311
|
const id = String(session || 'unknown');
|
|
316
312
|
const shown = Array.isArray(state[id]?.conditions) ? state[id].conditions : [];
|
|
317
|
-
if (shown.includes(
|
|
318
|
-
state[id] = { at: new Date(
|
|
313
|
+
if (shown.includes(condition)) return '';
|
|
314
|
+
state[id] = { at: new Date(now()).toISOString(), conditions: [...shown, condition] };
|
|
319
315
|
const recent = Object.entries(state).sort((a, b) => String(b[1]?.at).localeCompare(String(a[1]?.at))).slice(0, 20);
|
|
320
316
|
try { fs.writeFileSync(file, JSON.stringify(Object.fromEntries(recent)), { mode: 0o600 }); } catch { /* still show it once */ }
|
|
321
|
-
return
|
|
317
|
+
return message;
|
|
318
|
+
}
|
|
319
|
+
export function stopNotice({ journal, status, session }) {
|
|
320
|
+
if (!status?.stuck || !status.problem) return '';
|
|
321
|
+
return conditionNotice({ swarm: journal.swarm, session, condition: status.problem, now: () => journal.now(),
|
|
322
|
+
message: `[RuvNet Brain] ${recordingLine(status, journal.now())}` });
|
|
322
323
|
}
|
|
323
|
-
|
|
324
324
|
function defaultStore({ ruflo, db, key, value }) {
|
|
325
325
|
const cwd = rufloRunDir(db);
|
|
326
326
|
try {
|
|
@@ -86,7 +86,8 @@ catch (e) { BRAIN_OFF = !(e && (e.code === 'ENOENT' || e.code === 'ENOTDIR')); }
|
|
|
86
86
|
const TABLE = {
|
|
87
87
|
'session-start': { file: 'session-start-core.mjs', interpreter: 'node', mode: 'advisory', offBehavior: 'partial' },
|
|
88
88
|
'ground-ruvnet': { file: 'ground-ruvnet.sh', interpreter: 'bash', mode: 'advisory', offBehavior: 'silence', stdinBytes: 32768 },
|
|
89
|
-
|
|
89
|
+
// Capacity advice is a Brain prompt hint; OFF must not execute its resource probes or steer work.
|
|
90
|
+
'capacity-aware-parallel-work': { file: 'capacity-aware-parallel-work.mjs', interpreter: 'node', mode: 'advisory', offBehavior: 'silence', stdinBytes: 32768 },
|
|
90
91
|
// ADR-063 / issue #103: `blocking` so an opt-in refusal can actually reach the host. The hook
|
|
91
92
|
// still exits 0 for every user at the shipped default (managedMemoryBoundary=advise), so this
|
|
92
93
|
// changes the CEILING of what it may do, not what it does.
|
|
@@ -46,10 +46,11 @@ const storeList = (source) => {
|
|
|
46
46
|
return stores && typeof stores === 'object' ? Object.entries(stores).map(([kbName, value]) => ({ kbName, ...value })) : [];
|
|
47
47
|
};
|
|
48
48
|
const stemOf = (file) => path.basename(String(file)).replace(/(?:\.big)?\.rvf$/i, '').toLowerCase();
|
|
49
|
-
// The store a sidecar belongs to: <store>.big.rvf[.embed|.idmap].json, <store
|
|
50
|
-
// <store
|
|
49
|
+
// The store a sidecar belongs to: <store>.big.rvf[.embed|.idmap].json, <store>[.big].meta.json,
|
|
50
|
+
// <store>[.big].passages.jsonl, <store>[.big].symbols.json, <store>-primer.md.
|
|
51
51
|
const storeStem = (file) => path.basename(String(file)).toLowerCase()
|
|
52
|
-
.replace(/-primer\.md$/, '').replace(/(?:\.big)?\.rvf(?:\.[a-z]+\.json)?$/, '')
|
|
52
|
+
.replace(/-primer\.md$/, '').replace(/(?:\.big)?\.rvf(?:\.[a-z]+\.json)?$/, '')
|
|
53
|
+
.replace(/(?:\.big)?(?:\.(?:meta|symbols)\.json|\.passages\.jsonl)$/, '');
|
|
53
54
|
// Files the installer/updater writes into a KB that no bundle ships (bin/install.mjs placeUpdater,
|
|
54
55
|
// placeTrustedCoverageValidator, ensureVerifier; the updater's snapshot receipt). Re-created on every install.
|
|
55
56
|
const INSTALLER_WRITTEN = new Set(['coverage-integrity.mjs', 'RUNTIME-IDENTITY.json', '.refresh-snapshot.json',
|
|
@@ -101,8 +101,12 @@ export function describeFailedRefreshRun(receipt, { reasonLimit = 200 } = {}) {
|
|
|
101
101
|
const ledger = receipt.phases.map((entry) => entry?.phase);
|
|
102
102
|
if (ledger.length > declared.length || ledger.some((phase, index) => phase !== declared[index])) return null;
|
|
103
103
|
if (ledger.length === 0) return `failed before its first phase (${receipt.terminalVerdict || 'unknown'})`;
|
|
104
|
-
const failing =
|
|
105
|
-
|
|
104
|
+
const failing = receipt.phases.find((entry) => entry?.required !== false && entry?.status === 'FAIL')
|
|
105
|
+
|| receipt.phases.find((entry) => entry?.status === 'FAIL')
|
|
106
|
+
|| [...receipt.phases].reverse().find((entry) => entry?.status !== 'PASS')
|
|
107
|
+
|| receipt.phases[receipt.phases.length - 1];
|
|
108
|
+
const reason = String(failing.evidence?.updateResult?.reason ?? failing.evidence?.error
|
|
109
|
+
?? failing.evidence?.reason ?? '').split('\n')[0].trim();
|
|
106
110
|
return reason ? `failed at ${failing.phase}: ${reason.slice(0, reasonLimit)}` : `failed at ${failing.phase}`;
|
|
107
111
|
}
|
|
108
112
|
|
|
@@ -6,12 +6,14 @@ import { spawn, spawnSync } from 'node:child_process';
|
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
7
|
import { developmentHooksSuspended } from './development-maintenance.mjs';
|
|
8
8
|
import { resolveTurnDb } from './turn-outcome-capture.mjs';
|
|
9
|
+
import { enrichStateWithObservation } from './project-progression-hook.mjs';
|
|
9
10
|
import { buildProjectProgression } from './project-progression-producer.mjs';
|
|
10
11
|
import { resolveProjectStore } from './project-store-resolver.mjs';
|
|
11
12
|
import { redactProgression } from './project-progression-contract.mjs';
|
|
12
13
|
import { ProgressionOutbox } from './project-progression-outbox.mjs';
|
|
13
14
|
import { captureNormalizedTransition } from './project-transition-hook.mjs';
|
|
14
15
|
import { boundedStoreFactory, runSessionSnapshotHook } from './session-snapshot-hook.mjs';
|
|
16
|
+
import { operatorProgressionSuspension } from './project-progression-suspension.mjs';
|
|
15
17
|
|
|
16
18
|
/**
|
|
17
19
|
* How long the detached worker may spend per step. The lock is refreshed between steps and goes stale
|
|
@@ -48,6 +50,7 @@ const mtimeOf = (projectDir, name) => { try { return fs.statSync(path.join(proje
|
|
|
48
50
|
*/
|
|
49
51
|
export function queueCapture({ projectDir, originProjectDir = projectDir, event, host, payload, env = process.env }) {
|
|
50
52
|
try {
|
|
53
|
+
if (operatorProgressionSuspension(env)) return null;
|
|
51
54
|
const consent = resolveTurnDb({ projectDir: originProjectDir, requestedStorePath: path.join(projectDir, '.swarm', 'memory.db'),
|
|
52
55
|
brainHome: env.RUVNET_BRAIN_HOME || path.join(env.HOME || os.homedir(), '.cache', 'ruvnet-brain') });
|
|
53
56
|
if (consent.skipped) return null;
|
|
@@ -57,6 +60,10 @@ export function queueCapture({ projectDir, originProjectDir = projectDir, event,
|
|
|
57
60
|
if (!progression && !payload?.normalizedTransition) {
|
|
58
61
|
try { progression = buildProjectProgression({ resolution: resolveProjectStore({ projectDir: originProjectDir }), projectDir: originProjectDir, payload, host, trigger: event }).projectProgression; } catch { return null; }
|
|
59
62
|
}
|
|
63
|
+
// Freeze the bounded native observation before discarding raw host input. Replay receives no
|
|
64
|
+
// tool fields, so the native writer cannot append it twice.
|
|
65
|
+
if (progression && (payload?.tool_name || payload?.tool_input)) progression = { ...progression,
|
|
66
|
+
completeProjectState: enrichStateWithObservation(progression.completeProjectState, { ...payload, hook_event_name: event }) };
|
|
60
67
|
// This queue is durable: never serialize arbitrary host prompts, tool input or output.
|
|
61
68
|
const minimized = { session_id: payload?.session_id, hook_event_name: event,
|
|
62
69
|
...(progression ? { projectProgression: progression } : {}),
|
|
@@ -227,13 +234,16 @@ export function releaseReplayLock(projectDir, token) {
|
|
|
227
234
|
}
|
|
228
235
|
|
|
229
236
|
/** Hand the lock (or take it, if free) to a detached worker. Returns whether one was started. Never throws. */
|
|
230
|
-
export function replayOutboxDetached({ projectDir, token = null, spawnFn = spawn } = {}) {
|
|
237
|
+
export function replayOutboxDetached({ projectDir, token = null, spawnFn = spawn, env = process.env } = {}) {
|
|
238
|
+
try {
|
|
239
|
+
if (operatorProgressionSuspension(env)) { if (token) releaseReplayLock(projectDir, token); return false; }
|
|
240
|
+
} catch { if (token) releaseReplayLock(projectDir, token); return false; }
|
|
231
241
|
const held = token || takeReplayLock(projectDir);
|
|
232
242
|
if (!held) return false;
|
|
233
243
|
try {
|
|
234
244
|
const child = spawnFn(process.execPath, [path.join(path.dirname(fileURLToPath(import.meta.url)), 'session-snapshot-hook.mjs'), '--replay-outbox'], {
|
|
235
245
|
cwd: projectDir, detached: true, stdio: 'ignore', windowsHide: true,
|
|
236
|
-
env: { ...
|
|
246
|
+
env: { ...env, RUVNET_REPLAY_LOCK_TOKEN: held },
|
|
237
247
|
});
|
|
238
248
|
child.unref?.();
|
|
239
249
|
return true;
|
|
@@ -254,10 +264,11 @@ export function replayOutboxDetached({ projectDir, token = null, spawnFn = spawn
|
|
|
254
264
|
*/
|
|
255
265
|
export function runOutboxReplay({ projectDir, token = process.env.RUVNET_REPLAY_LOCK_TOKEN || null, budgetMs = DETACHED_REPLAY_BUDGET_MS,
|
|
256
266
|
makeStoreFactory = boundedStoreFactory, now = Date.now, runCapture = runSessionSnapshotHook, onClaim = null,
|
|
257
|
-
captureNormalized = captureNormalizedTransition, onCaptured = null } = {}) {
|
|
267
|
+
captureNormalized = captureNormalizedTransition, onCaptured = null, env = process.env } = {}) {
|
|
258
268
|
const deadlineAt = now() + budgetMs;
|
|
259
269
|
if (developmentHooksSuspended(projectDir)) return 0;
|
|
260
|
-
|
|
270
|
+
if (operatorProgressionSuspension(env)) { if (token) releaseReplayLock(projectDir, token); return 0; }
|
|
271
|
+
const brainHome = env.RUVNET_BRAIN_HOME || path.join(env.HOME || os.homedir(), '.cache', 'ruvnet-brain');
|
|
261
272
|
try {
|
|
262
273
|
const consent = resolveTurnDb({ projectDir, brainHome });
|
|
263
274
|
if (consent.skipped && !consent.skipped.startsWith('no project memory db')) return 0;
|
|
@@ -269,14 +280,17 @@ export function runOutboxReplay({ projectDir, token = process.env.RUVNET_REPLAY_
|
|
|
269
280
|
if (!adoptReplayLock(projectDir, held)) return replayed;
|
|
270
281
|
reclaimOrphans(projectDir);
|
|
271
282
|
const resolution = resolveProjectStore({ projectDir });
|
|
272
|
-
const store = makeStoreFactory(deadlineAt)({ projectDir, requestedStorePath: resolution.canonicalAgentDbPath });
|
|
283
|
+
const store = makeStoreFactory(deadlineAt)({ projectDir, env, requestedStorePath: resolution.canonicalAgentDbPath });
|
|
273
284
|
for (const snapshot of store.outbox.pendingSnapshots()) {
|
|
285
|
+
if (operatorProgressionSuspension(env)) return replayed;
|
|
274
286
|
if (now() >= deadlineAt) return replayed;
|
|
275
287
|
if (!refreshReplayLock(projectDir, held)) return replayed;
|
|
276
|
-
store.
|
|
288
|
+
store.captureFrozen(snapshot, { canCommit: () => refreshReplayLock(projectDir, held) });
|
|
289
|
+
// captureFrozen commits only the verified key, retaining conflicting original history.
|
|
277
290
|
replayed += 1;
|
|
278
291
|
}
|
|
279
292
|
for (const file of queuedCaptures(projectDir)) {
|
|
293
|
+
if (operatorProgressionSuspension(env)) return replayed;
|
|
280
294
|
if (now() >= deadlineAt) return replayed;
|
|
281
295
|
if (!refreshReplayLock(projectDir, held)) return replayed;
|
|
282
296
|
const claimed = claimQueued(file);
|
|
@@ -286,6 +300,7 @@ export function runOutboxReplay({ projectDir, token = process.env.RUVNET_REPLAY_
|
|
|
286
300
|
returnClaim(claimed);
|
|
287
301
|
return replayed;
|
|
288
302
|
}
|
|
303
|
+
if (operatorProgressionSuspension(env)) { returnClaim(claimed); return replayed; }
|
|
289
304
|
let job = null;
|
|
290
305
|
try { job = JSON.parse(fs.readFileSync(claimed, 'utf8')); } catch { /* torn: dropped below */ }
|
|
291
306
|
let committed = false;
|
|
@@ -296,7 +311,7 @@ export function runOutboxReplay({ projectDir, token = process.env.RUVNET_REPLAY_
|
|
|
296
311
|
const consent = resolveTurnDb({ projectDir: job.originProjectDir || projectDir, brainHome });
|
|
297
312
|
if (developmentHooksSuspended(job.originProjectDir || projectDir)
|
|
298
313
|
|| (consent.skipped && !consent.skipped.startsWith('no project memory db'))) { returnClaim(claimed); return replayed; }
|
|
299
|
-
const options = { rawInput: JSON.stringify(job.payload), host: job.host,
|
|
314
|
+
const options = { rawInput: JSON.stringify(job.payload), host: job.host, env,
|
|
300
315
|
budgetMs: Math.max(0, deadlineAt - now()), makeStoreFactory: () => makeStoreFactory(deadlineAt), now, ordered: held, writeMetadata: false,
|
|
301
316
|
captureTurn: () => ({ recorded: false, skipped: 'detached replay' }),
|
|
302
317
|
captureEvents: () => ({ recorded: 0, skipped: 'detached replay' }) };
|
|
@@ -321,6 +336,7 @@ export function runOutboxReplay({ projectDir, token = process.env.RUVNET_REPLAY_
|
|
|
321
336
|
|
|
322
337
|
/** Synchronous bounded startup drain. Pending debt must downgrade restore, never disappear. */
|
|
323
338
|
export function drainCaptureQueue({ projectDir, budgetMs = 1000, ...options } = {}) {
|
|
339
|
+
if (operatorProgressionSuspension(options.env || process.env)) return { state: 'suspended', replayed: 0, pending: null };
|
|
324
340
|
const startedAt = Date.now();
|
|
325
341
|
const resolution = resolveProjectStore({ projectDir, gitTimeoutMs: Math.max(1, Math.min(300, budgetMs)) });
|
|
326
342
|
const root = resolution.projectRoot;
|
|
@@ -330,4 +346,3 @@ export function drainCaptureQueue({ projectDir, budgetMs = 1000, ...options } =
|
|
|
330
346
|
const pending = queuedWork(root) + outboxPending;
|
|
331
347
|
return { state: pending ? 'pending' : 'settled', replayed, pending };
|
|
332
348
|
}
|
|
333
|
-
|
|
@@ -3,6 +3,7 @@ import path from 'node:path';
|
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
import { createProgressionSnapshot } from './project-progression-contract.mjs';
|
|
5
5
|
import { ProjectProgressionStore } from './project-progression-store.mjs';
|
|
6
|
+
import { redactText } from './continuity-events.mjs';
|
|
6
7
|
|
|
7
8
|
const HOSTS = new Set(['claude', 'codex']);
|
|
8
9
|
const CAPTURE_TRIGGERS = new Set([
|
|
@@ -105,15 +106,17 @@ function toolAction(payload) {
|
|
|
105
106
|
const exitCode = Number.isSafeInteger(explicitCode)
|
|
106
107
|
? explicitCode
|
|
107
108
|
: textualCode ? Number(textualCode[1]) : undefined;
|
|
108
|
-
const
|
|
109
|
+
const error = boundedText(typeof responseRecord?.error === 'string' ? redactText(responseRecord.error) : undefined);
|
|
110
|
+
const signal = boundedText(typeof responseRecord?.signal === 'string' ? redactText(responseRecord.signal) : undefined);
|
|
111
|
+
const interrupted = responseRecord?.interrupted === true || Boolean(signal);
|
|
109
112
|
const explicitError = responseRecord?.isError === true || payload.is_error === true;
|
|
110
113
|
const declaredOutcome = ['success', 'failure', 'interrupted', 'unknown'].includes(responseRecord?.outcome)
|
|
111
114
|
? responseRecord.outcome : null;
|
|
112
|
-
const failed = explicitError || (Number.isSafeInteger(exitCode) && exitCode !== 0);
|
|
115
|
+
const failed = explicitError || Boolean(error) || (Number.isSafeInteger(exitCode) && exitCode !== 0);
|
|
113
116
|
const terminal = failed || Number.isSafeInteger(exitCode)
|
|
114
117
|
|| responseRecord?.success === true || responseRecord?.ok === true;
|
|
115
118
|
const outcome = payload.hook_event_name === 'PostToolUse'
|
|
116
|
-
? (interrupted ? 'interrupted' : failed ? 'failure' : declaredOutcome || (terminal ? 'success' : 'unknown'))
|
|
119
|
+
? (error ? 'failure' : interrupted ? 'interrupted' : failed ? 'failure' : declaredOutcome || (terminal ? 'success' : 'unknown'))
|
|
117
120
|
: 'pending';
|
|
118
121
|
const observation = {
|
|
119
122
|
trigger: payload.hook_event_name,
|
|
@@ -121,7 +124,9 @@ function toolAction(payload) {
|
|
|
121
124
|
...(command ? { command } : {}),
|
|
122
125
|
...(filePath && !command ? { filePath } : {}),
|
|
123
126
|
outcome,
|
|
124
|
-
...(Number.isSafeInteger(exitCode) ? { exitCode } : {}),
|
|
127
|
+
...(Number.isSafeInteger(exitCode) ? { exitCode } : responseRecord?.exit_code === null || responseRecord?.exitCode === null ? { exitCode: null } : {}),
|
|
128
|
+
...(error ? { error } : {}),
|
|
129
|
+
...(signal ? { signal } : {}),
|
|
125
130
|
...(interrupted ? { interrupted: true } : {}),
|
|
126
131
|
...(explicitError ? { isError: true } : {}),
|
|
127
132
|
};
|
|
@@ -137,7 +142,7 @@ function toolAction(payload) {
|
|
|
137
142
|
return observation;
|
|
138
143
|
}
|
|
139
144
|
|
|
140
|
-
function enrichStateWithObservation(state, payload) {
|
|
145
|
+
export function enrichStateWithObservation(state, payload) {
|
|
141
146
|
requireRecord(state, 'completeProjectState');
|
|
142
147
|
const observation = toolAction(payload);
|
|
143
148
|
if (!observation) return state;
|
|
@@ -164,6 +169,8 @@ export function captureProjectTransition({
|
|
|
164
169
|
payload,
|
|
165
170
|
projectDir,
|
|
166
171
|
adapterVersion = readProgressionAdapterVersion(),
|
|
172
|
+
recoverFrozen = false,
|
|
173
|
+
canCommit,
|
|
167
174
|
storeFactory = (options) => new ProjectProgressionStore(options),
|
|
168
175
|
} = {}) {
|
|
169
176
|
const normalizedHost = requireString(host, 'host').toLowerCase();
|
|
@@ -191,7 +198,7 @@ export function captureProjectTransition({
|
|
|
191
198
|
aliased(progression, 'sourceIdentity', 'source_identity'),
|
|
192
199
|
store.resolution.checkoutRoot, projectDir,
|
|
193
200
|
);
|
|
194
|
-
|
|
201
|
+
let snapshot = createProgressionSnapshot({
|
|
195
202
|
projectIdentity: store.resolution.projectIdentity,
|
|
196
203
|
sourceIdentity,
|
|
197
204
|
hostIdentity: { host: normalizedHost, adapterVersion },
|
|
@@ -206,7 +213,9 @@ export function captureProjectTransition({
|
|
|
206
213
|
payload,
|
|
207
214
|
),
|
|
208
215
|
});
|
|
209
|
-
|
|
216
|
+
let receipt;
|
|
217
|
+
if (recoverFrozen) ({ snapshot, receipt } = store.captureFrozen(snapshot, { canCommit }));
|
|
218
|
+
else receipt = store.capture(snapshot);
|
|
210
219
|
verifyReceipt(snapshot, receipt);
|
|
211
220
|
return { snapshot, receipt };
|
|
212
221
|
}
|