@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ronkovic/aad",
3
- "version": "0.3.4",
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
- // Parse CLI args
52
- program.parse();
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
- registerTaskDispatchHandler({ app, runId, parentBranch: runState.parentBranch });
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. Register task dispatch handler (shared with resume)
148
- registerTaskDispatchHandler({ app, runId, parentBranch });
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) // Check branch name for 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
- const deletedBranches = await branchManager.cleanupOrphanBranches(runId, true);
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
- const branchName = `aad/${runId}/${taskId}`;
57
- const worktreePath = await worktreeManager.createTaskWorktree(taskId, branchName);
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, process.cwd(),
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
+ }
@@ -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 on existing branch", async () => {
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 worktreePath = await worktreeManager.createParentWorktree(runId, parentBranch);
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, "HEAD"],
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(runId: RunId, parentBranch: string): Promise<string> {
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 on existing branch (no -b flag)
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
+ }