@warpmetrics/coder 0.2.3 → 0.2.5

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,12 +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
- log(' Ensuring GitHub CLI has required scopes (project, repo)...');
59
+ // Need 'project' (read:project is not enough) and 'repo'
60
+ log(' Checking GitHub CLI scopes...');
60
61
  try {
61
- execSync('gh auth refresh -s project,repo', { stdio: 'inherit' });
62
- log(' \u2713 GitHub CLI scopes updated');
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) {
73
+ log(' \u2713 GitHub CLI scopes OK');
74
+ } else {
75
+ log(` Missing scopes: ${missing.join(', ')}`);
76
+ execSync(`gh auth refresh -s ${missing.join(',')}`, { stdio: 'inherit' });
77
+ log(' \u2713 GitHub CLI scopes updated');
78
+ }
63
79
  } catch {
64
- log(' \u26a0 Could not refresh gh scopes — run manually: gh auth refresh -s project,repo');
80
+ log(' \u26a0 Could not verify gh scopes — run manually: gh auth refresh -s project,repo');
65
81
  }
66
82
  log('');
67
83
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@warpmetrics/coder",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
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
@@ -17,7 +17,7 @@ export async function implement(item, { board, config, log }) {
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 {
@@ -41,8 +46,9 @@ export async function implement(item, { board, config, log }) {
41
46
  issueNumber,
42
47
  issueTitle,
43
48
  });
49
+ runId = pipeline.runId;
44
50
  groupId = pipeline.groupId;
45
- log(` pipeline: run=${pipeline.runId} group=${groupId}`);
51
+ log(` pipeline: run=${runId} group=${groupId}`);
46
52
  } catch (err) {
47
53
  log(` warning: pipeline start failed: ${err.message}`);
48
54
  }
@@ -102,7 +108,7 @@ export async function implement(item, { board, config, log }) {
102
108
  '1. Read the codebase to understand relevant context',
103
109
  '2. Implement the changes',
104
110
  '3. Run tests to verify nothing is broken',
105
- '4. Commit with a clear message',
111
+ '4. Commit all changes with a clear message — this is critical, do not skip the commit',
106
112
  '',
107
113
  'Do NOT create branches, push, or open PRs — just implement and commit.',
108
114
  'If the issue is unclear or you cannot implement it, explain what is missing.',
@@ -118,6 +124,8 @@ export async function implement(item, { board, config, log }) {
118
124
  maxTurns: config.claude?.maxTurns,
119
125
  });
120
126
  log(` claude done (cost: $${claudeResult.costUsd ?? '?'})`);
127
+ log(` git status: ${git.status(workdir) || '(clean)'}`);
128
+ log(` git log: ${git.hasNewCommits(workdir) ? 'has new commits' : 'NO new commits'}`);
121
129
 
122
130
  // Hook: onBeforePush
