hungry-ghost-hive 0.43.2 → 0.45.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.
Files changed (201) hide show
  1. package/dist/agents/base-agent.d.ts +1 -0
  2. package/dist/agents/base-agent.d.ts.map +1 -1
  3. package/dist/agents/base-agent.js +4 -0
  4. package/dist/agents/base-agent.js.map +1 -1
  5. package/dist/agents/intermediate.js +2 -2
  6. package/dist/agents/intermediate.js.map +1 -1
  7. package/dist/agents/junior.js +2 -2
  8. package/dist/agents/junior.js.map +1 -1
  9. package/dist/agents/qa.d.ts.map +1 -1
  10. package/dist/agents/qa.js +5 -5
  11. package/dist/agents/qa.js.map +1 -1
  12. package/dist/agents/senior.d.ts.map +1 -1
  13. package/dist/agents/senior.js +5 -5
  14. package/dist/agents/senior.js.map +1 -1
  15. package/dist/agents/tech-lead.d.ts.map +1 -1
  16. package/dist/agents/tech-lead.js +8 -3
  17. package/dist/agents/tech-lead.js.map +1 -1
  18. package/dist/cli/commands/assign.d.ts.map +1 -1
  19. package/dist/cli/commands/assign.js +4 -2
  20. package/dist/cli/commands/assign.js.map +1 -1
  21. package/dist/cli/commands/assign.test.js +5 -0
  22. package/dist/cli/commands/assign.test.js.map +1 -1
  23. package/dist/cli/commands/manager/handoff-recovery.d.ts.map +1 -1
  24. package/dist/cli/commands/manager/handoff-recovery.js +4 -2
  25. package/dist/cli/commands/manager/handoff-recovery.js.map +1 -1
  26. package/dist/cli/commands/manager/index.d.ts.map +1 -1
  27. package/dist/cli/commands/manager/index.js +16 -12
  28. package/dist/cli/commands/manager/index.js.map +1 -1
  29. package/dist/cli/commands/manager/tech-lead-lifecycle.d.ts.map +1 -1
  30. package/dist/cli/commands/manager/tech-lead-lifecycle.js +8 -3
  31. package/dist/cli/commands/manager/tech-lead-lifecycle.js.map +1 -1
  32. package/dist/cli/commands/msg.d.ts.map +1 -1
  33. package/dist/cli/commands/msg.js +8 -7
  34. package/dist/cli/commands/msg.js.map +1 -1
  35. package/dist/cli/commands/my-stories.js +3 -3
  36. package/dist/cli/commands/my-stories.js.map +1 -1
  37. package/dist/cli/commands/nuke.d.ts.map +1 -1
  38. package/dist/cli/commands/nuke.js +18 -7
  39. package/dist/cli/commands/nuke.js.map +1 -1
  40. package/dist/cli/commands/nuke.test.js +24 -0
  41. package/dist/cli/commands/nuke.test.js.map +1 -1
  42. package/dist/cli/commands/pr.js +5 -0
  43. package/dist/cli/commands/pr.js.map +1 -1
  44. package/dist/cli/commands/pr.test.js +43 -1
  45. package/dist/cli/commands/pr.test.js.map +1 -1
  46. package/dist/cli/commands/req.d.ts +1 -1
  47. package/dist/cli/commands/req.d.ts.map +1 -1
  48. package/dist/cli/commands/req.js +9 -6
  49. package/dist/cli/commands/req.js.map +1 -1
  50. package/dist/cli/commands/resume.d.ts.map +1 -1
  51. package/dist/cli/commands/resume.js +4 -1
  52. package/dist/cli/commands/resume.js.map +1 -1
  53. package/dist/cli/commands/stories.js +3 -3
  54. package/dist/cli/commands/stories.js.map +1 -1
  55. package/dist/cli/dashboard/panels/agents.d.ts.map +1 -1
  56. package/dist/cli/dashboard/panels/agents.js +7 -3
  57. package/dist/cli/dashboard/panels/agents.js.map +1 -1
  58. package/dist/cli-runtimes/chrome.d.ts +17 -0
  59. package/dist/cli-runtimes/chrome.d.ts.map +1 -0
  60. package/dist/cli-runtimes/chrome.js +36 -0
  61. package/dist/cli-runtimes/chrome.js.map +1 -0
  62. package/dist/cli-runtimes/claude.d.ts +3 -3
  63. package/dist/cli-runtimes/claude.d.ts.map +1 -1
  64. package/dist/cli-runtimes/claude.js +14 -8
  65. package/dist/cli-runtimes/claude.js.map +1 -1
  66. package/dist/cli-runtimes/codex.d.ts +3 -3
  67. package/dist/cli-runtimes/codex.d.ts.map +1 -1
  68. package/dist/cli-runtimes/codex.js +2 -2
  69. package/dist/cli-runtimes/codex.js.map +1 -1
  70. package/dist/cli-runtimes/gemini.d.ts +3 -3
  71. package/dist/cli-runtimes/gemini.d.ts.map +1 -1
  72. package/dist/cli-runtimes/gemini.js +2 -2
  73. package/dist/cli-runtimes/gemini.js.map +1 -1
  74. package/dist/cli-runtimes/index.d.ts +3 -2
  75. package/dist/cli-runtimes/index.d.ts.map +1 -1
  76. package/dist/cli-runtimes/index.js +1 -0
  77. package/dist/cli-runtimes/index.js.map +1 -1
  78. package/dist/cli-runtimes/index.test.js +133 -1
  79. package/dist/cli-runtimes/index.test.js.map +1 -1
  80. package/dist/cli-runtimes/types.d.ts +9 -2
  81. package/dist/cli-runtimes/types.d.ts.map +1 -1
  82. package/dist/config/schema.d.ts +8 -0
  83. package/dist/config/schema.d.ts.map +1 -1
  84. package/dist/config/schema.js +6 -0
  85. package/dist/config/schema.js.map +1 -1
  86. package/dist/context-files/generator.d.ts +1 -1
  87. package/dist/context-files/generator.d.ts.map +1 -1
  88. package/dist/context-files/generator.js +3 -2
  89. package/dist/context-files/generator.js.map +1 -1
  90. package/dist/context-files/index.test.js +2 -0
  91. package/dist/context-files/index.test.js.map +1 -1
  92. package/dist/db/client.d.ts +1 -0
  93. package/dist/db/client.d.ts.map +1 -1
  94. package/dist/db/client.js +6 -0
  95. package/dist/db/client.js.map +1 -1
  96. package/dist/db/migrations/015-add-story-markdown-path.sql +5 -0
  97. package/dist/db/queries/stories.d.ts +3 -3
  98. package/dist/db/queries/stories.d.ts.map +1 -1
  99. package/dist/db/queries/stories.js +23 -5
  100. package/dist/db/queries/stories.js.map +1 -1
  101. package/dist/db/queries/test-helpers.d.ts.map +1 -1
  102. package/dist/db/queries/test-helpers.js +1 -0
  103. package/dist/db/queries/test-helpers.js.map +1 -1
  104. package/dist/git/worktree.d.ts.map +1 -1
  105. package/dist/git/worktree.js +7 -0
  106. package/dist/git/worktree.js.map +1 -1
  107. package/dist/git/worktree.test.js +30 -0
  108. package/dist/git/worktree.test.js.map +1 -1
  109. package/dist/orchestrator/prompt-templates.d.ts +3 -1
  110. package/dist/orchestrator/prompt-templates.d.ts.map +1 -1
  111. package/dist/orchestrator/prompt-templates.js +16 -8
  112. package/dist/orchestrator/prompt-templates.js.map +1 -1
  113. package/dist/orchestrator/prompt-templates.test.js +4 -0
  114. package/dist/orchestrator/prompt-templates.test.js.map +1 -1
  115. package/dist/orchestrator/scheduler.d.ts.map +1 -1
  116. package/dist/orchestrator/scheduler.js +23 -12
  117. package/dist/orchestrator/scheduler.js.map +1 -1
  118. package/dist/orchestrator/scheduler.test.js +1 -0
  119. package/dist/orchestrator/scheduler.test.js.map +1 -1
  120. package/dist/tmux/manager.d.ts +7 -6
  121. package/dist/tmux/manager.d.ts.map +1 -1
  122. package/dist/tmux/manager.js +29 -13
  123. package/dist/tmux/manager.js.map +1 -1
  124. package/dist/utils/auto-merge.d.ts.map +1 -1
  125. package/dist/utils/auto-merge.js +66 -5
  126. package/dist/utils/auto-merge.js.map +1 -1
  127. package/dist/utils/auto-merge.test.js +62 -0
  128. package/dist/utils/auto-merge.test.js.map +1 -1
  129. package/dist/utils/instance.d.ts +32 -0
  130. package/dist/utils/instance.d.ts.map +1 -0
  131. package/dist/utils/instance.js +82 -0
  132. package/dist/utils/instance.js.map +1 -0
  133. package/dist/utils/instance.test.d.ts +2 -0
  134. package/dist/utils/instance.test.d.ts.map +1 -0
  135. package/dist/utils/instance.test.js +103 -0
  136. package/dist/utils/instance.test.js.map +1 -0
  137. package/dist/utils/paths.d.ts +2 -0
  138. package/dist/utils/paths.d.ts.map +1 -1
  139. package/dist/utils/paths.js +2 -0
  140. package/dist/utils/paths.js.map +1 -1
  141. package/dist/utils/paths.test.js +6 -0
  142. package/dist/utils/paths.test.js.map +1 -1
  143. package/dist/utils/story-markdown.d.ts +16 -0
  144. package/dist/utils/story-markdown.d.ts.map +1 -0
  145. package/dist/utils/story-markdown.js +82 -0
  146. package/dist/utils/story-markdown.js.map +1 -0
  147. package/dist/utils/story-markdown.test.d.ts +2 -0
  148. package/dist/utils/story-markdown.test.d.ts.map +1 -0
  149. package/dist/utils/story-markdown.test.js +143 -0
  150. package/dist/utils/story-markdown.test.js.map +1 -0
  151. package/package.json +1 -1
  152. package/src/agents/base-agent.ts +5 -0
  153. package/src/agents/intermediate.ts +2 -2
  154. package/src/agents/junior.ts +2 -2
  155. package/src/agents/qa.ts +13 -8
  156. package/src/agents/senior.ts +21 -11
  157. package/src/agents/tech-lead.ts +28 -13
  158. package/src/cli/commands/assign.test.ts +5 -0
  159. package/src/cli/commands/assign.ts +4 -2
  160. package/src/cli/commands/manager/handoff-recovery.ts +4 -2
  161. package/src/cli/commands/manager/index.ts +16 -11
  162. package/src/cli/commands/manager/tech-lead-lifecycle.ts +9 -3
  163. package/src/cli/commands/msg.ts +8 -7
  164. package/src/cli/commands/my-stories.ts +22 -13
  165. package/src/cli/commands/nuke.test.ts +31 -0
  166. package/src/cli/commands/nuke.ts +18 -7
  167. package/src/cli/commands/pr.test.ts +77 -1
  168. package/src/cli/commands/pr.ts +5 -0
  169. package/src/cli/commands/req.ts +13 -6
  170. package/src/cli/commands/resume.ts +4 -1
  171. package/src/cli/commands/stories.ts +22 -13
  172. package/src/cli/dashboard/panels/agents.ts +7 -3
  173. package/src/cli-runtimes/chrome.ts +43 -0
  174. package/src/cli-runtimes/claude.ts +26 -9
  175. package/src/cli-runtimes/codex.ts +12 -3
  176. package/src/cli-runtimes/gemini.ts +12 -3
  177. package/src/cli-runtimes/index.test.ts +158 -0
  178. package/src/cli-runtimes/index.ts +3 -2
  179. package/src/cli-runtimes/types.ts +19 -2
  180. package/src/config/schema.ts +6 -0
  181. package/src/context-files/generator.ts +3 -2
  182. package/src/context-files/index.test.ts +2 -0
  183. package/src/db/client.ts +7 -0
  184. package/src/db/migrations/015-add-story-markdown-path.sql +5 -0
  185. package/src/db/queries/stories.ts +29 -5
  186. package/src/db/queries/test-helpers.ts +1 -0
  187. package/src/git/worktree.test.ts +43 -0
  188. package/src/git/worktree.ts +10 -0
  189. package/src/orchestrator/prompt-templates.test.ts +4 -0
  190. package/src/orchestrator/prompt-templates.ts +20 -8
  191. package/src/orchestrator/scheduler.test.ts +1 -0
  192. package/src/orchestrator/scheduler.ts +33 -12
  193. package/src/tmux/manager.ts +42 -13
  194. package/src/utils/auto-merge.test.ts +81 -0
  195. package/src/utils/auto-merge.ts +78 -5
  196. package/src/utils/instance.test.ts +129 -0
  197. package/src/utils/instance.ts +95 -0
  198. package/src/utils/paths.test.ts +8 -0
  199. package/src/utils/paths.ts +3 -0
  200. package/src/utils/story-markdown.test.ts +176 -0
  201. package/src/utils/story-markdown.ts +94 -0
