@damper/cli 0.9.2 → 0.9.4

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.
@@ -1,10 +1,10 @@
1
1
  import * as fs from 'node:fs';
2
2
  import pc from 'picocolors';
3
3
  import { createDamperApi } from '../services/damper-api.js';
4
- import { createWorktree, getMainProjectRoot } from '../services/worktree.js';
4
+ import { createWorktree, getMainProjectRoot, removeWorktreeDir } from '../services/worktree.js';
5
5
  import { bootstrapContext, refreshContext } from '../services/context-bootstrap.js';
6
6
  import { pickTask } from '../ui/task-picker.js';
7
- import { launchClaude, postTaskFlow, isClaudeInstalled, isDamperMcpConfigured, configureDamperMcp } from '../services/claude.js';
7
+ import { launchClaude, launchClaudeForReview, postTaskFlow, isClaudeInstalled, isDamperMcpConfigured, configureDamperMcp } from '../services/claude.js';
8
8
  import { getWorktreesForProject, cleanupStaleWorktrees } from '../services/state.js';
9
9
  import { getApiKey, isProjectConfigured, getProjectConfigPath } from '../services/config.js';
10
10
  import { shortIdRaw } from '../ui/format.js';
@@ -77,23 +77,39 @@ export async function startCommand(options) {
77
77
  }
78
78
  }
79
79
  else {
80
- // Interactive task picker
81
- const result = await pickTask({
82
- api,
83
- worktrees,
84
- typeFilter: options.type,
85
- statusFilter: options.status,
86
- });
87
- if (!result) {
88
- process.exit(0);
89
- }
90
- taskId = result.task.id;
91
- taskTitle = result.task.title;
92
- isResume = result.isResume;
93
- isNewTask = result.isNewTask || false;
94
- forceTakeover = result.forceTakeover || options.force || false;
95
- if (result.worktree) {
96
- worktreePath = result.worktree.path;
80
+ // Interactive task picker with review loop
81
+ while (true) {
82
+ // Re-fetch worktrees each iteration (handles cleanup between reviews)
83
+ const currentWorktrees = getWorktreesForProject(projectRoot);
84
+ const result = await pickTask({
85
+ api,
86
+ worktrees: currentWorktrees,
87
+ typeFilter: options.type,
88
+ statusFilter: options.status,
89
+ });
90
+ if (!result) {
91
+ process.exit(0);
92
+ }
93
+ if (result.action === 'review') {
94
+ await handleReviewAndComplete({
95
+ api,
96
+ apiKey,
97
+ task: result.task,
98
+ worktree: result.worktree,
99
+ projectRoot,
100
+ });
101
+ continue;
102
+ }
103
+ // action is 'start' or 'resume' — break into normal flow
104
+ taskId = result.task.id;
105
+ taskTitle = result.task.title;
106
+ isResume = result.isResume;
107
+ isNewTask = result.isNewTask || false;
108
+ forceTakeover = result.forceTakeover || options.force || false;
109
+ if (result.worktree) {
110
+ worktreePath = result.worktree.path;
111
+ }
112
+ break;
97
113
  }
98
114
  }
99
115
  if (isResume && worktreePath) {
@@ -194,3 +210,103 @@ export async function startCommand(options) {
194
210
  isNewTask,
195
211
  });
196
212
  }
