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.
- package/dist/auto-work.d.ts +11 -0
- package/dist/auto-work.d.ts.map +1 -0
- package/dist/auto-work.js +22 -0
- package/dist/auto-work.js.map +1 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +99 -0
- package/dist/cli.js.map +1 -1
- package/dist/comment.d.ts +29 -0
- package/dist/comment.d.ts.map +1 -0
- package/dist/comment.js +390 -0
- package/dist/comment.js.map +1 -0
- package/dist/git.d.ts +12 -0
- package/dist/git.d.ts.map +1 -1
- package/dist/git.js +57 -14
- package/dist/git.js.map +1 -1
- package/dist/harnesses/pi.d.ts +2 -0
- package/dist/harnesses/pi.d.ts.map +1 -1
- package/dist/harnesses/pi.js +92 -6
- package/dist/harnesses/pi.js.map +1 -1
- package/dist/install-ci.d.ts +7 -0
- package/dist/install-ci.d.ts.map +1 -0
- package/dist/install-ci.js +760 -0
- package/dist/install-ci.js.map +1 -0
- package/dist/orchestrator.d.ts +24 -4
- package/dist/orchestrator.d.ts.map +1 -1
- package/dist/orchestrator.js +252 -67
- package/dist/orchestrator.js.map +1 -1
- package/dist/prompts.d.ts.map +1 -1
- package/dist/prompts.js +1 -0
- package/dist/prompts.js.map +1 -1
- package/dist/providers/github-ci.d.ts +16 -0
- package/dist/providers/github-ci.d.ts.map +1 -0
- package/dist/providers/github-ci.js +733 -0
- package/dist/providers/github-ci.js.map +1 -0
- package/dist/providers/github.d.ts +21 -0
- package/dist/providers/github.d.ts.map +1 -1
- package/dist/providers/github.js +88 -3
- package/dist/providers/github.js.map +1 -1
- package/dist/providers/index.d.ts +1 -0
- package/dist/providers/index.d.ts.map +1 -1
- package/dist/providers/issue-provider.d.ts +26 -0
- package/dist/providers/issue-provider.d.ts.map +1 -1
- package/dist/task-manager.d.ts +4 -0
- package/dist/task-manager.d.ts.map +1 -1
- package/dist/task-manager.js +6 -0
- package/dist/task-manager.js.map +1 -1
- package/dist/types.d.ts +9 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -1
- package/package.json +2 -1
- package/src/auto-work.ts +26 -0
- package/src/cli.ts +114 -0
- package/src/comment.ts +531 -0
- package/src/git.ts +58 -12
- package/src/harnesses/pi.ts +108 -6
- package/src/orchestrator.ts +287 -76
- package/src/prompts.ts +1 -0
- package/src/providers/github-ci.ts +840 -0
- package/src/providers/github.ts +118 -5
- package/src/providers/index.ts +1 -0
- package/src/providers/issue-provider.ts +25 -1
- package/src/task-manager.ts +7 -0
- package/src/types.ts +7 -0
package/src/harnesses/pi.ts
CHANGED
|
@@ -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() ||
|
|
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
|
-
`
|
|
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
|
|
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} --
|
|
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
|
});
|
package/src/orchestrator.ts
CHANGED
|
@@ -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
|
-
//
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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:
|
|
152
|
+
// Priority 2: Auto-approve — merge 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
|
|
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
|
-
//
|
|
130
|
-
if (
|
|
131
|
-
continue;
|
|
132
|
-
}
|
|
209
|
+
// Skip completed tasks
|
|
210
|
+
if (completedTaskFiles.has(task.filePath)) continue;
|
|
133
211
|
|
|
134
|
-
// Check
|
|
135
|
-
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
//
|
|
217
|
-
await this.git.
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
const
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
|
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 = `
|
|
417
|
+
const branch = `issue/${issue.number}`;
|
|
253
418
|
|
|
254
419
|
try {
|
|
255
|
-
//
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
-
//
|
|
283
|
-
await this.git.
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
}
|