fix: address all 9 review comments from @illegalcall

1. prompt-builder: gate PR/CI instructions on project.repo — use
   trimmed BASE_AGENT_PROMPT_NO_REPO when no remote configured
2. orchestrator-prompt: gate Quick Start and Available Commands
   sections on project.repo — omit issue/PR commands when absent
3. types.ts: add changeset (minor for ao-core) with migration note
4. config.ts: consistent includes("/") guard for tracker inference
5. start.ts: fail fast with clear error when run in non-git directory
6. start.ts: stricter repo validation regex (requires non-empty
   segments on both sides of slash)
7. start.ts: addProjectToConfig now prompts for repo like
   autoCreateConfig does, with matching warning message
8. scm-webhooks.ts: pre-filter projects without repo in
   findWebhookProjects to skip unnecessary SCM plugin lookups
9. lifecycle-manager.ts: fix GitLab subgroup split — use lastIndexOf
   to correctly handle group/subgroup/repo paths

Closes #1154

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
adil 2026-04-15 03:04:12 +05:30
parent 9ae7c00cef
commit c8af50fac2
9 changed files with 122 additions and 25 deletions

View File

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

View File

@ -535,6 +535,15 @@ async function autoCreateConfig(workingDir: string): Promise<OrchestratorConfig>
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<OrchestratorConfig>
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<OrchestratorConfig>
"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;

View File

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

View File

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

View File

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

View File

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

View File

@ -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 <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 |`,
];
// Available Commands
sections.push(`## Available Commands
| Command | Description |
|---------|-------------|
| \`ao status\` | Show all sessions with PR/CI/review status |
| \`ao spawn [issue] [--prompt <text>] [--claim-pr <pr>]\` | Spawn a worker session; use issue ID or --prompt for freeform tasks |
| \`ao batch-spawn <issues...>\` | Spawn multiple sessions in parallel (project auto-detected) |
| \`ao session ls [-p project]\` | List all sessions (optionally filter by project) |
| \`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 |`);
${cmdRows.join("\n")}`);
// Session Management
sections.push(`## Session Management

View File

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

View File

@ -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>("scm", project.scm.plugin);