whitesmith 0.0.1 → 0.0.2

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 (64) hide show
  1. package/dist/auto-work.d.ts +11 -0
  2. package/dist/auto-work.d.ts.map +1 -0
  3. package/dist/auto-work.js +22 -0
  4. package/dist/auto-work.js.map +1 -0
  5. package/dist/cli.d.ts.map +1 -1
  6. package/dist/cli.js +99 -0
  7. package/dist/cli.js.map +1 -1
  8. package/dist/comment.d.ts +29 -0
  9. package/dist/comment.d.ts.map +1 -0
  10. package/dist/comment.js +390 -0
  11. package/dist/comment.js.map +1 -0
  12. package/dist/git.d.ts +12 -0
  13. package/dist/git.d.ts.map +1 -1
  14. package/dist/git.js +57 -14
  15. package/dist/git.js.map +1 -1
  16. package/dist/harnesses/pi.d.ts +2 -0
  17. package/dist/harnesses/pi.d.ts.map +1 -1
  18. package/dist/harnesses/pi.js +92 -6
  19. package/dist/harnesses/pi.js.map +1 -1
  20. package/dist/install-ci.d.ts +7 -0
  21. package/dist/install-ci.d.ts.map +1 -0
  22. package/dist/install-ci.js +760 -0
  23. package/dist/install-ci.js.map +1 -0
  24. package/dist/orchestrator.d.ts +24 -4
  25. package/dist/orchestrator.d.ts.map +1 -1
  26. package/dist/orchestrator.js +252 -67
  27. package/dist/orchestrator.js.map +1 -1
  28. package/dist/prompts.d.ts.map +1 -1
  29. package/dist/prompts.js +1 -0
  30. package/dist/prompts.js.map +1 -1
  31. package/dist/providers/github-ci.d.ts +16 -0
  32. package/dist/providers/github-ci.d.ts.map +1 -0
  33. package/dist/providers/github-ci.js +733 -0
  34. package/dist/providers/github-ci.js.map +1 -0
  35. package/dist/providers/github.d.ts +21 -0
  36. package/dist/providers/github.d.ts.map +1 -1
  37. package/dist/providers/github.js +88 -3
  38. package/dist/providers/github.js.map +1 -1
  39. package/dist/providers/index.d.ts +1 -0
  40. package/dist/providers/index.d.ts.map +1 -1
  41. package/dist/providers/issue-provider.d.ts +26 -0
  42. package/dist/providers/issue-provider.d.ts.map +1 -1
  43. package/dist/task-manager.d.ts +4 -0
  44. package/dist/task-manager.d.ts.map +1 -1
  45. package/dist/task-manager.js +6 -0
  46. package/dist/task-manager.js.map +1 -1
  47. package/dist/types.d.ts +9 -0
  48. package/dist/types.d.ts.map +1 -1
  49. package/dist/types.js +2 -0
  50. package/dist/types.js.map +1 -1
  51. package/package.json +2 -1
  52. package/src/auto-work.ts +26 -0
  53. package/src/cli.ts +114 -0
  54. package/src/comment.ts +531 -0
  55. package/src/git.ts +58 -12
  56. package/src/harnesses/pi.ts +108 -6
  57. package/src/orchestrator.ts +287 -76
  58. package/src/prompts.ts +1 -0
  59. package/src/providers/github-ci.ts +840 -0
  60. package/src/providers/github.ts +118 -5
  61. package/src/providers/index.ts +1 -0
  62. package/src/providers/issue-provider.ts +25 -1
  63. package/src/task-manager.ts +7 -0
  64. package/src/types.ts +7 -0
@@ -1,8 +1,26 @@
1
1
  import {exec, execSync} from 'node:child_process';
2
2
  import * as fs from 'node:fs';
3
+ import {homedir} from 'node:os';
3
4
  import * as path from 'node:path';
4
5
  import type {AgentHarness, AgentHarnessConfig} from './agent-harness.js';
5
6
 