@@ -21,6 +21,11 @@ vi.mock('./paths.js', () => ({
21
21
  getHivePaths: vi.fn(() => ({ hiveDir: '/mock/hive' })),
22
22
  }));
23
23
 
24
+ vi.mock('../connectors/project-management/operations.js', () => ({
25
+ postLifecycleComment: vi.fn().mockResolvedValue(undefined),
26
+ syncStatusForStory: vi.fn(),
27
+ }));
28
+
24
29
  import { loadConfig } from '../config/loader.js';
25
30
  import { autoMergeApprovedPRs } from './auto-merge.js';
26
31
 
@@ -266,6 +271,82 @@ describe('auto-merge functionality', () => {
266
271
  expect(mockLoadConfig).toHaveBeenCalledWith('/mock/hive');
267
272
  });
268
273
 
274
+ it('should keep PR as queued when auto-merge is pending (PR still open after gh pr merge --auto)', async () => {
275
+ const pr = createPullRequest(db, {
276
+ storyId,
277
+ teamId,
278
+ branchName: 'feature/auto-merge-pending',
279
+ githubPrNumber: 456,
280
+ });
281
+ updatePullRequest(db, pr.id, { status: 'approved' });
282
+
283
+ mockLoadConfig.mockReturnValue({
284
+ integrations: {
285
+ autonomy: { level: 'full' },
286
+ source_control: { provider: 'github' },
287
+ project_management: { provider: 'none' },
288
+ },
289
+ } as any);
290
+
291
+ // Mock execSync: first call returns OPEN+MERGEABLE, merge command succeeds,
292
+ // second call (post-merge check) returns OPEN (auto-merge pending)
293
+ const mockExecSync = vi.fn();
294
+ mockExecSync
295
+ .mockReturnValueOnce(
296
+ JSON.stringify({ state: 'OPEN', mergeable: 'MERGEABLE', mergeStateStatus: 'CLEAN' })
297
+ )
298
+ .mockReturnValueOnce(undefined) // gh pr merge --auto
299
+ .mockReturnValueOnce(
300
+ JSON.stringify({ state: 'OPEN', mergeable: 'MERGEABLE', mergeStateStatus: 'BLOCKED' })
301
+ );
302
+
303
+ vi.doMock('child_process', () => ({ execSync: mockExecSync }));
304
+
305
+ const dbClient = { db, save: vi.fn(), close: vi.fn(), runMigrations: vi.fn() };
306
+ const result = await autoMergeApprovedPRs('/mock/root', dbClient);
307
+
308
+ // Should return 0 because the PR was not actually merged yet
309
+ expect(result).toBe(0);
310
+ // PR should remain 'queued' (not rolled back to 'approved' or advanced to 'merged')
311
+ expect(getPullRequestById(db, pr.id)?.status).toBe('queued');
312
+ });
313
+
314
+ it('should reset stale branch PR to approved after updating behind branch', async () => {
315
+ const pr = createPullRequest(db, {
316
+ storyId,
317
+ teamId,
318
+ branchName: 'feature/stale-branch',
319
+ githubPrNumber: 789,
320
+ });
321
+ updatePullRequest(db, pr.id, { status: 'approved' });
322
+
323
+ mockLoadConfig.mockReturnValue({
324
+ integrations: {
325
+ autonomy: { level: 'full' },
326
+ source_control: { provider: 'github' },
327
+ project_management: { provider: 'none' },
328
+ },
329
+ } as any);
330
+
331
+ // Mock execSync: PR state shows BEHIND, then gh pr update-branch succeeds
332
+ const mockExecSync = vi.fn();
333
+ mockExecSync
334
+ .mockReturnValueOnce(
335
+ JSON.stringify({ state: 'OPEN', mergeable: 'MERGEABLE', mergeStateStatus: 'BEHIND' })
336
+ )
337
+ .mockReturnValueOnce(undefined); // gh pr update-branch
338
+
339
+ vi.doMock('child_process', () => ({ execSync: mockExecSync }));
340
+
341
+ const dbClient = { db, save: vi.fn(), close: vi.fn(), runMigrations: vi.fn() };
342
+ const result = await autoMergeApprovedPRs('/mock/root', dbClient);
343
+
344
+ // Should return 0 because no merge happened yet
345
+ expect(result).toBe(0);
346
+ // PR should be reset to 'approved' to be retried on next cycle
347
+ expect(getPullRequestById(db, pr.id)?.status).toBe('approved');
348
+ });
349
+
269
350
  it('should skip approved PRs marked for manual merge', async () => {
270
351
  const pr = createPullRequest(db, {
271
352
  storyId,
@@ -29,6 +29,7 @@ const PR_MERGE_TIMEOUT_MS = 60000;
29
29
  interface GitHubPRState {
30
30
  state: string;
31
31
  mergeable: string;
32
+ mergeStateStatus: string;
32
33
  }
33
34
 
34
35
  /**
@@ -149,6 +150,8 @@ export async function autoMergeApprovedPRs(
149
150
  claimed: ClaimedPR;
150
151
  outcome:
151
152
  | { type: 'merged' }
153
+ | { type: 'auto_merge_pending' }
154
+ | { type: 'branch_updated' }
152
155
  | { type: 'already_closed'; prState: GitHubPRState }
153
156
  | { type: 'conflicts' }
154
157
  | { type: 'unknown_state' }
@@ -163,10 +166,9 @@ export async function autoMergeApprovedPRs(
163
166
  try {
164
167
  // Check PR state
165
168
  let prState: GitHubPRState;
166
- let mergeableStatus: boolean;
167
169
  try {
168
170
  const prViewOutput = execSync(
169
- `gh pr view ${pr.github_pr_number} --json state,mergeable${repoFlag}`,
171
+ `gh pr view ${pr.github_pr_number} --json state,mergeable,mergeStateStatus${repoFlag}`,
170
172
  {
171
173
  stdio: 'pipe',
172
174
  cwd: repoCwd,
@@ -175,7 +177,6 @@ export async function autoMergeApprovedPRs(
175
177
  }
176
178
  );
177
179
  prState = JSON.parse(prViewOutput);
178
- mergeableStatus = prState.mergeable === 'MERGEABLE';
179
180
  } catch {
180
181
  results.push({ claimed, outcome: { type: 'unknown_state' } });
181
182
  continue;
@@ -186,7 +187,22 @@ export async function autoMergeApprovedPRs(
186
187
  continue;
187
188
  }
188
189
 
189
- if (!mergeableStatus) {
190
+ // Update stale branches that are behind the base branch
191
+ if (prState.mergeStateStatus === 'BEHIND') {
192
+ try {
193
+ execSync(`gh pr update-branch ${pr.github_pr_number}${repoFlag}`, {
194
+ stdio: 'pipe',
195
+ cwd: repoCwd,
196
+ timeout: PR_MERGE_TIMEOUT_MS,
197
+ });
198
+ results.push({ claimed, outcome: { type: 'branch_updated' } });
199
+ } catch {
200
+ results.push({ claimed, outcome: { type: 'unknown_state' } });
201
+ }
202
+ continue;
203
+ }
204
+
205
+ if (prState.mergeable !== 'MERGEABLE') {
190
206
  results.push({ claimed, outcome: { type: 'conflicts' } });
191
207
  continue;
192
208
  }
@@ -198,7 +214,32 @@ export async function autoMergeApprovedPRs(
198
214
  cwd: repoCwd,
199
215
  timeout: PR_MERGE_TIMEOUT_MS,
200
216
  });
201
- results.push({ claimed, outcome: { type: 'merged' } });
217
+
218
+ // Verify actual merge state: --auto may queue the merge rather than merge immediately
219
+ let postMergeState: GitHubPRState;
220
+ try {
221
+ const postMergeOutput = execSync(
222
+ `gh pr view ${pr.github_pr_number} --json state,mergeable,mergeStateStatus${repoFlag}`,
223
+ {
224
+ stdio: 'pipe',
225
+ cwd: repoCwd,
226
+ encoding: 'utf-8',
227
+ timeout: PR_STATE_CHECK_TIMEOUT_MS,
228
+ }
229
+ );
230
+ postMergeState = JSON.parse(postMergeOutput);
231
+ } catch {
232
+ // If we can't re-check, assume it merged (command succeeded)
233
+ results.push({ claimed, outcome: { type: 'merged' } });
234
+ continue;
235
+ }
236
+
237
+ if (postMergeState.state === 'MERGED') {
238
+ results.push({ claimed, outcome: { type: 'merged' } });
239
+ } else {
240
+ // PR is OPEN with auto-merge enabled — GitHub will merge when CI passes
241
+ results.push({ claimed, outcome: { type: 'auto_merge_pending' } });
242
+ }
202
243
  } catch (mergeErr) {
203
244
  results.push({
204
245
  claimed,
@@ -340,6 +381,38 @@ export async function autoMergeApprovedPRs(
340
381
  break;
341
382
  }
342
383
 
384
+ case 'auto_merge_pending': {
385
+ createLog(phaseDb.db, {
386
+ agentId: 'manager',
387
+ storyId: pr.story_id || undefined,
388
+ eventType: 'PR_MERGE_SKIPPED',
389
+ status: 'info',
390
+ message: `PR #${pr.github_pr_number} is queued for auto-merge, waiting for CI checks to complete`,
391
+ metadata: { pr_id: pr.id },
392
+ });
393
+ break;
394
+ }
395
+
396
+ case 'branch_updated': {
397
+ // Reset to 'approved' so the PR is retried on the next cycle once CI passes
398
+ await withTransaction(
399
+ phaseDb.db,
400
+ () => {
401
+ updatePullRequest(phaseDb.db, pr.id, { status: 'approved' });
402
+ createLog(phaseDb.db, {
403
+ agentId: 'manager',
404
+ storyId: pr.story_id || undefined,
405
+ eventType: 'PR_MERGE_SKIPPED',
406
+ status: 'info',
407
+ message: `Updated stale branch for PR #${pr.github_pr_number} (was behind base branch), will retry merge`,
408
+ metadata: { pr_id: pr.id },
409
+ });
410
+ },
411
+ () => phaseDb.save()
412
+ );
413
+ break;
414
+ }
415
+
343
416
  case 'merge_failed': {
344
417
  await withTransaction(
345
418
  phaseDb.db,
@@ -0,0 +1,129 @@
1
+ // Licensed under the Hungry Ghost Hive License. See LICENSE.
2
+
3
+ import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs';
4
+ import { tmpdir } from 'os';
5
+ import { join } from 'path';
6
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
7
+ import {
8
+ buildInstanceSessionName,
9
+ getInstanceId,
10
+ getInstancePrefix,
11
+ getManagerLockPath,
12
+ getManagerSessionName,
13
+ getTechLeadSessionName,
14
+ } from './instance.js';
15
+
16
+ describe('instance', () => {
17
+ let testHiveDir: string;
18
+
19
+ beforeEach(() => {
20
+ testHiveDir = join(tmpdir(), `hive-test-instance-${Date.now()}`);
21
+ mkdirSync(testHiveDir, { recursive: true });
22
+ });
23
+
24
+ afterEach(() => {
25
+ rmSync(testHiveDir, { recursive: true, force: true });
26
+ });
27
+
28
+ describe('getInstanceId', () => {
29
+ it('should return null if hive directory does not exist', () => {
30
+ const result = getInstanceId('/nonexistent/path/.hive');
31
+ expect(result).toBeNull();
32
+ });
33
+
34
+ it('should create and persist an instance ID', () => {
35
+ const id = getInstanceId(testHiveDir);
36
+ expect(id).toBeTruthy();
37
+ expect(typeof id).toBe('string');
38
+ expect(id!.length).toBe(6);
39
+
40
+ // Should be persisted
41
+ const fileContent = readFileSync(join(testHiveDir, 'instance.id'), 'utf-8').trim();
42
+ expect(fileContent).toBe(id);
43
+ });
44
+
45
+ it('should return the same ID on subsequent calls', () => {
46
+ const id1 = getInstanceId(testHiveDir);
47
+ const id2 = getInstanceId(testHiveDir);
48
+ expect(id1).toBe(id2);
49
+ });
50
+
51
+ it('should read existing instance ID from file', () => {
52
+ writeFileSync(join(testHiveDir, 'instance.id'), 'testid', 'utf-8');
53
+ const id = getInstanceId(testHiveDir);
54
+ expect(id).toBe('testid');
55
+ });
56
+ });
57
+
58
+ describe('getInstancePrefix', () => {
59
+ it('should return hive-<instanceId> when hive dir exists', () => {
60
+ writeFileSync(join(testHiveDir, 'instance.id'), 'abc123', 'utf-8');
61
+ const prefix = getInstancePrefix(testHiveDir);
62
+ expect(prefix).toBe('hive-abc123');
63
+ });
64
+
65
+ it('should fall back to hive when hive dir does not exist', () => {
66
+ const prefix = getInstancePrefix('/nonexistent/path/.hive');
67
+ expect(prefix).toBe('hive');
68
+ });
69
+ });
70
+
71
+ describe('buildInstanceSessionName', () => {
72
+ it('should build instance-scoped session name', () => {
73
+ writeFileSync(join(testHiveDir, 'instance.id'), 'abc123', 'utf-8');
74
+ const name = buildInstanceSessionName(testHiveDir, 'senior', 'my-team');
75
+ expect(name).toBe('hive-abc123-senior-my-team');
76
+ });
77
+
78
+ it('should include index when > 1', () => {
79
+ writeFileSync(join(testHiveDir, 'instance.id'), 'abc123', 'utf-8');
80
+ const name = buildInstanceSessionName(testHiveDir, 'senior', 'my-team', 3);
81
+ expect(name).toBe('hive-abc123-senior-my-team-3');
82
+ });
83
+
84
+ it('should omit index when 1', () => {
85
+ writeFileSync(join(testHiveDir, 'instance.id'), 'abc123', 'utf-8');
86
+ const name = buildInstanceSessionName(testHiveDir, 'senior', 'my-team', 1);
87
+ expect(name).toBe('hive-abc123-senior-my-team');
88
+ });
89
+
90
+ it('should fall back to old format when no hive dir', () => {
91
+ const name = buildInstanceSessionName('/nonexistent', 'senior', 'my-team');
92
+ expect(name).toBe('hive-senior-my-team');
93
+ });
94
+ });
95
+
96
+ describe('getTechLeadSessionName', () => {
97
+ it('should return instance-scoped tech lead session name', () => {
98
+ writeFileSync(join(testHiveDir, 'instance.id'), 'xyz789', 'utf-8');
99
+ const name = getTechLeadSessionName(testHiveDir);
100
+ expect(name).toBe('hive-xyz789-tech-lead');
101
+ });
102
+
103
+ it('should fall back to hive-tech-lead when no instance', () => {
104
+ const name = getTechLeadSessionName('/nonexistent');
105
+ expect(name).toBe('hive-tech-lead');
106
+ });
107
+ });
108
+
109
+ describe('getManagerSessionName', () => {
110
+ it('should return instance-scoped manager session name', () => {
111
+ writeFileSync(join(testHiveDir, 'instance.id'), 'xyz789', 'utf-8');
112
+ const name = getManagerSessionName(testHiveDir);
113
+ expect(name).toBe('hive-xyz789-manager');
114
+ });
115
+ });
116
+
117
+ describe('getManagerLockPath', () => {
118
+ it('should return instance-scoped lock path', () => {
119
+ writeFileSync(join(testHiveDir, 'instance.id'), 'xyz789', 'utf-8');
120
+ const lockPath = getManagerLockPath(testHiveDir);
121
+ expect(lockPath).toBe(join(testHiveDir, 'manager-xyz789.lock'));
122
+ });
123
+
124
+ it('should fall back to default lock path when no instance', () => {
125
+ const lockPath = getManagerLockPath('/nonexistent');
126
+ expect(lockPath).toBe(join('/nonexistent', 'manager.lock'));
127
+ });
128
+ });
129
+ });
@@ -0,0 +1,95 @@
1
+ // Licensed under the Hungry Ghost Hive License. See LICENSE.
2
+
3
+ import { existsSync, readFileSync, writeFileSync } from 'fs';
4
+ import { nanoid } from 'nanoid';
5
+ import { join } from 'path';
6
+
7
+ const INSTANCE_ID_FILE = 'instance.id';
8
+
9
+ /**
10
+ * Get or create the instance ID for a hive workspace.
11
+ * The ID is stored in .hive/instance.id.
12
+ * Returns null if the hive directory does not exist.
13
+ * Only creates the file if the .hive directory already exists
14
+ * (i.e., this is a real workspace, not a test environment).
15
+ */
16
+ export function getInstanceId(hiveDir: string): string | null {
17
+ if (!existsSync(hiveDir)) {
18
+ return null;
19
+ }
20
+
21
+ const instancePath = join(hiveDir, INSTANCE_ID_FILE);
22
+
23
+ if (existsSync(instancePath)) {
24
+ const id = readFileSync(instancePath, 'utf-8').trim();
25
+ if (id) return id;
26
+ }
27
+
28
+ // Generate a short unique ID for this workspace instance
29
+ const id = nanoid(6);
30
+ try {
31
+ writeFileSync(instancePath, id, 'utf-8');
32
+ } catch {
33
+ // If write fails (e.g., read-only filesystem), use in-memory ID
34
+ }
35
+ return id;
36
+ }
37
+
38
+ /**
39
+ * Get the instance-scoped tmux session prefix.
40
+ * Falls back to 'hive' if no instance ID is available.
41
+ */
42
+ export function getInstancePrefix(hiveDir: string): string {
43
+ const instanceId = getInstanceId(hiveDir);
44
+ if (instanceId) {
45
+ return `hive-${instanceId}`;
46
+ }
47
+ return 'hive';
48
+ }
49
+
50
+ /**
51
+ * Build an instance-scoped session name.
52
+ * Pattern: hive-<instanceId>-<agentType>[-<teamName>][-<index>]
53
+ * Falls back to hive-<agentType>[-<teamName>][-<index>] if no instance ID.
54
+ */
55
+ export function buildInstanceSessionName(
56
+ hiveDir: string,
57
+ agentType: string,
58
+ teamName?: string,
59
+ index?: number
60
+ ): string {
61
+ const prefix = getInstancePrefix(hiveDir);
62
+ let name = `${prefix}-${agentType}`;
63
+ if (teamName) {
64
+ name += `-${teamName}`;
65
+ }
66
+ if (index !== undefined && index > 1) {
67
+ name += `-${index}`;
68
+ }
69
+ return name;
70
+ }
71
+
72
+ /**
73
+ * Build the instance-scoped tech lead session name.
74
+ */
75
+ export function getTechLeadSessionName(hiveDir: string): string {
76
+ return buildInstanceSessionName(hiveDir, 'tech-lead');
77
+ }
78
+
79
+ /**
80
+ * Build the instance-scoped manager session name.
81
+ */
82
+ export function getManagerSessionName(hiveDir: string): string {
83
+ return buildInstanceSessionName(hiveDir, 'manager');
84
+ }
85
+
86
+ /**
87
+ * Build the instance-scoped manager lock path.
88
+ */
89
+ export function getManagerLockPath(hiveDir: string): string {
90
+ const instanceId = getInstanceId(hiveDir);
91
+ if (instanceId) {
92
+ return join(hiveDir, `manager-${instanceId}.lock`);
93
+ }
94
+ return join(hiveDir, 'manager.lock');
95
+ }
@@ -102,6 +102,7 @@ describe('paths utility', () => {
102
102
  agentsDir: join(rootDir, '.hive', 'agents'),
103
103
  logsDir: join(rootDir, '.hive', 'logs'),
104
104
  reposDir: join(rootDir, 'repos'),
105
+ storiesDir: join(rootDir, '.hive', 'stories'),
105
106
  });
106
107
  });
107
108
 
@@ -128,6 +129,7 @@ describe('paths utility', () => {
128
129
  expect(result.configPath).toContain(result.hiveDir);
129
130
  expect(result.agentsDir).toContain(result.hiveDir);
130
131
  expect(result.logsDir).toContain(result.hiveDir);
132
+ expect(result.storiesDir).toContain(result.hiveDir);
131
133
  expect(result.reposDir).not.toContain(result.hiveDir);
132
134
  });
133
135
 
@@ -154,6 +156,12 @@ describe('paths utility', () => {
154
156
 
155
157
  expect(result.logsDir).toContain(paths.LOGS_DIR_NAME);
156
158
  });
159
+
160
+ it('should use STORIES_DIR_NAME constant for stories directory', () => {
161
+ const result = paths.getHivePaths(rootDir);
162
+
163
+ expect(result.storiesDir).toBe(join(rootDir, '.hive', paths.STORIES_DIR_NAME));
164
+ });
157
165
  });
