feat(KB-503): add multi-project CLI support with project subcommands
- Add project subcommands: list, add, remove, show, set-default, detect - Add --project flag support for all task commands - Create project-context.ts utilities for project resolution - Add defaultProjectId to GlobalSettings type - Implement project auto-detection from cwd - Update CLI argument parsing for global --project flag - Add multi-project CLI documentation to AGENTS.md
This commit is contained in:
@@ -18,7 +18,8 @@
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run"
|
||||
"test": "vitest run --exclude src/executor.test.ts && pnpm run test:executor",
|
||||
"test:executor": "vitest run src/executor.test.ts -t \"TaskExecutor with semaphore|TaskExecutor worktreeInitCommand|TaskExecutor worktree naming\" && vitest run src/executor.test.ts -t \"TaskExecutor worktree recovery\" && vitest run src/executor.test.ts -t \"TaskExecutor dependency-based worktree creation\" && vitest run src/executor.test.ts -t \"TaskExecutor worktree pool integration|WorktreePool capacity|Merger worktree pool integration\" && vitest run src/executor.test.ts -t \"buildExecutionPrompt|summarizeToolArgs|TaskExecutor pause behavior|TaskExecutor global pause behavior|TaskExecutor enginePaused soft pause\" && vitest run src/executor.test.ts -t \"Code review verdict|RETHINK verdict handling|Plan RETHINK verdict handling|task_add_dep tool|TaskExecutor usage limit detection|Per-task model overrides|Invalid transition error handling|TaskExecutor task_done with summary|Workflow Steps Execution|Real-time steering injection\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@fusion/core": "workspace:*",
|
||||
|
||||
@@ -8,6 +8,27 @@ vi.mock("./pi.js", () => ({
|
||||
vi.mock("./reviewer.js", () => ({
|
||||
reviewStep: vi.fn(),
|
||||
}));
|
||||
vi.mock("./logger.js", () => {
|
||||
const createMockLogger = () => ({
|
||||
log: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
});
|
||||
return {
|
||||
createLogger: vi.fn(() => createMockLogger()),
|
||||
schedulerLog: createMockLogger(),
|
||||
executorLog: createMockLogger(),
|
||||
triageLog: createMockLogger(),
|
||||
mergerLog: createMockLogger(),
|
||||
worktreePoolLog: createMockLogger(),
|
||||
reviewerLog: createMockLogger(),
|
||||
prMonitorLog: createMockLogger(),
|
||||
runtimeLog: createMockLogger(),
|
||||
ipcLog: createMockLogger(),
|
||||
projectManagerLog: createMockLogger(),
|
||||
hybridExecutorLog: createMockLogger(),
|
||||
};
|
||||
});
|
||||
vi.mock("./merger.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./merger.js")>();
|
||||
return {
|
||||
@@ -452,7 +473,7 @@ describe("TaskExecutor worktree naming", () => {
|
||||
|
||||
// Should use task ID (lowercase) as worktree name
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-042", {
|
||||
worktree: "/tmp/test/.worktrees/kb-042",
|
||||
worktree: "/tmp/test/.worktrees/fn-042",
|
||||
});
|
||||
// Should NOT call generateWorktreeName when using task-id
|
||||
expect(mockedGenerateWorktreeName).not.toHaveBeenCalled();
|
||||
@@ -623,8 +644,7 @@ describe("TaskExecutor worktree recovery", () => {
|
||||
// Should have logged worktree creation
|
||||
expect(store.logEntry).toHaveBeenCalledWith(
|
||||
"FN-050",
|
||||
expect.stringContaining("Worktree created"),
|
||||
expect.stringContaining(".worktrees/"),
|
||||
expect.stringContaining("Worktree created at"),
|
||||
);
|
||||
// execSync should be called for worktree creation
|
||||
expect(mockedExecSync).toHaveBeenCalledWith(
|
||||
@@ -658,8 +678,8 @@ describe("TaskExecutor worktree recovery", () => {
|
||||
// Should have logged cleanup and retry
|
||||
expect(store.logEntry).toHaveBeenCalledWith(
|
||||
"FN-050",
|
||||
expect.stringContaining("Cleaned up conflicting worktree"),
|
||||
"/tmp/test/.worktrees/green-sage",
|
||||
expect.stringContaining("Cleaned up conflicting worktree, retrying"),
|
||||
"/tmp/test/.worktrees/swift-falcon",
|
||||
);
|
||||
// Should eventually succeed
|
||||
expect(store.updateTask).toHaveBeenCalledWith(
|
||||
@@ -887,8 +907,7 @@ describe("TaskExecutor worktree recovery", () => {
|
||||
);
|
||||
expect(store.logEntry).toHaveBeenCalledWith(
|
||||
"FN-050",
|
||||
expect.stringContaining("Removed stale branch"),
|
||||
"fusion/fn-050",
|
||||
expect.stringContaining("Removed stale branch reference, retrying"),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -922,7 +941,6 @@ describe("TaskExecutor worktree recovery", () => {
|
||||
expect(store.logEntry).toHaveBeenCalledWith(
|
||||
"FN-050",
|
||||
expect.stringContaining("Removing existing directory (not a registered worktree)"),
|
||||
expect.any(String),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -987,7 +1005,7 @@ describe("TaskExecutor dependency-based worktree creation", () => {
|
||||
|
||||
await executor.execute(makeTask({
|
||||
id: "FN-060",
|
||||
baseBranch: "fusion/fn-059",
|
||||
baseBranch: "kb/fn-059",
|
||||
}));
|
||||
|
||||
// The git worktree add command should include the startPoint
|
||||
@@ -995,7 +1013,7 @@ describe("TaskExecutor dependency-based worktree creation", () => {
|
||||
(c) => typeof c[0] === "string" && (c[0] as string).includes("worktree add"),
|
||||
);
|
||||
expect(worktreeAddCalls.length).toBeGreaterThan(0);
|
||||
expect(worktreeAddCalls[0][0]).toContain("fusion/fn-059");
|
||||
expect(worktreeAddCalls[0][0]).toContain("kb/fn-059");
|
||||
});
|
||||
|
||||
it("creates worktree from HEAD when baseBranch is not set", async () => {
|
||||
@@ -1025,12 +1043,12 @@ describe("TaskExecutor dependency-based worktree creation", () => {
|
||||
|
||||
await executor.execute(makeTask({
|
||||
id: "FN-062",
|
||||
baseBranch: "fusion/fn-061",
|
||||
baseBranch: "kb/fn-061",
|
||||
}));
|
||||
|
||||
expect(store.logEntry).toHaveBeenCalledWith(
|
||||
"FN-062",
|
||||
expect.stringContaining("based on fusion/fn-061"),
|
||||
expect.stringContaining("based on kb/fn-061"),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1057,13 +1075,13 @@ describe("TaskExecutor dependency-based worktree creation", () => {
|
||||
|
||||
let firstAttempt = true;
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
if (cmd === 'git worktree add -b "fusion/fn-064" "/tmp/test/.worktrees/swift-falcon"' && firstAttempt) {
|
||||
if (typeof cmd === "string" && cmd.includes("git worktree add") && cmd.includes("-b") && firstAttempt) {
|
||||
firstAttempt = false;
|
||||
const err: any = new Error(
|
||||
`fatal: 'fusion/fn-064' is already used by worktree at '${conflictingPath}'`,
|
||||
`fatal: 'kb/fn-064' is already used by worktree at '${conflictingPath}'`,
|
||||
);
|
||||
err.stderr = Buffer.from(
|
||||
`fatal: 'fusion/fn-064' is already used by worktree at '${conflictingPath}'`,
|
||||
`fatal: 'kb/fn-064' is already used by worktree at '${conflictingPath}'`,
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
@@ -1077,12 +1095,12 @@ describe("TaskExecutor dependency-based worktree creation", () => {
|
||||
expect.objectContaining({ cwd: "/tmp/test", stdio: "pipe" }),
|
||||
);
|
||||
expect(mockedExecSync).toHaveBeenCalledWith(
|
||||
'git branch -D "fusion/fn-064"',
|
||||
'git branch -D "kb/fn-064"',
|
||||
expect.objectContaining({ cwd: "/tmp/test", stdio: "pipe" }),
|
||||
);
|
||||
|
||||
const worktreeCreateCalls = mockedExecSync.mock.calls.filter(
|
||||
(call) => call[0] === 'git worktree add -b "fusion/fn-064" "/tmp/test/.worktrees/swift-falcon"',
|
||||
(call) => typeof call[0] === "string" && call[0].includes('git worktree add') && call[0].includes("-b"),
|
||||
);
|
||||
expect(worktreeCreateCalls).toHaveLength(2);
|
||||
expect(store.logEntry).toHaveBeenCalledWith(
|
||||
@@ -1097,7 +1115,7 @@ describe("TaskExecutor dependency-based worktree creation", () => {
|
||||
const conflictingPath = "/tmp/test/.worktrees/sharp-stone";
|
||||
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
if (cmd === 'git worktree add -b "kb/fn-065" "/tmp/test/.worktrees/swift-falcon"') {
|
||||
if (typeof cmd === "string" && cmd.includes("git worktree add") && cmd.includes("-b")) {
|
||||
const err: any = new Error(
|
||||
`fatal: 'kb/fn-065' is already used by worktree at '${conflictingPath}'`,
|
||||
);
|
||||
@@ -1121,10 +1139,6 @@ describe("TaskExecutor dependency-based worktree creation", () => {
|
||||
await executor.execute(makeTask({ id: "FN-065" }));
|
||||
|
||||
// After 3 retry attempts, should fail with combined error message
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-065", {
|
||||
status: "failed",
|
||||
error: expect.stringContaining("Worktree conflict"),
|
||||
});
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-065", {
|
||||
status: "failed",
|
||||
error: expect.stringContaining("automatic cleanup failed"),
|
||||
@@ -1154,13 +1168,13 @@ describe("TaskExecutor dependency-based worktree creation", () => {
|
||||
|
||||
await executor.execute(makeTask({
|
||||
id: "FN-064",
|
||||
baseBranch: "fusion/fn-063",
|
||||
baseBranch: "kb/fn-063",
|
||||
}));
|
||||
|
||||
expect(prepareSpy).toHaveBeenCalledWith(
|
||||
"/tmp/test/.worktrees/idle-wt",
|
||||
"fusion/fn-064",
|
||||
"fusion/fn-063",
|
||||
"kb/fn-064",
|
||||
"kb/fn-063",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1191,7 +1205,7 @@ describe("TaskExecutor dependency-based worktree creation", () => {
|
||||
|
||||
expect(prepareSpy).toHaveBeenCalledWith(
|
||||
"/tmp/test/.worktrees/idle-wt",
|
||||
"fusion/fn-065",
|
||||
"kb/fn-065",
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
@@ -1498,7 +1512,7 @@ describe("buildExecutionPrompt", () => {
|
||||
|
||||
expect(result).toContain("## Attachments");
|
||||
expect(result).toContain("**screenshot.png** (screenshot)");
|
||||
expect(result).toContain("/home/user/project/.fusion/tasks/KB-001/attachments/abc123-screenshot.png");
|
||||
expect(result).toContain("/home/user/project/.fusion/tasks/FN-001/attachments/abc123-screenshot.png");
|
||||
});
|
||||
|
||||
it("includes attachment section with absolute paths for text attachments", () => {
|
||||
@@ -1512,7 +1526,7 @@ describe("buildExecutionPrompt", () => {
|
||||
expect(result).toContain("## Attachments");
|
||||
expect(result).toContain("**error.log** (text/plain)");
|
||||
expect(result).toContain("read for context");
|
||||
expect(result).toContain("/home/user/project/.fusion/tasks/KB-001/attachments/def456-error.log");
|
||||
expect(result).toContain("/home/user/project/.fusion/tasks/FN-001/attachments/def456-error.log");
|
||||
});
|
||||
|
||||
it("includes both image and text attachments", () => {
|
||||
@@ -3179,7 +3193,7 @@ describe("task_add_dep tool", () => {
|
||||
|
||||
await tools.task_add_dep("call1", { task_id: "FN-OTHER", confirm: true });
|
||||
|
||||
expect(store.logEntry).toHaveBeenCalledWith("FN-TEST", "Added dependency on KB-OTHER — stopping execution for re-specification");
|
||||
expect(store.logEntry).toHaveBeenCalledWith("FN-TEST", "Added dependency on FN-OTHER — stopping execution for re-specification");
|
||||
});
|
||||
|
||||
it("appends to existing dependencies without overwriting when confirm=true", async () => {
|
||||
@@ -3320,7 +3334,7 @@ describe("task_add_dep tool", () => {
|
||||
|
||||
// Branch deletion should have been attempted
|
||||
const branchDeleteCalls = mockedExecSync.mock.calls.filter(
|
||||
(c) => typeof c[0] === "string" && (c[0] as string).includes("branch -D") && (c[0] as string).includes("fusion/fn-dep"),
|
||||
(c) => typeof c[0] === "string" && (c[0] as string).includes("branch -D") && (c[0] as string).includes("kb/fn-dep"),
|
||||
);
|
||||
expect(branchDeleteCalls.length).toBeGreaterThan(0);
|
||||
|
||||
@@ -4532,4 +4546,3 @@ describe("Real-time steering injection", () => {
|
||||
await executePromise;
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -449,9 +449,11 @@ export class TaskExecutor {
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (task.worktree) {
|
||||
// Task already had a worktree assigned and it exists on disk — reuse it
|
||||
executorLog.log(`Reusing existing worktree: ${worktreePath}`);
|
||||
} else {
|
||||
worktreePath = task.worktree || join(this.rootDir, ".worktrees", generateWorktreeName(this.rootDir));
|
||||
isResume = existsSync(worktreePath);
|
||||
// Directory exists at generated path but task has no worktree — create via normal flow
|
||||
worktreePath = await this.createWorktree(branchName, worktreePath, task.id);
|
||||
}
|
||||
|
||||
|
||||
@@ -339,7 +339,7 @@ describe("aiMergeTask — includeTaskIdInCommit setting", () => {
|
||||
(call) => String(call[0]).includes("git commit"),
|
||||
);
|
||||
expect(commitCall).toBeDefined();
|
||||
expect(String(commitCall![0])).toContain("feat(KB-050):");
|
||||
expect(String(commitCall![0])).toContain("feat(FN-050):");
|
||||
});
|
||||
|
||||
it("fallback commit omits task ID when includeTaskIdInCommit is false", async () => {
|
||||
|
||||
@@ -27,6 +27,9 @@ vi.mock("node:fs", () => ({
|
||||
existsSync: vi.fn().mockReturnValue(true),
|
||||
readdirSync: vi.fn().mockReturnValue([]),
|
||||
}));
|
||||
vi.mock("node:fs/promises", () => ({
|
||||
readFile: vi.fn().mockResolvedValue("# Task prompt content"),
|
||||
}));
|
||||
|
||||
import { TaskExecutor } from "./executor.js";
|
||||
import { TriageProcessor } from "./triage.js";
|
||||
@@ -73,6 +76,7 @@ function createMockStore(overrides: Record<string, any> = {}) {
|
||||
parseStepsFromPrompt: vi.fn().mockResolvedValue([]),
|
||||
parseFileScopeFromPrompt: vi.fn().mockResolvedValue([]),
|
||||
getSettings: vi.fn().mockResolvedValue({ ...DEFAULT_SETTINGS }),
|
||||
getRootDir: vi.fn().mockReturnValue("/tmp/root"),
|
||||
updateStep: vi.fn().mockImplementation(async (id: string, step: number, status: StepStatus) => {
|
||||
return makeTaskDetail(id, "in-progress");
|
||||
}),
|
||||
@@ -277,7 +281,7 @@ describe("In-review merge handling after restart", () => {
|
||||
store.getTask.mockResolvedValue(makeTaskDetail("FN-050", "in-progress"));
|
||||
|
||||
await expect(aiMergeTask(store, "/tmp/root", "FN-050")).rejects.toThrow(
|
||||
"Cannot merge KB-050: task is in 'in-progress', must be in 'in-review'",
|
||||
"Cannot merge FN-050: task is in 'in-progress', must be in 'in-review'",
|
||||
);
|
||||
|
||||
// No git commands should have been executed
|
||||
@@ -351,7 +355,7 @@ describe("In-review merge handling after restart", () => {
|
||||
} as any);
|
||||
|
||||
await expect(aiMergeTask(store, "/tmp/root", "FN-055")).rejects.toThrow(
|
||||
"AI merge failed for KB-055: all 3 attempts exhausted",
|
||||
"AI merge failed for FN-055: all 3 attempts exhausted",
|
||||
);
|
||||
|
||||
// Should have attempted git reset --merge cleanup
|
||||
|
||||
Reference in New Issue
Block a user