@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.
- package/dist/commands/cleanup.js +3 -5
- package/dist/commands/release.js +8 -3
- package/dist/commands/start.js +7 -3
- package/dist/commands/status.js +31 -12
- package/dist/services/claude.d.ts +1 -0
- package/dist/services/claude.js +62 -7
- package/dist/services/damper-api.d.ts +5 -1
- package/dist/services/damper-api.js +6 -2
- package/dist/templates/CLAUDE_APPEND.md.js +5 -4
- package/dist/ui/format.d.ts +32 -0
- package/dist/ui/format.js +144 -0
- package/dist/ui/task-picker.d.ts +1 -0
- package/dist/ui/task-picker.js +83 -59
- package/package.json +1 -1
package/dist/commands/cleanup.js
CHANGED
|
@@ -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 =
|
|
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;
|
package/dist/commands/release.js
CHANGED
|
@@ -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:
|
|
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;
|
package/dist/commands/start.js
CHANGED
|
@@ -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
|
}
|
package/dist/commands/status.js
CHANGED
|
@@ -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
|
|
53
|
-
// Try to get task
|
|
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
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
}
|
package/dist/services/claude.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
324
|
-
|
|
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.
|
|
28
|
-
2. Use \`
|
|
29
|
-
3.
|
|
30
|
-
4.
|
|
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
|
+
}
|
package/dist/ui/task-picker.d.ts
CHANGED
package/dist/ui/task-picker.js
CHANGED
|
@@ -1,62 +1,81 @@
|
|
|
1
1
|
import { select, confirm, input, Separator } from '@inquirer/prompts';
|
|
2
2
|
import pc from 'picocolors';
|
|
3
|
-
|
|
4
|
-
|
|
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
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
|
23
|
-
const
|
|
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
|
-
|
|
29
|
-
|
|
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
|
|
32
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
if (
|
|
48
|
-
|
|
49
|
-
}
|
|
50
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
186
|
-
message: '
|
|
187
|
-
validate: (value) => value.trim().length > 0 || '
|
|
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(
|
|
200
|
-
console.log(pc.green(`✓ Created task #${
|
|
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
|
}
|