@aion0/forge 0.9.12 → 0.9.14
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.
- package/RELEASE_NOTES.md +8 -6
- package/app/api/prompts/[name]/route.ts +37 -0
- package/app/api/prompts/route.ts +35 -0
- package/app/api/schedules/extract/route.ts +184 -0
- package/app/api/schedules/route.ts +14 -37
- package/components/ScheduleCreateModal.tsx +237 -537
- package/components/ScheduleQuickCreate.tsx +404 -0
- package/components/SchedulesView.tsx +18 -6
- package/lib/chat/agent-loop.ts +24 -0
- package/lib/chat/llm/anthropic.ts +128 -78
- package/lib/chat/llm/openai.ts +75 -180
- package/lib/chat/tool-dispatcher.ts +221 -0
- package/lib/forge-mcp-server.ts +84 -0
- package/lib/init.ts +7 -0
- package/lib/pipeline.ts +19 -0
- package/lib/projects.ts +67 -2
- package/lib/prompts/store.ts +142 -0
- package/lib/prompts/types.ts +53 -0
- package/lib/schedules/scheduler.ts +51 -143
- package/lib/schedules/store.ts +6 -15
- package/lib/schedules/types.ts +10 -14
- package/package.json +1 -1
|
@@ -37,6 +37,183 @@ export type BuiltinHandler = (input: unknown) => Promise<string>;
|
|
|
37
37
|
|
|
38
38
|
const BUILTINS: Record<string, BuiltinHandler> = {
|
|
39
39
|
get_current_time: async () => new Date().toISOString(),
|
|
40
|
+
|
|
41
|
+
// Trigger a pipeline workflow defined under flows/<name>.yaml. Mirrors
|
|
42
|
+
// the same MCP tool used by Claude Code tasks (forge-mcp-server.ts), but
|
|
43
|
+
// available directly inside the chat agent so users can say "run the
|
|
44
|
+
// mantis-bug-fix pipeline for bug 1234" and have the agent dispatch it.
|
|
45
|
+
//
|
|
46
|
+
// Call without args first to see workflows + their input schemas — saves
|
|
47
|
+
// a round-trip of guessing field names. Schema includes which fields are
|
|
48
|
+
// required (no default) vs optional (have default) so the agent can omit
|
|
49
|
+
// optional ones rather than passing wrong placeholder values.
|
|
50
|
+
trigger_pipeline: async (input) => {
|
|
51
|
+
const params = (input as { workflow?: string; input?: Record<string, unknown> } | undefined) || {};
|
|
52
|
+
const { listWorkflows, startPipeline, getPipeline } = await import('../pipeline');
|
|
53
|
+
if (!params.workflow) {
|
|
54
|
+
const workflows = listWorkflows();
|
|
55
|
+
if (workflows.length === 0) return 'No workflows found. Create one in <dataDir>/flows/.';
|
|
56
|
+
const lines: string[] = ['Available workflows (only pass the marked-required inputs; optional ones use their default):', ''];
|
|
57
|
+
for (const w of workflows) {
|
|
58
|
+
lines.push(`• ${w.name}${w.description ? ' — ' + w.description.split('\n')[0].slice(0, 160) : ''}`);
|
|
59
|
+
const entries = Object.entries(w.input || {});
|
|
60
|
+
if (entries.length === 0) {
|
|
61
|
+
lines.push(' Inputs: (none)');
|
|
62
|
+
} else {
|
|
63
|
+
for (const [name, spec] of entries) {
|
|
64
|
+
// spec may be a string (legacy description-only) or an object.
|
|
65
|
+
const isObj = spec && typeof spec === 'object';
|
|
66
|
+
const desc = isObj ? (spec as any).description : (typeof spec === 'string' ? spec : '');
|
|
67
|
+
const type = isObj ? ((spec as any).type || 'string') : 'string';
|
|
68
|
+
const required = isObj ? !!(spec as any).required : false;
|
|
69
|
+
const hasDefault = isObj && Object.prototype.hasOwnProperty.call(spec, 'default');
|
|
70
|
+
const def = hasDefault ? (spec as any).default : undefined;
|
|
71
|
+
const star = required ? '*' : '';
|
|
72
|
+
const defNote = hasDefault
|
|
73
|
+
? ` [default: ${String(def).split('\n')[0].slice(0, 60)}${String(def).length > 60 ? '…' : ''}]`
|
|
74
|
+
: '';
|
|
75
|
+
const descShort = desc ? ` — ${String(desc).split('\n')[0].slice(0, 120)}` : '';
|
|
76
|
+
lines.push(` - ${name}${star} (${type})${defNote}${descShort}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
lines.push('');
|
|
80
|
+
}
|
|
81
|
+
lines.push('Required inputs are marked with *. Optional ones with defaults will use the default if you omit them — DO NOT pass empty strings or guesses for those.');
|
|
82
|
+
return lines.join('\n');
|
|
83
|
+
}
|
|
84
|
+
// Pre-flight schema validation. The pipeline orchestrator currently
|
|
85
|
+
// silently accepts unknown input keys and lets required fields default to
|
|
86
|
+
// empty — leading to "status: done" no-op runs. Surface those mistakes
|
|
87
|
+
// here so the LLM (or any caller) gets a structured error instead of a
|
|
88
|
+
// misleading success. Workflow-side validation (B in the plan) is deferred
|
|
89
|
+
// because it'd affect schedules.
|
|
90
|
+
const workflows = listWorkflows();
|
|
91
|
+
const wf = workflows.find((w: any) => w.name === params.workflow);
|
|
92
|
+
if (!wf) {
|
|
93
|
+
return `Unknown workflow: "${params.workflow}". Available: ${workflows.map((w: any) => w.name).join(', ') || '(none)'}. Call trigger_pipeline() with no args to see schemas.`;
|
|
94
|
+
}
|
|
95
|
+
const declared = wf.input || {};
|
|
96
|
+
const declaredKeys = new Set(Object.keys(declared));
|
|
97
|
+
const requiredKeys: string[] = [];
|
|
98
|
+
for (const [k, spec] of Object.entries(declared)) {
|
|
99
|
+
if (spec && typeof spec === 'object') {
|
|
100
|
+
const s = spec as any;
|
|
101
|
+
const hasDefault = Object.prototype.hasOwnProperty.call(s, 'default');
|
|
102
|
+
if (s.required && !hasDefault) requiredKeys.push(k);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const givenKeys = Object.keys(params.input || {});
|
|
106
|
+
const unknown = givenKeys.filter((k) => !declaredKeys.has(k));
|
|
107
|
+
if (unknown.length > 0) {
|
|
108
|
+
return `Unknown input fields for "${params.workflow}": ${unknown.join(', ')}. This workflow only accepts: ${[...declaredKeys].join(', ')}. NOTE: input field names are snake_case (e.g. bug_id) — they are NOT the same as the bash variable names inside pipeline scripts (e.g. BUG_ID). Consider memory_remember_block({key: "rule.pipeline_input_naming", value: "Forge pipeline input fields are exact, snake_case, declared in the workflow yaml. Never invent uppercase / bash-variable names like BUG_ID, BASE, PROJECT_PATH.", pinned: true}) to save this lesson.`;
|
|
109
|
+
}
|
|
110
|
+
const missing = requiredKeys.filter((k) => {
|
|
111
|
+
const v = (params.input || {})[k];
|
|
112
|
+
return v == null || (typeof v === 'string' && v.trim() === '');
|
|
113
|
+
});
|
|
114
|
+
if (missing.length > 0) {
|
|
115
|
+
return `Missing required input fields for "${params.workflow}": ${missing.join(', ')}. All required fields (no defaults): ${requiredKeys.join(', ') || '(none)'}. Re-call with these provided.`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Coerce numbers/booleans to strings (pipeline orchestrator expects Record<string, string>).
|
|
119
|
+
const stringInput: Record<string, string> = {};
|
|
120
|
+
for (const [k, v] of Object.entries(params.input || {})) {
|
|
121
|
+
stringInput[k] = v == null ? '' : typeof v === 'string' ? v : String(v);
|
|
122
|
+
}
|
|
123
|
+
const pipeline = startPipeline(params.workflow, stringInput);
|
|
124
|
+
let line = `Pipeline started: ${pipeline.id} (workflow: ${params.workflow}, status: ${pipeline.status})`;
|
|
125
|
+
if (pipeline.status === 'failed') {
|
|
126
|
+
const fresh = getPipeline(pipeline.id) || pipeline;
|
|
127
|
+
const errs: string[] = [];
|
|
128
|
+
for (const [nid, n] of Object.entries(fresh.nodes || {})) {
|
|
129
|
+
if ((n as any).error) errs.push(`${nid}: ${(n as any).error}`);
|
|
130
|
+
}
|
|
131
|
+
if (errs.length > 0) line += `\nFailure(s): ${errs.join(' | ').slice(0, 500)}`;
|
|
132
|
+
} else if (pipeline.status === 'done') {
|
|
133
|
+
// For for_each workflows, a "done" with zero iterations is the silent
|
|
134
|
+
// failure mode (empty source). Warn the LLM explicitly so it doesn't
|
|
135
|
+
// claim success.
|
|
136
|
+
const fresh = getPipeline(pipeline.id) || pipeline;
|
|
137
|
+
const forEach = (fresh as any).forEach;
|
|
138
|
+
if (forEach && typeof forEach === 'object') {
|
|
139
|
+
const iters = Array.isArray(forEach.iterations) ? forEach.iterations.length : 0;
|
|
140
|
+
const total = typeof forEach.total === 'number' ? forEach.total : iters;
|
|
141
|
+
if (total === 0) {
|
|
142
|
+
line += '\n⚠ Pipeline finished with 0 iterations — likely empty source list. This is NOT a success; the work the user asked for did NOT happen. Re-check input fields (especially the one feeding for_each.source) and retry.';
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
} else {
|
|
146
|
+
line += '. Watch progress in the Pipelines view.';
|
|
147
|
+
}
|
|
148
|
+
return line;
|
|
149
|
+
},
|
|
150
|
+
|
|
151
|
+
// Surface Forge's local context (projects + agents + skills) so the chat
|
|
152
|
+
// agent can pick valid values for inputs like trigger_pipeline.input.project
|
|
153
|
+
// without guessing. Cheap call — read-only directory + DB lookups.
|
|
154
|
+
list_forge_context: async () => {
|
|
155
|
+
const out: string[] = [];
|
|
156
|
+
try {
|
|
157
|
+
const { scanProjects } = await import('../projects');
|
|
158
|
+
const projects = scanProjects();
|
|
159
|
+
out.push('Forge projects (use the name as input.project for tasks / pipelines):');
|
|
160
|
+
if (projects.length === 0) out.push(' (none — configure project roots in Settings)');
|
|
161
|
+
for (const p of projects) {
|
|
162
|
+
out.push(` - ${p.name}${p.hasClaudeMd ? ' [has CLAUDE.md]' : ''}${p.language ? ' [' + p.language + ']' : ''}`);
|
|
163
|
+
}
|
|
164
|
+
} catch (e) {
|
|
165
|
+
out.push(`(failed to list projects: ${(e as Error).message})`);
|
|
166
|
+
}
|
|
167
|
+
out.push('');
|
|
168
|
+
try {
|
|
169
|
+
const { loadSettings } = await import('../settings');
|
|
170
|
+
const settings = loadSettings();
|
|
171
|
+
const agents = settings.agents || {};
|
|
172
|
+
out.push('Agent profiles (CLI agents; use the id as dispatch_task.agent):');
|
|
173
|
+
const ids = Object.keys(agents);
|
|
174
|
+
if (ids.length === 0) out.push(' (none configured)');
|
|
175
|
+
for (const id of ids) {
|
|
176
|
+
const a = agents[id] as any;
|
|
177
|
+
out.push(` - ${id}${a?.name ? ' (' + a.name + ')' : ''}`);
|
|
178
|
+
}
|
|
179
|
+
} catch (e) {
|
|
180
|
+
out.push(`(failed to list agents: ${(e as Error).message})`);
|
|
181
|
+
}
|
|
182
|
+
out.push('');
|
|
183
|
+
try {
|
|
184
|
+
const { listSkills } = await import('../skills');
|
|
185
|
+
const skills = listSkills();
|
|
186
|
+
out.push('Skills (available to dispatch_task / pipeline via auto-install):');
|
|
187
|
+
if (skills.length === 0) out.push(' (none)');
|
|
188
|
+
for (const s of skills) {
|
|
189
|
+
out.push(` - ${s.name}${s.description ? ' — ' + s.description.slice(0, 80) : ''}`);
|
|
190
|
+
}
|
|
191
|
+
} catch (e) {
|
|
192
|
+
out.push(`(failed to list skills: ${(e as Error).message})`);
|
|
193
|
+
}
|
|
194
|
+
return out.join('\n');
|
|
195
|
+
},
|
|
196
|
+
|
|
197
|
+
// Dispatch a one-shot background task. Agent + skills optional; project is
|
|
198
|
+
// required (defaults to 'scratch' if not given). Returns the task id; the
|
|
199
|
+
// caller can ask "what's the status of task <id>?" later — we don't block.
|
|
200
|
+
dispatch_task: async (input) => {
|
|
201
|
+
const params = (input as { project?: string; prompt?: string; agent?: string } | undefined) || {};
|
|
202
|
+
if (!params.prompt) return 'dispatch_task failed: prompt is required';
|
|
203
|
+
const { getProjectInfo, SCRATCH_PROJECT_NAME } = await import('../projects');
|
|
204
|
+
const projectName = params.project?.trim() || SCRATCH_PROJECT_NAME;
|
|
205
|
+
const project = getProjectInfo(projectName);
|
|
206
|
+
if (!project) return `dispatch_task failed: project "${projectName}" not found`;
|
|
207
|
+
const { createTask } = await import('../task-manager');
|
|
208
|
+
const task = createTask({
|
|
209
|
+
projectName: project.name,
|
|
210
|
+
projectPath: project.path,
|
|
211
|
+
prompt: params.prompt,
|
|
212
|
+
conversationId: '',
|
|
213
|
+
agent: params.agent || undefined,
|
|
214
|
+
});
|
|
215
|
+
return `Task dispatched: ${task.id} (project: ${project.name}, status: ${task.status}). Watch in the Tasks view.`;
|
|
216
|
+
},
|
|
40
217
|
};
|
|
41
218
|
|
|
42
219
|
export interface BuiltinToolDef {
|
|
@@ -51,6 +228,50 @@ export const BUILTIN_TOOL_DEFS: BuiltinToolDef[] = [
|
|
|
51
228
|
description: 'Get the current local time as an ISO 8601 string. Use whenever the user asks about "now" or "today".',
|
|
52
229
|
input_schema: { type: 'object', properties: {} },
|
|
53
230
|
},
|
|
231
|
+
{
|
|
232
|
+
name: 'trigger_pipeline',
|
|
233
|
+
description: 'Trigger a Forge pipeline workflow (YAML under flows/). Two-step usage: (1) call with NO args first — returns every workflow + its input schema (which fields are required vs have defaults). (2) call again with workflow=<name> and input={...} passing ONLY required fields and any optional fields the user explicitly specified. NEVER pass invented placeholder values for optional fields with defaults — omit them and the default is used. If the pipeline fails immediately, the response includes the validation error so you can fix the inputs and retry.',
|
|
234
|
+
input_schema: {
|
|
235
|
+
type: 'object',
|
|
236
|
+
properties: {
|
|
237
|
+
workflow: {
|
|
238
|
+
type: 'string',
|
|
239
|
+
description: 'Workflow name (file basename of flows/<name>.yaml). Omit to list workflows + schemas.',
|
|
240
|
+
},
|
|
241
|
+
input: {
|
|
242
|
+
type: 'object',
|
|
243
|
+
description: 'Pipeline input fields as a flat object. Pass ONLY required fields (marked * in the list response) and optional fields the user explicitly named. Omit optional fields to use their defaults.',
|
|
244
|
+
},
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
},
|
|
248
|
+
{
|
|
249
|
+
name: 'list_forge_context',
|
|
250
|
+
description: "Return the current Forge instance's local context: project names (use these as input.project for pipelines / dispatch_task), agent profile ids, and installed skills. Call this whenever the user references a project / agent / skill by name and you need to validate the name OR when picking defaults for trigger_pipeline / dispatch_task. No arguments.",
|
|
251
|
+
input_schema: { type: 'object', properties: {} },
|
|
252
|
+
},
|
|
253
|
+
{
|
|
254
|
+
name: 'dispatch_task',
|
|
255
|
+
description: 'Dispatch a one-shot background Claude task in a Forge project. Use for longer-running asks the user wants to fire-and-forget ("analyze X codebase and write findings to a file", "run the test suite and summarize failures"). Returns immediately with the task id; the task runs in the background and the user can check the Tasks view for output.',
|
|
256
|
+
input_schema: {
|
|
257
|
+
type: 'object',
|
|
258
|
+
properties: {
|
|
259
|
+
prompt: {
|
|
260
|
+
type: 'string',
|
|
261
|
+
description: 'The full instruction text Claude should execute. Be specific about what files to read, what to produce.',
|
|
262
|
+
},
|
|
263
|
+
project: {
|
|
264
|
+
type: 'string',
|
|
265
|
+
description: 'Forge project name (the working directory). Defaults to "scratch" if omitted. Use "scratch" for connector-only / no-filesystem tasks.',
|
|
266
|
+
},
|
|
267
|
+
agent: {
|
|
268
|
+
type: 'string',
|
|
269
|
+
description: 'Optional agent id override. Omit to use the project default.',
|
|
270
|
+
},
|
|
271
|
+
},
|
|
272
|
+
required: ['prompt'],
|
|
273
|
+
},
|
|
274
|
+
},
|
|
54
275
|
];
|
|
55
276
|
|
|
56
277
|
// ─── Connector dispatch ──────────────────────────────────
|
package/lib/forge-mcp-server.ts
CHANGED
|
@@ -394,6 +394,90 @@ function createForgeMcpServer(sessionId: string): McpServer {
|
|
|
394
394
|
}
|
|
395
395
|
);
|
|
396
396
|
|
|
397
|
+
// ── list_connectors ────────────────────────────
|
|
398
|
+
// V3 ai-orchestration MVP — let an agent discover what installed
|
|
399
|
+
// connectors + tools it can call. Pairs with call_connector below.
|
|
400
|
+
server.tool(
|
|
401
|
+
'list_connectors',
|
|
402
|
+
'List installed Forge connectors and their callable tools. Use this BEFORE call_connector to discover what tool ids exist and what parameters each expects. Returns id, tool name, description, parameter schema, destructive flag, and (when present) author-provided returns hint.',
|
|
403
|
+
{},
|
|
404
|
+
async () => {
|
|
405
|
+
try {
|
|
406
|
+
const { listInstalledConnectors } = await import('./connectors/registry');
|
|
407
|
+
const connectors = listInstalledConnectors().filter((c) => c.enabled);
|
|
408
|
+
if (connectors.length === 0) {
|
|
409
|
+
return {
|
|
410
|
+
content: [{ type: 'text', text: 'No connectors installed. Install one from Settings → Marketplace.' }],
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
type ToolSpec = { description?: string; parameters?: Record<string, unknown>; destructive?: boolean; returns?: string; protocol?: string };
|
|
414
|
+
const out: Array<{
|
|
415
|
+
plugin_id: string;
|
|
416
|
+
name: string;
|
|
417
|
+
version: string;
|
|
418
|
+
tools: Array<{ name: string; description: string; parameters?: Record<string, unknown>; destructive?: boolean; returns?: string; protocol?: string }>;
|
|
419
|
+
}> = [];
|
|
420
|
+
for (const c of connectors) {
|
|
421
|
+
const def = c.definition as { id: string; name: string; version: string; tools?: Record<string, ToolSpec>; connectors?: Array<{ id: string; tools: Record<string, ToolSpec> }> };
|
|
422
|
+
// Flatten: single-entry connectors expose tools at top level, suite
|
|
423
|
+
// connectors (Atlassian-style) under .connectors[].tools.
|
|
424
|
+
const sources: Array<[string, Record<string, ToolSpec>]> = [];
|
|
425
|
+
if (def.tools) sources.push([def.id, def.tools]);
|
|
426
|
+
for (const entry of def.connectors || []) sources.push([entry.id, entry.tools]);
|
|
427
|
+
for (const [pluginId, tools] of sources) {
|
|
428
|
+
out.push({
|
|
429
|
+
plugin_id: pluginId,
|
|
430
|
+
name: def.name,
|
|
431
|
+
version: def.version,
|
|
432
|
+
tools: Object.entries(tools).map(([toolName, t]) => ({
|
|
433
|
+
name: toolName,
|
|
434
|
+
description: t.description || '(no description)',
|
|
435
|
+
...(t.parameters ? { parameters: t.parameters } : {}),
|
|
436
|
+
...(t.destructive ? { destructive: true } : {}),
|
|
437
|
+
...(t.returns ? { returns: t.returns } : {}),
|
|
438
|
+
...(t.protocol ? { protocol: t.protocol } : {}),
|
|
439
|
+
})),
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
return { content: [{ type: 'text', text: JSON.stringify(out, null, 2) }] };
|
|
444
|
+
} catch (err: any) {
|
|
445
|
+
return { content: [{ type: 'text', text: `Error listing connectors: ${err.message}` }] };
|
|
446
|
+
}
|
|
447
|
+
},
|
|
448
|
+
);
|
|
449
|
+
|
|
450
|
+
// ── call_connector ─────────────────────────────
|
|
451
|
+
// Invoke one connector tool. Same backend as POST /api/connector-tool
|
|
452
|
+
// (single internal entry point — anything pipelines / jobs / chat already
|
|
453
|
+
// call is what an ai-orchestration-skilled agent gets too).
|
|
454
|
+
server.tool(
|
|
455
|
+
'call_connector',
|
|
456
|
+
'Call one installed connector tool. Use list_connectors first to find plugin_id + tool_name. Returns the tool result text; sets is_error=true on failure. Destructive tools (add_comment, send_message, etc.) require explicit user confirmation in the surrounding workflow — the call itself is not blocked here.',
|
|
457
|
+
{
|
|
458
|
+
plugin_id: z.string().describe("Connector id from list_connectors (e.g. 'mantis', 'gitlab', 'teams')."),
|
|
459
|
+
tool: z.string().describe("Tool name within the connector (e.g. 'get_bug', 'search_mrs', 'send_message')."),
|
|
460
|
+
input: z.record(z.string(), z.any()).optional().describe('Parameter object matching the tool schema returned by list_connectors. Omit for parameter-less tools.'),
|
|
461
|
+
},
|
|
462
|
+
async (params) => {
|
|
463
|
+
try {
|
|
464
|
+
const { dispatchTool } = await import('./chat/tool-dispatcher');
|
|
465
|
+
const name = `${params.plugin_id}.${params.tool}`;
|
|
466
|
+
const result = await dispatchTool({
|
|
467
|
+
id: `mcp-${Date.now()}`,
|
|
468
|
+
name,
|
|
469
|
+
input: params.input ?? {},
|
|
470
|
+
});
|
|
471
|
+
return {
|
|
472
|
+
content: [{ type: 'text', text: result.content }],
|
|
473
|
+
...(result.is_error ? { isError: true } : {}),
|
|
474
|
+
};
|
|
475
|
+
} catch (err: any) {
|
|
476
|
+
return { content: [{ type: 'text', text: `Error calling ${params.plugin_id}.${params.tool}: ${err.message}` }], isError: true };
|
|
477
|
+
}
|
|
478
|
+
},
|
|
479
|
+
);
|
|
480
|
+
|
|
397
481
|
// ── get_pipeline_status ────────────────────────
|
|
398
482
|
server.tool(
|
|
399
483
|
'get_pipeline_status',
|
package/lib/init.ts
CHANGED
|
@@ -86,6 +86,13 @@ export function ensureInitialized() {
|
|
|
86
86
|
|
|
87
87
|
time('logger', () => { try { const { initLogger } = require('./logger'); initLogger(); } catch {} });
|
|
88
88
|
time('migrateDataDir', () => { try { const { migrateDataDir } = require('./dirs'); migrateDataDir(); } catch {} });
|
|
89
|
+
time('ensureScratchProject', () => {
|
|
90
|
+
// Synthetic 'scratch' project under <dataDir>/scratch — default
|
|
91
|
+
// workspace for prompt schedules / chat-launched temp tasks that
|
|
92
|
+
// don't care about a real project.
|
|
93
|
+
try { const { ensureScratchProject } = require('./projects'); ensureScratchProject(); }
|
|
94
|
+
catch (e) { console.warn('[init] ensureScratchProject failed:', (e as Error).message); }
|
|
95
|
+
});
|
|
89
96
|
time('migrateSecrets', migrateSecrets);
|
|
90
97
|
time('migratePluginSecrets', () => {
|
|
91
98
|
try {
|
package/lib/pipeline.ts
CHANGED
|
@@ -871,6 +871,25 @@ export function startPipeline(
|
|
|
871
871
|
const workflow = getWorkflow(workflowName);
|
|
872
872
|
if (!workflow) throw new Error(`Workflow not found: ${workflowName}`);
|
|
873
873
|
|
|
874
|
+
// Apply declared defaults for any field the caller didn't pass. UI callers
|
|
875
|
+
// (Schedule form, Fire dialog) already pre-fill all fields with their
|
|
876
|
+
// defaults at form-load time, so for them this merge is a no-op. Sparse-input
|
|
877
|
+
// callers (chat trigger_pipeline, MCP, direct API) only send what the user
|
|
878
|
+
// explicitly named; without this merge multi-line defaults like
|
|
879
|
+
// mr_body_template / user_prompt come through as empty → "{{input.X}}"
|
|
880
|
+
// renders to '' → MR body empty / triage prompt empty.
|
|
881
|
+
//
|
|
882
|
+
// Semantics: ONLY fill when the key is missing from the input map. An
|
|
883
|
+
// empty-string value is respected as "intentionally cleared" so schedule
|
|
884
|
+
// users who clear a field in the form keep that behaviour.
|
|
885
|
+
for (const [key, spec] of Object.entries(workflow.input || {})) {
|
|
886
|
+
if (Object.prototype.hasOwnProperty.call(input, key)) continue;
|
|
887
|
+
if (!spec || typeof spec !== 'object') continue;
|
|
888
|
+
const def = (spec as WorkflowInputFieldSpec).default;
|
|
889
|
+
if (def == null) continue;
|
|
890
|
+
input = { ...input, [key]: String(def) };
|
|
891
|
+
}
|
|
892
|
+
|
|
874
893
|
// Conversation mode — separate execution path
|
|
875
894
|
if (workflow.type === 'conversation' && workflow.conversation) {
|
|
876
895
|
return startConversationPipeline(workflow, input);
|
package/lib/projects.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { readdirSync, existsSync, statSync, readFileSync } from 'node:fs';
|
|
1
|
+
import { readdirSync, existsSync, statSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { loadSettings } from './settings';
|
|
4
|
+
import { getDataDir } from './dirs';
|
|
4
5
|
|
|
5
6
|
export interface LocalProject {
|
|
6
7
|
name: string;
|
|
@@ -12,6 +13,67 @@ export interface LocalProject {
|
|
|
12
13
|
lastModified: string;
|
|
13
14
|
}
|
|
14
15
|
|
|
16
|
+
/** Reserved name for the synthetic "scratch" project that lives under
|
|
17
|
+
* <dataDir>/scratch. Default workspace for tasks that don't need a real
|
|
18
|
+
* project (prompt schedules calling connectors / chat-launched temp tasks
|
|
19
|
+
* / anything that just talks to APIs and doesn't touch files). */
|
|
20
|
+
export const SCRATCH_PROJECT_NAME = 'scratch';
|
|
21
|
+
|
|
22
|
+
/** Materialize <dataDir>/scratch on first call. Idempotent. Returns the
|
|
23
|
+
* project path. Called from init.ts on startup so the dir exists before
|
|
24
|
+
* any task tries to cd into it. */
|
|
25
|
+
export function ensureScratchProject(): string {
|
|
26
|
+
const dir = join(getDataDir(), 'scratch');
|
|
27
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
28
|
+
const claudeMd = join(dir, 'CLAUDE.md');
|
|
29
|
+
if (!existsSync(claudeMd)) {
|
|
30
|
+
try {
|
|
31
|
+
writeFileSync(claudeMd,
|
|
32
|
+
'# Forge Scratch\n\n' +
|
|
33
|
+
'Default workspace for tasks that do not target a specific project.\n' +
|
|
34
|
+
'Files here are managed by Forge (per-instance, follows the data dir).\n' +
|
|
35
|
+
'Safe to wipe by hand if it accumulates clutter.\n',
|
|
36
|
+
'utf-8',
|
|
37
|
+
);
|
|
38
|
+
} catch { /* best-effort */ }
|
|
39
|
+
}
|
|
40
|
+
// Scratch is Forge-owned (no user hand-rolled .mcp.json to defy), so
|
|
41
|
+
// unlike real projects we materialize the root .mcp.json directly here.
|
|
42
|
+
// Without it, prompt-mode schedule tasks running in scratch can't see
|
|
43
|
+
// the Forge MCP server (claude CLI only auto-discovers root .mcp.json,
|
|
44
|
+
// not .forge/mcp.json). Always rewrite to keep mcpPort + user-managed
|
|
45
|
+
// entries in sync.
|
|
46
|
+
try {
|
|
47
|
+
const mcpPort = Number(process.env.MCP_PORT) || 8406;
|
|
48
|
+
let userServers: Record<string, unknown> = {};
|
|
49
|
+
try { userServers = loadSettings().mcpServers || {}; } catch {}
|
|
50
|
+
const cfg = {
|
|
51
|
+
mcpServers: {
|
|
52
|
+
forge: { type: 'sse', url: `http://localhost:${mcpPort}/sse` },
|
|
53
|
+
...userServers,
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
writeFileSync(join(dir, '.mcp.json'), JSON.stringify(cfg, null, 2), 'utf-8');
|
|
57
|
+
} catch { /* best-effort */ }
|
|
58
|
+
return dir;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function scratchProject(): LocalProject {
|
|
62
|
+
const path = ensureScratchProject();
|
|
63
|
+
let mtime: string;
|
|
64
|
+
try { mtime = statSync(path).mtime.toISOString(); }
|
|
65
|
+
catch { mtime = new Date().toISOString(); }
|
|
66
|
+
return {
|
|
67
|
+
name: SCRATCH_PROJECT_NAME,
|
|
68
|
+
path,
|
|
69
|
+
root: getDataDir(),
|
|
70
|
+
hasGit: false,
|
|
71
|
+
hasClaudeMd: existsSync(join(path, 'CLAUDE.md')),
|
|
72
|
+
language: null,
|
|
73
|
+
lastModified: mtime,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
15
77
|
export function scanProjects(): LocalProject[] {
|
|
16
78
|
const settings = loadSettings();
|
|
17
79
|
const roots = settings.projectRoots;
|
|
@@ -52,7 +114,10 @@ export function scanProjects(): LocalProject[] {
|
|
|
52
114
|
}
|
|
53
115
|
}
|
|
54
116
|
|
|
55
|
-
|
|
117
|
+
// Prepend the synthetic scratch project so the UI surfaces it as a
|
|
118
|
+
// first-class option. It's stamped with the current date so it tends
|
|
119
|
+
// to sort to the top — that's intentional: it's the default fallback.
|
|
120
|
+
return [scratchProject(), ...projects.sort((a, b) => b.lastModified.localeCompare(a.lastModified))];
|
|
56
121
|
}
|
|
57
122
|
|
|
58
123
|
function detectLanguage(projectPath: string): string | null {
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prompts file store — `<dataDir>/prompts/<name>.yaml`.
|
|
3
|
+
*
|
|
4
|
+
* Same external-file pattern as pipelines: name in DB / schedule row,
|
|
5
|
+
* configuration in YAML on disk. Edits don't require migrations.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync, readFileSync, readdirSync, writeFileSync, unlinkSync, mkdirSync } from 'node:fs';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import YAML from 'yaml';
|
|
11
|
+
import { getDataDir } from '@/lib/dirs';
|
|
12
|
+
import type { Prompt, CreatePromptInput, UpdatePromptInput } from './types';
|
|
13
|
+
|
|
14
|
+
function dir(): string {
|
|
15
|
+
return join(getDataDir(), 'prompts');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Same constraint as pipeline names — also serves as filename `<name>.yaml`.
|
|
19
|
+
const NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/;
|
|
20
|
+
|
|
21
|
+
function pathFor(name: string): string {
|
|
22
|
+
return join(dir(), `${name}.yaml`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function parsePrompt(yaml: string): Prompt {
|
|
26
|
+
const raw = YAML.parse(yaml);
|
|
27
|
+
if (!raw || typeof raw !== 'object') throw new Error('prompt yaml must be a mapping');
|
|
28
|
+
if (typeof raw.name !== 'string' || !raw.name) throw new Error('prompt.name required');
|
|
29
|
+
if (typeof raw.prompt !== 'string') throw new Error('prompt.prompt required (string)');
|
|
30
|
+
|
|
31
|
+
const ex = raw.executor || {};
|
|
32
|
+
const skills = Array.isArray(ex.skills)
|
|
33
|
+
? ex.skills.filter((s: unknown) => typeof s === 'string')
|
|
34
|
+
: [];
|
|
35
|
+
const now = new Date().toISOString();
|
|
36
|
+
return {
|
|
37
|
+
name: raw.name,
|
|
38
|
+
prompt: raw.prompt,
|
|
39
|
+
executor: {
|
|
40
|
+
agent: typeof ex.agent === 'string' && ex.agent ? ex.agent : null,
|
|
41
|
+
skills,
|
|
42
|
+
config: ex.config && typeof ex.config === 'object' ? ex.config : undefined,
|
|
43
|
+
},
|
|
44
|
+
created_at: typeof raw.created_at === 'string' ? raw.created_at : now,
|
|
45
|
+
updated_at: typeof raw.updated_at === 'string' ? raw.updated_at : now,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function serialize(p: Prompt): string {
|
|
50
|
+
const executorOut: Record<string, unknown> = {
|
|
51
|
+
agent: p.executor.agent,
|
|
52
|
+
skills: p.executor.skills,
|
|
53
|
+
};
|
|
54
|
+
if (p.executor.config) executorOut.config = p.executor.config;
|
|
55
|
+
return YAML.stringify({
|
|
56
|
+
name: p.name,
|
|
57
|
+
prompt: p.prompt,
|
|
58
|
+
executor: executorOut,
|
|
59
|
+
created_at: p.created_at,
|
|
60
|
+
updated_at: p.updated_at,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function listPrompts(): Prompt[] {
|
|
65
|
+
const d = dir();
|
|
66
|
+
if (!existsSync(d)) return [];
|
|
67
|
+
const out: Prompt[] = [];
|
|
68
|
+
for (const f of readdirSync(d).filter(f => f.endsWith('.yaml') || f.endsWith('.yml'))) {
|
|
69
|
+
try {
|
|
70
|
+
out.push(parsePrompt(readFileSync(join(d, f), 'utf-8')));
|
|
71
|
+
} catch (e) {
|
|
72
|
+
console.warn(`[listPrompts] skip ${f}: ${(e as Error).message}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return out.sort((a, b) => a.name.localeCompare(b.name));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function getPrompt(name: string): Prompt | null {
|
|
79
|
+
if (!NAME_RE.test(name)) return null;
|
|
80
|
+
const path = pathFor(name);
|
|
81
|
+
if (!existsSync(path)) return null;
|
|
82
|
+
try {
|
|
83
|
+
return parsePrompt(readFileSync(path, 'utf-8'));
|
|
84
|
+
} catch (e) {
|
|
85
|
+
console.warn(`[getPrompt] parse ${name}: ${(e as Error).message}`);
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function createPrompt(input: CreatePromptInput): Prompt {
|
|
91
|
+
if (!NAME_RE.test(input.name)) {
|
|
92
|
+
throw new Error(`invalid name (allowed: letters/digits/._-, 1-64 chars): '${input.name}'`);
|
|
93
|
+
}
|
|
94
|
+
if (!input.prompt || !input.prompt.trim()) {
|
|
95
|
+
throw new Error('prompt text required');
|
|
96
|
+
}
|
|
97
|
+
mkdirSync(dir(), { recursive: true });
|
|
98
|
+
if (existsSync(pathFor(input.name))) {
|
|
99
|
+
throw new Error(`prompt '${input.name}' already exists`);
|
|
100
|
+
}
|
|
101
|
+
const now = new Date().toISOString();
|
|
102
|
+
const p: Prompt = {
|
|
103
|
+
name: input.name,
|
|
104
|
+
prompt: input.prompt,
|
|
105
|
+
executor: {
|
|
106
|
+
agent: input.executor?.agent ?? null,
|
|
107
|
+
skills: input.executor?.skills ?? [],
|
|
108
|
+
config: input.executor?.config,
|
|
109
|
+
},
|
|
110
|
+
created_at: now,
|
|
111
|
+
updated_at: now,
|
|
112
|
+
};
|
|
113
|
+
writeFileSync(pathFor(input.name), serialize(p), 'utf-8');
|
|
114
|
+
return p;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function updatePrompt(name: string, patch: UpdatePromptInput): Prompt {
|
|
118
|
+
const cur = getPrompt(name);
|
|
119
|
+
if (!cur) throw new Error(`prompt '${name}' not found`);
|
|
120
|
+
const next: Prompt = {
|
|
121
|
+
...cur,
|
|
122
|
+
prompt: patch.prompt ?? cur.prompt,
|
|
123
|
+
executor: patch.executor
|
|
124
|
+
? {
|
|
125
|
+
agent: patch.executor.agent ?? cur.executor.agent,
|
|
126
|
+
skills: patch.executor.skills ?? cur.executor.skills,
|
|
127
|
+
config: patch.executor.config ?? cur.executor.config,
|
|
128
|
+
}
|
|
129
|
+
: cur.executor,
|
|
130
|
+
updated_at: new Date().toISOString(),
|
|
131
|
+
};
|
|
132
|
+
writeFileSync(pathFor(name), serialize(next), 'utf-8');
|
|
133
|
+
return next;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function deletePrompt(name: string): boolean {
|
|
137
|
+
if (!NAME_RE.test(name)) return false;
|
|
138
|
+
const path = pathFor(name);
|
|
139
|
+
if (!existsSync(path)) return false;
|
|
140
|
+
unlinkSync(path);
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Forge Prompts — types (V3 B1)
|
|
3
|
+
*
|
|
4
|
+
* A Prompt is a stored body for `body_kind='prompt'` schedules. The schedule
|
|
5
|
+
* references it by name; the actual configuration (text + executor) lives
|
|
6
|
+
* in `<dataDir>/prompts/<name>.yaml`. Same external-file pattern Pipeline
|
|
7
|
+
* uses, so prompt fields can evolve without DB migrations.
|
|
8
|
+
*
|
|
9
|
+
* Wired into scheduler dispatch in B2 (#263).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface PromptExecutorRetry {
|
|
13
|
+
/** 0 = don't retry (default). Failed prompt tasks just wait for the next tick. */
|
|
14
|
+
max: number;
|
|
15
|
+
backoff?: 'linear' | 'exponential';
|
|
16
|
+
/** Seconds before first retry. */
|
|
17
|
+
initial_delay?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface PromptExecutorConfig {
|
|
21
|
+
/** Task timeout in seconds. Falls through to system default if unset. */
|
|
22
|
+
timeout?: number;
|
|
23
|
+
retry?: PromptExecutorRetry;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface PromptExecutor {
|
|
27
|
+
/** Agent id from Settings. null = use system default. */
|
|
28
|
+
agent: string | null;
|
|
29
|
+
/** Skills attached to the dispatched task; typically includes
|
|
30
|
+
* `ai-orchestration` so the agent can discover + call connectors. */
|
|
31
|
+
skills: string[];
|
|
32
|
+
config?: PromptExecutorConfig;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface Prompt {
|
|
36
|
+
name: string;
|
|
37
|
+
/** User-authored prompt text. MUST be preserved verbatim — no LLM rewrites. */
|
|
38
|
+
prompt: string;
|
|
39
|
+
executor: PromptExecutor;
|
|
40
|
+
created_at: string;
|
|
41
|
+
updated_at: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface CreatePromptInput {
|
|
45
|
+
name: string;
|
|
46
|
+
prompt: string;
|
|
47
|
+
executor?: Partial<PromptExecutor>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface UpdatePromptInput {
|
|
51
|
+
prompt?: string;
|
|
52
|
+
executor?: Partial<PromptExecutor>;
|
|
53
|
+
}
|