@warpmetrics/coder 0.2.5 → 0.2.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@warpmetrics/coder",
3
- "version": "0.2.5",
3
+ "version": "0.2.7",
4
4
  "type": "module",
5
5
  "description": "Local agent loop for implementing GitHub issues with Claude Code. Powered by WarpMetrics.",
6
6
  "bin": {
package/src/agent.js CHANGED
@@ -12,7 +12,7 @@ import { reflect } from './reflect.js';
12
12
 
13
13
  const CONFIG_DIR = '.warp-coder';
14
14
 
15
- export async function implement(item, { board, config, log }) {
15
+ export async function implement(item, { board, config, log, refActId }) {
16
16
  const issueNumber = item.content?.number;
17
17
  const issueTitle = item.content?.title || `Issue #${issueNumber}`;
18
18
  const issueBody = item.content?.body || '';
@@ -45,6 +45,7 @@ export async function implement(item, { board, config, log }) {
45
45
  repo: repoName,
46
46
  issueNumber,
47
47
  issueTitle,
48
+ refActId,
48
49
  });
49
50
  runId = pipeline.runId;
50
51
  groupId = pipeline.groupId;
@@ -203,9 +204,9 @@ export async function implement(item, { board, config, log }) {
203
204
  log(` outcome: ${outcome.name}`);
204
205
 
205
206
  // Emit act so warp-review can link its run as a follow-up
206
- if (success && actId) {
207
+ if (success && actId && outcome.runOutcomeId) {
207
208
  await warp.emitAct(config.warpmetricsApiKey, {
208
- outcomeId: outcome.id,
209
+ outcomeId: outcome.runOutcomeId,
209
210
  actId,
210
211
  name: 'review',
211
212
  });
@@ -111,6 +111,14 @@ export function create({ project, owner, statusField = 'Status', columns = {} })
111
111
  const repo = item.content.repository || `${owner}/${item.content.repository}`;
112
112
  const reviews = ghJson(`api repos/${repo}/pulls/${prNumber}/reviews`);
113
113
  item._prNumber = prNumber;
114
+
115
+ // Parse act ID from the most recent warp-review review body
116
+ const actMatch = reviews
117
+ ?.slice().reverse()
118
+ .map(r => (r.body || '').match(/<!-- wm:act:(wm_act_\w+) -->/))
119
+ .find(m => m);
120
+ if (actMatch) item._reviewActId = actMatch[1];
121
+
114
122
  if (reviews?.some(r => r.state === 'APPROVED')) {
115
123
  approved.push(item);
116
124
  } else if (reviews?.some(r => r.state === 'COMMENTED' || r.state === 'CHANGES_REQUESTED')) {
package/src/git.js CHANGED
@@ -59,6 +59,14 @@ export function getReviewComments(prNumber, { repo }) {
59
59
  return JSON.parse(out);
60
60
  }
61
61
 
62
+ export function updatePRBody(prNumber, { repo, body }) {
63
+ run(`gh pr edit ${prNumber} --repo ${repo} --body ${JSON.stringify(body)}`);
64
+ }
65
+
66
+ export function getPRBody(prNumber, { repo }) {
67
+ return run(`gh pr view ${prNumber} --repo ${repo} --json body --jq .body`);
68
+ }
69
+
62
70
  export function getPRBranch(prNumber, { repo }) {
63
71
  const out = run(`gh pr view ${prNumber} --repo ${repo} --json headRefName --jq .headRefName`);
64
72
  return out;
package/src/revise.js CHANGED
@@ -12,7 +12,7 @@ import { reflect } from './reflect.js';
12
12
 
13
13
  const CONFIG_DIR = '.warp-coder';
14
14
 
15
- export async function revise(item, { board, config, log }) {
15
+ export async function revise(item, { board, config, log, refActId }) {
16
16
  const prNumber = item._prNumber || item.content?.number;
17
17
  const repo = config.repo;
18
18
  const repoName = repo.replace(/\.git$/, '').replace(/^.*github\.com[:\/]/, '');
@@ -20,6 +20,9 @@ export async function revise(item, { board, config, log }) {
20
20
  const workdir = join(tmpdir(), 'warp-coder', `revise-${prNumber}`);
21
21
  const configDir = join(process.cwd(), CONFIG_DIR);
22
22
 
23
+ // Pre-generate act ID for chaining (update PR body so warp-review can link next review)
24
+ const actId = config.warpmetricsApiKey ? warp.generateId('act') : null;
25
+
23
26
  log(`Revising PR #${prNumber}`);
24
27
 
25
28
  // Move to In Progress
@@ -33,13 +36,13 @@ export async function revise(item, { board, config, log }) {
33
36
  // Check revision limit
34
37
  if (config.warpmetricsApiKey) {
35
38
  try {
36
- const count = await warp.countRevisions(config.warpmetricsApiKey, { prNumber, repo: repoName });
37
- if (count >= maxRevisions) {
38
- log(` revision limit reached (${count}/${maxRevisions}) — moving to Blocked`);
39
+ const revisionCount = await warp.countRevisions(config.warpmetricsApiKey, { prNumber, repo: repoName });
40
+ if (revisionCount >= maxRevisions) {
41
+ log(` revision limit reached (${revisionCount}/${maxRevisions}) — moving to Blocked`);
39
42
  try { await board.moveToBlocked(item); } catch {}
40
- return false;
43
+ return { success: false, reason: 'max_retries', count: revisionCount };
41
44
  }
42
- log(` revision ${count + 1}/${maxRevisions}`);
45
+ log(` revision ${revisionCount + 1}/${maxRevisions}`);
43
46
  } catch (err) {
44
47
  log(` warning: revision check failed: ${err.message}`);
45
48
  }
@@ -54,6 +57,7 @@ export async function revise(item, { board, config, log }) {
54
57
  step: 'revise',
55
58
  repo: repoName,
56
59
  prNumber,
60
+ refActId,
57
61
  });
58
62
  runId = pipeline.runId;
59
63
  groupId = pipeline.groupId;
@@ -182,6 +186,20 @@ export async function revise(item, { board, config, log }) {
182
186
  log(' pushing...');
183
187
  git.push(workdir, branch);
184
188
 
189
+ // Update PR body with new act ID for next review cycle
190
+ if (actId) {
191
+ try {
192
+ let body = git.getPRBody(prNumber, { repo: repoName });
193
+ body = body.replace(/<!-- wm:act:wm_act_\w+ -->/, `<!-- wm:act:${actId} -->`);
194
+ if (!body.includes(`<!-- wm:act:${actId} -->`)) {
195
+ body += `\n\n<!-- wm:act:${actId} -->`;
196
+ }
197
+ git.updatePRBody(prNumber, { repo: repoName, body });
198
+ } catch (err) {
199
+ log(` warning: could not update PR body with act ID: ${err.message}`);
200
+ }
201
+ }
202
+
185
203
  // Move back to In Review
186
204
  try {
187
205
  await board.moveToReview(item);
@@ -212,6 +230,15 @@ export async function revise(item, { board, config, log }) {
212
230
  reviewCommentCount: reviewComments.length,
213
231
  });
214
232
  log(` outcome: ${outcome.name}`);
233
+
234
+ // Emit act so warp-review can link its next review as a follow-up
235
+ if (success && actId && outcome.runOutcomeId) {
236
+ await warp.emitAct(config.warpmetricsApiKey, {
237
+ outcomeId: outcome.runOutcomeId,
238
+ actId,
239
+ name: 'review',
240
+ });
241
+ }
215
242
  } catch (err) {
216
243
  log(` warning: outcome recording failed: ${err.message}`);
217
244
  }
@@ -241,5 +268,5 @@ export async function revise(item, { board, config, log }) {
241
268
  rmSync(workdir, { recursive: true, force: true });
242
269
  }
243
270
 
244
- return success;
271
+ return { success, reason: success ? 'ok' : 'error' };
245
272
  }
package/src/warp.js CHANGED
@@ -69,6 +69,8 @@ export async function registerClassifications(apiKey) {
69
69
  const items = [
70
70
  { name: 'PR Created', classification: 'success' },
71
71
  { name: 'Fixes Applied', classification: 'success' },
72
+ { name: 'Merged', classification: 'success' },
73
+ { name: 'Shipped', classification: 'success' },
72
74
  { name: 'Issue Understood', classification: 'success' },
73
75
  { name: 'Needs Clarification', classification: 'neutral' },
74
76
  { name: 'Needs Human', classification: 'neutral' },
@@ -94,11 +96,37 @@ export async function registerClassifications(apiKey) {
94
96
  }
95
97
  }
96
98
 
99
+ // ---------------------------------------------------------------------------
100
+ // Issue run — the root of the lifecycle chain
101
+ // ---------------------------------------------------------------------------
102
+
103
+ export async function createIssueRun(apiKey, { repo, issueNumber, issueTitle }) {
104
+ const runId = generateId('run');
105
+ const outcomeId = generateId('oc');
106
+ const actId = generateId('act');
107
+ const now = new Date().toISOString();
108
+
109
+ await sendEvents(apiKey, {
110
+ runs: [{ id: runId, label: 'issue', opts: { repo, issue: String(issueNumber), title: issueTitle }, refId: null, timestamp: now }],
111
+ outcomes: [{ id: outcomeId, refId: runId, name: 'Started', opts: null, timestamp: now }],
112
+ acts: [{ id: actId, refId: outcomeId, name: 'implement', opts: null, timestamp: now }],
113
+ });
114
+
115
+ return { runId, actId };
116
+ }
117
+
118
+ export async function closeIssueRun(apiKey, { runId, name, opts }) {
119
+ const now = new Date().toISOString();
120
+ await sendEvents(apiKey, {
121
+ outcomes: [{ id: generateId('oc'), refId: runId, name, opts: opts || null, timestamp: now }],
122
+ });
123
+ }
124
+
97
125
  // ---------------------------------------------------------------------------
98
126
  // Pipeline helpers — start run/group and record outcome
99
127
  // ---------------------------------------------------------------------------
100
128
 
101
- export async function startPipeline(apiKey, { step, repo, issueNumber, issueTitle, prNumber }) {
129
+ export async function startPipeline(apiKey, { step, repo, issueNumber, issueTitle, prNumber, refActId }) {
102
130
  const runId = generateId('run');
103
131
  const groupId = generateId('grp');
104
132
  const now = new Date().toISOString();
@@ -109,7 +137,7 @@ export async function startPipeline(apiKey, { step, repo, issueNumber, issueTitl
109
137
  if (prNumber) opts.pr_number = String(prNumber);
110
138
 
111
139
  await sendEvents(apiKey, {
112
- runs: [{ id: runId, label: 'agent-pipeline', opts, refId: null, timestamp: now }],
140
+ runs: [{ id: runId, label: 'agent-pipeline', opts, refId: refActId || null, timestamp: now }],
113
141
  groups: [{ id: groupId, label: step, opts: { triggered_at: now }, timestamp: now }],
114
142
  links: [{ parentId: runId, childId: groupId, type: 'group', timestamp: now }],
115
143
  });
@@ -121,6 +149,7 @@ export async function recordOutcome(apiKey, { runId, groupId }, { step, success,
121
149
  const names = {
122
150
  implement: { true: 'PR Created', false: 'Implementation Failed' },
123
151
  revise: { true: 'Fixes Applied', false: 'Revision Failed' },
152
+ merge: { true: 'Merged', false: 'Merge Failed' },
124
153
  };
125
154
 
126
155
  const name = names[step]?.[String(success)] || `${step}: ${success ? 'success' : 'failure'}`;
@@ -134,16 +163,20 @@ export async function recordOutcome(apiKey, { runId, groupId }, { step, success,
134
163
  if (prNumber) opts.pr_number = String(prNumber);
135
164
  if (reviewCommentCount) opts.review_comments = String(reviewCommentCount);
136
165
 
166
+ const groupOutcomeId = generateId('oc');
137
167
  const outcomes = [
138
- { id: generateId('oc'), refId: groupId, name, opts, timestamp: now },
168
+ { id: groupOutcomeId, refId: groupId, name, opts, timestamp: now },
139
169
  ];
170
+
171
+ let runOutcomeId = null;
140
172
  if (runId) {
141
- outcomes.push({ id: generateId('oc'), refId: runId, name, opts, timestamp: now });
173
+ runOutcomeId = generateId('oc');
174
+ outcomes.push({ id: runOutcomeId, refId: runId, name, opts, timestamp: now });
142
175
  }
143
176
 
144
177
  await sendEvents(apiKey, { outcomes });
145
178
 
146
- return { id: outcomes[0].id, name };
179
+ return { id: groupOutcomeId, runOutcomeId, name };
147
180
  }
148
181
 
149
182
  export async function emitAct(apiKey, { outcomeId, actId, name, opts }) {
package/src/watch.js CHANGED
@@ -5,12 +5,17 @@ import { createBoard } from './boards/index.js';
5
5
  import { implement } from './agent.js';
6
6
  import { revise } from './revise.js';
7
7
  import * as git from './git.js';
8
+ import * as warp from './warp.js';
8
9
  import { runHook } from './hooks.js';
9
10
 
10
11
  export async function watch() {
11
12
  const config = loadConfig();
12
13
  const board = createBoard(config);
13
14
  const pollInterval = (config.pollInterval || 30) * 1000;
15
+ const repoName = config.repo.replace(/\.git$/, '').replace(/^.*github\.com[:\/]/, '');
16
+
17
+ // Track issue runs across poll cycles (issueNumber → { runId })
18
+ const issueRuns = new Map();
14
19
 
15
20
  let running = true;
16
21
  let sleepResolve = null;
@@ -42,8 +47,26 @@ export async function watch() {
42
47
  const todoItems = await board.listTodo();
43
48
  if (todoItems.length > 0) {
44
49
  const item = todoItems[0];
45
- log(`Found todo: #${item.content?.number} — ${item.content?.title}`);
46
- await implement(item, { board, config, log });
50
+ const issueNumber = item.content?.number;
51
+ const issueTitle = item.content?.title;
52
+ log(`Found todo: #${issueNumber} — ${issueTitle}`);
53
+
54
+ // Create issue run (root of the lifecycle chain)
55
+ let implementActId = null;
56
+ if (config.warpmetricsApiKey && !issueRuns.has(issueNumber)) {
57
+ try {
58
+ const issue = await warp.createIssueRun(config.warpmetricsApiKey, {
59
+ repo: repoName, issueNumber, issueTitle,
60
+ });
61
+ issueRuns.set(issueNumber, { runId: issue.runId });
62
+ implementActId = issue.actId;
63
+ log(` issue run: ${issue.runId}`);
64
+ } catch (err) {
65
+ log(` warning: issue run creation failed: ${err.message}`);
66
+ }
67
+ }
68
+
69
+ await implement(item, { board, config, log, refActId: implementActId });
47
70
  }
48
71
 
49
72
  // 2. Check for items needing revision
@@ -51,7 +74,24 @@ export async function watch() {
51
74
  for (const item of reviewItems) {
52
75
  if (!running) break;
53
76
  log(`Found review feedback: PR #${item._prNumber || item.content?.number}`);
54
- await revise(item, { board, config, log });
77
+ const result = await revise(item, { board, config, log, refActId: item._reviewActId });
78
+
79
+ // Record outcome on the issue run if revision failed terminally
80
+ const issueNumber = item.content?.number;
81
+ const issueCtx = issueNumber ? issueRuns.get(issueNumber) : null;
82
+ if (!result.success && issueCtx && config.warpmetricsApiKey) {
83
+ const name = result.reason === 'max_retries' ? 'Max Retries' : 'Revision Failed';
84
+ try {
85
+ await warp.closeIssueRun(config.warpmetricsApiKey, {
86
+ runId: issueCtx.runId,
87
+ name,
88
+ opts: { pr_number: String(item._prNumber || ''), revisions: String(result.count || '') },
89
+ });
90
+ log(` issue run: ${name}`);
91
+ } catch (err) {
92
+ log(` warning: issue run outcome failed: ${err.message}`);
93
+ }
94
+ }
55
95
  }
56
96
 
57
97
  // 3. Merge approved PRs
@@ -59,16 +99,60 @@ export async function watch() {
59
99
  for (const item of approvedItems) {
60
100
  if (!running) break;
61
101
  const prNumber = item._prNumber || item.content?.number;
62
- const repoName = config.repo.replace(/\.git$/, '').replace(/^.*github\.com[:\/]/, '');
102
+ const refActId = item._reviewActId;
63
103
  log(`Merging approved PR #${prNumber}`);
104
+
105
+ // Create merge run (follow-up of review's "merge" act)
106
+ let mergeRunId = null;
107
+ let mergeGroupId = null;
108
+ if (config.warpmetricsApiKey) {
109
+ try {
110
+ const pipeline = await warp.startPipeline(config.warpmetricsApiKey, {
111
+ step: 'merge', repo: repoName, prNumber, refActId,
112
+ });
113
+ mergeRunId = pipeline.runId;
114
+ mergeGroupId = pipeline.groupId;
115
+ } catch (err) {
116
+ log(` warning: merge pipeline failed: ${err.message}`);
117
+ }
118
+ }
119
+
64
120
  try {
65
121
  runHook('onBeforeMerge', config, { prNumber, repo: repoName });
66
122
  git.mergePR(prNumber, { repo: repoName });
67
123
  runHook('onMerged', config, { prNumber, repo: repoName });
68
124
  await board.moveToDone(item);
69
125
  log(` merged and moved to Done`);
126
+
127
+ // Record merge outcome
128
+ if (config.warpmetricsApiKey && mergeGroupId) {
129
+ try {
130
+ await warp.recordOutcome(config.warpmetricsApiKey, { runId: mergeRunId, groupId: mergeGroupId }, {
131
+ step: 'merge', success: true, prNumber,
132
+ });
133
+ } catch {}
134
+ }
135
+
136
+ // Close the issue run with "Shipped"
137
+ const issueNumber = item.content?.number;
138
+ const issueCtx = issueRuns.get(issueNumber);
139
+ if (config.warpmetricsApiKey && issueCtx) {
140
+ try {
141
+ await warp.closeIssueRun(config.warpmetricsApiKey, {
142
+ runId: issueCtx.runId, name: 'Shipped',
143
+ });
144
+ issueRuns.delete(issueNumber);
145
+ } catch {}
146
+ }
70
147
  } catch (err) {
71
148
  log(` merge failed: ${err.message}`);
149
+ if (config.warpmetricsApiKey && mergeGroupId) {
150
+ try {
151
+ await warp.recordOutcome(config.warpmetricsApiKey, { runId: mergeRunId, groupId: mergeGroupId }, {
152
+ step: 'merge', success: false, error: err.message, prNumber,
153
+ });
154
+ } catch {}
155
+ }
72
156
  }
73
157
  }
74
158
  } catch (err) {