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.
- package/dist/cli/commands/init.d.ts.map +1 -1
- package/dist/cli/commands/init.js +8 -0
- package/dist/cli/commands/init.js.map +1 -1
- package/dist/cli/commands/manager/auto-assignment.js +4 -4
- package/dist/cli/commands/manager/auto-assignment.js.map +1 -1
- package/dist/cli/commands/manager/auto-reject-comment-only-reviews.test.d.ts +2 -0
- package/dist/cli/commands/manager/auto-reject-comment-only-reviews.test.d.ts.map +1 -0
- package/dist/cli/commands/manager/auto-reject-comment-only-reviews.test.js +462 -0
- package/dist/cli/commands/manager/auto-reject-comment-only-reviews.test.js.map +1 -0
- package/dist/cli/commands/manager/index.d.ts +12 -0
- package/dist/cli/commands/manager/index.d.ts.map +1 -1
- package/dist/cli/commands/manager/index.js +171 -3
- package/dist/cli/commands/manager/index.js.map +1 -1
- package/dist/cli/wizard/init-wizard.d.ts +2 -1
- package/dist/cli/wizard/init-wizard.d.ts.map +1 -1
- package/dist/cli/wizard/init-wizard.js +56 -3
- package/dist/cli/wizard/init-wizard.js.map +1 -1
- package/dist/cli/wizard/init-wizard.test.js +56 -9
- package/dist/cli/wizard/init-wizard.test.js.map +1 -1
- package/dist/config/schema.d.ts +389 -0
- package/dist/config/schema.d.ts.map +1 -1
- package/dist/config/schema.js +18 -0
- package/dist/config/schema.js.map +1 -1
- package/dist/context-files/index.test.js +6 -0
- package/dist/context-files/index.test.js.map +1 -1
- package/dist/git/github.d.ts +9 -0
- package/dist/git/github.d.ts.map +1 -1
- package/dist/git/github.js +15 -0
- package/dist/git/github.js.map +1 -1
- package/dist/git/github.test.js +77 -0
- package/dist/git/github.test.js.map +1 -1
- package/dist/orchestrator/scheduler.d.ts.map +1 -1
- package/dist/orchestrator/scheduler.js +1 -0
- package/dist/orchestrator/scheduler.js.map +1 -1
- package/dist/tmux/manager.d.ts.map +1 -1
- package/dist/tmux/manager.js +15 -8
- package/dist/tmux/manager.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/commands/init.ts +8 -0
- package/src/cli/commands/manager/auto-assignment.ts +4 -4
- package/src/cli/commands/manager/auto-reject-comment-only-reviews.test.ts +589 -0
- package/src/cli/commands/manager/index.ts +228 -3
- package/src/cli/wizard/init-wizard.test.ts +62 -9
- package/src/cli/wizard/init-wizard.ts +66 -4
- package/src/config/schema.ts +20 -0
- package/src/context-files/index.test.ts +6 -0
- package/src/git/github.test.ts +93 -0
- package/src/git/github.ts +28 -0
- package/src/orchestrator/scheduler.ts +1 -0
- 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')
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
}
|
package/src/config/schema.ts
CHANGED
|
@@ -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: {
|