@claude-flow/cli 3.55.0 → 3.56.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.
Files changed (64) 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/daemon.js +11 -19
  8. package/dist/src/commands/doctor.js +65 -58
  9. package/dist/src/commands/memory-backup.d.ts +1 -1
  10. package/dist/src/commands/memory-backup.js +23 -9
  11. package/dist/src/commands/memory.d.ts +0 -4
  12. package/dist/src/commands/memory.js +10 -2
  13. package/dist/src/commands/plugins.js +13 -1
  14. package/dist/src/commands/task.js +12 -0
  15. package/dist/src/init/settings-generator.js +3 -3
  16. package/dist/src/mcp-tools/agent-execute-core.js +244 -188
  17. package/dist/src/mcp-tools/agent-tools.js +15 -16
  18. package/dist/src/mcp-tools/agentdb-tools.js +22 -3
  19. package/dist/src/mcp-tools/claims-tools.js +3 -11
  20. package/dist/src/mcp-tools/hooks-tools.js +47 -2
  21. package/dist/src/mcp-tools/memory-tools.js +29 -10
  22. package/dist/src/mcp-tools/record-store.d.ts +3 -0
  23. package/dist/src/mcp-tools/record-store.js +21 -0
  24. package/dist/src/mcp-tools/session-tools.js +47 -13
  25. package/dist/src/mcp-tools/task-tools.js +5 -11
  26. package/dist/src/mcp-tools/workflow-tools.js +78 -29
  27. package/dist/src/memory/append-conditions.d.ts +28 -0
  28. package/dist/src/memory/append-conditions.js +56 -0
  29. package/dist/src/memory/graph-edge-writer.d.ts +9 -0
  30. package/dist/src/memory/graph-edge-writer.js +90 -0
  31. package/dist/src/memory/memory-bridge.d.ts +4 -0
  32. package/dist/src/memory/memory-bridge.js +50 -9
  33. package/dist/src/memory/memory-initializer.d.ts +4 -0
  34. package/dist/src/memory/memory-initializer.js +95 -61
  35. package/dist/src/memory/memory-root.d.ts +2 -0
  36. package/dist/src/memory/memory-root.js +28 -0
  37. package/dist/src/memory/sona-optimizer.d.ts +2 -0
  38. package/dist/src/memory/sona-optimizer.js +4 -0
  39. package/dist/src/parser.js +7 -0
  40. package/dist/src/plugins/store/discovery.d.ts +6 -0
  41. package/dist/src/plugins/store/discovery.js +7 -3
  42. package/dist/src/ruvector/neural-router.js +7 -2
  43. package/dist/src/services/claim-service.js +39 -18
  44. package/dist/src/services/claims-store.d.ts +3 -0
  45. package/dist/src/services/claims-store.js +40 -0
  46. package/dist/src/services/distill-tuning.js +17 -10
  47. package/dist/src/services/memory-backup.d.ts +7 -0
  48. package/dist/src/services/memory-backup.js +29 -2
  49. package/dist/src/services/memory-distillation.js +2 -2
  50. package/dist/src/services/worker-daemon.d.ts +4 -2
  51. package/dist/src/services/worker-daemon.js +14 -6
  52. package/node_modules/@claude-flow/codex/dist/cli.js +0 -0
  53. package/node_modules/@claude-flow/security/dist/oauth/callback-server.d.ts +2 -1
  54. package/node_modules/@claude-flow/security/dist/oauth/callback-server.d.ts.map +1 -1
  55. package/node_modules/@claude-flow/security/dist/oauth/callback-server.js +48 -16
  56. package/node_modules/@claude-flow/security/dist/oauth/callback-server.js.map +1 -1
  57. package/node_modules/@claude-flow/security/dist/path-validator.d.ts +2 -2
  58. package/node_modules/@claude-flow/security/dist/path-validator.d.ts.map +1 -1
  59. package/node_modules/@claude-flow/security/dist/path-validator.js +15 -4
  60. package/node_modules/@claude-flow/security/dist/path-validator.js.map +1 -1
  61. package/node_modules/@claude-flow/security/package.json +1 -1
  62. package/package.json +4 -4
  63. package/plugins/ruflo-metaharness/CHANGELOG.md +48 -0
  64. package/.claude/agents/tmp.json +0 -0
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
8
8
  import { join } from 'node:path';
