hungry-ghost-hive 0.44.0 → 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 (148) 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 +4 -2
  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 +4 -2
  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/req.d.ts +1 -1
  43. package/dist/cli/commands/req.d.ts.map +1 -1
  44. package/dist/cli/commands/req.js +7 -5
  45. package/dist/cli/commands/req.js.map +1 -1
  46. package/dist/cli/commands/stories.js +3 -3
  47. package/dist/cli/commands/stories.js.map +1 -1
  48. package/dist/cli/dashboard/panels/agents.d.ts.map +1 -1
  49. package/dist/cli/dashboard/panels/agents.js +7 -3
  50. package/dist/cli/dashboard/panels/agents.js.map +1 -1
  51. package/dist/context-files/generator.d.ts +1 -1
  52. package/dist/context-files/generator.d.ts.map +1 -1
  53. package/dist/context-files/generator.js +3 -2
  54. package/dist/context-files/generator.js.map +1 -1
  55. package/dist/context-files/index.test.js +1 -0
  56. package/dist/context-files/index.test.js.map +1 -1
  57. package/dist/db/client.d.ts +1 -0
  58. package/dist/db/client.d.ts.map +1 -1
  59. package/dist/db/client.js +6 -0
  60. package/dist/db/client.js.map +1 -1
  61. package/dist/db/migrations/015-add-story-markdown-path.sql +5 -0
  62. package/dist/db/queries/stories.d.ts +3 -3
  63. package/dist/db/queries/stories.d.ts.map +1 -1
  64. package/dist/db/queries/stories.js +23 -5
  65. package/dist/db/queries/stories.js.map +1 -1
  66. package/dist/db/queries/test-helpers.d.ts.map +1 -1
  67. package/dist/db/queries/test-helpers.js +1 -0
  68. package/dist/db/queries/test-helpers.js.map +1 -1
  69. package/dist/git/worktree.d.ts.map +1 -1
  70. package/dist/git/worktree.js +7 -0
  71. package/dist/git/worktree.js.map +1 -1
  72. package/dist/git/worktree.test.js +30 -0
  73. package/dist/git/worktree.test.js.map +1 -1
  74. package/dist/orchestrator/prompt-templates.d.ts +3 -1
  75. package/dist/orchestrator/prompt-templates.d.ts.map +1 -1
  76. package/dist/orchestrator/prompt-templates.js +16 -8
  77. package/dist/orchestrator/prompt-templates.js.map +1 -1
  78. package/dist/orchestrator/prompt-templates.test.js +4 -0
  79. package/dist/orchestrator/prompt-templates.test.js.map +1 -1
  80. package/dist/orchestrator/scheduler.d.ts.map +1 -1
  81. package/dist/orchestrator/scheduler.js +19 -11
  82. package/dist/orchestrator/scheduler.js.map +1 -1
  83. package/dist/orchestrator/scheduler.test.js +1 -0
  84. package/dist/orchestrator/scheduler.test.js.map +1 -1
  85. package/dist/tmux/manager.d.ts +7 -6
  86. package/dist/tmux/manager.d.ts.map +1 -1
  87. package/dist/tmux/manager.js +29 -13
  88. package/dist/tmux/manager.js.map +1 -1
  89. package/dist/utils/instance.d.ts +32 -0
  90. package/dist/utils/instance.d.ts.map +1 -0
  91. package/dist/utils/instance.js +82 -0
  92. package/dist/utils/instance.js.map +1 -0
  93. package/dist/utils/instance.test.d.ts +2 -0
  94. package/dist/utils/instance.test.d.ts.map +1 -0
  95. package/dist/utils/instance.test.js +103 -0
  96. package/dist/utils/instance.test.js.map +1 -0
  97. package/dist/utils/paths.d.ts +2 -0
  98. package/dist/utils/paths.d.ts.map +1 -1
  99. package/dist/utils/paths.js +2 -0
  100. package/dist/utils/paths.js.map +1 -1
  101. package/dist/utils/paths.test.js +6 -0
  102. package/dist/utils/paths.test.js.map +1 -1
  103. package/dist/utils/story-markdown.d.ts +16 -0
  104. package/dist/utils/story-markdown.d.ts.map +1 -0
  105. package/dist/utils/story-markdown.js +82 -0
  106. package/dist/utils/story-markdown.js.map +1 -0
  107. package/dist/utils/story-markdown.test.d.ts +2 -0
  108. package/dist/utils/story-markdown.test.d.ts.map +1 -0
  109. package/dist/utils/story-markdown.test.js +143 -0
  110. package/dist/utils/story-markdown.test.js.map +1 -0
  111. package/package.json +1 -1
  112. package/src/agents/base-agent.ts +5 -0
  113. package/src/agents/intermediate.ts +2 -2
  114. package/src/agents/junior.ts +2 -2
  115. package/src/agents/qa.ts +13 -8
  116. package/src/agents/senior.ts +21 -11
  117. package/src/agents/tech-lead.ts +24 -12
  118. package/src/cli/commands/assign.test.ts +5 -0
  119. package/src/cli/commands/assign.ts +4 -2
  120. package/src/cli/commands/manager/handoff-recovery.ts +4 -2
  121. package/src/cli/commands/manager/index.ts +16 -11
  122. package/src/cli/commands/manager/tech-lead-lifecycle.ts +5 -2
  123. package/src/cli/commands/msg.ts +8 -7
  124. package/src/cli/commands/my-stories.ts +22 -13
  125. package/src/cli/commands/nuke.test.ts +31 -0
  126. package/src/cli/commands/nuke.ts +18 -7
  127. package/src/cli/commands/req.ts +9 -5
  128. package/src/cli/commands/stories.ts +22 -13
  129. package/src/cli/dashboard/panels/agents.ts +7 -3
  130. package/src/context-files/generator.ts +3 -2
  131. package/src/context-files/index.test.ts +1 -0
  132. package/src/db/client.ts +7 -0
  133. package/src/db/migrations/015-add-story-markdown-path.sql +5 -0
  134. package/src/db/queries/stories.ts +29 -5
  135. package/src/db/queries/test-helpers.ts +1 -0
  136. package/src/git/worktree.test.ts +43 -0
  137. package/src/git/worktree.ts +10 -0
  138. package/src/orchestrator/prompt-templates.test.ts +4 -0
  139. package/src/orchestrator/prompt-templates.ts +20 -8
  140. package/src/orchestrator/scheduler.test.ts +1 -0
  141. package/src/orchestrator/scheduler.ts +24 -11
  142. package/src/tmux/manager.ts +42 -13
  143. package/src/utils/instance.test.ts +129 -0
  144. package/src/utils/instance.ts +95 -0
  145. package/src/utils/paths.test.ts +8 -0
  146. package/src/utils/paths.ts +3 -0
  147. package/src/utils/story-markdown.test.ts +176 -0
  148. package/src/utils/story-markdown.ts +94 -0
