@synergenius/flow-weaver-pack-weaver 0.9.152 → 0.9.154

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 (104) 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 +46 -33
  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 +28 -9
  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/system-prompt.d.ts.map +1 -1
  37. package/dist/bot/system-prompt.js +2 -0
  38. package/dist/bot/system-prompt.js.map +1 -1
  39. package/dist/bot/task-types.d.ts +1 -0
  40. package/dist/bot/task-types.d.ts.map +1 -1
  41. package/dist/bot/weaver-tools.d.ts +1 -1
  42. package/dist/bot/weaver-tools.d.ts.map +1 -1
  43. package/dist/bot/weaver-tools.js +12 -1
  44. package/dist/bot/weaver-tools.js.map +1 -1
  45. package/dist/node-types/agent-execute.js +2 -2
  46. package/dist/node-types/agent-execute.js.map +1 -1
  47. package/dist/node-types/bot-report.d.ts.map +1 -1
  48. package/dist/node-types/bot-report.js +5 -2
  49. package/dist/node-types/bot-report.js.map +1 -1
  50. package/dist/node-types/build-context.d.ts.map +1 -1
  51. package/dist/node-types/build-context.js +13 -1
  52. package/dist/node-types/build-context.js.map +1 -1
  53. package/dist/node-types/exec-validate-retry.d.ts +3 -3
  54. package/dist/node-types/exec-validate-retry.d.ts.map +1 -1
  55. package/dist/node-types/exec-validate-retry.js +13 -184
  56. package/dist/node-types/exec-validate-retry.js.map +1 -1
  57. package/dist/node-types/load-config.d.ts +1 -0
  58. package/dist/node-types/load-config.d.ts.map +1 -1
  59. package/dist/node-types/load-config.js +1 -0
  60. package/dist/node-types/load-config.js.map +1 -1
  61. package/dist/node-types/plan-task.d.ts +7 -5
  62. package/dist/node-types/plan-task.d.ts.map +1 -1
  63. package/dist/node-types/plan-task.js +282 -83
  64. package/dist/node-types/plan-task.js.map +1 -1
  65. package/dist/ui/bot-panel.js +1 -1
  66. package/dist/ui/capability-editor.js +46 -33
  67. package/dist/ui/chat-task-result.js +7 -7
  68. package/dist/ui/profile-editor.js +44 -31
  69. package/dist/ui/swarm-dashboard.js +80 -47
  70. package/dist/ui/task-detail-view.js +31 -11
  71. package/dist/ui/task-editor.js +1 -1
  72. package/dist/ui/task-pool-list.js +1 -1
  73. package/dist/workflows/weaver-bot.d.ts +5 -4
  74. package/dist/workflows/weaver-bot.d.ts.map +1 -1
  75. package/dist/workflows/weaver-bot.js +8 -7
  76. package/dist/workflows/weaver-bot.js.map +1 -1
  77. package/flowweaver.manifest.json +1 -1
  78. package/package.json +1 -1
  79. package/src/ai-chat-provider.ts +4 -4
  80. package/src/bot/ai-client.ts +65 -0
  81. package/src/bot/behavior-defaults.ts +5 -2
  82. package/src/bot/capability-registry.ts +46 -33
  83. package/src/bot/file-validator.ts +97 -0
  84. package/src/bot/instance-manager.ts +77 -7
  85. package/src/bot/orchestrator.ts +63 -126
  86. package/src/bot/runner.ts +124 -70
  87. package/src/bot/step-executor.ts +30 -9
  88. package/src/bot/swarm-controller.ts +65 -76
  89. package/src/bot/system-prompt.ts +2 -0
  90. package/src/bot/task-types.ts +1 -0
  91. package/src/bot/weaver-tools.ts +14 -1
  92. package/src/node-types/agent-execute.ts +2 -2
  93. package/src/node-types/bot-report.ts +5 -2
  94. package/src/node-types/build-context.ts +13 -1
  95. package/src/node-types/exec-validate-retry.ts +14 -203
  96. package/src/node-types/load-config.ts +1 -0
  97. package/src/node-types/plan-task.ts +313 -88
  98. package/src/ui/bot-panel.tsx +1 -1
  99. package/src/ui/chat-task-result.tsx +10 -8
  100. package/src/ui/swarm-dashboard.tsx +4 -4
  101. package/src/ui/task-detail-view.tsx +35 -12
  102. package/src/ui/task-editor.tsx +2 -2
  103. package/src/ui/task-pool-list.tsx +2 -2
  104. package/src/workflows/weaver-bot.ts +8 -7
