@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.
@@ -0,0 +1,404 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * ScheduleQuickCreate — V3 D entry point (#266).
5
+ *
6
+ * Two stages:
7
+ * 1. Natural-language textarea → POST /api/schedules/extract
8
+ * 2. ScheduleConfirmCard renders the parsed spec, lets user adjust
9
+ * fields inline, then POSTs to /api/prompts + /api/schedules.
10
+ *
11
+ * Always half-automatic — Forge never auto-creates the schedule. The
12
+ * user must Confirm. Low confidence (< 0.7) or non-empty `missing[]`
13
+ * shows a banner asking them to verify before saving.
14
+ *
15
+ * Chat-thread integration (extractor invoked mid-chat with a Confirm Card
16
+ * inline) is a follow-up — same Card component will be reused there.
17
+ */
18
+
19
+ import { useEffect, useState } from 'react';
20
+
21
+ interface Extracted {
22
+ name: string;
23
+ prompt_name: string;
24
+ prompt: string;
25
+ agent: string | null;
26
+ skills: string[];
27
+ project: string | null;
28
+ trigger: {
29
+ kind: 'period' | 'cron' | 'once' | 'manual';
30
+ interval_minutes: number | null;
31
+ cron: string | null;
32
+ at_iso: string | null;
33
+ };
34
+ action: {
35
+ kind: 'none' | 'chat' | 'email' | 'telegram';
36
+ config: Record<string, unknown>;
37
+ };
38
+ confidence: number;
39
+ missing: string[];
40
+ }
41
+
42
+ interface AgentInfo { id: string; label?: string; backendType?: string }
43
+ interface ProjectInfo { name: string; path: string }
44
+
45
+ interface Props {
46
+ onClose: () => void;
47
+ onCreated: () => void;
48
+ }
49
+
50
+ export default function ScheduleQuickCreate({ onClose, onCreated }: Props) {
51
+ const [text, setText] = useState('');
52
+ const [extracting, setExtracting] = useState(false);
53
+ const [extracted, setExtracted] = useState<Extracted | null>(null);
54
+ const [err, setErr] = useState('');
55
+
56
+ async function extract() {
57
+ setErr('');
58
+ setExtracting(true);
59
+ try {
60
+ const r = await fetch('/api/schedules/extract', {
61
+ method: 'POST',
62
+ headers: { 'content-type': 'application/json' },
63
+ body: JSON.stringify({ text: text.trim() }),
64
+ });
65
+ const j = await r.json();
66
+ if (!r.ok || !j.ok) {
67
+ setErr(j.error || `HTTP ${r.status}`);
68
+ return;
69
+ }
70
+ setExtracted(j.extracted as Extracted);
71
+ } catch (e) {
72
+ setErr(e instanceof Error ? e.message : String(e));
73
+ } finally {
74
+ setExtracting(false);
75
+ }
76
+ }
77
+
78
+ return (
79
+ <div className="fixed inset-0 z-50 flex items-start justify-center bg-black/50 pt-[10vh]">
80
+ <div className="bg-[var(--bg-primary)] border border-[var(--border)] rounded shadow-2xl w-[680px] max-w-[95vw] max-h-[85vh] flex flex-col">
81
+ <div className="px-4 py-3 border-b border-[var(--border)] flex items-center">
82
+ <h2 className="text-[13px] font-semibold flex-1">✨ Quick create schedule</h2>
83
+ <button onClick={onClose} className="text-[14px] text-[var(--text-secondary)] hover:text-[var(--text-primary)] w-6 h-6">✕</button>
84
+ </div>
85
+
86
+ <div className="flex-1 overflow-auto p-4">
87
+ {!extracted && (
88
+ <>
89
+ <div className="text-[11px] text-[var(--text-secondary)] mb-2">
90
+ Describe what you want Forge to run on a schedule. Be specific about
91
+ <em> when</em>, <em>what</em>, and <em>where to send the result</em>.
92
+ Forge will parse it into a draft you can review and tweak before saving.
93
+ </div>
94
+ <textarea
95
+ value={text}
96
+ onChange={(e) => setText(e.target.value)}
97
+ rows={6}
98
+ placeholder="e.g. 每天上午 9 点查一下我今天分到的 mantis bug,发我 Telegram"
99
+ className="w-full text-[12px] font-mono px-3 py-2 border border-[var(--border)] rounded bg-[var(--bg-secondary)] resize-y"
100
+ />
101
+ <div className="text-[10px] text-[var(--text-secondary)] mt-1">
102
+ Your wording stays verbatim — Forge does NOT rewrite it.
103
+ </div>
104
+ </>
105
+ )}
106
+
107
+ {extracted && (
108
+ <ScheduleConfirmCard
109
+ initial={extracted}
110
+ originalText={text}
111
+ onCancel={() => setExtracted(null)}
112
+ onCreated={onCreated}
113
+ />
114
+ )}
115
+
116
+ {err && <div className="text-[11px] text-[var(--red)] bg-[var(--red)]/10 rounded p-2 mt-3">{err}</div>}
117
+ </div>
118
+
119
+ {!extracted && (
120
+ <div className="px-4 py-3 border-t border-[var(--border)] flex items-center justify-between">
121
+ <button onClick={onClose} className="text-[11px] px-3 py-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)]">Cancel</button>
122
+ <button
123
+ onClick={() => void extract()}
124
+ disabled={!text.trim() || extracting}
125
+ className="text-[11px] px-3 py-1 bg-[var(--accent)] text-[var(--bg-primary)] rounded font-semibold disabled:opacity-50"
126
+ >{extracting ? 'Parsing…' : 'Parse →'}</button>
127
+ </div>
128
+ )}
129
+ </div>
130
+ </div>
131
+ );
132
+ }
133
+
134
+ function ScheduleConfirmCard({
135
+ initial, originalText, onCancel, onCreated,
136
+ }: {
137
+ initial: Extracted;
138
+ originalText: string;
139
+ onCancel: () => void;
140
+ onCreated: () => void;
141
+ }) {
142
+ const [name, setName] = useState(initial.name || initial.prompt_name || 'untitled');
143
+ const [promptText, setPromptText] = useState(initial.prompt);
144
+ const [agent, setAgent] = useState(initial.agent || '');
145
+ const [skills, setSkills] = useState<string[]>(initial.skills);
146
+ const [skillsRaw, setSkillsRaw] = useState(initial.skills.join(', '));
147
+ const [project, setProject] = useState(initial.project || '');
148
+ const [triggerKind, setTriggerKind] = useState(initial.trigger.kind);
149
+ const [interval, setInterval] = useState(initial.trigger.interval_minutes ?? 30);
150
+ const [cron, setCron] = useState(initial.trigger.cron ?? '');
151
+ const [atIso, setAtIso] = useState(initial.trigger.at_iso ?? '');
152
+ const [actionKind, setActionKind] = useState(initial.action.kind);
153
+ const [actionConfigJson, setActionConfigJson] = useState(
154
+ JSON.stringify(initial.action.config ?? {}, null, 2)
155
+ );
156
+
157
+ const [agents, setAgents] = useState<AgentInfo[]>([]);
158
+ const [projects, setProjects] = useState<ProjectInfo[]>([]);
159
+ const [submitting, setSubmitting] = useState(false);
160
+ const [err, setErr] = useState('');
161
+
162
+ const lowConfidence = initial.confidence < 0.7 || initial.missing.length > 0;
163
+
164
+ useEffect(() => {
165
+ (async () => {
166
+ try {
167
+ const [skR, agR] = await Promise.all([
168
+ fetch('/api/skills').then((r) => r.json()),
169
+ fetch('/api/agents?include=all').then((r) => r.json()).catch(() => ({ agents: [] })),
170
+ ]);
171
+ if (Array.isArray(skR?.projects)) setProjects(skR.projects);
172
+ if (Array.isArray(agR?.agents)) setAgents(agR.agents);
173
+ } catch {/* ignore — fields fall back to free text */}
174
+ })();
175
+ }, []);
176
+
177
+ async function submit() {
178
+ setErr('');
179
+ setSubmitting(true);
180
+ try {
181
+ const cleanedSkills = skillsRaw
182
+ .split(/[,\s]+/)
183
+ .map((s) => s.trim())
184
+ .filter(Boolean);
185
+ // 1. Create prompts/<name>.yaml. Auto-suffix on 409 (same logic as
186
+ // ScheduleCreateModal — keeps quick-create idempotent across retries).
187
+ const base = slugifyName(name) || initial.prompt_name || `prompt-${Date.now().toString(36).slice(-6)}`;
188
+ let promptNameToUse: string | null = null;
189
+ let candidate = base;
190
+ for (let i = 2; i <= 50; i++) {
191
+ const r = await fetch('/api/prompts', {
192
+ method: 'POST',
193
+ headers: { 'content-type': 'application/json' },
194
+ body: JSON.stringify({
195
+ name: candidate,
196
+ prompt: promptText,
197
+ executor: { agent: agent || null, skills: cleanedSkills },
198
+ }),
199
+ });
200
+ if (r.ok) { promptNameToUse = candidate; break; }
201
+ if (r.status === 409) { candidate = `${base}-${i}`; continue; }
202
+ const j = await r.json().catch(() => ({}));
203
+ setErr(j.error || `prompt save failed (${r.status})`);
204
+ return;
205
+ }
206
+ if (!promptNameToUse) {
207
+ setErr(`couldn't find a free prompt name starting from "${base}"`);
208
+ return;
209
+ }
210
+
211
+ // 2. Create the schedule
212
+ let actionConfig: Record<string, unknown>;
213
+ try {
214
+ actionConfig = actionConfigJson.trim() ? JSON.parse(actionConfigJson) : {};
215
+ } catch (e) {
216
+ setErr(`action_config is not valid JSON: ${(e as Error).message}`);
217
+ return;
218
+ }
219
+ const body: Record<string, unknown> = {
220
+ name,
221
+ body_kind: 'prompt',
222
+ body_ref: promptNameToUse,
223
+ input: { project },
224
+ enabled: true,
225
+ schedule_kind: triggerKind,
226
+ schedule_interval_minutes: triggerKind === 'period' ? interval : null,
227
+ schedule_cron: triggerKind === 'cron' ? cron.trim() : null,
228
+ schedule_at: triggerKind === 'once' && atIso ? new Date(atIso).toISOString() : null,
229
+ action_kind: actionKind,
230
+ action_config: actionConfig,
231
+ };
232
+ const r = await fetch('/api/schedules', {
233
+ method: 'POST',
234
+ headers: { 'content-type': 'application/json' },
235
+ body: JSON.stringify(body),
236
+ });
237
+ if (!r.ok) {
238
+ const j = await r.json().catch(() => ({}));
239
+ setErr(j.error || `schedule create failed (${r.status})`);
240
+ return;
241
+ }
242
+ onCreated();
243
+ } catch (e) {
244
+ setErr(e instanceof Error ? e.message : String(e));
245
+ } finally {
246
+ setSubmitting(false);
247
+ }
248
+ }
249
+
250
+ return (
251
+ <div className="space-y-3">
252
+ <div className="text-[11px] text-[var(--text-secondary)]">
253
+ Forge parsed your request below. Review, tweak anything, then Create.
254
+ </div>
255
+
256
+ {lowConfidence && (
257
+ <div className="text-[11px] text-[var(--yellow)] bg-[var(--yellow)]/10 rounded p-2 border border-[var(--yellow)]/30">
258
+ ⚠ confidence {initial.confidence.toFixed(2)}
259
+ {initial.missing.length > 0 && <> · missing: <code className="font-mono">{initial.missing.join(', ')}</code></>}
260
+ <div className="text-[10px] text-[var(--text-secondary)] mt-0.5">Verify the fields before saving.</div>
261
+ </div>
262
+ )}
263
+
264
+ <Field label="Schedule name">
265
+ <input value={name} onChange={(e) => setName(e.target.value)} className={inputCls} />
266
+ </Field>
267
+
268
+ <Field label="Prompt (verbatim)" hint="The agent will receive this exact text. Tweak if Forge mis-quoted.">
269
+ <textarea
270
+ value={promptText}
271
+ onChange={(e) => setPromptText(e.target.value)}
272
+ rows={5}
273
+ className={inputCls + ' font-mono'}
274
+ />
275
+ </Field>
276
+
277
+ <div className="grid grid-cols-2 gap-3">
278
+ <Field label="Project" hint="Leave on scratch for prompts that just call connectors.">
279
+ <select value={project} onChange={(e) => setProject(e.target.value)} className={inputCls}>
280
+ <option value="">— scratch (default) —</option>
281
+ {projects.filter((p) => p.name !== 'scratch').map((p) => <option key={p.name} value={p.name}>{p.name}</option>)}
282
+ {project && project !== 'scratch' && !projects.some((p) => p.name === project) && (
283
+ <option value={project}>{project} (not in projects list)</option>
284
+ )}
285
+ </select>
286
+ </Field>
287
+ <Field label="Agent">
288
+ <select value={agent} onChange={(e) => setAgent(e.target.value)} className={inputCls}>
289
+ <option value="">— system default —</option>
290
+ {agents.map((a) => <option key={a.id} value={a.id}>{a.label || a.id}</option>)}
291
+ </select>
292
+ </Field>
293
+ </div>
294
+
295
+ <Field label="Skills" hint="Comma-separated. ai-orchestration is the default V3 skill (lets the agent call connectors).">
296
+ <input value={skillsRaw} onChange={(e) => setSkillsRaw(e.target.value)} className={inputCls + ' font-mono'} />
297
+ </Field>
298
+
299
+ <Field label="Trigger">
300
+ <div className="flex gap-2 items-baseline">
301
+ <select
302
+ value={triggerKind}
303
+ onChange={(e) => setTriggerKind(e.target.value as Extracted['trigger']['kind'])}
304
+ className={inputCls + ' w-32'}
305
+ >
306
+ <option value="period">period</option>
307
+ <option value="cron">cron</option>
308
+ <option value="once">once</option>
309
+ <option value="manual">manual</option>
310
+ </select>
311
+ {triggerKind === 'period' && (
312
+ <>
313
+ <input
314
+ type="number" min={1}
315
+ value={interval}
316
+ onChange={(e) => setInterval(Number(e.target.value) || 1)}
317
+ className={inputCls + ' w-24'}
318
+ />
319
+ <span className="text-[10px] text-[var(--text-secondary)]">minutes</span>
320
+ </>
321
+ )}
322
+ {triggerKind === 'cron' && (
323
+ <input value={cron} onChange={(e) => setCron(e.target.value)} placeholder="e.g. 0 9 * * *"
324
+ className={inputCls + ' font-mono flex-1'} />
325
+ )}
326
+ {triggerKind === 'once' && (
327
+ <input
328
+ type="datetime-local"
329
+ value={atIso ? new Date(atIso).toISOString().slice(0, 16) : ''}
330
+ onChange={(e) => setAtIso(e.target.value ? new Date(e.target.value).toISOString() : '')}
331
+ className={inputCls + ' flex-1'}
332
+ />
333
+ )}
334
+ </div>
335
+ </Field>
336
+
337
+ <Field label="Action">
338
+ <div className="flex gap-2 items-start">
339
+ <select
340
+ value={actionKind}
341
+ onChange={(e) => setActionKind(e.target.value as Extracted['action']['kind'])}
342
+ className={inputCls + ' w-32'}
343
+ >
344
+ <option value="none">none</option>
345
+ <option value="chat">chat</option>
346
+ <option value="email">email</option>
347
+ <option value="telegram">telegram</option>
348
+ </select>
349
+ {actionKind !== 'none' && (
350
+ <textarea
351
+ value={actionConfigJson}
352
+ onChange={(e) => setActionConfigJson(e.target.value)}
353
+ rows={3}
354
+ placeholder='{ "chat_id": "..." }'
355
+ className={inputCls + ' font-mono flex-1'}
356
+ />
357
+ )}
358
+ </div>
359
+ {actionKind !== 'none' && (
360
+ <div className="text-[10px] text-[var(--text-secondary)] mt-1">
361
+ JSON object; see Schedules help doc for the per-action shape. Chat needs <code className="font-mono">session_id</code>, email needs <code className="font-mono">to</code>, telegram needs <code className="font-mono">chat_id</code> (or default subscriber).
362
+ </div>
363
+ )}
364
+ </Field>
365
+
366
+ {err && <div className="text-[11px] text-[var(--red)] bg-[var(--red)]/10 rounded p-2">{err}</div>}
367
+
368
+ <details className="text-[10px] text-[var(--text-secondary)]">
369
+ <summary className="cursor-pointer">Original text</summary>
370
+ <pre className="mt-1 whitespace-pre-wrap break-words font-mono">{originalText}</pre>
371
+ </details>
372
+
373
+ <div className="flex justify-between pt-2 border-t border-[var(--border)]">
374
+ <button onClick={onCancel} className="text-[11px] px-3 py-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)]">← Re-parse</button>
375
+ <button
376
+ onClick={() => void submit()}
377
+ disabled={submitting || !promptText.trim() || !name.trim() || (triggerKind === 'cron' && !cron.trim()) || (triggerKind === 'once' && !atIso)}
378
+ className="text-[11px] px-3 py-1 bg-[var(--accent)] text-[var(--bg-primary)] rounded font-semibold disabled:opacity-50"
379
+ >{submitting ? 'Creating…' : 'Confirm create'}</button>
380
+ </div>
381
+ </div>
382
+ );
383
+ }
384
+
385
+ const inputCls = 'text-[11px] px-2 py-1 border border-[var(--border)] rounded bg-[var(--bg-secondary)] w-full';
386
+
387
+ function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
388
+ return (
389
+ <div>
390
+ <div className="text-[11px] mb-0.5">{label}</div>
391
+ {children}
392
+ {hint && <div className="text-[10px] text-[var(--text-secondary)] mt-0.5">{hint}</div>}
393
+ </div>
394
+ );
395
+ }
396
+
397
+ // Mirror the slug rule in ScheduleCreateModal so quick-create + manual create
398
+ // produce comparable prompt filenames.
399
+ function slugifyName(s: string): string {
400
+ return s.toLowerCase()
401
+ .replace(/[^a-z0-9]+/g, '-')
402
+ .replace(/^-+|-+$/g, '')
403
+ .slice(0, 50);
404
+ }
@@ -14,12 +14,13 @@
14
14
 
