opencode-hive 0.5.1 → 0.6.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,7 +12,7 @@ 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 (17 total)
16
16
 
17
17
  | Domain | Tools |
18
18
  |--------|-------|
@@ -20,6 +20,7 @@ Plan-first development: Write plan → User reviews → Approve → Execute task
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
22
  | Exec | hive_exec_start, hive_exec_complete, hive_exec_abort |
23
+ | Merge | hive_merge, hive_worktree_list |
23
24
  | Context | hive_context_write, hive_context_read, hive_context_list |
24
25
  | Session | hive_session_open, hive_session_list |
25
26
 
@@ -30,7 +31,11 @@ Plan-first development: Write plan → User reviews → Approve → Execute task
30
31
  3. User adds comments in VSCode → \`hive_plan_read\` to see them
31
32
  4. Revise plan → User approves
32
33
  5. \`hive_tasks_sync()\` - Generate tasks from plan
33
- 6. \`hive_exec_start(task)\` → work → \`hive_exec_complete(task, summary)\`
34
+ 6. \`hive_exec_start(task)\` → work in worktree → \`hive_exec_complete(task, summary)\`
35
+ 7. \`hive_merge(task)\` - Merge task branch into main (when ready)
36
+
37
+ **Important:** \`hive_exec_complete\` commits changes to task branch but does NOT merge.
38
+ Use \`hive_merge\` to explicitly integrate changes. Worktrees persist until manually removed.
34
39
 
35
40
  ### Plan Format
36
41
 
@@ -299,7 +304,7 @@ const plugin = async (ctx) => {
299
304
  },
300
305
  }),
301
306
  hive_exec_complete: tool({
302
- description: 'Complete task: apply changes, write report',
307
+ description: 'Complete task: commit changes to branch, write report (does NOT merge or cleanup)',
303
308
  args: {
304
309
  task: tool.schema.string().describe('Task folder name'),
305
310
  summary: tool.schema.string().describe('Summary of what was done'),
@@ -314,22 +319,15 @@ const plugin = async (ctx) => {
314
319
  return `Error: Task "${task}" not found`;
315
320
  if (taskInfo.status !== 'in_progress')
316
321
  return "Error: Task not in progress";
322
+ const commitResult = await worktreeService.commitChanges(feature, task, `hive(${task}): ${summary.slice(0, 50)}`);
317
323
  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
324
  const reportLines = [
328
325
  `# Task Report: ${task}`,
329
326
  '',
330
327
  `**Feature:** ${feature}`,
331
328
  `**Completed:** ${new Date().toISOString()}`,
332
- `**Status:** ${applyError ? 'completed with errors' : 'success'}`,
329
+ `**Status:** success`,
330
+ `**Commit:** ${commitResult.sha || 'none'}`,
333
331
  '',
334
332
  '---',
335
333
  '',
@@ -353,11 +351,8 @@ const plugin = async (ctx) => {
353
351
  }
354
352
  taskService.writeReport(feature, task, reportLines.join('\n'));
355
353
  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." : ""}`;
354
+ const worktree = await worktreeService.get(feature, task);
355
+ return `Task "${task}" completed. Changes committed to branch ${worktree?.branch || 'unknown'}.\nUse hive_merge to integrate changes. Worktree preserved at ${worktree?.path || 'unknown'}.`;
361
356
  },
362
357
  }),
363
358
  hive_exec_abort: tool({
@@ -375,6 +370,52 @@ const plugin = async (ctx) => {
375
370
  return `Task "${task}" aborted. Status reset to pending.`;
376
371
  },
377
372
  }),
373
+ hive_merge: tool({
374
+ description: 'Merge completed task branch into current branch (explicit integration)',
375
+ args: {
376
+ task: tool.schema.string().describe('Task folder name to merge'),
377
+ strategy: tool.schema.enum(['merge', 'squash', 'rebase']).optional().describe('Merge strategy (default: merge)'),
378
+ feature: tool.schema.string().optional().describe('Feature name (defaults to active)'),
379
+ },
380
+ async execute({ task, strategy = 'merge', feature: explicitFeature }) {
381
+ const feature = resolveFeature(explicitFeature);
382
+ if (!feature)
383
+ return "Error: No feature specified. Create a feature or provide feature param.";
384
+ const taskInfo = taskService.get(feature, task);
385
+ if (!taskInfo)
386
+ return `Error: Task "${task}" not found`;
387
+ if (taskInfo.status !== 'done')
388
+ return "Error: Task must be completed before merging. Use hive_exec_complete first.";
389
+ const result = await worktreeService.merge(feature, task, strategy);
390
+ if (!result.success) {
391
+ if (result.conflicts && result.conflicts.length > 0) {
392
+ return `Merge failed with conflicts in:\n${result.conflicts.map(f => `- ${f}`).join('\n')}\n\nResolve conflicts manually or try a different strategy.`;
393
+ }
394
+ return `Merge failed: ${result.error}`;
395
+ }
396
+ return `Task "${task}" merged successfully using ${strategy} strategy.\nCommit: ${result.sha}\nFiles changed: ${result.filesChanged?.length || 0}`;
397
+ },
398
+ }),
399
+ hive_worktree_list: tool({
400
+ description: 'List all worktrees for current feature',
401
+ args: {
402
+ feature: tool.schema.string().optional().describe('Feature name (defaults to active)'),
403
+ },
404
+ async execute({ feature: explicitFeature }) {
405
+ const feature = resolveFeature(explicitFeature);
406
+ if (!feature)
407
+ return "Error: No feature specified. Create a feature or provide feature param.";
408
+ const worktrees = await worktreeService.list(feature);
409
+ if (worktrees.length === 0)
410
+ return "No worktrees found for this feature.";
411
+ const lines = ['| Task | Branch | Has Changes |', '|------|--------|-------------|'];
412
+ for (const wt of worktrees) {
413
+ const hasChanges = await worktreeService.hasUncommittedChanges(wt.feature, wt.step);
414
+ lines.push(`| ${wt.step} | ${wt.branch} | ${hasChanges ? 'Yes' : 'No'} |`);
415
+ }
416
+ return lines.join('\n');
417
+ },
418
+ }),
378
419
  // Context Tools
379
420
  hive_context_write: tool({
380
421
  description: 'Write a context file for the feature. Context files store persistent notes, decisions, and reference material.',
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-hive",
3
- "version": "0.5.1",
3
+ "version": "0.6.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",