import { readdir, mkdir, rm } from 'node:fs/promises'; import { join, dirname } from 'node:path'; import { itemsDir, OFFICER_ITEMS_DIR } from '../../data-path'; import { capabilitySources, type CapabilitySource } from '../../capability-sources'; // File-backed task store. Every task is a directory under $OFFICER_ITEMS_DIR/tasks// with a // TASK.md (metadata + prose body) and, for script-mode tasks, a sibling implementation file. There are // no scope tiers and no database — the directory name is the task's identity. // // ── Plus every enabled plugin's own tasks/ directory ── // // Read where they live, never copied in — the same rule as skills and affordances, and the same reasons // (`capability-sources.ts`, `docs/plugin-capabilities.md`). It lands here rather than in each caller // because EVERY execution path goes through `getTaskByDirName`: the context menu, the runner sockets, // the job queue and the pipeline executor all resolve a task by its directory name and then read // `implementation` off the record. Teaching this one function about plugin sources makes a plugin's task // runnable everywhere at once, and leaves the identity — the directory name — exactly as it was. // // The owner's own store is searched FIRST, so a name the owner is already using cannot be taken over by // a plugin that happened to pick the same directory name. // // Writing is a different matter: create, update and delete only ever touch the owner's store, and refuse // a task that belongs to a plugin. A plugin's task lives in a git checkout that `plugins:update` pulls — // editing it in place would be overwritten by the next update, and deleting it would make the plugin's // own files disappear from a screen that says nothing about plugins. const YAML = (Bun as unknown as { YAML: { parse(input: string): unknown } }).YAML; export type TaskFrontmatter = { name: string; description: string | null; category: string | null; // groups the task into a context-menu submenu; null falls back to 'Other' version: number; mode: string; // How the task runs: false = background job, true = ephemerally in the modal, 'ask' = the modal // offers the choice per run. inline: boolean | 'ask'; language: string | null; args: string[] | null; tags: string[] | null; tools: string[] | null; skills: string[] | null; inputs: unknown; outputs: unknown; dependencies: unknown; config: unknown; trigger: unknown; }; export type TaskRecord = TaskFrontmatter & { dirName: string; body: string; implementation: string | null; filePath: string; /** The plugin that ships it, or null when it is the owner's own. Read-only when set. */ plugin: string | null; }; export type TaskSummary = { dirName: string; name: string; description: string | null; category: string | null; mode: string; inline: boolean | 'ask'; version: number; trigger: unknown; plugin: string | null; }; // Frontmatter keys, in write order. These mirror the old `tasks` table columns 1:1. const FM_KEYS = [ 'name', 'description', 'category', 'version', 'mode', 'inline', 'language', 'args', 'tags', 'tools', 'skills', 'inputs', 'outputs', 'dependencies', 'config', 'trigger', ] as const; // Script implementation lives in a sibling file named by language, matching the marketplace registry. const IMPL_FILE: Record = { bash: 'run.sh', python: 'run.py', typescript: 'index.ts', javascript: 'index.js', }; const implFileName = (language: string | null) => IMPL_FILE[language ?? 'bash'] ?? 'run.sh'; const tasksRoot = () => itemsDir('tasks'); const taskDir = (dirName: string) => join(tasksRoot(), dirName); const taskFile = (dirName: string) => join(taskDir(dirName), 'TASK.md'); const asArray = (v: unknown): string[] | null => (Array.isArray(v) ? v.map(String) : null); export const slugify = (name: string) => name .trim() .toLowerCase() .replace(/\s+/g, '-') .replace(/[^a-z0-9-]/g, ''); function parseTaskMd(raw: string): { fm: TaskFrontmatter; body: string } { const match = raw.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/); const body = match ? match[2]! : raw; const yaml = match ? match[1]! : ''; let parsed: Record = {}; if (yaml.trim()) { try { parsed = (YAML.parse(yaml) as Record) ?? {}; } catch { parsed = {}; } } const versionRaw = parsed.version; const version = typeof versionRaw === 'number' ? versionRaw : Number(versionRaw) || 1; return { fm: { name: parsed.name == null ? '' : String(parsed.name), description: parsed.description == null ? null : String(parsed.description), category: parsed.category == null ? null : String(parsed.category), version, mode: parsed.mode == null ? 'agentic' : String(parsed.mode), inline: parsed.inline === 'ask' ? 'ask' : parsed.inline === true, language: parsed.language == null ? null : String(parsed.language), args: asArray(parsed.args), tags: asArray(parsed.tags), tools: asArray(parsed.tools), skills: asArray(parsed.skills), inputs: parsed.inputs ?? null, outputs: parsed.outputs ?? null, dependencies: parsed.dependencies ?? null, config: parsed.config ?? null, trigger: parsed.trigger ?? null, }, body, }; } // Serialize as JSON-flow YAML: every value is JSON.stringify'd, which is valid YAML and round-trips // losslessly. Bun.YAML.parse also accepts hand-written multi-line YAML, so files stay editable. function buildTaskMd(fm: Partial, body: string): string { const lines: string[] = ['---']; for (const key of FM_KEYS) { const val = (fm as Record)[key]; if (val === undefined || val === null) continue; if (Array.isArray(val) && val.length === 0) continue; lines.push(`${key}: ${JSON.stringify(val)}`); } lines.push('---', ''); lines.push(body ?? ''); return lines.join('\n'); } async function readImplementation(dir: string, language: string | null): Promise { const file = Bun.file(join(dir, implFileName(language))); return (await file.exists()) ? file.text() : null; } // categories.yaml at the root of the items store lists the task categories in the order they should // appear in the file browser's Run Task submenu. It is optional and purely presentational: the // platform never decides a task's category, only how known ones sort. A missing, empty or malformed // file leaves everything to alphabetical ordering. export async function readCategoryOrder(): Promise { const file = Bun.file(join(OFFICER_ITEMS_DIR, 'categories.yaml')); if (!(await file.exists())) return []; try { const parsed = YAML.parse(await file.text()); if (!Array.isArray(parsed)) return []; return parsed .filter((entry): entry is string => typeof entry === 'string') .map((entry) => entry.trim()) .filter(Boolean); } catch { return []; } } /** * Every task on offer, the owner's own first. * * `sources` is injectable so this can be tested against temporary directories — resolving them for real * reads the database and the plugin directory, which would make a test assert whatever this machine * happens to have installed. */ export async function listTasks(sources?: CapabilitySource[]): Promise { const roots = sources ?? (await capabilitySources('tasks')); const byDirName = new Map(); for (const source of roots) { let entries; try { entries = await readdir(source.dir, { withFileTypes: true }); } catch { continue; // most plugins ship no tasks at all — the ordinary case, not a fault } for (const entry of entries) { if (!entry.isDirectory() || byDirName.has(entry.name)) continue; const file = Bun.file(join(source.dir, entry.name, 'TASK.md')); if (!(await file.exists())) continue; const { fm } = parseTaskMd(await file.text()); byDirName.set(entry.name, { dirName: entry.name, name: fm.name || entry.name, description: fm.description, category: fm.category, mode: fm.mode, inline: fm.inline, version: fm.version, trigger: fm.trigger ?? [], plugin: source.plugin, }); } } return [...byDirName.values()].sort((a, b) => a.name.localeCompare(b.name)); } export async function getTaskByDirName(dirName: string, sources?: CapabilitySource[]): Promise { // A directory name reaches this from a websocket message and a job row, so it is caller-supplied and // gets joined onto a path. Traversal would otherwise read any TASK.md on the disk. if (!dirName || dirName.includes('/') || dirName.includes('\\') || dirName === '.' || dirName === '..') return null; const roots = sources ?? (await capabilitySources('tasks')); for (const source of roots) { const dir = join(source.dir, dirName); const filePath = join(dir, 'TASK.md'); const file = Bun.file(filePath); if (!(await file.exists())) continue; const { fm, body } = parseTaskMd(await file.text()); const implementation = await readImplementation(dir, fm.language); return { ...fm, dirName, body, implementation, filePath, plugin: source.plugin }; } return null; } type CreateTaskInput = { name: string; description?: string | null; mode?: string; language?: string | null; }; export async function createTask(input: CreateTaskInput): Promise { const name = input.name.trim(); const dirName = slugify(name); if (!dirName) throw new Error('Invalid name'); if (await Bun.file(taskFile(dirName)).exists()) throw new Error('Task already exists'); const mode = input.mode ?? 'agentic'; const language = input.language ?? null; await mkdir(taskDir(dirName), { recursive: true }); await Bun.write( taskFile(dirName), buildTaskMd({ name, description: input.description ?? null, version: 1, mode, language }, ''), ); if (mode === 'script') await Bun.write(join(taskDir(dirName), implFileName(language)), ''); return { dirName, name, description: input.description ?? null, category: null, mode, inline: false, version: 1, trigger: [], plugin: null, filePath: taskFile(dirName), }; } export async function updateTask( dirName: string, patch: Partial & { body?: string; implementation?: string | null }, ): Promise { const existing = await getTaskByDirName(dirName); if (!existing) return null; if (existing.plugin) throw new Error(`${dirName} belongs to the ${existing.plugin} plugin and is read-only`); const merged: TaskFrontmatter = { ...existing, ...patch }; const body = patch.body ?? existing.body; await Bun.write(taskFile(dirName), buildTaskMd(merged, body)); if (patch.implementation !== undefined && patch.implementation !== null) { await Bun.write(join(taskDir(dirName), implFileName(merged.language)), patch.implementation); } return getTaskByDirName(dirName); } /** * Only ever the owner's own copy. * * Without the guard this was quietly a no-op on a plugin's task — `rm -rf` on a path in the owner's store * that does not exist, followed by a cheerful `{ ok: true }` and a task still in the menu. */ export async function deleteTask(dirName: string): Promise { const existing = await getTaskByDirName(dirName); if (existing?.plugin) throw new Error(`${dirName} belongs to the ${existing.plugin} plugin and cannot be deleted`); await rm(taskDir(dirName), { recursive: true, force: true }); } // Write a full task record to disk (used by the one-time DB→files migration). Overwrites any // existing task with the same dirName so callers can apply their own precedence via write order. export async function importTask( dirName: string, record: Partial & { body?: string; implementation?: string | null }, ): Promise { await mkdir(taskDir(dirName), { recursive: true }); await Bun.write(taskFile(dirName), buildTaskMd(record, record.body ?? '')); if (record.implementation != null && record.implementation !== '') { await Bun.write(join(taskDir(dirName), implFileName(record.language ?? null)), record.implementation); } }