@ronkovic/aad 0.3.6 → 0.3.8

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/README.md CHANGED
@@ -5,19 +5,25 @@ TypeScript/Bun製マルチエージェント開発オーケストレーター。
5
5
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
6
6
  [![TypeScript](https://img.shields.io/badge/TypeScript-5.7-blue)](https://www.typescriptlang.org/)
7
7
  [![Bun](https://img.shields.io/badge/Bun-1.2+-black)](https://bun.sh/)
8
- [![Tests](https://img.shields.io/badge/tests-738%2B%20passing-brightgreen)]()
9
- [![Version](https://img.shields.io/badge/version-0.3.0-orange)]()
8
+ [![npm](https://img.shields.io/npm/v/@ronkovic/aad)](https://www.npmjs.com/package/@ronkovic/aad)
9
+ [![Tests](https://img.shields.io/badge/tests-913%20passing-brightgreen)]()
10
+ [![Version](https://img.shields.io/badge/version-0.3.7-orange)]()
10
11
 
11
12
  ## 主要機能
12
13
 
13
14
  - **マルチエージェント並列実行**: 複数のClaudeエージェントが独立したタスクを並列処理
14
15
  - **TDDパイプライン**: Red → Green → Verify → Review → Merge の自動化されたテスト駆動開発
15
- - **マルチリポジトリ対応**: 複数リポジトリにまたがるタスクの統合管理 *(v0.3.0)*
16
- - **プラグインシステム**: カスタムアダプター・パイプラインステップの拡張 *(v0.3.0)*
16
+ - **インタラクティブモード**: `aad` で対話型ウィザードを起動、認証・プロジェクト設定を簡単に *(v0.3.6)*
17
+ - **セルフアップデート**: `aad update` で最新版に自動更新 *(v0.3.6)*
18
+ - **スマートブランチ命名**: 要件ドキュメントから自動でconventional commit prefix付きブランチ名を生成 *(v0.3.7)*
19
+ - **適応的エフォート調整**: タスク複雑度に応じてClaudeのeffort levelを自動調整
20
+ - **マルチリポジトリ対応**: 複数リポジトリにまたがるタスクの統合管理
21
+ - **プラグインシステム**: カスタムアダプター・パイプラインステップの拡張
17
22
  - **Git Workspace管理**: タスクごとに独立したworktreeを自動作成・管理
18
23
  - **依存関係解決**: タスク間の依存関係を自動検出し、適切な順序で実行
19
24
  - **リアルタイム監視**: Webダッシュボードで進捗・ログをリアルタイム表示
20
25
  - **構造化ログ**: pinoによる構造化ログで詳細なトレーシングが可能
26
+ - **メモリ安全機構**: OOMを防止する自動メモリゲート・シーケンシャルフォールバック
21
27
 
22
28
  ## アーキテクチャ
23
29
 
@@ -129,26 +135,34 @@ bun run dev
129
135
  ### 2. タスク実行
130
136
 
131
137
  ```bash
138
+ # 直接実行
132
139
  aad run requirements.md --workers 4
140
+
141
+ # インタラクティブモード(対話型ウィザード)
142
+ aad
133
143
  ```
134
144
 
135
145
  AADが自動的に:
136
- 1. タスクを分解・依存関係を解析
137
- 2. 各タスク用のworktreeを作成
138
- 3. 4つのワーカーで並列実行 (TDD: テスト作成 → 実装 → 検証 → レビュー)
139
- 4. 親ブランチへのマージ
146
+ 1. 要件ドキュメントからfeature名を生成(例: `feat/user-auth-impl`)
147
+ 2. タスクを分解・依存関係を解析
148
+ 3. 親ブランチ + タスクブランチをworktreeで作成
149
+ 4. ワーカーで並列実行 (TDD: テスト作成 → 実装 → 検証 → レビュー)
150
+ 5. 親ブランチへのマージ
140
151
 
141
- ### 3. 進捗確認
152
+ ### 3. 進捗確認・管理
142
153
 
143
154
  ```bash
144
155
  # ステータス確認
145
- aad status
156
+ aad status [run_id]
146
157
 
147
158
  # 実行の再開
148
159
  aad resume <run_id>
149
160
 
150
161
  # worktreeのクリーンアップ
151
- aad cleanup
162
+ aad cleanup [run_id] [--force]
163
+
164
+ # バージョン確認・アップデート
165
+ aad update [--check]
152
166
  ```
153
167
 
154
168
  ### 4. Webダッシュボード
@@ -256,6 +270,20 @@ describe("MyModule", () => {
256
270
 
257
271
  設定では短縮名(`sonnet`、`opus`、`haiku`)またはフルネームが使用できます。
258
272
 
273
+ ## ブランチフロー
274
+
275
+ AADは要件ドキュメントからconventional commit prefix付きのfeature名を自動生成し、以下のブランチ構造を作成します:
276
+
277
+ ```
278
+ main (ソースブランチ)
279
+ └─ feat/user-auth-impl/parent ← 親ブランチ + worktree
280
+ ├─ feat/user-auth-impl/task-001 ← タスクブランチ(parentから分岐)
281
+ ├─ feat/user-auth-impl/task-002
282
+ └─ feat/user-auth-impl/task-003
283
+ ```
284
+
285
+ 各タスクはparentブランチから分岐し、完了後にparentへマージされます。prefix(`feat/`, `fix/`, `refactor/`等)は要件の内容から自動判定されます。
286
+
259
287
  ## Claude Provider: SDK vs CLI
260
288
 
261
289
  AADはClaudeとの通信に2つのadapterを提供:
@@ -320,9 +348,7 @@ aad cleanup <run_id> --force
320
348
 
321
349
  ## リンク
322
350
 
323
- - [Architecture Documentation](docs/architecture.md)
324
- - [Case Studies](docs/case-studies/) - Real-world execution examples
325
- - [Rewrite Plan (Internal)](docs/rewrite-plan/README.md)
351
+ - [npm Package](https://www.npmjs.com/package/@ronkovic/aad)
326
352
  - [Project Guidelines](CLAUDE.md)
327
353
  - [Issue Tracker](https://github.com/ronkovic/aad/issues)
328
354
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ronkovic/aad",
3
- "version": "0.3.6",
3
+ "version": "0.3.8",
4
4
  "description": "Autonomous Agent Development Orchestrator - Multi-agent TDD pipeline powered by Claude",
5
5
  "module": "src/main.ts",
6
6
  "type": "module",
@@ -270,6 +270,12 @@ describe("E2E Pipeline", () => {
270
270
  result: { taskId: createTaskId("task-1"), status: "completed", duration: 50 },
271
271
  });
272
272
 
273
+ // Emit worker:idle to trigger dispatch (mirrors real task-dispatch-handler behavior)
274
+ eventBus.emit({
275
+ type: "worker:idle",
276
+ workerId: createWorkerId("w-1"),
277
+ });
278
+
273
279
  await waitFor(() => dispatchedTasks.length >= 2, 3000);
274
280
 
275
281
  expect(dispatchedTasks[1]).toBe(createTaskId("task-2"));
@@ -77,16 +77,19 @@ export function createApp(options: AppOptions = {}): App {
77
77
  // 2. EventBus初期化
78
78
  const eventBus = new EventBusImpl();
79
79
 
80
- // 3. Logger初期化
80
+ // 3. LogStore初期化 (loggerより先に必要)
81
+ const logStore = new LogStore();
82
+
83
+ // 4. Logger初期化 (dashboard有効時はlogStore+eventBusに橋渡し)
81
84
  const logger = createLogger({
82
85
  service: "aad-cli",
83
86
  debug: config.debug,
87
+ logStore: config.dashboard.enabled ? logStore : undefined,
88
+ eventBus: config.dashboard.enabled ? eventBus : undefined,
84
89
  });
85
90
  logger.debug({ config, options }, "AAD CLI starting");
86
91
 
87
- // 4. LogStore + SSE Transport初期化
88
- const logStore = new LogStore();
89
- // SSE transport registers EventBus listeners internally; no direct reference needed
92
+ // SSE transport: EventBus経由のlog:entryをLogStoreに書く (手動emitされるログ用)
90
93
  createSSETransport({ logStore, eventBus });
91
94
 
92
95
  // 5. ProviderRegistry初期化
@@ -132,7 +132,7 @@ export async function resumeRun(app: App, runIdStr: string): Promise<void> {
132
132
  const branchResult = await import("../../git-workspace").then(
133
133
  (m) => m.getCurrentBranch(parentWorktreePath),
134
134
  );
135
- if (branchResult?.startsWith("aad/")) {
135
+ if (branchResult && /^(feat|fix|docs|style|refactor|perf|test|chore)\//.test(branchResult)) {
136
136
  featureBranchName = branchResult;
137
137
  }
138
138
  } catch {
@@ -149,12 +149,12 @@ export async function runPipeline(app: App, requirementsPath: string, keepWorktr
149
149
  let parentWorktreePath: string | undefined;
150
150
  let featureBranchName: string | undefined;
151
151
  try {
152
- const featureName = await generateFeatureName(
152
+ const { prefix, name: featureName } = await generateFeatureName(
153
153
  requirementsPath,
154
154
  providerRegistry.getProvider("implementer"),
155
155
  logger,
156
156
  );
157
- featureBranchName = `aad/${featureName}/parent`;
157
+ featureBranchName = `${prefix}/${featureName}/parent`;
158
158
  const result = await worktreeManager.createParentWorktree(runId, parentBranch, featureBranchName);
159
159
  parentWorktreePath = result.worktreePath;
160
160
  featureBranchName = result.branch;
@@ -228,17 +228,25 @@ export async function runPipeline(app: App, requirementsPath: string, keepWorktr
228
228
  // List all worktrees
229
229
  const worktrees = await worktreeManager.listWorktrees();
230
230
  const worktreeBase = `${process.cwd()}/.aad/worktrees`;
231
+ // branch field from `git worktree list --porcelain` is full ref: "refs/heads/feat/..."
232
+ const featurePrefix = featureBranchName?.replace(/\/parent$/, "") ?? "";
231
233
  const runWorktrees = worktrees.filter(
232
- (wt) => wt.path.startsWith(worktreeBase)
233
- && (wt.branch.includes(runId) || (featureBranchName && wt.branch.startsWith(featureBranchName.replace(/\/parent$/, "")))) // Match by runId or feature name
234
- && !wt.path.includes(`parent-${runId}`) // Keep parent worktree (contains merge results)
234
+ (wt) => {
235
+ if (!wt.path.startsWith(worktreeBase)) return false;
236
+ const shortBranch = wt.branch.replace(/^refs\/heads\//, "");
237
+ const matchesRun = shortBranch.includes(runId) || (featurePrefix && shortBranch.startsWith(featurePrefix));
238
+ if (!matchesRun) return false;
239
+ // Keep parent worktree (contains merge results for review)
240
+ if (wt.path.includes(`parent-${runId}`)) return false;
241
+ return true;
242
+ }
235
243
  );
236
244
 
237
- // Remove worktrees for this run
245
+ // Remove task worktrees (parent worktree is preserved)
238
246
  let removed = 0;
239
247
  for (const worktree of runWorktrees) {
240
248
  try {
241
- await worktreeManager.removeWorktree(worktree.path, false);
249
+ await worktreeManager.removeWorktree(worktree.path, true);
242
250
  removed++;
243
251
  logger.debug({ path: worktree.path }, "Worktree removed");
244
252
  } catch (error) {
@@ -256,7 +264,7 @@ export async function runPipeline(app: App, requirementsPath: string, keepWorktr
256
264
  if (featureBranchName) {
257
265
  const featurePrefix = featureBranchName.replace(/\/parent$/, "");
258
266
  const featureBranches = await branchManager.cleanupOrphanBranches(
259
- createRunId(featurePrefix.replace("aad/", "")),
267
+ createRunId(featurePrefix.replace(/^[^/]+\//, "")),
260
268
  true,
261
269
  );
262
270
  deletedBranches = [...deletedBranches, ...featureBranches];
@@ -28,7 +28,7 @@ export interface TaskDispatchContext {
28
28
  /** Path to the parent worktree where task branches are merged into.
29
29
  * If not provided, falls back to process.cwd() (repo root). */
30
30
  parentWorktreePath?: string;
31
- /** Feature branch name (e.g. aad/auth-feature/parent).
31
+ /** Feature branch name (e.g. feat/auth-feature/parent).
32
32
  * Task branches are created from this branch. */
33
33
  featureBranchName?: string;
34
34
  }
@@ -61,10 +61,10 @@ export function registerTaskDispatchHandler(ctx: TaskDispatchContext): void {
61
61
  }
62
62
 
63
63
  // Derive task branch name from feature branch or runId
64
- // e.g. aad/auth-feature/task-001 (from aad/auth-feature/parent)
64
+ // e.g. feat/auth-feature/task-001 (from feat/auth-feature/parent)
65
65
  const branchPrefix = featureBranchName
66
66
  ? featureBranchName.replace(/\/parent$/, "")
67
- : `aad/${runId}`;
67
+ : `feat/${runId}`;
68
68
  const branchName = `${branchPrefix}/${taskId}`;
69
69
  const worktreePath = await worktreeManager.createTaskWorktree(taskId, branchName, featureBranchName);
70
70
  logger.info({ taskId, worktreePath, branchName }, "Worktree created");
@@ -34,11 +34,15 @@ describe("cleanupOrphanedFromPreviousRuns", () => {
34
34
  };
35
35
  }
36
36
  if (args[0] === "branch" && args[1] === "--list") {
37
- return {
38
- stdout: " aad/run1/task-001\n aad/run1/task-002\n",
39
- stderr: "",
40
- exitCode: 0,
41
- };
37
+ // Only return branches for matching prefix pattern
38
+ if (args[2] === "aad/*") {
39
+ return {
40
+ stdout: " aad/run1/task-001\n aad/run1/task-002\n",
41
+ stderr: "",
42
+ exitCode: 0,
43
+ };
44
+ }
45
+ return { stdout: "", stderr: "", exitCode: 0 };
42
46
  }
43
47
  return { stdout: "", stderr: "", exitCode: 0 };
44
48
  }),
@@ -74,7 +74,7 @@ describe("WorktreeManager", () => {
74
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
+ const featureBranch = "feat/auth-feature/parent";
78
78
 
79
79
  const result = await worktreeManager.createParentWorktree(runId, parentBranch, featureBranch);
80
80
 
@@ -92,9 +92,9 @@ describe("WorktreeManager", () => {
92
92
 
93
93
  const result = await worktreeManager.createParentWorktree(runId, parentBranch);
94
94
 
95
- expect(result.branch).toBe("aad/run-001/parent");
95
+ expect(result.branch).toBe("feat/run-001/parent");
96
96
  expect(mockGitOps.gitExec).toHaveBeenCalledWith(
97
- ["worktree", "add", "-b", "aad/run-001/parent", "/test/worktrees/parent-run-001", parentBranch],
97
+ ["worktree", "add", "-b", "feat/run-001/parent", "/test/worktrees/parent-run-001", parentBranch],
98
98
  expect.objectContaining({ cwd: repoRoot })
99
99
  );
100
100
  });
@@ -137,10 +137,17 @@ export class BranchManager {
137
137
  * Cleanup orphaned AAD branches (branches without worktrees)
138
138
  */
139
139
  async cleanupOrphanBranches(runId?: RunId, force = false): Promise<string[]> {
140
- // Support both aad/* (slash) and aad-* (hyphen) patterns for backward compatibility
140
+ // Support aad/*, conventional commit prefixes (feat/*, fix/*, etc.), and legacy aad-* patterns
141
+ const conventionalPrefixes = ["aad", "feat", "fix", "docs", "style", "refactor", "perf", "test", "chore"];
141
142
  const patterns = runId
142
- ? [`aad/${runId as string}/*`, `aad-*-${runId as string}*`]
143
- : ["aad/*", "aad-*"];
143
+ ? [
144
+ ...conventionalPrefixes.map((p) => `${p}/${runId as string}/*`),
145
+ `aad-*-${runId as string}*`,
146
+ ]
147
+ : [
148
+ ...conventionalPrefixes.map((p) => `${p}/*`),
149
+ "aad-*",
150
+ ];
144
151
 
145
152
  this.logger?.info({ patterns, force }, "Cleaning up orphan branches");
146
153
 
@@ -1,5 +1,7 @@
1
1
  import type { Logger } from "pino";
2
2
  import type { TaskId } from "@aad/shared/types";
3
+ import { readFile } from "node:fs/promises";
4
+ import { resolve, join } from "node:path";
3
5
  import { gitExec } from "./git-exec";
4
6
  import { FileLock } from "../persistence";
5
7
 
@@ -14,6 +16,30 @@ export interface MergeResult {
14
16
  message?: string;
15
17
  }
16
18
 
19
+ /**
20
+ * Resolve the actual .git directory for a path.
21
+ * Worktrees have a `.git` file containing "gitdir: <path>",
22
+ * while regular repos have a `.git` directory.
23
+ */
24
+ async function resolveGitDir(worktreePath: string): Promise<string> {
25
+ const dotGit = join(worktreePath, ".git");
26
+ try {
27
+ const stat = await Bun.file(dotGit).exists();
28
+ if (!stat) return dotGit; // fallback
29
+
30
+ const content = await readFile(dotGit, "utf-8");
31
+ const match = content.match(/^gitdir:\s*(.+)$/m);
32
+ if (match?.[1]) {
33
+ const gitdir = match[1].trim();
34
+ // gitdir can be relative or absolute
35
+ return resolve(worktreePath, gitdir);
36
+ }
37
+ return dotGit; // It's a directory, not a file
38
+ } catch {
39
+ return dotGit; // fallback
40
+ }
41
+ }
42
+
17
43
  /**
18
44
  * Merge task branches into parent branch with conflict detection
19
45
  */
@@ -35,7 +61,8 @@ export class MergeService {
35
61
  parentBranch: string,
36
62
  parentWorktree: string
37
63
  ): Promise<MergeResult> {
38
- const lockDir = `${parentWorktree}/.git/aad-merge.lock`;
64
+ const gitDir = await resolveGitDir(parentWorktree);
65
+ const lockDir = join(gitDir, "aad-merge.lock");
39
66
  const lock = new FileLock({ lockDir, timeout: 60000, logger: this.logger });
40
67
 
41
68
  try {
@@ -76,11 +76,11 @@ export class WorktreeManager {
76
76
  * Handles cases where a previous run left behind artifacts.
77
77
  */
78
78
  private async cleanupStaleWorktree(worktreePath: string, branch: string): Promise<void> {
79
- // 1. Remove existing worktree directory if it exists
79
+ // 1. Remove existing worktree directory if it exists (no logger to suppress error-level output)
80
80
  try {
81
81
  await this.gitOps.gitExec(
82
82
  ["worktree", "remove", worktreePath, "--force"],
83
- { cwd: this.repoRoot, logger: this.logger }
83
+ { cwd: this.repoRoot }
84
84
  );
85
85
  this.logger?.info({ worktreePath }, "Removed stale worktree");
86
86
  } catch {
@@ -99,11 +99,11 @@ export class WorktreeManager {
99
99
  // Non-critical
100
100
  }
101
101
 
102
- // 3. Delete stale branch if it exists
102
+ // 3. Delete stale branch if it exists (no logger to suppress error-level git output)
103
103
  try {
104
104
  await this.gitOps.gitExec(
105
105
  ["branch", "-D", branch],
106
- { cwd: this.repoRoot, logger: this.logger }
106
+ { cwd: this.repoRoot }
107
107
  );
108
108
  this.logger?.info({ branch }, "Deleted stale branch");
109
109
  } catch {
@@ -120,7 +120,7 @@ export class WorktreeManager {
120
120
  featureBranch?: string,
121
121
  ): Promise<{ worktreePath: string; branch: string }> {
122
122
  const worktreePath = join(this.worktreeBase, `parent-${runId as string}`);
123
- const branch = featureBranch ?? `aad/${runId as string}/parent`;
123
+ const branch = featureBranch ?? `feat/${runId as string}/parent`;
124
124
 
125
125
  this.logger?.info({ runId, parentBranch, branch, worktreePath }, "Creating parent worktree");
126
126
 
@@ -128,7 +128,7 @@ export class WorktreeManager {
128
128
  await this.fsOps.mkdir(this.worktreeBase, { recursive: true });
129
129
 
130
130
  // Create worktree with a new branch based on parentBranch
131
- // e.g. git worktree add -b aad/auth-feature/parent <path> main
131
+ // e.g. git worktree add -b feat/auth-feature/parent <path> main
132
132
  await this.gitOps.gitExec(
133
133
  ["worktree", "add", "-b", branch, worktreePath, parentBranch],
134
134
  { cwd: this.repoRoot, logger: this.logger }
@@ -287,28 +287,36 @@ export async function cleanupOrphanedFromPreviousRuns(
287
287
  // non-critical
288
288
  }
289
289
 
290
- // 3. Delete all branches matching aad/*
290
+ // 3. Delete all branches matching aad/*, feat/*, fix/*, etc.
291
+ const branchPrefixes = ["aad", "feat", "fix", "docs", "style", "refactor", "perf", "test", "chore"];
291
292
  try {
292
293
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
293
294
  const gitOps = (worktreeManager as any).gitOps as GitOps;
294
295
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
295
296
  const repoRoot = (worktreeManager as any).repoRoot as string;
296
- const result = await gitOps.gitExec(
297
- ["branch", "--list", "aad/*"],
298
- { cwd: repoRoot, logger },
299
- );
300
- const branches = result.stdout
301
- .split("\n")
302
- .map((b: string) => b.trim().replace(/^\* /, ""))
303
- .filter((b: string) => b.length > 0);
304
297
 
305
- for (const branch of branches) {
298
+ for (const prefix of branchPrefixes) {
306
299
  try {
307
- await gitOps.gitExec(["branch", "-D", branch], { cwd: repoRoot, logger });
308
- deletedBranches++;
309
- logger.debug({ branch }, "Deleted orphaned branch");
300
+ const result = await gitOps.gitExec(
301
+ ["branch", "--list", `${prefix}/*`],
302
+ { cwd: repoRoot },
303
+ );
304
+ const branches = result.stdout
305
+ .split("\n")
306
+ .map((b: string) => b.trim().replace(/^[*+]\s*/, ""))
307
+ .filter((b: string) => b.length > 0);
308
+
309
+ for (const branch of branches) {
310
+ try {
311
+ await gitOps.gitExec(["branch", "-D", branch], { cwd: repoRoot });
312
+ deletedBranches++;
313
+ logger.debug({ branch }, "Deleted orphaned branch");
314
+ } catch {
315
+ logger.debug({ branch }, "Failed to delete orphaned branch (ignored)");
316
+ }
317
+ }
310
318
  } catch {
311
- logger.debug({ branch }, "Failed to delete orphaned branch (ignored)");
319
+ // Pattern didn't match any branches
312
320
  }
313
321
  }
314
322
  } catch (error) {
@@ -1,13 +1,27 @@
1
1
  import pino from "pino";
2
+ import { Writable } from "node:stream";
3
+ import type { LogStore, LogEntry } from "./log-store";
4
+ import type { EventBus } from "../../shared/events";
2
5
 
3
6
  export interface LoggerOptions {
4
7
  service: string;
5
8
  debug?: boolean;
6
9
  logFilePath?: string;
10
+ /** When provided, all pino logs are also forwarded to LogStore + EventBus for dashboard SSE */
11
+ logStore?: LogStore;
12
+ eventBus?: EventBus;
13
+ }
14
+
15
+ /** Map pino numeric levels to LogEntry level strings */
16
+ function pinoLevelToString(level: number): "debug" | "info" | "warn" | "error" {
17
+ if (level <= 20) return "debug";
18
+ if (level <= 30) return "info";
19
+ if (level <= 40) return "warn";
20
+ return "error";
7
21
  }
8
22
 
9
23
  export function createLogger(options: LoggerOptions): pino.Logger {
10
- const { service, debug = false, logFilePath } = options;
24
+ const { service, debug = false, logFilePath, logStore, eventBus } = options;
11
25
 
12
26
  const targets: pino.TransportTargetOptions[] = [];
13
27
 
@@ -46,10 +60,74 @@ export function createLogger(options: LoggerOptions): pino.Logger {
46
60
  });
47
61
  }
48
62
 
49
- const logger = pino({
63
+ const baseLogger = pino({
50
64
  level: debug ? "debug" : "warn",
51
65
  transport: targets.length === 1 ? targets[0] : { targets },
52
66
  });
53
67
 
54
- return logger.child({ service });
68
+ // If logStore and eventBus provided, create a multistream logger that also
69
+ // writes to the dashboard. We create a separate pino instance that writes
70
+ // to a custom Writable, then hook into the base logger.
71
+ if (logStore && eventBus) {
72
+ const dashboardStream = new Writable({
73
+ write(chunk: Buffer, _encoding: string, callback: () => void): void {
74
+ try {
75
+ const obj = JSON.parse(chunk.toString());
76
+ const entry: LogEntry = {
77
+ level: pinoLevelToString(obj.level ?? 30),
78
+ service: obj.service ?? service,
79
+ message: obj.msg ?? "",
80
+ timestamp: obj.time ?? Date.now(),
81
+ taskId: obj.taskId,
82
+ workerId: obj.workerId,
83
+ };
84
+ logStore.add(entry);
85
+ eventBus.emit({ type: "log:entry", entry });
86
+ } catch {
87
+ // ignore parse errors
88
+ }
89
+ callback();
90
+ },
91
+ });
92
+
93
+ // Use streams for everything when dashboard is active
94
+ // (pino transports run in worker threads, can't combine with custom streams)
95
+ const streams: pino.StreamEntry[] = [];
96
+
97
+ if (debug) {
98
+ // We can't use pino-pretty as a stream directly (it's a transport).
99
+ // Write raw JSON to stderr in debug mode instead.
100
+ streams.push({
101
+ level: "debug" as pino.Level,
102
+ stream: process.stderr,
103
+ });
104
+ } else {
105
+ streams.push({
106
+ level: "warn" as pino.Level,
107
+ stream: process.stderr,
108
+ });
109
+ }
110
+
111
+ if (logFilePath) {
112
+ streams.push({
113
+ level: "info" as pino.Level,
114
+ stream: pino.destination(logFilePath),
115
+ });
116
+ }
117
+
118
+ // Dashboard stream
119
+ streams.push({
120
+ level: (debug ? "debug" : "info") as pino.Level,
121
+ stream: dashboardStream,
122
+ });
123
+
124
+ const combinedLogger = pino(
125
+ { level: debug ? "debug" : "info" },
126
+ pino.multistream(streams)
127
+ );
128
+
129
+ return combinedLogger.child({ service });
130
+ }
131
+
132
+ return baseLogger.child({ service });
55
133
  }
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from "bun:test";
2
- import { sanitizeBranchName, generateFeatureName } from "../feature-name-generator";
2
+ import { sanitizeBranchName, parsePrefix, generateFeatureName } from "../feature-name-generator";
3
3
  import type { ClaudeProvider } from "../../claude-provider";
4
4
 
5
5
  describe("Feature Name Generator", () => {
@@ -20,8 +20,13 @@ describe("Feature Name Generator", () => {
20
20
  expect(sanitizeBranchName("-hello-")).toBe("hello");
21
21
  });
22
22
 
23
- it("truncates to 20 characters", () => {
24
- expect(sanitizeBranchName("this-is-a-very-long-branch-name-that-exceeds-limit").length).toBeLessThanOrEqual(20);
23
+ it("truncates to 30 characters by default", () => {
24
+ const result = sanitizeBranchName("this-is-a-very-long-branch-name-that-exceeds-limit");
25
+ expect(result.length).toBeLessThanOrEqual(30);
26
+ });
27
+
28
+ it("accepts custom maxLength", () => {
29
+ expect(sanitizeBranchName("abcdefghij", 5)).toBe("abcde");
25
30
  });
26
31
 
27
32
  it("handles empty string", () => {
@@ -33,11 +38,51 @@ describe("Feature Name Generator", () => {
33
38
  });
34
39
  });
35
40
 
41
+ describe("parsePrefix", () => {
42
+ it("parses valid prefixes", () => {
43
+ expect(parsePrefix("feat")).toBe("feat");
44
+ expect(parsePrefix("fix")).toBe("fix");
45
+ expect(parsePrefix("docs")).toBe("docs");
46
+ expect(parsePrefix("refactor")).toBe("refactor");
47
+ expect(parsePrefix("chore")).toBe("chore");
48
+ });
49
+
50
+ it("handles trailing colon", () => {
51
+ expect(parsePrefix("feat:")).toBe("feat");
52
+ });
53
+
54
+ it("handles case insensitivity", () => {
55
+ expect(parsePrefix("FEAT")).toBe("feat");
56
+ expect(parsePrefix("Fix")).toBe("fix");
57
+ });
58
+
59
+ it("defaults to feat for unknown", () => {
60
+ expect(parsePrefix("unknown")).toBe("feat");
61
+ expect(parsePrefix("")).toBe("feat");
62
+ });
63
+ });
64
+
36
65
  describe("generateFeatureName", () => {
37
- it("uses Claude result when successful", async () => {
66
+ it("parses prefix and name from two-line response", async () => {
67
+ const mockProvider: ClaudeProvider = {
68
+ call: async () => ({
69
+ result: "fix\nauth-login-bug\n",
70
+ exitCode: 0,
71
+ model: "claude-haiku-4-5",
72
+ effortLevel: "low" as const,
73
+ duration: 100,
74
+ }),
75
+ };
76
+
77
+ const result = await generateFeatureName("/dev/null", mockProvider);
78
+ expect(result.prefix).toBe("fix");
79
+ expect(result.name).toBe("auth-login-bug");
80
+ });
81
+
82
+ it("defaults prefix to feat when single line", async () => {
38
83
  const mockProvider: ClaudeProvider = {
39
84
  call: async () => ({
40
- result: " auth-login \n",
85
+ result: "auth-login\n",
41
86
  exitCode: 0,
42
87
  model: "claude-haiku-4-5",
43
88
  effortLevel: "low" as const,
@@ -45,8 +90,9 @@ describe("Feature Name Generator", () => {
45
90
  }),
46
91
  };
47
92
 
48
- const name = await generateFeatureName("/dev/null", mockProvider);
49
- expect(name).toBe("auth-login");
93
+ const result = await generateFeatureName("/dev/null", mockProvider);
94
+ expect(result.prefix).toBe("feat");
95
+ expect(result.name).toBe("auth-login");
50
96
  });
51
97
 
52
98
  it("falls back to filename on provider error", async () => {
@@ -54,14 +100,15 @@ describe("Feature Name Generator", () => {
54
100
  call: async () => { throw new Error("API error"); },
55
101
  };
56
102
 
57
- const name = await generateFeatureName("/some/path/auth-feature.md", mockProvider);
58
- expect(name).toBe("auth-feature");
103
+ const result = await generateFeatureName("/some/path/auth-feature.md", mockProvider);
104
+ expect(result.prefix).toBe("feat");
105
+ expect(result.name).toBe("auth-feature");
59
106
  });
60
107
 
61
108
  it("falls back to filename when result too short", async () => {
62
109
  const mockProvider: ClaudeProvider = {
63
110
  call: async () => ({
64
- result: "ab",
111
+ result: "feat\nab",
65
112
  exitCode: 0,
66
113
  model: "claude-haiku-4-5",
67
114
  effortLevel: "low" as const,
@@ -69,8 +116,9 @@ describe("Feature Name Generator", () => {
69
116
  }),
70
117
  };
71
118
 
72
- const name = await generateFeatureName("/path/user-signup.md", mockProvider);
73
- expect(name).toBe("user-signup");
119
+ const result = await generateFeatureName("/path/user-signup.md", mockProvider);
120
+ expect(result.prefix).toBe("feat");
121
+ expect(result.name).toBe("user-signup");
74
122
  });
75
123
  });
76
124
  });
@@ -1,7 +1,10 @@
1
1
  /**
2
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
3
+ * Generates a git branch prefix and feature name from a requirements document.
4
+ * Used for branch naming: {prefix}/{featureName}/parent, {prefix}/{featureName}/task-001
5
+ *
6
+ * Prefix follows conventional commit types:
7
+ * feat, fix, docs, style, refactor, perf, test, chore
5
8
  *
6
9
  * Ported from .aad/scripts/run-parallel.sh extract_requirement_title()
7
10
  */
@@ -10,12 +13,24 @@ import type pino from "pino";
10
13
  import { readFile } from "node:fs/promises";
11
14
  import { basename } from "node:path";
12
15
 
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.
16
+ const VALID_PREFIXES = [
17
+ "feat",
18
+ "fix",
19
+ "docs",
20
+ "style",
21
+ "refactor",
22
+ "perf",
23
+ "test",
24
+ "chore",
25
+ ] as const;
26
+
27
+ export type BranchPrefix = (typeof VALID_PREFIXES)[number];
28
+
29
+ const FEATURE_NAME_PROMPT = `You are a git branch name generator. Given the requirements below, output EXACTLY two lines:
30
+ Line 1: The conventional commit type (one of: feat, fix, docs, style, refactor, perf, test, chore)
31
+ Line 2: A short English identifier for the branch name (lowercase alphanumeric and hyphens only, 30 chars max)
14
32
 
15
- Rules:
16
- - Lowercase alphanumeric and hyphens only
17
- - 20 characters max
18
- - No explanation, no decoration, just the identifier
33
+ No explanation, no decoration, just the two lines.
19
34
 
20
35
  Requirements:
21
36
  `;
@@ -23,24 +38,40 @@ Requirements:
23
38
  /**
24
39
  * Sanitize a string to be a valid git branch name component
25
40
  */
26
- export function sanitizeBranchName(name: string): string {
41
+ export function sanitizeBranchName(name: string, maxLength = 30): string {
27
42
  return name
28
43
  .toLowerCase()
29
44
  .replace(/[^a-z0-9-]/g, "-")
30
45
  .replace(/-+/g, "-")
31
46
  .replace(/^-|-$/g, "")
32
- .slice(0, 20);
47
+ .slice(0, maxLength);
33
48
  }
34
49
 
35
50
  /**
36
- * Generate a feature name from requirements file using Claude
51
+ * Parse prefix from Claude response, defaulting to "feat"
52
+ */
53
+ export function parsePrefix(line: string): BranchPrefix {
54
+ const trimmed = line.trim().toLowerCase().replace(/:$/, "");
55
+ if (VALID_PREFIXES.includes(trimmed as BranchPrefix)) {
56
+ return trimmed as BranchPrefix;
57
+ }
58
+ return "feat";
59
+ }
60
+
61
+ export interface FeatureNameResult {
62
+ prefix: BranchPrefix;
63
+ name: string;
64
+ }
65
+
66
+ /**
67
+ * Generate a feature name and prefix from requirements file using Claude
37
68
  * Falls back to filename-based name on failure
38
69
  */
39
70
  export async function generateFeatureName(
40
71
  requirementsPath: string,
41
72
  provider: ClaudeProvider,
42
73
  logger?: pino.Logger,
43
- ): Promise<string> {
74
+ ): Promise<FeatureNameResult> {
44
75
  try {
45
76
  // Read first 20 lines of requirements
46
77
  const fullContent = await readFile(requirementsPath, "utf-8");
@@ -51,13 +82,14 @@ export async function generateFeatureName(
51
82
  model: "claude-haiku-4-5",
52
83
  });
53
84
 
54
- const rawName = result.result.trim();
55
-
85
+ const lines = result.result.trim().split("\n").filter(Boolean);
86
+ const prefix = parsePrefix(lines[0] ?? "");
87
+ const rawName = (lines[1] ?? lines[0] ?? "").trim();
56
88
  const sanitized = sanitizeBranchName(rawName);
57
89
 
58
90
  if (sanitized.length >= 3) {
59
- logger?.info({ featureName: sanitized, raw: rawName }, "Feature name generated");
60
- return sanitized;
91
+ logger?.info({ prefix, featureName: sanitized, raw: rawName }, "Feature name generated");
92
+ return { prefix, name: sanitized };
61
93
  }
62
94
  } catch (error) {
63
95
  logger?.warn({ error }, "Feature name generation failed, using fallback");
@@ -67,5 +99,5 @@ export async function generateFeatureName(
67
99
  const fallback = sanitizeBranchName(
68
100
  basename(requirementsPath).replace(/\.[^.]*$/, ""),
69
101
  );
70
- return fallback || "default";
102
+ return { prefix: "feat", name: fallback || "default" };
71
103
  }
@@ -226,8 +226,11 @@ export class Dispatcher {
226
226
 
227
227
  this.deps.logger.info({ taskId }, "Task completed");
228
228
 
229
- // Try to dispatch next tasks
230
- await this.dispatchNext();
229
+ // Note: Do NOT call dispatchNext() here.
230
+ // The worker:idle event (emitted by task-dispatch-handler after completeTask)
231
+ // will trigger handleWorkerIdle → dispatchNext(). Calling it here too causes
232
+ // a race condition where two dispatchNext() calls run concurrently without a mutex,
233
+ // potentially dispatching multiple tasks to the same worker.
231
234
  }
232
235
 
233
236
  async handleTaskFailed(taskId: TaskId, error: string): Promise<void> {
@@ -259,8 +262,7 @@ export class Dispatcher {
259
262
  "Task failed, retrying"
260
263
  );
261
264
 
262
- // Try to dispatch
263
- await this.dispatchNext();
265
+ // Dispatch deferred to worker:idle event (avoids race condition)
264
266
  } else {
265
267
  // Failed permanently
266
268
  task.status = "failed";