hungry-ghost-hive 0.44.0 → 0.46.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 (238) 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/cluster.d.ts.map +1 -1
  24. package/dist/cli/commands/cluster.js +348 -1
  25. package/dist/cli/commands/cluster.js.map +1 -1
  26. package/dist/cli/commands/cluster.test.js +313 -9
  27. package/dist/cli/commands/cluster.test.js.map +1 -1
  28. package/dist/cli/commands/manager/handoff-recovery.d.ts.map +1 -1
  29. package/dist/cli/commands/manager/handoff-recovery.js +4 -2
  30. package/dist/cli/commands/manager/handoff-recovery.js.map +1 -1
  31. package/dist/cli/commands/manager/index.d.ts.map +1 -1
  32. package/dist/cli/commands/manager/index.js +16 -12
  33. package/dist/cli/commands/manager/index.js.map +1 -1
  34. package/dist/cli/commands/manager/tech-lead-lifecycle.d.ts.map +1 -1
  35. package/dist/cli/commands/manager/tech-lead-lifecycle.js +4 -2
  36. package/dist/cli/commands/manager/tech-lead-lifecycle.js.map +1 -1
  37. package/dist/cli/commands/msg.d.ts.map +1 -1
  38. package/dist/cli/commands/msg.js +8 -7
  39. package/dist/cli/commands/msg.js.map +1 -1
  40. package/dist/cli/commands/my-stories.js +3 -3
  41. package/dist/cli/commands/my-stories.js.map +1 -1
  42. package/dist/cli/commands/nuke.d.ts.map +1 -1
  43. package/dist/cli/commands/nuke.js +18 -7
  44. package/dist/cli/commands/nuke.js.map +1 -1
  45. package/dist/cli/commands/nuke.test.js +24 -0
  46. package/dist/cli/commands/nuke.test.js.map +1 -1
  47. package/dist/cli/commands/req-spawn.test.d.ts +2 -0
  48. package/dist/cli/commands/req-spawn.test.d.ts.map +1 -0
  49. package/dist/cli/commands/req-spawn.test.js +116 -0
  50. package/dist/cli/commands/req-spawn.test.js.map +1 -0
  51. package/dist/cli/commands/req.d.ts +1 -1
  52. package/dist/cli/commands/req.d.ts.map +1 -1
  53. package/dist/cli/commands/req.js +28 -18
  54. package/dist/cli/commands/req.js.map +1 -1
  55. package/dist/cli/commands/stories.js +3 -3
  56. package/dist/cli/commands/stories.js.map +1 -1
  57. package/dist/cli/dashboard/panels/agents.d.ts.map +1 -1
  58. package/dist/cli/dashboard/panels/agents.js +7 -3
  59. package/dist/cli/dashboard/panels/agents.js.map +1 -1
  60. package/dist/cluster/cluster-http-server.d.ts +32 -0
  61. package/dist/cluster/cluster-http-server.d.ts.map +1 -1
  62. package/dist/cluster/cluster-http-server.js +42 -0
  63. package/dist/cluster/cluster-http-server.js.map +1 -1
  64. package/dist/cluster/distributed-runtime-coverage.test.js +9 -0
  65. package/dist/cluster/distributed-runtime-coverage.test.js.map +1 -1
  66. package/dist/cluster/distributed-system.test.js +135 -0
  67. package/dist/cluster/distributed-system.test.js.map +1 -1
  68. package/dist/cluster/events.d.ts +23 -0
  69. package/dist/cluster/events.d.ts.map +1 -1
  70. package/dist/cluster/events.js +74 -0
  71. package/dist/cluster/events.js.map +1 -1
  72. package/dist/cluster/heartbeat-manager.d.ts +2 -0
  73. package/dist/cluster/heartbeat-manager.d.ts.map +1 -1
  74. package/dist/cluster/heartbeat-manager.js +42 -6
  75. package/dist/cluster/heartbeat-manager.js.map +1 -1
  76. package/dist/cluster/membership.test.d.ts +2 -0
  77. package/dist/cluster/membership.test.d.ts.map +1 -0
  78. package/dist/cluster/membership.test.js +416 -0
  79. package/dist/cluster/membership.test.js.map +1 -0
  80. package/dist/cluster/partition-safety.test.d.ts +2 -0
  81. package/dist/cluster/partition-safety.test.d.ts.map +1 -0
  82. package/dist/cluster/partition-safety.test.js +440 -0
  83. package/dist/cluster/partition-safety.test.js.map +1 -0
  84. package/dist/cluster/raft-state-machine.d.ts +33 -1
  85. package/dist/cluster/raft-state-machine.d.ts.map +1 -1
  86. package/dist/cluster/raft-state-machine.js +65 -3
  87. package/dist/cluster/raft-state-machine.js.map +1 -1
  88. package/dist/cluster/raft-store.d.ts +26 -1
  89. package/dist/cluster/raft-store.d.ts.map +1 -1
  90. package/dist/cluster/raft-store.js +137 -0
  91. package/dist/cluster/raft-store.js.map +1 -1
  92. package/dist/cluster/replication-lag.test.d.ts +2 -0
  93. package/dist/cluster/replication-lag.test.d.ts.map +1 -0
  94. package/dist/cluster/replication-lag.test.js +239 -0
  95. package/dist/cluster/replication-lag.test.js.map +1 -0
  96. package/dist/cluster/replication.d.ts +2 -2
  97. package/dist/cluster/replication.d.ts.map +1 -1
  98. package/dist/cluster/replication.js +1 -1
  99. package/dist/cluster/replication.js.map +1 -1
  100. package/dist/cluster/runtime.d.ts +78 -0
  101. package/dist/cluster/runtime.d.ts.map +1 -1
  102. package/dist/cluster/runtime.js +400 -13
  103. package/dist/cluster/runtime.js.map +1 -1
  104. package/dist/cluster/state-recovery.test.d.ts +2 -0
  105. package/dist/cluster/state-recovery.test.d.ts.map +1 -0
  106. package/dist/cluster/state-recovery.test.js +310 -0
  107. package/dist/cluster/state-recovery.test.js.map +1 -0
  108. package/dist/cluster/types.d.ts +30 -0
  109. package/dist/cluster/types.d.ts.map +1 -1
  110. package/dist/config/schema.d.ts +48 -0
  111. package/dist/config/schema.d.ts.map +1 -1
  112. package/dist/config/schema.js +11 -0
  113. package/dist/config/schema.js.map +1 -1
  114. package/dist/context-files/generator.d.ts +1 -1
  115. package/dist/context-files/generator.d.ts.map +1 -1
  116. package/dist/context-files/generator.js +4 -3
  117. package/dist/context-files/generator.js.map +1 -1
  118. package/dist/context-files/generator.test.js +51 -0
  119. package/dist/context-files/generator.test.js.map +1 -1
  120. package/dist/context-files/index.test.js +1 -0
  121. package/dist/context-files/index.test.js.map +1 -1
  122. package/dist/db/client.d.ts +1 -0
  123. package/dist/db/client.d.ts.map +1 -1
  124. package/dist/db/client.js +6 -0
  125. package/dist/db/client.js.map +1 -1
  126. package/dist/db/migrations/015-add-story-markdown-path.sql +5 -0
  127. package/dist/db/queries/stories.d.ts +3 -3
  128. package/dist/db/queries/stories.d.ts.map +1 -1
  129. package/dist/db/queries/stories.js +23 -5
  130. package/dist/db/queries/stories.js.map +1 -1
  131. package/dist/db/queries/test-helpers.d.ts.map +1 -1
  132. package/dist/db/queries/test-helpers.js +1 -0
  133. package/dist/db/queries/test-helpers.js.map +1 -1
  134. package/dist/git/worktree.d.ts.map +1 -1
  135. package/dist/git/worktree.js +7 -0
  136. package/dist/git/worktree.js.map +1 -1
  137. package/dist/git/worktree.test.js +30 -0
  138. package/dist/git/worktree.test.js.map +1 -1
  139. package/dist/orchestrator/orphan-recovery.d.ts +1 -1
  140. package/dist/orchestrator/orphan-recovery.d.ts.map +1 -1
  141. package/dist/orchestrator/orphan-recovery.js +4 -4
  142. package/dist/orchestrator/orphan-recovery.js.map +1 -1
  143. package/dist/orchestrator/prompt-templates.d.ts +6 -2
  144. package/dist/orchestrator/prompt-templates.d.ts.map +1 -1
  145. package/dist/orchestrator/prompt-templates.js +61 -16
  146. package/dist/orchestrator/prompt-templates.js.map +1 -1
  147. package/dist/orchestrator/prompt-templates.test.js +214 -0
  148. package/dist/orchestrator/prompt-templates.test.js.map +1 -1
  149. package/dist/orchestrator/scheduler.d.ts +1 -0
  150. package/dist/orchestrator/scheduler.d.ts.map +1 -1
  151. package/dist/orchestrator/scheduler.js +30 -17
  152. package/dist/orchestrator/scheduler.js.map +1 -1
  153. package/dist/orchestrator/scheduler.test.js +98 -6
  154. package/dist/orchestrator/scheduler.test.js.map +1 -1
  155. package/dist/tmux/manager.d.ts +7 -6
  156. package/dist/tmux/manager.d.ts.map +1 -1
  157. package/dist/tmux/manager.js +29 -13
  158. package/dist/tmux/manager.js.map +1 -1
  159. package/dist/utils/instance.d.ts +32 -0
  160. package/dist/utils/instance.d.ts.map +1 -0
  161. package/dist/utils/instance.js +82 -0
  162. package/dist/utils/instance.js.map +1 -0
  163. package/dist/utils/instance.test.d.ts +2 -0
  164. package/dist/utils/instance.test.d.ts.map +1 -0
  165. package/dist/utils/instance.test.js +103 -0
  166. package/dist/utils/instance.test.js.map +1 -0
  167. package/dist/utils/paths.d.ts +2 -0
  168. package/dist/utils/paths.d.ts.map +1 -1
  169. package/dist/utils/paths.js +2 -0
  170. package/dist/utils/paths.js.map +1 -1
  171. package/dist/utils/paths.test.js +6 -0
  172. package/dist/utils/paths.test.js.map +1 -1
  173. package/dist/utils/story-markdown.d.ts +16 -0
  174. package/dist/utils/story-markdown.d.ts.map +1 -0
  175. package/dist/utils/story-markdown.js +82 -0
  176. package/dist/utils/story-markdown.js.map +1 -0
  177. package/dist/utils/story-markdown.test.d.ts +2 -0
  178. package/dist/utils/story-markdown.test.d.ts.map +1 -0
  179. package/dist/utils/story-markdown.test.js +143 -0
  180. package/dist/utils/story-markdown.test.js.map +1 -0
  181. package/package.json +1 -1
  182. package/src/agents/base-agent.ts +5 -0
  183. package/src/agents/intermediate.ts +2 -2
  184. package/src/agents/junior.ts +2 -2
  185. package/src/agents/qa.ts +13 -8
  186. package/src/agents/senior.ts +21 -11
  187. package/src/agents/tech-lead.ts +24 -12
  188. package/src/cli/commands/assign.test.ts +5 -0
  189. package/src/cli/commands/assign.ts +4 -2
  190. package/src/cli/commands/cluster.test.ts +387 -9
  191. package/src/cli/commands/cluster.ts +486 -1
  192. package/src/cli/commands/manager/handoff-recovery.ts +4 -2
  193. package/src/cli/commands/manager/index.ts +16 -11
  194. package/src/cli/commands/manager/tech-lead-lifecycle.ts +5 -2
  195. package/src/cli/commands/msg.ts +8 -7
  196. package/src/cli/commands/my-stories.ts +22 -13
  197. package/src/cli/commands/nuke.test.ts +31 -0
  198. package/src/cli/commands/nuke.ts +18 -7
  199. package/src/cli/commands/req-spawn.test.ts +153 -0
  200. package/src/cli/commands/req.ts +40 -23
  201. package/src/cli/commands/stories.ts +22 -13
  202. package/src/cli/dashboard/panels/agents.ts +7 -3
  203. package/src/cluster/cluster-http-server.ts +80 -0
  204. package/src/cluster/distributed-runtime-coverage.test.ts +9 -0
  205. package/src/cluster/distributed-system.test.ts +168 -0
  206. package/src/cluster/events.ts +90 -0
  207. package/src/cluster/heartbeat-manager.ts +48 -6
  208. package/src/cluster/membership.test.ts +498 -0
  209. package/src/cluster/partition-safety.test.ts +523 -0
  210. package/src/cluster/raft-state-machine.ts +76 -4
  211. package/src/cluster/raft-store.ts +167 -1
  212. package/src/cluster/replication-lag.test.ts +284 -0
  213. package/src/cluster/replication.ts +6 -0
  214. package/src/cluster/runtime.ts +551 -12
  215. package/src/cluster/state-recovery.test.ts +420 -0
  216. package/src/cluster/types.ts +32 -0
  217. package/src/config/schema.ts +11 -0
  218. package/src/context-files/generator.test.ts +55 -0
  219. package/src/context-files/generator.ts +8 -7
  220. package/src/context-files/index.test.ts +1 -0
  221. package/src/db/client.ts +7 -0
  222. package/src/db/migrations/015-add-story-markdown-path.sql +5 -0
  223. package/src/db/queries/stories.ts +29 -5
  224. package/src/db/queries/test-helpers.ts +1 -0
  225. package/src/git/worktree.test.ts +43 -0
  226. package/src/git/worktree.ts +10 -0
  227. package/src/orchestrator/orphan-recovery.ts +32 -13
  228. package/src/orchestrator/prompt-templates.test.ts +267 -0
  229. package/src/orchestrator/prompt-templates.ts +69 -16
  230. package/src/orchestrator/scheduler.test.ts +130 -6
  231. package/src/orchestrator/scheduler.ts +66 -27
  232. package/src/tmux/manager.ts +42 -13
  233. package/src/utils/instance.test.ts +129 -0
  234. package/src/utils/instance.ts +95 -0
  235. package/src/utils/paths.test.ts +8 -0
  236. package/src/utils/paths.ts +3 -0
  237. package/src/utils/story-markdown.test.ts +176 -0
  238. package/src/utils/story-markdown.ts +94 -0
@@ -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
+ }