9
+ import { readRecordStore } from './record-store.js';
9
10
  import { getProjectCwd } from './types.js';
10
11
  import { validateIdentifier, validateText, validateAgentSpawn } from './validate-input.js';
11
12
  import { executeAgentTask } from './agent-execute-core.js';
@@ -32,17 +33,7 @@ function ensureAgentDir() {
32
33
  }
33
34
  }
34
35
  function loadAgentStore() {
35
- try {
36
- const path = getAgentPath();
37
- if (existsSync(path)) {
38
- const data = readFileSync(path, 'utf-8');
39
- return JSON.parse(data);
40
- }
41
- }
42
- catch {
43
- // Return empty store on error
44
- }
45
- return { agents: {}, version: '3.0.0' };
36
+ return readRecordStore(getAgentPath(), 'agents', () => ({ agents: {}, version: '3.0.0' }));
46
37
  }
47
38
  function saveAgentStore(store) {
48
39
  ensureAgentDir();
@@ -247,7 +238,6 @@ export const agentTools = [
247
238
  if (!validation.valid) {
248
239
  return { success: false, error: `Input validation failed: ${validation.errors.join('; ')}` };
249
240
  }
250
- const store = loadAgentStore();
251
241
  const agentId = input.agentId || `agent-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
252
242
  const agentType = input.agentType;
253
243
  const config = input.config || {};
@@ -285,6 +275,7 @@ export const agentTools = [
285
275
  : routingResult.provider ? { provider: routingResult.provider } : {}),
286
276
  ...(routingResult.openrouterModel ? { openrouterModel: routingResult.openrouterModel } : {}),
287
277
  };
278
+ const store = loadAgentStore();
288
279
  store.agents[agentId] = agent;
289
280
  saveAgentStore(store);
290
281
  // #2085 — also push to the swarm store's agents array so that
@@ -337,8 +328,13 @@ export const agentTools = [
337
328
  memoryBranch = br.branchPath;
338
329
  agent.memoryBranch = br.branchPath;
339
330
  agent.memoryBase = br.basePath;
340
- store.agents[agentId] = agent;
341
- saveAgentStore(store);
331
+ const latest = loadAgentStore();
332
+ const current = latest.agents[agentId];
333
+ if (current && current.status !== 'terminated') {
334
+ current.memoryBranch = br.branchPath;
335
+ current.memoryBase = br.basePath;
336
+ saveAgentStore(latest);
337
+ }
342
338
  }
343
339
  // else: degraded (agenticow missing / kill-switched) — agent stands
344
340
  // without an isolated branch; callers see no memoryBranch field.
@@ -629,7 +625,10 @@ export const agentTools = [
629
625
  };
630
626
  }
631
627
  if (action === 'scale') {
632
- const targetSize = input.targetSize || 5;
628
+ const targetSize = input.targetSize === undefined ? 5 : input.targetSize;
629
+ if (typeof targetSize !== 'number' || !Number.isSafeInteger(targetSize) || targetSize < 0) {
630
+ return { action, error: 'targetSize must be a non-negative safe integer' };
631
+ }
633
632
  const agentType = input.agentType || 'worker';
634
633
  const currentSize = agents.filter(a => a.agentType === agentType).length;
635
634
  const delta = targetSize - currentSize;
@@ -663,7 +662,7 @@ export const agentTools = [
663
662
  agentType,
664
663
  previousSize: currentSize,
665
664
  targetSize,
666
- newSize: currentSize + delta,
665
+ newSize: currentSize + added.length - removed.length,
667
666
  added,
668
667
  removed,
669
668
  };
@@ -61,6 +61,25 @@ async function getGraphEdgeWriter() {
61
61
  graphEdgeWriterMod = await import('../memory/graph-edge-writer.js');
62
62
  return graphEdgeWriterMod;
63
63
  }
64
+ // #2889 — a null graph_edges DB handle can mean several unrelated things
65
+ // (missing native module, permissions, genuine corruption, or an RFE1
66
+ // encrypted-at-rest store). Recommending `memory init` for the encrypted
67
+ // case is actively harmful — it creates a fresh EMPTY store instead of
68
+ // using the existing encrypted data — so distinguish it before choosing
69
+ // a hint.
70
+ async function bridgeUnavailableResult() {
71
+ const { isBridgeDbEncryptedAtRest } = await getGraphEdgeWriter();
72
+ if (isBridgeDbEncryptedAtRest()) {
73
+ return {
74
+ error: 'graph_edges DB is encrypted at rest (RFE1) — the native bridge cannot open it directly.',
75
+ hint: 'Your existing data is intact; this is a known limitation, not corruption. Do NOT run `memory init` — see https://github.com/ruvnet/ruflo/issues/2889.',
76
+ };
77
+ }
78
+ return {
79
+ error: 'graph_edges DB unavailable (sql.js could not load)',
80
+ hint: 'Check Node version + try `ruflo memory init` to initialize manually.',
81
+ };
82
+ }
64
83
  let memInitMod = null;
65
84
  async function getMemInit() {
66
85
  if (!memInitMod)
@@ -1152,7 +1171,7 @@ export const agentdbGraphQuery = {
1152
1171
  // fresh environments work without a pre-existing memory init.
1153
1172
  const db = await getBridgeDb(undefined, { createIfMissing: true });
1154
1173
  if (!db)
1155
- return { success: false, error: 'graph_edges DB unavailable (sql.js could not load)', hint: 'Check Node version + try `ruflo memory init` to initialize manually.', mode, nodeId };
1174
+ return { success: false, ...(await bridgeUnavailableResult()), mode, nodeId };
1156
1175
  // Load all rows with embedding_ref and score by cosine.
1157
1176
  // better-sqlite3 API — `db.exec(sql, params)` (sql.js) silently
1158
1177
  // throws "datatype mismatch" because exec ignores params, so `?`
@@ -1194,7 +1213,7 @@ export const agentdbGraphQuery = {
1194
1213
  // fresh environments work without a pre-existing memory init.
1195
1214
  const db = await getBridgeDb(undefined, { createIfMissing: true });
1196
1215
  if (!db)
1197
- return { success: false, error: 'graph_edges DB unavailable (sql.js could not load)', hint: 'Check Node version + try `ruflo memory init` to initialize manually.', mode, nodeId };
1216
+ return { success: false, ...(await bridgeUnavailableResult()), mode, nodeId };
1198
1217
  // better-sqlite3 API — see semantic-mode comment above.
1199
1218
  const edges = db.prepare(`SELECT source_id, target_id, weight FROM graph_edges LIMIT ?`).raw().all(budget.maxNodesVisited);
1200
1219
  if (edges.length === 0) {
@@ -1380,7 +1399,7 @@ export const agentdbGraphPathfinder = {
1380
1399
  // #2246 fix: lazy-create memory.db on first pathfinder call.
1381
1400
  const db = await getBridgeDb(undefined, { createIfMissing: true });
1382
1401
  if (!db)
1383
- return { success: false, error: 'graph_edges DB unavailable (sql.js could not load)', hint: 'Check Node version + try `ruflo memory init` to initialize manually.', seedNodeId };
1402
+ return { success: false, ...(await bridgeUnavailableResult()), seedNodeId };
1384
1403
  const colsSql = algorithm === 'witness-chain-divergence'
1385
1404
  ? 'source_id, target_id, weight, last_reinforced, witness_id'
1386
1405
  : algorithm === 'temporal-centrality'
@@ -6,9 +6,10 @@
6
6
  *
7
7
  * @module @claude-flow/cli/mcp-tools/claims
8
8
  */
9
+ import { readClaimsStore } from '../services/claims-store.js';
9
10
  import { validateIdentifier, validateText } from './validate-input.js';
10
11
  // File-based persistence
11
- import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
12
+ import { existsSync, writeFileSync, mkdirSync } from 'fs';
12
13
  import { join, resolve } from 'path';
13
14
  const CLAIMS_DIR = '.claude-flow/claims';
14
15
  const CLAIMS_FILE = 'claims.json';
@@ -22,16 +23,7 @@ function ensureClaimsDir() {
22
23
  }
23
24
  }
24
25
  function loadClaims() {
25
- try {
26
- const path = getClaimsPath();
27
- if (existsSync(path)) {
28
- return JSON.parse(readFileSync(path, 'utf-8'));
29
- }
30
- }
31
- catch {
32
- // Return empty store on error
33
- }
34
- return { claims: {}, stealable: {}, contests: {} };
26
+ return readClaimsStore(getClaimsPath());
35
27
  }
36
28
  function saveClaims(store) {
37
29
  ensureClaimsDir();
@@ -1154,14 +1154,59 @@ export const hooksRoute = {
1154
1154
  catch {
1155
1155
  // AgentDB router not available — fall through to local routing
1156
1156
  }
1157
+ // Trajectory learning persists SONA outcomes separately from the local
1158
+ // semantic index. Only consume an eligible learned pattern here; native,
1159
+ // Q-learning and keyword suggestions retain their existing routing paths.
1160
+ try {
1161
+ const sonaStart = performance.now();
1162
+ const sona = await getSONAOptimizer();
1163
+ const suggestion = await sona?.getRoutingSuggestion(task);
1164
+ const { isSupportedSonaAgent } = await import('../memory/sona-optimizer.js');
1165
+ if (suggestion?.source === 'sona-pattern'
1166
+ && Number.isFinite(suggestion.confidence)
1167
+ && suggestion.confidence >= 0.6 && suggestion.confidence <= 1
1168
+ && isSupportedSonaAgent(suggestion.agent)) {
1169
+ const confidence = Math.round(suggestion.confidence * 100) / 100;
1170
+ const complexity = task.length > 200 ? 'high' : task.length < 50 ? 'low' : 'medium';
1171
+ return {
1172
+ task,
1173
+ routing: { method: 'sona-pattern', backend: 'SONA persisted keyword patterns', latencyMs: performance.now() - sonaStart, throughput: 'N/A' },
1174
+ matched: true,
1175
+ matchedPattern: 'sona-pattern',
1176
+ semanticMatches: [],
1177
+ primaryAgent: {
1178
+ type: suggestion.agent,
1179
+ confidence,
1180
+ reason: `Persisted SONA outcome pattern (${Math.round(suggestion.confidence * 100)}% confidence)`,
1181
+ },
1182
+ alternativeAgents: suggestion.alternatives
1183
+ .filter(alternative => isSupportedSonaAgent(alternative.agent) && Number.isFinite(alternative.score))
1184
+ .map(alternative => ({
1185
+ type: alternative.agent,
1186
+ confidence: Math.round(alternative.score * 100) / 100,
1187
+ reason: 'Alternative from persisted SONA patterns',
1188
+ })),
1189
+ estimatedMetrics: {
1190
+ successProbability: confidence,
1191
+ estimatedDuration: complexity === 'high' ? '2-4 hours' : complexity === 'medium' ? '30-60 min' : '10-30 min',
1192
+ complexity,
1193
+ },
1194
+ swarmRecommendation: null,
1195
+ learning: { source: suggestion.source, matchedKeywords: suggestion.matchedKeywords },
1196
+ };
1197
+ }
1198
+ }
1199
+ catch {
1200
+ // Optional SONA state unavailable — preserve the existing local route.
1201
+ }
1157
1202
  }
1158
1203
  return routeTaskLocal(task, context, useSemanticRouter);
1159
1204
  }),
1160
1205
  };
1161
1206
  /**
1162
1207
  * hooks_route's local routing (semantic index + keyword fallback), run after the
1163
- * AgentDB pre-route. Shared with {@link routeTaskForBench} so the benchmark
1164
- * measures exactly the path hooks_route takes. (Body kept at its original
1208
+ * AgentDB and eligible SONA pre-routes. Shared with {@link routeTaskForBench}
1209
+ * so the benchmark measures this local path. (Body kept at its original
1165
1210
  * indentation to keep this refactor's diff small.)
1166
1211
  */
1167
1212
  async function routeTaskLocal(task, context, useSemanticRouter, embedderOverride) {
@@ -1500,7 +1500,21 @@ export const memoryTools = [
1500
1500
  // entry body. Without this, `value` is always null because listEntries
1501
1501
  // strips content by default (callers pay for the JSON parse only when
1502
1502
  // they need it).
1503
- const all = await listEntries({ limit: 100000, namespace, includeContent: true });
1503
+ // A successful export must cover the whole reported store. Read all
1504
+ // pages before touching an existing snapshot, including on read failure.
1505
+ const entries = [];
1506
+ let total;
1507
+ do {
1508
+ const page = await listEntries({ limit: 10000, offset: entries.length, namespace, includeContent: true });
1509
+ if (!page.success)
1510
+ return { error: page.error || 'Could not read memory for export' };
1511
+ total ??= page.total;
1512
+ if (page.entries.length === 0 && entries.length < total) {
1513
+ return { error: `Incomplete memory export: read ${entries.length} of ${total} entries` };
1514
+ }
1515
+ entries.push(...page.entries);
1516
+ } while (entries.length < total);
1517
+ const all = { entries };
1504
1518
  const payload = {
1505
1519
  schema: 'ruflo-memory-export/v1',
1506
1520
  exportedAt: new Date().toISOString(),
@@ -1595,6 +1609,8 @@ export const memoryTools = [
1595
1609
  }
1596
1610
  let imported = 0;
1597
1611
  let skipped = 0;
1612
+ let failed = 0;
1613
+ const errors = [];
1598
1614
  for (const e of entries) {
1599
1615
  if (!e || typeof e.key !== 'string') {
1600
1616
  skipped++;
@@ -1603,19 +1619,22 @@ export const memoryTools = [
1603
1619
  const value = typeof e.value === 'string' ? e.value : JSON.stringify(e.value ?? null);
1604
1620
  try {
1605
1621
  const result = await storeEntry({ key: e.key, value, namespace: nsOverride ?? e.namespace ?? 'default', upsert: input.merge !== false, dbPath });
1606
- if (result.success) {
1607
- imported++;
1608
- if (result.embedding)
1609
- vectors++;
1610
- }
1611
- else
1612
- skipped++;
1622
+ if (!result.success)
1623
+ throw new Error(result.error || 'Memory write was rejected');
1624
+ imported++;
1625
+ if (result.embedding)
1626
+ vectors++;
1613
1627
  }
1614
- catch {
1615
- skipped++;
1628
+ catch (error) {
1629
+ failed++;
1630
+ if (errors.length < 10)
1631
+ errors.push(`${e.key}: ${error instanceof Error ? error.message : String(error)}`);
1616
1632
  }
1617
1633
  }
1618
1634
  return {
1635
+ success: failed === 0,
1636
+ error: failed > 0 ? `${failed} memory write(s) failed: ${errors.join('; ')}` : undefined,
1637
+ failed,
1619
1638
  inputPath,
1620
1639
  imported: { entries: imported, vectors, patterns: 0 },
1621
1640
  skipped,
@@ -0,0 +1,3 @@
1
+ /** Only an absent store is empty. Failed reads must never authorize replacement. */
2
+ export declare function readRecordStore<T>(path: string, collection: string, empty: () => T): T;
3
+ //# sourceMappingURL=record-store.d.ts.map
@@ -0,0 +1,21 @@
1
+ import { readFileSync } from 'node:fs';
2
+ /** Only an absent store is empty. Failed reads must never authorize replacement. */
3
+ export function readRecordStore(path, collection, empty) {
4
+ let text;
5
+ try {
6
+ text = readFileSync(path, 'utf-8');
7
+ }
8
+ catch (error) {
9
+ if (error.code === 'ENOENT')
10
+ return empty();
11
+ throw error;
12
+ }
13
+ const store = JSON.parse(text);
14
+ const records = store?.[collection];
15
+ if (!store || typeof store !== 'object' || Array.isArray(store)
16
+ || !records || typeof records !== 'object' || Array.isArray(records)) {
17
+ throw new Error(`Invalid ${collection} store at ${path}; existing data was preserved`);
18
+ }
19
+ return store;
20
+ }
21
+ //# sourceMappingURL=record-store.js.map
@@ -10,6 +10,33 @@ import { join } from 'node:path';
10
10
  import { getProjectCwd } from './types.js';
11
11
  import { mkdirRestricted, readFileMaybeEncrypted, writeFileRestricted, } from '../fs-secure.js';
12
12
  import { validateIdentifier, validateText } from './validate-input.js';
13
+ function isRecord(value) {
14
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
15
+ }
16
+ // Imported snapshots become live stores on restore. Validate their containers
17
+ // before registering them, while allowing older metadata-only snapshots.
18
+ function validateImportedSession(value) {
19
+ if (!isRecord(value) || (typeof value.name !== 'string' && !isSessionRecordLike(value))) {
20
+ return 'Invalid session: Not a session record; expected a named snapshot or snapshot data';
21
+ }
22
+ if (value.name !== undefined && typeof value.name !== 'string') {
23
+ return 'Invalid session: name must be a string';
24
+ }
25
+ if (value.data === undefined)
26
+ return undefined;
27
+ if (!isRecord(value.data))
28
+ return 'Invalid session: data must be an object';
29
+ for (const [component, collection] of [['tasks', 'tasks'], ['agents', 'agents'], ['memory', 'entries']]) {
30
+ const store = value.data[component];
31
+ if (store === undefined)
32
+ continue;
33
+ if (!isRecord(store) || !isRecord(store[collection]) ||
34
+ Object.values(store[collection]).some(record => !isRecord(record))) {
35
+ return `Invalid session: data.${component}.${collection} must be a record map`;
36
+ }
37
+ }
38
+ return undefined;
39
+ }
13
40
  // Storage paths
14
41
  const STORAGE_DIR = '.claude-flow';
15
42
  const SESSION_DIR = 'sessions';
@@ -306,9 +333,9 @@ export const sessionTools = [
306
333
  properties: {
307
334
  name: { type: 'string', description: 'Session name' },
308
335
  description: { type: 'string', description: 'Session description' },
309
- includeMemory: { type: 'boolean', description: 'Include memory in session' },
310
- includeTasks: { type: 'boolean', description: 'Include tasks in session' },
311
- includeAgents: { type: 'boolean', description: 'Include agents in session' },
336
+ includeMemory: { type: 'boolean', default: true, description: 'Include memory in session' },
337
+ includeTasks: { type: 'boolean', default: true, description: 'Include tasks in session' },
338
+ includeAgents: { type: 'boolean', default: true, description: 'Include agents in session' },
312
339
  },
313
340
  required: ['name'],
314
341
  },
@@ -325,9 +352,9 @@ export const sessionTools = [
325
352
  const sessionId = `session-${Date.now()}-${randomUUID().slice(0, 8)}`;
326
353
  // Load related data based on options
327
354
  const { data, memoryCapture } = await loadRelatedStores({
328
- includeMemory: input.includeMemory,
329
- includeTasks: input.includeTasks,
330
- includeAgents: input.includeAgents,
355
+ includeMemory: input.includeMemory !== false,
356
+ includeTasks: input.includeTasks !== false,
357
+ includeAgents: input.includeAgents !== false,
331
358
  });
332
359
  // Calculate stats
333
360
  const stats = {
@@ -692,19 +719,26 @@ export const sessionTools = [
692
719
  return { error: `Invalid session JSON: ${e.message}` };
693
720
  }
694
721
  }
695
- if (!isSessionRecordLike(parsed)) {
696
- return { error: 'Not a session record: expected an object produced by session export' };
697
- }
722
+ const validationError = validateImportedSession(parsed);
723
+ if (validationError)
724
+ return { error: validationError };
725
+ const record = parsed;
698
726
  const newId = `session-${Date.now()}-${randomUUID().slice(0, 8)}`;
699
- const stats = { tasks: 0, agents: 0, memoryEntries: 0, totalSize: 0, ...(parsed.stats || {}) };
727
+ const stats = {
728
+ tasks: Object.keys(record.data?.tasks?.tasks || {}).length,
729
+ agents: Object.keys(record.data?.agents?.agents || {}).length,
730
+ memoryEntries: countMemoryEntries(record.data?.memory),
731
+ totalSize: 0,
732
+ };
700
733
  const session = {
701
734
  sessionId: newId,
702
- name: input.name ? String(input.name) : (parsed.name || 'imported-session'),
703
- description: parsed.description,
735
+ name: input.name ? String(input.name) : (record.name || 'imported-session'),
736
+ description: record.description,
704
737
  savedAt: new Date().toISOString(),
705
738
  stats,
706
- data: parsed.data,
739
+ data: record.data,
707
740
  };
741
+ session.stats.totalSize = Buffer.byteLength(JSON.stringify(session), 'utf-8');
708
742
  saveSession(session);
709
743
  let activated = false;
710
744
  if (input.activate === true) {
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
7
7
  import { join } from 'node:path';
8
+ import { readRecordStore } from './record-store.js';
8
9
  import { getProjectCwd } from './types.js';
9
10
  import { validateIdentifier, validateText } from './validate-input.js';
10
11
  // Storage paths
@@ -24,17 +25,7 @@ function ensureTaskDir() {
24
25
  }
25
26
  }
26
27
  function loadTaskStore() {
27
- try {
28
- const path = getTaskPath();
29
- if (existsSync(path)) {
30
- const data = readFileSync(path, 'utf-8');
31
- return JSON.parse(data);
32
- }
33
- }
34
- catch {
35
- // Return empty store on error
36
- }
37
- return { tasks: {}, version: '3.0.0' };
28
+ return readRecordStore(getTaskPath(), 'tasks', () => ({ tasks: {}, version: '3.0.0' }));
38
29
  }
39
30
  function saveTaskStore(store) {
40
31
  ensureTaskDir();
@@ -378,6 +369,9 @@ export const taskTools = [
378
369
  return { taskId, error: 'Task not found' };
379
370
  }
380
371
  const previouslyAssigned = [...task.assignedTo];
372
+ // Load agent store to sync worker state
373
+ const agentStorePath = join(getProjectCwd(), STORAGE_DIR, 'agents', 'store.json');
374
+ readRecordStore(agentStorePath, 'agents', () => ({ agents: {} }));
381
375
  if (input.unassign) {
382
376
  // Revert previously assigned agents to idle
383
377
  releaseAgents(previouslyAssigned, taskId);
@@ -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
  {