@aion0/forge 0.9.11 → 0.9.13

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/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
- return projects.sort((a, b) => b.lastModified.localeCompare(a.lastModified));
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
+ }
@@ -12,7 +12,6 @@
12
12
  * No sequential / on_failure / dedup / retry knobs. All body-internal.
13
13
  */
14
14
 
15
- import { randomUUID } from 'node:crypto';
16
15
  import { CronExpressionParser } from 'cron-parser';
17
16
  import { startPipeline, getWorkflow } from '@/lib/pipeline';
18
17
  import {
@@ -21,9 +20,9 @@ import {
21
20
  onTaskEvent,
22
21
  taskAppendSystemPromptOverrides,
23
22
  } from '@/lib/task-manager';
24
- import { getProjectInfo } from '@/lib/projects';
25
- import { dispatchTool } from '@/lib/chat/tool-dispatcher';
23
+ import { getProjectInfo, SCRATCH_PROJECT_NAME } from '@/lib/projects';
26
24
  import { ensureInstalledInProject } from '@/lib/skills';
25
+ import { getPrompt } from '@/lib/prompts/store';
27
26
  import { getDb } from '@/src/core/db/database';
28
27
  import { getDbPath } from '@/src/config';
29
28
  import {
@@ -50,7 +49,7 @@ export function startSchedulesScheduler(): void {
50
49
  if (g[schedulerKey]) return;
51
50
  g[schedulerKey] = true;
52
51
  ensureSchema();
53
- installSkillTaskListener();
52
+ installTaskBackedRunListener();
54
53
  console.log('[schedules-scheduler] started');
55
54
  void tick();
56
55
  setInterval(() => { void tick(); }, TICK_INTERVAL_MS).unref?.();
@@ -135,9 +134,8 @@ export function advanceSchedule(s: Schedule): void {
135
134
  * the manual fire API. Returns the target_id (the dispatched body's id).
136
135
  */
137
136
  export async function executeSchedule(s: Schedule, trigger: ScheduleRunTrigger): Promise<string> {
138
- if (s.body_kind === 'pipeline') return executePipelineBody(s, trigger);
139
- if (s.body_kind === 'skill') return executeSkillBody(s, trigger);
140
- if (s.body_kind === 'connector_tool') return executeConnectorToolBody(s, trigger);
137
+ if (s.body_kind === 'pipeline') return executePipelineBody(s, trigger);
138
+ if (s.body_kind === 'prompt') return executePromptBody(s, trigger);
141
139
  throw new Error(`unknown body_kind: ${s.body_kind}`);
142
140
  }
143
141
 
@@ -202,38 +200,40 @@ async function preinstallSkillsForPipeline(
202
200
  }
203
201
  }
204
202
 
205
- // ─── skill body ───────────────────────────────────────────
203
+ // ─── prompt body (V3) ────────────────────────────────────
206
204
 
207
205
  /**
208
- * Skill body: create a one-shot Claude task in the configured project
209
- * with the skill loaded via --append-system-prompt. We use the task as
210
- * the dispatched unit; on completion the task event listener captures
211
- * task.resultSummary and settles the schedule_run.
206
+ * Prompt body: load prompts/<body_ref>.yaml, dispatch a one-shot Claude
207
+ * task with the YAML's prompt text + executor.agent + executor.skills.
208
+ * Schedule.input.project chooses where the task runs. The schedule_run
209
+ * carries task.id as target_id; the task event listener (below) settles
210
+ * status + body_output when the task reaches terminal state.
212
211
  *
213
- * Required input fields:
214
- * - project (string, project name from Forge's local projects list)
215
- *
216
- * Optional input fields:
217
- * - user_prompt (string, the actual question to ask Claude)
218
- * defaults to "Run skill /<body_ref>"
219
- * - <any other key/value> — passed in the user message verbatim
220
- * (advanced; phase 2 doesn't do template expansion)
212
+ * The user's prompt text is sent VERBATIM — no LLM rewrites, no template
213
+ * expansion. Schedule.input is currently ignored (project is the only
214
+ * required field); future versions may surface input.* as template vars.
221
215
  */
222
- async function executeSkillBody(s: Schedule, trigger: ScheduleRunTrigger): Promise<string> {
223
- const projectName = strField(s.input.project);
224
- if (!projectName) {
225
- throw new Error('skill body requires input.project (Forge project name)');
226
- }
216
+ async function executePromptBody(s: Schedule, trigger: ScheduleRunTrigger): Promise<string> {
217
+ const def = getPrompt(s.body_ref);
218
+ if (!def) throw new Error(`prompt definition not found: prompts/${s.body_ref}.yaml`);
219
+
220
+ // input.project is optional for prompt body — empty / unset falls back
221
+ // to the synthetic 'scratch' project (<dataDir>/scratch) materialized
222
+ // at init time. Schedules created before the optional-project change
223
+ // also work: empty string → scratch.
224
+ const projectName = strField(s.input.project) || SCRATCH_PROJECT_NAME;
227
225
  const project = getProjectInfo(projectName);
228
- if (!project) {
229
- throw new Error(`project not found: "${projectName}"`);
230
- }
226
+ if (!project) throw new Error(`project not found: "${projectName}"`);
227
+
228
+ // Merge prompt-yaml skills with any extra skills the schedule row carries.
229
+ // YAML is the primary source (designed per-prompt); schedule.skills is a
230
+ // legacy/override hook that we keep for parity with pipeline/skill bodies.
231
+ const skills = [
232
+ ...(def.executor.skills || []),
233
+ ...(s.skills || []),
234
+ ].filter((v, i, arr) => v && arr.indexOf(v) === i);
231
235
 
232
- // Pre-install body_ref + any extras into the target project. Idempotent;
233
- // body_ref was validated as installed at POST time but only on SOME
234
- // project (or globally) — make sure it's actually on disk for THIS one.
235
- const skillsToInstall = [s.body_ref, ...(s.skills || [])].filter((v, i, arr) => v && arr.indexOf(v) === i);
236
- for (const skill of skillsToInstall) {
236
+ for (const skill of skills) {
237
237
  try {
238
238
  const r = await ensureInstalledInProject(skill, project.path);
239
239
  if (!r.installed) console.warn(`[schedules] skill "${skill}" not installable in ${project.path}: ${r.reason}`);
@@ -242,31 +242,15 @@ async function executeSkillBody(s: Schedule, trigger: ScheduleRunTrigger): Promi
242
242
  }
243
243
  }
244
244
 
245
- const userPrompt = strField(s.input.user_prompt) || `Run skill /${s.body_ref}`;
246
- // Pass other input keys as a JSON block appended after user_prompt
247
- // so the skill can extract them.
248
- const extras: Record<string, string> = {};
249
- for (const [k, v] of Object.entries(s.input || {})) {
250
- if (k === 'project' || k === 'user_prompt') continue;
251
- extras[k] = strField(v);
252
- }
253
- const extrasBlock = Object.keys(extras).length
254
- ? `\n\n--- Schedule input ---\n${JSON.stringify(extras, null, 2)}\n`
255
- : '';
256
-
257
245
  const task = createTask({
258
246
  projectName: project.name,
259
247
  projectPath: project.path,
260
- prompt: userPrompt + extrasBlock,
248
+ prompt: def.prompt,
261
249
  conversationId: '',
250
+ agent: def.executor.agent || undefined,
262
251
  });
263
252
 
264
- // Skill is hooked via --append-system-prompt before the runner picks
265
- // the task up. Same mechanism Pipelines use (lib/pipeline.ts:1448+).
266
- // body_ref is the primary skill; extra s.skills are merged for tasks
267
- // that need additional skills alongside (e.g. /forge-mr-triage + /git-helpers).
268
- const skillSet = [s.body_ref, ...(s.skills || [])].filter((v, i, arr) => v && arr.indexOf(v) === i);
269
- const append = renderSkillsAppendPrompt(skillSet);
253
+ const append = renderSkillsAppendPrompt(skills);
270
254
  if (append) taskAppendSystemPromptOverrides.set(task.id, append);
271
255
 
272
256
  insertScheduleRun({ schedule_id: s.id, target_id: task.id, trigger });
@@ -274,82 +258,6 @@ async function executeSkillBody(s: Schedule, trigger: ScheduleRunTrigger): Promi
274
258
  return task.id;
275
259
  }
276
260
 
277
- // ─── connector_tool body ──────────────────────────────────
278
-
279
- /**
280
- * Connector tool body: a single synchronous HTTP-style invocation
281
- * (run through tool-dispatcher; no Claude, no pipeline). body_ref is
282
- * "<plugin>.<tool>" (e.g. "mantis.search_bugs"). input is passed
283
- * verbatim as tool input.
284
- *
285
- * Synthetic target_id (uuid prefix "ct_") since there's no backing
286
- * task/pipeline row. The schedule_run carries the full lifecycle.
287
- *
288
- * Dispatch is fire-and-forget — we return the target_id immediately
289
- * so the caller (tick loop / manual fire API) doesn't block on slow
290
- * connector tools. The async helper settles body_output + status
291
- * when the call resolves.
292
- */
293
- function executeConnectorToolBody(s: Schedule, trigger: ScheduleRunTrigger): string {
294
- const targetId = `ct_${randomUUID().slice(0, 12).replace(/-/g, '')}`;
295
- insertScheduleRun({ schedule_id: s.id, target_id: targetId, trigger });
296
- setLastRunAt(s.id, toSqlIso(new Date()));
297
- void invokeConnectorTool(s, targetId).catch((err) => {
298
- console.error(`[schedules] connector_tool body for ${s.id} crashed`, err);
299
- updateScheduleRunStatus({
300
- target_id: targetId,
301
- status: 'failed',
302
- error: (err as Error)?.message || String(err),
303
- });
304
- });
305
- return targetId;
306
- }
307
-
308
- async function invokeConnectorTool(s: Schedule, targetId: string): Promise<void> {
309
- // body_ref is "<plugin_id>.<tool_name>". Some tools could contain
310
- // dots, but our internal naming is always plugin.tool and the
311
- // dispatcher splits on the FIRST dot.
312
- const dot = s.body_ref.indexOf('.');
313
- if (dot <= 0 || dot === s.body_ref.length - 1) {
314
- updateScheduleRunStatus({
315
- target_id: targetId,
316
- status: 'failed',
317
- error: `invalid body_ref: expected "<plugin>.<tool>", got "${s.body_ref}"`,
318
- });
319
- return;
320
- }
321
- const fullName = s.body_ref;
322
-
323
- try {
324
- const result = await dispatchTool({
325
- id: `schedule-${targetId}`,
326
- name: fullName,
327
- input: s.input || {},
328
- });
329
- const output = typeof result.content === 'string' ? result.content : JSON.stringify(result.content ?? '');
330
- setScheduleRunBodyOutputByTarget(targetId, output);
331
- if (result.is_error) {
332
- updateScheduleRunStatus({
333
- target_id: targetId,
334
- status: 'failed',
335
- error: output.slice(0, 500) || 'connector tool returned is_error',
336
- });
337
- } else {
338
- updateScheduleRunStatus({ target_id: targetId, status: 'done' });
339
- }
340
- } catch (err) {
341
- updateScheduleRunStatus({
342
- target_id: targetId,
343
- status: 'failed',
344
- error: (err as Error)?.message || String(err),
345
- });
346
- } finally {
347
- void runActionForTarget(targetId).catch((e) => {
348
- console.error(`[schedules-scheduler] action for connector_tool target ${targetId} crashed`, e);
349
- });
350
- }
351
- }
352
-
353
261
  function strField(v: unknown): string {
354
262
  if (v == null) return '';
355
263
  return typeof v === 'string' ? v : (typeof v === 'object' ? JSON.stringify(v) : String(v));
@@ -367,31 +275,31 @@ function renderSkillsAppendPrompt(skills: string[] | undefined): string {
367
275
  ].join('\n');
368
276
  }
369
277
 
370
- // ─── Skill task settler ───────────────────────────────────
278
+ // ─── Task-backed run settler ──────────────────────────────
371
279
 
372
280
  /**
373
281
  * Global task event listener that settles schedule_runs whose target_id
374
- * is a Claude task (i.e. body_kind='skill'). We only act on terminal
375
- * status events; reads getTask to capture resultSummary as body_output.
282
+ * is a Claude task (body_kind='prompt' dispatches one-shot tasks and stores
283
+ * task.id as target_id).
284
+ *
285
+ * We only act on terminal status events; reads getTask to capture
286
+ * resultSummary as body_output.
376
287
  *
377
- * Idempotent: updateScheduleRunStatus only touches rows with status='started',
378
- * so a duplicate event is a no-op.
288
+ * Idempotent: updateScheduleRunStatus only touches rows with status='started'
289
+ * AND target_id=taskId, so a duplicate event (or a non-schedule task) is a no-op.
379
290
  */
380
- let skillListenerInstalled = false;
381
- function installSkillTaskListener(): void {
382
- if (skillListenerInstalled) return;
383
- skillListenerInstalled = true;
291
+ let taskBackedListenerInstalled = false;
292
+ function installTaskBackedRunListener(): void {
293
+ if (taskBackedListenerInstalled) return;
294
+ taskBackedListenerInstalled = true;
384
295
  onTaskEvent((taskId, event, data) => {
385
296
  if (event !== 'status') return;
386
297
  if (data !== 'done' && data !== 'failed' && data !== 'cancelled') return;
387
- settleSkillRunIfMatch(taskId, data as ScheduleRunStatus);
298
+ settleTaskBackedRun(taskId, data as ScheduleRunStatus);
388
299
  });
389
300
  }
390
301
 
391
- function settleSkillRunIfMatch(taskId: string, status: ScheduleRunStatus): void {
392
- // We don't know body kind without a join; just attempt the update.
393
- // updateScheduleRunStatus only touches rows with status='started'
394
- // AND target_id=taskId, so it's a no-op for non-schedule tasks.
302
+ function settleTaskBackedRun(taskId: string, status: ScheduleRunStatus): void {
395
303
  const t = getTask(taskId);
396
304
  if (!t) {
397
305
  updateScheduleRunStatus({ target_id: taskId, status, error: 'task row gone' });
@@ -404,7 +312,7 @@ function settleSkillRunIfMatch(taskId: string, status: ScheduleRunStatus): void
404
312
  error: status === 'failed' ? (t.error || null) : null,
405
313
  });
406
314
  void runActionForTarget(taskId).catch((e) => {
407
- console.error(`[schedules-scheduler] action for skill target ${taskId} crashed`, e);
315
+ console.error(`[schedules-scheduler] action for task target ${taskId} crashed`, e);
408
316
  });
409
317
  }
410
318
 
@@ -485,10 +485,9 @@ export function setScheduleRunAction(args: {
485
485
 
486
486
  // ─── Zombie reconciliation ────────────────────────────────
487
487
 
488
- /** Same idea as Job's reconcileStalePipelineRuns: any started+30s+
489
- * schedule_run whose target is gone OR in terminal state fix the
490
- * DB row. Handles body_kind=pipeline (read JSON file) and
491
- * body_kind=skill (read tasks table). connector_tool lands in phase 3. */
488
+ /** Any started+30s+ schedule_run whose target is gone OR in terminal
489
+ * state fix the DB row. Handles body_kind=pipeline (read JSON file)
490
+ * and body_kind=prompt (read tasks table). */
492
491
  export function reconcileStaleScheduleRuns(): void {
493
492
  try {
494
493
  const stale = db().prepare(`
@@ -513,16 +512,8 @@ export function reconcileStaleScheduleRuns(): void {
513
512
  let settled = false;
514
513
  if (row.body_kind === 'pipeline') {
515
514
  settled = reconcilePipelineRun(row.id, row.target_id, pipelineDir);
516
- } else if (row.body_kind === 'skill') {
517
- settled = reconcileSkillRun(row.id, row.target_id);
518
- } else if (row.body_kind === 'connector_tool') {
519
- // connector_tool dispatch is in-memory async. If it's still
520
- // 'started' 30s later it means forge crashed mid-call, or the
521
- // tool itself is hanging. Either way: fail the run so future
522
- // ticks aren't blocked by isScheduleBusy.
523
- markRunStatus(row.id, 'failed');
524
- console.warn(`[schedules] reconciled stale connector_tool schedule_run ${row.id} → failed (no in-memory progress after 30s)`);
525
- settled = true;
515
+ } else if (row.body_kind === 'prompt') {
516
+ settled = reconcileTaskBackedRun(row.id, row.target_id);
526
517
  } else {
527
518
  markRunStatus(row.id, 'failed');
528
519
  console.warn(`[schedules] reconciled schedule_run ${row.id} → failed (unsupported body_kind '${row.body_kind}')`);
@@ -590,7 +581,7 @@ function reconcilePipelineRun(
590
581
 
591
582
  /** Same contract as reconcilePipelineRun: true if settled, false if
592
583
  * task still in flight (caller must NOT fire action yet). */
593
- function reconcileSkillRun(runId: string, taskId: string): boolean {
584
+ function reconcileTaskBackedRun(runId: string, taskId: string): boolean {
594
585
  try {
595
586
  const t = db().prepare(
596
587
  `SELECT status, result_summary FROM tasks WHERE id = ?`,
@@ -1,21 +1,15 @@
1
1
  /**
2
- * Forge Schedules — types (V2)
2
+ * Forge Schedules — types (V3)
3
3
  *
4
- * V2 generalizes Schedule from "pipeline + input + trigger" to
5
- * "trigger + body + action". body is one of pipeline / skill /
6
- * connector_tool (skill + connector_tool land in later phases).
7
- * action is one of none / chat / email / telegram (chat/email/telegram
8
- * land in later phases).
9
- *
10
- * Phase 1 only renames + extends fields; runtime behavior unchanged
11
- * (body_kind defaults to 'pipeline', action_kind defaults to 'none').
4
+ * trigger + body + action. body is one of pipeline / prompt.
5
+ * action is one of none / chat / email / telegram.
12
6
  */
13
7
 
14
8
  export type ScheduleKind = 'period' | 'once' | 'cron' | 'manual';
15
9
  export type ScheduleRunStatus = 'started' | 'done' | 'failed' | 'cancelled';
16
10
  export type ScheduleRunTrigger = 'schedule' | 'manual';
17
11
 
18
- export type ScheduleBodyKind = 'pipeline' | 'skill' | 'connector_tool';
12
+ export type ScheduleBodyKind = 'pipeline' | 'prompt';
19
13
  export type ScheduleActionKind = 'none' | 'chat' | 'email' | 'telegram';
20
14
  export type ScheduleActionStatus = 'pending' | 'done' | 'failed' | 'skipped';
21
15
 
@@ -27,15 +21,17 @@ export interface Schedule {
27
21
 
28
22
  // Body — what to run when this schedule fires.
29
23
  body_kind: ScheduleBodyKind;
30
- /** Reference depends on body_kind: pipeline name / skill id / `<plugin>.<tool>`. */
24
+ /** Reference depends on body_kind:
25
+ * pipeline → pipeline name (flows/<name>.yaml)
26
+ * prompt → prompt name (prompts/<name>.yaml) */
31
27
  body_ref: string;
32
28
  /** Input params; shape depends on body_kind. */
33
29
  input: Record<string, unknown>;
34
30
 
35
31
  /** Extra Forge skills attached to the dispatched body. For body=pipeline
36
32
  * these are forwarded to startPipeline({skills}) → every task gets the
37
- * --append-system-prompt block. For body=skill they're merged with
38
- * body_ref into a single prompt. body=connector_tool ignores this. */
33
+ * --append-system-prompt block. For body=prompt they're merged with the
34
+ * prompt yaml's executor.skills and installed before dispatch. */
39
35
  skills: string[];
40
36
 
41
37
  // Action — what to do with body's output. action_config JSON shape
@@ -72,7 +68,7 @@ export interface ScheduleRun {
72
68
  id: string;
73
69
  schedule_id: string;
74
70
  /** ID of the dispatched body — pipeline_id for body=pipeline,
75
- * task_id for body=skill, fresh uuid for body=connector_tool. */
71
+ * task_id for body=prompt. */
76
72
  target_id: string;
77
73
  trigger: ScheduleRunTrigger;
78
74
  status: ScheduleRunStatus;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aion0/forge",
3
- "version": "0.9.11",
3
+ "version": "0.9.13",
4
4
  "description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
5
5
  "type": "module",
6
6
  "scripts": {