opencode-hive 0.5.2 → 0.8.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/index.js +184 -18
- package/dist/services/taskService.d.ts +13 -1
- package/dist/services/taskService.js +153 -1
- package/dist/services/taskService.test.js +132 -1
- package/dist/services/worktreeService.d.ts +17 -0
- package/dist/services/worktreeService.js +144 -2
- package/dist/services/worktreeService.test.js +69 -1
- package/dist/types.d.ts +17 -0
- package/dist/utils/paths.d.ts +5 -0
- package/dist/utils/paths.js +18 -0
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -12,14 +12,16 @@ const HIVE_SYSTEM_PROMPT = `
|
|
|
12
12
|
|
|
13
13
|
Plan-first development: Write plan → User reviews → Approve → Execute tasks
|
|
14
14
|
|
|
15
|
-
### Tools (
|
|
15
|
+
### Tools (24 total)
|
|
16
16
|
|
|
17
17
|
| Domain | Tools |
|
|
18
18
|
|--------|-------|
|
|
19
19
|
| Feature | hive_feature_create, hive_feature_list, hive_feature_complete |
|
|
20
20
|
| Plan | hive_plan_write, hive_plan_read, hive_plan_approve |
|
|
21
21
|
| Task | hive_tasks_sync, hive_task_create, hive_task_update |
|
|
22
|
+
| Subtask | hive_subtask_create, hive_subtask_update, hive_subtask_list, hive_subtask_spec_write, hive_subtask_report_write |
|
|
22
23
|
| Exec | hive_exec_start, hive_exec_complete, hive_exec_abort |
|
|
24
|
+
| Merge | hive_merge, hive_worktree_list |
|
|
23
25
|
| Context | hive_context_write, hive_context_read, hive_context_list |
|
|
24
26
|
| Session | hive_session_open, hive_session_list |
|
|
25
27
|
|
|
@@ -30,7 +32,26 @@ Plan-first development: Write plan → User reviews → Approve → Execute task
|
|
|
30
32
|
3. User adds comments in VSCode → \`hive_plan_read\` to see them
|
|
31
33
|
4. Revise plan → User approves
|
|
32
34
|
5. \`hive_tasks_sync()\` - Generate tasks from plan
|
|
33
|
-
6. \`hive_exec_start(task)\` → work → \`hive_exec_complete(task, summary)\`
|
|
35
|
+
6. \`hive_exec_start(task)\` → work in worktree → \`hive_exec_complete(task, summary)\`
|
|
36
|
+
7. \`hive_merge(task)\` - Merge task branch into main (when ready)
|
|
37
|
+
|
|
38
|
+
**Important:** \`hive_exec_complete\` commits changes to task branch but does NOT merge.
|
|
39
|
+
Use \`hive_merge\` to explicitly integrate changes. Worktrees persist until manually removed.
|
|
40
|
+
|
|
41
|
+
### Subtasks & TDD
|
|
42
|
+
|
|
43
|
+
For complex tasks, break work into subtasks:
|
|
44
|
+
|
|
45
|
+
\`\`\`
|
|
46
|
+
hive_subtask_create(task, "Write failing tests", "test")
|
|
47
|
+
hive_subtask_create(task, "Implement until green", "implement")
|
|
48
|
+
hive_subtask_create(task, "Run test suite", "verify")
|
|
49
|
+
\`\`\`
|
|
50
|
+
|
|
51
|
+
Subtask types: test, implement, review, verify, research, debug, custom
|
|
52
|
+
|
|
53
|
+
**Test-Driven Development**: For implementation tasks, consider writing tests first.
|
|
54
|
+
Tests define "done" and provide feedback loops that improve quality.
|
|
34
55
|
|
|
35
56
|
### Plan Format
|
|
36
57
|
|
|
@@ -299,7 +320,7 @@ const plugin = async (ctx) => {
|
|
|
299
320
|
},
|
|
300
321
|
}),
|
|
301
322
|
hive_exec_complete: tool({
|
|
302
|
-
description: 'Complete task:
|
|
323
|
+
description: 'Complete task: commit changes to branch, write report (does NOT merge or cleanup)',
|
|
303
324
|
args: {
|
|
304
325
|
task: tool.schema.string().describe('Task folder name'),
|
|
305
326
|
summary: tool.schema.string().describe('Summary of what was done'),
|
|
@@ -314,22 +335,15 @@ const plugin = async (ctx) => {
|
|
|
314
335
|
return `Error: Task "${task}" not found`;
|
|
315
336
|
if (taskInfo.status !== 'in_progress')
|
|
316
337
|
return "Error: Task not in progress";
|
|
338
|
+
const commitResult = await worktreeService.commitChanges(feature, task, `hive(${task}): ${summary.slice(0, 50)}`);
|
|
317
339
|
const diff = await worktreeService.getDiff(feature, task);
|
|
318
|
-
let changesApplied = false;
|
|
319
|
-
let applyError = "";
|
|
320
|
-
if (diff?.hasDiff) {
|
|
321
|
-
const result = await worktreeService.applyDiff(feature, task);
|
|
322
|
-
changesApplied = result.success;
|
|
323
|
-
if (!result.success) {
|
|
324
|
-
applyError = result.error || "Unknown apply error";
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
340
|
const reportLines = [
|
|
328
341
|
`# Task Report: ${task}`,
|
|
329
342
|
'',
|
|
330
343
|
`**Feature:** ${feature}`,
|
|
331
344
|
`**Completed:** ${new Date().toISOString()}`,
|
|
332
|
-
`**Status:**
|
|
345
|
+
`**Status:** success`,
|
|
346
|
+
`**Commit:** ${commitResult.sha || 'none'}`,
|
|
333
347
|
'',
|
|
334
348
|
'---',
|
|
335
349
|
'',
|
|
@@ -353,11 +367,8 @@ const plugin = async (ctx) => {
|
|
|
353
367
|
}
|
|
354
368
|
taskService.writeReport(feature, task, reportLines.join('\n'));
|
|
355
369
|
taskService.update(feature, task, { status: 'done', summary });
|
|
356
|
-
await worktreeService.
|
|
357
|
-
|
|
358
|
-
return `Task "${task}" completed but changes failed to apply: ${applyError}`;
|
|
359
|
-
}
|
|
360
|
-
return `Task "${task}" completed.${changesApplied ? " Changes applied." : ""}`;
|
|
370
|
+
const worktree = await worktreeService.get(feature, task);
|
|
371
|
+
return `Task "${task}" completed. Changes committed to branch ${worktree?.branch || 'unknown'}.\nUse hive_merge to integrate changes. Worktree preserved at ${worktree?.path || 'unknown'}.`;
|
|
361
372
|
},
|
|
362
373
|
}),
|
|
363
374
|
hive_exec_abort: tool({
|
|
@@ -375,6 +386,52 @@ const plugin = async (ctx) => {
|
|
|
375
386
|
return `Task "${task}" aborted. Status reset to pending.`;
|
|
376
387
|
},
|
|
377
388
|
}),
|
|
389
|
+
hive_merge: tool({
|
|
390
|
+
description: 'Merge completed task branch into current branch (explicit integration)',
|
|
391
|
+
args: {
|
|
392
|
+
task: tool.schema.string().describe('Task folder name to merge'),
|
|
393
|
+
strategy: tool.schema.enum(['merge', 'squash', 'rebase']).optional().describe('Merge strategy (default: merge)'),
|
|
394
|
+
feature: tool.schema.string().optional().describe('Feature name (defaults to active)'),
|
|
395
|
+
},
|
|
396
|
+
async execute({ task, strategy = 'merge', feature: explicitFeature }) {
|
|
397
|
+
const feature = resolveFeature(explicitFeature);
|
|
398
|
+
if (!feature)
|
|
399
|
+
return "Error: No feature specified. Create a feature or provide feature param.";
|
|
400
|
+
const taskInfo = taskService.get(feature, task);
|
|
401
|
+
if (!taskInfo)
|
|
402
|
+
return `Error: Task "${task}" not found`;
|
|
403
|
+
if (taskInfo.status !== 'done')
|
|
404
|
+
return "Error: Task must be completed before merging. Use hive_exec_complete first.";
|
|
405
|
+
const result = await worktreeService.merge(feature, task, strategy);
|
|
406
|
+
if (!result.success) {
|
|
407
|
+
if (result.conflicts && result.conflicts.length > 0) {
|
|
408
|
+
return `Merge failed with conflicts in:\n${result.conflicts.map(f => `- ${f}`).join('\n')}\n\nResolve conflicts manually or try a different strategy.`;
|
|
409
|
+
}
|
|
410
|
+
return `Merge failed: ${result.error}`;
|
|
411
|
+
}
|
|
412
|
+
return `Task "${task}" merged successfully using ${strategy} strategy.\nCommit: ${result.sha}\nFiles changed: ${result.filesChanged?.length || 0}`;
|
|
413
|
+
},
|
|
414
|
+
}),
|
|
415
|
+
hive_worktree_list: tool({
|
|
416
|
+
description: 'List all worktrees for current feature',
|
|
417
|
+
args: {
|
|
418
|
+
feature: tool.schema.string().optional().describe('Feature name (defaults to active)'),
|
|
419
|
+
},
|
|
420
|
+
async execute({ feature: explicitFeature }) {
|
|
421
|
+
const feature = resolveFeature(explicitFeature);
|
|
422
|
+
if (!feature)
|
|
423
|
+
return "Error: No feature specified. Create a feature or provide feature param.";
|
|
424
|
+
const worktrees = await worktreeService.list(feature);
|
|
425
|
+
if (worktrees.length === 0)
|
|
426
|
+
return "No worktrees found for this feature.";
|
|
427
|
+
const lines = ['| Task | Branch | Has Changes |', '|------|--------|-------------|'];
|
|
428
|
+
for (const wt of worktrees) {
|
|
429
|
+
const hasChanges = await worktreeService.hasUncommittedChanges(wt.feature, wt.step);
|
|
430
|
+
lines.push(`| ${wt.step} | ${wt.branch} | ${hasChanges ? 'Yes' : 'No'} |`);
|
|
431
|
+
}
|
|
432
|
+
return lines.join('\n');
|
|
433
|
+
},
|
|
434
|
+
}),
|
|
378
435
|
// Context Tools
|
|
379
436
|
hive_context_write: tool({
|
|
380
437
|
description: 'Write a context file for the feature. Context files store persistent notes, decisions, and reference material.',
|
|
@@ -488,6 +545,115 @@ const plugin = async (ctx) => {
|
|
|
488
545
|
}).join('\n');
|
|
489
546
|
},
|
|
490
547
|
}),
|
|
548
|
+
hive_subtask_create: tool({
|
|
549
|
+
description: 'Create a subtask within a task. Use for TDD: create test/implement/verify subtasks.',
|
|
550
|
+
args: {
|
|
551
|
+
task: tool.schema.string().describe('Task folder name'),
|
|
552
|
+
name: tool.schema.string().describe('Subtask description'),
|
|
553
|
+
type: tool.schema.enum(['test', 'implement', 'review', 'verify', 'research', 'debug', 'custom']).optional().describe('Subtask type'),
|
|
554
|
+
feature: tool.schema.string().optional().describe('Feature name (defaults to active)'),
|
|
555
|
+
},
|
|
556
|
+
async execute({ task, name, type, feature: explicitFeature }) {
|
|
557
|
+
const feature = resolveFeature(explicitFeature);
|
|
558
|
+
if (!feature)
|
|
559
|
+
return "Error: No feature specified. Create a feature or provide feature param.";
|
|
560
|
+
try {
|
|
561
|
+
const subtask = taskService.createSubtask(feature, task, name, type);
|
|
562
|
+
return `Subtask created: ${subtask.id} - ${subtask.name} [${subtask.type || 'custom'}]`;
|
|
563
|
+
}
|
|
564
|
+
catch (e) {
|
|
565
|
+
return `Error: ${e.message}`;
|
|
566
|
+
}
|
|
567
|
+
},
|
|
568
|
+
}),
|
|
569
|
+
hive_subtask_update: tool({
|
|
570
|
+
description: 'Update subtask status',
|
|
571
|
+
args: {
|
|
572
|
+
task: tool.schema.string().describe('Task folder name'),
|
|
573
|
+
subtask: tool.schema.string().describe('Subtask ID (e.g., "1.1")'),
|
|
574
|
+
status: tool.schema.enum(['pending', 'in_progress', 'done', 'cancelled']).describe('New status'),
|
|
575
|
+
feature: tool.schema.string().optional().describe('Feature name (defaults to active)'),
|
|
576
|
+
},
|
|
577
|
+
async execute({ task, subtask, status, feature: explicitFeature }) {
|
|
578
|
+
const feature = resolveFeature(explicitFeature);
|
|
579
|
+
if (!feature)
|
|
580
|
+
return "Error: No feature specified. Create a feature or provide feature param.";
|
|
581
|
+
try {
|
|
582
|
+
const updated = taskService.updateSubtask(feature, task, subtask, status);
|
|
583
|
+
return `Subtask ${updated.id} updated: ${updated.status}`;
|
|
584
|
+
}
|
|
585
|
+
catch (e) {
|
|
586
|
+
return `Error: ${e.message}`;
|
|
587
|
+
}
|
|
588
|
+
},
|
|
589
|
+
}),
|
|
590
|
+
hive_subtask_list: tool({
|
|
591
|
+
description: 'List all subtasks for a task',
|
|
592
|
+
args: {
|
|
593
|
+
task: tool.schema.string().describe('Task folder name'),
|
|
594
|
+
feature: tool.schema.string().optional().describe('Feature name (defaults to active)'),
|
|
595
|
+
},
|
|
596
|
+
async execute({ task, feature: explicitFeature }) {
|
|
597
|
+
const feature = resolveFeature(explicitFeature);
|
|
598
|
+
if (!feature)
|
|
599
|
+
return "Error: No feature specified. Create a feature or provide feature param.";
|
|
600
|
+
try {
|
|
601
|
+
const subtasks = taskService.listSubtasks(feature, task);
|
|
602
|
+
if (subtasks.length === 0)
|
|
603
|
+
return "No subtasks for this task.";
|
|
604
|
+
return subtasks.map(s => {
|
|
605
|
+
const typeTag = s.type ? ` [${s.type}]` : '';
|
|
606
|
+
const statusIcon = s.status === 'done' ? '✓' : s.status === 'in_progress' ? '→' : '○';
|
|
607
|
+
return `${statusIcon} ${s.id}: ${s.name}${typeTag}`;
|
|
608
|
+
}).join('\n');
|
|
609
|
+
}
|
|
610
|
+
catch (e) {
|
|
611
|
+
return `Error: ${e.message}`;
|
|
612
|
+
}
|
|
613
|
+
},
|
|
614
|
+
}),
|
|
615
|
+
hive_subtask_spec_write: tool({
|
|
616
|
+
description: 'Write spec.md for a subtask (detailed instructions)',
|
|
617
|
+
args: {
|
|
618
|
+
task: tool.schema.string().describe('Task folder name'),
|
|
619
|
+
subtask: tool.schema.string().describe('Subtask ID (e.g., "1.1")'),
|
|
620
|
+
content: tool.schema.string().describe('Spec content (markdown)'),
|
|
621
|
+
feature: tool.schema.string().optional().describe('Feature name (defaults to active)'),
|
|
622
|
+
},
|
|
623
|
+
async execute({ task, subtask, content, feature: explicitFeature }) {
|
|
624
|
+
const feature = resolveFeature(explicitFeature);
|
|
625
|
+
if (!feature)
|
|
626
|
+
return "Error: No feature specified. Create a feature or provide feature param.";
|
|
627
|
+
try {
|
|
628
|
+
const specPath = taskService.writeSubtaskSpec(feature, task, subtask, content);
|
|
629
|
+
return `Subtask spec written: ${specPath}`;
|
|
630
|
+
}
|
|
631
|
+
catch (e) {
|
|
632
|
+
return `Error: ${e.message}`;
|
|
633
|
+
}
|
|
634
|
+
},
|
|
635
|
+
}),
|
|
636
|
+
hive_subtask_report_write: tool({
|
|
637
|
+
description: 'Write report.md for a subtask (what was done)',
|
|
638
|
+
args: {
|
|
639
|
+
task: tool.schema.string().describe('Task folder name'),
|
|
640
|
+
subtask: tool.schema.string().describe('Subtask ID (e.g., "1.1")'),
|
|
641
|
+
content: tool.schema.string().describe('Report content (markdown)'),
|
|
642
|
+
feature: tool.schema.string().optional().describe('Feature name (defaults to active)'),
|
|
643
|
+
},
|
|
644
|
+
async execute({ task, subtask, content, feature: explicitFeature }) {
|
|
645
|
+
const feature = resolveFeature(explicitFeature);
|
|
646
|
+
if (!feature)
|
|
647
|
+
return "Error: No feature specified. Create a feature or provide feature param.";
|
|
648
|
+
try {
|
|
649
|
+
const reportPath = taskService.writeSubtaskReport(feature, task, subtask, content);
|
|
650
|
+
return `Subtask report written: ${reportPath}`;
|
|
651
|
+
}
|
|
652
|
+
catch (e) {
|
|
653
|
+
return `Error: ${e.message}`;
|
|
654
|
+
}
|
|
655
|
+
},
|
|
656
|
+
}),
|
|
491
657
|
},
|
|
492
658
|
command: {
|
|
493
659
|
hive: {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { TaskStatus, TasksSyncResult, TaskInfo } from '../types.js';
|
|
1
|
+
import { TaskStatus, TaskStatusType, TasksSyncResult, TaskInfo, Subtask, SubtaskType } from '../types.js';
|
|
2
2
|
export declare class TaskService {
|
|
3
3
|
private projectRoot;
|
|
4
4
|
constructor(projectRoot: string);
|
|
@@ -14,4 +14,16 @@ export declare class TaskService {
|
|
|
14
14
|
private deleteTask;
|
|
15
15
|
private getNextOrder;
|
|
16
16
|
private parseTasksFromPlan;
|
|
17
|
+
createSubtask(featureName: string, taskFolder: string, name: string, type?: SubtaskType): Subtask;
|
|
18
|
+
updateSubtask(featureName: string, taskFolder: string, subtaskId: string, status: TaskStatusType): Subtask;
|
|
19
|
+
listSubtasks(featureName: string, taskFolder: string): Subtask[];
|
|
20
|
+
deleteSubtask(featureName: string, taskFolder: string, subtaskId: string): void;
|
|
21
|
+
getSubtask(featureName: string, taskFolder: string, subtaskId: string): Subtask | null;
|
|
22
|
+
writeSubtaskSpec(featureName: string, taskFolder: string, subtaskId: string, content: string): string;
|
|
23
|
+
writeSubtaskReport(featureName: string, taskFolder: string, subtaskId: string, content: string): string;
|
|
24
|
+
readSubtaskSpec(featureName: string, taskFolder: string, subtaskId: string): string | null;
|
|
25
|
+
readSubtaskReport(featureName: string, taskFolder: string, subtaskId: string): string | null;
|
|
26
|
+
private listSubtaskFolders;
|
|
27
|
+
private findSubtaskFolder;
|
|
28
|
+
private slugify;
|
|
17
29
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as fs from 'fs';
|
|
2
|
-
import { getTasksPath, getTaskPath, getTaskStatusPath, getTaskReportPath, getTaskSpecPath, getPlanPath, ensureDir, readJson, writeJson, readText, writeText, fileExists, } from '../utils/paths.js';
|
|
2
|
+
import { getTasksPath, getTaskPath, getTaskStatusPath, getTaskReportPath, getTaskSpecPath, getSubtasksPath, getSubtaskPath, getSubtaskStatusPath, getSubtaskSpecPath, getSubtaskReportPath, getPlanPath, ensureDir, readJson, writeJson, readText, writeText, fileExists, } from '../utils/paths.js';
|
|
3
3
|
export class TaskService {
|
|
4
4
|
projectRoot;
|
|
5
5
|
constructor(projectRoot) {
|
|
@@ -227,4 +227,156 @@ export class TaskService {
|
|
|
227
227
|
}
|
|
228
228
|
return tasks;
|
|
229
229
|
}
|
|
230
|
+
createSubtask(featureName, taskFolder, name, type) {
|
|
231
|
+
const subtasksPath = getSubtasksPath(this.projectRoot, featureName, taskFolder);
|
|
232
|
+
ensureDir(subtasksPath);
|
|
233
|
+
const existingFolders = this.listSubtaskFolders(featureName, taskFolder);
|
|
234
|
+
const taskOrder = parseInt(taskFolder.split('-')[0], 10);
|
|
235
|
+
const nextOrder = existingFolders.length + 1;
|
|
236
|
+
const subtaskId = `${taskOrder}.${nextOrder}`;
|
|
237
|
+
const folderName = `${nextOrder}-${this.slugify(name)}`;
|
|
238
|
+
const subtaskPath = getSubtaskPath(this.projectRoot, featureName, taskFolder, folderName);
|
|
239
|
+
ensureDir(subtaskPath);
|
|
240
|
+
const subtaskStatus = {
|
|
241
|
+
status: 'pending',
|
|
242
|
+
type,
|
|
243
|
+
createdAt: new Date().toISOString(),
|
|
244
|
+
};
|
|
245
|
+
writeJson(getSubtaskStatusPath(this.projectRoot, featureName, taskFolder, folderName), subtaskStatus);
|
|
246
|
+
const specContent = `# Subtask: ${name}\n\n**Type:** ${type || 'custom'}\n**ID:** ${subtaskId}\n\n## Instructions\n\n_Add detailed instructions here_\n`;
|
|
247
|
+
writeText(getSubtaskSpecPath(this.projectRoot, featureName, taskFolder, folderName), specContent);
|
|
248
|
+
return {
|
|
249
|
+
id: subtaskId,
|
|
250
|
+
name,
|
|
251
|
+
folder: folderName,
|
|
252
|
+
status: 'pending',
|
|
253
|
+
type,
|
|
254
|
+
createdAt: subtaskStatus.createdAt,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
updateSubtask(featureName, taskFolder, subtaskId, status) {
|
|
258
|
+
const subtaskFolder = this.findSubtaskFolder(featureName, taskFolder, subtaskId);
|
|
259
|
+
if (!subtaskFolder) {
|
|
260
|
+
throw new Error(`Subtask '${subtaskId}' not found in task '${taskFolder}'`);
|
|
261
|
+
}
|
|
262
|
+
const statusPath = getSubtaskStatusPath(this.projectRoot, featureName, taskFolder, subtaskFolder);
|
|
263
|
+
const current = readJson(statusPath);
|
|
264
|
+
if (!current) {
|
|
265
|
+
throw new Error(`Subtask status not found for '${subtaskId}'`);
|
|
266
|
+
}
|
|
267
|
+
const updated = { ...current, status };
|
|
268
|
+
if (status === 'done' && !current.completedAt) {
|
|
269
|
+
updated.completedAt = new Date().toISOString();
|
|
270
|
+
}
|
|
271
|
+
writeJson(statusPath, updated);
|
|
272
|
+
const name = subtaskFolder.replace(/^\d+-/, '');
|
|
273
|
+
return {
|
|
274
|
+
id: subtaskId,
|
|
275
|
+
name,
|
|
276
|
+
folder: subtaskFolder,
|
|
277
|
+
status,
|
|
278
|
+
type: current.type,
|
|
279
|
+
createdAt: current.createdAt,
|
|
280
|
+
completedAt: updated.completedAt,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
listSubtasks(featureName, taskFolder) {
|
|
284
|
+
const folders = this.listSubtaskFolders(featureName, taskFolder);
|
|
285
|
+
const taskOrder = parseInt(taskFolder.split('-')[0], 10);
|
|
286
|
+
return folders.map((folder, index) => {
|
|
287
|
+
const statusPath = getSubtaskStatusPath(this.projectRoot, featureName, taskFolder, folder);
|
|
288
|
+
const status = readJson(statusPath);
|
|
289
|
+
const name = folder.replace(/^\d+-/, '');
|
|
290
|
+
const subtaskOrder = parseInt(folder.split('-')[0], 10) || (index + 1);
|
|
291
|
+
return {
|
|
292
|
+
id: `${taskOrder}.${subtaskOrder}`,
|
|
293
|
+
name,
|
|
294
|
+
folder,
|
|
295
|
+
status: status?.status || 'pending',
|
|
296
|
+
type: status?.type,
|
|
297
|
+
createdAt: status?.createdAt,
|
|
298
|
+
completedAt: status?.completedAt,
|
|
299
|
+
};
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
deleteSubtask(featureName, taskFolder, subtaskId) {
|
|
303
|
+
const subtaskFolder = this.findSubtaskFolder(featureName, taskFolder, subtaskId);
|
|
304
|
+
if (!subtaskFolder) {
|
|
305
|
+
throw new Error(`Subtask '${subtaskId}' not found in task '${taskFolder}'`);
|
|
306
|
+
}
|
|
307
|
+
const subtaskPath = getSubtaskPath(this.projectRoot, featureName, taskFolder, subtaskFolder);
|
|
308
|
+
if (fileExists(subtaskPath)) {
|
|
309
|
+
fs.rmSync(subtaskPath, { recursive: true });
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
getSubtask(featureName, taskFolder, subtaskId) {
|
|
313
|
+
const subtaskFolder = this.findSubtaskFolder(featureName, taskFolder, subtaskId);
|
|
314
|
+
if (!subtaskFolder)
|
|
315
|
+
return null;
|
|
316
|
+
const statusPath = getSubtaskStatusPath(this.projectRoot, featureName, taskFolder, subtaskFolder);
|
|
317
|
+
const status = readJson(statusPath);
|
|
318
|
+
if (!status)
|
|
319
|
+
return null;
|
|
320
|
+
const taskOrder = parseInt(taskFolder.split('-')[0], 10);
|
|
321
|
+
const subtaskOrder = parseInt(subtaskFolder.split('-')[0], 10);
|
|
322
|
+
const name = subtaskFolder.replace(/^\d+-/, '');
|
|
323
|
+
return {
|
|
324
|
+
id: `${taskOrder}.${subtaskOrder}`,
|
|
325
|
+
name,
|
|
326
|
+
folder: subtaskFolder,
|
|
327
|
+
status: status.status,
|
|
328
|
+
type: status.type,
|
|
329
|
+
createdAt: status.createdAt,
|
|
330
|
+
completedAt: status.completedAt,
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
writeSubtaskSpec(featureName, taskFolder, subtaskId, content) {
|
|
334
|
+
const subtaskFolder = this.findSubtaskFolder(featureName, taskFolder, subtaskId);
|
|
335
|
+
if (!subtaskFolder) {
|
|
336
|
+
throw new Error(`Subtask '${subtaskId}' not found in task '${taskFolder}'`);
|
|
337
|
+
}
|
|
338
|
+
const specPath = getSubtaskSpecPath(this.projectRoot, featureName, taskFolder, subtaskFolder);
|
|
339
|
+
writeText(specPath, content);
|
|
340
|
+
return specPath;
|
|
341
|
+
}
|
|
342
|
+
writeSubtaskReport(featureName, taskFolder, subtaskId, content) {
|
|
343
|
+
const subtaskFolder = this.findSubtaskFolder(featureName, taskFolder, subtaskId);
|
|
344
|
+
if (!subtaskFolder) {
|
|
345
|
+
throw new Error(`Subtask '${subtaskId}' not found in task '${taskFolder}'`);
|
|
346
|
+
}
|
|
347
|
+
const reportPath = getSubtaskReportPath(this.projectRoot, featureName, taskFolder, subtaskFolder);
|
|
348
|
+
writeText(reportPath, content);
|
|
349
|
+
return reportPath;
|
|
350
|
+
}
|
|
351
|
+
readSubtaskSpec(featureName, taskFolder, subtaskId) {
|
|
352
|
+
const subtaskFolder = this.findSubtaskFolder(featureName, taskFolder, subtaskId);
|
|
353
|
+
if (!subtaskFolder)
|
|
354
|
+
return null;
|
|
355
|
+
const specPath = getSubtaskSpecPath(this.projectRoot, featureName, taskFolder, subtaskFolder);
|
|
356
|
+
return readText(specPath);
|
|
357
|
+
}
|
|
358
|
+
readSubtaskReport(featureName, taskFolder, subtaskId) {
|
|
359
|
+
const subtaskFolder = this.findSubtaskFolder(featureName, taskFolder, subtaskId);
|
|
360
|
+
if (!subtaskFolder)
|
|
361
|
+
return null;
|
|
362
|
+
const reportPath = getSubtaskReportPath(this.projectRoot, featureName, taskFolder, subtaskFolder);
|
|
363
|
+
return readText(reportPath);
|
|
364
|
+
}
|
|
365
|
+
listSubtaskFolders(featureName, taskFolder) {
|
|
366
|
+
const subtasksPath = getSubtasksPath(this.projectRoot, featureName, taskFolder);
|
|
367
|
+
if (!fileExists(subtasksPath))
|
|
368
|
+
return [];
|
|
369
|
+
return fs.readdirSync(subtasksPath, { withFileTypes: true })
|
|
370
|
+
.filter(d => d.isDirectory())
|
|
371
|
+
.map(d => d.name)
|
|
372
|
+
.sort();
|
|
373
|
+
}
|
|
374
|
+
findSubtaskFolder(featureName, taskFolder, subtaskId) {
|
|
375
|
+
const folders = this.listSubtaskFolders(featureName, taskFolder);
|
|
376
|
+
const subtaskOrder = subtaskId.split('.')[1];
|
|
377
|
+
return folders.find(f => f.startsWith(`${subtaskOrder}-`)) || null;
|
|
378
|
+
}
|
|
379
|
+
slugify(name) {
|
|
380
|
+
return name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
|
|
381
|
+
}
|
|
230
382
|
}
|
|
@@ -3,7 +3,7 @@ import * as fs from "fs";
|
|
|
3
3
|
import { TaskService } from "./taskService";
|
|
4
4
|
import { FeatureService } from "./featureService";
|
|
5
5
|
import { PlanService } from "./planService";
|
|
6
|
-
import { getTaskPath, getTaskStatusPath, getTaskReportPath } from "../utils/paths";
|
|
6
|
+
import { getTaskPath, getTaskStatusPath, getTaskReportPath, getSubtaskPath, getSubtaskStatusPath, getSubtaskSpecPath } from "../utils/paths";
|
|
7
7
|
const TEST_ROOT = "/tmp/hive-test-task";
|
|
8
8
|
describe("TaskService", () => {
|
|
9
9
|
let taskService;
|
|
@@ -156,4 +156,135 @@ Description
|
|
|
156
156
|
expect(fs.readFileSync(reportPath, "utf-8")).toBe(report);
|
|
157
157
|
});
|
|
158
158
|
});
|
|
159
|
+
describe("subtasks", () => {
|
|
160
|
+
let taskFolder;
|
|
161
|
+
beforeEach(() => {
|
|
162
|
+
taskFolder = taskService.create("test-feature", "parent-task");
|
|
163
|
+
});
|
|
164
|
+
describe("createSubtask", () => {
|
|
165
|
+
it("creates subtask folder with status.json and spec.md", () => {
|
|
166
|
+
const subtask = taskService.createSubtask("test-feature", taskFolder, "Write tests", "test");
|
|
167
|
+
expect(subtask.id).toBe("1.1");
|
|
168
|
+
expect(subtask.name).toBe("Write tests");
|
|
169
|
+
expect(subtask.folder).toBe("1-write-tests");
|
|
170
|
+
expect(subtask.status).toBe("pending");
|
|
171
|
+
expect(subtask.type).toBe("test");
|
|
172
|
+
const subtaskPath = getSubtaskPath(TEST_ROOT, "test-feature", taskFolder, subtask.folder);
|
|
173
|
+
expect(fs.existsSync(subtaskPath)).toBe(true);
|
|
174
|
+
const statusPath = getSubtaskStatusPath(TEST_ROOT, "test-feature", taskFolder, subtask.folder);
|
|
175
|
+
expect(fs.existsSync(statusPath)).toBe(true);
|
|
176
|
+
const specPath = getSubtaskSpecPath(TEST_ROOT, "test-feature", taskFolder, subtask.folder);
|
|
177
|
+
expect(fs.existsSync(specPath)).toBe(true);
|
|
178
|
+
expect(fs.readFileSync(specPath, "utf-8")).toContain("Write tests");
|
|
179
|
+
});
|
|
180
|
+
it("auto-increments subtask order", () => {
|
|
181
|
+
const first = taskService.createSubtask("test-feature", taskFolder, "First", "test");
|
|
182
|
+
const second = taskService.createSubtask("test-feature", taskFolder, "Second", "implement");
|
|
183
|
+
const third = taskService.createSubtask("test-feature", taskFolder, "Third", "verify");
|
|
184
|
+
expect(first.id).toBe("1.1");
|
|
185
|
+
expect(second.id).toBe("1.2");
|
|
186
|
+
expect(third.id).toBe("1.3");
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
describe("listSubtasks", () => {
|
|
190
|
+
it("returns empty array when no subtasks", () => {
|
|
191
|
+
expect(taskService.listSubtasks("test-feature", taskFolder)).toEqual([]);
|
|
192
|
+
});
|
|
193
|
+
it("returns all subtasks sorted by order", () => {
|
|
194
|
+
taskService.createSubtask("test-feature", taskFolder, "Third", "verify");
|
|
195
|
+
taskService.createSubtask("test-feature", taskFolder, "First", "test");
|
|
196
|
+
const subtasks = taskService.listSubtasks("test-feature", taskFolder);
|
|
197
|
+
expect(subtasks.length).toBe(2);
|
|
198
|
+
expect(subtasks[0].folder).toBe("1-third");
|
|
199
|
+
expect(subtasks[1].folder).toBe("2-first");
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
describe("updateSubtask", () => {
|
|
203
|
+
it("updates subtask status", () => {
|
|
204
|
+
const subtask = taskService.createSubtask("test-feature", taskFolder, "Test", "test");
|
|
205
|
+
const updated = taskService.updateSubtask("test-feature", taskFolder, subtask.id, "in_progress");
|
|
206
|
+
expect(updated.status).toBe("in_progress");
|
|
207
|
+
});
|
|
208
|
+
it("sets completedAt when status becomes done", () => {
|
|
209
|
+
const subtask = taskService.createSubtask("test-feature", taskFolder, "Test", "test");
|
|
210
|
+
const updated = taskService.updateSubtask("test-feature", taskFolder, subtask.id, "done");
|
|
211
|
+
expect(updated.completedAt).toBeDefined();
|
|
212
|
+
});
|
|
213
|
+
it("throws for non-existing subtask", () => {
|
|
214
|
+
expect(() => taskService.updateSubtask("test-feature", taskFolder, "1.99", "done")).toThrow();
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
describe("deleteSubtask", () => {
|
|
218
|
+
it("removes subtask folder", () => {
|
|
219
|
+
const subtask = taskService.createSubtask("test-feature", taskFolder, "ToDelete", "test");
|
|
220
|
+
const subtaskPath = getSubtaskPath(TEST_ROOT, "test-feature", taskFolder, subtask.folder);
|
|
221
|
+
expect(fs.existsSync(subtaskPath)).toBe(true);
|
|
222
|
+
taskService.deleteSubtask("test-feature", taskFolder, subtask.id);
|
|
223
|
+
expect(fs.existsSync(subtaskPath)).toBe(false);
|
|
224
|
+
});
|
|
225
|
+
it("throws for non-existing subtask", () => {
|
|
226
|
+
expect(() => taskService.deleteSubtask("test-feature", taskFolder, "1.99")).toThrow();
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
describe("getSubtask", () => {
|
|
230
|
+
it("returns subtask info", () => {
|
|
231
|
+
const created = taskService.createSubtask("test-feature", taskFolder, "MySubtask", "implement");
|
|
232
|
+
const subtask = taskService.getSubtask("test-feature", taskFolder, created.id);
|
|
233
|
+
expect(subtask).not.toBeNull();
|
|
234
|
+
expect(subtask.id).toBe("1.1");
|
|
235
|
+
expect(subtask.name).toBe("mysubtask");
|
|
236
|
+
expect(subtask.folder).toBe("1-mysubtask");
|
|
237
|
+
expect(subtask.type).toBe("implement");
|
|
238
|
+
});
|
|
239
|
+
it("returns null for non-existing subtask", () => {
|
|
240
|
+
expect(taskService.getSubtask("test-feature", taskFolder, "1.99")).toBeNull();
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
describe("writeSubtaskSpec", () => {
|
|
244
|
+
it("writes spec.md content", () => {
|
|
245
|
+
const subtask = taskService.createSubtask("test-feature", taskFolder, "Test", "test");
|
|
246
|
+
const content = "# Custom Spec\n\nDetailed instructions here.";
|
|
247
|
+
const specPath = taskService.writeSubtaskSpec("test-feature", taskFolder, subtask.id, content);
|
|
248
|
+
expect(fs.readFileSync(specPath, "utf-8")).toBe(content);
|
|
249
|
+
});
|
|
250
|
+
it("throws for non-existing subtask", () => {
|
|
251
|
+
expect(() => taskService.writeSubtaskSpec("test-feature", taskFolder, "1.99", "content")).toThrow();
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
describe("writeSubtaskReport", () => {
|
|
255
|
+
it("writes report.md content", () => {
|
|
256
|
+
const subtask = taskService.createSubtask("test-feature", taskFolder, "Test", "test");
|
|
257
|
+
const content = "# Report\n\nCompleted successfully.";
|
|
258
|
+
const reportPath = taskService.writeSubtaskReport("test-feature", taskFolder, subtask.id, content);
|
|
259
|
+
expect(fs.readFileSync(reportPath, "utf-8")).toBe(content);
|
|
260
|
+
});
|
|
261
|
+
it("throws for non-existing subtask", () => {
|
|
262
|
+
expect(() => taskService.writeSubtaskReport("test-feature", taskFolder, "1.99", "content")).toThrow();
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
describe("readSubtaskSpec", () => {
|
|
266
|
+
it("reads spec.md content", () => {
|
|
267
|
+
const subtask = taskService.createSubtask("test-feature", taskFolder, "Test", "test");
|
|
268
|
+
const content = "Custom spec content";
|
|
269
|
+
taskService.writeSubtaskSpec("test-feature", taskFolder, subtask.id, content);
|
|
270
|
+
const spec = taskService.readSubtaskSpec("test-feature", taskFolder, subtask.id);
|
|
271
|
+
expect(spec).toBe(content);
|
|
272
|
+
});
|
|
273
|
+
it("returns null for non-existing subtask", () => {
|
|
274
|
+
expect(taskService.readSubtaskSpec("test-feature", taskFolder, "1.99")).toBeNull();
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
describe("readSubtaskReport", () => {
|
|
278
|
+
it("reads report.md content", () => {
|
|
279
|
+
const subtask = taskService.createSubtask("test-feature", taskFolder, "Test", "test");
|
|
280
|
+
const content = "Report content";
|
|
281
|
+
taskService.writeSubtaskReport("test-feature", taskFolder, subtask.id, content);
|
|
282
|
+
const report = taskService.readSubtaskReport("test-feature", taskFolder, subtask.id);
|
|
283
|
+
expect(report).toBe(content);
|
|
284
|
+
});
|
|
285
|
+
it("returns null for non-existing subtask", () => {
|
|
286
|
+
expect(taskService.readSubtaskReport("test-feature", taskFolder, "1.99")).toBeNull();
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
});
|
|
159
290
|
});
|
|
@@ -17,6 +17,19 @@ export interface ApplyResult {
|
|
|
17
17
|
error?: string;
|
|
18
18
|
filesAffected: string[];
|
|
19
19
|
}
|
|
20
|
+
export interface CommitResult {
|
|
21
|
+
committed: boolean;
|
|
22
|
+
sha: string;
|
|
23
|
+
message?: string;
|
|
24
|
+
}
|
|
25
|
+
export interface MergeResult {
|
|
26
|
+
success: boolean;
|
|
27
|
+
merged: boolean;
|
|
28
|
+
sha?: string;
|
|
29
|
+
filesChanged?: string[];
|
|
30
|
+
conflicts?: string[];
|
|
31
|
+
error?: string;
|
|
32
|
+
}
|
|
20
33
|
export interface WorktreeConfig {
|
|
21
34
|
baseDir: string;
|
|
22
35
|
hiveDir: string;
|
|
@@ -45,5 +58,9 @@ export declare class WorktreeService {
|
|
|
45
58
|
}>;
|
|
46
59
|
checkConflicts(feature: string, step: string, baseBranch?: string): Promise<string[]>;
|
|
47
60
|
checkConflictsFromSavedDiff(diffPath: string, reverse?: boolean): Promise<string[]>;
|
|
61
|
+
commitChanges(feature: string, step: string, message?: string): Promise<CommitResult>;
|
|
62
|
+
merge(feature: string, step: string, strategy?: 'merge' | 'squash' | 'rebase'): Promise<MergeResult>;
|
|
63
|
+
hasUncommittedChanges(feature: string, step: string): Promise<boolean>;
|
|
64
|
+
private parseConflictsFromError;
|
|
48
65
|
}
|
|
49
66
|
export declare function createWorktreeService(projectDir: string): WorktreeService;
|
|
@@ -97,8 +97,18 @@ export class WorktreeService {
|
|
|
97
97
|
const worktreeGit = this.getGit(worktreePath);
|
|
98
98
|
try {
|
|
99
99
|
await worktreeGit.raw(["add", "-A"]);
|
|
100
|
-
const
|
|
101
|
-
const
|
|
100
|
+
const status = await worktreeGit.status();
|
|
101
|
+
const hasStaged = status.staged.length > 0;
|
|
102
|
+
let diffContent = "";
|
|
103
|
+
let stat = "";
|
|
104
|
+
if (hasStaged) {
|
|
105
|
+
diffContent = await worktreeGit.diff(["--cached"]);
|
|
106
|
+
stat = diffContent ? await worktreeGit.diff(["--cached", "--stat"]) : "";
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
diffContent = await worktreeGit.diff([`${base}..HEAD`]).catch(() => "");
|
|
110
|
+
stat = diffContent ? await worktreeGit.diff([`${base}..HEAD`, "--stat"]) : "";
|
|
111
|
+
}
|
|
102
112
|
const statLines = stat.split("\n").filter((l) => l.trim());
|
|
103
113
|
const filesChanged = statLines
|
|
104
114
|
.slice(0, -1)
|
|
@@ -347,6 +357,138 @@ export class WorktreeService {
|
|
|
347
357
|
return conflicts;
|
|
348
358
|
}
|
|
349
359
|
}
|
|
360
|
+
async commitChanges(feature, step, message) {
|
|
361
|
+
const worktreePath = this.getWorktreePath(feature, step);
|
|
362
|
+
try {
|
|
363
|
+
await fs.access(worktreePath);
|
|
364
|
+
}
|
|
365
|
+
catch {
|
|
366
|
+
return { committed: false, sha: "", message: "Worktree not found" };
|
|
367
|
+
}
|
|
368
|
+
const worktreeGit = this.getGit(worktreePath);
|
|
369
|
+
try {
|
|
370
|
+
await worktreeGit.add("-A");
|
|
371
|
+
const status = await worktreeGit.status();
|
|
372
|
+
const hasChanges = status.staged.length > 0 || status.modified.length > 0 || status.not_added.length > 0;
|
|
373
|
+
if (!hasChanges) {
|
|
374
|
+
const currentSha = (await worktreeGit.revparse(["HEAD"])).trim();
|
|
375
|
+
return { committed: false, sha: currentSha, message: "No changes to commit" };
|
|
376
|
+
}
|
|
377
|
+
const commitMessage = message || `hive(${step}): task changes`;
|
|
378
|
+
const result = await worktreeGit.commit(commitMessage, ["--allow-empty-message"]);
|
|
379
|
+
return {
|
|
380
|
+
committed: true,
|
|
381
|
+
sha: result.commit,
|
|
382
|
+
message: commitMessage,
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
catch (error) {
|
|
386
|
+
const err = error;
|
|
387
|
+
const currentSha = (await worktreeGit.revparse(["HEAD"]).catch(() => "")).trim();
|
|
388
|
+
return {
|
|
389
|
+
committed: false,
|
|
390
|
+
sha: currentSha,
|
|
391
|
+
message: err.message || "Commit failed",
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
async merge(feature, step, strategy = 'merge') {
|
|
396
|
+
const branchName = this.getBranchName(feature, step);
|
|
397
|
+
const git = this.getGit();
|
|
398
|
+
try {
|
|
399
|
+
const branches = await git.branch();
|
|
400
|
+
if (!branches.all.includes(branchName)) {
|
|
401
|
+
return { success: false, merged: false, error: `Branch ${branchName} not found` };
|
|
402
|
+
}
|
|
403
|
+
const currentBranch = branches.current;
|
|
404
|
+
const diffStat = await git.diff([`${currentBranch}...${branchName}`, "--stat"]);
|
|
405
|
+
const filesChanged = diffStat
|
|
406
|
+
.split("\n")
|
|
407
|
+
.filter(l => l.trim() && l.includes("|"))
|
|
408
|
+
.map(l => l.split("|")[0].trim());
|
|
409
|
+
if (strategy === 'squash') {
|
|
410
|
+
await git.raw(["merge", "--squash", branchName]);
|
|
411
|
+
const result = await git.commit(`hive: merge ${step} (squashed)`);
|
|
412
|
+
return {
|
|
413
|
+
success: true,
|
|
414
|
+
merged: true,
|
|
415
|
+
sha: result.commit,
|
|
416
|
+
filesChanged,
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
else if (strategy === 'rebase') {
|
|
420
|
+
const commits = await git.log([`${currentBranch}..${branchName}`]);
|
|
421
|
+
const commitsToApply = [...commits.all].reverse();
|
|
422
|
+
for (const commit of commitsToApply) {
|
|
423
|
+
await git.raw(["cherry-pick", commit.hash]);
|
|
424
|
+
}
|
|
425
|
+
const head = (await git.revparse(["HEAD"])).trim();
|
|
426
|
+
return {
|
|
427
|
+
success: true,
|
|
428
|
+
merged: true,
|
|
429
|
+
sha: head,
|
|
430
|
+
filesChanged,
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
else {
|
|
434
|
+
const result = await git.merge([branchName, "--no-ff", "-m", `hive: merge ${step}`]);
|
|
435
|
+
const head = (await git.revparse(["HEAD"])).trim();
|
|
436
|
+
return {
|
|
437
|
+
success: true,
|
|
438
|
+
merged: !result.failed,
|
|
439
|
+
sha: head,
|
|
440
|
+
filesChanged,
|
|
441
|
+
conflicts: result.conflicts?.map(c => c.file || String(c)) || [],
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
catch (error) {
|
|
446
|
+
const err = error;
|
|
447
|
+
if (err.message?.includes("CONFLICT") || err.message?.includes("conflict")) {
|
|
448
|
+
await git.raw(["merge", "--abort"]).catch(() => { });
|
|
449
|
+
await git.raw(["rebase", "--abort"]).catch(() => { });
|
|
450
|
+
await git.raw(["cherry-pick", "--abort"]).catch(() => { });
|
|
451
|
+
return {
|
|
452
|
+
success: false,
|
|
453
|
+
merged: false,
|
|
454
|
+
error: "Merge conflicts detected",
|
|
455
|
+
conflicts: this.parseConflictsFromError(err.message || ""),
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
return {
|
|
459
|
+
success: false,
|
|
460
|
+
merged: false,
|
|
461
|
+
error: err.message || "Merge failed",
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
async hasUncommittedChanges(feature, step) {
|
|
466
|
+
const worktreePath = this.getWorktreePath(feature, step);
|
|
467
|
+
try {
|
|
468
|
+
const worktreeGit = this.getGit(worktreePath);
|
|
469
|
+
const status = await worktreeGit.status();
|
|
470
|
+
return status.modified.length > 0 ||
|
|
471
|
+
status.not_added.length > 0 ||
|
|
472
|
+
status.staged.length > 0 ||
|
|
473
|
+
status.deleted.length > 0 ||
|
|
474
|
+
status.created.length > 0;
|
|
475
|
+
}
|
|
476
|
+
catch {
|
|
477
|
+
return false;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
parseConflictsFromError(errorMessage) {
|
|
481
|
+
const conflicts = [];
|
|
482
|
+
const lines = errorMessage.split("\n");
|
|
483
|
+
for (const line of lines) {
|
|
484
|
+
if (line.includes("CONFLICT") && line.includes("Merge conflict in")) {
|
|
485
|
+
const match = line.match(/Merge conflict in (.+)/);
|
|
486
|
+
if (match)
|
|
487
|
+
conflicts.push(match[1]);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
return conflicts;
|
|
491
|
+
}
|
|
350
492
|
}
|
|
351
493
|
export function createWorktreeService(projectDir) {
|
|
352
494
|
return new WorktreeService({
|
|
@@ -87,7 +87,7 @@ describe("WorktreeService", () => {
|
|
|
87
87
|
expect(diff.diffContent).toBe("");
|
|
88
88
|
expect(diff.filesChanged).toEqual([]);
|
|
89
89
|
});
|
|
90
|
-
it("returns diff when files changed", async () => {
|
|
90
|
+
it("returns diff when files changed and committed", async () => {
|
|
91
91
|
const worktree = await service.create("my-feature", "01-task");
|
|
92
92
|
fs.writeFileSync(path.join(worktree.path, "new-file.txt"), "content");
|
|
93
93
|
const { execSync } = await import("child_process");
|
|
@@ -97,6 +97,74 @@ describe("WorktreeService", () => {
|
|
|
97
97
|
expect(diff.hasDiff).toBe(true);
|
|
98
98
|
expect(diff.filesChanged).toContain("new-file.txt");
|
|
99
99
|
});
|
|
100
|
+
it("returns diff for uncommitted staged changes", async () => {
|
|
101
|
+
const worktree = await service.create("my-feature", "01-task");
|
|
102
|
+
fs.writeFileSync(path.join(worktree.path, "uncommitted.txt"), "staged content");
|
|
103
|
+
const diff = await service.getDiff("my-feature", "01-task");
|
|
104
|
+
expect(diff.hasDiff).toBe(true);
|
|
105
|
+
expect(diff.filesChanged).toContain("uncommitted.txt");
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
describe("commitChanges", () => {
|
|
109
|
+
it("commits all staged changes", async () => {
|
|
110
|
+
const worktree = await service.create("my-feature", "01-task");
|
|
111
|
+
fs.writeFileSync(path.join(worktree.path, "file.txt"), "content");
|
|
112
|
+
const result = await service.commitChanges("my-feature", "01-task", "test commit");
|
|
113
|
+
expect(result.committed).toBe(true);
|
|
114
|
+
expect(result.sha).toBeTruthy();
|
|
115
|
+
});
|
|
116
|
+
it("returns committed=false when no changes", async () => {
|
|
117
|
+
await service.create("my-feature", "01-task");
|
|
118
|
+
const result = await service.commitChanges("my-feature", "01-task");
|
|
119
|
+
expect(result.committed).toBe(false);
|
|
120
|
+
expect(result.message).toBe("No changes to commit");
|
|
121
|
+
});
|
|
122
|
+
it("returns error when worktree not found", async () => {
|
|
123
|
+
const result = await service.commitChanges("nope", "nope");
|
|
124
|
+
expect(result.committed).toBe(false);
|
|
125
|
+
expect(result.message).toBe("Worktree not found");
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
describe("hasUncommittedChanges", () => {
|
|
129
|
+
it("returns false when no changes", async () => {
|
|
130
|
+
await service.create("my-feature", "01-task");
|
|
131
|
+
const result = await service.hasUncommittedChanges("my-feature", "01-task");
|
|
132
|
+
expect(result).toBe(false);
|
|
133
|
+
});
|
|
134
|
+
it("returns true when files modified", async () => {
|
|
135
|
+
const worktree = await service.create("my-feature", "01-task");
|
|
136
|
+
fs.writeFileSync(path.join(worktree.path, "new.txt"), "content");
|
|
137
|
+
const result = await service.hasUncommittedChanges("my-feature", "01-task");
|
|
138
|
+
expect(result).toBe(true);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
describe("merge", () => {
|
|
142
|
+
it("merges task branch into main", async () => {
|
|
143
|
+
const worktree = await service.create("my-feature", "01-task");
|
|
144
|
+
fs.writeFileSync(path.join(worktree.path, "feature.txt"), "feature content");
|
|
145
|
+
const { execSync } = await import("child_process");
|
|
146
|
+
execSync("git add . && git commit -m 'feature'", { cwd: worktree.path });
|
|
147
|
+
const result = await service.merge("my-feature", "01-task");
|
|
148
|
+
expect(result.success).toBe(true);
|
|
149
|
+
expect(result.merged).toBe(true);
|
|
150
|
+
expect(fs.existsSync(path.join(TEST_ROOT, "feature.txt"))).toBe(true);
|
|
151
|
+
});
|
|
152
|
+
it("returns error for non-existent branch", async () => {
|
|
153
|
+
const result = await service.merge("nope", "nope");
|
|
154
|
+
expect(result.success).toBe(false);
|
|
155
|
+
expect(result.error).toContain("not found");
|
|
156
|
+
});
|
|
157
|
+
it("supports squash strategy", async () => {
|
|
158
|
+
const worktree = await service.create("my-feature", "01-task");
|
|
159
|
+
fs.writeFileSync(path.join(worktree.path, "file1.txt"), "1");
|
|
160
|
+
const { execSync } = await import("child_process");
|
|
161
|
+
execSync("git add . && git commit -m 'commit 1'", { cwd: worktree.path });
|
|
162
|
+
fs.writeFileSync(path.join(worktree.path, "file2.txt"), "2");
|
|
163
|
+
execSync("git add . && git commit -m 'commit 2'", { cwd: worktree.path });
|
|
164
|
+
const result = await service.merge("my-feature", "01-task", "squash");
|
|
165
|
+
expect(result.success).toBe(true);
|
|
166
|
+
expect(result.merged).toBe(true);
|
|
167
|
+
});
|
|
100
168
|
});
|
|
101
169
|
describe("cleanup", () => {
|
|
102
170
|
it("removes invalid worktrees for a feature", async () => {
|
package/dist/types.d.ts
CHANGED
|
@@ -10,6 +10,22 @@ export interface FeatureJson {
|
|
|
10
10
|
}
|
|
11
11
|
export type TaskStatusType = 'pending' | 'in_progress' | 'done' | 'cancelled';
|
|
12
12
|
export type TaskOrigin = 'plan' | 'manual';
|
|
13
|
+
export type SubtaskType = 'test' | 'implement' | 'review' | 'verify' | 'research' | 'debug' | 'custom';
|
|
14
|
+
export interface Subtask {
|
|
15
|
+
id: string;
|
|
16
|
+
name: string;
|
|
17
|
+
folder: string;
|
|
18
|
+
status: TaskStatusType;
|
|
19
|
+
type?: SubtaskType;
|
|
20
|
+
createdAt?: string;
|
|
21
|
+
completedAt?: string;
|
|
22
|
+
}
|
|
23
|
+
export interface SubtaskStatus {
|
|
24
|
+
status: TaskStatusType;
|
|
25
|
+
type?: SubtaskType;
|
|
26
|
+
createdAt: string;
|
|
27
|
+
completedAt?: string;
|
|
28
|
+
}
|
|
13
29
|
export interface TaskStatus {
|
|
14
30
|
status: TaskStatusType;
|
|
15
31
|
origin: TaskOrigin;
|
|
@@ -17,6 +33,7 @@ export interface TaskStatus {
|
|
|
17
33
|
startedAt?: string;
|
|
18
34
|
completedAt?: string;
|
|
19
35
|
baseCommit?: string;
|
|
36
|
+
subtasks?: Subtask[];
|
|
20
37
|
}
|
|
21
38
|
export interface PlanComment {
|
|
22
39
|
id: string;
|
package/dist/utils/paths.d.ts
CHANGED
|
@@ -10,6 +10,11 @@ export declare function getTaskPath(projectRoot: string, featureName: string, ta
|
|
|
10
10
|
export declare function getTaskStatusPath(projectRoot: string, featureName: string, taskFolder: string): string;
|
|
11
11
|
export declare function getTaskReportPath(projectRoot: string, featureName: string, taskFolder: string): string;
|
|
12
12
|
export declare function getTaskSpecPath(projectRoot: string, featureName: string, taskFolder: string): string;
|
|
13
|
+
export declare function getSubtasksPath(projectRoot: string, featureName: string, taskFolder: string): string;
|
|
14
|
+
export declare function getSubtaskPath(projectRoot: string, featureName: string, taskFolder: string, subtaskFolder: string): string;
|
|
15
|
+
export declare function getSubtaskStatusPath(projectRoot: string, featureName: string, taskFolder: string, subtaskFolder: string): string;
|
|
16
|
+
export declare function getSubtaskSpecPath(projectRoot: string, featureName: string, taskFolder: string, subtaskFolder: string): string;
|
|
17
|
+
export declare function getSubtaskReportPath(projectRoot: string, featureName: string, taskFolder: string, subtaskFolder: string): string;
|
|
13
18
|
export declare function ensureDir(dirPath: string): void;
|
|
14
19
|
export declare function fileExists(filePath: string): boolean;
|
|
15
20
|
export declare function readJson<T>(filePath: string): T | null;
|
package/dist/utils/paths.js
CHANGED
|
@@ -45,6 +45,24 @@ export function getTaskReportPath(projectRoot, featureName, taskFolder) {
|
|
|
45
45
|
export function getTaskSpecPath(projectRoot, featureName, taskFolder) {
|
|
46
46
|
return path.join(getTaskPath(projectRoot, featureName, taskFolder), 'spec.md');
|
|
47
47
|
}
|
|
48
|
+
// Subtask paths
|
|
49
|
+
const SUBTASKS_DIR = 'subtasks';
|
|
50
|
+
const SPEC_FILE = 'spec.md';
|
|
51
|
+
export function getSubtasksPath(projectRoot, featureName, taskFolder) {
|
|
52
|
+
return path.join(getTaskPath(projectRoot, featureName, taskFolder), SUBTASKS_DIR);
|
|
53
|
+
}
|
|
54
|
+
export function getSubtaskPath(projectRoot, featureName, taskFolder, subtaskFolder) {
|
|
55
|
+
return path.join(getSubtasksPath(projectRoot, featureName, taskFolder), subtaskFolder);
|
|
56
|
+
}
|
|
57
|
+
export function getSubtaskStatusPath(projectRoot, featureName, taskFolder, subtaskFolder) {
|
|
58
|
+
return path.join(getSubtaskPath(projectRoot, featureName, taskFolder, subtaskFolder), STATUS_FILE);
|
|
59
|
+
}
|
|
60
|
+
export function getSubtaskSpecPath(projectRoot, featureName, taskFolder, subtaskFolder) {
|
|
61
|
+
return path.join(getSubtaskPath(projectRoot, featureName, taskFolder, subtaskFolder), SPEC_FILE);
|
|
62
|
+
}
|
|
63
|
+
export function getSubtaskReportPath(projectRoot, featureName, taskFolder, subtaskFolder) {
|
|
64
|
+
return path.join(getSubtaskPath(projectRoot, featureName, taskFolder, subtaskFolder), REPORT_FILE);
|
|
65
|
+
}
|
|
48
66
|
export function ensureDir(dirPath) {
|
|
49
67
|
if (!fs.existsSync(dirPath)) {
|
|
50
68
|
fs.mkdirSync(dirPath, { recursive: true });
|