@ronkovic/aad 0.3.8 → 0.4.0

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.
Files changed (114) hide show
  1. package/README.md +292 -12
  2. package/package.json +6 -1
  3. package/src/__tests__/e2e/pipeline-e2e.test.ts +1 -0
  4. package/src/__tests__/e2e/resume-e2e.test.ts +2 -0
  5. package/src/__tests__/integration/pipeline.test.ts +1 -0
  6. package/src/modules/claude-provider/__tests__/claude-sdk-real-env.test.ts +1 -1
  7. package/src/modules/claude-provider/__tests__/claude-sdk.adapter.test.ts +2 -0
  8. package/src/modules/claude-provider/__tests__/provider-registry.test.ts +1 -0
  9. package/src/modules/cli/__tests__/cleanup.test.ts +72 -0
  10. package/src/modules/cli/__tests__/resume.test.ts +1 -0
  11. package/src/modules/cli/__tests__/run.test.ts +1 -0
  12. package/src/modules/cli/commands/__tests__/task-dispatch-handler.test.ts +145 -0
  13. package/src/modules/cli/commands/cleanup.ts +26 -11
  14. package/src/modules/cli/commands/resume.ts +3 -2
  15. package/src/modules/cli/commands/run.ts +57 -7
  16. package/src/modules/cli/commands/task-dispatch-handler.ts +73 -3
  17. package/src/modules/dashboard/__tests__/api-graph.test.ts +332 -0
  18. package/src/modules/dashboard/__tests__/api-timeline.test.ts +461 -0
  19. package/src/modules/dashboard/routes/sse.ts +3 -2
  20. package/src/modules/dashboard/server.ts +1 -0
  21. package/src/modules/dashboard/services/sse-broadcaster.ts +29 -0
  22. package/src/modules/dashboard/ui/dashboard.html +143 -18
  23. package/src/modules/git-workspace/__tests__/branch-manager.test.ts +52 -0
  24. package/src/modules/git-workspace/__tests__/dependency-installer.test.ts +77 -0
  25. package/src/modules/git-workspace/__tests__/git-exec.test.ts +26 -0
  26. package/src/modules/git-workspace/__tests__/merge-service.test.ts +19 -0
  27. package/src/modules/git-workspace/__tests__/pr-manager.test.ts +80 -0
  28. package/src/modules/git-workspace/__tests__/template-copy.test.ts +189 -0
  29. package/src/modules/git-workspace/__tests__/worktree-cleanup.test.ts +29 -2
  30. package/src/modules/git-workspace/__tests__/worktree-manager.test.ts +64 -4
  31. package/src/modules/git-workspace/branch-manager.ts +24 -3
  32. package/src/modules/git-workspace/dependency-installer.ts +113 -0
  33. package/src/modules/git-workspace/git-exec.ts +3 -2
  34. package/src/modules/git-workspace/index.ts +10 -1
  35. package/src/modules/git-workspace/merge-service.ts +40 -10
  36. package/src/modules/git-workspace/pr-manager.ts +278 -0
  37. package/src/modules/git-workspace/template-copy.ts +302 -0
  38. package/src/modules/git-workspace/worktree-manager.ts +37 -11
  39. package/src/modules/planning/__tests__/planning-service.test.ts +1 -0
  40. package/src/modules/planning/__tests__/planning.service.test.ts +149 -0
  41. package/src/modules/planning/__tests__/project-detection.test.ts +7 -1
  42. package/src/modules/planning/planning.service.ts +16 -2
  43. package/src/modules/planning/project-detection.ts +4 -1
  44. package/src/modules/process-manager/__tests__/process-manager.test.ts +1 -0
  45. package/src/modules/task-execution/__tests__/executor.test.ts +86 -0
  46. package/src/modules/task-execution/__tests__/tester-verify.test.ts +4 -3
  47. package/src/modules/task-execution/executor.ts +87 -4
  48. package/src/modules/task-execution/phases/implementer-green.ts +22 -5
  49. package/src/modules/task-execution/phases/merge.ts +44 -2
  50. package/src/modules/task-execution/phases/tester-red.ts +22 -5
  51. package/src/modules/task-execution/phases/tester-verify.ts +22 -6
  52. package/src/modules/task-queue/dispatcher.ts +50 -1
  53. package/src/shared/__tests__/prerequisites.test.ts +176 -0
  54. package/src/shared/config.ts +6 -0
  55. package/src/shared/prerequisites.ts +190 -0
  56. package/src/shared/types.ts +13 -0
  57. package/templates/CLAUDE.md +122 -0
  58. package/templates/settings.json +117 -0
  59. package/src/modules/persistence/__tests__/.tmp-stores-test-81991/progress.json +0 -10
  60. package/src/modules/persistence/__tests__/.tmp-stores-test-81991/queue/completed/task-getall-2.json +0 -10
  61. package/src/modules/persistence/__tests__/.tmp-stores-test-81991/queue/pending/task-1.json +0 -13
  62. package/src/modules/persistence/__tests__/.tmp-stores-test-81991/queue/pending/task-getall-1.json +0 -10
  63. package/src/modules/persistence/__tests__/.tmp-stores-test-81991/queue/pending/task-status-change.json +0 -10
  64. package/src/modules/persistence/__tests__/.tmp-stores-test-81991/workers/worker-1.json +0 -5
  65. package/src/modules/persistence/__tests__/.tmp-stores-test-81991/workers/worker-2.json +0 -5
  66. package/src/modules/persistence/__tests__/.tmp-stores-test-82469/progress.json +0 -10
  67. package/src/modules/persistence/__tests__/.tmp-stores-test-82469/queue/completed/task-getall-2.json +0 -10
  68. package/src/modules/persistence/__tests__/.tmp-stores-test-82469/queue/pending/task-1.json +0 -13
  69. package/src/modules/persistence/__tests__/.tmp-stores-test-82469/queue/pending/task-getall-1.json +0 -10
  70. package/src/modules/persistence/__tests__/.tmp-stores-test-82469/queue/pending/task-status-change.json +0 -10
  71. package/src/modules/persistence/__tests__/.tmp-stores-test-82469/workers/worker-1.json +0 -5
  72. package/src/modules/persistence/__tests__/.tmp-stores-test-82469/workers/worker-2.json +0 -5
  73. package/src/modules/persistence/__tests__/.tmp-stores-test-85301/progress.json +0 -10
  74. package/src/modules/persistence/__tests__/.tmp-stores-test-85301/queue/completed/task-getall-2.json +0 -10
  75. package/src/modules/persistence/__tests__/.tmp-stores-test-85301/queue/pending/task-1.json +0 -13
  76. package/src/modules/persistence/__tests__/.tmp-stores-test-85301/queue/pending/task-getall-1.json +0 -10
  77. package/src/modules/persistence/__tests__/.tmp-stores-test-85301/queue/pending/task-status-change.json +0 -10
  78. package/src/modules/persistence/__tests__/.tmp-stores-test-85301/workers/worker-1.json +0 -5
  79. package/src/modules/persistence/__tests__/.tmp-stores-test-85301/workers/worker-2.json +0 -5
  80. package/src/modules/persistence/__tests__/.tmp-stores-test-85759/progress.json +0 -10
  81. package/src/modules/persistence/__tests__/.tmp-stores-test-85759/queue/completed/task-getall-2.json +0 -10
  82. package/src/modules/persistence/__tests__/.tmp-stores-test-85759/queue/pending/task-1.json +0 -13
  83. package/src/modules/persistence/__tests__/.tmp-stores-test-85759/queue/pending/task-getall-1.json +0 -10
  84. package/src/modules/persistence/__tests__/.tmp-stores-test-85759/queue/pending/task-status-change.json +0 -10
  85. package/src/modules/persistence/__tests__/.tmp-stores-test-85759/workers/worker-1.json +0 -5
  86. package/src/modules/persistence/__tests__/.tmp-stores-test-85759/workers/worker-2.json +0 -5
  87. package/src/modules/persistence/__tests__/.tmp-stores-test-86184/progress.json +0 -10
  88. package/src/modules/persistence/__tests__/.tmp-stores-test-86184/queue/completed/task-getall-2.json +0 -10
  89. package/src/modules/persistence/__tests__/.tmp-stores-test-86184/queue/pending/task-1.json +0 -13
  90. package/src/modules/persistence/__tests__/.tmp-stores-test-86184/queue/pending/task-getall-1.json +0 -10
  91. package/src/modules/persistence/__tests__/.tmp-stores-test-86184/queue/pending/task-status-change.json +0 -10
  92. package/src/modules/persistence/__tests__/.tmp-stores-test-86184/workers/worker-1.json +0 -5
  93. package/src/modules/persistence/__tests__/.tmp-stores-test-86184/workers/worker-2.json +0 -5
  94. package/src/modules/persistence/__tests__/.tmp-stores-test-88026/progress.json +0 -10
  95. package/src/modules/persistence/__tests__/.tmp-stores-test-88026/queue/completed/task-getall-2.json +0 -10
  96. package/src/modules/persistence/__tests__/.tmp-stores-test-88026/queue/pending/task-1.json +0 -13
  97. package/src/modules/persistence/__tests__/.tmp-stores-test-88026/queue/pending/task-getall-1.json +0 -10
  98. package/src/modules/persistence/__tests__/.tmp-stores-test-88026/queue/pending/task-status-change.json +0 -10
  99. package/src/modules/persistence/__tests__/.tmp-stores-test-88026/workers/worker-1.json +0 -5
  100. package/src/modules/persistence/__tests__/.tmp-stores-test-88026/workers/worker-2.json +0 -5
  101. package/src/modules/persistence/__tests__/.tmp-stores-test-89475/progress.json +0 -10
  102. package/src/modules/persistence/__tests__/.tmp-stores-test-89475/queue/completed/task-getall-2.json +0 -10
  103. package/src/modules/persistence/__tests__/.tmp-stores-test-89475/queue/pending/task-1.json +0 -13
  104. package/src/modules/persistence/__tests__/.tmp-stores-test-89475/queue/pending/task-getall-1.json +0 -10
  105. package/src/modules/persistence/__tests__/.tmp-stores-test-89475/queue/pending/task-status-change.json +0 -10
  106. package/src/modules/persistence/__tests__/.tmp-stores-test-89475/workers/worker-1.json +0 -5
  107. package/src/modules/persistence/__tests__/.tmp-stores-test-89475/workers/worker-2.json +0 -5
  108. package/src/modules/persistence/__tests__/.tmp-stores-test-89924/progress.json +0 -10
  109. package/src/modules/persistence/__tests__/.tmp-stores-test-89924/queue/completed/task-getall-2.json +0 -10
  110. package/src/modules/persistence/__tests__/.tmp-stores-test-89924/queue/pending/task-1.json +0 -13
  111. package/src/modules/persistence/__tests__/.tmp-stores-test-89924/queue/pending/task-getall-1.json +0 -10
  112. package/src/modules/persistence/__tests__/.tmp-stores-test-89924/queue/pending/task-status-change.json +0 -10
  113. package/src/modules/persistence/__tests__/.tmp-stores-test-89924/workers/worker-1.json +0 -5
  114. package/src/modules/persistence/__tests__/.tmp-stores-test-89924/workers/worker-2.json +0 -5
