@synergenius/flow-weaver-pack-weaver 0.9.151 → 0.9.153

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 (93) hide show
  1. package/dist/ai-chat-provider.js +4 -4
  2. package/dist/ai-chat-provider.js.map +1 -1
  3. package/dist/bot/ai-client.d.ts +30 -0
  4. package/dist/bot/ai-client.d.ts.map +1 -1
  5. package/dist/bot/ai-client.js +37 -0
  6. package/dist/bot/ai-client.js.map +1 -1
  7. package/dist/bot/behavior-defaults.d.ts.map +1 -1
  8. package/dist/bot/behavior-defaults.js +7 -2
  9. package/dist/bot/behavior-defaults.js.map +1 -1
  10. package/dist/bot/capability-registry.d.ts.map +1 -1
  11. package/dist/bot/capability-registry.js +48 -30
  12. package/dist/bot/capability-registry.js.map +1 -1
  13. package/dist/bot/file-validator.d.ts +7 -0
  14. package/dist/bot/file-validator.d.ts.map +1 -1
  15. package/dist/bot/file-validator.js +76 -0
  16. package/dist/bot/file-validator.js.map +1 -1
  17. package/dist/bot/instance-manager.d.ts +22 -7
  18. package/dist/bot/instance-manager.d.ts.map +1 -1
  19. package/dist/bot/instance-manager.js +69 -7
  20. package/dist/bot/instance-manager.js.map +1 -1
  21. package/dist/bot/orchestrator.d.ts +11 -9
  22. package/dist/bot/orchestrator.d.ts.map +1 -1
  23. package/dist/bot/orchestrator.js +56 -107
  24. package/dist/bot/orchestrator.js.map +1 -1
  25. package/dist/bot/runner.d.ts +29 -0
  26. package/dist/bot/runner.d.ts.map +1 -1
  27. package/dist/bot/runner.js +114 -73
  28. package/dist/bot/runner.js.map +1 -1
  29. package/dist/bot/step-executor.d.ts.map +1 -1
  30. package/dist/bot/step-executor.js +106 -25
  31. package/dist/bot/step-executor.js.map +1 -1
  32. package/dist/bot/swarm-controller.d.ts +7 -6
  33. package/dist/bot/swarm-controller.d.ts.map +1 -1
  34. package/dist/bot/swarm-controller.js +64 -74
  35. package/dist/bot/swarm-controller.js.map +1 -1
  36. package/dist/bot/task-types.d.ts +1 -0
  37. package/dist/bot/task-types.d.ts.map +1 -1
  38. package/dist/bot/weaver-tools.d.ts +1 -1
  39. package/dist/bot/weaver-tools.d.ts.map +1 -1
  40. package/dist/bot/weaver-tools.js +6 -1
  41. package/dist/bot/weaver-tools.js.map +1 -1
  42. package/dist/node-types/agent-execute.js +2 -2
  43. package/dist/node-types/agent-execute.js.map +1 -1
  44. package/dist/node-types/bot-report.d.ts.map +1 -1
  45. package/dist/node-types/bot-report.js +5 -2
  46. package/dist/node-types/bot-report.js.map +1 -1
  47. package/dist/node-types/build-context.js +2 -1
  48. package/dist/node-types/build-context.js.map +1 -1
  49. package/dist/node-types/exec-validate-retry.d.ts +3 -3
  50. package/dist/node-types/exec-validate-retry.d.ts.map +1 -1
  51. package/dist/node-types/exec-validate-retry.js +13 -184
  52. package/dist/node-types/exec-validate-retry.js.map +1 -1
  53. package/dist/node-types/load-config.d.ts +1 -0
  54. package/dist/node-types/load-config.d.ts.map +1 -1
  55. package/dist/node-types/load-config.js +1 -0
  56. package/dist/node-types/load-config.js.map +1 -1
  57. package/dist/node-types/plan-task.d.ts +7 -5
  58. package/dist/node-types/plan-task.d.ts.map +1 -1
  59. package/dist/node-types/plan-task.js +282 -83
  60. package/dist/node-types/plan-task.js.map +1 -1
  61. package/dist/ui/bot-panel.js +1 -1
  62. package/dist/ui/capability-editor.js +48 -30
  63. package/dist/ui/profile-editor.js +46 -28
  64. package/dist/ui/swarm-dashboard.js +71 -33
  65. package/dist/ui/task-detail-view.js +22 -2
  66. package/dist/workflows/weaver-bot.d.ts +2 -2
  67. package/dist/workflows/weaver-bot.d.ts.map +1 -1
  68. package/dist/workflows/weaver-bot.js +5 -4
  69. package/dist/workflows/weaver-bot.js.map +1 -1
  70. package/flowweaver.manifest.json +1 -1
  71. package/package.json +1 -1
  72. package/src/ai-chat-provider.ts +4 -4
  73. package/src/bot/ai-client.ts +65 -0
  74. package/src/bot/behavior-defaults.ts +5 -2
  75. package/src/bot/capability-registry.ts +48 -30
  76. package/src/bot/file-validator.ts +97 -0
  77. package/src/bot/instance-manager.ts +77 -7
  78. package/src/bot/orchestrator.ts +63 -126
  79. package/src/bot/runner.ts +124 -70
  80. package/src/bot/step-executor.ts +115 -25
  81. package/src/bot/swarm-controller.ts +65 -76
  82. package/src/bot/task-types.ts +1 -0
  83. package/src/bot/weaver-tools.ts +7 -1
  84. package/src/node-types/agent-execute.ts +2 -2
  85. package/src/node-types/bot-report.ts +5 -2
  86. package/src/node-types/build-context.ts +2 -1
  87. package/src/node-types/exec-validate-retry.ts +14 -203
  88. package/src/node-types/load-config.ts +1 -0
  89. package/src/node-types/plan-task.ts +313 -88
  90. package/src/ui/bot-panel.tsx +1 -1
  91. package/src/ui/swarm-dashboard.tsx +3 -3
  92. package/src/ui/task-detail-view.tsx +25 -2
  93. package/src/workflows/weaver-bot.ts +5 -4
