@leclabs/agent-flow-navigator-mcp 1.2.0 → 1.4.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.
@@ -125,6 +125,12 @@
125
125
  {
126
126
  "from": "commit",
127
127
  "to": "end_success"
128
+ },
129
+ {
130
+ "from": "hitl_failed",
131
+ "to": "implement",
132
+ "on": "passed",
133
+ "label": "Human resolved issue, resume"
128
134
  }
129
135
  ]
130
136
  }
@@ -148,6 +148,18 @@
148
148
  {
149
149
  "from": "commit",
150
150
  "to": "end_success"
151
+ },
152
+ {
153
+ "from": "hitl_cannot_reproduce",
154
+ "to": "reproduce",
155
+ "on": "passed",
156
+ "label": "Human provided reproduction info, resume"
157
+ },
158
+ {
159
+ "from": "hitl_fix_failed",
160
+ "to": "write_fix",
161
+ "on": "passed",
162
+ "label": "Human resolved fix issue, resume"
151
163
  }
152
164
  ]
153
165
  }
@@ -105,6 +105,12 @@
105
105
  {
106
106
  "from": "commit",
107
107
  "to": "end_success"
108
+ },
109
+ {
110
+ "from": "hitl_blocked",
111
+ "to": "build",
112
+ "on": "passed",
113
+ "label": "Human resolved issue, resume"
108
114
  }
109
115
  ]
110
116
  }
@@ -103,6 +103,12 @@
103
103
  {
104
104
  "from": "commit",
105
105
  "to": "end_success"
106
+ },
107
+ {
108
+ "from": "hitl_blocked",
109
+ "to": "build",
110
+ "on": "passed",
111
+ "label": "Human resolved issue, resume"
106
112
  }
107
113
  ]
108
114
  }
@@ -61,12 +61,19 @@
61
61
  "name": "Complete",
62
62
  "description": "Context optimization complete"
63
63
  },
64
- "hitl_failed": {
64
+ "hitl_design_failed": {
65
65
  "type": "end",
66
66
  "result": "blocked",
67
67
  "escalation": "hitl",
68
- "name": "Needs Help",
69
- "description": "Optimization needs human guidance"
68
+ "name": "Design Needs Help",
69
+ "description": "Design optimization needs human guidance"
70
+ },
71
+ "hitl_verify_failed": {
72
+ "type": "end",
73
+ "result": "blocked",
74
+ "escalation": "hitl",
75
+ "name": "Verification Needs Help",
76
+ "description": "Verification needs human intervention"
70
77
  }
71
78
  },
72
79
  "edges": [
@@ -94,7 +101,7 @@
94
101
  },
95
102
  {
96
103
  "from": "review_design",
97
- "to": "hitl_failed",
104
+ "to": "hitl_design_failed",
98
105
  "on": "failed",
99
106
  "label": "Design needs human input"
100
107
  },
@@ -116,7 +123,7 @@
116
123
  },
117
124
  {
118
125
  "from": "verify",
119
- "to": "hitl_failed",
126
+ "to": "hitl_verify_failed",
120
127
  "on": "failed",
121
128
  "label": "Verification failed"
122
129
  },
@@ -125,6 +132,18 @@
125
132
  "to": "end_success",
126
133
  "on": "passed",
127
134
  "label": "Optimization verified"
135
+ },
136
+ {
137
+ "from": "hitl_design_failed",
138
+ "to": "design_improvements",
139
+ "on": "passed",
140
+ "label": "Human resolved design issue, resume"
141
+ },
142
+ {
143
+ "from": "hitl_verify_failed",
144
+ "to": "implement",
145
+ "on": "passed",
146
+ "label": "Human resolved verification issue, resume"
128
147
  }
129
148
  ]
130
149
  }
@@ -220,6 +220,18 @@
220
220
  {
221
221
  "from": "create_pr",
222
222
  "to": "end_success"
223
+ },
224
+ {
225
+ "from": "hitl_plan_failed",
226
+ "to": "create_plan",
227
+ "on": "passed",
228
+ "label": "Human resolved planning issue, resume"
229
+ },
230
+ {
231
+ "from": "hitl_impl_failed",
232
+ "to": "implement",
233
+ "on": "passed",
234
+ "label": "Human resolved implementation issue, resume"
223
235
  }
224
236
  ]
225
237
  }
