@geminilight/mindos 0.6.29 → 0.6.31

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 (110) hide show
  1. package/README.md +10 -4
  2. package/README_zh.md +10 -4
  3. package/app/app/api/acp/config/route.ts +82 -0
  4. package/app/app/api/acp/detect/route.ts +71 -48
  5. package/app/app/api/acp/install/route.ts +51 -0
  6. package/app/app/api/acp/session/route.ts +141 -11
  7. package/app/app/api/ask/route.ts +126 -18
  8. package/app/app/api/export/route.ts +105 -0
  9. package/app/app/api/workflows/route.ts +156 -0
  10. package/app/app/globals.css +2 -2
  11. package/app/app/page.tsx +7 -2
  12. package/app/app/trash/page.tsx +7 -0
  13. package/app/app/view/[...path]/ViewPageClient.tsx +234 -2
  14. package/app/components/ActivityBar.tsx +12 -4
  15. package/app/components/AskModal.tsx +4 -1
  16. package/app/components/ExportModal.tsx +220 -0
  17. package/app/components/FileTree.tsx +42 -11
  18. package/app/components/HomeContent.tsx +92 -20
  19. package/app/components/MarkdownView.tsx +45 -10
  20. package/app/components/Panel.tsx +1 -0
  21. package/app/components/RightAskPanel.tsx +5 -1
  22. package/app/components/Sidebar.tsx +10 -1
  23. package/app/components/SidebarLayout.tsx +6 -0
  24. package/app/components/TrashPageClient.tsx +263 -0
  25. package/app/components/agents/AgentDetailContent.tsx +263 -47
  26. package/app/components/agents/AgentsContentPage.tsx +11 -0
  27. package/app/components/agents/AgentsPanelA2aTab.tsx +285 -46
  28. package/app/components/agents/AgentsPanelSessionsTab.tsx +166 -0
  29. package/app/components/agents/agents-content-model.ts +2 -2
  30. package/app/components/ask/AgentSelectorCapsule.tsx +218 -0
  31. package/app/components/ask/AskContent.tsx +197 -239
  32. package/app/components/ask/FileChip.tsx +82 -17
  33. package/app/components/ask/MentionPopover.tsx +21 -3
  34. package/app/components/ask/MessageList.tsx +30 -9
  35. package/app/components/ask/SlashCommandPopover.tsx +21 -3
  36. package/app/components/ask/ToolCallBlock.tsx +102 -18
  37. package/app/components/changes/ChangesContentPage.tsx +58 -14
  38. package/app/components/explore/ExploreContent.tsx +4 -7
  39. package/app/components/explore/UseCaseCard.tsx +18 -1
  40. package/app/components/explore/use-cases.generated.ts +76 -0
  41. package/app/components/explore/use-cases.yaml +185 -0
  42. package/app/components/panels/AgentsPanel.tsx +1 -0
  43. package/app/components/panels/AgentsPanelHubNav.tsx +9 -2
  44. package/app/components/panels/DiscoverPanel.tsx +1 -1
  45. package/app/components/panels/WorkflowsPanel.tsx +206 -0
  46. package/app/components/renderers/workflow-yaml/StepEditor.tsx +164 -0
  47. package/app/components/renderers/workflow-yaml/WorkflowEditor.tsx +211 -0
  48. package/app/components/renderers/workflow-yaml/WorkflowRunner.tsx +269 -0
  49. package/app/components/renderers/workflow-yaml/WorkflowYamlRenderer.tsx +126 -0
  50. package/app/components/renderers/workflow-yaml/execution.ts +229 -0
  51. package/app/components/renderers/workflow-yaml/index.ts +6 -0
  52. package/app/components/renderers/workflow-yaml/manifest.ts +21 -0
  53. package/app/components/renderers/workflow-yaml/parser.ts +172 -0
  54. package/app/components/renderers/workflow-yaml/selectors.tsx +574 -0
  55. package/app/components/renderers/workflow-yaml/serializer.ts +56 -0
  56. package/app/components/renderers/workflow-yaml/types.ts +46 -0
  57. package/app/components/settings/AiTab.tsx +191 -174
  58. package/app/components/settings/AppearanceTab.tsx +168 -77
  59. package/app/components/settings/KnowledgeTab.tsx +131 -136
  60. package/app/components/settings/McpTab.tsx +11 -11
  61. package/app/components/settings/Primitives.tsx +60 -0
  62. package/app/components/settings/SettingsContent.tsx +15 -8
  63. package/app/components/settings/SyncTab.tsx +12 -12
  64. package/app/components/settings/UninstallTab.tsx +8 -18
  65. package/app/components/settings/UpdateTab.tsx +82 -82
  66. package/app/components/settings/types.ts +17 -8
  67. package/app/hooks/useAcpConfig.ts +96 -0
  68. package/app/hooks/useAcpDetection.ts +69 -14
  69. package/app/hooks/useAcpRegistry.ts +46 -11
  70. package/app/hooks/useAskModal.ts +12 -5
  71. package/app/hooks/useAskPanel.ts +8 -5
  72. package/app/hooks/useAskSession.ts +19 -2
  73. package/app/hooks/useImageUpload.ts +152 -0
  74. package/app/lib/acp/acp-tools.ts +3 -1
  75. package/app/lib/acp/agent-descriptors.ts +274 -0
  76. package/app/lib/acp/bridge.ts +6 -0
  77. package/app/lib/acp/index.ts +20 -4
  78. package/app/lib/acp/registry.ts +74 -7
  79. package/app/lib/acp/session.ts +490 -28
  80. package/app/lib/acp/subprocess.ts +307 -21
  81. package/app/lib/acp/types.ts +158 -20
  82. package/app/lib/actions.ts +57 -3
  83. package/app/lib/agent/model.ts +18 -3
  84. package/app/lib/agent/stream-consumer.ts +18 -0
  85. package/app/lib/agent/to-agent-messages.ts +25 -2
  86. package/app/lib/agent/tools.ts +56 -9
  87. package/app/lib/core/export.ts +116 -0
  88. package/app/lib/core/trash.ts +241 -0
  89. package/app/lib/fs.ts +47 -0
  90. package/app/lib/hooks/usePinnedFiles.ts +90 -0
  91. package/app/lib/i18n/generated/explore-i18n.generated.ts +138 -0
  92. package/app/lib/i18n/index.ts +3 -0
  93. package/app/lib/i18n/modules/knowledge.ts +124 -6
  94. package/app/lib/i18n/modules/navigation.ts +2 -0
  95. package/app/lib/i18n/modules/onboarding.ts +2 -134
  96. package/app/lib/i18n/modules/panels.ts +146 -2
  97. package/app/lib/i18n/modules/settings.ts +12 -0
  98. package/app/lib/pi-integration/skills.ts +21 -6
  99. package/app/lib/renderers/index.ts +2 -2
  100. package/app/lib/settings.ts +10 -0
  101. package/app/lib/types.ts +12 -1
  102. package/app/next-env.d.ts +1 -1
  103. package/app/package.json +11 -3
  104. package/app/scripts/generate-explore.ts +145 -0
  105. package/package.json +1 -1
  106. package/templates/en/.mindos/workflows/Sprint Release.flow.yaml +130 -0
  107. package/templates/zh/.mindos/workflows//345/221/250/350/277/255/344/273/243/346/243/200/346/237/245.flow.yaml +84 -0
  108. package/app/components/explore/use-cases.ts +0 -58
  109. package/app/components/renderers/workflow/WorkflowRenderer.tsx +0 -409
  110. package/app/components/renderers/workflow/manifest.ts +0 -14