package/src/bot/runner.ts CHANGED
@@ -84,7 +84,7 @@ function parseConfigFile(configPath: string): WeaverConfig {
84
84
  }
85
85
  }
86
86
 
87
- function buildSummary(result: unknown): string {
87
+ export function buildSummary(result: unknown): string {
88
88
  if (!result || typeof result !== 'object') return String(result);
89
89
 
90
90
  const r = result as Record<string, unknown>;
@@ -101,6 +101,120 @@ function buildSummary(result: unknown): string {
101
101
  return parts.length > 0 ? parts.join(', ') : 'completed';
102
102
  }
103
103
 
104
+ /**
105
+ * Build a markdown report from the workflow result's ctx field.
106
+ * Extracted for testability — same logic used inside runWorkflow.
107
+ */
108
+ export function buildReport(
109
+ result: Record<string, unknown> | null,
110
+ success: boolean,
111
+ stepLog?: import('./types.js').StepLogEntry[],
112
+ ): string | undefined {
113
+ try {
114
+ const ctxStr = result?.ctx as string | undefined;
115
+ if (!ctxStr) return undefined;
116
+ const ctx = JSON.parse(ctxStr);
117
+ const md: string[] = [];
118
+ md.push(`## ${success ? 'Task Completed' : 'Task Failed'}`);
119
+ md.push('');
120
+
121
+ // Steps
122
+ if (stepLog && stepLog.length > 0) {
123
+ md.push('### Steps');
124
+ md.push('');
125
+ for (const s of stepLog) {
126
+ const icon = s.status === 'ok' ? '**ok**' : s.status === 'error' ? '**error**' : '**blocked**';
127
+ md.push(`- ${s.step} (${icon})${s.detail ? `: ${s.detail}` : ''}`);
128
+ }
129
+ md.push('');
130
+ }
131
+
132
+ // Files
133
+ const files: string[] = ctx.filesModified ? JSON.parse(ctx.filesModified) : [];
134
+ if (files.length > 0) {
135
+ md.push('### Files Modified');
136
+ md.push('');
137
+ for (const f of files) md.push(`- \`${f}\``);
138
+ md.push('');
139
+ }
140
+
141
+ // Review
142
+ if (ctx.reviewJson) {
143
+ const review = JSON.parse(ctx.reviewJson) as Record<string, string>;
144
+ if (review.intent || review.execution || review.result || review.completeness) {
145
+ md.push('### Review');
146
+ md.push('');
147
+ for (const key of ['intent', 'execution', 'result', 'completeness']) {
148
+ if (review[key]) md.push(`- **${key}:** ${review[key]}`);
149
+ }
150
+ if (review.reason) md.push(`\n${review.reason}`);
151
+ md.push('');
152
+ }
153
+ }
154
+
155
+ return md.length > 2 ? md.join('\n') : undefined;
156
+ } catch {
157
+ return undefined;
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Extract stepLog and plan from the result's ctx field.
163
+ * Extracted for testability — same logic used inside runWorkflow.
164
+ */
165
+ export function extractCtxData(result: Record<string, unknown> | null): {
166
+ stepLog?: import('./types.js').StepLogEntry[];
167
+ plan?: { summary: string; steps: Array<{ id: string; operation: string; description: string; args?: Record<string, unknown> }> };
168
+ } {
169
+ try {
170
+ const ctxStr = result?.ctx as string | undefined;
171
+ if (!ctxStr) return {};
172
+ const ctx = JSON.parse(ctxStr);
173
+ let stepLog: import('./types.js').StepLogEntry[] | undefined;
174
+ let plan: { summary: string; steps: Array<{ id: string; operation: string; description: string; args?: Record<string, unknown> }> } | undefined;
175
+
176
+ if (ctx.stepLogJson) stepLog = JSON.parse(ctx.stepLogJson);
177
+ if (ctx.planJson) {
178
+ const parsed = JSON.parse(ctx.planJson);
179
+ if (parsed?.steps) {
180
+ plan = {
181
+ summary: parsed.summary ?? '',
182
+ steps: (parsed.steps as Array<Record<string, unknown>>).map((s) => ({
183
+ id: String(s.id ?? ''),
184
+ operation: String(s.operation ?? ''),
185
+ description: String(s.description ?? ''),
186
+ ...(s.args ? { args: s.args as Record<string, unknown> } : {}),
187
+ })),
188
+ };
189
+ }
190
+ }
191
+ return { stepLog, plan };
192
+ } catch {
193
+ return {};
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Fallback: extract report from trace outputs (bot-report node's "Markdown report" port).
199
+ */
200
+ export function extractReportFromTrace(
201
+ trace: Array<{ type: string; outputs?: unknown[] }>,
202
+ ): string | undefined {
203
+ for (const te of trace) {
204
+ if (te.type === 'node-complete' && te.outputs) {
205
+ for (const o of te.outputs) {
206
+ if (typeof o === 'object' && o && 'portLabel' in o) {
207
+ const po = o as { portLabel: string; value: unknown };
208
+ if (po.portLabel === 'Markdown report' && typeof po.value === 'string' && po.value.length > 0) {
209
+ return po.value;
210
+ }
211
+ }
212
+ }
213
+ }
214
+ }
215
+ return undefined;
216
+ }
217
+
104
218
  export async function runWorkflow(
105
219
  filePath: string,
106
220
  options?: {
@@ -272,7 +386,7 @@ export async function runWorkflow(
272
386
  }
273
387
  }
274
388
  }
275
- for (const inst of parseResult?.workflow?.instances ?? []) {
389
+ for (const inst of (parseResult?.workflows?.[0]?.instances ?? parseResult?.workflow?.instances ?? [])) {
276
390
  const label = inst.config?.label ?? nodeLabels.get(inst.nodeType);
277
391
  if (label) nodeLabels.set(inst.id, label);
278
392
  // Inherit visual meta from node type to instance
@@ -415,77 +529,17 @@ export async function runWorkflow(
415
529
  }
416
530
 
417
531
  // Extract stepLog and plan from WeaverContext if available
418
- let stepLog: import('./types.js').StepLogEntry[] | undefined;
419
- let plan: RunRecord['plan'] | undefined;
420
- try {
421
- const ctxStr = result?.ctx as string | undefined;
422
- if (ctxStr) {
423
- const ctx = JSON.parse(ctxStr);
424
- if (ctx.stepLogJson) stepLog = JSON.parse(ctx.stepLogJson);
425
- if (ctx.planJson) {
426
- const parsed = JSON.parse(ctx.planJson);
427
- if (parsed?.steps) {
428
- plan = {
429
- summary: parsed.summary ?? '',
430
- steps: (parsed.steps as Array<Record<string, unknown>>).map((s) => ({
431
- id: String(s.id ?? ''),
432
- operation: String(s.operation ?? ''),
433
- description: String(s.description ?? ''),
434
- ...(s.args ? { args: s.args as Record<string, unknown> } : {}),
435
- })),
436
- };
437
- }
438
- }
439
- }
440
- } catch { /* extraction is best-effort */ }
532
+ const { stepLog, plan } = extractCtxData(result);
441
533
 
442
534
  // Build markdown report from the extracted context data
443
- let report: string | undefined;
444
- try {
445
- const ctxStr = result?.ctx as string | undefined;
446
- if (ctxStr) {
447
- const ctx = JSON.parse(ctxStr);
448
- const md: string[] = [];
449
- md.push(`## ${success ? 'Task Completed' : 'Task Failed'}`);
450
- md.push('');
451
-
452
- // Steps
453
- if (stepLog && stepLog.length > 0) {
454
- md.push('### Steps');
455
- md.push('');
456
- for (const s of stepLog) {
457
- const icon = s.status === 'ok' ? '**ok**' : s.status === 'error' ? '**error**' : '**blocked**';
458
- md.push(`- ${s.step} (${icon})${s.detail ? `: ${s.detail}` : ''}`);
459
- }
460
- md.push('');
461
- }
462
-
463
- // Files
464
- const files: string[] = ctx.filesModified ? JSON.parse(ctx.filesModified) : [];
465
- if (files.length > 0) {
466
- md.push('### Files Modified');
467
- md.push('');
468
- for (const f of files) md.push(`- \`${f}\``);
469
- md.push('');
470
- }
471
-
472
- // Review
473
- if (ctx.reviewJson) {
474
- const review = JSON.parse(ctx.reviewJson) as Record<string, string>;
475
- if (review.intent || review.execution || review.result || review.completeness) {
476
- md.push('### Review');
477
- md.push('');
478
- for (const key of ['intent', 'execution', 'result', 'completeness']) {
479
- if (review[key]) md.push(`- **${key}:** ${review[key]}`);
480
- }
481
- if (review.reason) md.push(`\n${review.reason}`);
482
- md.push('');
483
- }
484
- }
535
+ let report = buildReport(result, success, stepLog);
485
536
 
486
- if (md.length > 2) report = md.join('\n');
487
- }
488
- } catch { /* report generation is best-effort */ }
537
+ // Fallback: extract report from the bot-report node's trace output.
538
+ // The FW compiled workflow doesn't wire bot-report's report port to END,
539
+ // so result.report is undefined. But the trace captured all node outputs.
540
+ if (!report) {
541
+ report = extractReportFromTrace(collectedTrace);
542
+ }
489
543
 
490
544
  await notifier({
491
545
  type: 'workflow-complete',
@@ -189,18 +189,24 @@ export async function executeStep(
189
189
  }
190
190
  assertSafePath(file, projectDir);
191
191
  const filePath = path.resolve(projectDir, file);
192
- const content = (args.content as string) ?? (args.body as string) ?? '';
192
+ // Use the relative path for reporting never leak absolute server paths
193
+ const relPath = path.relative(projectDir, filePath);
194
+ const rawContent = args.content ?? args.body ?? '';
195
+ if (typeof rawContent !== 'string') {
196
+ return { blocked: true, blockReason: `${step.operation}: "content" must be a string, got ${typeof rawContent}.` };
197
+ }
198
+ const content = rawContent;
193
199
 
194
200
  const guard = checkWriteSafety(filePath, content);
195
201
  if (!guard.allowed) {
196
- return { file: filePath, blocked: true, blockReason: guard.reason };
202
+ return { file: relPath, blocked: true, blockReason: guard.reason };
197
203
  }
198
204
 
199
205
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
200
206
  const existed = fs.existsSync(filePath);
201
207
  fs.writeFileSync(filePath, content, 'utf-8');
202
208
  filesWrittenThisPlan++;
203
- return { file: filePath, created: !existed };
209
+ return { file: relPath, created: !existed };
204
210
  }
205
211
 
206
212
  // -----------------------------------------------------------------
@@ -212,6 +218,7 @@ export async function executeStep(
212
218
  }
213
219
  assertSafePath(file, projectDir);
214
220
  const filePath = path.resolve(projectDir, file);
221
+ const relPath = path.relative(projectDir, filePath);
215
222
 
216
223
  if (!fs.existsSync(filePath)) {
217
224
  return { blocked: true, blockReason: `File not found: ${file}` };
@@ -234,6 +241,17 @@ export async function executeStep(
234
241
  return { blocked: true, blockReason: 'patch_file requires "patches" array or "find"+"replace" args.' };
235
242
  }
236
243
 
244
+ // Validate each patch entry has string find and replace
245
+ for (let i = 0; i < patches.length; i++) {
246
+ const p = patches[i];
247
+ if (typeof p.find !== 'string') {
248
+ return { blocked: true, blockReason: `patch_file: patch[${i}].find must be a string, got ${typeof p.find}.` };
249
+ }
250
+ if (typeof p.replace !== 'string') {
251
+ return { blocked: true, blockReason: `patch_file: patch[${i}].replace must be a string, got ${typeof p.replace}.` };
252
+ }
253
+ }
254
+
237
255
  let applied = 0;
238
256
  const notFound: string[] = [];
239
257
 
@@ -248,7 +266,7 @@ export async function executeStep(
248
266
 
249
267
  if (applied === 0) {
250
268
  return {
251
- file: filePath,
269
+ file: relPath,
252
270
  output: `No patches applied. Search strings not found: ${notFound.join('; ')}`,
253
271
  };
254
272
  }
@@ -259,7 +277,7 @@ export async function executeStep(
259
277
  if (originalSize > SHRINK_GUARD_MIN_SIZE && newSize < originalSize * MAX_SHRINK_RATIO) {
260
278
  const shrinkPct = Math.round((1 - newSize / originalSize) * 100);
261
279
  return {
262
- file: filePath,
280
+ file: relPath,
263
281
  blocked: true,
264
282
  blockReason:
265
283
  `Refusing to patch ${path.basename(filePath)}: result (${newSize}B) ` +
@@ -273,7 +291,7 @@ export async function executeStep(
273
291
 
274
292
  const summary = `Applied ${applied}/${patches.length} patches` +
275
293
  (notFound.length ? `. Not found: ${notFound.join('; ')}` : '');
276
- return { file: filePath, output: summary };
294
+ return { file: relPath, output: summary };
277
295
  }
278
296
 
279
297
  // -----------------------------------------------------------------
@@ -281,10 +299,11 @@ export async function executeStep(
281
299
  // -----------------------------------------------------------------
282
300
  case OP_READ_FILE: {
283
301
  if (!file) {
284
- return { output: '' };
302
+ return { blocked: true, blockReason: 'read_file requires a "file" argument.' };
285
303
  }
286
304
  assertSafePath(file, projectDir);
287
305
  const filePath = path.resolve(projectDir, file);
306
+ const relPath = path.relative(projectDir, filePath);
288
307
  if (!fs.existsSync(filePath)) {
289
308
  return { output: `File not found: ${file}` };
290
309
  }
@@ -298,9 +317,9 @@ export async function executeStep(
298
317
  return { output: `File too large to read (${stat.size} bytes, max ${MAX_READ_SIZE}). Use run_shell with head/tail instead.` };
299
318
  }
300
319
  const content = fs.readFileSync(filePath, 'utf-8');
301
- return { file: filePath, output: content };
320
+ return { file: relPath, output: content };
302
321
  } catch (err: unknown) {
303
- const msg = err instanceof Error ? err.message : String(err);
322
+ const msg = (err instanceof Error ? err.message : String(err)).replaceAll(projectDir + '/', '');
304
323
  return { output: `Error reading file "${file}": ${msg}` };
305
324
  }
306
325
  }
@@ -341,8 +360,9 @@ export async function executeStep(
341
360
  const stdout = (execErr.stdout ?? '').trim();
342
361
  const stderr = (execErr.stderr ?? '').trim();
343
362
  const combined = [stdout, stderr].filter(Boolean).join('\n');
363
+ const raw = combined || (err instanceof Error ? err.message : String(err));
344
364
  return {
345
- output: combined || (err instanceof Error ? err.message : String(err)),
365
+ output: raw.replaceAll(projectDir + '/', ''),
346
366
  };
347
367
  }
348
368
  }
@@ -364,7 +384,7 @@ export async function executeStep(
364
384
  try {
365
385
  entries = fs.readdirSync(targetDir, { recursive: true, encoding: 'utf-8' }) as string[];
366
386
  } catch (err: unknown) {
367
- const msg = err instanceof Error ? err.message : String(err);
387
+ const msg = (err instanceof Error ? err.message : String(err)).replaceAll(projectDir + '/', '');
368
388
  return { output: `Error listing directory "${dir}": ${msg}` };
369
389
  }
370
390
  let files = entries
@@ -376,6 +396,9 @@ export async function executeStep(
376
396
  .sort();
377
397
 
378
398
  if (pattern) {
399
+ if (pattern.length > 200) {
400
+ return { blocked: true, blockReason: `list_files: regex pattern too long (${pattern.length} chars, max 200).` };
401
+ }
379
402
  try {
380
403
  const regex = new RegExp(pattern);
381
404
  files = files.filter(f => regex.test(f));
@@ -399,7 +422,60 @@ export async function executeStep(
399
422
  if (!title) return { blocked: true, blockReason: 'task_create requires a "title" argument.' };
400
423
 
401
424
  const store = new TaskStore(projectDir);
402
- const assignedProfile = args.assignedProfile as string | undefined;
425
+
426
+ // Validate assignedProfile: must be a valid slug (lowercase alphanumeric + hyphens)
427
+ const rawProfile = args.assignedProfile as string | undefined;
428
+ let assignedProfile: string | undefined;
429
+ if (rawProfile !== undefined && rawProfile !== null) {
430
+ if (typeof rawProfile !== 'string' || !rawProfile.trim()) {
431
+ return { blocked: true, blockReason: 'task_create: assignedProfile must be a non-empty string.' };
432
+ }
433
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(rawProfile)) {
434
+ return { blocked: true, blockReason: `task_create: assignedProfile "${rawProfile}" is not a valid slug (use lowercase alphanumeric with hyphens).` };
435
+ }
436
+ assignedProfile = rawProfile;
437
+ }
438
+
439
+ // Validate complexity against enum, default to 'simple' if invalid
440
+ const VALID_COMPLEXITIES = new Set(['trivial', 'simple', 'moderate', 'complex']);
441
+ const rawComplexity = args.complexity as string | undefined;
442
+ const complexity: 'trivial' | 'simple' | 'moderate' | 'complex' =
443
+ rawComplexity && VALID_COMPLEXITIES.has(rawComplexity)
444
+ ? (rawComplexity as 'trivial' | 'simple' | 'moderate' | 'complex')
445
+ : 'simple';
446
+
447
+ // Validate priority: coerce to number, default to 0 if NaN
448
+ const rawPriority = Number(args.priority);
449
+ const priority = Number.isFinite(rawPriority) ? rawPriority : 0;
450
+
451
+ // Validate parentId — must reference an existing task
452
+ const rawParentId = args.parentId as string | undefined;
453
+ let parentId: string | undefined;
454
+ if (rawParentId) {
455
+ // Resolve through symbolicIdMap first (in case it's a title or symbolic ref)
456
+ const resolved = symbolicIdMap?.[rawParentId] ?? rawParentId;
457
+ const parentTask = await store.get(resolved);
458
+ if (!parentTask) {
459
+ return { blocked: true, blockReason: `task_create: parentId "${rawParentId}" does not match any existing task. Use the exact Task ID from the prompt.` };
460
+ }
461
+ parentId = resolved;
462
+ }
463
+
464
+ // Idempotent: if a task with the same title and parentId already exists, return it
465
+ // instead of creating a duplicate. This prevents retry loops from producing duplicates.
466
+ if (parentId) {
467
+ const existing = (await store.list()).find(
468
+ t => t.parentId === parentId && t.title.toLowerCase() === title.toLowerCase(),
469
+ );
470
+ if (existing) {
471
+ if (symbolicIdMap) {
472
+ const symbolicKey = (args.symbolicId as string) ?? (args.id as string);
473
+ if (symbolicKey) symbolicIdMap[symbolicKey] = existing.id;
474
+ symbolicIdMap[title] = existing.id;
475
+ }
476
+ return { output: `Task "${title}" already exists (${existing.id}), skipped duplicate.` };
477
+ }
478
+ }
403
479
 
404
480
  // Resolve symbolic IDs in dependsOn through the map
405
481
  const rawDeps = (args.dependsOn as string[]) ?? [];
@@ -410,9 +486,9 @@ export async function executeStep(
410
486
  const input: CreateTaskInput = {
411
487
  title,
412
488
  description: (args.description as string) ?? title,
413
- complexity: (args.complexity as 'trivial' | 'simple' | 'moderate' | 'complex') ?? 'simple',
414
- priority: (args.priority as number) ?? 0,
415
- parentId: args.parentId as string | undefined,
489
+ complexity,
490
+ priority,
491
+ parentId,
416
492
  dependsOn: resolvedDeps,
417
493
  assignedProfile,
418
494
  };
@@ -420,16 +496,30 @@ export async function executeStep(
420
496
  // Support inline subtasks
421
497
  const subtasksArg = args.subtasks as Array<Record<string, unknown>> | undefined;
422
498
  if (subtasksArg?.length) {
423
- input.subtasks = subtasksArg.map((s) => ({
424
- title: (s.title as string) ?? '',
425
- description: (s.description as string) ?? '',
426
- complexity: (s.complexity as 'trivial' | 'simple' | 'moderate' | 'complex') ?? 'simple',
427
- priority: (s.priority as number) ?? 0,
428
- assignedProfile: (s.assignedProfile as string) ?? assignedProfile ?? 'developer',
429
- dependsOn: symbolicIdMap
430
- ? ((s.dependsOn as string[]) ?? []).map(dep => symbolicIdMap[dep] ?? dep)
431
- : (s.dependsOn as string[]) ?? [],
432
- }));
499
+ // Validate subtask titles are non-empty
500
+ for (let i = 0; i < subtasksArg.length; i++) {
501
+ const subTitle = (subtasksArg[i].title as string) ?? '';
502
+ if (!subTitle.trim()) {
503
+ return { blocked: true, blockReason: `task_create: subtask[${i}] has an empty title. All subtasks must have non-empty titles.` };
504
+ }
505
+ }
506
+
507
+ input.subtasks = subtasksArg.map((s) => {
508
+ const subComplexity = s.complexity as string | undefined;
509
+ const subPriority = Number(s.priority);
510
+ return {
511
+ title: (s.title as string) ?? '',
512
+ description: (s.description as string) ?? '',
513
+ complexity: subComplexity && VALID_COMPLEXITIES.has(subComplexity)
514
+ ? (subComplexity as 'trivial' | 'simple' | 'moderate' | 'complex')
515
+ : 'simple',
516
+ priority: Number.isFinite(subPriority) ? subPriority : 0,
517
+ assignedProfile: (s.assignedProfile as string) ?? assignedProfile ?? 'developer',
518
+ dependsOn: symbolicIdMap
519
+ ? ((s.dependsOn as string[]) ?? []).map(dep => symbolicIdMap[dep] ?? dep)
520
+ : (s.dependsOn as string[]) ?? [],
521
+ };
522
+ });
433
523
  }
434
524
 
435
525
  const task = await store.create(input);