@nyxa/nyx-agent 0.7.0 → 0.8.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.
@@ -1,22 +1,25 @@
1
1
  import path from "node:path";
2
+ import { execa } from "execa";
2
3
  import { loadConfig } from "../config/loadConfig.js";
3
- import { ensureDir, pathExists, readText } from "./files.js";
4
+ import { globalReviewRoles, } from "../config/schema.js";
5
+ import { ensureDir, pathExists, readText, writeText } from "./files.js";
4
6
  import { deleteBranch, removeRunWorktree, setUpRunWorktree, } from "./gitLifecycle.js";
5
7
  import { markWorkItemCompleted, readWorkItemLedger, writeWorkItemLedger, } from "./ledger.js";
6
8
  import { getNyxDir, relativeToProject } from "./paths.js";
7
- import { buildContextBlock, buildPhasePrompt, EXECUTION_PROMPT, GLOBAL_REVIEW_PROMPT, GLOBAL_REVISION_PROMPT, REVIEW_PROMPT, REVISION_PROMPT, SELECTION_PROMPT, truncateForPrompt, } from "./prompts.js";
9
+ import { buildContextBlock, buildPhasePrompt, EXECUTION_PROMPT, GLOBAL_REVIEW_CHALLENGE_PROMPT, GLOBAL_REVIEW_PROMPT, GLOBAL_REVIEW_VALIDATION_PROMPT, GLOBAL_REVISION_PROMPT, REVIEW_CHALLENGE_PROMPT, REVIEW_PROMPT, REVIEW_VALIDATION_PROMPT, REVISION_PROMPT, SELECTION_PROMPT, } from "./prompts.js";
8
10
  import { createRunReporter } from "./reporter.js";
9
11
  import { runAgentPhase, } from "./runPhase.js";
10
- import { GLOBAL_REVIEW_SCHEMA, REVIEW_SCHEMA, SELECTION_SCHEMA, } from "./schemas.js";
12
+ import { REVIEW_CHALLENGE_SCHEMA, REVIEW_DISCOVERY_SCHEMA, GLOBAL_REVIEW_SCHEMA, REVIEW_VALIDATION_SCHEMA, SELECTION_SCHEMA, } from "./schemas.js";
11
13
  import { commitAll, commitsAhead, createPullRequest, pushBranch, rangeDiff, stageAllAndDiff, } from "./scm.js";
12
14
  import { confirmWorkItemSelection, } from "./selectionConfirmation.js";
13
15
  import { createRunId } from "./time.js";
14
- import { filterAvailable, listGitHubIssues, resolveSelectedQueue, } from "./workItems.js";
16
+ import { filterAvailable, listGitHubWorkItemInventory, resolveSelectedQueue, } from "./workItems.js";
15
17
  const MAX_CANDIDATES = 50;
16
18
  const EXCERPT_CHARS = 800;