@@ -0,0 +1,46 @@
1
+ {
2
+ "id": "hitl-test",
3
+ "name": "HITL Test",
4
+ "description": "Minimal workflow for testing HITL recovery: work, gate, escalate, human resumes.",
5
+ "nodes": {
6
+ "start": {
7
+ "type": "start",
8
+ "name": "Start"
9
+ },
10
+ "work": {
11
+ "type": "task",
12
+ "name": "Do Work",
13
+ "description": "Do the thing",
14
+ "agent": "Developer",
15
+ "stage": "development"
16
+ },
17
+ "check": {
18
+ "type": "gate",
19
+ "name": "Check",
20
+ "description": "Pass or fail",
21
+ "agent": "Reviewer",
22
+ "stage": "verification",
23
+ "maxRetries": 1
24
+ },
25
+ "end_success": {
26
+ "type": "end",
27
+ "result": "success",
28
+ "name": "Done"
29
+ },
30
+ "hitl_blocked": {
31
+ "type": "end",
32
+ "result": "blocked",
33
+ "escalation": "hitl",
34
+ "name": "Blocked",
35
+ "description": "Needs human help"
36
+ }
37
+ },
38
+ "edges": [
39
+ { "from": "start", "to": "work" },
40
+ { "from": "work", "to": "check" },
41
+ { "from": "check", "to": "end_success", "on": "passed" },
42
+ { "from": "check", "to": "work", "on": "failed", "label": "Retry" },
43
+ { "from": "check", "to": "hitl_blocked", "on": "failed", "label": "Escalate" },
44
+ { "from": "hitl_blocked", "to": "work", "on": "passed", "label": "Human fixed it, resume" }
45
+ ]
46
+ }
@@ -110,6 +110,12 @@
110
110
  {
111
111
  "from": "commit",
112
112
  "to": "end_success"
113
+ },
114
+ {
115
+ "from": "hitl_blocked",
116
+ "to": "execute",
117
+ "on": "passed",
118
+ "label": "Human resolved issue, resume"
113
119
  }
114
120
  ]
115
121
  }
@@ -231,6 +231,18 @@
231
231
  {
232
232
  "from": "commit",
233
233
  "to": "end_success"
234
+ },
235
+ {
236
+ "from": "hitl_analysis_failed",
237
+ "to": "design_refactor",
238
+ "on": "passed",
239
+ "label": "Human resolved analysis issue, resume"
240
+ },
241
+ {
242
+ "from": "hitl_dev_failed",
243
+ "to": "extract_core",
244
+ "on": "passed",
245
+ "label": "Human resolved development issue, resume"
234
246
  }
235
247
  ]
236
248
  }
@@ -148,6 +148,12 @@
148
148
  {
149
149
  "from": "commit",
150
150
  "to": "end_success"
151
+ },
152
+ {
153
+ "from": "hitl_failed",
154
+ "to": "write_tests",
155
+ "on": "passed",
156
+ "label": "Human resolved issue, resume"
151
157
  }
152
158
  ]
153
159
  }
@@ -236,6 +236,24 @@
236
236
  {
237
237
  "from": "commit",
238
238
  "to": "end_success"
239
+ },
240
+ {
241
+ "from": "hitl_ir_failed",
242
+ "to": "ir_component_tree",
243
+ "on": "passed",
244
+ "label": "Human resolved IR issue, resume"
245
+ },
246
+ {
247
+ "from": "hitl_build_failed",
248
+ "to": "uiRebuild_build",
249
+ "on": "passed",
250
+ "label": "Human resolved build issue, resume"
251
+ },
252
+ {
253
+ "from": "hitl_final_failed",
254
+ "to": "uiRebuild_build",
255
+ "on": "passed",
256
+ "label": "Human resolved final review issue, resume"
239
257
  }
240
258
  ]
241
259
  }
package/engine.js CHANGED
@@ -14,6 +14,7 @@
14
14
  */
15
15
 
16
16
  import { existsSync, readFileSync, writeFileSync } from "fs";
17
+ import { join } from "path";
17
18
 
18
19
  /**
19
20
  * Read and parse a task file
@@ -52,16 +53,56 @@ export function getTerminalType(node) {
52
53
  }
53
54
 
54
55
  /**
55
- * Convert agent ID to subagent reference
56
- * e.g., "developer" -> "@flow:developer"
56
+ * Return agent ID as-is from workflow definition.
57
+ * Prefixing (e.g., @flow:) is the caller's responsibility.
57
58
  */