@@ -4,6 +4,11 @@ import { execa } from 'execa';
4
4
  import { chmodSync, existsSync, mkdirSync, writeFileSync } from 'fs';
5
5
  import { tmpdir } from 'os';
6
6
  import { dirname, isAbsolute, join, resolve } from 'path';
7
+ import {
8
+ buildInstanceSessionName,
9
+ getInstancePrefix,
10
+ getManagerSessionName,
11
+ } from '../utils/instance.js';
7
12
 
8
13
  // --- Named constants (extracted from inline magic numbers) ---
9
14
 
@@ -162,8 +167,12 @@ export async function listTmuxSessions(): Promise<TmuxSession[]> {
162
167
  }
163
168
  }
164
169
 
165
- export async function getHiveSessions(): Promise<TmuxSession[]> {
170
+ export async function getHiveSessions(hiveDir?: string): Promise<TmuxSession[]> {
166
171
  const sessions = await listTmuxSessions();
172
+ if (hiveDir) {
173
+ const prefix = getInstancePrefix(hiveDir);
174
+ return sessions.filter(s => s.name.startsWith(prefix));
175
+ }
167
176
  return sessions.filter(s => s.name.startsWith('hive-'));
168
177
  }
169
178
 
@@ -235,8 +244,8 @@ export async function killTmuxSession(sessionName: string): Promise<void> {
235
244
  }
236
245
  }