19
+ const CORRECTION_VALIDATION_MAX_ATTEMPTS = 3;
17
20
  export function defaultPipelineDependencies() {
18
21
  return {
19
- listIssues: listGitHubIssues,
22
+ listIssues: listGitHubWorkItemInventory,
20
23
  runPhase: runAgentPhase,
21
24
  pushBranch,
22
25
  createPullRequest,
@@ -37,25 +40,32 @@ export async function runPipeline(input = {}, deps = defaultPipelineDependencies
37
40
  const nyxDir = getNyxDir(projectRoot);
38
41
  const configPath = input.configPath ?? path.join(nyxDir, "config.json");
39
42
  const config = await loadConfig(configPath);
40
- const harness = input.harness ?? config.harness;
43
+ const baseAgent = resolveAgentProfile({
44
+ config,
45
+ cliHarness: input.harness,
46
+ });
41
47
  const runId = createRunId();
42
48
  const runDir = path.join(nyxDir, "runs", runId);
43
49
  await ensureDir(runDir);
44
50
  const reporter = input.reporter ?? createRunReporter({ verbose: input.verbose ?? false });
45
51
  reporter.heading(`NyxAgent run ${runId}`);
46
- reporter.info(`Harness: ${harness} · model: ${config.model} · review: ${config.review}`);
52
+ reporter.info(`Harness: ${baseAgent.harness} · model: ${baseAgent.model} · review: ${config.review}`);
47
53
  reporter.detail(`Config: ${relativeToProject(projectRoot, configPath)}`);
48
54
  reporter.detail(`Artifacts: ${relativeToProject(projectRoot, runDir)}`);
49
55
  reporter.detail(`Tracker: ${config.tracker.repo}`);
56
+ if (config.review_max_attempts !== undefined) {
57
+ reporter.warn("review_max_attempts is deprecated and ignored; use review_rounds.each/global instead.");
58
+ }
50
59
  const ledger = await readWorkItemLedger(nyxDir);
51
60
  reporter.detail(`Completed work items already in ledger: ${ledger.completed_work_item_keys.length}`);
52
61
  // 1. Selection runs read-only in the main checkout, before any branch exists.
62
+ const inventory = normalizeWorkItemInventory(await deps.listIssues({
63
+ repo: config.tracker.repo,
64
+ maxCandidates: MAX_CANDIDATES,
65
+ excerptChars: EXCERPT_CHARS,
66
+ }));
53
67
  const candidates = filterAvailable({
54
- candidates: await deps.listIssues({
55
- repo: config.tracker.repo,
56
- maxCandidates: MAX_CANDIDATES,
57
- excerptChars: EXCERPT_CHARS,
58
- }),
68
+ candidates: inventory.candidates,
59
69
  completedKeys: ledger.completed_work_item_keys,
60
70
  });
61
71
  reporter.detail(`Available work item candidates: ${candidates.length}`);
@@ -66,7 +76,7 @@ export async function runPipeline(input = {}, deps = defaultPipelineDependencies
66
76
  const proposed = await runSelection({
67
77
  projectRoot,
68
78
  runDir,
69
- harness,
79
+ cliHarness: input.harness,
70
80
  config,
71
81
  candidates,
72
82
  runPhase: deps.runPhase,
@@ -114,7 +124,7 @@ export async function runPipeline(input = {}, deps = defaultPipelineDependencies
114
124
  item,
115
125
  guidance: executionGuidance,
116
126
  git,
117
- harness,
127
+ cliHarness: input.harness,
118
128
  config,
119
129
  runPhase: deps.runPhase,
120
130
  reporter,
@@ -124,9 +134,9 @@ export async function runPipeline(input = {}, deps = defaultPipelineDependencies
124
134
  iterationDir,
125
135
  item,
126
136
  git,
127
- harness,
137
+ cliHarness: input.harness,
128
138
  config,
129
- maxAttempts: config.review_max_attempts,
139
+ rounds: config.review_rounds.each,
130
140
  runPhase: deps.runPhase,
131
141
  reporter,
132
142
  });
@@ -153,9 +163,10 @@ export async function runPipeline(input = {}, deps = defaultPipelineDependencies
153
163
  const corrections = await runGlobalReviewLoop({
154
164
  runDir,
155
165
  git,
156
- harness,
166
+ completed,
167
+ cliHarness: input.harness,
157
168
  config,
158
- maxAttempts: config.review_max_attempts,
169
+ rounds: config.review_rounds.global,
159
170
  runPhase: deps.runPhase,
160
171
  reporter,
161
172
  });
@@ -177,7 +188,10 @@ export async function runPipeline(input = {}, deps = defaultPipelineDependencies
177
188
  base: git.base,
178
189
  head: git.branch,
179
190
  title: buildPrTitle(completed),
180
- body: buildPrBody(completed),
191
+ body: buildPrBody({
192
+ items: completed,
193
+ parents: inventory.parents,
194
+ }),
181
195
  });
182
196
  reporter.success(`\nPull request opened: ${prUrl}`);
183
197
  success = true;
@@ -193,6 +207,7 @@ export async function runPipeline(input = {}, deps = defaultPipelineDependencies
193
207
  producedCommits,
194
208
  completed,
195
209
  config,
210
+ parents: inventory.parents,
196
211
  deps,
197
212
  reporter,
198
213
  });
@@ -242,7 +257,11 @@ async function salvageFailedRun(input) {
242
257
  base: input.git.base,
243
258
  head: input.git.branch,
244
259
  title: buildDraftPrTitle(input.completed),
245
- body: buildDraftPrBody(input.completed, reason),
260
+ body: buildDraftPrBody({
261
+ items: input.completed,
262
+ parents: input.parents,
263
+ reason,
264
+ }),
246
265
  draft: true,
247
266
  });
248
267
  input.reporter.warn(`\nRun failed, but the work was salvaged into a DRAFT pull request: ${url}`);
@@ -257,6 +276,10 @@ async function salvageFailedRun(input) {
257
276
  }
258
277
  }
259
278
  async function runSelection(input) {
279
+ const agent = resolveAgentProfile({
280
+ config: input.config,
281
+ cliHarness: input.cliHarness,
282
+ });
260
283
  const context = buildContextBlock([
261
284
  ["Repository", input.config.tracker.repo],
262
285
  ["Max work items this run", input.config.max_iterations],
@@ -267,6 +290,7 @@ async function runSelection(input) {
267
290
  number: candidate.number,
268
291
  title: candidate.title,
269
292
  labels: candidate.labels,
293
+ parent: candidate.parent,
270
294
  excerpt: candidate.excerpt,
271
295
  })),
272
296
  ],
@@ -275,9 +299,9 @@ async function runSelection(input) {
275
299
  phaseId: "selection",
276
300
  phaseDir: path.join(input.runDir, "selection"),
277
301
  workdir: input.projectRoot,
278
- harness: input.harness,
279
- model: input.config.model,
280
- reasoning: input.config.reasoning_effort,
302
+ harness: agent.harness,
303
+ model: agent.model,
304
+ reasoning: agent.reasoning_effort,
281
305
  capability: "readonly",
282
306
  prompt: buildPhasePrompt({
283
307
  guidance: SELECTION_PROMPT,
@@ -304,6 +328,11 @@ async function runSelection(input) {
304
328
  return resolved.queue;
305
329
  }
306
330
  async function runExecution(input) {
331
+ const agent = resolveAgentProfile({
332
+ config: input.config,
333
+ cliHarness: input.cliHarness,
334
+ phase: "execution",
335
+ });
307
336
  const context = buildContextBlock([
308
337
  ["Work item", workItemSummary(input.item)],
309
338
  ["Issue description", input.item.excerpt ?? "(no description provided)"],
@@ -314,9 +343,9 @@ async function runExecution(input) {
314
343
  phaseId: "execution",
315
344
  phaseDir: path.join(input.iterationDir, "execution"),
316
345
  workdir: input.git.worktree,
317
- harness: input.harness,
318
- model: input.config.model,
319
- reasoning: input.config.reasoning_effort,
346
+ harness: agent.harness,
347
+ model: agent.model,
348
+ reasoning: agent.reasoning_effort,
320
349
  capability: "write",
321
350
  prompt: buildPhasePrompt({ guidance: input.guidance, context }),
322
351
  reporter: input.reporter,
@@ -326,54 +355,275 @@ async function runExecution(input) {
326
355
  }
327
356
  }
328
357
  async function runReviewLoop(input) {
329
- for (let attempt = 1; attempt <= input.maxAttempts; attempt += 1) {
330
- const diff = await stageAllAndDiff(input.git.worktree);
331
- const reviewResult = await input.runPhase({
358
+ const agent = resolveAgentProfile({
359
+ config: input.config,
360
+ cliHarness: input.cliHarness,
361
+ phase: "review",
362
+ });
363
+ const validationHistory = [];
364
+ for (let round = 1; round <= input.rounds; round += 1) {
365
+ const roundDir = path.join(input.iterationDir, `review-round-${round}`);
366
+ const discoveryPack = await createReviewContextPack({
367
+ dir: path.join(roundDir, "discovery", "review-context"),
368
+ git: input.git,
369
+ scope: "item",
370
+ workItems: [input.item],
371
+ validations: validationHistory,
372
+ });
373
+ const discoveryResult = await input.runPhase({
332
374
  phaseId: "review",
333
- phaseDir: path.join(input.iterationDir, `review-${attempt}`),
375
+ phaseDir: path.join(roundDir, "discovery"),
334
376
  workdir: input.git.worktree,
335
- harness: input.harness,
336
- model: input.config.model,
337
- reasoning: input.config.reasoning_effort,
377
+ harness: agent.harness,
378
+ model: agent.model,
379
+ reasoning: agent.reasoning_effort,
338
380
  capability: "readonly",
339
381
  prompt: buildPhasePrompt({
340
382
  guidance: REVIEW_PROMPT,
341
383
  context: buildContextBlock([
342
384
  ["Work item", workItemSummary(input.item)],
343
- [
344
- "Uncommitted changes (diff)",
345
- truncateForPrompt(diff || "(no changes)"),
346
- ],
385
+ ["Review round", round],
386
+ ["Review context", reviewContextSummary(discoveryPack)],
347
387
  ]),
348
- schema: REVIEW_SCHEMA,
388
+ schema: REVIEW_DISCOVERY_SCHEMA,
349
389
  }),
350
- schema: REVIEW_SCHEMA,
390
+ schema: REVIEW_DISCOVERY_SCHEMA,
351
391
  reporter: input.reporter,
352
392
  });
353
- if (!reviewResult.ok) {
354
- throw new Error(reviewResult.error);
393
+ if (!discoveryResult.ok) {
394
+ throw new Error(discoveryResult.error);
355
395
  }
356
- const review = reviewResult.result;
357
- input.reporter.info(` review: ${review.outcome}`);
358
- if (review.outcome === "approved") {
359
- return;
396
+ const discovery = discoveryResult.result;
397
+ const proposedBlockers = normalizeFindings(discovery.blockers);
398
+ input.reporter.info(` review round ${round}: ${proposedBlockers.length} proposed blocker(s)`);
399
+ const challenge = await runReviewChallenge({
400
+ phaseId: "review_challenge",
401
+ phaseDir: path.join(roundDir, "challenge"),
402
+ workdir: input.git.worktree,
403
+ agent,
404
+ runPhase: input.runPhase,
405
+ reporter: input.reporter,
406
+ guidance: REVIEW_CHALLENGE_PROMPT,
407
+ contextEntries: [
408
+ ["Work item", workItemSummary(input.item)],
409
+ ["Review round", round],
410
+ ["Review context", reviewContextSummary(discoveryPack)],
411
+ ["Proposed blockers", proposedBlockers],
412
+ ["Rejected findings from discovery", discovery.rejected_findings ?? []],
413
+ ],
414
+ });
415
+ input.reporter.info(` review round ${round}: ${challenge.blockers.length} verified blocker(s)`);
416
+ await runCorrectionValidationLoop({
417
+ scope: "item",
418
+ roundDir,
419
+ git: input.git,
420
+ workItems: [input.item],
421
+ validationHistory,
422
+ blockers: challenge.blockers,
423
+ agent,
424
+ runPhase: input.runPhase,
425
+ reporter: input.reporter,
426
+ revisionPhaseId: "revision",
427
+ validationPhaseId: "review_validation",
428
+ revisionGuidance: REVISION_PROMPT,
429
+ validationGuidance: REVIEW_VALIDATION_PROMPT,
430
+ failureMessage: (blockers) => `Review for #${input.item.number} has unresolved blocker(s) after ${CORRECTION_VALIDATION_MAX_ATTEMPTS} correction validation attempts:${formatBlockers(blockers)}`,
431
+ });
432
+ }
433
+ }
434
+ async function runGlobalReviewLoop(input) {
435
+ let committedCorrections = false;
436
+ const validationHistory = [];
437
+ for (let round = 1; round <= input.rounds; round += 1) {
438
+ const roundDir = path.join(input.runDir, "final", `global-round-${round}`);
439
+ const discoveryPack = await createReviewContextPack({
440
+ dir: path.join(roundDir, "discovery", "review-context"),
441
+ git: input.git,
442
+ scope: "global",
443
+ workItems: input.completed,
444
+ validations: validationHistory,
445
+ });
446
+ const discoveries = [];
447
+ for (const role of globalReviewRoles) {
448
+ const roleAgent = resolveAgentProfile({
449
+ config: input.config,
450
+ cliHarness: input.cliHarness,
451
+ phase: "global_review",
452
+ role,
453
+ });
454
+ const reviewResult = await input.runPhase({
455
+ phaseId: "global_review",
456
+ phaseDir: path.join(roundDir, "discovery", role),
457
+ workdir: input.git.worktree,
458
+ harness: roleAgent.harness,
459
+ model: roleAgent.model,
460
+ reasoning: roleAgent.reasoning_effort,
461
+ capability: "readonly",
462
+ prompt: buildPhasePrompt({
463
+ guidance: buildGlobalReviewGuidance(role),
464
+ context: buildContextBlock([
465
+ ["Run branch", `${input.git.branch} (base ${input.git.base})`],
466
+ ["Review round", round],
467
+ ["Reviewer role", role],
468
+ ["Review context", reviewContextSummary(discoveryPack)],
469
+ ]),
470
+ schema: GLOBAL_REVIEW_SCHEMA,
471
+ }),
472
+ schema: GLOBAL_REVIEW_SCHEMA,
473
+ reporter: input.reporter,
474
+ });
475
+ if (!reviewResult.ok) {
476
+ throw new Error(reviewResult.error);
477
+ }
478
+ discoveries.push(reviewResult.result);
360
479
  }
361
- if (attempt === input.maxAttempts) {
362
- throw new Error(`Review for #${input.item.number} not approved after ${input.maxAttempts} attempts: ${review.summary}${formatRequiredChanges(review.required_changes)}`);
480
+ const aggregated = aggregateDiscoveries(discoveries);
481
+ input.reporter.info(`global review round ${round}: ${aggregated.blockers.length} proposed blocker(s)`);
482
+ const challengeAgent = resolveAgentProfile({
483
+ config: input.config,
484
+ cliHarness: input.cliHarness,
485
+ phase: "global_review",
486
+ });
487
+ const challenge = await runReviewChallenge({
488
+ phaseId: "global_review_challenge",
489
+ phaseDir: path.join(roundDir, "challenge"),
490
+ workdir: input.git.worktree,
491
+ agent: challengeAgent,
492
+ runPhase: input.runPhase,
493
+ reporter: input.reporter,
494
+ guidance: GLOBAL_REVIEW_CHALLENGE_PROMPT,
495
+ contextEntries: [
496
+ ["Run branch", `${input.git.branch} (base ${input.git.base})`],
497
+ ["Review round", round],
498
+ ["Review context", reviewContextSummary(discoveryPack)],
499
+ ["Aggregated proposed blockers", aggregated.blockers],
500
+ ["Aggregated rejected findings", aggregated.rejected_findings],
501
+ ],
502
+ });
503
+ input.reporter.info(`global review round ${round}: ${challenge.blockers.length} verified blocker(s)`);
504
+ const roundCommittedCorrections = await runCorrectionValidationLoop({
505
+ scope: "global",
506
+ roundDir,
507
+ git: input.git,
508
+ workItems: input.completed,
509
+ validationHistory,
510
+ blockers: challenge.blockers,
511
+ agent: challengeAgent,
512
+ runPhase: input.runPhase,
513
+ reporter: input.reporter,
514
+ revisionPhaseId: "global_revision",
515
+ validationPhaseId: "global_review_validation",
516
+ revisionGuidance: GLOBAL_REVISION_PROMPT,
517
+ validationGuidance: GLOBAL_REVIEW_VALIDATION_PROMPT,
518
+ commitMessage: "Apply global review corrections",
519
+ failureMessage: (blockers) => `Global review has unresolved blocker(s) after ${CORRECTION_VALIDATION_MAX_ATTEMPTS} correction validation attempts:${formatBlockers(blockers)}`,
520
+ });
521
+ if (roundCommittedCorrections) {
522
+ committedCorrections = true;
363
523
  }
524
+ }
525
+ return committedCorrections;
526
+ }
527
+ function resolveAgentProfile(input) {
528
+ const profile = {
529
+ harness: input.cliHarness ?? input.config.harness,
530
+ model: input.config.model,
531
+ reasoning_effort: input.config.reasoning_effort,
532
+ };
533
+ const phaseOverride = input.phase
534
+ ? phaseAgentOverride(input.config, input.phase)
535
+ : undefined;
536
+ const roleOverride = input.role
537
+ ? input.config.agents?.global_review?.roles?.[input.role]
538
+ : undefined;
539
+ return applyAgentOverride(applyAgentOverride(profile, phaseOverride), roleOverride);
540
+ }
541
+ function phaseAgentOverride(config, phase) {
542
+ if (phase === "execution") {
543
+ return config.agents?.execution;
544
+ }
545
+ if (phase === "review") {
546
+ return config.agents?.review;
547
+ }
548
+ return config.agents?.global_review;
549
+ }
550
+ function applyAgentOverride(profile, override) {
551
+ if (!override) {
552
+ return profile;
553
+ }
554
+ return {
555
+ harness: override.harness ?? profile.harness,
556
+ model: override.model ?? profile.model,
557
+ reasoning_effort: override.reasoning_effort ?? profile.reasoning_effort,
558
+ };
559
+ }
560
+ function buildGlobalReviewGuidance(role) {
561
+ const focus = {
562
+ "diff-contract": "Focus on the public contract of the diff: APIs, CLI behavior, schemas, config compatibility, and generated artifacts.",
563
+ integration: "Focus on integration across touched modules, phase sequencing, artifact paths, and cross-item behavior.",
564
+ "domain-invariants": "Focus on NyxAgent workflow invariants: engine-owned git side effects, read-only review phases, closed pipeline control flow, and review semantics.",
565
+ "tests-validation": "Focus on test coverage, validation evidence, failure modes, and whether the committed changes are demonstrably safe.",
566
+ };
567
+ return `${GLOBAL_REVIEW_PROMPT}\n\nRole focus (${role}): ${focus[role]}`;
568
+ }
569
+ async function runReviewChallenge(input) {
570
+ const result = await input.runPhase({
571
+ phaseId: input.phaseId,
572
+ phaseDir: input.phaseDir,
573
+ workdir: input.workdir,
574
+ harness: input.agent.harness,
575
+ model: input.agent.model,
576
+ reasoning: input.agent.reasoning_effort,
577
+ capability: "readonly",
578
+ prompt: buildPhasePrompt({
579
+ guidance: input.guidance,
580
+ context: buildContextBlock(input.contextEntries),
581
+ schema: REVIEW_CHALLENGE_SCHEMA,
582
+ }),
583
+ schema: REVIEW_CHALLENGE_SCHEMA,
584
+ reporter: input.reporter,
585
+ });
586
+ if (!result.ok) {
587
+ throw new Error(result.error);
588
+ }
589
+ const challenge = result.result;
590
+ return {
591
+ ...challenge,
592
+ blockers: normalizeFindings(challenge.blockers),
593
+ rejected_findings: normalizeFindings(challenge.rejected_findings),
594
+ };
595
+ }
596
+ async function runCorrectionValidationLoop(input) {
597
+ let pending = normalizeFindings(input.blockers);
598
+ let committedCorrections = false;
599
+ if (pending.length === 0) {
600
+ return committedCorrections;
601
+ }
602
+ for (let attempt = 1; attempt <= CORRECTION_VALIDATION_MAX_ATTEMPTS; attempt += 1) {
603
+ const revisionPack = await createReviewContextPack({
604
+ dir: path.join(input.roundDir, `${input.revisionPhaseId}-${attempt}`, "review-context"),
605
+ git: input.git,
606
+ scope: input.scope,
607
+ workItems: input.workItems,
608
+ validations: input.validationHistory,
609
+ });
364
610
  const revision = await input.runPhase({
365
- phaseId: "revision",
366
- phaseDir: path.join(input.iterationDir, `revision-${attempt}`),
611
+ phaseId: input.revisionPhaseId,
612
+ phaseDir: path.join(input.roundDir, `${input.revisionPhaseId}-${attempt}`),
367
613
  workdir: input.git.worktree,
368
- harness: input.harness,
369
- model: input.config.model,
370
- reasoning: input.config.reasoning_effort,
614
+ harness: input.agent.harness,
615
+ model: input.agent.model,
616
+ reasoning: input.agent.reasoning_effort,
371
617
  capability: "write",
372
618
  prompt: buildPhasePrompt({
373
- guidance: REVISION_PROMPT,
619
+ guidance: input.revisionGuidance,
374
620
  context: buildContextBlock([
375
- ["Work item", workItemSummary(input.item)],
376
- ["Required changes", review.required_changes ?? []],
621
+ ["Review context", reviewContextSummary(revisionPack)],
622
+ [
623
+ "Correction attempt",
624
+ `${attempt}/${CORRECTION_VALIDATION_MAX_ATTEMPTS}`,
625
+ ],
626
+ ["Verified blockers", pending],
377
627
  ]),
378
628
  }),
379
629
  reporter: input.reporter,
@@ -381,74 +631,233 @@ async function runReviewLoop(input) {
381
631
  if (!revision.ok) {
382
632
  throw new Error(revision.error);
383
633
  }
384
- }
385
- }
386
- async function runGlobalReviewLoop(input) {
387
- let committedCorrections = false;
388
- for (let attempt = 1; attempt <= input.maxAttempts; attempt += 1) {
389
- const diff = await rangeDiff(input.git.worktree, input.git.base);
390
- const reviewResult = await input.runPhase({
391
- phaseId: "global_review",
392
- phaseDir: path.join(input.runDir, "final", `global-review-${attempt}`),
634
+ if (input.commitMessage) {
635
+ const { committed } = await commitAll({
636
+ cwd: input.git.worktree,
637
+ message: input.commitMessage,
638
+ });
639
+ if (committed) {
640
+ committedCorrections = true;
641
+ input.reporter.detail("Committed global review corrections.");
642
+ }
643
+ }
644
+ const validationPack = await createReviewContextPack({
645
+ dir: path.join(input.roundDir, `${input.validationPhaseId}-${attempt}`, "review-context"),
646
+ git: input.git,
647
+ scope: input.scope,
648
+ workItems: input.workItems,
649
+ validations: input.validationHistory,
650
+ });
651
+ const validationResult = await input.runPhase({
652
+ phaseId: input.validationPhaseId,
653
+ phaseDir: path.join(input.roundDir, `${input.validationPhaseId}-${attempt}`),
393
654
  workdir: input.git.worktree,
394
- harness: input.harness,
395
- model: input.config.model,
396
- reasoning: input.config.reasoning_effort,
655
+ harness: input.agent.harness,
656
+ model: input.agent.model,
657
+ reasoning: input.agent.reasoning_effort,
397
658
  capability: "readonly",
398
659
  prompt: buildPhasePrompt({
399
- guidance: GLOBAL_REVIEW_PROMPT,
660
+ guidance: input.validationGuidance,
400
661
  context: buildContextBlock([
401
- ["Run branch", `${input.git.branch} (base ${input.git.base})`],
662
+ ["Review context", reviewContextSummary(validationPack)],
402
663
  [
403
- "Combined run diff (base...HEAD)",
404
- truncateForPrompt(diff || "(no changes)"),
664
+ "Correction attempt",
665
+ `${attempt}/${CORRECTION_VALIDATION_MAX_ATTEMPTS}`,
405
666
  ],
667
+ ["Validated blockers", pending],
406
668
  ]),
407
- schema: GLOBAL_REVIEW_SCHEMA,
669
+ schema: REVIEW_VALIDATION_SCHEMA,
408
670
  }),
409
- schema: GLOBAL_REVIEW_SCHEMA,
671
+ schema: REVIEW_VALIDATION_SCHEMA,
410
672
  reporter: input.reporter,
411
673
  });
412
- if (!reviewResult.ok) {
413
- throw new Error(reviewResult.error);
674
+ if (!validationResult.ok) {
675
+ throw new Error(validationResult.error);
414
676
  }
415
- const review = reviewResult.result;
416
- input.reporter.info(`global review: ${review.outcome}`);
417
- if (review.outcome === "approved") {
677
+ const validation = validationResult.result;
678
+ const validations = normalizeValidations(validation.validations);
679
+ input.validationHistory.push(...validations);
680
+ pending = blockersNeedingCorrection(pending, validations);
681
+ input.reporter.info(`${input.scope} validation attempt ${attempt}: ${pending.length} unresolved blocker(s)`);
682
+ if (pending.length === 0) {
418
683
  return committedCorrections;
419
684
  }
420
- if (attempt === input.maxAttempts) {
421
- throw new Error(`Global review not approved after ${input.maxAttempts} attempts: ${review.summary}${formatRequiredChanges(review.required_changes)}`);
685
+ }
686
+ throw new Error(input.failureMessage(pending));
687
+ }
688
+ async function createReviewContextPack(input) {
689
+ await ensureDir(input.dir);
690
+ const patch = input.scope === "item"
691
+ ? await stageAllAndDiff(input.git.worktree)
692
+ : await rangeDiff(input.git.worktree, input.git.base);
693
+ const diffstat = input.scope === "item"
694
+ ? await gitOutput(input.git.worktree, ["diff", "--cached", "--stat"], "diff --cached --stat")
695
+ : await gitOutput(input.git.worktree, ["diff", "--stat", `${input.git.base}..HEAD`], "diff --stat range");
696
+ const files = input.scope === "item"
697
+ ? await gitOutput(input.git.worktree, ["diff", "--cached", "--name-only"], "diff --cached --name-only")
698
+ : await gitOutput(input.git.worktree, ["diff", "--name-only", `${input.git.base}..HEAD`], "diff --name-only range");
699
+ const commits = await gitOutput(input.git.worktree, ["log", "--oneline", `${input.git.base}..HEAD`], "log range");
700
+ const pack = {
701
+ dir: input.dir,
702
+ summaryPath: path.join(input.dir, "summary.md"),
703
+ patchPath: path.join(input.dir, "combined.patch"),
704
+ diffstatPath: path.join(input.dir, "diffstat.txt"),
705
+ commitsPath: path.join(input.dir, "commits.txt"),
706
+ filesPath: path.join(input.dir, "modified-files.txt"),
707
+ issuesPath: path.join(input.dir, "issues.json"),
708
+ validationsPath: path.join(input.dir, "validations.json"),
709
+ };
710
+ await writeText(pack.patchPath, textOrPlaceholder(patch, "(no changes)"));
711
+ await writeText(pack.diffstatPath, textOrPlaceholder(diffstat, "(no diffstat)"));
712
+ await writeText(pack.commitsPath, textOrPlaceholder(commits, "(no commits yet)"));
713
+ await writeText(pack.filesPath, textOrPlaceholder(files, "(no modified files)"));
714
+ await writeText(pack.issuesPath, `${JSON.stringify((input.workItems ?? []).map(workItemSummary), null, 2)}\n`);
715
+ await writeText(pack.validationsPath, `${JSON.stringify(input.validations, null, 2)}\n`);
716
+ await writeText(pack.summaryPath, [
717
+ "# NyxAgent review context",
718
+ "",
719
+ `Scope: ${input.scope}`,
720
+ `Branch: ${input.git.branch}`,
721
+ `Base: ${input.git.base}`,
722
+ "",
723
+ "Artifacts:",
724
+ `- combined patch: ${pack.patchPath}`,
725
+ `- diffstat: ${pack.diffstatPath}`,
726
+ `- modified files: ${pack.filesPath}`,
727
+ `- commits: ${pack.commitsPath}`,
728
+ `- issues: ${pack.issuesPath}`,
729
+ `- validations: ${pack.validationsPath}`,
730
+ "",
731
+ "Inspect these files, or run the corresponding git commands in the working directory.",
732
+ ].join("\n"));
733
+ return pack;
734
+ }
735
+ async function gitOutput(cwd, args, label) {
736
+ const result = await execa("git", args, { cwd, reject: false });
737
+ if (result.exitCode !== 0) {
738
+ const detail = (result.stderr || result.stdout || "unknown error").trim();
739
+ throw new Error(`git ${label} failed: ${detail}`);
740
+ }
741
+ return result.stdout;
742
+ }
743
+ function reviewContextSummary(pack) {
744
+ return {
745
+ directory: pack.dir,
746
+ summary: pack.summaryPath,
747
+ combined_patch: pack.patchPath,
748
+ diffstat: pack.diffstatPath,
749
+ modified_files: pack.filesPath,
750
+ commits: pack.commitsPath,
751
+ issues: pack.issuesPath,
752
+ validations: pack.validationsPath,
753
+ };
754
+ }
755
+ function aggregateDiscoveries(discoveries) {
756
+ return {
757
+ summary: discoveries
758
+ .map((discovery) => discovery.summary)
759
+ .filter((summary) => Boolean(summary))
760
+ .join("\n"),
761
+ blockers: dedupeFindings(discoveries.flatMap((discovery) => normalizeFindings(discovery.blockers))),
762
+ test_gaps: dedupeFindings(discoveries.flatMap((discovery) => normalizeFindings(discovery.test_gaps))),
763
+ advisory_findings: dedupeFindings(discoveries.flatMap((discovery) => normalizeFindings(discovery.advisory_findings))),
764
+ uncertain_findings: dedupeFindings(discoveries.flatMap((discovery) => normalizeFindings(discovery.uncertain_findings))),
765
+ rejected_findings: dedupeFindings(discoveries.flatMap((discovery) => normalizeFindings(discovery.rejected_findings))),
766
+ };
767
+ }
768
+ function blockersNeedingCorrection(pending, validations) {
769
+ const byTitle = new Map(pending.map((blocker) => [normalizeTitle(blocker.title), blocker]));
770
+ const seen = new Set();
771
+ const unresolved = [];
772
+ for (const validation of validations) {
773
+ const key = normalizeTitle(validation.blocker_title);
774
+ seen.add(key);
775
+ if (validation.status === "unresolved") {
776
+ const original = byTitle.get(key);
777
+ if (original) {
778
+ unresolved.push({
779
+ ...original,
780
+ required_change: validation.required_change ?? original.required_change,
781
+ evidence: normalizeEvidence(validation.evidence, original.evidence),
782
+ });
783
+ }
784
+ continue;
422
785
  }
423
- const revision = await input.runPhase({
424
- phaseId: "global_revision",
425
- phaseDir: path.join(input.runDir, "final", `global-revision-${attempt}`),
426
- workdir: input.git.worktree,
427
- harness: input.harness,
428
- model: input.config.model,
429
- reasoning: input.config.reasoning_effort,
430
- capability: "write",
431
- prompt: buildPhasePrompt({
432
- guidance: GLOBAL_REVISION_PROMPT,
433
- context: buildContextBlock([
434
- ["Required changes", review.required_changes ?? []],
435
- ]),
436
- }),
437
- reporter: input.reporter,
438
- });
439
- if (!revision.ok) {
440
- throw new Error(revision.error);
786
+ if (validation.status === "regression_from_correction") {
787
+ unresolved.push({
788
+ title: validation.blocker_title,
789
+ required_change: validation.required_change ??
790
+ `Fix regression from correction: ${validation.blocker_title}`,
791
+ confidence: "high",
792
+ evidence: normalizeEvidence(validation.evidence),
793
+ });
441
794
  }
442
- const { committed } = await commitAll({
443
- cwd: input.git.worktree,
444
- message: "Apply global review corrections",
445
- });
446
- if (committed) {
447
- committedCorrections = true;
448
- input.reporter.detail("Committed global review corrections.");
795
+ }
796
+ for (const blocker of pending) {
797
+ if (!seen.has(normalizeTitle(blocker.title))) {
798
+ unresolved.push(blocker);
449
799
  }
450
800
  }
451
- return committedCorrections;
801
+ return dedupeFindings(unresolved);
802
+ }
803
+ function normalizeFindings(findings) {
804
+ return dedupeFindings((findings ?? [])
805
+ .filter((finding) => finding.title && finding.required_change)
806
+ .map((finding) => ({
807
+ title: finding.title,
808
+ required_change: finding.required_change,
809
+ confidence: normalizeConfidence(finding.confidence),
810
+ evidence: normalizeEvidence(finding.evidence),
811
+ })));
812
+ }
813
+ function normalizeValidations(validations) {
814
+ const allowed = new Set([
815
+ "resolved",
816
+ "unresolved",
817
+ "false_positive",
818
+ "regression_from_correction",
819
+ ]);
820
+ return (validations ?? [])
821
+ .filter((validation) => validation.blocker_title && allowed.has(validation.status))
822
+ .map((validation) => ({
823
+ ...validation,
824
+ evidence: normalizeEvidence(validation.evidence),
825
+ }));
826
+ }
827
+ function dedupeFindings(findings) {
828
+ const seen = new Set();
829
+ const deduped = [];
830
+ for (const finding of findings) {
831
+ const key = `${normalizeTitle(finding.title)}\n${finding.required_change.trim().toLowerCase()}`;
832
+ if (seen.has(key)) {
833
+ continue;
834
+ }
835
+ seen.add(key);
836
+ deduped.push(finding);
837
+ }
838
+ return deduped;
839
+ }
840
+ function normalizeEvidence(evidence, fallback) {
841
+ if (Array.isArray(evidence) && evidence.length > 0) {
842
+ return evidence;
843
+ }
844
+ if (fallback && fallback.length > 0) {
845
+ return fallback;
846
+ }
847
+ return [{ detail: "No evidence provided." }];
848
+ }
849
+ function normalizeConfidence(value) {
850
+ if (value === "low" || value === "medium" || value === "high") {
851
+ return value;
852
+ }
853
+ return "medium";
854
+ }
855
+ function normalizeTitle(value) {
856
+ return value.trim().toLowerCase();
857
+ }
858
+ function textOrPlaceholder(text, placeholder) {
859
+ const trimmed = text.trim();
860
+ return `${trimmed.length > 0 ? trimmed : placeholder}\n`;
452
861
  }
453
862
  async function loadExecutionGuidance(nyxDir) {
454
863
  const override = path.join(nyxDir, "prompts", "execution.md");
@@ -468,6 +877,7 @@ function workItemSummary(item) {
468
877
  locator: item.source.locator,
469
878
  url: item.url,
470
879
  labels: item.labels,
880
+ parent: item.parent,
471
881
  };
472
882
  }
473
883
  function buildCommitMessage(item) {
@@ -479,41 +889,90 @@ function buildPrTitle(items) {
479
889
  }
480
890
  return `NyxAgent: ${items.length} work items`;
481
891
  }
482
- function buildPrBody(items) {
483
- const list = items
892
+ function normalizeWorkItemInventory(result) {
893
+ if (Array.isArray(result)) {
894
+ return { candidates: result, parents: [] };
895
+ }
896
+ return {
897
+ candidates: result.candidates,
898
+ parents: result.parents ?? [],
899
+ };
900
+ }
901
+ function buildPrBody(input) {
902
+ const list = input.items
484
903
  .map((item) => `- ${item.title} (#${item.number})`)
485
904
  .join("\n");
486
- const closes = items.map((item) => `Closes #${item.number}`).join("\n");
487
- return [
905
+ const completedParents = completedParentClosures(input);
906
+ const closeNumbers = [
907
+ ...uniqueNumbers(input.items.map((item) => item.number)),
908
+ ...uniqueNumbers(completedParents.map((parent) => parent.number)),
909
+ ];
910
+ const closes = closeNumbers.map((number) => `Closes #${number}`).join("\n");
911
+ const sections = [
488
912
  "Automated changes by NyxAgent.",
489
913
  "",
490
914
  "## Work items",
491
915
  "",
492
916
  list,
493
- "",
494
- closes,
495
- ].join("\n");
917
+ ];
918
+ if (completedParents.length > 0) {
919
+ sections.push("", "## Completed plans", "", completedParents
920
+ .map((parent) => `- ${parent.title ?? `Parent issue ${parent.number}`} (#${parent.number})`)
921
+ .join("\n"));
922
+ }
923
+ sections.push("", closes);
924
+ return sections.join("\n");
496
925
  }
497
926
  function buildDraftPrTitle(items) {
498
927
  return `[Draft] ${buildPrTitle(items)}`;
499
928
  }
500
- function buildDraftPrBody(items, reason) {
929
+ function buildDraftPrBody(input) {
501
930
  return [
502
931
  "> [!WARNING]",
503
932
  "> This pull request was opened automatically by NyxAgent after the run",
504
933
  "> **failed review**. The work is preserved here for a human to finish.",
505
934
  "",
506
- `**Why the run failed:** ${reason}`,
935
+ `**Why the run failed:** ${input.reason}`,
507
936
  "",
508
- buildPrBody(items),
937
+ buildPrBody({
938
+ items: input.items,
939
+ parents: input.parents,
940
+ }),
509
941
  ].join("\n");
510
942
  }
511
- /** Render review `required_changes` as a bullet list to append to a failure message. */
512
- function formatRequiredChanges(changes) {
513
- if (!changes || changes.length === 0) {
943
+ function completedParentClosures(input) {
944
+ const completedKeys = new Set(input.items.map((item) => item.key));
945
+ const completedParentKeys = new Set(input.items
946
+ .map((item) => item.parent?.key)
947
+ .filter((key) => Boolean(key)));
948
+ const completedParents = [];
949
+ for (const parent of input.parents ?? []) {
950
+ if (!parent.closable || !completedParentKeys.has(parent.key)) {
951
+ continue;
952
+ }
953
+ const openChildren = parent.children.filter((child) => child.state !== "closed");
954
+ const openContainerChild = openChildren.some((child) => !child.executable);
955
+ if (openContainerChild) {
956
+ continue;
957
+ }
958
+ const completedChildInThisPr = parent.children.some((child) => completedKeys.has(child.key));
959
+ if (!completedChildInThisPr ||
960
+ openChildren.some((child) => !completedKeys.has(child.key))) {
961
+ continue;
962
+ }
963
+ completedParents.push(parent);
964
+ }
965
+ return completedParents;
966
+ }
967
+ function uniqueNumbers(numbers) {
968
+ return [...new Set(numbers)];
969
+ }
970
+ /** Render unresolved blockers as a bullet list to append to a failure message. */
971
+ function formatBlockers(blockers) {
972
+ if (blockers.length === 0) {
514
973
  return "";
515
974
  }
516
- return `\n\nUnresolved review feedback:\n${changes
517
- .map((change) => `- ${change}`)
975
+ return `\n\nUnresolved review blockers:\n${blockers
976
+ .map((blocker) => `- ${blocker.title}: ${blocker.required_change}`)
518
977
  .join("\n")}`;
519
978
  }