@claude-flow/cli 3.68.0 → 3.70.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/skills/agentdb-advanced/SKILL.md +1 -1
  3. package/.claude/skills/agentdb-learning/SKILL.md +1 -1
  4. package/.claude/skills/agentdb-memory-patterns/SKILL.md +1 -1
  5. package/.claude/skills/agentdb-optimization/SKILL.md +1 -1
  6. package/.claude/skills/agentdb-vector-search/SKILL.md +1 -1
  7. package/.claude/skills/hooks-automation/SKILL.md +1 -1
  8. package/.claude/skills/pair-programming/SKILL.md +1 -1
  9. package/.claude/skills/reasoningbank-agentdb/SKILL.md +1 -1
  10. package/.claude/skills/reasoningbank-intelligence/SKILL.md +1 -1
  11. package/.claude/skills/skill-builder/SKILL.md +1 -1
  12. package/.claude/skills/swarm-orchestration/SKILL.md +1 -1
  13. package/.claude/skills/v3-cli-modernization/SKILL.md +1 -1
  14. package/.claude/skills/v3-core-implementation/SKILL.md +1 -1
  15. package/.claude/skills/v3-ddd-architecture/SKILL.md +1 -1
  16. package/.claude/skills/v3-integration-deep/SKILL.md +1 -1
  17. package/.claude/skills/v3-mcp-optimization/SKILL.md +1 -1
  18. package/.claude/skills/v3-memory-unification/SKILL.md +1 -1
  19. package/.claude/skills/v3-performance-optimization/SKILL.md +1 -1
  20. package/.claude/skills/v3-security-overhaul/SKILL.md +1 -1
  21. package/.claude/skills/v3-swarm-coordination/SKILL.md +1 -1
  22. package/.claude/skills/verification-quality/SKILL.md +1 -1
  23. package/catalog-manifest.json +3 -3
  24. package/dist/src/commands/context-plan.d.ts +4 -0
  25. package/dist/src/commands/context-plan.js +36 -0
  26. package/dist/src/commands/hooks.js +20 -4
  27. package/dist/src/commands/workflow.js +3 -1
  28. package/dist/src/index.d.ts +4 -0
  29. package/dist/src/index.js +3 -0
  30. package/dist/src/mcp-tools/agent-tools.js +31 -14
  31. package/dist/src/mcp-tools/agentdb-episode-tools.d.ts +16 -0
  32. package/dist/src/mcp-tools/agentdb-episode-tools.js +214 -0
  33. package/dist/src/mcp-tools/agentdb-tools.js +2 -0
  34. package/dist/src/mcp-tools/claims-tools.js +7 -3
  35. package/dist/src/mcp-tools/config-tools.js +48 -22
  36. package/dist/src/mcp-tools/coordination-tools.js +5 -2
  37. package/dist/src/mcp-tools/daa-tools.js +12 -5
  38. package/dist/src/mcp-tools/hooks-tools.js +46 -7
  39. package/dist/src/mcp-tools/session-tools.d.ts +10 -0
  40. package/dist/src/mcp-tools/session-tools.js +15 -2
  41. package/dist/src/mcp-tools/workflow-tools.js +9 -4
  42. package/dist/src/memory/episode-crud.d.ts +70 -0
  43. package/dist/src/memory/episode-crud.js +114 -0
  44. package/dist/src/memory/ewc-consolidation.d.ts +7 -0
  45. package/dist/src/memory/ewc-consolidation.js +19 -1
  46. package/dist/src/memory/memory-bridge.d.ts +5 -0
  47. package/dist/src/memory/memory-bridge.js +38 -3
  48. package/dist/src/mods/command-registry/schema.d.ts +6 -6
  49. package/dist/src/services/anthropic-context-adapter.d.ts +81 -0
  50. package/dist/src/services/anthropic-context-adapter.js +257 -0
  51. package/dist/src/services/context-delegation.d.ts +107 -0
  52. package/dist/src/services/context-delegation.js +193 -0
  53. package/node_modules/@claude-flow/codex/dist/cli.js +0 -0
  54. package/node_modules/@claude-flow/plugin-agent-federation/dist/application/policy-engine.d.ts +1 -0
  55. package/node_modules/@claude-flow/plugin-agent-federation/dist/application/policy-engine.d.ts.map +1 -1
  56. package/node_modules/@claude-flow/plugin-agent-federation/dist/application/policy-engine.js +8 -0
  57. package/node_modules/@claude-flow/plugin-agent-federation/dist/application/policy-engine.js.map +1 -1
  58. package/node_modules/@claude-flow/plugin-agent-federation/dist/bin.js +0 -0
  59. package/node_modules/@claude-flow/plugin-agent-federation/dist/domain/entities/trust-level.d.ts.map +1 -1
  60. package/node_modules/@claude-flow/plugin-agent-federation/dist/domain/entities/trust-level.js +9 -2
  61. package/node_modules/@claude-flow/plugin-agent-federation/dist/domain/entities/trust-level.js.map +1 -1
  62. package/node_modules/@claude-flow/security/dist/input-validator.d.ts +6 -6
  63. package/package.json +5 -5
  64. package/.claude/memory.db +0 -0
