@warpmetrics/coder 0.1.3 → 0.2.1
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/README.md +58 -25
- package/bin/cli.js +173 -0
- package/defaults/config.json +26 -0
- package/package.json +6 -5
- package/src/agent.js +202 -0
- package/src/boards/github-projects.js +114 -0
- package/src/boards/index.js +14 -0
- package/src/claude.js +37 -0
- package/src/config.js +13 -0
- package/src/git.js +44 -0
- package/src/hooks.js +25 -0
- package/src/memory.js +17 -0
- package/src/reflect.js +81 -0
- package/src/revise.js +188 -0
- package/{defaults/scripts/pipeline.js → src/warp.js} +57 -11
- package/src/watch.js +76 -0
- package/bin/init.js +0 -213
- package/defaults/agent-implement.yml +0 -74
- package/defaults/agent-revise.yml +0 -81
- package/defaults/scripts/check-revision-limit.js +0 -47
- package/defaults/scripts/pipeline-outcome.js +0 -33
- package/defaults/scripts/pipeline-start.js +0 -40
package/README.md
CHANGED
|
@@ -1,41 +1,76 @@
|
|
|
1
1
|
# @warpmetrics/coder
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Local agent loop that watches a GitHub Projects board for tasks, implements them using Claude Code, and pushes PRs.
|
|
4
4
|
|
|
5
5
|
## Quick Start
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
8
|
npx @warpmetrics/coder init
|
|
9
|
+
npx @warpmetrics/coder watch
|
|
9
10
|
```
|
|
10
11
|
|
|
11
|
-
This will:
|
|
12
|
-
1. Set up `ANTHROPIC_API_KEY` and `WARPMETRICS_API_KEY` as GitHub secrets
|
|
13
|
-
2. Add two workflow files to `.github/workflows/`
|
|
14
|
-
3. Add pipeline scripts to `.github/scripts/`
|
|
15
|
-
4. Register outcome classifications with WarpMetrics
|
|
16
|
-
|
|
17
12
|
## How It Works
|
|
18
13
|
|
|
19
14
|
```
|
|
20
|
-
|
|
21
|
-
→
|
|
22
|
-
→
|
|
23
|
-
→
|
|
24
|
-
→
|
|
25
|
-
→
|
|
15
|
+
Board: "Todo" column
|
|
16
|
+
→ warp-coder picks up the task
|
|
17
|
+
→ clones repo, creates branch, runs Claude Code
|
|
18
|
+
→ pushes branch, opens PR, moves to "In Review"
|
|
19
|
+
→ if review feedback arrives: applies fixes, pushes again
|
|
20
|
+
→ if approved: squash-merges, moves to "Done"
|
|
26
21
|
```
|
|
27
22
|
|
|
28
|
-
|
|
23
|
+
The agent polls your GitHub Projects board and processes tasks sequentially:
|
|
29
24
|
|
|
30
|
-
|
|
25
|
+
1. **Todo** — picks the first item, implements it, opens a PR
|
|
26
|
+
2. **In Review** — detects new review comments, applies feedback (up to 3 revisions)
|
|
27
|
+
3. **Approved** — squash-merges the PR, runs `onMerged` hook, moves to "Done"
|
|
28
|
+
|
|
29
|
+
Every step is instrumented with [WarpMetrics](https://warpmetrics.com) — you get runs, groups, and outcomes tracking the full pipeline.
|
|
31
30
|
|
|
32
|
-
|
|
31
|
+
## Config
|
|
32
|
+
|
|
33
|
+
`warp-coder init` creates `.warp-coder/config.json`:
|
|
34
|
+
|
|
35
|
+
```json
|
|
36
|
+
{
|
|
37
|
+
"board": {
|
|
38
|
+
"provider": "github-projects",
|
|
39
|
+
"project": 1,
|
|
40
|
+
"owner": "your-org",
|
|
41
|
+
"columns": {
|
|
42
|
+
"todo": "Todo",
|
|
43
|
+
"inProgress": "In Progress",
|
|
44
|
+
"inReview": "In Review",
|
|
45
|
+
"done": "Done",
|
|
46
|
+
"blocked": "Blocked"
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
"hooks": {
|
|
50
|
+
"onBeforePush": "npm test",
|
|
51
|
+
"onMerged": "npm run deploy:prod"
|
|
52
|
+
},
|
|
53
|
+
"claude": {
|
|
54
|
+
"allowedTools": "Bash,Read,Edit,Write,Glob,Grep",
|
|
55
|
+
"maxTurns": 20
|
|
56
|
+
},
|
|
57
|
+
"pollInterval": 30,
|
|
58
|
+
"maxRevisions": 3,
|
|
59
|
+
"repo": "git@github.com:your-org/your-repo.git"
|
|
60
|
+
}
|
|
61
|
+
```
|
|
33
62
|
|
|
34
|
-
|
|
63
|
+
## Lifecycle Hooks
|
|
35
64
|
|
|
36
|
-
|
|
65
|
+
| Hook | When | Use case |
|
|
66
|
+
|------|------|----------|
|
|
67
|
+
| `onBranchCreate` | After creating the implementation branch | Set up environment |
|
|
68
|
+
| `onBeforePush` | Before pushing (implement or revise) | Run tests/lint |
|
|
69
|
+
| `onPRCreated` | After opening a PR | Notify, add labels |
|
|
70
|
+
| `onBeforeMerge` | Before squash-merging | Final checks |
|
|
71
|
+
| `onMerged` | After merge completes | Deploy |
|
|
37
72
|
|
|
38
|
-
|
|
73
|
+
Hooks receive env vars: `ISSUE_NUMBER`, `PR_NUMBER`, `BRANCH`, `REPO`.
|
|
39
74
|
|
|
40
75
|
## Outcome Classifications
|
|
41
76
|
|
|
@@ -51,13 +86,11 @@ Triggered when `github-actions[bot]` submits a review with comments (i.e., warp-
|
|
|
51
86
|
| Revision Failed | failure |
|
|
52
87
|
| Max Retries | failure |
|
|
53
88
|
|
|
54
|
-
##
|
|
55
|
-
|
|
56
|
-
For the full implement → review → revise loop, install [warp-review](https://github.com/warpmetrics/warp-review):
|
|
89
|
+
## Requirements
|
|
57
90
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
91
|
+
- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) installed and authenticated
|
|
92
|
+
- [GitHub CLI](https://cli.github.com/) (`gh`) installed and authenticated
|
|
93
|
+
- A GitHub Projects v2 board with Status field
|
|
61
94
|
|
|
62
95
|
## License
|
|
63
96
|
|
package/bin/cli.js
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { createInterface } from 'readline';
|
|
4
|
+
import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'fs';
|
|
5
|
+
import { execSync } from 'child_process';
|
|
6
|
+
import { fileURLToPath } from 'url';
|
|
7
|
+
import { dirname, join } from 'path';
|
|
8
|
+
import { registerClassifications } from '../src/warp.js';
|
|
9
|
+
import { discoverProjectFields } from '../src/boards/github-projects.js';
|
|
10
|
+
import { loadMemory } from '../src/memory.js';
|
|
11
|
+
import { reflect } from '../src/reflect.js';
|
|
12
|
+
|
|
13
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
const defaultsDir = join(__dirname, '..', 'defaults');
|
|
15
|
+
|
|
16
|
+
const command = process.argv[2];
|
|
17
|
+
|
|
18
|
+
if (command === 'watch') {
|
|
19
|
+
const { watch } = await import('../src/watch.js');
|
|
20
|
+
await watch();
|
|
21
|
+
} else if (command === 'init') {
|
|
22
|
+
await runInit();
|
|
23
|
+
} else if (command === 'memory') {
|
|
24
|
+
const configDir = join(process.cwd(), '.warp-coder');
|
|
25
|
+
const memory = loadMemory(configDir);
|
|
26
|
+
console.log(memory || '(no memory yet)');
|
|
27
|
+
} else if (command === 'compact') {
|
|
28
|
+
const configDir = join(process.cwd(), '.warp-coder');
|
|
29
|
+
const { loadConfig } = await import('../src/config.js');
|
|
30
|
+
const config = loadConfig();
|
|
31
|
+
console.log('Compacting memory...');
|
|
32
|
+
await reflect({ configDir, step: 'compact', success: true, maxLines: config.memory?.maxLines || 100 });
|
|
33
|
+
console.log('Done.');
|
|
34
|
+
} else {
|
|
35
|
+
console.log('');
|
|
36
|
+
console.log(' warp-coder — local agent loop for implementing GitHub issues');
|
|
37
|
+
console.log('');
|
|
38
|
+
console.log(' Usage:');
|
|
39
|
+
console.log(' warp-coder init Set up config for a project');
|
|
40
|
+
console.log(' warp-coder watch Start the poll loop');
|
|
41
|
+
console.log(' warp-coder memory Print current memory file');
|
|
42
|
+
console.log(' warp-coder compact Force-rewrite memory file');
|
|
43
|
+
console.log('');
|
|
44
|
+
process.exit(command ? 1 : 0);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// Init wizard
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
async function runInit() {
|
|
52
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
53
|
+
const ask = q => new Promise(resolve => rl.question(q, resolve));
|
|
54
|
+
const log = msg => console.log(msg);
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
log('');
|
|
58
|
+
log(' warp-coder — set up agent config');
|
|
59
|
+
log('');
|
|
60
|
+
|
|
61
|
+
// 1. WarpMetrics API key
|
|
62
|
+
const wmKey = await ask(' ? WarpMetrics API key (get one at warpmetrics.com/app/api-keys): ');
|
|
63
|
+
if (wmKey && !wmKey.startsWith('wm_')) {
|
|
64
|
+
log(' \u26a0 Warning: key doesn\'t start with wm_ — make sure this is a valid WarpMetrics API key');
|
|
65
|
+
}
|
|
66
|
+
log('');
|
|
67
|
+
|
|
68
|
+
// 2. Repo URL
|
|
69
|
+
let repoDefault = '';
|
|
70
|
+
try {
|
|
71
|
+
repoDefault = execSync('git remote get-url origin', { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
72
|
+
} catch {}
|
|
73
|
+
const repoPrompt = repoDefault ? ` ? Repository URL (${repoDefault}): ` : ' ? Repository URL: ';
|
|
74
|
+
const repoInput = await ask(repoPrompt);
|
|
75
|
+
const repo = repoInput || repoDefault;
|
|
76
|
+
if (!repo) {
|
|
77
|
+
log(' \u2717 Repository URL is required');
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
80
|
+
log('');
|
|
81
|
+
|
|
82
|
+
// 3. Board provider
|
|
83
|
+
log(' Board: GitHub Projects v2');
|
|
84
|
+
const projectNumber = await ask(' ? Project number: ');
|
|
85
|
+
if (!projectNumber) {
|
|
86
|
+
log(' \u2717 Project number is required');
|
|
87
|
+
process.exit(1);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Try to infer owner from repo URL
|
|
91
|
+
let ownerDefault = '';
|
|
92
|
+
const match = repo.match(/github\.com[:/]([^/]+)\//);
|
|
93
|
+
if (match) ownerDefault = match[1];
|
|
94
|
+
const ownerPrompt = ownerDefault ? ` ? Project owner (${ownerDefault}): ` : ' ? Project owner: ';
|
|
95
|
+
const ownerInput = await ask(ownerPrompt);
|
|
96
|
+
const owner = ownerInput || ownerDefault;
|
|
97
|
+
log('');
|
|
98
|
+
|
|
99
|
+
// 4. Discover field IDs and column names
|
|
100
|
+
let columns = { todo: 'Todo', inProgress: 'In Progress', inReview: 'In Review', done: 'Done', blocked: 'Blocked' };
|
|
101
|
+
try {
|
|
102
|
+
log(' Discovering project fields...');
|
|
103
|
+
const fields = discoverProjectFields(parseInt(projectNumber, 10), owner);
|
|
104
|
+
const statusField = fields.find(f => f.name === 'Status');
|
|
105
|
+
if (statusField?.options) {
|
|
106
|
+
const available = statusField.options.map(o => o.name);
|
|
107
|
+
log(` Found columns: ${available.join(', ')}`);
|
|
108
|
+
// Map available columns to our column keys, keeping defaults for any not found
|
|
109
|
+
for (const key of Object.keys(columns)) {
|
|
110
|
+
if (!available.includes(columns[key])) {
|
|
111
|
+
log(` \u26a0 Column "${columns[key]}" not found in project`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
log('');
|
|
116
|
+
} catch (err) {
|
|
117
|
+
log(` \u26a0 Could not discover fields: ${err.message}`);
|
|
118
|
+
log(' Using default column names');
|
|
119
|
+
log('');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// 5. Build config
|
|
123
|
+
const config = {
|
|
124
|
+
board: {
|
|
125
|
+
provider: 'github-projects',
|
|
126
|
+
project: parseInt(projectNumber, 10),
|
|
127
|
+
owner,
|
|
128
|
+
columns,
|
|
129
|
+
},
|
|
130
|
+
hooks: {},
|
|
131
|
+
claude: {
|
|
132
|
+
allowedTools: 'Bash,Read,Edit,Write,Glob,Grep',
|
|
133
|
+
maxTurns: 20,
|
|
134
|
+
},
|
|
135
|
+
pollInterval: 30,
|
|
136
|
+
maxRevisions: 3,
|
|
137
|
+
repo,
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
if (wmKey) {
|
|
141
|
+
config.warpmetricsApiKey = wmKey;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// 6. Write config
|
|
145
|
+
const configDir = '.warp-coder';
|
|
146
|
+
mkdirSync(configDir, { recursive: true });
|
|
147
|
+
writeFileSync(join(configDir, 'config.json'), JSON.stringify(config, null, 2) + '\n');
|
|
148
|
+
log(` \u2713 ${configDir}/config.json created`);
|
|
149
|
+
|
|
150
|
+
// 7. Register outcome classifications
|
|
151
|
+
if (wmKey) {
|
|
152
|
+
log(' Registering outcome classifications with WarpMetrics...');
|
|
153
|
+
try {
|
|
154
|
+
await registerClassifications(wmKey);
|
|
155
|
+
log(' \u2713 Outcomes configured');
|
|
156
|
+
} catch (err) {
|
|
157
|
+
log(` \u26a0 Some classifications failed: ${err.message}`);
|
|
158
|
+
log(' You can set them manually in the WarpMetrics dashboard');
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// 8. Next steps
|
|
163
|
+
log('');
|
|
164
|
+
log(' Done! Next steps:');
|
|
165
|
+
log(' 1. Add .warp-coder/config.json to .gitignore (contains API key)');
|
|
166
|
+
log(' 2. Run: warp-coder watch');
|
|
167
|
+
log(' 3. Add issues to the "Todo" column of your project board');
|
|
168
|
+
log(' 4. View pipeline analytics at https://app.warpmetrics.com');
|
|
169
|
+
log('');
|
|
170
|
+
} finally {
|
|
171
|
+
rl.close();
|
|
172
|
+
}
|
|
173
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"board": {
|
|
3
|
+
"provider": "github-projects",
|
|
4
|
+
"project": 1,
|
|
5
|
+
"owner": "your-org",
|
|
6
|
+
"columns": {
|
|
7
|
+
"todo": "Todo",
|
|
8
|
+
"inProgress": "In Progress",
|
|
9
|
+
"inReview": "In Review",
|
|
10
|
+
"done": "Done",
|
|
11
|
+
"blocked": "Blocked"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"hooks": {},
|
|
15
|
+
"claude": {
|
|
16
|
+
"allowedTools": "Bash,Read,Edit,Write,Glob,Grep",
|
|
17
|
+
"maxTurns": 20
|
|
18
|
+
},
|
|
19
|
+
"memory": {
|
|
20
|
+
"enabled": true,
|
|
21
|
+
"maxLines": 100
|
|
22
|
+
},
|
|
23
|
+
"pollInterval": 30,
|
|
24
|
+
"maxRevisions": 3,
|
|
25
|
+
"repo": "git@github.com:your-org/your-repo.git"
|
|
26
|
+
}
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@warpmetrics/coder",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "
|
|
5
|
+
"description": "Local agent loop for implementing GitHub issues with Claude Code. Powered by WarpMetrics.",
|
|
6
6
|
"bin": {
|
|
7
|
-
"warp-coder": "./bin/
|
|
7
|
+
"warp-coder": "./bin/cli.js"
|
|
8
8
|
},
|
|
9
9
|
"scripts": {
|
|
10
10
|
"release:patch": "npm version patch && git push origin main --tags",
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
},
|
|
13
13
|
"files": [
|
|
14
14
|
"bin/",
|
|
15
|
+
"src/",
|
|
15
16
|
"defaults/",
|
|
16
17
|
"README.md",
|
|
17
18
|
"LICENSE"
|
|
@@ -24,9 +25,9 @@
|
|
|
24
25
|
"keywords": [
|
|
25
26
|
"agent",
|
|
26
27
|
"claude-code",
|
|
27
|
-
"github-action",
|
|
28
28
|
"warpmetrics",
|
|
29
29
|
"llm",
|
|
30
|
-
"code-generation"
|
|
30
|
+
"code-generation",
|
|
31
|
+
"automation"
|
|
31
32
|
]
|
|
32
33
|
}
|
package/src/agent.js
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// Implement a single task: clone → branch → claude → push → PR
|
|
2
|
+
|
|
3
|
+
import { tmpdir } from 'os';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
import { mkdirSync, rmSync } from 'fs';
|
|
6
|
+
import * as git from './git.js';
|
|
7
|
+
import * as claude from './claude.js';
|
|
8
|
+
import * as warp from './warp.js';
|
|
9
|
+
import { runHook } from './hooks.js';
|
|
10
|
+
import { loadMemory } from './memory.js';
|
|
11
|
+
import { reflect } from './reflect.js';
|
|
12
|
+
|
|
13
|
+
const CONFIG_DIR = '.warp-coder';
|
|
14
|
+
|
|
15
|
+
export async function implement(item, { board, config, log }) {
|
|
16
|
+
const issueNumber = item.content?.number;
|
|
17
|
+
const issueTitle = item.content?.title || `Issue #${issueNumber}`;
|
|
18
|
+
const issueBody = item.content?.body || '';
|
|
19
|
+
const repo = config.repo;
|
|
20
|
+
const repoName = repo.replace(/\.git$/, '').split('/').pop(); // owner/repo or just repo
|
|
21
|
+
const branch = `agent/issue-${issueNumber}`;
|
|
22
|
+
const workdir = join(tmpdir(), 'warp-coder', String(issueNumber));
|
|
23
|
+
const configDir = join(process.cwd(), CONFIG_DIR);
|
|
24
|
+
|
|
25
|
+
log(`Implementing #${issueNumber}: ${issueTitle}`);
|
|
26
|
+
|
|
27
|
+
// Move to In Progress
|
|
28
|
+
try {
|
|
29
|
+
await board.moveToInProgress(item);
|
|
30
|
+
} catch (err) {
|
|
31
|
+
log(` warning: could not move to In Progress: ${err.message}`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// WarpMetrics: start pipeline
|
|
35
|
+
let groupId = null;
|
|
36
|
+
if (config.warpmetricsApiKey) {
|
|
37
|
+
try {
|
|
38
|
+
const pipeline = await warp.startPipeline(config.warpmetricsApiKey, {
|
|
39
|
+
step: 'implement',
|
|
40
|
+
repo: repoName,
|
|
41
|
+
issueNumber,
|
|
42
|
+
issueTitle,
|
|
43
|
+
});
|
|
44
|
+
groupId = pipeline.groupId;
|
|
45
|
+
log(` pipeline: run=${pipeline.runId} group=${groupId}`);
|
|
46
|
+
} catch (err) {
|
|
47
|
+
log(` warning: pipeline start failed: ${err.message}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let success = false;
|
|
52
|
+
let claudeResult = null;
|
|
53
|
+
let taskError = null;
|
|
54
|
+
const hookOutputs = [];
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
// Clone
|
|
58
|
+
rmSync(workdir, { recursive: true, force: true });
|
|
59
|
+
mkdirSync(workdir, { recursive: true });
|
|
60
|
+
log(` cloning into ${workdir}`);
|
|
61
|
+
git.cloneRepo(repo, workdir);
|
|
62
|
+
|
|
63
|
+
// Branch
|
|
64
|
+
git.createBranch(workdir, branch);
|
|
65
|
+
log(` branch: ${branch}`);
|
|
66
|
+
|
|
67
|
+
// Hook: onBranchCreate
|
|
68
|
+
try {
|
|
69
|
+
const h = runHook('onBranchCreate', config, { workdir, issueNumber, branch, repo: repoName });
|
|
70
|
+
if (h.ran) hookOutputs.push(h);
|
|
71
|
+
} catch (err) {
|
|
72
|
+
if (err.hookResult) hookOutputs.push(err.hookResult);
|
|
73
|
+
throw err;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Load memory for prompt enrichment
|
|
77
|
+
const memory = config.memory?.enabled !== false ? loadMemory(configDir) : '';
|
|
78
|
+
|
|
79
|
+
// Claude
|
|
80
|
+
const promptParts = [
|
|
81
|
+
`You are working on the repository ${repoName}.`,
|
|
82
|
+
'',
|
|
83
|
+
];
|
|
84
|
+
|
|
85
|
+
if (memory) {
|
|
86
|
+
promptParts.push(
|
|
87
|
+
'Lessons learned from previous tasks in this repository:',
|
|
88
|
+
'',
|
|
89
|
+
memory,
|
|
90
|
+
'',
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
promptParts.push(
|
|
95
|
+
`Implement the following GitHub issue:`,
|
|
96
|
+
'',
|
|
97
|
+
`**#${issueNumber}: ${issueTitle}**`,
|
|
98
|
+
'',
|
|
99
|
+
issueBody,
|
|
100
|
+
'',
|
|
101
|
+
'Steps:',
|
|
102
|
+
'1. Read the codebase to understand relevant context',
|
|
103
|
+
'2. Implement the changes',
|
|
104
|
+
'3. Run tests to verify nothing is broken',
|
|
105
|
+
'4. Commit with a clear message',
|
|
106
|
+
'',
|
|
107
|
+
'Do NOT create branches, push, or open PRs — just implement and commit.',
|
|
108
|
+
'If the issue is unclear or you cannot implement it, explain what is missing.',
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
const prompt = promptParts.join('\n');
|
|
112
|
+
|
|
113
|
+
log(' running claude...');
|
|
114
|
+
claudeResult = await claude.run({
|
|
115
|
+
prompt,
|
|
116
|
+
workdir,
|
|
117
|
+
allowedTools: config.claude?.allowedTools,
|
|
118
|
+
maxTurns: config.claude?.maxTurns,
|
|
119
|
+
});
|
|
120
|
+
log(` claude done (cost: $${claudeResult.costUsd ?? '?'})`);
|
|
121
|
+
|
|
122
|
+
// Hook: onBeforePush
|
|
123
|
+
try {
|
|
124
|
+
const h = runHook('onBeforePush', config, { workdir, issueNumber, branch, repo: repoName });
|
|
125
|
+
if (h.ran) hookOutputs.push(h);
|
|
126
|
+
} catch (err) {
|
|
127
|
+
if (err.hookResult) hookOutputs.push(err.hookResult);
|
|
128
|
+
throw err;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Push + PR
|
|
132
|
+
log(' pushing...');
|
|
133
|
+
git.push(workdir, branch);
|
|
134
|
+
const pr = git.createPR(workdir, {
|
|
135
|
+
title: issueTitle,
|
|
136
|
+
body: `Closes #${issueNumber}\n\nImplemented by warp-coder.`,
|
|
137
|
+
});
|
|
138
|
+
log(` PR created: ${pr.url}`);
|
|
139
|
+
|
|
140
|
+
// Hook: onPRCreated
|
|
141
|
+
try {
|
|
142
|
+
const h = runHook('onPRCreated', config, { workdir, issueNumber, prNumber: pr.number, branch, repo: repoName });
|
|
143
|
+
if (h.ran) hookOutputs.push(h);
|
|
144
|
+
} catch (err) {
|
|
145
|
+
if (err.hookResult) hookOutputs.push(err.hookResult);
|
|
146
|
+
throw err;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Move to In Review
|
|
150
|
+
try {
|
|
151
|
+
await board.moveToReview(item);
|
|
152
|
+
} catch (err) {
|
|
153
|
+
log(` warning: could not move to In Review: ${err.message}`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
success = true;
|
|
157
|
+
} catch (err) {
|
|
158
|
+
taskError = err.message;
|
|
159
|
+
log(` failed: ${err.message}`);
|
|
160
|
+
} finally {
|
|
161
|
+
// WarpMetrics: record outcome
|
|
162
|
+
if (config.warpmetricsApiKey && groupId) {
|
|
163
|
+
try {
|
|
164
|
+
const outcome = await warp.recordOutcome(config.warpmetricsApiKey, groupId, {
|
|
165
|
+
step: 'implement',
|
|
166
|
+
success,
|
|
167
|
+
costUsd: claudeResult?.costUsd,
|
|
168
|
+
error: taskError,
|
|
169
|
+
hooksFailed: hookOutputs.some(h => h.exitCode !== 0),
|
|
170
|
+
issueNumber,
|
|
171
|
+
});
|
|
172
|
+
log(` outcome: ${outcome.name}`);
|
|
173
|
+
} catch (err) {
|
|
174
|
+
log(` warning: outcome recording failed: ${err.message}`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Reflect
|
|
179
|
+
if (config.memory?.enabled !== false) {
|
|
180
|
+
try {
|
|
181
|
+
await reflect({
|
|
182
|
+
configDir,
|
|
183
|
+
step: 'implement',
|
|
184
|
+
issue: { number: issueNumber, title: issueTitle },
|
|
185
|
+
success,
|
|
186
|
+
error: taskError,
|
|
187
|
+
hookOutputs: hookOutputs.filter(h => h.ran),
|
|
188
|
+
claudeOutput: claudeResult?.result,
|
|
189
|
+
maxLines: config.memory?.maxLines || 100,
|
|
190
|
+
});
|
|
191
|
+
log(' reflect: memory updated');
|
|
192
|
+
} catch (err) {
|
|
193
|
+
log(` warning: reflect failed: ${err.message}`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Cleanup
|
|
198
|
+
rmSync(workdir, { recursive: true, force: true });
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return success;
|
|
202
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// GitHub Projects v2 board adapter.
|
|
2
|
+
// Uses `gh` CLI for all API interactions.
|
|
3
|
+
|
|
4
|
+
import { execSync } from 'child_process';
|
|
5
|
+
|
|
6
|
+
function gh(args, opts = {}) {
|
|
7
|
+
return execSync(`gh ${args}`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], ...opts }).trim();
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function ghJson(args, opts = {}) {
|
|
11
|
+
const out = gh(args, opts);
|
|
12
|
+
return out ? JSON.parse(out) : null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function create({ project, owner, statusField = 'Status', columns = {} }) {
|
|
16
|
+
const colNames = {
|
|
17
|
+
todo: columns.todo || 'Todo',
|
|
18
|
+
inProgress: columns.inProgress || 'In Progress',
|
|
19
|
+
inReview: columns.inReview || 'In Review',
|
|
20
|
+
done: columns.done || 'Done',
|
|
21
|
+
blocked: columns.blocked || 'Blocked',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// Cache field/option IDs (discovered on first use)
|
|
25
|
+
let fieldId = null;
|
|
26
|
+
let optionIds = null;
|
|
27
|
+
|
|
28
|
+
function discoverField() {
|
|
29
|
+
if (fieldId) return;
|
|
30
|
+
const fields = ghJson(`project field-list ${project} --owner ${owner} --format json`);
|
|
31
|
+
const field = fields?.fields?.find(f => f.name === statusField);
|
|
32
|
+
if (!field) throw new Error(`Status field "${statusField}" not found in project ${project}`);
|
|
33
|
+
fieldId = field.id;
|
|
34
|
+
optionIds = {};
|
|
35
|
+
for (const opt of field.options || []) {
|
|
36
|
+
optionIds[opt.name] = opt.id;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function getOptionId(colKey) {
|
|
41
|
+
discoverField();
|
|
42
|
+
const name = colNames[colKey];
|
|
43
|
+
const id = optionIds[name];
|
|
44
|
+
if (!id) throw new Error(`Column "${name}" not found. Available: ${Object.keys(optionIds).join(', ')}`);
|
|
45
|
+
return id;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function listItemsByStatus(statusName) {
|
|
49
|
+
const items = ghJson(`project item-list ${project} --owner ${owner} --format json`);
|
|
50
|
+
return (items?.items || []).filter(item => {
|
|
51
|
+
const status = item.status || item.fields?.find(f => f.name === statusField)?.value;
|
|
52
|
+
return status === statusName;
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function moveItem(item, colKey) {
|
|
57
|
+
discoverField();
|
|
58
|
+
const optId = getOptionId(colKey);
|
|
59
|
+
gh(`project item-edit --id ${item.id} --project-id ${item.projectId || project} --field-id ${fieldId} --single-select-option-id ${optId}`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
async listTodo() {
|
|
64
|
+
return listItemsByStatus(colNames.todo);
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
async listInReview() {
|
|
68
|
+
const items = await listItemsByStatus(colNames.inReview);
|
|
69
|
+
// Filter to items that have new reviews
|
|
70
|
+
const withReviews = [];
|
|
71
|
+
for (const item of items) {
|
|
72
|
+
if (!item.content?.number) continue;
|
|
73
|
+
try {
|
|
74
|
+
const reviews = ghJson(`api repos/${owner}/${item.content.repository}/pulls/${item.content.number}/reviews`);
|
|
75
|
+
const hasNew = reviews?.some(r => r.state === 'COMMENTED' || r.state === 'CHANGES_REQUESTED');
|
|
76
|
+
if (hasNew) withReviews.push(item);
|
|
77
|
+
} catch {
|
|
78
|
+
// Skip items we can't check
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return withReviews;
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
async listApproved() {
|
|
85
|
+
const items = await listItemsByStatus(colNames.inReview);
|
|
86
|
+
const approved = [];
|
|
87
|
+
for (const item of items) {
|
|
88
|
+
if (!item.content?.number) continue;
|
|
89
|
+
try {
|
|
90
|
+
const reviews = ghJson(`api repos/${owner}/${item.content.repository}/pulls/${item.content.number}/reviews`);
|
|
91
|
+
const isApproved = reviews?.some(r => r.state === 'APPROVED');
|
|
92
|
+
if (isApproved) approved.push(item);
|
|
93
|
+
} catch {
|
|
94
|
+
// Skip
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return approved;
|
|
98
|
+
},
|
|
99
|
+
|
|
100
|
+
moveToInProgress(item) { return moveItem(item, 'inProgress'); },
|
|
101
|
+
moveToReview(item) { return moveItem(item, 'inReview'); },
|
|
102
|
+
moveToBlocked(item) { return moveItem(item, 'blocked'); },
|
|
103
|
+
moveToDone(item) { return moveItem(item, 'done'); },
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
// Field discovery for init wizard
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
export function discoverProjectFields(project, owner) {
|
|
112
|
+
const fields = ghJson(`project field-list ${project} --owner ${owner} --format json`);
|
|
113
|
+
return fields?.fields || [];
|
|
114
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { create as createGitHubProjects } from './github-projects.js';
|
|
2
|
+
|
|
3
|
+
const providers = {
|
|
4
|
+
'github-projects': createGitHubProjects,
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export function createBoard(config) {
|
|
8
|
+
const provider = config.board?.provider;
|
|
9
|
+
const factory = providers[provider];
|
|
10
|
+
if (!factory) {
|
|
11
|
+
throw new Error(`Unknown board provider: ${provider}. Available: ${Object.keys(providers).join(', ')}`);
|
|
12
|
+
}
|
|
13
|
+
return factory(config.board);
|
|
14
|
+
}
|