hungry-ghost-hive 0.40.3 → 0.41.1

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 (50) hide show
  1. package/dist/cli/commands/init.d.ts.map +1 -1
  2. package/dist/cli/commands/init.js +8 -0
  3. package/dist/cli/commands/init.js.map +1 -1
  4. package/dist/cli/commands/manager/auto-assignment.js +4 -4
  5. package/dist/cli/commands/manager/auto-assignment.js.map +1 -1
  6. package/dist/cli/commands/manager/auto-reject-comment-only-reviews.test.d.ts +2 -0
  7. package/dist/cli/commands/manager/auto-reject-comment-only-reviews.test.d.ts.map +1 -0
  8. package/dist/cli/commands/manager/auto-reject-comment-only-reviews.test.js +462 -0
  9. package/dist/cli/commands/manager/auto-reject-comment-only-reviews.test.js.map +1 -0
  10. package/dist/cli/commands/manager/index.d.ts +12 -0
  11. package/dist/cli/commands/manager/index.d.ts.map +1 -1
  12. package/dist/cli/commands/manager/index.js +171 -3
  13. package/dist/cli/commands/manager/index.js.map +1 -1
  14. package/dist/cli/wizard/init-wizard.d.ts +2 -1
  15. package/dist/cli/wizard/init-wizard.d.ts.map +1 -1
  16. package/dist/cli/wizard/init-wizard.js +56 -3
  17. package/dist/cli/wizard/init-wizard.js.map +1 -1
  18. package/dist/cli/wizard/init-wizard.test.js +56 -9
  19. package/dist/cli/wizard/init-wizard.test.js.map +1 -1
  20. package/dist/config/schema.d.ts +389 -0
  21. package/dist/config/schema.d.ts.map +1 -1
  22. package/dist/config/schema.js +18 -0
  23. package/dist/config/schema.js.map +1 -1
  24. package/dist/context-files/index.test.js +6 -0
  25. package/dist/context-files/index.test.js.map +1 -1
  26. package/dist/git/github.d.ts +9 -0
  27. package/dist/git/github.d.ts.map +1 -1
  28. package/dist/git/github.js +15 -0
  29. package/dist/git/github.js.map +1 -1
  30. package/dist/git/github.test.js +77 -0
  31. package/dist/git/github.test.js.map +1 -1
  32. package/dist/orchestrator/scheduler.d.ts.map +1 -1
  33. package/dist/orchestrator/scheduler.js +1 -0
  34. package/dist/orchestrator/scheduler.js.map +1 -1
  35. package/dist/tmux/manager.d.ts.map +1 -1
  36. package/dist/tmux/manager.js +15 -8
  37. package/dist/tmux/manager.js.map +1 -1
  38. package/package.json +1 -1
  39. package/src/cli/commands/init.ts +8 -0
  40. package/src/cli/commands/manager/auto-assignment.ts +4 -4
  41. package/src/cli/commands/manager/auto-reject-comment-only-reviews.test.ts +589 -0
  42. package/src/cli/commands/manager/index.ts +228 -3
  43. package/src/cli/wizard/init-wizard.test.ts +62 -9
  44. package/src/cli/wizard/init-wizard.ts +66 -4
  45. package/src/config/schema.ts +20 -0
  46. package/src/context-files/index.test.ts +6 -0
  47. package/src/git/github.test.ts +93 -0
  48. package/src/git/github.ts +28 -0
  49. package/src/orchestrator/scheduler.ts +1 -0
  50. package/src/tmux/manager.ts +19 -8
@@ -16,6 +16,7 @@ import { createLog } from '../../../db/queries/logs.js';
16
16
  import { getAllPendingMessages, markMessagesRead, } from '../../../db/queries/messages.js';
17
17
  import { backfillGithubPrNumbers, createPullRequest, getMergeQueue, getOpenPullRequestsByStory, getPullRequestsByStatus, updatePullRequest, } from '../../../db/queries/pull-requests.js';
18
18
  import { getStoriesByStatus, getStoryById, updateStory } from '../../../db/queries/stories.js';
19
+ import { getPullRequestComments, getPullRequestReviews } from '../../../git/github.js';
19
20
  import { Scheduler } from '../../../orchestrator/scheduler.js';
