hungry-ghost-hive 0.43.0 → 0.43.2

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 (133) hide show
  1. package/dist/cli/commands/agents.d.ts.map +1 -1
  2. package/dist/cli/commands/agents.js +4 -11
  3. package/dist/cli/commands/agents.js.map +1 -1
  4. package/dist/cli/commands/approach.d.ts.map +1 -1
  5. package/dist/cli/commands/approach.js +2 -6
  6. package/dist/cli/commands/approach.js.map +1 -1
  7. package/dist/cli/commands/init.d.ts.map +1 -1
  8. package/dist/cli/commands/init.js +9 -0
  9. package/dist/cli/commands/init.js.map +1 -1
  10. package/dist/cli/commands/init.test.js +3 -0
  11. package/dist/cli/commands/init.test.js.map +1 -1
  12. package/dist/cli/commands/manager/index.d.ts +2 -27
  13. package/dist/cli/commands/manager/index.d.ts.map +1 -1
  14. package/dist/cli/commands/manager/index.js +23 -1519
  15. package/dist/cli/commands/manager/index.js.map +1 -1
  16. package/dist/cli/commands/manager/manager-utils.d.ts +9 -0
  17. package/dist/cli/commands/manager/manager-utils.d.ts.map +1 -0
  18. package/dist/cli/commands/manager/manager-utils.js +49 -0
  19. package/dist/cli/commands/manager/manager-utils.js.map +1 -0
  20. package/dist/cli/commands/manager/pr-sync-orchestrator.d.ts +7 -0
  21. package/dist/cli/commands/manager/pr-sync-orchestrator.d.ts.map +1 -0
  22. package/dist/cli/commands/manager/pr-sync-orchestrator.js +537 -0
  23. package/dist/cli/commands/manager/pr-sync-orchestrator.js.map +1 -0
  24. package/dist/cli/commands/manager/qa-review-handler.d.ts +15 -0
  25. package/dist/cli/commands/manager/qa-review-handler.d.ts.map +1 -0
  26. package/dist/cli/commands/manager/qa-review-handler.js +290 -0
  27. package/dist/cli/commands/manager/qa-review-handler.js.map +1 -0
  28. package/dist/cli/commands/manager/stuck-story-helpers.d.ts +32 -0
  29. package/dist/cli/commands/manager/stuck-story-helpers.d.ts.map +1 -0
  30. package/dist/cli/commands/manager/stuck-story-helpers.js +163 -0
  31. package/dist/cli/commands/manager/stuck-story-helpers.js.map +1 -0
  32. package/dist/cli/commands/manager/stuck-story-processor.d.ts +8 -0
  33. package/dist/cli/commands/manager/stuck-story-processor.d.ts.map +1 -0
  34. package/dist/cli/commands/manager/stuck-story-processor.js +392 -0
  35. package/dist/cli/commands/manager/stuck-story-processor.js.map +1 -0
  36. package/dist/cli/commands/manager/tech-lead-lifecycle.d.ts +3 -0
  37. package/dist/cli/commands/manager/tech-lead-lifecycle.d.ts.map +1 -0
  38. package/dist/cli/commands/manager/tech-lead-lifecycle.js +141 -0
  39. package/dist/cli/commands/manager/tech-lead-lifecycle.js.map +1 -0
  40. package/dist/cli/commands/my-stories.d.ts.map +1 -1
  41. package/dist/cli/commands/my-stories.js +5 -20
  42. package/dist/cli/commands/my-stories.js.map +1 -1
  43. package/dist/cli/commands/pr.js +7 -22
  44. package/dist/cli/commands/pr.js.map +1 -1
  45. package/dist/cli/commands/progress.d.ts.map +1 -1
  46. package/dist/cli/commands/progress.js +2 -5
  47. package/dist/cli/commands/progress.js.map +1 -1
  48. package/dist/cli/commands/resume.d.ts.map +1 -1
  49. package/dist/cli/commands/resume.js +3 -6
  50. package/dist/cli/commands/resume.js.map +1 -1
  51. package/dist/cli/commands/status.d.ts.map +1 -1
  52. package/dist/cli/commands/status.js +2 -5
  53. package/dist/cli/commands/status.js.map +1 -1
  54. package/dist/cli/commands/stories.d.ts.map +1 -1
  55. package/dist/cli/commands/stories.js +2 -5
  56. package/dist/cli/commands/stories.js.map +1 -1
  57. package/dist/cluster/adapters.d.ts +3 -2
  58. package/dist/cluster/adapters.d.ts.map +1 -1
  59. package/dist/cluster/adapters.js +2 -11
  60. package/dist/cluster/adapters.js.map +1 -1
  61. package/dist/cluster/cluster-http-server.d.ts +20 -0
  62. package/dist/cluster/cluster-http-server.d.ts.map +1 -0
  63. package/dist/cluster/cluster-http-server.js +140 -0
  64. package/dist/cluster/cluster-http-server.js.map +1 -0
  65. package/dist/cluster/heartbeat-manager.d.ts +24 -0
  66. package/dist/cluster/heartbeat-manager.d.ts.map +1 -0
  67. package/dist/cluster/heartbeat-manager.js +74 -0
  68. package/dist/cluster/heartbeat-manager.js.map +1 -0
  69. package/dist/cluster/raft-state-machine.d.ts +48 -0
  70. package/dist/cluster/raft-state-machine.d.ts.map +1 -0
  71. package/dist/cluster/raft-state-machine.js +207 -0
  72. package/dist/cluster/raft-state-machine.js.map +1 -0
  73. package/dist/cluster/runtime.d.ts +5 -29
  74. package/dist/cluster/runtime.d.ts.map +1 -1
  75. package/dist/cluster/runtime.js +58 -406
  76. package/dist/cluster/runtime.js.map +1 -1
  77. package/dist/integrations/jira/sync.d.ts +2 -5
  78. package/dist/integrations/jira/sync.d.ts.map +1 -1
  79. package/dist/integrations/jira/sync.js +116 -178
  80. package/dist/integrations/jira/sync.js.map +1 -1
  81. package/dist/utils/cli-helpers.d.ts +19 -0
  82. package/dist/utils/cli-helpers.d.ts.map +1 -0
  83. package/dist/utils/cli-helpers.js +51 -0
  84. package/dist/utils/cli-helpers.js.map +1 -0
  85. package/dist/utils/cli-helpers.test.d.ts +2 -0
  86. package/dist/utils/cli-helpers.test.d.ts.map +1 -0
  87. package/dist/utils/cli-helpers.test.js +100 -0
  88. package/dist/utils/cli-helpers.test.js.map +1 -0
  89. package/dist/utils/github-cli.d.ts +3 -0
  90. package/dist/utils/github-cli.d.ts.map +1 -0
  91. package/dist/utils/github-cli.js +4 -0
  92. package/dist/utils/github-cli.js.map +1 -0
  93. package/dist/utils/pr-sync.d.ts.map +1 -1
  94. package/dist/utils/pr-sync.js +1 -2
  95. package/dist/utils/pr-sync.js.map +1 -1
  96. package/dist/utils/story-status.d.ts +19 -0
  97. package/dist/utils/story-status.d.ts.map +1 -0
  98. package/dist/utils/story-status.js +58 -0
  99. package/dist/utils/story-status.js.map +1 -0
  100. package/dist/utils/story-status.test.d.ts +2 -0
  101. package/dist/utils/story-status.test.d.ts.map +1 -0
  102. package/dist/utils/story-status.test.js +65 -0
  103. package/dist/utils/story-status.test.js.map +1 -0
  104. package/package.json +1 -1
  105. package/src/cli/commands/agents.ts +3 -11
  106. package/src/cli/commands/approach.ts +2 -7
  107. package/src/cli/commands/init.test.ts +4 -0
  108. package/src/cli/commands/init.ts +9 -0
  109. package/src/cli/commands/manager/index.ts +166 -2236
  110. package/src/cli/commands/manager/manager-utils.ts +85 -0
  111. package/src/cli/commands/manager/pr-sync-orchestrator.ts +659 -0
  112. package/src/cli/commands/manager/qa-review-handler.ts +399 -0
  113. package/src/cli/commands/manager/stuck-story-helpers.ts +255 -0
  114. package/src/cli/commands/manager/stuck-story-processor.ts +604 -0
  115. package/src/cli/commands/manager/tech-lead-lifecycle.ts +210 -0
  116. package/src/cli/commands/my-stories.ts +5 -30
  117. package/src/cli/commands/pr.ts +6 -22
  118. package/src/cli/commands/progress.ts +2 -7
  119. package/src/cli/commands/resume.ts +3 -6
  120. package/src/cli/commands/status.ts +2 -5
  121. package/src/cli/commands/stories.ts +2 -5
  122. package/src/cluster/adapters.ts +3 -12
  123. package/src/cluster/cluster-http-server.ts +187 -0
  124. package/src/cluster/heartbeat-manager.ts +112 -0
  125. package/src/cluster/raft-state-machine.ts +267 -0
  126. package/src/cluster/runtime.ts +71 -515
  127. package/src/integrations/jira/sync.ts +157 -215
  128. package/src/utils/cli-helpers.test.ts +138 -0
  129. package/src/utils/cli-helpers.ts +61 -0
  130. package/src/utils/github-cli.ts +4 -0
  131. package/src/utils/pr-sync.ts +1 -3
  132. package/src/utils/story-status.test.ts +74 -0
  133. package/src/utils/story-status.ts +62 -0
