diff --git a/.changeset/optional-repo-field.md b/.changeset/optional-repo-field.md new file mode 100644 index 000000000..299c6114b --- /dev/null +++ b/.changeset/optional-repo-field.md @@ -0,0 +1,16 @@ +--- +"@aoagents/ao-core": minor +"@aoagents/ao-cli": patch +"@aoagents/ao-web": patch +"@aoagents/ao-plugin-scm-github": patch +"@aoagents/ao-plugin-scm-gitlab": patch +"@aoagents/ao-plugin-tracker-github": patch +"@aoagents/ao-plugin-tracker-gitlab": patch +--- + +Make `ProjectConfig.repo` optional to support projects without a configured remote. + +**Migration:** `ProjectConfig.repo` is now `string | undefined` instead of `string`. +External plugins that access `project.repo` directly (e.g. `project.repo.split("/")`) must +add a null check first. Use a guard like `if (!project.repo) return null;` or a helper that +throws with a descriptive error. diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index 43d67cfb4..5c2155f78 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -535,6 +535,15 @@ async function autoCreateConfig(workingDir: string): Promise console.log(chalk.dim(" Detecting project and generating config...\n")); const env = await detectEnvironment(workingDir); + + if (!env.isGitRepo) { + throw new Error( + `"${workingDir}" is not a git repository.\n` + + ` ao requires a git repo to manage worktrees and branches.\n` + + ` Run \`git init\` first, then try again.`, + ); + } + const projectType = detectProjectType(workingDir); // Show detection results @@ -561,7 +570,7 @@ async function autoCreateConfig(workingDir: string): Promise const agentRules = generateRulesFromTemplates(projectType); // Build config with smart defaults - const projectId = env.isGitRepo ? basename(workingDir) : "my-project"; + const projectId = basename(workingDir); let repo: string | undefined = env.ownerRepo ?? undefined; const path = workingDir; const defaultBranch = env.defaultBranch || "main"; @@ -575,7 +584,7 @@ async function autoCreateConfig(workingDir: string): Promise "owner/repo", ); const trimmed = entered.trim(); - if (trimmed && trimmed.includes("/")) { + if (trimmed && /^[^\s/]+\/[^\s/]+/.test(trimmed)) { repo = trimmed; console.log(chalk.green(` ✓ Repo: ${repo}`)); } else if (trimmed) { @@ -696,6 +705,24 @@ async function addProjectToConfig( if (match) ownerRepo = match[1]; } + // If no repo detected, prompt the user (same as autoCreateConfig) + /* c8 ignore start -- interactive prompt */ + if (!ownerRepo && isHumanCaller()) { + console.log(chalk.yellow(" ⚠ Could not auto-detect a GitHub/GitLab remote.")); + const entered = await promptText( + " Enter repo (owner/repo) or leave empty to skip:", + "owner/repo", + ); + const trimmed = entered.trim(); + if (trimmed && /^[^\s/]+\/[^\s/]+/.test(trimmed)) { + ownerRepo = trimmed; + console.log(chalk.green(` ✓ Repo: ${ownerRepo}`)); + } else if (trimmed) { + console.log(chalk.yellow(` ⚠ "${trimmed}" doesn't look like owner/repo — skipping.`)); + } + } + /* c8 ignore stop */ + const defaultBranch = await detectDefaultBranch(resolvedPath, ownerRepo); // Generate unique session prefix @@ -749,8 +776,8 @@ async function addProjectToConfig( console.log(chalk.green(`\n✓ Added "${projectId}" to ${config.configPath}\n`)); if (!ownerRepo) { - console.log(chalk.yellow("⚠ Could not detect GitHub remote.")); - console.log(chalk.dim(" Update the 'repo' field in the config before spawning agents.\n")); + console.log(chalk.yellow("⚠ No repo configured — issue tracking and PR features will be unavailable.")); + console.log(chalk.dim(" Add a 'repo' field (owner/repo) to the config to enable them.\n")); } return projectId; diff --git a/packages/core/src/__tests__/prompt-builder.test.ts b/packages/core/src/__tests__/prompt-builder.test.ts index 7d775bae0..f0e68efe2 100644 --- a/packages/core/src/__tests__/prompt-builder.test.ts +++ b/packages/core/src/__tests__/prompt-builder.test.ts @@ -3,7 +3,7 @@ import { mkdirSync, writeFileSync, rmSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { randomUUID } from "node:crypto"; -import { buildPrompt, BASE_AGENT_PROMPT } from "../prompt-builder.js"; +import { buildPrompt, BASE_AGENT_PROMPT, BASE_AGENT_PROMPT_NO_REPO } from "../prompt-builder.js"; import type { ProjectConfig } from "../types.js"; let tmpDir: string; @@ -55,6 +55,16 @@ describe("buildPrompt", () => { expect(result).toContain("main"); }); + it("uses trimmed base prompt when repo is not configured", () => { + const noRepoProject = { ...project, repo: undefined }; + const result = buildPrompt({ project: noRepoProject, projectId: "test-app" }); + expect(result).toContain(BASE_AGENT_PROMPT_NO_REPO); + expect(result).not.toContain(BASE_AGENT_PROMPT); + expect(result).not.toContain("create a PR"); + expect(result).not.toContain("PR Best Practices"); + expect(result).not.toContain("Repository:"); + }); + it("includes issue ID in task section", () => { const result = buildPrompt({ project, diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index d6c869bb0..c5b3f7eed 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -480,7 +480,7 @@ function applyProjectDefaults(config: OrchestratorConfig): OrchestratorConfig { } // Infer tracker from repo if not set (default to github issues) - if (!project.tracker && project.repo) { + if (!project.tracker && project.repo?.includes("/")) { project.tracker = { plugin: inferredPlugin }; } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b376aeecd..6ee00fa70 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -58,7 +58,7 @@ export { createLifecycleManager } from "./lifecycle-manager.js"; export type { LifecycleManagerDeps } from "./lifecycle-manager.js"; // Prompt builder — layered prompt composition -export { buildPrompt, BASE_AGENT_PROMPT } from "./prompt-builder.js"; +export { buildPrompt, BASE_AGENT_PROMPT, BASE_AGENT_PROMPT_NO_REPO } from "./prompt-builder.js"; export type { PromptBuildConfig } from "./prompt-builder.js"; // Orchestrator prompt — generates orchestrator context for `ao start` diff --git a/packages/core/src/lifecycle-manager.ts b/packages/core/src/lifecycle-manager.ts index 2d4393605..82d85d437 100644 --- a/packages/core/src/lifecycle-manager.ts +++ b/packages/core/src/lifecycle-manager.ts @@ -245,7 +245,11 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan // Find the project for this PR const project = Object.values(config.projects).find((p) => { if (!p.repo) return false; - const [owner, repo] = p.repo.split("/"); + // Use lastIndexOf to correctly handle GitLab subgroup paths (group/subgroup/repo) + const slashIdx = p.repo.lastIndexOf("/"); + if (slashIdx < 0) return false; + const owner = p.repo.slice(0, slashIdx); + const repo = p.repo.slice(slashIdx + 1); return owner === pr.owner && repo === pr.repo; }); if (!project?.scm?.plugin) continue; diff --git a/packages/core/src/orchestrator-prompt.ts b/packages/core/src/orchestrator-prompt.ts index e427200d3..1a70d1ff0 100644 --- a/packages/core/src/orchestrator-prompt.ts +++ b/packages/core/src/orchestrator-prompt.ts @@ -48,8 +48,9 @@ Your role is to coordinate and manage worker agent sessions. You do NOT write co - **Local Path**: ${project.path} - **Dashboard Port**: ${config.port ?? 3000}`); - // Quick Start - sections.push(`## Quick Start + // 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 @@ -78,24 +79,51 @@ 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 ]${project.repo ? " [--claim-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 \` | 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 [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 |`, + ]; - // 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 |`); +${cmdRows.join("\n")}`); // Session Management sections.push(`## Session Management diff --git a/packages/core/src/prompt-builder.ts b/packages/core/src/prompt-builder.ts index f5275b650..8d7aefbe0 100644 --- a/packages/core/src/prompt-builder.ts +++ b/packages/core/src/prompt-builder.ts @@ -39,6 +39,17 @@ export const BASE_AGENT_PROMPT = `You are an AI coding agent managed by the Agen - If the repo has CI checks, make sure they pass before requesting review. - Respond to every review comment, even if just to acknowledge it.`; +/** Trimmed base prompt for projects without a configured repo/remote. */ +export const BASE_AGENT_PROMPT_NO_REPO = `You are an AI coding agent managed by the Agent Orchestrator (ao). + +## Session Lifecycle +- You are running inside a managed session. Focus on the assigned task. +- No remote repository is configured — work locally. PR, CI, and review features are unavailable. + +## Git Workflow +- Always create a feature branch from the default branch (never commit directly to it). +- Use conventional commit messages (feat:, fix:, chore:, etc.).`; + // ============================================================================= // TYPES // ============================================================================= @@ -151,7 +162,8 @@ export function buildPrompt(config: PromptBuildConfig): string { const sections: string[] = []; // Layer 1: Base prompt is always included for every managed session. - sections.push(BASE_AGENT_PROMPT); + // Use trimmed prompt when no repo is configured (PR/CI instructions don't apply). + sections.push(config.project.repo ? BASE_AGENT_PROMPT : BASE_AGENT_PROMPT_NO_REPO); // Layer 2: Config-derived context sections.push(buildConfigLayer(config)); diff --git a/packages/web/src/lib/scm-webhooks.ts b/packages/web/src/lib/scm-webhooks.ts index 12a6328bd..63f8d301a 100644 --- a/packages/web/src/lib/scm-webhooks.ts +++ b/packages/web/src/lib/scm-webhooks.ts @@ -36,7 +36,7 @@ export function findWebhookProjects( pathname: string, ): WebhookProjectMatch[] { return Object.entries(config.projects).flatMap(([projectId, project]) => { - if (!project.scm?.plugin) return []; + if (!project.repo || !project.scm?.plugin) return []; const webhookPath = getProjectWebhookPath(project); if (!webhookPath || webhookPath !== pathname) return []; const scm = registry.get("scm", project.scm.plugin);