From 1e16afcb67c3285af98606b693897b20c54deaf2 Mon Sep 17 00:00:00 2001 From: Denis Darii Date: Thu, 2 Apr 2026 22:17:19 +0200 Subject: [PATCH 01/16] Add optional branchName on Issue for tracker-supplied git branch strings Made-with: Cursor --- packages/core/src/types.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index a5c5f804a..8f8f8841d 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -519,6 +519,7 @@ export interface Issue { labels: string[]; assignee?: string; priority?: number; + branchName?: string; } export interface IssueFilters { From 8d8a1aeb9d693214dbfc1f74b6751e4fbebfa594 Mon Sep 17 00:00:00 2001 From: Denis Darii Date: Thu, 2 Apr 2026 22:17:20 +0200 Subject: [PATCH 02/16] Query Linear issue branchName in GraphQL, map onto Issue Made-with: Cursor --- packages/plugins/tracker-linear/src/index.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/plugins/tracker-linear/src/index.ts b/packages/plugins/tracker-linear/src/index.ts index 5f114f3f4..7cf00773f 100644 --- a/packages/plugins/tracker-linear/src/index.ts +++ b/packages/plugins/tracker-linear/src/index.ts @@ -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, + ...(node.branchName ? { branchName: node.branchName } : {}), }; }, @@ -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, + ...(node.branchName ? { branchName: node.branchName } : {}), })); }, From 42a4349e3f9095ea2c20406e1c8e1d7ef200ed8e Mon Sep 17 00:00:00 2001 From: Denis Darii Date: Thu, 2 Apr 2026 22:17:21 +0200 Subject: [PATCH 03/16] Update Linear tracker tests: expect branchName on issue payloads Made-with: Cursor --- packages/plugins/tracker-linear/test/index.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/plugins/tracker-linear/test/index.test.ts b/packages/plugins/tracker-linear/test/index.test.ts index 65f010a1c..16184732a 100644 --- a/packages/plugins/tracker-linear/test/index.test.ts +++ b/packages/plugins/tracker-linear/test/index.test.ts @@ -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", }); }); From 915251a34fade7d46d03e745e25116e8b440377b Mon Sep 17 00:00:00 2001 From: Denis Darii Date: Thu, 2 Apr 2026 22:17:22 +0200 Subject: [PATCH 04/16] Prefer resolved issue branchName when choosing spawn worktree branch Made-with: Cursor --- packages/core/src/session-manager.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/core/src/session-manager.ts b/packages/core/src/session-manager.ts index d23333cc4..e1d22f987 100644 --- a/packages/core/src/session-manager.ts +++ b/packages/core/src/session-manager.ts @@ -952,7 +952,9 @@ 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); + branch = resolvedIssue.branchName + ? resolvedIssue.branchName + : 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. From 36b5a50ab3234bf22e9ee874bbf42dbd1c69174f Mon Sep 17 00:00:00 2001 From: Denis Darii Date: Thu, 2 Apr 2026 22:17:25 +0200 Subject: [PATCH 05/16] Add spawn test: Issue.branchName overrides tracker.branchName fallback Made-with: Cursor --- .../__tests__/session-manager/spawn.test.ts | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/packages/core/src/__tests__/session-manager/spawn.test.ts b/packages/core/src/__tests__/session-manager/spawn.test.ts index 07aa3b741..1803ff714 100644 --- a/packages/core/src/__tests__/session-manager/spawn.test.ts +++ b/packages/core/src/__tests__/session-manager/spawn.test.ts @@ -107,6 +107,36 @@ 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("sanitizes free-text issueId into a valid branch slug", async () => { const sm = createSessionManager({ config, registry: mockRegistry }); From 693ab290b4cdc1e15b02ee647b0fdc61084e1cf6 Mon Sep 17 00:00:00 2001 From: Denis Darii Date: Thu, 2 Apr 2026 22:17:26 +0200 Subject: [PATCH 06/16] Extend Linear integration test: getIssue returns non-empty branchName Made-with: Cursor --- .../integration-tests/src/tracker-linear.integration.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/integration-tests/src/tracker-linear.integration.test.ts b/packages/integration-tests/src/tracker-linear.integration.test.ts index c29a6539e..41d0ed0c5 100644 --- a/packages/integration-tests/src/tracker-linear.integration.test.ts +++ b/packages/integration-tests/src/tracker-linear.integration.test.ts @@ -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).toContain(issueIdentifier); }); it("isCompleted returns false for an open issue", async () => { From e42a9dac69ff726429a5be70ccaec805c0a5d272 Mon Sep 17 00:00:00 2001 From: Denis Darii Date: Thu, 2 Apr 2026 22:17:27 +0200 Subject: [PATCH 07/16] Expand SETUP Linear docs: Copy git branch name, Branch format, feat/ fallback Made-with: Cursor --- SETUP.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/SETUP.md b/SETUP.md index bb19d5992..dfd6e1651 100644 --- a/SETUP.md +++ b/SETUP.md @@ -344,6 +344,8 @@ gh auth status teamId: "your-team-id" ``` +**Branch names:** On `ao spawn ` with the Linear tracker, AO **prefers** Linear’s branch name (same as **Copy git branch name**, API field `branchName`). If that value is missing, it **falls back** to the previous convention: `feat/` (e.g. `feat/INT-123`). To change how Linear generates `branchName`, use **Linear → Settings → Integrations → GitHub → Branch format**. + **Verification:** ```bash From 5e637b38b97c96ab2800ae61d29124a58ed45f4a Mon Sep 17 00:00:00 2001 From: Denis Darii Date: Thu, 2 Apr 2026 22:17:28 +0200 Subject: [PATCH 08/16] Update examples readme: Linear branchName vs feat/ when spawning Made-with: Cursor --- examples/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/examples/README.md b/examples/README.md index 2365c0590..d68ba6de6 100644 --- a/examples/README.md +++ b/examples/README.md @@ -32,6 +32,8 @@ Use this if: Integrates with Linear for issue tracking. Requires `LINEAR_API_KEY` environment variable. +Spawns prefer Linear’s **Copy git branch name** (API `branchName`); if absent, AO uses `feat/` as before. To change Linear’s pattern, use **Linear → Settings → Integrations → GitHub → Branch format**. + Use this if: - Your team uses Linear for project management From 2c39076a52d0def8fe6fa6ff05780b686044d62c Mon Sep 17 00:00:00 2001 From: Denis Darii Date: Thu, 2 Apr 2026 22:17:29 +0200 Subject: [PATCH 09/16] Align claude-batch-spawn prompts: use AO-prepared branch, drop conflicting git hints Made-with: Cursor --- scripts/claude-batch-spawn | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/claude-batch-spawn b/scripts/claude-batch-spawn index 107e59394..efd4308a0 100755 --- a/scripts/claude-batch-spawn +++ b/scripts/claude-batch-spawn @@ -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." 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." fi # Send prompt to the running Claude session From 0a31f50dfd70878c7e075a038d8eb5206c2418c7 Mon Sep 17 00:00:00 2001 From: Denis Darii Date: Thu, 2 Apr 2026 22:17:30 +0200 Subject: [PATCH 10/16] Add changeset: patch ao-core and tracker-linear for branchName spawn Made-with: Cursor --- .changeset/linear-spawn-branch-name.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/linear-spawn-branch-name.md diff --git a/.changeset/linear-spawn-branch-name.md b/.changeset/linear-spawn-branch-name.md new file mode 100644 index 000000000..2191ad378 --- /dev/null +++ b/.changeset/linear-spawn-branch-name.md @@ -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/`. Query `branchName` in the Linear tracker plugin. Document branch format in SETUP and examples. From adb3958d26896916a822e878e21a8d9d09f2f485 Mon Sep 17 00:00:00 2001 From: Denis Darii Date: Fri, 3 Apr 2026 09:37:43 +0200 Subject: [PATCH 11/16] Add branchName to issue payload in Linear tracker --- packages/plugins/tracker-linear/src/index.ts | 1 + packages/plugins/tracker-linear/test/index.test.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/plugins/tracker-linear/src/index.ts b/packages/plugins/tracker-linear/src/index.ts index 7cf00773f..8e61d985e 100644 --- a/packages/plugins/tracker-linear/src/index.ts +++ b/packages/plugins/tracker-linear/src/index.ts @@ -622,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, + ...(node.branchName ? { branchName: node.branchName } : {}), }; // Assign after creation (Linear's issueCreate uses assigneeId, not display name) diff --git a/packages/plugins/tracker-linear/test/index.test.ts b/packages/plugins/tracker-linear/test/index.test.ts index 16184732a..d33a52a9f 100644 --- a/packages/plugins/tracker-linear/test/index.test.ts +++ b/packages/plugins/tracker-linear/test/index.test.ts @@ -641,6 +641,7 @@ describe("tracker-linear plugin", () => { id: "INT-123", title: "Fix login bug", state: "in_progress", + branchName: "feat/INT-123", }); }); From f92c5f6c9dcae47c763ddd39dde4bc8e9f18f724 Mon Sep 17 00:00:00 2001 From: Denis Darii Date: Fri, 3 Apr 2026 09:59:58 +0200 Subject: [PATCH 12/16] Validate Linear branchName before spawn --- .../__tests__/session-manager/spawn.test.ts | 76 +++++++++++++++++++ packages/core/src/__tests__/utils.test.ts | 31 +++++++- packages/core/src/index.ts | 1 + packages/core/src/session-manager.ts | 9 ++- packages/core/src/utils.ts | 22 ++++++ .../src/tracker-linear.integration.test.ts | 2 +- packages/plugins/tracker-linear/src/index.ts | 6 +- scripts/claude-batch-spawn | 4 +- 8 files changed, 141 insertions(+), 10 deletions(-) diff --git a/packages/core/src/__tests__/session-manager/spawn.test.ts b/packages/core/src/__tests__/session-manager/spawn.test.ts index 1803ff714..3e8006b4e 100644 --- a/packages/core/src/__tests__/session-manager/spawn.test.ts +++ b/packages/core/src/__tests__/session-manager/spawn.test.ts @@ -137,6 +137,82 @@ describe("spawn", () => { 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 }); diff --git a/packages/core/src/__tests__/utils.test.ts b/packages/core/src/__tests__/utils.test.ts index 341040f39..85f12643f 100644 --- a/packages/core/src/__tests__/utils.test.ts +++ b/packages/core/src/__tests__/utils.test.ts @@ -2,7 +2,12 @@ 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 } from "../utils.js"; +import { + isGitBranchNameSafe, + isRetryableHttpStatus, + normalizeRetryConfig, + readLastJsonlEntry, +} from "../utils.js"; import { parsePrFromUrl } from "../utils/pr.js"; describe("readLastJsonlEntry", () => { @@ -87,6 +92,30 @@ 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 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); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ef028390a..c8ddac0c5 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -98,6 +98,7 @@ export { shellEscape, escapeAppleScript, validateUrl, + isGitBranchNameSafe, isRetryableHttpStatus, normalizeRetryConfig, readLastJsonlEntry, diff --git a/packages/core/src/session-manager.ts b/packages/core/src/session-manager.ts index e1d22f987..dfd53b4fd 100644 --- a/packages/core/src/session-manager.ts +++ b/packages/core/src/session-manager.ts @@ -73,6 +73,7 @@ import { } from "./global-pause.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); @@ -952,9 +953,11 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM if (spawnConfig.branch) { branch = spawnConfig.branch; } else if (spawnConfig.issueId && plugins.tracker && resolvedIssue) { - branch = resolvedIssue.branchName - ? resolvedIssue.branchName - : 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. diff --git a/packages/core/src/utils.ts b/packages/core/src/utils.ts index ad27fd858..ee3bb7465 100644 --- a/packages/core/src/utils.ts +++ b/packages/core/src/utils.ts @@ -33,6 +33,28 @@ 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.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. diff --git a/packages/integration-tests/src/tracker-linear.integration.test.ts b/packages/integration-tests/src/tracker-linear.integration.test.ts index 41d0ed0c5..6aedde21f 100644 --- a/packages/integration-tests/src/tracker-linear.integration.test.ts +++ b/packages/integration-tests/src/tracker-linear.integration.test.ts @@ -220,7 +220,7 @@ describe.skipIf(!canRun)("tracker-linear (integration)", () => { expect(issue.branchName).toBeDefined(); expect(typeof issue.branchName).toBe("string"); - expect(issue.branchName).toContain(issueIdentifier); + expect(issue.branchName!.length).toBeGreaterThan(0); }); it("isCompleted returns false for an open issue", async () => { diff --git a/packages/plugins/tracker-linear/src/index.ts b/packages/plugins/tracker-linear/src/index.ts index 8e61d985e..bef407675 100644 --- a/packages/plugins/tracker-linear/src/index.ts +++ b/packages/plugins/tracker-linear/src/index.ts @@ -296,7 +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, - ...(node.branchName ? { branchName: node.branchName } : {}), + branchName: node.branchName ?? undefined, }; }, @@ -429,7 +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, - ...(node.branchName ? { branchName: node.branchName } : {}), + branchName: node.branchName ?? undefined, })); }, @@ -622,7 +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, - ...(node.branchName ? { branchName: node.branchName } : {}), + branchName: node.branchName ?? undefined, }; // Assign after creation (Linear's issueCreate uses assigneeId, not display name) diff --git a/scripts/claude-batch-spawn b/scripts/claude-batch-spawn index efd4308a0..e097081f1 100755 --- a/scripts/claude-batch-spawn +++ b/scripts/claude-batch-spawn @@ -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 implement the solution in the branch already prepared for this session." + 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, and implement the task in the branch already prepared for this session." + 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 From 034de8a3fa20b44d8c9eccc94f86c643fc78942c Mon Sep 17 00:00:00 2001 From: Denis Darii Date: Fri, 3 Apr 2026 15:28:59 +0200 Subject: [PATCH 13/16] Reject consecutive slashes and dot-prefixed components in branch validation --- packages/core/src/__tests__/utils.test.ts | 5 +++++ packages/core/src/utils.ts | 2 ++ 2 files changed, 7 insertions(+) diff --git a/packages/core/src/__tests__/utils.test.ts b/packages/core/src/__tests__/utils.test.ts index 85f12643f..6e12eeb6d 100644 --- a/packages/core/src/__tests__/utils.test.ts +++ b/packages/core/src/__tests__/utils.test.ts @@ -106,6 +106,11 @@ describe("isGitBranchNameSafe", () => { 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); diff --git a/packages/core/src/utils.ts b/packages/core/src/utils.ts index ee3bb7465..1f1f94ac7 100644 --- a/packages/core/src/utils.ts +++ b/packages/core/src/utils.ts @@ -44,6 +44,8 @@ export function isGitBranchNameSafe(name: string): boolean { 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++) { From 3fb2ddf06da8a54f3386b38827b7780415396e72 Mon Sep 17 00:00:00 2001 From: Harsh Batheja Date: Fri, 10 Apr 2026 13:00:57 +0530 Subject: [PATCH 14/16] refactor: remove --decompose feature entirely The --decompose flag and supporting decomposer module had two unfixable bugs (#1045): it crashed when ANTHROPIC_API_KEY was unset, and it created multiple branches/PRs per issue, fragmenting history and complicating review/merge. Removing the feature instead of patching either bug. - Delete packages/core/src/decomposer.ts and all re-exports - Drop @anthropic-ai/sdk dependency from @composio/ao-core - Remove --decompose / --max-depth options from ao spawn - Remove decomposer field from ProjectConfig (zod default-strip silently ignores existing decomposer: blocks in user yaml) - Remove lineage / siblings from SessionSpawnConfig and the prompt-builder Layer 4 block (only used by decomposer) - Gut the matching backlog reactor branch in web/services.ts so the polling path no longer hits the same bugs - Remove decompose parameter from openclaw-plugin ao_spawn tool - Drop decomposer mocks from web services.test.ts --- docs/design-cli-redesign-analysis.html | 2 +- openclaw-plugin/index.ts | 7 +- packages/cli/src/commands/spawn.ts | 64 +---- packages/cli/src/lib/config-instruction.ts | 7 - packages/core/package.json | 1 - packages/core/src/config.ts | 15 -- packages/core/src/decomposer.ts | 276 -------------------- packages/core/src/index.ts | 19 -- packages/core/src/prompt-builder.ts | 25 -- packages/core/src/session-manager.ts | 2 - packages/core/src/types.ts | 16 -- packages/web/src/__tests__/services.test.ts | 5 - packages/web/src/lib/services.ts | 67 +---- pnpm-lock.yaml | 9 - 14 files changed, 5 insertions(+), 510 deletions(-) delete mode 100644 packages/core/src/decomposer.ts diff --git a/docs/design-cli-redesign-analysis.html b/docs/design-cli-redesign-analysis.html index 84b1e5c33..745cfec89 100644 --- a/docs/design-cli-redesign-analysis.html +++ b/docs/design-cli-redesign-analysis.html @@ -431,7 +431,7 @@ ao start

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 ao config-help.