@@ -0,0 +1,214 @@
1
+ /**
2
+ * agentdb_episode-get / -list / -update / -delete (ADR-520).
3
+ *
4
+ * Episodes are the records session ends and `memory distill` write and that
5
+ * agentdb_episode-recall searches. Needs agentdb >= 3.3.0 (in-place update that keeps the id
6
+ * and re-indexes); older builds get a clear error instead of a partial operation.
7
+ *
8
+ * @module v3/cli/mcp-tools/agentdb-episode-tools
9
+ */
10
+ import { validateText } from './validate-input.js';
11
+ const TEXT_MAX = 50_000;
12
+ const METADATA_MAX_BYTES = 64 * 1024;
13
+ const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
14
+ let crud = null;
15
+ async function episodeCrud() {
16
+ crud ??= await import('../memory/episode-crud.js');
17
+ return crud;
18
+ }
19
+ const unavailable = { success: false, error: 'AgentDB ReflexionMemory not available. Use memory_* tools instead.' };
20
+ function idOf(value, parse) {
21
+ const id = parse(value);
22
+ return id ?? { error: 'id must be a positive integer' };
23
+ }
24
+ function hasForbiddenKey(value, depth = 0) {
25
+ if (depth > 16)
26
+ return true;
27
+ if (!value || typeof value !== 'object')
28
+ return false;
29
+ for (const [k, v] of Object.entries(value)) {
30
+ if (FORBIDDEN_KEYS.has(k) || hasForbiddenKey(v, depth + 1))
31
+ return true;
32
+ }
33
+ return false;
34
+ }
35
+ /** Optional text field: undefined = leave alone, null = clear, string = set. */
36
+ function optionalText(value, name, nullable) {
37
+ if (value === undefined)
38
+ return {};
39
+ if (value === null)
40
+ return nullable ? { value: null } : { error: `${name} cannot be null` };
41
+ const v = validateText(value, name, TEXT_MAX);
42
+ if (!v.valid)
43
+ return { error: v.error };
44
+ if (!nullable && !v.sanitized.trim())
45
+ return { error: `${name} cannot be empty` };
46
+ return { value: v.sanitized };
47
+ }
48
+ export const agentdbEpisodeGet = {
49
+ name: 'agentdb_episode-get',
50
+ description: 'Get one past episode (a session summary or distilled trajectory) by id. Use when you already have an episode id from agentdb_episode-recall or agentdb_episode-list and need its full record; memory_retrieve is wrong here because episodes are not key-value entries. Returns episode: null when missing. Needs agentdb >= 3.3.0.',
51
+ inputSchema: {
52
+ type: 'object',
53
+ properties: { id: { type: 'number', description: 'Episode id' } },
54
+ required: ['id'],
55
+ },
56
+ handler: async (params) => {
57
+ try {
58
+ const m = await episodeCrud();
59
+ const id = idOf(params.id, m.parseEpisodeId);
60
+ if (typeof id !== 'number')
61
+ return { success: false, ...id };
62
+ return (await m.bridgeGetEpisode(id)) ?? unavailable;
63
+ }
64
+ catch (error) {
65
+ return { success: false, error: error instanceof Error ? error.message.slice(0, 500) : 'Internal error' };
66
+ }
67
+ },
68
+ };
69
+ export const agentdbEpisodeList = {
70
+ name: 'agentdb_episode-list',
71
+ description: 'Page through past episodes, newest first, optionally for one session or only successes/failures, with a total count. Use when you need to browse or audit what was recorded (e.g. every episode from one session); use agentdb_episode-recall instead when you want the episodes most relevant to a task. Needs agentdb >= 3.3.0.',
72
+ inputSchema: {
73
+ type: 'object',
74
+ properties: {
75
+ sessionId: { type: 'string', description: 'Only episodes from this session' },
76
+ success: { type: 'boolean', description: 'Only successes (true) or failures (false)' },
77
+ limit: { type: 'number', description: 'Page size (1-100)', default: 20 },
78
+ offset: { type: 'number', description: 'Episodes to skip', default: 0 },
79
+ order: { type: 'string', enum: ['newest', 'oldest'], default: 'newest' },
80
+ },
81
+ },
82
+ handler: async (params) => {
83
+ try {
84
+ if (params.sessionId !== undefined) {
85
+ const v = validateText(params.sessionId, 'sessionId', 256);
86
+ if (!v.valid)
87
+ return { success: false, error: v.error };
88
+ }
89
+ if (params.success !== undefined && typeof params.success !== 'boolean') {
90
+ return { success: false, error: 'success must be a boolean' };
91
+ }
92
+ const limit = params.limit === undefined ? 20 : Number(params.limit);
93
+ const offset = params.offset === undefined ? 0 : Number(params.offset);
94
+ if (!Number.isInteger(limit) || limit < 1 || limit > 100)
95
+ return { success: false, error: 'limit must be an integer 1-100' };
96
+ if (!Number.isInteger(offset) || offset < 0)
97
+ return { success: false, error: 'offset must be a non-negative integer' };
98
+ if (params.order !== undefined && params.order !== 'newest' && params.order !== 'oldest') {
99
+ return { success: false, error: 'order must be "newest" or "oldest"' };
100
+ }
101
+ const m = await episodeCrud();
102
+ return (await m.bridgeListEpisodes({
103
+ sessionId: params.sessionId,
104
+ success: params.success,
105
+ limit,
106
+ offset,
107
+ order: params.order ?? 'newest',
108
+ })) ?? unavailable;
109
+ }
110
+ catch (error) {
111
+ return { success: false, error: error instanceof Error ? error.message.slice(0, 500) : 'Internal error' };
112
+ }
113
+ },
114
+ };
115
+ export const agentdbEpisodeUpdate = {
116
+ name: 'agentdb_episode-update',
117
+ description: 'Correct a past episode in place, keeping its id: task/critique/output text, reward, success flag, tags or metadata. Use when an outcome has been verified or an episode turned out wrong (e.g. success=false with a critique); deleting and re-recording is wrong because it changes the id. Text changes are re-embedded and re-indexed so recall sees them; metadata merges unless replaceMetadata is true. Needs agentdb >= 3.3.0.',
118
+ inputSchema: {
119
+ type: 'object',
120
+ properties: {
121
+ id: { type: 'number', description: 'Episode id' },
122
+ task: { type: 'string', description: 'New task text' },
123
+ critique: { type: ['string', 'null'], description: 'New critique, or null to clear' },
124
+ output: { type: ['string', 'null'], description: 'New output, or null to clear' },
125
+ reward: { type: 'number', description: 'New reward (0-1)' },
126
+ success: { type: 'boolean', description: 'New success flag' },
127
+ tags: { type: 'array', items: { type: 'string' }, description: 'Replacement tag list' },
128
+ metadata: { type: 'object', description: 'Metadata patch' },
129
+ replaceMetadata: { type: 'boolean', description: 'Replace metadata instead of merging', default: false },
130
+ },
131
+ required: ['id'],
132
+ },
133
+ handler: async (params) => {
134
+ try {
135
+ const allowed = new Set(['id', 'task', 'critique', 'output', 'reward', 'success', 'tags', 'metadata', 'replaceMetadata']);
136
+ const unknown = Object.keys(params).filter(k => !allowed.has(k));
137
+ if (unknown.length)
138
+ return { success: false, error: `unknown field(s): ${unknown.join(', ')}` };
139
+ const m = await episodeCrud();
140
+ const id = idOf(params.id, m.parseEpisodeId);
141
+ if (typeof id !== 'number')
142
+ return { success: false, ...id };
143
+ const patch = {};
144
+ for (const [field, nullable] of [['task', false], ['critique', true], ['output', true]]) {
145
+ const r = optionalText(params[field], field, nullable);
146
+ if (r.error)
147
+ return { success: false, error: r.error };
148
+ if (r.value !== undefined)
149
+ patch[field] = r.value;
150
+ }
151
+ if (params.reward !== undefined) {
152
+ if (typeof params.reward !== 'number' || !Number.isFinite(params.reward) || params.reward < 0 || params.reward > 1) {
153
+ return { success: false, error: 'reward must be a number between 0 and 1' };
154
+ }
155
+ patch.reward = params.reward;
156
+ }
157
+ if (params.success !== undefined) {
158
+ if (typeof params.success !== 'boolean')
159
+ return { success: false, error: 'success must be a boolean' };
160
+ patch.success = params.success;
161
+ }
162
+ if (params.tags !== undefined) {
163
+ if (!Array.isArray(params.tags) || params.tags.length > 64 || params.tags.some(t => typeof t !== 'string' || t.length > 128)) {
164
+ return { success: false, error: 'tags must be up to 64 strings of at most 128 characters' };
165
+ }
166
+ patch.tags = params.tags;
167
+ }
168
+ if (params.metadata !== undefined) {
169
+ const md = params.metadata;
170
+ if (md !== null && (typeof md !== 'object' || Array.isArray(md)))
171
+ return { success: false, error: 'metadata must be an object or null' };
172
+ if (md && hasForbiddenKey(md))
173
+ return { success: false, error: 'metadata contains a forbidden key (__proto__, constructor, prototype) or is nested too deeply' };
174
+ if (md && Buffer.byteLength(JSON.stringify(md), 'utf8') > METADATA_MAX_BYTES)
175
+ return { success: false, error: 'metadata exceeds 64 KB' };
176
+ patch.metadata = md;
177
+ }
178
+ if (Object.keys(patch).length === 0)
179
+ return { success: false, error: 'nothing to update' };
180
+ return (await m.bridgeUpdateEpisode(id, patch, { replaceMetadata: params.replaceMetadata === true })) ?? unavailable;
181
+ }
182
+ catch (error) {
183
+ return { success: false, error: error instanceof Error ? error.message.slice(0, 500) : 'Internal error' };
184
+ }
185
+ },
186
+ };
187
+ export const agentdbEpisodeDelete = {
188
+ name: 'agentdb_episode-delete',
189
+ description: 'Delete a past episode by id, with its embedding and index entries, so recall stops returning it. Use when an episode must not be recalled at all (wrong, duplicate or sensitive); prefer agentdb_episode-update over deleting when it only needs correcting. Irreversible. Needs agentdb >= 3.3.0.',
190
+ inputSchema: {
191
+ type: 'object',
192
+ properties: { id: { type: 'number', description: 'Episode id' } },
193
+ required: ['id'],
194
+ },
195
+ handler: async (params) => {
196
+ try {
197
+ const m = await episodeCrud();
198
+ const id = idOf(params.id, m.parseEpisodeId);
199
+ if (typeof id !== 'number')
200
+ return { success: false, ...id };
201
+ return (await m.bridgeDeleteEpisode(id)) ?? unavailable;
202
+ }
203
+ catch (error) {
204
+ return { success: false, error: error instanceof Error ? error.message.slice(0, 500) : 'Internal error' };
205
+ }
206
+ },
207
+ };
208
+ export const agentdbEpisodeCrudTools = [
209
+ agentdbEpisodeGet,
210
+ agentdbEpisodeList,
211
+ agentdbEpisodeUpdate,
212
+ agentdbEpisodeDelete,
213
+ ];
214
+ //# sourceMappingURL=agentdb-episode-tools.js.map
@@ -1,5 +1,6 @@
1
1
  import { feedbackPatternsSchema, validateFeedbackPatterns } from '../memory/feedback-patterns.js';
