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.
- package/dist/agents/base-agent.d.ts +1 -0
- package/dist/agents/base-agent.d.ts.map +1 -1
- package/dist/agents/base-agent.js +4 -0
- package/dist/agents/base-agent.js.map +1 -1
- package/dist/agents/intermediate.js +2 -2
- package/dist/agents/intermediate.js.map +1 -1
- package/dist/agents/junior.js +2 -2
- package/dist/agents/junior.js.map +1 -1
- package/dist/agents/qa.d.ts.map +1 -1
- package/dist/agents/qa.js +5 -5
- package/dist/agents/qa.js.map +1 -1
- package/dist/agents/senior.d.ts.map +1 -1
- package/dist/agents/senior.js +5 -5
- package/dist/agents/senior.js.map +1 -1
- package/dist/agents/tech-lead.d.ts.map +1 -1
- package/dist/agents/tech-lead.js +4 -2
- package/dist/agents/tech-lead.js.map +1 -1
- package/dist/cli/commands/assign.d.ts.map +1 -1
- package/dist/cli/commands/assign.js +4 -2
- package/dist/cli/commands/assign.js.map +1 -1
- package/dist/cli/commands/assign.test.js +5 -0
- package/dist/cli/commands/assign.test.js.map +1 -1
- package/dist/cli/commands/manager/handoff-recovery.d.ts.map +1 -1
- package/dist/cli/commands/manager/handoff-recovery.js +4 -2
- package/dist/cli/commands/manager/handoff-recovery.js.map +1 -1
- package/dist/cli/commands/manager/index.d.ts.map +1 -1
- package/dist/cli/commands/manager/index.js +16 -12
- package/dist/cli/commands/manager/index.js.map +1 -1
- package/dist/cli/commands/manager/tech-lead-lifecycle.d.ts.map +1 -1
- package/dist/cli/commands/manager/tech-lead-lifecycle.js +4 -2
- package/dist/cli/commands/manager/tech-lead-lifecycle.js.map +1 -1
- package/dist/cli/commands/msg.d.ts.map +1 -1
- package/dist/cli/commands/msg.js +8 -7
- package/dist/cli/commands/msg.js.map +1 -1
- package/dist/cli/commands/my-stories.js +3 -3
- package/dist/cli/commands/my-stories.js.map +1 -1
- package/dist/cli/commands/nuke.d.ts.map +1 -1
- package/dist/cli/commands/nuke.js +18 -7
- package/dist/cli/commands/nuke.js.map +1 -1
- package/dist/cli/commands/nuke.test.js +24 -0
- package/dist/cli/commands/nuke.test.js.map +1 -1
- package/dist/cli/commands/req.d.ts +1 -1
- package/dist/cli/commands/req.d.ts.map +1 -1
- package/dist/cli/commands/req.js +7 -5
- package/dist/cli/commands/req.js.map +1 -1
- package/dist/cli/commands/stories.js +3 -3
- package/dist/cli/commands/stories.js.map +1 -1
- package/dist/cli/dashboard/panels/agents.d.ts.map +1 -1
- package/dist/cli/dashboard/panels/agents.js +7 -3
- package/dist/cli/dashboard/panels/agents.js.map +1 -1
- package/dist/context-files/generator.d.ts +1 -1
- package/dist/context-files/generator.d.ts.map +1 -1
- package/dist/context-files/generator.js +3 -2
- package/dist/context-files/generator.js.map +1 -1
- package/dist/context-files/index.test.js +1 -0
- package/dist/context-files/index.test.js.map +1 -1
- package/dist/db/client.d.ts +1 -0
- package/dist/db/client.d.ts.map +1 -1
- package/dist/db/client.js +6 -0
- package/dist/db/client.js.map +1 -1
- package/dist/db/migrations/015-add-story-markdown-path.sql +5 -0
- package/dist/db/queries/stories.d.ts +3 -3
- package/dist/db/queries/stories.d.ts.map +1 -1
- package/dist/db/queries/stories.js +23 -5
- package/dist/db/queries/stories.js.map +1 -1
- package/dist/db/queries/test-helpers.d.ts.map +1 -1
- package/dist/db/queries/test-helpers.js +1 -0
- package/dist/db/queries/test-helpers.js.map +1 -1
- package/dist/git/worktree.d.ts.map +1 -1
- package/dist/git/worktree.js +7 -0
- package/dist/git/worktree.js.map +1 -1
- package/dist/git/worktree.test.js +30 -0
- package/dist/git/worktree.test.js.map +1 -1
- package/dist/orchestrator/prompt-templates.d.ts +3 -1
- package/dist/orchestrator/prompt-templates.d.ts.map +1 -1
- package/dist/orchestrator/prompt-templates.js +16 -8
- package/dist/orchestrator/prompt-templates.js.map +1 -1
- package/dist/orchestrator/prompt-templates.test.js +4 -0
- package/dist/orchestrator/prompt-templates.test.js.map +1 -1
- package/dist/orchestrator/scheduler.d.ts.map +1 -1
- package/dist/orchestrator/scheduler.js +19 -11
- package/dist/orchestrator/scheduler.js.map +1 -1
- package/dist/orchestrator/scheduler.test.js +1 -0
- package/dist/orchestrator/scheduler.test.js.map +1 -1
- package/dist/tmux/manager.d.ts +7 -6
- package/dist/tmux/manager.d.ts.map +1 -1
- package/dist/tmux/manager.js +29 -13
- package/dist/tmux/manager.js.map +1 -1
- package/dist/utils/instance.d.ts +32 -0
- package/dist/utils/instance.d.ts.map +1 -0
- package/dist/utils/instance.js +82 -0
- package/dist/utils/instance.js.map +1 -0
- package/dist/utils/instance.test.d.ts +2 -0
- package/dist/utils/instance.test.d.ts.map +1 -0
- package/dist/utils/instance.test.js +103 -0
- package/dist/utils/instance.test.js.map +1 -0
- package/dist/utils/paths.d.ts +2 -0
- package/dist/utils/paths.d.ts.map +1 -1
- package/dist/utils/paths.js +2 -0
- package/dist/utils/paths.js.map +1 -1
- package/dist/utils/paths.test.js +6 -0
- package/dist/utils/paths.test.js.map +1 -1
- package/dist/utils/story-markdown.d.ts +16 -0
- package/dist/utils/story-markdown.d.ts.map +1 -0
- package/dist/utils/story-markdown.js +82 -0
- package/dist/utils/story-markdown.js.map +1 -0
- package/dist/utils/story-markdown.test.d.ts +2 -0
- package/dist/utils/story-markdown.test.d.ts.map +1 -0
- package/dist/utils/story-markdown.test.js +143 -0
- package/dist/utils/story-markdown.test.js.map +1 -0
- package/package.json +1 -1
- package/src/agents/base-agent.ts +5 -0
- package/src/agents/intermediate.ts +2 -2
- package/src/agents/junior.ts +2 -2
- package/src/agents/qa.ts +13 -8
- package/src/agents/senior.ts +21 -11
- package/src/agents/tech-lead.ts +24 -12
- package/src/cli/commands/assign.test.ts +5 -0
- package/src/cli/commands/assign.ts +4 -2
- package/src/cli/commands/manager/handoff-recovery.ts +4 -2
- package/src/cli/commands/manager/index.ts +16 -11
- package/src/cli/commands/manager/tech-lead-lifecycle.ts +5 -2
- package/src/cli/commands/msg.ts +8 -7
- package/src/cli/commands/my-stories.ts +22 -13
- package/src/cli/commands/nuke.test.ts +31 -0
- package/src/cli/commands/nuke.ts +18 -7
- package/src/cli/commands/req.ts +9 -5
- package/src/cli/commands/stories.ts +22 -13
- package/src/cli/dashboard/panels/agents.ts +7 -3
- package/src/context-files/generator.ts +3 -2
- package/src/context-files/index.test.ts +1 -0
- package/src/db/client.ts +7 -0
- package/src/db/migrations/015-add-story-markdown-path.sql +5 -0
- package/src/db/queries/stories.ts +29 -5
- package/src/db/queries/test-helpers.ts +1 -0
- package/src/git/worktree.test.ts +43 -0
- package/src/git/worktree.ts +10 -0
- package/src/orchestrator/prompt-templates.test.ts +4 -0
- package/src/orchestrator/prompt-templates.ts +20 -8
- package/src/orchestrator/scheduler.test.ts +1 -0
- package/src/orchestrator/scheduler.ts +24 -11
- package/src/tmux/manager.ts +42 -13
- package/src/utils/instance.test.ts +129 -0
- package/src/utils/instance.ts +95 -0
- package/src/utils/paths.test.ts +8 -0
- package/src/utils/paths.ts +3 -0
- package/src/utils/story-markdown.test.ts +176 -0
- package/src/utils/story-markdown.ts +94 -0
package/src/tmux/manager.ts
CHANGED
|
@@ -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(
|
|
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
|
|
530
|
-
|
|
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(
|
|
534
|
-
|
|
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',
|
|
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',
|
|
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
|
-
|
|
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(
|
|
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
|
+
}
|
package/src/utils/paths.test.ts
CHANGED
|
@@ -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', () => {
|
package/src/utils/paths.ts
CHANGED
|
@@ -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
|
+
}
|