diff --git a/openclaw-plugin/index.ts b/openclaw-plugin/index.ts index 9bd0f14eb..80a7f8b23 100644 --- a/openclaw-plugin/index.ts +++ b/openclaw-plugin/index.ts @@ -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 { diff --git a/packages/cli/src/commands/spawn.ts b/packages/cli/src/commands/spawn.ts index 1e0b6fb24..81395de4c 100644 --- a/packages/cli/src/commands/spawn.ts +++ b/packages/cli/src/commands/spawn.ts @@ -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 ", "Override the agent plugin (e.g. codex, claude-code)") .option("--claim-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 ", "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 @@ -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); diff --git a/packages/cli/src/lib/config-instruction.ts b/packages/cli/src/lib/config-instruction.ts index 1ad8b3493..85063247a 100644 --- a/packages/cli/src/lib/config-instruction.ts +++ b/packages/cli/src/lib/config-instruction.ts @@ -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: diff --git a/packages/core/package.json b/packages/core/package.json index c32b6e0e8..9c1c99bcc 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -47,7 +47,6 @@ "clean": "rm -rf dist" }, "dependencies": { - "@anthropic-ai/sdk": "^0.52.0", "yaml": "^2.7.0", "zod": "^3.24.0" }, diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index d7c9f1db7..41bf358b6 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -163,20 +163,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(), @@ -204,7 +190,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({ diff --git a/packages/core/src/decomposer.ts b/packages/core/src/decomposer.ts deleted file mode 100644 index 4868238a8..000000000 --- a/packages/core/src/decomposer.ts +++ /dev/null @@ -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 { - 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 { - 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 { - 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 { - 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"; - } -} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 611e027f8..96720f477 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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"; diff --git a/packages/core/src/prompt-builder.ts b/packages/core/src/prompt-builder.ts index 2cbcdccbb..eb0a972fe 100644 --- a/packages/core/src/prompt-builder.ts +++ b/packages/core/src/prompt-builder.ts @@ -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}`); diff --git a/packages/core/src/session-manager.ts b/packages/core/src/session-manager.ts index 1d9765b63..c9c27eb8f 100644 --- a/packages/core/src/session-manager.ts +++ b/packages/core/src/session-manager.ts @@ -1061,8 +1061,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 diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 463dc02ea..df7338455 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -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 */ @@ -1185,18 +1181,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 { diff --git a/packages/web/src/__tests__/services.test.ts b/packages/web/src/__tests__/services.test.ts index c798f2fd8..db3fe8869 100644 --- a/packages/web/src/__tests__/services.test.ts +++ b/packages/web/src/__tests__/services.test.ts @@ -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, })); diff --git a/packages/web/src/lib/services.ts b/packages/web/src/lib/services.ts index 0e7fc5649..9c2874dd4 100644 --- a/packages/web/src/lib/services.ts +++ b/packages/web/src/lib/services.ts @@ -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"; @@ -272,65 +266,8 @@ export async function pollBacklog(): Promise { 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()); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6be3b461a..12045319e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -141,9 +141,6 @@ importers: packages/core: dependencies: - '@anthropic-ai/sdk': - specifier: ^0.52.0 - version: 0.52.0 yaml: specifier: ^2.7.0 version: 2.8.2 @@ -721,10 +718,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==} @@ -4247,8 +4240,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) From 1a476a415c45de43135ac3c5414574c7114ce00b Mon Sep 17 00:00:00 2001 From: Harsh Batheja Date: Fri, 10 Apr 2026 13:09:30 +0530 Subject: [PATCH 15/16] test(core): fix stale @composio scope refs in plugin-registry tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two notifier alias tests in plugin-registry.test.ts still referenced @composio/ao-plugin-notifier-slack after the @composio→@aoagents rename in #983. The mock fallback hit the throw branch, the slack plugin never got registered, and the assertions failed with "undefined" / "called 0 times". CI on main has been red on these two tests since #967 merged. Other tests in the same file (webhook line 231, openclaw line 333) already use @aoagents — these two were missed. --- packages/core/src/__tests__/plugin-registry.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/src/__tests__/plugin-registry.test.ts b/packages/core/src/__tests__/plugin-registry.test.ts index 511b33e3b..6cb9168dc 100644 --- a/packages/core/src/__tests__/plugin-registry.test.ts +++ b/packages/core/src/__tests__/plugin-registry.test.ts @@ -258,7 +258,7 @@ describe("loadBuiltins", () => { }); await registry.loadBuiltins(config, async (pkg: string) => { - if (pkg === "@composio/ao-plugin-notifier-slack") return fakeSlackNotifier; + if (pkg === "@aoagents/ao-plugin-notifier-slack") return fakeSlackNotifier; throw new Error(`Not found: ${pkg}`); }); @@ -300,7 +300,7 @@ describe("loadBuiltins", () => { }); await registry.loadBuiltins(config, async (pkg: string) => { - if (pkg === "@composio/ao-plugin-notifier-slack") return fakeSlackNotifier; + if (pkg === "@aoagents/ao-plugin-notifier-slack") return fakeSlackNotifier; throw new Error(`Not found: ${pkg}`); }); From 0f4e2bcc3c20fe94d86a5e155dd4f93b7ecae51d Mon Sep 17 00:00:00 2001 From: adil Date: Fri, 10 Apr 2026 00:53:10 +0530 Subject: [PATCH 16/16] feat(cli): hide orchestrator sessions from `ao session ls` by default (#1088) Orchestrator sessions are control-plane sessions that clutter the ls output alongside worker sessions. Filter them out using the existing `isOrchestratorSessionName()` helper. Add `--all` / `-a` flag to include them when needed. Closes #1088 Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/commands/session.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/commands/session.ts b/packages/cli/src/commands/session.ts index 98557b7e1..6669457c2 100644 --- a/packages/cli/src/commands/session.ts +++ b/packages/cli/src/commands/session.ts @@ -35,8 +35,9 @@ export function registerSession(program: Command): void { .command("ls") .description("List all sessions") .option("-p, --project ", "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}`)); @@ -44,7 +45,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();