7
+ /** Subset of pi JSON event fields we care about */
8
+ interface PiEvent {
9
+ type: string;
10
+ toolName?: string;
11
+ args?: any;
12
+ result?: any;
13
+ isError?: boolean;
14
+ assistantMessageEvent?: {type: string; delta?: string};
15
+ reason?: string;
16
+ attempt?: number;
17
+ maxAttempts?: number;
18
+ delayMs?: number;
19
+ errorMessage?: string;
20
+ success?: boolean;
21
+ finalError?: string;
22
+ }
23
+
6
24
  /**
7
25
  * Agent harness for @mariozechner/pi-coding-agent.
8
26
  *
@@ -31,10 +49,30 @@ export class PiHarness implements AgentHarness {
31
49
  );
32
50
  }
33
51
 
52
+ // Check auth.json exists and has the expected provider
53
+ const homeDir = process.env.HOME || homedir();
54
+ const authJsonPath = path.join(homeDir, '.pi', 'agent', 'auth.json');
55
+ if (fs.existsSync(authJsonPath)) {
56
+ try {
57
+ const authData = JSON.parse(fs.readFileSync(authJsonPath, 'utf-8'));
58
+ const providers = Object.keys(authData);
59
+ console.log(`Auth file found at ${authJsonPath} with providers: ${providers.join(', ')}`);
60
+ if (!authData[this.provider]) {
61
+ console.warn(
62
+ `WARNING: Provider '${this.provider}' not found in auth.json (has: ${providers.join(', ')})`,
63
+ );
64
+ }
65
+ } catch (e: any) {
66
+ console.warn(`WARNING: Could not parse auth.json: ${e.message}`);
67
+ }
68
+ } else {
69
+ console.warn(`WARNING: No auth.json found at ${authJsonPath}`);
70
+ }
71
+
34
72
  // Validate auth by making a minimal API call
35
73
  try {
36
74
  const result = execSync(
37
- `${this.cmd} --print --no-tools --provider ${this.provider} --model ${this.model} "respond with OK"`,
75
+ `${this.cmd} --print --no-tools --no-session --provider ${this.provider} --model ${this.model} "respond with OK"`,
38
76
  {stdio: 'pipe', timeout: 30_000},
39
77
  );
40
78
  const output = result.toString().trim();
@@ -43,11 +81,17 @@ export class PiHarness implements AgentHarness {
43
81
  }
44
82
  console.log(`Auth check passed (response: ${output.slice(0, 20)})`);
45
83
  } catch (error: any) {
46
- const stderr = error.stderr?.toString() || error.message || '';
84
+ const stderr = error.stderr?.toString() || '';
85
+ const stdout = error.stdout?.toString() || '';
86
+ const details =
87
+ [stderr, stdout].filter(Boolean).join('\n') || error.message || 'unknown error';
47
88
  throw new Error(
48
89
  `Agent auth validation failed. Ensure valid credentials are configured.\n` +
49
90
  `Set ANTHROPIC_API_KEY or configure OAuth via ~/.pi/agent/auth.json\n` +
50
- `Details: ${stderr.slice(0, 500)}`,
91
+ `Auth file path: ${authJsonPath}\n` +
92
+ `Auth file exists: ${fs.existsSync(authJsonPath)}\n` +
93
+ `HOME: ${homeDir}\n` +
94
+ `Details: ${details.slice(0, 800)}`,
51
95
  );
52
96
  }
53
97
  }
@@ -57,13 +101,13 @@ export class PiHarness implements AgentHarness {
57
101
  workDir: string;
58
102
  logFile?: string;
59
103
  }): Promise<{output: string; exitCode: number}> {
60
- // Write prompt to a temp file to avoid shell escaping issues
104
+ // Write prompt to a temp file and use @file syntax so pi reads contents
61
105
  const promptFile = path.join(options.workDir, '.whitesmith-prompt.md');
62
106
  fs.writeFileSync(promptFile, options.prompt, 'utf-8');
63
107
 
64
108
  try {
65
109
  const result = await this.exec(
66
- `${this.cmd} --prompt-file "${promptFile}" --yes --provider ${this.provider} --model ${this.model}`,
110
+ `${this.cmd} --print --mode json --no-session --provider ${this.provider} --model ${this.model} @"${promptFile}"`,
67
111
  options.workDir,
68
112
  options.logFile,
69
113
  );
@@ -78,6 +122,43 @@ export class PiHarness implements AgentHarness {
78
122
  }
79
123
  }
80
124
 
125
+ /** Format a pi JSON event as a human-readable log line (null = skip) */
126
+ private formatEvent(event: PiEvent): string | null {
127
+ switch (event.type) {
128
+ case 'agent_start':
129
+ return '\n🤖 Agent started';
130
+ case 'agent_end':
131
+ return '\n🏁 Agent finished';
132
+ case 'turn_start':
133
+ return '\n--- turn ---';
134
+ case 'message_update': {
135
+ const evt = event.assistantMessageEvent;
136
+ if (evt?.type === 'text_delta' && evt.delta) return evt.delta;
137
+ if (evt?.type === 'thinking_delta' && evt.delta) return evt.delta;
138
+ return null;
139
+ }
140
+ case 'tool_execution_start': {
141
+ const argsStr = JSON.stringify(event.args);
142
+ const truncArgs = argsStr.length > 200 ? argsStr.slice(0, 200) + '…' : argsStr;
143
+ return `\n🔧 ${event.toolName}(${truncArgs})`;
144
+ }
145
+ case 'tool_execution_end': {
146
+ const icon = event.isError ? '❌' : '✅';
147
+ const res = typeof event.result === 'string' ? event.result : JSON.stringify(event.result);
148
+ const truncRes = res.length > 500 ? res.slice(0, 500) + '…' : res;
149
+ return `${icon} ${event.toolName} → ${truncRes}`;
150
+ }
151
+ case 'compaction_start':
152
+ return `\n📦 Compaction (${event.reason})`;
153
+ case 'auto_retry_start':
154
+ return `\n🔄 Retry ${event.attempt}/${event.maxAttempts}: ${event.errorMessage}`;
155
+ case 'auto_retry_end':
156
+ return event.success ? '🔄 Retry succeeded' : `🔄 Retry failed: ${event.finalError}`;
157
+ default:
158
+ return null;
159
+ }
160
+ }
161
+
81
162
  private exec(
82
163
  cmd: string,
83
164
  workDir: string,
@@ -90,15 +171,35 @@ export class PiHarness implements AgentHarness {
90
171
  timeout: 30 * 60 * 1000, // 30 minute timeout
91
172
  });
