@nyxa/nyx-agent 0.6.1 → 0.8.0

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,26 +1,29 @@
1
1
  import path from "node:path";
2
- import pc from "picocolors";
2
+ import { execa } from "execa";
3
3
  import { loadConfig } from "../config/loadConfig.js";
4
- import { ensureDir, pathExists, readText } from "./files.js";
5
- import { deleteBranch, removeRunWorktree, setUpRunWorktree } from "./gitLifecycle.js";
6
- import { markWorkItemCompleted, readWorkItemLedger, writeWorkItemLedger } from "./ledger.js";
4
+ import { globalReviewRoles, } from "../config/schema.js";
5
+ import { ensureDir, pathExists, readText, writeText } from "./files.js";
6
+ import { deleteBranch, removeRunWorktree, setUpRunWorktree, } from "./gitLifecycle.js";
7
+ import { markWorkItemCompleted, readWorkItemLedger, writeWorkItemLedger, } from "./ledger.js";
7
8
  import { getNyxDir, relativeToProject } from "./paths.js";
8
- import { buildContextBlock, buildPhasePrompt, EXECUTION_PROMPT, GLOBAL_REVIEW_PROMPT, GLOBAL_REVISION_PROMPT, REVIEW_PROMPT, REVISION_PROMPT, SELECTION_PROMPT, truncateForPrompt } from "./prompts.js";
9
- import { runAgentPhase } from "./runPhase.js";
10
- import { GLOBAL_REVIEW_SCHEMA, REVIEW_SCHEMA, SELECTION_SCHEMA } from "./schemas.js";
11
- import { commitAll, commitsAhead, createPullRequest, pushBranch, rangeDiff, stageAllAndDiff } from "./scm.js";
12
- import { confirmWorkItemSelection } from "./selectionConfirmation.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";
10
+ import { createRunReporter } from "./reporter.js";
11
+ import { runAgentPhase, } from "./runPhase.js";
12
+ import { REVIEW_CHALLENGE_SCHEMA, REVIEW_DISCOVERY_SCHEMA, GLOBAL_REVIEW_SCHEMA, REVIEW_VALIDATION_SCHEMA, SELECTION_SCHEMA, } from "./schemas.js";
13
+ import { commitAll, commitsAhead, createPullRequest, pushBranch, rangeDiff, stageAllAndDiff, } from "./scm.js";
14
+ import { confirmWorkItemSelection, } from "./selectionConfirmation.js";
13
15
  import { createRunId } from "./time.js";
14
- import { filterAvailable, listGitHubIssues, resolveSelectedQueue } from "./workItems.js";
16
+ import { filterAvailable, listGitHubIssues, 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
22
  listIssues: listGitHubIssues,
20
23
  runPhase: runAgentPhase,
21
24
  pushBranch,
22
25
  createPullRequest,
23
- confirmSelection: confirmWorkItemSelection
26
+ confirmSelection: confirmWorkItemSelection,
24
27
  };
25
28
  }
