@warpmetrics/coder 0.1.4 → 0.2.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/README.md +58 -25
- package/bin/cli.js +198 -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 +40 -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 +82 -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 -75
- package/defaults/agent-revise.yml +0 -82
- 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
|
@@ -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
|
+
}
|
package/src/claude.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
|
+
|
|
3
|
+
export function run({ prompt, workdir, allowedTools = 'Bash,Read,Edit,Write,Glob,Grep', maxTurns, verbose = true }) {
|
|
4
|
+
return new Promise((resolve, reject) => {
|
|
5
|
+
const args = ['-p', prompt, '--output-format', 'json', '--allowedTools', allowedTools];
|
|
6
|
+
if (maxTurns) args.push('--max-turns', String(maxTurns));
|
|
7
|
+
|
|
8
|
+
const proc = spawn('claude', args, {
|
|
9
|
+
cwd: workdir,
|
|
10
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
let stdout = '';
|
|
14
|
+
let stderr = '';
|
|
15
|
+
proc.stdout.on('data', d => { stdout += d; });
|
|
16
|
+
proc.stderr.on('data', d => {
|
|
17
|
+
stderr += d;
|
|
18
|
+
if (verbose) process.stderr.write(d);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
proc.on('close', code => {
|
|
22
|
+
if (code !== 0) {
|
|
23
|
+
return reject(new Error(`claude exited with code ${code}: ${stderr}`));
|
|
24
|
+
}
|
|
25
|
+
try {
|
|
26
|
+
const result = JSON.parse(stdout);
|
|
27
|
+
resolve({
|
|
28
|
+
result: result.result || result,
|
|
29
|
+
sessionId: result.session_id || null,
|
|
30
|
+
costUsd: result.cost_usd || null,
|
|
31
|
+
});
|
|
32
|
+
} catch {
|
|
33
|
+
// Non-JSON output — still succeeded
|
|
34
|
+
resolve({ result: stdout, sessionId: null, costUsd: null });
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
proc.on('error', reject);
|
|
39
|
+
});
|
|
40
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
|
|
4
|
+
const CONFIG_DIR = '.warp-coder';
|
|
5
|
+
const CONFIG_FILE = 'config.json';
|
|
6
|
+
|
|
7
|
+
export function loadConfig(cwd = process.cwd()) {
|
|
8
|
+
const configPath = join(cwd, CONFIG_DIR, CONFIG_FILE);
|
|
9
|
+
if (!existsSync(configPath)) {
|
|
10
|
+
throw new Error(`Config not found: ${configPath}\nRun "warp-coder init" to create one.`);
|
|
11
|
+
}
|
|
12
|
+
return JSON.parse(readFileSync(configPath, 'utf-8'));
|
|
13
|
+
}
|
package/src/git.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { execSync } from 'child_process';
|
|
2
|
+
|
|
3
|
+
function run(cmd, opts = {}) {
|
|
4
|
+
return execSync(cmd, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], ...opts }).trim();
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function cloneRepo(repoUrl, dest) {
|
|
8
|
+
run(`git clone --depth 1 ${repoUrl} ${dest}`);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function checkoutBranch(dir, branch) {
|
|
12
|
+
run(`git checkout ${branch}`, { cwd: dir });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function createBranch(dir, name) {
|
|
16
|
+
run(`git checkout -b ${name}`, { cwd: dir });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function push(dir, branch) {
|
|
20
|
+
run(`git push -u origin ${branch}`, { cwd: dir });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function createPR(dir, { title, body, base = 'main' }) {
|
|
24
|
+
const out = run(`gh pr create --title ${JSON.stringify(title)} --body ${JSON.stringify(body)} --base ${base}`, { cwd: dir });
|
|
25
|
+
// gh pr create prints the PR URL as the last line
|
|
26
|
+
const lines = out.split('\n');
|
|
27
|
+
const url = lines[lines.length - 1];
|
|
28
|
+
const match = url.match(/\/pull\/(\d+)/);
|
|
29
|
+
return { url, number: match ? parseInt(match[1], 10) : null };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function mergePR(prNumber, { repo }) {
|
|
33
|
+
run(`gh pr merge ${prNumber} --squash --delete-branch --repo ${repo}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function getReviews(prNumber, { repo }) {
|
|
37
|
+
const out = run(`gh api repos/${repo}/pulls/${prNumber}/reviews`);
|
|
38
|
+
return JSON.parse(out);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function getPRBranch(prNumber, { repo }) {
|
|
42
|
+
const out = run(`gh pr view ${prNumber} --repo ${repo} --json headRefName --jq .headRefName`);
|
|
43
|
+
return out;
|
|
44
|
+
}
|
package/src/hooks.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { execSync } from 'child_process';
|
|
2
|
+
|
|
3
|
+
export function runHook(name, config, context) {
|
|
4
|
+
const cmd = config.hooks?.[name];
|
|
5
|
+
if (!cmd) return { ran: false };
|
|
6
|
+
|
|
7
|
+
console.log(` hook: ${name} → ${cmd}`);
|
|
8
|
+
|
|
9
|
+
const env = { ...process.env };
|
|
10
|
+
if (context.issueNumber) env.ISSUE_NUMBER = String(context.issueNumber);
|
|
11
|
+
if (context.prNumber) env.PR_NUMBER = String(context.prNumber);
|
|
12
|
+
if (context.branch) env.BRANCH = context.branch;
|
|
13
|
+
if (context.repo) env.REPO = context.repo;
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
const stdout = execSync(cmd, { cwd: context.workdir, env, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
17
|
+
return { ran: true, hook: name, stdout, stderr: '', exitCode: 0 };
|
|
18
|
+
} catch (err) {
|
|
19
|
+
const result = { ran: true, hook: name, stdout: err.stdout || '', stderr: err.stderr || '', exitCode: err.status ?? 1 };
|
|
20
|
+
// Re-throw with the captured output attached so callers still get the error
|
|
21
|
+
const wrapped = new Error(`Hook "${name}" failed (exit ${result.exitCode}): ${(err.stderr || err.message).slice(0, 200)}`);
|
|
22
|
+
wrapped.hookResult = result;
|
|
23
|
+
throw wrapped;
|
|
24
|
+
}
|
|
25
|
+
}
|
package/src/memory.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
2
|
+
import { join, dirname } from 'path';
|
|
3
|
+
|
|
4
|
+
const MEMORY_FILE = 'memory.md';
|
|
5
|
+
|
|
6
|
+
export function loadMemory(configDir) {
|
|
7
|
+
try {
|
|
8
|
+
return readFileSync(join(configDir, MEMORY_FILE), 'utf-8');
|
|
9
|
+
} catch {
|
|
10
|
+
return '';
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function saveMemory(configDir, content) {
|
|
15
|
+
mkdirSync(configDir, { recursive: true });
|
|
16
|
+
writeFileSync(join(configDir, MEMORY_FILE), content);
|
|
17
|
+
}
|
package/src/reflect.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import * as claude from './claude.js';
|
|
2
|
+
import { loadMemory, saveMemory } from './memory.js';
|
|
3
|
+
|
|
4
|
+
export async function reflect({ configDir, step, issue, prNumber, success, error, hookOutputs, reviewComments, claudeOutput, maxLines = 100 }) {
|
|
5
|
+
const currentMemory = loadMemory(configDir);
|
|
6
|
+
|
|
7
|
+
const sections = [
|
|
8
|
+
'# Reflect on this task and update the memory file.',
|
|
9
|
+
'',
|
|
10
|
+
`Step: ${step}`,
|
|
11
|
+
`Outcome: ${success ? 'success' : 'failure'}`,
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
if (issue) sections.push(`Issue: #${issue.number} — ${issue.title}`);
|
|
15
|
+
if (prNumber) sections.push(`PR: #${prNumber}`);
|
|
16
|
+
|
|
17
|
+
if (error) {
|
|
18
|
+
sections.push('', '## Error', '```', truncate(error, 500), '```');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (hookOutputs?.length) {
|
|
22
|
+
sections.push('', '## Hook outputs');
|
|
23
|
+
for (const h of hookOutputs) {
|
|
24
|
+
sections.push(`### ${h.hook} (exit ${h.exitCode})`);
|
|
25
|
+
const output = (h.stdout + h.stderr).trim();
|
|
26
|
+
if (output) sections.push('```', truncate(output, 1000), '```');
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (reviewComments?.length) {
|
|
31
|
+
sections.push('', '## Review comments');
|
|
32
|
+
for (const c of reviewComments) {
|
|
33
|
+
sections.push(`- ${c.user?.login || 'reviewer'}: ${truncate(c.body || '', 200)}`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (claudeOutput) {
|
|
38
|
+
sections.push('', '## Claude output (truncated)', '```', truncate(String(claudeOutput), 1000), '```');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const prompt = [
|
|
42
|
+
'You are a memory manager for an automated coding agent.',
|
|
43
|
+
'',
|
|
44
|
+
`Here is the agent's current memory file (lessons learned from past tasks):`,
|
|
45
|
+
'',
|
|
46
|
+
currentMemory ? '```\n' + currentMemory + '\n```' : '(no memory yet)',
|
|
47
|
+
'',
|
|
48
|
+
'Here is what just happened:',
|
|
49
|
+
'',
|
|
50
|
+
sections.join('\n'),
|
|
51
|
+
'',
|
|
52
|
+
'Instructions:',
|
|
53
|
+
`- Output the COMPLETE updated memory file (markdown).`,
|
|
54
|
+
`- Keep it under ${maxLines} lines.`,
|
|
55
|
+
`- Preserve relevant existing lessons. Add new ones from this task.`,
|
|
56
|
+
`- Remove lessons that are contradicted by new evidence.`,
|
|
57
|
+
`- Be concise — each lesson should be 1-2 lines max.`,
|
|
58
|
+
`- Group lessons by topic (e.g. "## Testing", "## Code patterns").`,
|
|
59
|
+
`- Output ONLY the memory file content, no explanation.`,
|
|
60
|
+
].join('\n');
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
const result = await claude.run({
|
|
64
|
+
prompt,
|
|
65
|
+
workdir: process.cwd(),
|
|
66
|
+
allowedTools: '',
|
|
67
|
+
maxTurns: 1,
|
|
68
|
+
verbose: false,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const content = typeof result.result === 'string' ? result.result : JSON.stringify(result.result);
|
|
72
|
+
saveMemory(configDir, content.trim() + '\n');
|
|
73
|
+
} catch (err) {
|
|
74
|
+
// Reflection is best-effort — don't break the pipeline
|
|
75
|
+
console.log(` warning: reflect failed: ${err.message}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function truncate(str, maxLen) {
|
|
80
|
+
if (str.length <= maxLen) return str;
|
|
81
|
+
return str.slice(0, maxLen) + '\n... (truncated)';
|
|
82
|
+
}
|
package/src/revise.js
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// Apply review feedback on an existing 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 revise(item, { board, config, log }) {
|
|
16
|
+
const prNumber = item.content?.number;
|
|
17
|
+
const repo = config.repo;
|
|
18
|
+
const repoName = repo.replace(/\.git$/, '').split('/').pop();
|
|
19
|
+
const maxRevisions = config.maxRevisions || 3;
|
|
20
|
+
const workdir = join(tmpdir(), 'warp-coder', `revise-${prNumber}`);
|
|
21
|
+
const configDir = join(process.cwd(), CONFIG_DIR);
|
|
22
|
+
|
|
23
|
+
log(`Revising PR #${prNumber}`);
|
|
24
|
+
|
|
25
|
+
// Check revision limit
|
|
26
|
+
if (config.warpmetricsApiKey) {
|
|
27
|
+
try {
|
|
28
|
+
const count = await warp.countRevisions(config.warpmetricsApiKey, { prNumber, repo: repoName });
|
|
29
|
+
if (count >= maxRevisions) {
|
|
30
|
+
log(` revision limit reached (${count}/${maxRevisions}) — moving to Blocked`);
|
|
31
|
+
try { await board.moveToBlocked(item); } catch {}
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
log(` revision ${count + 1}/${maxRevisions}`);
|
|
35
|
+
} catch (err) {
|
|
36
|
+
log(` warning: revision check failed: ${err.message}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// WarpMetrics: start pipeline
|
|
41
|
+
let groupId = null;
|
|
42
|
+
if (config.warpmetricsApiKey) {
|
|
43
|
+
try {
|
|
44
|
+
const pipeline = await warp.startPipeline(config.warpmetricsApiKey, {
|
|
45
|
+
step: 'revise',
|
|
46
|
+
repo: repoName,
|
|
47
|
+
prNumber,
|
|
48
|
+
});
|
|
49
|
+
groupId = pipeline.groupId;
|
|
50
|
+
log(` pipeline: run=${pipeline.runId} group=${groupId}`);
|
|
51
|
+
} catch (err) {
|
|
52
|
+
log(` warning: pipeline start failed: ${err.message}`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let success = false;
|
|
57
|
+
let claudeResult = null;
|
|
58
|
+
let taskError = null;
|
|
59
|
+
let reviewComments = [];
|
|
60
|
+
const hookOutputs = [];
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
// Clone + checkout PR branch
|
|
64
|
+
rmSync(workdir, { recursive: true, force: true });
|
|
65
|
+
mkdirSync(workdir, { recursive: true });
|
|
66
|
+
log(` cloning into ${workdir}`);
|
|
67
|
+
git.cloneRepo(repo, workdir);
|
|
68
|
+
|
|
69
|
+
const branch = git.getPRBranch(prNumber, { repo: repoName });
|
|
70
|
+
git.checkoutBranch(workdir, branch);
|
|
71
|
+
log(` branch: ${branch}`);
|
|
72
|
+
|
|
73
|
+
// Fetch review comments for context
|
|
74
|
+
try {
|
|
75
|
+
reviewComments = git.getReviews(prNumber, { repo: repoName });
|
|
76
|
+
} catch (err) {
|
|
77
|
+
log(` warning: could not fetch reviews: ${err.message}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Load memory for prompt enrichment
|
|
81
|
+
const memory = config.memory?.enabled !== false ? loadMemory(configDir) : '';
|
|
82
|
+
|
|
83
|
+
// Claude
|
|
84
|
+
const promptParts = [
|
|
85
|
+
`You are working on PR #${prNumber} in ${repoName}.`,
|
|
86
|
+
'',
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
if (memory) {
|
|
90
|
+
promptParts.push(
|
|
91
|
+
'Lessons learned from previous tasks in this repository:',
|
|
92
|
+
'',
|
|
93
|
+
memory,
|
|
94
|
+
'',
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
promptParts.push(
|
|
99
|
+
'A code review has been submitted with comments. Your job:',
|
|
100
|
+
'',
|
|
101
|
+
'1. Read all review comments on this PR',
|
|
102
|
+
'2. Apply the suggested fixes',
|
|
103
|
+
'3. Run tests to make sure everything passes',
|
|
104
|
+
'4. Commit the fixes with a message like "Address review feedback"',
|
|
105
|
+
'',
|
|
106
|
+
'Do NOT open a new PR — just implement the fixes and commit.',
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
const prompt = promptParts.join('\n');
|
|
110
|
+
|
|
111
|
+
log(' running claude...');
|
|
112
|
+
claudeResult = await claude.run({
|
|
113
|
+
prompt,
|
|
114
|
+
workdir,
|
|
115
|
+
allowedTools: config.claude?.allowedTools,
|
|
116
|
+
maxTurns: config.claude?.maxTurns,
|
|
117
|
+
});
|
|
118
|
+
log(` claude done (cost: $${claudeResult.costUsd ?? '?'})`);
|
|
119
|
+
|
|
120
|
+
// Hook: onBeforePush
|
|
121
|
+
try {
|
|
122
|
+
const h = runHook('onBeforePush', config, { workdir, prNumber, branch, repo: repoName });
|
|
123
|
+
if (h.ran) hookOutputs.push(h);
|
|
124
|
+
} catch (err) {
|
|
125
|
+
if (err.hookResult) hookOutputs.push(err.hookResult);
|
|
126
|
+
throw err;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Push
|
|
130
|
+
log(' pushing...');
|
|
131
|
+
git.push(workdir, branch);
|
|
132
|
+
|
|
133
|
+
// Move back to In Review
|
|
134
|
+
try {
|
|
135
|
+
await board.moveToReview(item);
|
|
136
|
+
} catch (err) {
|
|
137
|
+
log(` warning: could not move to In Review: ${err.message}`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
success = true;
|
|
141
|
+
} catch (err) {
|
|
142
|
+
taskError = err.message;
|
|
143
|
+
log(` failed: ${err.message}`);
|
|
144
|
+
} finally {
|
|
145
|
+
// WarpMetrics: record outcome
|
|
146
|
+
if (config.warpmetricsApiKey && groupId) {
|
|
147
|
+
try {
|
|
148
|
+
const outcome = await warp.recordOutcome(config.warpmetricsApiKey, groupId, {
|
|
149
|
+
step: 'revise',
|
|
150
|
+
success,
|
|
151
|
+
costUsd: claudeResult?.costUsd,
|
|
152
|
+
error: taskError,
|
|
153
|
+
hooksFailed: hookOutputs.some(h => h.exitCode !== 0),
|
|
154
|
+
prNumber,
|
|
155
|
+
reviewCommentCount: reviewComments.length,
|
|
156
|
+
});
|
|
157
|
+
log(` outcome: ${outcome.name}`);
|
|
158
|
+
} catch (err) {
|
|
159
|
+
log(` warning: outcome recording failed: ${err.message}`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Reflect
|
|
164
|
+
if (config.memory?.enabled !== false) {
|
|
165
|
+
try {
|
|
166
|
+
await reflect({
|
|
167
|
+
configDir,
|
|
168
|
+
step: 'revise',
|
|
169
|
+
prNumber,
|
|
170
|
+
success,
|
|
171
|
+
error: taskError,
|
|
172
|
+
hookOutputs: hookOutputs.filter(h => h.ran),
|
|
173
|
+
reviewComments,
|
|
174
|
+
claudeOutput: claudeResult?.result,
|
|
175
|
+
maxLines: config.memory?.maxLines || 100,
|
|
176
|
+
});
|
|
177
|
+
log(' reflect: memory updated');
|
|
178
|
+
} catch (err) {
|
|
179
|
+
log(` warning: reflect failed: ${err.message}`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Cleanup
|
|
184
|
+
rmSync(workdir, { recursive: true, force: true });
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return success;
|
|
188
|
+
}
|
|
@@ -1,11 +1,9 @@
|
|
|
1
|
-
//
|
|
1
|
+
// WarpMetrics instrumentation client.
|
|
2
2
|
// Zero external dependencies — uses Node built-ins + global fetch.
|
|
3
3
|
|
|
4
4
|
import crypto from 'crypto';
|
|
5
|
-
import { writeFileSync, readFileSync, existsSync } from 'fs';
|
|
6
5
|
|
|
7
6
|
const API_URL = 'https://api.warpmetrics.com';
|
|
8
|
-
const STATE_FILE = '.pipeline-state.json';
|
|
9
7
|
|
|
10
8
|
// ---------------------------------------------------------------------------
|
|
11
9
|
// ID generation
|
|
@@ -18,7 +16,7 @@ export function generateId(prefix) {
|
|
|
18
16
|
}
|
|
19
17
|
|
|
20
18
|
// ---------------------------------------------------------------------------
|
|
21
|
-
//
|
|
19
|
+
// Events API (same wire format as @warpmetrics/warp)
|
|
22
20
|
// ---------------------------------------------------------------------------
|
|
23
21
|
|
|
24
22
|
export async function sendEvents(apiKey, batch) {
|
|
@@ -50,7 +48,7 @@ export async function sendEvents(apiKey, batch) {
|
|
|
50
48
|
}
|
|
51
49
|
|
|
52
50
|
// ---------------------------------------------------------------------------
|
|
53
|
-
//
|
|
51
|
+
// Query API
|
|
54
52
|
// ---------------------------------------------------------------------------
|
|
55
53
|
|
|
56
54
|
export async function findRuns(apiKey, label, { limit = 20 } = {}) {
|
|
@@ -97,14 +95,62 @@ export async function registerClassifications(apiKey) {
|
|
|
97
95
|
}
|
|
98
96
|
|
|
99
97
|
// ---------------------------------------------------------------------------
|
|
100
|
-
//
|
|
98
|
+
// Pipeline helpers — start run/group and record outcome
|
|
101
99
|
// ---------------------------------------------------------------------------
|
|
102
100
|
|
|
103
|
-
export function
|
|
104
|
-
|
|
101
|
+
export async function startPipeline(apiKey, { step, repo, issueNumber, issueTitle, prNumber }) {
|
|
102
|
+
const runId = generateId('run');
|
|
103
|
+
const groupId = generateId('grp');
|
|
104
|
+
const now = new Date().toISOString();
|
|
105
|
+
|
|
106
|
+
const opts = { repo, step };
|
|
107
|
+
if (issueNumber) opts.issue = String(issueNumber);
|
|
108
|
+
if (issueTitle) opts.title = issueTitle;
|
|
109
|
+
if (prNumber) opts.pr_number = String(prNumber);
|
|
110
|
+
|
|
111
|
+
await sendEvents(apiKey, {
|
|
112
|
+
runs: [{ id: runId, label: 'agent-pipeline', opts, refId: null, timestamp: now }],
|
|
113
|
+
groups: [{ id: groupId, label: step, opts: { triggered_at: now }, timestamp: now }],
|
|
114
|
+
links: [{ parentId: runId, childId: groupId, type: 'group', timestamp: now }],
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
return { runId, groupId };
|
|
105
118
|
}
|
|
106
119
|
|
|
107
|
-
export function
|
|
108
|
-
|
|
109
|
-
|
|
120
|
+
export async function recordOutcome(apiKey, groupId, { step, success, costUsd, error, hooksFailed, issueNumber, prNumber, reviewCommentCount }) {
|
|
121
|
+
const names = {
|
|
122
|
+
implement: { true: 'PR Created', false: 'Implementation Failed' },
|
|
123
|
+
revise: { true: 'Fixes Applied', false: 'Revision Failed' },
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const name = names[step]?.[String(success)] || `${step}: ${success ? 'success' : 'failure'}`;
|
|
127
|
+
const id = generateId('oc');
|
|
128
|
+
const now = new Date().toISOString();
|
|
129
|
+
|
|
130
|
+
const opts = { status: success ? 'success' : 'failure', step };
|
|
131
|
+
if (costUsd != null) opts.cost_usd = String(costUsd);
|
|
132
|
+
if (error) opts.error = error.slice(0, 500);
|
|
133
|
+
if (hooksFailed) opts.hooks_failed = 'true';
|
|
134
|
+
if (issueNumber) opts.issue = String(issueNumber);
|
|
135
|
+
if (prNumber) opts.pr_number = String(prNumber);
|
|
136
|
+
if (reviewCommentCount) opts.review_comments = String(reviewCommentCount);
|
|
137
|
+
|
|
138
|
+
await sendEvents(apiKey, {
|
|
139
|
+
outcomes: [{ id, refId: groupId, name, opts, timestamp: now }],
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
return { id, name };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export async function countRevisions(apiKey, { prNumber, repo }) {
|
|
146
|
+
try {
|
|
147
|
+
const runs = await findRuns(apiKey, 'agent-pipeline');
|
|
148
|
+
return runs.filter(r =>
|
|
149
|
+
r.opts?.step === 'revise' &&
|
|
150
|
+
r.opts?.pr_number === String(prNumber) &&
|
|
151
|
+
r.opts?.repo === repo
|
|
152
|
+
).length;
|
|
153
|
+
} catch {
|
|
154
|
+
return 0;
|
|
155
|
+
}
|
|
110
156
|
}
|
package/src/watch.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Main poll loop: watches the board and processes tasks sequentially.
|
|
2
|
+
|
|
3
|
+
import { loadConfig } from './config.js';
|
|
4
|
+
import { createBoard } from './boards/index.js';
|
|
5
|
+
import { implement } from './agent.js';
|
|
6
|
+
import { revise } from './revise.js';
|
|
7
|
+
import * as git from './git.js';
|
|
8
|
+
import { runHook } from './hooks.js';
|
|
9
|
+
|
|
10
|
+
export async function watch() {
|
|
11
|
+
const config = loadConfig();
|
|
12
|
+
const board = createBoard(config);
|
|
13
|
+
const pollInterval = (config.pollInterval || 30) * 1000;
|
|
14
|
+
|
|
15
|
+
let running = true;
|
|
16
|
+
const shutdown = () => {
|
|
17
|
+
console.log('\nShutting down...');
|
|
18
|
+
running = false;
|
|
19
|
+
};
|
|
20
|
+
process.on('SIGINT', shutdown);
|
|
21
|
+
process.on('SIGTERM', shutdown);
|
|
22
|
+
|
|
23
|
+
const log = msg => console.log(`[${new Date().toISOString()}] ${msg}`);
|
|
24
|
+
|
|
25
|
+
log('warp-coder watching...');
|
|
26
|
+
log(` board: ${config.board.provider} (project ${config.board.project})`);
|
|
27
|
+
log(` repo: ${config.repo}`);
|
|
28
|
+
log(` poll interval: ${config.pollInterval || 30}s`);
|
|
29
|
+
|
|
30
|
+
while (running) {
|
|
31
|
+
try {
|
|
32
|
+
// 1. Pick up new tasks from Todo
|
|
33
|
+
const todoItems = await board.listTodo();
|
|
34
|
+
if (todoItems.length > 0) {
|
|
35
|
+
const item = todoItems[0];
|
|
36
|
+
log(`Found todo: #${item.content?.number} — ${item.content?.title}`);
|
|
37
|
+
await implement(item, { board, config, log });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// 2. Check for items needing revision
|
|
41
|
+
const reviewItems = await board.listInReview();
|
|
42
|
+
for (const item of reviewItems) {
|
|
43
|
+
if (!running) break;
|
|
44
|
+
log(`Found review feedback: PR #${item.content?.number}`);
|
|
45
|
+
await revise(item, { board, config, log });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// 3. Merge approved PRs
|
|
49
|
+
const approvedItems = await board.listApproved();
|
|
50
|
+
for (const item of approvedItems) {
|
|
51
|
+
if (!running) break;
|
|
52
|
+
const prNumber = item.content?.number;
|
|
53
|
+
const repoName = config.repo.replace(/\.git$/, '').split('/').pop();
|
|
54
|
+
log(`Merging approved PR #${prNumber}`);
|
|
55
|
+
try {
|
|
56
|
+
runHook('onBeforeMerge', config, { prNumber, repo: repoName });
|
|
57
|
+
git.mergePR(prNumber, { repo: repoName });
|
|
58
|
+
runHook('onMerged', config, { prNumber, repo: repoName });
|
|
59
|
+
await board.moveToDone(item);
|
|
60
|
+
log(` merged and moved to Done`);
|
|
61
|
+
} catch (err) {
|
|
62
|
+
log(` merge failed: ${err.message}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
} catch (err) {
|
|
66
|
+
log(`Poll error: ${err.message}`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Sleep
|
|
70
|
+
if (running) {
|
|
71
|
+
await new Promise(resolve => setTimeout(resolve, pollInterval));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
log('Stopped.');
|
|
76
|
+
}
|