@damper/cli 0.9.1 → 0.9.3

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
  */
@@ -24,10 +24,11 @@ ${planSection}
24
24
  - \`.claude/settings.local.json\`
25
25
 
26
26
  **Your responsibilities (via Damper MCP):**
27
- 1. Use \`add_commit\` after each git commit
28
- 2. Use \`add_note\` ONLY for non-obvious approach decisions (e.g. "Decision: chose X because Y")
29
- 3. When done: call \`complete_task\` with a one-line summary
30
- 4. If stopping early: call \`abandon_task\` with what remains and blockers
27
+ 1. **Do NOT commit or complete tasks without explicit user confirmation** - Always ask the user before running \`git commit\` or calling \`complete_task\`
28
+ 2. Use \`add_commit\` after each git commit
29
+ 3. Use \`add_note\` ONLY for non-obvious approach decisions (e.g. "Decision: chose X because Y")
30
+ 4. When user confirms: call \`complete_task\` with a one-line summary
31
+ 5. If stopping early: call \`abandon_task\` with what remains and blockers
31
32
 
32
33
  The CLI just bootstrapped this environment - YOU handle the task lifecycle.
33
34
  `.trim();
@@ -44,7 +44,8 @@ export function generateTaskContext(options) {
44
44
  lines.push('- Generated this TASK_CONTEXT.md from Damper API');
45
45
  lines.push('- Configured Damper MCP tools for task lifecycle');
46
46
  lines.push('');
47
- lines.push('**Cleanup:** When done, call `complete_task` or `abandon_task`. The user will run');
47
+ lines.push('**Cleanup:** Ask the user for confirmation before committing or completing the task.');
48
+ lines.push('Once confirmed, call `complete_task` or `abandon_task`. The user will run');
48
49
  lines.push('`npx @damper/cli cleanup` to remove the worktree and branch - you don\'t need to');
49
50
  lines.push('provide manual cleanup instructions.');
50
51
  lines.push('');
@@ -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)
@@ -168,40 +168,6 @@ export async function pickTask(options) {
168
168
  ];
169
169
  const layout = getMetaLayout(allDisplayTasks, getTerminalWidth());
170
170
  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
171
  if (inProgressChoices.length === 0 && availableChoices.length === 0 && lockedChoices.length === 0) {
206
172
  console.log(pc.yellow('\nNo existing tasks found.'));
207
173
  if (typeFilter) {
@@ -209,19 +175,58 @@ export async function pickTask(options) {
209
175
  }
210
176
  }
211
177
  console.log(pc.bold(`\nProject: ${project}`));
212
- const selected = await select({
213
- message: 'Select a task to work on:',
214
- choices: choices,
178
+ // Build choices with search filtering support
179
+ const buildChoices = (term) => {
180
+ const lowerTerm = term?.toLowerCase().trim() || '';
181
+ const matches = (task) => !lowerTerm || task.title.toLowerCase().includes(lowerTerm) || task.id.includes(lowerTerm);
182
+ const filtered = [];
183
+ const filteredInProgress = inProgressChoices.filter(c => matches(c.task));
184
+ if (filteredInProgress.length > 0) {
185
+ filtered.push(new Separator(`\n${sectionHeader(`In Progress (${filteredInProgress.length})`)}`));
186
+ for (const choice of filteredInProgress) {
187
+ filtered.push({ name: formatTaskChoice(choice, titleWidth, layout), value: choice });
188
+ }
189
+ }
190
+ const filteredAvailable = availableChoices.filter(c => matches(c.task));
191
+ if (filteredAvailable.length > 0) {
192
+ filtered.push(new Separator(`\n${sectionHeader(`Available (${filteredAvailable.length})`)}`));
193
+ for (const choice of filteredAvailable) {
194
+ filtered.push({ name: formatTaskChoice(choice, titleWidth, layout), value: choice });
195
+ }
196
+ }
197
+ const filteredLocked = lockedChoices.filter(c => matches(c.task));
198
+ if (filteredLocked.length > 0) {
199
+ filtered.push(new Separator(`\n${sectionHeader(`Locked (${filteredLocked.length})`)}`));
200
+ for (const choice of filteredLocked) {
201
+ filtered.push({ name: formatTaskChoice(choice, titleWidth, layout), value: choice });
202
+ }
203
+ }
204
+ // Always show "Create new task"
205
+ filtered.push(new Separator(''));
206
+ filtered.push({ name: pc.green('+ Create new task'), value: { type: 'create_new' } });
207
+ return filtered;
208
+ };
209
+ const selected = await search({
210
+ message: 'Select a task (type to filter):',
211
+ source: (term) => buildChoices(term),
215
212
  pageSize: 20,
216
213
  });
217
214
  if (selected.type === 'create_new') {
218
215
  return handleCreateNewTask(api);
219
216
  }
220
217
  if (selected.type === 'in_progress') {
218
+ const action = await select({
219
+ message: `#${shortIdRaw(selected.task.id)} ${selected.task.title}`,
220
+ choices: [
221
+ { name: 'Resume working', value: 'resume' },
222
+ { name: 'Review & complete', value: 'review' },
223
+ ],
224
+ });
221
225
  return {
222
226
  task: selected.task,
223
227
  worktree: selected.worktree,
224
228
  isResume: true,
229
+ action,
225
230
  };
226
231
  }
227
232
  if (selected.type === 'locked') {
@@ -240,11 +245,20 @@ export async function pickTask(options) {
240
245
  task: selected.task,
241
246
  isResume: false,
242
247
  forceTakeover: true,
248
+ action: 'start',
243
249
  };
244
250
  }
251
+ const action = await select({
252
+ message: `#${shortIdRaw(selected.task.id)} ${selected.task.title}`,
253
+ choices: [
254
+ { name: 'Start working', value: 'start' },
255
+ { name: 'Quick complete', value: 'review' },
256
+ ],
257
+ });
245
258
  return {
246
259
  task: selected.task,
247
260
  isResume: false,
261
+ action,
248
262
  };
249
263
  }
250
264
  async function handleCreateNewTask(api) {
@@ -275,5 +289,6 @@ async function handleCreateNewTask(api) {
275
289
  task,
276
290
  isResume: false,
277
291
  isNewTask: true,
292
+ action: 'start',
278
293
  };
279
294
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@damper/cli",
3
- "version": "0.9.1",
3
+ "version": "0.9.3",
4
4
  "description": "CLI tool for orchestrating Damper task workflows with Claude Code",
5
5
  "author": "Damper <hello@usedamper.com>",
6
6
  "repository": {