Merge pull request #1206 from yyovil/yyovil/fix-issue-1175

refactor(core): templated orchestrator prompt
This commit is contained in:
yyovil 2026-04-16 23:54:44 +05:30 committed by GitHub
commit 8b7a54ba5a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 868 additions and 290 deletions

View File

@ -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,

View File

@ -80,7 +80,7 @@
"node": ">=20.0.0"
},
"scripts": {
"build": "tsc -p tsconfig.build.json",
"build": "tsx ./node_modules/rollup/dist/bin/rollup -c rollup.config.ts",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
@ -91,8 +91,12 @@
"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",
"tsx": "^4.21.0",
"tslib": "^2.8.1",
"typescript": "^5.7.0",
"vitest": "^4.0.18"
}

View File

@ -0,0 +1,74 @@
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"]);
const nodeBuiltins = new Set([
...builtinModules,
...builtinModules.map((moduleName) => `node:${moduleName}`),
]);
function getPackageName(importId: string): string | null {
if (importId.startsWith(".") || importId.startsWith("/")) {
return null;
}
const parts = importId.split("/");
return importId.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0];
}
function rawMarkdown(): Plugin {
return {
name: "raw-markdown",
async load(id: string) {
if (!id.endsWith(".md")) {
return null;
}
return `export default ${JSON.stringify(await readFile(id, "utf8"))};`;
},
};
}
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",
format: "es",
preserveModules: true,
preserveModulesRoot: "src",
sourcemap: true,
},
external(id: string) {
if (nodeBuiltins.has(id) || id.startsWith("node:")) {
return true;
}
const packageName = getPackageName(id);
return packageName ? externalPackages.has(packageName) : false;
},
plugins: [
cleanDist(),
rawMarkdown(),
typescript({
compilerOptions: {
declaration: true,
declarationMap: true,
module: "Node16",
},
tsconfig: "./tsconfig.build.json",
}),
],
};
export default config;

View File

@ -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");
}, 15000);
});

View File