213
+ /**
214
+ * Review a task and attempt to complete it via Claude, then return to the picker.
215
+ */
216
+ async function handleReviewAndComplete(options) {
217
+ const { api, apiKey, task, worktree, projectRoot } = options;
218
+ const { confirm } = await import('@inquirer/prompts');
219
+ const { execa } = await import('execa');
220
+ const taskId = task.id;
221
+ const cwd = worktree?.path || projectRoot;
222
+ const hasWorktree = !!worktree;
223
+ console.log(pc.cyan(`\nReviewing task #${shortIdRaw(taskId)}: ${task.title}`));
224
+ console.log(pc.dim(`Directory: ${cwd}`));
225
+ // Lock the task
226
+ console.log(pc.dim('\nLocking task in Damper...'));
227
+ try {
228
+ await api.startTask(taskId, true);
229
+ console.log(pc.green('✓ Task locked'));
230
+ }
231
+ catch (err) {
232
+ const error = err;
233
+ console.log(pc.red(`Failed to lock task: ${error.message}`));
234
+ return;
235
+ }
236
+ // Launch Claude for review
237
+ console.log(pc.dim('\nLaunching Claude for review...\n'));
238
+ await launchClaudeForReview({ cwd, apiKey, taskId });
239
+ console.log(pc.dim('\n─────────────────────────────────────────\n'));
240
+ // Check task status after Claude exits
241
+ let taskStatus;
242
+ try {
243
+ const updatedTask = await api.getTask(taskId);
244
+ taskStatus = updatedTask.status;
245
+ }
246
+ catch {
247
+ console.log(pc.yellow('Could not fetch task status from Damper.'));
248
+ }
249
+ if (taskStatus === 'done') {
250
+ console.log(pc.green(`✓ Task #${shortIdRaw(taskId)} completed!`));
251
+ if (hasWorktree) {
252
+ // Check for unpushed commits and offer to push + cleanup
253
+ let hasUnpushed = false;
254
+ try {
255
+ const { stdout } = await execa('git', ['log', '@{u}..HEAD', '--oneline'], { cwd, stdio: 'pipe' });
256
+ hasUnpushed = stdout.trim().length > 0;
257
+ }
258
+ catch {
259
+ try {
260
+ const { stdout } = await execa('git', ['log', 'origin/main..HEAD', '--oneline'], { cwd, stdio: 'pipe' });
261
+ hasUnpushed = stdout.trim().length > 0;
262
+ }
263
+ catch {
264
+ // Assume there might be commits
265
+ hasUnpushed = true;
266
+ }
267
+ }
268
+ if (hasUnpushed) {
269
+ const shouldPush = await confirm({
270
+ message: 'Push unpushed commits?',
271
+ default: true,
272
+ });
273
+ if (shouldPush) {
274
+ try {
275
+ const { stdout: branch } = await execa('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd, stdio: 'pipe' });
276
+ await execa('git', ['push', '-u', 'origin', branch.trim()], { cwd, stdio: 'inherit' });
277
+ console.log(pc.green('✓ Pushed to remote'));
278
+ }
279
+ catch {
280
+ console.log(pc.red('Failed to push. You can push manually.'));
281
+ }
282
+ }
283
+ }
284
+ const shouldCleanup = await confirm({
285
+ message: 'Remove worktree and branch?',
286
+ default: true,
287
+ });
288
+ if (shouldCleanup) {
289
+ try {
290
+ await removeWorktreeDir(cwd, projectRoot);
291
+ console.log(pc.green('✓ Worktree and branch removed'));
292
+ }
293
+ catch (err) {
294
+ const error = err;
295
+ console.log(pc.red(`Failed to remove worktree: ${error.message}`));
296
+ }
297
+ }
298
+ }
299
+ }
300
+ else {
301
+ // Not completed — release the lock
302
+ console.log(pc.yellow(`Task #${shortIdRaw(taskId)} not completed (status: ${taskStatus || 'unknown'})`));
303
+ try {
304
+ await api.abandonTask(taskId, 'Review session ended without completion');
305
+ console.log(pc.dim('Task lock released.'));
306
+ }
307
+ catch {
308
+ // Ignore — task may already be unlocked
309
+ }
310
+ }
311
+ console.log();
312
+ }
@@ -47,6 +47,14 @@ export declare function postTaskFlow(options: {
47
47
  projectRoot: string;
48
48
  isNewTask?: boolean;
49
49
  }): Promise<void>;
50
+ /**
51
+ * Launch Claude to review and complete a task
52
+ */
53
+ export declare function launchClaudeForReview(options: {
54
+ cwd: string;
55
+ apiKey: string;
56
+ taskId: string;
57
+ }): Promise<void>;
50
58
  /**
51
59
  * Check if Claude Code CLI is installed
52
60
  */
@@ -441,6 +441,34 @@ export async function postTaskFlow(options) {
441
441
  }
442
442
  console.log();
443
443
  }
