@damper/cli 0.6.11 → 0.6.13

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.
@@ -5,6 +5,7 @@ import pc from 'picocolors';
5
5
  import { getWorktrees, cleanupStaleWorktrees, removeWorktree } from '../services/state.js';
6
6
  import { createDamperApi } from '../services/damper-api.js';
7
7
  import { removeWorktreeDir } from '../services/worktree.js';
8
+ import { shortId, shortIdRaw, formatTaskLine } from '../ui/format.js';
8
9
  async function hasUncommittedChanges(worktreePath) {
9
10
  try {
10
11
  const { stdout } = await execa('git', ['status', '--porcelain'], {
@@ -108,10 +109,7 @@ export async function cleanupCommand() {
108
109
  console.log(pc.bold('\nWorktrees available for cleanup:\n'));
109
110
  const choices = candidates.map(c => {
110
111
  const worktreeName = c.worktree.path.split('/').pop() || c.worktree.path;
111
- let description = `#${c.worktree.taskId}`;
112
- if (c.task) {
113
- description += ` - ${c.task.title}`;
114
- }
112
+ let description = c.task ? formatTaskLine(c.task) : shortId(c.worktree.taskId);
115
113
  let reasonBadge = '';
116
114
  switch (c.reason) {
117
115
  case 'completed':
@@ -177,7 +175,7 @@ export async function cleanupCommand() {
177
175
  if (candidate.reason === 'in_progress' && api) {
178
176
  try {
179
177
  await api.abandonTask(worktree.taskId, 'Dropped via CLI cleanup');
180
- console.log(pc.green(`✓ Released task #${worktree.taskId}`));
178
+ console.log(pc.green(`✓ Released task #${shortIdRaw(worktree.taskId)}`));
181
179
  }
182
180
  catch (err) {
183
181
  const error = err;
@@ -4,6 +4,7 @@ import { createDamperApi } from '../services/damper-api.js';
4
4
  import { getWorktrees, removeWorktree } from '../services/state.js';
5
5
  import { removeWorktreeDir, getGitRoot } from '../services/worktree.js';
6
6
  import { getApiKey } from '../services/config.js';
7
+ import { shortId, shortIdRaw, getTypeIcon, getPriorityIcon, formatEffort } from '../ui/format.js';
7
8
  export async function releaseCommand() {
8
9
  // Get project root
9
10
  let projectRoot;
@@ -34,10 +35,14 @@ export async function releaseCommand() {
34
35
  const worktrees = getWorktrees();
35
36
  const choices = tasks.map(task => {
36
37
  const worktree = worktrees.find(w => w.taskId === task.id);
38
+ const priorityIcon = getPriorityIcon(task.priority);
39
+ const typeIcon = getTypeIcon(task.type);
40
+ const id = shortId(task.id);
41
+ const effort = formatEffort(task.effort);
37
42
  const lockedInfo = task.lockedBy ? pc.dim(` (locked by ${task.lockedBy})`) : '';
38
43
  const worktreeInfo = worktree ? pc.dim(` [has worktree]`) : '';
39
44
  return {
40
- name: `#${task.id} ${task.title}${lockedInfo}${worktreeInfo}`,
45
+ name: `${priorityIcon}${typeIcon} ${id} ${task.title}${effort ? ` ${effort}` : ''}${lockedInfo}${worktreeInfo}`,
41
46
  value: { task, worktree },
42
47
  };
43
48
  });
@@ -49,7 +54,7 @@ export async function releaseCommand() {
49
54
  // Confirm
50
55
  console.log();
51
56
  console.log(pc.yellow(`This will:`));
52
- console.log(pc.dim(` • Release the lock on task #${task.id}`));
57
+ console.log(pc.dim(` • Release the lock on task #${shortIdRaw(task.id)}`));
53
58
  console.log(pc.dim(` • Set task status back to "planned"`));
54
59
  if (worktree) {
55
60
  console.log(pc.dim(` • Optionally remove the worktree`));
@@ -66,7 +71,7 @@ export async function releaseCommand() {
66
71
  // Abandon the task in Damper
67
72
  try {
68
73
  await api.abandonTask(task.id, 'Released via CLI');
69
- console.log(pc.green(`\n✓ Task #${task.id} released to planned status`));
74
+ console.log(pc.green(`\n✓ Task #${shortIdRaw(task.id)} released to planned status`));
70
75
  }
71
76
  catch (err) {
72
77
  const error = err;
@@ -7,6 +7,7 @@ import { pickTask } from '../ui/task-picker.js';
7
7
  import { launchClaude, 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
+ import { shortIdRaw } from '../ui/format.js';
10
11
  export async function startCommand(options) {
11
12
  // Check Claude CLI first
12
13
  const claudeInstalled = await isClaudeInstalled();
@@ -56,6 +57,7 @@ export async function startCommand(options) {
56
57
  let taskId;
57
58
  let taskTitle;
58
59
  let isResume = false;
60
+ let isNewTask = false;
59
61
  let worktreePath;
60
62
  let forceTakeover = options.force || false;
61
63
  if (options.taskId) {
@@ -68,10 +70,10 @@ export async function startCommand(options) {
68
70
  if (existingWorktree && fs.existsSync(existingWorktree.path)) {
69
71
  isResume = true;
70
72
  worktreePath = existingWorktree.path;
71
- console.log(pc.cyan(`\nResuming task #${taskId}: ${taskTitle}`));
73
+ console.log(pc.cyan(`\nResuming task #${shortIdRaw(taskId)}: ${taskTitle}`));
72
74
  }
73
75
  else {
74
- console.log(pc.cyan(`\nStarting task #${taskId}: ${taskTitle}`));
76
+ console.log(pc.cyan(`\nStarting task #${shortIdRaw(taskId)}: ${taskTitle}`));
75
77
  }
76
78
  }
77
79
  else {
@@ -88,6 +90,7 @@ export async function startCommand(options) {
88
90
  taskId = result.task.id;
89
91
  taskTitle = result.task.title;
90
92
  isResume = result.isResume;
93
+ isNewTask = result.isNewTask || false;
91
94
  forceTakeover = result.forceTakeover || options.force || false;
92
95
  if (result.worktree) {
93
96
  worktreePath = result.worktree.path;
@@ -95,7 +98,7 @@ export async function startCommand(options) {
95
98
  }
96
99
  if (isResume && worktreePath) {
97
100
  // Resume existing worktree
98
- console.log(pc.green(`\n✓ Resuming: #${taskId} ${taskTitle}`));
101
+ console.log(pc.green(`\n✓ Resuming: #${shortIdRaw(taskId)} ${taskTitle}`));
99
102
  console.log(pc.dim(` Worktree: ${worktreePath}`));
100
103
  // Refresh context with latest from Damper
101
104
  console.log(pc.dim('\nRefreshing context from Damper...'));
@@ -166,5 +169,6 @@ export async function startCommand(options) {
166
169
  taskId: result.taskId,
167
170
  apiKey: result.apiKey,
168
171
  projectRoot,
172
+ isNewTask,
169
173
  });
170
174
  }
@@ -3,6 +3,7 @@ import pc from 'picocolors';
3
3
  import { getWorktrees, cleanupStaleWorktrees } from '../services/state.js';
4
4
  import { createDamperApi } from '../services/damper-api.js';
5
5
  import { getGitRoot } from '../services/worktree.js';
6
+ import { shortId, getTypeIcon, getPriorityIcon, formatEffort, formatSubtaskProgress, relativeTime, statusColor as getStatusColor, } from '../ui/format.js';
6
7
  export async function statusCommand() {
7
8
  // Clean up stale entries first
8
9
  const stale = cleanupStaleWorktrees();
@@ -49,25 +50,43 @@ export async function statusCommand() {
49
50
  console.log();
50
51
  for (const wt of projectWorktrees) {
51
52
  const exists = fs.existsSync(wt.path);
52
- const shortTaskId = wt.taskId.slice(0, 8);
53
- // Try to get task status and title
54
- let taskStatus = '';
55
- let taskTitle = '';
53
+ const statusIcon = exists ? pc.green('●') : pc.red('○');
54
+ // Try to get task details from Damper
56
55
  if (api) {
57
56
  try {
58
57
  const task = await api.getTask(wt.taskId);
59
- const statusColor = task.status === 'done' ? pc.green : task.status === 'in_progress' ? pc.yellow : pc.dim;
60
- taskStatus = statusColor(`[${task.status}]`);
61
- taskTitle = task.title;
58
+ const priorityIcon = getPriorityIcon(task.priority);
59
+ const typeIcon = getTypeIcon(task.type);
60
+ const colorFn = getStatusColor(task.status);
61
+ const statusBadge = colorFn(`[${task.status}]`);
62
+ const effort = formatEffort(task.effort);
63
+ const progress = formatSubtaskProgress(task.subtaskProgress);
64
+ const time = relativeTime(wt.createdAt);
65
+ console.log(` ${statusIcon} ${priorityIcon}${typeIcon} ${shortId(task.id)} ${task.title} ${statusBadge}${effort ? ` ${effort}` : ''}`);
66
+ const metaParts = [wt.branch];
67
+ if (time)
68
+ metaParts.push(time);
69
+ if (progress)
70
+ metaParts.push(progress);
71
+ console.log(` ${metaParts.join(` ${pc.dim('\u00B7')} `)}`);
62
72
  }
63
73
  catch {
64
- taskStatus = pc.dim('[unknown]');
74
+ console.log(` ${statusIcon} ${shortId(wt.taskId)} ${pc.dim('[unknown]')}`);
75
+ const time = relativeTime(wt.createdAt);
76
+ const metaParts = [wt.branch];
77
+ if (time)
78
+ metaParts.push(time);
79
+ console.log(` ${metaParts.join(` ${pc.dim('\u00B7')} `)}`);
65
80
  }
66
81
  }
67
- const statusIcon = exists ? pc.green('●') : pc.red('○');
68
- const titlePart = taskTitle ? ` ${taskTitle}` : '';
69
- console.log(` ${statusIcon} ${pc.cyan(`#${shortTaskId}`)}${titlePart} ${taskStatus}`);
70
- console.log(` ${wt.branch} ${pc.dim('·')} ${pc.dim(`created ${new Date(wt.createdAt).toLocaleDateString()}`)}`);
82
+ else {
83
+ console.log(` ${statusIcon} ${shortId(wt.taskId)}`);
84
+ const time = relativeTime(wt.createdAt);
85
+ const metaParts = [wt.branch];
86
+ if (time)
87
+ metaParts.push(time);
88
+ console.log(` ${metaParts.join(` ${pc.dim('\u00B7')} `)}`);
89
+ }
71
90
  console.log();
72
91
  }
73
92
  }
@@ -44,6 +44,7 @@ export declare function postTaskFlow(options: {
44
44
  taskId: string;
45
45
  apiKey: string;
46
46
  projectRoot: string;
47
+ isNewTask?: boolean;
47
48
  }): Promise<void>;
48
49
  /**
49
50
  * Check if Claude Code CLI is installed
@@ -4,6 +4,7 @@ import * as os from 'node:os';
4
4
  import { spawn } from 'node:child_process';
5
5
  import { execa } from 'execa';
6
6
  import pc from 'picocolors';
7
+ import { shortIdRaw } from '../ui/format.js';
7
8
  const CLAUDE_SETTINGS_DIR = path.join(os.homedir(), '.claude');
8
9
  const CLAUDE_SETTINGS_FILE = path.join(CLAUDE_SETTINGS_DIR, 'settings.json');
9
10
  /**
@@ -85,7 +86,7 @@ export function configureDamperMcp() {
85
86
  */
86
87
  export async function launchClaude(options) {
87
88
  const { cwd, taskId, taskTitle, apiKey, yolo } = options;
88
- console.log(pc.green(`\nStarting Claude Code for task #${taskId}: ${taskTitle}`));
89
+ console.log(pc.green(`\nStarting Claude Code for task #${shortIdRaw(taskId)}: ${taskTitle}`));
89
90
  console.log(pc.dim(`Directory: ${cwd}`));
90
91
  console.log();
91
92
  // Show workflow explanation
@@ -138,16 +139,14 @@ export async function launchClaude(options) {
138
139
  });
139
140
  child.on('close', () => resolve());
140
141
  });
141
- // Post-task flow
142
- console.log(pc.dim('\n─────────────────────────────────────────'));
143
- console.log(pc.bold('\nClaude session ended.\n'));
142
+ console.log(pc.dim('\n─────────────────────────────────────────\n'));
144
143
  return { cwd, taskId, apiKey };
145
144
  }
146
145
  /**
147
146
  * Post-task actions after Claude exits
148
147
  */
149
148
  export async function postTaskFlow(options) {
150
- const { cwd, taskId, apiKey, projectRoot } = options;
149
+ const { cwd, taskId, apiKey, projectRoot, isNewTask } = options;
151
150
  const { confirm, select } = await import('@inquirer/prompts');
152
151
  const { createDamperApi } = await import('./damper-api.js');
153
152
  const { removeWorktreeDir } = await import('./worktree.js');
@@ -155,15 +154,34 @@ export async function postTaskFlow(options) {
155
154
  // Check task status from Damper
156
155
  let taskStatus;
157
156
  let taskTitle;
157
+ let hasCommits = false;
158
158
  try {
159
159
  const api = createDamperApi(apiKey);
160
160
  const task = await api.getTask(taskId);
161
161
  taskStatus = task.status;
162
162
  taskTitle = task.title;
163
+ hasCommits = (task.commits?.length ?? 0) > 0;
163
164
  }
164
165
  catch {
165
166
  console.log(pc.yellow('Could not fetch task status from Damper.'));
166
167
  }
168
+ // Clean up CLI-generated modifications to tracked files before checking git status
169
+ // The CLI appends a "## Current Task:" section to CLAUDE.md which is never meant to be committed
170
+ try {
171
+ const claudeMdPath = path.join(cwd, 'CLAUDE.md');
172
+ if (fs.existsSync(claudeMdPath)) {
173
+ const content = fs.readFileSync(claudeMdPath, 'utf-8');
174
+ const marker = '## Current Task:';
175
+ const markerIndex = content.indexOf(marker);
176
+ if (markerIndex !== -1) {
177
+ const restored = content.slice(0, markerIndex).trimEnd() + '\n';
178
+ fs.writeFileSync(claudeMdPath, restored, 'utf-8');
179
+ }
180
+ }
181
+ }
182
+ catch {
183
+ // Ignore cleanup errors
184
+ }
167
185
  // Check git status
168
186
  let hasUnpushedCommits = false;
169
187
  let hasUncommittedChanges = false;
@@ -219,6 +237,7 @@ export async function postTaskFlow(options) {
219
237
  console.log();
220
238
  // Offer actions based on state
221
239
  const hasChanges = hasUnpushedCommits || hasUncommittedChanges;
240
+ let worktreeRemoved = false;
222
241
  if (hasChanges) {
223
242
  if (hasUncommittedChanges) {
224
243
  console.log(pc.yellow('⚠ There are uncommitted changes. You may want to commit them first.\n'));
@@ -285,6 +304,7 @@ export async function postTaskFlow(options) {
285
304
  if (shouldCleanup) {
286
305
  try {
287
306
  await removeWorktreeDir(cwd, projectRoot);
307
+ worktreeRemoved = true;
288
308
  console.log(pc.green('✓ Worktree and branch removed\n'));
289
309
  }
290
310
  catch (err) {
@@ -313,6 +333,7 @@ export async function postTaskFlow(options) {
313
333
  if (shouldRemove) {
314
334
  try {
315
335
  await removeWorktreeDir(cwd, projectRoot);
336
+ worktreeRemoved = true;
316
337
  console.log(pc.green('✓ Worktree and branch removed\n'));
317
338
  }
318
339
  catch (err) {
@@ -320,10 +341,44 @@ export async function postTaskFlow(options) {
320
341
  console.log(pc.red(`Failed to remove worktree: ${error.message}\n`));
321
342
  }
322
343
  }
323
- else {
324
- console.log(pc.dim('\nWorktree kept. Run `npx @damper/cli cleanup` to remove later.\n'));
344
+ }
345
+ // Offer to delete CLI-created tasks with no work done
346
+ let taskDeleted = false;
347
+ if (isNewTask && !hasCommits && taskStatus !== 'done') {
348
+ const shouldDelete = await confirm({
349
+ message: 'No work was committed. Remove task from backlog?',
350
+ default: true,
351
+ });
352
+ if (shouldDelete) {
353
+ try {
354
+ const api = createDamperApi(apiKey);
355
+ await api.deleteTask(taskId);
356
+ taskDeleted = true;
357
+ console.log(pc.green('✓ Task removed from backlog\n'));
358
+ }
359
+ catch (err) {
360
+ const error = err;
361
+ console.log(pc.yellow(`Could not delete task: ${error.message}\n`));
362
+ }
363
+ }
364
+ }
365
+ // Final summary
366
+ console.log(pc.dim('─────────────────────────────────────────'));
367
+ if (taskDeleted) {
368
+ console.log(pc.dim(`\n Task removed. No changes saved.\n`));
369
+ }
370
+ else {
371
+ const statusColor = taskStatus === 'done' ? pc.green : taskStatus === 'in_progress' ? pc.yellow : pc.dim;
372
+ const statusLabel = taskStatus || 'unknown';
373
+ console.log(pc.bold(`\n #${shortIdRaw(taskId)} ${taskTitle || 'Unknown task'}`) + ` ${statusColor(statusLabel)}`);
374
+ if (!worktreeRemoved) {
375
+ console.log(pc.dim(` ${cwd}`));
376
+ console.log(`\n To continue, run:`);
377
+ console.log(pc.cyan(` cd ${cwd} && claude -c`));
378
+ console.log(pc.dim(` or: npx @damper/cli --task ${taskId}`));
325
379
  }
326
380
  }
381
+ console.log();
327
382
  }
328
383
  /**
329
384
  * Check if Claude Code CLI is installed
@@ -170,7 +170,11 @@ export declare class DamperApi {
170
170
  }>;
171
171
  getModule(name: string): Promise<Module>;
172
172
  getAgentInstructions(format?: 'markdown' | 'section'): Promise<AgentInstructions>;
173
- createTask(title: string, type?: 'bug' | 'feature' | 'improvement' | 'task'): Promise<Task>;
173
+ createTask(title: string, type?: 'bug' | 'feature' | 'improvement' | 'task', description?: string): Promise<Task>;
174
+ deleteTask(taskId: string): Promise<{
175
+ id: string;
176
+ deleted: boolean;
177
+ }>;
174
178
  getLinkedFeedback(taskId: string): Promise<Array<{
175
179
  id: string;
176
180
  title: string;
@@ -152,8 +152,12 @@ This project uses Damper MCP for task tracking. **You MUST follow this workflow.
152
152
  };
153
153
  }
154
154
  // Create task
155
- async createTask(title, type = 'task') {
156
- return this.request('POST', '/api/agent/tasks', { title, type, status: 'planned' });
155
+ async createTask(title, type = 'task', description) {
156
+ return this.request('POST', '/api/agent/tasks', { title, type, status: 'planned', description });
157
+ }
158
+ // Delete task (only planned, no commits)
159
+ async deleteTask(taskId) {
160
+ return this.request('DELETE', `/api/agent/tasks/${taskId}`);
157
161
  }
158
162
  // Feedback
159
163
  async getLinkedFeedback(taskId) {
@@ -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. After planning, use \`update_task\` to set a concise title if the current one looks like instructions/description
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 done: 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();
@@ -0,0 +1,32 @@
1
+ import type { Task } from '../services/damper-api.js';
2
+ /** Colored short ID: dim `#` + cyan first 8 chars */
3
+ export declare function shortId(id: string): string;
4
+ /** Plain 8-char slice (for use inside other color wrappers) */
5
+ export declare function shortIdRaw(id: string): string;
6
+ /** Type emoji icon */
7
+ export declare function getTypeIcon(type: string): string;
8
+ /** Priority prefix: red/yellow circle + space, or empty */
9
+ export declare function getPriorityIcon(priority: string): string;
10
+ /** Dim effort badge like `▪ L`, or empty string */
11
+ export declare function formatEffort(effort: string | null | undefined): string;
12
+ /** Dim feedback count like `💬 3`, or empty string */
13
+ export declare function formatFeedback(count: number | null | undefined): string;
14
+ /** Dim labels joined with ` · `, or empty string */
15
+ export declare function formatLabels(labels: string[] | null | undefined): string;
16
+ /** 10-char progress bar: green filled, dim empty, with count */
17
+ export declare function formatSubtaskProgress(progress: {
18
+ done: number;
19
+ total: number;
20
+ } | null | undefined): string;
21
+ /** Relative time string from date, no dependencies */
22
+ export declare function relativeTime(date: string | Date | null | undefined): string;
23
+ /** Returns a picocolors function for the given status */
24
+ export declare function statusColor(status: string): (s: string) => string;
25
+ /** Terminal width (min 80) */
26
+ export declare function getTerminalWidth(): number;
27
+ /** Section header spanning terminal width: `── Label ───────────` */
28
+ export declare function sectionHeader(label: string, width?: number): string;
29
+ /** Compact one-liner: `🔴 ✨ #a1b2c3d4 Title ▪ L` */
30
+ export declare function formatTaskLine(task: Task): string;
31
+ /** Dim metadata second line with segments joined by ` · ` */
32
+ export declare function formatTaskMeta(task: Task): string;
@@ -0,0 +1,144 @@
1
+ import pc from 'picocolors';
2
+ /** Colored short ID: dim `#` + cyan first 8 chars */
3
+ export function shortId(id) {
4
+ return `${pc.dim('#')}${pc.cyan(id.slice(0, 8))}`;
5
+ }
6
+ /** Plain 8-char slice (for use inside other color wrappers) */
7
+ export function shortIdRaw(id) {
8
+ return id.slice(0, 8);
9
+ }
10
+ /** Type emoji icon */
11
+ export function getTypeIcon(type) {
12
+ switch (type) {
13
+ case 'bug': return '\u{1F41B}';
14
+ case 'feature': return '\u{2728}';
15
+ case 'improvement': return '\u{1F4A1}';
16
+ default: return '\u{1F4CC}';
17
+ }
18
+ }
19
+ /** Priority prefix: red/yellow circle + space, or empty */
20
+ export function getPriorityIcon(priority) {
21
+ switch (priority) {
22
+ case 'high': return '\u{1F534} ';
23
+ case 'medium': return '\u{1F7E1} ';
24
+ default: return '';
25
+ }
26
+ }
27
+ /** Dim effort badge like `▪ L`, or empty string */
28
+ export function formatEffort(effort) {
29
+ if (!effort)
30
+ return '';
31
+ return pc.dim(`\u25AA ${effort.toUpperCase()}`);
32
+ }
33
+ /** Dim feedback count like `💬 3`, or empty string */
34
+ export function formatFeedback(count) {
35
+ if (!count)
36
+ return '';
37
+ return pc.dim(`\u{1F4AC} ${count}`);
38
+ }
39
+ /** Dim labels joined with ` · `, or empty string */
40
+ export function formatLabels(labels) {
41
+ if (!labels || labels.length === 0)
42
+ return '';
43
+ return pc.dim(labels.join(' \u00B7 '));
44
+ }
45
+ /** 10-char progress bar: green filled, dim empty, with count */
46
+ export function formatSubtaskProgress(progress) {
47
+ if (!progress || progress.total === 0)
48
+ return '';
49
+ const { done, total } = progress;
50
+ const width = 10;
51
+ const filled = Math.round((done / total) * width);
52
+ const empty = width - filled;
53
+ const bar = pc.green('\u2501'.repeat(filled)) + pc.dim('\u2591'.repeat(empty));
54
+ return `${bar} ${done}/${total}`;
55
+ }
56
+ /** Relative time string from date, no dependencies */
57
+ export function relativeTime(date) {
58
+ if (!date)
59
+ return '';
60
+ const now = Date.now();
61
+ const then = date instanceof Date ? date.getTime() : new Date(date).getTime();
62
+ if (isNaN(then))
63
+ return '';
64
+ const diffMs = now - then;
65
+ if (diffMs < 0)
66
+ return 'just now';
67
+ const seconds = Math.floor(diffMs / 1000);
68
+ if (seconds < 60)
69
+ return 'just now';
70
+ const minutes = Math.floor(seconds / 60);
71
+ if (minutes < 60)
72
+ return `${minutes}m ago`;
73
+ const hours = Math.floor(minutes / 60);
74
+ if (hours < 24)
75
+ return `${hours}h ago`;
76
+ const days = Math.floor(hours / 24);
77
+ if (days < 14)
78
+ return `${days}d ago`;
79
+ const weeks = Math.floor(days / 7);
80
+ if (weeks < 8)
81
+ return `${weeks}w ago`;
82
+ const months = Math.floor(days / 30);
83
+ if (months < 12)
84
+ return `${months}mo ago`;
85
+ const years = Math.floor(days / 365);
86
+ return `${years}y ago`;
87
+ }
88
+ /** Returns a picocolors function for the given status */
89
+ export function statusColor(status) {
90
+ switch (status) {
91
+ case 'done': return pc.green;
92
+ case 'in_progress': return pc.yellow;
93
+ default: return pc.dim;
94
+ }
95
+ }
96
+ /** Terminal width (min 80) */
97
+ export function getTerminalWidth() {
98
+ return Math.max(80, process.stdout.columns || 80);
99
+ }
100
+ /** Section header spanning terminal width: `── Label ───────────` */
101
+ export function sectionHeader(label, width) {
102
+ const w = width ?? getTerminalWidth();
103
+ const prefix = `── ${label} `;
104
+ const remaining = Math.max(0, w - prefix.length);
105
+ return pc.dim(prefix + '─'.repeat(remaining));
106
+ }
107
+ /** Compact one-liner: `🔴 ✨ #a1b2c3d4 Title ▪ L` */
108
+ export function formatTaskLine(task) {
109
+ const priority = getPriorityIcon(task.priority);
110
+ const icon = getTypeIcon(task.type);
111
+ const id = shortId(task.id);
112
+ const effort = formatEffort(task.effort);
113
+ const parts = [
114
+ `${priority}${icon} ${id} ${task.title}`,
115
+ ];
116
+ if (effort)
117
+ parts.push(effort);
118
+ return parts.join(' ');
119
+ }
120
+ /** Dim metadata second line with segments joined by ` · ` */
121
+ export function formatTaskMeta(task) {
122
+ const segments = [];
123
+ if (task.quarter) {
124
+ segments.push(pc.dim(task.quarter));
125
+ }
126
+ if (task.hasImplementationPlan) {
127
+ segments.push(pc.dim('has plan'));
128
+ }
129
+ const progress = formatSubtaskProgress(task.subtaskProgress);
130
+ if (progress) {
131
+ segments.push(progress);
132
+ }
133
+ const feedback = formatFeedback(task.feedbackCount);
134
+ if (feedback) {
135
+ segments.push(feedback);
136
+ }
137
+ const labels = formatLabels(task.labels);
138
+ if (labels) {
139
+ segments.push(labels);
140
+ }
141
+ if (segments.length === 0)
142
+ return '';
143
+ return segments.join(` ${pc.dim('\u00B7')} `);
144
+ }
@@ -11,6 +11,7 @@ interface TaskPickerResult {
11
11
  worktree?: WorktreeState;
12
12
  isResume: boolean;
13
13
  forceTakeover?: boolean;
14
+ isNewTask?: boolean;
14
15
  }
15
16
  export declare function pickTask(options: TaskPickerOptions): Promise<TaskPickerResult | null>;
16
17
  export {};
@@ -1,62 +1,81 @@
1
1
  import { select, confirm, input, Separator } from '@inquirer/prompts';
2
2
  import pc from 'picocolors';
3
- function shortId(id) {
4
- return id.slice(0, 8);
3
+ import { shortId, shortIdRaw, getTypeIcon, getPriorityIcon, formatEffort, formatSubtaskProgress, sectionHeader, relativeTime, getTerminalWidth, } from './format.js';
4
+ // Layout constants (terminal column widths)
5
+ const CURSOR_WIDTH = 2; // inquirer select prefix (❯ or )
6
+ const PRIORITY_WIDTH = 3; // emoji(2) + space(1), or 3 spaces
7
+ const TYPE_WIDTH = 2; // emoji(2)
8
+ const GAP1 = 1; // space after type
9
+ const ID_WIDTH = 9; // # + 8 chars
10
+ const GAP2 = 2; // spaces after ID
11
+ const GAP3 = 2; // spaces between title and meta
12
+ const META_RESERVE = 16; // max width for right-side meta
13
+ const MIN_TITLE = 20; // minimum title column width
14
+ const LEFT_FIXED = CURSOR_WIDTH + PRIORITY_WIDTH + TYPE_WIDTH + GAP1 + ID_WIDTH + GAP2; // 19
15
+ const INDENT_WIDTH = PRIORITY_WIDTH + TYPE_WIDTH + GAP1 + ID_WIDTH + GAP2; // 17 (without cursor)
16
+ function getTitleWidth() {
17
+ const termWidth = getTerminalWidth();
18
+ return Math.max(MIN_TITLE, termWidth - LEFT_FIXED - GAP3 - META_RESERVE);
5
19
  }
6
- function getTypeIcon(type) {
7
- switch (type) {
8
- case 'bug': return '🐛';
9
- case 'feature': return '';
10
- case 'improvement': return '💡';
11
- default: return '📌';
12
- }
13
- }
14
- function getPriorityIcon(priority) {
15
- switch (priority) {
16
- case 'high': return '🔴 ';
17
- case 'medium': return '🟡 ';
18
- default: return '';
19
- }
20
+ function formatTableRow(task, titleWidth) {
21
+ // Priority: always 3 terminal cols
22
+ const hasPriority = task.priority === 'high' || task.priority === 'medium';
23
+ const priorityStr = hasPriority ? getPriorityIcon(task.priority) : ' ';
24
+ // Type icon: 2 terminal cols
25
+ const typeStr = getTypeIcon(task.type);
26
+ // ID: 9 visible chars
27
+ const idStr = shortId(task.id);
28
+ // Title: padded or truncated to fixed width
29
+ const titleStr = task.title.length > titleWidth
30
+ ? task.title.slice(0, titleWidth - 1) + '\u2026'
31
+ : task.title + ' '.repeat(titleWidth - task.title.length);
32
+ // Right-side meta (compact)
33
+ const metaParts = [];
34
+ if (task.effort)
35
+ metaParts.push(formatEffort(task.effort));
36
+ if (task.feedbackCount)
37
+ metaParts.push(pc.dim(`\u{1F4AC}${task.feedbackCount}`));
38
+ if (task.subtaskProgress?.total)
39
+ metaParts.push(formatSubtaskProgress(task.subtaskProgress));
40
+ const meta = metaParts.join(' ');
41
+ return `${priorityStr}${typeStr} ${idStr} ${titleStr} ${meta}`;
20
42
  }
21
- function formatTaskChoice(choice) {
22
- const { task } = choice;
23
- const typeIcon = getTypeIcon(task.type);
24
- const priorityIcon = getPriorityIcon(task.priority);
25
- const id = `${pc.dim('#')}${pc.cyan(shortId(task.id))}`;
43
+ function formatTaskChoice(choice, titleWidth) {
44
+ const firstLine = formatTableRow(choice.task, titleWidth);
45
+ const indent = ' '.repeat(INDENT_WIDTH);
26
46
  if (choice.type === 'in_progress') {
27
47
  const worktreeName = choice.worktree.path.split('/').pop() || choice.worktree.path;
28
- let description = `${priorityIcon}${typeIcon} ${id} ${task.title}`;
29
- description += `\n ${pc.dim(worktreeName)}`;
48
+ const time = relativeTime(choice.worktree.createdAt);
49
+ const metaParts = [worktreeName];
50
+ if (time)
51
+ metaParts.push(time);
52
+ let description = `${firstLine}\n${indent}${pc.dim(metaParts.join(' \u00B7 '))}`;
30
53
  if (choice.lastNote) {
31
- const note = choice.lastNote.length > 60 ? choice.lastNote.slice(0, 60) + '...' : choice.lastNote;
32
- description += `\n ${pc.dim(`Last: "${note}"`)}`;
54
+ const maxNoteWidth = titleWidth;
55
+ const note = choice.lastNote.length > maxNoteWidth ? choice.lastNote.slice(0, maxNoteWidth - 3) + '...' : choice.lastNote;
56
+ description += `\n${indent}${pc.dim(`Last: "${note}"`)}`;
33
57
  }
34
58
  return description;
35
59
  }
36
60
  if (choice.type === 'locked') {
37
- let description = `${priorityIcon}${typeIcon} ${id} ${task.title}`;
38
- description += `\n ${pc.yellow(`locked by ${choice.lockedBy}`)}`;
39
- return description;
40
- }
41
- // Available task
42
- let description = `${priorityIcon}${typeIcon} ${id} ${task.title}`;
43
- const meta = [];
44
- if (task.quarter) {
45
- meta.push(task.quarter);
46
- }
47
- if (task.hasImplementationPlan) {
48
- meta.push('has plan');
49
- }
50
- if (task.subtaskProgress) {
51
- meta.push(`${task.subtaskProgress.done}/${task.subtaskProgress.total} subtasks`);
52
- }
53
- if (meta.length > 0) {
54
- description += `\n ${pc.dim(meta.join(' · '))}`;
55
- }
56
- return description;
61
+ return `${firstLine}\n${indent}${pc.yellow(`locked by ${choice.lockedBy}`)}`;
62
+ }
63
+ // Available task - add secondary meta line if there's useful info
64
+ const secondaryParts = [];
65
+ if (choice.task.quarter)
66
+ secondaryParts.push(choice.task.quarter);
67
+ if (choice.task.hasImplementationPlan)
68
+ secondaryParts.push('has plan');
69
+ if (choice.task.labels?.length)
70
+ secondaryParts.push(choice.task.labels.join(' \u00B7 '));
71
+ if (secondaryParts.length > 0) {
72
+ return `${firstLine}\n${indent}${pc.dim(secondaryParts.join(` \u00B7 `))}`;
73
+ }
74
+ return firstLine;
57
75
  }
58
76
  export async function pickTask(options) {
59
77
  const { api, worktrees, typeFilter, statusFilter } = options;
78
+ const titleWidth = getTitleWidth();
60
79
  // Fetch tasks from Damper
61
80
  const { tasks, project } = await api.listTasks({
62
81
  status: statusFilter || 'all',
@@ -74,8 +93,6 @@ export async function pickTask(options) {
74
93
  const task = tasks.find(t => t.id === worktree.taskId);
75
94
  if (task) {
76
95
  worktreeTaskIds.add(task.id);
77
- // Get last note for display (we'd need to fetch task detail for this)
78
- // For now, we'll leave it undefined - the API doesn't include notes in list
79
96
  inProgressChoices.push({
80
97
  type: 'in_progress',
81
98
  task,
@@ -91,7 +108,6 @@ export async function pickTask(options) {
91
108
  continue;
92
109
  const taskAny = task;
93
110
  if (task.status === 'in_progress' && taskAny.lockedBy) {
94
- // Task is locked by someone else
95
111
  lockedChoices.push({
96
112
  type: 'locked',
97
113
  task,
@@ -104,28 +120,28 @@ export async function pickTask(options) {
104
120
  }
105
121
  const choices = [];
106
122
  if (inProgressChoices.length > 0) {
107
- choices.push(new Separator(pc.bold(`\n--- In Progress (${inProgressChoices.length}) ---`)));
123
+ choices.push(new Separator(`\n${sectionHeader(`In Progress (${inProgressChoices.length})`)}`));
108
124
  for (const choice of inProgressChoices) {
109
125
  choices.push({
110
- name: formatTaskChoice(choice),
126
+ name: formatTaskChoice(choice, titleWidth),
111
127
  value: choice,
112
128
  });
113
129
  }
114
130
  }
115
131
  if (availableChoices.length > 0) {
116
- choices.push(new Separator(pc.bold(`\n--- Available (${availableChoices.length}) ---`)));
132
+ choices.push(new Separator(`\n${sectionHeader(`Available (${availableChoices.length})`)}`));
117
133
  for (const choice of availableChoices) {
118
134
  choices.push({
119
- name: formatTaskChoice(choice),
135
+ name: formatTaskChoice(choice, titleWidth),
120
136
  value: choice,
121
137
  });
122
138
  }
123
139
  }
124
140
  if (lockedChoices.length > 0) {
125
- choices.push(new Separator(pc.bold(`\n--- Locked (${lockedChoices.length}) ---`)));
141
+ choices.push(new Separator(`\n${sectionHeader(`Locked (${lockedChoices.length})`)}`));
126
142
  for (const choice of lockedChoices) {
127
143
  choices.push({
128
- name: formatTaskChoice(choice),
144
+ name: formatTaskChoice(choice, titleWidth),
129
145
  value: choice,
130
146
  });
131
147
  }
@@ -182,9 +198,9 @@ export async function pickTask(options) {
182
198
  };
183
199
  }
184
200
  async function handleCreateNewTask(api) {
185
- const title = await input({
186
- message: 'Task title:',
187
- validate: (value) => value.trim().length > 0 || 'Title is required',
201
+ const instructions = await input({
202
+ message: 'What needs to be done?',
203
+ validate: (value) => value.trim().length > 0 || 'Instructions are required',
188
204
  });
189
205
  const type = await select({
190
206
  message: 'Task type:',
@@ -195,11 +211,19 @@ async function handleCreateNewTask(api) {
195
211
  { name: 'Task', value: 'task' },
196
212
  ],
197
213
  });
214
+ // Use instructions as description; derive a placeholder title
215
+ const trimmed = instructions.trim();
216
+ const maxTitleLen = 60;
217
+ const placeholderTitle = trimmed.length <= maxTitleLen
218
+ ? trimmed
219
+ : trimmed.slice(0, trimmed.lastIndexOf(' ', maxTitleLen) || maxTitleLen) + '…';
198
220
  console.log(pc.dim('\nCreating task in Damper...'));
199
- const task = await api.createTask(title.trim(), type);
200
- console.log(pc.green(`✓ Created task #${shortId(task.id)}: ${task.title}`));
221
+ const task = await api.createTask(placeholderTitle, type, trimmed);
222
+ console.log(pc.green(`✓ Created task #${shortIdRaw(task.id)}: ${task.title}`));
223
+ console.log(pc.dim(' The agent will refine the title after planning.'));
201
224
  return {
202
225
  task,
203
226
  isResume: false,
227
+ isNewTask: true,
204
228
  };
205
229
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@damper/cli",
3
- "version": "0.6.11",
3
+ "version": "0.6.13",
4
4
  "description": "CLI tool for orchestrating Damper task workflows with Claude Code",
5
5
  "author": "Damper <hello@usedamper.com>",
6
6
  "repository": {