58
59
  export function toSubagentRef(agentId) {
59
60
  if (!agentId) return null;
60
- if (agentId.startsWith("@")) return agentId;
61
- // Namespaced: "org:developer" -> "@org:developer"
62
- if (agentId.includes(":")) return `@${agentId}`;
63
- // Simple: "developer" -> "@flow:developer"
64
- return `@flow:${agentId}`;
61
+ return agentId;
62
+ }
63
+
64
+ /**
65
+ * Workflow emoji mapping for task subjects
66
+ */
67
+ const WORKFLOW_EMOJIS = {
68
+ "feature-development": "✨",
69
+ "bug-fix": "🐛",
70
+ "agile-task": "📋",
71
+ "context-optimization": "🔧",
72
+ "quick-task": "⚡",
73
+ "ui-reconstruction": "🎨",
74
+ "test-coverage": "🧪",
75
+ };
76
+
77
+ /**
78
+ * Build formatted task subject for write-through
79
+ */
80
+ export function buildTaskSubject(taskId, userDescription, workflowType, stepId, subagent, terminal, maxRetries, retryCount) {
81
+ const emoji = WORKFLOW_EMOJIS[workflowType] || "";
82
+ const line1 = `#${taskId} ${userDescription}${emoji ? ` ${emoji}` : ""}`;
83
+
84
+ let line2;
85
+ if (terminal === "success") {
86
+ line2 = `→ ${workflowType} · completed ✓`;
87
+ } else if (terminal === "hitl" || terminal === "failure") {
88
+ line2 = `→ ${workflowType} · ${stepId} · HITL`;
89
+ } else {
90
+ const agent = subagent ? `(${subagent})` : "(direct)";
91
+ const retries = maxRetries > 0 ? ` · retries: ${retryCount}/${maxRetries}` : "";
92
+ line2 = `→ ${workflowType} · ${stepId} ${agent}${retries}`;
93
+ }
94
+
95
+ return `${line1}\n${line2}`;
96
+ }
97
+
98
+ /**
99
+ * Build activeForm for task spinner display
100
+ */
101
+ export function buildTaskActiveForm(stepName, subagent, terminal) {
102
+ if (terminal === "success") return "Completed";
103
+ if (terminal === "hitl" || terminal === "failure") return "HITL - Needs human help";
104
+ const agent = subagent ? ` (${subagent})` : "";
105
+ return `${stepName}${agent}`;
65
106
  }
66
107
 
67
108
  /**
@@ -71,8 +112,13 @@ export function getBaselineInstructions(stepId, stepName) {
71
112
  const id = stepId.toLowerCase();
72
113
  const name = (stepName || "").toLowerCase();
73
114
 
74
- // Analysis/Planning steps
75
- if (id.includes("analyze") || id.includes("analysis") || name.includes("analyze")) {
115
+ // Review steps (checked early — "plan_review" is a review, not a plan)
116
+ if (id.includes("review")) {
117
+ return "Check for correctness, code quality, and adherence to project standards. Verify the implementation meets requirements.";
118
+ }
119
+
120
+ // Analysis/Requirements steps
121
+ if (id.includes("analyze") || id.includes("analysis") || id.includes("parse") || id.includes("requirements") || name.includes("analyze")) {
76
122
  return "Review the task requirements carefully. Identify key constraints, dependencies, and acceptance criteria. Create a clear plan before proceeding.";
77
123
  }
78
124
  if (id.includes("plan") || id.includes("design") || name.includes("plan")) {
@@ -90,16 +136,16 @@ export function getBaselineInstructions(stepId, stepName) {
90
136
  return "Improve code structure without changing behavior. Ensure all tests pass before and after changes.";
91
137
  }
92
138
 
139
+ // Lint/format steps
140
+ if (id.includes("lint") || id.includes("format")) {
141
+ return "Run linting and formatting checks. Auto-fix issues where possible. Flag any issues that require manual attention.";
142
+ }
143
+
93
144
  // Testing steps
94
145
  if (id.includes("test") || id.includes("verify") || id.includes("validate")) {
95
146
  return "Verify the implementation works correctly. Test happy paths, edge cases, and error conditions. Document any issues found.";
96
147
  }
97
148
 
98
- // Review steps
99
- if (id.includes("review")) {
100
- return "Check for correctness, code quality, and adherence to project standards. Verify the implementation meets requirements.";
101
- }
102
-
103
149
  // Documentation steps
104
150
  if (id.includes("document") || id.includes("readme")) {
105
151
  return "Write clear, concise documentation. Focus on what users need to know, not implementation details.";
@@ -127,18 +173,61 @@ export function getBaselineInstructions(stepId, stepName) {
127
173
  return "Complete this step thoroughly. Document your findings and any decisions made.";
128
174
  }
129
175
 
176
+ /**
177
+ * Resolve a context_files entry to an absolute path.
178
+ *
179
+ * Convention (follows Claude Code plugin path rules):
180
+ * - "./path" → relative to the workflow's source root (plugin root, project root, etc.)
181
+ * - "path" → relative to projectRoot
182
+ *
183
+ * @param {string} file - Context file entry
184
+ * @param {string} projectRoot - Project root directory
185
+ * @param {string|null} sourceRoot - Root directory of the workflow's source
186
+ * @returns {string} Absolute file path
187
+ */
188
+ export function resolveContextFile(file, projectRoot, sourceRoot) {
189
+ if (file.startsWith("./") && sourceRoot) {
190
+ return join(sourceRoot, file);
191
+ }
192
+ return join(projectRoot, file);
193
+ }
194
+
195
+ /**
196
+ * Resolve ./ prefixed paths in prose text against sourceRoot.
197
+ * Leaves text unchanged when sourceRoot is null or text has no ./ references.
198
+ * @param {string|null} text - Prose text that may contain ./ paths
199
+ * @param {string|null} sourceRoot - Root directory for ./ resolution
200
+ * @returns {string|null} Text with ./ paths resolved to absolute paths
201
+ */
202
+ export function resolveProseRefs(text, sourceRoot) {
203
+ if (!text || !sourceRoot) return text;
204
+ return text.replace(/\.\/[\w\-\.\/]+/g, (match) => join(sourceRoot, match));
205
+ }
206
+
207
+ /**
208
+ * Build context loading instructions from step-level context_files.
209
+ * Returns a markdown section or null if no context declared.
210
+ */
211
+ export function buildContextInstructions({ contextFiles, projectRoot, sourceRoot }) {
212
+ if (!contextFiles?.length || !projectRoot) return null;
213
+ const lines = contextFiles.map((file) => `- Read file: ${resolveContextFile(file, projectRoot, sourceRoot)}`);
214
+ return `## Context\n\nBefore beginning, load the following:\n${lines.join("\n")}`;
215
+ }
216
+
130
217
  /**
131
218
  * Build orchestrator instructions for task creation/update
132
219
  * Returns null for terminal nodes (no further work)
133
220
  */
