Merge remote-tracking branch 'origin/main' into feat/windows-platform-adapter

# Conflicts:
#	packages/core/src/__tests__/utils.test.ts
This commit is contained in:
Priyanshu Choudhary 2026-04-11 14:53:59 +05:30
commit a8e9ed9d9f
25 changed files with 213 additions and 516 deletions

View File

@ -0,0 +1,6 @@
---
"@composio/ao-core": patch
"@composio/ao-plugin-tracker-linear": patch
---
Honor Linear issue `branchName` when spawning worktrees; fall back to `feat/<id>`. Query `branchName` in the Linear tracker plugin. Document branch format in SETUP and examples.

View File

@ -344,6 +344,8 @@ gh auth status
teamId: "your-team-id"
```
**Branch names:** On `ao spawn <issue>` with the Linear tracker, AO **prefers** Linears branch name (same as **Copy git branch name**, API field `branchName`). If that value is missing, it **falls back** to the previous convention: `feat/<ISSUE-ID>` (e.g. `feat/INT-123`). To change how Linear generates `branchName`, use **Linear → Settings → Integrations → GitHub → Branch format**.
**Verification:**
```bash

View File

@ -431,7 +431,7 @@ ao start</code></pre>
<p>
Returns a comprehensive annotated YAML schema covering every config field:
ports, defaults (runtime, agent, workspace, notifiers), project settings (repo, path, branch,
agentConfig, agentRules, workspace symlinks/postCreate, tracker, SCM, decomposer),
agentConfig, agentRules, workspace symlinks/postCreate, tracker, SCM),
notification channels, and notification routing. Used by <code>ao config-help</code>.
</p>

View File

@ -32,6 +32,8 @@ Use this if:
Integrates with Linear for issue tracking. Requires `LINEAR_API_KEY` environment variable.
Spawns prefer Linears **Copy git branch name** (API `branchName`); if absent, AO uses `feat/<issue-id>` as before. To change Linears pattern, use **Linear → Settings → Integrations → GitHub → Branch format**.
Use this if:
- Your team uses Linear for project management

View File

