@warpmetrics/coder 0.1.4 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,41 +1,76 @@
1
1
  # @warpmetrics/coder
2
2
 
3
- Agent pipeline for implementing GitHub issues with Claude Code. Label an issue with `agent` and it gets implemented, reviewed, and revised automatically.
3
+ Local agent loop that watches a GitHub Projects board for tasks, implements them using Claude Code, and pushes PRs.
4
4
 
5
5
  ## Quick Start
6
6
 
7
7
  ```bash
8
8
  npx @warpmetrics/coder init
9
+ npx @warpmetrics/coder watch
9
10
  ```
10
11
 
11
- This will:
12
- 1. Set up `ANTHROPIC_API_KEY` and `WARPMETRICS_API_KEY` as GitHub secrets
13
- 2. Add two workflow files to `.github/workflows/`
14
- 3. Add pipeline scripts to `.github/scripts/`
15
- 4. Register outcome classifications with WarpMetrics
16
-
17
12
  ## How It Works
18
13
 
19
14
  ```
20
- Issue labeled "agent"
21
- agent-implement.yml runs Claude Code Action
22
- Claude reads the issue, creates a branch, implements, opens PR
23
- warp-review reviews the PR (if installed)
24
- agent-revise.yml applies feedback and pushes fixes
25
- Loop until approved or revision limit (3) reached
15
+ Board: "Todo" column
16
+ warp-coder picks up the task
17
+ clones repo, creates branch, runs Claude Code
18
+ pushes branch, opens PR, moves to "In Review"
19
+ if review feedback arrives: applies fixes, pushes again
20
+ if approved: squash-merges, moves to "Done"
26
21
  ```
27
22
 
