@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 +40 -14
- package/package.json +1 -1
- package/src/__tests__/e2e/pipeline-e2e.test.ts +6 -0
- package/src/modules/cli/app.ts +7 -4
- package/src/modules/cli/commands/resume.ts +1 -1
- package/src/modules/cli/commands/run.ts +16 -8
- package/src/modules/cli/commands/task-dispatch-handler.ts +3 -3
- package/src/modules/git-workspace/__tests__/worktree-cleanup.test.ts +9 -5
- package/src/modules/git-workspace/__tests__/worktree-manager.test.ts +3 -3
- package/src/modules/git-workspace/branch-manager.ts +10 -3
- package/src/modules/git-workspace/merge-service.ts +28 -1
- package/src/modules/git-workspace/worktree-manager.ts +28 -20
- package/src/modules/logging/logger.ts +81 -3
- package/src/modules/planning/__tests__/feature-name-generator.test.ts +60 -12
- package/src/modules/planning/feature-name-generator.ts +48 -16
- package/src/modules/task-queue/dispatcher.ts +6 -4
package/README.md
CHANGED
|
@@ -5,19 +5,25 @@ TypeScript/Bun製マルチエージェント開発オーケストレーター。
|
|
|
5
5
|
[](LICENSE)
|
|
6
6
|
[](https://www.typescriptlang.org/)
|
|
7
7
|
[](https://bun.sh/)
|
|
8
|
-
[](https://www.npmjs.com/package/@ronkovic/aad)
|
|
9
|
+
[]()
|
|
10
|
+
[]()
|
|
10
11
|
|
|
11
12
|
## 主要機能
|
|
12
13
|
|
|
13
14
|
- **マルチエージェント並列実行**: 複数のClaudeエージェントが独立したタスクを並列処理
|
|
14
15
|
- **TDDパイプライン**: Red → Green → Verify → Review → Merge の自動化されたテスト駆動開発
|
|
15
|
-
-
|
|
16
|
-
-
|
|
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.
|
|
138
|
-
3.
|
|
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
|
-
- [
|
|
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
|
@@ -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"));
|
package/src/modules/cli/app.ts
CHANGED
|
@@ -77,16 +77,19 @@ export function createApp(options: AppOptions = {}): App {
|
|
|
77
77
|
// 2. EventBus初期化
|
|
78
78
|
const eventBus = new EventBusImpl();
|
|
79
79
|
|
|
80
|
-
// 3.
|
|
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
|
-
//
|
|
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
|
|
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 =
|
|
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) =>
|
|
233
|
-
|
|
234
|
-
|
|
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
|
|
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,
|
|
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(
|
|
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.
|
|
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.
|
|
64
|
+
// e.g. feat/auth-feature/task-001 (from feat/auth-feature/parent)
|
|
65
65
|
const branchPrefix = featureBranchName
|
|
66
66
|
? featureBranchName.replace(/\/parent$/, "")
|
|
67
|
-
: `
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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 = "
|
|
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("
|
|
95
|
+
expect(result.branch).toBe("feat/run-001/parent");
|
|
96
96
|
expect(mockGitOps.gitExec).toHaveBeenCalledWith(
|
|
97
|
-
["worktree", "add", "-b", "
|
|
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
|
|
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
|
-
? [
|
|
143
|
-
|
|
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
|
|
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
|
|
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
|
|
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 ?? `
|
|
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
|
|
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
|
|
298
|
+
for (const prefix of branchPrefixes) {
|
|
306
299
|
try {
|
|
307
|
-
await gitOps.gitExec(
|
|
308
|
-
|
|
309
|
-
|
|
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
|
-
|
|
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
|
|
63
|
+
const baseLogger = pino({
|
|
50
64
|
level: debug ? "debug" : "warn",
|
|
51
65
|
transport: targets.length === 1 ? targets[0] : { targets },
|
|
52
66
|
});
|
|
53
67
|
|
|
54
|
-
|
|
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
|
|
24
|
-
|
|
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("
|
|
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: "
|
|
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
|
|
49
|
-
expect(
|
|
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
|
|
58
|
-
expect(
|
|
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: "
|
|
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
|
|
73
|
-
expect(
|
|
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
|
|
4
|
-
* Used for branch naming:
|
|
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
|
|
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
|
-
|
|
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,
|
|
47
|
+
.slice(0, maxLength);
|
|
33
48
|
}
|
|
34
49
|
|
|
35
50
|
/**
|
|
36
|
-
*
|
|
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<
|
|
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
|
|
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
|
-
//
|
|
230
|
-
|
|
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
|
-
//
|
|
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";
|