134
- function buildOrchestratorInstructions(workflowType, stepId, stage, subagent, stepInstructions, description) {
221
+ function buildOrchestratorInstructions(workflowType, stepId, stage, subagent, stepInstructions, description, contextBlock) {
135
222
  if (!stepInstructions) return null; // Terminal nodes have no instructions
136
223
 
137
224
  const delegationPrefix = subagent ? `Invoke ${subagent} to complete the following task: ` : "";
138
225
 
139
- return `${delegationPrefix}${stepInstructions.guidance}
226
+ let result = `${delegationPrefix}${stepInstructions.guidance}
140
227
 
141
228
  ${description || "{task description}"}`;
229
+ if (contextBlock) result += `\n\n${contextBlock}`;
230
+ return result;
142
231
  }
143
232
 
144
233
  /**
@@ -152,31 +241,46 @@ function buildNavigateResponse(
152
241
  action,
153
242
  retriesIncremented = false,
154
243
  retryCount = 0,
155
- description = null
244
+ description = null,
245
+ resetRetryCount = false,
246
+ projectRoot = null,
247
+ sourceRoot = null
156
248
  ) {
157
249
  const stage = stepDef.stage || null;
158
250
  const subagent = stepDef.agent ? toSubagentRef(stepDef.agent) : null;
159
251
 
160
252
  // Build step instructions from workflow definition + baseline
253
+ // Resolve ./ paths in prose fields against sourceRoot (same convention as context_files)
161
254
  const isTerminal = isTerminalNode(stepDef);
162
255
  const stepInstructions = isTerminal
163
256
  ? null
164
257
  : {
165
258
  name: stepDef.name || stepId,
166
- description: stepDef.description || null,
167
- guidance: stepDef.instructions || getBaselineInstructions(stepId, stepDef.name),
259
+ description: resolveProseRefs(stepDef.description, sourceRoot) || null,
260
+ guidance: resolveProseRefs(stepDef.instructions, sourceRoot) || getBaselineInstructions(stepId, stepDef.name),
168
261
  };
169
262
 
263
+ // Build context block from step-level context_files
264
+ const contextBlock = isTerminal
265
+ ? null
266
+ : buildContextInstructions({ contextFiles: stepDef.context_files, projectRoot, sourceRoot });
267
+
170
268
  // Build orchestrator instructions for all non-terminal actions
171
269
  const orchestratorInstructions = isTerminal
172
270
  ? null
173
- : buildOrchestratorInstructions(workflowType, stepId, stage, subagent, stepInstructions, description);
271
+ : buildOrchestratorInstructions(workflowType, stepId, stage, subagent, stepInstructions, description, contextBlock);
174
272
 
175
273
  // Build metadata for task storage
274
+ // Increment on retry, reset on start or explicit forward progress (conditional advance),
275
+ // preserve on unconditional advances within retry loops and escalations
176
276
  const metadata = {
177
277
  workflowType,
178
278
  currentStep: stepId,
179
- retryCount: retriesIncremented ? retryCount + 1 : retryCount,
279
+ retryCount: retriesIncremented
280
+ ? retryCount + 1
281
+ : action === "start" || resetRetryCount
282
+ ? 0
283
+ : retryCount,
180
284
  };
181
285
 
182
286
  return {
@@ -187,6 +291,7 @@ function buildNavigateResponse(
187
291
  terminal: getTerminalType(stepDef),
188
292
  action,
189
293
  retriesIncremented,
294
+ maxRetries: stepDef.maxRetries || 0,
190
295
  orchestratorInstructions,
191
296
  metadata,
192
297
  };
@@ -357,7 +462,7 @@ export class WorkflowEngine {
357
462
  * @param {string} [options.description] - User's task description
358
463
  * @returns {Object} Navigation response with currentStep, stepInstructions, terminal, action, metadata, etc.
359
464
  */
360
- navigate({ taskFilePath, workflowType, result, description } = {}) {
465
+ navigate({ taskFilePath, workflowType, result, description, projectRoot } = {}) {
361
466
  let currentStep = null;
362
467
  let retryCount = 0;
363
468
 
@@ -397,6 +502,9 @@ export class WorkflowEngine {
397
502
  throw new Error(`Workflow '${workflowType}' must have nodes`);
398
503
  }
399
504
 
505
+ // Resolve source root for context_files with ./ prefix
506
+ const sourceRoot = this.store.getSourceRoot?.(workflowType) || null;
507
+
400
508
  const { nodes } = wfDef;
401
509
 
402
510
  // Case 1: No currentStep - start at first work step
@@ -417,7 +525,7 @@ export class WorkflowEngine {
417
525
  throw new Error(`First step '${firstEdge.to}' not found in workflow`);
418
526
  }
419
527
 
420
- return buildNavigateResponse(workflowType, firstEdge.to, firstStepDef, "start", false, 0, description);
528
+ return buildNavigateResponse(workflowType, firstEdge.to, firstStepDef, "start", false, 0, description, false, projectRoot, sourceRoot);
421
529
  }
422
530
 
423
531
  // Case 2: currentStep but no result - return current state
@@ -427,7 +535,7 @@ export class WorkflowEngine {
427
535
  throw new Error(`Step '${currentStep}' not found in workflow '${workflowType}'`);
428
536
  }
429
537
 
430
- return buildNavigateResponse(workflowType, currentStep, stepDef, "current", false, retryCount, description);
538
+ return buildNavigateResponse(workflowType, currentStep, stepDef, "current", false, retryCount, description, false, projectRoot, sourceRoot);
431
539
  }
432
540
 
433
541
  // Case 3: currentStep and result - advance to next step
@@ -447,9 +555,13 @@ export class WorkflowEngine {
447
555
  }
448
556
 
449
557
  // Determine action and whether retries incremented
558
+ const currentStepDef = nodes[currentStep];
559
+ const isHitlResume = getTerminalType(currentStepDef) === "hitl";
450
560
  const isRetry = evaluation.action === "retry";
451
561
  let action;
452
- if (isRetry) {
562
+ if (isHitlResume) {
563
+ action = "advance"; // Human fixed it → fresh advance, retryCount resets
564
+ } else if (isRetry) {
453
565
  action = "retry";
454
566
  } else if (getTerminalType(nextStepDef) === "hitl") {
455
567
  action = "escalate";
@@ -457,6 +569,10 @@ export class WorkflowEngine {
457
569
  action = "advance";
458
570
  }
459
571
 
572
+ // Only reset retryCount on genuine forward progress (conditional edge like on:"passed")
573
+ // Unconditional advances within retry loops (e.g., work → gate) preserve the count
574
+ const resetRetryCount = action === "advance" && evaluation.action === "conditional";
575
+
460
576
  const response = buildNavigateResponse(
461
577
  workflowType,
462
578
  evaluation.nextStep,
@@ -464,14 +580,30 @@ export class WorkflowEngine {
464
580
  action,
465
581
  isRetry,
466
582
  retryCount,
467
- description
583
+ description,
584
+ resetRetryCount,
585
+ projectRoot,
586
+ sourceRoot
468
587
  );
469
588
 
470
- // Write-through: persist state transition to task file
589
+ // Write-through: persist state transition and presentation to task file
471
590
  if (taskFilePath) {
472
591
  const task = readTaskFile(taskFilePath);
473
592
  if (task) {
593
+ const userDesc = task.metadata?.userDescription || "";
474
594
  task.metadata = { ...task.metadata, ...response.metadata };
595
+ task.subject = buildTaskSubject(
596
+ task.id, userDesc, response.metadata.workflowType,
597
+ response.currentStep, response.subagent, response.terminal,
598
+ response.maxRetries, response.metadata.retryCount
599
+ );
600
+ task.activeForm = buildTaskActiveForm(
601
+ response.stepInstructions?.name || response.currentStep,
602
+ response.subagent, response.terminal
603
+ );
604
+ if (response.orchestratorInstructions) {
605
+ task.description = response.orchestratorInstructions;
606
+ }
475
607
  writeFileSync(taskFilePath, JSON.stringify(task, null, 2));
476
608
  }
477
609
  }
package/index.js CHANGED
@@ -10,6 +10,7 @@
10
10
  * - ListWorkflows: List available workflows
11
11
  * - Diagram: Generate mermaid diagram for workflow
12
12
  * - CopyWorkflows: Copy workflows from catalog to project
13
+ * - LoadWorkflows: Load workflows from a directory at runtime (external plugins or project reload)
13
14
  * - ListCatalog: List workflows available in catalog
14
15
  */
15
16
 
@@ -101,23 +102,51 @@ function loadCatalogWorkflows(dirPath) {
101
102
  return loaded;
102
103
  }
103
104
 
105
+ /**
106
+ * Load external workflows from a directory: flat {id}.json files
107
+ * Used by the LoadWorkflows tool at runtime.
108
+ * @param {string} dirPath - Directory containing {id}.json workflow files
109
+ * @param {string} sourceRoot - Root path for resolving ./ context_files
110
+ * @returns {string[]} Array of loaded workflow IDs
111
+ */
112
+ function loadExternalWorkflows(dirPath, sourceRoot) {
113
+ if (!existsSync(dirPath)) return [];
114
+
115
+ const loaded = [];
116
+ const files = readdirSync(dirPath).filter((f) => f.endsWith(".json"));
117
+
118
+ for (const file of files) {
119
+ const id = file.replace(".json", "");
120
+ try {
121
+ const content = JSON.parse(readFileSync(join(dirPath, file), "utf-8"));
122
+ if (validateWorkflow(id, content)) {
123
+ store.loadDefinition(id, content, "external", sourceRoot);
124
+ loaded.push(id);
125
+ }
126
+ } catch (e) {
127
+ console.error(`Error loading external workflow ${id}: ${e.message}`);
128
+ }
129
+ }
130
+
131
+ return loaded;
132
+ }
133
+
104
134
  /**
105
135
  * Load workflows: catalog first, then project overwrites (project takes precedence)
106
136
  */
107
137
  function loadWorkflows() {
108
138
  const catalogPath = join(CATALOG_PATH, "workflows");
109
139
  const catalogLoaded = loadCatalogWorkflows(catalogPath);
110
-
111
140
  const projectLoaded = existsSync(WORKFLOWS_PATH) ? loadProjectWorkflows(WORKFLOWS_PATH) : [];
112
141
 
113
- // Determine which IDs came from where (project overwrites catalog)
114
- const fromCatalog = catalogLoaded.filter((id) => !projectLoaded.includes(id));
142
+ const allLoaded = [...new Set([...catalogLoaded, ...projectLoaded])];
115
143
  const fromProject = projectLoaded;
144
+ const fromCatalog = catalogLoaded.filter((id) => !projectLoaded.includes(id));
116
145
 
117
146
  return {
118
147
  catalog: fromCatalog,
119
148
  project: fromProject,
120
- loaded: [...new Set([...catalogLoaded, ...projectLoaded])],
149
+ loaded: allLoaded,
121
150
  };
122
151
  }
123
152
 
@@ -186,6 +215,10 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
186
215
  properties: {
187
216
  workflowType: { type: "string", description: "Workflow ID to visualize" },
188
217
  currentStep: { type: "string", description: "Optional: highlight this step" },
218
+ filePath: {
219
+ type: "string",
220
+ description: "Optional: absolute path to save the diagram. Defaults to .flow/diagrams/{workflowType}.md",
221
+ },
189
222
  },
190
223
  required: ["workflowType"],
191
224
  },
@@ -205,6 +238,26 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
205
238
  },
206
239
  },
207
240
  },
241
+ {
242
+ name: "LoadWorkflows",
243
+ description:
244
+ "Load workflows from a directory at runtime. External plugins pass their root path; omit path to reload project workflows.",
245
+ inputSchema: {
246
+ type: "object",
247
+ properties: {
248
+ path: {
249
+ type: "string",
250
+ description:
251
+ "Directory containing {id}.json workflow files. Omit to reload project workflows from .flow/workflows/.",
252
+ },
253
+ sourceRoot: {
254
+ type: "string",
255
+ description:
256
+ "Root path for resolving ./ context_files entries. Defaults to path (or project root when path omitted).",
257
+ },
258
+ },
259
+ },
260
+ },
208
261
  {
209
262
  name: "ListCatalog",
210
263
  description: "List workflows available in the catalog.",
@@ -229,6 +282,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
229
282
  workflowType: args.workflowType,
230
283
  result: args.result,
231
284
  description: args.description,
285
+ projectRoot: PROJECT_ROOT,
232
286
  });
233
287
  return jsonResponse(result);
234
288
  }
@@ -264,11 +318,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
264
318
  const source = store.getSource(args.workflowType);
265
319
  const markdown = generateDiagram(wfDef, args.currentStep);
266
320
 
267
- // Save diagram to file
268
- if (!existsSync(DIAGRAMS_PATH)) {
269
- mkdirSync(DIAGRAMS_PATH, { recursive: true });
321
+ // Save diagram to file (use provided path or default)
322
+ const filePath = args.filePath || join(DIAGRAMS_PATH, `${args.workflowType}.md`);
323
+ const fileDir = dirname(filePath);
324
+ if (!existsSync(fileDir)) {
325
+ mkdirSync(fileDir, { recursive: true });
270
326
  }
271
- const filePath = join(DIAGRAMS_PATH, `${args.workflowType}.md`);
272
327
  writeFileSync(filePath, markdown);
273
328
 
274
329
  return jsonResponse({ savedTo: filePath, source });
@@ -331,6 +386,32 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
331
386
  });
332
387
  }
333
388
 
389
+ case "LoadWorkflows": {
390
+ if (args.path) {
391
+ // External plugin: load from provided path
392
+ const dirPath = resolve(args.path);
393
+ const root = args.sourceRoot ? resolve(args.sourceRoot) : dirPath;
394
+ const loaded = loadExternalWorkflows(dirPath, root);
395
+ return jsonResponse({
396
+ schemaVersion: 2,
397
+ loaded,
398
+ source: "external",
399
+ sourceRoot: root,
400
+ path: dirPath,
401
+ });
402
+ } else {
403
+ // Project: reload from .flow/workflows/
404
+ const loaded = existsSync(WORKFLOWS_PATH) ? loadProjectWorkflows(WORKFLOWS_PATH) : [];
405
+ return jsonResponse({
406
+ schemaVersion: 2,
407
+ loaded,
408
+ source: "project",
409
+ sourceRoot: PROJECT_ROOT,
410
+ path: WORKFLOWS_PATH,
411
+ });
412
+ }
413
+ }
414
+
334
415
  case "ListCatalog": {
335
416
  const catalogPath = join(CATALOG_PATH, "workflows");
336
417
  if (!existsSync(catalogPath)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leclabs/agent-flow-navigator-mcp",
3
- "version": "1.2.0",
3
+ "version": "1.4.0",
4
4
  "description": "MCP server that navigates agents through DAG-based workflows",
5
5
  "license": "MIT",
6
6
  "author": "leclabs",
package/store.js CHANGED
@@ -29,19 +29,24 @@ export function validateWorkflow(id, content) {
29
29
  export class WorkflowStore {
30
30
  constructor() {
31
31
  this.workflows = new Map();
32
- this.sources = new Map(); // Track source: "catalog" | "project"
32
+ this.sources = new Map(); // Track source: "catalog" | "project" | "external"
33
+ this.sourceRoots = new Map(); // Track source root path per workflow (for ./ resolution)
33
34
  }
34
35
 
35
36
  /**
36
37
  * Load a workflow definition into the store
37
38
  * @param {string} id - Workflow identifier
38
39
  * @param {Object} workflow - Workflow definition
39
- * @param {string} source - Source: "catalog" | "project"
40
+ * @param {string} source - Source: "catalog" | "project" | "external"
41
+ * @param {string|null} sourceRoot - Root path for resolving ./ context_files
40
42
  * @returns {string} The workflow id
41
43
  */
42
- loadDefinition(id, workflow, source = "catalog") {
44
+ loadDefinition(id, workflow, source = "catalog", sourceRoot = null) {
43
45
  this.workflows.set(id, workflow);
44
46
  this.sources.set(id, source);
47
+ if (sourceRoot) {
48
+ this.sourceRoots.set(id, sourceRoot);
49
+ }
45
50
  return id;
46
51
  }
47
52
 
@@ -56,7 +61,7 @@ export class WorkflowStore {
56
61
 
57
62
  /**
58
63
  * List all loaded workflows with metadata
59
- * @param {string} filter - Filter by source: "all" | "project" | "catalog"
64
+ * @param {string} filter - Filter by source: "all" | "project" | "catalog" | "external"
60
65
  * @returns {Array} Array of workflow summaries
61
66
  */
62
67
  listWorkflows(filter = "all") {
@@ -86,6 +91,17 @@ export class WorkflowStore {
86
91
  return false;
87
92
  }
88
93
 
94
+ /**
95
+ * Check if any external workflows exist
96
+ * @returns {boolean}
97
+ */
98
+ hasExternalWorkflows() {
99
+ for (const source of this.sources.values()) {
100
+ if (source === "external") return true;
101
+ }
102
+ return false;
103
+ }
104
+
89
105
  /**
90
106
  * Get the source of a workflow
91
107
  * @param {string} id - Workflow identifier
@@ -95,6 +111,15 @@ export class WorkflowStore {
95
111
  return this.sources.get(id);
96
112
  }
97
113
 
114
+ /**
115
+ * Get the source root path for a workflow (for ./ context_files resolution)
116
+ * @param {string} id - Workflow identifier
117
+ * @returns {string|undefined} Source root path or undefined
118
+ */
119
+ getSourceRoot(id) {
120
+ return this.sourceRoots.get(id);
121
+ }
122
+
98
123
  /**
99
124
  * Check if a workflow exists
100
125
  * @param {string} id - Workflow identifier
@@ -110,6 +135,7 @@ export class WorkflowStore {
110
135
  clear() {
111
136
  this.workflows.clear();
112
137
  this.sources.clear();
138
+ this.sourceRoots.clear();
113
139
  }
114
140
 
115
141
  /**
package/types.d.ts CHANGED
@@ -51,6 +51,7 @@ export interface TaskNode {
51
51
  outputs?: string[];
52
52
  maxRetries?: number;
53
53
  config?: Record<string, unknown>;
54
+ context_files?: string[];
54
55
  }
55
56
 
56
57
  /**
@@ -66,6 +67,7 @@ export interface GateNode {
66
67
  outputs?: string[];
67
68
  maxRetries?: number;
68
69
  config?: Record<string, unknown>;
70
+ context_files?: string[];
69
71
  }
70
72
 
71
73
  /**
@@ -131,3 +133,47 @@ export type EdgeAction =
131
133
  | "escalate" // Failed and exceeded retry limit
132
134
  | "no_outgoing_edges" // Terminal node (no edges)
133
135
  | "no_matching_edge"; // No edge matched the output
136
+
137
+ // =============================================================================
138
+ // Navigate Options
139
+ // =============================================================================
140
+
141
+ export interface NavigateOptions {
142
+ taskFilePath?: string;
143
+ workflowType?: string;
144
+ result?: "passed" | "failed";
145
+ description?: string;
146
+ projectRoot?: string;
147
+ }
148
+
149
+ // =============================================================================
150
+ // Context Resolution
151
+ // =============================================================================
152
+
153
+ /**
154
+ * Resolve a context_files entry to an absolute path.
155
+ * - "./path" → relative to sourceRoot (the workflow's source directory)
156
+ * - "path" → relative to projectRoot
157
+ */
158
+ export function resolveContextFile(
159
+ file: string,
160
+ projectRoot: string,
161
+ sourceRoot?: string | null
162
+ ): string;
163
+
164
+ export function resolveProseRefs(
165
+ text: string | null,
166
+ sourceRoot: string | null
167
+ ): string | null;
168
+
169
+ export function buildContextInstructions(options: {
170
+ contextFiles?: string[];
171
+ projectRoot?: string | null;
172
+ sourceRoot?: string | null;
173
+ }): string | null;
174
+
175
+ // =============================================================================
176
+ // Store Types
177
+ // =============================================================================
178
+
179
+ export type WorkflowSource = "catalog" | "project" | "external";