28
- Every step is instrumented with [WarpMetrics](https://warpmetrics.com) you get runs, groups, and outcomes tracking the full pipeline.
23
+ The agent polls your GitHub Projects board and processes tasks sequentially:
29
24
 
30
- ## Workflows
25
+ 1. **Todo** — picks the first item, implements it, opens a PR
26
+ 2. **In Review** — detects new review comments, applies feedback (up to 3 revisions)
27
+ 3. **Approved** — squash-merges the PR, runs `onMerged` hook, moves to "Done"
28
+
29
+ Every step is instrumented with [WarpMetrics](https://warpmetrics.com) — you get runs, groups, and outcomes tracking the full pipeline.
31
30
 
32
- ### agent-implement.yml
31
+ ## Config
32
+
33
+ `warp-coder init` creates `.warp-coder/config.json`:
34
+
35
+ ```json
36
+ {
37
+ "board": {
38
+ "provider": "github-projects",
39
+ "project": 1,
40
+ "owner": "your-org",
41
+ "columns": {
42
+ "todo": "Todo",
43
+ "inProgress": "In Progress",
44
+ "inReview": "In Review",
45
+ "done": "Done",
46
+ "blocked": "Blocked"
47
+ }
48
+ },
49
+ "hooks": {
50
+ "onBeforePush": "npm test",
51
+ "onMerged": "npm run deploy:prod"
52
+ },
53
+ "claude": {
54
+ "allowedTools": "Bash,Read,Edit,Write,Glob,Grep",
55
+ "maxTurns": 20
56
+ },
57
+ "pollInterval": 30,
58
+ "maxRevisions": 3,
59
+ "repo": "git@github.com:your-org/your-repo.git"
60
+ }
61
+ ```
33
62
 
34
- Triggered when an issue is labeled `agent`. Creates a branch `agent/issue-{number}`, implements the issue, and opens a PR.
63
+ ## Lifecycle Hooks
35
64
 
36
- ### agent-revise.yml
65
+ | Hook | When | Use case |
66
+ |------|------|----------|
67
+ | `onBranchCreate` | After creating the implementation branch | Set up environment |
68
+ | `onBeforePush` | Before pushing (implement or revise) | Run tests/lint |
69
+ | `onPRCreated` | After opening a PR | Notify, add labels |
70
+ | `onBeforeMerge` | Before squash-merging | Final checks |
71
+ | `onMerged` | After merge completes | Deploy |
37
72
 
38
- Triggered when `github-actions[bot]` submits a review with comments (i.e., warp-review feedback). Applies the review feedback and pushes to the same branch. Stops after 3 revision attempts.
73
+ Hooks receive env vars: `ISSUE_NUMBER`, `PR_NUMBER`, `BRANCH`, `REPO`.
39
74
 
40
75
  ## Outcome Classifications
41
76
 
@@ -51,13 +86,11 @@ Triggered when `github-actions[bot]` submits a review with comments (i.e., warp-
51
86
  | Revision Failed | failure |
52
87
  | Max Retries | failure |
53
88
 
54
- ## Pairing with warp-review
55
-
56
- For the full implement → review → revise loop, install [warp-review](https://github.com/warpmetrics/warp-review):
89
+ ## Requirements
57
90
 
58
- ```bash
59
- npx @warpmetrics/review init
60
- ```
91
+ - [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) installed and authenticated
92
+ - [GitHub CLI](https://cli.github.com/) (`gh`) installed and authenticated
93
+ - A GitHub Projects v2 board with Status field
61
94
 
62
95
  ## License
63
96
 
package/bin/cli.js ADDED
@@ -0,0 +1,198 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { createInterface } from 'readline';
4
+ import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'fs';
5
+ import { execSync } from 'child_process';
6
+ import { fileURLToPath } from 'url';
7
+ import { dirname, join } from 'path';
8
+ import { registerClassifications } from '../src/warp.js';
9
+ import { discoverProjectFields } from '../src/boards/github-projects.js';
10
+ import { loadMemory } from '../src/memory.js';
11
+ import { reflect } from '../src/reflect.js';
12
+
13
+ const __dirname = dirname(fileURLToPath(import.meta.url));
14
+ const defaultsDir = join(__dirname, '..', 'defaults');
15
+
16
+ const command = process.argv[2];
17
+
18
+ if (command === 'watch') {
19
+ const { watch } = await import('../src/watch.js');
20
+ await watch();
21
+ } else if (command === 'init') {
22
+ await runInit();
23
+ } else if (command === 'memory') {
24
+ const configDir = join(process.cwd(), '.warp-coder');
25
+ const memory = loadMemory(configDir);
26
+ console.log(memory || '(no memory yet)');
27
+ } else if (command === 'compact') {
28
+ const configDir = join(process.cwd(), '.warp-coder');
29
+ const { loadConfig } = await import('../src/config.js');
30
+ const config = loadConfig();
31
+ console.log('Compacting memory...');
32
+ await reflect({ configDir, step: 'compact', success: true, maxLines: config.memory?.maxLines || 100 });
33
+ console.log('Done.');
34
+ } else {
35
+ console.log('');
36
+ console.log(' warp-coder — local agent loop for implementing GitHub issues');
37
+ console.log('');
38
+ console.log(' Usage:');
39
+ console.log(' warp-coder init Set up config for a project');
40
+ console.log(' warp-coder watch Start the poll loop');
41
+ console.log(' warp-coder memory Print current memory file');
42
+ console.log(' warp-coder compact Force-rewrite memory file');
43
+ console.log('');
44
+ process.exit(command ? 1 : 0);
45
+ }
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Init wizard
49
+ // ---------------------------------------------------------------------------
50
+
51
+ async function runInit() {
52
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
53
+ const ask = q => new Promise(resolve => rl.question(q, resolve));
54
+ const log = msg => console.log(msg);
55
+
56
+ try {
57
+ log('');
58
+ log(' warp-coder — set up agent config');
59
+ log('');
60
+
61
+ // 1. Ensure gh has the right scopes
62
+ log(' Ensuring GitHub CLI has required scopes (project, repo)...');
63
+ try {
64
+ execSync('gh auth refresh -s project,repo', { stdio: 'inherit' });
65
+ log(' \u2713 GitHub CLI scopes updated');
66
+ } catch {
67
+ log(' \u26a0 Could not refresh gh scopes — run manually: gh auth refresh -s project,repo');
68
+ }
69
+ log('');
70
+
71
+ // 2. WarpMetrics API key
72
+ const wmKey = await ask(' ? WarpMetrics API key (get one at warpmetrics.com/app/api-keys): ');
73
+ if (wmKey && !wmKey.startsWith('wm_')) {
74
+ log(' \u26a0 Warning: key doesn\'t start with wm_ — make sure this is a valid WarpMetrics API key');
75
+ }
76
+ log('');
77
+
78
+ // 3. Repo URL
79
+ let repoDefault = '';
80
+ try {
81
+ repoDefault = execSync('git remote get-url origin', { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
82
+ } catch {}
83
+ const repoPrompt = repoDefault ? ` ? Repository URL (${repoDefault}): ` : ' ? Repository URL: ';
84
+ const repoInput = await ask(repoPrompt);
85
+ const repo = repoInput || repoDefault;
86
+ if (!repo) {
87
+ log(' \u2717 Repository URL is required');
88
+ process.exit(1);
89
+ }
90
+ log('');
91
+
92
+ // 4. Board provider
93
+ log(' Board: GitHub Projects v2');
94
+ const projectNumber = await ask(' ? Project number: ');
95
+ if (!projectNumber) {
96
+ log(' \u2717 Project number is required');
97
+ process.exit(1);
98
+ }
99
+
100
+ // Try to infer owner from repo URL
101
+ let ownerDefault = '';
102
+ const match = repo.match(/github\.com[:/]([^/]+)\//);
103
+ if (match) ownerDefault = match[1];
104
+ const ownerPrompt = ownerDefault ? ` ? Project owner (${ownerDefault}): ` : ' ? Project owner: ';
105
+ const ownerInput = await ask(ownerPrompt);
106
+ const owner = ownerInput || ownerDefault;
107
+ log('');
108
+
109
+ // 5. Discover field IDs and column names
110
+ let columns = { todo: 'Todo', inProgress: 'In Progress', inReview: 'In Review', done: 'Done', blocked: 'Blocked' };
111
+ try {
112
+ log(' Discovering project fields...');
113
+ const fields = discoverProjectFields(parseInt(projectNumber, 10), owner);
114
+ const statusField = fields.find(f => f.name === 'Status');
115
+ if (statusField?.options) {
116
+ const available = statusField.options.map(o => o.name);
117
+ log(` Found columns: ${available.join(', ')}`);
118
+ // Map available columns to our column keys, keeping defaults for any not found
119
+ for (const key of Object.keys(columns)) {
120
+ if (!available.includes(columns[key])) {
121
+ log(` \u26a0 Column "${columns[key]}" not found in project`);
122
+ }
123
+ }
124
+ }
125
+ log('');
126
+ } catch (err) {
127
+ log(` \u26a0 Could not discover fields: ${err.message}`);
128
+ log(' Using default column names');
129
+ log('');
130
+ }
131
+
132
+ // 6. Build config
133
+ const config = {
134
+ board: {
135
+ provider: 'github-projects',
136
+ project: parseInt(projectNumber, 10),
137
+ owner,
138
+ columns,
139
+ },
140
+ hooks: {},
141
+ claude: {
142
+ allowedTools: 'Bash,Read,Edit,Write,Glob,Grep',
143
+ maxTurns: 20,
144
+ },
145
+ pollInterval: 30,
146
+ maxRevisions: 3,
147
+ repo,
148
+ };
149
+
150
+ if (wmKey) {
151
+ config.warpmetricsApiKey = wmKey;
152
+ }
153
+
154
+ // 7. Write config
155
+ const configDir = '.warp-coder';
156
+ mkdirSync(configDir, { recursive: true });
157
+ writeFileSync(join(configDir, 'config.json'), JSON.stringify(config, null, 2) + '\n');
158
+ log(` \u2713 ${configDir}/config.json created`);
159
+
160
+ // 8. Add to .gitignore
161
+ const gitignorePath = '.gitignore';
162
+ const entry = '.warp-coder/';
163
+ if (existsSync(gitignorePath)) {
164
+ const content = readFileSync(gitignorePath, 'utf-8');
165
+ if (!content.split('\n').some(line => line.trim() === entry)) {
166
+ writeFileSync(gitignorePath, content.trimEnd() + '\n' + entry + '\n');
167
+ log(` \u2713 Added ${entry} to .gitignore`);
168
+ } else {
169
+ log(` \u2713 ${entry} already in .gitignore`);
170
+ }
171
+ } else {
172
+ writeFileSync(gitignorePath, entry + '\n');
173
+ log(` \u2713 Created .gitignore with ${entry}`);
174
+ }
175
+
176
+ // 9. Register outcome classifications
177
+ if (wmKey) {
178
+ log(' Registering outcome classifications with WarpMetrics...');
179
+ try {
180
+ await registerClassifications(wmKey);
181
+ log(' \u2713 Outcomes configured');
182
+ } catch (err) {
183
+ log(` \u26a0 Some classifications failed: ${err.message}`);
184
+ log(' You can set them manually in the WarpMetrics dashboard');
185
+ }
186
+ }
187
+
188
+ // 10. Next steps
189
+ log('');
190
+ log(' Done! Next steps:');
191
+ log(' 1. Run: warp-coder watch');
192
+ log(' 2. Add issues to the "Ready" column of your project board');
193
+ log(' 3. View pipeline analytics at https://app.warpmetrics.com');
194
+ log('');
195
+ } finally {
196
+ rl.close();
197
+ }
198
+ }
@@ -0,0 +1,26 @@
1
+ {
2
+ "board": {
3
+ "provider": "github-projects",
4
+ "project": 1,
5
+ "owner": "your-org",
6
+ "columns": {
7
+ "todo": "Todo",
8
+ "inProgress": "In Progress",
9
+ "inReview": "In Review",
10
+ "done": "Done",
11
+ "blocked": "Blocked"
12
+ }
13
+ },
14
+ "hooks": {},
15
+ "claude": {
16
+ "allowedTools": "Bash,Read,Edit,Write,Glob,Grep",
17
+ "maxTurns": 20
18
+ },
19
+ "memory": {
20
+ "enabled": true,
21
+ "maxLines": 100
22
+ },
23
+ "pollInterval": 30,
24
+ "maxRevisions": 3,
25
+ "repo": "git@github.com:your-org/your-repo.git"
26
+ }
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@warpmetrics/coder",
3
- "version": "0.1.4",
3
+ "version": "0.2.2",
4
4
  "type": "module",
5
- "description": "Agent pipeline for implementing GitHub issues with Claude Code. Powered by WarpMetrics.",
5
+ "description": "Local agent loop for implementing GitHub issues with Claude Code. Powered by WarpMetrics.",
6
6
  "bin": {
7
- "warp-coder": "./bin/init.js"
7
+ "warp-coder": "./bin/cli.js"
8
8
  },
9
9
  "scripts": {
10
10
  "release:patch": "npm version patch && git push origin main --tags",
@@ -12,6 +12,7 @@
12
12
  },
13
13
  "files": [
14
14
  "bin/",
15
+ "src/",
15
16
  "defaults/",
16
17
  "README.md",
17
18
  "LICENSE"
@@ -24,9 +25,9 @@
24
25
  "keywords": [
25
26
  "agent",
26
27
  "claude-code",
27
- "github-action",
28
28
  "warpmetrics",
29
29
  "llm",
30
- "code-generation"
30
+ "code-generation",
31
+ "automation"
31
32
  ]
32
33
  }
package/src/agent.js ADDED
@@ -0,0 +1,202 @@
1
+ // Implement a single task: clone → branch → claude → push → 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 implement(item, { board, config, log }) {
16
+ const issueNumber = item.content?.number;
17
+ const issueTitle = item.content?.title || `Issue #${issueNumber}`;
18
+ const issueBody = item.content?.body || '';
19
+ const repo = config.repo;
20
+ const repoName = repo.replace(/\.git$/, '').split('/').pop(); // owner/repo or just repo
21
+ const branch = `agent/issue-${issueNumber}`;
22
+ const workdir = join(tmpdir(), 'warp-coder', String(issueNumber));
23
+ const configDir = join(process.cwd(), CONFIG_DIR);
24
+
25
+ log(`Implementing #${issueNumber}: ${issueTitle}`);
26
+
27
+ // Move to In Progress
28
+ try {
29
+ await board.moveToInProgress(item);
30
+ } catch (err) {
31
+ log(` warning: could not move to In Progress: ${err.message}`);
32
+ }
33
+
34
+ // WarpMetrics: start pipeline
35
+ let groupId = null;
36
+ if (config.warpmetricsApiKey) {
37
+ try {
38
+ const pipeline = await warp.startPipeline(config.warpmetricsApiKey, {
39
+ step: 'implement',
40
+ repo: repoName,
41
+ issueNumber,
42
+ issueTitle,
43
+ });
44
+ groupId = pipeline.groupId;
45
+ log(` pipeline: run=${pipeline.runId} group=${groupId}`);
46
+ } catch (err) {
47
+ log(` warning: pipeline start failed: ${err.message}`);
48
+ }
49
+ }
50
+
51
+ let success = false;
52
+ let claudeResult = null;
53
+ let taskError = null;
54
+ const hookOutputs = [];
55
+
56
+ try {
57
+ // Clone
58
+ rmSync(workdir, { recursive: true, force: true });
59
+ mkdirSync(workdir, { recursive: true });
60
+ log(` cloning into ${workdir}`);
61
+ git.cloneRepo(repo, workdir);
62
+
63
+ // Branch
64
+ git.createBranch(workdir, branch);
65
+ log(` branch: ${branch}`);
66
+
67
+ // Hook: onBranchCreate
68
+ try {
69
+ const h = runHook('onBranchCreate', config, { workdir, issueNumber, branch, repo: repoName });
70
+ if (h.ran) hookOutputs.push(h);
71
+ } catch (err) {
72
+ if (err.hookResult) hookOutputs.push(err.hookResult);
73
+ throw err;
74
+ }
75
+
76
+ // Load memory for prompt enrichment
77
+ const memory = config.memory?.enabled !== false ? loadMemory(configDir) : '';
78
+
79
+ // Claude
80
+ const promptParts = [
81
+ `You are working on the repository ${repoName}.`,
82
+ '',
83
+ ];
84
+
85
+ if (memory) {
86
+ promptParts.push(
87
+ 'Lessons learned from previous tasks in this repository:',
88
+ '',
89
+ memory,
90
+ '',
91
+ );
92
+ }
93
+
94
+ promptParts.push(
95
+ `Implement the following GitHub issue:`,
96
+ '',
97
+ `**#${issueNumber}: ${issueTitle}**`,
98
+ '',
99
+ issueBody,
100
+ '',
101
+ 'Steps:',
102
+ '1. Read the codebase to understand relevant context',
103
+ '2. Implement the changes',
104
+ '3. Run tests to verify nothing is broken',
105
+ '4. Commit with a clear message',
106
+ '',
107
+ 'Do NOT create branches, push, or open PRs — just implement and commit.',
108
+ 'If the issue is unclear or you cannot implement it, explain what is missing.',
109
+ );
110
+
111
+ const prompt = promptParts.join('\n');
112
+
113
+ log(' running claude...');
114
+ claudeResult = await claude.run({
115
+ prompt,
116
+ workdir,
117
+ allowedTools: config.claude?.allowedTools,
118
+ maxTurns: config.claude?.maxTurns,
119
+ });
120
+ log(` claude done (cost: $${claudeResult.costUsd ?? '?'})`);
121
+
122
+ // Hook: onBeforePush
123
+ try {
124
+ const h = runHook('onBeforePush', config, { workdir, issueNumber, branch, repo: repoName });
125
+ if (h.ran) hookOutputs.push(h);
126
+ } catch (err) {
127
+ if (err.hookResult) hookOutputs.push(err.hookResult);
128
+ throw err;
129
+ }
130
+
131
+ // Push + PR
132
+ log(' pushing...');
133
+ git.push(workdir, branch);
134
+ const pr = git.createPR(workdir, {
135
+ title: issueTitle,
136
+ body: `Closes #${issueNumber}\n\nImplemented by warp-coder.`,
137
+ });
138
+ log(` PR created: ${pr.url}`);
139
+
140
+ // Hook: onPRCreated
141
+ try {
142
+ const h = runHook('onPRCreated', config, { workdir, issueNumber, prNumber: pr.number, branch, repo: repoName });
143
+ if (h.ran) hookOutputs.push(h);
144
+ } catch (err) {
145
+ if (err.hookResult) hookOutputs.push(err.hookResult);
146
+ throw err;
147
+ }
148
+
149
+ // Move to In Review
150
+ try {
151
+ await board.moveToReview(item);
152
+ } catch (err) {
153
+ log(` warning: could not move to In Review: ${err.message}`);
154
+ }
155
+
156
+ success = true;
157
+ } catch (err) {
158
+ taskError = err.message;
159
+ log(` failed: ${err.message}`);
160
+ } finally {
161
+ // WarpMetrics: record outcome
162
+ if (config.warpmetricsApiKey && groupId) {
163
+ try {
164
+ const outcome = await warp.recordOutcome(config.warpmetricsApiKey, groupId, {
165
+ step: 'implement',
166
+ success,
167
+ costUsd: claudeResult?.costUsd,
168
+ error: taskError,
169
+ hooksFailed: hookOutputs.some(h => h.exitCode !== 0),
170
+ issueNumber,
171
+ });
172
+ log(` outcome: ${outcome.name}`);
173
+ } catch (err) {
174
+ log(` warning: outcome recording failed: ${err.message}`);
175
+ }
176
+ }
177
+
178
+ // Reflect
179
+ if (config.memory?.enabled !== false) {
180
+ try {
181
+ await reflect({
182
+ configDir,
183
+ step: 'implement',
184
+ issue: { number: issueNumber, title: issueTitle },
185
+ success,
186
+ error: taskError,
187
+ hookOutputs: hookOutputs.filter(h => h.ran),
188
+ claudeOutput: claudeResult?.result,
189
+ maxLines: config.memory?.maxLines || 100,
190
+ });
191
+ log(' reflect: memory updated');
192
+ } catch (err) {
193
+ log(` warning: reflect failed: ${err.message}`);
194
+ }
195
+ }
196
+
197
+ // Cleanup
198
+ rmSync(workdir, { recursive: true, force: true });
199
+ }
200
+
201
+ return success;
202
+ }
@@ -0,0 +1,114 @@
1
+ // GitHub Projects v2 board adapter.
2
+ // Uses `gh` CLI for all API interactions.
3
+
4
+ import { execSync } from 'child_process';
5
+
6
+ function gh(args, opts = {}) {
7
+ return execSync(`gh ${args}`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], ...opts }).trim();
8
+ }
9
+
10
+ function ghJson(args, opts = {}) {
11
+ const out = gh(args, opts);
12
+ return out ? JSON.parse(out) : null;
13
+ }
14
+
15
+ export function create({ project, owner, statusField = 'Status', columns = {} }) {
16
+ const colNames = {
17
+ todo: columns.todo || 'Todo',
18
+ inProgress: columns.inProgress || 'In Progress',
19
+ inReview: columns.inReview || 'In Review',
20
+ done: columns.done || 'Done',
21
+ blocked: columns.blocked || 'Blocked',
22
+ };
23
+
24
+ // Cache field/option IDs (discovered on first use)
25
+ let fieldId = null;
26
+ let optionIds = null;
27
+
28
+ function discoverField() {
29
+ if (fieldId) return;
30
+ const fields = ghJson(`project field-list ${project} --owner ${owner} --format json`);
31
+ const field = fields?.fields?.find(f => f.name === statusField);
32
+ if (!field) throw new Error(`Status field "${statusField}" not found in project ${project}`);
33
+ fieldId = field.id;
34
+ optionIds = {};
35
+ for (const opt of field.options || []) {
36
+ optionIds[opt.name] = opt.id;
37
+ }
38
+ }
39
+
40
+ function getOptionId(colKey) {
41
+ discoverField();
42
+ const name = colNames[colKey];
43
+ const id = optionIds[name];
44
+ if (!id) throw new Error(`Column "${name}" not found. Available: ${Object.keys(optionIds).join(', ')}`);
45
+ return id;
46
+ }
47
+
48
+ async function listItemsByStatus(statusName) {
49
+ const items = ghJson(`project item-list ${project} --owner ${owner} --format json`);
50
+ return (items?.items || []).filter(item => {
51
+ const status = item.status || item.fields?.find(f => f.name === statusField)?.value;
52
+ return status === statusName;
53
+ });
54
+ }
55
+
56
+ async function moveItem(item, colKey) {
57
+ discoverField();
58
+ 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}`);
60
+ }
61
+
62
+ return {
63
+ async listTodo() {
64
+ return listItemsByStatus(colNames.todo);
65
+ },
66
+
67
+ 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;
82
+ },
83
+
84
+ 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;
98
+ },
99
+
100
+ moveToInProgress(item) { return moveItem(item, 'inProgress'); },
101
+ moveToReview(item) { return moveItem(item, 'inReview'); },
102
+ moveToBlocked(item) { return moveItem(item, 'blocked'); },
103
+ moveToDone(item) { return moveItem(item, 'done'); },
104
+ };
105
+ }
106
+
107
+ // ---------------------------------------------------------------------------
108
+ // Field discovery for init wizard
109
+ // ---------------------------------------------------------------------------
110
+
111
+ export function discoverProjectFields(project, owner) {
112
+ const fields = ghJson(`project field-list ${project} --owner ${owner} --format json`);
113
+ return fields?.fields || [];
114
+ }