123
131
  try {
@@ -128,12 +136,29 @@ export async function implement(item, { board, config, log }) {
128
136
  throw err;
129
137
  }
130
138
 
139
+ // Auto-commit if Claude left uncommitted changes
140
+ if (!git.hasNewCommits(workdir) && git.status(workdir)) {
141
+ log(' claude forgot to commit — auto-committing');
142
+ git.commitAll(workdir, `Implement #${issueNumber}: ${issueTitle}`);
143
+ }
144
+
145
+ if (!git.hasNewCommits(workdir)) {
146
+ throw new Error('Claude did not produce any changes.');
147
+ }
148
+
131
149
  // Push + PR
132
150
  log(' pushing...');
133
151
  git.push(workdir, branch);
152
+ const prBody = [
153
+ `Closes #${issueNumber}`,
154
+ '',
155
+ 'Implemented by warp-coder.',
156
+ ...(actId ? ['', `<!-- wm:act:${actId} -->`] : []),
157
+ ].join('\n');
134
158
  const pr = git.createPR(workdir, {
135
159
  title: issueTitle,
136
- body: `Closes #${issueNumber}\n\nImplemented by warp-coder.`,
160
+ body: prBody,
161
+ head: branch,
137
162
  });
138
163
  log(` PR created: ${pr.url}`);
139
164
 
@@ -149,6 +174,7 @@ export async function implement(item, { board, config, log }) {
149
174
  // Move to In Review
150
175
  try {
151
176
  await board.moveToReview(item);
177
+ log(' moved to In Review');
152
178
  } catch (err) {
153
179
  log(` warning: could not move to In Review: ${err.message}`);
154
180
  }
@@ -157,11 +183,16 @@ export async function implement(item, { board, config, log }) {
157
183
  } catch (err) {
158
184
  taskError = err.message;
159
185
  log(` failed: ${err.message}`);
186
+ try {
187
+ await board.moveToBlocked(item);
188
+ } catch (moveErr) {
189
+ log(` warning: could not move to Blocked: ${moveErr.message}`);
190
+ }
160
191
  } finally {
161
192
  // WarpMetrics: record outcome
162
193
  if (config.warpmetricsApiKey && groupId) {
163
194
  try {
164
- const outcome = await warp.recordOutcome(config.warpmetricsApiKey, groupId, {
195
+ const outcome = await warp.recordOutcome(config.warpmetricsApiKey, { runId, groupId }, {
165
196
  step: 'implement',
166
197
  success,
167
198
  costUsd: claudeResult?.costUsd,
@@ -170,6 +201,15 @@ export async function implement(item, { board, config, log }) {
170
201
  issueNumber,
171
202
  });
172
203
  log(` outcome: ${outcome.name}`);
204
+
205
+ // Emit act so warp-review can link its run as a follow-up
206
+ if (success && actId) {
207
+ await warp.emitAct(config.warpmetricsApiKey, {
208
+ outcomeId: outcome.id,
209
+ actId,
210
+ name: 'review',
211
+ });
212
+ }
173
213
  } catch (err) {
174
214
  log(` warning: outcome recording failed: ${err.message}`);
175
215
  }
@@ -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,67 @@ 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
+ if (reviews?.some(r => r.state === 'APPROVED')) {
115
+ approved.push(item);
116
+ } else if (reviews?.some(r => r.state === 'COMMENTED' || r.state === 'CHANGES_REQUESTED')) {
117
+ needsRevision.push(item);
118
+ }
119
+ } catch {
120
+ // Skip items we can't check
121
+ }
122
+ }
123
+ cachedReviewClassification = { needsRevision, approved };
124
+ return cachedReviewClassification;
125
+ }
126
+
56
127
  async function moveItem(item, colKey) {
57
128
  discoverField();
58
129
  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}`);
130
+ gh(`project item-edit --id ${item.id} --project-id ${projectNodeId} --field-id ${fieldId} --single-select-option-id ${optId}`);
60
131
  }
61
132
 
62
133
  return {
134
+ refresh() {
135
+ cachedItems = ghJson(`project item-list ${project} --owner ${owner} --format json`);
136
+ cachedReviewClassification = null;
137
+ },
138
+
63
139
  async listTodo() {
64
- return listItemsByStatus(colNames.todo);
140
+ return getItemsByStatus(colNames.todo);
65
141
  },
66
142
 
67
143
  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;
144
+ return classifyInReview().needsRevision;
82
145
  },
83
146
 
84
147
  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;
148
+ return classifyInReview().approved;
98
149
  },
99
150
 
100
151
  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,11 @@ 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
+
41
62
  export function getPRBranch(prNumber, { repo }) {
42
63
  const out = run(`gh pr view ${prNumber} --repo ${repo} --json headRefName --jq .headRefName`);
43
64
  return out;
package/src/revise.js CHANGED
@@ -13,15 +13,23 @@ import { reflect } from './reflect.js';
13
13
  const CONFIG_DIR = '.warp-coder';
14
14
 
15
15
  export async function revise(item, { board, config, log }) {
16
- const prNumber = item.content?.number;
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
23
  log(`Revising PR #${prNumber}`);
24
24
 
25
+ // Move to In Progress
26
+ try {
27
+ await board.moveToInProgress(item);
28
+ log(' moved to In Progress');
29
+ } catch (err) {
30
+ log(` warning: could not move to In Progress: ${err.message}`);
31
+ }
32
+
25
33
  // Check revision limit
26
34
  if (config.warpmetricsApiKey) {
27
35
  try {
@@ -38,6 +46,7 @@ export async function revise(item, { board, config, log }) {
38
46
  }
39
47
 
40
48
  // WarpMetrics: start pipeline
49
+ let runId = null;
41
50
  let groupId = null;
42
51
  if (config.warpmetricsApiKey) {
43
52
  try {
@@ -46,8 +55,9 @@ export async function revise(item, { board, config, log }) {
46
55
  repo: repoName,
47
56
  prNumber,
48
57
  });
58
+ runId = pipeline.runId;
49
59
  groupId = pipeline.groupId;
50
- log(` pipeline: run=${pipeline.runId} group=${groupId}`);
60
+ log(` pipeline: run=${runId} group=${groupId}`);
51
61
  } catch (err) {
52
62
  log(` warning: pipeline start failed: ${err.message}`);
53
63
  }
@@ -63,23 +73,52 @@ export async function revise(item, { board, config, log }) {
63
73
  // Clone + checkout PR branch
64
74
  rmSync(workdir, { recursive: true, force: true });
65
75
  mkdirSync(workdir, { recursive: true });
66
- log(` cloning into ${workdir}`);
67
- git.cloneRepo(repo, workdir);
68
-
69
76
  const branch = git.getPRBranch(prNumber, { repo: repoName });
70
- git.checkoutBranch(workdir, branch);
71
- log(` branch: ${branch}`);
77
+ log(` cloning into ${workdir} (branch: ${branch})`);
78
+ git.cloneRepo(repo, workdir, { branch });
72
79
 
73
80
  // Fetch review comments for context
81
+ let inlineComments = [];
74
82
  try {
75
83
  reviewComments = git.getReviews(prNumber, { repo: repoName });
76
84
  } catch (err) {
77
85
  log(` warning: could not fetch reviews: ${err.message}`);
78
86
  }
87
+ try {
88
+ inlineComments = git.getReviewComments(prNumber, { repo: repoName });
89
+ } catch (err) {
90
+ log(` warning: could not fetch inline comments: ${err.message}`);
91
+ }
79
92
 
80
93
  // Load memory for prompt enrichment
81
94
  const memory = config.memory?.enabled !== false ? loadMemory(configDir) : '';
82
95
 
96
+ // Build review feedback section (truncate to ~20k chars to stay within context)
97
+ const maxReviewChars = 20000;
98
+ let reviewSection = '';
99
+
100
+ for (const r of reviewComments) {
101
+ const body = (r.body || '').trim();
102
+ if (!body) continue;
103
+ const user = r.user?.login || 'unknown';
104
+ reviewSection += `**${user}** (${r.state || 'COMMENT'}):\n${body}\n\n`;
105
+ }
106
+
107
+ if (inlineComments.length > 0) {
108
+ reviewSection += '### Inline comments\n\n';
109
+ for (const c of inlineComments) {
110
+ const user = c.user?.login || 'unknown';
111
+ const body = (c.body || '').trim();
112
+ if (!body) continue;
113
+ const location = c.path ? `\`${c.path}${c.line ? `:${c.line}` : ''}\`` : '';
114
+ reviewSection += `${location} — **${user}**:\n${body}\n\n`;
115
+ }
116
+ }
117
+
118
+ if (reviewSection.length > maxReviewChars) {
119
+ reviewSection = reviewSection.slice(0, maxReviewChars) + '\n\n(Review truncated — focus on the comments shown above.)\n';
120
+ }
121
+
83
122
  // Claude
84
123
  const promptParts = [
85
124
  `You are working on PR #${prNumber} in ${repoName}.`,
@@ -95,13 +134,20 @@ export async function revise(item, { board, config, log }) {
95
134
  );
96
135
  }
97
136
 
137
+ if (reviewSection) {
138
+ promptParts.push('A code review has been submitted. Here is the feedback:');
139
+ promptParts.push('');
140
+ promptParts.push(reviewSection);
141
+ } else {
142
+ promptParts.push('A code review has been submitted but no comments could be fetched — check the PR manually.');
143
+ promptParts.push('');
144
+ }
98
145
  promptParts.push(
99
- 'A code review has been submitted with comments. Your job:',
146
+ 'Your job:',
100
147
  '',
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"',
148
+ '1. Apply the suggested fixes',
149
+ '2. Run tests to make sure everything passes',
150
+ '3. Commit all changes with a message like "Address review feedback" — this is critical, do not skip the commit',
105
151
  '',
106
152
  'Do NOT open a new PR — just implement the fixes and commit.',
107
153
  );
@@ -126,6 +172,12 @@ export async function revise(item, { board, config, log }) {
126
172
  throw err;
127
173
  }
128
174
 
175
+ // Auto-commit if Claude left uncommitted changes
176
+ if (git.status(workdir)) {
177
+ log(' claude forgot to commit — auto-committing');
178
+ git.commitAll(workdir, 'Address review feedback');
179
+ }
180
+
129
181
  // Push
130
182
  log(' pushing...');
131
183
  git.push(workdir, branch);
@@ -141,11 +193,16 @@ export async function revise(item, { board, config, log }) {
141
193
  } catch (err) {
142
194
  taskError = err.message;
143
195
  log(` failed: ${err.message}`);
196
+ try {
197
+ await board.moveToBlocked(item);
198
+ } catch (moveErr) {
199
+ log(` warning: could not move to Blocked: ${moveErr.message}`);
200
+ }
144
201
  } finally {
145
202
  // WarpMetrics: record outcome
146
203
  if (config.warpmetricsApiKey && groupId) {
147
204
  try {
148
- const outcome = await warp.recordOutcome(config.warpmetricsApiKey, groupId, {
205
+ const outcome = await warp.recordOutcome(config.warpmetricsApiKey, { runId, groupId }, {
149
206
  step: 'revise',
150
207
  success,
151
208
  costUsd: claudeResult?.costUsd,
package/src/warp.js CHANGED
@@ -117,14 +117,13 @@ export async function startPipeline(apiKey, { step, repo, issueNumber, issueTitl
117
117
  return { runId, groupId };
118
118
  }
119
119
 
120
- export async function recordOutcome(apiKey, groupId, { step, success, costUsd, error, hooksFailed, issueNumber, prNumber, reviewCommentCount }) {
120
+ export async function recordOutcome(apiKey, { runId, groupId }, { step, success, costUsd, error, hooksFailed, issueNumber, prNumber, reviewCommentCount }) {
121
121
  const names = {
122
122
  implement: { true: 'PR Created', false: 'Implementation Failed' },
123
123
  revise: { true: 'Fixes Applied', false: 'Revision Failed' },
124
124
  };
125
125
 
126
126
  const name = names[step]?.[String(success)] || `${step}: ${success ? 'success' : 'failure'}`;
127
- const id = generateId('oc');
128
127
  const now = new Date().toISOString();
129
128
 
130
129
  const opts = { status: success ? 'success' : 'failure', step };
@@ -135,11 +134,23 @@ export async function recordOutcome(apiKey, groupId, { step, success, costUsd, e
135
134
  if (prNumber) opts.pr_number = String(prNumber);
136
135
  if (reviewCommentCount) opts.review_comments = String(reviewCommentCount);
137
136
 
137
+ const outcomes = [
138
+ { id: generateId('oc'), refId: groupId, name, opts, timestamp: now },
139
+ ];
140
+ if (runId) {
141
+ outcomes.push({ id: generateId('oc'), refId: runId, name, opts, timestamp: now });
142
+ }
143
+
144
+ await sendEvents(apiKey, { outcomes });
145
+
146
+ return { id: outcomes[0].id, name };
147
+ }
148
+
149
+ export async function emitAct(apiKey, { outcomeId, actId, name, opts }) {
150
+ const now = new Date().toISOString();
138
151
  await sendEvents(apiKey, {
139
- outcomes: [{ id, refId: groupId, name, opts, timestamp: now }],
152
+ acts: [{ id: actId, refId: outcomeId, name, opts: opts || null, timestamp: now }],
140
153
  });
141
-
142
- return { id, name };
143
154
  }
144
155
 
145
156
  export async function countRevisions(apiKey, { prNumber, repo }) {
package/src/watch.js CHANGED
@@ -13,9 +13,15 @@ export async function watch() {
13
13
  const pollInterval = (config.pollInterval || 30) * 1000;
14
14
 
15
15
  let running = true;
16
+ let sleepResolve = null;
16
17
  const shutdown = () => {
17
- console.log('\nShutting down...');
18
+ if (!running) {
19
+ console.log('\nForce exit.');
20
+ process.exit(1);
21
+ }
22
+ console.log('\nShutting down... (Ctrl+C again to force)');
18
23
  running = false;
24
+ if (sleepResolve) sleepResolve();
19
25
  };
20
26
  process.on('SIGINT', shutdown);
21
27
  process.on('SIGTERM', shutdown);
@@ -29,6 +35,9 @@ export async function watch() {
29
35
 
30
36
  while (running) {
31
37
  try {
38
+ // Fetch all project items once per poll cycle
39
+ board.refresh();
40
+
32
41
  // 1. Pick up new tasks from Todo
33
42
  const todoItems = await board.listTodo();
34
43
  if (todoItems.length > 0) {
@@ -41,7 +50,7 @@ export async function watch() {
41
50
  const reviewItems = await board.listInReview();
42
51
  for (const item of reviewItems) {
43
52
  if (!running) break;
44
- log(`Found review feedback: PR #${item.content?.number}`);
53
+ log(`Found review feedback: PR #${item._prNumber || item.content?.number}`);
45
54
  await revise(item, { board, config, log });
46
55
  }
47
56
 
@@ -49,8 +58,8 @@ export async function watch() {
49
58
  const approvedItems = await board.listApproved();
50
59
  for (const item of approvedItems) {
51
60
  if (!running) break;
52
- const prNumber = item.content?.number;
53
- const repoName = config.repo.replace(/\.git$/, '').split('/').pop();
61
+ const prNumber = item._prNumber || item.content?.number;
62
+ const repoName = config.repo.replace(/\.git$/, '').replace(/^.*github\.com[:\/]/, '');
54
63
  log(`Merging approved PR #${prNumber}`);
55
64
  try {
56
65
  runHook('onBeforeMerge', config, { prNumber, repo: repoName });
@@ -66,9 +75,12 @@ export async function watch() {
66
75
  log(`Poll error: ${err.message}`);
67
76
  }
68
77
 
69
- // Sleep
78
+ // Sleep (interruptible)
70
79
  if (running) {
71
- await new Promise(resolve => setTimeout(resolve, pollInterval));
80
+ await new Promise(resolve => {
81
+ sleepResolve = resolve;
82
+ setTimeout(() => { sleepResolve = null; resolve(); }, pollInterval);
83
+ });
72
84
  }
73
85
  }
74
86