@claude-flow/cli 3.55.0 → 3.56.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/.claude/helpers/helpers.manifest.json +2 -2
  2. package/.claude/settings.json +9 -9
  3. package/catalog-manifest.json +2 -2
  4. package/dist/src/auth/client.js +40 -24
  5. package/dist/src/commands/agent.js +12 -0
  6. package/dist/src/commands/analyze.js +18 -14
  7. package/dist/src/commands/config.js +1 -1
  8. package/dist/src/commands/daemon.js +11 -19
  9. package/dist/src/commands/doctor.js +65 -58
  10. package/dist/src/commands/memory-backup.d.ts +1 -1
  11. package/dist/src/commands/memory-backup.js +23 -9
  12. package/dist/src/commands/memory.d.ts +0 -4
  13. package/dist/src/commands/memory.js +10 -2
  14. package/dist/src/commands/plugins.js +13 -1
  15. package/dist/src/commands/task.js +12 -0
  16. package/dist/src/init/settings-generator.js +3 -3
  17. package/dist/src/mcp-tools/agent-execute-core.js +244 -188
  18. package/dist/src/mcp-tools/agent-tools.js +15 -16
  19. package/dist/src/mcp-tools/agentdb-tools.js +22 -3
  20. package/dist/src/mcp-tools/claims-tools.js +3 -11
  21. package/dist/src/mcp-tools/config-tools.js +118 -13
  22. package/dist/src/mcp-tools/hooks-tools.js +47 -2
  23. package/dist/src/mcp-tools/memory-tools.js +29 -10
  24. package/dist/src/mcp-tools/record-store.d.ts +3 -0
  25. package/dist/src/mcp-tools/record-store.js +21 -0
  26. package/dist/src/mcp-tools/session-tools.js +47 -13
  27. package/dist/src/mcp-tools/task-tools.js +5 -11
  28. package/dist/src/mcp-tools/workflow-tools.js +78 -29
  29. package/dist/src/memory/append-conditions.d.ts +28 -0
  30. package/dist/src/memory/append-conditions.js +56 -0
  31. package/dist/src/memory/graph-edge-writer.d.ts +9 -0
  32. package/dist/src/memory/graph-edge-writer.js +90 -0
  33. package/dist/src/memory/memory-bridge.d.ts +4 -0
  34. package/dist/src/memory/memory-bridge.js +50 -9
  35. package/dist/src/memory/memory-initializer.d.ts +4 -0
  36. package/dist/src/memory/memory-initializer.js +100 -62
  37. package/dist/src/memory/memory-root.d.ts +2 -0
  38. package/dist/src/memory/memory-root.js +28 -0
  39. package/dist/src/memory/sona-optimizer.d.ts +2 -0
  40. package/dist/src/memory/sona-optimizer.js +4 -0
  41. package/dist/src/parser.js +7 -0
  42. package/dist/src/plugins/store/discovery.d.ts +6 -0
  43. package/dist/src/plugins/store/discovery.js +7 -3
  44. package/dist/src/ruvector/neural-router.js +7 -2
  45. package/dist/src/services/claim-service.js +39 -18
  46. package/dist/src/services/claims-store.d.ts +3 -0
  47. package/dist/src/services/claims-store.js +40 -0
  48. package/dist/src/services/config-file-manager.d.ts +2 -2
  49. package/dist/src/services/config-file-manager.js +22 -3
  50. package/dist/src/services/distill-tuning.js +17 -10
  51. package/dist/src/services/memory-backup.d.ts +7 -0
  52. package/dist/src/services/memory-backup.js +29 -2
  53. package/dist/src/services/memory-distillation.js +2 -2
  54. package/dist/src/services/policy-ledger-anchor.d.ts +8 -0
  55. package/dist/src/services/policy-ledger-anchor.js +18 -3
  56. package/dist/src/services/policy-runtime.js +2 -2
  57. package/dist/src/services/worker-daemon.d.ts +4 -2
  58. package/dist/src/services/worker-daemon.js +14 -6
  59. package/node_modules/@claude-flow/plugin-agent-federation/dist/bin.js +0 -0
  60. package/node_modules/@claude-flow/security/dist/oauth/callback-server.d.ts +2 -1
  61. package/node_modules/@claude-flow/security/dist/oauth/callback-server.d.ts.map +1 -1
  62. package/node_modules/@claude-flow/security/dist/oauth/callback-server.js +48 -16
  63. package/node_modules/@claude-flow/security/dist/oauth/callback-server.js.map +1 -1
  64. package/node_modules/@claude-flow/security/dist/path-validator.d.ts +2 -2
  65. package/node_modules/@claude-flow/security/dist/path-validator.d.ts.map +1 -1
  66. package/node_modules/@claude-flow/security/dist/path-validator.js +15 -4
  67. package/node_modules/@claude-flow/security/dist/path-validator.js.map +1 -1
  68. package/node_modules/@claude-flow/security/package.json +1 -1
  69. package/package.json +4 -4
  70. package/plugins/ruflo-metaharness/CHANGELOG.md +48 -0
  71. package/.claude/agents/tmp.json +0 -0