20
21
  import { AgentState } from '../../../state-detectors/types.js';
21
22
  import { captureTmuxPane, getHiveSessions, isManagerRunning, isTmuxSessionRunning, killTmuxSession, sendToTmuxSession, spawnTmuxSession, stopManager as stopManagerSession, } from '../../../tmux/manager.js';
@@ -688,6 +689,8 @@ async function managerCheck(root, config, clusterRuntime, verbose = false) {
688
689
  await batchMarkMessagesRead(ctx);
689
690
  verboseLogCtx(ctx, 'Step: notify QA about queued PRs');
690
691
  await notifyQAOfQueuedPRs(ctx);
692
+ verboseLogCtx(ctx, 'Step: auto-reject comment-only reviews');
693
+ await autoRejectCommentOnlyReviews(ctx);
691
694
  verboseLogCtx(ctx, 'Step: handle rejected PRs');
692
695
  await handleRejectedPRs(ctx);
693
696
  verboseLogCtx(ctx, 'Step: recover unassigned qa_failed stories');
@@ -1160,8 +1163,26 @@ async function recoverStaleReviewingPRs(ctx) {
1160
1163
  const parsed = JSON.parse(result.stdout);
1161
1164
  const state = parsed.state?.toUpperCase();
1162
1165
  const url = parsed.url || null;
1163
- if (state === 'OPEN')
1166
+ if (state === 'OPEN') {
1167
+ // PR is still open on GitHub but stale in 'reviewing' — the QA agent
1168
+ // may have missed the original nudge. Re-nudge if QA agent is idle.
1169
+ if (candidate.reviewedBy) {
1170
+ const qaAgent = ctx.agentsBySessionName.get(candidate.reviewedBy);
1171
+ if (qaAgent && qaAgent.status === 'idle') {
1172
+ const githubLine = candidate.repoSlug
1173
+ ? `\n# GitHub: https://github.com/${candidate.repoSlug}/pull/${candidate.githubPrNumber}`
1174
+ : '';
1175
+ await sendManagerNudge(ctx, candidate.reviewedBy, `# [REMINDER] You are assigned PR review ${candidate.id} (${candidate.storyId || 'no-story'}).${githubLine}
1176
+ # This PR has been waiting for review. Execute now:
1177
+ # hive pr show ${candidate.id}
1178
+ # hive pr approve ${candidate.id}
1179
+ # or reject:
1180
+ # hive pr reject ${candidate.id} -r "reason"`);
1181
+ verboseLogCtx(ctx, `recoverStaleReviewingPRs: re-nudged idle QA ${candidate.reviewedBy} for stale pr=${candidate.id}`);
1182
+ }
1183
+ }
1164
1184
  continue;
1185
+ }
1165
1186
  if (state === 'MERGED') {
1166
1187
  mergedResults.push({
1167
1188
  candidate,
@@ -1412,9 +1433,16 @@ async function scanAgentSessions(ctx) {
1412
1433
  for (const session of ctx.hiveSessions) {
1413
1434
  if (session.name === 'hive-manager')
1414
1435
  continue;
1415
- activeSessionNames.add(session.name);
1416
1436
  const agent = ctx.agentsBySessionName.get(session.name);
1417
- const agentCliTool = (agent?.cli_tool || 'claude');
1437
+ // Skip sessions not registered in our DB (cross-project sessions).
1438
+ // This prevents escalation noise from sessions belonging to other
1439
+ // teams/projects sharing the same tmux server.
1440
+ if (!agent) {
1441
+ verboseLogCtx(ctx, `Skipping ${session.name}: no agent registered in DB (cross-project)`);
1442
+ continue;
1443
+ }
1444
+ activeSessionNames.add(session.name);
1445
+ const agentCliTool = (agent.cli_tool || 'claude');
1418
1446
  const safetyMode = getAgentSafetyMode(ctx.config, agent);
1419
1447
  verboseLogCtx(ctx, `Agent check: ${session.name} (cli=${agentCliTool}, safety=${safetyMode}, story=${agent?.current_story_id || '-'})`);
1420
1448
  // Forward unread messages (tmux I/O, no lock needed)
@@ -1650,6 +1678,146 @@ async function notifyQAOfQueuedPRs(ctx) {
1650
1678
  }
1651
1679
  }
1652
1680
  }
1681
+ /**
1682
+ * Auto-reject PRs where the QA agent posted review comments/feedback on GitHub
1683
+ * but never formally approved or rejected via `hive pr approve/reject`.
1684
+ *
1685
+ * Detection: PR is in 'reviewing' status, the assigned QA agent is idle,
1686
+ * and there are GitHub comments or CHANGES_REQUESTED reviews on the PR.
1687
+ *
1688
+ * Action: Auto-reject the PR with the QA's feedback as the rejection reason,
1689
+ * which triggers the standard qa_failed flow back to the developer agent.
1690
+ */
1691
+ export async function autoRejectCommentOnlyReviews(ctx) {
1692
+ // Phase 1: Identify reviewing PRs with idle QA agents (brief lock)
1693
+ const candidates = await ctx.withDb(async (db) => {
1694
+ const reviewingPRs = getPullRequestsByStatus(db.db, 'reviewing').filter(pr => pr.github_pr_number && pr.team_id && pr.reviewed_by);
1695
+ verboseLogCtx(ctx, `autoRejectCommentOnlyReviews: reviewingWithQA=${reviewingPRs.length}`);
1696
+ if (reviewingPRs.length === 0)
1697
+ return [];
1698
+ // Only consider PRs whose QA agent is idle (finished reviewing but didn't approve/reject)
1699
+ const idlePRs = reviewingPRs.filter(pr => {
1700
+ const qaAgent = ctx.agentsBySessionName.get(pr.reviewed_by);
1701
+ if (!qaAgent)
1702
+ return false;
1703
+ // Check if the QA agent is idle or if their session shows idle state
1704
+ const qaState = agentStates.get(pr.reviewed_by);
1705
+ return qaAgent.status === 'idle' || qaState?.lastState === AgentState.IDLE_AT_PROMPT;
1706
+ });
1707
+ verboseLogCtx(ctx, `autoRejectCommentOnlyReviews: idleQACandidates=${idlePRs.length}`);
1708
+ if (idlePRs.length === 0)
1709
+ return [];
1710
+ const { getAllTeams } = await import('../../../db/queries/teams.js');
1711
+ const teams = getAllTeams(db.db);
1712
+ const teamsById = new Map(teams.map(team => [team.id, team]));
1713
+ return idlePRs
1714
+ .map(pr => {
1715
+ const team = teamsById.get(pr.team_id);
1716
+ if (!team?.repo_path)
1717
+ return null;
1718
+ return {
1719
+ id: pr.id,
1720
+ storyId: pr.story_id,
1721
+ teamId: pr.team_id,
1722
+ branchName: pr.branch_name,
1723
+ githubPrNumber: pr.github_pr_number,
1724
+ reviewedBy: pr.reviewed_by,
1725
+ submittedBy: pr.submitted_by,
1726
+ repoDir: `${ctx.root}/${team.repo_path}`,
1727
+ };
1728
+ })
1729
+ .filter(Boolean);
1730
+ });
1731
+ if (candidates.length === 0)
1732
+ return;
1733
+ // Phase 2: Check GitHub for comments/reviews on each candidate (no lock)
1734
+ const toReject = [];
1735
+ for (const candidate of candidates) {
1736
+ try {
1737
+ // Fetch both reviews and comments from GitHub
1738
+ const [reviews, comments] = await Promise.all([
1739
+ getPullRequestReviews(candidate.repoDir, candidate.githubPrNumber).catch(() => []),
1740
+ getPullRequestComments(candidate.repoDir, candidate.githubPrNumber).catch(() => []),
1741
+ ]);
1742
+ // If there's a formal APPROVED review, skip (QA approved via GitHub directly)
1743
+ const hasApproval = reviews.some(r => r.state === 'APPROVED');
1744
+ if (hasApproval) {
1745
+ verboseLogCtx(ctx, `autoRejectCommentOnlyReviews: pr=${candidate.id} has GitHub approval, skipping`);
1746
+ continue;
1747
+ }
1748
+ // Check for CHANGES_REQUESTED reviews
1749
+ const changesRequested = reviews.filter(r => r.state === 'CHANGES_REQUESTED');
1750
+ // Check for substantive issue comments (filter out bot noise and very short comments)
1751
+ const substantiveComments = comments.filter(c => {
1752
+ if (c.body.length < 20)
1753
+ return false;
1754
+ // Skip known bot comments (Ellipsis, etc.)
1755
+ if (c.body.includes('Looks good to me') && c.body.length < 100)
1756
+ return false;
1757
+ return true;
1758
+ });
1759
+ // If there are review feedback items, auto-reject
1760
+ if (changesRequested.length > 0 || substantiveComments.length > 0) {
1761
+ // Build rejection reason from the feedback
1762
+ const feedbackParts = [];
1763
+ for (const review of changesRequested) {
1764
+ if (review.body)
1765
+ feedbackParts.push(review.body);
1766
+ }
1767
+ for (const comment of substantiveComments) {
1768
+ feedbackParts.push(comment.body);
1769
+ }
1770
+ const reason = feedbackParts.length > 0
1771
+ ? feedbackParts.join('\n---\n').slice(0, 2000)
1772
+ : 'QA posted review feedback on GitHub without formal approval. See PR comments.';
1773
+ toReject.push({ candidate, reason });
1774
+ verboseLogCtx(ctx, `autoRejectCommentOnlyReviews: pr=${candidate.id} has ${changesRequested.length} changes_requested + ${substantiveComments.length} comments, will auto-reject`);
1775
+ }
1776
+ }
1777
+ catch (err) {
1778
+ verboseLogCtx(ctx, `autoRejectCommentOnlyReviews: skip pr=${candidate.id} github_check_failed=${err instanceof Error ? err.message : String(err)}`);
1779
+ }
1780
+ }
1781
+ if (toReject.length === 0)
1782
+ return;
1783
+ // Phase 3: Reject PRs in DB (brief lock)
1784
+ await ctx.withDb(async (db) => {
1785
+ for (const { candidate, reason } of toReject) {
1786
+ await withTransaction(db.db, () => {
1787
+ updatePullRequest(db.db, candidate.id, {
1788
+ status: 'rejected',
1789
+ reviewNotes: reason,
1790
+ });
1791
+ if (candidate.storyId) {
1792
+ updateStory(db.db, candidate.storyId, { status: 'qa_failed' });
1793
+ }
1794
+ createLog(db.db, {
1795
+ agentId: 'manager',
1796
+ eventType: 'PR_REJECTED',
1797
+ message: `Auto-rejected PR ${candidate.id}: QA posted review comments without formal approve/reject`,
1798
+ storyId: candidate.storyId || undefined,
1799
+ metadata: { pr_id: candidate.id, auto_rejected: true },
1800
+ });
1801
+ }, () => db.save());
1802
+ console.log(chalk.yellow(` Auto-rejected PR ${candidate.id} (story: ${candidate.storyId || '-'}): QA left review comments without approving`));
1803
+ }
1804
+ });
1805
+ // Phase 4: Notify developer agents via tmux (no lock)
1806
+ for (const { candidate, reason } of toReject) {
1807
+ if (candidate.submittedBy) {
1808
+ const devSession = ctx.hiveSessions.find(s => s.name === candidate.submittedBy);
1809
+ if (devSession) {
1810
+ await sendManagerNudge(ctx, devSession.name, `# ⚠️ PR AUTO-REJECTED - QA REVIEW FEEDBACK ⚠️
1811
+ # Story: ${candidate.storyId || 'Unknown'}
1812
+ # QA agent (${candidate.reviewedBy}) posted review feedback without formally approving.
1813
+ # Feedback:
1814
+ # ${reason.split('\n').slice(0, 10).join('\n# ')}
1815
+ #
1816
+ # Fix the issues and resubmit: hive pr submit -b ${candidate.branchName} -s ${candidate.storyId || 'STORY-ID'} --from ${devSession.name}`);
1817
+ }
1818
+ }
1819
+ }
1820
+ }
1653
1821
  async function handleRejectedPRs(ctx) {
1654
1822
  // Phase 1: Read rejected PRs and update DB (brief lock)
1655
1823
  const rejectedPRData = await ctx.withDb(async (db) => {