444
+ /**
445
+ * Launch Claude to review and complete a task
446
+ */
447
+ export async function launchClaudeForReview(options) {
448
+ const { cwd, apiKey, taskId } = options;
449
+ const prompt = [
450
+ `Review task #${taskId} and determine if it is ready to be marked as complete.`,
451
+ '',
452
+ 'Steps:',
453
+ '1. Read TASK_CONTEXT.md (if it exists) to understand the requirements',
454
+ '2. Check git log and git diff main...HEAD to review what was done',
455
+ '3. Run the project tests (use `bun run test` from the monorepo root, NEVER `bun test`)',
456
+ '4. Check the completion checklist via get_project_settings MCP tool',
457
+ '5. If everything looks good, call complete_task via MCP with a summary and confirmations',
458
+ '6. If NOT ready, explain what is missing or broken — do NOT call complete_task',
459
+ '',
460
+ 'IMPORTANT: Do NOT make any code changes. This is a review-only session.',
461
+ ].join('\n');
462
+ await new Promise((resolve) => {
463
+ const child = spawn('claude', [prompt], {
464
+ cwd,
465
+ stdio: 'inherit',
466
+ env: { ...process.env, DAMPER_API_KEY: apiKey },
467
+ });
468
+ child.on('error', () => resolve());
469
+ child.on('close', () => resolve());
470
+ });
471
+ }
444
472
  /**
445
473
  * Launch Claude to resolve merge conflicts
446
474
  */