15
15
  import { useCallback, useEffect, useState } from 'react';
16
16
  import ScheduleCreateModal from './ScheduleCreateModal';
17
+ import ScheduleQuickCreate from './ScheduleQuickCreate';
17
18
 
18
19
  interface Schedule {
19
20
  id: string;
20
21
  name: string;
21
22
  enabled: boolean;
22
- body_kind: 'pipeline' | 'skill' | 'connector_tool';
23
+ body_kind: 'pipeline' | 'prompt';
23
24
  body_ref: string;
24
25
  input: Record<string, unknown>;
25
26
  skills: string[];
@@ -66,6 +67,7 @@ export default function SchedulesView({ onViewPipeline }: Props) {
66
67
  const [runsBySchedule, setRunsBySchedule] = useState<Record<string, ScheduleRun[]>>({});
67
68
  const [filter, setFilter] = useState<Filter>('all');
68
69
  const [showCreate, setShowCreate] = useState(false);
70
+ const [showQuickCreate, setShowQuickCreate] = useState(false);
69
71
  const [editingSchedule, setEditingSchedule] = useState<Schedule | null>(null);
70
72
 
71
73
  const refresh = useCallback(async () => {
@@ -190,6 +192,11 @@ export default function SchedulesView({ onViewPipeline }: Props) {
190
192
  </p>
191
193
  </div>
192
194
  <div className="flex items-center gap-2">
195
+ <button
196
+ onClick={() => setShowQuickCreate(true)}
197
+ className="text-[10px] px-3 py-1 border border-[var(--accent)] text-[var(--accent)] rounded hover:bg-[var(--accent)]/10"
198
+ title="Describe in natural language; Forge parses + you confirm"
199
+ >✨ Quick create</button>
193
200
  <button
194
201
  onClick={() => setShowCreate(true)}
195
202
  className="text-[10px] px-3 py-1 bg-[var(--accent)] text-[var(--bg-primary)] rounded font-semibold hover:opacity-90"
@@ -252,6 +259,12 @@ export default function SchedulesView({ onViewPipeline }: Props) {
252
259
  onCreated={() => { setShowCreate(false); void refresh(); }}
253
260
  />
254
261
  )}
262
+ {showQuickCreate && (
263
+ <ScheduleQuickCreate
264
+ onClose={() => setShowQuickCreate(false)}
265
+ onCreated={() => { setShowQuickCreate(false); void refresh(); }}
266
+ />
267
+ )}
255
268
  {editingSchedule && (
256
269
  <ScheduleCreateModal
257
270
  existing={editingSchedule}
@@ -503,10 +516,9 @@ function RunRow({ run: r, isPipelineBody, onViewPipeline }: {
503
516
  isPipelineBody: boolean;
504
517
  onViewPipeline?: (pipelineId: string) => void;
505
518
  }) {
506
- // body_output/error captured for connector_tool runs (target_id starts
507
- // ct_*) and skill runs aren't navigable to a pipeline detail page; the
508
- // only place to surface them is right here, inline. Failed runs default
509
- // to expanded so the user doesn't have to hunt.
519
+ // body_output/error captured for prompt runs aren't navigable to a
520
+ // pipeline detail page; the only place to surface them is right here,
521
+ // inline. Failed runs default to expanded so the user doesn't have to hunt.
510
522
  // Surface ALL three stages — trigger / body / action — instead of
511
523
  // just body status. Skipped or failed action was previously
512
524
  // invisible (e.g. "body status was started" race where action got
@@ -514,7 +526,7 @@ function RunRow({ run: r, isPipelineBody, onViewPipeline }: {
514
526
  // to see WHY chat/email/telegram never fired).
515
527
  const actionFailedOrSkipped = r.action_status === 'failed' || r.action_status === 'skipped';
516
528
  const [expanded, setExpanded] = useState(r.status === 'failed' || actionFailedOrSkipped);
517
- const canOpenPipeline = isPipelineBody && !!onViewPipeline && !r.target_id.startsWith('ct_');
529
+ const canOpenPipeline = isPipelineBody && !!onViewPipeline;
518
530
  const hasDetails = !!(r.body_output || r.error || r.action_error);
519
531
 
520
532
  return (
@@ -164,6 +164,18 @@ function resolveProvider(sessionProvider: string | null, sessionModel: string |
164
164
 
165
165
  function buildSystemPrompt(connectorTools: LlmTool[], builtinDefs: typeof BUILTIN_TOOL_DEFS, sessionSystemPrompt: string | null): string {
166
166
  const now = new Date().toISOString();
167
+
168
+ // Inject a brief Forge context block (project names only) so the LLM can
169
+ // validate names the user mentions ("FortiNAC" → real project? → yes) and
170
+ // pass them to trigger_pipeline / dispatch_task without guessing. Full
171
+ // details (paths, agents, skills) are behind list_forge_context — only
172
+ // names are cheap enough to ship every turn.
173
+ let projectNames: string[] = [];
174
+ try {
175
+ const { scanProjects } = require('../projects') as typeof import('../projects');
176
+ projectNames = scanProjects().map((p) => p.name);
177
+ } catch { /* projects roots not configured / read failed — omit */ }
178
+
167
179
  const lines: string[] = [
168
180
  "You are Forge, the user's personal AI assistant.",
169
181
  '',
@@ -174,6 +186,14 @@ function buildSystemPrompt(connectorTools: LlmTool[], builtinDefs: typeof BUILTI
174
186
  ' Don\'t explain how to do something manually before trying the tool. The tools below run inside the user\'s actual logged-in browser session — they CAN do things you might think only the user can do manually.',
175
187
  '- For Teams in particular: send_message can target any chat by name; if the chat doesn\'t exist yet, the tool will return a specific error and THEN you can advise. Don\'t pre-judge.',
176
188
  '- If a tool call fails, read its error carefully — it usually tells you what to fix (wrong arg, missing setting, login required). Retry with the fix. Only give up after the tool explicitly says it cannot do the task.',
189
+ '- For trigger_pipeline / dispatch_task: when the user names a "project" (e.g. "FortiNAC"), pass it as input.project verbatim. The names in the "Forge projects" list below ARE the valid values. Call list_forge_context only if you need paths / agents / skills.',
190
+ '',
191
+ 'trigger_pipeline specifics — these are easy to get wrong, READ CAREFULLY:',
192
+ '- FIRST call this session: call trigger_pipeline() with NO arguments. The response lists every workflow + which input fields are required (*) vs have defaults. Field names are EXACT, snake_case (e.g. bug_id), declared by the workflow yaml. They are NOT the same as bash variable names inside pipeline scripts (BUG_ID / BASE / PROJECT_PATH are wrong). DO NOT pass uppercase / made-up names.',
193
+ '- For optional fields with defaults (mr_body_template / user_prompt / teams_message_template / etc.), OMIT them — let the default apply. NEVER pass empty strings or invented placeholder values.',
194
+ '- If the response says "Unknown input fields", "Missing required", or "0 iterations" — the pipeline did NOT do what the user asked. Fix the input and retry. Optionally save a pinned memory rule via memory_remember_block({pinned: true, ...}) so the lesson sticks for future sessions.',
195
+ '- DO NOT trust earlier assistant messages in this conversation that claim a pipeline "already ran" — those may be wrong. If the user re-asks, fire fresh; verify only by re-checking the actual target system (e.g. mantis.get_bug for status).',
196
+ '',
177
197
  '- Reply without tools ONLY when no system + no time question is involved.',
178
198
  '',
179
199
  'Other:',
@@ -181,6 +201,10 @@ function buildSystemPrompt(connectorTools: LlmTool[], builtinDefs: typeof BUILTI
181
201
  'Keep replies short and direct.',
182
202
  ];
183
203
 
204
+ if (projectNames.length > 0) {
205
+ lines.push('', `Forge projects (valid input.project values): ${projectNames.join(', ')}`);
206
+ }
207
+
184
208
  if (connectorTools.length > 0) {
185
209
  lines.push('', 'Connector tools available:');
186
210
  for (const t of connectorTools) {