@warpmetrics/coder 0.2.4 → 0.2.6

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/bin/cli.js CHANGED
@@ -56,23 +56,28 @@ async function runInit() {
56
56
  log('');
57
57
 
58
58
  // 1. Ensure gh has the right scopes (before readline takes over stdin)
59
+ // Need 'project' (read:project is not enough) and 'repo'
59
60
  log(' Checking GitHub CLI scopes...');
60
61
  try {
61
- const authStatus = execSync('gh auth status', { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).toLowerCase();
62
- const hasScopes = authStatus.includes('project') && authStatus.includes('repo');
63
- if (hasScopes) {
62
+ let authOutput = '';
63
+ try {
64
+ authOutput = execSync('gh auth status 2>&1', { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
65
+ } catch (err) {
66
+ authOutput = err.stdout || err.stderr || '';
67
+ }
68
+ const scopesMatch = authOutput.match(/Token scopes:\s*(.+)/i);
69
+ const scopeList = (scopesMatch?.[1]?.match(/'([^']+)'/g) || []).map(s => s.replace(/'/g, ''));
70
+ const missing = ['project', 'repo'].filter(s => !scopeList.includes(s));
71
+
72
+ if (missing.length === 0) {
64
73
  log(' \u2713 GitHub CLI scopes OK');
65
74
  } else {
66
- throw new Error('missing scopes');
67
- }
68
- } catch {
69
- log(' Requesting required scopes (project, repo)...');
70
- try {
71
- execSync('gh auth refresh -s project,repo', { stdio: 'inherit' });
75
+ log(` Missing scopes: ${missing.join(', ')}`);
76
+ execSync(`gh auth refresh -s ${missing.join(',')}`, { stdio: 'inherit' });
72
77
  log(' \u2713 GitHub CLI scopes updated');
73
- } catch {
74
- log(' \u26a0 Could not refresh gh scopes — run manually: gh auth refresh -s project,repo');
75
78
  }
79
+ } catch {
80
+ log(' \u26a0 Could not verify gh scopes — run manually: gh auth refresh -s project,repo');
76
81
  }
77
82
  log('');
78
83
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@warpmetrics/coder",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
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,12 +12,12 @@ 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 || '';
19
19
  const repo = config.repo;
20
- const repoName = repo.replace(/\.git$/, '').split('/').pop(); // owner/repo or just repo
20
+ const repoName = repo.replace(/\.git$/, '').replace(/^.*github\.com[:\/]/, '');
21
21
  const branch = `agent/issue-${issueNumber}`;
22
22
  const workdir = join(tmpdir(), 'warp-coder', String(issueNumber));
23
23
  const configDir = join(process.cwd(), CONFIG_DIR);
@@ -27,11 +27,16 @@ export async function implement(item, { board, config, log }) {
27
27
  // Move to In Progress
28
28
  try {
29
29
  await board.moveToInProgress(item);
30
+ log(' moved to In Progress');
30
31
  } catch (err) {
31
32
  log(` warning: could not move to In Progress: ${err.message}`);
32
33
  }
33
34
 
35
+ // Pre-generate act ID for chaining (embedded in PR body so warp-review can link back)
36
+ const actId = config.warpmetricsApiKey ? warp.generateId('act') : null;
37
+
34
38
  // WarpMetrics: start pipeline
39
+ let runId = null;
35
40
  let groupId = null;
36
41
  if (config.warpmetricsApiKey) {
37
42
  try {
@@ -40,9 +45,11 @@ export async function implement(item, { board, config, log }) {
40
45
  repo: repoName,
41
46
  issueNumber,
42
47
  issueTitle,
48
+ refActId,
43
49
  });
50
+ runId = pipeline.runId;
44
51
  groupId = pipeline.groupId;
45
- log(` pipeline: run=${pipeline.runId} group=${groupId}`);
52
+ log(` pipeline: run=${runId} group=${groupId}`);
46
53
  } catch (err) {
47
54
  log(` warning: pipeline start failed: ${err.message}`);
48
55
  }
@@ -102,7 +109,7 @@ export async function implement(item, { board, config, log }) {
102
109
  '1. Read the codebase to understand relevant context',
103
110
  '2. Implement the changes',
104
111
  '3. Run tests to verify nothing is broken',
105
- '4. Commit with a clear message',
112
+ '4. Commit all changes with a clear message — this is critical, do not skip the commit',
106
113
  '',
107
114
  'Do NOT create branches, push, or open PRs — just implement and commit.',
108
115
  'If the issue is unclear or you cannot implement it, explain what is missing.',
@@ -118,6 +125,8 @@ export async function implement(item, { board, config, log }) {
118
125
  maxTurns: config.claude?.maxTurns,
119
126
  });
120
127
  log(` claude done (cost: $${claudeResult.costUsd ?? '?'})`);
128
+ log(` git status: ${git.status(workdir) || '(clean)'}`);
129
+ log(` git log: ${git.hasNewCommits(workdir) ? 'has new commits' : 'NO new commits'}`);
121
130
 
122
131
  // Hook: onBeforePush
123
132
  try {
@@ -128,12 +137,29 @@ export async function implement(item, { board, config, log }) {
128
137
  throw err;
129
138
  }
130
139
 
140
+ // Auto-commit if Claude left uncommitted changes
141
+ if (!git.hasNewCommits(workdir) && git.status(workdir)) {
142
+ log(' claude forgot to commit — auto-committing');
143
+ git.commitAll(workdir, `Implement #${issueNumber}: ${issueTitle}`);
144
+ }
145
+
146
+ if (!git.hasNewCommits(workdir)) {
147
+ throw new Error('Claude did not produce any changes.');
148
+ }
149
+
131
150
  // Push + PR
132
151
  log(' pushing...');
133
152
  git.push(workdir, branch);
153
+ const prBody = [
154
+ `Closes #${issueNumber}`,
155
+ '',
156
+ 'Implemented by warp-coder.',
157
+ ...(actId ? ['', `<!-- wm:act:${actId} -->`] : []),
158
+ ].join('\n');
134
159
  const pr = git.createPR(workdir, {
135
160
  title: issueTitle,
136
- body: `Closes #${issueNumber}\n\nImplemented by warp-coder.`,
161
+ body: prBody,
162
+ head: branch,
137
163
  });
138
164
  log(` PR created: ${pr.url}`);
139
165
 
@@ -149,6 +175,7 @@ export async function implement(item, { board, config, log }) {
149
175
  // Move to In Review
150
176
  try {
151
177
  await board.moveToReview(item);
178
+ log(' moved to In Review');
152
179
  } catch (err) {
153
180
  log(` warning: could not move to In Review: ${err.message}`);
154
181
  }
@@ -157,11 +184,16 @@ export async function implement(item, { board, config, log }) {
157
184
  } catch (err) {
158
185
  taskError = err.message;
159
186
  log(` failed: ${err.message}`);
187
+ try {
188
+ await board.moveToBlocked(item);
189
+ } catch (moveErr) {
190
+ log(` warning: could not move to Blocked: ${moveErr.message}`);
191
+ }
160
192
  } finally {
161
193
  // WarpMetrics: record outcome
162
194
  if (config.warpmetricsApiKey && groupId) {
163
195
  try {
164
- const outcome = await warp.recordOutcome(config.warpmetricsApiKey, groupId, {
196
+ const outcome = await warp.recordOutcome(config.warpmetricsApiKey, { runId, groupId }, {
165
197
  step: 'implement',
166
198
  success,
167
199
  costUsd: claudeResult?.costUsd,
@@ -170,6 +202,15 @@ export async function implement(item, { board, config, log }) {
170
202
  issueNumber,
171
203
  });
172
204
  log(` outcome: ${outcome.name}`);
205
+
206
+ // Emit act so warp-review can link its run as a follow-up
207
+ if (success && actId && outcome.runOutcomeId) {
208
+ await warp.emitAct(config.warpmetricsApiKey, {
209
+ outcomeId: outcome.runOutcomeId,
210
+ actId,
211
+ name: 'review',
212
+ });
213
+ }
173
214
  } catch (err) {
174
215
  log(` warning: outcome recording failed: ${err.message}`);
175
216
  }
@@ -21,12 +21,52 @@ export function create({ project, owner, statusField = 'Status', columns = {} })
21
21
  blocked: columns.blocked || 'Blocked',
22
22
  };
23
23
 
24
- // Cache field/option IDs (discovered on first use)
24
+ // PR lookup cache (issue number PR number or null). Persists across polls.
25
+ const prCache = new Map();
26
+
27
+ // Find the PR linked to an issue item (via "Closes #N" convention)
28
+ function findLinkedPR(item) {
29
+ // If the item is already a PR, use its number directly
30
+ if (item.content?.type === 'PullRequest') return item.content.number;
31
+ // Otherwise look for a linked PR via the branch naming convention
32
+ const issueNumber = item.content?.number;
33
+ if (!issueNumber) return null;
34
+ if (prCache.has(issueNumber)) return prCache.get(issueNumber);
35
+ let result = null;
36
+ try {
37
+ const repo = item.content.repository || `${owner}/${item.content.repository}`;
38
+ // Search for PRs that reference this issue
39
+ const prs = ghJson(`api repos/${repo}/pulls?state=open&head=agent/issue-${issueNumber} --jq '.[0].number'`);
40
+ if (prs && typeof prs === 'number') result = prs;
41
+ } catch {}
42
+ if (!result) {
43
+ try {
44
+ // Fallback: search via gh pr list
45
+ const repo = item.content.repository;
46
+ const out = gh(`pr list --repo ${repo} --search "Closes #${issueNumber}" --json number --jq '.[0].number'`);
47
+ if (out) result = parseInt(out, 10);
48
+ } catch {}
49
+ }
50
+ prCache.set(issueNumber, result);
51
+ return result;
52
+ }
53
+
54
+ // Cache field/option IDs and project node ID (discovered on first use)
55
+ let projectNodeId = null;
25
56
  let fieldId = null;
26
57
  let optionIds = null;
27
58
 
59
+ function discoverProject() {
60
+ if (projectNodeId) return;
61
+ const projects = ghJson(`project list --owner ${owner} --format json`);
62
+ const proj = projects?.projects?.find(p => p.number === project);
63
+ if (!proj) throw new Error(`Project #${project} not found for owner ${owner}`);
64
+ projectNodeId = proj.id;
65
+ }
66
+
28
67
  function discoverField() {
29
68
  if (fieldId) return;
69
+ discoverProject();
30
70
  const fields = ghJson(`project field-list ${project} --owner ${owner} --format json`);
31
71
  const field = fields?.fields?.find(f => f.name === statusField);
32
72
  if (!field) throw new Error(`Status field "${statusField}" not found in project ${project}`);
@@ -45,56 +85,75 @@ export function create({ project, owner, statusField = 'Status', columns = {} })
45
85
  return id;
46
86
  }
47
87
 
48
- async function listItemsByStatus(statusName) {
49
- const items = ghJson(`project item-list ${project} --owner ${owner} --format json`);
50
- return (items?.items || []).filter(item => {
88
+ // Cached item list — refreshed once per poll cycle via refresh()
89
+ let cachedItems = null;
90
+ let cachedReviewClassification = null;
91
+
92
+ function getItemsByStatus(statusName) {
93
+ return (cachedItems?.items || []).filter(item => {
51
94
  const status = item.status || item.fields?.find(f => f.name === statusField)?.value;
52
95
  return status === statusName;
53
96
  });
54
97
  }
55
98
 
99
+ // Classify "In Review" items in a single pass — one review API call per item.
100
+ // Result is cached per poll cycle so listInReview + listApproved share one pass.
101
+ function classifyInReview() {
102
+ if (cachedReviewClassification) return cachedReviewClassification;
103
+ const items = getItemsByStatus(colNames.inReview);
104
+ const needsRevision = [];
105
+ const approved = [];
106
+ for (const item of items) {
107
+ if (!item.content?.number) continue;
108
+ try {
109
+ const prNumber = findLinkedPR(item);
110
+ if (!prNumber) continue;
111
+ const repo = item.content.repository || `${owner}/${item.content.repository}`;
112
+ const reviews = ghJson(`api repos/${repo}/pulls/${prNumber}/reviews`);
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
+
122
+ if (reviews?.some(r => r.state === 'APPROVED')) {
123
+ approved.push(item);
124
+ } else if (reviews?.some(r => r.state === 'COMMENTED' || r.state === 'CHANGES_REQUESTED')) {
125
+ needsRevision.push(item);
126
+ }
127
+ } catch {
128
+ // Skip items we can't check
129
+ }
130
+ }
131
+ cachedReviewClassification = { needsRevision, approved };
132
+ return cachedReviewClassification;
133
+ }
134
+
56
135
  async function moveItem(item, colKey) {
57
136
  discoverField();
58
137
  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}`);
138
+ gh(`project item-edit --id ${item.id} --project-id ${projectNodeId} --field-id ${fieldId} --single-select-option-id ${optId}`);
60
139
  }
61
140
 
62
141
  return {
142
+ refresh() {
143
+ cachedItems = ghJson(`project item-list ${project} --owner ${owner} --format json`);
144
+ cachedReviewClassification = null;
145
+ },
146
+
63
147
  async listTodo() {
64
- return listItemsByStatus(colNames.todo);
148
+ return getItemsByStatus(colNames.todo);
65
149
  },
66
150
 
67
151
  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;
152
+ return classifyInReview().needsRevision;
82
153
  },
83
154
 
84
155
  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;
156
+ return classifyInReview().approved;
98
157
  },
99
158
 
100
159
  moveToInProgress(item) { return moveItem(item, 'inProgress'); },
package/src/claude.js CHANGED
@@ -2,7 +2,13 @@ import { spawn } from 'child_process';
2
2
 
3
3
  export function run({ prompt, workdir, allowedTools = 'Bash,Read,Edit,Write,Glob,Grep', maxTurns, verbose = true }) {
4
4
  return new Promise((resolve, reject) => {
5
- const args = ['-p', prompt, '--output-format', 'json', '--allowedTools', allowedTools];
5
+ const args = [
6
+ '-p', prompt,
7
+ '--output-format', 'stream-json',
8
+ '--verbose',
9
+ '--allowedTools', allowedTools,
10
+ '--dangerously-skip-permissions',
11
+ ];
6
12
  if (maxTurns) args.push('--max-turns', String(maxTurns));
7
13
 
8
14
  const proc = spawn('claude', args, {
@@ -10,29 +16,62 @@ export function run({ prompt, workdir, allowedTools = 'Bash,Read,Edit,Write,Glob
10
16
  stdio: ['ignore', 'pipe', 'pipe'],
11
17
  });
12
18
 
19
+ let resultEvent = null;
20
+ let lastAssistantText = '';
13
21
  let stdout = '';
14
22
  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);
23
+ let buffer = '';
24
+ let pendingTools = [];
25
+
26
+ function flushTools() {
27
+ if (pendingTools.length === 0) return;
28
+ process.stderr.write(` claude: ${pendingTools.join(', ')}\n`);
29
+ pendingTools = [];
30
+ }
31
+
32
+ proc.stdout.on('data', d => {
33
+ stdout += d;
34
+ buffer += d;
35
+ const lines = buffer.split('\n');
36
+ buffer = lines.pop();
37
+ for (const line of lines) {
38
+ if (!line.trim()) continue;
39
+ try {
40
+ const event = JSON.parse(line);
41
+ if (event.type === 'result') {
42
+ resultEvent = event;
43
+ } else if (event.type === 'assistant' && event.message?.content) {
44
+ for (const block of event.message.content) {
45
+ if (block.type === 'text') {
46
+ lastAssistantText = block.text;
47
+ if (verbose) {
48
+ flushTools();
49
+ const text = block.text.replace(/\n/g, ' ').trim();
50
+ if (text) process.stderr.write(` claude: ${text.slice(0, 300)}\n`);
51
+ }
52
+ } else if (block.type === 'tool_use') {
53
+ if (verbose) pendingTools.push(block.name);
54
+ }
55
+ }
56
+ }
57
+ } catch {
58
+ // not valid JSON, skip
59
+ }
60
+ }
19
61
  });
20
62
 
63
+ proc.stderr.on('data', d => { stderr += d; });
64
+
21
65
  proc.on('close', code => {
66
+ if (verbose) flushTools();
22
67
  if (code !== 0) {
23
68
  return reject(new Error(`claude exited with code ${code}: ${stderr}`));
24
69
  }
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
- }
70
+ resolve({
71
+ result: resultEvent?.result ?? lastAssistantText ?? stdout,
72
+ sessionId: resultEvent?.session_id ?? null,
73
+ costUsd: resultEvent?.total_cost_usd ?? null,
74
+ });
36
75
  });
37
76
 
38
77
  proc.on('error', reject);
package/src/git.js CHANGED
@@ -4,8 +4,9 @@ function run(cmd, opts = {}) {
4
4
  return execSync(cmd, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], ...opts }).trim();
5
5
  }
6
6
 
7
- export function cloneRepo(repoUrl, dest) {
8
- run(`git clone --depth 1 ${repoUrl} ${dest}`);
7
+ export function cloneRepo(repoUrl, dest, { branch } = {}) {
8
+ const branchFlag = branch ? ` --branch ${branch}` : '';
9
+ run(`git clone --depth 1${branchFlag} ${repoUrl} ${dest}`);
9
10
  }
10
11
 
11
12
  export function checkoutBranch(dir, branch) {
@@ -16,12 +17,27 @@ export function createBranch(dir, name) {
16
17
  run(`git checkout -b ${name}`, { cwd: dir });
17
18
  }
18
19
 
20
+ export function hasNewCommits(dir, base = 'main') {
21
+ const log = run(`git log ${base}..HEAD --oneline`, { cwd: dir });
22
+ return log.length > 0;
23
+ }
24
+
25
+ export function status(dir) {
26
+ return run(`git status --short`, { cwd: dir });
27
+ }
28
+
29
+ export function commitAll(dir, message) {
30
+ run(`git add -A`, { cwd: dir });
31
+ run(`git commit -m ${JSON.stringify(message)}`, { cwd: dir });
32
+ }
33
+
19
34
  export function push(dir, branch) {
20
35
  run(`git push -u origin ${branch}`, { cwd: dir });
21
36
  }
22
37
 
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 });
38
+ export function createPR(dir, { title, body, base = 'main', head }) {
39
+ const headFlag = head ? ` --head ${head}` : '';
40
+ const out = run(`gh pr create --title ${JSON.stringify(title)} --body ${JSON.stringify(body)} --base ${base}${headFlag}`, { cwd: dir });
25
41
  // gh pr create prints the PR URL as the last line
26
42
  const lines = out.split('\n');
27
43
  const url = lines[lines.length - 1];
@@ -38,6 +54,19 @@ export function getReviews(prNumber, { repo }) {
38
54
  return JSON.parse(out);
39
55
  }
40
56
 
57
+ export function getReviewComments(prNumber, { repo }) {
58
+ const out = run(`gh api repos/${repo}/pulls/${prNumber}/comments`);
59
+ return JSON.parse(out);
60
+ }
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
+
41
70
  export function getPRBranch(prNumber, { repo }) {
42
71
  const out = run(`gh pr view ${prNumber} --repo ${repo} --json headRefName --jq .headRefName`);
43
72
  return out;
package/src/revise.js CHANGED
@@ -12,16 +12,27 @@ 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 }) {
16
- const prNumber = item.content?.number;
15
+ export async function revise(item, { board, config, log, refActId }) {
16
+ const prNumber = item._prNumber || item.content?.number;
17
17
  const repo = config.repo;
18
- const repoName = repo.replace(/\.git$/, '').split('/').pop();
18
+ const repoName = repo.replace(/\.git$/, '').replace(/^.*github\.com[:\/]/, '');
19
19
  const maxRevisions = config.maxRevisions || 3;
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
 
28
+ // Move to In Progress
29
+ try {
30
+ await board.moveToInProgress(item);
31
+ log(' moved to In Progress');
32
+ } catch (err) {
33
+ log(` warning: could not move to In Progress: ${err.message}`);
34
+ }
35
+
25
36
  // Check revision limit
26
37
  if (config.warpmetricsApiKey) {
27
38
  try {
@@ -38,6 +49,7 @@ export async function revise(item, { board, config, log }) {
38
49
  }
39
50
 
40
51
  // WarpMetrics: start pipeline
52
+ let runId = null;
41
53
  let groupId = null;
42
54
  if (config.warpmetricsApiKey) {
43
55
  try {
@@ -45,9 +57,11 @@ export async function revise(item, { board, config, log }) {
45
57
  step: 'revise',
46
58
  repo: repoName,
47
59
  prNumber,
60
+ refActId,
48
61
  });
62
+ runId = pipeline.runId;
49
63
  groupId = pipeline.groupId;
50
- log(` pipeline: run=${pipeline.runId} group=${groupId}`);
64
+ log(` pipeline: run=${runId} group=${groupId}`);
51
65
  } catch (err) {
52
66
  log(` warning: pipeline start failed: ${err.message}`);
53
67
  }
@@ -63,23 +77,52 @@ export async function revise(item, { board, config, log }) {
63
77
  // Clone + checkout PR branch
64
78
  rmSync(workdir, { recursive: true, force: true });
65
79
  mkdirSync(workdir, { recursive: true });
66
- log(` cloning into ${workdir}`);
67
- git.cloneRepo(repo, workdir);
68
-
69
80
  const branch = git.getPRBranch(prNumber, { repo: repoName });
70
- git.checkoutBranch(workdir, branch);
71
- log(` branch: ${branch}`);
81
+ log(` cloning into ${workdir} (branch: ${branch})`);
82
+ git.cloneRepo(repo, workdir, { branch });
72
83
 
73
84
  // Fetch review comments for context
85
+ let inlineComments = [];
74
86
  try {
75
87
  reviewComments = git.getReviews(prNumber, { repo: repoName });
76
88
  } catch (err) {
77
89
  log(` warning: could not fetch reviews: ${err.message}`);
78
90
  }
91
+ try {
92
+ inlineComments = git.getReviewComments(prNumber, { repo: repoName });
93
+ } catch (err) {
94
+ log(` warning: could not fetch inline comments: ${err.message}`);
95
+ }
79
96
 
80
97
  // Load memory for prompt enrichment
81
98
  const memory = config.memory?.enabled !== false ? loadMemory(configDir) : '';
82
99
 
100
+ // Build review feedback section (truncate to ~20k chars to stay within context)
101
+ const maxReviewChars = 20000;
102
+ let reviewSection = '';
103
+
104
+ for (const r of reviewComments) {
105
+ const body = (r.body || '').trim();
106
+ if (!body) continue;
107
+ const user = r.user?.login || 'unknown';
108
+ reviewSection += `**${user}** (${r.state || 'COMMENT'}):\n${body}\n\n`;
109
+ }
110
+
111
+ if (inlineComments.length > 0) {
112
+ reviewSection += '### Inline comments\n\n';
113
+ for (const c of inlineComments) {
114
+ const user = c.user?.login || 'unknown';
115
+ const body = (c.body || '').trim();
116
+ if (!body) continue;
117
+ const location = c.path ? `\`${c.path}${c.line ? `:${c.line}` : ''}\`` : '';
118
+ reviewSection += `${location} — **${user}**:\n${body}\n\n`;
119
+ }
120
+ }
121
+
122
+ if (reviewSection.length > maxReviewChars) {
123
+ reviewSection = reviewSection.slice(0, maxReviewChars) + '\n\n(Review truncated — focus on the comments shown above.)\n';
124
+ }
125
+
83
126
  // Claude
84
127
  const promptParts = [
85
128
  `You are working on PR #${prNumber} in ${repoName}.`,
@@ -95,13 +138,20 @@ export async function revise(item, { board, config, log }) {
95
138
  );
96
139
  }
97
140
 
141
+ if (reviewSection) {
142
+ promptParts.push('A code review has been submitted. Here is the feedback:');
143
+ promptParts.push('');
144
+ promptParts.push(reviewSection);
145
+ } else {
146
+ promptParts.push('A code review has been submitted but no comments could be fetched — check the PR manually.');
147
+ promptParts.push('');
148
+ }
98
149
  promptParts.push(
99
- 'A code review has been submitted with comments. Your job:',
150
+ 'Your job:',
100
151
  '',
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"',
152
+ '1. Apply the suggested fixes',
153
+ '2. Run tests to make sure everything passes',
154
+ '3. Commit all changes with a message like "Address review feedback" — this is critical, do not skip the commit',
105
155
  '',
106
156
  'Do NOT open a new PR — just implement the fixes and commit.',
107
157
  );
@@ -126,10 +176,30 @@ export async function revise(item, { board, config, log }) {
126
176
  throw err;
127
177
  }
128
178
 
179
+ // Auto-commit if Claude left uncommitted changes
180
+ if (git.status(workdir)) {
181
+ log(' claude forgot to commit — auto-committing');
182
+ git.commitAll(workdir, 'Address review feedback');
183
+ }
184
+
129
185
  // Push
130
186
  log(' pushing...');
131
187
  git.push(workdir, branch);
132
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
+
133
203
  // Move back to In Review
134
204
  try {
135
205
  await board.moveToReview(item);
@@ -141,11 +211,16 @@ export async function revise(item, { board, config, log }) {
141
211
  } catch (err) {
142
212
  taskError = err.message;
143
213
  log(` failed: ${err.message}`);
214
+ try {
215
+ await board.moveToBlocked(item);
216
+ } catch (moveErr) {
217
+ log(` warning: could not move to Blocked: ${moveErr.message}`);
218
+ }
144
219
  } finally {
145
220
  // WarpMetrics: record outcome
146
221
  if (config.warpmetricsApiKey && groupId) {
147
222
  try {
148
- const outcome = await warp.recordOutcome(config.warpmetricsApiKey, groupId, {
223
+ const outcome = await warp.recordOutcome(config.warpmetricsApiKey, { runId, groupId }, {
149
224
  step: 'revise',
150
225
  success,
151
226
  costUsd: claudeResult?.costUsd,
@@ -155,6 +230,15 @@ export async function revise(item, { board, config, log }) {
155
230
  reviewCommentCount: reviewComments.length,
156
231
  });
157
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
+ }
158
242
  } catch (err) {
159
243
  log(` warning: outcome recording failed: ${err.message}`);
160
244
  }
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
  });
@@ -117,14 +145,14 @@ export async function startPipeline(apiKey, { step, repo, issueNumber, issueTitl
117
145
  return { runId, groupId };
118
146
  }
119
147
 
120
- export async function recordOutcome(apiKey, groupId, { step, success, costUsd, error, hooksFailed, issueNumber, prNumber, reviewCommentCount }) {
148
+ export async function recordOutcome(apiKey, { runId, groupId }, { step, success, costUsd, error, hooksFailed, issueNumber, prNumber, reviewCommentCount }) {
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'}`;
127
- const id = generateId('oc');
128
156
  const now = new Date().toISOString();
129
157
 
130
158
  const opts = { status: success ? 'success' : 'failure', step };
@@ -135,11 +163,27 @@ export async function recordOutcome(apiKey, groupId, { step, success, costUsd, e
135
163
  if (prNumber) opts.pr_number = String(prNumber);
136
164
  if (reviewCommentCount) opts.review_comments = String(reviewCommentCount);
137
165
 
166
+ const groupOutcomeId = generateId('oc');
167
+ const outcomes = [
168
+ { id: groupOutcomeId, refId: groupId, name, opts, timestamp: now },
169
+ ];
170
+
171
+ let runOutcomeId = null;
172
+ if (runId) {
173
+ runOutcomeId = generateId('oc');
174
+ outcomes.push({ id: runOutcomeId, refId: runId, name, opts, timestamp: now });
175
+ }
176
+
177
+ await sendEvents(apiKey, { outcomes });
178
+
179
+ return { id: groupOutcomeId, runOutcomeId, name };
180
+ }
181
+
182
+ export async function emitAct(apiKey, { outcomeId, actId, name, opts }) {
183
+ const now = new Date().toISOString();
138
184
  await sendEvents(apiKey, {
139
- outcomes: [{ id, refId: groupId, name, opts, timestamp: now }],
185
+ acts: [{ id: actId, refId: outcomeId, name, opts: opts || null, timestamp: now }],
140
186
  });
141
-
142
- return { id, name };
143
187
  }
144
188
 
145
189
  export async function countRevisions(apiKey, { prNumber, repo }) {
package/src/watch.js CHANGED
@@ -5,17 +5,28 @@ 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;
21
+ let sleepResolve = null;
16
22
  const shutdown = () => {
17
- console.log('\nShutting down...');
23
+ if (!running) {
24
+ console.log('\nForce exit.');
25
+ process.exit(1);
26
+ }
27
+ console.log('\nShutting down... (Ctrl+C again to force)');
18
28
  running = false;
29
+ if (sleepResolve) sleepResolve();
19
30
  };
20
31
  process.on('SIGINT', shutdown);
21
32
  process.on('SIGTERM', shutdown);
@@ -29,46 +40,114 @@ export async function watch() {
29
40
 
30
41
  while (running) {
31
42
  try {
43
+ // Fetch all project items once per poll cycle
44
+ board.refresh();
45
+
32
46
  // 1. Pick up new tasks from Todo
33
47
  const todoItems = await board.listTodo();
34
48
  if (todoItems.length > 0) {
35
49
  const item = todoItems[0];
36
- log(`Found todo: #${item.content?.number} — ${item.content?.title}`);
37
- 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 });
38
70
  }
39
71
 
40
72
  // 2. Check for items needing revision
41
73
  const reviewItems = await board.listInReview();
42
74
  for (const item of reviewItems) {
43
75
  if (!running) break;
44
- log(`Found review feedback: PR #${item.content?.number}`);
45
- await revise(item, { board, config, log });
76
+ log(`Found review feedback: PR #${item._prNumber || item.content?.number}`);
77
+ await revise(item, { board, config, log, refActId: item._reviewActId });
46
78
  }
47
79
 
48
80
  // 3. Merge approved PRs
49
81
  const approvedItems = await board.listApproved();
50
82
  for (const item of approvedItems) {
51
83
  if (!running) break;
52
- const prNumber = item.content?.number;
53
- const repoName = config.repo.replace(/\.git$/, '').split('/').pop();
84
+ const prNumber = item._prNumber || item.content?.number;
85
+ const refActId = item._reviewActId;
54
86
  log(`Merging approved PR #${prNumber}`);
87
+
88
+ // Create merge run (follow-up of review's "merge" act)
89
+ let mergeRunId = null;
90
+ let mergeGroupId = null;
91
+ if (config.warpmetricsApiKey) {
92
+ try {
93
+ const pipeline = await warp.startPipeline(config.warpmetricsApiKey, {
94
+ step: 'merge', repo: repoName, prNumber, refActId,
95
+ });
96
+ mergeRunId = pipeline.runId;
97
+ mergeGroupId = pipeline.groupId;
98
+ } catch (err) {
99
+ log(` warning: merge pipeline failed: ${err.message}`);
100
+ }
101
+ }
102
+
55
103
  try {
56
104
  runHook('onBeforeMerge', config, { prNumber, repo: repoName });
57
105
  git.mergePR(prNumber, { repo: repoName });
58
106
  runHook('onMerged', config, { prNumber, repo: repoName });
59
107
  await board.moveToDone(item);
60
108
  log(` merged and moved to Done`);
109
+
110
+ // Record merge outcome
111
+ if (config.warpmetricsApiKey && mergeGroupId) {
112
+ try {
113
+ await warp.recordOutcome(config.warpmetricsApiKey, { runId: mergeRunId, groupId: mergeGroupId }, {
114
+ step: 'merge', success: true, prNumber,
115
+ });
116
+ } catch {}
117
+ }
118
+
119
+ // Close the issue run with "Shipped"
120
+ const issueNumber = item.content?.number;
121
+ const issueCtx = issueRuns.get(issueNumber);
122
+ if (config.warpmetricsApiKey && issueCtx) {
123
+ try {
124
+ await warp.closeIssueRun(config.warpmetricsApiKey, {
125
+ runId: issueCtx.runId, name: 'Shipped',
126
+ });
127
+ issueRuns.delete(issueNumber);
128
+ } catch {}
129
+ }
61
130
  } catch (err) {
62
131
  log(` merge failed: ${err.message}`);
132
+ if (config.warpmetricsApiKey && mergeGroupId) {
133
+ try {
134
+ await warp.recordOutcome(config.warpmetricsApiKey, { runId: mergeRunId, groupId: mergeGroupId }, {
135
+ step: 'merge', success: false, error: err.message, prNumber,
136
+ });
137
+ } catch {}
138
+ }
63
139
  }
64
140
  }
65
141
  } catch (err) {
66
142
  log(`Poll error: ${err.message}`);
67
143
  }
68
144
 
69
- // Sleep
145
+ // Sleep (interruptible)
70
146
  if (running) {
71
- await new Promise(resolve => setTimeout(resolve, pollInterval));
147
+ await new Promise(resolve => {
148
+ sleepResolve = resolve;
149
+ setTimeout(() => { sleepResolve = null; resolve(); }, pollInterval);
150
+ });
72
151
  }
73
152
  }
74
153