@@ -105,6 +105,17 @@ export declare class DamperApi {
105
105
  quarter?: string;
106
106
  sort?: 'importance' | 'newest' | 'votes';
107
107
  limit?: number;
108
+ offset?: number;
109
+ }): Promise<{
110
+ project: string;
111
+ tasks: Task[];
112
+ total: number;
113
+ }>;
114
+ listAllTasks(filters?: {
115
+ status?: 'planned' | 'in_progress' | 'done' | 'all';
116
+ type?: 'bug' | 'feature' | 'improvement' | 'task';
117
+ quarter?: string;
118
+ sort?: 'importance' | 'newest' | 'votes';
108
119
  }): Promise<{
109
120
  project: string;
110
121
  tasks: Task[];
@@ -40,9 +40,23 @@ export class DamperApi {
40
40
  params.set('sort', filters.sort);
41
41
  if (filters?.limit)
42
42
  params.set('limit', String(filters.limit));
43
+ if (filters?.offset)
44
+ params.set('offset', String(filters.offset));
43
45
  const query = params.toString();
44
46
  const data = await this.request('GET', `/api/agent/tasks${query ? `?${query}` : ''}`);
45
- return { project: data.project.name, tasks: data.tasks };
47
+ return { project: data.project.name, tasks: data.tasks, total: data.total };
48
+ }
49
+ async listAllTasks(filters) {
50
+ const pageSize = 100;
51
+ const first = await this.listTasks({ ...filters, limit: pageSize });
52
+ const allTasks = [...first.tasks];
53
+ while (allTasks.length < first.total) {
54
+ const page = await this.listTasks({ ...filters, limit: pageSize, offset: allTasks.length });
55
+ allTasks.push(...page.tasks);
56
+ if (page.tasks.length === 0)
57
+ break; // safety: avoid infinite loop
58
+ }
59
+ return { project: first.project, tasks: allTasks };
46
60
  }
47
61
  async getTask(taskId) {
48
62
  return this.request('GET', `/api/agent/tasks/${taskId}`);
@@ -12,6 +12,7 @@ interface TaskPickerResult {
12
12
  isResume: boolean;
13
13
  forceTakeover?: boolean;
14
14
  isNewTask?: boolean;
15
+ action: 'start' | 'resume' | 'review';
15
16
  }
16
17
  export declare function pickTask(options: TaskPickerOptions): Promise<TaskPickerResult | null>;
17
18
  export {};
@@ -1,4 +1,4 @@
1
- import { select, confirm, input, Separator } from '@inquirer/prompts';
1
+ import { search, select, confirm, input, Separator } from '@inquirer/prompts';
2
2
  import pc from 'picocolors';
3
3
  import { shortId, shortIdRaw, getTypeIcon, getPriorityIcon, formatEffort, formatProgressCompact, formatDueDate, sectionHeader, relativeTime, getTerminalWidth, padEnd, padStart, } from './format.js';
4
4
  // Layout constants (terminal column widths)
@@ -118,12 +118,11 @@ function formatTaskChoice(choice, titleWidth, layout) {
118
118
  }
119
119
  export async function pickTask(options) {
120
120
  const { api, worktrees, typeFilter, statusFilter } = options;
121
- // Fetch tasks from Damper
122
- const { tasks, project } = await api.listTasks({
121
+ // Fetch all tasks from Damper (paginated)
122
+ const { tasks, project } = await api.listAllTasks({
123
123
  status: statusFilter || 'all',
124
124
  type: typeFilter,
125
125
  sort: 'importance',
126
- limit: 100,
127
126
  });
128
127
  // Filter out completed tasks unless specifically requested
129
128
  const availableTasks = tasks.filter(t => statusFilter === 'done' || statusFilter === 'all' ||
@@ -168,40 +167,6 @@ export async function pickTask(options) {
168
167
  ];
169
168
  const layout = getMetaLayout(allDisplayTasks, getTerminalWidth());
170
169
  const titleWidth = getTitleWidth(layout);
171
- const choices = [];
172
- if (inProgressChoices.length > 0) {
173
- choices.push(new Separator(`\n${sectionHeader(`In Progress (${inProgressChoices.length})`)}`));
174
- for (const choice of inProgressChoices) {
175
- choices.push({
176
- name: formatTaskChoice(choice, titleWidth, layout),
177
- value: choice,
178
- });
179
- }
180
- }
181
- if (availableChoices.length > 0) {
182
- choices.push(new Separator(`\n${sectionHeader(`Available (${availableChoices.length})`)}`));
183
- for (const choice of availableChoices) {
184
- choices.push({
185
- name: formatTaskChoice(choice, titleWidth, layout),
186
- value: choice,
187
- });
188
- }
189
- }
190
- if (lockedChoices.length > 0) {
191
- choices.push(new Separator(`\n${sectionHeader(`Locked (${lockedChoices.length})`)}`));
192
- for (const choice of lockedChoices) {
193
- choices.push({
194
- name: formatTaskChoice(choice, titleWidth, layout),
195
- value: choice,
196
- });
197
- }
198
- }
199
- // Always show "Create new task" option
200
- choices.push(new Separator(''));
201
- choices.push({
202
- name: pc.green('+ Create new task'),
203
- value: { type: 'create_new' },
204
- });
205
170
  if (inProgressChoices.length === 0 && availableChoices.length === 0 && lockedChoices.length === 0) {
206
171
  console.log(pc.yellow('\nNo existing tasks found.'));
207
172
  if (typeFilter) {
@@ -209,19 +174,58 @@ export async function pickTask(options) {
209
174
  }
210
175
  }
211
176
  console.log(pc.bold(`\nProject: ${project}`));
212
- const selected = await select({
213
- message: 'Select a task to work on:',
214
- choices: choices,
177
+ // Build choices with search filtering support
178
+ const buildChoices = (term) => {
179
+ const lowerTerm = term?.toLowerCase().trim() || '';
180
+ const matches = (task) => !lowerTerm || task.title.toLowerCase().includes(lowerTerm) || task.id.includes(lowerTerm);
181
+ const filtered = [];
182
+ const filteredInProgress = inProgressChoices.filter(c => matches(c.task));
183
+ if (filteredInProgress.length > 0) {
184
+ filtered.push(new Separator(`\n${sectionHeader(`In Progress (${filteredInProgress.length})`)}`));
185
+ for (const choice of filteredInProgress) {
186
+ filtered.push({ name: formatTaskChoice(choice, titleWidth, layout), value: choice });
187
+ }
188
+ }
189
+ const filteredAvailable = availableChoices.filter(c => matches(c.task));
190
+ if (filteredAvailable.length > 0) {
191
+ filtered.push(new Separator(`\n${sectionHeader(`Available (${filteredAvailable.length})`)}`));
192
+ for (const choice of filteredAvailable) {
193
+ filtered.push({ name: formatTaskChoice(choice, titleWidth, layout), value: choice });
194
+ }
195
+ }
196
+ const filteredLocked = lockedChoices.filter(c => matches(c.task));
197
+ if (filteredLocked.length > 0) {
198
+ filtered.push(new Separator(`\n${sectionHeader(`Locked (${filteredLocked.length})`)}`));
199
+ for (const choice of filteredLocked) {
200
+ filtered.push({ name: formatTaskChoice(choice, titleWidth, layout), value: choice });
201
+ }
202
+ }
203
+ // Always show "Create new task"
204
+ filtered.push(new Separator(''));
205
+ filtered.push({ name: pc.green('+ Create new task'), value: { type: 'create_new' } });
206
+ return filtered;
207
+ };
208
+ const selected = await search({
209
+ message: 'Select a task (type to filter):',
210
+ source: (term) => buildChoices(term),
215
211
  pageSize: 20,
216
212
  });
217
213
  if (selected.type === 'create_new') {
218
214
  return handleCreateNewTask(api);
219
215
  }
220
216
  if (selected.type === 'in_progress') {
217
+ const action = await select({
218
+ message: `#${shortIdRaw(selected.task.id)} ${selected.task.title}`,
219
+ choices: [
220
+ { name: 'Resume working', value: 'resume' },
221
+ { name: 'Review & complete', value: 'review' },
222
+ ],
223
+ });
221
224
  return {
222
225
  task: selected.task,
223
226
  worktree: selected.worktree,
224
227
  isResume: true,
228
+ action,
225
229
  };
226
230
  }
227
231
  if (selected.type === 'locked') {
@@ -240,11 +244,20 @@ export async function pickTask(options) {
240
244
  task: selected.task,
241
245
  isResume: false,
242
246
  forceTakeover: true,
247
+ action: 'start',
243
248
  };
244
249
  }
250
+ const action = await select({
251
+ message: `#${shortIdRaw(selected.task.id)} ${selected.task.title}`,
252
+ choices: [
253
+ { name: 'Start working', value: 'start' },
254
+ { name: 'Quick complete', value: 'review' },
255
+ ],
256
+ });
245
257
  return {
246
258
  task: selected.task,
247
259
  isResume: false,
260
+ action,
248
261
  };
249
262
  }
250
263
  async function handleCreateNewTask(api) {
@@ -275,5 +288,6 @@ async function handleCreateNewTask(api) {
275
288
  task,
276
289
  isResume: false,
277
290
  isNewTask: true,
291
+ action: 'start',
278
292
  };
279
293
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@damper/cli",
3
- "version": "0.9.2",
3
+ "version": "0.9.4",
4
4
  "description": "CLI tool for orchestrating Damper task workflows with Claude Code",
5
5
  "author": "Damper <hello@usedamper.com>",
6
6
  "repository": {