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
@@ -43,6 +43,7 @@ import {
43
43
  updatePullRequest,
44
44
  } from '../../../db/queries/pull-requests.js';
45
45
  import { getStoriesByStatus, getStoryById, updateStory } from '../../../db/queries/stories.js';
46
+ import { getPullRequestComments, getPullRequestReviews } from '../../../git/github.js';
46
47
  import { Scheduler } from '../../../orchestrator/scheduler.js';
47
48
  import { AgentState } from '../../../state-detectors/types.js';
48
49
  import {
@@ -979,6 +980,8 @@ async function managerCheck(
979
980
  await batchMarkMessagesRead(ctx);
980
981
  verboseLogCtx(ctx, 'Step: notify QA about queued PRs');
981
982
  await notifyQAOfQueuedPRs(ctx);
983
+ verboseLogCtx(ctx, 'Step: auto-reject comment-only reviews');
984
+ await autoRejectCommentOnlyReviews(ctx);
982
985
  verboseLogCtx(ctx, 'Step: handle rejected PRs');
983
986
  await handleRejectedPRs(ctx);
984
987
  verboseLogCtx(ctx, 'Step: recover unassigned qa_failed stories');
@@ -1555,7 +1558,33 @@ async function recoverStaleReviewingPRs(ctx: ManagerCheckContext): Promise<void>
1555
1558
  const state = parsed.state?.toUpperCase();
1556
1559
  const url = parsed.url || null;
1557
1560
 
1558
- if (state === 'OPEN') continue;
1561
+ if (state === 'OPEN') {
1562
+ // PR is still open on GitHub but stale in 'reviewing' — the QA agent
1563
+ // may have missed the original nudge. Re-nudge if QA agent is idle.
1564
+ if (candidate.reviewedBy) {
1565
+ const qaAgent = ctx.agentsBySessionName.get(candidate.reviewedBy);
1566
+ if (qaAgent && qaAgent.status === 'idle') {
1567
+ const githubLine = candidate.repoSlug
1568
+ ? `\n# GitHub: https://github.com/${candidate.repoSlug}/pull/${candidate.githubPrNumber}`
1569
+ : '';
1570
+ await sendManagerNudge(
1571
+ ctx,
1572
+ candidate.reviewedBy,
1573
+ `# [REMINDER] You are assigned PR review ${candidate.id} (${candidate.storyId || 'no-story'}).${githubLine}
1574
+ # This PR has been waiting for review. Execute now:
1575
+ # hive pr show ${candidate.id}
1576
+ # hive pr approve ${candidate.id}
1577
+ # or reject:
1578
+ # hive pr reject ${candidate.id} -r "reason"`
1579
+ );
1580
+ verboseLogCtx(
1581
+ ctx,
1582
+ `recoverStaleReviewingPRs: re-nudged idle QA ${candidate.reviewedBy} for stale pr=${candidate.id}`
1583
+ );
1584
+ }
1585
+ }
1586
+ continue;
1587
+ }
1559
1588
  if (state === 'MERGED') {
1560
1589
  mergedResults.push({
1561
1590
  candidate,
@@ -1876,10 +1905,19 @@ async function scanAgentSessions(ctx: ManagerCheckContext): Promise<void> {
1876
1905
  // Phase 2: Per-session processing (tmux/AI outside lock, DB writes under brief locks)
1877
1906
  for (const session of ctx.hiveSessions) {
1878
1907
  if (session.name === 'hive-manager') continue;
1879
- activeSessionNames.add(session.name);
1880
1908
 
1881
1909
  const agent = ctx.agentsBySessionName.get(session.name);
1882
- const agentCliTool = (agent?.cli_tool || 'claude') as CLITool;
1910
+
1911
+ // Skip sessions not registered in our DB (cross-project sessions).
1912
+ // This prevents escalation noise from sessions belonging to other
1913
+ // teams/projects sharing the same tmux server.
1914
+ if (!agent) {
1915
+ verboseLogCtx(ctx, `Skipping ${session.name}: no agent registered in DB (cross-project)`);
1916
+ continue;
1917
+ }
1918
+
1919
+ activeSessionNames.add(session.name);
1920
+ const agentCliTool = (agent.cli_tool || 'claude') as CLITool;
1883
1921
  const safetyMode = getAgentSafetyMode(ctx.config, agent);
1884
1922
  verboseLogCtx(
1885
1923
  ctx,
@@ -2230,6 +2268,193 @@ async function notifyQAOfQueuedPRs(ctx: ManagerCheckContext): Promise<void> {
2230
2268
  }
2231
2269
  }
2232
2270
 
2271
+ /**
2272
+ * Auto-reject PRs where the QA agent posted review comments/feedback on GitHub
2273
+ * but never formally approved or rejected via `hive pr approve/reject`.
2274
+ *
2275
+ * Detection: PR is in 'reviewing' status, the assigned QA agent is idle,
2276
+ * and there are GitHub comments or CHANGES_REQUESTED reviews on the PR.
2277
+ *
2278
+ * Action: Auto-reject the PR with the QA's feedback as the rejection reason,
2279
+ * which triggers the standard qa_failed flow back to the developer agent.
2280
+ */
2281
+ export async function autoRejectCommentOnlyReviews(ctx: ManagerCheckContext): Promise<void> {
2282
+ // Phase 1: Identify reviewing PRs with idle QA agents (brief lock)
2283
+ const candidates = await ctx.withDb(async db => {
2284
+ const reviewingPRs = getPullRequestsByStatus(db.db, 'reviewing').filter(
2285
+ pr => pr.github_pr_number && pr.team_id && pr.reviewed_by
2286
+ );
2287
+
2288
+ verboseLogCtx(ctx, `autoRejectCommentOnlyReviews: reviewingWithQA=${reviewingPRs.length}`);
2289
+ if (reviewingPRs.length === 0) return [];
2290
+
2291
+ // Only consider PRs whose QA agent is idle (finished reviewing but didn't approve/reject)
2292
+ const idlePRs = reviewingPRs.filter(pr => {
2293
+ const qaAgent = ctx.agentsBySessionName.get(pr.reviewed_by!);
2294
+ if (!qaAgent) return false;
2295
+ // Check if the QA agent is idle or if their session shows idle state
2296
+ const qaState = agentStates.get(pr.reviewed_by!);
2297
+ return qaAgent.status === 'idle' || qaState?.lastState === AgentState.IDLE_AT_PROMPT;
2298
+ });
2299
+
2300
+ verboseLogCtx(ctx, `autoRejectCommentOnlyReviews: idleQACandidates=${idlePRs.length}`);
2301
+ if (idlePRs.length === 0) return [];
2302
+
2303
+ const { getAllTeams } = await import('../../../db/queries/teams.js');
2304
+ const teams = getAllTeams(db.db);
2305
+ const teamsById = new Map(teams.map(team => [team.id, team]));
2306
+
2307
+ return idlePRs
2308
+ .map(pr => {
2309
+ const team = teamsById.get(pr.team_id!);
2310
+ if (!team?.repo_path) return null;
2311
+ return {
2312
+ id: pr.id,
2313
+ storyId: pr.story_id,
2314
+ teamId: pr.team_id!,
2315
+ branchName: pr.branch_name,
2316
+ githubPrNumber: pr.github_pr_number!,
2317
+ reviewedBy: pr.reviewed_by!,
2318
+ submittedBy: pr.submitted_by,
2319
+ repoDir: `${ctx.root}/${team.repo_path}`,
2320
+ };
2321
+ })
2322
+ .filter(Boolean) as Array<{
2323
+ id: string;
2324
+ storyId: string | null;
2325
+ branchName: string;
2326
+ githubPrNumber: number;
2327
+ reviewedBy: string;
2328
+ submittedBy: string | null;
2329
+ teamId: string;
2330
+ repoDir: string;
2331
+ }>;
2332
+ });
2333
+
2334
+ if (candidates.length === 0) return;
2335
+
2336
+ // Phase 2: Check GitHub for comments/reviews on each candidate (no lock)
2337
+ const toReject: Array<{
2338
+ candidate: (typeof candidates)[number];
2339
+ reason: string;
2340
+ }> = [];
2341
+
2342
+ for (const candidate of candidates) {
2343
+ try {
2344
+ // Fetch both reviews and comments from GitHub
2345
+ const [reviews, comments] = await Promise.all([
2346
+ getPullRequestReviews(candidate.repoDir, candidate.githubPrNumber).catch(
2347
+ (): Array<{ author: string; state: string; body: string }> => []
2348
+ ),
2349
+ getPullRequestComments(candidate.repoDir, candidate.githubPrNumber).catch(
2350
+ (): Array<{ author: string; body: string; createdAt: string }> => []
2351
+ ),
2352
+ ]);
2353
+
2354
+ // If there's a formal APPROVED review, skip (QA approved via GitHub directly)
2355
+ const hasApproval = reviews.some(r => r.state === 'APPROVED');
2356
+ if (hasApproval) {
2357
+ verboseLogCtx(
2358
+ ctx,
2359
+ `autoRejectCommentOnlyReviews: pr=${candidate.id} has GitHub approval, skipping`
2360
+ );
2361
+ continue;
2362
+ }
2363
+
2364
+ // Check for CHANGES_REQUESTED reviews
2365
+ const changesRequested = reviews.filter(r => r.state === 'CHANGES_REQUESTED');
2366
+
2367
+ // Check for substantive issue comments (filter out bot noise and very short comments)
2368
+ const substantiveComments = comments.filter(c => {
2369
+ if (c.body.length < 20) return false;
2370
+ // Skip known bot comments (Ellipsis, etc.)
2371
+ if (c.body.includes('Looks good to me') && c.body.length < 100) return false;
2372
+ return true;
2373
+ });
2374
+
2375
+ // If there are review feedback items, auto-reject
2376
+ if (changesRequested.length > 0 || substantiveComments.length > 0) {
2377
+ // Build rejection reason from the feedback
2378
+ const feedbackParts: string[] = [];
2379
+ for (const review of changesRequested) {
2380
+ if (review.body) feedbackParts.push(review.body);
2381
+ }
2382
+ for (const comment of substantiveComments) {
2383
+ feedbackParts.push(comment.body);
2384
+ }
2385
+ const reason =
2386
+ feedbackParts.length > 0
2387
+ ? feedbackParts.join('\n---\n').slice(0, 2000)
2388
+ : 'QA posted review feedback on GitHub without formal approval. See PR comments.';
2389
+
2390
+ toReject.push({ candidate, reason });
2391
+ verboseLogCtx(
2392
+ ctx,
2393
+ `autoRejectCommentOnlyReviews: pr=${candidate.id} has ${changesRequested.length} changes_requested + ${substantiveComments.length} comments, will auto-reject`
2394
+ );
2395
+ }
2396
+ } catch (err) {
2397
+ verboseLogCtx(
2398
+ ctx,
2399
+ `autoRejectCommentOnlyReviews: skip pr=${candidate.id} github_check_failed=${err instanceof Error ? err.message : String(err)}`
2400
+ );
2401
+ }
2402
+ }
2403
+
2404
+ if (toReject.length === 0) return;
2405
+
2406
+ // Phase 3: Reject PRs in DB (brief lock)
2407
+ await ctx.withDb(async db => {
2408
+ for (const { candidate, reason } of toReject) {
2409
+ await withTransaction(
2410
+ db.db,
2411
+ () => {
2412
+ updatePullRequest(db.db, candidate.id, {
2413
+ status: 'rejected',
2414
+ reviewNotes: reason,
2415
+ });
2416
+ if (candidate.storyId) {
2417
+ updateStory(db.db, candidate.storyId, { status: 'qa_failed' });
2418
+ }
2419
+ createLog(db.db, {
2420
+ agentId: 'manager',
2421
+ eventType: 'PR_REJECTED',
2422
+ message: `Auto-rejected PR ${candidate.id}: QA posted review comments without formal approve/reject`,
2423
+ storyId: candidate.storyId || undefined,
2424
+ metadata: { pr_id: candidate.id, auto_rejected: true },
2425
+ });
2426
+ },
2427
+ () => db.save()
2428
+ );
2429
+ console.log(
2430
+ chalk.yellow(
2431
+ ` Auto-rejected PR ${candidate.id} (story: ${candidate.storyId || '-'}): QA left review comments without approving`
2432
+ )
2433
+ );
2434
+ }
2435
+ });
2436
+
2437
+ // Phase 4: Notify developer agents via tmux (no lock)
2438
+ for (const { candidate, reason } of toReject) {
2439
+ if (candidate.submittedBy) {
2440
+ const devSession = ctx.hiveSessions.find(s => s.name === candidate.submittedBy);
2441
+ if (devSession) {
2442
+ await sendManagerNudge(
2443
+ ctx,
2444
+ devSession.name,
2445
+ `# ⚠️ PR AUTO-REJECTED - QA REVIEW FEEDBACK ⚠️
2446
+ # Story: ${candidate.storyId || 'Unknown'}
2447
+ # QA agent (${candidate.reviewedBy}) posted review feedback without formally approving.
2448
+ # Feedback:
2449
+ # ${reason.split('\n').slice(0, 10).join('\n# ')}
2450
+ #
2451
+ # Fix the issues and resubmit: hive pr submit -b ${candidate.branchName} -s ${candidate.storyId || 'STORY-ID'} --from ${devSession.name}`
2452
+ );
2453
+ }
2454
+ }
2455
+ }
2456
+ }
2457
+
2233
2458
  async function handleRejectedPRs(ctx: ManagerCheckContext): Promise<void> {
2234
2459
  // Phase 1: Read rejected PRs and update DB (brief lock)
2235
2460
  const rejectedPRData = await ctx.withDb(async db => {
@@ -138,12 +138,13 @@ describe('Init Wizard', () => {
138
138
  mockSelect.mockResolvedValueOnce('none');
139
139
  mockSelect.mockResolvedValueOnce('full');
140
140
  mockSelect.mockResolvedValueOnce('claude');
141
- vi.mocked(confirm).mockResolvedValueOnce(false);
141
+ // personas=no, E2E=no
142
+ vi.mocked(confirm).mockResolvedValueOnce(false).mockResolvedValueOnce(false);
142
143
 
143
144
  const result = await runInitWizard();
144
145
 
145
146
  expect(mockSelect).toHaveBeenCalledTimes(4);
146
- expect(confirm).toHaveBeenCalledTimes(1);
147
+ expect(confirm).toHaveBeenCalledTimes(2);
147
148
  expect(result).toEqual({
148
149
  integrations: {
149
150
  source_control: { provider: 'github' },
@@ -160,7 +161,8 @@ describe('Init Wizard', () => {
160
161
  mockSelect.mockResolvedValueOnce('none');
161
162
  mockSelect.mockResolvedValueOnce('full');
162
163
  mockSelect.mockResolvedValueOnce('claude');
163
- vi.mocked(confirm).mockResolvedValueOnce(true);
164
+ // personas=no, E2E=yes
165
+ vi.mocked(confirm).mockResolvedValueOnce(false).mockResolvedValueOnce(true);
164
166
  vi.mocked(input).mockResolvedValueOnce('./e2e');
165
167
 
166
168
  const result = await runInitWizard();
@@ -174,7 +176,8 @@ describe('Init Wizard', () => {
174
176
  mockSelect.mockResolvedValueOnce('none');
175
177
  mockSelect.mockResolvedValueOnce('full');
176
178
  mockSelect.mockResolvedValueOnce('claude');
177
- vi.mocked(confirm).mockResolvedValueOnce(false);
179
+ // personas=no, E2E=no
180
+ vi.mocked(confirm).mockResolvedValueOnce(false).mockResolvedValueOnce(false);
178
181
 
179
182
  const result = await runInitWizard();
180
183
 
@@ -187,7 +190,8 @@ describe('Init Wizard', () => {
187
190
  mockSelect.mockResolvedValueOnce('jira');
188
191
  mockSelect.mockResolvedValueOnce('partial');
189
192
  mockSelect.mockResolvedValueOnce('codex');
190
- vi.mocked(confirm).mockResolvedValueOnce(false);
193
+ // personas=no, E2E=no
194
+ vi.mocked(confirm).mockResolvedValueOnce(false).mockResolvedValueOnce(false);
191
195
 
192
196
  const mockInput = vi.mocked(input);
193
197
  mockInput.mockResolvedValueOnce('test-client-id');
@@ -224,7 +228,8 @@ describe('Init Wizard', () => {
224
228
  mockSelect.mockResolvedValueOnce('none');
225
229
  mockSelect.mockResolvedValueOnce('full');
226
230
  mockSelect.mockResolvedValueOnce('claude');
227
- vi.mocked(confirm).mockResolvedValueOnce(false);
231
+ // personas=no, E2E=no
232
+ vi.mocked(confirm).mockResolvedValueOnce(false).mockResolvedValueOnce(false);
228
233
 
229
234
  // Mock registry to return available providers
230
235
  vi.mocked(registry.listSourceControlProviders).mockReturnValueOnce(['github']);
@@ -242,7 +247,8 @@ describe('Init Wizard', () => {
242
247
  mockSelect.mockResolvedValueOnce('none');
243
248
  mockSelect.mockResolvedValueOnce('full');
244
249
  mockSelect.mockResolvedValueOnce('claude');
245
- vi.mocked(confirm).mockResolvedValueOnce(false);
250
+ // personas=no, E2E=no
251
+ vi.mocked(confirm).mockResolvedValueOnce(false).mockResolvedValueOnce(false);
246
252
 
247
253
  // Mock registry to return available providers
248
254
  vi.mocked(registry.listProjectManagementProviders).mockReturnValueOnce(['jira']);
@@ -263,7 +269,8 @@ describe('Init Wizard', () => {
263
269
  mockSelect.mockResolvedValueOnce('none');
264
270
  mockSelect.mockResolvedValueOnce('full');
265
271
  mockSelect.mockResolvedValueOnce('claude');
266
- vi.mocked(confirm).mockResolvedValueOnce(false);
272
+ // personas=no, E2E=no
273
+ vi.mocked(confirm).mockResolvedValueOnce(false).mockResolvedValueOnce(false);
267
274
 
268
275
  await runInitWizard();
269
276
 
@@ -283,7 +290,8 @@ describe('Init Wizard', () => {
283
290
  mockSelect.mockResolvedValueOnce('none');
284
291
  mockSelect.mockResolvedValueOnce('full');
285
292
  mockSelect.mockResolvedValueOnce('claude');
286
- vi.mocked(confirm).mockResolvedValueOnce(false);
293
+ // personas=no, E2E=no
294
+ vi.mocked(confirm).mockResolvedValueOnce(false).mockResolvedValueOnce(false);
287
295
 
288
296
  await runInitWizard();
289
297
 
@@ -297,6 +305,51 @@ describe('Init Wizard', () => {
297
305
  { name: 'Codex', value: 'codex' },
298
306
  ]);
299
307
  });
308
+
309
+ it('should configure personas when user opts in', async () => {
310
+ const mockSelect = vi.mocked(select);
311
+ mockSelect.mockResolvedValueOnce('github');
312
+ mockSelect.mockResolvedValueOnce('none');
313
+ mockSelect.mockResolvedValueOnce('full');
314
+ mockSelect.mockResolvedValueOnce('claude');
315
+ // personas=yes, E2E=no
316
+ vi.mocked(confirm).mockResolvedValueOnce(true).mockResolvedValueOnce(false);
317
+ const mockInput = vi.mocked(input);
318
+ // tech_lead: 1 persona
319
+ mockInput.mockResolvedValueOnce('1');
320
+ mockInput.mockResolvedValueOnce('Ava');
321
+ mockInput.mockResolvedValueOnce('Strategic thinker');
322
+ // senior: 0
323
+ mockInput.mockResolvedValueOnce('0');
324
+ // intermediate: 0
325
+ mockInput.mockResolvedValueOnce('0');
326
+ // junior: 0
327
+ mockInput.mockResolvedValueOnce('0');
328
+ // qa: 0
329
+ mockInput.mockResolvedValueOnce('0');
330
+ // feature_test: 0
331
+ mockInput.mockResolvedValueOnce('0');
332
+
333
+ const result = await runInitWizard();
334
+
335
+ expect(result.personas).toEqual({
336
+ tech_lead: [{ name: 'Ava', persona: 'Strategic thinker' }],
337
+ });
338
+ });
339
+
340
+ it('should not include personas when user declines', async () => {
341
+ const mockSelect = vi.mocked(select);
342
+ mockSelect.mockResolvedValueOnce('github');
343
+ mockSelect.mockResolvedValueOnce('none');
344
+ mockSelect.mockResolvedValueOnce('full');
345
+ mockSelect.mockResolvedValueOnce('claude');
346
+ // personas=no, E2E=no
347
+ vi.mocked(confirm).mockResolvedValueOnce(false).mockResolvedValueOnce(false);
348
+
349
+ const result = await runInitWizard();
350
+
351
+ expect(result.personas).toBeUndefined();
352
+ });
300
353
  });
301
354
 
302
355
  describe('e2e_tests non-interactive', () => {
@@ -6,7 +6,7 @@ import { existsSync } from 'fs';
6
6
  import { join, resolve } from 'path';
7
7
  import { startJiraOAuthFlow, storeJiraTokens } from '../../auth/jira-oauth.js';
8
8
  import { TokenStore } from '../../auth/token-store.js';
9
- import type { E2ETestsConfig, IntegrationsConfig } from '../../config/schema.js';
9
+ import type { E2ETestsConfig, IntegrationsConfig, PersonaConfig } from '../../config/schema.js';
10
10
  import { bootstrapConnectors } from '../../connectors/bootstrap.js';
11
11
  import { registry } from '../../connectors/registry.js';
12
12
  import { openBrowser } from '../../utils/open-browser.js';
@@ -29,6 +29,7 @@ export interface InitWizardResult {
29
29
  integrations: IntegrationsConfig;
30
30
  agent_runtime: AgentRuntime;
31
31
  e2e_tests?: E2ETestsConfig;
32
+ personas?: Record<string, PersonaConfig[]>;
32
33
  }
33
34
 
34
35
  export async function runInitWizard(options: InitWizardOptions = {}): Promise<InitWizardResult> {
@@ -100,7 +101,63 @@ export async function runInitWizard(options: InitWizardOptions = {}): Promise<In
100
101
  default: 'claude',
101
102
  });
102
103
 
103
- // Step 5: E2E testing configuration (optional)
104
+ // Step 5: Agent personas (optional)
105
+ const agentTypes: { key: string; label: string }[] = [
106
+ { key: 'tech_lead', label: 'Tech Lead' },
107
+ { key: 'senior', label: 'Senior Developer' },
108
+ { key: 'intermediate', label: 'Intermediate Developer' },
109
+ { key: 'junior', label: 'Junior Developer' },
110
+ { key: 'qa', label: 'QA Engineer' },
111
+ { key: 'feature_test', label: 'Feature Test Agent' },
112
+ ];
113
+
114
+ let personas: Record<string, PersonaConfig[]> | undefined;
115
+
116
+ const wantsPersonas = await confirm({
117
+ message: 'Configure agent personas?',
118
+ default: false,
119
+ });
120
+
121
+ if (wantsPersonas) {
122
+ personas = {};
123
+ for (const { key, label } of agentTypes) {
124
+ const countStr = await input({
125
+ message: `How many personas for ${label}? (0 to skip)`,
126
+ default: '0',
127
+ validate: (value: string) => {
128
+ const n = parseInt(value, 10);
129
+ if (isNaN(n) || n < 0 || !Number.isInteger(n)) {
130
+ return 'Enter a non-negative integer';
131
+ }
132
+ return true;
133
+ },
134
+ });
135
+
136
+ const count = parseInt(countStr, 10);
137
+ if (count > 0) {
138
+ const agentPersonas: PersonaConfig[] = [];
139
+ for (let i = 1; i <= count; i++) {
140
+ const name = await input({
141
+ message: `Name for ${label} persona #${i}`,
142
+ validate: (value: string) => (value.length > 0 ? true : 'Name is required'),
143
+ });
144
+ const persona = await input({
145
+ message: `Personality description for ${name}`,
146
+ validate: (value: string) => (value.length > 0 ? true : 'Description is required'),
147
+ });
148
+ agentPersonas.push({ name, persona });
149
+ }
150
+ personas[key] = agentPersonas;
151
+ }
152
+ }
153
+
154
+ // Only keep personas if at least one agent type has entries
155
+ if (Object.keys(personas).length === 0) {
156
+ personas = undefined;
157
+ }
158
+ }
159
+
160
+ // Step 6: E2E testing configuration (optional)
104
161
  const wantsE2E = await confirm({
105
162
  message: 'Configure E2E testing?',
106
163
  default: false,
@@ -138,7 +195,8 @@ export async function runInitWizard(options: InitWizardOptions = {}): Promise<In
138
195
  autonomy as 'full' | 'partial',
139
196
  agentRuntime as AgentRuntime,
140
197
  options,
141
- e2eTestPath
198
+ e2eTestPath,
199
+ personas
142
200
  );
143
201
  }
144
202
 
@@ -227,7 +285,8 @@ async function buildResult(
227
285
  autonomy: 'full' | 'partial',
228
286
  agentRuntime: AgentRuntime,
229
287
  options: InitWizardOptions = {},
230
- e2eTestPath?: string
288
+ e2eTestPath?: string,
289
+ personas?: Record<string, PersonaConfig[]>
231
290
  ): Promise<InitWizardResult> {
232
291
  const integrations: IntegrationsConfig = {
233
292
  source_control: { provider: sourceControl },
@@ -314,5 +373,8 @@ async function buildResult(
314
373
  if (e2eTestPath) {
315
374
  result.e2e_tests = { path: e2eTestPath };
316
375
  }
376
+ if (personas) {
377
+ result.personas = personas;
378
+ }
317
379
  return result;
318
380
  }
@@ -2,6 +2,12 @@
2
2
 
3
3
  import { z } from 'zod';
4
4
 
5
+ // Persona configuration for agent personality
6
+ const PersonaSchema = z.object({
7
+ name: z.string(),
8
+ persona: z.string(),
9
+ });
10
+
5
11
  // Model configuration for each agent type
6
12
  const ModelConfigSchema = z.object({
7
13
  provider: z.enum(['anthropic', 'openai']),
@@ -10,6 +16,7 @@ const ModelConfigSchema = z.object({
10
16
  temperature: z.number().min(0).max(2).default(0.5),
11
17
  cli_tool: z.enum(['claude', 'codex', 'gemini']).optional().default('claude'),
12
18
  safety_mode: z.enum(['safe', 'unsafe']).optional().default('unsafe'),
19
+ personas: z.array(PersonaSchema).optional().default([]),
13
20
  });
14
21
 
15
22
  // Models configuration for all agent types
@@ -324,6 +331,7 @@ export const HiveConfigSchema = z.object({
324
331
  });
325
332
 
326
333
  // Export types
334
+ export type PersonaConfig = z.infer<typeof PersonaSchema>;
327
335
  export type ModelConfig = z.infer<typeof ModelConfigSchema>;
328
336
  export type ModelsConfig = z.infer<typeof ModelsConfigSchema>;
329
337
  export type ScalingConfig = z.infer<typeof ScalingConfigSchema>;
@@ -385,6 +393,8 @@ models:
385
393
  cli_tool: claude
386
394
  # Runtime safety policy (safe = approval prompts, unsafe = full automation)
387
395
  safety_mode: unsafe
396
+ # Agent personas can be configured during 'hive init'.
397
+ # Re-run 'hive init --force' to reconfigure.
388
398
 
389
399
  senior:
390
400
  provider: anthropic
@@ -393,6 +403,8 @@ models:
393
403
  temperature: 0.5
394
404
  cli_tool: claude
395
405
  safety_mode: unsafe
406
+ # Agent personas can be configured during 'hive init'.
407
+ # Re-run 'hive init --force' to reconfigure.
396
408
 
397
409
  intermediate:
398
410
  provider: anthropic
@@ -401,6 +413,8 @@ models:
401
413
  temperature: 0.3
402
414
  cli_tool: claude
403
415
  safety_mode: unsafe
416
+ # Agent personas can be configured during 'hive init'.
417
+ # Re-run 'hive init --force' to reconfigure.
404
418
 
405
419
  junior:
406
420
  provider: anthropic
@@ -409,6 +423,8 @@ models:
409
423
  temperature: 0.2
410
424
  cli_tool: claude
411
425
  safety_mode: unsafe
426
+ # Agent personas can be configured during 'hive init'.
427
+ # Re-run 'hive init --force' to reconfigure.
412
428
 
413
429
  qa:
414
430
  provider: anthropic
@@ -417,6 +433,8 @@ models:
417
433
  temperature: 0.2
418
434
  cli_tool: claude
419
435
  safety_mode: unsafe
436
+ # Agent personas can be configured during 'hive init'.
437
+ # Re-run 'hive init --force' to reconfigure.
420
438
 
421
439
  feature_test:
422
440
  provider: anthropic
@@ -425,6 +443,8 @@ models:
425
443
  temperature: 0.3
426
444
  cli_tool: claude
427
445
  safety_mode: unsafe
446
+ # Agent personas can be configured during 'hive init'.
447
+ # Re-run 'hive init --force' to reconfigure.
428
448
 
429
449
  # Team scaling rules
430
450
  scaling:
@@ -122,6 +122,7 @@ describe('context-files module', () => {
122
122
  temperature: 0.7,
123
123
  cli_tool: 'claude',
124
124
  safety_mode: 'unsafe',
125
+ personas: [],
125
126
  },
126
127
  senior: {
127
128
  provider: 'anthropic',
@@ -130,6 +131,7 @@ describe('context-files module', () => {
130
131
  temperature: 0.5,
131
132
  cli_tool: 'claude',
132
133
  safety_mode: 'unsafe',
134
+ personas: [],
133
135
  },
134
136
  intermediate: {
135
137
  provider: 'anthropic',
@@ -138,6 +140,7 @@ describe('context-files module', () => {
138
140
  temperature: 0.3,
139
141
  cli_tool: 'claude',
140
142
  safety_mode: 'unsafe',
143
+ personas: [],
141
144
  },
142
145
  junior: {
143
146
  provider: 'anthropic',
@@ -146,6 +149,7 @@ describe('context-files module', () => {
146
149
  temperature: 0.3,
147
150
  cli_tool: 'claude',
148
151
  safety_mode: 'unsafe',
152
+ personas: [],
149
153
  },
150
154
  qa: {
151
155
  provider: 'anthropic',
@@ -154,6 +158,7 @@ describe('context-files module', () => {
154
158
  temperature: 0.2,
155
159
  cli_tool: 'claude',
156
160
  safety_mode: 'unsafe',
161
+ personas: [],
157
162
  },
158
163
  feature_test: {
159
164
  provider: 'anthropic',
@@ -162,6 +167,7 @@ describe('context-files module', () => {
162
167
  temperature: 0.3,
163
168
  cli_tool: 'claude',
164
169
  safety_mode: 'unsafe',
170
+ personas: [],
165
171
  },
166
172
  },
167
173
  scaling: {