From 0523c482bab142bd078e5aa0dabb4f5e4ce07f54 Mon Sep 17 00:00:00 2001 From: yyovil Date: Mon, 13 Apr 2026 00:34:31 +0530 Subject: [PATCH 01/14] refactor(core): move orchestrator prompt template (#1175) --- packages/core/package.json | 2 +- .../src/__tests__/orchestrator-prompt.test.ts | 80 +++- packages/core/src/orchestrator-prompt.ts | 350 ++++++------------ packages/core/src/prompts/orchestrator.md | 206 +++++++++++ 4 files changed, 401 insertions(+), 237 deletions(-) create mode 100644 packages/core/src/prompts/orchestrator.md diff --git a/packages/core/package.json b/packages/core/package.json index e760f4a77..ebb4db284 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -80,7 +80,7 @@ "node": ">=20.0.0" }, "scripts": { - "build": "tsc -p tsconfig.build.json", + "build": "tsc -p tsconfig.build.json && mkdir -p dist/prompts && cp src/prompts/orchestrator.md dist/prompts/orchestrator.md", "typecheck": "tsc --noEmit", "test": "vitest run", "test:watch": "vitest", diff --git a/packages/core/src/__tests__/orchestrator-prompt.test.ts b/packages/core/src/__tests__/orchestrator-prompt.test.ts index 7283ce9a3..1e9015422 100644 --- a/packages/core/src/__tests__/orchestrator-prompt.test.ts +++ b/packages/core/src/__tests__/orchestrator-prompt.test.ts @@ -1,6 +1,7 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { generateOrchestratorPrompt } from "../orchestrator-prompt.js"; -import type { OrchestratorConfig } from "../types.js"; +import type * as NodeFsModule from "node:fs"; +import type { OrchestratorConfig, ProjectConfig } from "../types.js"; const config: OrchestratorConfig = { configPath: "/tmp/agent-orchestrator.yaml", @@ -32,6 +33,12 @@ const config: OrchestratorConfig = { }; describe("generateOrchestratorPrompt", () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.doUnmock("node:fs"); + vi.resetModules(); + }); + it("requires read-only investigation from the orchestrator session", () => { const prompt = generateOrchestratorPrompt({ config, @@ -66,4 +73,73 @@ describe("generateOrchestratorPrompt", () => { expect(prompt).toContain("Never claim a PR into `app-orchestrator`"); expect(prompt).toContain("Delegate implementation, test execution, or PR claiming"); }); + + it("expands markdown template placeholders with typed render data", () => { + const prompt = generateOrchestratorPrompt({ + config, + projectId: "my-app", + project: config.projects["my-app"]!, + }); + + expect(prompt).toContain("# My App Orchestrator"); + expect(prompt).toContain("- **Repository**: org/my-app"); + expect(prompt).toContain("ao session ls -p my-app"); + expect(prompt).toContain("http://localhost:3000"); + }); + + it("throws when the markdown template contains an unresolved placeholder", async () => { + vi.doMock("node:fs", async () => { + const actual = await vi.importActual("node:fs"); + + return { + ...actual, + readFileSync: vi.fn(() => "Hello {{missingPlaceholder}}"), + }; + }); + + const { generateOrchestratorPrompt: generateWithMockedTemplate } = + await import("../orchestrator-prompt.js"); + + expect(() => + generateWithMockedTemplate({ + config, + projectId: "my-app", + project: config.projects["my-app"]!, + }), + ).toThrow("Unresolved template placeholder: missingPlaceholder"); + }); + + it("renders optional sections only when project data is present", () => { + const projectWithOptionalSections: ProjectConfig = { + ...config.projects["my-app"]!, + reactions: { + ci_failed: { + auto: true, + action: "send-to-agent", + retries: 2, + escalateAfter: 3, + }, + }, + orchestratorRules: "Escalate production incidents immediately.", + }; + + const promptWithOptionalSections = generateOrchestratorPrompt({ + config, + projectId: "my-app", + project: projectWithOptionalSections, + }); + + const promptWithoutOptionalSections = generateOrchestratorPrompt({ + config, + projectId: "my-app", + project: config.projects["my-app"]!, + }); + + expect(promptWithOptionalSections).toContain("## Automated Reactions"); + expect(promptWithOptionalSections).toContain("**ci_failed**"); + expect(promptWithOptionalSections).toContain("## Project-Specific Rules"); + expect(promptWithOptionalSections).toContain("Escalate production incidents immediately."); + expect(promptWithoutOptionalSections).not.toContain("## Automated Reactions"); + expect(promptWithoutOptionalSections).not.toContain("## Project-Specific Rules"); + }); }); diff --git a/packages/core/src/orchestrator-prompt.ts b/packages/core/src/orchestrator-prompt.ts index 043931c99..e8e24d9e9 100644 --- a/packages/core/src/orchestrator-prompt.ts +++ b/packages/core/src/orchestrator-prompt.ts @@ -1,10 +1,13 @@ /** - * Orchestrator Prompt Generator — generates orchestrator prompt content. + * Orchestrator Prompt Generator - generates orchestrator prompt content. * * This is injected via `ao start` to provide orchestrator-specific context * when the orchestrator agent runs. */ +import * as fs from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; import type { OrchestratorConfig, ProjectConfig } from "./types.js"; export interface OrchestratorPromptConfig { @@ -13,243 +16,122 @@ export interface OrchestratorPromptConfig { project: ProjectConfig; } +interface OrchestratorPromptRenderData { + projectId: string; + projectName: string; + projectRepo: string; + projectDefaultBranch: string; + projectSessionPrefix: string; + projectPath: string; + dashboardPort: string; + automatedReactionsSection: string; + projectSpecificRulesSection: string; +} + +const moduleDir = dirname(fileURLToPath(import.meta.url)); +const ORCHESTRATOR_TEMPLATE_PATHS = [ + join(moduleDir, "prompts", "orchestrator.md"), + join(moduleDir, "..", "src", "prompts", "orchestrator.md"), +]; + +function isErrnoException(error: unknown): error is NodeJS.ErrnoException { + return error instanceof Error && "code" in error; +} + +function loadOrchestratorTemplate(): string { + for (const templatePath of ORCHESTRATOR_TEMPLATE_PATHS) { + try { + return fs.readFileSync(templatePath, "utf-8").trim(); + } catch (error) { + if (isErrnoException(error) && error.code === "ENOENT") { + continue; + } + throw error; + } + } + + throw new Error( + `Unable to find orchestrator prompt template. Checked: ${ORCHESTRATOR_TEMPLATE_PATHS.join(", ")}`, + ); +} + +function buildAutomatedReactionsSection(project: ProjectConfig): string { + const reactionLines: string[] = []; + + for (const [event, reaction] of Object.entries(project.reactions ?? {})) { + if (reaction.auto && reaction.action === "send-to-agent") { + reactionLines.push( + `- **${event}**: Auto-sends instruction to agent (retries: ${reaction.retries ?? "none"}, escalates after: ${reaction.escalateAfter ?? "never"})`, + ); + continue; + } + + if (reaction.auto && reaction.action === "notify") { + reactionLines.push( + `- **${event}**: Notifies human (priority: ${reaction.priority ?? "info"})`, + ); + } + } + + if (reactionLines.length === 0) { + return ""; + } + + return `## Automated Reactions + +The system automatically handles these events: + +${reactionLines.join("\n")}`; +} + +function buildProjectSpecificRulesSection(project: ProjectConfig): string { + const rules = project.orchestratorRules?.trim(); + if (!rules) { + return ""; + } + + return `## Project-Specific Rules + +${rules}`; +} + +function createRenderData(opts: OrchestratorPromptConfig): OrchestratorPromptRenderData { + const { config, projectId, project } = opts; + + return { + projectId, + projectName: project.name, + projectRepo: project.repo, + projectDefaultBranch: project.defaultBranch, + projectSessionPrefix: project.sessionPrefix, + projectPath: project.path, + dashboardPort: String(config.port ?? 3000), + automatedReactionsSection: buildAutomatedReactionsSection(project), + projectSpecificRulesSection: buildProjectSpecificRulesSection(project), + }; +} + +function renderTemplate(template: string, data: OrchestratorPromptRenderData): string { + return template.replace(/\{\{([a-zA-Z0-9]+)\}\}/g, (_match, rawKey: string) => { + if (!(rawKey in data)) { + throw new Error(`Unresolved template placeholder: ${rawKey}`); + } + + return data[rawKey as keyof OrchestratorPromptRenderData]; + }); +} + +function normalizeRenderedPrompt(prompt: string): string { + return prompt.replace(/\n{3,}/g, "\n\n").trim(); +} + /** * Generate orchestrator prompt content. * Provides orchestrator agent with context about available commands, * session management workflows, and project configuration. */ export function generateOrchestratorPrompt(opts: OrchestratorPromptConfig): string { - const { config, projectId, project } = opts; - const sections: string[] = []; - - // Header - sections.push(`# ${project.name} Orchestrator - -You are the **orchestrator agent** for the ${project.name} project. - -Your role is to coordinate and manage worker agent sessions. You do NOT write code yourself — you spawn worker agents to do the implementation work, monitor their progress, and intervene when they need help.`); - - sections.push(`## Non-Negotiable Rules - -- Investigations from the orchestrator session are **read-only**. Inspect status, logs, metadata, PR state, and worker output, but do not edit repository files or implement fixes from the orchestrator session. -- Any code change, test run tied to implementation, git branch work, or PR takeover must be delegated to a **worker session**. -- The orchestrator session must never own a PR. Never claim a PR into the orchestrator session, and never treat the orchestrator as the worker responsible for implementation. -- If an investigation discovers follow-up work, either spawn a worker session or direct an existing worker session with clear instructions. -- **Always use \`ao send\` to communicate with sessions** — never use raw \`tmux send-keys\` or \`tmux capture-pane\`. Direct tmux access bypasses busy detection, retry logic, and input sanitization, and breaks multi-line input for some agents (e.g. Codex). -- When a session might be busy, use \`ao send --no-wait \` to send without waiting for the session to become idle.`); - - // Project Info - sections.push(`## Project Info - -- **Name**: ${project.name} -- **Repository**: ${project.repo} -- **Default Branch**: ${project.defaultBranch} -- **Session Prefix**: ${project.sessionPrefix} -- **Local Path**: ${project.path} -- **Dashboard Port**: ${config.port ?? 3000}`); - - // Quick Start - sections.push(`## Quick Start - -\`\`\`bash -# See all sessions at a glance -ao status - -# Spawn sessions for issues (GitHub: #123, Linear: INT-1234, etc.) -ao spawn INT-1234 -ao spawn --claim-pr 123 -ao batch-spawn INT-1 INT-2 INT-3 - -# Spawn a session without a tracker issue (prompt-driven) -ao spawn --prompt "Refactor the auth module to use JWT" - -# List sessions -ao session ls -p ${projectId} - -# Send message to a session -ao send ${project.sessionPrefix}-1 "Your message here" - -# Claim an existing PR for a worker session -ao session claim-pr 123 ${project.sessionPrefix}-1 - -# Kill a session -ao session kill ${project.sessionPrefix}-1 - -# Open all sessions in terminal tabs -ao open ${projectId} -\`\`\``); - - // Available Commands - sections.push(`## Available Commands - -| Command | Description | -|---------|-------------| -| \`ao status\` | Show all sessions with PR/CI/review status | -| \`ao spawn [issue] [--prompt ] [--claim-pr ]\` | Spawn a worker session; use issue ID or --prompt for freeform tasks | -| \`ao batch-spawn \` | Spawn multiple sessions in parallel (project auto-detected) | -| \`ao session ls [-p project]\` | List all sessions (optionally filter by project) | -| \`ao session claim-pr [session]\` | Attach an existing PR to a worker session | -| \`ao session attach \` | Attach to a session's tmux window | -| \`ao session kill \` | Kill a specific session | -| \`ao session cleanup [-p project]\` | Kill completed/merged sessions | -| \`ao send \` | Send a message to a running session | -| \`ao send --no-wait \` | Send without waiting for session to become idle | -| \`ao dashboard\` | Start the web dashboard (http://localhost:${config.port ?? 3000}) | -| \`ao open \` | Open all project sessions in terminal tabs |`); - - // Session Management - sections.push(`## Session Management - -### Spawning Sessions - -When you spawn a session: -1. A git worktree is created from \`${project.defaultBranch}\` -2. A feature branch is created (e.g., \`feat/INT-1234\` for issues, \`session/\` for prompt-driven) -3. A tmux session is started (e.g., \`${project.sessionPrefix}-1\`) -4. The agent is launched with context about the issue or prompt -5. Metadata is written to the project-specific sessions directory - -A tracker issue is **not required**. Use \`--prompt\` to spawn freeform sessions: -\`\`\`bash -ao spawn --prompt "Add rate limiting to the /api/upload endpoint" -\`\`\` - -### Monitoring Progress - -Use \`ao status\` to see: -- Current session status (working, pr_open, review_pending, etc.) -- PR state (open/merged/closed) -- CI status (passing/failing/pending) -- Review decision (approved/changes_requested/pending) -- Unresolved comments count - -### Sending Messages - -Send instructions to a running agent: -\`\`\`bash -ao send ${project.sessionPrefix}-1 "Please address the review comments on your PR" -\`\`\` - -### PR Takeover - -If a worker session needs to continue work on an existing PR: -\`\`\`bash -ao session claim-pr 123 ${project.sessionPrefix}-1 -# or do it at spawn time -ao spawn --claim-pr 123 -\`\`\` - -This updates AO metadata, switches the worker worktree onto the PR branch, and lets lifecycle reactions keep routing CI and review feedback to that worker session. - -Never claim a PR into \`${project.sessionPrefix}-orchestrator\`. If a PR needs implementation or takeover, delegate it to a worker session instead. - -### Investigation Workflow - -When debugging or triaging from the orchestrator session: -1. Inspect with read-only commands such as \`ao status\`, \`ao session ls\`, \`ao session attach\`, and SCM/tracker lookups. -2. Decide whether a worker already owns the work or a new worker is needed. -3. Delegate implementation, test execution, or PR claiming to that worker session. -4. Return to monitoring and coordination once the worker has the task. - -### Cleanup - -Remove completed sessions: -\`\`\`bash -ao session cleanup -p ${projectId} # Kill sessions where PR is merged or issue is closed -\`\`\``); - - // Dashboard - sections.push(`## Dashboard - -The web dashboard runs at **http://localhost:${config.port ?? 3000}**. - -Features: -- Live session cards with activity status -- PR table with CI checks and review state -- Attention zones (merge ready, needs response, working, done) -- One-click actions (send message, kill, merge PR) -- Real-time updates via Server-Sent Events`); - - // Reactions (if configured) - if (project.reactions && Object.keys(project.reactions).length > 0) { - const reactionLines: string[] = []; - for (const [event, reaction] of Object.entries(project.reactions)) { - if (reaction.auto && reaction.action === "send-to-agent") { - reactionLines.push( - `- **${event}**: Auto-sends instruction to agent (retries: ${reaction.retries ?? "none"}, escalates after: ${reaction.escalateAfter ?? "never"})`, - ); - } else if (reaction.auto && reaction.action === "notify") { - reactionLines.push( - `- **${event}**: Notifies human (priority: ${reaction.priority ?? "info"})`, - ); - } - } - - if (reactionLines.length > 0) { - sections.push(`## Automated Reactions - -The system automatically handles these events: - -${reactionLines.join("\n")}`); - } - } - - // Workflows - sections.push(`## Common Workflows - -### Bulk Issue Processing -1. Get list of issues from tracker (GitHub/Linear/etc.) -2. Use \`ao batch-spawn\` to spawn sessions for each issue -3. Monitor with \`ao status\` or the dashboard -4. Agents will fetch, implement, test, PR, and respond to reviews -5. Use \`ao session cleanup\` when PRs are merged - -### Handling Stuck Agents -1. Check \`ao status\` for sessions in "stuck" or "needs_input" state -2. Attach with \`ao session attach \` to see what they're doing -3. Send clarification or instructions with \`ao send '...'\` -4. Or kill and respawn with fresh context if needed - -### PR Review Flow -1. Agent creates PR and pushes -2. CI runs automatically -3. If CI fails: reaction auto-sends fix instructions to agent -4. If reviewers request changes: reaction auto-sends comments to agent -5. When approved + green: notify human to merge (unless auto-merge enabled) - -### Manual Intervention -When an agent needs human judgment: -1. You'll get a notification (desktop/slack/webhook) -2. Check the dashboard or \`ao status\` for details -3. Attach to the session if needed: \`ao session attach \` -4. Send instructions: \`ao send '...'\` -5. Or handle the human-only action yourself (merge PR, close issue, etc.) while keeping implementation in worker sessions.`); - - // Tips - sections.push(`## Tips - -1. **Use batch-spawn for multiple issues** — Much faster than spawning one at a time. - -2. **Check status before spawning** — Avoid creating duplicate sessions for issues already being worked on. - -3. **Let reactions handle routine issues** — CI failures and review comments are auto-forwarded to agents. - -4. **Trust the metadata** — Session metadata tracks branch, PR, status, and more for each session. - -5. **Use the dashboard for overview** — Terminal for details, dashboard for at-a-glance status. - -6. **Cleanup regularly** — \`ao session cleanup\` removes merged/closed sessions and keeps things tidy. - -7. **Monitor the event log** — Full system activity is logged for debugging and auditing. - -8. **Don't micro-manage** — Spawn agents, walk away, let notifications bring you back when needed.`); - - // Project-specific rules (if any) - if (project.orchestratorRules) { - sections.push(`## Project-Specific Rules - -${project.orchestratorRules}`); - } - - return sections.join("\n\n"); + return normalizeRenderedPrompt( + renderTemplate(loadOrchestratorTemplate(), createRenderData(opts)), + ); } diff --git a/packages/core/src/prompts/orchestrator.md b/packages/core/src/prompts/orchestrator.md new file mode 100644 index 000000000..438f08fd2 --- /dev/null +++ b/packages/core/src/prompts/orchestrator.md @@ -0,0 +1,206 @@ +# {{projectName}} Orchestrator + +You are the **orchestrator agent** for the {{projectName}} project. + +Your role is to coordinate and manage worker agent sessions. You do NOT write code yourself - you spawn worker agents to do the implementation work, monitor their progress, and intervene when they need help. + +## Non-Negotiable Rules + +- Investigations from the orchestrator session are **read-only**. Inspect status, logs, metadata, PR state, and worker output, but do not edit repository files or implement fixes from the orchestrator session. +- Any code change, test run tied to implementation, git branch work, or PR takeover must be delegated to a **worker session**. +- The orchestrator session must never own a PR. Never claim a PR into the orchestrator session, and never treat the orchestrator as the worker responsible for implementation. +- If an investigation discovers follow-up work, either spawn a worker session or direct an existing worker session with clear instructions. +- **Always use `ao send` to communicate with sessions** - never use raw `tmux send-keys` or `tmux capture-pane`. Direct tmux access bypasses busy detection, retry logic, and input sanitization, and breaks multi-line input for some agents (e.g. Codex). +- When a session might be busy, use `ao send --no-wait ` to send without waiting for the session to become idle. + +## Project Info + +- **Name**: {{projectName}} +- **Repository**: {{projectRepo}} +- **Default Branch**: {{projectDefaultBranch}} +- **Session Prefix**: {{projectSessionPrefix}} +- **Local Path**: {{projectPath}} +- **Dashboard Port**: {{dashboardPort}} + +## Quick Start + +```bash +# See all sessions at a glance +ao status + +# Spawn sessions for issues (GitHub: #123, Linear: INT-1234, etc.) +ao spawn INT-1234 +ao spawn --claim-pr 123 +ao batch-spawn INT-1 INT-2 INT-3 + +# Spawn a session without a tracker issue (prompt-driven) +ao spawn --prompt "Refactor the auth module to use JWT" + +# List sessions +ao session ls -p {{projectId}} + +# Send message to a session +ao send {{projectSessionPrefix}}-1 "Your message here" + +# Claim an existing PR for a worker session +ao session claim-pr 123 {{projectSessionPrefix}}-1 + +# Kill a session +ao session kill {{projectSessionPrefix}}-1 + +# Open all sessions in terminal tabs +ao open {{projectId}} +``` + +## Available Commands + +| Command | Description | +| ------------------------------------------------------ | ------------------------------------------------------------------- | +| `ao status` | Show all sessions with PR/CI/review status | +| `ao spawn [issue] [--prompt ] [--claim-pr ]` | Spawn a worker session; use issue ID or --prompt for freeform tasks | +| `ao batch-spawn ` | Spawn multiple sessions in parallel (project auto-detected) | +| `ao session ls [-p project]` | List all sessions (optionally filter by project) | +| `ao session claim-pr [session]` | Attach an existing PR to a worker session | +| `ao session attach ` | Attach to a session's tmux window | +| `ao session kill ` | Kill a specific session | +| `ao session cleanup [-p project]` | Kill completed/merged sessions | +| `ao send ` | Send a message to a running session | +| `ao send --no-wait ` | Send without waiting for session to become idle | +| `ao dashboard` | Start the web dashboard (http://localhost:{{dashboardPort}}) | +| `ao open ` | Open all project sessions in terminal tabs | + +## Session Management + +### Spawning Sessions + +When you spawn a session: + +1. A git worktree is created from `{{projectDefaultBranch}}` +2. A feature branch is created (e.g., `feat/INT-1234` for issues, `session/` for prompt-driven) +3. A tmux session is started (e.g., `{{projectSessionPrefix}}-1`) +4. The agent is launched with context about the issue or prompt +5. Metadata is written to the project-specific sessions directory + +A tracker issue is **not required**. Use `--prompt` to spawn freeform sessions: + +```bash +ao spawn --prompt "Add rate limiting to the /api/upload endpoint" +``` + +### Monitoring Progress + +Use `ao status` to see: + +- Current session status (working, pr_open, review_pending, etc.) +- PR state (open/merged/closed) +- CI status (passing/failing/pending) +- Review decision (approved/changes_requested/pending) +- Unresolved comments count + +### Sending Messages + +Send instructions to a running agent: + +```bash +ao send {{projectSessionPrefix}}-1 "Please address the review comments on your PR" +``` + +### PR Takeover + +If a worker session needs to continue work on an existing PR: + +```bash +ao session claim-pr 123 {{projectSessionPrefix}}-1 +# or do it at spawn time +ao spawn --claim-pr 123 +``` + +This updates AO metadata, switches the worker worktree onto the PR branch, and lets lifecycle reactions keep routing CI and review feedback to that worker session. + +Never claim a PR into `{{projectSessionPrefix}}-orchestrator`. If a PR needs implementation or takeover, delegate it to a worker session instead. + +### Investigation Workflow + +When debugging or triaging from the orchestrator session: + +1. Inspect with read-only commands such as `ao status`, `ao session ls`, `ao session attach`, and SCM/tracker lookups. +2. Decide whether a worker already owns the work or a new worker is needed. +3. Delegate implementation, test execution, or PR claiming to that worker session. +4. Return to monitoring and coordination once the worker has the task. + +### Cleanup + +Remove completed sessions: + +```bash +ao session cleanup -p {{projectId}} # Kill sessions where PR is merged or issue is closed +``` + +## Dashboard + +The web dashboard runs at **http://localhost:{{dashboardPort}}**. + +Features: + +- Live session cards with activity status +- PR table with CI checks and review state +- Attention zones (merge ready, needs response, working, done) +- One-click actions (send message, kill, merge PR) +- Real-time updates via Server-Sent Events + +{{automatedReactionsSection}} + +## Common Workflows + +### Bulk Issue Processing + +1. Get list of issues from tracker (GitHub/Linear/etc.) +2. Use `ao batch-spawn` to spawn sessions for each issue +3. Monitor with `ao status` or the dashboard +4. Agents will fetch, implement, test, PR, and respond to reviews +5. Use `ao session cleanup` when PRs are merged + +### Handling Stuck Agents + +1. Check `ao status` for sessions in "stuck" or "needs_input" state +2. Attach with `ao session attach ` to see what they're doing +3. Send clarification or instructions with `ao send '...'` +4. Or kill and respawn with fresh context if needed + +### PR Review Flow + +1. Agent creates PR and pushes +2. CI runs automatically +3. If CI fails: reaction auto-sends fix instructions to agent +4. If reviewers request changes: reaction auto-sends comments to agent +5. When approved + green: notify human to merge (unless auto-merge enabled) + +### Manual Intervention + +When an agent needs human judgment: + +1. You'll get a notification (desktop/slack/webhook) +2. Check the dashboard or `ao status` for details +3. Attach to the session if needed: `ao session attach ` +4. Send instructions: `ao send '...'` +5. Or handle the human-only action yourself (merge PR, close issue, etc.) while keeping implementation in worker sessions. + +## Tips + +1. **Use batch-spawn for multiple issues** - Much faster than spawning one at a time. + +2. **Check status before spawning** - Avoid creating duplicate sessions for issues already being worked on. + +3. **Let reactions handle routine issues** - CI failures and review comments are auto-forwarded to agents. + +4. **Trust the metadata** - Session metadata tracks branch, PR, status, and more for each session. + +5. **Use the dashboard for overview** - Terminal for details, dashboard for at-a-glance status. + +6. **Cleanup regularly** - `ao session cleanup` removes merged/closed sessions and keeps things tidy. + +7. **Monitor the event log** - Full system activity is logged for debugging and auditing. + +8. **Don't micro-manage** - Spawn agents, walk away, let notifications bring you back when needed. + +{{projectSpecificRulesSection}} From 84e669a5f95bdd0a681159cf0b8b960c5b7f51c5 Mon Sep 17 00:00:00 2001 From: yyovil Date: Mon, 13 Apr 2026 13:19:29 +0530 Subject: [PATCH 02/14] refactor(core): improve orchestrator prompt template flow Entire-Checkpoint: 1c43a93af76f --- .github/pr-1175.md | 14 +++++ .../src/__tests__/orchestrator-prompt.test.ts | 2 +- packages/core/src/orchestrator-prompt.ts | 63 +++++++++++++++---- packages/core/src/prompts/orchestrator.md | 10 +++ 4 files changed, 76 insertions(+), 13 deletions(-) create mode 100644 .github/pr-1175.md diff --git a/.github/pr-1175.md b/.github/pr-1175.md new file mode 100644 index 000000000..bebaf0dd4 --- /dev/null +++ b/.github/pr-1175.md @@ -0,0 +1,14 @@ +## What Changed +- Move orchestrator prompt content assembly into `orchestrator.md` and keep `orchestrator-prompt.ts` focused on runtime data and safe interpolation. +- Make optional sections (`Automated Reactions`, `Project-Specific Rules`) template-driven and conditionally rendered. +- Preserve existing output formatting so rendered prompt text remains unchanged. + +## Why +- Keeps template edits independent from TypeScript logic, matching requested readability/refactor direction. +- Removes inline markdown from core prompt generator and makes future edits safer. + +## Validation +- `pnpm --filter @aoagents/ao-core build` +- `pnpm --filter @aoagents/ao-core test -- orchestrator-prompt.test.ts` + +Fixes #1175 diff --git a/packages/core/src/__tests__/orchestrator-prompt.test.ts b/packages/core/src/__tests__/orchestrator-prompt.test.ts index 1e9015422..e33769ea7 100644 --- a/packages/core/src/__tests__/orchestrator-prompt.test.ts +++ b/packages/core/src/__tests__/orchestrator-prompt.test.ts @@ -136,7 +136,7 @@ describe("generateOrchestratorPrompt", () => { }); expect(promptWithOptionalSections).toContain("## Automated Reactions"); - expect(promptWithOptionalSections).toContain("**ci_failed**"); + expect(promptWithOptionalSections).toContain("ci_failed"); expect(promptWithOptionalSections).toContain("## Project-Specific Rules"); expect(promptWithOptionalSections).toContain("Escalate production incidents immediately."); expect(promptWithoutOptionalSections).not.toContain("## Automated Reactions"); diff --git a/packages/core/src/orchestrator-prompt.ts b/packages/core/src/orchestrator-prompt.ts index e8e24d9e9..807bbc860 100644 --- a/packages/core/src/orchestrator-prompt.ts +++ b/packages/core/src/orchestrator-prompt.ts @@ -29,9 +29,11 @@ interface OrchestratorPromptRenderData { } const moduleDir = dirname(fileURLToPath(import.meta.url)); +const ORCHESTRATOR_PROMPT_DIR = "prompts"; +const ORCHESTRATOR_PROMPT_TEMPLATE = "orchestrator.md"; const ORCHESTRATOR_TEMPLATE_PATHS = [ - join(moduleDir, "prompts", "orchestrator.md"), - join(moduleDir, "..", "src", "prompts", "orchestrator.md"), + join(moduleDir, ORCHESTRATOR_PROMPT_DIR, ORCHESTRATOR_PROMPT_TEMPLATE), + join(moduleDir, "..", "src", ORCHESTRATOR_PROMPT_DIR, ORCHESTRATOR_PROMPT_TEMPLATE), ]; function isErrnoException(error: unknown): error is NodeJS.ErrnoException { @@ -56,19 +58,22 @@ function loadOrchestratorTemplate(): string { } function buildAutomatedReactionsSection(project: ProjectConfig): string { + const markdownBold = String.fromCharCode(42).repeat(2); + const bold = (text: string): string => `${markdownBold}${text}${markdownBold}`; + const reactionLines: string[] = []; for (const [event, reaction] of Object.entries(project.reactions ?? {})) { if (reaction.auto && reaction.action === "send-to-agent") { reactionLines.push( - `- **${event}**: Auto-sends instruction to agent (retries: ${reaction.retries ?? "none"}, escalates after: ${reaction.escalateAfter ?? "never"})`, + `- ${bold(event)}: Auto-sends instruction to agent (retries: ${reaction.retries ?? "none"}, escalates after: ${reaction.escalateAfter ?? "never"})`, ); continue; } if (reaction.auto && reaction.action === "notify") { reactionLines.push( - `- **${event}**: Notifies human (priority: ${reaction.priority ?? "info"})`, + `- ${bold(event)}: Notifies human (priority: ${reaction.priority ?? "info"})`, ); } } @@ -77,11 +82,7 @@ function buildAutomatedReactionsSection(project: ProjectConfig): string { return ""; } - return `## Automated Reactions - -The system automatically handles these events: - -${reactionLines.join("\n")}`; + return reactionLines.join("\n"); } function buildProjectSpecificRulesSection(project: ProjectConfig): string { @@ -90,9 +91,43 @@ function buildProjectSpecificRulesSection(project: ProjectConfig): string { return ""; } - return `## Project-Specific Rules + return rules; +} -${rules}`; +function removeOptionalSectionBlocks( + template: string, + data: OrchestratorPromptRenderData, +): string { + const templates = [ + ["AUTOMATED_REACTIONS_SECTION_START", "AUTOMATED_REACTIONS_SECTION_END", data.automatedReactionsSection], + ["PROJECT_SPECIFIC_RULES_SECTION_START", "PROJECT_SPECIFIC_RULES_SECTION_END", data.projectSpecificRulesSection], + ] as const; + + let interpolated = template; + for (const [startKey, endKey, section] of templates) { + const startMarker = `{{${startKey}}}`; + const endMarker = `{{${endKey}}}`; + const start = interpolated.indexOf(startMarker); + if (start === -1) { + continue; + } + const end = interpolated.indexOf(endMarker, start); + if (end === -1) { + continue; + } + + const fullStart = start; + const fullEnd = end + endMarker.length; + const blockContent = interpolated.slice(start + startMarker.length, end); + const replacement = section ? blockContent : ""; + + interpolated = + interpolated.slice(0, fullStart) + + replacement + + interpolated.slice(fullEnd); + } + + return interpolated; } function createRenderData(opts: OrchestratorPromptConfig): OrchestratorPromptRenderData { @@ -131,7 +166,11 @@ function normalizeRenderedPrompt(prompt: string): string { * session management workflows, and project configuration. */ export function generateOrchestratorPrompt(opts: OrchestratorPromptConfig): string { + const data = createRenderData(opts); + const template = loadOrchestratorTemplate(); + const templateWithOptionalSections = removeOptionalSectionBlocks(template, data); + return normalizeRenderedPrompt( - renderTemplate(loadOrchestratorTemplate(), createRenderData(opts)), + renderTemplate(templateWithOptionalSections, data), ); } diff --git a/packages/core/src/prompts/orchestrator.md b/packages/core/src/prompts/orchestrator.md index 438f08fd2..9de7430e5 100644 --- a/packages/core/src/prompts/orchestrator.md +++ b/packages/core/src/prompts/orchestrator.md @@ -148,7 +148,13 @@ Features: - One-click actions (send message, kill, merge PR) - Real-time updates via Server-Sent Events +{{AUTOMATED_REACTIONS_SECTION_START}} +## Automated Reactions + +The system automatically handles these events: + {{automatedReactionsSection}} +{{AUTOMATED_REACTIONS_SECTION_END}} ## Common Workflows @@ -203,4 +209,8 @@ When an agent needs human judgment: 8. **Don't micro-manage** - Spawn agents, walk away, let notifications bring you back when needed. +{{PROJECT_SPECIFIC_RULES_SECTION_START}} +## Project-Specific Rules + {{projectSpecificRulesSection}} +{{PROJECT_SPECIFIC_RULES_SECTION_END}} From 3c9c534b65f7cca3647cd736dc3f8175ff20a455 Mon Sep 17 00:00:00 2001 From: yyovil Date: Mon, 13 Apr 2026 13:24:11 +0530 Subject: [PATCH 03/14] chore(core): add concise PR body for issue 1175 --- .github/pr-1175.md | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/.github/pr-1175.md b/.github/pr-1175.md index bebaf0dd4..f133e6631 100644 --- a/.github/pr-1175.md +++ b/.github/pr-1175.md @@ -1,11 +1,7 @@ -## What Changed -- Move orchestrator prompt content assembly into `orchestrator.md` and keep `orchestrator-prompt.ts` focused on runtime data and safe interpolation. -- Make optional sections (`Automated Reactions`, `Project-Specific Rules`) template-driven and conditionally rendered. -- Preserve existing output formatting so rendered prompt text remains unchanged. - -## Why -- Keeps template edits independent from TypeScript logic, matching requested readability/refactor direction. -- Removes inline markdown from core prompt generator and makes future edits safer. +## Summary +This PR moves orchestrator prompt generation to a template-first flow in `packages/core/src/prompts/orchestrator.md` and makes `packages/core/src/orchestrator-prompt.ts` responsible for data preparation and template interpolation only. +I also made optional sections (`Automated Reactions`, `Project-Specific Rules`) conditional at render-time while preserving output formatting, so missing data cleanly drops the whole block. +Tests were updated to cover the optional-section behavior and unresolved placeholder errors. ## Validation - `pnpm --filter @aoagents/ao-core build` From 64586e87d363137ec28659e5072dbcb771c7f33c Mon Sep 17 00:00:00 2001 From: yyovil Date: Mon, 13 Apr 2026 13:32:12 +0530 Subject: [PATCH 04/14] --amend --- packages/core/src/prompts/orchestrator.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/core/src/prompts/orchestrator.md b/packages/core/src/prompts/orchestrator.md index 9de7430e5..8f457aef7 100644 --- a/packages/core/src/prompts/orchestrator.md +++ b/packages/core/src/prompts/orchestrator.md @@ -66,7 +66,7 @@ ao open {{projectId}} | `ao session cleanup [-p project]` | Kill completed/merged sessions | | `ao send ` | Send a message to a running session | | `ao send --no-wait ` | Send without waiting for session to become idle | -| `ao dashboard` | Start the web dashboard (http://localhost:{{dashboardPort}}) | +| `ao dashboard` | Start the web dashboard () | | `ao open ` | Open all project sessions in terminal tabs | ## Session Management @@ -138,7 +138,7 @@ ao session cleanup -p {{projectId}} # Kill sessions where PR is merged or issue ## Dashboard -The web dashboard runs at **http://localhost:{{dashboardPort}}**. +The web dashboard runs at ****. Features: @@ -149,6 +149,7 @@ Features: - Real-time updates via Server-Sent Events {{AUTOMATED_REACTIONS_SECTION_START}} + ## Automated Reactions The system automatically handles these events: @@ -210,6 +211,7 @@ When an agent needs human judgment: 8. **Don't micro-manage** - Spawn agents, walk away, let notifications bring you back when needed. {{PROJECT_SPECIFIC_RULES_SECTION_START}} + ## Project-Specific Rules {{projectSpecificRulesSection}} From b7e2a985863ac9bd1906f83016afd479ed762e98 Mon Sep 17 00:00:00 2001 From: yyovil Date: Mon, 13 Apr 2026 14:03:31 +0530 Subject: [PATCH 05/14] Delete .github/pr-1175.md irrelevant --- .github/pr-1175.md | 10 ---------- 1 file changed, 10 deletions(-) delete mode 100644 .github/pr-1175.md diff --git a/.github/pr-1175.md b/.github/pr-1175.md deleted file mode 100644 index f133e6631..000000000 --- a/.github/pr-1175.md +++ /dev/null @@ -1,10 +0,0 @@ -## Summary -This PR moves orchestrator prompt generation to a template-first flow in `packages/core/src/prompts/orchestrator.md` and makes `packages/core/src/orchestrator-prompt.ts` responsible for data preparation and template interpolation only. -I also made optional sections (`Automated Reactions`, `Project-Specific Rules`) conditional at render-time while preserving output formatting, so missing data cleanly drops the whole block. -Tests were updated to cover the optional-section behavior and unresolved placeholder errors. - -## Validation -- `pnpm --filter @aoagents/ao-core build` -- `pnpm --filter @aoagents/ao-core test -- orchestrator-prompt.test.ts` - -Fixes #1175 From c977be1be5007faf34f88d55486c5ef426ef6a9d Mon Sep 17 00:00:00 2001 From: yyovil Date: Mon, 13 Apr 2026 14:15:45 +0530 Subject: [PATCH 06/14] fix(core): harden prompt template validation Entire-Checkpoint: 5049cee2a3a0 --- .../src/__tests__/orchestrator-prompt.test.ts | 85 ++++++++++++++----- packages/core/src/orchestrator-prompt.ts | 27 ++++-- 2 files changed, 84 insertions(+), 28 deletions(-) diff --git a/packages/core/src/__tests__/orchestrator-prompt.test.ts b/packages/core/src/__tests__/orchestrator-prompt.test.ts index e33769ea7..58e4a9e1e 100644 --- a/packages/core/src/__tests__/orchestrator-prompt.test.ts +++ b/packages/core/src/__tests__/orchestrator-prompt.test.ts @@ -1,6 +1,4 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { generateOrchestratorPrompt } from "../orchestrator-prompt.js"; -import type * as NodeFsModule from "node:fs"; import type { OrchestratorConfig, ProjectConfig } from "../types.js"; const config: OrchestratorConfig = { @@ -32,6 +30,25 @@ const config: OrchestratorConfig = { readyThresholdMs: 300_000, }; +async function loadGenerateOrchestratorPrompt() { + vi.resetModules(); + return (await import("../orchestrator-prompt.js")).generateOrchestratorPrompt; +} + +async function loadGenerateOrchestratorPromptWithTemplate(template: string) { + vi.resetModules(); + vi.doMock("node:fs", async () => { + const actual = await vi.importActual("node:fs"); + + return { + ...actual, + readFileSync: vi.fn(() => template), + }; + }); + + return (await import("../orchestrator-prompt.js")).generateOrchestratorPrompt; +} + describe("generateOrchestratorPrompt", () => { afterEach(() => { vi.restoreAllMocks(); @@ -39,7 +56,8 @@ describe("generateOrchestratorPrompt", () => { vi.resetModules(); }); - it("requires read-only investigation from the orchestrator session", () => { + it("requires read-only investigation from the orchestrator session", async () => { + const generateOrchestratorPrompt = await loadGenerateOrchestratorPrompt(); const prompt = generateOrchestratorPrompt({ config, projectId: "my-app", @@ -50,7 +68,8 @@ describe("generateOrchestratorPrompt", () => { expect(prompt).toContain("do not edit repository files or implement fixes"); }); - it("mandates ao send and bans raw tmux access", () => { + it("mandates ao send and bans raw tmux access", async () => { + const generateOrchestratorPrompt = await loadGenerateOrchestratorPrompt(); const prompt = generateOrchestratorPrompt({ config, projectId: "my-app", @@ -62,7 +81,8 @@ describe("generateOrchestratorPrompt", () => { expect(prompt).toContain("ao send --no-wait"); }); - it("pushes implementation and PR claiming into worker sessions", () => { + it("pushes implementation and PR claiming into worker sessions", async () => { + const generateOrchestratorPrompt = await loadGenerateOrchestratorPrompt(); const prompt = generateOrchestratorPrompt({ config, projectId: "my-app", @@ -74,7 +94,8 @@ describe("generateOrchestratorPrompt", () => { expect(prompt).toContain("Delegate implementation, test execution, or PR claiming"); }); - it("expands markdown template placeholders with typed render data", () => { + it("expands markdown template placeholders with typed render data", async () => { + const generateOrchestratorPrompt = await loadGenerateOrchestratorPrompt(); const prompt = generateOrchestratorPrompt({ config, projectId: "my-app", @@ -87,29 +108,51 @@ describe("generateOrchestratorPrompt", () => { expect(prompt).toContain("http://localhost:3000"); }); - it("throws when the markdown template contains an unresolved placeholder", async () => { - vi.doMock("node:fs", async () => { - const actual = await vi.importActual("node:fs"); - - return { - ...actual, - readFileSync: vi.fn(() => "Hello {{missingPlaceholder}}"), - }; - }); - - const { generateOrchestratorPrompt: generateWithMockedTemplate } = - await import("../orchestrator-prompt.js"); + it("throws when the markdown template contains an unresolved snake_case placeholder", async () => { + const generateOrchestratorPrompt = + await loadGenerateOrchestratorPromptWithTemplate("Hello {{missing_placeholder}}"); expect(() => - generateWithMockedTemplate({ + generateOrchestratorPrompt({ config, projectId: "my-app", project: config.projects["my-app"]!, }), - ).toThrow("Unresolved template placeholder: missingPlaceholder"); + ).toThrow("Unresolved template placeholder: missing_placeholder"); }); - it("renders optional sections only when project data is present", () => { + it("throws when the markdown template placeholder matches a prototype property", async () => { + const generateOrchestratorPrompt = + await loadGenerateOrchestratorPromptWithTemplate("Hello {{toString}}"); + + expect(() => + generateOrchestratorPrompt({ + config, + projectId: "my-app", + project: config.projects["my-app"]!, + }), + ).toThrow("Unresolved template placeholder: toString"); + }); + + it("throws when the markdown template contains an unmatched optional-section marker", async () => { + const generateOrchestratorPrompt = + await loadGenerateOrchestratorPromptWithTemplate( + "{{AUTOMATED_REACTIONS_SECTION_START}}\n{{automatedReactionsSection}}", + ); + + expect(() => + generateOrchestratorPrompt({ + config, + projectId: "my-app", + project: config.projects["my-app"]!, + }), + ).toThrow( + "Malformed optional section block: expected {{AUTOMATED_REACTIONS_SECTION_START}} before {{AUTOMATED_REACTIONS_SECTION_END}}", + ); + }); + + it("renders optional sections only when project data is present", async () => { + const generateOrchestratorPrompt = await loadGenerateOrchestratorPrompt(); const projectWithOptionalSections: ProjectConfig = { ...config.projects["my-app"]!, reactions: { diff --git a/packages/core/src/orchestrator-prompt.ts b/packages/core/src/orchestrator-prompt.ts index 807bbc860..ed9ef5b95 100644 --- a/packages/core/src/orchestrator-prompt.ts +++ b/packages/core/src/orchestrator-prompt.ts @@ -28,6 +28,8 @@ interface OrchestratorPromptRenderData { projectSpecificRulesSection: string; } +type OrchestratorPromptRenderKey = keyof OrchestratorPromptRenderData; + const moduleDir = dirname(fileURLToPath(import.meta.url)); const ORCHESTRATOR_PROMPT_DIR = "prompts"; const ORCHESTRATOR_PROMPT_TEMPLATE = "orchestrator.md"; @@ -108,12 +110,16 @@ function removeOptionalSectionBlocks( const startMarker = `{{${startKey}}}`; const endMarker = `{{${endKey}}}`; const start = interpolated.indexOf(startMarker); - if (start === -1) { + const end = interpolated.indexOf(endMarker); + + if (start === -1 && end === -1) { continue; } - const end = interpolated.indexOf(endMarker, start); - if (end === -1) { - continue; + + if (start === -1 || end === -1 || end < start) { + throw new Error( + `Malformed optional section block: expected ${startMarker} before ${endMarker}`, + ); } const fullStart = start; @@ -130,6 +136,13 @@ function removeOptionalSectionBlocks( return interpolated; } +function hasRenderDataKey( + data: OrchestratorPromptRenderData, + key: string, +): key is OrchestratorPromptRenderKey { + return Object.prototype.hasOwnProperty.call(data, key); +} + function createRenderData(opts: OrchestratorPromptConfig): OrchestratorPromptRenderData { const { config, projectId, project } = opts; @@ -147,12 +160,12 @@ function createRenderData(opts: OrchestratorPromptConfig): OrchestratorPromptRen } function renderTemplate(template: string, data: OrchestratorPromptRenderData): string { - return template.replace(/\{\{([a-zA-Z0-9]+)\}\}/g, (_match, rawKey: string) => { - if (!(rawKey in data)) { + return template.replace(/\{\{([a-zA-Z0-9_]+)\}\}/g, (_match, rawKey: string) => { + if (!hasRenderDataKey(data, rawKey)) { throw new Error(`Unresolved template placeholder: ${rawKey}`); } - return data[rawKey as keyof OrchestratorPromptRenderData]; + return data[rawKey]; }); } From dc8b9d6211aca8f6d842d87160802f5520000763 Mon Sep 17 00:00:00 2001 From: yyovil Date: Mon, 13 Apr 2026 14:55:01 +0530 Subject: [PATCH 07/14] build(core): bundle prompt templates with rollup --- packages/core/package.json | 7 +- packages/core/rollup.config.mjs | 61 ++++++++++++++ .../src/__tests__/orchestrator-prompt.test.ts | 13 +-- packages/core/src/markdown.d.ts | 4 + packages/core/src/orchestrator-prompt.ts | 36 +------- packages/core/vitest.config.ts | 16 +++- pnpm-lock.yaml | 84 +++++++++++++++++++ 7 files changed, 175 insertions(+), 46 deletions(-) create mode 100644 packages/core/rollup.config.mjs create mode 100644 packages/core/src/markdown.d.ts diff --git a/packages/core/package.json b/packages/core/package.json index ebb4db284..93dc3a2b8 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -80,7 +80,7 @@ "node": ">=20.0.0" }, "scripts": { - "build": "tsc -p tsconfig.build.json && mkdir -p dist/prompts && cp src/prompts/orchestrator.md dist/prompts/orchestrator.md", + "build": "rm -rf dist && rollup -c rollup.config.mjs && tsc -p tsconfig.build.json --emitDeclarationOnly", "typecheck": "tsc --noEmit", "test": "vitest run", "test:watch": "vitest", @@ -91,8 +91,11 @@ "zod": "^3.24.0" }, "devDependencies": { - "@vitest/coverage-v8": "^4.0.18", + "@rollup/plugin-typescript": "^12.3.0", "@types/node": "^25.2.3", + "@vitest/coverage-v8": "^4.0.18", + "rollup": "^4.60.1", + "tslib": "^2.8.1", "typescript": "^5.7.0", "vitest": "^4.0.18" } diff --git a/packages/core/rollup.config.mjs b/packages/core/rollup.config.mjs new file mode 100644 index 000000000..7ef7645c0 --- /dev/null +++ b/packages/core/rollup.config.mjs @@ -0,0 +1,61 @@ +import { readFile } from "node:fs/promises"; +import { builtinModules } from "node:module"; +import typescript from "@rollup/plugin-typescript"; + +const externalPackages = new Set(["yaml", "zod"]); +const nodeBuiltins = new Set([ + ...builtinModules, + ...builtinModules.map((moduleName) => `node:${moduleName}`), +]); + +function getPackageName(importId) { + if (importId.startsWith(".") || importId.startsWith("/")) { + return null; + } + + const parts = importId.split("/"); + return importId.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0]; +} + +function rawMarkdown() { + return { + name: "raw-markdown", + async load(id) { + if (!id.endsWith(".md")) { + return null; + } + + return `export default ${JSON.stringify(await readFile(id, "utf8"))};`; + }, + }; +} + +export default { + input: "src/index.ts", + output: { + dir: "dist", + format: "es", + preserveModules: true, + preserveModulesRoot: "src", + sourcemap: true, + }, + external(id) { + if (nodeBuiltins.has(id) || id.startsWith("node:")) { + return true; + } + + const packageName = getPackageName(id); + return packageName ? externalPackages.has(packageName) : false; + }, + plugins: [ + rawMarkdown(), + typescript({ + compilerOptions: { + declaration: false, + declarationMap: false, + module: "Node16", + }, + tsconfig: "./tsconfig.json", + }), + ], +}; diff --git a/packages/core/src/__tests__/orchestrator-prompt.test.ts b/packages/core/src/__tests__/orchestrator-prompt.test.ts index 58e4a9e1e..29bfc80ba 100644 --- a/packages/core/src/__tests__/orchestrator-prompt.test.ts +++ b/packages/core/src/__tests__/orchestrator-prompt.test.ts @@ -37,14 +37,9 @@ async function loadGenerateOrchestratorPrompt() { async function loadGenerateOrchestratorPromptWithTemplate(template: string) { vi.resetModules(); - vi.doMock("node:fs", async () => { - const actual = await vi.importActual("node:fs"); - - return { - ...actual, - readFileSync: vi.fn(() => template), - }; - }); + vi.doMock("../prompts/orchestrator.md", () => ({ + default: template, + })); return (await import("../orchestrator-prompt.js")).generateOrchestratorPrompt; } @@ -52,7 +47,7 @@ async function loadGenerateOrchestratorPromptWithTemplate(template: string) { describe("generateOrchestratorPrompt", () => { afterEach(() => { vi.restoreAllMocks(); - vi.doUnmock("node:fs"); + vi.doUnmock("../prompts/orchestrator.md"); vi.resetModules(); }); diff --git a/packages/core/src/markdown.d.ts b/packages/core/src/markdown.d.ts new file mode 100644 index 000000000..c94d67b1a --- /dev/null +++ b/packages/core/src/markdown.d.ts @@ -0,0 +1,4 @@ +declare module "*.md" { + const content: string; + export default content; +} diff --git a/packages/core/src/orchestrator-prompt.ts b/packages/core/src/orchestrator-prompt.ts index ed9ef5b95..4d42e37fd 100644 --- a/packages/core/src/orchestrator-prompt.ts +++ b/packages/core/src/orchestrator-prompt.ts @@ -5,9 +5,7 @@ * when the orchestrator agent runs. */ -import * as fs from "node:fs"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; +import orchestratorTemplate from "./prompts/orchestrator.md"; import type { OrchestratorConfig, ProjectConfig } from "./types.js"; export interface OrchestratorPromptConfig { @@ -30,35 +28,6 @@ interface OrchestratorPromptRenderData { type OrchestratorPromptRenderKey = keyof OrchestratorPromptRenderData; -const moduleDir = dirname(fileURLToPath(import.meta.url)); -const ORCHESTRATOR_PROMPT_DIR = "prompts"; -const ORCHESTRATOR_PROMPT_TEMPLATE = "orchestrator.md"; -const ORCHESTRATOR_TEMPLATE_PATHS = [ - join(moduleDir, ORCHESTRATOR_PROMPT_DIR, ORCHESTRATOR_PROMPT_TEMPLATE), - join(moduleDir, "..", "src", ORCHESTRATOR_PROMPT_DIR, ORCHESTRATOR_PROMPT_TEMPLATE), -]; - -function isErrnoException(error: unknown): error is NodeJS.ErrnoException { - return error instanceof Error && "code" in error; -} - -function loadOrchestratorTemplate(): string { - for (const templatePath of ORCHESTRATOR_TEMPLATE_PATHS) { - try { - return fs.readFileSync(templatePath, "utf-8").trim(); - } catch (error) { - if (isErrnoException(error) && error.code === "ENOENT") { - continue; - } - throw error; - } - } - - throw new Error( - `Unable to find orchestrator prompt template. Checked: ${ORCHESTRATOR_TEMPLATE_PATHS.join(", ")}`, - ); -} - function buildAutomatedReactionsSection(project: ProjectConfig): string { const markdownBold = String.fromCharCode(42).repeat(2); const bold = (text: string): string => `${markdownBold}${text}${markdownBold}`; @@ -180,8 +149,7 @@ function normalizeRenderedPrompt(prompt: string): string { */ export function generateOrchestratorPrompt(opts: OrchestratorPromptConfig): string { const data = createRenderData(opts); - const template = loadOrchestratorTemplate(); - const templateWithOptionalSections = removeOptionalSectionBlocks(template, data); + const templateWithOptionalSections = removeOptionalSectionBlocks(orchestratorTemplate.trim(), data); return normalizeRenderedPrompt( renderTemplate(templateWithOptionalSections, data), diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts index e0b2771ee..9654a19dc 100644 --- a/packages/core/vitest.config.ts +++ b/packages/core/vitest.config.ts @@ -1,10 +1,24 @@ -import { defineConfig } from "vitest/config"; +import { readFile } from "node:fs/promises"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { defineConfig } from "vitest/config"; const __dirname = dirname(fileURLToPath(import.meta.url)); export default defineConfig({ + plugins: [ + { + name: "raw-markdown", + enforce: "pre", + async load(id) { + if (!id.endsWith(".md")) { + return null; + } + + return `export default ${JSON.stringify(await readFile(id, "utf8"))};`; + }, + }, + ], test: { alias: { // Integration tests import real plugins. These aliases resolve diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dbf639165..3a9074b95 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -151,12 +151,21 @@ importers: specifier: ^3.24.0 version: 3.25.76 devDependencies: + '@rollup/plugin-typescript': + specifier: ^12.3.0 + version: 12.3.0(rollup@4.60.1)(tslib@2.8.1)(typescript@5.9.3) '@types/node': specifier: ^25.2.3 version: 25.6.0 '@vitest/coverage-v8': specifier: ^4.0.18 version: 4.1.4(vitest@4.1.4) + rollup: + specifier: ^4.60.1 + version: 4.60.1 + tslib: + specifier: ^2.8.1 + version: 2.8.1 typescript: specifier: ^5.7.0 version: 5.9.3 @@ -1682,6 +1691,28 @@ packages: '@rolldown/pluginutils@1.0.0-beta.27': resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} + '@rollup/plugin-typescript@12.3.0': + resolution: {integrity: sha512-7DP0/p7y3t67+NabT9f8oTBFE6gGkto4SA6Np2oudYmZE/m1dt8RB0SjL1msMxFpLo631qjRCcBlAbq1ml/Big==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^2.14.0||^3.0.0||^4.0.0 + tslib: '*' + typescript: '>=3.7.0' + peerDependenciesMeta: + rollup: + optional: true + tslib: + optional: true + + '@rollup/pluginutils@5.3.0': + resolution: {integrity: sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + '@rollup/rollup-android-arm-eabi@4.60.1': resolution: {integrity: sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==} cpu: [arm] @@ -2613,6 +2644,9 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -2868,6 +2902,10 @@ packages: resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} engines: {node: '>= 12'} + is-core-module@2.16.1: + resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} + engines: {node: '>= 0.4'} + is-docker@2.2.1: resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} engines: {node: '>=8'} @@ -3423,6 +3461,9 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + path-root-regex@0.1.2: resolution: {integrity: sha512-4GlJ6rZDhQZFE0DPVKh0e9jmZ5egZfxTkp7bcRDuPlJXbAwhxcl2dINPUAsjLdejqaLsCeg8axcLjIbvBjN4pQ==} engines: {node: '>=0.10.0'} @@ -3564,6 +3605,11 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + resolve@1.22.12: + resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} + engines: {node: '>= 0.4'} + hasBin: true + restore-cursor@5.1.0: resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} engines: {node: '>=18'} @@ -3745,6 +3791,10 @@ packages: resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} engines: {node: '>=10'} + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} @@ -5145,6 +5195,23 @@ snapshots: '@rolldown/pluginutils@1.0.0-beta.27': {} + '@rollup/plugin-typescript@12.3.0(rollup@4.60.1)(tslib@2.8.1)(typescript@5.9.3)': + dependencies: + '@rollup/pluginutils': 5.3.0(rollup@4.60.1) + resolve: 1.22.12 + typescript: 5.9.3 + optionalDependencies: + rollup: 4.60.1 + tslib: 2.8.1 + + '@rollup/pluginutils@5.3.0(rollup@4.60.1)': + dependencies: + '@types/estree': 1.0.8 + estree-walker: 2.0.2 + picomatch: 4.0.4 + optionalDependencies: + rollup: 4.60.1 + '@rollup/rollup-android-arm-eabi@4.60.1': optional: true @@ -6142,6 +6209,8 @@ snapshots: estraverse@5.3.0: {} + estree-walker@2.0.2: {} + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.8 @@ -6391,6 +6460,10 @@ snapshots: ip-address@10.1.0: {} + is-core-module@2.16.1: + dependencies: + hasown: 2.0.2 + is-docker@2.2.1: {} is-extglob@2.1.1: {} @@ -6915,6 +6988,8 @@ snapshots: path-key@3.1.1: {} + path-parse@1.0.7: {} + path-root-regex@0.1.2: {} path-root@0.1.1: @@ -7026,6 +7101,13 @@ snapshots: resolve-pkg-maps@1.0.0: {} + resolve@1.22.12: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + restore-cursor@5.1.0: dependencies: onetime: 7.0.0 @@ -7238,6 +7320,8 @@ snapshots: dependencies: has-flag: 4.0.0 + supports-preserve-symlinks-flag@1.0.0: {} + symbol-tree@3.2.4: {} tailwindcss@4.2.2: {} From 72340af73fbf8fafbefb4c73f39c8cbf4f07424b Mon Sep 17 00:00:00 2001 From: yyovil Date: Mon, 13 Apr 2026 20:57:08 +0530 Subject: [PATCH 08/14] build(core): use ts rollup config Entire-Checkpoint: a4d62ba98cfd --- packages/core/package.json | 2 +- .../{rollup.config.mjs => rollup.config.ts} | 29 ++++++++++++++----- packages/core/tsconfig.build.json | 4 --- 3 files changed, 22 insertions(+), 13 deletions(-) rename packages/core/{rollup.config.mjs => rollup.config.ts} (68%) delete mode 100644 packages/core/tsconfig.build.json diff --git a/packages/core/package.json b/packages/core/package.json index 93dc3a2b8..e396e1eb8 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -80,7 +80,7 @@ "node": ">=20.0.0" }, "scripts": { - "build": "rm -rf dist && rollup -c rollup.config.mjs && tsc -p tsconfig.build.json --emitDeclarationOnly", + "build": "rollup -c rollup.config.ts", "typecheck": "tsc --noEmit", "test": "vitest run", "test:watch": "vitest", diff --git a/packages/core/rollup.config.mjs b/packages/core/rollup.config.ts similarity index 68% rename from packages/core/rollup.config.mjs rename to packages/core/rollup.config.ts index 7ef7645c0..d32a216cb 100644 --- a/packages/core/rollup.config.mjs +++ b/packages/core/rollup.config.ts @@ -1,5 +1,6 @@ -import { readFile } from "node:fs/promises"; +import { readFile, rm } from "node:fs/promises"; import { builtinModules } from "node:module"; +import type { Plugin, RollupOptions } from "rollup"; import typescript from "@rollup/plugin-typescript"; const externalPackages = new Set(["yaml", "zod"]); @@ -8,7 +9,7 @@ const nodeBuiltins = new Set([ ...builtinModules.map((moduleName) => `node:${moduleName}`), ]); -function getPackageName(importId) { +function getPackageName(importId: string): string | null { if (importId.startsWith(".") || importId.startsWith("/")) { return null; } @@ -17,10 +18,10 @@ function getPackageName(importId) { return importId.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0]; } -function rawMarkdown() { +function rawMarkdown(): Plugin { return { name: "raw-markdown", - async load(id) { + async load(id: string) { if (!id.endsWith(".md")) { return null; } @@ -30,7 +31,16 @@ function rawMarkdown() { }; } -export default { +function cleanDist(): Plugin { + return { + name: "clean-dist", + async buildStart() { + await rm("dist", { force: true, recursive: true }); + }, + }; +} + +const config: RollupOptions = { input: "src/index.ts", output: { dir: "dist", @@ -39,7 +49,7 @@ export default { preserveModulesRoot: "src", sourcemap: true, }, - external(id) { + external(id: string) { if (nodeBuiltins.has(id) || id.startsWith("node:")) { return true; } @@ -48,14 +58,17 @@ export default { return packageName ? externalPackages.has(packageName) : false; }, plugins: [ + cleanDist(), rawMarkdown(), typescript({ compilerOptions: { - declaration: false, - declarationMap: false, + declaration: true, + declarationMap: true, module: "Node16", }, tsconfig: "./tsconfig.json", }), ], }; + +export default config; diff --git a/packages/core/tsconfig.build.json b/packages/core/tsconfig.build.json deleted file mode 100644 index 4dc23fb9c..000000000 --- a/packages/core/tsconfig.build.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "extends": "./tsconfig.json", - "exclude": ["src/__tests__"] -} From aede05e55f63a5ed960438f9e54272a01bdf65be Mon Sep 17 00:00:00 2001 From: yyovil Date: Mon, 13 Apr 2026 21:23:55 +0530 Subject: [PATCH 09/14] build(core): load rollup ts config in ci --- packages/core/package.json | 3 ++- pnpm-lock.yaml | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/core/package.json b/packages/core/package.json index e396e1eb8..0d19b170d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -80,7 +80,7 @@ "node": ">=20.0.0" }, "scripts": { - "build": "rollup -c rollup.config.ts", + "build": "tsx ./node_modules/rollup/dist/bin/rollup -c rollup.config.ts", "typecheck": "tsc --noEmit", "test": "vitest run", "test:watch": "vitest", @@ -95,6 +95,7 @@ "@types/node": "^25.2.3", "@vitest/coverage-v8": "^4.0.18", "rollup": "^4.60.1", + "tsx": "^4.21.0", "tslib": "^2.8.1", "typescript": "^5.7.0", "vitest": "^4.0.18" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3a9074b95..ccb56c967 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -166,6 +166,9 @@ importers: tslib: specifier: ^2.8.1 version: 2.8.1 + tsx: + specifier: ^4.21.0 + version: 4.21.0 typescript: specifier: ^5.7.0 version: 5.9.3 From 50ee804e2f704cb8843db5fab9582e07c9fadd7b Mon Sep 17 00:00:00 2001 From: yyovil Date: Mon, 13 Apr 2026 21:28:27 +0530 Subject: [PATCH 10/14] test(cli): load markdown prompt templates --- packages/cli/vitest.config.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts index cf0b05683..fda3d709d 100644 --- a/packages/cli/vitest.config.ts +++ b/packages/cli/vitest.config.ts @@ -1,7 +1,21 @@ +import { readFile } from "node:fs/promises"; import { resolve } from "node:path"; import { defineConfig } from "vitest/config"; export default defineConfig({ + plugins: [ + { + name: "raw-markdown", + enforce: "pre", + async load(id) { + if (!id.endsWith(".md")) { + return null; + } + + return `export default ${JSON.stringify(await readFile(id, "utf8"))};`; + }, + }, + ], test: { include: ["__tests__/**/*.test.ts"], testTimeout: 10000, From 02d2c756c1dd67dae805005169646a8477f4d61d Mon Sep 17 00:00:00 2001 From: yyovil Date: Tue, 14 Apr 2026 00:20:11 +0530 Subject: [PATCH 11/14] fix: align prompt asset test/build tooling --- packages/core/rollup.config.ts | 2 +- packages/core/tsconfig.build.json | 4 ++++ packages/web/vitest.config.ts | 16 +++++++++++++++- 3 files changed, 20 insertions(+), 2 deletions(-) create mode 100644 packages/core/tsconfig.build.json diff --git a/packages/core/rollup.config.ts b/packages/core/rollup.config.ts index d32a216cb..6adee8d18 100644 --- a/packages/core/rollup.config.ts +++ b/packages/core/rollup.config.ts @@ -66,7 +66,7 @@ const config: RollupOptions = { declarationMap: true, module: "Node16", }, - tsconfig: "./tsconfig.json", + tsconfig: "./tsconfig.build.json", }), ], }; diff --git a/packages/core/tsconfig.build.json b/packages/core/tsconfig.build.json new file mode 100644 index 000000000..6dc6b9013 --- /dev/null +++ b/packages/core/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["src/__tests__/**"] +} diff --git a/packages/web/vitest.config.ts b/packages/web/vitest.config.ts index d1c3f7a87..7da000157 100644 --- a/packages/web/vitest.config.ts +++ b/packages/web/vitest.config.ts @@ -1,9 +1,23 @@ +import { readFile } from "node:fs/promises"; import { defineConfig } from "vitest/config"; import react from "@vitejs/plugin-react"; import { resolve } from "node:path"; export default defineConfig({ - plugins: [react()], + plugins: [ + react(), + { + name: "raw-markdown", + enforce: "pre", + async load(id) { + if (!id.endsWith(".md")) { + return null; + } + + return `export default ${JSON.stringify(await readFile(id, "utf8"))};`; + }, + }, + ], test: { globals: true, environment: "jsdom", From b853fe30805083f03f6097cb2afe43ef50f1ec14 Mon Sep 17 00:00:00 2001 From: yyovil <149292478+yyovil@users.noreply.github.com> Date: Thu, 16 Apr 2026 14:43:27 +0530 Subject: [PATCH 12/14] fix(core): harden prompt template rendering --- .../orchestrator-prompt.dist.test.ts | 63 +++++++++++++++++++ .../src/__tests__/orchestrator-prompt.test.ts | 46 ++++++++++++++ packages/core/src/orchestrator-prompt.ts | 47 +++++++++++--- 3 files changed, 147 insertions(+), 9 deletions(-) create mode 100644 packages/core/src/__tests__/orchestrator-prompt.dist.test.ts diff --git a/packages/core/src/__tests__/orchestrator-prompt.dist.test.ts b/packages/core/src/__tests__/orchestrator-prompt.dist.test.ts new file mode 100644 index 000000000..a5b9e6828 --- /dev/null +++ b/packages/core/src/__tests__/orchestrator-prompt.dist.test.ts @@ -0,0 +1,63 @@ +import { execFileSync } from "node:child_process"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { dirname, resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import type { OrchestratorConfig } from "../types.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const packageRoot = resolve(__dirname, "../.."); +const distModuleUrl = pathToFileURL(resolve(packageRoot, "dist/orchestrator-prompt.js")).href; +const pnpmCommand = process.platform === "win32" ? "pnpm.cmd" : "pnpm"; + +const config: OrchestratorConfig = { + configPath: "/tmp/agent-orchestrator.yaml", + port: 3000, + power: { preventIdleSleep: false }, + defaults: { + runtime: "tmux", + agent: "claude-code", + workspace: "worktree", + notifiers: ["desktop"], + }, + projects: { + "my-app": { + name: "My App", + repo: "org/my-app", + path: "/tmp/my-app", + defaultBranch: "main", + sessionPrefix: "app", + }, + }, + notifiers: {}, + notificationRouting: { + urgent: ["desktop"], + action: ["desktop"], + warning: [], + info: [], + }, + reactions: {}, + readyThresholdMs: 300_000, +}; + +describe("generateOrchestratorPrompt dist smoke test", () => { + it("imports the built artifact and loads the bundled markdown template at runtime", async () => { + execFileSync(pnpmCommand, ["build"], { + cwd: packageRoot, + stdio: "pipe", + }); + + const { generateOrchestratorPrompt } = await import(`${distModuleUrl}?t=${Date.now()}`); + const prompt = generateOrchestratorPrompt({ + config, + projectId: "my-app", + project: { + ...config.projects["my-app"]!, + orchestratorRules: "First block\n\n\nSecond block", + }, + }); + + expect(prompt).toContain("# My App Orchestrator"); + expect(prompt).toContain("ao session ls -p my-app"); + expect(prompt).toContain("First block\n\n\nSecond block"); + }); +}); diff --git a/packages/core/src/__tests__/orchestrator-prompt.test.ts b/packages/core/src/__tests__/orchestrator-prompt.test.ts index 1d1ca0ca8..b6d472ef7 100644 --- a/packages/core/src/__tests__/orchestrator-prompt.test.ts +++ b/packages/core/src/__tests__/orchestrator-prompt.test.ts @@ -147,6 +147,19 @@ describe("generateOrchestratorPrompt", () => { ).toThrow("Unresolved template placeholder: toString"); }); + it("throws when the markdown template leaves behind a malformed placeholder", async () => { + const generateOrchestratorPrompt = + await loadGenerateOrchestratorPromptWithTemplate("Hello {{ projectName }}"); + + expect(() => + generateOrchestratorPrompt({ + config, + projectId: "my-app", + project: config.projects["my-app"]!, + }), + ).toThrow("Unresolved template placeholder: {{ projectName }}"); + }); + it("throws when the markdown template contains an unmatched optional-section marker", async () => { const generateOrchestratorPrompt = await loadGenerateOrchestratorPromptWithTemplate( @@ -164,6 +177,23 @@ describe("generateOrchestratorPrompt", () => { ); }); + it("throws when the markdown template nests the same optional section type", async () => { + const generateOrchestratorPrompt = + await loadGenerateOrchestratorPromptWithTemplate( + "{{REPO_CONFIGURED_SECTION_START}}outer {{REPO_CONFIGURED_SECTION_START}}inner{{REPO_CONFIGURED_SECTION_END}}{{REPO_CONFIGURED_SECTION_END}}", + ); + + expect(() => + generateOrchestratorPrompt({ + config, + projectId: "my-app", + project: config.projects["my-app"]!, + }), + ).toThrow( + "Nested optional section blocks are not supported: {{REPO_CONFIGURED_SECTION_START}} before {{REPO_CONFIGURED_SECTION_END}}", + ); + }); + it("renders optional sections only when project data is present", async () => { const generateOrchestratorPrompt = await loadGenerateOrchestratorPrompt(); const projectWithOptionalSections: ProjectConfig = { @@ -198,4 +228,20 @@ describe("generateOrchestratorPrompt", () => { expect(promptWithoutOptionalSections).not.toContain("## Automated Reactions"); expect(promptWithoutOptionalSections).not.toContain("## Project-Specific Rules"); }); + + it("preserves intentional blank lines inside project-specific rules", async () => { + const generateOrchestratorPrompt = await loadGenerateOrchestratorPrompt(); + const projectWithSpacedRules: ProjectConfig = { + ...config.projects["my-app"]!, + orchestratorRules: "First block\n\n\nSecond block", + }; + + const prompt = generateOrchestratorPrompt({ + config, + projectId: "my-app", + project: projectWithSpacedRules, + }); + + expect(prompt).toContain("First block\n\n\nSecond block"); + }); }); diff --git a/packages/core/src/orchestrator-prompt.ts b/packages/core/src/orchestrator-prompt.ts index 923d5de02..9583fdfc7 100644 --- a/packages/core/src/orchestrator-prompt.ts +++ b/packages/core/src/orchestrator-prompt.ts @@ -100,18 +100,40 @@ function removeOptionalSectionBlocks( const fullStart = start; const fullEnd = end + endMarker.length; const blockContent = interpolated.slice(start + startMarker.length, end); - const replacement = section ? blockContent : ""; + // Optional sections are flat by design. Reject nesting of the same block + // type so future template edits fail loudly instead of matching ambiguously. + if (blockContent.includes(startMarker)) { + throw new Error( + `Nested optional section blocks are not supported: ${startMarker} before ${endMarker}`, + ); + } - interpolated = - interpolated.slice(0, fullStart) + - replacement + - interpolated.slice(fullEnd); + const replacement = section ? blockContent : ""; + const before = interpolated.slice(0, fullStart); + const after = interpolated.slice(fullEnd); + + interpolated = replacement + ? before + replacement + after + : collapseOptionalGap(before, after); } } return interpolated; } +function collapseOptionalGap(before: string, after: string): string { + const trailingNewlines = before.match(/\n*$/)?.[0] ?? ""; + const leadingNewlines = after.match(/^\n*/)?.[0] ?? ""; + const totalNewlines = trailingNewlines.length + leadingNewlines.length; + const boundary = totalNewlines >= 2 ? "\n\n" : trailingNewlines + leadingNewlines; + + return ( + before.slice(0, before.length - trailingNewlines.length) + + boundary + + after.slice(leadingNewlines.length) + ); +} + function hasRenderDataKey( data: OrchestratorPromptRenderData, key: string, @@ -139,17 +161,24 @@ function createRenderData(opts: OrchestratorPromptConfig): OrchestratorPromptRen } function renderTemplate(template: string, data: OrchestratorPromptRenderData): string { - return template.replace(/\{\{([a-zA-Z0-9_]+)\}\}/g, (_match, rawKey: string) => { + const rendered = template.replace(/\{\{([a-zA-Z0-9_]+)\}\}/g, (_match, rawKey: string) => { if (!hasRenderDataKey(data, rawKey)) { throw new Error(`Unresolved template placeholder: ${rawKey}`); } return data[rawKey]; }); + + const unresolvedPlaceholder = rendered.match(/\{\{[^}]+\}\}/); + if (unresolvedPlaceholder) { + throw new Error(`Unresolved template placeholder: ${unresolvedPlaceholder[0]}`); + } + + return rendered; } -function normalizeRenderedPrompt(prompt: string): string { - return prompt.replace(/\n{3,}/g, "\n\n").trim(); +function finalizeRenderedPrompt(prompt: string): string { + return prompt.trim(); } /** @@ -164,7 +193,7 @@ export function generateOrchestratorPrompt(opts: OrchestratorPromptConfig): stri data, ); - return normalizeRenderedPrompt( + return finalizeRenderedPrompt( renderTemplate(templateWithOptionalSections, data), ); } From e418f6f9f360cc21c409ce34e5d9e019116287e5 Mon Sep 17 00:00:00 2001 From: yyovil <149292478+yyovil@users.noreply.github.com> Date: Thu, 16 Apr 2026 19:14:33 +0530 Subject: [PATCH 13/14] chore(core): simplify command prompt markdown --- packages/core/src/prompts/orchestrator.md | 26 +++++++++++------------ 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/packages/core/src/prompts/orchestrator.md b/packages/core/src/prompts/orchestrator.md index 8674c0fce..941fda018 100644 --- a/packages/core/src/prompts/orchestrator.md +++ b/packages/core/src/prompts/orchestrator.md @@ -59,20 +59,18 @@ ao open {{projectId}}{{REPO_CONFIGURED_SECTION_END}} ## Available Commands -| Command | Description | -| ------- | ----------- | -| `ao status` | Show all sessions{{REPO_CONFIGURED_SECTION_START}} with PR/CI/review status{{REPO_CONFIGURED_SECTION_END}} | -| `ao spawn [issue] [--prompt ]{{REPO_CONFIGURED_SECTION_START}} [--claim-pr ]{{REPO_CONFIGURED_SECTION_END}}` | Spawn a worker session{{REPO_CONFIGURED_SECTION_START}}; use issue ID or --prompt for freeform tasks{{REPO_CONFIGURED_SECTION_END}}{{REPO_NOT_CONFIGURED_SECTION_START}} with --prompt for freeform tasks{{REPO_NOT_CONFIGURED_SECTION_END}} | -{{REPO_CONFIGURED_SECTION_START}}| `ao batch-spawn ` | Spawn multiple sessions in parallel (project auto-detected) | -{{REPO_CONFIGURED_SECTION_END}}| `ao session ls [-p project]` | List all sessions (optionally filter by project) | -{{REPO_CONFIGURED_SECTION_START}}| `ao session claim-pr [session]` | Attach an existing PR to a worker session | -{{REPO_CONFIGURED_SECTION_END}}| `ao session attach ` | Attach to a session's tmux window | -| `ao session kill ` | Kill a specific session | -| `ao session cleanup [-p project]` | Kill completed/merged sessions | -| `ao send ` | Send a message to a running session | -| `ao send --no-wait ` | Send without waiting for session to become idle | -| `ao dashboard` | Start the web dashboard (http://localhost:{{dashboardPort}}) | -| `ao open ` | Open all project sessions in terminal tabs | +- `ao status`: Show all sessions{{REPO_CONFIGURED_SECTION_START}} with PR/CI/review status{{REPO_CONFIGURED_SECTION_END}} +- `ao spawn [issue] [--prompt ]{{REPO_CONFIGURED_SECTION_START}} [--claim-pr ]{{REPO_CONFIGURED_SECTION_END}}`: Spawn a worker session{{REPO_CONFIGURED_SECTION_START}}; use issue ID or --prompt for freeform tasks{{REPO_CONFIGURED_SECTION_END}}{{REPO_NOT_CONFIGURED_SECTION_START}} with --prompt for freeform tasks{{REPO_NOT_CONFIGURED_SECTION_END}} +{{REPO_CONFIGURED_SECTION_START}}- `ao batch-spawn `: Spawn multiple sessions in parallel (project auto-detected) +{{REPO_CONFIGURED_SECTION_END}}- `ao session ls [-p project]`: List all sessions (optionally filter by project) +{{REPO_CONFIGURED_SECTION_START}}- `ao session claim-pr [session]`: Attach an existing PR to a worker session +{{REPO_CONFIGURED_SECTION_END}}- `ao session attach `: Attach to a session's tmux window +- `ao session kill `: Kill a specific session +- `ao session cleanup [-p project]`: Kill completed/merged sessions +- `ao send `: Send a message to a running session +- `ao send --no-wait `: Send without waiting for session to become idle +- `ao dashboard`: Start the web dashboard (http://localhost:{{dashboardPort}}) +- `ao open `: Open all project sessions in terminal tabs ## Session Management From 4f687dd59eb7a4d302ad174cca80a9d1affa4345 Mon Sep 17 00:00:00 2001 From: yyovil <149292478+yyovil@users.noreply.github.com> Date: Thu, 16 Apr 2026 20:13:41 +0530 Subject: [PATCH 14/14] fix(core): validate template placeholders before render Entire-Checkpoint: 7fdb8af31510 --- .../__tests__/orchestrator-prompt.dist.test.ts | 2 +- .../src/__tests__/orchestrator-prompt.test.ts | 16 ++++++++++++++++ packages/core/src/orchestrator-prompt.ts | 16 ++++++++-------- 3 files changed, 25 insertions(+), 9 deletions(-) diff --git a/packages/core/src/__tests__/orchestrator-prompt.dist.test.ts b/packages/core/src/__tests__/orchestrator-prompt.dist.test.ts index a5b9e6828..712680075 100644 --- a/packages/core/src/__tests__/orchestrator-prompt.dist.test.ts +++ b/packages/core/src/__tests__/orchestrator-prompt.dist.test.ts @@ -59,5 +59,5 @@ describe("generateOrchestratorPrompt dist smoke test", () => { expect(prompt).toContain("# My App Orchestrator"); expect(prompt).toContain("ao session ls -p my-app"); expect(prompt).toContain("First block\n\n\nSecond block"); - }); + }, 15000); }); diff --git a/packages/core/src/__tests__/orchestrator-prompt.test.ts b/packages/core/src/__tests__/orchestrator-prompt.test.ts index b6d472ef7..268ce1124 100644 --- a/packages/core/src/__tests__/orchestrator-prompt.test.ts +++ b/packages/core/src/__tests__/orchestrator-prompt.test.ts @@ -244,4 +244,20 @@ describe("generateOrchestratorPrompt", () => { expect(prompt).toContain("First block\n\n\nSecond block"); }); + + it("allows literal handlebars inside project-specific rules", async () => { + const generateOrchestratorPrompt = await loadGenerateOrchestratorPrompt(); + const projectWithHandlebarsRules: ProjectConfig = { + ...config.projects["my-app"]!, + orchestratorRules: "Use {{example}} literally in the docs.", + }; + + const prompt = generateOrchestratorPrompt({ + config, + projectId: "my-app", + project: projectWithHandlebarsRules, + }); + + expect(prompt).toContain("Use {{example}} literally in the docs."); + }); }); diff --git a/packages/core/src/orchestrator-prompt.ts b/packages/core/src/orchestrator-prompt.ts index 9583fdfc7..fa8058bd1 100644 --- a/packages/core/src/orchestrator-prompt.ts +++ b/packages/core/src/orchestrator-prompt.ts @@ -161,20 +161,20 @@ function createRenderData(opts: OrchestratorPromptConfig): OrchestratorPromptRen } function renderTemplate(template: string, data: OrchestratorPromptRenderData): string { - const rendered = template.replace(/\{\{([a-zA-Z0-9_]+)\}\}/g, (_match, rawKey: string) => { + const unresolvedPlaceholder = template + .replace(/\{\{([a-zA-Z0-9_]+)\}\}/g, "") + .match(/\{\{[^}]+\}\}/); + if (unresolvedPlaceholder) { + throw new Error(`Unresolved template placeholder: ${unresolvedPlaceholder[0]}`); + } + + return template.replace(/\{\{([a-zA-Z0-9_]+)\}\}/g, (_match, rawKey: string) => { if (!hasRenderDataKey(data, rawKey)) { throw new Error(`Unresolved template placeholder: ${rawKey}`); } return data[rawKey]; }); - - const unresolvedPlaceholder = rendered.match(/\{\{[^}]+\}\}/); - if (unresolvedPlaceholder) { - throw new Error(`Unresolved template placeholder: ${unresolvedPlaceholder[0]}`); - } - - return rendered; } function finalizeRenderedPrompt(prompt: string): string {