whitesmith 0.0.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.
- package/dist/__tests__/task-manager.test.d.ts +2 -0
- package/dist/__tests__/task-manager.test.d.ts.map +1 -0
- package/dist/__tests__/task-manager.test.js +95 -0
- package/dist/__tests__/task-manager.test.js.map +1 -0
- package/dist/cli.d.ts +5 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +147 -0
- package/dist/cli.js.map +1 -0
- package/dist/git.d.ts +60 -0
- package/dist/git.d.ts.map +1 -0
- package/dist/git.js +138 -0
- package/dist/git.js.map +1 -0
- package/dist/harnesses/agent-harness.d.ts +19 -0
- package/dist/harnesses/agent-harness.d.ts.map +1 -0
- package/dist/harnesses/agent-harness.js +2 -0
- package/dist/harnesses/agent-harness.js.map +1 -0
- package/dist/harnesses/index.d.ts +3 -0
- package/dist/harnesses/index.d.ts.map +1 -0
- package/dist/harnesses/index.js +2 -0
- package/dist/harnesses/index.js.map +1 -0
- package/dist/harnesses/pi.d.ts +23 -0
- package/dist/harnesses/pi.d.ts.map +1 -0
- package/dist/harnesses/pi.js +63 -0
- package/dist/harnesses/pi.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/orchestrator.d.ts +44 -0
- package/dist/orchestrator.d.ts.map +1 -0
- package/dist/orchestrator.js +241 -0
- package/dist/orchestrator.js.map +1 -0
- package/dist/prompts.d.ts +12 -0
- package/dist/prompts.d.ts.map +1 -0
- package/dist/prompts.js +112 -0
- package/dist/prompts.js.map +1 -0
- package/dist/providers/github.d.ts +34 -0
- package/dist/providers/github.d.ts.map +1 -0
- package/dist/providers/github.js +135 -0
- package/dist/providers/github.js.map +1 -0
- package/dist/providers/index.d.ts +3 -0
- package/dist/providers/index.d.ts.map +1 -0
- package/dist/providers/index.js +2 -0
- package/dist/providers/index.js.map +1 -0
- package/dist/providers/issue-provider.d.ts +59 -0
- package/dist/providers/issue-provider.d.ts.map +1 -0
- package/dist/providers/issue-provider.js +2 -0
- package/dist/providers/issue-provider.js.map +1 -0
- package/dist/task-manager.d.ts +57 -0
- package/dist/task-manager.d.ts.map +1 -0
- package/dist/task-manager.js +158 -0
- package/dist/task-manager.js.map +1 -0
- package/dist/types.d.ts +92 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +14 -0
- package/dist/types.js.map +1 -0
- package/package.json +46 -0
- package/src/cli.ts +172 -0
- package/src/git.ts +148 -0
- package/src/harnesses/agent-harness.ts +15 -0
- package/src/harnesses/index.ts +2 -0
- package/src/harnesses/pi.ts +82 -0
- package/src/index.ts +13 -0
- package/src/orchestrator.ts +294 -0
- package/src/prompts.ts +114 -0
- package/src/providers/github.ts +180 -0
- package/src/providers/index.ts +2 -0
- package/src/providers/issue-provider.ts +59 -0
- package/src/task-manager.ts +190 -0
- package/src/types.ts +88 -0
package/src/git.ts
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import {exec} from 'node:child_process';
|
|
2
|
+
import {promisify} from 'node:util';
|
|
3
|
+
|
|
4
|
+
const execAsync = promisify(exec);
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Git operations for whitesmith.
|
|
8
|
+
*/
|
|
9
|
+
export class GitManager {
|
|
10
|
+
private workDir: string;
|
|
11
|
+
|
|
12
|
+
constructor(workDir: string) {
|
|
13
|
+
this.workDir = workDir;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
private async git(args: string): Promise<string> {
|
|
17
|
+
const {stdout} = await execAsync(`git ${args}`, {cwd: this.workDir});
|
|
18
|
+
return stdout.trim();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Get the current branch name
|
|
23
|
+
*/
|
|
24
|
+
async getCurrentBranch(): Promise<string> {
|
|
25
|
+
return this.git('rev-parse --abbrev-ref HEAD');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Fetch latest from origin
|
|
30
|
+
*/
|
|
31
|
+
async fetch(): Promise<void> {
|
|
32
|
+
await this.git('fetch origin');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Checkout a branch (create if it doesn't exist)
|
|
37
|
+
*/
|
|
38
|
+
async checkout(branch: string, options?: {create?: boolean; startPoint?: string}): Promise<void> {
|
|
39
|
+
if (options?.create) {
|
|
40
|
+
const startPoint = options.startPoint || 'origin/main';
|
|
41
|
+
await this.git(`checkout -b ${branch} ${startPoint}`);
|
|
42
|
+
} else {
|
|
43
|
+
await this.git(`checkout ${branch}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Checkout main and pull latest
|
|
49
|
+
*/
|
|
50
|
+
async checkoutMain(): Promise<void> {
|
|
51
|
+
await this.git('checkout main');
|
|
52
|
+
await this.git('pull origin main');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Stage all changes and commit
|
|
57
|
+
*/
|
|
58
|
+
async commitAll(message: string, exclude?: string[]): Promise<boolean> {
|
|
59
|
+
// Check if there are changes
|
|
60
|
+
const status = await this.git('status --porcelain');
|
|
61
|
+
if (!status) return false;
|
|
62
|
+
|
|
63
|
+
let addCmd = 'git add -A';
|
|
64
|
+
if (exclude && exclude.length > 0) {
|
|
65
|
+
// Add all then unstage excluded
|
|
66
|
+
await this.git('add -A');
|
|
67
|
+
for (const pattern of exclude) {
|
|
68
|
+
try {
|
|
69
|
+
await this.git(`reset HEAD -- ${pattern}`);
|
|
70
|
+
} catch {
|
|
71
|
+
// File might not be staged
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
} else {
|
|
75
|
+
await this.git('add -A');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
await this.git(`commit -m "${message.replace(/"/g, '\\"')}"`);
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Push branch to origin
|
|
84
|
+
*/
|
|
85
|
+
async push(branch: string): Promise<void> {
|
|
86
|
+
await this.git(`push origin ${branch}`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Force push branch to origin
|
|
91
|
+
*/
|
|
92
|
+
async forcePush(branch: string): Promise<void> {
|
|
93
|
+
await this.git(`push --force-with-lease origin ${branch}`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Check if there are uncommitted changes
|
|
98
|
+
*/
|
|
99
|
+
async hasChanges(): Promise<boolean> {
|
|
100
|
+
const status = await this.git('status --porcelain');
|
|
101
|
+
return status.length > 0;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Check if a local branch exists
|
|
106
|
+
*/
|
|
107
|
+
async localBranchExists(branch: string): Promise<boolean> {
|
|
108
|
+
try {
|
|
109
|
+
await this.git(`rev-parse --verify ${branch}`);
|
|
110
|
+
return true;
|
|
111
|
+
} catch {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Delete a local branch
|
|
118
|
+
*/
|
|
119
|
+
async deleteLocalBranch(branch: string): Promise<void> {
|
|
120
|
+
try {
|
|
121
|
+
await this.git(`branch -D ${branch}`);
|
|
122
|
+
} catch {
|
|
123
|
+
// Branch might not exist
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Get the default branch (usually main)
|
|
129
|
+
*/
|
|
130
|
+
async getDefaultBranch(): Promise<string> {
|
|
131
|
+
try {
|
|
132
|
+
const ref = await this.git('symbolic-ref refs/remotes/origin/HEAD');
|
|
133
|
+
return ref.replace('refs/remotes/origin/', '');
|
|
134
|
+
} catch {
|
|
135
|
+
return 'main';
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Verify we're on the expected branch
|
|
141
|
+
*/
|
|
142
|
+
async verifyBranch(expected: string): Promise<void> {
|
|
143
|
+
const current = await this.getCurrentBranch();
|
|
144
|
+
if (current !== expected) {
|
|
145
|
+
throw new Error(`Expected to be on branch '${expected}' but on '${current}'`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interface for AI agent harnesses.
|
|
3
|
+
* Implementations wrap specific tools (pi, claude CLI, aider, etc.)
|
|
4
|
+
*/
|
|
5
|
+
export interface AgentHarness {
|
|
6
|
+
/**
|
|
7
|
+
* Run the agent with a prompt and return its output.
|
|
8
|
+
* The agent is expected to execute in the given working directory.
|
|
9
|
+
*/
|
|
10
|
+
run(options: {
|
|
11
|
+
prompt: string;
|
|
12
|
+
workDir: string;
|
|
13
|
+
logFile?: string;
|
|
14
|
+
}): Promise<{output: string; exitCode: number}>;
|
|
15
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import {exec} from 'node:child_process';
|
|
2
|
+
import * as fs from 'node:fs';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import type {AgentHarness} from './agent-harness.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Agent harness for @mariozechner/pi-coding-agent.
|
|
8
|
+
*
|
|
9
|
+
* Runs `pi` with a prompt passed via a temp file, captures output.
|
|
10
|
+
*/
|
|
11
|
+
export class PiHarness implements AgentHarness {
|
|
12
|
+
private cmd: string;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param cmd - Command to invoke pi (default: "pi")
|
|
16
|
+
*/
|
|
17
|
+
constructor(cmd: string = 'pi') {
|
|
18
|
+
this.cmd = cmd;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async run(options: {
|
|
22
|
+
prompt: string;
|
|
23
|
+
workDir: string;
|
|
24
|
+
logFile?: string;
|
|
25
|
+
}): Promise<{output: string; exitCode: number}> {
|
|
26
|
+
// Write prompt to a temp file to avoid shell escaping issues
|
|
27
|
+
const promptFile = path.join(options.workDir, '.whitesmith-prompt.md');
|
|
28
|
+
fs.writeFileSync(promptFile, options.prompt, 'utf-8');
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
const result = await this.exec(
|
|
32
|
+
`${this.cmd} --prompt-file "${promptFile}" --yes`,
|
|
33
|
+
options.workDir,
|
|
34
|
+
options.logFile,
|
|
35
|
+
);
|
|
36
|
+
return result;
|
|
37
|
+
} finally {
|
|
38
|
+
// Clean up prompt file
|
|
39
|
+
try {
|
|
40
|
+
fs.unlinkSync(promptFile);
|
|
41
|
+
} catch {
|
|
42
|
+
// Ignore
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private exec(
|
|
48
|
+
cmd: string,
|
|
49
|
+
workDir: string,
|
|
50
|
+
logFile?: string,
|
|
51
|
+
): Promise<{output: string; exitCode: number}> {
|
|
52
|
+
return new Promise((resolve) => {
|
|
53
|
+
const child = exec(cmd, {
|
|
54
|
+
cwd: workDir,
|
|
55
|
+
maxBuffer: 50 * 1024 * 1024,
|
|
56
|
+
timeout: 30 * 60 * 1000, // 30 minute timeout
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
let output = '';
|
|
60
|
+
const logStream = logFile
|
|
61
|
+
? fs.createWriteStream(path.resolve(workDir, logFile), {flags: 'a'})
|
|
62
|
+
: null;
|
|
63
|
+
|
|
64
|
+
child.stdout?.on('data', (data: string) => {
|
|
65
|
+
output += data;
|
|
66
|
+
process.stdout.write(data);
|
|
67
|
+
logStream?.write(data);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
child.stderr?.on('data', (data: string) => {
|
|
71
|
+
output += data;
|
|
72
|
+
process.stderr.write(data);
|
|
73
|
+
logStream?.write(data);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
child.on('close', (code) => {
|
|
77
|
+
logStream?.end();
|
|
78
|
+
resolve({output, exitCode: code ?? 1});
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export {Orchestrator} from './orchestrator.js';
|
|
2
|
+
export {TaskManager} from './task-manager.js';
|
|
3
|
+
export {GitManager} from './git.js';
|
|
4
|
+
export {buildInvestigatePrompt, buildImplementPrompt} from './prompts.js';
|
|
5
|
+
|
|
6
|
+
export type {IssueProvider} from './providers/issue-provider.js';
|
|
7
|
+
export {GitHubProvider} from './providers/github.js';
|
|
8
|
+
|
|
9
|
+
export type {AgentHarness} from './harnesses/agent-harness.js';
|
|
10
|
+
export {PiHarness} from './harnesses/pi.js';
|
|
11
|
+
|
|
12
|
+
export type {Issue, Task, TaskFrontmatter, DevPulseConfig, Action} from './types.js';
|
|
13
|
+
export {LABELS} from './types.js';
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import type {DevPulseConfig, Issue, Task, Action} from './types.js';
|
|
2
|
+
import {LABELS} from './types.js';
|
|
3
|
+
import type {IssueProvider} from './providers/issue-provider.js';
|
|
4
|
+
import type {AgentHarness} from './harnesses/agent-harness.js';
|
|
5
|
+
import {TaskManager} from './task-manager.js';
|
|
6
|
+
import {GitManager} from './git.js';
|
|
7
|
+
import {buildInvestigatePrompt, buildImplementPrompt} from './prompts.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Main orchestrator for whitesmith.
|
|
11
|
+
*
|
|
12
|
+
* The loop:
|
|
13
|
+
* 1. Reconcile — check if any issues with tasks-accepted have all tasks done
|
|
14
|
+
* 2. Investigate — pick an unlabeled issue, generate tasks
|
|
15
|
+
* 3. Implement — pick an available task, implement it
|
|
16
|
+
*/
|
|
17
|
+
export class Orchestrator {
|
|
18
|
+
private config: DevPulseConfig;
|
|
19
|
+
private issues: IssueProvider;
|
|
20
|
+
private agent: AgentHarness;
|
|
21
|
+
private tasks: TaskManager;
|
|
22
|
+
private git: GitManager;
|
|
23
|
+
|
|
24
|
+
constructor(config: DevPulseConfig, issues: IssueProvider, agent: AgentHarness) {
|
|
25
|
+
this.config = config;
|
|
26
|
+
this.issues = issues;
|
|
27
|
+
this.agent = agent;
|
|
28
|
+
this.tasks = new TaskManager(config.workDir);
|
|
29
|
+
this.git = new GitManager(config.workDir);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Run the main loop
|
|
34
|
+
*/
|
|
35
|
+
async run(): Promise<void> {
|
|
36
|
+
console.log('=== whitesmith ===');
|
|
37
|
+
console.log(`Working directory: ${this.config.workDir}`);
|
|
38
|
+
console.log(`Max iterations: ${this.config.maxIterations}`);
|
|
39
|
+
console.log(`Agent command: ${this.config.agentCmd}`);
|
|
40
|
+
console.log('');
|
|
41
|
+
|
|
42
|
+
// Ensure labels exist
|
|
43
|
+
await this.issues.ensureLabels(Object.values(LABELS));
|
|
44
|
+
|
|
45
|
+
for (let i = 1; i <= this.config.maxIterations; i++) {
|
|
46
|
+
console.log('');
|
|
47
|
+
console.log(`=== Iteration ${i}/${this.config.maxIterations} ===`);
|
|
48
|
+
|
|
49
|
+
// Make sure we're on main with latest
|
|
50
|
+
await this.git.fetch();
|
|
51
|
+
await this.git.checkoutMain();
|
|
52
|
+
|
|
53
|
+
// Decide what to do
|
|
54
|
+
const action = await this.decideAction();
|
|
55
|
+
console.log(`Action: ${action.type}`);
|
|
56
|
+
|
|
57
|
+
switch (action.type) {
|
|
58
|
+
case 'reconcile':
|
|
59
|
+
await this.reconcile(action.issue);
|
|
60
|
+
break;
|
|
61
|
+
case 'investigate':
|
|
62
|
+
await this.investigate(action.issue);
|
|
63
|
+
break;
|
|
64
|
+
case 'implement':
|
|
65
|
+
await this.implement(action.task, action.issue);
|
|
66
|
+
break;
|
|
67
|
+
case 'idle':
|
|
68
|
+
console.log('Nothing to do. All issues are either in-progress or completed.');
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (!this.config.noSleep && i < this.config.maxIterations) {
|
|
73
|
+
console.log('Sleeping 5s...');
|
|
74
|
+
await new Promise((r) => setTimeout(r, 5000));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
console.log('');
|
|
79
|
+
console.log('=== Iteration limit reached ===');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Decide the next action to take
|
|
84
|
+
*/
|
|
85
|
+
private async decideAction(): Promise<Action> {
|
|
86
|
+
// Priority 1: Reconcile — issues with tasks-accepted where all tasks are done
|
|
87
|
+
const acceptedIssues = await this.issues.listIssues({labels: [LABELS.TASKS_ACCEPTED]});
|
|
88
|
+
for (const issue of acceptedIssues) {
|
|
89
|
+
if (!this.tasks.hasRemainingTasks(issue.number)) {
|
|
90
|
+
return {type: 'reconcile', issue};
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Priority 2: Implement — find an available task
|
|
95
|
+
const implementAction = await this.findAvailableTask(acceptedIssues);
|
|
96
|
+
if (implementAction) {
|
|
97
|
+
return implementAction;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Priority 3: Investigate — find a new issue (no whitesmith labels)
|
|
101
|
+
const allDevPulseLabels = Object.values(LABELS);
|
|
102
|
+
const newIssues = await this.issues.listIssues({noLabels: allDevPulseLabels});
|
|
103
|
+
if (newIssues.length > 0) {
|
|
104
|
+
// Pick the oldest issue
|
|
105
|
+
const issue = newIssues[newIssues.length - 1];
|
|
106
|
+
return {type: 'investigate', issue};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return {type: 'idle'};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Find an available task to implement
|
|
114
|
+
*/
|
|
115
|
+
private async findAvailableTask(
|
|
116
|
+
acceptedIssues: Issue[],
|
|
117
|
+
): Promise<{type: 'implement'; task: Task; issue: Issue} | null> {
|
|
118
|
+
for (const issue of acceptedIssues) {
|
|
119
|
+
const issueTasks = this.tasks.listTasks(issue.number);
|
|
120
|
+
|
|
121
|
+
for (const task of issueTasks) {
|
|
122
|
+
// Check dependencies are satisfied
|
|
123
|
+
if (!this.tasks.areDependenciesSatisfied(task)) {
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Check if someone is already working on it (branch exists)
|
|
128
|
+
const branch = `task/${task.id}`;
|
|
129
|
+
const branchExists = await this.issues.remoteBranchExists(branch);
|
|
130
|
+
if (branchExists) {
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return {type: 'implement', task, issue};
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Phase 1: Reconcile — mark issue as completed, close it
|
|
143
|
+
*/
|
|
144
|
+
private async reconcile(issue: Issue): Promise<void> {
|
|
145
|
+
console.log(`Reconciling issue #${issue.number}: ${issue.title}`);
|
|
146
|
+
console.log('All tasks completed. Marking issue as done.');
|
|
147
|
+
|
|
148
|
+
await this.issues.addLabel(issue.number, LABELS.COMPLETED);
|
|
149
|
+
await this.issues.removeLabel(issue.number, LABELS.TASKS_ACCEPTED);
|
|
150
|
+
await this.issues.comment(
|
|
151
|
+
issue.number,
|
|
152
|
+
`✅ All tasks for this issue have been implemented and merged. Closing.`,
|
|
153
|
+
);
|
|
154
|
+
await this.issues.closeIssue(issue.number);
|
|
155
|
+
|
|
156
|
+
console.log(`Issue #${issue.number} closed.`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Phase 2: Investigate — generate tasks for a new issue
|
|
161
|
+
*/
|
|
162
|
+
private async investigate(issue: Issue): Promise<void> {
|
|
163
|
+
console.log(`Investigating issue #${issue.number}: ${issue.title}`);
|
|
164
|
+
|
|
165
|
+
// Claim the issue
|
|
166
|
+
await this.issues.addLabel(issue.number, LABELS.INVESTIGATING);
|
|
167
|
+
|
|
168
|
+
const branch = `investigate/${issue.number}`;
|
|
169
|
+
const issueTasksDir = `tasks/${issue.number}`;
|
|
170
|
+
|
|
171
|
+
try {
|
|
172
|
+
// Create branch from main
|
|
173
|
+
await this.git.deleteLocalBranch(branch);
|
|
174
|
+
await this.git.checkout(branch, {create: true, startPoint: 'origin/main'});
|
|
175
|
+
|
|
176
|
+
// Run agent to generate tasks
|
|
177
|
+
const prompt = buildInvestigatePrompt(issue, issueTasksDir);
|
|
178
|
+
const {exitCode} = await this.agent.run({
|
|
179
|
+
prompt,
|
|
180
|
+
workDir: this.config.workDir,
|
|
181
|
+
logFile: this.config.logFile,
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
if (exitCode !== 0) {
|
|
185
|
+
console.error(`Agent failed with exit code ${exitCode}`);
|
|
186
|
+
await this.issues.removeLabel(issue.number, LABELS.INVESTIGATING);
|
|
187
|
+
await this.git.checkoutMain();
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Verify task files were created
|
|
192
|
+
await this.git.verifyBranch(branch);
|
|
193
|
+
const tasks = this.tasks.listTasks(issue.number);
|
|
194
|
+
if (tasks.length === 0) {
|
|
195
|
+
console.error('Agent did not create any task files');
|
|
196
|
+
await this.issues.removeLabel(issue.number, LABELS.INVESTIGATING);
|
|
197
|
+
await this.git.checkoutMain();
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Ensure changes are committed
|
|
202
|
+
await this.git.commitAll(`tasks(#${issue.number}): generate implementation tasks`);
|
|
203
|
+
|
|
204
|
+
console.log(`Generated ${tasks.length} task(s) for issue #${issue.number}`);
|
|
205
|
+
|
|
206
|
+
if (this.config.noPush) {
|
|
207
|
+
console.log(`Branch '${branch}' ready (--no-push mode)`);
|
|
208
|
+
} else {
|
|
209
|
+
// Push and create PR
|
|
210
|
+
await this.git.push(branch);
|
|
211
|
+
|
|
212
|
+
const taskList = tasks.map((t) => `- [ ] **${t.id}**: ${t.title}`).join('\n');
|
|
213
|
+
const prUrl = await this.issues.createPR({
|
|
214
|
+
head: branch,
|
|
215
|
+
base: 'main',
|
|
216
|
+
title: `tasks(#${issue.number}): ${issue.title}`,
|
|
217
|
+
body: `## Generated Tasks for #${issue.number}\n\n${taskList}\n\n---\n*Generated by whitesmith from issue #${issue.number}*`,
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
await this.issues.removeLabel(issue.number, LABELS.INVESTIGATING);
|
|
221
|
+
await this.issues.addLabel(issue.number, LABELS.TASKS_PROPOSED);
|
|
222
|
+
await this.issues.comment(
|
|
223
|
+
issue.number,
|
|
224
|
+
`📋 Tasks have been generated. Review the PR: ${prUrl}`,
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
console.log(`PR created: ${prUrl}`);
|
|
228
|
+
}
|
|
229
|
+
} catch (error) {
|
|
230
|
+
console.error('Investigation failed:', error instanceof Error ? error.message : error);
|
|
231
|
+
await this.issues.removeLabel(issue.number, LABELS.INVESTIGATING);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Return to main
|
|
235
|
+
await this.git.checkoutMain();
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Phase 3: Implement — implement a task and create a PR
|
|
240
|
+
*/
|
|
241
|
+
private async implement(task: Task, issue: Issue): Promise<void> {
|
|
242
|
+
console.log(`Implementing task ${task.id}: ${task.title}`);
|
|
243
|
+
console.log(`For issue #${issue.number}: ${issue.title}`);
|
|
244
|
+
|
|
245
|
+
const branch = `task/${task.id}`;
|
|
246
|
+
|
|
247
|
+
try {
|
|
248
|
+
// Create branch from main
|
|
249
|
+
await this.git.deleteLocalBranch(branch);
|
|
250
|
+
await this.git.checkout(branch, {create: true, startPoint: 'origin/main'});
|
|
251
|
+
|
|
252
|
+
// Run agent to implement
|
|
253
|
+
const prompt = buildImplementPrompt(task, issue);
|
|
254
|
+
const {exitCode} = await this.agent.run({
|
|
255
|
+
prompt,
|
|
256
|
+
workDir: this.config.workDir,
|
|
257
|
+
logFile: this.config.logFile,
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
if (exitCode !== 0) {
|
|
261
|
+
console.error(`Agent failed with exit code ${exitCode}`);
|
|
262
|
+
await this.git.checkoutMain();
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Verify we're still on the right branch
|
|
267
|
+
await this.git.verifyBranch(branch);
|
|
268
|
+
|
|
269
|
+
// Ensure changes are committed
|
|
270
|
+
await this.git.commitAll(`feat(#${issue.number}): ${task.title}`);
|
|
271
|
+
|
|
272
|
+
if (this.config.noPush) {
|
|
273
|
+
console.log(`Branch '${branch}' ready (--no-push mode)`);
|
|
274
|
+
} else {
|
|
275
|
+
// Push and create PR
|
|
276
|
+
await this.git.push(branch);
|
|
277
|
+
|
|
278
|
+
const prUrl = await this.issues.createPR({
|
|
279
|
+
head: branch,
|
|
280
|
+
base: 'main',
|
|
281
|
+
title: `feat(#${issue.number}): ${task.title}`,
|
|
282
|
+
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)`,
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
console.log(`PR created: ${prUrl}`);
|
|
286
|
+
}
|
|
287
|
+
} catch (error) {
|
|
288
|
+
console.error('Implementation failed:', error instanceof Error ? error.message : error);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Return to main
|
|
292
|
+
await this.git.checkoutMain();
|
|
293
|
+
}
|
|
294
|
+
}
|
package/src/prompts.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import type {Issue, Task} from './types.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Build the prompt for the "investigate" phase.
|
|
5
|
+
* The agent reads the issue, understands the codebase, and generates task files.
|
|
6
|
+
*/
|
|
7
|
+
export function buildInvestigatePrompt(issue: Issue, issueTasksDir: string): string {
|
|
8
|
+
return `# Task: Generate implementation tasks for Issue #${issue.number}
|
|
9
|
+
|
|
10
|
+
## Issue
|
|
11
|
+
**Title:** ${issue.title}
|
|
12
|
+
**URL:** ${issue.url}
|
|
13
|
+
|
|
14
|
+
### Description
|
|
15
|
+
${issue.body}
|
|
16
|
+
|
|
17
|
+
## Your Job
|
|
18
|
+
|
|
19
|
+
You are an AI assistant helping break down a GitHub issue into concrete implementation tasks.
|
|
20
|
+
|
|
21
|
+
1. **Read and understand** the issue above.
|
|
22
|
+
2. **Explore the codebase** to understand the architecture, conventions, and relevant code.
|
|
23
|
+
3. **Break the issue down** into 1 or more tasks. Each task should represent a single, reviewable PR's worth of work.
|
|
24
|
+
4. **Write task files** to the \`${issueTasksDir}\` directory.
|
|
25
|
+
|
|
26
|
+
## Task File Format
|
|
27
|
+
|
|
28
|
+
Each task file should be named \`<seq>-<short-slug>.md\` (e.g., \`001-add-validation.md\`) and contain:
|
|
29
|
+
|
|
30
|
+
\`\`\`markdown
|
|
31
|
+
---
|
|
32
|
+
id: "${issue.number}-<seq>"
|
|
33
|
+
issue: ${issue.number}
|
|
34
|
+
title: "<concise title>"
|
|
35
|
+
depends_on: []
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## Description
|
|
39
|
+
<detailed description of what needs to be done>
|
|
40
|
+
|
|
41
|
+
## Acceptance Criteria
|
|
42
|
+
- <criterion 1>
|
|
43
|
+
- <criterion 2>
|
|
44
|
+
|
|
45
|
+
## Implementation Notes
|
|
46
|
+
<any relevant notes about approach, files to modify, etc.>
|
|
47
|
+
\`\`\`
|
|
48
|
+
|
|
49
|
+
## Rules
|
|
50
|
+
|
|
51
|
+
- Sequence numbers start at 001 and increment.
|
|
52
|
+
- The \`id\` field must be \`"${issue.number}-<seq>"\` (e.g., "${issue.number}-001").
|
|
53
|
+
- Use \`depends_on\` to list task IDs that must be completed before this task. For example, if task 002 depends on task 001, set \`depends_on: ["${issue.number}-001"]\`.
|
|
54
|
+
- Each task should be a meaningful, self-contained unit of work that results in one PR.
|
|
55
|
+
- Be specific in descriptions and acceptance criteria — another AI agent will implement these.
|
|
56
|
+
- Consider the existing codebase patterns and conventions.
|
|
57
|
+
- Create the \`${issueTasksDir}\` directory if it doesn't exist.
|
|
58
|
+
|
|
59
|
+
## When Done
|
|
60
|
+
|
|
61
|
+
After creating all task files, commit your changes:
|
|
62
|
+
\`\`\`
|
|
63
|
+
git add tasks/
|
|
64
|
+
git commit -m "tasks(#${issue.number}): generate implementation tasks"
|
|
65
|
+
\`\`\`
|
|
66
|
+
|
|
67
|
+
Do NOT push. Do NOT create a PR. The orchestrator will handle that.
|
|
68
|
+
`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Build the prompt for the "implement" phase.
|
|
73
|
+
* The agent implements a specific task and deletes the task file.
|
|
74
|
+
*/
|
|
75
|
+
export function buildImplementPrompt(task: Task, issue: Issue): string {
|
|
76
|
+
return `# Task: Implement "${task.title}"
|
|
77
|
+
|
|
78
|
+
## Context
|
|
79
|
+
|
|
80
|
+
You are implementing a task generated from GitHub Issue #${issue.number}: "${issue.title}"
|
|
81
|
+
|
|
82
|
+
**Issue URL:** ${issue.url}
|
|
83
|
+
**Task ID:** ${task.id}
|
|
84
|
+
**Task File:** ${task.filePath}
|
|
85
|
+
|
|
86
|
+
## Task Details
|
|
87
|
+
|
|
88
|
+
${task.content}
|
|
89
|
+
|
|
90
|
+
## Your Job
|
|
91
|
+
|
|
92
|
+
1. **Read the task** above carefully.
|
|
93
|
+
2. **Explore the codebase** to understand the architecture and conventions.
|
|
94
|
+
3. **Implement the changes** described in the task.
|
|
95
|
+
4. **Verify** your implementation meets the acceptance criteria.
|
|
96
|
+
5. **Delete the task file** at \`${task.filePath}\` — this marks the task as complete.
|
|
97
|
+
6. **Clean up**: if the task directory \`tasks/${task.issue}/\` is now empty, delete it too.
|
|
98
|
+
7. **Commit** all changes (implementation + task file deletion):
|
|
99
|
+
|
|
100
|
+
\`\`\`
|
|
101
|
+
git add -A
|
|
102
|
+
git commit -m "feat(#${issue.number}): ${task.title}"
|
|
103
|
+
\`\`\`
|
|
104
|
+
|
|
105
|
+
## Rules
|
|
106
|
+
|
|
107
|
+
- Follow existing code conventions and patterns.
|
|
108
|
+
- Make clean, reviewable changes.
|
|
109
|
+
- Do NOT push. Do NOT create a PR. The orchestrator will handle that.
|
|
110
|
+
- Do NOT modify other task files.
|
|
111
|
+
- You MUST delete \`${task.filePath}\` as part of your commit.
|
|
112
|
+
- If the \`tasks/${task.issue}/\` directory is empty after deletion, remove it.
|
|
113
|
+
`;
|
|
114
|
+
}
|