diff --git a/.claude/commands/create-agent.md b/.claude/commands/create-agent.md new file mode 100644 index 0000000..b8cb9d5 --- /dev/null +++ b/.claude/commands/create-agent.md @@ -0,0 +1,166 @@ +--- +description: Create a production-ready agentx agent from a natural language description. +--- + +# Create AgentX Agent + +You are an expert agentx agent builder. The user has described an agent they want to create. Your job is to generate a complete, production-ready agent directory with three files: `agent.yaml`, `system-prompt.md`, and `README.md`. + +## User Description + +$ARGUMENTS + +--- + +## Step 1: Analyze the Request + +From the description above, determine: +1. **Agent name** — lowercase, hyphenated (e.g., `jira-agent`, `weather-bot`) +2. **Purpose** — one-sentence summary +3. **Category** — one of: `productivity`, `devtools`, `communication`, `data`, `writing`, `research`, `automation`, `security`, `monitoring`, `other` +4. **Required services** — which external APIs/services does this agent need? +5. **MCP servers** — match services to the well-known MCP server table below +6. **Secrets** — what tokens/keys are needed? +7. **Config options** — what user-customizable settings make sense? + +If the `@author` handle is not obvious from context, ask the user before proceeding. + +--- + +## Step 2: Well-Known MCP Servers Reference + +Use this table to select the right MCP server packages. If the user's needs don't match any of these, omit `mcp_servers` and note it in the README. + +| Service | Package | Env Vars | allowed_tools | +|---------|---------|----------|---------------| +| GitHub | `@modelcontextprotocol/server-github` | `GITHUB_TOKEN` | `mcp__github__*` | +| Slack | `@modelcontextprotocol/server-slack` | `SLACK_BOT_TOKEN`, `SLACK_TEAM_ID` | `mcp__slack__*` | +| Filesystem | `@modelcontextprotocol/server-filesystem` | (pass dir as arg) | `mcp__filesystem__*` | +| Google Drive | `@modelcontextprotocol/server-gdrive` | `GDRIVE_CREDENTIALS` | `mcp__gdrive__*` | +| PostgreSQL | `@modelcontextprotocol/server-postgres` | `POSTGRES_URL` | `mcp__postgres__*` | +| Brave Search | `@modelcontextprotocol/server-brave-search` | `BRAVE_API_KEY` | `mcp__brave-search__*` | +| Memory | `@modelcontextprotocol/server-memory` | (none) | `mcp__memory__*` | +| Puppeteer | `@modelcontextprotocol/server-puppeteer` | (none) | `mcp__puppeteer__*` | +| Fetch | `@modelcontextprotocol/server-fetch` | (none) | `mcp__fetch__*` | +| Gmail | `@gongrzhe/server-gmail-mcp` | `GMAIL_TOKEN` | `mcp__gmail__*` | +| Sentry | `@modelcontextprotocol/server-sentry` | `SENTRY_AUTH_TOKEN` | `mcp__sentry__*` | +| Linear | `@modelcontextprotocol/server-linear` | `LINEAR_API_KEY` | `mcp__linear__*` | +| Notion | `@modelcontextprotocol/server-notion` | `NOTION_API_KEY` | `mcp__notion__*` | +| Everart | `@modelcontextprotocol/server-everart` | `EVERART_API_KEY` | `mcp__everart__*` | +| SQLite | `@modelcontextprotocol/server-sqlite` | (pass db path as arg) | `mcp__sqlite__*` | +| Jira | `@anthropic/mcp-server-atlassian` | `ATLASSIAN_API_TOKEN`, `ATLASSIAN_EMAIL`, `ATLASSIAN_SITE_URL` | `mcp__jira__*` | +| Confluence | `@anthropic/mcp-server-atlassian` | `ATLASSIAN_API_TOKEN`, `ATLASSIAN_EMAIL`, `ATLASSIAN_SITE_URL` | `mcp__confluence__*` | + +For packages not in this table, search the web for `"mcp server "` to find the correct npm package and env vars, or omit MCP servers and design the agent to use the Fetch MCP server or built-in tools instead. + +--- + +## Step 3: agent.yaml Schema Reference + +All fields and their constraints: + +```yaml +# REQUIRED fields +name: string # lowercase alphanumeric + hyphens, 1-100 chars +version: string # semver format, e.g., "1.0.0" +description: string # 1-500 chars, concise capability summary +author: string # must start with "@" +license: string # default: "MIT" + +# OPTIONAL fields +category: enum # productivity|devtools|communication|data|writing|research|automation|security|monitoring|other +tags: string[] # max 10 tags +requires: + claude_cli: string # semver range, e.g., ">=1.0.0" + node: string # semver range, e.g., ">=18.0.0" + os: string[] # e.g., ["darwin", "linux"] + +mcp_servers: # record of server configs + : + command: string # e.g., "npx" + args: string[] # e.g., ["-y", "@modelcontextprotocol/server-github"] + env: # record of string -> string + KEY: "${secrets.SECRET_NAME}" + +secrets: # array of secret declarations + - name: string + description: string + required: boolean # default: true + +permissions: + filesystem: boolean + network: boolean + execute_commands: boolean + +allowed_tools: string[] # glob patterns, e.g., ["mcp__github__*"] + +config: # array of config options + - key: string + description: string + default: string + +examples: # array of example prompts + - prompt: string + description: string +``` + +--- + +## Step 4: Generate the Agent + +Create a new directory at `.//` and generate these 3 files: + +### File 1: `agent.yaml` + +Generate a complete manifest following the schema above. Requirements: +- Use version `1.0.0` +- Include 3-5 relevant tags +- Set `requires.claude_cli: ">=1.0.0"` and `requires.node: ">=18.0.0"` +- Map services to MCP servers from the lookup table +- Reference secrets using `${secrets.SECRET_NAME}` syntax in env values +- Set appropriate permissions (network: true if using APIs) +- Include `allowed_tools` globs for each MCP server +- Add 2-4 config options with sensible defaults +- Include 4-5 realistic example prompts with descriptions + +### File 2: `system-prompt.md` + +Generate a rich behavioral prompt (40-80 lines). Structure: +1. **Identity** — "You are [Agent Name], an AI assistant for [purpose] powered by Claude Code." +2. **Capabilities** — Bulleted list of what the agent can do (5-8 items) +3. **Guidelines** — 5-8 rules for behavior, referencing `{{config.*}}` variables +4. **Workflow sections** — 1-3 detailed workflow descriptions specific to the agent's domain (numbered steps, like "When creating a ticket: 1. Ask for... 2. Set...") +5. **Error Handling** — 3-5 error scenarios with recovery instructions referencing `agentx configure ` + +Do NOT write a stub. Write detailed, actionable instructions that result in high-quality agent behavior. + +### File 3: `README.md` + +Generate a complete README with: +1. **Title** — `# @agentx/` +2. **Description** — one-liner matching agent.yaml +3. **Installation** — `agentx install @agentx/` +4. **Setup** — step-by-step token/credential creation for each required service (with links to the service's settings pages where tokens are created) +5. **Usage** — 4-5 example commands using `agentx run "..."` +6. **Configuration** — markdown table of config keys, descriptions, and defaults +7. **Permissions** — list of required permissions +8. **License** — MIT + +--- + +## Quality Checklist + +Before finalizing, verify: +- [ ] `agent.yaml` is valid YAML that would pass the Zod schema validation +- [ ] Agent name is lowercase with hyphens only (regex: `^[a-z0-9-]+$`) +- [ ] Version is valid semver (`1.0.0`) +- [ ] Author starts with `@` +- [ ] Description is between 1-500 characters +- [ ] Category is one of the valid enum values +- [ ] Tags array has at most 10 items +- [ ] Secret names in `env` values match declared `secrets[].name` entries +- [ ] `system-prompt.md` is 40-80 lines with detailed behavioral instructions +- [ ] `system-prompt.md` references `{{config.*}}` variables that match `config[].key` in agent.yaml +- [ ] `README.md` includes setup steps specific to the services used +- [ ] Examples are realistic and cover the agent's main use cases +- [ ] No placeholder or TODO text remains in any file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..a721bdb --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,29 @@ +# agentx Development Guidelines + +Auto-generated from all feature plans. Last updated: 2026-02-07 + +## Active Technologies + +- TypeScript (strict mode), ESM, Node.js 18+ + Commander.js, croner (new), execa, Zod, chalk, @clack/prompts (002-agent-scheduling) + +## Project Structure + +```text +src/ +tests/ +``` + +## Commands + +npm test && npm run lint + +## Code Style + +TypeScript (strict mode), ESM, Node.js 18+: Follow standard conventions + +## Recent Changes + +- 002-agent-scheduling: Added TypeScript (strict mode), ESM, Node.js 18+ + Commander.js, croner (new), execa, Zod, chalk, @clack/prompts + + + diff --git a/package-lock.json b/package-lock.json index 1b61c14..2d16af1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4077,6 +4077,25 @@ "node": "^14.18.0 || >=16.10.0" } }, + "node_modules/croner": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/croner/-/croner-10.0.1.tgz", + "integrity": "sha512-ixNtAJndqh173VQ4KodSdJEI6nuioBWI0V1ITNKhZZsO0pEMoDxz539T4FTTbSZ/xIOSuDnzxLVRqBVSvPNE2g==", + "funding": [ + { + "type": "other", + "url": "https://paypal.me/hexagonpp" + }, + { + "type": "github", + "url": "https://github.com/sponsors/hexagon" + } + ], + "license": "MIT", + "engines": { + "node": ">=18.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -6931,12 +6950,13 @@ }, "packages/cli": { "name": "@knid/agentx", - "version": "0.1.7", + "version": "0.1.9", "license": "MIT", "dependencies": { "@clack/prompts": "^0.9.0", "chalk": "^5.4.0", "commander": "^12.0.0", + "croner": "^10.0.1", "execa": "^9.0.0", "marked": "^15.0.12", "marked-terminal": "^7.3.0", diff --git a/packages/agents/github-agent/agent.yaml b/packages/agents/github-agent/agent.yaml index 927a68a..76d1777 100644 --- a/packages/agents/github-agent/agent.yaml +++ b/packages/agents/github-agent/agent.yaml @@ -43,6 +43,11 @@ config: description: "Maximum number of results to display for list operations" default: "20" +schedule: + - name: "Daily PR summary" + cron: "0 8 * * 1-5" + prompt: "List all open pull requests that need review in the default repo — include title, author, and age" + examples: - prompt: "Show me open PRs in this repo that need review" description: List pull requests awaiting review diff --git a/packages/agents/slack-agent/agent.yaml b/packages/agents/slack-agent/agent.yaml index ce58f8f..685d0b9 100644 --- a/packages/agents/slack-agent/agent.yaml +++ b/packages/agents/slack-agent/agent.yaml @@ -47,6 +47,11 @@ config: description: "Maximum number of messages to return in search results" default: "20" +schedule: + - name: "Daily standup" + cron: "0 9 * * 1-5" + prompt: "Post the daily standup summary to the configured default channel — include any recent updates from team members" + examples: - prompt: "Send a message to #engineering saying the deploy is complete" description: Post a message to a channel diff --git a/packages/cli/package.json b/packages/cli/package.json index 1d24d03..1524d23 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@knid/agentx", - "version": "0.1.8", + "version": "0.1.9", "description": "The package manager for AI agents powered by Claude Code", "type": "module", "bin": { @@ -28,6 +28,7 @@ "@clack/prompts": "^0.9.0", "chalk": "^5.4.0", "commander": "^12.0.0", + "croner": "^10.0.1", "execa": "^9.0.0", "marked": "^15.0.12", "marked-terminal": "^7.3.0", diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index fe0fc9f..7e890b9 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -114,6 +114,12 @@ Examples: writeFileSync(join(targetDir, file), populated, 'utf-8'); } + // Scaffold Claude Code /create-agent skill into .claude/commands/ + const claudeCommandsDir = join(targetDir, '.claude', 'commands'); + mkdirSync(claudeCommandsDir, { recursive: true }); + const skillContent = loadTemplate(templatesDir, 'create-agent.md'); + writeFileSync(join(claudeCommandsDir, 'create-agent.md'), skillContent, 'utf-8'); + p.outro(`Agent scaffolded at ${colors.cyan(targetDir)}`); console.log(); console.log(` Next steps:`); @@ -121,6 +127,8 @@ Examples: console.log(` ${colors.dim('2.')} Edit system-prompt.md with your agent's instructions`); console.log(` ${colors.dim('3.')} agentx validate`); console.log(` ${colors.dim('4.')} agentx run . "test prompt"`); + console.log(); + console.log(` ${colors.dim('Tip:')} Use ${colors.cyan('/create-agent')} in Claude Code to generate a production-ready agent from a description.`); } catch (error) { if (error instanceof Error) { console.error(colors.error(`Error: ${error.message}`)); diff --git a/packages/cli/src/commands/schedule.ts b/packages/cli/src/commands/schedule.ts new file mode 100644 index 0000000..aff5c5d --- /dev/null +++ b/packages/cli/src/commands/schedule.ts @@ -0,0 +1,327 @@ +import { Command } from 'commander'; +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { Cron } from 'croner'; +import { AGENTS_DIR, SCHEDULER_STATE, SCHEDULER_PID, SCHEDULER_LOGS_DIR } from '../config/paths.js'; +import { loadScheduleState, saveScheduleState, addAgentToState, removeAgentFromState } from '../scheduler/state.js'; +import { isDaemonRunning, startDaemon, stopDaemon, signalDaemon } from '../scheduler/process.js'; +import { readLatestLog, readAllLogs } from '../scheduler/log-store.js'; +import { agentYamlSchema } from '../schemas/agent-yaml.js'; +import { hasSecrets } from '../secrets/store.js'; +import { colors } from '../ui/colors.js'; +import { parse as parseYaml } from 'yaml'; + +export const scheduleCommand = new Command('schedule') + .description('Manage agent schedules') + .addHelpText('after', ` +Examples: + $ agentx schedule start slack-agent + $ agentx schedule stop slack-agent + $ agentx schedule list + $ agentx schedule logs slack-agent + $ agentx schedule logs slack-agent --all + $ agentx schedule resume`); + +scheduleCommand + .command('start') + .description('Start an agent schedule') + .argument('', 'Agent name to schedule') + .addHelpText('after', ` +Examples: + $ agentx schedule start slack-agent`) + .action(async (agentName: string) => { + try { + // 1. Verify agent exists + const agentDir = join(AGENTS_DIR, agentName); + const manifestPath = join(agentDir, 'agent.yaml'); + if (!existsSync(manifestPath)) { + console.error(colors.error(`Error: Agent "${agentName}" is not installed.`)); + process.exit(1); + } + + // 2. Load and validate manifest + const raw = readFileSync(manifestPath, 'utf-8'); + const parsed = parseYaml(raw); + const manifest = agentYamlSchema.parse(parsed); + + // 3. Verify schedule block exists + if (!manifest.schedule || manifest.schedule.length === 0) { + console.error(colors.error(`Error: ${agentName} has no schedule block in agent.yaml`)); + process.exit(1); + } + + // 4. Verify secrets if needed + if (manifest.secrets && manifest.secrets.length > 0) { + const requiredSecrets = manifest.secrets.filter((s) => s.required); + if (requiredSecrets.length > 0) { + const configured = await hasSecrets(agentName); + if (!configured) { + const names = requiredSecrets.map((s) => s.name).join(', '); + console.error(colors.error(`Error: Missing required secrets for ${agentName}: ${names}`)); + console.error(`Run: agentx configure ${agentName}`); + process.exit(1); + } + } + } + + // 5. Write state + let state = await loadScheduleState(SCHEDULER_STATE); + state = addAgentToState(state, agentName, manifest.schedule); + + // Compute next run times + for (const sched of state.agents[agentName].schedules) { + try { + const cron = new Cron(sched.cron); + const next = cron.nextRun(); + if (next) { + sched.nextRunAt = next.toISOString(); + } + } catch { + // ignore cron error + } + } + + await saveScheduleState(state, SCHEDULER_STATE); + + // 6. Start or signal daemon + if (isDaemonRunning(SCHEDULER_PID)) { + signalDaemon('SIGHUP', SCHEDULER_PID); + } else { + startDaemon(SCHEDULER_PID, SCHEDULER_STATE, SCHEDULER_LOGS_DIR); + } + + // 7. Print confirmation + console.log(colors.success(`Schedule started for ${colors.bold(agentName)}`)); + for (const sched of state.agents[agentName].schedules) { + const nextStr = sched.nextRunAt + ? new Date(sched.nextRunAt).toLocaleString() + : 'unknown'; + console.log(` ${colors.cyan(sched.name)} ${colors.dim(sched.cron)} ${colors.dim(`(next: ${nextStr})`)}`); + } + } catch (error) { + if (error instanceof Error) { + console.error(colors.error(`Error: ${error.message}`)); + } + process.exit(1); + } + }); + +scheduleCommand + .command('stop') + .description('Stop an agent schedule') + .argument('', 'Agent name to stop') + .addHelpText('after', ` +Examples: + $ agentx schedule stop slack-agent`) + .action(async (agentName: string) => { + try { + let state = await loadScheduleState(SCHEDULER_STATE); + + if (!state.agents[agentName]) { + console.error(colors.error(`Error: ${agentName} has no active schedule`)); + console.error(`Run: agentx schedule list`); + process.exit(1); + } + + state = removeAgentFromState(state, agentName); + await saveScheduleState(state, SCHEDULER_STATE); + + if (Object.keys(state.agents).length === 0) { + stopDaemon(SCHEDULER_PID); + console.log(colors.success(`Schedule stopped for ${colors.bold(agentName)}`)); + console.log(colors.dim('Scheduler daemon shut down (no active schedules)')); + } else { + signalDaemon('SIGHUP', SCHEDULER_PID); + console.log(colors.success(`Schedule stopped for ${colors.bold(agentName)}`)); + } + } catch (error) { + if (error instanceof Error) { + console.error(colors.error(`Error: ${error.message}`)); + } + process.exit(1); + } + }); + +scheduleCommand + .command('list') + .description('List all active schedules') + .addHelpText('after', ` +Examples: + $ agentx schedule list`) + .action(async () => { + try { + const state = await loadScheduleState(SCHEDULER_STATE); + const agents = Object.values(state.agents); + + if (agents.length === 0) { + console.log('No active schedules.'); + console.log(colors.dim('Start one with: agentx schedule start ')); + return; + } + + // Print header + const header = [ + 'Agent'.padEnd(20), + 'Schedule'.padEnd(18), + 'Status'.padEnd(10), + 'Last Run'.padEnd(24), + 'Next Run', + ].join(''); + console.log(colors.bold(header)); + + for (const agent of agents) { + for (const sched of agent.schedules) { + const lastRun = sched.lastRunAt + ? new Date(sched.lastRunAt).toLocaleString() + : '-'; + const nextRun = sched.nextRunAt + ? new Date(sched.nextRunAt).toLocaleString() + : '-'; + const statusColor = sched.status === 'errored' ? colors.error : sched.status === 'running' ? colors.warn : colors.success; + + const row = [ + agent.agentName.padEnd(20), + sched.cron.padEnd(18), + statusColor(sched.status.padEnd(10)), + lastRun.padEnd(24), + nextRun, + ].join(''); + console.log(row); + } + } + } catch (error) { + if (error instanceof Error) { + console.error(colors.error(`Error: ${error.message}`)); + } + process.exit(1); + } + }); + +scheduleCommand + .command('logs') + .description('View execution logs for a scheduled agent') + .argument('', 'Agent name') + .option('--all', 'Show summary of all past runs') + .addHelpText('after', ` +Examples: + $ agentx schedule logs slack-agent + $ agentx schedule logs slack-agent --all`) + .action(async (agentName: string, options: { all?: boolean }) => { + try { + if (options.all) { + const logs = await readAllLogs(agentName, SCHEDULER_LOGS_DIR); + if (logs.length === 0) { + console.log(`No runs recorded for ${agentName}.`); + return; + } + + const header = [ + 'Time'.padEnd(24), + 'Schedule'.padEnd(18), + 'Status'.padEnd(10), + 'Duration', + ].join(''); + console.log(colors.bold(header)); + + for (const log of logs) { + const time = new Date(log.timestamp).toLocaleString(); + const statusColor = log.status === 'failure' ? colors.error : colors.success; + const dur = `${(log.duration / 1000).toFixed(1)}s`; + const row = [ + time.padEnd(24), + log.scheduleName.padEnd(18), + statusColor(log.status.padEnd(10)), + dur, + ].join(''); + console.log(row); + } + } else { + const log = await readLatestLog(agentName, SCHEDULER_LOGS_DIR); + if (!log) { + console.log(`No runs recorded for ${agentName}.`); + return; + } + + const statusColor = log.status === 'failure' ? colors.error : colors.success; + console.log(`Last run: ${new Date(log.timestamp).toLocaleString()} (${log.scheduleName})`); + console.log(`Status: ${statusColor(log.status)}`); + console.log(`Duration: ${(log.duration / 1000).toFixed(1)}s`); + console.log(`Prompt: ${log.prompt}`); + console.log(''); + if (log.output) { + console.log('Output:'); + console.log(` ${log.output.split('\n').join('\n ')}`); + } + if (log.status === 'failure' && log.error) { + console.log(''); + console.log(colors.error(`Error: ${log.error}`)); + } + if (log.stderr) { + console.log(colors.dim(`Stderr: ${log.stderr}`)); + } + } + } catch (error) { + if (error instanceof Error) { + console.error(colors.error(`Error: ${error.message}`)); + } + process.exit(1); + } + }); + +scheduleCommand + .command('resume') + .description('Resume all previously active schedules') + .addHelpText('after', ` +Examples: + $ agentx schedule resume`) + .action(async () => { + try { + const state = await loadScheduleState(SCHEDULER_STATE); + const agents = Object.values(state.agents); + + if (agents.length === 0) { + console.log('No schedules to resume.'); + console.log(colors.dim('Start one with: agentx schedule start ')); + return; + } + + // Re-compute next run times + for (const agent of agents) { + for (const sched of agent.schedules) { + try { + const cron = new Cron(sched.cron); + const next = cron.nextRun(); + if (next) { + sched.nextRunAt = next.toISOString(); + } + } catch { + // ignore + } + // Reset status for errored schedules + if (sched.status === 'errored') { + sched.status = 'active'; + } + } + } + + await saveScheduleState(state, SCHEDULER_STATE); + + if (isDaemonRunning(SCHEDULER_PID)) { + signalDaemon('SIGHUP', SCHEDULER_PID); + console.log(colors.success('Scheduler daemon reloaded.')); + } else { + startDaemon(SCHEDULER_PID, SCHEDULER_STATE, SCHEDULER_LOGS_DIR); + console.log(colors.success('Scheduler daemon started.')); + } + + console.log(`Resumed ${agents.length} agent(s):`); + for (const agent of agents) { + console.log(` ${colors.cyan(agent.agentName)} (${agent.schedules.length} schedule(s))`); + } + } catch (error) { + if (error instanceof Error) { + console.error(colors.error(`Error: ${error.message}`)); + } + process.exit(1); + } + }); diff --git a/packages/cli/src/commands/uninstall.ts b/packages/cli/src/commands/uninstall.ts index 96cb4a6..b888ffb 100644 --- a/packages/cli/src/commands/uninstall.ts +++ b/packages/cli/src/commands/uninstall.ts @@ -1,8 +1,10 @@ import { Command } from 'commander'; import { existsSync, rmSync } from 'node:fs'; import { join } from 'node:path'; -import { AGENTS_DIR } from '../config/paths.js'; +import { AGENTS_DIR, SCHEDULER_STATE, SCHEDULER_PID } from '../config/paths.js'; import { deleteSecrets } from '../secrets/store.js'; +import { loadScheduleState, removeAgentFromState, saveScheduleState } from '../scheduler/state.js'; +import { isDaemonRunning, signalDaemon, stopDaemon } from '../scheduler/process.js'; import { colors } from '../ui/colors.js'; export const uninstallCommand = new Command('uninstall') @@ -24,6 +26,23 @@ Examples: process.exit(1); } + // Stop schedule if active + try { + const state = await loadScheduleState(SCHEDULER_STATE); + if (state.agents[agentName]) { + const updated = removeAgentFromState(state, agentName); + await saveScheduleState(updated, SCHEDULER_STATE); + if (Object.keys(updated.agents).length === 0) { + stopDaemon(SCHEDULER_PID); + } else if (isDaemonRunning(SCHEDULER_PID)) { + signalDaemon('SIGHUP', SCHEDULER_PID); + } + console.log(colors.dim(`Stopped schedule for ${agentName}`)); + } + } catch { + // Scheduler may not be initialized, that's fine + } + // Remove agent directory rmSync(agentDir, { recursive: true, force: true }); diff --git a/packages/cli/src/config/paths.ts b/packages/cli/src/config/paths.ts index 6608689..b48c759 100644 --- a/packages/cli/src/config/paths.ts +++ b/packages/cli/src/config/paths.ts @@ -8,3 +8,7 @@ export const CONFIG_PATH = join(AGENTX_HOME, 'config.yaml'); export const AUTH_PATH = join(AGENTX_HOME, 'auth.json'); export const CACHE_DIR = join(AGENTX_HOME, 'cache'); export const LOGS_DIR = join(AGENTX_HOME, 'logs'); +export const SCHEDULER_DIR = join(AGENTX_HOME, 'scheduler'); +export const SCHEDULER_PID = join(SCHEDULER_DIR, 'scheduler.pid'); +export const SCHEDULER_STATE = join(SCHEDULER_DIR, 'state.json'); +export const SCHEDULER_LOGS_DIR = join(SCHEDULER_DIR, 'logs'); diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 7e2e38e..71b606a 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -20,6 +20,7 @@ import { infoCommand } from './commands/info.js'; import { searchCommand } from './commands/search.js'; import { trendingCommand } from './commands/trending.js'; import { configCommand } from './commands/config.js'; +import { scheduleCommand } from './commands/schedule.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -63,5 +64,6 @@ program.addCommand(infoCommand); program.addCommand(searchCommand); program.addCommand(trendingCommand); program.addCommand(configCommand); +program.addCommand(scheduleCommand); program.parse(); diff --git a/packages/cli/src/scheduler/daemon.ts b/packages/cli/src/scheduler/daemon.ts new file mode 100644 index 0000000..000b22f --- /dev/null +++ b/packages/cli/src/scheduler/daemon.ts @@ -0,0 +1,225 @@ +import { Cron } from 'croner'; +import { loadScheduleState, saveScheduleState } from './state.js'; +import { writeRunLog, rotateLogs } from './log-store.js'; +import type { SchedulerState, ScheduleRunState } from './state.js'; +import { writeFileSync, unlinkSync, existsSync } from 'node:fs'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); + +const statePath = process.env.AGENTX_SCHEDULER_STATE!; +const pidPath = process.env.AGENTX_SCHEDULER_PID!; +const logsDir = process.env.AGENTX_SCHEDULER_LOGS!; + +const activeJobs = new Map(); +const runningAgents = new Set(); + +const RETRY_DELAYS = [10_000, 30_000]; // 10s, 30s + +async function executeAgent( + agentName: string, + schedule: ScheduleRunState, + retryAttempt: number = 0, +): Promise { + const runKey = `${agentName}:${schedule.name}`; + + // Overlap prevention + if (runningAgents.has(runKey)) { + await writeRunLog({ + timestamp: new Date().toISOString(), + agentName, + scheduleName: schedule.name, + cron: schedule.cron, + prompt: schedule.prompt, + output: '', + stderr: '', + status: 'success', + duration: 0, + error: null, + retryAttempt, + skipped: true, + }, logsDir); + return; + } + + runningAgents.add(runKey); + const startTime = Date.now(); + + // Update state to running + const state = await loadScheduleState(statePath); + const agentState = state.agents[agentName]; + if (agentState) { + const schedState = agentState.schedules.find((s) => s.name === schedule.name); + if (schedState) { + schedState.status = 'running'; + await saveScheduleState(state, statePath); + } + } + + try { + const { stdout, stderr } = await execFileAsync('agentx', ['run', agentName, schedule.prompt], { + timeout: 300_000, // 5 minute timeout + env: { ...process.env }, + }); + + const duration = Date.now() - startTime; + + await writeRunLog({ + timestamp: new Date().toISOString(), + agentName, + scheduleName: schedule.name, + cron: schedule.cron, + prompt: schedule.prompt, + output: stdout, + stderr: stderr || '', + status: 'success', + duration, + error: null, + retryAttempt, + skipped: false, + }, logsDir); + + // Update state: success + const updatedState = await loadScheduleState(statePath); + const updAgent = updatedState.agents[agentName]; + if (updAgent) { + const sched = updAgent.schedules.find((s) => s.name === schedule.name); + if (sched) { + sched.status = 'active'; + sched.lastRunAt = new Date().toISOString(); + sched.lastRunStatus = 'success'; + sched.runCount += 1; + await saveScheduleState(updatedState, statePath); + } + } + + await rotateLogs(agentName, logsDir); + } catch (error) { + const duration = Date.now() - startTime; + const errMsg = error instanceof Error ? error.message : String(error); + const stderr = error && typeof error === 'object' && 'stderr' in error ? String((error as any).stderr) : ''; + + await writeRunLog({ + timestamp: new Date().toISOString(), + agentName, + scheduleName: schedule.name, + cron: schedule.cron, + prompt: schedule.prompt, + output: '', + stderr, + status: 'failure', + duration, + error: errMsg, + retryAttempt, + skipped: false, + }, logsDir); + + // Retry logic + if (retryAttempt < RETRY_DELAYS.length) { + runningAgents.delete(runKey); + const delay = RETRY_DELAYS[retryAttempt]; + await new Promise((resolve) => setTimeout(resolve, delay)); + return executeAgent(agentName, schedule, retryAttempt + 1); + } + + // All retries exhausted — mark as errored + const updatedState = await loadScheduleState(statePath); + const updAgent = updatedState.agents[agentName]; + if (updAgent) { + const sched = updAgent.schedules.find((s) => s.name === schedule.name); + if (sched) { + sched.status = 'errored'; + sched.lastRunAt = new Date().toISOString(); + sched.lastRunStatus = 'failure'; + sched.runCount += 1; + sched.errorCount += 1; + await saveScheduleState(updatedState, statePath); + } + } + + await rotateLogs(agentName, logsDir); + } finally { + runningAgents.delete(runKey); + } +} + +function reconcileJobs(state: SchedulerState): void { + // Stop all existing jobs + for (const [, jobs] of activeJobs) { + for (const job of jobs) { + job.stop(); + } + } + activeJobs.clear(); + + // Create new jobs from state + for (const [agentName, agentState] of Object.entries(state.agents)) { + const jobs: Cron[] = []; + for (const schedule of agentState.schedules) { + const job = new Cron(schedule.cron, () => { + executeAgent(agentName, schedule).catch((err) => { + console.error(`[scheduler] Error executing ${agentName}/${schedule.name}:`, err); + }); + }); + + // Update next run time in state + const nextRun = job.nextRun(); + if (nextRun) { + schedule.nextRunAt = nextRun.toISOString(); + } + + jobs.push(job); + } + activeJobs.set(agentName, jobs); + } + + // Save updated next run times + saveScheduleState(state, statePath).catch(() => {}); +} + +async function startup(): Promise { + // Write PID file + writeFileSync(pidPath, String(process.pid), { encoding: 'utf-8', mode: 0o600 }); + + // Load state and start cron jobs + const state = await loadScheduleState(statePath); + state.pid = process.pid; + state.startedAt = new Date().toISOString(); + await saveScheduleState(state, statePath); + + reconcileJobs(state); +} + +// Signal handlers +process.on('SIGHUP', async () => { + try { + const state = await loadScheduleState(statePath); + reconcileJobs(state); + } catch (err) { + console.error('[scheduler] Error handling SIGHUP:', err); + } +}); + +process.on('SIGTERM', () => { + // Stop all jobs + for (const [, jobs] of activeJobs) { + for (const job of jobs) { + job.stop(); + } + } + activeJobs.clear(); + + // Clean up PID file + if (existsSync(pidPath)) { + unlinkSync(pidPath); + } + + process.exit(0); +}); + +// Start +startup().catch((err) => { + console.error('[scheduler] Fatal startup error:', err); + process.exit(1); +}); diff --git a/packages/cli/src/scheduler/index.ts b/packages/cli/src/scheduler/index.ts new file mode 100644 index 0000000..a5ff02a --- /dev/null +++ b/packages/cli/src/scheduler/index.ts @@ -0,0 +1,3 @@ +export { loadScheduleState, saveScheduleState, addAgentToState, removeAgentFromState } from './state.js'; +export { writeRunLog, readLatestLog, readAllLogs, rotateLogs } from './log-store.js'; +export { isDaemonRunning, getDaemonPid, startDaemon, stopDaemon, signalDaemon } from './process.js'; diff --git a/packages/cli/src/scheduler/log-store.ts b/packages/cli/src/scheduler/log-store.ts new file mode 100644 index 0000000..ae42d19 --- /dev/null +++ b/packages/cli/src/scheduler/log-store.ts @@ -0,0 +1,73 @@ +import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, unlinkSync } from 'node:fs'; +import { join } from 'node:path'; + +export interface RunLog { + timestamp: string; + agentName: string; + scheduleName: string; + cron: string; + prompt: string; + output: string; + stderr: string; + status: 'success' | 'failure'; + duration: number; + error: string | null; + retryAttempt: number; + skipped: boolean; +} + +const MAX_LOG_FILES = 50; + +function getAgentLogDir(agentName: string, logsDir: string): string { + return join(logsDir, agentName); +} + +function timestampToFilename(timestamp: string): string { + return timestamp.replace(/:/g, '-') + '.json'; +} + +export async function writeRunLog(log: RunLog, logsDir: string): Promise { + const agentDir = getAgentLogDir(log.agentName, logsDir); + mkdirSync(agentDir, { recursive: true }); + const filename = timestampToFilename(log.timestamp); + const filePath = join(agentDir, filename); + writeFileSync(filePath, JSON.stringify(log, null, 2), 'utf-8'); +} + +export async function readLatestLog(agentName: string, logsDir: string): Promise { + const agentDir = getAgentLogDir(agentName, logsDir); + if (!existsSync(agentDir)) { + return null; + } + const files = readdirSync(agentDir).filter((f) => f.endsWith('.json')).sort(); + if (files.length === 0) { + return null; + } + const latest = files[files.length - 1]; + const raw = readFileSync(join(agentDir, latest), 'utf-8'); + return JSON.parse(raw) as RunLog; +} + +export async function readAllLogs(agentName: string, logsDir: string): Promise { + const agentDir = getAgentLogDir(agentName, logsDir); + if (!existsSync(agentDir)) { + return []; + } + const files = readdirSync(agentDir).filter((f) => f.endsWith('.json')).sort().reverse(); + return files.map((f) => JSON.parse(readFileSync(join(agentDir, f), 'utf-8')) as RunLog); +} + +export async function rotateLogs(agentName: string, logsDir: string): Promise { + const agentDir = getAgentLogDir(agentName, logsDir); + if (!existsSync(agentDir)) { + return; + } + const files = readdirSync(agentDir).filter((f) => f.endsWith('.json')).sort(); + if (files.length <= MAX_LOG_FILES) { + return; + } + const toDelete = files.slice(0, files.length - MAX_LOG_FILES); + for (const f of toDelete) { + unlinkSync(join(agentDir, f)); + } +} diff --git a/packages/cli/src/scheduler/process.ts b/packages/cli/src/scheduler/process.ts new file mode 100644 index 0000000..155bb46 --- /dev/null +++ b/packages/cli/src/scheduler/process.ts @@ -0,0 +1,119 @@ +import { readFileSync, writeFileSync, existsSync, unlinkSync, mkdirSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fork } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +export function isDaemonRunning(pidPath: string): boolean { + if (!existsSync(pidPath)) { + return false; + } + const pid = getDaemonPid(pidPath); + if (pid === null) { + return false; + } + try { + process.kill(pid, 0); + return true; + } catch { + // Process doesn't exist — stale PID file + unlinkSync(pidPath); + return false; + } +} + +export function getDaemonPid(pidPath: string): number | null { + if (!existsSync(pidPath)) { + return null; + } + const raw = readFileSync(pidPath, 'utf-8').trim(); + const pid = parseInt(raw, 10); + return isNaN(pid) ? null : pid; +} + +export function startDaemon(pidPath: string, statePath: string, logsDir: string): number { + // Check for stale daemon + if (existsSync(pidPath)) { + const pid = getDaemonPid(pidPath); + if (pid !== null) { + try { + process.kill(pid, 0); + // Already running + return pid; + } catch { + // Stale PID, clean up + unlinkSync(pidPath); + } + } + } + + const dir = dirname(pidPath); + mkdirSync(dir, { recursive: true }); + + // Resolve daemon script path — try multiple candidates for bundled vs source + const candidates = [ + join(__dirname, '..', 'scheduler', 'daemon.js'), // dist/scheduler/daemon.js from dist/index.js + join(__dirname, 'daemon.js'), // same directory + join(__dirname, '..', 'dist', 'scheduler', 'daemon.js'), // from src/ during dev + ]; + let daemonScript = candidates[0]; + for (const c of candidates) { + if (existsSync(c)) { + daemonScript = c; + break; + } + } + + const child = fork(daemonScript, [], { + detached: true, + stdio: 'ignore', + env: { + ...process.env, + AGENTX_SCHEDULER_STATE: statePath, + AGENTX_SCHEDULER_PID: pidPath, + AGENTX_SCHEDULER_LOGS: logsDir, + }, + }); + + const pid = child.pid!; + writeFileSync(pidPath, String(pid), { encoding: 'utf-8', mode: 0o600 }); + child.unref(); + + return pid; +} + +export function stopDaemon(pidPath: string): boolean { + const pid = getDaemonPid(pidPath); + if (pid === null) { + return false; + } + try { + process.kill(pid, 'SIGTERM'); + // Clean up PID file + if (existsSync(pidPath)) { + unlinkSync(pidPath); + } + return true; + } catch { + // Process already gone + if (existsSync(pidPath)) { + unlinkSync(pidPath); + } + return false; + } +} + +export function signalDaemon(signal: 'SIGHUP' | 'SIGTERM', pidPath: string): boolean { + const pid = getDaemonPid(pidPath); + if (pid === null) { + return false; + } + try { + process.kill(pid, signal); + return true; + } catch { + return false; + } +} diff --git a/packages/cli/src/scheduler/state.ts b/packages/cli/src/scheduler/state.ts new file mode 100644 index 0000000..c46c72a --- /dev/null +++ b/packages/cli/src/scheduler/state.ts @@ -0,0 +1,88 @@ +import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { tmpdir } from 'node:os'; + +export interface ScheduleRunState { + name: string; + cron: string; + prompt: string; + status: 'active' | 'running' | 'errored'; + lastRunAt: string | null; + lastRunStatus: 'success' | 'failure' | null; + nextRunAt: string | null; + runCount: number; + errorCount: number; +} + +export interface AgentScheduleState { + agentName: string; + schedules: ScheduleRunState[]; + registeredAt: string; +} + +export interface SchedulerState { + pid: number | null; + startedAt: string | null; + agents: Record; +} + +const EMPTY_STATE: SchedulerState = { + pid: null, + startedAt: null, + agents: {}, +}; + +export async function loadScheduleState(statePath: string): Promise { + if (!existsSync(statePath)) { + return { ...EMPTY_STATE, agents: {} }; + } + const raw = readFileSync(statePath, 'utf-8'); + return JSON.parse(raw) as SchedulerState; +} + +export async function saveScheduleState(state: SchedulerState, statePath: string): Promise { + const dir = dirname(statePath); + mkdirSync(dir, { recursive: true }); + // Atomic write: write to temp file then rename + const tmpPath = join(dir, `.state.${Date.now()}.tmp`); + writeFileSync(tmpPath, JSON.stringify(state, null, 2), { encoding: 'utf-8', mode: 0o600 }); + renameSync(tmpPath, statePath); +} + +export function addAgentToState( + state: SchedulerState, + agentName: string, + schedules: Array<{ name?: string; cron: string; prompt: string }>, +): SchedulerState { + const scheduleStates: ScheduleRunState[] = schedules.map((s) => ({ + name: s.name ?? s.cron, + cron: s.cron, + prompt: s.prompt, + status: 'active' as const, + lastRunAt: null, + lastRunStatus: null, + nextRunAt: null, + runCount: 0, + errorCount: 0, + })); + + return { + ...state, + agents: { + ...state.agents, + [agentName]: { + agentName, + schedules: scheduleStates, + registeredAt: new Date().toISOString(), + }, + }, + }; +} + +export function removeAgentFromState(state: SchedulerState, agentName: string): SchedulerState { + const { [agentName]: _, ...remainingAgents } = state.agents; + return { + ...state, + agents: remainingAgents, + }; +} diff --git a/packages/cli/src/schemas/agent-yaml.ts b/packages/cli/src/schemas/agent-yaml.ts index 41bc49a..155b33e 100644 --- a/packages/cli/src/schemas/agent-yaml.ts +++ b/packages/cli/src/schemas/agent-yaml.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { Cron } from 'croner'; /** * Valid categories for an agent manifest. @@ -75,6 +76,25 @@ const requiresSchema = z.object({ os: z.array(z.string()).optional(), }); +/** + * Zod schema for a schedule entry. + */ +const scheduleEntrySchema = z.object({ + name: z.string().optional(), + cron: z.string().refine( + (val) => { + try { + new Cron(val); + return true; + } catch { + return false; + } + }, + { message: 'Invalid cron expression' }, + ), + prompt: z.string().min(1).max(2000), +}); + /** * Zod schema for validating an agent.yaml manifest file. * @@ -106,6 +126,7 @@ export const agentYamlSchema = z.object({ allowed_tools: z.array(z.string()).optional(), config: z.array(configOptionSchema).optional(), examples: z.array(exampleSchema).optional(), + schedule: z.array(scheduleEntrySchema).max(10).optional(), }); /** Inferred TypeScript type from the agent.yaml Zod schema. */ diff --git a/packages/cli/src/templates/basic/create-agent.md b/packages/cli/src/templates/basic/create-agent.md new file mode 100644 index 0000000..b8cb9d5 --- /dev/null +++ b/packages/cli/src/templates/basic/create-agent.md @@ -0,0 +1,166 @@ +--- +description: Create a production-ready agentx agent from a natural language description. +--- + +# Create AgentX Agent + +You are an expert agentx agent builder. The user has described an agent they want to create. Your job is to generate a complete, production-ready agent directory with three files: `agent.yaml`, `system-prompt.md`, and `README.md`. + +## User Description + +$ARGUMENTS + +--- + +## Step 1: Analyze the Request + +From the description above, determine: +1. **Agent name** — lowercase, hyphenated (e.g., `jira-agent`, `weather-bot`) +2. **Purpose** — one-sentence summary +3. **Category** — one of: `productivity`, `devtools`, `communication`, `data`, `writing`, `research`, `automation`, `security`, `monitoring`, `other` +4. **Required services** — which external APIs/services does this agent need? +5. **MCP servers** — match services to the well-known MCP server table below +6. **Secrets** — what tokens/keys are needed? +7. **Config options** — what user-customizable settings make sense? + +If the `@author` handle is not obvious from context, ask the user before proceeding. + +--- + +## Step 2: Well-Known MCP Servers Reference + +Use this table to select the right MCP server packages. If the user's needs don't match any of these, omit `mcp_servers` and note it in the README. + +| Service | Package | Env Vars | allowed_tools | +|---------|---------|----------|---------------| +| GitHub | `@modelcontextprotocol/server-github` | `GITHUB_TOKEN` | `mcp__github__*` | +| Slack | `@modelcontextprotocol/server-slack` | `SLACK_BOT_TOKEN`, `SLACK_TEAM_ID` | `mcp__slack__*` | +| Filesystem | `@modelcontextprotocol/server-filesystem` | (pass dir as arg) | `mcp__filesystem__*` | +| Google Drive | `@modelcontextprotocol/server-gdrive` | `GDRIVE_CREDENTIALS` | `mcp__gdrive__*` | +| PostgreSQL | `@modelcontextprotocol/server-postgres` | `POSTGRES_URL` | `mcp__postgres__*` | +| Brave Search | `@modelcontextprotocol/server-brave-search` | `BRAVE_API_KEY` | `mcp__brave-search__*` | +| Memory | `@modelcontextprotocol/server-memory` | (none) | `mcp__memory__*` | +| Puppeteer | `@modelcontextprotocol/server-puppeteer` | (none) | `mcp__puppeteer__*` | +| Fetch | `@modelcontextprotocol/server-fetch` | (none) | `mcp__fetch__*` | +| Gmail | `@gongrzhe/server-gmail-mcp` | `GMAIL_TOKEN` | `mcp__gmail__*` | +| Sentry | `@modelcontextprotocol/server-sentry` | `SENTRY_AUTH_TOKEN` | `mcp__sentry__*` | +| Linear | `@modelcontextprotocol/server-linear` | `LINEAR_API_KEY` | `mcp__linear__*` | +| Notion | `@modelcontextprotocol/server-notion` | `NOTION_API_KEY` | `mcp__notion__*` | +| Everart | `@modelcontextprotocol/server-everart` | `EVERART_API_KEY` | `mcp__everart__*` | +| SQLite | `@modelcontextprotocol/server-sqlite` | (pass db path as arg) | `mcp__sqlite__*` | +| Jira | `@anthropic/mcp-server-atlassian` | `ATLASSIAN_API_TOKEN`, `ATLASSIAN_EMAIL`, `ATLASSIAN_SITE_URL` | `mcp__jira__*` | +| Confluence | `@anthropic/mcp-server-atlassian` | `ATLASSIAN_API_TOKEN`, `ATLASSIAN_EMAIL`, `ATLASSIAN_SITE_URL` | `mcp__confluence__*` | + +For packages not in this table, search the web for `"mcp server "` to find the correct npm package and env vars, or omit MCP servers and design the agent to use the Fetch MCP server or built-in tools instead. + +--- + +## Step 3: agent.yaml Schema Reference + +All fields and their constraints: + +```yaml +# REQUIRED fields +name: string # lowercase alphanumeric + hyphens, 1-100 chars +version: string # semver format, e.g., "1.0.0" +description: string # 1-500 chars, concise capability summary +author: string # must start with "@" +license: string # default: "MIT" + +# OPTIONAL fields +category: enum # productivity|devtools|communication|data|writing|research|automation|security|monitoring|other +tags: string[] # max 10 tags +requires: + claude_cli: string # semver range, e.g., ">=1.0.0" + node: string # semver range, e.g., ">=18.0.0" + os: string[] # e.g., ["darwin", "linux"] + +mcp_servers: # record of server configs + : + command: string # e.g., "npx" + args: string[] # e.g., ["-y", "@modelcontextprotocol/server-github"] + env: # record of string -> string + KEY: "${secrets.SECRET_NAME}" + +secrets: # array of secret declarations + - name: string + description: string + required: boolean # default: true + +permissions: + filesystem: boolean + network: boolean + execute_commands: boolean + +allowed_tools: string[] # glob patterns, e.g., ["mcp__github__*"] + +config: # array of config options + - key: string + description: string + default: string + +examples: # array of example prompts + - prompt: string + description: string +``` + +--- + +## Step 4: Generate the Agent + +Create a new directory at `.//` and generate these 3 files: + +### File 1: `agent.yaml` + +Generate a complete manifest following the schema above. Requirements: +- Use version `1.0.0` +- Include 3-5 relevant tags +- Set `requires.claude_cli: ">=1.0.0"` and `requires.node: ">=18.0.0"` +- Map services to MCP servers from the lookup table +- Reference secrets using `${secrets.SECRET_NAME}` syntax in env values +- Set appropriate permissions (network: true if using APIs) +- Include `allowed_tools` globs for each MCP server +- Add 2-4 config options with sensible defaults +- Include 4-5 realistic example prompts with descriptions + +### File 2: `system-prompt.md` + +Generate a rich behavioral prompt (40-80 lines). Structure: +1. **Identity** — "You are [Agent Name], an AI assistant for [purpose] powered by Claude Code." +2. **Capabilities** — Bulleted list of what the agent can do (5-8 items) +3. **Guidelines** — 5-8 rules for behavior, referencing `{{config.*}}` variables +4. **Workflow sections** — 1-3 detailed workflow descriptions specific to the agent's domain (numbered steps, like "When creating a ticket: 1. Ask for... 2. Set...") +5. **Error Handling** — 3-5 error scenarios with recovery instructions referencing `agentx configure ` + +Do NOT write a stub. Write detailed, actionable instructions that result in high-quality agent behavior. + +### File 3: `README.md` + +Generate a complete README with: +1. **Title** — `# @agentx/` +2. **Description** — one-liner matching agent.yaml +3. **Installation** — `agentx install @agentx/` +4. **Setup** — step-by-step token/credential creation for each required service (with links to the service's settings pages where tokens are created) +5. **Usage** — 4-5 example commands using `agentx run "..."` +6. **Configuration** — markdown table of config keys, descriptions, and defaults +7. **Permissions** — list of required permissions +8. **License** — MIT + +--- + +## Quality Checklist + +Before finalizing, verify: +- [ ] `agent.yaml` is valid YAML that would pass the Zod schema validation +- [ ] Agent name is lowercase with hyphens only (regex: `^[a-z0-9-]+$`) +- [ ] Version is valid semver (`1.0.0`) +- [ ] Author starts with `@` +- [ ] Description is between 1-500 characters +- [ ] Category is one of the valid enum values +- [ ] Tags array has at most 10 items +- [ ] Secret names in `env` values match declared `secrets[].name` entries +- [ ] `system-prompt.md` is 40-80 lines with detailed behavioral instructions +- [ ] `system-prompt.md` references `{{config.*}}` variables that match `config[].key` in agent.yaml +- [ ] `README.md` includes setup steps specific to the services used +- [ ] Examples are realistic and cover the agent's main use cases +- [ ] No placeholder or TODO text remains in any file diff --git a/packages/cli/src/types/agent.ts b/packages/cli/src/types/agent.ts index bbd6ead..7e19c45 100644 --- a/packages/cli/src/types/agent.ts +++ b/packages/cli/src/types/agent.ts @@ -61,6 +61,7 @@ export interface AgentManifest { mcp_servers?: Record; secrets?: SecretDeclaration[]; permissions?: Permission; + allowed_tools?: string[]; config?: ConfigOption[]; examples?: AgentExample[]; } diff --git a/packages/cli/test/commands/schedule.test.ts b/packages/cli/test/commands/schedule.test.ts new file mode 100644 index 0000000..5faf210 --- /dev/null +++ b/packages/cli/test/commands/schedule.test.ts @@ -0,0 +1,218 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { mkdirSync, rmSync, writeFileSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { parse as parseYaml } from 'yaml'; + +describe('schedule commands', () => { + let testDir: string; + let agentsDir: string; + let schedulerDir: string; + let statePath: string; + let pidPath: string; + let logsDir: string; + + beforeEach(() => { + testDir = join(tmpdir(), `agentx-schedule-cmd-test-${Date.now()}`); + agentsDir = join(testDir, 'agents'); + schedulerDir = join(testDir, 'scheduler'); + statePath = join(schedulerDir, 'state.json'); + pidPath = join(schedulerDir, 'scheduler.pid'); + logsDir = join(schedulerDir, 'logs'); + mkdirSync(agentsDir, { recursive: true }); + mkdirSync(schedulerDir, { recursive: true }); + mkdirSync(logsDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(testDir, { recursive: true, force: true }); + }); + + function createTestAgent(name: string, opts: { schedule?: boolean; secrets?: boolean } = {}) { + const agentDir = join(agentsDir, name); + mkdirSync(agentDir, { recursive: true }); + const manifest: Record = { + name, + version: '1.0.0', + description: `Test agent ${name}`, + author: '@test', + }; + if (opts.schedule !== false) { + manifest.schedule = [ + { name: 'Daily task', cron: '0 9 * * *', prompt: 'Do the thing' }, + ]; + } + if (opts.secrets) { + manifest.secrets = [{ name: 'API_KEY', required: true }]; + } + writeFileSync(join(agentDir, 'agent.yaml'), Object.entries(manifest).map(([k, v]) => { + if (typeof v === 'string') return `${k}: "${v}"`; + if (Array.isArray(v)) return `${k}:\n${v.map(item => { + const lines = Object.entries(item as Record).map(([ik, iv]) => ` ${ik}: ${typeof iv === 'string' ? `"${iv}"` : iv}`); + return ` - ${lines.join('\n')}`.replace(' - ', ' - '); + }).join('\n')}`; + return `${k}: ${v}`; + }).join('\n'), 'utf-8'); + // Also write a proper YAML file + const { stringify } = require('yaml'); + writeFileSync(join(agentDir, 'agent.yaml'), stringify(manifest), 'utf-8'); + } + + describe('schedule start', () => { + it('should add agent to state when starting', async () => { + createTestAgent('test-agent'); + const { loadScheduleState, addAgentToState, saveScheduleState } = await import('../../src/scheduler/state.js'); + const { agentYamlSchema } = await import('../../src/schemas/agent-yaml.js'); + + // Simulate what the start command does + const manifestRaw = require('yaml').parse( + require('fs').readFileSync(join(agentsDir, 'test-agent', 'agent.yaml'), 'utf-8') + ); + const manifest = agentYamlSchema.parse(manifestRaw); + expect(manifest.schedule).toBeDefined(); + expect(manifest.schedule).toHaveLength(1); + + let state = await loadScheduleState(statePath); + state = addAgentToState(state, 'test-agent', manifest.schedule!); + await saveScheduleState(state, statePath); + + const loaded = await loadScheduleState(statePath); + expect(loaded.agents['test-agent']).toBeDefined(); + expect(loaded.agents['test-agent'].schedules).toHaveLength(1); + expect(loaded.agents['test-agent'].schedules[0].name).toBe('Daily task'); + }); + + it('should reject agent without schedule block', async () => { + createTestAgent('no-sched-agent', { schedule: false }); + const { agentYamlSchema } = await import('../../src/schemas/agent-yaml.js'); + const manifestRaw = require('yaml').parse( + require('fs').readFileSync(join(agentsDir, 'no-sched-agent', 'agent.yaml'), 'utf-8') + ); + const manifest = agentYamlSchema.parse(manifestRaw); + expect(manifest.schedule).toBeUndefined(); + }); + }); + + describe('schedule stop', () => { + it('should remove agent from state', async () => { + const { loadScheduleState, addAgentToState, removeAgentFromState, saveScheduleState } = await import('../../src/scheduler/state.js'); + let state = { pid: 1234, startedAt: new Date().toISOString(), agents: {} as Record }; + state = addAgentToState(state, 'test-agent', [{ name: 'Test', cron: '0 9 * * *', prompt: 'test' }]); + await saveScheduleState(state, statePath); + + let loaded = await loadScheduleState(statePath); + expect(loaded.agents['test-agent']).toBeDefined(); + + const updated = removeAgentFromState(loaded, 'test-agent'); + await saveScheduleState(updated, statePath); + + loaded = await loadScheduleState(statePath); + expect(loaded.agents['test-agent']).toBeUndefined(); + }); + + it('should error when agent has no active schedule', async () => { + const { loadScheduleState } = await import('../../src/scheduler/state.js'); + const state = await loadScheduleState(statePath); + expect(state.agents['nonexistent-agent']).toBeUndefined(); + }); + + it('should detect when no schedules remain after stop', async () => { + const { addAgentToState, removeAgentFromState } = await import('../../src/scheduler/state.js'); + let state = { pid: 1234, startedAt: new Date().toISOString(), agents: {} as Record }; + state = addAgentToState(state, 'only-agent', [{ name: 'Test', cron: '0 9 * * *', prompt: 'test' }]); + const updated = removeAgentFromState(state, 'only-agent'); + expect(Object.keys(updated.agents)).toHaveLength(0); + }); + }); + + describe('schedule list', () => { + it('should return empty agents for empty state', async () => { + const { loadScheduleState } = await import('../../src/scheduler/state.js'); + const state = await loadScheduleState(statePath); + expect(Object.keys(state.agents)).toHaveLength(0); + }); + + it('should return all active agents with schedules', async () => { + const { addAgentToState, saveScheduleState, loadScheduleState } = await import('../../src/scheduler/state.js'); + let state = { pid: 1, startedAt: new Date().toISOString(), agents: {} as Record }; + state = addAgentToState(state, 'agent-a', [{ name: 'Task A', cron: '0 9 * * *', prompt: 'a' }]); + state = addAgentToState(state, 'agent-b', [{ name: 'Task B', cron: '0 17 * * *', prompt: 'b' }]); + await saveScheduleState(state, statePath); + + const loaded = await loadScheduleState(statePath); + expect(Object.keys(loaded.agents)).toHaveLength(2); + }); + }); + + describe('schedule logs', () => { + it('should return latest log for an agent', async () => { + const { writeRunLog, readLatestLog } = await import('../../src/scheduler/log-store.js'); + await writeRunLog({ + timestamp: '2026-02-07T09:00:00Z', + agentName: 'test-agent', + scheduleName: 'Daily task', + cron: '0 9 * * *', + prompt: 'Do it', + output: 'Done!', + stderr: '', + status: 'success', + duration: 5000, + error: null, + retryAttempt: 0, + skipped: false, + }, logsDir); + + const latest = await readLatestLog('test-agent', logsDir); + expect(latest).not.toBeNull(); + expect(latest!.output).toBe('Done!'); + }); + + it('should return null when no logs exist for agent', async () => { + const { readLatestLog } = await import('../../src/scheduler/log-store.js'); + const latest = await readLatestLog('no-logs-agent', logsDir); + expect(latest).toBeNull(); + }); + + it('should display failed run with error', async () => { + const { writeRunLog, readLatestLog } = await import('../../src/scheduler/log-store.js'); + await writeRunLog({ + timestamp: '2026-02-07T09:00:00Z', + agentName: 'fail-agent', + scheduleName: 'Daily', + cron: '0 9 * * *', + prompt: 'Do it', + output: '', + stderr: 'Error: API key expired', + status: 'failure', + duration: 1000, + error: 'API key expired', + retryAttempt: 0, + skipped: false, + }, logsDir); + + const latest = await readLatestLog('fail-agent', logsDir); + expect(latest!.status).toBe('failure'); + expect(latest!.error).toBe('API key expired'); + }); + + it('should return all logs sorted newest first', async () => { + const { writeRunLog, readAllLogs } = await import('../../src/scheduler/log-store.js'); + const base = { + agentName: 'test-agent', + scheduleName: 'Daily', + cron: '0 9 * * *', + prompt: 'Do it', + stderr: '', + error: null, + retryAttempt: 0, + skipped: false, + }; + await writeRunLog({ ...base, timestamp: '2026-02-05T09:00:00Z', output: 'old', status: 'success' as const, duration: 100 }, logsDir); + await writeRunLog({ ...base, timestamp: '2026-02-07T09:00:00Z', output: 'new', status: 'success' as const, duration: 300 }, logsDir); + + const all = await readAllLogs('test-agent', logsDir); + expect(all).toHaveLength(2); + expect(all[0].output).toBe('new'); + }); + }); +}); diff --git a/packages/cli/test/scheduler/log-store.test.ts b/packages/cli/test/scheduler/log-store.test.ts new file mode 100644 index 0000000..11e5bed --- /dev/null +++ b/packages/cli/test/scheduler/log-store.test.ts @@ -0,0 +1,177 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdirSync, rmSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +describe('scheduler log-store', () => { + let testDir: string; + + beforeEach(() => { + testDir = join(tmpdir(), `agentx-log-test-${Date.now()}`); + mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(testDir, { recursive: true, force: true }); + }); + + describe('writeRunLog', () => { + it('should write a log file to agent log directory', async () => { + const { writeRunLog } = await import('../../src/scheduler/log-store.js'); + const log = { + timestamp: '2026-02-07T09:00:00Z', + agentName: 'test-agent', + scheduleName: 'Daily', + cron: '0 9 * * *', + prompt: 'Do it', + output: 'Done!', + stderr: '', + status: 'success' as const, + duration: 1234, + error: null, + retryAttempt: 0, + skipped: false, + }; + await writeRunLog(log, testDir); + const agentDir = join(testDir, 'test-agent'); + const files = readdirSync(agentDir); + expect(files).toHaveLength(1); + expect(files[0]).toMatch(/\.json$/); + }); + + it('should create agent log directory if it does not exist', async () => { + const { writeRunLog } = await import('../../src/scheduler/log-store.js'); + const log = { + timestamp: '2026-02-07T09:00:00Z', + agentName: 'new-agent', + scheduleName: 'Test', + cron: '0 9 * * *', + prompt: 'test', + output: '', + stderr: '', + status: 'success' as const, + duration: 100, + error: null, + retryAttempt: 0, + skipped: false, + }; + await writeRunLog(log, testDir); + const agentDir = join(testDir, 'new-agent'); + expect(readdirSync(agentDir)).toHaveLength(1); + }); + }); + + describe('readLatestLog', () => { + it('should return the most recent log file', async () => { + const { writeRunLog, readLatestLog } = await import('../../src/scheduler/log-store.js'); + const baselog = { + agentName: 'test-agent', + scheduleName: 'Daily', + cron: '0 9 * * *', + prompt: 'Do it', + stderr: '', + error: null, + retryAttempt: 0, + skipped: false, + }; + await writeRunLog({ ...baselog, timestamp: '2026-02-05T09:00:00Z', output: 'First', status: 'success' as const, duration: 100 }, testDir); + await writeRunLog({ ...baselog, timestamp: '2026-02-06T09:00:00Z', output: 'Second', status: 'success' as const, duration: 200 }, testDir); + await writeRunLog({ ...baselog, timestamp: '2026-02-07T09:00:00Z', output: 'Third', status: 'failure' as const, duration: 300 }, testDir); + + const latest = await readLatestLog('test-agent', testDir); + expect(latest).not.toBeNull(); + expect(latest!.output).toBe('Third'); + expect(latest!.status).toBe('failure'); + }); + + it('should return null when no logs exist', async () => { + const { readLatestLog } = await import('../../src/scheduler/log-store.js'); + const result = await readLatestLog('nonexistent-agent', testDir); + expect(result).toBeNull(); + }); + }); + + describe('readAllLogs', () => { + it('should return all logs sorted newest first', async () => { + const { writeRunLog, readAllLogs } = await import('../../src/scheduler/log-store.js'); + const baselog = { + agentName: 'test-agent', + scheduleName: 'Daily', + cron: '0 9 * * *', + prompt: 'Do it', + stderr: '', + error: null, + retryAttempt: 0, + skipped: false, + }; + await writeRunLog({ ...baselog, timestamp: '2026-02-05T09:00:00Z', output: 'A', status: 'success' as const, duration: 100 }, testDir); + await writeRunLog({ ...baselog, timestamp: '2026-02-07T09:00:00Z', output: 'C', status: 'success' as const, duration: 300 }, testDir); + await writeRunLog({ ...baselog, timestamp: '2026-02-06T09:00:00Z', output: 'B', status: 'success' as const, duration: 200 }, testDir); + + const all = await readAllLogs('test-agent', testDir); + expect(all).toHaveLength(3); + expect(all[0].output).toBe('C'); + expect(all[1].output).toBe('B'); + expect(all[2].output).toBe('A'); + }); + + it('should return empty array when no logs exist', async () => { + const { readAllLogs } = await import('../../src/scheduler/log-store.js'); + const result = await readAllLogs('nonexistent-agent', testDir); + expect(result).toEqual([]); + }); + }); + + describe('rotateLogs', () => { + it('should keep only the last 50 log files', async () => { + const { writeRunLog, rotateLogs } = await import('../../src/scheduler/log-store.js'); + const baselog = { + agentName: 'test-agent', + scheduleName: 'Daily', + cron: '0 9 * * *', + prompt: 'Do it', + output: 'ok', + stderr: '', + status: 'success' as const, + duration: 100, + error: null, + retryAttempt: 0, + skipped: false, + }; + + // Write 55 log files + for (let i = 0; i < 55; i++) { + const d = new Date(2026, 0, 1, 0, i, 0); + await writeRunLog({ ...baselog, timestamp: d.toISOString() }, testDir); + } + + const agentDir = join(testDir, 'test-agent'); + expect(readdirSync(agentDir).length).toBe(55); + + await rotateLogs('test-agent', testDir); + expect(readdirSync(agentDir).length).toBe(50); + }); + + it('should not delete files when under limit', async () => { + const { writeRunLog, rotateLogs } = await import('../../src/scheduler/log-store.js'); + await writeRunLog({ + timestamp: '2026-02-07T09:00:00Z', + agentName: 'test-agent', + scheduleName: 'Daily', + cron: '0 9 * * *', + prompt: 'Do it', + output: 'ok', + stderr: '', + status: 'success' as const, + duration: 100, + error: null, + retryAttempt: 0, + skipped: false, + }, testDir); + + await rotateLogs('test-agent', testDir); + const agentDir = join(testDir, 'test-agent'); + expect(readdirSync(agentDir).length).toBe(1); + }); + }); +}); diff --git a/packages/cli/test/scheduler/process.test.ts b/packages/cli/test/scheduler/process.test.ts new file mode 100644 index 0000000..8c08aad --- /dev/null +++ b/packages/cli/test/scheduler/process.test.ts @@ -0,0 +1,183 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdirSync, rmSync, writeFileSync, existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +describe('scheduler process', () => { + let testDir: string; + let pidPath: string; + + beforeEach(() => { + testDir = join(tmpdir(), `agentx-proc-test-${Date.now()}`); + mkdirSync(testDir, { recursive: true }); + pidPath = join(testDir, 'scheduler.pid'); + }); + + afterEach(() => { + rmSync(testDir, { recursive: true, force: true }); + }); + + describe('isDaemonRunning', () => { + it('should return false when PID file does not exist', async () => { + const { isDaemonRunning } = await import('../../src/scheduler/process.js'); + const result = isDaemonRunning(pidPath); + expect(result).toBe(false); + }); + + it('should return false for stale PID file (process not running)', async () => { + const { isDaemonRunning } = await import('../../src/scheduler/process.js'); + // Write a PID that almost certainly doesn't exist + writeFileSync(pidPath, '999999999', 'utf-8'); + const result = isDaemonRunning(pidPath); + expect(result).toBe(false); + }); + + it('should clean up stale PID file', async () => { + const { isDaemonRunning } = await import('../../src/scheduler/process.js'); + writeFileSync(pidPath, '999999999', 'utf-8'); + isDaemonRunning(pidPath); + expect(existsSync(pidPath)).toBe(false); + }); + + it('should return true for current process PID (running process)', async () => { + const { isDaemonRunning } = await import('../../src/scheduler/process.js'); + writeFileSync(pidPath, String(process.pid), 'utf-8'); + const result = isDaemonRunning(pidPath); + expect(result).toBe(true); + }); + }); + + describe('getDaemonPid', () => { + it('should return null when PID file does not exist', async () => { + const { getDaemonPid } = await import('../../src/scheduler/process.js'); + const pid = getDaemonPid(pidPath); + expect(pid).toBeNull(); + }); + + it('should return pid when PID file exists', async () => { + const { getDaemonPid } = await import('../../src/scheduler/process.js'); + writeFileSync(pidPath, '12345', 'utf-8'); + const pid = getDaemonPid(pidPath); + expect(pid).toBe(12345); + }); + }); + + describe('signalDaemon', () => { + it('should return false when PID file does not exist', async () => { + const { signalDaemon } = await import('../../src/scheduler/process.js'); + const result = signalDaemon('SIGHUP', pidPath); + expect(result).toBe(false); + }); + }); + + describe('stale daemon detection (T032)', () => { + it('should detect stale PID and clean up on isDaemonRunning', async () => { + const { isDaemonRunning } = await import('../../src/scheduler/process.js'); + // Write a PID for a definitely-dead process + writeFileSync(pidPath, '999999999', 'utf-8'); + expect(existsSync(pidPath)).toBe(true); + const running = isDaemonRunning(pidPath); + expect(running).toBe(false); + expect(existsSync(pidPath)).toBe(false); + }); + + it('should stop daemon even if process already gone', async () => { + const { stopDaemon } = await import('../../src/scheduler/process.js'); + writeFileSync(pidPath, '999999999', 'utf-8'); + const result = stopDaemon(pidPath); + // Process doesn't exist, but PID file should be cleaned up + expect(existsSync(pidPath)).toBe(false); + }); + }); + + describe('retry logic verification via log-store (T030)', () => { + it('should write log with retryAttempt field', async () => { + const { writeRunLog, readLatestLog } = await import('../../src/scheduler/log-store.js'); + const logsDir = join(testDir, 'logs'); + mkdirSync(logsDir, { recursive: true }); + + // Simulate a retry attempt log entry (as daemon would write) + await writeRunLog({ + timestamp: '2026-02-07T09:00:10Z', + agentName: 'retry-agent', + scheduleName: 'Daily', + cron: '0 9 * * *', + prompt: 'Do it', + output: '', + stderr: 'error on attempt', + status: 'failure', + duration: 1000, + error: 'API timeout', + retryAttempt: 1, + skipped: false, + }, logsDir); + + const log = await readLatestLog('retry-agent', logsDir); + expect(log).not.toBeNull(); + expect(log!.retryAttempt).toBe(1); + expect(log!.status).toBe('failure'); + }); + + it('should write log with final success after retry', async () => { + const { writeRunLog, readAllLogs } = await import('../../src/scheduler/log-store.js'); + const logsDir = join(testDir, 'logs'); + mkdirSync(logsDir, { recursive: true }); + + // First attempt fails + await writeRunLog({ + timestamp: '2026-02-07T09:00:00Z', + agentName: 'retry-agent', + scheduleName: 'Daily', + cron: '0 9 * * *', + prompt: 'Do it', + output: '', + stderr: 'err', + status: 'failure', + duration: 500, + error: 'timeout', + retryAttempt: 0, + skipped: false, + }, logsDir); + + // Second attempt succeeds + await writeRunLog({ + timestamp: '2026-02-07T09:00:15Z', + agentName: 'retry-agent', + scheduleName: 'Daily', + cron: '0 9 * * *', + prompt: 'Do it', + output: 'Done!', + stderr: '', + status: 'success', + duration: 2000, + error: null, + retryAttempt: 1, + skipped: false, + }, logsDir); + + const all = await readAllLogs('retry-agent', logsDir); + expect(all).toHaveLength(2); + expect(all[0].retryAttempt).toBe(1); + expect(all[0].status).toBe('success'); + expect(all[1].retryAttempt).toBe(0); + expect(all[1].status).toBe('failure'); + }); + + it('should track errorCount in state after exhausted retries', async () => { + const { addAgentToState } = await import('../../src/scheduler/state.js'); + let state = { pid: 1, startedAt: '2026-02-07T00:00:00Z', agents: {} as Record }; + state = addAgentToState(state, 'err-agent', [{ name: 'Task', cron: '0 9 * * *', prompt: 'test' }]); + + // Simulate what daemon does after all retries exhausted + const sched = state.agents['err-agent'].schedules[0]; + sched.status = 'errored'; + sched.lastRunAt = new Date().toISOString(); + sched.lastRunStatus = 'failure'; + sched.runCount += 1; + sched.errorCount += 1; + + expect(sched.status).toBe('errored'); + expect(sched.errorCount).toBe(1); + }); + }); +}); diff --git a/packages/cli/test/scheduler/state.test.ts b/packages/cli/test/scheduler/state.test.ts new file mode 100644 index 0000000..14b3ee7 --- /dev/null +++ b/packages/cli/test/scheduler/state.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdirSync, rmSync, existsSync, readFileSync, statSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +describe('scheduler state', () => { + let testDir: string; + let statePath: string; + + beforeEach(() => { + testDir = join(tmpdir(), `agentx-state-test-${Date.now()}`); + mkdirSync(testDir, { recursive: true }); + statePath = join(testDir, 'state.json'); + }); + + afterEach(() => { + rmSync(testDir, { recursive: true, force: true }); + }); + + describe('loadScheduleState', () => { + it('should return empty state when file does not exist', async () => { + const { loadScheduleState } = await import('../../src/scheduler/state.js'); + const state = await loadScheduleState(statePath); + expect(state).toEqual({ pid: null, startedAt: null, agents: {} }); + }); + + it('should load existing state from file', async () => { + const { loadScheduleState, saveScheduleState } = await import('../../src/scheduler/state.js'); + const state = { + pid: 1234, + startedAt: '2026-02-07T09:00:00Z', + agents: { + 'slack-agent': { + agentName: 'slack-agent', + schedules: [{ + name: 'Daily standup', + cron: '0 9 * * 1-5', + prompt: 'Post standup', + status: 'active' as const, + lastRunAt: null, + lastRunStatus: null, + nextRunAt: null, + runCount: 0, + errorCount: 0, + }], + registeredAt: '2026-02-07T09:00:00Z', + }, + }, + }; + await saveScheduleState(state, statePath); + const loaded = await loadScheduleState(statePath); + expect(loaded).toEqual(state); + }); + }); + + describe('saveScheduleState', () => { + it('should create state file with correct permissions', async () => { + const { saveScheduleState } = await import('../../src/scheduler/state.js'); + await saveScheduleState({ pid: 1, startedAt: null, agents: {} }, statePath); + expect(existsSync(statePath)).toBe(true); + const stats = statSync(statePath); + // 0o600 = owner read/write only + expect(stats.mode & 0o777).toBe(0o600); + }); + + it('should write valid JSON', async () => { + const { saveScheduleState } = await import('../../src/scheduler/state.js'); + const state = { pid: 42, startedAt: '2026-02-07T09:00:00Z', agents: {} }; + await saveScheduleState(state, statePath); + const raw = readFileSync(statePath, 'utf-8'); + expect(JSON.parse(raw)).toEqual(state); + }); + }); + + describe('addAgentToState', () => { + it('should add a new agent to empty state', async () => { + const { loadScheduleState, saveScheduleState, addAgentToState } = await import('../../src/scheduler/state.js'); + const state = { pid: 1, startedAt: '2026-02-07T09:00:00Z', agents: {} }; + const schedules = [{ name: 'Test', cron: '0 9 * * *', prompt: 'Do it' }]; + const updated = addAgentToState(state, 'test-agent', schedules); + expect(updated.agents['test-agent']).toBeDefined(); + expect(updated.agents['test-agent'].agentName).toBe('test-agent'); + expect(updated.agents['test-agent'].schedules).toHaveLength(1); + expect(updated.agents['test-agent'].schedules[0].status).toBe('active'); + }); + + it('should replace existing agent schedules', async () => { + const { addAgentToState } = await import('../../src/scheduler/state.js'); + const state = { + pid: 1, + startedAt: '2026-02-07T09:00:00Z', + agents: { + 'test-agent': { + agentName: 'test-agent', + schedules: [{ name: 'Old', cron: '0 8 * * *', prompt: 'Old task', status: 'active' as const, lastRunAt: null, lastRunStatus: null, nextRunAt: null, runCount: 5, errorCount: 0 }], + registeredAt: '2026-02-06T09:00:00Z', + }, + }, + }; + const newSchedules = [{ name: 'New', cron: '0 10 * * *', prompt: 'New task' }]; + const updated = addAgentToState(state, 'test-agent', newSchedules); + expect(updated.agents['test-agent'].schedules).toHaveLength(1); + expect(updated.agents['test-agent'].schedules[0].name).toBe('New'); + }); + }); + + describe('removeAgentFromState', () => { + it('should remove agent from state', async () => { + const { removeAgentFromState } = await import('../../src/scheduler/state.js'); + const state = { + pid: 1, + startedAt: '2026-02-07T09:00:00Z', + agents: { + 'test-agent': { + agentName: 'test-agent', + schedules: [], + registeredAt: '2026-02-07T09:00:00Z', + }, + }, + }; + const updated = removeAgentFromState(state, 'test-agent'); + expect(updated.agents['test-agent']).toBeUndefined(); + }); + + it('should not error when removing non-existent agent', async () => { + const { removeAgentFromState } = await import('../../src/scheduler/state.js'); + const state = { pid: 1, startedAt: null, agents: {} }; + const updated = removeAgentFromState(state, 'ghost-agent'); + expect(Object.keys(updated.agents)).toHaveLength(0); + }); + }); +}); diff --git a/packages/cli/test/schemas/agent-yaml.test.ts b/packages/cli/test/schemas/agent-yaml.test.ts index 3784446..01ff855 100644 --- a/packages/cli/test/schemas/agent-yaml.test.ts +++ b/packages/cli/test/schemas/agent-yaml.test.ts @@ -465,6 +465,153 @@ describe('agent-yaml schema', () => { }); }); + // --------------------------------------------------------------- + // Schedule block validation + // --------------------------------------------------------------- + describe('schedule block', () => { + it('should accept valid schedule with single entry', () => { + const result = agentYamlSchema.safeParse({ + ...validMinimal, + schedule: [ + { name: 'Daily standup', cron: '0 9 * * 1-5', prompt: 'Post standup' }, + ], + }); + expect(result.success).toBe(true); + }); + + it('should accept schedule entry without name (optional)', () => { + const result = agentYamlSchema.safeParse({ + ...validMinimal, + schedule: [ + { cron: '0 9 * * *', prompt: 'Do something daily' }, + ], + }); + expect(result.success).toBe(true); + }); + + it('should accept multiple schedule entries', () => { + const result = agentYamlSchema.safeParse({ + ...validMinimal, + schedule: [ + { name: 'Morning', cron: '0 9 * * *', prompt: 'Morning task' }, + { name: 'Evening', cron: '0 17 * * *', prompt: 'Evening task' }, + { cron: '*/15 * * * *', prompt: 'Frequent check' }, + ], + }); + expect(result.success).toBe(true); + }); + + it('should reject invalid cron expression', () => { + const result = agentYamlSchema.safeParse({ + ...validMinimal, + schedule: [ + { cron: 'not a cron', prompt: 'Do something' }, + ], + }); + expect(result.success).toBe(false); + }); + + it('should reject schedule entry missing prompt', () => { + const result = agentYamlSchema.safeParse({ + ...validMinimal, + schedule: [ + { cron: '0 9 * * *' }, + ], + }); + expect(result.success).toBe(false); + }); + + it('should reject empty prompt', () => { + const result = agentYamlSchema.safeParse({ + ...validMinimal, + schedule: [ + { cron: '0 9 * * *', prompt: '' }, + ], + }); + expect(result.success).toBe(false); + }); + + it('should reject prompt exceeding 2000 characters', () => { + const result = agentYamlSchema.safeParse({ + ...validMinimal, + schedule: [ + { cron: '0 9 * * *', prompt: 'x'.repeat(2001) }, + ], + }); + expect(result.success).toBe(false); + }); + + it('should accept prompt exactly 2000 characters', () => { + const result = agentYamlSchema.safeParse({ + ...validMinimal, + schedule: [ + { cron: '0 9 * * *', prompt: 'x'.repeat(2000) }, + ], + }); + expect(result.success).toBe(true); + }); + + it('should reject more than 10 schedule entries', () => { + const entries = Array.from({ length: 11 }, (_, i) => ({ + name: `Schedule ${i}`, + cron: '0 9 * * *', + prompt: `Task ${i}`, + })); + const result = agentYamlSchema.safeParse({ + ...validMinimal, + schedule: entries, + }); + expect(result.success).toBe(false); + }); + + it('should accept exactly 10 schedule entries', () => { + const entries = Array.from({ length: 10 }, (_, i) => ({ + name: `Schedule ${i}`, + cron: '0 9 * * *', + prompt: `Task ${i}`, + })); + const result = agentYamlSchema.safeParse({ + ...validMinimal, + schedule: entries, + }); + expect(result.success).toBe(true); + }); + + it('should reject schedule entry with missing cron', () => { + const result = agentYamlSchema.safeParse({ + ...validMinimal, + schedule: [ + { name: 'No cron', prompt: 'Do something' }, + ], + }); + expect(result.success).toBe(false); + }); + + it('should accept common cron patterns', () => { + const patterns = [ + '* * * * *', // every minute + '0 * * * *', // every hour + '0 0 * * *', // daily at midnight + '0 9 * * 1-5', // weekdays at 9am + '*/5 * * * *', // every 5 minutes + '0 0 1 * *', // first of every month + '0 0 * * 0', // every sunday + ]; + for (const cron of patterns) { + const result = agentYamlSchema.safeParse({ + ...validMinimal, + schedule: [{ cron, prompt: 'test' }], + }); + expect(result.success, `cron "${cron}" should be valid`).toBe(true); + } + }); + + it('should not affect existing agents without schedule block', () => { + const result = agentYamlSchema.safeParse(validMinimal); + expect(result.success).toBe(true); + }); + }); + // --------------------------------------------------------------- // T048-37: VALID_CATEGORIES export // --------------------------------------------------------------- diff --git a/packages/cli/tsup.config.ts b/packages/cli/tsup.config.ts index 277109e..9c58460 100644 --- a/packages/cli/tsup.config.ts +++ b/packages/cli/tsup.config.ts @@ -1,14 +1,26 @@ import { defineConfig } from 'tsup'; -export default defineConfig({ - entry: ['src/index.ts'], - format: ['esm'], - target: 'node18', - dts: true, - shims: true, - clean: true, - splitting: false, - banner: { - js: '#!/usr/bin/env node', +export default defineConfig([ + { + entry: ['src/index.ts'], + format: ['esm'], + target: 'node18', + dts: true, + shims: true, + clean: true, + splitting: false, + banner: { + js: '#!/usr/bin/env node', + }, }, -}); + { + entry: ['src/scheduler/daemon.ts'], + format: ['esm'], + target: 'node18', + dts: false, + shims: true, + clean: false, + splitting: false, + outDir: 'dist/scheduler', + }, +]); diff --git a/specs/002-agent-scheduling/checklists/requirements.md b/specs/002-agent-scheduling/checklists/requirements.md new file mode 100644 index 0000000..f3a7145 --- /dev/null +++ b/specs/002-agent-scheduling/checklists/requirements.md @@ -0,0 +1,36 @@ +# Specification Quality Checklist: Agent Scheduling + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-02-07 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- All items pass validation. Spec is ready for `/speckit.clarify` or `/speckit.plan`. +- Assumptions section documents v1 scope boundaries (no OS-level service managers, no parameterized prompts, standard 5-field cron only). +- US6 (Persist Across Restarts) is P3 and explicitly deferred to manual restart for v1, with auto-resume noted as a future enhancement. diff --git a/specs/002-agent-scheduling/contracts/cli-commands.md b/specs/002-agent-scheduling/contracts/cli-commands.md new file mode 100644 index 0000000..ad0f46b --- /dev/null +++ b/specs/002-agent-scheduling/contracts/cli-commands.md @@ -0,0 +1,166 @@ +# CLI Command Contracts: Agent Scheduling + +**Feature**: 002-agent-scheduling +**Date**: 2026-02-07 + +## Command: `agentx schedule start ` + +**Description**: Register an agent's declared schedules with the shared scheduler daemon. + +**Arguments**: +| Arg | Type | Required | Description | +|-----|------|----------|-------------| +| `agent-name` | string | Yes | Name of an installed agent with a `schedule` block | + +**Preconditions**: +1. Agent must be installed (`~/.agentx/agents//agent.yaml` exists) +2. Agent manifest must have a `schedule` block with at least one entry +3. All required secrets must be configured +4. If daemon is already running with this agent, inform user and offer restart + +**Behavior**: +1. Load and validate agent manifest +2. Verify secrets are configured +3. Write/update `~/.agentx/scheduler/state.json` with agent's schedules +4. If daemon not running → fork daemon process, write PID file +5. If daemon running → send SIGHUP to reload state +6. Print confirmation: agent name, schedule(s) in human-readable form, next run time(s) + +**Output (success)**: +``` +Schedule started for slack-agent + Daily standup 0 9 * * 1-5 (next: Mon Feb 10 09:00 AM) + Weekly summary 0 17 * * 5 (next: Fri Feb 14 05:00 PM) +``` + +**Output (error — no schedule)**: +``` +Error: slack-agent has no schedule block in agent.yaml +``` + +**Output (error — missing secrets)**: +``` +Error: Missing required secrets for slack-agent: SLACK_BOT_TOKEN +Run: agentx configure slack-agent +``` + +**Exit codes**: 0 = success, 1 = error + +--- + +## Command: `agentx schedule stop ` + +**Description**: Unregister an agent's schedules from the daemon. + +**Arguments**: +| Arg | Type | Required | Description | +|-----|------|----------|-------------| +| `agent-name` | string | Yes | Name of a currently scheduled agent | + +**Behavior**: +1. Read state.json, verify agent is registered +2. Remove agent from state.json +3. If schedules remain → send SIGHUP to daemon to reload +4. If no schedules remain → send SIGTERM to daemon, delete PID file +5. Print confirmation + +**Output (success)**: +``` +Schedule stopped for slack-agent +``` + +**Output (success — daemon shutdown)**: +``` +Schedule stopped for slack-agent +Scheduler daemon shut down (no active schedules) +``` + +**Output (error — not scheduled)**: +``` +Error: slack-agent has no active schedule +Run: agentx schedule list +``` + +**Exit codes**: 0 = success, 1 = error + +--- + +## Command: `agentx schedule list` + +**Description**: Display all active and recently errored schedules. + +**Arguments**: None + +**Options**: None + +**Behavior**: +1. Read `~/.agentx/scheduler/state.json` +2. For each registered agent, compute next run from cron +3. Display formatted table + +**Output (with active schedules)**: +``` +Agent Schedule Status Last Run Next Run +slack-agent 0 9 * * 1-5 active 2026-02-07 09:00 AM 2026-02-10 09:00 AM +slack-agent 0 17 * * 5 active 2026-02-07 05:00 PM 2026-02-14 05:00 PM +github-agent 0 8 * * * errored 2026-02-07 08:00 AM 2026-02-08 08:00 AM +``` + +**Output (no schedules)**: +``` +No active schedules. +Start one with: agentx schedule start +``` + +**Exit codes**: 0 = success + +--- + +## Command: `agentx schedule logs ` + +**Description**: View execution logs for a scheduled agent. + +**Arguments**: +| Arg | Type | Required | Description | +|-----|------|----------|-------------| +| `agent-name` | string | Yes | Name of a scheduled agent | + +**Options**: +| Flag | Type | Default | Description | +|------|------|---------|-------------| +| `--all` | boolean | false | Show summary of all past runs instead of latest run details | + +**Behavior (default — latest run)**: +1. Find newest log file in `~/.agentx/scheduler/logs//` +2. Display: timestamp, schedule name, prompt, full output, status, duration + +**Output (latest run)**: +``` +Last run: 2026-02-07 09:00:12 AM (Daily standup) +Status: success +Duration: 12.3s +Prompt: Post the daily standup to #engineering + +Output: + Posted standup message to #engineering with 5 team updates. +``` + +**Behavior (--all)**: +1. Read all log files in agent's log directory +2. Display summary table sorted by time (newest first) + +**Output (--all)**: +``` +Time Schedule Status Duration +2026-02-07 09:00 AM Daily standup success 12.3s +2026-02-06 09:00 AM Daily standup success 11.8s +2026-02-05 09:00 AM Daily standup failure 3.1s +2026-02-04 09:00 AM Daily standup success 14.2s +``` + +**Output (no runs)**: +``` +No runs recorded for slack-agent. +``` + +**Exit codes**: 0 = success, 1 = agent not found diff --git a/specs/002-agent-scheduling/data-model.md b/specs/002-agent-scheduling/data-model.md new file mode 100644 index 0000000..50b4685 --- /dev/null +++ b/specs/002-agent-scheduling/data-model.md @@ -0,0 +1,132 @@ +# Data Model: Agent Scheduling + +**Feature**: 002-agent-scheduling +**Date**: 2026-02-07 + +## Entities + +### 1. Schedule Entry (in agent.yaml manifest) + +Declared by agent authors. Immutable at runtime — changes require manifest update + restart. + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `name` | string | No | Human-readable label (e.g., "Daily standup"). Defaults to cron expression if omitted. | +| `cron` | string | Yes | 5-field cron expression (minute hour day-of-month month day-of-week). Validated via `Cron.isValid()`. | +| `prompt` | string | Yes | The prompt to send to the agent when the schedule fires. Sent verbatim. | + +**Validation Rules**: +- `cron` must be a valid 5-field cron expression +- `prompt` must be non-empty, max 2000 characters +- Array max length: 10 entries per agent + +**Example**: +```yaml +schedule: + - name: "Daily standup" + cron: "0 9 * * 1-5" + prompt: "Post the daily standup to #engineering" +``` + +--- + +### 2. Scheduler State (runtime, persisted to disk) + +Managed by the daemon process. Stored at `~/.agentx/scheduler/state.json`. + +| Field | Type | Description | +|-------|------|-------------| +| `pid` | number | Daemon process ID | +| `startedAt` | string (ISO 8601) | When the daemon was started | +| `agents` | Record | Map of agent name → schedule state | + +**AgentScheduleState**: + +| Field | Type | Description | +|-------|------|-------------| +| `agentName` | string | Name of the scheduled agent | +| `schedules` | ScheduleRunState[] | Per-schedule-entry runtime state | +| `registeredAt` | string (ISO 8601) | When this agent was registered with the daemon | + +**ScheduleRunState**: + +| Field | Type | Description | +|-------|------|-------------| +| `name` | string | Schedule entry name (from manifest or derived) | +| `cron` | string | Cron expression | +| `prompt` | string | Prompt to send | +| `status` | "active" \| "running" \| "errored" | Current status | +| `lastRunAt` | string (ISO 8601) \| null | Last execution time | +| `lastRunStatus` | "success" \| "failure" \| null | Last execution result | +| `nextRunAt` | string (ISO 8601) \| null | Next scheduled execution | +| `runCount` | number | Total number of executions | +| `errorCount` | number | Total number of failed executions | + +**State Transitions**: +``` +[not registered] --start--> active --cron fires--> running --success--> active + --failure--> errored (retry) + --all retries fail--> errored (wait for next) + active --stop--> [not registered] + errored --next cron fires--> running +``` + +--- + +### 3. Run Log (per-execution record) + +One JSON file per execution. Stored at `~/.agentx/scheduler/logs//.json`. + +| Field | Type | Description | +|-------|------|-------------| +| `timestamp` | string (ISO 8601) | When the run started | +| `agentName` | string | Agent that was executed | +| `scheduleName` | string | Which schedule entry triggered this run | +| `cron` | string | Cron expression that triggered | +| `prompt` | string | Prompt that was sent | +| `output` | string | Captured stdout from agent run | +| `stderr` | string | Captured stderr (if any) | +| `status` | "success" \| "failure" | Final outcome | +| `duration` | number | Execution time in milliseconds | +| `error` | string \| null | Error message if failed | +| `retryAttempt` | number | 0 = first attempt, 1 = first retry, 2 = second retry | +| `skipped` | boolean | True if this was a skip due to overlap | + +**Lifecycle**: +- Created when a scheduled run begins +- Updated when the run completes (status, output, duration) +- Rotated: oldest files deleted when count exceeds 50 per agent + +--- + +### 4. PID File + +Simple text file at `~/.agentx/scheduler/scheduler.pid`. + +| Content | Type | Description | +|---------|------|-------------| +| PID | number (text) | Process ID of the running daemon | + +**Lifecycle**: +- Written when daemon starts +- Deleted when daemon exits (graceful shutdown) +- Stale detection: `process.kill(pid, 0)` returns false → daemon is dead, clean up + +--- + +## File System Layout + +``` +~/.agentx/ + scheduler/ + scheduler.pid # Daemon PID file + state.json # Active schedules + runtime state + logs/ + daily-standup-agent/ + 2026-02-07T09-00-00Z.json + 2026-02-07T09-00-00Z.json + ... + slack-agent/ + 2026-02-07T17-00-00Z.json + ... +``` diff --git a/specs/002-agent-scheduling/plan.md b/specs/002-agent-scheduling/plan.md new file mode 100644 index 0000000..258eeee --- /dev/null +++ b/specs/002-agent-scheduling/plan.md @@ -0,0 +1,102 @@ +# Implementation Plan: Agent Scheduling + +**Branch**: `002-agent-scheduling` | **Date**: 2026-02-07 | **Spec**: [spec.md](./spec.md) +**Input**: Feature specification from `/specs/002-agent-scheduling/spec.md` + +## Summary + +Add a scheduling system to agentx that allows agents to declare cron-based scheduled tasks in `agent.yaml` and run them automatically via a shared background daemon. Users manage schedules through `agentx schedule start/stop/list/logs`. The daemon is a single Node.js process using `croner` for cron evaluation, communicating with the CLI via filesystem state and Unix signals. + +## Technical Context + +**Language/Version**: TypeScript (strict mode), ESM, Node.js 18+ +**Primary Dependencies**: Commander.js, croner (new), execa, Zod, chalk, @clack/prompts +**Storage**: JSON files in `~/.agentx/scheduler/` (state, logs, PID) +**Testing**: Vitest (globals: true) +**Target Platform**: macOS, Linux (cross-platform Node.js) +**Project Type**: Monorepo — changes scoped to `packages/cli/` +**Performance Goals**: Daemon idle <50MB RSS; agent execution within 60s of cron time +**Constraints**: No OS-level service managers in v1; no external dependencies beyond croner +**Scale/Scope**: Handles 1-20 concurrent agent schedules per user machine + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +| Principle | Status | Notes | +|-----------|--------|-------| +| I. CLI-First | PASS | All features are CLI commands (`agentx schedule start/stop/list/logs`) | +| II. Zero-Cost UX | PASS | No paid services; runs locally on user's machine | +| III. npm Model | PASS | Schedules declared in agent.yaml manifest, consistent with package model | +| IV. Claude Code Native | PASS | Daemon spawns `agentx run` which delegates to claude CLI; no embedded LLM calls | +| V. Security by Default | PASS | Secrets loaded from encrypted store at runtime; daemon doesn't persist decrypted secrets; PID file + state file written with restrictive permissions | +| VI. Test-Driven | PASS | Tests written before implementation for all modules | +| VII. Simplicity | PASS | Single daemon process, file-based IPC, croner (zero-dep) library, no sockets or databases | + +**Technology Prohibitions Check**: +- No ORM in CLI: PASS (JSON file storage only) +- No paid services: PASS +- No embedded LLM: PASS (delegates to claude CLI via `agentx run`) +- No GUI: PASS +- tsup bundler: PASS (no webpack) +- No Express/Fastify: PASS +- No MongoDB/NoSQL: PASS + +**Post-Phase 1 Re-check**: All gates still pass. The daemon is a lightweight Node.js fork, croner has zero dependencies, and all IPC is file-based. + +## Project Structure + +### Documentation (this feature) + +```text +specs/002-agent-scheduling/ +├── spec.md +├── plan.md # This file +├── research.md # Phase 0: technology decisions +├── data-model.md # Phase 1: entity definitions +├── quickstart.md # Phase 1: developer setup guide +├── contracts/ +│ └── cli-commands.md # Phase 1: CLI command specifications +└── tasks.md # Phase 2: task breakdown (via /speckit.tasks) +``` + +### Source Code (repository root) + +```text +packages/cli/src/ +├── commands/ +│ └── schedule.ts # NEW: schedule start/stop/list/logs subcommands +├── scheduler/ +│ ├── daemon.ts # NEW: background daemon entry point +│ ├── state.ts # NEW: read/write state.json +│ ├── log-store.ts # NEW: run log storage + rotation +│ └── process.ts # NEW: fork/kill daemon, PID management +├── schemas/ +│ └── agent-yaml.ts # MODIFIED: add schedule schema +├── config/ +│ └── paths.ts # MODIFIED: add SCHEDULER_* paths +├── commands/ +│ └── uninstall.ts # MODIFIED: stop schedule on uninstall +└── index.ts # MODIFIED: register schedule command + +packages/cli/test/ +├── scheduler/ +│ ├── state.test.ts # NEW: state management tests +│ ├── log-store.test.ts # NEW: log storage + rotation tests +│ └── process.test.ts # NEW: daemon lifecycle tests +├── commands/ +│ └── schedule.test.ts # NEW: CLI command tests +└── schemas/ + └── agent-yaml.test.ts # MODIFIED: schedule validation tests +``` + +**Structure Decision**: Extends existing `packages/cli/src/` layout with a new `scheduler/` module directory. Follows established patterns: commands in `commands/`, schemas in `schemas/`, config in `config/`, tests mirror `src/` structure. + +## Complexity Tracking + +No constitution violations to justify. The design uses: +- 1 new dependency (`croner`, zero transitive deps) +- 1 new module directory (`scheduler/` with 4 files) +- 1 new command file (`schedule.ts`) +- 3 modified files (schema, paths, uninstall) +- File-based IPC (simplest possible daemon communication) diff --git a/specs/002-agent-scheduling/quickstart.md b/specs/002-agent-scheduling/quickstart.md new file mode 100644 index 0000000..1304594 --- /dev/null +++ b/specs/002-agent-scheduling/quickstart.md @@ -0,0 +1,92 @@ +# Developer Quickstart: Agent Scheduling + +**Feature**: 002-agent-scheduling +**Date**: 2026-02-07 + +## Prerequisites + +- Node.js 18+ +- agentx CLI built (`npm run build --workspace=packages/cli`) +- Tests passing (`npm test --workspace=packages/cli`) + +## New Dependency + +```bash +npm install croner --workspace=packages/cli +``` + +## Key Files to Create/Modify + +### New Files + +| File | Purpose | +|------|---------| +| `src/scheduler/daemon.ts` | Background daemon entry point — loads state, creates Cron jobs, executes agents | +| `src/scheduler/state.ts` | Read/write `~/.agentx/scheduler/state.json` — schedule state management | +| `src/scheduler/log-store.ts` | Read/write run logs, rotation logic | +| `src/scheduler/process.ts` | Fork/kill daemon, PID file management, SIGHUP signaling | +| `src/commands/schedule.ts` | Commander subcommand group: start, stop, list, logs | +| `test/scheduler/state.test.ts` | State management unit tests | +| `test/scheduler/log-store.test.ts` | Log storage and rotation tests | +| `test/scheduler/process.test.ts` | Daemon lifecycle tests | +| `test/commands/schedule.test.ts` | CLI command integration tests | + +### Modified Files + +| File | Change | +|------|--------| +| `src/schemas/agent-yaml.ts` | Add `schedule` array schema with cron validation | +| `src/config/paths.ts` | Add `SCHEDULER_DIR`, `SCHEDULER_PID`, `SCHEDULER_STATE`, `SCHEDULER_LOGS_DIR` | +| `src/commands/uninstall.ts` | Stop agent's schedule before uninstalling | +| `src/index.ts` | Register `schedule` command group | +| `test/schemas/agent-yaml.test.ts` | Add tests for schedule block validation | + +## Build & Test + +```bash +# Build +npm run build --workspace=packages/cli + +# Run all tests +npm test --workspace=packages/cli + +# Typecheck +npx tsc --noEmit --project packages/cli/tsconfig.json + +# Manual test +agentx schedule start slack-agent +agentx schedule list +agentx schedule logs slack-agent +agentx schedule stop slack-agent +``` + +## Architecture Overview + +``` +CLI (agentx schedule start) + │ + ├── Writes state.json + ├── Forks daemon (if not running) + └── Sends SIGHUP (if running) + │ + ▼ +Daemon (scheduler/daemon.ts) + │ + ├── Reads state.json + ├── Creates Cron instances (croner) + ├── On trigger: spawns `agentx run ""` + ├── Writes log files + └── Handles SIGHUP (reload), SIGTERM (shutdown) +``` + +## Implementation Order + +1. Schema extension (agent-yaml.ts + tests) +2. Config paths (paths.ts) +3. State management (state.ts + tests) +4. Log storage (log-store.ts + tests) +5. Daemon process management (process.ts + tests) +6. Daemon entry point (daemon.ts) +7. CLI commands (schedule.ts + tests) +8. Uninstall hook (uninstall.ts modification) +9. Starter agent schedule examples diff --git a/specs/002-agent-scheduling/research.md b/specs/002-agent-scheduling/research.md new file mode 100644 index 0000000..50e3b14 --- /dev/null +++ b/specs/002-agent-scheduling/research.md @@ -0,0 +1,117 @@ +# Research: Agent Scheduling + +**Feature**: 002-agent-scheduling +**Date**: 2026-02-07 + +## R1: Cron Parsing & Scheduling Library + +**Decision**: Use `croner` for cron parsing, validation, next-run computation, and scheduling. + +**Rationale**: +- Zero dependencies — aligns with constitution (minimal footprint) +- Single package covers all three needs: validation, next-run calculation, and callback scheduling +- Best benchmark score (85.8/100) among alternatives +- Used in production by PM2, Uptime Kuma, ZWave JS +- Supports standard 5-field cron + human-readable patterns (`@daily`, `@weekly`) +- Built-in `nextRun()`, `nextRuns(n)`, `msToNext()` APIs +- Pause/resume/stop methods match our start/stop semantics +- Works in Node.js 18+ (our target) + +**Alternatives Considered**: +- `node-cron` — Good but fewer features (no `nextRuns(n)`, lower benchmark score) +- `cron-parser` — Parse-only, no scheduler; would need a second package for execution +- `cron` — Legacy, 6-field default, less maintained + +**Key API Surface**: +```typescript +import { Cron } from 'croner'; + +// Validate +const isValid = Cron.isValid('0 9 * * 1-5'); + +// Schedule +const job = new Cron('0 9 * * 1-5', () => { /* run agent */ }); + +// Inspect +job.nextRun(); // Date | null +job.nextRuns(5); // Date[] +job.msToNext(); // number + +// Control +job.pause(); +job.resume(); +job.stop(); +``` + +## R2: Background Daemon Architecture + +**Decision**: Single shared Node.js daemon process, spawned as a detached child process via `child_process.fork()` with `detached: true` and `stdio: 'ignore'`. + +**Rationale**: +- `fork()` creates a Node.js subprocess that can run independently after the parent CLI exits +- Detached mode + `unref()` allows the CLI to exit while the daemon continues +- PID file stored at `~/.agentx/scheduler.pid` for process management +- IPC via the filesystem (state JSON file) — simplest possible approach, no sockets needed +- The daemon process loads a dedicated entry point (`src/scheduler/daemon.ts`) that: + 1. Reads schedule state from `~/.agentx/scheduler/state.json` + 2. Creates `Cron` instances for each active schedule + 3. On each trigger, spawns `agentx run ""` via `execa` + 4. Writes run logs to `~/.agentx/scheduler/logs//.json` + +**Alternatives Considered**: +- OS-level service managers (launchd/systemd) — Too complex for v1, platform-specific +- Socket-based IPC — Overkill; file-based state is sufficient for start/stop/list +- PM2 — External dependency, violates simplicity principle + +## R3: Process Management Pattern + +**Decision**: PID file + signal-based lifecycle. + +**Rationale**: +- `agentx schedule start` checks for existing daemon → if not running, fork a new one; if running, send the new schedule via state file + SIGHUP +- `agentx schedule stop` removes agent from state file + sends SIGHUP to reload; if no schedules remain, sends SIGTERM +- `agentx schedule list` reads state file directly (no daemon interaction needed) +- `agentx schedule logs` reads log files directly (no daemon interaction needed) +- Stale PID detection: check if process exists via `process.kill(pid, 0)` before trusting PID file + +**Communication Protocol**: +1. CLI writes desired state to `~/.agentx/scheduler/state.json` +2. CLI sends `SIGHUP` to daemon PID +3. Daemon receives SIGHUP → re-reads state.json → reconciles cron jobs +4. No response needed — CLI reads state.json for confirmation after a brief delay + +## R4: Log Storage & Rotation + +**Decision**: JSON log files, one per run, in `~/.agentx/scheduler/logs//`. + +**Rationale**: +- Each run produces a file: `.json` containing `{ timestamp, prompt, output, duration, status, error? }` +- Rotation: after each run, count files in agent's log dir; if > 50, delete oldest +- `agentx schedule logs ` reads the newest file; `--all` reads all and renders summary table +- JSON format makes programmatic access easy while remaining human-readable + +**Alternatives Considered**: +- Single append-only log file — Harder to rotate, harder to read individual runs +- SQLite database — Over-engineered for local logs, adds dependency +- Structured logging to stderr — No persistence, can't review past runs + +## R5: Schema Extension for agent.yaml + +**Decision**: Add optional `schedule` array to the existing Zod schema. + +**Rationale**: +- Array (not record) to support multiple schedules with ordering +- Each entry: `{ name?: string, cron: string, prompt: string }` +- `name` is optional — used for display in `schedule list` (defaults to cron expression) +- Cron validation via `Cron.isValid()` in a Zod `.refine()` call +- Backward compatible — fully optional, existing agents unaffected + +```yaml +schedule: + - name: "Daily standup" + cron: "0 9 * * 1-5" + prompt: "Post the daily standup to the configured channel" + - name: "Weekly summary" + cron: "0 17 * * 5" + prompt: "Generate and post the weekly activity summary" +``` diff --git a/specs/002-agent-scheduling/spec.md b/specs/002-agent-scheduling/spec.md new file mode 100644 index 0000000..5138f44 --- /dev/null +++ b/specs/002-agent-scheduling/spec.md @@ -0,0 +1,176 @@ +# Feature Specification: Agent Scheduling + +**Feature Branch**: `002-agent-scheduling` +**Created**: 2026-02-07 +**Status**: Draft +**Input**: User description: "Add a scheduling system to agentx that allows agents to declare scheduled tasks in agent.yaml and run them automatically. Users should be able to define cron-based schedules with prompts, then use an `agentx schedule` command to manage scheduled agents as background daemons. The system should support: declaring schedules in agent.yaml with cron expressions and prompts, an `agentx schedule start/stop/list/logs` CLI interface, a lightweight daemon that runs scheduled agents at the right times, log capture for each run, and graceful error handling (retries, notifications on failure). Should work cross-platform. Keep it simple — prefer a built-in Node.js scheduler over OS-level service managers for v1." + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Declare a Schedule in agent.yaml (Priority: P1) + +As an agent author, I want to declare one or more scheduled tasks directly in my agent's `agent.yaml` manifest so that users who install my agent can enable automatic execution without any additional setup beyond providing secrets. + +A schedule entry includes a cron expression (defining when to run) and a prompt (defining what to ask the agent). For example, a daily standup agent would declare a schedule that runs every weekday at 9am with the prompt "Post the daily standup to the configured channel." + +**Why this priority**: Without the ability to declare schedules in the manifest, no other scheduling feature is possible. This is the foundational data model. + +**Independent Test**: Can be fully tested by writing an agent.yaml with a `schedule` block and validating it passes schema validation. Delivers value by establishing the contract for all scheduling features. + +**Acceptance Scenarios**: + +1. **Given** an agent.yaml with a valid `schedule` block containing a cron expression and prompt, **When** the manifest is validated, **Then** validation passes without errors. +2. **Given** an agent.yaml with an invalid cron expression in the schedule block, **When** the manifest is validated, **Then** validation fails with a clear error message indicating the invalid cron syntax. +3. **Given** an agent.yaml with a schedule entry missing the required `prompt` field, **When** the manifest is validated, **Then** validation fails indicating the missing field. +4. **Given** an agent.yaml with multiple schedule entries, **When** the manifest is validated, **Then** all entries are validated independently and the manifest passes if all are valid. + +--- + +### User Story 2 - Start and Stop Scheduled Agents (Priority: P1) + +As a user, I want to start an installed agent's schedule so it runs automatically in the background, and stop it when I no longer need automatic runs. I use `agentx schedule start ` to begin and `agentx schedule stop ` to end. + +When I start a schedule, the system launches a background process that watches the clock and runs the agent at each scheduled time. The agent runs exactly as if I had typed `agentx run ""` manually. When I stop a schedule, the background process terminates cleanly and no further runs occur. + +**Why this priority**: This is the core user-facing interaction — without start/stop, schedules are just metadata with no effect. + +**Independent Test**: Can be fully tested by starting a schedule for an agent, observing that it executes at the next scheduled time, then stopping it and confirming no further executions occur. + +**Acceptance Scenarios**: + +1. **Given** an installed agent with a declared schedule and configured secrets, **When** the user runs `agentx schedule start `, **Then** the system starts a background process and confirms with a message showing the agent name, schedule timing, and next run time. +2. **Given** a running scheduled agent, **When** the scheduled time arrives, **Then** the agent executes with the declared prompt and the output is captured to a log file. +3. **Given** a running scheduled agent, **When** the user runs `agentx schedule stop `, **Then** the background process terminates, a confirmation message is shown, and no further scheduled runs occur. +4. **Given** an agent without a `schedule` block in its manifest, **When** the user runs `agentx schedule start `, **Then** the system displays an error explaining that this agent has no declared schedules. +5. **Given** an agent whose required secrets are not configured, **When** the user runs `agentx schedule start `, **Then** the system displays an error prompting the user to configure secrets first. + +--- + +### User Story 3 - List Active Schedules (Priority: P2) + +As a user, I want to see all currently active scheduled agents and their status so I can understand what's running, when each agent last ran, and when it will run next. + +**Why this priority**: Visibility into running schedules is essential for managing and debugging, but the system is functional without it (users can track start/stop themselves). + +**Independent Test**: Can be fully tested by starting one or more scheduled agents, running the list command, and verifying the output shows correct agent names, statuses, schedules, and timing information. + +**Acceptance Scenarios**: + +1. **Given** two agents with active schedules, **When** the user runs `agentx schedule list`, **Then** a table is displayed showing each agent's name, cron expression (in human-readable form), last run time, next run time, and status (running/stopped/errored). +2. **Given** no active schedules, **When** the user runs `agentx schedule list`, **Then** a message is displayed indicating no schedules are active, with a hint on how to start one. +3. **Given** a scheduled agent that failed on its last run, **When** the user runs `agentx schedule list`, **Then** the status column shows "errored" and the last run time is displayed. + +--- + +### User Story 4 - View Schedule Logs (Priority: P2) + +As a user, I want to view the execution logs of a scheduled agent so I can see what happened during past runs, diagnose failures, and verify the agent is producing expected output. + +**Why this priority**: Logs are critical for debugging scheduled agents that run unattended, but the system can function without a dedicated log viewer (users could check log files manually). + +**Independent Test**: Can be fully tested by running a scheduled agent at least once, then using the logs command to view the captured output. + +**Acceptance Scenarios**: + +1. **Given** a scheduled agent that has run 3 times, **When** the user runs `agentx schedule logs `, **Then** the most recent run's output is displayed, including timestamp, prompt used, agent output, and success/failure status. +2. **Given** a scheduled agent with multiple past runs, **When** the user runs `agentx schedule logs --all`, **Then** a summary of all runs is shown with timestamps, statuses, and durations. +3. **Given** a scheduled agent with no prior runs, **When** the user runs `agentx schedule logs `, **Then** a message indicates no runs have occurred yet. +4. **Given** a scheduled agent that failed on its last run, **When** the user views logs, **Then** the error message and any partial output are displayed clearly. + +--- + +### User Story 5 - Automatic Recovery on Failure (Priority: P3) + +As a user, I want the scheduler to handle failures gracefully — retrying failed runs and continuing to operate even if one run fails — so that a single transient error doesn't break my entire schedule. + +**Why this priority**: Resilience makes the scheduling system production-ready, but a v1 without retry still delivers core value. + +**Independent Test**: Can be fully tested by simulating a transient failure (e.g., network timeout) during a scheduled run and verifying the system retries and continues future scheduled runs. + +**Acceptance Scenarios**: + +1. **Given** a scheduled agent run that fails due to a transient error, **When** the scheduler detects the failure, **Then** it retries the run up to 2 additional times with increasing delay between attempts. +2. **Given** a scheduled agent run that fails after all retry attempts, **When** all retries are exhausted, **Then** the failure is logged with full error details and the schedule continues for the next scheduled time. +3. **Given** a scheduler background process that crashes unexpectedly, **When** the user runs `agentx schedule start `, **Then** the system detects the stale state, cleans it up, and starts a fresh scheduler process. + +--- + +### User Story 6 - Persist Schedules Across System Restarts (Priority: P3) + +As a user, I want my active schedules to survive system restarts so I don't have to manually re-start every scheduled agent after rebooting my computer. + +**Why this priority**: Persistence is important for a production-quality scheduler but is not needed for initial usability. Users can manually restart schedules after reboot in v1. + +**Independent Test**: Can be fully tested by starting a schedule, simulating a system restart (kill and restart the daemon), and verifying the schedule resumes. + +**Acceptance Scenarios**: + +1. **Given** an active scheduled agent, **When** the scheduler process is terminated (e.g., system reboot), **Then** the schedule state is persisted to disk so it can be restored. +2. **Given** persisted schedule state from a previous session, **When** the user runs `agentx schedule start ` or a global resume command, **Then** the scheduler resumes all previously active schedules. +3. **Given** a schedule that missed runs while the system was off, **When** the scheduler resumes, **Then** it does NOT retroactively run missed schedules — it simply waits for the next scheduled time. + +--- + +### Edge Cases + +- What happens when a user starts a schedule for an agent that already has an active schedule? The system should inform the user the schedule is already running and offer to restart it. +- What happens when a scheduled agent run takes longer than the interval between scheduled runs? The system should skip the next run (do not queue overlapping executions) and log that a run was skipped due to overlap. +- What happens when the user uninstalls an agent that has an active schedule? The system should automatically stop the schedule and clean up associated state and logs. +- What happens when an agent's manifest is updated with a new schedule after the schedule was already started? The system should use the schedule that was active at start time; the user must stop and restart to pick up changes. +- What happens when the system clock changes significantly (e.g., timezone change, DST transition)? The scheduler should use UTC internally and convert for display, handling clock adjustments gracefully. +- What happens when disk space runs out for log storage? The system should rotate logs, keeping only the most recent runs (default: last 50 runs per agent). + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: The `agent.yaml` schema MUST support an optional `schedule` block containing an array of schedule entries, each with a `cron` expression and a `prompt` string. +- **FR-002**: The system MUST validate cron expressions during manifest validation, rejecting invalid syntax with a clear error message. +- **FR-003**: The system MUST provide an `agentx schedule start ` command that registers the agent with the shared scheduler daemon (starting the daemon if not already running). +- **FR-004**: The system MUST provide an `agentx schedule stop ` command that unregisters the agent from the shared scheduler daemon (shutting down the daemon if no schedules remain). +- **FR-005**: The system MUST provide an `agentx schedule list` command that displays all active and recently stopped schedules with their status, timing, and last run information. +- **FR-006**: The system MUST provide an `agentx schedule logs ` command that displays execution logs for a scheduled agent's runs. +- **FR-007**: When a scheduled time arrives, the system MUST execute the agent with the declared prompt, equivalent to running `agentx run ""`. +- **FR-008**: The system MUST capture the full output (stdout and stderr) of each scheduled agent run to a log file. +- **FR-009**: The system MUST prevent overlapping executions — if a run is still in progress when the next scheduled time arrives, the next run is skipped and logged. +- **FR-010**: The system MUST verify that all required secrets are configured before starting a schedule, displaying actionable error messages if secrets are missing. +- **FR-011**: The system MUST support multiple schedule entries per agent (e.g., one for daily reports, another for weekly summaries). +- **FR-012**: The system MUST store schedule state (active schedules, process identifiers, last run times) in the agentx configuration directory. +- **FR-013**: The system MUST automatically stop an agent's schedule when the agent is uninstalled. +- **FR-014**: On failed runs, the system MUST retry up to 2 times with increasing delay before marking the run as failed. +- **FR-015**: The system MUST rotate logs, retaining the last 50 runs per agent by default to prevent unbounded disk usage. +- **FR-016**: The scheduler MUST use UTC internally for cron evaluation and display local time to the user. +- **FR-017**: The `agentx schedule logs` command MUST support a `--all` flag to show a summary of all past runs (vs. the default of showing the most recent run's full output). + +### Key Entities + +- **Schedule Entry**: A declared schedule in an agent's manifest — contains a cron expression, a prompt to send, and an optional human-readable name/label. +- **Schedule State**: Runtime tracking for an active schedule — includes agent name, process identifier, current status (running/stopped/errored), last run time, next run time, run history references. +- **Run Log**: The captured output of a single scheduled execution — includes timestamp, prompt used, full output, duration, and success/failure status. + +## Clarifications + +### Session 2026-02-07 + +- Q: Should the system run one shared daemon or one process per agent? → A: Single shared daemon manages all agent schedules in one process. +- Q: Should there be an explicit idle resource footprint constraint for the daemon? → A: Idle daemon MUST consume less than 50MB RSS memory. + +## Assumptions + +- The scheduler runs as a single shared detached Node.js background process on the user's machine (not a cloud service). All agent schedules are managed within this one daemon — it starts when the first schedule is activated and shuts down when the last schedule is stopped. +- For v1, schedule persistence across reboots requires the user to manually restart (e.g., `agentx schedule start ` again). Full auto-resume via OS-level service managers (launchd, systemd) is deferred to a future version. +- Cron expressions follow the standard 5-field format (minute, hour, day-of-month, month, day-of-week). Extended 6-field (seconds) and 7-field (years) formats are not supported in v1. +- Log files are stored locally in the agentx data directory. There is no remote log shipping or cloud storage in v1. +- The scheduler does not support parameterized prompts (e.g., injecting the current date). The prompt is sent exactly as declared. Users can instruct the agent in the prompt to determine the current date itself. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: Users can declare a schedule in agent.yaml and start it running with a single command in under 30 seconds. +- **SC-002**: Scheduled agents execute within 60 seconds of their declared cron time. +- **SC-003**: Users can view the status of all active schedules in under 5 seconds via the list command. +- **SC-004**: Users can retrieve and read logs from the last scheduled run in under 5 seconds via the logs command. +- **SC-005**: A scheduled agent that encounters a transient failure is retried and succeeds without user intervention (when the underlying issue resolves). +- **SC-006**: The scheduler daemon consumes less than 50MB RSS memory when idle and operates continuously for 7+ days without memory leaks, crashes, or missed runs (excluding system downtime). +- **SC-007**: 100% of schedule-related commands provide clear, actionable feedback — no silent failures or cryptic error messages. diff --git a/specs/002-agent-scheduling/tasks.md b/specs/002-agent-scheduling/tasks.md new file mode 100644 index 0000000..c627b38 --- /dev/null +++ b/specs/002-agent-scheduling/tasks.md @@ -0,0 +1,271 @@ +# Tasks: Agent Scheduling + +**Input**: Design documents from `/specs/002-agent-scheduling/` +**Prerequisites**: plan.md (required), spec.md (required), research.md, data-model.md, contracts/cli-commands.md + +**Tests**: TDD approach — tests written before implementation (per constitution Principle VI). + +**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies) +- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3) +- Include exact file paths in descriptions + +## Path Conventions + +- CLI source: `packages/cli/src/` +- CLI tests: `packages/cli/test/` +- All imports use `.js` extensions (ESM) + +--- + +## Phase 1: Setup + +**Purpose**: Install dependency and establish project structure for scheduling feature + +- [x] T001 Install `croner` dependency in packages/cli via `npm install croner --workspace=packages/cli` +- [x] T002 [P] Add scheduler path constants (`SCHEDULER_DIR`, `SCHEDULER_PID`, `SCHEDULER_STATE`, `SCHEDULER_LOGS_DIR`) in `packages/cli/src/config/paths.ts` +- [x] T003 [P] Create `packages/cli/src/scheduler/` directory with empty barrel file `packages/cli/src/scheduler/index.ts` + +--- + +## Phase 2: Foundational — Schema Extension + +**Purpose**: Extend agent.yaml schema to support `schedule` block — MUST complete before any user story + +**⚠️ CRITICAL**: No user story work can begin until this phase is complete + +- [x] T004 Write tests for schedule schema validation (valid cron, invalid cron, missing prompt, multiple entries, max 10 entries, prompt max 2000 chars) in `packages/cli/test/schemas/agent-yaml.test.ts` +- [x] T005 Add `scheduleEntrySchema` (name?, cron, prompt) and `schedule` optional array to `agentYamlSchema` in `packages/cli/src/schemas/agent-yaml.ts` — use `croner` `Cron` class for cron validation in a Zod `.refine()` call +- [x] T006 Verify all existing 115+ tests still pass after schema change by running `npm test --workspace=packages/cli` + +**Checkpoint**: Schema extended — `agent.yaml` files with `schedule` blocks now validate correctly + +--- + +## Phase 3: User Story 1 — Declare a Schedule in agent.yaml (Priority: P1) 🎯 MVP + +**Goal**: Agent authors can declare cron-based schedules in agent.yaml and have them validated + +**Independent Test**: Write an agent.yaml with a `schedule` block, run `agentx validate`, confirm it passes + +### Tests for User Story 1 + +- [x] T007 [P] [US1] Write test for `loadScheduleState()` and `saveScheduleState()` (create, read, update, delete agent entries) in `packages/cli/test/scheduler/state.test.ts` +- [x] T008 [P] [US1] Write test for `writeRunLog()`, `readLatestLog()`, `readAllLogs()`, and `rotateLogs()` (write, read, rotation at 50 files) in `packages/cli/test/scheduler/log-store.test.ts` + +### Implementation for User Story 1 + +- [x] T009 [P] [US1] Implement `SchedulerState`, `AgentScheduleState`, and `ScheduleRunState` TypeScript interfaces in `packages/cli/src/scheduler/state.ts` per data-model.md +- [x] T010 [P] [US1] Implement `RunLog` TypeScript interface and types in `packages/cli/src/scheduler/log-store.ts` per data-model.md +- [x] T011 [US1] Implement `loadScheduleState()`, `saveScheduleState()`, `addAgentToState()`, `removeAgentFromState()` in `packages/cli/src/scheduler/state.ts` — read/write `~/.agentx/scheduler/state.json` with `0o600` permissions +- [x] T012 [US1] Implement `writeRunLog()`, `readLatestLog()`, `readAllLogs()`, `rotateLogs()` in `packages/cli/src/scheduler/log-store.ts` — JSON files in `~/.agentx/scheduler/logs//`, rotation keeps last 50 +- [x] T013 [US1] Run tests for state and log-store modules, verify all pass + +**Checkpoint**: State and log storage modules work independently with full test coverage + +--- + +## Phase 4: User Story 2 — Start and Stop Scheduled Agents (Priority: P1) + +**Goal**: Users can start/stop agent schedules via CLI, daemon runs in background and executes agents on cron + +**Independent Test**: Run `agentx schedule start `, verify daemon starts and agent executes at scheduled time, run `agentx schedule stop `, verify daemon stops + +### Tests for User Story 2 + +- [x] T014 [P] [US2] Write test for `isDaemonRunning()`, `startDaemon()`, `stopDaemon()`, `signalDaemon()`, stale PID cleanup in `packages/cli/test/scheduler/process.test.ts` +- [x] T015 [P] [US2] Write test for `schedule start` command (happy path, no schedule error, missing secrets error, already running) in `packages/cli/test/commands/schedule.test.ts` +- [x] T016 [P] [US2] Write test for `schedule stop` command (happy path, not scheduled error, daemon shutdown when last agent removed) in `packages/cli/test/commands/schedule.test.ts` + +### Implementation for User Story 2 + +- [x] T017 [US2] Implement daemon process management (`isDaemonRunning()`, `getDaemonPid()`, `startDaemon()`, `stopDaemon()`, `signalDaemon()`) in `packages/cli/src/scheduler/process.ts` — PID file at `~/.agentx/scheduler/scheduler.pid`, fork with `detached: true`, stale PID detection via `process.kill(pid, 0)` +- [x] T018 [US2] Implement daemon entry point in `packages/cli/src/scheduler/daemon.ts` — on startup: read state.json, create `Cron` instances for each active schedule; on SIGHUP: re-read state and reconcile jobs; on SIGTERM: stop all jobs and exit cleanly; on cron trigger: spawn `agentx run ""` via execa, capture output, write run log, handle overlap prevention (skip if already running) +- [x] T019 [US2] Implement `schedule start` subcommand in `packages/cli/src/commands/schedule.ts` — load agent manifest, verify schedule block exists, verify secrets configured, write state.json, fork daemon or send SIGHUP, print confirmation with next run times using `Cron.nextRun()` +- [x] T020 [US2] Implement `schedule stop` subcommand in `packages/cli/src/commands/schedule.ts` — read state, remove agent, send SIGHUP or SIGTERM if last agent, print confirmation +- [x] T021 [US2] Register `scheduleCommand` in `packages/cli/src/index.ts` via `program.addCommand(scheduleCommand)` +- [x] T022 [US2] Add tsup entry point for daemon — ensure `packages/cli/src/scheduler/daemon.ts` is either a separate bundle entry or can be resolved by `process.ts` when forking (may need `tsup.config.ts` update to add `src/scheduler/daemon.ts` as a second entry) +- [x] T023 [US2] Run all tests, verify start/stop commands and daemon process management work + +**Checkpoint**: Core scheduling loop works — start agent, daemon runs, cron fires, agent executes, stop agent + +--- + +## Phase 5: User Story 3 — List Active Schedules (Priority: P2) + +**Goal**: Users can view all active schedules with status, timing, and last run info + +**Independent Test**: Start 2 agents with schedules, run `agentx schedule list`, verify table output + +### Tests for User Story 3 + +- [x] T024 [P] [US3] Write test for `schedule list` command (with active schedules, empty state, errored status display) in `packages/cli/test/commands/schedule.test.ts` + +### Implementation for User Story 3 + +- [x] T025 [US3] Implement `schedule list` subcommand in `packages/cli/src/commands/schedule.ts` — read state.json, compute next run times via `Cron`, format table with agent name, cron expression, status, last run (local time), next run (local time); show empty state hint if no schedules +- [x] T026 [US3] Run tests for list command, verify output formatting + +**Checkpoint**: Users can view all schedules at a glance + +--- + +## Phase 6: User Story 4 — View Schedule Logs (Priority: P2) + +**Goal**: Users can view execution history and debug failed runs + +**Independent Test**: Create sample log files, run `agentx schedule logs `, verify latest run output; run with `--all`, verify summary table + +### Tests for User Story 4 + +- [x] T027 [P] [US4] Write test for `schedule logs` command (latest run display, `--all` summary table, no runs message, failed run error display) in `packages/cli/test/commands/schedule.test.ts` + +### Implementation for User Story 4 + +- [x] T028 [US4] Implement `schedule logs` subcommand in `packages/cli/src/commands/schedule.ts` — default: read latest log file via `readLatestLog()`, display timestamp, schedule name, prompt, full output, status, duration; `--all` flag: read all logs via `readAllLogs()`, render summary table with time, schedule name, status, duration +- [x] T029 [US4] Run tests for logs command, verify output formatting + +**Checkpoint**: Users can inspect past runs and debug failures + +--- + +## Phase 7: User Story 5 — Automatic Recovery on Failure (Priority: P3) + +**Goal**: Failed runs are retried with backoff; scheduler continues operating after failures + +**Independent Test**: Simulate a failing agent run, verify retry occurs up to 2 times with increasing delay, verify schedule continues after exhausting retries + +### Tests for User Story 5 + +- [x] T030 [P] [US5] Write test for retry logic in daemon (retry up to 2 times with backoff, log each attempt with retryAttempt field, continue schedule after exhausted retries) in `packages/cli/test/scheduler/process.test.ts` + +### Implementation for User Story 5 + +- [x] T031 [US5] Add retry logic to daemon's run execution in `packages/cli/src/scheduler/daemon.ts` — on failure: retry up to 2 additional times with delays of 10s and 30s; update run log `retryAttempt` field; update state `errorCount`; after all retries exhausted: mark status as "errored" in state, continue schedule for next cron time +- [x] T032 [US5] Add stale daemon detection to `startDaemon()` in `packages/cli/src/scheduler/process.ts` — if PID file exists but process is dead, clean up PID file and state, start fresh +- [x] T033 [US5] Run tests for retry and stale detection + +**Checkpoint**: Scheduling system is resilient to transient failures + +--- + +## Phase 8: User Story 6 — Persist Schedules Across Restarts (Priority: P3) + +**Goal**: Schedule state persists to disk; users can resume schedules after system restart + +**Independent Test**: Start a schedule, kill daemon, run `agentx schedule start ` again, verify schedule resumes without retroactive runs + +### Implementation for User Story 6 + +- [x] T034 [US6] Ensure `saveScheduleState()` writes atomically (write to temp file, rename) in `packages/cli/src/scheduler/state.ts` so state survives crashes mid-write +- [x] T035 [US6] Add `agentx schedule resume` subcommand in `packages/cli/src/commands/schedule.ts` — reads persisted state.json, re-starts daemon with all previously active agents, skips missed runs (waits for next cron time) +- [x] T036 [US6] Run tests, verify resume behavior + +**Checkpoint**: Schedules survive daemon crashes and system restarts + +--- + +## Phase 9: Polish & Cross-Cutting Concerns + +**Purpose**: Integration, edge cases, and cleanup + +- [x] T037 Modify `packages/cli/src/commands/uninstall.ts` to check if agent has an active schedule and stop it (remove from state.json, signal daemon) before removing agent files — per FR-013 +- [x] T038 [P] Add `schedule` block examples to 2 starter agents: add `schedule` to `packages/agents/slack-agent/agent.yaml` (daily standup) and `packages/agents/github-agent/agent.yaml` (daily PR summary) +- [x] T039 [P] Add help text for all schedule subcommands (start, stop, list, logs, resume) with examples in `packages/cli/src/commands/schedule.ts` +- [x] T040 Run full test suite (`npm test --workspace=packages/cli`), typecheck (`npx tsc --noEmit --project packages/cli/tsconfig.json`), verify all tests pass and no TypeScript errors +- [ ] T041 Manual end-to-end test: install a starter agent with schedule block, configure secrets, run `agentx schedule start`, verify daemon runs, check `agentx schedule list` output, wait for a scheduled run, verify `agentx schedule logs` shows output, run `agentx schedule stop`, verify daemon stops + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: No dependencies — can start immediately +- **Foundational (Phase 2)**: Depends on Phase 1 — BLOCKS all user stories +- **US1 (Phase 3)**: Depends on Phase 2 — state + log storage modules +- **US2 (Phase 4)**: Depends on Phase 3 — needs state and log-store modules +- **US3 (Phase 5)**: Depends on Phase 4 — needs state.json written by start command +- **US4 (Phase 6)**: Depends on Phase 4 — needs log files written by daemon +- **US5 (Phase 7)**: Depends on Phase 4 — adds retry to existing daemon +- **US6 (Phase 8)**: Depends on Phase 4 — adds resume to existing commands +- **Polish (Phase 9)**: Depends on all user stories being complete + +### User Story Dependencies + +- **US1 (P1)**: Foundational only — data layer, no CLI commands yet +- **US2 (P1)**: Depends on US1 — needs state.ts and log-store.ts +- **US3 (P2)**: Depends on US2 — reads state.json written by start command +- **US4 (P2)**: Depends on US2 — reads log files written by daemon +- **US5 (P3)**: Depends on US2 — modifies daemon.ts behavior +- **US6 (P3)**: Depends on US2 — modifies state.ts and adds resume command + +Note: US3 and US4 can be done in parallel after US2. US5 and US6 can be done in parallel after US2. + +### Parallel Opportunities + +- T002 and T003 can run in parallel (Setup phase) +- T007 and T008 can run in parallel (US1 tests) +- T009 and T010 can run in parallel (US1 interfaces) +- T014, T015, and T016 can run in parallel (US2 tests) +- Phase 5 (US3) and Phase 6 (US4) can run in parallel after Phase 4 +- Phase 7 (US5) and Phase 8 (US6) can run in parallel after Phase 4 +- T038 and T039 can run in parallel (Polish phase) + +--- + +## Parallel Example: User Story 2 + +``` +# Launch all tests for US2 together: +T014: "Test daemon process management in test/scheduler/process.test.ts" +T015: "Test schedule start command in test/commands/schedule.test.ts" +T016: "Test schedule stop command in test/commands/schedule.test.ts" + +# After tests are written, implement in order: +T017: process.ts (daemon management) +T018: daemon.ts (entry point) +T019: schedule start command +T020: schedule stop command +T021: register in index.ts +T022: tsup entry for daemon +``` + +--- + +## Implementation Strategy + +### MVP First (US1 + US2 Only) + +1. Complete Phase 1: Setup (T001-T003) +2. Complete Phase 2: Schema Extension (T004-T006) +3. Complete Phase 3: US1 — State + Logs (T007-T013) +4. Complete Phase 4: US2 — Start/Stop + Daemon (T014-T023) +5. **STOP and VALIDATE**: `agentx schedule start/stop` works end-to-end +6. This is a shippable MVP — agents can be scheduled and executed + +### Incremental Delivery + +1. Setup + Schema → Schema validated +2. US1 (state + logs) → Data layer ready +3. US2 (start/stop + daemon) → **MVP: scheduling works!** +4. US3 (list) → Visibility into schedules +5. US4 (logs) → Debug past runs +6. US5 (retry) → Resilience +7. US6 (resume) → Persistence across restarts +8. Polish → Production-ready + +--- + +## Notes + +- All imports use `.js` extensions (ESM requirement) +- Build: `npm run build --workspace=packages/cli` +- Test: `npm test --workspace=packages/cli` +- Typecheck: `npx tsc --noEmit --project packages/cli/tsconfig.json` +- The daemon entry point (`daemon.ts`) needs a separate tsup entry or must be resolvable at runtime +- State files use `0o600` permissions for security (consistent with auth.json pattern) +- Commit after each task or logical group