@@ -1,8 +1,12 @@
1
1
  /**
2
- * Orchestrator — the routing brain that decides which tasks go to which bot instances.
2
+ * Orchestrator — the routing brain that decides which tasks go to which workers.
3
3
  *
4
- * Pure routing logic: no side-effects beyond decision logging.
5
- * Fast-path cascade: exact-match single-eligible ai-routed round-robin.
4
+ * In the worker pool model, workers are generic slots. The orchestrator:
5
+ * 1. Finds the profile for each task (via assignedProfile).
6
+ * 2. Assigns the task to any idle worker.
7
+ *
8
+ * Simplified cascade: exact-match profile → any idle worker.
9
+ * No more per-profile instance matching or scale-up actions.
6
10
  */
7
11
 
8
12
  import type {
@@ -15,7 +19,6 @@ import type {
15
19
 
16
20
  type Task = OrchestratorInput['pendingTasks'][number];
17
21
  type Assignment = OrchestratorOutput['assignments'][number];
18
- type ScaleAction = OrchestratorOutput['scaleActions'][number];
19
22
 
20
23
  // ---------------------------------------------------------------------------
21
24
  // AI Router types
@@ -50,11 +53,10 @@ export class Orchestrator {
50
53
 
51
54
  async route(input: OrchestratorInput): Promise<OrchestratorOutput> {
52
55
  const assignments: Assignment[] = [];
53
- const scaleActions: ScaleAction[] = [];
54
56
  const skippedTasks: OrchestratorOutput['skippedTasks'] = [];
55
57
 
56
- // Track instances claimed during this routing cycle to avoid double-assignment.
57
- const claimedInstanceIds = new Set<string>();
58
+ // Track workers claimed during this routing cycle to avoid double-assignment.
59
+ const claimedWorkerIds = new Set<string>();
58
60
 
59
61
  // Sort tasks by priority DESC (higher number = higher priority).
60
62
  const sorted = [...input.pendingTasks].sort((a, b) => b.priority - a.priority);
@@ -66,50 +68,43 @@ export class Orchestrator {
66
68
  continue;
67
69
  }
68
70
 
69
- const eligible = this._findEligible(task, input.profiles);
70
-
71
- if (eligible.length === 0) {
71
+ // Resolve profile for this task (includes routing method)
72
+ const resolution = await this._resolveProfile(task, input.profiles);
73
+ if (!resolution) {
72
74
  skippedTasks.push({ taskId: task.id, reason: 'no-eligible-profile' });
73
75
  continue;
74
76
  }
75
77
 
76
- const result = await this._selectInstance(task, eligible, input.instances, claimedInstanceIds);
77
-
78
- if (result) {
79
- claimedInstanceIds.add(result.instanceId);
80
- assignments.push({
81
- taskId: task.id,
82
- profileId: result.profileId,
83
- instanceId: result.instanceId,
84
- reason: result.reason,
85
- method: result.method,
86
- confidence: result.confidence,
87
- });
88
- this._recordDecision(task, result, eligible);
89
- } else {
90
- // No idle instance available — request scale-up if possible.
91
- const scaleProfile = eligible[0];
92
- const currentCount = input.instances.filter(
93
- (i) => i.profileId === scaleProfile.id,
94
- ).length;
95
-
96
- if (currentCount < scaleProfile.maxInstances) {
97
- // Only add one scale-up action per profile.
98
- if (!scaleActions.some((sa) => sa.profileId === scaleProfile.id)) {
99
- scaleActions.push({
100
- profileId: scaleProfile.id,
101
- action: 'scale-up',
102
- targetInstances: currentCount + 1,
103
- reason: `idle instances exhausted for profile ${scaleProfile.id}`,
104
- });
105
- }
106
- }
107
-
108
- skippedTasks.push({ taskId: task.id, reason: 'no-idle-instance' });
78
+ // Find any idle worker
79
+ const idleWorker = this._findIdleWorker(input.instances, claimedWorkerIds);
80
+ if (!idleWorker) {
81
+ skippedTasks.push({ taskId: task.id, reason: 'no-idle-worker' });
82
+ continue;
109
83
  }
84
+
85
+ claimedWorkerIds.add(idleWorker.instanceId);
86
+
87
+ const reason = `${resolution.method}: ${resolution.profileId} → ${idleWorker.instanceId}`;
88
+ assignments.push({
89
+ taskId: task.id,
90
+ profileId: resolution.profileId,
91
+ instanceId: idleWorker.instanceId,
92
+ reason,
93
+ method: resolution.method,
94
+ confidence: resolution.confidence,
95
+ });
96
+
97
+ this._recordDecision(task, {
98
+ profileId: resolution.profileId,
99
+ instanceId: idleWorker.instanceId,
100
+ method: resolution.method,
101
+ reason,
102
+ confidence: resolution.confidence,
103
+ }, input.profiles.filter(p => p.id === resolution.profileId));
110
104
  }
111
105
 
112
- return { assignments, scaleActions, skippedTasks };
106
+ // No scale actions in pool model — pool size is fixed at maxConcurrent
107
+ return { assignments, scaleActions: [], skippedTasks };
113
108
  }
114
109
 
115
110
  getDecisionLog(limit?: number): OrchestratorDecision[] {
@@ -126,106 +121,48 @@ export class Orchestrator {
126
121
  // Internal
127
122
  // ---------------------------------------------------------------------------
128
123
 
129
- /** Find profiles eligible for a task. */
130
- private _findEligible(task: Task, profiles: BotProfile[]): BotProfile[] {
124
+ /** Resolve which profileId should handle this task, including routing method. */
125
+ private async _resolveProfile(
126
+ task: Task,
127
+ profiles: BotProfile[],
128
+ ): Promise<{ profileId: string; method: OrchestratorDecision['method']; confidence?: number } | null> {
129
+ // Exact match: task has assignedProfile
131
130
  if (task.assignedProfile) {
132
- const match = profiles.filter(p => p.id === task.assignedProfile);
133
- if (match.length > 0) return match;
134
- // Assigned profile doesn't exist — return empty to skip this task
135
- // rather than falling through to all profiles (silent mis-routing).
131
+ const match = profiles.find(p => p.id === task.assignedProfile);
132
+ if (match) return { profileId: match.id, method: 'exact-match' };
136
133
  console.warn(
137
134
  `[orchestrator] Task "${task.id}" assigned to profile "${task.assignedProfile}" which does not exist — skipping`,
138
135
  );
139
- return [];
136
+ return null;
140
137
  }
141
- return profiles;
142
- }
143
138
 
144
- /** Select the best instance for a task from eligible profiles. */
145
- private async _selectInstance(
146
- task: Task,
147
- eligible: BotProfile[],
148
- instances: BotInstance[],
149
- claimed: Set<string>,
150
- ): Promise<{ profileId: string; instanceId: string; method: OrchestratorDecision['method']; reason: string; confidence?: number } | null> {
151
- // Determine routing method
152
- let method = this._routingMethod(task, eligible);
153
-
154
- if (eligible.length === 1) {
155
- const profile = eligible[0];
156
- const idle = instances.find(
157
- (i) => i.profileId === profile.id && i.status === 'idle' && !claimed.has(i.instanceId),
158
- );
159
- if (!idle) return null;
160
- return {
161
- profileId: profile.id,
162
- instanceId: idle.instanceId,
163
- method,
164
- reason: `${method}: ${profile.id}`,
165
- };
139
+ // Single eligible profile
140
+ if (profiles.length === 1) {
141
+ return { profileId: profiles[0].id, method: 'single-eligible' };
166
142
  }
167
143
 
168
- // AI routing: when multiple eligible profiles and AI router is provided
169
- if (this._aiRouter && eligible.length > 1) {
144
+ // Multiple eligible profiles try AI routing
145
+ if (this._aiRouter && profiles.length > 1) {
170
146
  try {
171
- const aiResult = await this._aiRouter.route(task, eligible);
172
- const aiProfile = eligible.find((p) => p.id === aiResult.profileId);
147
+ const aiResult = await this._aiRouter.route(task, profiles);
148
+ const aiProfile = profiles.find(p => p.id === aiResult.profileId);
173
149
  if (aiProfile) {
174
- const idle = instances.find(
175
- (i) => i.profileId === aiProfile.id && i.status === 'idle' && !claimed.has(i.instanceId),
176
- );
177
- if (idle) {
178
- return {
179
- profileId: aiProfile.id,
180
- instanceId: idle.instanceId,
181
- method: 'ai-routed',
182
- reason: `ai-routed: ${aiProfile.id} — ${aiResult.reason}`,
183
- confidence: aiResult.confidence,
184
- };
185
- }
186
- // AI chose a profile but no idle instance — fall through to round-robin
150
+ return { profileId: aiProfile.id, method: 'ai-routed', confidence: aiResult.confidence };
187
151
  }
188
- // AI returned unknown profile — fall through to round-robin
189
152
  } catch {
190
153
  // AI call failed — fall through to round-robin
191
154
  }
192
155
  }
193
156
 
194
- // Round-robin: pick profile with most idle instances
195
- method = 'round-robin';
196
- let bestProfile: BotProfile | null = null;
197
- let bestIdleCount = -1;
198
- let bestInstance: BotInstance | null = null;
199
-
200
- for (const profile of eligible) {
201
- const idleInstances = instances.filter(
202
- (i) => i.profileId === profile.id && i.status === 'idle' && !claimed.has(i.instanceId),
203
- );
204
- if (idleInstances.length > bestIdleCount) {
205
- bestIdleCount = idleInstances.length;
206
- bestProfile = profile;
207
- bestInstance = idleInstances[0] ?? null;
208
- }
209
- }
210
-
211
- if (!bestProfile || !bestInstance) return null;
212
-
213
- return {
214
- profileId: bestProfile.id,
215
- instanceId: bestInstance.instanceId,
216
- method,
217
- reason: `${method}: ${bestProfile.id}`,
218
- };
157
+ // Fallback: round-robin (first profile)
158
+ return profiles.length > 0 ? { profileId: profiles[0].id, method: 'round-robin' } : null;
219
159
  }
220
160
 
221
- /** Determine which routing method was used. */
222
- private _routingMethod(
223
- task: Task,
224
- eligible: BotProfile[],
225
- ): OrchestratorDecision['method'] {
226
- if (task.assignedProfile) return 'exact-match';
227
- if (eligible.length === 1) return 'single-eligible';
228
- return 'round-robin';
161
+ /** Find any idle worker that hasn't been claimed this cycle. */
162
+ private _findIdleWorker(instances: BotInstance[], claimed: Set<string>): BotInstance | null {
163
+ return instances.find(
164
+ (i) => i.status === 'idle' && !claimed.has(i.instanceId),
165
+ ) ?? null;
229
166
  }
230
167
 
231
168
  /** Record a decision in the log. */
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,6 +189,8 @@ export async function executeStep(
189
189
  }
190
190
  assertSafePath(file, projectDir);
191
191
  const filePath = path.resolve(projectDir, file);
192
+ // Use the relative path for reporting — never leak absolute server paths
193
+ const relPath = path.relative(projectDir, filePath);
192
194
  const rawContent = args.content ?? args.body ?? '';
193
195
  if (typeof rawContent !== 'string') {
194
196
  return { blocked: true, blockReason: `${step.operation}: "content" must be a string, got ${typeof rawContent}.` };
@@ -197,14 +199,14 @@ export async function executeStep(
197
199
 
198
200
  const guard = checkWriteSafety(filePath, content);
199
201
  if (!guard.allowed) {
200
- return { file: filePath, blocked: true, blockReason: guard.reason };
202
+ return { file: relPath, blocked: true, blockReason: guard.reason };
201
203
  }
202
204
 
203
205
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
204
206
  const existed = fs.existsSync(filePath);
205
207
  fs.writeFileSync(filePath, content, 'utf-8');
206
208
  filesWrittenThisPlan++;
207
- return { file: filePath, created: !existed };
209
+ return { file: relPath, created: !existed };
208
210
  }
209
211
 
210
212
  // -----------------------------------------------------------------
@@ -216,6 +218,7 @@ export async function executeStep(
216
218
  }
217
219
  assertSafePath(file, projectDir);
218
220
  const filePath = path.resolve(projectDir, file);
221
+ const relPath = path.relative(projectDir, filePath);
219
222
 
220
223
  if (!fs.existsSync(filePath)) {
221
224
  return { blocked: true, blockReason: `File not found: ${file}` };
@@ -263,7 +266,7 @@ export async function executeStep(
263
266
 
264
267
  if (applied === 0) {
265
268
  return {
266
- file: filePath,
269
+ file: relPath,
267
270
  output: `No patches applied. Search strings not found: ${notFound.join('; ')}`,
268
271
  };
269
272
  }
@@ -274,7 +277,7 @@ export async function executeStep(
274
277
  if (originalSize > SHRINK_GUARD_MIN_SIZE && newSize < originalSize * MAX_SHRINK_RATIO) {
275
278
  const shrinkPct = Math.round((1 - newSize / originalSize) * 100);
276
279
  return {
277
- file: filePath,
280
+ file: relPath,
278
281
  blocked: true,
279
282
  blockReason:
280
283
  `Refusing to patch ${path.basename(filePath)}: result (${newSize}B) ` +
@@ -288,7 +291,7 @@ export async function executeStep(
288
291
 
289
292
  const summary = `Applied ${applied}/${patches.length} patches` +
290
293
  (notFound.length ? `. Not found: ${notFound.join('; ')}` : '');
291
- return { file: filePath, output: summary };
294
+ return { file: relPath, output: summary };
292
295
  }
293
296
 
294
297
  // -----------------------------------------------------------------
@@ -300,6 +303,7 @@ export async function executeStep(
300
303
  }
301
304
  assertSafePath(file, projectDir);
302
305
  const filePath = path.resolve(projectDir, file);
306
+ const relPath = path.relative(projectDir, filePath);
303
307
  if (!fs.existsSync(filePath)) {
304
308
  return { output: `File not found: ${file}` };
305
309
  }
@@ -313,9 +317,9 @@ export async function executeStep(
313
317
  return { output: `File too large to read (${stat.size} bytes, max ${MAX_READ_SIZE}). Use run_shell with head/tail instead.` };
314
318
  }
315
319
  const content = fs.readFileSync(filePath, 'utf-8');
316
- return { file: filePath, output: content };
320
+ return { file: relPath, output: content };
317
321
  } catch (err: unknown) {
318
- const msg = err instanceof Error ? err.message : String(err);
322
+ const msg = (err instanceof Error ? err.message : String(err)).replaceAll(projectDir + '/', '');
319
323
  return { output: `Error reading file "${file}": ${msg}` };
320
324
  }
321
325
  }
@@ -356,8 +360,9 @@ export async function executeStep(
356
360
  const stdout = (execErr.stdout ?? '').trim();
357
361
  const stderr = (execErr.stderr ?? '').trim();
358
362
  const combined = [stdout, stderr].filter(Boolean).join('\n');
363
+ const raw = combined || (err instanceof Error ? err.message : String(err));
359
364
  return {
360
- output: combined || (err instanceof Error ? err.message : String(err)),
365
+ output: raw.replaceAll(projectDir + '/', ''),
361
366
  };
362
367
  }
363
368
  }
@@ -379,7 +384,7 @@ export async function executeStep(
379
384
  try {
380
385
  entries = fs.readdirSync(targetDir, { recursive: true, encoding: 'utf-8' }) as string[];
381
386
  } catch (err: unknown) {
382
- const msg = err instanceof Error ? err.message : String(err);
387
+ const msg = (err instanceof Error ? err.message : String(err)).replaceAll(projectDir + '/', '');
383
388
  return { output: `Error listing directory "${dir}": ${msg}` };
384
389
  }
385
390
  let files = entries
@@ -456,6 +461,22 @@ export async function executeStep(
456
461
  parentId = resolved;
457
462
  }
458
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
+ }
479
+
459
480
  // Resolve symbolic IDs in dependsOn through the map
460
481
  const rawDeps = (args.dependsOn as string[]) ?? [];
461
482
  const resolvedDeps = symbolicIdMap