@@ -0,0 +1,189 @@
1
+ import { describe, test, expect, beforeEach } from "bun:test";
2
+ import { mkdir, rm, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { copyTemplatesToWorktree, resolveTemplateDir } from "../template-copy";
5
+ import { createLogger } from "../../logging";
6
+
7
+ const testDir = join(import.meta.dir, ".tmp-template-copy-test");
8
+ const projectRoot = join(testDir, "project");
9
+ const templateDir = join(projectRoot, ".aad", "templates");
10
+ const targetDir = join(testDir, "target");
11
+
12
+ const logger = createLogger({ service: "test", debug: false });
13
+
14
+ beforeEach(async () => {
15
+ // Clean up test directory
16
+ await rm(testDir, { recursive: true, force: true });
17
+ await mkdir(testDir, { recursive: true });
18
+ await mkdir(projectRoot, { recursive: true });
19
+ await mkdir(templateDir, { recursive: true });
20
+ await mkdir(targetDir, { recursive: true });
21
+ });
22
+
23
+ describe("copyTemplatesToWorktree", () => {
24
+ test("copies CLAUDE.md with project append", async () => {
25
+ // Setup template and project CLAUDE.md
26
+ await writeFile(join(templateDir, "CLAUDE.md"), "# Template Context\n\nTemplate content.");
27
+ await writeFile(join(projectRoot, "CLAUDE.md"), "# Project Context\n\nProject content.");
28
+
29
+ await copyTemplatesToWorktree({
30
+ targetDir,
31
+ projectRoot,
32
+ templateDir,
33
+ logger,
34
+ });
35
+
36
+ const targetCLAUDE = await Bun.file(join(targetDir, "CLAUDE.md")).text();
37
+ expect(targetCLAUDE).toContain("Template content");
38
+ expect(targetCLAUDE).toContain("# プロジェクト固有コンテキスト");
39
+ expect(targetCLAUDE).toContain("Project content");
40
+ });
41
+
42
+ test("handles missing template CLAUDE.md", async () => {
43
+ // Only project CLAUDE.md
44
+ await writeFile(join(projectRoot, "CLAUDE.md"), "Project only.");
45
+
46
+ await copyTemplatesToWorktree({
47
+ targetDir,
48
+ projectRoot,
49
+ templateDir,
50
+ logger,
51
+ });
52
+
53
+ const targetCLAUDE = await Bun.file(join(targetDir, "CLAUDE.md")).text();
54
+ expect(targetCLAUDE).toContain("# プロジェクト固有コンテキスト");
55
+ expect(targetCLAUDE).toContain("Project only");
56
+ });
57
+
58
+ test("copies .claude/ with overlay merge", async () => {
59
+ // Setup project .claude/rules
60
+ await mkdir(join(projectRoot, ".claude", "rules"), { recursive: true });
61
+ await writeFile(join(projectRoot, ".claude", "rules", "project.md"), "Project rule");
62
+
63
+ // Setup template .claude/rules (should overlay)
64
+ await mkdir(join(templateDir, ".claude", "rules"), { recursive: true });
65
+ await writeFile(join(templateDir, ".claude", "rules", "template.md"), "Template rule");
66
+
67
+ await copyTemplatesToWorktree({
68
+ targetDir,
69
+ projectRoot,
70
+ templateDir,
71
+ logger,
72
+ });
73
+
74
+ // Both should exist
75
+ const projectRule = await Bun.file(join(targetDir, ".claude", "rules", "project.md")).text();
76
+ const templateRule = await Bun.file(join(targetDir, ".claude", "rules", "template.md")).text();
77
+
78
+ expect(projectRule).toBe("Project rule");
79
+ expect(templateRule).toBe("Template rule");
80
+ });
81
+
82
+ test("merges settings.json", async () => {
83
+ // Setup project settings
84
+ await mkdir(join(projectRoot, ".claude"), { recursive: true });
85
+ await writeFile(
86
+ join(projectRoot, ".claude", "settings.json"),
87
+ JSON.stringify({ permissions: { allow: ["read"] } })
88
+ );
89
+
90
+ // Setup template settings (now at root level, not in .claude/)
91
+ await writeFile(
92
+ join(templateDir, "settings.json"),
93
+ JSON.stringify({ permissions: { deny: ["write"] } })
94
+ );
95
+
96
+ await copyTemplatesToWorktree({
97
+ targetDir,
98
+ projectRoot,
99
+ templateDir,
100
+ logger,
101
+ });
102
+
103
+ const targetSettings = JSON.parse(
104
+ await Bun.file(join(targetDir, ".claude", "settings.json")).text()
105
+ );
106
+
107
+ expect(targetSettings.permissions).toBeDefined();
108
+ expect(targetSettings.permissions.allow).toEqual(["read"]);
109
+ expect(targetSettings.permissions.deny).toEqual(["write"]);
110
+ });
111
+
112
+ test("copies .gitignore from template", async () => {
113
+ await writeFile(join(templateDir, ".gitignore"), "node_modules/\n*.log");
114
+
115
+ await copyTemplatesToWorktree({
116
+ targetDir,
117
+ projectRoot,
118
+ templateDir,
119
+ logger,
120
+ });
121
+
122
+ const gitignore = await Bun.file(join(targetDir, ".gitignore")).text();
123
+ expect(gitignore).toContain("node_modules/");
124
+ expect(gitignore).toContain("*.log");
125
+ });
126
+
127
+ test("handles missing template directory gracefully", async () => {
128
+ const nonExistentTemplate = join(testDir, "nonexistent");
129
+
130
+ // Should not throw
131
+ await expect(
132
+ copyTemplatesToWorktree({
133
+ targetDir,
134
+ projectRoot,
135
+ templateDir: nonExistentTemplate,
136
+ logger,
137
+ })
138
+ ).resolves.toBeUndefined();
139
+
140
+ // Target directory should be created even if templates don't exist
141
+ const { stat } = await import("node:fs/promises");
142
+ const stats = await stat(targetDir);
143
+ expect(stats.isDirectory()).toBe(true);
144
+ });
145
+
146
+ test("preserves existing agent-memory", async () => {
147
+ // Setup project agent-memory
148
+ await mkdir(join(projectRoot, ".claude", "agent-memory", "explorer"), { recursive: true });
149
+ await writeFile(
150
+ join(projectRoot, ".claude", "agent-memory", "explorer", "MEMORY.md"),
151
+ "Existing memory"
152
+ );
153
+
154
+ // Setup template agent-memory (should NOT overwrite)
155
+ await mkdir(join(templateDir, ".claude", "agent-memory", "explorer"), { recursive: true });
156
+ await writeFile(
157
+ join(templateDir, ".claude", "agent-memory", "explorer", "MEMORY.md"),
158
+ "Template memory"
159
+ );
160
+
161
+ await copyTemplatesToWorktree({
162
+ targetDir,
163
+ projectRoot,
164
+ templateDir,
165
+ logger,
166
+ });
167
+
168
+ const memory = await Bun.file(
169
+ join(targetDir, ".claude", "agent-memory", "explorer", "MEMORY.md")
170
+ ).text();
171
+ expect(memory).toBe("Existing memory");
172
+ });
173
+ });
174
+
175
+ describe("resolveTemplateDir", () => {
176
+ test("returns local template dir when it exists", () => {
177
+ // This test assumes the actual project has templates/
178
+ const result = resolveTemplateDir(process.cwd());
179
+ expect(result).toBeDefined();
180
+ expect(result).toContain("templates");
181
+ });
182
+
183
+ test("falls back to package templates when local not found", () => {
184
+ const result = resolveTemplateDir("/nonexistent");
185
+ // Should resolve to {packageRoot}/templates, not one level above
186
+ expect(result).toContain("templates");
187
+ expect(result).not.toContain("sandbox/templates");
188
+ });
189
+ });
@@ -100,8 +100,35 @@ describe("cleanupOrphanedFromPreviousRuns", () => {
100
100
 
101
101
  const result = await cleanupOrphanedFromPreviousRuns(worktreeManager, logger);
102
102
 
103
- // First worktree fails, second succeeds
104
- expect(result.removedWorktrees).toBe(1);
103
+ // First worktree fails git remove but succeeds via fallback (force=true)
104
+ // Second worktree succeeds normally
105
+ expect(result.removedWorktrees).toBe(2);
105
106
  expect(result.deletedBranches).toBe(2);
106
107
  });
108
+
109
+ test("preserves parent worktrees (path matches /parent-xxx)", async () => {
110
+ mockGitOps.gitExec = mock(async (args: string[]) => {
111
+ if (args[0] === "worktree" && args[1] === "list") {
112
+ return {
113
+ stdout: [
114
+ "worktree /repo", "HEAD abc", "branch refs/heads/main", "",
115
+ "worktree /repo/.aad/worktrees/task-001", "HEAD def", "branch refs/heads/aad/r/task-001", "",
116
+ "worktree /repo/.aad/worktrees/parent-run-001", "HEAD ghi", "branch refs/heads/feat/run-001/parent", "",
117
+ ].join("\n"),
118
+ stderr: "", exitCode: 0,
119
+ };
120
+ }
121
+ if (args[0] === "branch" && args[1] === "--list") {
122
+ if (args[2] === "aad/*") return { stdout: " aad/r/task-001\n", stderr: "", exitCode: 0 };
123
+ if (args[2] === "feat/*") return { stdout: " feat/run-001/parent\n", stderr: "", exitCode: 0 };
124
+ return { stdout: "", stderr: "", exitCode: 0 };
125
+ }
126
+ return { stdout: "", stderr: "", exitCode: 0 };
127
+ });
128
+
129
+ const result = await cleanupOrphanedFromPreviousRuns(worktreeManager, logger);
130
+
131
+ expect(result.removedWorktrees).toBe(1); // task-001 のみ
132
+ expect(result.preservedBranches).toBe(1); // feat/run-001/parent
133
+ });
107
134
  });