92
173
 
174
+ // Close stdin immediately so pi doesn't hang waiting for piped input
175
+ child.stdin?.end();
176
+
93
177
  let output = '';
178
+ let lineBuffer = '';
94
179
  const logStream = logFile
95
180
  ? fs.createWriteStream(path.resolve(workDir, logFile), {flags: 'a'})
96
181
  : null;
97
182
 
183
+ const processLine = (line: string) => {
184
+ if (!line.trim()) return;
185
+ try {
186
+ const event: PiEvent = JSON.parse(line);
187
+ const formatted = this.formatEvent(event);
188
+ if (formatted !== null) {
189
+ process.stdout.write(formatted);
190
+ }
191
+ } catch {
192
+ process.stdout.write(line + '\n');
193
+ }
194
+ };
195
+
98
196
  child.stdout?.on('data', (data: string) => {
99
197
  output += data;
100
- process.stdout.write(data);
101
198
  logStream?.write(data);
199
+ lineBuffer += data;
200
+ const lines = lineBuffer.split('\n');
201
+ lineBuffer = lines.pop() ?? '';
202
+ for (const line of lines) processLine(line);
102
203
  });
103
204
 
104
205
  child.stderr?.on('data', (data: string) => {
@@ -108,6 +209,7 @@ export class PiHarness implements AgentHarness {
108
209
  });
109
210
 
110
211
  child.on('close', (code) => {
212
+ if (lineBuffer.trim()) processLine(lineBuffer);
111
213
  logStream?.end();
112
214
  resolve({output, exitCode: code ?? 1});
113
215
  });
@@ -5,6 +5,7 @@ import type {AgentHarness} from './harnesses/agent-harness.js';
5
5
  import {TaskManager} from './task-manager.js';
6
6
  import {GitManager} from './git.js';
7
7
  import {buildInvestigatePrompt, buildImplementPrompt} from './prompts.js';
8
+ import {isAutoWorkEnabled} from './auto-work.js';
8
9
 
9
10
  /**
10
11
  * Main orchestrator for whitesmith.
@@ -12,7 +13,11 @@ import {buildInvestigatePrompt, buildImplementPrompt} from './prompts.js';
12
13
  * The loop:
13
14
  * 1. Reconcile — check if any issues with tasks-accepted have all tasks done
14
15
  * 2. Investigate — pick an unlabeled issue, generate tasks
15
- * 3. Implement — pick an available task, implement it
16
+ * 3. Implement — pick an available task, implement it on the issue/<number> branch
17
+ *
18
+ * Implementation uses a single branch per issue (`issue/<number>`). Each task
19
+ * adds one commit to the branch. When the last task completes, a PR is created
20
+ * immediately. `reconcile()` is a safety net for crash recovery.
16
21
  */
17
22
  export class Orchestrator {
18
23
  private config: DevPulseConfig;
@@ -41,13 +46,16 @@ export class Orchestrator {
41
46
  console.log(`Model: ${this.config.model}`);
42
47
  console.log('');
43
48
 
44
- // Validate agent is available before doing anything
45
- await this.agent.validate();
46
- console.log('Agent validated successfully.');
47
- console.log('');
49
+ // Skip agent validation and label creation in dry-run mode
50
+ if (!this.config.dryRun) {
51
+ // Validate agent is available before doing anything
52
+ await this.agent.validate();
53
+ console.log('Agent validated successfully.');
54
+ console.log('');
48
55
 
49
- // Ensure labels exist
50
- await this.issues.ensureLabels(Object.values(LABELS));
56
+ // Ensure labels exist
57
+ await this.issues.ensureLabels(Object.values(LABELS));
58
+ }
51
59
 
52
60
  for (let i = 1; i <= this.config.maxIterations; i++) {
53
61
  console.log('');
@@ -61,10 +69,38 @@ export class Orchestrator {
61
69
  const action = await this.decideAction();
62
70
  console.log(`Action: ${action.type}`);
63
71
 
72
+ if (this.config.dryRun) {
73
+ switch (action.type) {
74
+ case 'reconcile':
75
+ console.log(`Would reconcile issue #${action.issue.number}: ${action.issue.title}`);
76
+ break;
77
+ case 'auto-approve':
78
+ console.log(
79
+ `Would auto-approve task PR for issue #${action.issue.number}: ${action.issue.title}`,
80
+ );
81
+ break;
82
+ case 'investigate':
83
+ console.log(`Would investigate issue #${action.issue.number}: ${action.issue.title}`);
84
+ break;
85
+ case 'implement':
86
+ console.log(
87
+ `Would implement task ${action.task.id}: ${action.task.title} (issue #${action.issue.number})`,
88
+ );
89
+ break;
90
+ case 'idle':
91
+ console.log('Nothing to do. All issues are either in-progress or completed.');
92
+ break;
93
+ }
94
+ return;
95
+ }
96
+
64
97
  switch (action.type) {
65
98
  case 'reconcile':
66
99
  await this.reconcile(action.issue);
67
100
  break;
101
+ case 'auto-approve':
102
+ await this.autoApprove(action.issue);
103
+ break;
68
104
  case 'investigate':
69
105
  await this.investigate(action.issue);
70
106
  break;
@@ -86,6 +122,20 @@ export class Orchestrator {
86
122
  console.log('=== Iteration limit reached ===');
87
123
  }
88
124
 
125
+ /**
126
+ * Check whether all tasks for an issue have been completed on the issue branch.
127
+ * Works without checking out the branch by inspecting the remote via git ls-tree.
128
+ */
129
+ private async allTasksCompletedOnBranch(issueNumber: number): Promise<boolean> {
130
+ const branch = `issue/${issueNumber}`;
131
+ const branchExists = await this.issues.remoteBranchExists(branch);
132
+ if (!branchExists) return false;
133
+
134
+ // Check if any task files remain on the issue branch
135
+ const hasFiles = await this.git.remotePathHasFiles(`origin/${branch}`, `tasks/${issueNumber}/`);
136
+ return !hasFiles;
137
+ }
138
+
89
139
  /**
90
140
  * Decide the next action to take
91
141
  */
@@ -93,18 +143,27 @@ export class Orchestrator {
93
143
  // Priority 1: Reconcile — issues with tasks-accepted where all tasks are done
94
144
  const acceptedIssues = await this.issues.listIssues({labels: [LABELS.TASKS_ACCEPTED]});
95
145
  for (const issue of acceptedIssues) {
96
- if (!this.tasks.hasRemainingTasks(issue.number)) {
146
+ const allDone = await this.allTasksCompletedOnBranch(issue.number);
147
+ if (allDone) {
97
148
  return {type: 'reconcile', issue};
98
149
  }
99
150
  }
100
151
 
101
- // Priority 2: Implementfind an available task
152
+ // Priority 2: Auto-approvemerge task PRs for issues with auto-work enabled
153
+ const proposedIssues = await this.issues.listIssues({labels: [LABELS.TASKS_PROPOSED]});
154
+ for (const issue of proposedIssues) {
155
+ if (isAutoWorkEnabled(this.config, issue)) {
156
+ return {type: 'auto-approve' as const, issue};
157
+ }
158
+ }
159
+
160
+ // Priority 3: Implement — find an available task
102
161
  const implementAction = await this.findAvailableTask(acceptedIssues);
103
162
  if (implementAction) {
104
163
  return implementAction;
105
164
  }
106
165
 
107
- // Priority 3: Investigate — find a new issue (no whitesmith labels)
166
+ // Priority 4: Investigate — find a new issue (no whitesmith labels)
108
167
  const allDevPulseLabels = Object.values(LABELS);
109
168
  const newIssues = await this.issues.listIssues({noLabels: allDevPulseLabels});
110
169
  if (newIssues.length > 0) {
@@ -117,26 +176,47 @@ export class Orchestrator {
117
176
  }
118
177
 
119
178
  /**
120
- * Find an available task to implement
179
+ * Find an available task to implement.
180
+ *
181
+ * Uses task files on `main` as the canonical list of pending tasks.
182
+ * If an issue branch exists, checks which task files have been deleted
183
+ * on it (= completed) and skips those.
121
184
  */
122
185
  private async findAvailableTask(
123
186
  acceptedIssues: Issue[],
124
187
  ): Promise<{type: 'implement'; task: Task; issue: Issue} | null> {
125
188
  for (const issue of acceptedIssues) {
126
189
  const issueTasks = this.tasks.listTasks(issue.number);
190
+ if (issueTasks.length === 0) continue;
191
+
192
+ // Determine which tasks are already completed on the issue branch
193
+ const branch = `issue/${issue.number}`;
194
+ const branchExists = await this.issues.remoteBranchExists(branch);
195
+ const completedTaskFiles = new Set<string>();
196
+
197
+ if (branchExists) {
198
+ // Check each task file's existence on the remote issue branch
199
+ for (const task of issueTasks) {
200
+ const existsOnBranch = await this.git.remoteFileExists(`origin/${branch}`, task.filePath);
201
+ if (!existsOnBranch) {
202
+ // Task file deleted on issue branch = completed
203
+ completedTaskFiles.add(task.filePath);
204
+ }
205
+ }
206
+ }
127
207
 
128
208
  for (const task of issueTasks) {
129
- // Check dependencies are satisfied
130
- if (!this.tasks.areDependenciesSatisfied(task)) {
131
- continue;
132
- }
209
+ // Skip completed tasks
210
+ if (completedTaskFiles.has(task.filePath)) continue;
133
211
 
134
- // Check if someone is already working on it (branch exists)
135
- const branch = `task/${task.id}`;
136
- const branchExists = await this.issues.remoteBranchExists(branch);
137
- if (branchExists) {
138
- continue;
139
- }
212
+ // Check dependencies are satisfied
213
+ // A dependency is satisfied if its task file is gone from main OR completed on the issue branch
214
+ const depsOk = task.dependsOn.every((depId) => {
215
+ const depTask = issueTasks.find((t) => t.id === depId);
216
+ if (!depTask) return true; // dep not in pending list on main = already merged
217
+ return completedTaskFiles.has(depTask.filePath);
218
+ });
219
+ if (!depsOk) continue;
140
220
 
141
221
  return {type: 'implement', task, issue};
142
222
  }
@@ -146,12 +226,36 @@ export class Orchestrator {
146
226
  }
147
227
 
148
228
  /**
149
- * Phase 1: Reconcile — mark issue as completed, close it
229
+ * Phase 1: Reconcile — mark issue as completed, close it.
230
+ * Also serves as safety net: creates PR if all tasks are done but no PR exists
231
+ * (e.g. agent crashed after last task push but before PR creation).
150
232
  */
151
233
  private async reconcile(issue: Issue): Promise<void> {
152
234
  console.log(`Reconciling issue #${issue.number}: ${issue.title}`);
153
235
  console.log('All tasks completed. Marking issue as done.');
154
236
 
237
+ // Safety net: ensure a PR exists for the issue branch
238
+ const branch = `issue/${issue.number}`;
239
+ const branchExists = await this.issues.remoteBranchExists(branch);
240
+ if (branchExists && !this.config.noPush) {
241
+ const existingPR = await this.issues.getPRForBranch(branch);
242
+ if (!existingPR) {
243
+ console.log(`Safety net: creating PR for ${branch} (missed during implement)`);
244
+ const issueTasks = this.tasks.listTasks(issue.number);
245
+ const taskSummary =
246
+ issueTasks.length > 0
247
+ ? issueTasks.map((t) => `- ✅ **${t.id}**: ${t.title}`).join('\n')
248
+ : '- All tasks completed';
249
+ const prUrl = await this.issues.createPR({
250
+ head: branch,
251
+ base: 'main',
252
+ title: `feat(#${issue.number}): ${issue.title}`,
253
+ body: `## Implementation for #${issue.number}\n\n${taskSummary}\n\n---\n*Implemented by whitesmith*\n\nCloses #${issue.number}`,
254
+ });
255
+ console.log(`Safety net PR created: ${prUrl}`);
256
+ }
257
+ }
258
+
155
259
  await this.issues.addLabel(issue.number, LABELS.COMPLETED);
156
260
  await this.issues.removeLabel(issue.number, LABELS.TASKS_ACCEPTED);
157
261
  await this.issues.comment(
@@ -163,6 +267,33 @@ export class Orchestrator {
163
267
  console.log(`Issue #${issue.number} closed.`);
164
268
  }
165
269
 
270
+ /**
271
+ * Phase 1.5: Auto-approve — merge the task-proposal PR when auto-work is enabled
272
+ */
273
+ private async autoApprove(issue: Issue): Promise<void> {
274
+ console.log(`Auto-approving task PR for issue #${issue.number}: ${issue.title}`);
275
+
276
+ const branch = `investigate/${issue.number}`;
277
+ const pr = await this.issues.getPRForBranch(branch);
278
+
279
+ if (!pr || pr.state !== 'open') {
280
+ console.log(`No open PR found for branch '${branch}', skipping auto-approve`);
281
+ return;
282
+ }
283
+
284
+ await this.issues.mergePR(pr.number);
285
+ console.log(`Merged PR #${pr.number}: ${pr.url}`);
286
+
287
+ await this.issues.removeLabel(issue.number, LABELS.TASKS_PROPOSED);
288
+ await this.issues.addLabel(issue.number, LABELS.TASKS_ACCEPTED);
289
+ await this.issues.comment(
290
+ issue.number,
291
+ `🤖 Task PR #${pr.number} has been auto-approved and merged. Tasks are now on \`main\`.`,
292
+ );
293
+
294
+ console.log(`Issue #${issue.number} transitioned to tasks-accepted.`);
295
+ }
296
+
166
297
  /**
167
298
  * Phase 2: Investigate — generate tasks for a new issue
168
299
  */
@@ -176,23 +307,47 @@ export class Orchestrator {
176
307
  const issueTasksDir = `tasks/${issue.number}`;
177
308
 
178
309
  try {
179
- // Create branch from main
310
+ // Check if a previous attempt already produced work on this branch
311
+ const remoteBranchExists = await this.issues.remoteBranchExists(branch);
312
+ let agentNeeded = true;
313
+
180
314
  await this.git.deleteLocalBranch(branch);
181
- await this.git.checkout(branch, {create: true, startPoint: 'origin/main'});
182
-
183
- // Run agent to generate tasks
184
- const prompt = buildInvestigatePrompt(issue, issueTasksDir);
185
- const {exitCode} = await this.agent.run({
186
- prompt,
187
- workDir: this.config.workDir,
188
- logFile: this.config.logFile,
189
- });
190
-
191
- if (exitCode !== 0) {
192
- console.error(`Agent failed with exit code ${exitCode}`);
193
- await this.issues.removeLabel(issue.number, LABELS.INVESTIGATING);
194
- await this.git.checkoutMain();
195
- return;
315
+
316
+ if (remoteBranchExists) {
317
+ // Checkout the existing remote branch to inspect it
318
+ await this.git.checkout(branch, {create: true, startPoint: `origin/${branch}`});
319
+ const existingTasks = this.tasks.listTasks(issue.number);
320
+ if (existingTasks.length > 0) {
321
+ // Previous attempt completed the work — skip the agent
322
+ console.log(
323
+ `Branch '${branch}' already exists with ${existingTasks.length} task(s), skipping agent`,
324
+ );
325
+ agentNeeded = false;
326
+ } else {
327
+ // Branch exists but no task files — start fresh
328
+ console.log(`Branch '${branch}' exists but has no tasks, starting fresh`);
329
+ await this.git.deleteLocalBranch(branch);
330
+ await this.git.checkout(branch, {create: true, startPoint: 'origin/main'});
331
+ }
332
+ } else {
333
+ await this.git.checkout(branch, {create: true, startPoint: 'origin/main'});
334
+ }
335
+
336
+ if (agentNeeded) {
337
+ // Run agent to generate tasks
338
+ const prompt = buildInvestigatePrompt(issue, issueTasksDir);
339
+ const {exitCode} = await this.agent.run({
340
+ prompt,
341
+ workDir: this.config.workDir,
342
+ logFile: this.config.logFile,
343
+ });
344
+
345
+ if (exitCode !== 0) {
346
+ console.error(`Agent failed with exit code ${exitCode}`);
347
+ await this.issues.removeLabel(issue.number, LABELS.INVESTIGATING);
348
+ await this.git.checkoutMain();
349
+ return;
350
+ }
196
351
  }
197
352
 
198
353
  // Verify task files were created
@@ -213,16 +368,25 @@ export class Orchestrator {
213
368
  if (this.config.noPush) {
214
369
  console.log(`Branch '${branch}' ready (--no-push mode)`);
215
370
  } else {
216
- // Push and create PR
217
- await this.git.push(branch);
218
-
219
- const taskList = tasks.map((t) => `- [ ] **${t.id}**: ${t.title}`).join('\n');
220
- const prUrl = await this.issues.createPR({
221
- head: branch,
222
- base: 'main',
223
- title: `tasks(#${issue.number}): ${issue.title}`,
224
- body: `## Generated Tasks for #${issue.number}\n\n${taskList}\n\n---\n*Generated by whitesmith from issue #${issue.number}*`,
225
- });
371
+ // Force push since the branch may exist from a previous failed attempt
372
+ await this.git.forcePush(branch);
373
+
374
+ // Check if a PR already exists for this branch
375
+ const existingPR = await this.issues.getPRForBranch(branch);
376
+ let prUrl: string;
377
+
378
+ if (existingPR && existingPR.state === 'open') {
379
+ prUrl = existingPR.url;
380
+ console.log(`PR already exists: ${prUrl}`);
381
+ } else {
382
+ const taskList = tasks.map((t) => `- [ ] **${t.id}**: ${t.title}`).join('\n');
383
+ prUrl = await this.issues.createPR({
384
+ head: branch,
385
+ base: 'main',
386
+ title: `tasks(#${issue.number}): ${issue.title}`,
387
+ body: `## Generated Tasks for #${issue.number}\n\n${taskList}\n\n---\n*Generated by whitesmith from issue #${issue.number}*`,
388
+ });
389
+ }
226
390
 
227
391
  await this.issues.removeLabel(issue.number, LABELS.INVESTIGATING);
228
392
  await this.issues.addLabel(issue.number, LABELS.TASKS_PROPOSED);
@@ -243,31 +407,60 @@ export class Orchestrator {
243
407
  }
244
408
 
245
409
  /**
246
- * Phase 3: Implement — implement a task and create a PR
410
+ * Phase 3: Implement — implement a task on the issue/<number> branch.
411
+ * Each task adds one commit. When all tasks are done, a PR is created immediately.
247
412
  */
248
413
  private async implement(task: Task, issue: Issue): Promise<void> {
249
414
  console.log(`Implementing task ${task.id}: ${task.title}`);
250
415
  console.log(`For issue #${issue.number}: ${issue.title}`);
251
416
 
252
- const branch = `task/${task.id}`;
417
+ const branch = `issue/${issue.number}`;
253
418
 
254
419
  try {
255
- // Create branch from main
420
+ // Check if the issue branch already exists (previous tasks may have committed to it)
421
+ const remoteBranchExists = await this.issues.remoteBranchExists(branch);
422
+ let agentNeeded = true;
423
+
256
424
  await this.git.deleteLocalBranch(branch);
257
- await this.git.checkout(branch, {create: true, startPoint: 'origin/main'});
258
-
259
- // Run agent to implement
260
- const prompt = buildImplementPrompt(task, issue);
261
- const {exitCode} = await this.agent.run({
262
- prompt,
263
- workDir: this.config.workDir,
264
- logFile: this.config.logFile,
265
- });
266
-
267
- if (exitCode !== 0) {
268
- console.error(`Agent failed with exit code ${exitCode}`);
269
- await this.git.checkoutMain();
270
- return;
425
+
426
+ if (remoteBranchExists) {
427
+ // Continue from existing issue branch (accumulate commits)
428
+ await this.git.checkout(branch, {create: true, startPoint: `origin/${branch}`});
429
+
430
+ // Check if this specific task was already completed on the branch
431
+ const taskFileExists = this.tasks.taskFileExists(task.filePath);
432
+ if (!taskFileExists) {
433
+ console.log(
434
+ `Task file '${task.filePath}' already deleted on branch '${branch}', skipping agent`,
435
+ );
436
+ agentNeeded = false;
437
+ }
438
+ } else {
439
+ await this.git.checkout(branch, {create: true, startPoint: 'origin/main'});
440
+ }
441
+
442
+ if (agentNeeded) {
443
+ const prompt = buildImplementPrompt(task, issue);
444
+ const {exitCode} = await this.agent.run({
445
+ prompt,
446
+ workDir: this.config.workDir,
447
+ logFile: this.config.logFile,
448
+ });
449
+
450
+ if (exitCode !== 0) {
451
+ console.error(`Agent failed with exit code ${exitCode}`);
452
+ await this.git.checkoutMain();
453
+ return;
454
+ }
455
+
456
+ // Verify the agent actually deleted the task file
457
+ if (this.tasks.taskFileExists(task.filePath)) {
458
+ console.error(
459
+ `Agent exited successfully but did not delete task file '${task.filePath}'. Treating as incomplete.`,
460
+ );
461
+ await this.git.checkoutMain();
462
+ return;
463
+ }
271
464
  }
272
465
 
273
466
  // Verify we're still on the right branch
@@ -279,17 +472,35 @@ export class Orchestrator {
279
472
  if (this.config.noPush) {
280
473
  console.log(`Branch '${branch}' ready (--no-push mode)`);
281
474
  } else {
282
- // Push and create PR
283
- await this.git.push(branch);
284
-
285
- const prUrl = await this.issues.createPR({
286
- head: branch,
287
- base: 'main',
288
- title: `feat(#${issue.number}): ${task.title}`,
289
- body: `## Task: ${task.title}\n\nImplements task \`${task.id}\` from issue #${issue.number}.\n\n### From the task spec:\n\n${task.content}\n\n---\n*Implemented by whitesmith*\n\nCloses #${issue.number} (if all tasks are complete)`,
290
- });
291
-
292
- console.log(`PR created: ${prUrl}`);
475
+ // Force push since the branch may exist from a previous failed attempt
476
+ await this.git.forcePush(branch);
477
+
478
+ // Check if all tasks for this issue are now complete
479
+ // (task files deleted on the current working tree = issue branch)
480
+ const remainingTasks = this.tasks.listTasks(issue.number);
481
+ if (remainingTasks.length === 0) {
482
+ // All tasks done create PR immediately
483
+ const existingPR = await this.issues.getPRForBranch(branch);
484
+ let prUrl: string;
485
+
486
+ if (existingPR && existingPR.state === 'open') {
487
+ prUrl = existingPR.url;
488
+ console.log(`PR already exists: ${prUrl}`);
489
+ } else {
490
+ prUrl = await this.issues.createPR({
491
+ head: branch,
492
+ base: 'main',
493
+ title: `feat(#${issue.number}): ${issue.title}`,
494
+ body: `## Implementation for #${issue.number}\n\nAll tasks completed.\n\n---\n*Implemented by whitesmith*\n\nCloses #${issue.number}`,
495
+ });
496
+ }
497
+
498
+ console.log(`PR created: ${prUrl}`);
499
+ } else {
500
+ console.log(
501
+ `Task ${task.id} committed. ${remainingTasks.length} task(s) remaining for issue #${issue.number}.`,
502
+ );
503
+ }
293
504
  }
294
505
  } catch (error) {
295
506
  console.error('Implementation failed:', error instanceof Error ? error.message : error);
package/src/prompts.ts CHANGED
@@ -110,5 +110,6 @@ git commit -m "feat(#${issue.number}): ${task.title}"
110
110
  - Do NOT modify other task files.
111
111
  - You MUST delete \`${task.filePath}\` as part of your commit.
112
112
  - If the \`tasks/${task.issue}/\` directory is empty after deletion, remove it.
113
+ - **Always use tool calls to make changes.** Never just describe what you plan to do — actually do it. If you produce a response with no tool calls, the session ends immediately and your work is lost.
113
114
  `;
114
115
  }