26
29
  /**
@@ -37,60 +40,74 @@ 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
- console.log(pc.bold(`NyxAgent run ${runId}`));
45
- console.log(`Harness: ${harness} · model: ${config.model} · review: ${config.review}`);
50
+ const reporter = input.reporter ?? createRunReporter({ verbose: input.verbose ?? false });
51
+ reporter.heading(`NyxAgent run ${runId}`);
52
+ reporter.info(`Harness: ${baseAgent.harness} · model: ${baseAgent.model} · review: ${config.review}`);
53
+ reporter.detail(`Config: ${relativeToProject(projectRoot, configPath)}`);
54
+ reporter.detail(`Artifacts: ${relativeToProject(projectRoot, runDir)}`);
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
+ }
46
59
  const ledger = await readWorkItemLedger(nyxDir);
60
+ reporter.detail(`Completed work items already in ledger: ${ledger.completed_work_item_keys.length}`);
47
61
  // 1. Selection runs read-only in the main checkout, before any branch exists.
48
62
  const candidates = filterAvailable({
49
63
  candidates: await deps.listIssues({
50
64
  repo: config.tracker.repo,
51
65
  maxCandidates: MAX_CANDIDATES,
52
- excerptChars: EXCERPT_CHARS
66
+ excerptChars: EXCERPT_CHARS,
53
67
  }),
54
- completedKeys: ledger.completed_work_item_keys
68
+ completedKeys: ledger.completed_work_item_keys,
55
69
  });
70
+ reporter.detail(`Available work item candidates: ${candidates.length}`);
56
71
  if (candidates.length === 0) {
57
- console.log("No open work items available. Nothing to do.");
72
+ reporter.info("No open work items available. Nothing to do.");
58
73
  return;
59
74
  }
60
75
  const proposed = await runSelection({
61
76
  projectRoot,
62
77
  runDir,
63
- harness,
78
+ cliHarness: input.harness,
64
79
  config,
65
80
  candidates,
66
- runPhase: deps.runPhase
81
+ runPhase: deps.runPhase,
82
+ reporter,
67
83
  });
68
84
  if (proposed.length === 0) {
69
- console.log("Selection chose no work items. Nothing to do.");
85
+ reporter.info("Selection chose no work items. Nothing to do.");
70
86
  return;
71
87
  }
72
88
  const selected = await deps.confirmSelection({
73
89
  candidates,
74
90
  proposed,
75
91
  maxItems: config.max_iterations,
76
- autoAccept: input.autoAcceptSelection ?? false
92
+ autoAccept: input.autoAcceptSelection ?? false,
77
93
  });
78
94
  if (selected.length === 0) {
79
- console.log("No work items selected. Nothing to do.");
95
+ reporter.info("No work items selected. Nothing to do.");
80
96
  return;
81
97
  }
82
98
  const planned = selected.slice(0, config.max_iterations);
83
- console.log(`Selected ${planned.length} work item(s):`);
99
+ reporter.info(`Selected ${planned.length} work item(s):`);
84
100
  for (const item of planned) {
85
- console.log(` - ${item.title} (#${item.number})`);
101
+ reporter.info(` - ${item.title} (#${item.number})`);
86
102
  }
87
103
  // 2. One branch + worktree per run (created only now that there is work).
88
104
  const git = await setUpRunWorktree({
89
105
  projectRoot,
90
106
  runId,
91
- base: config.base_branch
107
+ base: config.base_branch,
92
108
  });
93
- console.log(`Branch ${git.branch} (base ${git.base})`);
109
+ reporter.info(`Branch ${git.branch} (base ${git.base})`);
110
+ reporter.detail(`Worktree: ${relativeToProject(projectRoot, git.worktree)}`);
94
111
  let success = false;
95
112
  let producedCommits = false;
96
113
  let currentLedger = ledger;
@@ -100,40 +117,43 @@ export async function runPipeline(input = {}, deps = defaultPipelineDependencies
100
117
  for (const [index, item] of planned.entries()) {
101
118
  const iterationNumber = index + 1;
102
119
  const iterationDir = path.join(runDir, "iterations", String(iterationNumber).padStart(3, "0"));
103
- console.log(pc.cyan(`\n[${iterationNumber}/${planned.length}] ${item.title} (#${item.number})`));
120
+ reporter.section(`\n[${iterationNumber}/${planned.length}] ${item.title} (#${item.number})`);
104
121
  await runExecution({
105
122
  iterationDir,
106
123
  item,
107
124
  guidance: executionGuidance,
108
125
  git,
109
- harness,
126
+ cliHarness: input.harness,
110
127
  config,
111
- runPhase: deps.runPhase
128
+ runPhase: deps.runPhase,
129
+ reporter,
112
130
  });
113
131
  if (config.review === "each" || config.review === "both") {
114
132
  await runReviewLoop({
115
133
  iterationDir,
116
134
  item,
117
135
  git,
118
- harness,
136
+ cliHarness: input.harness,
119
137
  config,
120
- maxAttempts: config.review_max_attempts,
121
- runPhase: deps.runPhase
138
+ rounds: config.review_rounds.each,
139
+ runPhase: deps.runPhase,
140
+ reporter,
122
141
  });
123
142
  }
124
143
  const { committed } = await commitAll({
125
144
  cwd: git.worktree,
126
- message: buildCommitMessage(item)
145
+ message: buildCommitMessage(item),
127
146
  });
128
147
  if (committed) {
129
148
  producedCommits = true;
149
+ reporter.detail(`Committed work item #${item.number}.`);
130
150
  }
131
151
  else {
132
- console.log(pc.yellow(" No changes to commit for this item."));
152
+ reporter.warn(" No changes to commit for this item.");
133
153
  }
134
154
  currentLedger = markWorkItemCompleted({
135
155
  ledger: currentLedger,
136
- workItem: item
156
+ workItem: item,
137
157
  });
138
158
  await writeWorkItemLedger(nyxDir, currentLedger);
139
159
  completed.push(item);
@@ -142,20 +162,24 @@ export async function runPipeline(input = {}, deps = defaultPipelineDependencies
142
162
  const corrections = await runGlobalReviewLoop({
143
163
  runDir,
144
164
  git,
145
- harness,
165
+ completed,
166
+ cliHarness: input.harness,
146
167
  config,
147
- maxAttempts: config.review_max_attempts,
148
- runPhase: deps.runPhase
168
+ rounds: config.review_rounds.global,
169
+ runPhase: deps.runPhase,
170
+ reporter,
149
171
  });
150
172
  if (corrections) {
151
173
  producedCommits = true;
152
174
  }
153
175
  }
154
- if (!producedCommits || (await commitsAhead(git.worktree, git.base)) === 0) {
155
- console.log("\nRun produced no commits; skipping pull request.");
176
+ if (!producedCommits ||
177
+ (await commitsAhead(git.worktree, git.base)) === 0) {
178
+ reporter.info("\nRun produced no commits; skipping pull request.");
156
179
  success = true;
157
180
  return;
158
181
  }
182
+ reporter.detail(`Pushing branch ${git.branch}.`);
159
183
  await deps.pushBranch({ cwd: git.worktree, branch: git.branch });
160
184
  const prUrl = await deps.createPullRequest({
161
185
  cwd: git.worktree,
@@ -163,9 +187,9 @@ export async function runPipeline(input = {}, deps = defaultPipelineDependencies
163
187
  base: git.base,
164
188
  head: git.branch,
165
189
  title: buildPrTitle(completed),
166
- body: buildPrBody(completed)
190
+ body: buildPrBody(completed),
167
191
  });
168
- console.log(pc.green(`\nPull request opened: ${prUrl}`));
192
+ reporter.success(`\nPull request opened: ${prUrl}`);
169
193
  success = true;
170
194
  }
171
195
  catch (error) {
@@ -179,7 +203,8 @@ export async function runPipeline(input = {}, deps = defaultPipelineDependencies
179
203
  producedCommits,
180
204
  completed,
181
205
  config,
182
- deps
206
+ deps,
207
+ reporter,
183
208
  });
184
209
  throw error;
185
210
  }
@@ -211,14 +236,15 @@ async function salvageFailedRun(input) {
211
236
  }
212
237
  }
213
238
  if (ahead === 0) {
214
- console.log(pc.red(`\nRun failed. Branch ${input.git.branch} and worktree kept for debugging: ${location}`));
239
+ input.reporter.error(`\nRun failed. Branch ${input.git.branch} and worktree kept for debugging: ${location}`);
215
240
  return;
216
241
  }
217
242
  const reason = input.error instanceof Error ? input.error.message : String(input.error);
218
243
  try {
244
+ input.reporter.detail(`Pushing failed run branch ${input.git.branch}.`);
219
245
  await input.deps.pushBranch({
220
246
  cwd: input.git.worktree,
221
- branch: input.git.branch
247
+ branch: input.git.branch,
222
248
  });
223
249
  const url = await input.deps.createPullRequest({
224
250
  cwd: input.git.worktree,
@@ -227,20 +253,24 @@ async function salvageFailedRun(input) {
227
253
  head: input.git.branch,
228
254
  title: buildDraftPrTitle(input.completed),
229
255
  body: buildDraftPrBody(input.completed, reason),
230
- draft: true
256
+ draft: true,
231
257
  });
232
- console.log(pc.yellow(`\nRun failed, but the work was salvaged into a DRAFT pull request: ${url}`));
233
- console.log(pc.yellow(`Branch ${input.git.branch} and worktree kept: ${location}`));
258
+ input.reporter.warn(`\nRun failed, but the work was salvaged into a DRAFT pull request: ${url}`);
259
+ input.reporter.warn(`Branch ${input.git.branch} and worktree kept: ${location}`);
234
260
  }
235
261
  catch (salvageError) {
236
262
  const detail = salvageError instanceof Error
237
263
  ? salvageError.message
238
264
  : String(salvageError);
239
- console.log(pc.red(`\nRun failed, and salvaging the work into a draft pull request also failed: ${detail}`));
240
- console.log(pc.red(`Branch ${input.git.branch} and worktree kept for debugging: ${location}`));
265
+ input.reporter.error(`\nRun failed, and salvaging the work into a draft pull request also failed: ${detail}`);
266
+ input.reporter.error(`Branch ${input.git.branch} and worktree kept for debugging: ${location}`);
241
267
  }
242
268
  }
243
269
  async function runSelection(input) {
270
+ const agent = resolveAgentProfile({
271
+ config: input.config,
272
+ cliHarness: input.cliHarness,
273
+ });
244
274
  const context = buildContextBlock([
245
275
  ["Repository", input.config.tracker.repo],
246
276
  ["Max work items this run", input.config.max_iterations],
@@ -251,24 +281,25 @@ async function runSelection(input) {
251
281
  number: candidate.number,
252
282
  title: candidate.title,
253
283
  labels: candidate.labels,
254
- excerpt: candidate.excerpt
255
- }))
256
- ]
284
+ excerpt: candidate.excerpt,
285
+ })),
286
+ ],
257
287
  ]);
258
288
  const result = await input.runPhase({
259
289
  phaseId: "selection",
260
290
  phaseDir: path.join(input.runDir, "selection"),
261
291
  workdir: input.projectRoot,
262
- harness: input.harness,
263
- model: input.config.model,
264
- reasoning: input.config.reasoning_effort,
292
+ harness: agent.harness,
293
+ model: agent.model,
294
+ reasoning: agent.reasoning_effort,
265
295
  capability: "readonly",
266
296
  prompt: buildPhasePrompt({
267
297
  guidance: SELECTION_PROMPT,
268
298
  context,
269
- schema: SELECTION_SCHEMA
299
+ schema: SELECTION_SCHEMA,
270
300
  }),
271
- schema: SELECTION_SCHEMA
301
+ schema: SELECTION_SCHEMA,
302
+ reporter: input.reporter,
272
303
  });
273
304
  if (!result.ok) {
274
305
  throw new Error(result.error);
@@ -279,7 +310,7 @@ async function runSelection(input) {
279
310
  }
280
311
  const resolved = resolveSelectedQueue({
281
312
  keys: value.work_item_keys,
282
- candidates: input.candidates
313
+ candidates: input.candidates,
283
314
  });
284
315
  if (!resolved.ok) {
285
316
  throw new Error(`Selection produced an invalid queue: ${resolved.error}`);
@@ -287,142 +318,536 @@ async function runSelection(input) {
287
318
  return resolved.queue;
288
319
  }
289
320
  async function runExecution(input) {
321
+ const agent = resolveAgentProfile({
322
+ config: input.config,
323
+ cliHarness: input.cliHarness,
324
+ phase: "execution",
325
+ });
290
326
  const context = buildContextBlock([
291
327
  ["Work item", workItemSummary(input.item)],
292
328
  ["Issue description", input.item.excerpt ?? "(no description provided)"],
293
329
  ["Working directory", input.git.worktree],
294
- ["Branch", `${input.git.branch} (base ${input.git.base})`]
330
+ ["Branch", `${input.git.branch} (base ${input.git.base})`],
295
331
  ]);
296
332
  const result = await input.runPhase({
297
333
  phaseId: "execution",
298
334
  phaseDir: path.join(input.iterationDir, "execution"),
299
335
  workdir: input.git.worktree,
300
- harness: input.harness,
301
- model: input.config.model,
302
- reasoning: input.config.reasoning_effort,
336
+ harness: agent.harness,
337
+ model: agent.model,
338
+ reasoning: agent.reasoning_effort,
303
339
  capability: "write",
304
- prompt: buildPhasePrompt({ guidance: input.guidance, context })
340
+ prompt: buildPhasePrompt({ guidance: input.guidance, context }),
341
+ reporter: input.reporter,
305
342
  });
306
343
  if (!result.ok) {
307
344
  throw new Error(result.error);
308
345
  }
309
346
  }
310
347
  async function runReviewLoop(input) {
311
- for (let attempt = 1; attempt <= input.maxAttempts; attempt += 1) {
312
- const diff = await stageAllAndDiff(input.git.worktree);
313
- const reviewResult = await input.runPhase({
348
+ const agent = resolveAgentProfile({
349
+ config: input.config,
350
+ cliHarness: input.cliHarness,
351
+ phase: "review",
352
+ });
353
+ const validationHistory = [];
354
+ for (let round = 1; round <= input.rounds; round += 1) {
355
+ const roundDir = path.join(input.iterationDir, `review-round-${round}`);
356
+ const discoveryPack = await createReviewContextPack({
357
+ dir: path.join(roundDir, "discovery", "review-context"),
358
+ git: input.git,
359
+ scope: "item",
360
+ workItems: [input.item],
361
+ validations: validationHistory,
362
+ });
363
+ const discoveryResult = await input.runPhase({
314
364
  phaseId: "review",
315
- phaseDir: path.join(input.iterationDir, `review-${attempt}`),
365
+ phaseDir: path.join(roundDir, "discovery"),
316
366
  workdir: input.git.worktree,
317
- harness: input.harness,
318
- model: input.config.model,
319
- reasoning: input.config.reasoning_effort,
367
+ harness: agent.harness,
368
+ model: agent.model,
369
+ reasoning: agent.reasoning_effort,
320
370
  capability: "readonly",
321
371
  prompt: buildPhasePrompt({
322
372
  guidance: REVIEW_PROMPT,
323
373
  context: buildContextBlock([
324
374
  ["Work item", workItemSummary(input.item)],
325
- ["Uncommitted changes (diff)", truncateForPrompt(diff || "(no changes)")]
375
+ ["Review round", round],
376
+ ["Review context", reviewContextSummary(discoveryPack)],
326
377
  ]),
327
- schema: REVIEW_SCHEMA
378
+ schema: REVIEW_DISCOVERY_SCHEMA,
328
379
  }),
329
- schema: REVIEW_SCHEMA
380
+ schema: REVIEW_DISCOVERY_SCHEMA,
381
+ reporter: input.reporter,
330
382
  });
331
- if (!reviewResult.ok) {
332
- throw new Error(reviewResult.error);
383
+ if (!discoveryResult.ok) {
384
+ throw new Error(discoveryResult.error);
333
385
  }
334
- const review = reviewResult.result;
335
- console.log(` review: ${review.outcome}`);
336
- if (review.outcome === "approved") {
337
- return;
386
+ const discovery = discoveryResult.result;
387
+ const proposedBlockers = normalizeFindings(discovery.blockers);
388
+ input.reporter.info(` review round ${round}: ${proposedBlockers.length} proposed blocker(s)`);
389
+ const challenge = await runReviewChallenge({
390
+ phaseId: "review_challenge",
391
+ phaseDir: path.join(roundDir, "challenge"),
392
+ workdir: input.git.worktree,
393
+ agent,
394
+ runPhase: input.runPhase,
395
+ reporter: input.reporter,
396
+ guidance: REVIEW_CHALLENGE_PROMPT,
397
+ contextEntries: [
398
+ ["Work item", workItemSummary(input.item)],
399
+ ["Review round", round],
400
+ ["Review context", reviewContextSummary(discoveryPack)],
401
+ ["Proposed blockers", proposedBlockers],
402
+ ["Rejected findings from discovery", discovery.rejected_findings ?? []],
403
+ ],
404
+ });
405
+ input.reporter.info(` review round ${round}: ${challenge.blockers.length} verified blocker(s)`);
406
+ await runCorrectionValidationLoop({
407
+ scope: "item",
408
+ roundDir,
409
+ git: input.git,
410
+ workItems: [input.item],
411
+ validationHistory,
412
+ blockers: challenge.blockers,
413
+ agent,
414
+ runPhase: input.runPhase,
415
+ reporter: input.reporter,
416
+ revisionPhaseId: "revision",
417
+ validationPhaseId: "review_validation",
418
+ revisionGuidance: REVISION_PROMPT,
419
+ validationGuidance: REVIEW_VALIDATION_PROMPT,
420
+ failureMessage: (blockers) => `Review for #${input.item.number} has unresolved blocker(s) after ${CORRECTION_VALIDATION_MAX_ATTEMPTS} correction validation attempts:${formatBlockers(blockers)}`,
421
+ });
422
+ }
423
+ }
424
+ async function runGlobalReviewLoop(input) {
425
+ let committedCorrections = false;
426
+ const validationHistory = [];
427
+ for (let round = 1; round <= input.rounds; round += 1) {
428
+ const roundDir = path.join(input.runDir, "final", `global-round-${round}`);
429
+ const discoveryPack = await createReviewContextPack({
430
+ dir: path.join(roundDir, "discovery", "review-context"),
431
+ git: input.git,
432
+ scope: "global",
433
+ workItems: input.completed,
434
+ validations: validationHistory,
435
+ });
436
+ const discoveries = [];
437
+ for (const role of globalReviewRoles) {
438
+ const roleAgent = resolveAgentProfile({
439
+ config: input.config,
440
+ cliHarness: input.cliHarness,
441
+ phase: "global_review",
442
+ role,
443
+ });
444
+ const reviewResult = await input.runPhase({
445
+ phaseId: "global_review",
446
+ phaseDir: path.join(roundDir, "discovery", role),
447
+ workdir: input.git.worktree,
448
+ harness: roleAgent.harness,
449
+ model: roleAgent.model,
450
+ reasoning: roleAgent.reasoning_effort,
451
+ capability: "readonly",
452
+ prompt: buildPhasePrompt({
453
+ guidance: buildGlobalReviewGuidance(role),
454
+ context: buildContextBlock([
455
+ ["Run branch", `${input.git.branch} (base ${input.git.base})`],
456
+ ["Review round", round],
457
+ ["Reviewer role", role],
458
+ ["Review context", reviewContextSummary(discoveryPack)],
459
+ ]),
460
+ schema: GLOBAL_REVIEW_SCHEMA,
461
+ }),
462
+ schema: GLOBAL_REVIEW_SCHEMA,
463
+ reporter: input.reporter,
464
+ });
465
+ if (!reviewResult.ok) {
466
+ throw new Error(reviewResult.error);
467
+ }
468
+ discoveries.push(reviewResult.result);
338
469
  }
339
- if (attempt === input.maxAttempts) {
340
- throw new Error(`Review for #${input.item.number} not approved after ${input.maxAttempts} attempts: ${review.summary}${formatRequiredChanges(review.required_changes)}`);
470
+ const aggregated = aggregateDiscoveries(discoveries);
471
+ input.reporter.info(`global review round ${round}: ${aggregated.blockers.length} proposed blocker(s)`);
472
+ const challengeAgent = resolveAgentProfile({
473
+ config: input.config,
474
+ cliHarness: input.cliHarness,
475
+ phase: "global_review",
476
+ });
477
+ const challenge = await runReviewChallenge({
478
+ phaseId: "global_review_challenge",
479
+ phaseDir: path.join(roundDir, "challenge"),
480
+ workdir: input.git.worktree,
481
+ agent: challengeAgent,
482
+ runPhase: input.runPhase,
483
+ reporter: input.reporter,
484
+ guidance: GLOBAL_REVIEW_CHALLENGE_PROMPT,
485
+ contextEntries: [
486
+ ["Run branch", `${input.git.branch} (base ${input.git.base})`],
487
+ ["Review round", round],
488
+ ["Review context", reviewContextSummary(discoveryPack)],
489
+ ["Aggregated proposed blockers", aggregated.blockers],
490
+ ["Aggregated rejected findings", aggregated.rejected_findings],
491
+ ],
492
+ });
493
+ input.reporter.info(`global review round ${round}: ${challenge.blockers.length} verified blocker(s)`);
494
+ const roundCommittedCorrections = await runCorrectionValidationLoop({
495
+ scope: "global",
496
+ roundDir,
497
+ git: input.git,
498
+ workItems: input.completed,
499
+ validationHistory,
500
+ blockers: challenge.blockers,
501
+ agent: challengeAgent,
502
+ runPhase: input.runPhase,
503
+ reporter: input.reporter,
504
+ revisionPhaseId: "global_revision",
505
+ validationPhaseId: "global_review_validation",
506
+ revisionGuidance: GLOBAL_REVISION_PROMPT,
507
+ validationGuidance: GLOBAL_REVIEW_VALIDATION_PROMPT,
508
+ commitMessage: "Apply global review corrections",
509
+ failureMessage: (blockers) => `Global review has unresolved blocker(s) after ${CORRECTION_VALIDATION_MAX_ATTEMPTS} correction validation attempts:${formatBlockers(blockers)}`,
510
+ });
511
+ if (roundCommittedCorrections) {
512
+ committedCorrections = true;
341
513
  }
514
+ }
515
+ return committedCorrections;
516
+ }
517
+ function resolveAgentProfile(input) {
518
+ const profile = {
519
+ harness: input.cliHarness ?? input.config.harness,
520
+ model: input.config.model,
521
+ reasoning_effort: input.config.reasoning_effort,
522
+ };
523
+ const phaseOverride = input.phase
524
+ ? phaseAgentOverride(input.config, input.phase)
525
+ : undefined;
526
+ const roleOverride = input.role
527
+ ? input.config.agents?.global_review?.roles?.[input.role]
528
+ : undefined;
529
+ return applyAgentOverride(applyAgentOverride(profile, phaseOverride), roleOverride);
530
+ }
531
+ function phaseAgentOverride(config, phase) {
532
+ if (phase === "execution") {
533
+ return config.agents?.execution;
534
+ }
535
+ if (phase === "review") {
536
+ return config.agents?.review;
537
+ }
538
+ return config.agents?.global_review;
539
+ }
540
+ function applyAgentOverride(profile, override) {
541
+ if (!override) {
542
+ return profile;
543
+ }
544
+ return {
545
+ harness: override.harness ?? profile.harness,
546
+ model: override.model ?? profile.model,
547
+ reasoning_effort: override.reasoning_effort ?? profile.reasoning_effort,
548
+ };
549
+ }
550
+ function buildGlobalReviewGuidance(role) {
551
+ const focus = {
552
+ "diff-contract": "Focus on the public contract of the diff: APIs, CLI behavior, schemas, config compatibility, and generated artifacts.",
553
+ integration: "Focus on integration across touched modules, phase sequencing, artifact paths, and cross-item behavior.",
554
+ "domain-invariants": "Focus on NyxAgent workflow invariants: engine-owned git side effects, read-only review phases, closed pipeline control flow, and review semantics.",
555
+ "tests-validation": "Focus on test coverage, validation evidence, failure modes, and whether the committed changes are demonstrably safe.",
556
+ };
557
+ return `${GLOBAL_REVIEW_PROMPT}\n\nRole focus (${role}): ${focus[role]}`;
558
+ }
559
+ async function runReviewChallenge(input) {
560
+ const result = await input.runPhase({
561
+ phaseId: input.phaseId,
562
+ phaseDir: input.phaseDir,
563
+ workdir: input.workdir,
564
+ harness: input.agent.harness,
565
+ model: input.agent.model,
566
+ reasoning: input.agent.reasoning_effort,
567
+ capability: "readonly",
568
+ prompt: buildPhasePrompt({
569
+ guidance: input.guidance,
570
+ context: buildContextBlock(input.contextEntries),
571
+ schema: REVIEW_CHALLENGE_SCHEMA,
572
+ }),
573
+ schema: REVIEW_CHALLENGE_SCHEMA,
574
+ reporter: input.reporter,
575
+ });
576
+ if (!result.ok) {
577
+ throw new Error(result.error);
578
+ }
579
+ const challenge = result.result;
580
+ return {
581
+ ...challenge,
582
+ blockers: normalizeFindings(challenge.blockers),
583
+ rejected_findings: normalizeFindings(challenge.rejected_findings),
584
+ };
585
+ }
586
+ async function runCorrectionValidationLoop(input) {
587
+ let pending = normalizeFindings(input.blockers);
588
+ let committedCorrections = false;
589
+ if (pending.length === 0) {
590
+ return committedCorrections;
591
+ }
592
+ for (let attempt = 1; attempt <= CORRECTION_VALIDATION_MAX_ATTEMPTS; attempt += 1) {
593
+ const revisionPack = await createReviewContextPack({
594
+ dir: path.join(input.roundDir, `${input.revisionPhaseId}-${attempt}`, "review-context"),
595
+ git: input.git,
596
+ scope: input.scope,
597
+ workItems: input.workItems,
598
+ validations: input.validationHistory,
599
+ });
342
600
  const revision = await input.runPhase({
343
- phaseId: "revision",
344
- phaseDir: path.join(input.iterationDir, `revision-${attempt}`),
601
+ phaseId: input.revisionPhaseId,
602
+ phaseDir: path.join(input.roundDir, `${input.revisionPhaseId}-${attempt}`),
345
603
  workdir: input.git.worktree,
346
- harness: input.harness,
347
- model: input.config.model,
348
- reasoning: input.config.reasoning_effort,
604
+ harness: input.agent.harness,
605
+ model: input.agent.model,
606
+ reasoning: input.agent.reasoning_effort,
349
607
  capability: "write",
350
608
  prompt: buildPhasePrompt({
351
- guidance: REVISION_PROMPT,
609
+ guidance: input.revisionGuidance,
352
610
  context: buildContextBlock([
353
- ["Work item", workItemSummary(input.item)],
354
- ["Required changes", review.required_changes ?? []]
355
- ])
356
- })
611
+ ["Review context", reviewContextSummary(revisionPack)],
612
+ [
613
+ "Correction attempt",
614
+ `${attempt}/${CORRECTION_VALIDATION_MAX_ATTEMPTS}`,
615
+ ],
616
+ ["Verified blockers", pending],
617
+ ]),
618
+ }),
619
+ reporter: input.reporter,
357
620
  });
358
621
  if (!revision.ok) {
359
622
  throw new Error(revision.error);
360
623
  }
361
- }
362
- }
363
- async function runGlobalReviewLoop(input) {
364
- let committedCorrections = false;
365
- for (let attempt = 1; attempt <= input.maxAttempts; attempt += 1) {
366
- const diff = await rangeDiff(input.git.worktree, input.git.base);
367
- const reviewResult = await input.runPhase({
368
- phaseId: "global_review",
369
- phaseDir: path.join(input.runDir, "final", `global-review-${attempt}`),
624
+ if (input.commitMessage) {
625
+ const { committed } = await commitAll({
626
+ cwd: input.git.worktree,
627
+ message: input.commitMessage,
628
+ });
629
+ if (committed) {
630
+ committedCorrections = true;
631
+ input.reporter.detail("Committed global review corrections.");
632
+ }
633
+ }
634
+ const validationPack = await createReviewContextPack({
635
+ dir: path.join(input.roundDir, `${input.validationPhaseId}-${attempt}`, "review-context"),
636
+ git: input.git,
637
+ scope: input.scope,
638
+ workItems: input.workItems,
639
+ validations: input.validationHistory,
640
+ });
641
+ const validationResult = await input.runPhase({
642
+ phaseId: input.validationPhaseId,
643
+ phaseDir: path.join(input.roundDir, `${input.validationPhaseId}-${attempt}`),
370
644
  workdir: input.git.worktree,
371
- harness: input.harness,
372
- model: input.config.model,
373
- reasoning: input.config.reasoning_effort,
645
+ harness: input.agent.harness,
646
+ model: input.agent.model,
647
+ reasoning: input.agent.reasoning_effort,
374
648
  capability: "readonly",
375
649
  prompt: buildPhasePrompt({
376
- guidance: GLOBAL_REVIEW_PROMPT,
650
+ guidance: input.validationGuidance,
377
651
  context: buildContextBlock([
378
- ["Run branch", `${input.git.branch} (base ${input.git.base})`],
652
+ ["Review context", reviewContextSummary(validationPack)],
379
653
  [
380
- "Combined run diff (base...HEAD)",
381
- truncateForPrompt(diff || "(no changes)")
382
- ]
654
+ "Correction attempt",
655
+ `${attempt}/${CORRECTION_VALIDATION_MAX_ATTEMPTS}`,
656
+ ],
657
+ ["Validated blockers", pending],
383
658
  ]),
384
- schema: GLOBAL_REVIEW_SCHEMA
659
+ schema: REVIEW_VALIDATION_SCHEMA,
385
660
  }),
386
- schema: GLOBAL_REVIEW_SCHEMA
661
+ schema: REVIEW_VALIDATION_SCHEMA,
662
+ reporter: input.reporter,
387
663
  });
388
- if (!reviewResult.ok) {
389
- throw new Error(reviewResult.error);
664
+ if (!validationResult.ok) {
665
+ throw new Error(validationResult.error);
390
666
  }
391
- const review = reviewResult.result;
392
- console.log(`global review: ${review.outcome}`);
393
- if (review.outcome === "approved") {
667
+ const validation = validationResult.result;
668
+ const validations = normalizeValidations(validation.validations);
669
+ input.validationHistory.push(...validations);
670
+ pending = blockersNeedingCorrection(pending, validations);
671
+ input.reporter.info(`${input.scope} validation attempt ${attempt}: ${pending.length} unresolved blocker(s)`);
672
+ if (pending.length === 0) {
394
673
  return committedCorrections;
395
674
  }
396
- if (attempt === input.maxAttempts) {
397
- throw new Error(`Global review not approved after ${input.maxAttempts} attempts: ${review.summary}${formatRequiredChanges(review.required_changes)}`);
675
+ }
676
+ throw new Error(input.failureMessage(pending));
677
+ }
678
+ async function createReviewContextPack(input) {
679
+ await ensureDir(input.dir);
680
+ const patch = input.scope === "item"
681
+ ? await stageAllAndDiff(input.git.worktree)
682
+ : await rangeDiff(input.git.worktree, input.git.base);
683
+ const diffstat = input.scope === "item"
684
+ ? await gitOutput(input.git.worktree, ["diff", "--cached", "--stat"], "diff --cached --stat")
685
+ : await gitOutput(input.git.worktree, ["diff", "--stat", `${input.git.base}..HEAD`], "diff --stat range");
686
+ const files = input.scope === "item"
687
+ ? await gitOutput(input.git.worktree, ["diff", "--cached", "--name-only"], "diff --cached --name-only")
688
+ : await gitOutput(input.git.worktree, ["diff", "--name-only", `${input.git.base}..HEAD`], "diff --name-only range");
689
+ const commits = await gitOutput(input.git.worktree, ["log", "--oneline", `${input.git.base}..HEAD`], "log range");
690
+ const pack = {
691
+ dir: input.dir,
692
+ summaryPath: path.join(input.dir, "summary.md"),
693
+ patchPath: path.join(input.dir, "combined.patch"),
694
+ diffstatPath: path.join(input.dir, "diffstat.txt"),
695
+ commitsPath: path.join(input.dir, "commits.txt"),
696
+ filesPath: path.join(input.dir, "modified-files.txt"),
697
+ issuesPath: path.join(input.dir, "issues.json"),
698
+ validationsPath: path.join(input.dir, "validations.json"),
699
+ };
700
+ await writeText(pack.patchPath, textOrPlaceholder(patch, "(no changes)"));
701
+ await writeText(pack.diffstatPath, textOrPlaceholder(diffstat, "(no diffstat)"));
702
+ await writeText(pack.commitsPath, textOrPlaceholder(commits, "(no commits yet)"));
703
+ await writeText(pack.filesPath, textOrPlaceholder(files, "(no modified files)"));
704
+ await writeText(pack.issuesPath, `${JSON.stringify((input.workItems ?? []).map(workItemSummary), null, 2)}\n`);
705
+ await writeText(pack.validationsPath, `${JSON.stringify(input.validations, null, 2)}\n`);
706
+ await writeText(pack.summaryPath, [
707
+ "# NyxAgent review context",
708
+ "",
709
+ `Scope: ${input.scope}`,
710
+ `Branch: ${input.git.branch}`,
711
+ `Base: ${input.git.base}`,
712
+ "",
713
+ "Artifacts:",
714
+ `- combined patch: ${pack.patchPath}`,
715
+ `- diffstat: ${pack.diffstatPath}`,
716
+ `- modified files: ${pack.filesPath}`,
717
+ `- commits: ${pack.commitsPath}`,
718
+ `- issues: ${pack.issuesPath}`,
719
+ `- validations: ${pack.validationsPath}`,
720
+ "",
721
+ "Inspect these files, or run the corresponding git commands in the working directory.",
722
+ ].join("\n"));
723
+ return pack;
724
+ }
725
+ async function gitOutput(cwd, args, label) {
726
+ const result = await execa("git", args, { cwd, reject: false });
727
+ if (result.exitCode !== 0) {
728
+ const detail = (result.stderr || result.stdout || "unknown error").trim();
729
+ throw new Error(`git ${label} failed: ${detail}`);
730
+ }
731
+ return result.stdout;
732
+ }
733
+ function reviewContextSummary(pack) {
734
+ return {
735
+ directory: pack.dir,
736
+ summary: pack.summaryPath,
737
+ combined_patch: pack.patchPath,
738
+ diffstat: pack.diffstatPath,
739
+ modified_files: pack.filesPath,
740
+ commits: pack.commitsPath,
741
+ issues: pack.issuesPath,
742
+ validations: pack.validationsPath,
743
+ };
744
+ }
745
+ function aggregateDiscoveries(discoveries) {
746
+ return {
747
+ summary: discoveries
748
+ .map((discovery) => discovery.summary)
749
+ .filter((summary) => Boolean(summary))
750
+ .join("\n"),
751
+ blockers: dedupeFindings(discoveries.flatMap((discovery) => normalizeFindings(discovery.blockers))),
752
+ test_gaps: dedupeFindings(discoveries.flatMap((discovery) => normalizeFindings(discovery.test_gaps))),
753
+ advisory_findings: dedupeFindings(discoveries.flatMap((discovery) => normalizeFindings(discovery.advisory_findings))),
754
+ uncertain_findings: dedupeFindings(discoveries.flatMap((discovery) => normalizeFindings(discovery.uncertain_findings))),
755
+ rejected_findings: dedupeFindings(discoveries.flatMap((discovery) => normalizeFindings(discovery.rejected_findings))),
756
+ };
757
+ }
758
+ function blockersNeedingCorrection(pending, validations) {
759
+ const byTitle = new Map(pending.map((blocker) => [normalizeTitle(blocker.title), blocker]));
760
+ const seen = new Set();
761
+ const unresolved = [];
762
+ for (const validation of validations) {
763
+ const key = normalizeTitle(validation.blocker_title);
764
+ seen.add(key);
765
+ if (validation.status === "unresolved") {
766
+ const original = byTitle.get(key);
767
+ if (original) {
768
+ unresolved.push({
769
+ ...original,
770
+ required_change: validation.required_change ?? original.required_change,
771
+ evidence: normalizeEvidence(validation.evidence, original.evidence),
772
+ });
773
+ }
774
+ continue;
775
+ }
776
+ if (validation.status === "regression_from_correction") {
777
+ unresolved.push({
778
+ title: validation.blocker_title,
779
+ required_change: validation.required_change ??
780
+ `Fix regression from correction: ${validation.blocker_title}`,
781
+ confidence: "high",
782
+ evidence: normalizeEvidence(validation.evidence),
783
+ });
398
784
  }
399
- const revision = await input.runPhase({
400
- phaseId: "global_revision",
401
- phaseDir: path.join(input.runDir, "final", `global-revision-${attempt}`),
402
- workdir: input.git.worktree,
403
- harness: input.harness,
404
- model: input.config.model,
405
- reasoning: input.config.reasoning_effort,
406
- capability: "write",
407
- prompt: buildPhasePrompt({
408
- guidance: GLOBAL_REVISION_PROMPT,
409
- context: buildContextBlock([
410
- ["Required changes", review.required_changes ?? []]
411
- ])
412
- })
413
- });
414
- if (!revision.ok) {
415
- throw new Error(revision.error);
785
+ }
786
+ for (const blocker of pending) {
787
+ if (!seen.has(normalizeTitle(blocker.title))) {
788
+ unresolved.push(blocker);
416
789
  }
417
- const { committed } = await commitAll({
418
- cwd: input.git.worktree,
419
- message: "Apply global review corrections"
420
- });
421
- if (committed) {
422
- committedCorrections = true;
790
+ }
791
+ return dedupeFindings(unresolved);
792
+ }
793
+ function normalizeFindings(findings) {
794
+ return dedupeFindings((findings ?? [])
795
+ .filter((finding) => finding.title && finding.required_change)
796
+ .map((finding) => ({
797
+ title: finding.title,
798
+ required_change: finding.required_change,
799
+ confidence: normalizeConfidence(finding.confidence),
800
+ evidence: normalizeEvidence(finding.evidence),
801
+ })));
802
+ }
803
+ function normalizeValidations(validations) {
804
+ const allowed = new Set([
805
+ "resolved",
806
+ "unresolved",
807
+ "false_positive",
808
+ "regression_from_correction",
809
+ ]);
810
+ return (validations ?? [])
811
+ .filter((validation) => validation.blocker_title && allowed.has(validation.status))
812
+ .map((validation) => ({
813
+ ...validation,
814
+ evidence: normalizeEvidence(validation.evidence),
815
+ }));
816
+ }
817
+ function dedupeFindings(findings) {
818
+ const seen = new Set();
819
+ const deduped = [];
820
+ for (const finding of findings) {
821
+ const key = `${normalizeTitle(finding.title)}\n${finding.required_change.trim().toLowerCase()}`;
822
+ if (seen.has(key)) {
823
+ continue;
423
824
  }
825
+ seen.add(key);
826
+ deduped.push(finding);
424
827
  }
425
- return committedCorrections;
828
+ return deduped;
829
+ }
830
+ function normalizeEvidence(evidence, fallback) {
831
+ if (Array.isArray(evidence) && evidence.length > 0) {
832
+ return evidence;
833
+ }
834
+ if (fallback && fallback.length > 0) {
835
+ return fallback;
836
+ }
837
+ return [{ detail: "No evidence provided." }];
838
+ }
839
+ function normalizeConfidence(value) {
840
+ if (value === "low" || value === "medium" || value === "high") {
841
+ return value;
842
+ }
843
+ return "medium";
844
+ }
845
+ function normalizeTitle(value) {
846
+ return value.trim().toLowerCase();
847
+ }
848
+ function textOrPlaceholder(text, placeholder) {
849
+ const trimmed = text.trim();
850
+ return `${trimmed.length > 0 ? trimmed : placeholder}\n`;
426
851
  }
427
852
  async function loadExecutionGuidance(nyxDir) {
428
853
  const override = path.join(nyxDir, "prompts", "execution.md");
@@ -441,7 +866,7 @@ function workItemSummary(item) {
441
866
  title: item.title,
442
867
  locator: item.source.locator,
443
868
  url: item.url,
444
- labels: item.labels
869
+ labels: item.labels,
445
870
  };
446
871
  }
447
872
  function buildCommitMessage(item) {
@@ -454,7 +879,9 @@ function buildPrTitle(items) {
454
879
  return `NyxAgent: ${items.length} work items`;
455
880
  }
456
881
  function buildPrBody(items) {
457
- const list = items.map((item) => `- ${item.title} (#${item.number})`).join("\n");
882
+ const list = items
883
+ .map((item) => `- ${item.title} (#${item.number})`)
884
+ .join("\n");
458
885
  const closes = items.map((item) => `Closes #${item.number}`).join("\n");
459
886
  return [
460
887
  "Automated changes by NyxAgent.",
@@ -463,7 +890,7 @@ function buildPrBody(items) {
463
890
  "",
464
891
  list,
465
892
  "",
466
- closes
893
+ closes,
467
894
  ].join("\n");
468
895
  }
469
896
  function buildDraftPrTitle(items) {
@@ -477,15 +904,15 @@ function buildDraftPrBody(items, reason) {
477
904
  "",
478
905
  `**Why the run failed:** ${reason}`,
479
906
  "",
480
- buildPrBody(items)
907
+ buildPrBody(items),
481
908
  ].join("\n");
482
909
  }
483
- /** Render review `required_changes` as a bullet list to append to a failure message. */
484
- function formatRequiredChanges(changes) {
485
- if (!changes || changes.length === 0) {
910
+ /** Render unresolved blockers as a bullet list to append to a failure message. */
911
+ function formatBlockers(blockers) {
912
+ if (blockers.length === 0) {
486
913
  return "";
487
914
  }
488
- return `\n\nUnresolved review feedback:\n${changes
489
- .map((change) => `- ${change}`)
915
+ return `\n\nUnresolved review blockers:\n${blockers
916
+ .map((blocker) => `- ${blocker.title}: ${blocker.required_change}`)
490
917
  .join("\n")}`;
491
918
  }