@@ -0,0 +1,210 @@
1
+ // Licensed under the Hungry Ghost Hive License. See LICENSE.
2
+
3
+ import chalk from 'chalk';
4
+ import { getCliRuntimeBuilder, resolveRuntimeModelForCli } from '../../../cli-runtimes/index.js';
5
+ import { loadConfig } from '../../../config/loader.js';
6
+ import { getAgentsByType, updateAgent } from '../../../db/queries/agents.js';
7
+ import { createLog } from '../../../db/queries/logs.js';
8
+ import { getRequirementsByStatus } from '../../../db/queries/requirements.js';
9
+ import { getAllTeams } from '../../../db/queries/teams.js';
10
+ import { AgentState } from '../../../state-detectors/types.js';
11
+ import {
12
+ captureTmuxPane,
13
+ isTmuxSessionRunning,
14
+ killTmuxSession,
15
+ spawnTmuxSession,
16
+ } from '../../../tmux/manager.js';
17
+ import type { CLITool } from '../../../utils/cli-commands.js';
18
+ import { findHiveRoot as findHiveRootFromDir, getHivePaths } from '../../../utils/paths.js';
19
+ import { generateTechLeadPrompt } from '../req.js';
20
+ import { detectAgentState } from './agent-monitoring.js';
21
+ import { verboseLogCtx } from './manager-utils.js';
22
+ import { isTechLeadRestartOnCooldown } from './restart-cooldown.js';
23
+ import type { ManagerCheckContext } from './types.js';
24
+ import { TMUX_CAPTURE_LINES_SHORT } from './types.js';
25
+
26
+ const techLeadLastRestartByAgentId = new Map<string, number>();
27
+
28
+ export async function restartStaleTechLead(ctx: ManagerCheckContext): Promise<void> {
29
+ const maxAgeHours = ctx.config.manager.tech_lead_max_age_hours;
30
+ const maxAgeMs = maxAgeHours * 60 * 60 * 1000;
31
+ const now = Date.now();
32
+
33
+ // Phase 1: Read tech lead agents (brief lock)
34
+ const techLeads = await ctx.withDb(async db => {
35
+ const leads = getAgentsByType(db.db, 'tech_lead');
36
+ verboseLogCtx(ctx, `restartStaleTechLead: found ${leads.length} tech lead agent(s)`);
37
+ return leads.map(tl => ({
38
+ id: tl.id,
39
+ tmuxSession: tl.tmux_session,
40
+ cliTool: (tl.cli_tool || 'claude') as CLITool,
41
+ createdAt: tl.created_at,
42
+ }));
43
+ });
44
+
45
+ // Phase 2: Check sessions and restart (tmux I/O outside lock, DB writes under brief lock)
46
+ for (const techLead of techLeads) {
47
+ if (!techLead.tmuxSession) {
48
+ verboseLogCtx(ctx, `restartStaleTechLead: techLead=${techLead.id} skip=no_tmux_session`);
49
+ continue;
50
+ }
51
+
52
+ const sessionRunning = await isTmuxSessionRunning(techLead.tmuxSession);
53
+ if (!sessionRunning) {
54
+ verboseLogCtx(
55
+ ctx,
56
+ `restartStaleTechLead: techLead=${techLead.id} skip=session_not_running session=${techLead.tmuxSession}`
57
+ );
58
+ continue;
59
+ }
60
+
61
+ const createdAt = new Date(techLead.createdAt).getTime();
62
+ const ageMs = now - createdAt;
63
+ const ageHours = ageMs / (60 * 60 * 1000);
64
+
65
+ verboseLogCtx(
66
+ ctx,
67
+ `restartStaleTechLead: techLead=${techLead.id} age=${ageHours.toFixed(2)}h threshold=${maxAgeHours}h`
68
+ );
69
+
70
+ if (ageMs < maxAgeMs) {
71
+ verboseLogCtx(
72
+ ctx,
73
+ `restartStaleTechLead: techLead=${techLead.id} skip=not_stale remainingMs=${maxAgeMs - ageMs}`
74
+ );
75
+ continue;
76
+ }
77
+
78
+ const cooldown = isTechLeadRestartOnCooldown(
79
+ techLeadLastRestartByAgentId.get(techLead.id),
80
+ now,
81
+ maxAgeHours
82
+ );
83
+ if (cooldown.onCooldown) {
84
+ verboseLogCtx(
85
+ ctx,
86
+ `restartStaleTechLead: techLead=${techLead.id} skip=cooldown cooldownHours=${cooldown.cooldownHours} remainingMs=${cooldown.remainingMs}`
87
+ );
88
+ continue;
89
+ }
90
+
91
+ const output = await captureTmuxPane(techLead.tmuxSession, TMUX_CAPTURE_LINES_SHORT);
92
+ const stateResult = detectAgentState(output, techLead.cliTool);
93
+
94
+ verboseLogCtx(
95
+ ctx,
96
+ `restartStaleTechLead: techLead=${techLead.id} state=${stateResult.state} waiting=${stateResult.isWaiting} needsHuman=${stateResult.needsHuman}`
97
+ );
98
+
99
+ if (
100
+ !stateResult.isWaiting ||
101
+ stateResult.needsHuman ||
102
+ stateResult.state === AgentState.THINKING
103
+ ) {
104
+ verboseLogCtx(
105
+ ctx,
106
+ `restartStaleTechLead: techLead=${techLead.id} skip=not_safe_state state=${stateResult.state}`
107
+ );
108
+ continue;
109
+ }
110
+
111
+ verboseLogCtx(
112
+ ctx,
113
+ `restartStaleTechLead: techLead=${techLead.id} action=restarting session=${techLead.tmuxSession}`
114
+ );
115
+
116
+ // Kill the existing session (tmux I/O, no lock)
117
+ await killTmuxSession(techLead.tmuxSession);
118
+
119
+ // Spawn a new session with the same configuration (tmux I/O, no lock)
120
+ const hiveRoot = findHiveRootFromDir(ctx.root);
121
+ if (!hiveRoot) {
122
+ verboseLogCtx(ctx, `restartStaleTechLead: techLead=${techLead.id} error=hive_root_not_found`);
123
+ continue;
124
+ }
125
+
126
+ const paths = getHivePaths(hiveRoot);
127
+ const config = loadConfig(paths.hiveDir);
128
+ const agentConfig = config.models.tech_lead;
129
+ const cliTool = agentConfig.cli_tool;
130
+ const safetyMode = agentConfig.safety_mode;
131
+ const model = resolveRuntimeModelForCli(agentConfig.model, cliTool);
132
+
133
+ const runtimeBuilder = getCliRuntimeBuilder(cliTool);
134
+ const commandArgs = runtimeBuilder.buildSpawnCommand(model, safetyMode);
135
+
136
+ // Look up active requirement and teams to provide context to the restarted tech lead
137
+ const initialPrompt = await ctx.withDb(async db => {
138
+ const planningReqs = getRequirementsByStatus(db.db, 'planning');
139
+ const inProgressReqs = getRequirementsByStatus(db.db, 'in_progress');
140
+ const activeReq = planningReqs[0] ?? inProgressReqs[0] ?? null;
141
+ const teams = getAllTeams(db.db);
142
+
143
+ if (activeReq) {
144
+ return generateTechLeadPrompt(
145
+ activeReq.id,
146
+ activeReq.title,
147
+ activeReq.description,
148
+ teams,
149
+ activeReq.godmode === 1,
150
+ activeReq.target_branch || 'main'
151
+ );
152
+ }
153
+
154
+ return `You are the Tech Lead of Hive, an AI development team orchestrator.
155
+
156
+ You have been restarted to refresh your context. No active requirement is currently being planned.
157
+
158
+ ## Next Steps
159
+
160
+ 1. Check the current status of the Hive workspace:
161
+ \`\`\`bash
162
+ hive status
163
+ \`\`\`
164
+
165
+ 2. Check your inbox for messages from developers:
166
+ \`\`\`bash
167
+ hive msg inbox hive-tech-lead
168
+ \`\`\`
169
+
170
+ 3. If there are pending requirements, begin planning them. If all work is complete, monitor for new requirements.`;
171
+ });
172
+
173
+ await spawnTmuxSession({
174
+ sessionName: techLead.tmuxSession,
175
+ workDir: ctx.root,
176
+ commandArgs,
177
+ initialPrompt,
178
+ });
179
+
180
+ // DB writes under brief lock
181
+ await ctx.withDb(async db => {
182
+ createLog(db.db, {
183
+ agentId: 'manager',
184
+ eventType: 'AGENT_SPAWNED',
185
+ status: 'info',
186
+ message: `Tech lead ${techLead.id} restarted for context freshness (age: ${ageHours.toFixed(1)}h)`,
187
+ metadata: {
188
+ agent_id: techLead.id,
189
+ tmux_session: techLead.tmuxSession,
190
+ age_hours: ageHours,
191
+ threshold_hours: maxAgeHours,
192
+ restart_reason: 'context_freshness',
193
+ },
194
+ });
195
+ updateAgent(db.db, techLead.id, {
196
+ status: 'working',
197
+ createdAt: new Date().toISOString(),
198
+ });
199
+ db.save();
200
+ });
201
+
202
+ techLeadLastRestartByAgentId.set(techLead.id, now);
203
+
204
+ console.log(
205
+ chalk.green(
206
+ ` Tech lead ${techLead.id} restarted for context freshness (age: ${ageHours.toFixed(1)}h)`
207
+ )
208
+ );
209
+ }
210
+ }
@@ -6,6 +6,7 @@ import { syncStatusForStory } from '../../connectors/project-management/operatio
6
6
  import { queryAll, queryOne, run, type StoryRow } from '../../db/client.js';