237
246
 
238
- export async function killAllHiveSessions(): Promise<number> {
239
- const sessions = await getHiveSessions();
247
+ export async function killAllHiveSessions(hiveDir?: string): Promise<number> {
248
+ const sessions = await getHiveSessions(hiveDir);
240
249
  let killed = 0;
241
250
 
242
251
  for (const session of sessions) {
@@ -513,7 +522,15 @@ export async function autoApprovePermission(
513
522
  return false;
514
523
  }
515
524
 
516
- export function generateSessionName(agentType: string, teamName?: string, index?: number): string {
525
+ export function generateSessionName(
526
+ agentType: string,
527
+ teamName?: string,
528
+ index?: number,
529
+ hiveDir?: string
530
+ ): string {
531
+ if (hiveDir) {
532
+ return buildInstanceSessionName(hiveDir, agentType, teamName, index);
533
+ }
517
534
  let name = `hive-${agentType}`;
518
535
  if (teamName) {
519
536
  name += `-${teamName}`;
@@ -526,12 +543,23 @@ export function generateSessionName(agentType: string, teamName?: string, index?
526
543
 
527
544
  const MANAGER_SESSION = 'hive-manager';
528
545
 
529
- export async function isManagerRunning(): Promise<boolean> {
530
- return isTmuxSessionRunning(MANAGER_SESSION);
546
+ export function getManagerSession(hiveDir?: string): string {
547
+ if (hiveDir) {
548
+ return getManagerSessionName(hiveDir);
549
+ }
550
+ return MANAGER_SESSION;
551
+ }
552
+
553
+ export async function isManagerRunning(hiveDir?: string): Promise<boolean> {
554
+ return isTmuxSessionRunning(getManagerSession(hiveDir));
531
555
  }
532
556
 
533
- export async function startManager(interval = DEFAULT_MANAGER_INTERVAL): Promise<boolean> {
534
- if (await isManagerRunning()) {
557
+ export async function startManager(
558
+ interval = DEFAULT_MANAGER_INTERVAL,
559
+ hiveDir?: string
560
+ ): Promise<boolean> {
561
+ const session = getManagerSession(hiveDir);
562
+ if (await isTmuxSessionRunning(session)) {
535
563
  return false; // Already running
536
564
  }
537
565
 
@@ -539,22 +567,23 @@ export async function startManager(interval = DEFAULT_MANAGER_INTERVAL): Promise
539
567
  const sessionEnv = buildTmuxSessionEnv(workDir);
540
568
 
541
569
  // Start the manager in a detached tmux session
542
- await execa('tmux', ['new-session', '-d', '-s', MANAGER_SESSION, '-c', workDir], {
570
+ await execa('tmux', ['new-session', '-d', '-s', session, '-c', workDir], {
543
571
  env: sessionEnv,
544
572
  });
545
573
 
546
574
  // Send the manager command
547
575
  const managerCommand = `${buildHiveInvokeCommand()} manager start -i ${interval}`;
548
- await execa('tmux', ['send-keys', '-t', MANAGER_SESSION, managerCommand, 'Enter']);
576
+ await execa('tmux', ['send-keys', '-t', session, managerCommand, 'Enter']);
549
577
 
550
578
  return true;
551
579
  }
552
580
 
553
- export async function stopManager(): Promise<boolean> {
554
- if (!(await isManagerRunning())) {
581
+ export async function stopManager(hiveDir?: string): Promise<boolean> {
582
+ const session = getManagerSession(hiveDir);
583
+ if (!(await isTmuxSessionRunning(session))) {
555
584
  return false; // Not running
556
585
  }
557
586
 
558
- await killTmuxSession(MANAGER_SESSION);
587
+ await killTmuxSession(session);
559
588
  return true;
560
589
  }
@@ -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
+ });
@@ -0,0 +1,94 @@
1
+ // Licensed under the Hungry Ghost Hive License. See LICENSE.
2
+
3
+ import { existsSync, mkdirSync, rmSync, writeFileSync } from 'fs';
4
+ import { join } from 'path';
5
+ import type { StoryRow } from '../db/client.js';
6
+
7
+ /**
8
+ * Generate markdown content for a story.
9
+ */
10
+ export function generateStoryMarkdown(story: StoryRow): string {
11
+ const lines: string[] = [];
12
+
13
+ lines.push(`# ${story.title}`);
14
+ lines.push('');
15
+ lines.push(`**Story ID:** ${story.id}`);
16
+ lines.push(`**Status:** ${story.status}`);
17
+
18
+ if (story.team_id) {
19
+ lines.push(`**Team:** ${story.team_id}`);
20
+ }
21
+ if (story.requirement_id) {
22
+ lines.push(`**Requirement:** ${story.requirement_id}`);
23
+ }
24
+ if (story.assigned_agent_id) {
25
+ lines.push(`**Assigned Agent:** ${story.assigned_agent_id}`);
26
+ }
27
+ if (story.complexity_score !== null) {
28
+ lines.push(`**Complexity:** ${story.complexity_score}`);
29
+ }
30
+ if (story.story_points !== null) {
31
+ lines.push(`**Story Points:** ${story.story_points}`);
32
+ }
33
+ if (story.branch_name) {
34
+ lines.push(`**Branch:** ${story.branch_name}`);
35
+ }
36
+ if (story.pr_url) {
37
+ lines.push(`**PR:** ${story.pr_url}`);
38
+ }
39
+
40
+ lines.push('');
41
+ lines.push('## Description');
42
+ lines.push('');
43
+ lines.push(story.description);
44
+
45
+ if (story.acceptance_criteria) {
46
+ lines.push('');
47
+ lines.push('## Acceptance Criteria');
48
+ lines.push('');
49
+ try {
50
+ const criteria = JSON.parse(story.acceptance_criteria) as string[];
51
+ for (const criterion of criteria) {
52
+ lines.push(`- [ ] ${criterion}`);
53
+ }
54
+ } catch {
55
+ lines.push(story.acceptance_criteria);
56
+ }
57
+ }
58
+
59
+ lines.push('');
60
+ lines.push('---');
61
+ lines.push('');
62
+ lines.push(`*Created: ${story.created_at}*`);
63
+ lines.push(`*Updated: ${story.updated_at}*`);
64
+ lines.push('');
65
+
66
+ return lines.join('\n');
67
+ }
68
+
69
+ /**
70
+ * Write a story as a markdown file in the storiesDir.
71
+ * Creates the directory if it does not exist.
72
+ * Returns the path to the written file.
73
+ */
74
+ export function writeStoryMarkdown(storiesDir: string, story: StoryRow): string {
75
+ mkdirSync(storiesDir, { recursive: true });
76
+
77
+ const fileName = `${story.id}.md`;
78
+ const filePath = join(storiesDir, fileName);
79
+ const content = generateStoryMarkdown(story);
80
+
81
+ writeFileSync(filePath, content, 'utf-8');
82
+
83
+ return filePath;
84
+ }
85
+
86
+ /**
87
+ * Delete a story's markdown file if it exists.
88
+ */
89
+ export function deleteStoryMarkdown(storiesDir: string, storyId: string): void {
90
+ const filePath = join(storiesDir, `${storyId}.md`);
91
+ if (existsSync(filePath)) {
92
+ rmSync(filePath);
93
+ }
94
+ }