@ -943,15 +943,11 @@ export default function (api: PluginApi) {
type: "string",
description: "Immediately claim an existing PR number for the session",
},
decompose: {
type: "boolean",
description: "Decompose issue into subtasks before spawning",
},
},
},
async execute(
_toolCallId: string,
params: { issue?: string; agent?: string; claimPr?: string; decompose?: boolean },
params: { issue?: string; agent?: string; claimPr?: string },
) {
const args = ["spawn"];
if (params.issue) {
@ -963,7 +959,6 @@ export default function (api: PluginApi) {
}
if (params.agent) args.push("--agent", sanitizeCliArg(params.agent));
if (params.claimPr) args.push("--claim-pr", sanitizeCliArg(params.claimPr));
if (params.decompose) args.push("--decompose");
const result = await spawnWithRetry(config, args);
if (!result.ok) {
return {

View File

@ -31,8 +31,9 @@ export function registerSession(program: Command): void {
.command("ls")
.description("List all sessions")
.option("-p, --project <id>", "Filter by project ID")
.option("-a, --all", "Include orchestrator sessions")
.option("--json", "Output as JSON")
.action(async (opts: { project?: string; json?: boolean }) => {
.action(async (opts: { project?: string; all?: boolean; json?: boolean }) => {
const config = loadConfig();
if (opts.project && !config.projects[opts.project]) {
console.error(chalk.red(`Unknown project: ${opts.project}`));
@ -40,7 +41,14 @@ export function registerSession(program: Command): void {
}
const sm = await getSessionManager(config);
const sessions = await sm.list(opts.project);
const allSessions = await sm.list(opts.project);
// Filter out orchestrator sessions unless --all is passed
const sessions = opts.all
? allSessions
: allSessions.filter(
(s) => !isOrchestratorSessionName(config, s.id, s.projectId),
);
// Group sessions by project
const byProject = new Map<string, typeof sessions>();

View File

@ -4,14 +4,8 @@ import type { Command } from "commander";
import { resolve } from "node:path";
import {
loadConfig,
decompose,
getLeaves,
getSiblings,
formatPlanTree,
TERMINAL_STATUSES,
type OrchestratorConfig,
type DecomposerConfig,
DEFAULT_DECOMPOSER_CONFIG,
} from "@aoagents/ao-core";
import { DEFAULT_PORT } from "../lib/constants.js";
import { exec } from "../lib/shell.js";
@ -168,8 +162,6 @@ export function registerSpawn(program: Command): void {
.option("--agent <name>", "Override the agent plugin (e.g. codex, claude-code)")
.option("--claim-pr <pr>", "Immediately claim an existing PR for the spawned session")
.option("--assign-on-github", "Assign the claimed PR to the authenticated GitHub user")
.option("--decompose", "Decompose issue into subtasks before spawning")
.option("--max-depth <n>", "Max decomposition depth (default: 3)")
.action(
async (
first: string | undefined,
@ -179,8 +171,6 @@ export function registerSpawn(program: Command): void {
agent?: string;
claimPr?: string;
assignOnGithub?: boolean;
decompose?: boolean;
maxDepth?: string;
},
) => {
// Catch old two-arg usage: ao spawn <project> <issue>
@ -232,59 +222,7 @@ export function registerSpawn(program: Command): void {
await runSpawnPreflight(config, projectId, claimOptions);
await ensureLifecycleWorker(config, projectId);
if (opts.decompose && issueId) {
// Decompose the issue before spawning
const project = config.projects[projectId];
const decompConfig: DecomposerConfig = {
...DEFAULT_DECOMPOSER_CONFIG,
...(project.decomposer ?? {}),
maxDepth: opts.maxDepth
? parseInt(opts.maxDepth, 10)
: (project.decomposer?.maxDepth ?? 3),
};
const spinner = ora("Decomposing task...").start();
const issueTitle = issueId;
const plan = await decompose(issueTitle, decompConfig);
const leaves = getLeaves(plan.tree);
spinner.succeed(`Decomposed into ${chalk.bold(String(leaves.length))} subtasks`);
console.log();
console.log(chalk.dim(formatPlanTree(plan.tree)));
console.log();
if (leaves.length <= 1) {
console.log(chalk.yellow("Task is atomic — spawning directly."));
await spawnSession(config, projectId, issueId, opts.open, opts.agent, claimOptions);
} else {
// Create child issues and spawn sessions with lineage context
const sm = await getSessionManager(config);
console.log(chalk.bold(`Spawning ${leaves.length} sessions with lineage context...`));
console.log();
for (const leaf of leaves) {
const siblings = getSiblings(plan.tree, leaf.id);
try {
const session = await sm.spawn({
projectId,
issueId, // All work on the same parent issue for now
lineage: leaf.lineage,
siblings,
agent: opts.agent,
});
console.log(` ${chalk.green("✓")} ${session.id}${leaf.description}`);
} catch (err) {
console.error(
` ${chalk.red("✗")} ${leaf.description}${err instanceof Error ? err.message : err}`,
);
}
await new Promise((r) => setTimeout(r, 500));
}
}
} else {
await spawnSession(config, projectId, issueId, opts.open, opts.agent, claimOptions);
}
await spawnSession(config, projectId, issueId, opts.open, opts.agent, claimOptions);
} catch (err) {
console.error(chalk.red(`${err instanceof Error ? err.message : String(err)}`));
process.exit(1);

View File

@ -95,13 +95,6 @@ projects:
scm:
plugin: github # github | gitlab
# Task decomposition (optional)
decomposer:
enabled: false # Auto-decompose backlog issues
maxDepth: 3 # Max recursion depth
model: claude-sonnet-4-20250514
requireApproval: true # Require human approval before executing
# Per-project reaction overrides (optional)
# reactions:
# ci-failed:

View File

@ -47,7 +47,6 @@
"clean": "rm -rf dist"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.52.0",
"yaml": "^2.7.0",
"zod": "^3.24.0"
},

View File

@ -80,6 +80,112 @@ describe("spawn", () => {
expect(session.issueId).toBe("INT-100");
});
it("prefers tracker-provided Issue.branchName over tracker.branchName()", async () => {
const mockTracker: Tracker = {
name: "mock-tracker",
getIssue: vi.fn().mockResolvedValue({ branchName: "ABC-1234" }),
isCompleted: vi.fn().mockResolvedValue(false),
issueUrl: vi.fn().mockReturnValue(""),
branchName: vi.fn().mockReturnValue("custom/INT-100-my-feature"),
generatePrompt: vi.fn().mockResolvedValue(""),
};
const registryWithTracker: PluginRegistry = {
...mockRegistry,
get: vi.fn().mockImplementation((slot: string) => {
if (slot === "runtime") return mockRuntime;
if (slot === "agent") return mockAgent;
if (slot === "workspace") return mockWorkspace;
if (slot === "tracker") return mockTracker;
return null;
}),
};
const sm = createSessionManager({
config,
registry: registryWithTracker,
});
const session = await sm.spawn({ projectId: "my-app", issueId: "INT-100" });
expect(session.branch).toBe("ABC-1234");
});
it("uses tracker.branchName when Issue omits branchName", async () => {
const mockTracker: Tracker = {
name: "mock-tracker",
getIssue: vi.fn().mockResolvedValue({
id: "INT-100",
title: "T",
description: "",
url: "https://tracker.test/INT-100",
state: "open",
labels: [],
}),
isCompleted: vi.fn().mockResolvedValue(false),
issueUrl: vi.fn().mockReturnValue(""),
branchName: vi.fn().mockReturnValue("custom/INT-100-my-feature"),
generatePrompt: vi.fn().mockResolvedValue(""),
};
const registryWithTracker: PluginRegistry = {
...mockRegistry,
get: vi.fn().mockImplementation((slot: string) => {
if (slot === "runtime") return mockRuntime;
if (slot === "agent") return mockAgent;
if (slot === "workspace") return mockWorkspace;
if (slot === "tracker") return mockTracker;
return null;
}),
};
const sm = createSessionManager({
config,
registry: registryWithTracker,
});
const session = await sm.spawn({ projectId: "my-app", issueId: "INT-100" });
expect(session.branch).toBe("custom/INT-100-my-feature");
expect(mockTracker.branchName).toHaveBeenCalledWith("INT-100", expect.anything());
});
it("falls back to tracker.branchName when Issue.branchName is not git-safe", async () => {
const mockTracker: Tracker = {
name: "mock-tracker",
getIssue: vi.fn().mockResolvedValue({
id: "INT-100",
title: "T",
description: "",
url: "https://tracker.test/INT-100",
state: "open",
labels: [],
branchName: "bad branch with spaces",
}),
isCompleted: vi.fn().mockResolvedValue(false),
issueUrl: vi.fn().mockReturnValue(""),
branchName: vi.fn().mockReturnValue("feat/INT-100"),
generatePrompt: vi.fn().mockResolvedValue(""),
};
const registryWithTracker: PluginRegistry = {
...mockRegistry,
get: vi.fn().mockImplementation((slot: string) => {
if (slot === "runtime") return mockRuntime;
if (slot === "agent") return mockAgent;
if (slot === "workspace") return mockWorkspace;
if (slot === "tracker") return mockTracker;
return null;
}),
};
const sm = createSessionManager({
config,
registry: registryWithTracker,
});
const session = await sm.spawn({ projectId: "my-app", issueId: "INT-100" });
expect(session.branch).toBe("feat/INT-100");
});
it("sanitizes free-text issueId into a valid branch slug", async () => {
const sm = createSessionManager({ config, registry: mockRegistry });

View File

@ -2,7 +2,13 @@ import { describe, it, expect, afterEach } from "vitest";
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { isRetryableHttpStatus, normalizeRetryConfig, readLastJsonlEntry, shellEscape } from "../utils.js";
import {
isGitBranchNameSafe,
isRetryableHttpStatus,
normalizeRetryConfig,
readLastJsonlEntry,
shellEscape,
} from "../utils.js";
import { parsePrFromUrl } from "../utils/pr.js";
describe("readLastJsonlEntry", () => {
@ -87,6 +93,35 @@ describe("readLastJsonlEntry", () => {
});
});
describe("isGitBranchNameSafe", () => {
it("accepts typical Linear-style branch names", () => {
expect(isGitBranchNameSafe("feature/foo-bar-123")).toBe(true);
expect(isGitBranchNameSafe("feat/INT-123")).toBe(true);
});
it("rejects empty, @, lock suffix, double dots, and leading dot", () => {
expect(isGitBranchNameSafe("")).toBe(false);
expect(isGitBranchNameSafe("@")).toBe(false);
expect(isGitBranchNameSafe("foo.lock")).toBe(false);
expect(isGitBranchNameSafe("a..b")).toBe(false);
expect(isGitBranchNameSafe(".hidden")).toBe(false);
});
it("rejects consecutive slashes and dot-prefixed components", () => {
expect(isGitBranchNameSafe("feat//bar")).toBe(false);
expect(isGitBranchNameSafe("feat/.hidden")).toBe(false);
});
it("rejects characters invalid in git refs", () => {
expect(isGitBranchNameSafe("bad branch")).toBe(false);
expect(isGitBranchNameSafe("x:y")).toBe(false);
expect(isGitBranchNameSafe("x~y")).toBe(false);
expect(isGitBranchNameSafe("x?y")).toBe(false);
expect(isGitBranchNameSafe("x[y]")).toBe(false);
expect(isGitBranchNameSafe("a\nb")).toBe(false);
});
});
describe("retry utilities", () => {
it("marks 429 and 5xx statuses as retryable", () => {
expect(isRetryableHttpStatus(429)).toBe(true);

View File

@ -164,20 +164,6 @@ const RoleAgentConfigSchema = z
})
.optional();
const DecomposerConfigSchema = z
.object({
enabled: z.boolean().default(false),
maxDepth: z.number().min(1).max(5).default(3),
model: z.string().default("claude-sonnet-4-20250514"),
requireApproval: z.boolean().default(true),
})
.default({
enabled: false,
maxDepth: 3,
model: "claude-sonnet-4-20250514",
requireApproval: true,
});
const ProjectConfigSchema = z.object({
name: z.string().optional(),
repo: z.string(),
@ -205,7 +191,6 @@ const ProjectConfigSchema = z.object({
.enum(["reuse", "delete", "ignore", "delete-new", "ignore-new", "kill-previous"])
.optional(),
opencodeIssueSessionStrategy: z.enum(["reuse", "delete", "ignore"]).optional(),
decomposer: DecomposerConfigSchema.optional(),
});
const DefaultPluginsSchema = z.object({

View File

@ -1,276 +0,0 @@
/**
* Task Decomposer LLM-driven recursive task decomposition.
*
* Classifies issues as atomic (one agent can handle it) or composite
* (needs to be broken into subtasks). Composite tasks are recursively
* decomposed until all leaves are atomic.
*
* Integration: sits upstream of SessionManager.spawn(). When enabled,
* complex issues are decomposed into child issues before agents are spawned.
*/
import Anthropic from "@anthropic-ai/sdk";
// =============================================================================
// TYPES
// =============================================================================
export type TaskKind = "atomic" | "composite";
export type TaskStatus = "pending" | "decomposing" | "ready" | "running" | "done" | "failed";
export interface TaskNode {
id: string; // hierarchical: "1", "1.2", "1.2.3"
depth: number;
description: string;
kind?: TaskKind;
status: TaskStatus;
lineage: string[]; // ancestor descriptions root→parent
children: TaskNode[];
result?: string;
issueId?: string; // tracker issue created for this subtask
sessionId?: string; // AO session working on this task
}
export interface DecompositionPlan {
id: string;
rootTask: string;
tree: TaskNode;
maxDepth: number;
phase: "decomposing" | "review" | "approved" | "executing" | "done" | "failed";
createdAt: string;
approvedAt?: string;
parentIssueId?: string;
}
export interface DecomposerConfig {
/** Enable auto-decomposition for backlog issues (default: false) */
enabled: boolean;
/** Max recursion depth (default: 3) */
maxDepth: number;
/** Model to use for decomposition (default: claude-sonnet-4-20250514) */
model: string;
/** Require human approval before executing decomposed plans (default: true) */
requireApproval: boolean;
}
export const DEFAULT_DECOMPOSER_CONFIG: DecomposerConfig = {
enabled: false,
maxDepth: 3,
model: "claude-sonnet-4-20250514",
requireApproval: true,
};
// =============================================================================
// LINEAGE CONTEXT
// =============================================================================
/** Format the task lineage as an indented hierarchy for LLM context. */
export function formatLineage(lineage: string[], current: string): string {
const parts = lineage.map((desc, i) => `${" ".repeat(i)}${i}. ${desc}`);
parts.push(`${" ".repeat(lineage.length)}${lineage.length}. ${current} <-- (this task)`);
return parts.join("\n");
}
/** Format sibling tasks for awareness context. */
export function formatSiblings(siblings: string[], current: string): string {
if (siblings.length === 0) return "";
const lines = siblings.map((s) => (s === current ? ` - ${s} <-- (you)` : ` - ${s}`));
return `Sibling tasks being worked on in parallel:\n${lines.join("\n")}`;
}
// =============================================================================
// LLM CALLS
// =============================================================================
const CLASSIFY_SYSTEM = `You decide whether a software task is "atomic" or "composite".
- "atomic" = a developer can implement this directly without needing to plan further. It may involve multiple steps, but they're all part of one coherent unit of work.
- "composite" = this clearly contains 2+ independent concerns that should be worked on separately (e.g., backend + frontend, or auth + database + UI).
Decision heuristics:
- If the task names a single feature, endpoint, component, or module: atomic.
- If the task bundles unrelated concerns (e.g., "build auth and set up CI"): composite.
- If you're at depth 2 or deeper in the hierarchy, it is almost certainly atomic only mark composite if you can name 2+ truly independent deliverables.
- When in doubt, choose atomic. Over-decomposition creates more overhead than under-decomposition.
Respond with ONLY the word "atomic" or "composite". Nothing else.`;
const DECOMPOSE_SYSTEM = `You are a pragmatic task decomposition engine for software projects.
Given a composite task, break it into the MINIMUM number of subtasks needed:
- A simple task might only need 2 subtasks.
- A complex task might need up to 7, but only if each is truly distinct.
- Do NOT pad with extra subtasks. Do NOT create "test and polish" or "define requirements" subtasks.
- Do NOT create subtasks that overlap or restate each other.
- Each subtask should represent real, distinct work.
Think about how an experienced developer would actually split this work.
Respond with a JSON array of strings, each being a subtask description. Example:
["Implement Stripe webhook handler", "Build subscription management UI"]
Nothing else just the JSON array.`;
async function classifyTask(
client: Anthropic,
model: string,
task: string,
lineage: string[],
): Promise<TaskKind> {
const context = formatLineage(lineage, task);
const res = await client.messages.create({
model,
max_tokens: 10,
system: CLASSIFY_SYSTEM,
messages: [{ role: "user", content: `Task hierarchy:\n${context}` }],
});
const text = res.content[0].type === "text" ? res.content[0].text.trim().toLowerCase() : "";
return text === "composite" ? "composite" : "atomic";
}
async function decomposeTask(
client: Anthropic,
model: string,
task: string,
lineage: string[],
): Promise<string[]> {
const context = formatLineage(lineage, task);
const res = await client.messages.create({
model,
max_tokens: 1024,
system: DECOMPOSE_SYSTEM,
messages: [{ role: "user", content: `Task hierarchy:\n${context}` }],
});
const text = res.content[0].type === "text" ? res.content[0].text.trim() : "[]";
const jsonMatch = text.match(/\[[\s\S]*\]/);
if (!jsonMatch) {
throw new Error(`Decomposition failed — no JSON array in response: ${text}`);
}
const subtasks = JSON.parse(jsonMatch[0]) as string[];
if (!Array.isArray(subtasks) || subtasks.length < 2) {
throw new Error(`Decomposition produced ${subtasks.length} subtasks — need at least 2`);
}
return subtasks;
}
// =============================================================================
// TREE OPERATIONS
// =============================================================================
function createTaskNode(
id: string,
description: string,
depth: number,
lineage: string[],
): TaskNode {
return { id, depth, description, status: "pending", lineage, children: [] };
}
/** Recursively decompose a task tree (planning phase — no execution). */
async function planTree(
client: Anthropic,
model: string,
task: TaskNode,
maxDepth: number,
): Promise<TaskNode> {
const kind = task.depth >= maxDepth ? "atomic" : await classifyTask(client, model, task.description, task.lineage);
task.kind = kind;
if (kind === "atomic") {
task.status = "ready";
return task;
}
task.status = "decomposing";
const subtaskDescriptions = await decomposeTask(client, model, task.description, task.lineage);
const childLineage = [...task.lineage, task.description];
task.children = subtaskDescriptions.map((desc, i) =>
createTaskNode(`${task.id}.${i + 1}`, desc, task.depth + 1, childLineage),
);
// Recurse on children concurrently
await Promise.all(task.children.map((child) => planTree(client, model, child, maxDepth)));
task.status = "ready";
return task;
}
// =============================================================================
// PUBLIC API
// =============================================================================
/** Create a decomposition plan for a task. */
export async function decompose(
taskDescription: string,
config: DecomposerConfig = DEFAULT_DECOMPOSER_CONFIG,
): Promise<DecompositionPlan> {
const client = new Anthropic();
const tree = createTaskNode("1", taskDescription, 0, []);
await planTree(client, config.model, tree, config.maxDepth);
return {
id: `plan-${Date.now()}`,
rootTask: taskDescription,
tree,
maxDepth: config.maxDepth,
phase: config.requireApproval ? "review" : "approved",
createdAt: new Date().toISOString(),
};
}
/** Collect all leaf (atomic) tasks from a tree. */
export function getLeaves(task: TaskNode): TaskNode[] {
if (task.children.length === 0) return [task];
return task.children.flatMap(getLeaves);
}
/** Get sibling task descriptions for a given task. */
export function getSiblings(root: TaskNode, taskId: string): string[] {
function findParent(node: TaskNode): TaskNode | null {
for (const child of node.children) {
if (child.id === taskId) return node;
const found = findParent(child);
if (found) return found;
}
return null;
}
const parent = findParent(root);
if (!parent) return [];
return parent.children.filter((c) => c.id !== taskId).map((c) => c.description);
}
/** Format the plan tree as a human-readable string. */
export function formatPlanTree(task: TaskNode, indent = 0): string {
const prefix = " ".repeat(indent);
const kindTag = task.kind === "atomic" ? "[ATOMIC]" : task.kind === "composite" ? "[COMPOSITE]" : "";
const statusTag = task.status !== "ready" ? ` (${task.status})` : "";
let line = `${prefix}${task.id}. ${kindTag} ${task.description}${statusTag}`;
if (task.children.length > 0) {
const childLines = task.children.map((c) => formatPlanTree(c, indent + 1)).join("\n");
line += "\n" + childLines;
}
return line;
}
/** Propagate done/failed status up the tree. */
export function propagateStatus(task: TaskNode): void {
if (task.children.length === 0) return;
task.children.forEach(propagateStatus);
if (task.children.every((c) => c.status === "done")) {
task.status = "done";
} else if (task.children.some((c) => c.status === "failed")) {
task.status = "failed";
} else if (task.children.some((c) => c.status === "running" || c.status === "done")) {
task.status = "running";
}
}

View File

@ -61,25 +61,6 @@ export type { LifecycleManagerDeps } from "./lifecycle-manager.js";
export { buildPrompt, BASE_AGENT_PROMPT } from "./prompt-builder.js";
export type { PromptBuildConfig } from "./prompt-builder.js";
// Decomposer — LLM-driven task decomposition
export {
decompose,
getLeaves,
getSiblings,
formatPlanTree,
formatLineage,
formatSiblings,
propagateStatus,
DEFAULT_DECOMPOSER_CONFIG,
} from "./decomposer.js";
export type {
TaskNode,
TaskKind,
TaskStatus,
DecompositionPlan,
DecomposerConfig,
} from "./decomposer.js";
// Orchestrator prompt — generates orchestrator context for `ao start`
export { generateOrchestratorPrompt } from "./orchestrator-prompt.js";
export type { OrchestratorPromptConfig } from "./orchestrator-prompt.js";
@ -89,6 +70,7 @@ export {
shellEscape,
escapeAppleScript,
validateUrl,
isGitBranchNameSafe,
isRetryableHttpStatus,
normalizeRetryConfig,
readLastJsonlEntry,

View File

@ -58,12 +58,6 @@ export interface PromptBuildConfig {
/** Explicit user prompt (appended last) */
userPrompt?: string;
/** Decomposition context — ancestor task chain (from decomposer) */
lineage?: string[];
/** Decomposition context — sibling task descriptions (from decomposer) */
siblings?: string[];
}
// =============================================================================
@ -165,25 +159,6 @@ export function buildPrompt(config: PromptBuildConfig): string {
sections.push(`## Project Rules\n${userRules}`);
}
// Layer 4: Decomposition context (lineage + siblings)
if (config.lineage && config.lineage.length > 0) {
const hierarchy = config.lineage.map((desc, i) => `${" ".repeat(i)}${i}. ${desc}`);
// Add current task marker using issueId or last lineage entry
const currentLabel = config.issueId ?? "this task";
hierarchy.push(`${" ".repeat(config.lineage.length)}${config.lineage.length}. ${currentLabel} <-- (this task)`);
sections.push(
`## Task Hierarchy\nThis task is part of a larger decomposed plan. Your place in the hierarchy:\n\n\`\`\`\n${hierarchy.join("\n")}\n\`\`\`\n\nStay focused on YOUR specific task. Do not implement functionality that belongs to other tasks in the hierarchy.`,
);
}
if (config.siblings && config.siblings.length > 0) {
const siblingLines = config.siblings.map((s) => ` - ${s}`);
sections.push(
`## Parallel Work\nSibling tasks being worked on in parallel:\n${siblingLines.join("\n")}\n\nDo not duplicate work that sibling tasks handle. If you need interfaces/types from siblings, define reasonable stubs.`,
);
}
// Explicit user prompt (appended last, highest priority)
if (config.userPrompt) {
sections.push(`## Additional Instructions\n${config.userPrompt}`);

View File

@ -65,6 +65,7 @@ import { asValidOpenCodeSessionId } from "./opencode-session-id.js";
import { normalizeOrchestratorSessionStrategy } from "./orchestrator-session-strategy.js";
import { sessionFromMetadata } from "./utils/session-from-metadata.js";
import { safeJsonParse } from "./utils/validation.js";
import { isGitBranchNameSafe } from "./utils.js";
import { resolveAgentSelection, resolveSessionRole } from "./agent-selection.js";
const execFileAsync = promisify(execFile);
@ -994,7 +995,11 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
if (spawnConfig.branch) {
branch = spawnConfig.branch;
} else if (spawnConfig.issueId && plugins.tracker && resolvedIssue) {
branch = plugins.tracker.branchName(spawnConfig.issueId, project);
const fromIssue = resolvedIssue.branchName;
branch =
fromIssue && isGitBranchNameSafe(fromIssue)
? fromIssue
: plugins.tracker.branchName(spawnConfig.issueId, project);
} else if (spawnConfig.issueId) {
// If the issueId is already branch-safe (e.g. "INT-9999"), use as-is.
// Otherwise sanitize free-text (e.g. "fix login bug") into a valid slug.
@ -1067,8 +1072,6 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
issueId: spawnConfig.issueId,
issueContext,
userPrompt: spawnConfig.prompt,
lineage: spawnConfig.lineage,
siblings: spawnConfig.siblings,
});
// Get agent launch config and create runtime — clean up workspace on failure

View File

@ -232,10 +232,6 @@ export interface SessionSpawnConfig {
agent?: string;
/** Override the OpenCode subagent for this session (e.g. "sisyphus", "oracle") */
subagent?: string;
/** Decomposition context — ancestor task chain (passed to prompt builder) */
lineage?: string[];
/** Decomposition context — sibling task descriptions (passed to prompt builder) */
siblings?: string[];
}
/** Config for creating an orchestrator session */
@ -545,6 +541,7 @@ export interface Issue {
labels: string[];
assignee?: string;
priority?: number;
branchName?: string;
}
export interface IssueFilters {
@ -1185,18 +1182,6 @@ export interface ProjectConfig {
| "kill-previous";
opencodeIssueSessionStrategy?: "reuse" | "delete" | "ignore";
/** Task decomposition configuration */
decomposer?: {
/** Enable auto-decomposition for backlog issues (default: false) */
enabled: boolean;
/** Max recursion depth (default: 3) */
maxDepth: number;
/** Model to use for decomposition (default: claude-sonnet-4-20250514) */
model: string;
/** Require human approval before executing decomposed plans (default: true) */
requireApproval: boolean;
};
}
export interface TrackerConfig {

View File

@ -39,6 +39,30 @@ export function validateUrl(url: string, label: string): void {
}
}
/**
* Conservative subset of git `check-ref-format` rules for branch-like names.
* Used before passing tracker-supplied names to `git worktree` / `checkout -b`.
*
* Slashes are allowed (e.g. `feature/foo-bar`).
*/
export function isGitBranchNameSafe(name: string): boolean {
if (!name) return false;
if (name === "@" || name.startsWith(".") || name.endsWith(".") || name.endsWith("/")) return false;
if (name.endsWith(".lock")) return false;
if (name.includes("..")) return false;
if (name.includes("//")) return false;
if (name.includes("/.")) return false;
if (name.includes("@{")) return false;
if (name.startsWith("/")) return false;
for (let i = 0; i < name.length; i++) {
const c = name.charCodeAt(i);
if (c <= 0x1f || c === 0x7f) return false;
}
// Space and git-forbidden punctuation (see git-check-ref-format)
if (/[\s~^:?*[\\]/.test(name)) return false;
return true;
}
/**
* Returns true if an HTTP status code should be retried.
* Retry only 429 (rate-limit) and 5xx (server) failures.

View File

@ -217,6 +217,10 @@ describe.skipIf(!canRun)("tracker-linear (integration)", () => {
expect(issue.state).toBe("open");
expect(Array.isArray(issue.labels)).toBe(true);
expect(issue.priority).toBe(4);
expect(issue.branchName).toBeDefined();
expect(typeof issue.branchName).toBe("string");
expect(issue.branchName!.length).toBeGreaterThan(0);
});
it("isCompleted returns false for an open issue", async () => {

View File

@ -215,6 +215,7 @@ interface LinearIssueNode {
description: string | null;
url: string;
priority: number;
branchName?: string | null;
state: {
name: string;
type: string; // "triage" | "backlog" | "unstarted" | "started" | "completed" | "canceled"
@ -260,6 +261,7 @@ const ISSUE_FIELDS = `
description
url
priority
branchName
state { name type }
labels { nodes { name } }
assignee { name displayName }
@ -294,6 +296,7 @@ function createLinearTracker(query: GraphQLTransport): Tracker {
labels: node.labels.nodes.map((l) => l.name),
assignee: node.assignee?.displayName ?? node.assignee?.name,
priority: node.priority,
branchName: node.branchName ?? undefined,
};
},
@ -426,6 +429,7 @@ function createLinearTracker(query: GraphQLTransport): Tracker {
labels: node.labels.nodes.map((l) => l.name),
assignee: node.assignee?.displayName ?? node.assignee?.name,
priority: node.priority,
branchName: node.branchName ?? undefined,
}));
},
@ -618,6 +622,7 @@ function createLinearTracker(query: GraphQLTransport): Tracker {
labels: node.labels.nodes.map((l) => l.name),
assignee: node.assignee?.displayName ?? node.assignee?.name,
priority: node.priority,
branchName: node.branchName ?? undefined,
};
// Assign after creation (Linear's issueCreate uses assigneeId, not display name)

View File

@ -43,6 +43,7 @@ const sampleIssueNode = {
description: "Users can't log in with SSO",
url: "https://linear.app/acme/issue/INT-123",
priority: 2,
branchName: "feat/INT-123",
state: { name: "In Progress", type: "started" },
labels: { nodes: [{ name: "bug" }, { name: "high-priority" }] },
assignee: { name: "Alice Smith", displayName: "Alice" },
@ -189,6 +190,7 @@ describe("tracker-linear plugin", () => {
labels: ["bug", "high-priority"],
assignee: "Alice",
priority: 2,
branchName: "feat/INT-123",
});
});
@ -639,6 +641,7 @@ describe("tracker-linear plugin", () => {
id: "INT-123",
title: "Fix login bug",
state: "in_progress",
branchName: "feat/INT-123",
});
});

View File

@ -49,11 +49,6 @@ vi.mock("@aoagents/ao-core", () => ({
getStates: vi.fn(),
check: vi.fn(),
}),
decompose: vi.fn(),
getLeaves: vi.fn(),
getSiblings: vi.fn(),
formatPlanTree: vi.fn(),
DEFAULT_DECOMPOSER_CONFIG: {},
TERMINAL_STATUSES: new Set(["merged", "killed"]) as ReadonlySet<string>,
}));

View File

@ -17,10 +17,6 @@ import {
createPluginRegistry,
createSessionManager,
createLifecycleManager,
decompose,
getLeaves,
getSiblings,
formatPlanTree,
type OrchestratorConfig,
type PluginRegistry,
type OpenCodeSessionManager,
@ -30,8 +26,6 @@ import {
type Tracker,
type Issue,
type Session,
type DecomposerConfig,
DEFAULT_DECOMPOSER_CONFIG,
isOrchestratorSession,
TERMINAL_STATUSES,
} from "@aoagents/ao-core";
@ -274,65 +268,8 @@ export async function pollBacklog(): Promise<void> {
if (activeIssueIds.has(issue.id.toLowerCase())) continue;
try {
const decompConfig = project.decomposer;
const shouldDecompose = decompConfig?.enabled ?? false;
if (shouldDecompose) {
// Decompose the issue before spawning
const taskDescription = `${issue.title}\n\n${issue.description}`;
const decomposerConfig: DecomposerConfig = {
...DEFAULT_DECOMPOSER_CONFIG,
...decompConfig,
};
console.log(`[backlog] Decomposing issue ${issue.id}: "${issue.title}"`);
const plan = await decompose(taskDescription, decomposerConfig);
const leaves = getLeaves(plan.tree);
if (leaves.length <= 1) {
// Atomic — spawn directly
await sessionManager.spawn({ projectId, issueId: issue.id });
availableSlots--;
} else if (decomposerConfig.requireApproval) {
// Post plan as comment and wait for human approval
const treeText = formatPlanTree(plan.tree);
if (tracker.updateIssue) {
await tracker.updateIssue(
issue.id,
{
comment: `## Decomposition Plan\n\n\`\`\`\n${treeText}\n\`\`\`\n\n${leaves.length} subtasks identified. Remove \`agent:backlog\` and add \`agent:decompose-approved\` to execute.`,
labels: ["agent:decompose-pending"],
removeLabels: ["agent:backlog"],
},
project,
);
}
console.log(
`[backlog] Posted decomposition plan for ${issue.id} (${leaves.length} subtasks, awaiting approval)`,
);
continue;
} else {
// Auto-execute: spawn each leaf with lineage context
console.log(
`[backlog] Auto-executing decomposition for ${issue.id} (${leaves.length} subtasks)`,
);
for (const leaf of leaves) {
if (availableSlots <= 0) break;
const siblings = getSiblings(plan.tree, leaf.id);
await sessionManager.spawn({
projectId,
issueId: issue.id,
lineage: leaf.lineage,
siblings,
});
availableSlots--;
}
}
} else {
// No decomposition — spawn directly (classic behavior)
await sessionManager.spawn({ projectId, issueId: issue.id });
availableSlots--;
}
await sessionManager.spawn({ projectId, issueId: issue.id });
availableSlots--;
activeIssueIds.add(issue.id.toLowerCase());

View File

@ -144,9 +144,6 @@ importers:
packages/core:
dependencies:
'@anthropic-ai/sdk':
specifier: ^0.52.0
version: 0.52.0
yaml:
specifier: ^2.7.0
version: 2.8.3
@ -746,10 +743,6 @@ packages:
resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==}
engines: {node: '>=6.0.0'}
'@anthropic-ai/sdk@0.52.0':
resolution: {integrity: sha512-d4c+fg+xy9e46c8+YnrrgIQR45CZlAi7PwdzIfDXDM6ACxEZli1/fxhURsq30ZpMZy6LvSkr41jGq5aF5TD7rQ==}
hasBin: true
'@asamuzakjp/css-color@3.2.0':
resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==}
@ -4259,8 +4252,6 @@ snapshots:
'@jridgewell/gen-mapping': 0.3.13
'@jridgewell/trace-mapping': 0.3.31
'@anthropic-ai/sdk@0.52.0': {}
'@asamuzakjp/css-color@3.2.0':
dependencies:
'@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)

View File

@ -136,9 +136,9 @@ for ISSUE in "${ISSUES[@]}"; do
# Build initial prompt
if [ "$SESSION_PREFIX" = "splitly" ]; then
INITIAL_PROMPT="Please start working on GitHub issue #$ISSUE_NUM. First, fetch the issue details using: gh issue view $ISSUE_NUM --repo $GITHUB_REPO. Then create an appropriate feature branch (e.g., fix/issue-$ISSUE_NUM-short-description or feat/issue-$ISSUE_NUM-short-description), and start implementing the solution."
INITIAL_PROMPT="Please start working on GitHub issue #$ISSUE_NUM. First, fetch the issue details using: gh issue view $ISSUE_NUM --repo $GITHUB_REPO. Then implement the solution in the branch already prepared for this session (confirm with \`git branch --show-current\` in the worktree if needed)."
else
INITIAL_PROMPT="Please start working on $ISSUE, fetch ticket info, create the appropriate branch so that github auto links to linear, and start working on the task"
INITIAL_PROMPT="Please start working on $ISSUE, fetch ticket info, and implement the task in the branch already prepared for this session (confirm with \`git branch --show-current\` in the worktree if needed)."
fi
# Send prompt to the running Claude session