@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/src/claude.js ADDED
@@ -0,0 +1,37 @@
1
+ import { spawn } from 'child_process';
2
+
3
+ export function run({ prompt, workdir, allowedTools = 'Bash,Read,Edit,Write,Glob,Grep', maxTurns }) {
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 => { stderr += d; });
17
+
18
+ proc.on('close', code => {
19
+ if (code !== 0) {
20
+ return reject(new Error(`claude exited with code ${code}: ${stderr}`));
21
+ }
22
+ try {
23
+ const result = JSON.parse(stdout);
24
+ resolve({
25
+ result: result.result || result,
26
+ sessionId: result.session_id || null,
27
+ costUsd: result.cost_usd || null,
28
+ });
29
+ } catch {
30
+ // Non-JSON output — still succeeded
31
+ resolve({ result: stdout, sessionId: null, costUsd: null });
32
+ }
33
+ });
34
+
35
+ proc.on('error', reject);
36
+ });
37
+ }
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,81 @@
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
+ });
69
+
70
+ const content = typeof result.result === 'string' ? result.result : JSON.stringify(result.result);
71
+ saveMemory(configDir, content.trim() + '\n');
72
+ } catch (err) {
73
+ // Reflection is best-effort — don't break the pipeline
74
+ console.log(` warning: reflect failed: ${err.message}`);
75
+ }
76
+ }
77
+
78
+ function truncate(str, maxLen) {
79
+ if (str.length <= maxLen) return str;
80
+ return str.slice(0, maxLen) + '\n... (truncated)';
81
+ }
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
- // Agent pipeline — shared helpers for WarpMetrics instrumentation.
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
- // WarpMetrics Events API (same wire format as @warpmetrics/warp)
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
- // WarpMetrics Query API
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
- // Cross-step state
98
+ // Pipeline helpers — start run/group and record outcome
101
99
  // ---------------------------------------------------------------------------
102
100
 
103
- export function saveState(state) {
104
- writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
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 loadState() {
108
- if (!existsSync(STATE_FILE)) return null;
109
- return JSON.parse(readFileSync(STATE_FILE, 'utf-8'));
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
+ }