@@ -145,9 +145,24 @@ describe("WorktreeManager", () => {
145
145
  await expect(worktreeManager.removeWorktree(worktreePath)).resolves.toBeUndefined();
146
146
  });
147
147
 
148
- test("attempts fallback cleanup on failure", async () => {
148
+ test("does not attempt fallback cleanup on failure without force", async () => {
149
149
  const worktreePath = "/test/worktrees/task-004";
150
150
 
151
+ mockGitOps.gitExec = mock(async () => {
152
+ throw new Error("git worktree remove failed");
153
+ });
154
+
155
+ await expect(worktreeManager.removeWorktree(worktreePath, false)).rejects.toThrow(
156
+ GitWorkspaceError
157
+ );
158
+
159
+ // Should NOT call fallback rm when force=false
160
+ expect(mockFsOps.rm).not.toHaveBeenCalled();
161
+ });
162
+
163
+ test("attempts fallback cleanup on failure with force", async () => {
164
+ const worktreePath = "/test/worktrees/task-005";
165
+
151
166
  let callCount = 0;
152
167
  mockGitOps.gitExec = mock(async () => {
153
168
  callCount++;
@@ -157,9 +172,8 @@ describe("WorktreeManager", () => {
157
172
  return { stdout: "", stderr: "", exitCode: 0 };
158
173
  });
159
174
 
160
- await expect(worktreeManager.removeWorktree(worktreePath)).rejects.toThrow(
161
- GitWorkspaceError
162
- );
175
+ // Should succeed via fallback
176
+ await expect(worktreeManager.removeWorktree(worktreePath, true)).resolves.toBeUndefined();
163
177
 
164
178
  expect(mockFsOps.rm).toHaveBeenCalledWith(worktreePath, { recursive: true, force: true });
165
179
  expect(mockGitOps.gitExec).toHaveBeenCalledWith(
@@ -167,6 +181,22 @@ describe("WorktreeManager", () => {
167
181
  expect.objectContaining({ cwd: repoRoot })
168
182
  );
169
183
  });
184
+
185
+ test("throws if fallback also fails with force", async () => {
186
+ const worktreePath = "/test/worktrees/task-006";
187
+
188
+ mockGitOps.gitExec = mock(async () => {
189
+ throw new Error("git worktree remove failed");
190
+ });
191
+
192
+ mockFsOps.rm = mock(async () => {
193
+ throw new Error("rm failed");
194
+ });
195
+
196
+ await expect(worktreeManager.removeWorktree(worktreePath, true)).rejects.toThrow(
197
+ GitWorkspaceError
198
+ );
199
+ });
170
200
  });
171
201
 
172
202
  describe("listWorktrees", () => {
@@ -217,6 +247,36 @@ HEAD 789ghi
217
247
 
218
248
  expect(worktrees).toEqual([]);
219
249
  });
250
+
251
+ test("parses worktree list without trailing newline", async () => {
252
+ const porcelainOutput = `worktree /test/repo
253
+ HEAD abc123
254
+ branch refs/heads/main
255
+
256
+ worktree /test/worktrees/task-001
257
+ HEAD def456
258
+ branch refs/heads/task/001-feature`;
259
+
260
+ mockGitOps.gitExec = mock(async () => ({
261
+ stdout: porcelainOutput,
262
+ stderr: "",
263
+ exitCode: 0
264
+ }));
265
+
266
+ const worktrees = await worktreeManager.listWorktrees();
267
+
268
+ expect(worktrees).toHaveLength(2);
269
+ expect(worktrees[0]).toEqual({
270
+ path: "/test/repo",
271
+ branch: "refs/heads/main",
272
+ head: "abc123",
273
+ });
274
+ expect(worktrees[1]).toEqual({
275
+ path: "/test/worktrees/task-001",
276
+ branch: "refs/heads/task/001-feature",
277
+ head: "def456",
278
+ });
279
+ });
220
280
  });
221
281
 
222
282
  describe("verifyWorktree", () => {
@@ -9,6 +9,11 @@ export interface BranchManagerOptions {
9
9
  gitOps?: GitOps;
10
10
  }
11
11
 
12
+ export interface CleanupBranchOptions {
13
+ force?: boolean;
14
+ excludePatterns?: RegExp[];
15
+ }
16
+
12
17
  /**
13
18
  * Manage git branches for parallel task execution
14
19
  */
@@ -136,7 +141,15 @@ export class BranchManager {
136
141
  /**
137
142
  * Cleanup orphaned AAD branches (branches without worktrees)
138
143
  */
139
- async cleanupOrphanBranches(runId?: RunId, force = false): Promise<string[]> {
144
+ async cleanupOrphanBranches(
145
+ runId?: RunId,
146
+ forceOrOptions?: boolean | CleanupBranchOptions,
147
+ ): Promise<string[]> {
148
+ // Support both legacy boolean signature and new options object
149
+ const options: CleanupBranchOptions = typeof forceOrOptions === "boolean"
150
+ ? { force: forceOrOptions, excludePatterns: [/\/parent$/] }
151
+ : { force: forceOrOptions?.force ?? false, excludePatterns: forceOrOptions?.excludePatterns ?? [/\/parent$/] };
152
+
140
153
  // Support aad/*, conventional commit prefixes (feat/*, fix/*, etc.), and legacy aad-* patterns
141
154
  const conventionalPrefixes = ["aad", "feat", "fix", "docs", "style", "refactor", "perf", "test", "chore"];
142
155
  const patterns = runId
@@ -149,7 +162,7 @@ export class BranchManager {
149
162
  "aad-*",
150
163
  ];
151
164
 
152
- this.logger?.info({ patterns, force }, "Cleaning up orphan branches");
165
+ this.logger?.info({ patterns, force: options.force, excludePatterns: options.excludePatterns }, "Cleaning up orphan branches");
153
166
 
154
167
  const deleted: string[] = [];
155
168
 
@@ -157,9 +170,17 @@ export class BranchManager {
157
170
  const branches = await this.listBranches(pattern);
158
171
 
159
172
  for (const branch of branches) {
173
+ // Check if branch matches any exclude pattern
174
+ const excludePatterns = options.excludePatterns ?? [/\/parent$/];
175
+ const isExcluded = excludePatterns.some((pattern) => pattern.test(branch));
176
+ if (isExcluded) {
177
+ this.logger?.info({ branch }, "Preserving parent branch (contains commit history)");
178
+ continue;
179
+ }
180
+
160
181
  try {
161
182
  // Delete branch (force if requested)
162
- await this.deleteBranch(branch, force);
183
+ await this.deleteBranch(branch, options.force);
163
184
  deleted.push(branch);
164
185
  this.logger?.info({ branch }, "Deleted orphan branch");
165
186
  } catch (error) {
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Dependency Installer
3
+ * Installs package dependencies in task worktrees after creation.
4
+ */
5
+
6
+ import type { WorkspaceInfo } from "../../shared/types";
7
+ import type { Logger } from "pino";
8
+
9
+ export interface InstallResult {
10
+ success: boolean;
11
+ output: string;
12
+ duration: number;
13
+ skipped: boolean; // Go/Rust等で明示的にスキップした場合
14
+ }
15
+
16
+ /**
17
+ * パッケージマネージャからインストールコマンドを導出
18
+ * Returns null if no install command is available (unknown or no-op).
19
+ */
20
+ export function buildInstallCommand(workspace: WorkspaceInfo): string[] | null {
21
+ switch (workspace.packageManager) {
22
+ // Node.js 系
23
+ case "bun":
24
+ return ["bun", "install", "--frozen-lockfile"];
25
+ case "npm":
26
+ return ["npm", "ci"];
27
+ case "yarn":
28
+ return ["yarn", "install", "--frozen-lockfile"];
29
+ case "pnpm":
30
+ return ["pnpm", "install", "--frozen-lockfile"];
31
+
32
+ // Python 系
33
+ case "uv":
34
+ return ["uv", "sync"];
35
+ case "poetry":
36
+ return ["poetry", "install", "--no-interaction"];
37
+ case "pipenv":
38
+ return ["pipenv", "install"];
39
+ case "pip":
40
+ return ["pip", "install", "-r", "requirements.txt"];
41
+
42
+ // Go / Rust (テスト実行時に自動ダウンロードするが、先にfetchしておく)
43
+ case "go":
44
+ return ["go", "mod", "download"];
45
+ case "cargo":
46
+ return ["cargo", "fetch"];
47
+
48
+ case "unknown":
49
+ default:
50
+ return null; // インストール不要またはスキップ
51
+ }
52
+ }
53
+
54
+ /**
55
+ * worktreeの依存パッケージをインストール
56
+ * Non-fatal: 失敗してもエラーをthrowせず、InstallResultで報告。
57
+ * Claude CLIエージェントは自分でinstallコマンドを実行できるため、ここでの失敗は致命的ではない。
58
+ */
59
+ export async function installDependencies(
60
+ workspace: WorkspaceInfo,
61
+ logger: Logger,
62
+ timeout?: number
63
+ ): Promise<InstallResult> {
64
+ const startTime = Date.now();
65
+ const command = buildInstallCommand(workspace);
66
+
67
+ if (!command) {
68
+ logger.debug(
69
+ { packageManager: workspace.packageManager },
70
+ "No install command for package manager, skipping"
71
+ );
72
+ return { success: true, output: "", duration: 0, skipped: true };
73
+ }
74
+
75
+ const [cmd, ...args] = command;
76
+ logger.info({ cmd, args, cwd: workspace.path }, "Installing dependencies");
77
+
78
+ // Bun.spawn で実行(default-spawner.ts と同パターン)
79
+ const proc = Bun.spawn([cmd!, ...args], {
80
+ cwd: workspace.path,
81
+ stdout: "pipe",
82
+ stderr: "pipe",
83
+ });
84
+
85
+ const installTimeout = timeout ?? 180000; // デフォルト3分
86
+ const timer = setTimeout(() => proc.kill(), installTimeout);
87
+
88
+ try {
89
+ const [stdout, stderr, exitCode] = await Promise.all([
90
+ new Response(proc.stdout).text(),
91
+ new Response(proc.stderr).text(),
92
+ proc.exited,
93
+ ]);
94
+ clearTimeout(timer);
95
+ const duration = Date.now() - startTime;
96
+
97
+ if (exitCode !== 0) {
98
+ logger.warn(
99
+ { exitCode, stderr: stderr.slice(0, 500) },
100
+ "Dependency install failed"
101
+ );
102
+ return { success: false, output: stderr || stdout, duration, skipped: false };
103
+ }
104
+
105
+ logger.info({ duration }, "Dependencies installed successfully");
106
+ return { success: true, output: stdout, duration, skipped: false };
107
+ } catch (error) {
108
+ clearTimeout(timer);
109
+ const duration = Date.now() - startTime;
110
+ logger.warn({ error }, "Dependency install error");
111
+ return { success: false, output: String(error), duration, skipped: false };
112
+ }
113
+ }
@@ -5,6 +5,7 @@ export interface GitExecOptions {
5
5
  cwd?: string;
6
6
  timeout?: number;
7
7
  logger?: Logger;
8
+ allowNonZeroExit?: boolean;
8
9
  }
9
10
 
10
11
  export interface GitExecResult {
@@ -21,7 +22,7 @@ export async function gitExec(
21
22
  args: string[],
22
23
  options: GitExecOptions = {}
23
24
  ): Promise<GitExecResult> {
24
- const { cwd = process.cwd(), timeout = 30000, logger } = options;
25
+ const { cwd = process.cwd(), timeout = 30000, logger, allowNonZeroExit = false } = options;
25
26
 
26
27
  logger?.debug({ args, cwd }, "Executing git command");
27
28
 
@@ -45,7 +46,7 @@ export async function gitExec(
45
46
 
46
47
  clearTimeout(timeoutId);
47
48
 
48
- if (exitCode !== 0) {
49
+ if (exitCode !== 0 && !allowNonZeroExit) {
49
50
  logger?.error({ args, exitCode, stderr }, "Git command failed");
50
51
  throw new GitWorkspaceError("Git command failed", {
51
52
  args,
@@ -5,7 +5,7 @@ export { WorktreeManager, cleanupOrphanedFromPreviousRuns } from "./worktree-man
5
5
  export type { WorktreeManagerOptions, WorktreeManagerFsOps } from "./worktree-manager";
6
6
 
7
7
  export { BranchManager } from "./branch-manager";
8
- export type { BranchManagerOptions } from "./branch-manager";
8
+ export type { BranchManagerOptions, CleanupBranchOptions } from "./branch-manager";
9
9
 
10
10
  export { MergeService } from "./merge-service";
11
11
  export type { MergeServiceOptions, MergeResult } from "./merge-service";
@@ -15,3 +15,12 @@ export type { ClaudeSettings } from "./settings-merge";
15
15
 
16
16
  export { harvestMemory } from "./memory-sync";
17
17
  export type { MemorySyncOptions, MemorySyncFsOps } from "./memory-sync";
18
+
19
+ export { copyTemplatesToWorktree, resolveTemplateDir } from "./template-copy";
20
+ export type { CopyTemplatesOptions } from "./template-copy";
21
+
22
+ export { PrManager } from "./pr-manager";
23
+ export type { PrManagerOptions } from "./pr-manager";
24
+
25
+ export { installDependencies, buildInstallCommand } from "./dependency-installer";
26
+ export type { InstallResult } from "./dependency-installer";
@@ -14,6 +14,7 @@ export interface MergeResult {
14
14
  success: boolean;
15
15
  conflicts?: string[];
16
16
  message?: string;
17
+ alreadyUpToDate?: boolean;
17
18
  }
18
19
 
19
20
  /**
@@ -68,25 +69,54 @@ export class MergeService {
68
69
  try {
69
70
  await lock.acquire();
70
71
 
71
- this.logger?.info({ taskId, taskBranch, parentBranch }, "Merging task to parent");
72
+ // Check for stale MERGE_HEAD (interrupted previous merge) and abort
73
+ const mergeHeadPath = join(gitDir, "MERGE_HEAD");
74
+ try {
75
+ const mergeHeadExists = await Bun.file(mergeHeadPath).exists();
76
+ if (mergeHeadExists) {
77
+ this.logger?.warn({ taskId, parentWorktree }, "Stale MERGE_HEAD detected, aborting previous merge");
78
+ await gitExec(["merge", "--abort"], { cwd: parentWorktree, logger: this.logger });
79
+ }
80
+ } catch (_cleanupError) {
81
+ // Ignore cleanup errors
82
+ }
72
83
 
73
- // Fetch latest from task branch
74
- await gitExec(
75
- ["fetch", this.repoRoot, taskBranch],
76
- { cwd: parentWorktree, logger: this.logger }
77
- );
84
+ this.logger?.info({ taskId, taskBranch, parentBranch, parentWorktree }, "Merging task to parent");
85
+
86
+ // Before merge: commit any untracked files in parent worktree
87
+ // (shell parity: worker-executor.sh L388-393)
88
+ const status = await gitExec(["status", "--porcelain"], {
89
+ cwd: parentWorktree,
90
+ logger: this.logger,
91
+ });
92
+ if (status.stdout.trim() !== "") {
93
+ this.logger?.info({ parentWorktree }, "Committing untracked files before merge");
94
+ await gitExec(["add", "-A"], { cwd: parentWorktree, logger: this.logger });
95
+ await gitExec(
96
+ ["commit", "--no-gpg-sign", "-m", "chore: add template files before merge"],
97
+ { cwd: parentWorktree, logger: this.logger }
98
+ );
99
+ }
78
100
 
79
- // Try merge
101
+ // Worktrees share the same git object store, so we can merge the task
102
+ // branch directly without fetching. This is more reliable than
103
+ // `git fetch <repoRoot> <branch>` + `git merge FETCH_HEAD`.
80
104
  try {
81
- await gitExec(
82
- ["merge", "--no-ff", "-m", `Merge task ${taskId as string}: ${taskBranch}`, "FETCH_HEAD"],
105
+ const mergeOutput = await gitExec(
106
+ ["merge", "--no-ff", "-m", `Merge task ${taskId as string}: ${taskBranch}`, taskBranch],
83
107
  { cwd: parentWorktree, logger: this.logger }
84
108
  );
85
109
 
86
- this.logger?.info({ taskId, taskBranch }, "Merge succeeded");
110
+ const alreadyUpToDate = mergeOutput.stdout.includes("Already up to date");
111
+ if (alreadyUpToDate) {
112
+ this.logger?.warn({ taskId, taskBranch }, "No new commits on task branch");
113
+ } else {
114
+ this.logger?.info({ taskId, taskBranch }, "Merge succeeded");
115
+ }
87
116
 
88
117
  return {
89
118
  success: true,
119
+ alreadyUpToDate,
90
120
  message: `Successfully merged ${taskBranch} into ${parentBranch}`,
91
121
  };
92
122
  } catch (error) {