@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.
- package/README.md +14 -5
- package/dist/cli.js +4 -1
- package/dist/commands/init.js +53 -29
- package/dist/commands/run.js +2 -1
- package/dist/config/schema.js +45 -4
- package/dist/runtime/prompts.js +50 -17
- package/dist/runtime/reporter.js +65 -0
- package/dist/runtime/runPhase.js +76 -15
- package/dist/runtime/runPipeline.js +578 -151
- package/dist/runtime/schemas.js +108 -22
- package/package.json +1 -1
|
@@ -1,26 +1,29 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
|
-
import
|
|
2
|
+
import { execa } from "execa";
|
|
3
3
|
import { loadConfig } from "../config/loadConfig.js";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
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,
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
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
|
|
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
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
99
|
+
reporter.info(`Selected ${planned.length} work item(s):`);
|
|
84
100
|
for (const item of planned) {
|
|
85
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
165
|
+
completed,
|
|
166
|
+
cliHarness: input.harness,
|
|
146
167
|
config,
|
|
147
|
-
|
|
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 ||
|
|
155
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
233
|
-
|
|
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
|
-
|
|
240
|
-
|
|
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:
|
|
263
|
-
model:
|
|
264
|
-
reasoning:
|
|
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:
|
|
301
|
-
model:
|
|
302
|
-
reasoning:
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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(
|
|
365
|
+
phaseDir: path.join(roundDir, "discovery"),
|
|
316
366
|
workdir: input.git.worktree,
|
|
317
|
-
harness:
|
|
318
|
-
model:
|
|
319
|
-
reasoning:
|
|
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
|
-
["
|
|
375
|
+
["Review round", round],
|
|
376
|
+
["Review context", reviewContextSummary(discoveryPack)],
|
|
326
377
|
]),
|
|
327
|
-
schema:
|
|
378
|
+
schema: REVIEW_DISCOVERY_SCHEMA,
|
|
328
379
|
}),
|
|
329
|
-
schema:
|
|
380
|
+
schema: REVIEW_DISCOVERY_SCHEMA,
|
|
381
|
+
reporter: input.reporter,
|
|
330
382
|
});
|
|
331
|
-
if (!
|
|
332
|
-
throw new Error(
|
|
383
|
+
if (!discoveryResult.ok) {
|
|
384
|
+
throw new Error(discoveryResult.error);
|
|
333
385
|
}
|
|
334
|
-
const
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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
|
-
|
|
340
|
-
|
|
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:
|
|
344
|
-
phaseDir: path.join(input.
|
|
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.
|
|
348
|
-
reasoning: input.
|
|
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:
|
|
609
|
+
guidance: input.revisionGuidance,
|
|
352
610
|
context: buildContextBlock([
|
|
353
|
-
["
|
|
354
|
-
[
|
|
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
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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.
|
|
373
|
-
reasoning: input.
|
|
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:
|
|
650
|
+
guidance: input.validationGuidance,
|
|
377
651
|
context: buildContextBlock([
|
|
378
|
-
["
|
|
652
|
+
["Review context", reviewContextSummary(validationPack)],
|
|
379
653
|
[
|
|
380
|
-
"
|
|
381
|
-
|
|
382
|
-
]
|
|
654
|
+
"Correction attempt",
|
|
655
|
+
`${attempt}/${CORRECTION_VALIDATION_MAX_ATTEMPTS}`,
|
|
656
|
+
],
|
|
657
|
+
["Validated blockers", pending],
|
|
383
658
|
]),
|
|
384
|
-
schema:
|
|
659
|
+
schema: REVIEW_VALIDATION_SCHEMA,
|
|
385
660
|
}),
|
|
386
|
-
schema:
|
|
661
|
+
schema: REVIEW_VALIDATION_SCHEMA,
|
|
662
|
+
reporter: input.reporter,
|
|
387
663
|
});
|
|
388
|
-
if (!
|
|
389
|
-
throw new Error(
|
|
664
|
+
if (!validationResult.ok) {
|
|
665
|
+
throw new Error(validationResult.error);
|
|
390
666
|
}
|
|
391
|
-
const
|
|
392
|
-
|
|
393
|
-
|
|
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
|
-
|
|
397
|
-
|
|
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
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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
|
|
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
|
|
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
|
|
484
|
-
function
|
|
485
|
-
if (
|
|
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
|
|
489
|
-
.map((
|
|
915
|
+
return `\n\nUnresolved review blockers:\n${blockers
|
|
916
|
+
.map((blocker) => `- ${blocker.title}: ${blocker.required_change}`)
|
|
490
917
|
.join("\n")}`;
|
|
491
918
|
}
|