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