@ronkovic/aad 0.3.4 → 0.3.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -3
- package/src/main.ts +10 -2
- package/src/modules/cli/__tests__/interactive/auth-step.test.ts +24 -0
- package/src/modules/cli/__tests__/interactive/interactive.test.ts +14 -0
- package/src/modules/cli/__tests__/interactive/process-supervisor.test.ts +45 -0
- package/src/modules/cli/__tests__/interactive/prompts.test.ts +75 -0
- package/src/modules/cli/__tests__/update.test.ts +20 -0
- package/src/modules/cli/commands/cleanup.ts +3 -3
- package/src/modules/cli/commands/interactive/auth-step.ts +61 -0
- package/src/modules/cli/commands/interactive/launch-step.ts +76 -0
- package/src/modules/cli/commands/interactive/process-supervisor.ts +105 -0
- package/src/modules/cli/commands/interactive/project-step.ts +157 -0
- package/src/modules/cli/commands/interactive/prompts.ts +75 -0
- package/src/modules/cli/commands/interactive.ts +38 -0
- package/src/modules/cli/commands/resume.ts +25 -1
- package/src/modules/cli/commands/run.ts +35 -5
- package/src/modules/cli/commands/task-dispatch-handler.ts +16 -4
- package/src/modules/cli/commands/update.ts +119 -0
- package/src/modules/cli/index.ts +1 -0
- package/src/modules/git-workspace/__tests__/worktree-manager.test.ts +19 -4
- package/src/modules/git-workspace/worktree-manager.ts +17 -9
- package/src/modules/planning/__tests__/feature-name-generator.test.ts +76 -0
- package/src/modules/planning/feature-name-generator.ts +71 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ronkovic/aad",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.6",
|
|
4
4
|
"description": "Autonomous Agent Development Orchestrator - Multi-agent TDD pipeline powered by Claude",
|
|
5
5
|
"module": "src/main.ts",
|
|
6
6
|
"type": "module",
|
|
@@ -38,11 +38,12 @@
|
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
40
|
"@anthropic-ai/claude-agent-sdk": "^0.2.34",
|
|
41
|
+
"@inquirer/prompts": "^8.2.0",
|
|
42
|
+
"chokidar": "^4.0",
|
|
43
|
+
"commander": "^13.0",
|
|
41
44
|
"hono": "^4.6",
|
|
42
45
|
"pino": "^9.6",
|
|
43
46
|
"pino-pretty": "^13.0",
|
|
44
|
-
"chokidar": "^4.0",
|
|
45
|
-
"commander": "^13.0",
|
|
46
47
|
"zod": "^3.24"
|
|
47
48
|
},
|
|
48
49
|
"devDependencies": {
|
package/src/main.ts
CHANGED
|
@@ -11,9 +11,11 @@ import {
|
|
|
11
11
|
createResumeCommand,
|
|
12
12
|
createStatusCommand,
|
|
13
13
|
createCleanupCommand,
|
|
14
|
+
createUpdateCommand,
|
|
14
15
|
type App,
|
|
15
16
|
type AppOptions,
|
|
16
17
|
} from "./modules/cli";
|
|
18
|
+
import { registerInteractiveCommand } from "./modules/cli/commands/interactive.js";
|
|
17
19
|
import packageJson from "../package.json" with { type: "json" };
|
|
18
20
|
|
|
19
21
|
const program = new Command()
|
|
@@ -47,6 +49,12 @@ program.addCommand(createRunCommand(getApp));
|
|
|
47
49
|
program.addCommand(createResumeCommand(getApp));
|
|
48
50
|
program.addCommand(createStatusCommand(getApp));
|
|
49
51
|
program.addCommand(createCleanupCommand(getApp));
|
|
52
|
+
program.addCommand(createUpdateCommand());
|
|
53
|
+
registerInteractiveCommand(program);
|
|
50
54
|
|
|
51
|
-
//
|
|
52
|
-
|
|
55
|
+
// Default to interactive mode when no command is given
|
|
56
|
+
if (process.argv.length <= 2) {
|
|
57
|
+
program.parse(["node", "aad", "interactive"]);
|
|
58
|
+
} else {
|
|
59
|
+
program.parse();
|
|
60
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
|
2
|
+
import { runAuthStep } from "../../commands/interactive/auth-step";
|
|
3
|
+
|
|
4
|
+
// Mock @inquirer/prompts by testing the env detection logic directly
|
|
5
|
+
describe("Auth Step", () => {
|
|
6
|
+
let origApiKey: string | undefined;
|
|
7
|
+
let origOauth: string | undefined;
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
origApiKey = process.env.ANTHROPIC_API_KEY;
|
|
11
|
+
origOauth = process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
if (origApiKey !== undefined) process.env.ANTHROPIC_API_KEY = origApiKey;
|
|
16
|
+
else delete process.env.ANTHROPIC_API_KEY;
|
|
17
|
+
if (origOauth !== undefined) process.env.CLAUDE_CODE_OAUTH_TOKEN = origOauth;
|
|
18
|
+
else delete process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("module exports runAuthStep function", () => {
|
|
22
|
+
expect(typeof runAuthStep).toBe("function");
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { registerInteractiveCommand } from "../../commands/interactive";
|
|
3
|
+
import { Command } from "commander";
|
|
4
|
+
|
|
5
|
+
describe("Interactive Command Registration", () => {
|
|
6
|
+
it("registers interactive command on program", () => {
|
|
7
|
+
const program = new Command();
|
|
8
|
+
registerInteractiveCommand(program);
|
|
9
|
+
|
|
10
|
+
const cmd = program.commands.find((c) => c.name() === "interactive");
|
|
11
|
+
expect(cmd).toBeDefined();
|
|
12
|
+
expect(cmd?.description()).toContain("Interactive");
|
|
13
|
+
});
|
|
14
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { spawnProject, killAll } from "../../commands/interactive/process-supervisor";
|
|
3
|
+
import type { ProjectConfig } from "../../commands/interactive/project-step";
|
|
4
|
+
import { mkdtempSync } from "node:fs";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { resolve } from "node:path";
|
|
7
|
+
|
|
8
|
+
describe("Process Supervisor", () => {
|
|
9
|
+
it("exports spawnProject and killAll functions", () => {
|
|
10
|
+
expect(typeof spawnProject).toBe("function");
|
|
11
|
+
expect(typeof killAll).toBe("function");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("spawns a simple process and returns ManagedProcess", async () => {
|
|
15
|
+
const tmpDir = mkdtempSync(resolve(tmpdir(), "aad-test-"));
|
|
16
|
+
const project: ProjectConfig = {
|
|
17
|
+
directory: tmpDir,
|
|
18
|
+
branch: "main",
|
|
19
|
+
requirementsPath: "/dev/null",
|
|
20
|
+
workers: 1,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const mp = spawnProject(project, 0);
|
|
24
|
+
expect(mp.dashboardPort).toBe(7333);
|
|
25
|
+
expect(mp.project).toBe(project);
|
|
26
|
+
expect(mp.proc).toBeDefined();
|
|
27
|
+
|
|
28
|
+
// Kill immediately
|
|
29
|
+
await killAll([mp]);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("increments dashboard port by index", async () => {
|
|
33
|
+
const tmpDir = mkdtempSync(resolve(tmpdir(), "aad-test-"));
|
|
34
|
+
const project: ProjectConfig = {
|
|
35
|
+
directory: tmpDir,
|
|
36
|
+
branch: "main",
|
|
37
|
+
requirementsPath: "/dev/null",
|
|
38
|
+
workers: 1,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const mp = spawnProject(project, 3);
|
|
42
|
+
expect(mp.dashboardPort).toBe(7336);
|
|
43
|
+
await killAll([mp]);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { printBox, success, warn, error } from "../../commands/interactive/prompts";
|
|
3
|
+
|
|
4
|
+
describe("Interactive Prompts Utilities", () => {
|
|
5
|
+
describe("printBox", () => {
|
|
6
|
+
it("prints a box with lines", () => {
|
|
7
|
+
const logs: string[] = [];
|
|
8
|
+
const origLog = console.log;
|
|
9
|
+
console.log = (...args: unknown[]) => logs.push(args.join(" "));
|
|
10
|
+
try {
|
|
11
|
+
printBox(["line 1", "line 2"]);
|
|
12
|
+
expect(logs.length).toBe(4); // top + 2 lines + bottom
|
|
13
|
+
expect(logs[0]).toContain("┌");
|
|
14
|
+
expect(logs[3]).toContain("└");
|
|
15
|
+
} finally {
|
|
16
|
+
console.log = origLog;
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("prints a box with title", () => {
|
|
21
|
+
const logs: string[] = [];
|
|
22
|
+
const origLog = console.log;
|
|
23
|
+
console.log = (...args: unknown[]) => logs.push(args.join(" "));
|
|
24
|
+
try {
|
|
25
|
+
printBox(["content"], "Title");
|
|
26
|
+
expect(logs.length).toBe(5); // top + title + separator + content + bottom
|
|
27
|
+
expect(logs[1]).toContain("Title");
|
|
28
|
+
expect(logs[2]).toContain("├");
|
|
29
|
+
} finally {
|
|
30
|
+
console.log = origLog;
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("status helpers", () => {
|
|
36
|
+
it("success prints green check", () => {
|
|
37
|
+
const logs: string[] = [];
|
|
38
|
+
const origLog = console.log;
|
|
39
|
+
console.log = (...args: unknown[]) => logs.push(args.join(" "));
|
|
40
|
+
try {
|
|
41
|
+
success("done");
|
|
42
|
+
expect(logs[0]).toContain("✓");
|
|
43
|
+
expect(logs[0]).toContain("done");
|
|
44
|
+
} finally {
|
|
45
|
+
console.log = origLog;
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("warn prints yellow warning", () => {
|
|
50
|
+
const logs: string[] = [];
|
|
51
|
+
const origLog = console.log;
|
|
52
|
+
console.log = (...args: unknown[]) => logs.push(args.join(" "));
|
|
53
|
+
try {
|
|
54
|
+
warn("caution");
|
|
55
|
+
expect(logs[0]).toContain("⚠");
|
|
56
|
+
expect(logs[0]).toContain("caution");
|
|
57
|
+
} finally {
|
|
58
|
+
console.log = origLog;
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("error prints red cross", () => {
|
|
63
|
+
const logs: string[] = [];
|
|
64
|
+
const origLog = console.log;
|
|
65
|
+
console.log = (...args: unknown[]) => logs.push(args.join(" "));
|
|
66
|
+
try {
|
|
67
|
+
error("fail");
|
|
68
|
+
expect(logs[0]).toContain("✗");
|
|
69
|
+
expect(logs[0]).toContain("fail");
|
|
70
|
+
} finally {
|
|
71
|
+
console.log = origLog;
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { createUpdateCommand } from "../commands/update";
|
|
3
|
+
|
|
4
|
+
describe("Update Command", () => {
|
|
5
|
+
it("creates a command named 'update'", () => {
|
|
6
|
+
const cmd = createUpdateCommand();
|
|
7
|
+
expect(cmd.name()).toBe("update");
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("has --check option", () => {
|
|
11
|
+
const cmd = createUpdateCommand();
|
|
12
|
+
const checkOpt = cmd.options.find((o) => o.long === "--check");
|
|
13
|
+
expect(checkOpt).toBeDefined();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("has description", () => {
|
|
17
|
+
const cmd = createUpdateCommand();
|
|
18
|
+
expect(cmd.description()).toContain("Update");
|
|
19
|
+
});
|
|
20
|
+
});
|
|
@@ -102,10 +102,10 @@ export async function cleanupWorktrees(
|
|
|
102
102
|
await worktreeManager.pruneWorktrees();
|
|
103
103
|
logger.info("Pruned orphaned worktrees");
|
|
104
104
|
|
|
105
|
-
// 4. Cleanup orphaned branches
|
|
105
|
+
// 4. Cleanup orphaned branches (always force-delete — AAD branches are safe to remove)
|
|
106
106
|
const deletedBranches = runId
|
|
107
|
-
? await branchManager.cleanupOrphanBranches(runId)
|
|
108
|
-
: await branchManager.cleanupOrphanBranches();
|
|
107
|
+
? await branchManager.cleanupOrphanBranches(runId, true)
|
|
108
|
+
: await branchManager.cleanupOrphanBranches(undefined, true);
|
|
109
109
|
|
|
110
110
|
if (deletedBranches.length > 0) {
|
|
111
111
|
console.log(`\nDeleted ${deletedBranches.length} orphan branch(es):`);
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth check / input step for interactive mode
|
|
3
|
+
*/
|
|
4
|
+
import { askConfirm, askSelect, askPassword, success, warn } from "./prompts.js";
|
|
5
|
+
|
|
6
|
+
export interface AuthResult {
|
|
7
|
+
method: "api-key" | "oauth";
|
|
8
|
+
token: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function maskToken(token: string): string {
|
|
12
|
+
if (token.length <= 8) return "••••••••";
|
|
13
|
+
return `${token.slice(0, 6)}...${token.slice(-4)}`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function runAuthStep(): Promise<AuthResult> {
|
|
17
|
+
const apiKey = process.env.ANTHROPIC_API_KEY;
|
|
18
|
+
const oauthToken = process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
|
19
|
+
|
|
20
|
+
// Existing API key detected
|
|
21
|
+
if (apiKey) {
|
|
22
|
+
console.log(`\n API Key検出: ${maskToken(apiKey)}`);
|
|
23
|
+
const useExisting = await askConfirm("この設定を使用しますか?", true);
|
|
24
|
+
if (useExisting) {
|
|
25
|
+
success("API Key認証を使用");
|
|
26
|
+
return { method: "api-key", token: apiKey };
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Existing OAuth token detected
|
|
31
|
+
if (oauthToken) {
|
|
32
|
+
console.log(`\n OAuth Token検出: ${maskToken(oauthToken)}`);
|
|
33
|
+
const useExisting = await askConfirm("この設定を使用しますか?", true);
|
|
34
|
+
if (useExisting) {
|
|
35
|
+
success("OAuth認証を使用");
|
|
36
|
+
return { method: "oauth", token: oauthToken };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Manual input
|
|
41
|
+
if (!apiKey && !oauthToken) {
|
|
42
|
+
warn("認証トークンが未設定です");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const method = await askSelect<"api-key" | "oauth">("認証方式を選択:", [
|
|
46
|
+
{ name: "API Key (ANTHROPIC_API_KEY)", value: "api-key" },
|
|
47
|
+
{ name: "OAuth Token (CLAUDE_CODE_OAUTH_TOKEN)", value: "oauth" },
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
const token = await askPassword("トークンを入力:");
|
|
51
|
+
|
|
52
|
+
// Set in process env for this session
|
|
53
|
+
if (method === "api-key") {
|
|
54
|
+
process.env.ANTHROPIC_API_KEY = token;
|
|
55
|
+
} else {
|
|
56
|
+
process.env.CLAUDE_CODE_OAUTH_TOKEN = token;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
success(`${method === "api-key" ? "API Key" : "OAuth"}認証を設定(セッション限定)`);
|
|
60
|
+
return { method, token };
|
|
61
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Launch confirmation + execution step
|
|
3
|
+
*/
|
|
4
|
+
import { basename } from "node:path";
|
|
5
|
+
import { getMemoryStatus } from "@aad/shared/memory-check";
|
|
6
|
+
import type { ProjectConfig } from "./project-step.js";
|
|
7
|
+
import {
|
|
8
|
+
type ManagedProcess,
|
|
9
|
+
spawnAll,
|
|
10
|
+
setupSignalHandler,
|
|
11
|
+
} from "./process-supervisor.js";
|
|
12
|
+
import { askConfirm, printBox, success } from "./prompts.js";
|
|
13
|
+
|
|
14
|
+
function formatPlanLines(projects: ProjectConfig[]): string[] {
|
|
15
|
+
const lines: string[] = [];
|
|
16
|
+
for (let i = 0; i < projects.length; i++) {
|
|
17
|
+
const p = projects[i]!;
|
|
18
|
+
lines.push(`#${i + 1} ${p.directory}`);
|
|
19
|
+
lines.push(` ブランチ: ${p.branch}`);
|
|
20
|
+
lines.push(` 要件: ${p.requirementsPath}`);
|
|
21
|
+
lines.push(` ワーカー: ${p.workers}`);
|
|
22
|
+
if (i < projects.length - 1) lines.push("");
|
|
23
|
+
}
|
|
24
|
+
return lines;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function runLaunchStep(
|
|
28
|
+
projects: ProjectConfig[],
|
|
29
|
+
): Promise<ManagedProcess[] | null> {
|
|
30
|
+
const memStatus = await getMemoryStatus();
|
|
31
|
+
const availableGB = memStatus.freeGB;
|
|
32
|
+
const totalWorkers = projects.reduce((sum, p) => sum + p.workers, 0);
|
|
33
|
+
|
|
34
|
+
const planLines = formatPlanLines(projects);
|
|
35
|
+
planLines.push("");
|
|
36
|
+
planLines.push(
|
|
37
|
+
`合計ワーカー: ${totalWorkers} メモリ: ${availableGB.toFixed(1)}GB free`,
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
printBox(planLines, "実行プラン");
|
|
41
|
+
console.log();
|
|
42
|
+
|
|
43
|
+
const confirmed = await askConfirm("実行しますか?", true);
|
|
44
|
+
if (!confirmed) {
|
|
45
|
+
console.log("\n 実行をキャンセルしました\n");
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
console.log();
|
|
50
|
+
const managed = spawnAll(projects);
|
|
51
|
+
|
|
52
|
+
for (const mp of managed) {
|
|
53
|
+
const name = basename(mp.project.directory);
|
|
54
|
+
success(`${name} 起動 — Dashboard: http://localhost:${mp.dashboardPort}`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
setupSignalHandler(managed);
|
|
58
|
+
|
|
59
|
+
console.log("\n 全プロジェクト起動完了。ダッシュボードで進捗を確認できます。");
|
|
60
|
+
console.log(" 終了: Ctrl+C (全プロジェクト停止)\n");
|
|
61
|
+
|
|
62
|
+
// Wait for all processes to complete
|
|
63
|
+
const exitCodes = await Promise.all(managed.map((mp) => mp.proc.exited));
|
|
64
|
+
for (let i = 0; i < exitCodes.length; i++) {
|
|
65
|
+
const mp = managed[i]!;
|
|
66
|
+
const code = exitCodes[i]!;
|
|
67
|
+
const name = basename(mp.project.directory);
|
|
68
|
+
if (code === 0) {
|
|
69
|
+
success(`${name} 完了 (exit: 0)`);
|
|
70
|
+
} else {
|
|
71
|
+
console.log(` \x1b[31m✗\x1b[0m ${name} 終了 (exit: ${code})`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return managed;
|
|
76
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Child process management — spawn, monitor, and terminate AAD runs
|
|
3
|
+
*/
|
|
4
|
+
import { resolve } from "node:path";
|
|
5
|
+
import { mkdirSync, existsSync, openSync } from "node:fs";
|
|
6
|
+
import type { ProjectConfig } from "./project-step.js";
|
|
7
|
+
|
|
8
|
+
const BASE_DASHBOARD_PORT = 7333;
|
|
9
|
+
|
|
10
|
+
export interface ManagedProcess {
|
|
11
|
+
project: ProjectConfig;
|
|
12
|
+
proc: ReturnType<typeof Bun.spawn>;
|
|
13
|
+
dashboardPort: number;
|
|
14
|
+
logDir: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function ensureLogDir(directory: string, runId: string): string {
|
|
18
|
+
const logDir = resolve(directory, ".aad", "logs", runId);
|
|
19
|
+
if (!existsSync(logDir)) {
|
|
20
|
+
mkdirSync(logDir, { recursive: true });
|
|
21
|
+
}
|
|
22
|
+
return logDir;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function generateRunId(): string {
|
|
26
|
+
return Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function spawnProject(
|
|
30
|
+
project: ProjectConfig,
|
|
31
|
+
index: number,
|
|
32
|
+
): ManagedProcess {
|
|
33
|
+
const runId = generateRunId();
|
|
34
|
+
const logDir = ensureLogDir(project.directory, runId);
|
|
35
|
+
const dashboardPort = BASE_DASHBOARD_PORT + index;
|
|
36
|
+
|
|
37
|
+
const stdoutFd = openSync(resolve(logDir, "stdout.log"), "w");
|
|
38
|
+
const stderrFd = openSync(resolve(logDir, "stderr.log"), "w");
|
|
39
|
+
|
|
40
|
+
const proc = Bun.spawn(
|
|
41
|
+
[
|
|
42
|
+
"bun",
|
|
43
|
+
"run",
|
|
44
|
+
resolve(import.meta.dir, "../../../../main.ts"),
|
|
45
|
+
"run",
|
|
46
|
+
project.requirementsPath,
|
|
47
|
+
"--branch",
|
|
48
|
+
project.branch,
|
|
49
|
+
"--workers",
|
|
50
|
+
String(project.workers),
|
|
51
|
+
],
|
|
52
|
+
{
|
|
53
|
+
cwd: project.directory,
|
|
54
|
+
stdout: stdoutFd,
|
|
55
|
+
stderr: stderrFd,
|
|
56
|
+
env: {
|
|
57
|
+
...process.env,
|
|
58
|
+
AAD_DASHBOARD_PORT: String(dashboardPort),
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
return { project, proc, dashboardPort, logDir };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function spawnAll(projects: ProjectConfig[]): ManagedProcess[] {
|
|
67
|
+
return projects.map((project, i) => spawnProject(project, i));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function killAll(processes: ManagedProcess[]): Promise<void> {
|
|
71
|
+
for (const mp of processes) {
|
|
72
|
+
try {
|
|
73
|
+
mp.proc.kill("SIGTERM");
|
|
74
|
+
} catch {
|
|
75
|
+
// Process may have already exited
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Wait for all to exit (with timeout)
|
|
80
|
+
const timeout = setTimeout(() => {
|
|
81
|
+
for (const mp of processes) {
|
|
82
|
+
try {
|
|
83
|
+
mp.proc.kill("SIGKILL");
|
|
84
|
+
} catch {
|
|
85
|
+
// ignore
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}, 5000);
|
|
89
|
+
|
|
90
|
+
await Promise.allSettled(processes.map((mp) => mp.proc.exited));
|
|
91
|
+
clearTimeout(timeout);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function setupSignalHandler(processes: ManagedProcess[]): void {
|
|
95
|
+
const handler = (): void => {
|
|
96
|
+
console.log("\n\n 全プロセスを停止中...");
|
|
97
|
+
void killAll(processes).then(() => {
|
|
98
|
+
console.log(" 停止完了\n");
|
|
99
|
+
process.exit(0);
|
|
100
|
+
});
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
process.on("SIGINT", handler);
|
|
104
|
+
process.on("SIGTERM", handler);
|
|
105
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project configuration step — repeatable loop
|
|
3
|
+
*/
|
|
4
|
+
import { existsSync, statSync } from "node:fs";
|
|
5
|
+
import { resolve } from "node:path";
|
|
6
|
+
import { gitExec } from "@aad/git-workspace/git-exec";
|
|
7
|
+
import { getMemoryStatus } from "@aad/shared/memory-check";
|
|
8
|
+
import {
|
|
9
|
+
askConfirm,
|
|
10
|
+
askInput,
|
|
11
|
+
askSelect,
|
|
12
|
+
success,
|
|
13
|
+
warn,
|
|
14
|
+
error,
|
|
15
|
+
} from "./prompts.js";
|
|
16
|
+
|
|
17
|
+
export interface ProjectConfig {
|
|
18
|
+
directory: string;
|
|
19
|
+
branch: string;
|
|
20
|
+
requirementsPath: string;
|
|
21
|
+
workers: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function isGitRepo(dir: string): Promise<boolean> {
|
|
25
|
+
try {
|
|
26
|
+
await gitExec(["rev-parse", "--is-inside-work-tree"], { cwd: dir });
|
|
27
|
+
return true;
|
|
28
|
+
} catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function listBranches(dir: string): Promise<string[]> {
|
|
34
|
+
const result = await gitExec(["branch", "--format=%(refname:short)"], {
|
|
35
|
+
cwd: dir,
|
|
36
|
+
});
|
|
37
|
+
return result.stdout
|
|
38
|
+
.split("\n")
|
|
39
|
+
.map((b: string) => b.trim())
|
|
40
|
+
.filter(Boolean);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function getCurrentBranch(dir: string): Promise<string> {
|
|
44
|
+
const result = await gitExec(["branch", "--show-current"], { cwd: dir });
|
|
45
|
+
return result.stdout.trim();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function pullBranch(dir: string, branch: string): Promise<boolean> {
|
|
49
|
+
try {
|
|
50
|
+
await gitExec(["checkout", branch], { cwd: dir });
|
|
51
|
+
await gitExec(["pull", "--ff-only"], { cwd: dir });
|
|
52
|
+
success(`${branch} を最新に更新`);
|
|
53
|
+
return true;
|
|
54
|
+
} catch (e) {
|
|
55
|
+
warn(`git pull失敗: ${e instanceof Error ? e.message : String(e)}`);
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function suggestWorkers(availableGB: number): number {
|
|
61
|
+
if (availableGB >= 12) return 3;
|
|
62
|
+
if (availableGB >= 8) return 2;
|
|
63
|
+
return 1;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function configureProject(index: number): Promise<ProjectConfig> {
|
|
67
|
+
console.log(`\n── プロジェクト #${index + 1} ──\n`);
|
|
68
|
+
|
|
69
|
+
// Directory
|
|
70
|
+
const directory = await askInput("プロジェクトディレクトリパス:", {
|
|
71
|
+
validate: (v) => {
|
|
72
|
+
const resolved = resolve(v);
|
|
73
|
+
if (!existsSync(resolved)) return "ディレクトリが存在しません";
|
|
74
|
+
if (!statSync(resolved).isDirectory()) return "ディレクトリではありません";
|
|
75
|
+
return true;
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const resolvedDir = resolve(directory);
|
|
80
|
+
|
|
81
|
+
if (!(await isGitRepo(resolvedDir))) {
|
|
82
|
+
error("gitリポジトリが検出されませんでした");
|
|
83
|
+
throw new Error(`Not a git repository: ${resolvedDir}`);
|
|
84
|
+
}
|
|
85
|
+
success("gitリポジトリ検出");
|
|
86
|
+
|
|
87
|
+
// Branch selection
|
|
88
|
+
const branches = await listBranches(resolvedDir);
|
|
89
|
+
const currentBranch = await getCurrentBranch(resolvedDir);
|
|
90
|
+
const branchChoices = branches.map((b) => ({
|
|
91
|
+
name: b === currentBranch ? `${b} (現在)` : b,
|
|
92
|
+
value: b,
|
|
93
|
+
}));
|
|
94
|
+
|
|
95
|
+
const branch = await askSelect("worktree元ブランチ:", branchChoices);
|
|
96
|
+
|
|
97
|
+
// Pull
|
|
98
|
+
const doPull = await askConfirm("ブランチを最新にしますか? (git pull)", true);
|
|
99
|
+
if (doPull) {
|
|
100
|
+
await pullBranch(resolvedDir, branch);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Requirements file
|
|
104
|
+
const requirementsPath = await askInput("要件定義ドキュメントパス:", {
|
|
105
|
+
validate: (v) => {
|
|
106
|
+
const absPath = resolve(resolvedDir, v);
|
|
107
|
+
if (!existsSync(absPath)) return `ファイルが存在しません: ${absPath}`;
|
|
108
|
+
return true;
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
success("ファイル存在確認");
|
|
112
|
+
|
|
113
|
+
// Workers
|
|
114
|
+
const memStatus = await getMemoryStatus();
|
|
115
|
+
const availableGB = memStatus.freeGB;
|
|
116
|
+
const suggested = suggestWorkers(availableGB);
|
|
117
|
+
|
|
118
|
+
const workersStr = await askInput(
|
|
119
|
+
`並行ワーカー数 (推奨: ${suggested}, 空きメモリ: ${availableGB.toFixed(1)}GB):`,
|
|
120
|
+
{
|
|
121
|
+
default: String(suggested),
|
|
122
|
+
validate: (v) => {
|
|
123
|
+
const n = parseInt(v, 10);
|
|
124
|
+
if (isNaN(n) || n < 1) return "1以上の整数を入力してください";
|
|
125
|
+
if (n > 8) return "最大8ワーカーまでです";
|
|
126
|
+
return true;
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
const workers = parseInt(workersStr, 10);
|
|
132
|
+
|
|
133
|
+
console.log(`\n────────────────────────────`);
|
|
134
|
+
console.log(` プロジェクト #${index + 1} 設定完了`);
|
|
135
|
+
console.log(`────────────────────────────`);
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
directory: resolvedDir,
|
|
139
|
+
branch,
|
|
140
|
+
requirementsPath: resolve(resolvedDir, requirementsPath),
|
|
141
|
+
workers,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export async function runProjectStep(): Promise<ProjectConfig[]> {
|
|
146
|
+
const projects: ProjectConfig[] = [];
|
|
147
|
+
|
|
148
|
+
// At least one project
|
|
149
|
+
projects.push(await configureProject(0));
|
|
150
|
+
|
|
151
|
+
// Additional projects
|
|
152
|
+
while (await askConfirm("別のプロジェクトを追加しますか?", false)) {
|
|
153
|
+
projects.push(await configureProject(projects.length));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return projects;
|
|
157
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive UI utility wrappers around @inquirer/prompts
|
|
3
|
+
*/
|
|
4
|
+
import { confirm, input, select, password } from "@inquirer/prompts";
|
|
5
|
+
|
|
6
|
+
export interface Choice<T> {
|
|
7
|
+
name: string;
|
|
8
|
+
value: T;
|
|
9
|
+
description?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function askConfirm(
|
|
13
|
+
message: string,
|
|
14
|
+
defaultValue = true,
|
|
15
|
+
): Promise<boolean> {
|
|
16
|
+
return confirm({ message, default: defaultValue });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function askInput(
|
|
20
|
+
message: string,
|
|
21
|
+
opts?: { default?: string; validate?: (v: string) => string | true },
|
|
22
|
+
): Promise<string> {
|
|
23
|
+
return input({
|
|
24
|
+
message,
|
|
25
|
+
default: opts?.default,
|
|
26
|
+
validate: opts?.validate,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function askSelect<T>(
|
|
31
|
+
message: string,
|
|
32
|
+
choices: Array<Choice<T>>,
|
|
33
|
+
): Promise<T> {
|
|
34
|
+
return select({ message, choices });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function askPassword(message: string): Promise<string> {
|
|
38
|
+
return password({ message, mask: "•" });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function printBox(lines: string[], title?: string): void {
|
|
42
|
+
const maxLen = Math.max(
|
|
43
|
+
title?.length ?? 0,
|
|
44
|
+
...lines.map((l) => stripAnsi(l).length),
|
|
45
|
+
);
|
|
46
|
+
const width = maxLen + 2;
|
|
47
|
+
const border = "─".repeat(width);
|
|
48
|
+
|
|
49
|
+
console.log(`┌${border}┐`);
|
|
50
|
+
if (title) {
|
|
51
|
+
console.log(`│ ${title.padEnd(width - 1)}│`);
|
|
52
|
+
console.log(`├${border}┤`);
|
|
53
|
+
}
|
|
54
|
+
for (const line of lines) {
|
|
55
|
+
const pad = width - 1 - stripAnsi(line).length;
|
|
56
|
+
console.log(`│ ${line}${" ".repeat(Math.max(0, pad))}│`);
|
|
57
|
+
}
|
|
58
|
+
console.log(`└${border}┘`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function stripAnsi(str: string): string {
|
|
62
|
+
return str.replace(/\x1b\[[0-9;]*m/g, "");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function success(msg: string): void {
|
|
66
|
+
console.log(` \x1b[32m✓\x1b[0m ${msg}`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function warn(msg: string): void {
|
|
70
|
+
console.log(` \x1b[33m⚠\x1b[0m ${msg}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function error(msg: string): void {
|
|
74
|
+
console.log(` \x1b[31m✗\x1b[0m ${msg}`);
|
|
75
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive mode — main entry point
|
|
3
|
+
* Launched when `aad` is run with no arguments
|
|
4
|
+
*/
|
|
5
|
+
import type { Command } from "commander";
|
|
6
|
+
import { runAuthStep } from "./interactive/auth-step.js";
|
|
7
|
+
import { runProjectStep } from "./interactive/project-step.js";
|
|
8
|
+
import { runLaunchStep } from "./interactive/launch-step.js";
|
|
9
|
+
|
|
10
|
+
export function registerInteractiveCommand(program: Command): void {
|
|
11
|
+
program
|
|
12
|
+
.command("interactive")
|
|
13
|
+
.description("Interactive setup wizard for multi-project AAD execution")
|
|
14
|
+
.action(async () => {
|
|
15
|
+
console.log();
|
|
16
|
+
console.log(" ┌─────────────────────────────────────┐");
|
|
17
|
+
console.log(" │ AAD Interactive Mode │");
|
|
18
|
+
console.log(" └─────────────────────────────────────┘");
|
|
19
|
+
console.log();
|
|
20
|
+
|
|
21
|
+
// Phase 1: Auth
|
|
22
|
+
console.log(" ── Step 1: 認証設定 ──\n");
|
|
23
|
+
await runAuthStep();
|
|
24
|
+
|
|
25
|
+
// Phase 2: Project setup
|
|
26
|
+
console.log("\n ── Step 2: プロジェクト設定 ──");
|
|
27
|
+
const projects = await runProjectStep();
|
|
28
|
+
|
|
29
|
+
if (projects.length === 0) {
|
|
30
|
+
console.log("\n プロジェクトが設定されていません。終了します。\n");
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Phase 3: Launch
|
|
35
|
+
console.log("\n ── Step 3: 実行 ──\n");
|
|
36
|
+
await runLaunchStep(projects);
|
|
37
|
+
});
|
|
38
|
+
}
|
|
@@ -122,7 +122,31 @@ export async function resumeRun(app: App, runIdStr: string): Promise<void> {
|
|
|
122
122
|
stores: { runStore: stores.runStore, taskStore: stores.taskStore },
|
|
123
123
|
logger,
|
|
124
124
|
});
|
|
125
|
-
|
|
125
|
+
// Check if parent worktree exists from a previous run
|
|
126
|
+
const parentWorktreePath = `${process.cwd()}/.aad/worktrees/parent-${runId}`;
|
|
127
|
+
const parentWorktreeExists = await Bun.file(`${parentWorktreePath}/.git`).exists();
|
|
128
|
+
// Detect feature branch name from parent worktree
|
|
129
|
+
let featureBranchName: string | undefined;
|
|
130
|
+
if (parentWorktreeExists) {
|
|
131
|
+
try {
|
|
132
|
+
const branchResult = await import("../../git-workspace").then(
|
|
133
|
+
(m) => m.getCurrentBranch(parentWorktreePath),
|
|
134
|
+
);
|
|
135
|
+
if (branchResult?.startsWith("aad/")) {
|
|
136
|
+
featureBranchName = branchResult;
|
|
137
|
+
}
|
|
138
|
+
} catch {
|
|
139
|
+
// Fallback: no feature branch detection
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
registerTaskDispatchHandler({
|
|
144
|
+
app,
|
|
145
|
+
runId,
|
|
146
|
+
parentBranch: runState.parentBranch,
|
|
147
|
+
parentWorktreePath: parentWorktreeExists ? parentWorktreePath : undefined,
|
|
148
|
+
featureBranchName,
|
|
149
|
+
});
|
|
126
150
|
|
|
127
151
|
// 6. Dispatcher.start()
|
|
128
152
|
dispatcher.start();
|
|
@@ -12,6 +12,7 @@ import { getCurrentBranch, cleanupOrphanedFromPreviousRuns } from "../../git-wor
|
|
|
12
12
|
import { checkMemoryAndWarn } from "../../../shared/memory-check";
|
|
13
13
|
import { installShutdownHandler } from "../../../shared/shutdown-handler";
|
|
14
14
|
import { registerTaskDispatchHandler } from "./task-dispatch-handler";
|
|
15
|
+
import { generateFeatureName } from "../../planning/feature-name-generator";
|
|
15
16
|
|
|
16
17
|
export function createRunCommand(getApp: () => App): Command {
|
|
17
18
|
const command = new Command("run")
|
|
@@ -44,7 +45,7 @@ export function createRunCommand(getApp: () => App): Command {
|
|
|
44
45
|
* メインパイプライン実行
|
|
45
46
|
*/
|
|
46
47
|
export async function runPipeline(app: App, requirementsPath: string, keepWorktrees = false): Promise<void> {
|
|
47
|
-
const { config, logger, eventBus, planningService, dispatcher, processManager, worktreeManager, stores, branchManager } = app;
|
|
48
|
+
const { config, logger, eventBus, planningService, dispatcher, processManager, worktreeManager, stores, branchManager, providerRegistry } = app;
|
|
48
49
|
|
|
49
50
|
// Validate requirements file exists
|
|
50
51
|
const reqFile = Bun.file(requirementsPath);
|
|
@@ -144,8 +145,27 @@ export async function runPipeline(app: App, requirementsPath: string, keepWorktr
|
|
|
144
145
|
logger,
|
|
145
146
|
});
|
|
146
147
|
|
|
147
|
-
// 5.
|
|
148
|
-
|
|
148
|
+
// 5. Generate feature name and create parent worktree with real branch
|
|
149
|
+
let parentWorktreePath: string | undefined;
|
|
150
|
+
let featureBranchName: string | undefined;
|
|
151
|
+
try {
|
|
152
|
+
const featureName = await generateFeatureName(
|
|
153
|
+
requirementsPath,
|
|
154
|
+
providerRegistry.getProvider("implementer"),
|
|
155
|
+
logger,
|
|
156
|
+
);
|
|
157
|
+
featureBranchName = `aad/${featureName}/parent`;
|
|
158
|
+
const result = await worktreeManager.createParentWorktree(runId, parentBranch, featureBranchName);
|
|
159
|
+
parentWorktreePath = result.worktreePath;
|
|
160
|
+
featureBranchName = result.branch;
|
|
161
|
+
logger.info({ parentWorktreePath, featureBranchName }, "Parent worktree created for merging");
|
|
162
|
+
console.log(`🌿 Feature Branch: ${featureBranchName}`);
|
|
163
|
+
} catch (error) {
|
|
164
|
+
logger.warn({ error }, "Failed to create parent worktree, using repo root as fallback");
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// 5.5. Register task dispatch handler (shared with resume)
|
|
168
|
+
registerTaskDispatchHandler({ app, runId, parentBranch, parentWorktreePath, featureBranchName });
|
|
149
169
|
|
|
150
170
|
// 6. 進捗表示
|
|
151
171
|
const progressSpinner = createSpinner("Executing tasks...");
|
|
@@ -210,7 +230,7 @@ export async function runPipeline(app: App, requirementsPath: string, keepWorktr
|
|
|
210
230
|
const worktreeBase = `${process.cwd()}/.aad/worktrees`;
|
|
211
231
|
const runWorktrees = worktrees.filter(
|
|
212
232
|
(wt) => wt.path.startsWith(worktreeBase)
|
|
213
|
-
&& wt.branch.includes(runId) //
|
|
233
|
+
&& (wt.branch.includes(runId) || (featureBranchName && wt.branch.startsWith(featureBranchName.replace(/\/parent$/, "")))) // Match by runId or feature name
|
|
214
234
|
&& !wt.path.includes(`parent-${runId}`) // Keep parent worktree (contains merge results)
|
|
215
235
|
);
|
|
216
236
|
|
|
@@ -230,7 +250,17 @@ export async function runPipeline(app: App, requirementsPath: string, keepWorktr
|
|
|
230
250
|
await worktreeManager.pruneWorktrees();
|
|
231
251
|
|
|
232
252
|
// Cleanup orphaned branches for this run (force delete merged branches)
|
|
233
|
-
|
|
253
|
+
let deletedBranches = await branchManager.cleanupOrphanBranches(runId, true);
|
|
254
|
+
|
|
255
|
+
// Also cleanup feature-named branches if applicable
|
|
256
|
+
if (featureBranchName) {
|
|
257
|
+
const featurePrefix = featureBranchName.replace(/\/parent$/, "");
|
|
258
|
+
const featureBranches = await branchManager.cleanupOrphanBranches(
|
|
259
|
+
createRunId(featurePrefix.replace("aad/", "")),
|
|
260
|
+
true,
|
|
261
|
+
);
|
|
262
|
+
deletedBranches = [...deletedBranches, ...featureBranches];
|
|
263
|
+
}
|
|
234
264
|
|
|
235
265
|
console.log(` Removed ${removed} worktree(s)`);
|
|
236
266
|
console.log(` Deleted ${deletedBranches.length} branch(es)`);
|
|
@@ -25,6 +25,12 @@ export interface TaskDispatchContext {
|
|
|
25
25
|
app: App;
|
|
26
26
|
runId: RunId;
|
|
27
27
|
parentBranch: string;
|
|
28
|
+
/** Path to the parent worktree where task branches are merged into.
|
|
29
|
+
* If not provided, falls back to process.cwd() (repo root). */
|
|
30
|
+
parentWorktreePath?: string;
|
|
31
|
+
/** Feature branch name (e.g. aad/auth-feature/parent).
|
|
32
|
+
* Task branches are created from this branch. */
|
|
33
|
+
featureBranchName?: string;
|
|
28
34
|
}
|
|
29
35
|
|
|
30
36
|
/**
|
|
@@ -32,7 +38,8 @@ export interface TaskDispatchContext {
|
|
|
32
38
|
* Handles memory gate, worker state, worktree creation, and TDD pipeline execution.
|
|
33
39
|
*/
|
|
34
40
|
export function registerTaskDispatchHandler(ctx: TaskDispatchContext): void {
|
|
35
|
-
const { app, runId, parentBranch } = ctx;
|
|
41
|
+
const { app, runId, parentBranch, parentWorktreePath, featureBranchName } = ctx;
|
|
42
|
+
const mergeTarget = parentWorktreePath ?? process.cwd();
|
|
36
43
|
const { eventBus, logger, config, processManager, worktreeManager, providerRegistry, stores } = app;
|
|
37
44
|
|
|
38
45
|
let memoryGateLock: Promise<void> = Promise.resolve();
|
|
@@ -53,8 +60,13 @@ export function registerTaskDispatchHandler(ctx: TaskDispatchContext): void {
|
|
|
53
60
|
throw new Error(`Task not found: ${taskId}`);
|
|
54
61
|
}
|
|
55
62
|
|
|
56
|
-
|
|
57
|
-
|
|
63
|
+
// Derive task branch name from feature branch or runId
|
|
64
|
+
// e.g. aad/auth-feature/task-001 (from aad/auth-feature/parent)
|
|
65
|
+
const branchPrefix = featureBranchName
|
|
66
|
+
? featureBranchName.replace(/\/parent$/, "")
|
|
67
|
+
: `aad/${runId}`;
|
|
68
|
+
const branchName = `${branchPrefix}/${taskId}`;
|
|
69
|
+
const worktreePath = await worktreeManager.createTaskWorktree(taskId, branchName, featureBranchName);
|
|
58
70
|
logger.info({ taskId, worktreePath, branchName }, "Worktree created");
|
|
59
71
|
|
|
60
72
|
const workspace = await detectWorkspace(worktreePath, logger);
|
|
@@ -63,7 +75,7 @@ export function registerTaskDispatchHandler(ctx: TaskDispatchContext): void {
|
|
|
63
75
|
|
|
64
76
|
const provider = providerRegistry.getProvider("implementer");
|
|
65
77
|
const result = await executeTddPipeline(
|
|
66
|
-
task, workspace, branchName, parentBranch,
|
|
78
|
+
task, workspace, branchName, parentBranch, mergeTarget,
|
|
67
79
|
runId, app.config, provider, app.mergeService, eventBus,
|
|
68
80
|
);
|
|
69
81
|
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `aad update` — Self-update AAD to the latest version
|
|
3
|
+
*/
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
import packageJson from "../../../../package.json" with { type: "json" };
|
|
6
|
+
|
|
7
|
+
interface UpdateOptions {
|
|
8
|
+
check: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async function exec(
|
|
12
|
+
cmd: string[],
|
|
13
|
+
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
|
14
|
+
const proc = Bun.spawn(cmd, {
|
|
15
|
+
stdout: "pipe",
|
|
16
|
+
stderr: "pipe",
|
|
17
|
+
});
|
|
18
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
19
|
+
new Response(proc.stdout).text(),
|
|
20
|
+
new Response(proc.stderr).text(),
|
|
21
|
+
proc.exited,
|
|
22
|
+
]);
|
|
23
|
+
return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function getLatestVersion(): Promise<string | null> {
|
|
27
|
+
try {
|
|
28
|
+
const result = await exec(["npm", "view", "@ronkovic/aad", "version"]);
|
|
29
|
+
if (result.exitCode === 0 && result.stdout) {
|
|
30
|
+
return result.stdout;
|
|
31
|
+
}
|
|
32
|
+
} catch {
|
|
33
|
+
// ignore
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function compareVersions(current: string, latest: string): number {
|
|
39
|
+
const c = current.split(".").map(Number);
|
|
40
|
+
const l = latest.split(".").map(Number);
|
|
41
|
+
for (let i = 0; i < 3; i++) {
|
|
42
|
+
if ((c[i] ?? 0) < (l[i] ?? 0)) return -1;
|
|
43
|
+
if ((c[i] ?? 0) > (l[i] ?? 0)) return 1;
|
|
44
|
+
}
|
|
45
|
+
return 0;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function detectPackageManager(): "bun" | "npm" {
|
|
49
|
+
if (process.env.BUN_INSTALL || process.argv[0]?.includes("bun")) {
|
|
50
|
+
return "bun";
|
|
51
|
+
}
|
|
52
|
+
return "npm";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function clearBunxCache(): Promise<boolean> {
|
|
56
|
+
const cacheDir = `/private/tmp/bunx-${process.getuid?.() ?? 502}-@ronkovic/`;
|
|
57
|
+
try {
|
|
58
|
+
const result = await exec(["rm", "-rf", cacheDir]);
|
|
59
|
+
return result.exitCode === 0;
|
|
60
|
+
} catch {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function createUpdateCommand(): Command {
|
|
66
|
+
return new Command("update")
|
|
67
|
+
.description("Update AAD to the latest version")
|
|
68
|
+
.option("--check", "Check for updates without installing", false)
|
|
69
|
+
.action(async (opts: UpdateOptions) => {
|
|
70
|
+
const currentVersion = packageJson.version;
|
|
71
|
+
console.log(`\n 現在のバージョン: v${currentVersion}`);
|
|
72
|
+
|
|
73
|
+
console.log(" 最新バージョンを確認中...");
|
|
74
|
+
const latestVersion = await getLatestVersion();
|
|
75
|
+
|
|
76
|
+
if (!latestVersion) {
|
|
77
|
+
console.log(" \x1b[31m✗\x1b[0m レジストリからバージョン情報を取得できませんでした\n");
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
console.log(` 最新バージョン: v${latestVersion}`);
|
|
82
|
+
|
|
83
|
+
const cmp = compareVersions(currentVersion, latestVersion);
|
|
84
|
+
if (cmp >= 0) {
|
|
85
|
+
console.log(" \x1b[32m✓\x1b[0m 最新バージョンです\n");
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (opts.check) {
|
|
90
|
+
console.log(` \x1b[33m⚠\x1b[0m 更新可能: v${currentVersion} → v${latestVersion}\n`);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Perform update
|
|
95
|
+
const pm = await detectPackageManager();
|
|
96
|
+
console.log(`\n パッケージマネージャ: ${pm}`);
|
|
97
|
+
|
|
98
|
+
if (pm === "bun") {
|
|
99
|
+
console.log(" bunxキャッシュをクリア中...");
|
|
100
|
+
await clearBunxCache();
|
|
101
|
+
console.log(" \x1b[32m✓\x1b[0m キャッシュクリア完了");
|
|
102
|
+
console.log(`\n 次回 \`bunx @ronkovic/aad\` 実行時にv${latestVersion}が使用されます\n`);
|
|
103
|
+
} else {
|
|
104
|
+
console.log(" npm install中...");
|
|
105
|
+
const result = await exec([
|
|
106
|
+
"npm",
|
|
107
|
+
"install",
|
|
108
|
+
"-g",
|
|
109
|
+
`@ronkovic/aad@${latestVersion}`,
|
|
110
|
+
]);
|
|
111
|
+
if (result.exitCode === 0) {
|
|
112
|
+
console.log(` \x1b[32m✓\x1b[0m v${latestVersion}にアップデートしました\n`);
|
|
113
|
+
} else {
|
|
114
|
+
console.log(` \x1b[31m✗\x1b[0m アップデート失敗: ${result.stderr}\n`);
|
|
115
|
+
process.exit(1);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
}
|
package/src/modules/cli/index.ts
CHANGED
|
@@ -21,6 +21,7 @@ export { createRunCommand, runPipeline } from "./commands/run";
|
|
|
21
21
|
export { createResumeCommand, resumeRun } from "./commands/resume";
|
|
22
22
|
export { createStatusCommand, displayStatus } from "./commands/status";
|
|
23
23
|
export { createCleanupCommand, cleanupWorktrees } from "./commands/cleanup";
|
|
24
|
+
export { createUpdateCommand } from "./commands/update";
|
|
24
25
|
|
|
25
26
|
export {
|
|
26
27
|
createShutdownManager,
|
|
@@ -71,15 +71,30 @@ describe("WorktreeManager", () => {
|
|
|
71
71
|
});
|
|
72
72
|
|
|
73
73
|
describe("createParentWorktree", () => {
|
|
74
|
-
test("creates worktree
|
|
74
|
+
test("creates worktree with feature branch", async () => {
|
|
75
75
|
const runId = createRunId("run-001");
|
|
76
76
|
const parentBranch = "main";
|
|
77
|
+
const featureBranch = "aad/auth-feature/parent";
|
|
77
78
|
|
|
78
|
-
const
|
|
79
|
+
const result = await worktreeManager.createParentWorktree(runId, parentBranch, featureBranch);
|
|
79
80
|
|
|
80
|
-
expect(worktreePath).toBe("/test/worktrees/parent-run-001");
|
|
81
|
+
expect(result.worktreePath).toBe("/test/worktrees/parent-run-001");
|
|
82
|
+
expect(result.branch).toBe(featureBranch);
|
|
81
83
|
expect(mockGitOps.gitExec).toHaveBeenCalledWith(
|
|
82
|
-
["worktree", "add", "/test/worktrees/parent-run-001", parentBranch],
|
|
84
|
+
["worktree", "add", "-b", featureBranch, "/test/worktrees/parent-run-001", parentBranch],
|
|
85
|
+
expect.objectContaining({ cwd: repoRoot })
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("uses default branch name when featureBranch not provided", async () => {
|
|
90
|
+
const runId = createRunId("run-001");
|
|
91
|
+
const parentBranch = "main";
|
|
92
|
+
|
|
93
|
+
const result = await worktreeManager.createParentWorktree(runId, parentBranch);
|
|
94
|
+
|
|
95
|
+
expect(result.branch).toBe("aad/run-001/parent");
|
|
96
|
+
expect(mockGitOps.gitExec).toHaveBeenCalledWith(
|
|
97
|
+
["worktree", "add", "-b", "aad/run-001/parent", "/test/worktrees/parent-run-001", parentBranch],
|
|
83
98
|
expect.objectContaining({ cwd: repoRoot })
|
|
84
99
|
);
|
|
85
100
|
});
|
|
@@ -41,10 +41,11 @@ export class WorktreeManager {
|
|
|
41
41
|
/**
|
|
42
42
|
* Create worktree for a task
|
|
43
43
|
*/
|
|
44
|
-
async createTaskWorktree(taskId: TaskId, branch: string): Promise<string> {
|
|
44
|
+
async createTaskWorktree(taskId: TaskId, branch: string, baseBranch?: string): Promise<string> {
|
|
45
45
|
const worktreePath = join(this.worktreeBase, taskId as string);
|
|
46
|
+
const startPoint = baseBranch ?? "HEAD";
|
|
46
47
|
|
|
47
|
-
this.logger?.info({ taskId, branch, worktreePath }, "Creating task worktree");
|
|
48
|
+
this.logger?.info({ taskId, branch, baseBranch: startPoint, worktreePath }, "Creating task worktree");
|
|
48
49
|
|
|
49
50
|
try {
|
|
50
51
|
// Ensure worktree base directory exists
|
|
@@ -53,9 +54,9 @@ export class WorktreeManager {
|
|
|
53
54
|
// Clean up stale worktree/branch if they already exist
|
|
54
55
|
await this.cleanupStaleWorktree(worktreePath, branch);
|
|
55
56
|
|
|
56
|
-
// Create worktree with new branch
|
|
57
|
+
// Create worktree with new branch from baseBranch (parent feature branch)
|
|
57
58
|
await this.gitOps.gitExec(
|
|
58
|
-
["worktree", "add", "-b", branch, worktreePath,
|
|
59
|
+
["worktree", "add", "-b", branch, worktreePath, startPoint],
|
|
59
60
|
{ cwd: this.repoRoot, logger: this.logger }
|
|
60
61
|
);
|
|
61
62
|
|
|
@@ -113,25 +114,32 @@ export class WorktreeManager {
|
|
|
113
114
|
/**
|
|
114
115
|
* Create worktree for parent branch (used for merging)
|
|
115
116
|
*/
|
|
116
|
-
async createParentWorktree(
|
|
117
|
+
async createParentWorktree(
|
|
118
|
+
runId: RunId,
|
|
119
|
+
parentBranch: string,
|
|
120
|
+
featureBranch?: string,
|
|
121
|
+
): Promise<{ worktreePath: string; branch: string }> {
|
|
117
122
|
const worktreePath = join(this.worktreeBase, `parent-${runId as string}`);
|
|
123
|
+
const branch = featureBranch ?? `aad/${runId as string}/parent`;
|
|
118
124
|
|
|
119
|
-
this.logger?.info({ runId, parentBranch, worktreePath }, "Creating parent worktree");
|
|
125
|
+
this.logger?.info({ runId, parentBranch, branch, worktreePath }, "Creating parent worktree");
|
|
120
126
|
|
|
121
127
|
try {
|
|
122
128
|
await this.fsOps.mkdir(this.worktreeBase, { recursive: true });
|
|
123
129
|
|
|
124
|
-
// Create worktree
|
|
130
|
+
// Create worktree with a new branch based on parentBranch
|
|
131
|
+
// e.g. git worktree add -b aad/auth-feature/parent <path> main
|
|
125
132
|
await this.gitOps.gitExec(
|
|
126
|
-
["worktree", "add", worktreePath, parentBranch],
|
|
133
|
+
["worktree", "add", "-b", branch, worktreePath, parentBranch],
|
|
127
134
|
{ cwd: this.repoRoot, logger: this.logger }
|
|
128
135
|
);
|
|
129
136
|
|
|
130
|
-
return worktreePath;
|
|
137
|
+
return { worktreePath, branch };
|
|
131
138
|
} catch (error) {
|
|
132
139
|
throw new GitWorkspaceError("Failed to create parent worktree", {
|
|
133
140
|
runId: runId as string,
|
|
134
141
|
parentBranch,
|
|
142
|
+
branch,
|
|
135
143
|
worktreePath,
|
|
136
144
|
error: error instanceof Error ? error.message : String(error),
|
|
137
145
|
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { sanitizeBranchName, generateFeatureName } from "../feature-name-generator";
|
|
3
|
+
import type { ClaudeProvider } from "../../claude-provider";
|
|
4
|
+
|
|
5
|
+
describe("Feature Name Generator", () => {
|
|
6
|
+
describe("sanitizeBranchName", () => {
|
|
7
|
+
it("converts to lowercase", () => {
|
|
8
|
+
expect(sanitizeBranchName("Auth-Feature")).toBe("auth-feature");
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("removes invalid characters", () => {
|
|
12
|
+
expect(sanitizeBranchName("hello world!@#")).toBe("hello-world");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("collapses multiple hyphens", () => {
|
|
16
|
+
expect(sanitizeBranchName("a--b---c")).toBe("a-b-c");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("removes leading/trailing hyphens", () => {
|
|
20
|
+
expect(sanitizeBranchName("-hello-")).toBe("hello");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("truncates to 20 characters", () => {
|
|
24
|
+
expect(sanitizeBranchName("this-is-a-very-long-branch-name-that-exceeds-limit").length).toBeLessThanOrEqual(20);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("handles empty string", () => {
|
|
28
|
+
expect(sanitizeBranchName("")).toBe("");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("handles Japanese characters (fallback)", () => {
|
|
32
|
+
expect(sanitizeBranchName("認証機能")).toBe("");
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("generateFeatureName", () => {
|
|
37
|
+
it("uses Claude result when successful", async () => {
|
|
38
|
+
const mockProvider: ClaudeProvider = {
|
|
39
|
+
call: async () => ({
|
|
40
|
+
result: " auth-login \n",
|
|
41
|
+
exitCode: 0,
|
|
42
|
+
model: "claude-haiku-4-5",
|
|
43
|
+
effortLevel: "low" as const,
|
|
44
|
+
duration: 100,
|
|
45
|
+
}),
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const name = await generateFeatureName("/dev/null", mockProvider);
|
|
49
|
+
expect(name).toBe("auth-login");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("falls back to filename on provider error", async () => {
|
|
53
|
+
const mockProvider: ClaudeProvider = {
|
|
54
|
+
call: async () => { throw new Error("API error"); },
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const name = await generateFeatureName("/some/path/auth-feature.md", mockProvider);
|
|
58
|
+
expect(name).toBe("auth-feature");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("falls back to filename when result too short", async () => {
|
|
62
|
+
const mockProvider: ClaudeProvider = {
|
|
63
|
+
call: async () => ({
|
|
64
|
+
result: "ab",
|
|
65
|
+
exitCode: 0,
|
|
66
|
+
model: "claude-haiku-4-5",
|
|
67
|
+
effortLevel: "low" as const,
|
|
68
|
+
duration: 100,
|
|
69
|
+
}),
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const name = await generateFeatureName("/path/user-signup.md", mockProvider);
|
|
73
|
+
expect(name).toBe("user-signup");
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Feature Name Generator
|
|
3
|
+
* Generates a short English feature name from a requirements document.
|
|
4
|
+
* Used for branch naming: aad/{featureName}/parent, aad/{featureName}/task-001
|
|
5
|
+
*
|
|
6
|
+
* Ported from .aad/scripts/run-parallel.sh extract_requirement_title()
|
|
7
|
+
*/
|
|
8
|
+
import type { ClaudeProvider } from "../claude-provider";
|
|
9
|
+
import type pino from "pino";
|
|
10
|
+
import { readFile } from "node:fs/promises";
|
|
11
|
+
import { basename } from "node:path";
|
|
12
|
+
|
|
13
|
+
const FEATURE_NAME_PROMPT = `You are a branch name generator. Given the requirements below, output ONLY a short English identifier suitable for a git branch name.
|
|
14
|
+
|
|
15
|
+
Rules:
|
|
16
|
+
- Lowercase alphanumeric and hyphens only
|
|
17
|
+
- 20 characters max
|
|
18
|
+
- No explanation, no decoration, just the identifier
|
|
19
|
+
|
|
20
|
+
Requirements:
|
|
21
|
+
`;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Sanitize a string to be a valid git branch name component
|
|
25
|
+
*/
|
|
26
|
+
export function sanitizeBranchName(name: string): string {
|
|
27
|
+
return name
|
|
28
|
+
.toLowerCase()
|
|
29
|
+
.replace(/[^a-z0-9-]/g, "-")
|
|
30
|
+
.replace(/-+/g, "-")
|
|
31
|
+
.replace(/^-|-$/g, "")
|
|
32
|
+
.slice(0, 20);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Generate a feature name from requirements file using Claude
|
|
37
|
+
* Falls back to filename-based name on failure
|
|
38
|
+
*/
|
|
39
|
+
export async function generateFeatureName(
|
|
40
|
+
requirementsPath: string,
|
|
41
|
+
provider: ClaudeProvider,
|
|
42
|
+
logger?: pino.Logger,
|
|
43
|
+
): Promise<string> {
|
|
44
|
+
try {
|
|
45
|
+
// Read first 20 lines of requirements
|
|
46
|
+
const fullContent = await readFile(requirementsPath, "utf-8");
|
|
47
|
+
const content = fullContent.split("\n").slice(0, 20).join("\n");
|
|
48
|
+
|
|
49
|
+
const result = await provider.call({
|
|
50
|
+
prompt: FEATURE_NAME_PROMPT + content,
|
|
51
|
+
model: "claude-haiku-4-5",
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const rawName = result.result.trim();
|
|
55
|
+
|
|
56
|
+
const sanitized = sanitizeBranchName(rawName);
|
|
57
|
+
|
|
58
|
+
if (sanitized.length >= 3) {
|
|
59
|
+
logger?.info({ featureName: sanitized, raw: rawName }, "Feature name generated");
|
|
60
|
+
return sanitized;
|
|
61
|
+
}
|
|
62
|
+
} catch (error) {
|
|
63
|
+
logger?.warn({ error }, "Feature name generation failed, using fallback");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Fallback: filename-based
|
|
67
|
+
const fallback = sanitizeBranchName(
|
|
68
|
+
basename(requirementsPath).replace(/\.[^.]*$/, ""),
|
|
69
|
+
);
|
|
70
|
+
return fallback || "default";
|
|
71
|
+
}
|