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 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 (16 total)
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: apply changes, write report',
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:** ${applyError ? 'completed with errors' : 'success'}`,
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.remove(feature, task);
357
- if (applyError) {
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 diffContent = await worktreeGit.diff([`${base}...HEAD`]);
101
- const stat = await worktreeGit.diff([`${base}...HEAD`, "--stat"]);
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;
@@ -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;
@@ -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 });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-hive",
3
- "version": "0.5.2",
3
+ "version": "0.8.0",
4
4
  "type": "module",
5
5
  "description": "OpenCode plugin for Agent Hive - from vibe coding to hive coding",
6
6
  "license": "MIT WITH Commons-Clause",