158
166
 
159
167
  describe('isHiveWorkspace', () => {
@@ -7,6 +7,7 @@ export const HIVE_DIR_NAME = '.hive';
7
7
  export const REPOS_DIR_NAME = 'repos';
8
8
  export const AGENTS_DIR_NAME = 'agents';
9
9
  export const LOGS_DIR_NAME = 'logs';
10
+ export const STORIES_DIR_NAME = 'stories';
10
11
 
11
12
  export interface HivePaths {
12
13
  root: string;
@@ -16,6 +17,7 @@ export interface HivePaths {
16
17
  agentsDir: string;
17
18
  logsDir: string;
18
19
  reposDir: string;
20
+ storiesDir: string;
19
21
  }
20
22
 
21
23
  export function findHiveRoot(startDir: string = process.cwd()): string | null {
@@ -43,6 +45,7 @@ export function getHivePaths(rootDir: string): HivePaths {
43
45
  agentsDir: join(hiveDir, AGENTS_DIR_NAME),
44
46
  logsDir: join(hiveDir, LOGS_DIR_NAME),
45
47
  reposDir: join(rootDir, REPOS_DIR_NAME),
48
+ storiesDir: join(hiveDir, STORIES_DIR_NAME),
46
49
  };
47
50
  }
48
51
 
@@ -0,0 +1,176 @@
1
+ // Licensed under the Hungry Ghost Hive License. See LICENSE.
2
+
3
+ import { existsSync, mkdirSync, readFileSync, rmSync } from 'fs';
4
+ import { join } from 'path';
5
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
6
+ import type { StoryRow } from '../db/client.js';
7
+ import {
8
+ deleteStoryMarkdown,
9
+ generateStoryMarkdown,
10
+ writeStoryMarkdown,
11
+ } from './story-markdown.js';
12
+
13
+ const TEST_DIR = join('/tmp', `story-markdown-test-${Date.now()}`);
14
+
15
+ function makeStory(overrides: Partial<StoryRow> = {}): StoryRow {
16
+ return {
17
+ id: 'STORY-TEST01',
18
+ requirement_id: null,
19
+ team_id: null,
20
+ title: 'Test Story',
21
+ description: 'A test story description.',
22
+ acceptance_criteria: null,
23
+ complexity_score: null,
24
+ story_points: null,
25
+ status: 'draft',
26
+ assigned_agent_id: null,
27
+ branch_name: null,
28
+ pr_url: null,
29
+ jira_issue_key: null,
30
+ jira_issue_id: null,
31
+ jira_project_key: null,
32
+ jira_subtask_key: null,
33
+ jira_subtask_id: null,
34
+ external_issue_key: null,
35
+ external_issue_id: null,
36
+ external_project_key: null,
37
+ external_subtask_key: null,
38
+ external_subtask_id: null,
39
+ external_provider: null,
40
+ in_sprint: 0,
41
+ markdown_path: null,
42
+ created_at: '2026-01-01T00:00:00.000Z',
43
+ updated_at: '2026-01-01T00:00:00.000Z',
44
+ ...overrides,
45
+ };
46
+ }
47
+
48
+ describe('generateStoryMarkdown', () => {
49
+ it('should generate a markdown document with title and description', () => {
50
+ const story = makeStory();
51
+ const md = generateStoryMarkdown(story);
52
+
53
+ expect(md).toContain('# Test Story');
54
+ expect(md).toContain('**Story ID:** STORY-TEST01');
55
+ expect(md).toContain('**Status:** draft');
56
+ expect(md).toContain('A test story description.');
57
+ });
58
+
59
+ it('should include acceptance criteria as a checklist', () => {
60
+ const story = makeStory({
61
+ acceptance_criteria: JSON.stringify(['Criterion A', 'Criterion B']),
62
+ });
63
+ const md = generateStoryMarkdown(story);
64
+
65
+ expect(md).toContain('## Acceptance Criteria');
66
+ expect(md).toContain('- [ ] Criterion A');
67
+ expect(md).toContain('- [ ] Criterion B');
68
+ });
69
+
70
+ it('should include optional fields when present', () => {
71
+ const story = makeStory({
72
+ team_id: 'TEAM-1',
73
+ requirement_id: 'REQ-1',
74
+ assigned_agent_id: 'agent-123',
75
+ complexity_score: 5,
76
+ story_points: 5,
77
+ branch_name: 'feature/STORY-TEST01-test-story',
78
+ pr_url: 'https://github.com/test/repo/pull/42',
79
+ });
80
+ const md = generateStoryMarkdown(story);
81
+
82
+ expect(md).toContain('**Team:** TEAM-1');
83
+ expect(md).toContain('**Requirement:** REQ-1');
84
+ expect(md).toContain('**Assigned Agent:** agent-123');
85
+ expect(md).toContain('**Complexity:** 5');
86
+ expect(md).toContain('**Story Points:** 5');
87
+ expect(md).toContain('**Branch:** feature/STORY-TEST01-test-story');
88
+ expect(md).toContain('**PR:** https://github.com/test/repo/pull/42');
89
+ });
90
+
91
+ it('should include created and updated timestamps', () => {
92
+ const story = makeStory();
93
+ const md = generateStoryMarkdown(story);
94
+
95
+ expect(md).toContain('*Created: 2026-01-01T00:00:00.000Z*');
96
+ expect(md).toContain('*Updated: 2026-01-01T00:00:00.000Z*');
97
+ });
98
+
99
+ it('should handle malformed acceptance_criteria gracefully', () => {
100
+ const story = makeStory({ acceptance_criteria: 'not-json' });
101
+ const md = generateStoryMarkdown(story);
102
+
103
+ expect(md).toContain('not-json');
104
+ });
105
+ });
106
+
107
+ describe('writeStoryMarkdown', () => {
108
+ beforeEach(() => {
109
+ mkdirSync(TEST_DIR, { recursive: true });
110
+ });
111
+
112
+ afterEach(() => {
113
+ rmSync(TEST_DIR, { recursive: true, force: true });
114
+ });
115
+
116
+ it('should write a markdown file in storiesDir', () => {
117
+ const story = makeStory();
118
+ const filePath = writeStoryMarkdown(TEST_DIR, story);
119
+
120
+ expect(existsSync(filePath)).toBe(true);
121
+ expect(filePath).toBe(join(TEST_DIR, 'STORY-TEST01.md'));
122
+ });
123
+
124
+ it('should create the directory if it does not exist', () => {
125
+ const nestedDir = join(TEST_DIR, 'nested', 'stories');
126
+ const story = makeStory();
127
+ writeStoryMarkdown(nestedDir, story);
128
+
129
+ expect(existsSync(nestedDir)).toBe(true);
130
+ expect(existsSync(join(nestedDir, 'STORY-TEST01.md'))).toBe(true);
131
+ });
132
+
133
+ it('should write valid markdown content', () => {
134
+ const story = makeStory({ description: 'My description.' });
135
+ const filePath = writeStoryMarkdown(TEST_DIR, story);
136
+ const content = readFileSync(filePath, 'utf-8');
137
+
138
+ expect(content).toContain('# Test Story');
139
+ expect(content).toContain('My description.');
140
+ });
141
+
142
+ it('should overwrite existing file on update', () => {
143
+ const story = makeStory({ title: 'Old Title' });
144
+ writeStoryMarkdown(TEST_DIR, story);
145
+
146
+ const updatedStory = makeStory({ title: 'New Title' });
147
+ const filePath = writeStoryMarkdown(TEST_DIR, updatedStory);
148
+ const content = readFileSync(filePath, 'utf-8');
149
+
150
+ expect(content).toContain('# New Title');
151
+ expect(content).not.toContain('# Old Title');
152
+ });
153
+ });
154
+
155
+ describe('deleteStoryMarkdown', () => {
156
+ beforeEach(() => {
157
+ mkdirSync(TEST_DIR, { recursive: true });
158
+ });
159
+
160
+ afterEach(() => {
161
+ rmSync(TEST_DIR, { recursive: true, force: true });
162
+ });
163
+
164
+ it('should delete the markdown file', () => {
165
+ const story = makeStory();
166
+ const filePath = writeStoryMarkdown(TEST_DIR, story);
167
+ expect(existsSync(filePath)).toBe(true);
168
+
169
+ deleteStoryMarkdown(TEST_DIR, story.id);
170
+ expect(existsSync(filePath)).toBe(false);
171
+ });
172
+
173
+ it('should not throw if file does not exist', () => {
174
+ expect(() => deleteStoryMarkdown(TEST_DIR, 'STORY-NONEXISTENT')).not.toThrow();
175
+ });
176
+ });