@@ -41,6 +41,22 @@ function saveWorkflowStore(store) {
41
41
  ensureWorkflowDir();
42
42
  writeFileSync(getWorkflowPath(), JSON.stringify(store, null, 2), 'utf-8');
43
43
  }
44
+ // A paused in-flight step still owns its executor. Resume joins that execution
45
+ // instead of dispatching the same side effect again (within this process).
46
+ const workflowExecutions = new Map();
47
+ const workflowExecutionKey = (workflowId) => JSON.stringify([getWorkflowPath(), workflowId]);
48
+ function withWorkflowExecution(workflowId, execute) {
49
+ const key = workflowExecutionKey(workflowId);
50
+ if (workflowExecutions.has(key)) {
51
+ return Promise.resolve({ workflowId, error: 'Workflow already executing' });
52
+ }
53
+ const execution = execute();
54
+ workflowExecutions.set(key, execution);
55
+ return execution.finally(() => {
56
+ if (workflowExecutions.get(key) === execution)
57
+ workflowExecutions.delete(key);
58
+ });
59
+ }
44
60
  export const workflowTools = [
45
61
  {
46
62
  name: 'workflow_run',
@@ -227,7 +243,7 @@ export const workflowTools = [
227
243
  },
228
244
  required: ['workflowId'],
229
245
  },
230
- handler: async (input) => {
246
+ handler: async (input) => withWorkflowExecution(input.workflowId, async () => {
231
247
  // Validate user-provided input (#1425)
232
248
  const vId = validateIdentifier(input.workflowId, 'workflowId');
233
249
  if (!vId.valid)
@@ -241,13 +257,50 @@ export const workflowTools = [
241
257
  if (workflow.status === 'running') {
242
258
  return { workflowId, error: 'Workflow already running' };
243
259
  }
260
+ // Indices may name a step or the end boundary (used when resuming
261
+ // after the final step). Reject invalid control flow before any writes.
262
+ const validIndex = (value) => typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 && value <= workflow.steps.length;
263
+ const startFromStep = input.startFromStep === undefined ? 0 : input.startFromStep;
264
+ if (!validIndex(startFromStep)) {
265
+ return { workflowId, error: `startFromStep must be an integer between 0 and ${workflow.steps.length}` };
266
+ }
267
+ for (const step of workflow.steps) {
268
+ if (step.type !== 'condition')
269
+ continue;
270
+ for (const target of ['thenStep', 'elseStep']) {
271
+ if (step.config[target] !== undefined && !validIndex(step.config[target])) {
272
+ return { workflowId, error: `${step.stepId}.${target} must be an integer between 0 and ${workflow.steps.length}` };
273
+ }
274
+ }
275
+ }
276
+ const persistProgress = () => {
277
+ // An awaited step may overlap pause/stop/delete or a sibling workflow
278
+ // mutation. Merge only this workflow into the latest store snapshot.
279
+ const latest = loadWorkflowStore();
280
+ const current = latest.workflows[workflowId];
281
+ if (!current) {
282
+ workflow.status = 'failed';
283
+ workflow.error = 'Workflow was deleted during execution';
284
+ return;
285
+ }
286
+ if (current.status === 'failed') {
287
+ Object.assign(workflow, current);
288
+ return;
289
+ }
290
+ if (current.status === 'paused' && workflow.status !== 'failed')
291
+ workflow.status = 'paused';
292
+ latest.workflows[workflowId] = workflow;
293
+ saveWorkflowStore(latest);
294
+ };
244
295
  // Inject runtime variables
245
296
  if (input.variables) {
246
297
  workflow.variables = { ...workflow.variables, ...input.variables };
247
298
  }
248
299
  workflow.status = 'running';
249
300
  workflow.startedAt = new Date().toISOString();
250
- workflow.currentStep = input.startFromStep || 0;
301
+ delete workflow.error;
302
+ delete workflow.completedAt;
303
+ workflow.currentStep = startFromStep;
251
304
  saveWorkflowStore(store);
252
305
  // ADR-095 G3: real workflow runtime. Walk the steps in order;
253
306
  // dispatch each by type. Persist progress after each step so a
@@ -277,22 +330,26 @@ export const workflowTools = [
277
330
  while (i < workflow.steps.length) {
278
331
  // Honor pause/cancel signals between steps.
279
332
  const live = loadWorkflowStore().workflows[workflowId];
280
- if (!live || live.status === 'paused') {
333
+ if (!live) {
334
+ workflow.status = 'failed';
335
+ workflow.error = 'Workflow was deleted during execution';
336
+ break;
337
+ }
338
+ if (live.status === 'paused') {
281
339
  workflow.status = 'paused';
282
340
  workflow.currentStep = i;
283
- saveWorkflowStore(store);
341
+ persistProgress();
284
342
  break;
285
343
  }
286
344
  if (live.status === 'failed') {
287
- workflow.status = 'failed';
288
- saveWorkflowStore(store);
345
+ Object.assign(workflow, live);
289
346
  break;
290
347
  }
291
348
  const step = workflow.steps[i];
292
349
  step.status = 'running';
293
350
  step.startedAt = new Date().toISOString();
294
351
  const stepStart = Date.now();
295
- saveWorkflowStore(store);
352
+ persistProgress();
296
353
  let stepEntry = { stepId: step.stepId, type: step.type, status: 'running' };
297
354
  try {
298
355
  if (step.type === 'task') {
@@ -364,7 +421,7 @@ export const workflowTools = [
364
421
  workflow.status = 'failed';
365
422
  workflow.error = msg;
366
423
  workflow.completedAt = new Date().toISOString();
367
- saveWorkflowStore(store);
424
+ persistProgress();
368
425
  return {
369
426
  workflowId,
370
427
  status: 'failed',
@@ -379,17 +436,18 @@ export const workflowTools = [
379
436
  stepEntry.durationMs = Date.now() - stepStart;
380
437
  stepResults.push(stepEntry);
381
438
  workflow.currentStep = i + 1;
382
- saveWorkflowStore(store);
439
+ persistProgress();
383
440
  i++;
384
441
  }
385
442
  if (workflow.status === 'running') {
386
443
  workflow.status = 'completed';
387
444
  workflow.completedAt = new Date().toISOString();
388
- saveWorkflowStore(store);
445
+ persistProgress();
389
446
  }
390
447
  return {
391
448
  workflowId,
392
449
  status: workflow.status,
450
+ ...(workflow.error ? { error: workflow.error } : {}),
393
451
  totalSteps: workflow.steps.length,
394
452
  stepsCompleted: stepResults.filter(s => s.status === 'completed').length,
395
453
  stepsSkipped: stepResults.filter(s => s.status === 'skipped').length,
@@ -399,7 +457,7 @@ export const workflowTools = [
399
457
  completedAt: workflow.completedAt,
400
458
  durationMs: Date.now() - startedAt,
401
459
  };
402
- },
460
+ }),
403
461
  },
404
462
  {
405
463
  name: 'workflow_status',
@@ -560,24 +618,15 @@ export const workflowTools = [
560
618
  if (workflow.status !== 'paused') {
561
619
  return { workflowId, error: 'Workflow not paused' };
562
620
  }
563
- workflow.status = 'running';
564
- saveWorkflowStore(store);
565
- // Report current step states — do not auto-complete them
566
- const stepStates = workflow.steps.map(step => ({
567
- stepId: step.stepId,
568
- name: step.name,
569
- status: step.status,
570
- }));
571
- const remainingSteps = workflow.steps.length - workflow.currentStep;
572
- return {
573
- workflowId,
574
- status: workflow.status,
575
- resumed: true,
576
- currentStep: workflow.currentStep,
577
- remainingSteps,
578
- steps: stepStates,
579
- _note: 'Workflow resumed. Steps remain in their current state and must be executed via task tools.',
580
- };
621
+ const active = workflowExecutions.get(workflowExecutionKey(workflowId));
622
+ if (active) {
623
+ workflow.status = 'running';
624
+ saveWorkflowStore(store);
625
+ return { ...(await active), resumed: true };
626
+ }
627
+ const execute = workflowTools.find(tool => tool.name === 'workflow_execute');
628
+ const result = await execute.handler({ workflowId, startFromStep: workflow.currentStep });
629
+ return { ...result, resumed: true };
581
630
  },
582
631
  },
583
632
  {
@@ -0,0 +1,28 @@
1
+ export type AppendCondition = {
2
+ namespace: string;
3
+ } & ({
4
+ key: string;
5
+ sha256: string;
6
+ latestPrefix?: string;
7
+ } | {
8
+ absent: true;
9
+ latestPrefix: string;
10
+ } | {
11
+ keysSha256: string;
12
+ count: number;
13
+ });
14
+ export declare class AppendConditionFailed extends Error {
15
+ }
16
+ export declare function validateAppendConditions(input: unknown): AppendCondition[];
17
+ interface NativeReader {
18
+ prepare(sql: string): {
19
+ all(...parameters: string[]): Array<{
20
+ key: string;
21
+ content: string;
22
+ status: string | null;
23
+ }>;
24
+ };
25
+ }
26
+ export declare function assertAppendConditions(db: NativeReader, conditions: AppendCondition[]): void;
27
+ export {};
28
+ //# sourceMappingURL=append-conditions.d.ts.map
@@ -0,0 +1,56 @@
1
+ /** Native append predicates checked in the SAME write transaction as an immutable insert. */
2
+ import { createHash } from 'node:crypto';
3
+ export class AppendConditionFailed extends Error {
4
+ }
5
+ const text = (value, limit) => typeof value === 'string' && value.length > 0 && value.length <= limit;
6
+ const digest = (value) => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
7
+ export function validateAppendConditions(input) {
8
+ if (!Array.isArray(input) || input.length < 1 || input.length > 8)
9
+ throw new TypeError('append conditions must contain 1-8 predicates');
10
+ return input.map(condition => {
11
+ if (!condition || typeof condition !== 'object' || Array.isArray(condition))
12
+ throw new TypeError('invalid append condition');
13
+ const row = condition;
14
+ const fields = Object.keys(row).sort().join(',');
15
+ const validRecord = ['key,namespace,sha256', 'key,latestPrefix,namespace,sha256'].includes(fields)
16
+ && text(row.key, 1024) && digest(row.sha256) && (row.latestPrefix === undefined || text(row.latestPrefix, 1024));
17
+ const validAbsent = fields === 'absent,latestPrefix,namespace' && row.absent === true && text(row.latestPrefix, 1024);
18
+ const validKeys = fields === 'count,keysSha256,namespace' && digest(row.keysSha256)
19
+ && Number.isSafeInteger(row.count) && row.count >= 0 && row.count <= 100_000;
20
+ if (!text(row.namespace, 256) || !(validRecord || validAbsent || validKeys))
21
+ throw new TypeError('invalid append condition');
22
+ return row;
23
+ });
24
+ }
25
+ const sha = (value) => createHash('sha256').update(value).digest('hex');
26
+ export function assertAppendConditions(db, conditions) {
27
+ for (const condition of conditions) {
28
+ if ('keysSha256' in condition) {
29
+ const rows = db.prepare("SELECT key FROM memory_entries WHERE namespace = ? AND (status = 'active' OR status IS NULL)")
30
+ .all(condition.namespace);
31
+ const keys = rows.map(row => row.key).sort();
32
+ if (keys.length !== condition.count || sha(JSON.stringify(keys)) !== condition.keysSha256) {
33
+ throw new AppendConditionFailed('immutable append rejected: namespace membership changed');
34
+ }
35
+ continue;
36
+ }
37
+ if ('key' in condition) {
38
+ const rows = db.prepare('SELECT key, content, status FROM memory_entries WHERE namespace = ? AND key = ?')
39
+ .all(condition.namespace, condition.key);
40
+ if (rows.length !== 1 || !['active', null].includes(rows[0].status)
41
+ || typeof rows[0].content !== 'string' || sha(rows[0].content) !== condition.sha256) {
42
+ throw new AppendConditionFailed('immutable append rejected: expected parent/authority bytes changed or absent');
43
+ }
44
+ }
45
+ if (condition.latestPrefix !== undefined) {
46
+ // Include tombstones: deleting a new authority slot must never rewind to an older authority.
47
+ const pattern = condition.latestPrefix.replace(/[\\%_]/g, character => `\\${character}`) + '%';
48
+ const latest = db.prepare("SELECT key, content, status FROM memory_entries WHERE namespace = ? AND key LIKE ? ESCAPE '\\' ORDER BY key COLLATE BINARY DESC LIMIT 1")
49
+ .all(condition.namespace, pattern);
50
+ if ('absent' in condition ? latest.length !== 0 : latest.length !== 1 || latest[0].key !== condition.key) {
51
+ throw new AppendConditionFailed('immutable append rejected: newer parent/authority slot exists');
52
+ }
53
+ }
54
+ }
55
+ }
56
+ //# sourceMappingURL=append-conditions.js.map
@@ -29,6 +29,15 @@
29
29
  *
30
30
  * @module v3/cli/memory/graph-edge-writer
31
31
  */
32
+ /**
33
+ * True iff the memory.db bridge at `customDbPath` (or the default location)
34
+ * is currently RFE1-encrypted-at-rest. Callers that receive `null` from
35
+ * getBridgeDb()/insertGraphEdge() can use this to avoid recommending the
36
+ * destructive `memory init` command for this specific, non-corrupt case.
37
+ */
38
+ export declare function isBridgeDbEncryptedAtRest(customDbPath?: string): boolean;
39
+ /** Test-only: clear the once-per-path warning dedup set. */
40
+ export declare function _resetRfe1WarningFlag(): void;
32
41
  /**
33
42
  * Checkpoint and close the cached handle if it is open (optionally only if it
34
43
  * is open on `dbPath`). Returns true if a handle was released. Never throws.
@@ -35,6 +35,87 @@ import * as crypto from 'crypto';
35
35
  import { getMemoryRoot } from './memory-initializer.js';
36
36
  import { encodeEmbedding } from './embedding-quantization.js';
37
37
  import { loadBetterSqlite3 } from './shared-sqlite.js';
38
+ import { isEncryptedBlob } from '../encryption/vault.js';
39
+ // ============================================================================
40
+ // #2889 — RFE1 encryption-at-rest guard
41
+ // ============================================================================
42
+ //
43
+ // When CLAUDE_FLOW_ENCRYPT_AT_REST is on, memory.db is an opaque RFE1 blob
44
+ // (see encryption/vault.ts). `new BetterSqlite3(dbPath)` has no RFE1 sniff
45
+ // and throws SQLITE_NOTADB on it, which the generic catch below swallows —
46
+ // indistinguishable from genuine corruption, a missing native module, or a
47
+ // permissions failure. That ambiguity is what let #2889 happen: nothing
48
+ // stopped a caller from concluding the store was broken and running the
49
+ // destructive `memory init`, which creates a fresh EMPTY store instead of
50
+ // using the existing encrypted data.
51
+ //
52
+ // Fix: sniff the header bytes (never buffer the whole file — memory.db can
53
+ // be multi-GB) before attempting the native open. If it's RFE1-encrypted,
54
+ // skip the native open (it would only throw) and warn once per distinct
55
+ // dbPath (not once per process — a process can legitimately touch more
56
+ // than one encrypted store via a custom `dbPath` argument).
57
+ /** Read just the first `len` bytes of a file without loading the whole file
58
+ * into memory. Mirrors the pattern in commands/doctor.ts's readHeaderBytes
59
+ * (not imported from there — this is a memory-layer module and must not
60
+ * depend on the command layer). */
61
+ function readHeaderBytes(filePath, len) {
62
+ const fd = fs.openSync(filePath, 'r');
63
+ try {
64
+ const buf = Buffer.alloc(len);
65
+ const bytesRead = fs.readSync(fd, buf, 0, len, 0);
66
+ return buf.subarray(0, bytesRead);
67
+ }
68
+ finally {
69
+ fs.closeSync(fd);
70
+ }
71
+ }
72
+ // RFE1 wire format is magic(4) + iv(12) + ciphertext(N) + tag(16); the
73
+ // shortest possible blob is 32 bytes. 64 gives headroom.
74
+ const ENCRYPTION_SNIFF_LEN = 64;
75
+ /** True iff `filePath` is a legitimately RFE1-encrypted-at-rest file. A read
76
+ * failure (permissions, races) is treated as "not encrypted" — the caller's
77
+ * own open/query error handling still runs right after. */
78
+ function isRfe1EncryptedAtRest(filePath) {
79
+ try {
80
+ return isEncryptedBlob(readHeaderBytes(filePath, ENCRYPTION_SNIFF_LEN));
81
+ }
82
+ catch {
83
+ return false;
84
+ }
85
+ }
86
+ // Keyed by resolved path (not a single process-wide boolean) — a process
87
+ // that touches more than one RFE1-encrypted dbPath (a custom `dbPath` passed
88
+ // to insertGraphEdge/queryEdgesBySource/countGraphEdges, or any multi-store
89
+ // scenario) must still get the warning for EACH distinct path, not just the
90
+ // first one it happens to see.
91
+ const _warnedRfe1EncryptedPaths = new Set();
92
+ function warnRfe1EncryptedOnce(dbPath) {
93
+ const resolved = path.resolve(dbPath);
94
+ if (_warnedRfe1EncryptedPaths.has(resolved))
95
+ return;
96
+ _warnedRfe1EncryptedPaths.add(resolved);
97
+ // eslint-disable-next-line no-console
98
+ console.error(`[graph-edge-writer] ${dbPath} is encrypted at rest (CLAUDE_FLOW_ENCRYPT_AT_REST). ` +
99
+ 'The native graph-edge bridge cannot open an RFE1-encrypted store directly — ' +
100
+ 'this is a known limitation, not corruption. Your existing data is NOT lost ' +
101
+ 'or damaged. Do NOT run `memory init` in response to this — that would create ' +
102
+ 'a fresh, empty store instead of using your existing encrypted data. ' +
103
+ 'See #2889 (https://github.com/ruvnet/ruflo/issues/2889) for details and recovery options.');
104
+ }
105
+ /**
106
+ * True iff the memory.db bridge at `customDbPath` (or the default location)
107
+ * is currently RFE1-encrypted-at-rest. Callers that receive `null` from
108
+ * getBridgeDb()/insertGraphEdge() can use this to avoid recommending the
109
+ * destructive `memory init` command for this specific, non-corrupt case.
110
+ */
111
+ export function isBridgeDbEncryptedAtRest(customDbPath) {
112
+ const dbPath = customDbPath ?? path.join(getMemoryRoot(), 'memory.db');
113
+ return fs.existsSync(dbPath) && isRfe1EncryptedAtRest(dbPath);
114
+ }
115
+ /** Test-only: clear the once-per-path warning dedup set. */
116
+ export function _resetRfe1WarningFlag() {
117
+ _warnedRfe1EncryptedPaths.clear();
118
+ }
38
119
  // ============================================================================
39
120
  // Lazy-cached better-sqlite3 db handle
40
121
  // ============================================================================
@@ -134,6 +215,15 @@ export async function getBridgeDb(customDbPath, opts) {
134
215
  const dbExists = fs.existsSync(dbPath);
135
216
  if (!dbExists && !createIfMissing)
136
217
  return null;
218
+ // #2889 — detect RFE1 encryption-at-rest before even attempting the
219
+ // native open: `new BetterSqlite3(dbPath)` on an encrypted file would
220
+ // only throw SQLITE_NOTADB, caught by the generic catch below with no
221
+ // way to tell it apart from genuine corruption. Skip straight to a
222
+ // clear, actionable warning instead.
223
+ if (dbExists && isRfe1EncryptedAtRest(dbPath)) {
224
+ warnRfe1EncryptedOnce(dbPath);
225
+ return null;
226
+ }
137
227
  // Ensure parent dir exists for createIfMissing case.
138
228
  if (!dbExists && createIfMissing) {
139
229
  const dir = path.dirname(dbPath);
@@ -16,6 +16,7 @@
16
16
  *
17
17
  * @module v3/cli/memory-bridge
18
18
  */
19
+ import { type AppendCondition } from './append-conditions.js';
19
20
  /**
20
21
  * #3024: AgentDB's optional native controller stack can abort the whole Node
21
22
  * process on Windows during registry initialization (a Rust allocation panic),
@@ -67,6 +68,9 @@ export declare function bridgeStoreEntry(options: {
67
68
  ttl?: number;
68
69
  dbPath?: string;
69
70
  upsert?: boolean;
71
+ requireNative?: boolean;
72
+ appendOnly?: boolean;
73
+ appendConditions?: AppendCondition[];
70
74
  /** ADR-323: defaults to 'unknown' when omitted. */
71
75
  provenanceType?: string;
72
76
  }): Promise<{
@@ -16,6 +16,7 @@
16
16
  *
17
17
  * @module v3/cli/memory-bridge
18
18
  */
19
+ import { AppendConditionFailed, assertAppendConditions, validateAppendConditions } from './append-conditions.js';
19
20
  import { liveMemoryRowSql } from './live-memory-row.js';
20
21
  import { resolveAgentdbBetterSqlite3 } from './shared-sqlite.js';
21
22
  import { encodeEmbeddingQ8, MAX_LIST_EMBEDDINGS } from './embedding-q8.js';
@@ -1025,13 +1026,24 @@ export async function bridgeStoreEntry(options) {
1025
1026
  const ctx = getDb(registry);
1026
1027
  if (!ctx)
1027
1028
  return null;
1029
+ if (options.appendOnly && !options.requireNative) {
1030
+ return { success: false, id: '', error: 'Immutable append requires a native writer' };
1031
+ }
1032
+ if (options.appendConditions && (!options.requireNative || !options.appendOnly)) {
1033
+ return { success: false, id: "", error: "Conditional append requires native append-only storage" };
1034
+ }
1035
+ if (options.requireNative && (ctx.agentdb?.isWasm === true || typeof ctx.db.inTransaction !== 'boolean'
1036
+ || typeof ctx.db.transaction !== 'function' || ctx.db.memory !== false || typeof ctx.db.name !== 'string'
1037
+ || !ctx.db.name || canonicalDbPath(ctx.db.name) !== canonicalDbPath(options.dbPath))) {
1038
+ return { success: false, id: '', error: 'Native memory writer required; refusing non-native bridge driver' };
1039
+ }
1028
1040
  try {
1029
1041
  const { key, value, namespace = 'default', tags = [], ttl } = options;
1030
1042
  let provenanceType = options.provenanceType ?? 'unknown';
1031
1043
  // An omitted type on an upsert means "update the value", not "erase the
1032
1044
  // existing trust label". New rows still receive the backward-compatible
1033
1045
  // unknown default.
1034
- if (options.upsert && options.provenanceType === undefined) {
1046
+ if (options.upsert && !options.appendOnly && options.provenanceType === undefined) {
1035
1047
  try {
1036
1048
  const existing = ctx.db.prepare('SELECT provenance_type FROM memory_entries WHERE namespace = ? AND key = ? LIMIT 1').get(namespace, key);
1037
1049
  provenanceType = existing?.provenance_type || 'unknown';
@@ -1089,7 +1101,12 @@ export async function bridgeStoreEntry(options) {
1089
1101
  // below → typed "already exists"
1090
1102
  // error, NEVER a null demotion).
1091
1103
  // Upsert path (INSERT OR REPLACE) is unchanged.
1092
- const insertSql = options.upsert
1104
+ const immutableInsertSql = `INSERT INTO memory_entries (
1105
+ id, key, namespace, content, type,
1106
+ embedding, embedding_dimensions, embedding_model,
1107
+ tags, metadata, provenance_type, created_at, updated_at, expires_at, status
1108
+ ) VALUES (?, ?, ?, ?, 'semantic', ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active')`;
1109
+ const insertSql = options.appendOnly ? immutableInsertSql : options.upsert
1093
1110
  ? `INSERT OR REPLACE INTO memory_entries (
1094
1111
  id, key, namespace, content, type,
1095
1112
  embedding, embedding_dimensions, embedding_model,
@@ -1126,7 +1143,28 @@ export async function bridgeStoreEntry(options) {
1126
1143
  }
1127
1144
  catch { /* vector_indexes may not exist on legacy DBs — fall through */ }
1128
1145
  const stmt = ctx.db.prepare(insertSql);
1129
- const runResult = stmt.run(id, key, namespace, value, embeddingJson, dimensions || null, model, tags.length > 0 ? JSON.stringify(tags) : null, '{}', provenanceType, now, now, ttl ? now + (ttl * 1000) : null);
1146
+ const insert = () => stmt.run(id, key, namespace, value, embeddingJson, dimensions || null, model, tags.length > 0 ? JSON.stringify(tags) : null, '{}', provenanceType, now, now, ttl ? now + (ttl * 1000) : null);
1147
+ let runResult;
1148
+ try {
1149
+ runResult = options.appendOnly
1150
+ ? ctx.db.transaction(() => {
1151
+ // Legacy native tables may predate UNIQUE(namespace,key). Serialize the exact
1152
+ // logical-slot check with INSERT, including tombstones, instead of trusting DDL.
1153
+ if (ctx.db.prepare('SELECT key FROM memory_entries WHERE namespace = ? AND key = ?').all(namespace, key).length) {
1154
+ throw new AppendConditionFailed('immutable append rejected: logical key already exists');
1155
+ }
1156
+ if (options.appendConditions)
1157
+ assertAppendConditions(ctx.db, validateAppendConditions(options.appendConditions));
1158
+ return insert();
1159
+ }).immediate()
1160
+ : insert();
1161
+ }
1162
+ catch (error) {
1163
+ if (error instanceof AppendConditionFailed || error instanceof TypeError) {
1164
+ return { success: false, id: '', error: error.message };
1165
+ }
1166
+ throw error;
1167
+ }
1130
1168
  // A completed native write proves the bridge is currently healthy. Do not
1131
1169
  // retain a diagnostic from an earlier transient failure and append it to a
1132
1170
  // later, unrelated sql.js fallback refusal.
@@ -1143,18 +1181,21 @@ export async function bridgeStoreEntry(options) {
1143
1181
  error: `key "${key}" already exists in namespace "${namespace}" — pass upsert=true (--upsert on the CLI) to update it`,
1144
1182
  };
1145
1183
  }
1184
+ // A non-vector immutable INSERT cannot change the vector count, even with concurrent
1185
+ // writers. Unlike historical strict mode, it never resurrects a vector tombstone.
1146
1186
  // #2558: keep `vector_indexes.total_vectors` accurate so status/tooling
1147
1187
  // stop reporting "HNSW index: 0 vectors" while embedded entries exist.
1148
- try {
1149
- ctx.db
1150
- .prepare(`UPDATE vector_indexes SET
1188
+ if (!(options.appendOnly && embeddingJson === null))
1189
+ try {
1190
+ ctx.db
1191
+ .prepare(`UPDATE vector_indexes SET
1151
1192
  total_vectors = (SELECT COUNT(*) FROM memory_entries
1152
1193
  WHERE namespace = ? AND embedding IS NOT NULL),
1153
1194
  updated_at = ?
1154
1195
  WHERE name = ?`)
1155
- .run(namespace, now, namespace);
1156
- }
1157
- catch { /* vector_indexes may not exist on legacy DBs — non-fatal */ }
1196
+ .run(namespace, now, namespace);
1197
+ }
1198
+ catch { /* vector_indexes may not exist on legacy DBs — non-fatal */ }
1158
1199
  // #2558: better-sqlite3 opens the DB in WAL mode and, for the small write
1159
1200
  // volumes typical of CLI usage, may never reach the auto-checkpoint
1160
1201
  // threshold — leaving committed rows only in the -wal file. WAL-blind
@@ -8,6 +8,7 @@
8
8
  *
9
9
  * @module v3/cli/memory-initializer
10
10
  */
11
+ import type { AppendCondition } from './append-conditions.js';
11
12
  import { type EmbeddingQ8 } from './embedding-q8.js';
12
13
  /**
13
14
  * ADR-323 — typed memory provenance. Distinguishes WHO/WHAT wrote a memory
@@ -442,6 +443,9 @@ export declare function storeEntry(options: {
442
443
  ttl?: number;
443
444
  dbPath?: string;
444
445
  upsert?: boolean;
446
+ requireNative?: boolean;
447
+ appendOnly?: boolean;
448
+ appendConditions?: AppendCondition[];
445
449
  /** ADR-323: defaults to 'unknown' when omitted. */
446
450
  provenanceType?: string;
447
451
  }): Promise<{