@ronkovic/aad 0.3.6 → 0.3.7

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.6",
3
+ "version": "0.3.7",
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"));
@@ -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;
@@ -256,7 +256,7 @@ export async function runPipeline(app: App, requirementsPath: string, keepWorktr
256
256
  if (featureBranchName) {
257
257
  const featurePrefix = featureBranchName.replace(/\/parent$/, "");
258
258
  const featureBranches = await branchManager.cleanupOrphanBranches(
259
- createRunId(featurePrefix.replace("aad/", "")),
259
+ createRunId(featurePrefix.replace(/^[^/]+\//, "")),
260
260
  true,
261
261
  );
262
262
  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
 
@@ -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,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";