7
7
  import { createLog } from '../../db/queries/logs.js';
8
8
  import { createStory, getStoryDependencies, updateStory } from '../../db/queries/stories.js';
9
+ import { requireAgentBySession, requireStory } from '../../utils/cli-helpers.js';
9
10
  import { withHiveContext, withReadOnlyHiveContext } from '../../utils/with-hive-context.js';
10
11
 
11
12
  export const myStoriesCommand = new Command('my-stories')
@@ -123,23 +124,10 @@ myStoriesCommand
123
124
  .action(async (storyId: string, options: { session: string }) => {
124
125
  await withHiveContext(async ({ root, db }) => {
125
126
  // Find agent by session
126
- const agent = queryOne<{ id: string }>(
127
- db.db,
128
- "SELECT id FROM agents WHERE tmux_session = ? AND status != 'terminated'",
129
- [options.session]
130
- );
131
-
132
- if (!agent) {
133
- console.error(chalk.red(`No agent found with session: ${options.session}`));
134
- process.exit(1);
135
- }
127
+ const agent = requireAgentBySession(db.db, options.session);
136
128
 
137
129
  // Check story exists and is available
138
- const story = queryOne<StoryRow>(db.db, 'SELECT * FROM stories WHERE id = ?', [storyId]);
139
- if (!story) {
140
- console.error(chalk.red(`Story not found: ${storyId}`));
141
- process.exit(1);
142
- }
130
+ const story = requireStory(db.db, storyId);
143
131
 
144
132
  if (story.assigned_agent_id && story.assigned_agent_id !== agent.id) {
145
133
  console.error(chalk.red(`Story already assigned to another agent.`));
@@ -185,11 +173,7 @@ myStoriesCommand
185
173
  .description('Mark a story as complete (ready for review)')
186
174
  .action(async (storyId: string) => {
187
175
  await withHiveContext(async ({ root, db }) => {
188
- const story = queryOne<StoryRow>(db.db, 'SELECT * FROM stories WHERE id = ?', [storyId]);
189
- if (!story) {
190
- console.error(chalk.red(`Story not found: ${storyId}`));
191
- process.exit(1);
192
- }
176
+ requireStory(db.db, storyId);
193
177
 
194
178
  run(
195
179
  db.db,
@@ -245,16 +229,7 @@ myStoriesCommand
245
229
  }
246
230
 
247
231
  await withHiveContext(async ({ db }) => {
248
- const agent = queryOne<{ id: string; team_id: string | null }>(
249
- db.db,
250
- "SELECT id, team_id FROM agents WHERE tmux_session = ? AND status != 'terminated'",
251
- [options.session]
252
- );
253
-
254
- if (!agent) {
255
- console.error(chalk.red(`No agent found with session: ${options.session}`));
256
- process.exit(1);
257
- }
232
+ const agent = requireAgentBySession(db.db, options.session);
258
233
 
259
234
  if (!agent.team_id) {
260
235
  console.error(
@@ -16,15 +16,15 @@ import {
16
16
  getMergeQueue,
17
17
  getNextInQueue,
18
18
  getOpenPullRequestsByStory,
19
- getPullRequestById,
20
19
  getQueuePosition,
21
20
  updatePullRequest,
22
21
  } from '../../db/queries/pull-requests.js';
23
- import { getStoryById, updateStory } from '../../db/queries/stories.js';
22
+ import { updateStory } from '../../db/queries/stories.js';
24
23
  import { getTeamById } from '../../db/queries/teams.js';
25
24
  import { Scheduler } from '../../orchestrator/scheduler.js';
26
25
  import { isTmuxSessionRunning, sendToTmuxSession } from '../../tmux/manager.js';
27
26
  import { autoMergeApprovedPRs } from '../../utils/auto-merge.js';
27
+ import { requirePullRequest, requireStory } from '../../utils/cli-helpers.js';
28
28
  import { markManualMergeRequired } from '../../utils/manual-merge.js';
29
29
  import { getExistingPRIdentifiers, syncOpenGitHubPRs } from '../../utils/pr-sync.js';
30
30
  import { extractStoryIdFromBranch, normalizeStoryId } from '../../utils/story-id.js';
@@ -57,11 +57,7 @@ prCommand
57
57
 
58
58
  // Get team from story
59
59
  let teamId = options.team || null;
60
- const story = getStoryById(db.db, storyId);
61
- if (!story) {
62
- console.error(chalk.red(`Story not found: ${storyId}`));
63
- process.exit(1);
64
- }
60
+ const story = requireStory(db.db, storyId);
65
61
 
66
62
  teamId = story.team_id;
67
63
 
@@ -277,11 +273,7 @@ prCommand
277
273
  .description('View details of a PR')
278
274
  .action(async (prId: string) => {
279
275
  await withReadOnlyHiveContext(async ({ db }) => {
280
- const pr = getPullRequestById(db.db, prId);
281
- if (!pr) {
282
- console.error(chalk.red(`PR not found: ${prId}`));
283
- process.exit(1);
284
- }
276
+ const pr = requirePullRequest(db.db, prId);
285
277
 
286
278
  console.log(chalk.bold(`\nPull Request: ${pr.id}\n`));
287
279
  console.log(chalk.gray(`Branch: ${pr.branch_name}`));
@@ -317,11 +309,7 @@ prCommand
317
309
  .option('--no-merge', 'Approve without merging (manual merge needed)')
318
310
  .action(async (prId: string, options: { notes?: string; from?: string; merge?: boolean }) => {
319
311
  await withHiveContext(async ({ root, db }) => {
320
- const pr = getPullRequestById(db.db, prId);
321
- if (!pr) {
322
- console.error(chalk.red(`PR not found: ${prId}`));
323
- process.exit(1);
324
- }
312
+ const pr = requirePullRequest(db.db, prId);
325
313
 
326
314
  if (pr.status === 'merged') {
327
315
  console.log(chalk.yellow('PR already merged.'));
@@ -443,11 +431,7 @@ prCommand
443
431
  .option('--from <session>', 'QA agent session')
444
432
  .action(async (prId: string, options: { reason: string; from?: string }) => {
445
433
  await withHiveContext(async ({ root, db }) => {
446
- const pr = getPullRequestById(db.db, prId);
447
- if (!pr) {
448
- console.error(chalk.red(`PR not found: ${prId}`));
449
- process.exit(1);
450
- }
434
+ const pr = requirePullRequest(db.db, prId);
451
435
 
452
436
  updatePullRequest(db.db, prId, {
453
437
  status: 'rejected',
@@ -9,7 +9,7 @@ import {
9
9
  } from '../../connectors/project-management/operations.js';
10
10
  import { queryOne } from '../../db/client.js';
11
11
  import { createLog } from '../../db/queries/logs.js';
12
- import type { StoryRow } from '../../db/queries/stories.js';
12
+ import { requireStory } from '../../utils/cli-helpers.js';
13
13
  import { withHiveContext } from '../../utils/with-hive-context.js';
14
14
 
15
15
  export const progressCommand = new Command('progress')
@@ -43,12 +43,7 @@ export const progressCommand = new Command('progress')
43
43
  return;
44
44
  }
45
45
 
46
- const story = queryOne<StoryRow>(db.db, 'SELECT * FROM stories WHERE id = ?', [storyId]);
47
-
48
- if (!story) {
49
- console.error(chalk.red(`Story not found: ${storyId}`));
50
- process.exit(1);
51
- }
46
+ const story = requireStory(db.db, storyId);
52
47
 
53
48
  if (!story.external_subtask_key) {
54
49
  console.error(
@@ -10,10 +10,11 @@ import {
10
10
  } from '../../cli-runtimes/index.js';
11
11
  import { loadConfig } from '../../config/index.js';
12
12
  import { withTransaction } from '../../db/client.js';
13
- import { getAgentById, getAllAgents, updateAgent, type AgentRow } from '../../db/queries/agents.js';
13
+ import { getAllAgents, updateAgent, type AgentRow } from '../../db/queries/agents.js';
14
14
  import { createLog } from '../../db/queries/logs.js';
15
15
  import { getTeamById } from '../../db/queries/teams.js';
16
16
  import { isTmuxAvailable, isTmuxSessionRunning, spawnTmuxSession } from '../../tmux/manager.js';
17
+ import { requireAgent } from '../../utils/cli-helpers.js';
17
18
  import { withHiveContext } from '../../utils/with-hive-context.js';
18
19
 
19
20
  export const resumeCommand = new Command('resume')
@@ -34,11 +35,7 @@ export const resumeCommand = new Command('resume')
34
35
  let agentsToResume: AgentRow[];
35
36
 
36
37
  if (options.agent) {
37
- const agent = getAgentById(db.db, options.agent);
38
- if (!agent) {
39
- console.error(chalk.red(`Agent not found: ${options.agent}`));
40
- process.exit(1);
41
- }
38
+ const agent = requireAgent(db.db, options.agent);
42
39
  if (agent.status === 'terminated') {
43
40
  console.error(chalk.red('Cannot resume a terminated agent'));
44
41
  process.exit(1);
@@ -13,6 +13,7 @@ import {
13
13
  getStoryDependencies,
14
14
  } from '../../db/queries/stories.js';
15
15
  import { getAllTeams, getTeamByName } from '../../db/queries/teams.js';
16
+ import { requireStory } from '../../utils/cli-helpers.js';
16
17
  import { statusColor } from '../../utils/logger.js';
17
18
  import { withReadOnlyHiveContext } from '../../utils/with-hive-context.js';
18
19
 
@@ -237,11 +238,7 @@ function showTeamStatus(db: import('sql.js').Database, teamName: string, json?:
237
238
  }
238
239
 
239
240
  function showStoryStatus(db: import('sql.js').Database, storyId: string, json?: boolean): void {
240
- const story = getStoryById(db, storyId);
241
- if (!story) {
242
- console.error(chalk.red(`Story not found: ${storyId}`));
243
- process.exit(1);
244
- }
241
+ const story = requireStory(db, storyId);
245
242
 
246
243
  const dependencies = getStoryDependencies(db, story.id);
247
244
  const logs = getLogsByStory(db, story.id).slice(0, 10);
@@ -12,6 +12,7 @@ import {
12
12
  updateStory,
13
13
  type StoryStatus,
14
14
  } from '../../db/queries/stories.js';
15
+ import { requireStory } from '../../utils/cli-helpers.js';
15
16
  import { statusColor } from '../../utils/logger.js';
16
17
  import { withHiveContext, withReadOnlyHiveContext } from '../../utils/with-hive-context.js';
17
18
 
@@ -144,11 +145,7 @@ storiesCommand
144
145
  .description('Show story details')
145
146
  .action(async (storyId: string) => {
146
147
  await withReadOnlyHiveContext(async ({ db }) => {
147
- const story = getStoryById(db.db, storyId);
148
- if (!story) {
149
- console.error(chalk.red(`Story not found: ${storyId}`));
150
- process.exit(1);
151
- }
148
+ const story = requireStory(db.db, storyId);
152
149
 
153
150
  const dependencies = getStoryDependencies(db.db, story.id);
154
151
 
@@ -1,7 +1,8 @@
1
1
  // Licensed under the Hungry Ghost Hive License. See LICENSE.
2
2
 
3
3
  import { run } from '../db/client.js';
4
- import type { ReplicatedTable, StoryRecord, TableAdapter } from './types.js';
4
+ import { STORY_STATUS_ORDER } from '../utils/story-status.js';
5
+ import type { ReplicatedTable, TableAdapter } from './types.js';
5
6
  import {
6
7
  asNullableNumber,
7
8
  asNullableString,
@@ -12,17 +13,7 @@ import {
12
13
  toAgentLogPayload,
13
14
  } from './utils.js';
14
15
 
15
- export const STORY_STATUS_ORDER: Array<StoryRecord['status']> = [
16
- 'draft',
17
- 'estimated',
18
- 'planned',
19
- 'in_progress',
20
- 'review',
21
- 'qa',
22
- 'qa_failed',
23
- 'pr_submitted',
24
- 'merged',
25
- ];
16
+ export { STORY_STATUS_ORDER };
26
17
 
27
18
  export const REPLICATED_TABLES: TableAdapter[] = [
28
19
  {
@@ -0,0 +1,187 @@
1
+ // Licensed under the Hungry Ghost Hive License. See LICENSE.
2
+
3
+ import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'http';
4
+ import type { ClusterConfig } from '../config/schema.js';
5
+ import type { ClusterEvent, VersionVector } from './replication.js';
6
+
7
+ interface DeltaRequest {
8
+ version_vector: VersionVector;
9
+ limit?: number;
10
+ }
11
+
12
+ interface DeltaResponse {
13
+ events: ClusterEvent[];
14
+ version_vector: VersionVector;
15
+ }
16
+
17
+ const MAX_CLUSTER_REQUEST_BODY_BYTES = 1024 * 1024; // 1 MiB
18
+
19
+ export interface ClusterHttpHandlers {
20
+ getStatus: () => unknown;
21
+ handleVoteRequest: (body: unknown) => unknown;
22
+ handleHeartbeat: (body: unknown) => unknown;
23
+ getDeltaFromCache: (vector: VersionVector, limit: number) => ClusterEvent[];
24
+ getVersionVectorCache: () => VersionVector;
25
+ }
26
+
27
+ export class ClusterHttpServer {
28
+ private server: Server | null = null;
29
+
30
+ constructor(
31
+ private readonly config: ClusterConfig,
32
+ private readonly handlers: ClusterHttpHandlers
33
+ ) {}
34
+
35
+ async startServer(): Promise<void> {
36
+ this.server = createServer((req, res) => {
37
+ void this.handleHttpRequest(req, res);
38
+ });
39
+
40
+ await new Promise<void>((resolve, reject) => {
41
+ if (!this.server) return reject(new Error('Cluster HTTP server not initialized'));
42
+
43
+ this.server.once('error', reject);
44
+ this.server.listen(this.config.listen_port, this.config.listen_host, () => {
45
+ this.server?.removeListener('error', reject);
46
+ resolve();
47
+ });
48
+ });
49
+ }
50
+
51
+ async stopServer(): Promise<void> {
52
+ if (this.server) {
53
+ await new Promise<void>(resolve => {
54
+ this.server?.close(() => resolve());
55
+ });
56
+ this.server = null;
57
+ }
58
+ }
59
+
60
+ private async handleHttpRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
61
+ try {
62
+ if (!this.authorize(req)) {
63
+ sendJson(res, 401, { error: 'Unauthorized' });
64
+ return;
65
+ }
66
+
67
+ const method = req.method || 'GET';
68
+ const path = req.url?.split('?')[0] || '/';
69
+
70
+ if (method === 'GET' && path === '/cluster/v1/status') {
71
+ sendJson(res, 200, this.handlers.getStatus());
72
+ return;
73
+ }
74
+
75
+ if (method === 'POST' && path === '/cluster/v1/election/request-vote') {
76
+ const body = await readJsonBody(req);
77
+ const response = this.handlers.handleVoteRequest(body);
78
+ sendJson(res, 200, response);
79
+ return;
80
+ }
81
+
82
+ if (method === 'POST' && path === '/cluster/v1/election/heartbeat') {
83
+ const body = await readJsonBody(req);
84
+ const response = this.handlers.handleHeartbeat(body);
85
+ sendJson(res, 200, response);
86
+ return;
87
+ }
88
+
89
+ if (method === 'POST' && path === '/cluster/v1/events/delta') {
90
+ const body = (await readJsonBody(req)) as Partial<DeltaRequest>;
91
+ const vector = toVersionVector(body.version_vector);
92
+ const limit =
93
+ typeof body.limit === 'number' && Number.isFinite(body.limit) && body.limit > 0
94
+ ? Math.floor(body.limit)
95
+ : 2000;
96
+
97
+ const events = this.handlers.getDeltaFromCache(vector, limit);
98
+ sendJson(res, 200, {
99
+ events,
100
+ version_vector: this.handlers.getVersionVectorCache(),
101
+ } satisfies DeltaResponse);
102
+ return;
103
+ }
104
+
105
+ sendJson(res, 404, { error: 'Not found' });
106
+ } catch (error) {
107
+ if (error instanceof HttpRequestError) {
108
+ sendJson(res, error.statusCode, { error: error.message });
109
+ return;
110
+ }
111
+
112
+ const message = error instanceof Error ? error.message : String(error);
113
+ sendJson(res, 500, { error: message });
114
+ }
115
+ }
116
+
117
+ private authorize(req: IncomingMessage): boolean {
118
+ if (!this.config.auth_token) return true;
119
+
120
+ const authHeader = req.headers.authorization;
121
+ if (!authHeader) return false;
122
+
123
+ const expected = `Bearer ${this.config.auth_token}`;
124
+ return authHeader === expected;
125
+ }
126
+ }
127
+
128
+ class HttpRequestError extends Error {
129
+ constructor(
130
+ public readonly statusCode: number,
131
+ message: string
132
+ ) {
133
+ super(message);
134
+ this.name = 'HttpRequestError';
135
+ }
136
+ }
137
+
138
+ async function readJsonBody(
139
+ req: IncomingMessage,
140
+ maxBytes: number = MAX_CLUSTER_REQUEST_BODY_BYTES
141
+ ): Promise<unknown> {
142
+ const chunks: Buffer[] = [];
143
+ let totalBytes = 0;
144
+
145
+ for await (const chunk of req) {
146
+ const normalizedChunk = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
147
+ totalBytes += normalizedChunk.length;
148
+
149
+ if (totalBytes > maxBytes) {
150
+ throw new HttpRequestError(413, `Payload too large (max ${maxBytes} bytes)`);
151
+ }
152
+
153
+ chunks.push(normalizedChunk);
154
+ }
155
+
156
+ if (chunks.length === 0) return {};
157
+
158
+ const raw = Buffer.concat(chunks).toString('utf-8');
159
+ if (!raw.trim()) return {};
160
+
161
+ try {
162
+ return JSON.parse(raw) as unknown;
163
+ } catch {
164
+ throw new HttpRequestError(400, 'Invalid JSON payload');
165
+ }
166
+ }
167
+
168
+ function sendJson(res: ServerResponse, statusCode: number, body: unknown): void {
169
+ res.statusCode = statusCode;
170
+ res.setHeader('Content-Type', 'application/json');
171
+ res.end(JSON.stringify(body));
172
+ }
173
+
174
+ function toVersionVector(input: unknown): VersionVector {
175
+ if (!input || typeof input !== 'object') return {};
176
+
177
+ const vector: VersionVector = {};
178
+
179
+ for (const [key, value] of Object.entries(input as Record<string, unknown>)) {
180
+ const num = typeof value === 'number' ? value : Number(value);
181
+ if (Number.isFinite(num) && num >= 0) {
182
+ vector[key] = Math.floor(num);
183
+ }
184
+ }
185
+
186
+ return vector;
187
+ }