2
2
  import { validateIdentifier, validateText } from './validate-input.js';
3
+ import { agentdbEpisodeCrudTools } from './agentdb-episode-tools.js';
3
4
  // ===== Shared validation helpers =====
4
5
  const MAX_STRING_LENGTH = 100_000; // 100KB max for any string input
5
6
  const MAX_BATCH_SIZE = 500; // Max entries per batch operation
@@ -1603,6 +1604,7 @@ export const agentdbTools = [
1603
1604
  agentdbSessionStart,
1604
1605
  agentdbSessionEnd,
1605
1606
  agentdbEpisodeRecall, // ADR-519
1607
+ ...agentdbEpisodeCrudTools, // ADR-520
1606
1608
  agentdbHierarchicalStore,
1607
1609
  agentdbHierarchicalRecall,
1608
1610
  agentdbHierarchicalDelete,
@@ -220,7 +220,7 @@ export const claimsTools = [
220
220
  },
221
221
  progress: {
222
222
  type: 'number',
223
- description: 'Current progress percentage (0-100)',
223
+ description: 'Current progress percentage (0-100); omit to keep the recorded progress',
224
224
  },
225
225
  },
226
226
  required: ['issueId', 'from', 'to'],
@@ -230,7 +230,7 @@ export const claimsTools = [
230
230
  const fromStr = input.from;
231
231
  const toStr = input.to;
232
232
  const reason = input.reason;
233
- const progress = input.progress || 0;
233
+ const progress = input.progress;
234
234
  {
235
235
  const v = validateIdentifier(issueId, 'issueId');
236
236
  if (!v.valid)
@@ -269,7 +269,11 @@ export const claimsTools = [
269
269
  claim.statusChangedAt = now;
270
270
  claim.handoffTo = to;
271
271
  claim.handoffReason = reason;
272
- claim.progress = progress;
272
+ // Optional: keep the progress recorded with claims_status when it is
273
+ // left out, and clamp it to 0-100 the same way claims_status does.
274
+ if (progress !== undefined) {
275
+ claim.progress = Math.min(100, Math.max(0, progress));
276
+ }
273
277
  store.claims[issueId] = claim;
274
278
  saveClaims(store);
275
279
  return {
@@ -186,6 +186,30 @@ function filterDangerousKeys(obj) {
186
186
  }
187
187
  return filtered;
188
188
  }
189
+ // Scope names index store.scopes directly, so they must never resolve to
190
+ // Object.prototype (`__proto__`) or to inherited built-ins (`toString`).
191
+ function validateScope(scope) {
192
+ const v = validateIdentifier(scope, 'scope');
193
+ if (!v.valid)
194
+ return v;
195
+ if (DANGEROUS_KEYS.has(scope))
196
+ return { valid: false, error: 'scope uses a reserved name' };
197
+ return { valid: true };
198
+ }
199
+ function readScope(store, scope) {
200
+ if (!Object.hasOwn(store.scopes, scope))
201
+ return undefined;
202
+ const values = store.scopes[scope];
203
+ return isRecord(values) ? values : undefined;
204
+ }
205
+ function ensureScope(store, scope, reset = false) {
206
+ const existing = reset ? undefined : readScope(store, scope);
207
+ if (existing)
208
+ return existing;
209
+ const created = {};
210
+ store.scopes[scope] = created;
211
+ return created;
212
+ }
189
213
  function setNestedValue(obj, key, value) {
190
214
  const MAX_NESTING_DEPTH = 10;
191
215
  const parts = key.split('.');
@@ -226,7 +250,7 @@ export const configTools = [
226
250
  if (!vKey.valid)
227
251
  return { success: false, error: vKey.error };
228
252
  if (input.scope) {
229
- const v = validateIdentifier(input.scope, 'scope');
253
+ const v = validateScope(input.scope);
230
254
  if (!v.valid)
231
255
  return { success: false, error: v.error };
232
256
  }
@@ -235,8 +259,9 @@ export const configTools = [
235
259
  const scope = input.scope || 'default';
236
260
  let value;
237
261
  // Check scope first, then default values
238
- if (scope !== 'default' && store.scopes[scope]) {
239
- value = store.scopes[scope][key];
262
+ const scopeValues = scope !== 'default' ? readScope(store, scope) : undefined;
263
+ if (scopeValues && Object.hasOwn(scopeValues, key)) {
264
+ value = scopeValues[key];
240
265
  }
241
266
  if (value === undefined) {
242
267
  value = readDefaultValue(store, key);
@@ -272,7 +297,7 @@ export const configTools = [
272
297
  if (!vKey.valid)
273
298
  return { success: false, error: vKey.error };
274
299
  if (input.scope) {
275
- const v = validateIdentifier(input.scope, 'scope');
300
+ const v = validateScope(input.scope);
276
301
  if (!v.valid)
277
302
  return { success: false, error: v.error };
278
303
  }
@@ -285,10 +310,10 @@ export const configTools = [
285
310
  writeDefaultValue(store, key, value);
286
311
  }
287
312
  else {
288
- if (!store.scopes[scope]) {
289
- store.scopes[scope] = {};
313
+ if (DANGEROUS_KEYS.has(key)) {
314
+ return { success: false, error: 'key uses a reserved name' };
290
315
  }
291
- store.scopes[scope][key] = value;
316
+ ensureScope(store, scope)[key] = value;
292
317
  }
293
318
  saveConfigStore(store);
294
319
  return {
@@ -316,7 +341,7 @@ export const configTools = [
316
341
  handler: async (input) => {
317
342
  // Validate user-provided input (#1425)
318
343
  if (input.scope) {
319
- const v = validateIdentifier(input.scope, 'scope');
344
+ const v = validateScope(input.scope);
320
345
  if (!v.valid)
321
346
  return { success: false, error: v.error };
322
347
  }
@@ -380,7 +405,7 @@ export const configTools = [
380
405
  handler: async (input) => {
381
406
  // Validate user-provided input (#1425)
382
407
  if (input.scope) {
383
- const v = validateIdentifier(input.scope, 'scope');
408
+ const v = validateScope(input.scope);
384
409
  if (!v.valid)
385
410
  return { success: false, error: v.error };
386
411
  }
@@ -400,9 +425,12 @@ export const configTools = [
400
425
  resetKeys.push(key);
401
426
  }
402
427
  }
403
- else if (store.scopes[scope] && key in store.scopes[scope]) {
404
- delete store.scopes[scope][key];
405
- resetKeys.push(key);
428
+ else {
429
+ const scopeValues = readScope(store, scope);
430
+ if (scopeValues && Object.hasOwn(scopeValues, key)) {
431
+ delete scopeValues[key];
432
+ resetKeys.push(key);
433
+ }
406
434
  }
407
435
  }
408
436
  else {
@@ -411,8 +439,8 @@ export const configTools = [
411
439
  resetKeys = Object.keys(store.values);
412
440
  replaceDefaultValues(store, { ...DEFAULT_CONFIG });
413
441
  }
414
- else if (store.scopes[scope]) {
415
- resetKeys = Object.keys(store.scopes[scope]);
442
+ else if (readScope(store, scope)) {
443
+ resetKeys = Object.keys(readScope(store, scope));
416
444
  delete store.scopes[scope];
417
445
  }
418
446
  }
@@ -440,7 +468,7 @@ export const configTools = [
440
468
  handler: async (input) => {
441
469
  // Validate user-provided input (#1425)
442
470
  if (input.scope) {
443
- const v = validateIdentifier(input.scope, 'scope');
471
+ const v = validateScope(input.scope);
444
472
  if (!v.valid)
445
473
  return { success: false, error: v.error };
446
474
  }
@@ -452,8 +480,9 @@ export const configTools = [
452
480
  exportData = { ...DEFAULT_CONFIG };
453
481
  }
454
482
  Object.assign(exportData, store.values);
455
- if (scope !== 'default' && store.scopes[scope]) {
456
- Object.assign(exportData, store.scopes[scope]);
483
+ const scopeValues = scope !== 'default' ? readScope(store, scope) : undefined;
484
+ if (scopeValues) {
485
+ Object.assign(exportData, scopeValues);
457
486
  }
458
487
  return {
459
488
  config: exportData,
@@ -480,7 +509,7 @@ export const configTools = [
480
509
  handler: async (input) => {
481
510
  // Validate user-provided input (#1425)
482
511
  if (input.scope) {
483
- const v = validateIdentifier(input.scope, 'scope');
512
+ const v = validateScope(input.scope);
484
513
  if (!v.valid)
485
514
  return { success: false, error: v.error };
486
515
  }
@@ -499,10 +528,7 @@ export const configTools = [
499
528
  }
500
529
  }
501
530
  else {
502
- if (!store.scopes[scope] || !merge) {
503
- store.scopes[scope] = {};
504
- }
505
- Object.assign(store.scopes[scope], config);
531
+ Object.assign(ensureScope(store, scope, !merge), config);
506
532
  }
507
533
  saveConfigStore(store);
508
534
  return {
@@ -200,8 +200,11 @@ export const coordinationTools = [
200
200
  selectedNode = nodes.reduce((max, n) => (weights[n.id] || 1) > (weights[max.id] || 1) ? n : max);
201
201
  }
202
202
  else {
203
- // Round robin - just pick first active
204
- selectedNode = nodes[0];
203
+ // Round robin: the active node after the one the last task went to,
204
+ // wrapping around (and starting over if that node is gone).
205
+ const last = nodes.findIndex(n => n.id === store.loadBalance.lastAssigned);
206
+ selectedNode = nodes[(last + 1) % nodes.length];
207
+ store.loadBalance.lastAssigned = selectedNode.id;
205
208
  }
206
209
  // Update load
207
210
  selectedNode.load += 1;
@@ -221,10 +221,15 @@ export const daaTools = [
221
221
  id,
222
222
  name: input.name,
223
223
  status: 'pending',
224
- steps: (input.steps || []).map((s, i) => ({
225
- name: typeof s === 'string' ? s : `Step ${i + 1}`,
226
- status: 'pending',
227
- })),
224
+ // The schema declares object steps; keep their `name` instead of
225
+ // renaming every one to "Step N".
226
+ steps: (input.steps || []).map((s, i) => {
227
+ const given = typeof s === 'string' ? s : s?.name;
228
+ return {
229
+ name: typeof given === 'string' && given.trim() ? given : `Step ${i + 1}`,
230
+ status: 'pending',
231
+ };
232
+ }),
228
233
  strategy: input.strategy || 'adaptive',
229
234
  createdAt: new Date().toISOString(),
230
235
  };
@@ -323,7 +328,9 @@ export const daaTools = [
323
328
  const sourceId = input.sourceAgentId;
324
329
  const targetIds = input.targetAgentIds;
325
330
  const domain = input.knowledgeDomain || 'general';
326
- const knowledgeId = `knowledge-${Date.now()}`;
331
+ // A random suffix keeps two shares in the same millisecond from
332
+ // overwriting each other in store.knowledge (and in AgentDB).
333
+ const knowledgeId = `knowledge-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
327
334
  const knowledgeEntry = {
328
335
  domain,
329
336
  content: input.knowledgeContent || {},
@@ -5,7 +5,7 @@ import { feedbackPatternsSchema, validateFeedbackPatterns } from '../memory/feed
5
5
  */
6
6
  import { mkdirSync, writeFileSync, existsSync, readFileSync, statSync, unlinkSync, readdirSync } from 'fs';
7
7
  import * as nodeFs from 'fs';
8
- import { dirname, join, resolve, sep } from 'path';
8
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'path';
9
9
  import { getProjectCwd } from './types.js';
10
10
  import { validateIdentifier, validateText, validatePath } from './validate-input.js';
11
11
  import { commandRecordingDisabled, redactCommandForStorage } from '../services/command-recording.js';
@@ -5109,6 +5109,38 @@ function codemodLangForExt(abs) {
5109
5109
  return 'javascript';
5110
5110
  return 'typescript';
5111
5111
  }
5112
+ // Canonicalize a path that may not exist yet: realpath the nearest existing
5113
+ // ancestor and re-attach the missing tail, so a symlinked parent still
5114
+ // resolves to its real location (mirrors @claude-flow/security's
5115
+ // canonicalizeSync, which is not exported).
5116
+ function realpathNearest(p) {
5117
+ try {
5118
+ return nodeFs.realpathSync(p);
5119
+ }
5120
+ catch (err) {
5121
+ if (err?.code !== 'ENOENT')
5122
+ throw err;
5123
+ }
5124
+ const parent = dirname(p);
5125
+ if (parent === p)
5126
+ return p;
5127
+ return join(realpathNearest(parent), basename(p));
5128
+ }
5129
+ // CWE-59 (#4346): containment must compare real paths on BOTH sides — a
5130
+ // symlink inside the project pointing outside it must not pass, and a
5131
+ // symlinked project root (e.g. macOS /var -> /private/var) must not make
5132
+ // every path fail. Fails closed if canonicalization throws (ELOOP, EACCES).
5133
+ function isInsideRealRoot(realRoot, abs) {
5134
+ let real;
5135
+ try {
5136
+ real = realpathNearest(abs);
5137
+ }
5138
+ catch {
5139
+ return false;
5140
+ }
5141
+ const rel = relative(realRoot, real);
5142
+ return rel === '' || (rel !== '..' && !rel.startsWith('..' + sep) && !isAbsolute(rel));
5143
+ }
5112
5144
  // Deterministic codemod execution — the real Tier-1 path (ADR-143)
5113
5145
  export const hooksCodemod = {
5114
5146
  name: 'hooks_codemod',
@@ -5154,6 +5186,13 @@ export const hooksCodemod = {
5154
5186
  // Resolve the target file set (single / array / glob), with path containment.
5155
5187
  const resolveTargets = () => {
5156
5188
  const out = new Set();
5189
+ let realRoot;
5190
+ try {
5191
+ realRoot = realpathNearest(cwd);
5192
+ }
5193
+ catch (err) {
5194
+ return { abs: [], truncated: false, error: `cannot resolve project root: ${err.message}` };
5195
+ }
5157
5196
  const addRaw = (p) => {
5158
5197
  const v = validatePath(p, 'path');
5159
5198
  if (!v.valid)
@@ -5164,13 +5203,11 @@ export const hooksCodemod = {
5164
5203
  // path like `${cwd}-staging/x.ts` pass as "inside" `cwd`. Matches
5165
5204
  // the pattern already used correctly elsewhere in this codebase
5166
5205
  // (@claude-flow/security's `isWithinPrefix`, hooks/workers, plugins/security).
5167
- // KNOWN LIMITATION (disclosed 2026-10-20, not fixed here): this
5168
- // compares the resolved-but-not-realpath'd path, so a symlink
5169
- // inside cwd pointing outside it still escapes containment
5170
- // (CWE-59, a distinct/broader gap than the CWE-22 prefix bug this
5171
- // anchors). Needs fs.realpathSync-based containment as a follow-up.
5172
5206
  if (abs !== cwd && !abs.startsWith(cwd + sep))
5173
5207
  return `path escapes project root: ${p}`;
5208
+ // The lexical check above cannot see symlinks; re-check on real paths.
5209
+ if (!isInsideRealRoot(realRoot, abs))
5210
+ return `path escapes project root via symlink: ${p}`;
5174
5211
  out.add(abs);
5175
5212
  return undefined;
5176
5213
  };
@@ -5202,7 +5239,9 @@ export const hooksCodemod = {
5202
5239
  }
5203
5240
  for (const m of matches) {
5204
5241
  const abs = resolve(cwd, m);
5205
- if ((abs === cwd || abs.startsWith(cwd + sep)) && CODEMOD_EXTENSIONS.has(abs.slice(abs.lastIndexOf('.')).toLowerCase())) {
5242
+ // Matches that resolve outside the root (via a symlinked file or
5243
+ // directory) are skipped silently, like other non-matching entries.
5244
+ if ((abs === cwd || abs.startsWith(cwd + sep)) && isInsideRealRoot(realRoot, abs) && CODEMOD_EXTENSIONS.has(abs.slice(abs.lastIndexOf('.')).toLowerCase())) {
5206
5245
  out.add(abs);
5207
5246
  }
5208
5247
  }
@@ -19,5 +19,15 @@ export interface MemoryCaptureReport {
19
19
  };
20
20
  error?: string;
21
21
  }
22
+ /**
23
+ * #4404: snapshot map ID for a live (namespace, key) row. A bare
24
+ * `namespace::key` join collides when either part contains `::`
25
+ * (`team::alice`/`profile` vs `team`/`alice::profile`), so one row was
26
+ * dropped on save and a distinct sibling row was skipped as a mirror. `%` and
27
+ * `:` are percent-escaped in each part, which keeps ordinary IDs unchanged.
28
+ * Restore reads the explicit namespace/key fields, never this ID, so older
29
+ * snapshots restore as before.
30
+ */
31
+ export declare function memorySnapshotId(namespace: string, key: string): string;
22
32
  export declare const sessionTools: MCPTool[];
23
33
  //# sourceMappingURL=session-tools.d.ts.map
@@ -115,6 +115,19 @@ function listSessions() {
115
115
  /** Snapshot format written by session_save since #3573. */
116
116
  const MEMORY_SNAPSHOT_FORMAT = 'ruflo-session-memory/2';
117
117
  const MEMORY_PAGE_SIZE = 500;
118
+ /**
119
+ * #4404: snapshot map ID for a live (namespace, key) row. A bare
120
+ * `namespace::key` join collides when either part contains `::`
121
+ * (`team::alice`/`profile` vs `team`/`alice::profile`), so one row was
122
+ * dropped on save and a distinct sibling row was skipped as a mirror. `%` and
123
+ * `:` are percent-escaped in each part, which keeps ordinary IDs unchanged.
124
+ * Restore reads the explicit namespace/key fields, never this ID, so older
125
+ * snapshots restore as before.
126
+ */
127
+ export function memorySnapshotId(namespace, key) {
128
+ const escape = (part) => part.replace(/%/g, '%25').replace(/:/g, '%3A');
129
+ return `${escape(namespace)}::${escape(key)}`;
130
+ }
118
131
  async function readLiveEntries(listEntries, dbPath, encryptWrites) {
119
132
  const rows = [];
120
133
  // Bounded: stop on a short page, once `total` rows are in hand, or after a
@@ -170,7 +183,7 @@ async function captureMemorySnapshot() {
170
183
  if (existsSync(primary)) {
171
184
  sawStore = true;
172
185
  for (const row of await readLiveEntries(listEntries, primary)) {
173
- const id = `${row.namespace}::${row.key}`;
186
+ const id = memorySnapshotId(row.namespace, row.key);
174
187
  seen.add(id);
175
188
  entries[id] = {
176
189
  key: row.key, value: row.content ?? '', namespace: row.namespace,
@@ -189,7 +202,7 @@ async function captureMemorySnapshot() {
189
202
  sawStore = true;
190
203
  // The AgentDB store is read by native SQLite and must stay plaintext.
191
204
  for (const row of await readLiveEntries(listEntries, sibling, false)) {
192
- const id = `${row.namespace}::${row.key}`;
205
+ const id = memorySnapshotId(row.namespace, row.key);
193
206
  if (seen.has(id))
194
207
  continue;
195
208
  seen.add(id);
@@ -173,11 +173,13 @@ export const workflowTools = [
173
173
  name: task || `${templateName} workflow`,
174
174
  description: task,
175
175
  steps,
176
- status: 'running',
176
+ // #4609: nothing is dispatched here, so this is not a run yet. Saving it
177
+ // as 'running' made workflow_execute and workflow_delete refuse it.
178
+ // 'ready' matches workflow_create; workflow_execute starts it.
179
+ status: 'ready',
177
180
  currentStep: 0,
178
181
  variables: { template: templateName, ...options },
179
182
  createdAt: new Date().toISOString(),
180
- startedAt: new Date().toISOString(),
181
183
  };
182
184
  store.workflows[workflowId] = workflow;
183
185
  saveWorkflowStore(store);
@@ -185,14 +187,17 @@ export const workflowTools = [
185
187
  return {
186
188
  workflowId,
187
189
  template: templateName,
188
- status: dryRun ? 'validated' : 'running',
190
+ status: dryRun ? 'validated' : 'ready',
189
191
  stages,
190
192
  metrics: {
191
193
  totalStages: stages.length,
192
194
  completedStages: 0,
193
195
  agentsSpawned: 0,
194
- estimatedDuration: `${stages.length * 30}s`,
196
+ estimatedDuration: 'unknown',
195
197
  },
198
+ ...(dryRun ? {} : {
199
+ nextStep: 'Start it with workflow_execute; task steps need config.agentId or options.defaultAgentId.',
200
+ }),
196
201
  };
197
202
  },
198
203
  },