@ -1,6 +1,5 @@
import { describe, expect, it } from "vitest";
import { generateOrchestratorPrompt } from "../orchestrator-prompt.js";
import type { OrchestratorConfig } from "../types.js";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { OrchestratorConfig, ProjectConfig } from "../types.js";
const config: OrchestratorConfig = {
configPath: "/tmp/agent-orchestrator.yaml",
@ -32,8 +31,29 @@ 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("../prompts/orchestrator.md", () => ({
default: template,
}));
return (await import("../orchestrator-prompt.js")).generateOrchestratorPrompt;
}
describe("generateOrchestratorPrompt", () => {
it("requires read-only investigation from the orchestrator session", () => {
afterEach(() => {
vi.restoreAllMocks();
vi.doUnmock("../prompts/orchestrator.md");
vi.resetModules();
});
it("requires read-only investigation from the orchestrator session", async () => {
const generateOrchestratorPrompt = await loadGenerateOrchestratorPrompt();
const prompt = generateOrchestratorPrompt({
config,
projectId: "my-app",
@ -44,7 +64,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",
@ -56,7 +77,8 @@ describe("generateOrchestratorPrompt", () => {
expect(prompt).toContain("ao send --no-wait");
});
it("shows 'not configured' when repo is omitted", () => {
it("shows 'not configured' and omits repo-only guidance when repo is omitted", async () => {
const generateOrchestratorPrompt = await loadGenerateOrchestratorPrompt();
const noRepoProject = { ...config.projects["my-app"]!, repo: undefined };
const prompt = generateOrchestratorPrompt({
config,
@ -66,9 +88,14 @@ describe("generateOrchestratorPrompt", () => {
expect(prompt).toContain("not configured");
expect(prompt).not.toContain("undefined");
expect(prompt).not.toContain("ao batch-spawn");
expect(prompt).not.toContain("ao session claim-pr");
expect(prompt).not.toContain("PR state (open/merged/closed)");
expect(prompt).toContain("No repository remote is configured.");
});
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",
@ -79,4 +106,158 @@ 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", async () => {
const generateOrchestratorPrompt = await loadGenerateOrchestratorPrompt();
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 snake_case placeholder", async () => {
const generateOrchestratorPrompt =
await loadGenerateOrchestratorPromptWithTemplate("Hello {{missing_placeholder}}");
expect(() =>
generateOrchestratorPrompt({
config,
projectId: "my-app",
project: config.projects["my-app"]!,
}),
).toThrow("Unresolved template placeholder: missing_placeholder");
});
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 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(
"{{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("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 = {
...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");
});
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");
});
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.");
});
});

4
packages/core/src/markdown.d.ts vendored Normal file
View File

@ -0,0 +1,4 @@
declare module "*.md" {
const content: string;
export default content;
}

View File

@ -1,10 +1,11 @@
/**
* 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 orchestratorTemplate from "./prompts/orchestrator.md";
import type { OrchestratorConfig, ProjectConfig } from "./types.js";
export interface OrchestratorPromptConfig {
@ -13,287 +14,186 @@ 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;
repoConfiguredSection: string;
repoNotConfiguredSection: string;
}
type OrchestratorPromptRenderKey = keyof OrchestratorPromptRenderData;
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(
`- ${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(
`- ${bold(event)}: Notifies human (priority: ${reaction.priority ?? "info"})`,
);
}
}
if (reactionLines.length === 0) {
return "";
}
return reactionLines.join("\n");
}
function buildProjectSpecificRulesSection(project: ProjectConfig): string {
const rules = project.orchestratorRules?.trim();
if (!rules) {
return "";
}
return rules;
}
function removeOptionalSectionBlocks(
template: string,
data: OrchestratorPromptRenderData,
): string {
const templates = [
["REPO_CONFIGURED_SECTION_START", "REPO_CONFIGURED_SECTION_END", data.repoConfiguredSection],
["REPO_NOT_CONFIGURED_SECTION_START", "REPO_NOT_CONFIGURED_SECTION_END", data.repoNotConfiguredSection],
["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}}}`;
while (true) {
const start = interpolated.indexOf(startMarker);
const end = interpolated.indexOf(endMarker);
if (start === -1 && end === -1) {
break;
}
if (start === -1 || end === -1 || end < start) {
throw new Error(
`Malformed optional section block: expected ${startMarker} before ${endMarker}`,
);
}
const fullStart = start;
const fullEnd = end + endMarker.length;
const blockContent = interpolated.slice(start + startMarker.length, end);
// 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}`,
);
}
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,
): key is OrchestratorPromptRenderKey {
return Object.prototype.hasOwnProperty.call(data, key);
}
function createRenderData(opts: OrchestratorPromptConfig): OrchestratorPromptRenderData {
const { config, projectId, project } = opts;
const hasRepo = Boolean(project.repo);
return {
projectId,
projectName: project.name,
projectRepo: project.repo ?? "not configured",
projectDefaultBranch: project.defaultBranch,
projectSessionPrefix: project.sessionPrefix,
projectPath: project.path,
dashboardPort: String(config.port ?? 3000),
automatedReactionsSection: buildAutomatedReactionsSection(project),
projectSpecificRulesSection: buildProjectSpecificRulesSection(project),
repoConfiguredSection: hasRepo ? "true" : "",
repoNotConfiguredSection: hasRepo ? "" : "true",
};
}
function renderTemplate(template: string, data: OrchestratorPromptRenderData): 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];
});
}
function finalizeRenderedPrompt(prompt: string): string {
return prompt.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 <session> <message>\` to send without waiting for the session to become idle.`);
// Project Info
sections.push(`## Project Info
- **Name**: ${project.name}
- **Repository**: ${project.repo ?? "not configured"}
- **Default Branch**: ${project.defaultBranch}
- **Session Prefix**: ${project.sessionPrefix}
- **Local Path**: ${project.path}
- **Dashboard Port**: ${config.port ?? 3000}`);
// Quick Start — include issue/PR commands only when repo is configured
if (project.repo) {
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}
\`\`\``);
} else {
sections.push(`## Quick Start
\`\`\`bash
# See all sessions at a glance
ao status
# Spawn a session (prompt-driven, no issue tracker configured)
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"
# Kill a session
ao session kill ${project.sessionPrefix}-1
\`\`\`
> **Note:** No repository remote is configured. Issue tracking, PR, and CI features are unavailable.
> Add a \`repo\` field (owner/repo) to \`agent-orchestrator.yaml\` to enable them.`);
}
// Available Commands — omit PR/issue commands when no repo configured
const cmdRows = [
`| \`ao status\` | Show all sessions${project.repo ? " with PR/CI/review status" : ""} |`,
`| \`ao spawn [issue] [--prompt <text>]${project.repo ? " [--claim-pr <pr>]" : ""}\` | Spawn a worker session${project.repo ? "; use issue ID or --prompt for freeform tasks" : " with --prompt for freeform tasks"} |`,
...(project.repo ? [`| \`ao batch-spawn <issues...>\` | Spawn multiple sessions in parallel (project auto-detected) |`] : []),
`| \`ao session ls [-p project]\` | List all sessions (optionally filter by project) |`,
...(project.repo ? [`| \`ao session claim-pr <pr> [session]\` | Attach an existing PR to a worker session |`] : []),
`| \`ao session attach <session>\` | Attach to a session's tmux window |`,
`| \`ao session kill <session>\` | Kill a specific session |`,
`| \`ao session cleanup [-p project]\` | Kill completed/merged sessions |`,
`| \`ao send <session> <message>\` | Send a message to a running session |`,
`| \`ao send --no-wait <session> <message>\` | Send without waiting for session to become idle |`,
`| \`ao dashboard\` | Start the web dashboard (http://localhost:${config.port ?? 3000}) |`,
`| \`ao open <project>\` | Open all project sessions in terminal tabs |`,
];
sections.push(`## Available Commands
| Command | Description |
|---------|-------------|
${cmdRows.join("\n")}`);
// Session Management
const sessionMgmt = [`## 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/<id>\` 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.)${project.repo ? `
- 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"
\`\`\``];
if (project.repo) {
sessionMgmt.push(`
### 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.`);
}
sessionMgmt.push(`
### 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
\`\`\``);
sections.push(sessionMgmt.join("\n"));
// 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
const workflows = [`## Common Workflows`];
if (project.repo) {
workflows.push(`
### 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`);
}
workflows.push(`
### Handling Stuck Agents
1. Check \`ao status\` for sessions in "stuck" or "needs_input" state
2. Attach with \`ao session attach <session>\` to see what they're doing
3. Send clarification or instructions with \`ao send <session> '...'\`
4. Or kill and respawn with fresh context if needed`);
if (project.repo) {
workflows.push(`
### 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)`);
}
workflows.push(`
### 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 <session>\`
4. Send instructions: \`ao send <session> '...'\`
5. Or handle the human-only action yourself${project.repo ? " (merge PR, close issue, etc.)" : ""} while keeping implementation in worker sessions.`);
sections.push(workflows.join("\n"));
// 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");
const data = createRenderData(opts);
const templateWithOptionalSections = removeOptionalSectionBlocks(
orchestratorTemplate.trim(),
data,
);
return finalizeRenderedPrompt(
renderTemplate(templateWithOptionalSections, data),
);
}

View File

@ -0,0 +1,223 @@
# {{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 <session> <message>` 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
{{REPO_CONFIGURED_SECTION_START}}# 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
{{REPO_CONFIGURED_SECTION_END}}# 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"
{{REPO_CONFIGURED_SECTION_START}}# Claim an existing PR for a worker session
ao session claim-pr 123 {{projectSessionPrefix}}-1
{{REPO_CONFIGURED_SECTION_END}}# Kill a session
ao session kill {{projectSessionPrefix}}-1
{{REPO_CONFIGURED_SECTION_START}}
# Open all sessions in terminal tabs
ao open {{projectId}}{{REPO_CONFIGURED_SECTION_END}}
```
{{REPO_NOT_CONFIGURED_SECTION_START}}
> **Note:** No repository remote is configured. Issue tracking, PR, and CI features are unavailable.
> Add a `repo` field (owner/repo) to `agent-orchestrator.yaml` to enable them.
{{REPO_NOT_CONFIGURED_SECTION_END}}
## Available Commands
- `ao status`: Show all sessions{{REPO_CONFIGURED_SECTION_START}} with PR/CI/review status{{REPO_CONFIGURED_SECTION_END}}
- `ao spawn [issue] [--prompt <text>]{{REPO_CONFIGURED_SECTION_START}} [--claim-pr <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 <issues...>`: 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 <pr> [session]`: Attach an existing PR to a worker session
{{REPO_CONFIGURED_SECTION_END}}- `ao session attach <session>`: Attach to a session's tmux window
- `ao session kill <session>`: Kill a specific session
- `ao session cleanup [-p project]`: Kill completed/merged sessions
- `ao send <session> <message>`: Send a message to a running session
- `ao send --no-wait <session> <message>`: Send without waiting for session to become idle
- `ao dashboard`: Start the web dashboard (http://localhost:{{dashboardPort}})
- `ao open <project>`: 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/<id>` 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.)
{{REPO_CONFIGURED_SECTION_START}}- PR state (open/merged/closed)
- CI status (passing/failing/pending)
- Review decision (approved/changes_requested/pending)
- Unresolved comments count
{{REPO_CONFIGURED_SECTION_END}}
### Sending Messages
Send instructions to a running agent:
```bash
ao send {{projectSessionPrefix}}-1 "Please address the review comments on your PR"
```
{{REPO_CONFIGURED_SECTION_START}}### 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.
{{REPO_CONFIGURED_SECTION_END}}
### 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
{{AUTOMATED_REACTIONS_SECTION_START}}
## Automated Reactions
The system automatically handles these events:
{{automatedReactionsSection}}
{{AUTOMATED_REACTIONS_SECTION_END}}
## Common Workflows
{{REPO_CONFIGURED_SECTION_START}}### 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
{{REPO_CONFIGURED_SECTION_END}}### Handling Stuck Agents
1. Check `ao status` for sessions in "stuck" or "needs_input" state
2. Attach with `ao session attach <session>` to see what they're doing
3. Send clarification or instructions with `ao send <session> '...'`
4. Or kill and respawn with fresh context if needed
{{REPO_CONFIGURED_SECTION_START}}### 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)
{{REPO_CONFIGURED_SECTION_END}}### 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 <session>`
4. Send instructions: `ao send <session> '...'`
5. Or handle the human-only action yourself{{REPO_CONFIGURED_SECTION_START}} (merge PR, close issue, etc.){{REPO_CONFIGURED_SECTION_END}} 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.
{{PROJECT_SPECIFIC_RULES_SECTION_START}}
## Project-Specific Rules
{{projectSpecificRulesSection}}
{{PROJECT_SPECIFIC_RULES_SECTION_END}}

View File

@ -1,4 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["src/__tests__"]
"exclude": ["src/__tests__/**"]
}

View File

@ -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

View File

@ -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",

View File

@ -154,12 +154,24 @@ 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
tsx:
specifier: ^4.21.0
version: 4.21.0
typescript:
specifier: ^5.7.0
version: 5.9.3
@ -1685,6 +1697,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]
@ -2616,6 +2650,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==}
@ -2871,6 +2908,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'}
@ -3426,6 +3467,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'}
@ -3567,6 +3611,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'}
@ -3748,6 +3797,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==}
@ -5148,6 +5201,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
@ -6145,6 +6215,8 @@ snapshots:
estraverse@5.3.0: {}
estree-walker@2.0.2: {}
estree-walker@3.0.3:
dependencies:
'@types/estree': 1.0.8
@ -6394,6 +6466,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: {}
@ -6918,6 +6994,8 @@ snapshots:
path-key@3.1.1: {}
path-parse@1.0.7: {}
path-root-regex@0.1.2: {}
path-root@0.1.1:
@ -7029,6 +7107,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
@ -7241,6 +7326,8 @@ snapshots:
dependencies:
has-flag: 4.0.0
supports-preserve-symlinks-flag@1.0.0: {}
symbol-tree@3.2.4: {}
tailwindcss@4.2.2: {}