@@ -0,0 +1,126 @@
1
+ 'use client';
2
+
3
+ import { useMemo, useState, useCallback, useRef } from 'react';
4
+ import { Pencil, Play, AlertCircle, Zap } from 'lucide-react';
5
+ import type { RendererContext } from '@/lib/renderers/registry';
6
+ import { parseWorkflowYaml } from './parser';
7
+ import WorkflowEditor from './WorkflowEditor';
8
+ import WorkflowRunner from './WorkflowRunner';
9
+ import type { WorkflowYaml } from './types';
10
+
11
+ type Mode = 'edit' | 'run';
12
+
13
+ export function WorkflowYamlRenderer({ filePath, content }: RendererContext) {
14
+ const parsed = useMemo(() => parseWorkflowYaml(content), [content]);
15
+ const [mode, setMode] = useState<Mode>('edit');
16
+ const [dirty, setDirty] = useState(false);
17
+
18
+ const [editWorkflow, setEditWorkflow] = useState<WorkflowYaml>(() => {
19
+ if (parsed.workflow) return structuredClone(parsed.workflow);
20
+ return { title: '', description: '', steps: [] };
21
+ });
22
+
23
+ const savedContentRef = useRef(content);
24
+ const latestParsed = useMemo(() => parsed.workflow ?? null, [parsed.workflow]);
25
+
26
+ const handleEditorChange = useCallback((wf: WorkflowYaml) => {
27
+ setEditWorkflow(wf);
28
+ setDirty(true);
29
+ }, []);
30
+
31
+ const handleSaved = useCallback(() => setDirty(false), []);
32
+
33
+ const runWorkflow = latestParsed ?? editWorkflow;
34
+ const canRun = runWorkflow.steps.length > 0 && !!runWorkflow.title;
35
+ const hasParseErrors = parsed.errors.length > 0;
36
+
37
+ const runDisabledReason = !runWorkflow.title
38
+ ? 'Add a title first'
39
+ : runWorkflow.steps.length === 0
40
+ ? 'Add at least one step'
41
+ : dirty ? 'Save changes first' : undefined;
42
+
43
+ const handleModeSwitch = (target: Mode) => {
44
+ if (target === 'run' && !canRun) return;
45
+ setMode(target);
46
+ };
47
+
48
+ return (
49
+ <div className="max-w-[720px] mx-auto py-8 px-2">
50
+ {/* Hero header — gives the page identity */}
51
+ <div className="mb-8">
52
+ {/* Mode tabs + step count */}
53
+ <div className="flex items-center justify-between mb-4">
54
+ <div className="flex items-center gap-1 p-0.5 rounded-lg bg-muted/50">
55
+ <button
56
+ onClick={() => handleModeSwitch('edit')}
57
+ className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
58
+ mode === 'edit'
59
+ ? 'bg-background text-foreground shadow-sm'
60
+ : 'text-muted-foreground hover:text-foreground'
61
+ }`}
62
+ >
63
+ <Pencil size={11} />
64
+ Edit
65
+ </button>
66
+ <button
67
+ onClick={() => handleModeSwitch('run')}
68
+ disabled={!canRun}
69
+ title={runDisabledReason}
70
+ className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all disabled:opacity-30 disabled:cursor-not-allowed ${
71
+ mode === 'run'
72
+ ? 'bg-background text-foreground shadow-sm'
73
+ : 'text-muted-foreground hover:text-foreground'
74
+ }`}
75
+ >
76
+ <Play size={11} />
77
+ Run
78
+ </button>
79
+ </div>
80
+
81
+ {editWorkflow.steps.length > 0 && (
82
+ <span className="text-2xs text-muted-foreground/60 font-mono">
83
+ {editWorkflow.steps.length} step{editWorkflow.steps.length !== 1 ? 's' : ''}
84
+ </span>
85
+ )}
86
+ </div>
87
+
88
+ {/* Title */}
89
+ <div className="flex items-start gap-3">
90
+ <div className="mt-1 w-8 h-8 rounded-lg bg-[var(--amber)]/10 flex items-center justify-center shrink-0">
91
+ <Zap size={16} className="text-[var(--amber)]" />
92
+ </div>
93
+ <div className="min-w-0 flex-1">
94
+ <h1 className="text-lg font-semibold text-foreground truncate leading-tight">
95
+ {editWorkflow.title || 'Untitled Flow'}
96
+ {dirty && <span className="text-[var(--amber)] ml-1.5 text-sm" title="Unsaved changes">*</span>}
97
+ </h1>
98
+ {editWorkflow.description && (
99
+ <p className="text-xs text-muted-foreground mt-0.5 line-clamp-1">{editWorkflow.description}</p>
100
+ )}
101
+ </div>
102
+ </div>
103
+ </div>
104
+
105
+ {/* Parse error banner */}
106
+ {hasParseErrors && mode === 'edit' && (
107
+ <div className="mb-5 px-3.5 py-3 rounded-lg bg-[var(--error)]/8 border border-[var(--error)]/20">
108
+ <div className="flex items-center gap-2 mb-1.5">
109
+ <AlertCircle size={13} className="text-[var(--error)] shrink-0" />
110
+ <span className="text-xs font-medium text-[var(--error)]">Parse issues found</span>
111
+ </div>
112
+ <ul className="text-2xs text-[var(--error)]/70 pl-5 list-disc space-y-0.5">
113
+ {parsed.errors.slice(0, 3).map((e, i) => <li key={i}>{e}</li>)}
114
+ </ul>
115
+ </div>
116
+ )}
117
+
118
+ {/* Content */}
119
+ {mode === 'edit' ? (
120
+ <WorkflowEditor workflow={editWorkflow} filePath={filePath} onChange={handleEditorChange} onSaved={handleSaved} />
121
+ ) : (
122
+ <WorkflowRunner workflow={runWorkflow} filePath={filePath} />
123
+ )}
124
+ </div>
125
+ );
126
+ }
@@ -0,0 +1,229 @@
1
+ // Workflow step execution logic — fetches skills, resolves agents, constructs prompts, streams AI responses
2
+
3
+ import type { WorkflowYaml, WorkflowStepRuntime } from './types';
4
+
5
+ // ─── Skill Fetching ───────────────────────────────────────────────────────
6
+
7
+ /** Cache skill content for the duration of a workflow run to avoid redundant fetches */
8
+ const skillCache = new Map<string, string | null>();
9
+
10
+ export function clearSkillCache() {
11
+ skillCache.clear();
12
+ }
13
+
14
+ /**
15
+ * Fetch skill content from /api/skills. Returns the full SKILL.md content,
16
+ * or null if the skill doesn't exist. Results are cached per skill name.
17
+ */
18
+ async function fetchSkillContent(name: string, signal: AbortSignal): Promise<string | null> {
19
+ if (skillCache.has(name)) return skillCache.get(name)!;
20
+
21
+ try {
22
+ const res = await fetch('/api/skills', {
23
+ method: 'POST',
24
+ headers: { 'Content-Type': 'application/json' },
25
+ body: JSON.stringify({ action: 'read', name }),
26
+ signal,
27
+ });
28
+ if (!res.ok) {
29
+ skillCache.set(name, null);
30
+ return null;
31
+ }
32
+ const data = await res.json();
33
+ const content = data.content as string | undefined;
34
+ skillCache.set(name, content ?? null);
35
+ return content ?? null;
36
+ } catch {
37
+ // Network error or abort — don't cache failures
38
+ return null;
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Collect all unique skill names referenced by a step (step-level + workflow-level).
44
+ * Handles both legacy `skill` (singular) and new `skills` (array) fields.
45
+ */
46
+ function collectSkillNames(step: WorkflowStepRuntime, workflow: WorkflowYaml): string[] {
47
+ const names: string[] = [];
48
+ // Step-level skills (new array format takes priority)
49
+ if (step.skills?.length) {
50
+ for (const s of step.skills) {
51
+ if (!names.includes(s)) names.push(s);
52
+ }
53
+ } else if (step.skill) {
54
+ names.push(step.skill);
55
+ }
56
+ // Workflow-level skills
57
+ for (const s of workflow.skills ?? []) {
58
+ if (!names.includes(s)) names.push(s);
59
+ }
60
+ return names;
61
+ }
62
+
63
+ // ─── ACP Agent Resolution ────────────────────────────────────────────────
64
+
65
+ /** Resolve an agent name/id to an ACP agent selection { id, name } */
66
+ async function resolveAcpAgent(
67
+ agentId: string,
68
+ signal: AbortSignal,
69
+ ): Promise<{ id: string; name: string } | null> {
70
+ try {
71
+ const res = await fetch(`/api/acp/registry?agent=${encodeURIComponent(agentId)}`, { signal });
72
+ if (!res.ok) return null;
73
+ const data = await res.json();
74
+ if (data.agent?.id) {
75
+ return { id: data.agent.id, name: data.agent.name || agentId };
76
+ }
77
+ return null;
78
+ } catch {
79
+ return null;
80
+ }
81
+ }
82
+
83
+ // ─── Prompt Construction ──────────────────────────────────────────────────
84
+
85
+ function buildPrompt(
86
+ step: WorkflowStepRuntime,
87
+ workflow: WorkflowYaml,
88
+ skillContents: Map<string, string>,
89
+ ): string {
90
+ const allStepsSummary = workflow.steps.map((s, i) => `${i + 1}. ${s.name}`).join('\n');
91
+
92
+ // Determine primary skill name for labeling
93
+ const primarySkill = step.skills?.length ? step.skills[0] : step.skill;
94
+
95
+ // Build skill context block
96
+ let skillBlock = '';
97
+ if (skillContents.size > 0) {
98
+ const sections: string[] = [];
99
+ for (const [name, content] of skillContents) {
100
+ const isPrimary = name === primarySkill;
101
+ sections.push(
102
+ `### ${isPrimary ? '[Primary] ' : ''}Skill: ${name}\n\n${content}`
103
+ );
104
+ }
105
+ skillBlock = `\n\n---\n## Skill Reference\n\nFollow the guidelines from these skills when executing this step:\n\n${sections.join('\n\n---\n\n')}`;
106
+ }
107
+
108
+ return `You are executing step ${step.index + 1} of a workflow: "${step.name}".
109
+
110
+ Context of the full workflow "${workflow.title}":
111
+ ${allStepsSummary}
112
+
113
+ Current step instructions:
114
+ ${step.prompt || '(No specific instructions — use common sense.)'}${skillBlock}
115
+
116
+ Execute concisely. Provide:
117
+ 1. What you did / what the output is
118
+ 2. Any decisions made
119
+ 3. What the next step should watch out for
120
+
121
+ Be specific and actionable. Format in Markdown.`;
122
+ }
123
+
124
+ // ─── Streaming Execution ──────────────────────────────────────────────────
125
+
126
+ /**
127
+ * Execute a workflow step with AI:
128
+ * 1. Fetch referenced skill(s) content
129
+ * 2. Resolve ACP agent (if step.agent is set)
130
+ * 3. Build prompt with injected skill context
131
+ * 4. Stream response from /api/ask (routed to ACP agent if resolved)
132
+ */
133
+ export async function runStepWithAI(
134
+ step: WorkflowStepRuntime,
135
+ workflow: WorkflowYaml,
136
+ filePath: string,
137
+ onChunk: (accumulated: string) => void,
138
+ signal: AbortSignal,
139
+ ): Promise<void> {
140
+ // 1. Fetch skill content
141
+ const skillNames = collectSkillNames(step, workflow);
142
+ const skillContents = new Map<string, string>();
143
+
144
+ if (skillNames.length > 0) {
145
+ const results = await Promise.all(
146
+ skillNames.map(async (name) => {
147
+ const content = await fetchSkillContent(name, signal);
148
+ return [name, content] as const;
149
+ })
150
+ );
151
+ for (const [name, content] of results) {
152
+ if (content) skillContents.set(name, content);
153
+ }
154
+ }
155
+
156
+ // 2. Resolve ACP agent
157
+ let selectedAcpAgent: { id: string; name: string } | null = null;
158
+ if (step.agent) {
159
+ selectedAcpAgent = await resolveAcpAgent(step.agent, signal);
160
+ // If agent name doesn't resolve in ACP registry, skip silently
161
+ // (the step will run with the default MindOS agent)
162
+ }
163
+
164
+ // 3. Build prompt
165
+ const prompt = buildPrompt(step, workflow, skillContents);
166
+
167
+ // 4. Stream from /api/ask
168
+ const body: Record<string, unknown> = {
169
+ messages: [{ role: 'user', content: prompt }],
170
+ currentFile: filePath,
171
+ };
172
+ if (selectedAcpAgent) {
173
+ body.selectedAcpAgent = selectedAcpAgent;
174
+ }
175
+
176
+ const res = await fetch('/api/ask', {
177
+ method: 'POST',
178
+ headers: { 'Content-Type': 'application/json' },
179
+ body: JSON.stringify(body),
180
+ signal,
181
+ });
182
+
183
+ if (!res.ok) throw new Error(`Request failed (HTTP ${res.status})`);
184
+ if (!res.body) throw new Error('No response body');
185
+
186
+ const reader = res.body.getReader();
187
+ const decoder = new TextDecoder();
188
+ let acc = '';
189
+
190
+ while (true) {
191
+ const { done, value } = await reader.read();
192
+ if (done) break;
193
+ const raw = decoder.decode(value, { stream: true });
194
+ for (const line of raw.split('\n')) {
195
+ // SSE format: data:{"type":"text_delta","delta":"..."}
196
+ const sseMatch = line.match(/^data:(.+)$/);
197
+ if (sseMatch) {
198
+ try {
199
+ const event = JSON.parse(sseMatch[1]);
200
+ if (event.type === 'text_delta' && typeof event.delta === 'string') {
201
+ acc += event.delta;
202
+ onChunk(acc);
203
+ } else if (event.type === 'thinking_delta' && typeof event.delta === 'string') {
204
+ // Show agent thinking as dimmed text
205
+ acc += event.delta;
206
+ onChunk(acc);
207
+ } else if (event.type === 'error' && event.message) {
208
+ // ACP agent error — throw so WorkflowRunner shows it in step error state
209
+ throw new Error(event.message);
210
+ }
211
+ } catch {
212
+ // Not valid JSON — try legacy Vercel AI SDK format: 0:"..."
213
+ const legacyMatch = line.match(/^0:"((?:[^"\\]|\\.)*)"$/);
214
+ if (legacyMatch) {
215
+ acc += legacyMatch[1].replace(/\\n/g, '\n').replace(/\\"/g, '"').replace(/\\\\/g, '\\');
216
+ onChunk(acc);
217
+ }
218
+ }
219
+ continue;
220
+ }
221
+ // Legacy Vercel AI SDK format (without SSE prefix)
222
+ const legacyMatch = line.match(/^0:"((?:[^"\\]|\\.)*)"$/);
223
+ if (legacyMatch) {
224
+ acc += legacyMatch[1].replace(/\\n/g, '\n').replace(/\\"/g, '"').replace(/\\\\/g, '\\');
225
+ onChunk(acc);
226
+ }
227
+ }
228
+ }
229
+ }
@@ -0,0 +1,6 @@
1
+ export { manifest } from './manifest';
2
+ export { WorkflowYamlRenderer } from './WorkflowYamlRenderer';
3
+ export { parseWorkflowYaml, validateWorkflowSchema, getStepDescription } from './parser';
4
+ export { runStepWithAI, clearSkillCache } from './execution';
5
+ export { serializeWorkflowYaml, generateStepId } from './serializer';
6
+ export type { WorkflowStep, WorkflowYaml, WorkflowStepRuntime, StepStatus, ParseResult, ValidationResult } from './types';
@@ -0,0 +1,21 @@
1
+ import type { RendererDefinition } from '@/lib/renderers/registry';
2
+
3
+ export const manifest: RendererDefinition = {
4
+ id: 'workflow-yaml',
5
+ name: 'Workflow Runner',
6
+ description: 'Execute step-by-step YAML workflows with Skills & Agent support.',
7
+ author: 'MindOS',
8
+ icon: '⚡',
9
+ tags: ['workflow', 'automation', 'steps', 'ai', 'yaml'],
10
+ builtin: true,
11
+ core: true,
12
+ appBuiltinFeature: true,
13
+ entryPath: '.mindos/workflows/',
14
+ match: ({ extension, filePath }) => {
15
+ if (extension !== 'yaml' && extension !== 'yml') return false;
16
+ return /\.flow\.(yaml|yml)$/i.test(filePath);
17
+ },
18
+ load: () => import('./WorkflowYamlRenderer').then(m => ({
19
+ default: m.WorkflowYamlRenderer
20
+ })),
21
+ };
@@ -0,0 +1,172 @@
1
+ import YAML from 'js-yaml';
2
+ import type { WorkflowYaml, ParseResult, ValidationResult, WorkflowStep } from './types';
3
+
4
+ export function parseWorkflowYaml(content: string): ParseResult {
5
+ try {
6
+ const parsed = YAML.load(content, { schema: YAML.JSON_SCHEMA }) as unknown;
7
+
8
+ if (!parsed) {
9
+ return {
10
+ workflow: null,
11
+ errors: ['File is empty'],
12
+ };
13
+ }
14
+
15
+ if (typeof parsed !== 'object') {
16
+ return {
17
+ workflow: null,
18
+ errors: ['Content is not valid YAML'],
19
+ };
20
+ }
21
+
22
+ const validation = validateWorkflowSchema(parsed);
23
+ if (!validation.valid) {
24
+ return {
25
+ workflow: null,
26
+ errors: validation.errors,
27
+ };
28
+ }
29
+
30
+ return {
31
+ workflow: parsed as WorkflowYaml,
32
+ errors: [],
33
+ };
34
+ } catch (err) {
35
+ return {
36
+ workflow: null,
37
+ errors: [err instanceof Error ? err.message : String(err)],
38
+ };
39
+ }
40
+ }
41
+
42
+ export function validateWorkflowSchema(obj: unknown): ValidationResult {
43
+ const errors: string[] = [];
44
+
45
+ if (!obj || typeof obj !== 'object') {
46
+ return { valid: false, errors: ['Must be a valid YAML object'] };
47
+ }
48
+
49
+ const w = obj as Record<string, any>;
50
+
51
+ // Validate title
52
+ if (!w.title || typeof w.title !== 'string' || !w.title.trim()) {
53
+ errors.push("Missing required field 'title' (string)");
54
+ }
55
+
56
+ // Validate description (optional)
57
+ if (w.description !== undefined && typeof w.description !== 'string') {
58
+ errors.push("'description' must be a string");
59
+ }
60
+
61
+ // Validate skills (optional)
62
+ if (w.skills !== undefined) {
63
+ if (!Array.isArray(w.skills)) {
64
+ errors.push("'skills' must be an array of strings");
65
+ } else if (!w.skills.every((s: any) => typeof s === 'string')) {
66
+ errors.push("'skills' must contain only strings");
67
+ }
68
+ }
69
+
70
+ // Validate tools (optional)
71
+ if (w.tools !== undefined) {
72
+ if (!Array.isArray(w.tools)) {
73
+ errors.push("'tools' must be an array of strings");
74
+ } else if (!w.tools.every((t: any) => typeof t === 'string')) {
75
+ errors.push("'tools' must contain only strings");
76
+ }
77
+ }
78
+
79
+ // Validate steps
80
+ if (!w.steps) {
81
+ errors.push("Missing required field 'steps' (array)");
82
+ } else if (!Array.isArray(w.steps)) {
83
+ errors.push("'steps' must be an array");
84
+ } else if (w.steps.length === 0) {
85
+ errors.push("'steps' must have at least 1 step");
86
+ } else {
87
+ const seenIds = new Set<string>();
88
+ w.steps.forEach((step: any, idx: number) => {
89
+ const stepErrors = validateStep(step, idx);
90
+ errors.push(...stepErrors);
91
+ // Check for duplicate IDs
92
+ const id = step?.id;
93
+ if (typeof id === 'string' && id.trim()) {
94
+ if (seenIds.has(id)) {
95
+ errors.push(`steps[${idx}].id: duplicate id '${id}' (each step must have a unique id)`);
96
+ }
97
+ seenIds.add(id);
98
+ }
99
+ });
100
+ }
101
+
102
+ return {
103
+ valid: errors.length === 0,
104
+ errors,
105
+ };
106
+ }
107
+
108
+ function validateStep(step: unknown, index: number): string[] {
109
+ const errors: string[] = [];
110
+ const prefix = `steps[${index}]`;
111
+
112
+ if (!step || typeof step !== 'object') {
113
+ errors.push(`${prefix}: must be a valid object`);
114
+ return errors;
115
+ }
116
+
117
+ const s = step as Record<string, any>;
118
+
119
+ // Validate id
120
+ if (!s.id || typeof s.id !== 'string' || !s.id.trim()) {
121
+ errors.push(`${prefix}: missing required field 'id' (non-empty string)`);
122
+ } else if (!/^[a-z0-9_-]+$/.test(s.id)) {
123
+ errors.push(`${prefix}.id: '${s.id}' is invalid — use only lowercase letters, numbers, hyphens, underscores (e.g., 'run-tests')`);
124
+ }
125
+
126
+ // Validate name
127
+ if (!s.name || typeof s.name !== 'string' || !s.name.trim()) {
128
+ errors.push(`${prefix}: missing required field 'name' (non-empty string)`);
129
+ }
130
+
131
+ // Validate description (optional)
132
+ if (s.description !== undefined && typeof s.description !== 'string') {
133
+ errors.push(`${prefix}.description: must be a string`);
134
+ }
135
+
136
+ // Validate agent (optional)
137
+ if (s.agent !== undefined && (typeof s.agent !== 'string' || !s.agent.trim())) {
138
+ errors.push(`${prefix}.agent: must be a non-empty string`);
139
+ }
140
+
141
+ // Validate skill (optional)
142
+ if (s.skill !== undefined && (typeof s.skill !== 'string' || !s.skill.trim())) {
143
+ errors.push(`${prefix}.skill: must be a non-empty string`);
144
+ }
145
+
146
+ // Validate tools (optional)
147
+ if (s.tools !== undefined) {
148
+ if (!Array.isArray(s.tools)) {
149
+ errors.push(`${prefix}.tools: must be an array of strings`);
150
+ } else if (!s.tools.every((t: any) => typeof t === 'string')) {
151
+ errors.push(`${prefix}.tools: must contain only strings`);
152
+ }
153
+ }
154
+
155
+ // Validate prompt (required)
156
+ if (!s.prompt || typeof s.prompt !== 'string' || !s.prompt.trim()) {
157
+ errors.push(`${prefix}: missing required field 'prompt' (non-empty string)`);
158
+ }
159
+
160
+ // Validate timeout (optional)
161
+ if (s.timeout !== undefined) {
162
+ if (typeof s.timeout !== 'number' || s.timeout <= 0) {
163
+ errors.push(`${prefix}.timeout: must be a positive number (seconds)`);
164
+ }
165
+ }
166
+
167
+ return errors;
168
+ }
169
+
170
+ export function getStepDescription(step: WorkflowStep): string {
171
+ return step.description || step.prompt.split('\n')[0].slice(0, 100);
172
+ }