@damper/cli 0.1.0
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.d.ts +1 -0
- package/dist/commands/cleanup.js +149 -0
- package/dist/commands/setup.d.ts +1 -0
- package/dist/commands/setup.js +67 -0
- package/dist/commands/start.d.ts +6 -0
- package/dist/commands/start.js +139 -0
- package/dist/commands/status.d.ts +1 -0
- package/dist/commands/status.js +74 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +125 -0
- package/dist/services/claude.d.ts +44 -0
- package/dist/services/claude.js +221 -0
- package/dist/services/context-bootstrap.d.ts +18 -0
- package/dist/services/context-bootstrap.js +94 -0
- package/dist/services/damper-api.d.ts +154 -0
- package/dist/services/damper-api.js +151 -0
- package/dist/services/state.d.ts +15 -0
- package/dist/services/state.js +77 -0
- package/dist/services/worktree.d.ts +34 -0
- package/dist/services/worktree.js +289 -0
- package/dist/templates/CLAUDE_APPEND.md.d.ts +6 -0
- package/dist/templates/CLAUDE_APPEND.md.js +17 -0
- package/dist/templates/TASK_CONTEXT.md.d.ts +15 -0
- package/dist/templates/TASK_CONTEXT.md.js +115 -0
- package/dist/ui/task-picker.d.ts +15 -0
- package/dist/ui/task-picker.js +121 -0
- package/dist/utils/config.d.ts +7 -0
- package/dist/utils/config.js +32 -0
- package/package.json +50 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import * as os from 'node:os';
|
|
4
|
+
const STATE_DIR = path.join(os.homedir(), '.damper');
|
|
5
|
+
const WORKTREES_FILE = path.join(STATE_DIR, 'worktrees.json');
|
|
6
|
+
function ensureStateDir() {
|
|
7
|
+
if (!fs.existsSync(STATE_DIR)) {
|
|
8
|
+
fs.mkdirSync(STATE_DIR, { recursive: true });
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
function readState() {
|
|
12
|
+
ensureStateDir();
|
|
13
|
+
if (!fs.existsSync(WORKTREES_FILE)) {
|
|
14
|
+
return { worktrees: [] };
|
|
15
|
+
}
|
|
16
|
+
try {
|
|
17
|
+
const content = fs.readFileSync(WORKTREES_FILE, 'utf-8');
|
|
18
|
+
return JSON.parse(content);
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return { worktrees: [] };
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function writeState(state) {
|
|
25
|
+
ensureStateDir();
|
|
26
|
+
fs.writeFileSync(WORKTREES_FILE, JSON.stringify(state, null, 2));
|
|
27
|
+
}
|
|
28
|
+
export function getWorktrees() {
|
|
29
|
+
return readState().worktrees;
|
|
30
|
+
}
|
|
31
|
+
export function getWorktreeByTaskId(taskId) {
|
|
32
|
+
return readState().worktrees.find(w => w.taskId === taskId);
|
|
33
|
+
}
|
|
34
|
+
export function getWorktreeByPath(worktreePath) {
|
|
35
|
+
const normalized = path.resolve(worktreePath);
|
|
36
|
+
return readState().worktrees.find(w => path.resolve(w.path) === normalized);
|
|
37
|
+
}
|
|
38
|
+
export function addWorktree(worktree) {
|
|
39
|
+
const state = readState();
|
|
40
|
+
// Remove any existing entry for this task or path
|
|
41
|
+
state.worktrees = state.worktrees.filter(w => w.taskId !== worktree.taskId && path.resolve(w.path) !== path.resolve(worktree.path));
|
|
42
|
+
state.worktrees.push(worktree);
|
|
43
|
+
writeState(state);
|
|
44
|
+
}
|
|
45
|
+
export function removeWorktree(taskId) {
|
|
46
|
+
const state = readState();
|
|
47
|
+
state.worktrees = state.worktrees.filter(w => w.taskId !== taskId);
|
|
48
|
+
writeState(state);
|
|
49
|
+
}
|
|
50
|
+
export function removeWorktreeByPath(worktreePath) {
|
|
51
|
+
const normalized = path.resolve(worktreePath);
|
|
52
|
+
const state = readState();
|
|
53
|
+
state.worktrees = state.worktrees.filter(w => path.resolve(w.path) !== normalized);
|
|
54
|
+
writeState(state);
|
|
55
|
+
}
|
|
56
|
+
export function cleanupStaleWorktrees() {
|
|
57
|
+
const state = readState();
|
|
58
|
+
const validWorktrees = [];
|
|
59
|
+
const staleWorktrees = [];
|
|
60
|
+
for (const w of state.worktrees) {
|
|
61
|
+
if (fs.existsSync(w.path)) {
|
|
62
|
+
validWorktrees.push(w);
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
staleWorktrees.push(w);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (staleWorktrees.length > 0) {
|
|
69
|
+
state.worktrees = validWorktrees;
|
|
70
|
+
writeState(state);
|
|
71
|
+
}
|
|
72
|
+
return staleWorktrees;
|
|
73
|
+
}
|
|
74
|
+
export function getWorktreesForProject(projectRoot) {
|
|
75
|
+
const normalized = path.resolve(projectRoot);
|
|
76
|
+
return readState().worktrees.filter(w => path.resolve(w.projectRoot) === normalized);
|
|
77
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export interface WorktreeOptions {
|
|
2
|
+
taskId: string;
|
|
3
|
+
taskTitle: string;
|
|
4
|
+
projectRoot: string;
|
|
5
|
+
}
|
|
6
|
+
export interface WorktreeResult {
|
|
7
|
+
path: string;
|
|
8
|
+
branch: string;
|
|
9
|
+
isNew: boolean;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Create a new git worktree for a task
|
|
13
|
+
*/
|
|
14
|
+
export declare function createWorktree(options: WorktreeOptions): Promise<WorktreeResult>;
|
|
15
|
+
/**
|
|
16
|
+
* Remove a git worktree
|
|
17
|
+
*/
|
|
18
|
+
export declare function removeWorktreeDir(worktreePath: string, projectRoot: string): Promise<void>;
|
|
19
|
+
/**
|
|
20
|
+
* List all git worktrees for a project
|
|
21
|
+
*/
|
|
22
|
+
export declare function listWorktrees(projectRoot: string): Promise<Array<{
|
|
23
|
+
path: string;
|
|
24
|
+
branch: string;
|
|
25
|
+
head: string;
|
|
26
|
+
}>>;
|
|
27
|
+
/**
|
|
28
|
+
* Check if a path is inside a git worktree
|
|
29
|
+
*/
|
|
30
|
+
export declare function isInWorktree(dir: string): Promise<boolean>;
|
|
31
|
+
/**
|
|
32
|
+
* Get the git root directory
|
|
33
|
+
*/
|
|
34
|
+
export declare function getGitRoot(dir: string): Promise<string>;
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { execa } from 'execa';
|
|
4
|
+
import pc from 'picocolors';
|
|
5
|
+
import { addWorktree, removeWorktree, getWorktreeByTaskId } from './state.js';
|
|
6
|
+
/**
|
|
7
|
+
* Convert a task title to a safe slug for branch/directory names
|
|
8
|
+
*/
|
|
9
|
+
function slugify(text) {
|
|
10
|
+
return text
|
|
11
|
+
.toLowerCase()
|
|
12
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
13
|
+
.replace(/^-+|-+$/g, '')
|
|
14
|
+
.slice(0, 50);
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Get the project name from package.json or directory name
|
|
18
|
+
*/
|
|
19
|
+
function getProjectName(projectRoot) {
|
|
20
|
+
const packageJsonPath = path.join(projectRoot, 'package.json');
|
|
21
|
+
if (fs.existsSync(packageJsonPath)) {
|
|
22
|
+
try {
|
|
23
|
+
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
|
|
24
|
+
if (pkg.name) {
|
|
25
|
+
// Strip scope if present
|
|
26
|
+
return pkg.name.replace(/^@[^/]+\//, '');
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
// Fall through to directory name
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return path.basename(projectRoot);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Detect monorepo structure by looking for workspaces in package.json
|
|
37
|
+
*/
|
|
38
|
+
function detectMonorepoPackages(projectRoot) {
|
|
39
|
+
const packageJsonPath = path.join(projectRoot, 'package.json');
|
|
40
|
+
if (!fs.existsSync(packageJsonPath)) {
|
|
41
|
+
return [];
|
|
42
|
+
}
|
|
43
|
+
try {
|
|
44
|
+
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
|
|
45
|
+
const workspaces = pkg.workspaces || [];
|
|
46
|
+
const packages = [];
|
|
47
|
+
for (const pattern of workspaces) {
|
|
48
|
+
// Simple glob expansion for common patterns
|
|
49
|
+
if (pattern.includes('*')) {
|
|
50
|
+
const base = pattern.replace(/\/\*$/, '').replace(/\*$/, '');
|
|
51
|
+
const basePath = path.join(projectRoot, base);
|
|
52
|
+
if (fs.existsSync(basePath) && fs.statSync(basePath).isDirectory()) {
|
|
53
|
+
const dirs = fs.readdirSync(basePath).filter(d => {
|
|
54
|
+
const fullPath = path.join(basePath, d);
|
|
55
|
+
return fs.statSync(fullPath).isDirectory() &&
|
|
56
|
+
fs.existsSync(path.join(fullPath, 'package.json'));
|
|
57
|
+
});
|
|
58
|
+
packages.push(...dirs.map(d => path.join(base, d)));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
// Direct path
|
|
63
|
+
const pkgPath = path.join(projectRoot, pattern);
|
|
64
|
+
if (fs.existsSync(path.join(pkgPath, 'package.json'))) {
|
|
65
|
+
packages.push(pattern);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return packages;
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return [];
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Find all node_modules directories to symlink
|
|
77
|
+
*/
|
|
78
|
+
function findNodeModulesDirs(projectRoot) {
|
|
79
|
+
const dirs = [];
|
|
80
|
+
// Root node_modules
|
|
81
|
+
const rootModules = path.join(projectRoot, 'node_modules');
|
|
82
|
+
if (fs.existsSync(rootModules)) {
|
|
83
|
+
dirs.push('node_modules');
|
|
84
|
+
}
|
|
85
|
+
// Monorepo package node_modules
|
|
86
|
+
const packages = detectMonorepoPackages(projectRoot);
|
|
87
|
+
for (const pkg of packages) {
|
|
88
|
+
const pkgModules = path.join(projectRoot, pkg, 'node_modules');
|
|
89
|
+
if (fs.existsSync(pkgModules)) {
|
|
90
|
+
dirs.push(path.join(pkg, 'node_modules'));
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return dirs;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Find all .env files to copy
|
|
97
|
+
*/
|
|
98
|
+
function findEnvFiles(projectRoot) {
|
|
99
|
+
const envFiles = [];
|
|
100
|
+
// Root .env files
|
|
101
|
+
const rootEnvFiles = ['.env', '.env.local', '.env.development'];
|
|
102
|
+
for (const envFile of rootEnvFiles) {
|
|
103
|
+
if (fs.existsSync(path.join(projectRoot, envFile))) {
|
|
104
|
+
envFiles.push(envFile);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
// Monorepo package .env files
|
|
108
|
+
const packages = detectMonorepoPackages(projectRoot);
|
|
109
|
+
for (const pkg of packages) {
|
|
110
|
+
for (const envFile of rootEnvFiles) {
|
|
111
|
+
const envPath = path.join(pkg, envFile);
|
|
112
|
+
if (fs.existsSync(path.join(projectRoot, envPath))) {
|
|
113
|
+
envFiles.push(envPath);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
// Common server locations
|
|
118
|
+
const serverDirs = ['server', 'api', 'backend', 'packages/server'];
|
|
119
|
+
for (const serverDir of serverDirs) {
|
|
120
|
+
for (const envFile of rootEnvFiles) {
|
|
121
|
+
const envPath = path.join(serverDir, envFile);
|
|
122
|
+
if (fs.existsSync(path.join(projectRoot, envPath)) && !envFiles.includes(envPath)) {
|
|
123
|
+
envFiles.push(envPath);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return envFiles;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Create a new git worktree for a task
|
|
131
|
+
*/
|
|
132
|
+
export async function createWorktree(options) {
|
|
133
|
+
const { taskId, taskTitle, projectRoot } = options;
|
|
134
|
+
// Check if worktree already exists for this task
|
|
135
|
+
const existing = getWorktreeByTaskId(taskId);
|
|
136
|
+
if (existing && fs.existsSync(existing.path)) {
|
|
137
|
+
return {
|
|
138
|
+
path: existing.path,
|
|
139
|
+
branch: existing.branch,
|
|
140
|
+
isNew: false,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
const projectName = getProjectName(projectRoot);
|
|
144
|
+
const slug = slugify(taskTitle);
|
|
145
|
+
const worktreePath = path.resolve(projectRoot, '..', `${projectName}-${slug}`);
|
|
146
|
+
const branchName = `feature/${slug}`;
|
|
147
|
+
// Create worktree
|
|
148
|
+
console.log(pc.dim(`Creating worktree at ${worktreePath}...`));
|
|
149
|
+
await execa('git', ['worktree', 'add', worktreePath, '-b', branchName], {
|
|
150
|
+
cwd: projectRoot,
|
|
151
|
+
stdio: 'pipe',
|
|
152
|
+
});
|
|
153
|
+
// Symlink node_modules
|
|
154
|
+
const nodeModulesDirs = findNodeModulesDirs(projectRoot);
|
|
155
|
+
for (const dir of nodeModulesDirs) {
|
|
156
|
+
const source = path.join(projectRoot, dir);
|
|
157
|
+
const target = path.join(worktreePath, dir);
|
|
158
|
+
// Ensure parent directory exists
|
|
159
|
+
const targetDir = path.dirname(target);
|
|
160
|
+
if (!fs.existsSync(targetDir)) {
|
|
161
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
162
|
+
}
|
|
163
|
+
console.log(pc.dim(`Linking ${dir}...`));
|
|
164
|
+
await fs.promises.symlink(source, target, 'junction');
|
|
165
|
+
}
|
|
166
|
+
// Copy .env files
|
|
167
|
+
const envFiles = findEnvFiles(projectRoot);
|
|
168
|
+
for (const envFile of envFiles) {
|
|
169
|
+
const source = path.join(projectRoot, envFile);
|
|
170
|
+
const target = path.join(worktreePath, envFile);
|
|
171
|
+
// Ensure parent directory exists
|
|
172
|
+
const targetDir = path.dirname(target);
|
|
173
|
+
if (!fs.existsSync(targetDir)) {
|
|
174
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
175
|
+
}
|
|
176
|
+
console.log(pc.dim(`Copying ${envFile}...`));
|
|
177
|
+
await fs.promises.copyFile(source, target);
|
|
178
|
+
}
|
|
179
|
+
// Save to state
|
|
180
|
+
const worktreeState = {
|
|
181
|
+
taskId,
|
|
182
|
+
path: worktreePath,
|
|
183
|
+
branch: branchName,
|
|
184
|
+
projectRoot,
|
|
185
|
+
createdAt: new Date().toISOString(),
|
|
186
|
+
};
|
|
187
|
+
addWorktree(worktreeState);
|
|
188
|
+
return {
|
|
189
|
+
path: worktreePath,
|
|
190
|
+
branch: branchName,
|
|
191
|
+
isNew: true,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Remove a git worktree
|
|
196
|
+
*/
|
|
197
|
+
export async function removeWorktreeDir(worktreePath, projectRoot) {
|
|
198
|
+
console.log(pc.dim(`Removing worktree at ${worktreePath}...`));
|
|
199
|
+
// Remove symlinks first to avoid issues
|
|
200
|
+
const nodeModulesDirs = findNodeModulesDirs(projectRoot);
|
|
201
|
+
for (const dir of nodeModulesDirs) {
|
|
202
|
+
const target = path.join(worktreePath, dir);
|
|
203
|
+
if (fs.existsSync(target)) {
|
|
204
|
+
try {
|
|
205
|
+
await fs.promises.unlink(target);
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
// Ignore errors
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
// Remove worktree
|
|
213
|
+
await execa('git', ['worktree', 'remove', worktreePath, '--force'], {
|
|
214
|
+
cwd: projectRoot,
|
|
215
|
+
stdio: 'pipe',
|
|
216
|
+
});
|
|
217
|
+
// Get the worktree state to find the task ID
|
|
218
|
+
const state = await import('./state.js');
|
|
219
|
+
const worktree = state.getWorktreeByPath(worktreePath);
|
|
220
|
+
if (worktree) {
|
|
221
|
+
removeWorktree(worktree.taskId);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* List all git worktrees for a project
|
|
226
|
+
*/
|
|
227
|
+
export async function listWorktrees(projectRoot) {
|
|
228
|
+
const { stdout } = await execa('git', ['worktree', 'list', '--porcelain'], {
|
|
229
|
+
cwd: projectRoot,
|
|
230
|
+
stdio: 'pipe',
|
|
231
|
+
});
|
|
232
|
+
const worktrees = [];
|
|
233
|
+
let current = {};
|
|
234
|
+
for (const line of stdout.split('\n')) {
|
|
235
|
+
if (line.startsWith('worktree ')) {
|
|
236
|
+
current.path = line.slice(9);
|
|
237
|
+
}
|
|
238
|
+
else if (line.startsWith('HEAD ')) {
|
|
239
|
+
current.head = line.slice(5);
|
|
240
|
+
}
|
|
241
|
+
else if (line.startsWith('branch ')) {
|
|
242
|
+
current.branch = line.slice(7);
|
|
243
|
+
}
|
|
244
|
+
else if (line === '' && current.path) {
|
|
245
|
+
if (current.path && current.branch && current.head) {
|
|
246
|
+
worktrees.push({
|
|
247
|
+
path: current.path,
|
|
248
|
+
branch: current.branch,
|
|
249
|
+
head: current.head,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
current = {};
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
// Handle last entry
|
|
256
|
+
if (current.path && current.branch && current.head) {
|
|
257
|
+
worktrees.push({
|
|
258
|
+
path: current.path,
|
|
259
|
+
branch: current.branch,
|
|
260
|
+
head: current.head,
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
return worktrees;
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Check if a path is inside a git worktree
|
|
267
|
+
*/
|
|
268
|
+
export async function isInWorktree(dir) {
|
|
269
|
+
try {
|
|
270
|
+
const { stdout } = await execa('git', ['rev-parse', '--is-inside-work-tree'], {
|
|
271
|
+
cwd: dir,
|
|
272
|
+
stdio: 'pipe',
|
|
273
|
+
});
|
|
274
|
+
return stdout.trim() === 'true';
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
return false;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Get the git root directory
|
|
282
|
+
*/
|
|
283
|
+
export async function getGitRoot(dir) {
|
|
284
|
+
const { stdout } = await execa('git', ['rev-parse', '--show-toplevel'], {
|
|
285
|
+
cwd: dir,
|
|
286
|
+
stdio: 'pipe',
|
|
287
|
+
});
|
|
288
|
+
return stdout.trim();
|
|
289
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export function generateClaudeAppend(options) {
|
|
2
|
+
const { taskId, taskTitle } = options;
|
|
3
|
+
return `
|
|
4
|
+
## Current Task: #${taskId} ${taskTitle}
|
|
5
|
+
|
|
6
|
+
**IMPORTANT**: Read TASK_CONTEXT.md for full task details and architecture context.
|
|
7
|
+
If you feel you've lost context, re-read that file.
|
|
8
|
+
|
|
9
|
+
**Your responsibilities (via Damper MCP):**
|
|
10
|
+
1. Use \`add_commit\` after each git commit
|
|
11
|
+
2. Use \`add_note\` for important decisions
|
|
12
|
+
3. When done: call \`complete_task\` with summary
|
|
13
|
+
4. If stopping early: call \`abandon_task\` with handoff notes
|
|
14
|
+
|
|
15
|
+
The CLI just bootstrapped this environment - YOU handle the task lifecycle.
|
|
16
|
+
`.trim();
|
|
17
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { TaskDetail, ContextSection, Module } from '../services/damper-api.js';
|
|
2
|
+
interface TaskContextOptions {
|
|
3
|
+
task: TaskDetail;
|
|
4
|
+
criticalRules: string[];
|
|
5
|
+
sections: ContextSection[];
|
|
6
|
+
templates: Array<{
|
|
7
|
+
name: string;
|
|
8
|
+
description?: string | null;
|
|
9
|
+
filePattern?: string | null;
|
|
10
|
+
}>;
|
|
11
|
+
modules: Module[];
|
|
12
|
+
damperInstructions: string;
|
|
13
|
+
}
|
|
14
|
+
export declare function generateTaskContext(options: TaskContextOptions): string;
|
|
15
|
+
export {};
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
export function generateTaskContext(options) {
|
|
2
|
+
const { task, criticalRules, sections, templates, modules, damperInstructions } = options;
|
|
3
|
+
const typeIcon = task.type === 'bug' ? 'Bug' : task.type === 'feature' ? 'Feature' : task.type === 'improvement' ? 'Improvement' : 'Task';
|
|
4
|
+
const lines = [];
|
|
5
|
+
// Header
|
|
6
|
+
lines.push(`# Task: ${task.title} (#${task.id})`);
|
|
7
|
+
lines.push('');
|
|
8
|
+
lines.push(`**Type:** ${typeIcon} | **Status:** ${task.status} | **Priority:** ${task.priority || 'none'}`);
|
|
9
|
+
if (task.effort)
|
|
10
|
+
lines.push(`**Effort:** ${task.effort}`);
|
|
11
|
+
if (task.quarter)
|
|
12
|
+
lines.push(`**Quarter:** ${task.quarter}`);
|
|
13
|
+
lines.push('');
|
|
14
|
+
// Critical Rules
|
|
15
|
+
if (criticalRules.length > 0) {
|
|
16
|
+
lines.push('## Critical Rules (DO NOT SKIP)');
|
|
17
|
+
lines.push('');
|
|
18
|
+
for (const rule of criticalRules) {
|
|
19
|
+
lines.push(`- ${rule}`);
|
|
20
|
+
}
|
|
21
|
+
lines.push('');
|
|
22
|
+
}
|
|
23
|
+
// Damper Workflow
|
|
24
|
+
lines.push('## Damper Workflow');
|
|
25
|
+
lines.push('');
|
|
26
|
+
lines.push(damperInstructions);
|
|
27
|
+
lines.push('');
|
|
28
|
+
// Task Description
|
|
29
|
+
if (task.description) {
|
|
30
|
+
lines.push('## Task Description');
|
|
31
|
+
lines.push('');
|
|
32
|
+
lines.push(task.description);
|
|
33
|
+
lines.push('');
|
|
34
|
+
}
|
|
35
|
+
// Implementation Plan
|
|
36
|
+
if (task.implementationPlan) {
|
|
37
|
+
lines.push('## Implementation Plan');
|
|
38
|
+
lines.push('');
|
|
39
|
+
lines.push(task.implementationPlan);
|
|
40
|
+
lines.push('');
|
|
41
|
+
}
|
|
42
|
+
// Subtasks
|
|
43
|
+
if (task.subtasks && task.subtasks.length > 0) {
|
|
44
|
+
const done = task.subtasks.filter(s => s.done).length;
|
|
45
|
+
lines.push(`## Subtasks (${done}/${task.subtasks.length})`);
|
|
46
|
+
lines.push('');
|
|
47
|
+
for (const subtask of task.subtasks) {
|
|
48
|
+
lines.push(`- [${subtask.done ? 'x' : ' '}] ${subtask.title} (id: ${subtask.id})`);
|
|
49
|
+
}
|
|
50
|
+
lines.push('');
|
|
51
|
+
}
|
|
52
|
+
// Previous Session Notes
|
|
53
|
+
if (task.agentNotes) {
|
|
54
|
+
lines.push('## Previous Session Notes (IMPORTANT for continuity)');
|
|
55
|
+
lines.push('');
|
|
56
|
+
lines.push(task.agentNotes);
|
|
57
|
+
lines.push('');
|
|
58
|
+
}
|
|
59
|
+
// Previous Commits
|
|
60
|
+
if (task.commits && task.commits.length > 0) {
|
|
61
|
+
lines.push(`## Previous Commits (${task.commits.length})`);
|
|
62
|
+
lines.push('');
|
|
63
|
+
for (const commit of task.commits) {
|
|
64
|
+
lines.push(`- ${commit.hash.slice(0, 7)}: ${commit.message}`);
|
|
65
|
+
}
|
|
66
|
+
lines.push('');
|
|
67
|
+
}
|
|
68
|
+
// Project Context Sections
|
|
69
|
+
if (sections.length > 0) {
|
|
70
|
+
lines.push('## Relevant Architecture');
|
|
71
|
+
lines.push('');
|
|
72
|
+
for (const section of sections) {
|
|
73
|
+
lines.push(`### ${section.section}`);
|
|
74
|
+
lines.push('');
|
|
75
|
+
lines.push(section.content);
|
|
76
|
+
lines.push('');
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
// Templates
|
|
80
|
+
if (templates.length > 0) {
|
|
81
|
+
lines.push('## Templates Available');
|
|
82
|
+
lines.push('');
|
|
83
|
+
for (const template of templates) {
|
|
84
|
+
const pattern = template.filePattern ? ` (${template.filePattern})` : '';
|
|
85
|
+
const desc = template.description ? `: ${template.description}` : '';
|
|
86
|
+
lines.push(`- \`${template.name}\`${pattern}${desc}`);
|
|
87
|
+
}
|
|
88
|
+
lines.push('');
|
|
89
|
+
}
|
|
90
|
+
// Modules
|
|
91
|
+
if (modules.length > 0) {
|
|
92
|
+
lines.push('## Module Structure');
|
|
93
|
+
lines.push('');
|
|
94
|
+
for (const mod of modules) {
|
|
95
|
+
const port = mod.port ? ` (port ${mod.port})` : '';
|
|
96
|
+
const deps = mod.dependsOn && mod.dependsOn.length > 0 ? ` → ${mod.dependsOn.join(', ')}` : '';
|
|
97
|
+
lines.push(`- **${mod.name}**: ${mod.path}${port}${deps}`);
|
|
98
|
+
}
|
|
99
|
+
lines.push('');
|
|
100
|
+
}
|
|
101
|
+
// Linked Feedback
|
|
102
|
+
if (task.feedback && task.feedback.length > 0) {
|
|
103
|
+
lines.push('## Linked Feedback (customer requests driving this task)');
|
|
104
|
+
lines.push('');
|
|
105
|
+
for (const fb of task.feedback) {
|
|
106
|
+
lines.push(`- "${fb.title}" (${fb.voterCount} votes)`);
|
|
107
|
+
}
|
|
108
|
+
lines.push('');
|
|
109
|
+
}
|
|
110
|
+
// Footer
|
|
111
|
+
lines.push('---');
|
|
112
|
+
lines.push(`Generated by @damper/cli at ${new Date().toISOString()}.`);
|
|
113
|
+
lines.push('Re-read this file if conversation context is lost.');
|
|
114
|
+
return lines.join('\n');
|
|
115
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Task, DamperApi } from '../services/damper-api.js';
|
|
2
|
+
import type { WorktreeState } from '../services/state.js';
|
|
3
|
+
interface TaskPickerOptions {
|
|
4
|
+
api: DamperApi;
|
|
5
|
+
worktrees: WorktreeState[];
|
|
6
|
+
typeFilter?: 'bug' | 'feature' | 'improvement' | 'task';
|
|
7
|
+
statusFilter?: 'planned' | 'in_progress' | 'done' | 'all';
|
|
8
|
+
}
|
|
9
|
+
interface TaskPickerResult {
|
|
10
|
+
task: Task;
|
|
11
|
+
worktree?: WorktreeState;
|
|
12
|
+
isResume: boolean;
|
|
13
|
+
}
|
|
14
|
+
export declare function pickTask(options: TaskPickerOptions): Promise<TaskPickerResult | null>;
|
|
15
|
+
export {};
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { select, Separator } from '@inquirer/prompts';
|
|
2
|
+
import pc from 'picocolors';
|
|
3
|
+
function getTypeIcon(type) {
|
|
4
|
+
switch (type) {
|
|
5
|
+
case 'bug': return pc.red('bug');
|
|
6
|
+
case 'feature': return pc.green('feature');
|
|
7
|
+
case 'improvement': return pc.blue('improvement');
|
|
8
|
+
default: return pc.gray('task');
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
function getPriorityIcon(priority) {
|
|
12
|
+
switch (priority) {
|
|
13
|
+
case 'high': return pc.red('!');
|
|
14
|
+
case 'medium': return pc.yellow('~');
|
|
15
|
+
default: return pc.dim('-');
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function formatTaskChoice(choice) {
|
|
19
|
+
const { task } = choice;
|
|
20
|
+
const typeIcon = getTypeIcon(task.type);
|
|
21
|
+
const priorityIcon = getPriorityIcon(task.priority);
|
|
22
|
+
if (choice.type === 'in_progress') {
|
|
23
|
+
const worktreeName = choice.worktree.path.split('/').pop() || choice.worktree.path;
|
|
24
|
+
let description = `${pc.dim('#')}${pc.cyan(task.id)} ${task.title} ${pc.dim(`[${typeIcon}]`)} ${priorityIcon}`;
|
|
25
|
+
description += `\n ${pc.dim(`Worktree: ${worktreeName}`)}`;
|
|
26
|
+
if (choice.lastNote) {
|
|
27
|
+
// Truncate long notes
|
|
28
|
+
const note = choice.lastNote.length > 60 ? choice.lastNote.slice(0, 60) + '...' : choice.lastNote;
|
|
29
|
+
description += `\n ${pc.dim(`Last: "${note}"`)}`;
|
|
30
|
+
}
|
|
31
|
+
return description;
|
|
32
|
+
}
|
|
33
|
+
// Available task
|
|
34
|
+
let description = `${pc.dim('#')}${pc.cyan(task.id)} ${task.title} ${pc.dim(`[${typeIcon}]`)} ${priorityIcon}`;
|
|
35
|
+
if (task.quarter) {
|
|
36
|
+
description += ` ${pc.dim(task.quarter)}`;
|
|
37
|
+
}
|
|
38
|
+
if (task.hasImplementationPlan) {
|
|
39
|
+
description += pc.dim(' [plan]');
|
|
40
|
+
}
|
|
41
|
+
if (task.subtaskProgress) {
|
|
42
|
+
description += pc.dim(` [${task.subtaskProgress.done}/${task.subtaskProgress.total}]`);
|
|
43
|
+
}
|
|
44
|
+
return description;
|
|
45
|
+
}
|
|
46
|
+
export async function pickTask(options) {
|
|
47
|
+
const { api, worktrees, typeFilter, statusFilter } = options;
|
|
48
|
+
// Fetch tasks from Damper
|
|
49
|
+
const { tasks, project } = await api.listTasks({
|
|
50
|
+
status: statusFilter || 'all',
|
|
51
|
+
type: typeFilter,
|
|
52
|
+
sort: 'importance',
|
|
53
|
+
});
|
|
54
|
+
// Filter out completed tasks unless specifically requested
|
|
55
|
+
const availableTasks = tasks.filter(t => statusFilter === 'done' || statusFilter === 'all' ||
|
|
56
|
+
(t.status === 'planned' || t.status === 'in_progress'));
|
|
57
|
+
// Match worktrees with tasks
|
|
58
|
+
const inProgressChoices = [];
|
|
59
|
+
const worktreeTaskIds = new Set();
|
|
60
|
+
for (const worktree of worktrees) {
|
|
61
|
+
const task = tasks.find(t => t.id === worktree.taskId);
|
|
62
|
+
if (task) {
|
|
63
|
+
worktreeTaskIds.add(task.id);
|
|
64
|
+
// Get last note for display (we'd need to fetch task detail for this)
|
|
65
|
+
// For now, we'll leave it undefined - the API doesn't include notes in list
|
|
66
|
+
inProgressChoices.push({
|
|
67
|
+
type: 'in_progress',
|
|
68
|
+
task,
|
|
69
|
+
worktree,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
// Available tasks are those not in our worktrees and either planned or in_progress
|
|
74
|
+
const availableChoices = availableTasks
|
|
75
|
+
.filter(t => !worktreeTaskIds.has(t.id) && (t.status === 'planned' || t.status === 'in_progress'))
|
|
76
|
+
.map(task => ({ type: 'available', task }));
|
|
77
|
+
if (inProgressChoices.length === 0 && availableChoices.length === 0) {
|
|
78
|
+
console.log(pc.yellow('\nNo tasks available.'));
|
|
79
|
+
if (typeFilter) {
|
|
80
|
+
console.log(pc.dim(`(filtered by type: ${typeFilter})`));
|
|
81
|
+
}
|
|
82
|
+
console.log(pc.dim('Create new tasks in Damper or check your filters.\n'));
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
const choices = [];
|
|
86
|
+
if (inProgressChoices.length > 0) {
|
|
87
|
+
choices.push(new Separator(pc.bold('\n--- In Progress (your worktrees) ---')));
|
|
88
|
+
for (const choice of inProgressChoices) {
|
|
89
|
+
choices.push({
|
|
90
|
+
name: formatTaskChoice(choice),
|
|
91
|
+
value: choice,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (availableChoices.length > 0) {
|
|
96
|
+
choices.push(new Separator(pc.bold('\n--- Available Tasks ---')));
|
|
97
|
+
for (const choice of availableChoices) {
|
|
98
|
+
choices.push({
|
|
99
|
+
name: formatTaskChoice(choice),
|
|
100
|
+
value: choice,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
console.log(pc.bold(`\nProject: ${project}`));
|
|
105
|
+
const selected = await select({
|
|
106
|
+
message: 'Select a task to work on:',
|
|
107
|
+
choices: choices,
|
|
108
|
+
pageSize: 15,
|
|
109
|
+
});
|
|
110
|
+
if (selected.type === 'in_progress') {
|
|
111
|
+
return {
|
|
112
|
+
task: selected.task,
|
|
113
|
+
worktree: selected.worktree,
|
|
114
|
+
isResume: true,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
task: selected.task,
|
|
119
|
+
isResume: false,
|
|
120
|
+
};
|
|
121
|
+
}
|