@nyxa/nyx-agent 0.4.1 → 0.6.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 +58 -9
- package/dist/cli.js +13 -16
- package/dist/commands/init.js +112 -462
- package/dist/commands/run.js +17 -3
- package/dist/commands/update.js +1 -0
- package/dist/config/loadConfig.js +17 -3
- package/dist/config/schema.js +29 -146
- package/dist/runtime/files.js +1 -0
- package/dist/runtime/git.js +1 -0
- package/dist/runtime/gitLifecycle.js +19 -57
- package/dist/runtime/harness.js +26 -0
- package/dist/runtime/ledger.js +1 -0
- package/dist/runtime/paths.js +1 -12
- package/dist/runtime/prompts.js +103 -0
- package/dist/runtime/runPhase.js +85 -254
- package/dist/runtime/runPipeline.js +479 -0
- package/dist/runtime/schemas.js +52 -0
- package/dist/runtime/scm.js +80 -0
- package/dist/runtime/time.js +1 -0
- package/dist/runtime/validateResult.js +2 -3
- package/dist/runtime/workItems.js +43 -118
- package/package.json +2 -5
- package/dist/runtime/buildPrompt.js +0 -54
- package/dist/runtime/effectiveConfig.js +0 -14
- package/dist/runtime/renderTemplate.js +0 -28
- package/dist/runtime/runWorkflow.js +0 -680
- package/dist/runtime/validateWorkItem.js +0 -212
- package/dist/runtime/workItemAnnotations.js +0 -39
- package/docs/nyxagent-v0-spec.md +0 -742
- package/templates/default/prompts/closure.md +0 -30
- package/templates/default/prompts/execution.md +0 -11
- package/templates/default/prompts/finalize.md +0 -7
- package/templates/default/prompts/global-review.md +0 -24
- package/templates/default/prompts/global-revision.md +0 -9
- package/templates/default/prompts/pull-request.md +0 -23
- package/templates/default/prompts/repair-result.md +0 -29
- package/templates/default/prompts/review.md +0 -18
- package/templates/default/prompts/revision.md +0 -7
- package/templates/default/prompts/selection.md +0 -46
- package/templates/default/schemas/closure.schema.json +0 -35
- package/templates/default/schemas/global-review.schema.json +0 -60
- package/templates/default/schemas/pull-request.schema.json +0 -44
- package/templates/default/schemas/review.schema.json +0 -60
- package/templates/default/schemas/selection.schema.json +0 -135
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import pc from "picocolors";
|
|
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";
|
|
7
|
+
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 { createRunId } from "./time.js";
|
|
13
|
+
import { filterAvailable, listGitHubIssues, resolveSelectedQueue } from "./workItems.js";
|
|
14
|
+
const MAX_CANDIDATES = 50;
|
|
15
|
+
const EXCERPT_CHARS = 800;
|
|
16
|
+
export function defaultPipelineDependencies() {
|
|
17
|
+
return {
|
|
18
|
+
listIssues: listGitHubIssues,
|
|
19
|
+
runPhase: runAgentPhase,
|
|
20
|
+
pushBranch,
|
|
21
|
+
createPullRequest
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The fixed NyxAgent pipeline:
|
|
26
|
+
*
|
|
27
|
+
* select -> for each item: implement -> [review/revise] -> commit
|
|
28
|
+
* -> [global review/revise] -> open pull request
|
|
29
|
+
*
|
|
30
|
+
* Control flow lives here in code (not in config). The agent only implements,
|
|
31
|
+
* reviews, and revises; every git/gh side effect is performed by the engine.
|
|
32
|
+
*/
|
|
33
|
+
export async function runPipeline(input = {}, deps = defaultPipelineDependencies()) {
|
|
34
|
+
const projectRoot = path.resolve(input.projectRoot ?? process.cwd());
|
|
35
|
+
const nyxDir = getNyxDir(projectRoot);
|
|
36
|
+
const configPath = input.configPath ?? path.join(nyxDir, "config.json");
|
|
37
|
+
const config = await loadConfig(configPath);
|
|
38
|
+
const harness = input.harness ?? config.harness;
|
|
39
|
+
const runId = createRunId();
|
|
40
|
+
const runDir = path.join(nyxDir, "runs", runId);
|
|
41
|
+
await ensureDir(runDir);
|
|
42
|
+
console.log(pc.bold(`NyxAgent run ${runId}`));
|
|
43
|
+
console.log(`Harness: ${harness} · model: ${config.model} · review: ${config.review}`);
|
|
44
|
+
const ledger = await readWorkItemLedger(nyxDir);
|
|
45
|
+
// 1. Selection runs read-only in the main checkout, before any branch exists.
|
|
46
|
+
const candidates = filterAvailable({
|
|
47
|
+
candidates: await deps.listIssues({
|
|
48
|
+
repo: config.tracker.repo,
|
|
49
|
+
maxCandidates: MAX_CANDIDATES,
|
|
50
|
+
excerptChars: EXCERPT_CHARS
|
|
51
|
+
}),
|
|
52
|
+
completedKeys: ledger.completed_work_item_keys
|
|
53
|
+
});
|
|
54
|
+
if (candidates.length === 0) {
|
|
55
|
+
console.log("No open work items available. Nothing to do.");
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const selected = await runSelection({
|
|
59
|
+
projectRoot,
|
|
60
|
+
runDir,
|
|
61
|
+
harness,
|
|
62
|
+
config,
|
|
63
|
+
candidates,
|
|
64
|
+
runPhase: deps.runPhase
|
|
65
|
+
});
|
|
66
|
+
if (selected.length === 0) {
|
|
67
|
+
console.log("Selection chose no work items. Nothing to do.");
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const planned = selected.slice(0, config.max_iterations);
|
|
71
|
+
console.log(`Selected ${planned.length} work item(s):`);
|
|
72
|
+
for (const item of planned) {
|
|
73
|
+
console.log(` - ${item.title} (#${item.number})`);
|
|
74
|
+
}
|
|
75
|
+
// 2. One branch + worktree per run (created only now that there is work).
|
|
76
|
+
const git = await setUpRunWorktree({
|
|
77
|
+
projectRoot,
|
|
78
|
+
runId,
|
|
79
|
+
base: config.base_branch
|
|
80
|
+
});
|
|
81
|
+
console.log(`Branch ${git.branch} (base ${git.base})`);
|
|
82
|
+
let success = false;
|
|
83
|
+
let producedCommits = false;
|
|
84
|
+
let currentLedger = ledger;
|
|
85
|
+
const completed = [];
|
|
86
|
+
try {
|
|
87
|
+
const executionGuidance = await loadExecutionGuidance(nyxDir);
|
|
88
|
+
for (const [index, item] of planned.entries()) {
|
|
89
|
+
const iterationNumber = index + 1;
|
|
90
|
+
const iterationDir = path.join(runDir, "iterations", String(iterationNumber).padStart(3, "0"));
|
|
91
|
+
console.log(pc.cyan(`\n[${iterationNumber}/${planned.length}] ${item.title} (#${item.number})`));
|
|
92
|
+
await runExecution({
|
|
93
|
+
iterationDir,
|
|
94
|
+
item,
|
|
95
|
+
guidance: executionGuidance,
|
|
96
|
+
git,
|
|
97
|
+
harness,
|
|
98
|
+
config,
|
|
99
|
+
runPhase: deps.runPhase
|
|
100
|
+
});
|
|
101
|
+
if (config.review === "each" || config.review === "both") {
|
|
102
|
+
await runReviewLoop({
|
|
103
|
+
iterationDir,
|
|
104
|
+
item,
|
|
105
|
+
git,
|
|
106
|
+
harness,
|
|
107
|
+
config,
|
|
108
|
+
maxAttempts: config.review_max_attempts,
|
|
109
|
+
runPhase: deps.runPhase
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
const { committed } = await commitAll({
|
|
113
|
+
cwd: git.worktree,
|
|
114
|
+
message: buildCommitMessage(item)
|
|
115
|
+
});
|
|
116
|
+
if (committed) {
|
|
117
|
+
producedCommits = true;
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
console.log(pc.yellow(" No changes to commit for this item."));
|
|
121
|
+
}
|
|
122
|
+
currentLedger = markWorkItemCompleted({
|
|
123
|
+
ledger: currentLedger,
|
|
124
|
+
workItem: item
|
|
125
|
+
});
|
|
126
|
+
await writeWorkItemLedger(nyxDir, currentLedger);
|
|
127
|
+
completed.push(item);
|
|
128
|
+
}
|
|
129
|
+
if (config.review === "all" || config.review === "both") {
|
|
130
|
+
const corrections = await runGlobalReviewLoop({
|
|
131
|
+
runDir,
|
|
132
|
+
git,
|
|
133
|
+
harness,
|
|
134
|
+
config,
|
|
135
|
+
maxAttempts: config.review_max_attempts,
|
|
136
|
+
runPhase: deps.runPhase
|
|
137
|
+
});
|
|
138
|
+
if (corrections) {
|
|
139
|
+
producedCommits = true;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (!producedCommits || (await commitsAhead(git.worktree, git.base)) === 0) {
|
|
143
|
+
console.log("\nRun produced no commits; skipping pull request.");
|
|
144
|
+
success = true;
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
await deps.pushBranch({ cwd: git.worktree, branch: git.branch });
|
|
148
|
+
const prUrl = await deps.createPullRequest({
|
|
149
|
+
cwd: git.worktree,
|
|
150
|
+
repo: config.tracker.repo,
|
|
151
|
+
base: git.base,
|
|
152
|
+
head: git.branch,
|
|
153
|
+
title: buildPrTitle(completed),
|
|
154
|
+
body: buildPrBody(completed)
|
|
155
|
+
});
|
|
156
|
+
console.log(pc.green(`\nPull request opened: ${prUrl}`));
|
|
157
|
+
success = true;
|
|
158
|
+
}
|
|
159
|
+
catch (error) {
|
|
160
|
+
// A failed run that already produced commits is salvaged into a draft PR so
|
|
161
|
+
// the work is never stranded on an orphaned branch; the error still
|
|
162
|
+
// propagates so the exit code reflects the failure.
|
|
163
|
+
await salvageFailedRun({
|
|
164
|
+
error,
|
|
165
|
+
projectRoot,
|
|
166
|
+
git,
|
|
167
|
+
producedCommits,
|
|
168
|
+
completed,
|
|
169
|
+
config,
|
|
170
|
+
deps
|
|
171
|
+
});
|
|
172
|
+
throw error;
|
|
173
|
+
}
|
|
174
|
+
finally {
|
|
175
|
+
if (success) {
|
|
176
|
+
await removeRunWorktree({ projectRoot, worktree: git.worktree });
|
|
177
|
+
if (!producedCommits) {
|
|
178
|
+
await deleteBranch({ projectRoot, branch: git.branch });
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Failure handling that preserves work. If the run produced commits, push the
|
|
185
|
+
* branch and open a DRAFT pull request describing why it failed, so a human can
|
|
186
|
+
* finish it. Otherwise just keep the branch/worktree for debugging. The branch
|
|
187
|
+
* and worktree are always kept on failure.
|
|
188
|
+
*/
|
|
189
|
+
async function salvageFailedRun(input) {
|
|
190
|
+
const location = relativeToProject(input.projectRoot, input.git.worktree);
|
|
191
|
+
// Best-effort: never let salvage throw and mask the original failure.
|
|
192
|
+
let ahead = 0;
|
|
193
|
+
if (input.producedCommits) {
|
|
194
|
+
try {
|
|
195
|
+
ahead = await commitsAhead(input.git.worktree, input.git.base);
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
ahead = 0;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (ahead === 0) {
|
|
202
|
+
console.log(pc.red(`\nRun failed. Branch ${input.git.branch} and worktree kept for debugging: ${location}`));
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
const reason = input.error instanceof Error ? input.error.message : String(input.error);
|
|
206
|
+
try {
|
|
207
|
+
await input.deps.pushBranch({
|
|
208
|
+
cwd: input.git.worktree,
|
|
209
|
+
branch: input.git.branch
|
|
210
|
+
});
|
|
211
|
+
const url = await input.deps.createPullRequest({
|
|
212
|
+
cwd: input.git.worktree,
|
|
213
|
+
repo: input.config.tracker.repo,
|
|
214
|
+
base: input.git.base,
|
|
215
|
+
head: input.git.branch,
|
|
216
|
+
title: buildDraftPrTitle(input.completed),
|
|
217
|
+
body: buildDraftPrBody(input.completed, reason),
|
|
218
|
+
draft: true
|
|
219
|
+
});
|
|
220
|
+
console.log(pc.yellow(`\nRun failed, but the work was salvaged into a DRAFT pull request: ${url}`));
|
|
221
|
+
console.log(pc.yellow(`Branch ${input.git.branch} and worktree kept: ${location}`));
|
|
222
|
+
}
|
|
223
|
+
catch (salvageError) {
|
|
224
|
+
const detail = salvageError instanceof Error
|
|
225
|
+
? salvageError.message
|
|
226
|
+
: String(salvageError);
|
|
227
|
+
console.log(pc.red(`\nRun failed, and salvaging the work into a draft pull request also failed: ${detail}`));
|
|
228
|
+
console.log(pc.red(`Branch ${input.git.branch} and worktree kept for debugging: ${location}`));
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
async function runSelection(input) {
|
|
232
|
+
const context = buildContextBlock([
|
|
233
|
+
["Repository", input.config.tracker.repo],
|
|
234
|
+
["Max work items this run", input.config.max_iterations],
|
|
235
|
+
[
|
|
236
|
+
"Available candidates",
|
|
237
|
+
input.candidates.map((candidate) => ({
|
|
238
|
+
key: candidate.key,
|
|
239
|
+
number: candidate.number,
|
|
240
|
+
title: candidate.title,
|
|
241
|
+
labels: candidate.labels,
|
|
242
|
+
excerpt: candidate.excerpt
|
|
243
|
+
}))
|
|
244
|
+
]
|
|
245
|
+
]);
|
|
246
|
+
const result = await input.runPhase({
|
|
247
|
+
phaseId: "selection",
|
|
248
|
+
phaseDir: path.join(input.runDir, "selection"),
|
|
249
|
+
workdir: input.projectRoot,
|
|
250
|
+
harness: input.harness,
|
|
251
|
+
model: input.config.model,
|
|
252
|
+
reasoning: input.config.reasoning_effort,
|
|
253
|
+
capability: "readonly",
|
|
254
|
+
prompt: buildPhasePrompt({
|
|
255
|
+
guidance: SELECTION_PROMPT,
|
|
256
|
+
context,
|
|
257
|
+
schema: SELECTION_SCHEMA
|
|
258
|
+
}),
|
|
259
|
+
schema: SELECTION_SCHEMA
|
|
260
|
+
});
|
|
261
|
+
if (!result.ok) {
|
|
262
|
+
throw new Error(result.error);
|
|
263
|
+
}
|
|
264
|
+
const value = result.result;
|
|
265
|
+
if (value.outcome === "no_work") {
|
|
266
|
+
return [];
|
|
267
|
+
}
|
|
268
|
+
const resolved = resolveSelectedQueue({
|
|
269
|
+
keys: value.work_item_keys,
|
|
270
|
+
candidates: input.candidates
|
|
271
|
+
});
|
|
272
|
+
if (!resolved.ok) {
|
|
273
|
+
throw new Error(`Selection produced an invalid queue: ${resolved.error}`);
|
|
274
|
+
}
|
|
275
|
+
return resolved.queue;
|
|
276
|
+
}
|
|
277
|
+
async function runExecution(input) {
|
|
278
|
+
const context = buildContextBlock([
|
|
279
|
+
["Work item", workItemSummary(input.item)],
|
|
280
|
+
["Issue description", input.item.excerpt ?? "(no description provided)"],
|
|
281
|
+
["Working directory", input.git.worktree],
|
|
282
|
+
["Branch", `${input.git.branch} (base ${input.git.base})`]
|
|
283
|
+
]);
|
|
284
|
+
const result = await input.runPhase({
|
|
285
|
+
phaseId: "execution",
|
|
286
|
+
phaseDir: path.join(input.iterationDir, "execution"),
|
|
287
|
+
workdir: input.git.worktree,
|
|
288
|
+
harness: input.harness,
|
|
289
|
+
model: input.config.model,
|
|
290
|
+
reasoning: input.config.reasoning_effort,
|
|
291
|
+
capability: "write",
|
|
292
|
+
prompt: buildPhasePrompt({ guidance: input.guidance, context })
|
|
293
|
+
});
|
|
294
|
+
if (!result.ok) {
|
|
295
|
+
throw new Error(result.error);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
async function runReviewLoop(input) {
|
|
299
|
+
for (let attempt = 1; attempt <= input.maxAttempts; attempt += 1) {
|
|
300
|
+
const diff = await stageAllAndDiff(input.git.worktree);
|
|
301
|
+
const reviewResult = await input.runPhase({
|
|
302
|
+
phaseId: "review",
|
|
303
|
+
phaseDir: path.join(input.iterationDir, `review-${attempt}`),
|
|
304
|
+
workdir: input.git.worktree,
|
|
305
|
+
harness: input.harness,
|
|
306
|
+
model: input.config.model,
|
|
307
|
+
reasoning: input.config.reasoning_effort,
|
|
308
|
+
capability: "readonly",
|
|
309
|
+
prompt: buildPhasePrompt({
|
|
310
|
+
guidance: REVIEW_PROMPT,
|
|
311
|
+
context: buildContextBlock([
|
|
312
|
+
["Work item", workItemSummary(input.item)],
|
|
313
|
+
["Uncommitted changes (diff)", truncateForPrompt(diff || "(no changes)")]
|
|
314
|
+
]),
|
|
315
|
+
schema: REVIEW_SCHEMA
|
|
316
|
+
}),
|
|
317
|
+
schema: REVIEW_SCHEMA
|
|
318
|
+
});
|
|
319
|
+
if (!reviewResult.ok) {
|
|
320
|
+
throw new Error(reviewResult.error);
|
|
321
|
+
}
|
|
322
|
+
const review = reviewResult.result;
|
|
323
|
+
console.log(` review: ${review.outcome}`);
|
|
324
|
+
if (review.outcome === "approved") {
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
if (attempt === input.maxAttempts) {
|
|
328
|
+
throw new Error(`Review for #${input.item.number} not approved after ${input.maxAttempts} attempts: ${review.summary}${formatRequiredChanges(review.required_changes)}`);
|
|
329
|
+
}
|
|
330
|
+
const revision = await input.runPhase({
|
|
331
|
+
phaseId: "revision",
|
|
332
|
+
phaseDir: path.join(input.iterationDir, `revision-${attempt}`),
|
|
333
|
+
workdir: input.git.worktree,
|
|
334
|
+
harness: input.harness,
|
|
335
|
+
model: input.config.model,
|
|
336
|
+
reasoning: input.config.reasoning_effort,
|
|
337
|
+
capability: "write",
|
|
338
|
+
prompt: buildPhasePrompt({
|
|
339
|
+
guidance: REVISION_PROMPT,
|
|
340
|
+
context: buildContextBlock([
|
|
341
|
+
["Work item", workItemSummary(input.item)],
|
|
342
|
+
["Required changes", review.required_changes ?? []]
|
|
343
|
+
])
|
|
344
|
+
})
|
|
345
|
+
});
|
|
346
|
+
if (!revision.ok) {
|
|
347
|
+
throw new Error(revision.error);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
async function runGlobalReviewLoop(input) {
|
|
352
|
+
let committedCorrections = false;
|
|
353
|
+
for (let attempt = 1; attempt <= input.maxAttempts; attempt += 1) {
|
|
354
|
+
const diff = await rangeDiff(input.git.worktree, input.git.base);
|
|
355
|
+
const reviewResult = await input.runPhase({
|
|
356
|
+
phaseId: "global_review",
|
|
357
|
+
phaseDir: path.join(input.runDir, "final", `global-review-${attempt}`),
|
|
358
|
+
workdir: input.git.worktree,
|
|
359
|
+
harness: input.harness,
|
|
360
|
+
model: input.config.model,
|
|
361
|
+
reasoning: input.config.reasoning_effort,
|
|
362
|
+
capability: "readonly",
|
|
363
|
+
prompt: buildPhasePrompt({
|
|
364
|
+
guidance: GLOBAL_REVIEW_PROMPT,
|
|
365
|
+
context: buildContextBlock([
|
|
366
|
+
["Run branch", `${input.git.branch} (base ${input.git.base})`],
|
|
367
|
+
[
|
|
368
|
+
"Combined run diff (base...HEAD)",
|
|
369
|
+
truncateForPrompt(diff || "(no changes)")
|
|
370
|
+
]
|
|
371
|
+
]),
|
|
372
|
+
schema: GLOBAL_REVIEW_SCHEMA
|
|
373
|
+
}),
|
|
374
|
+
schema: GLOBAL_REVIEW_SCHEMA
|
|
375
|
+
});
|
|
376
|
+
if (!reviewResult.ok) {
|
|
377
|
+
throw new Error(reviewResult.error);
|
|
378
|
+
}
|
|
379
|
+
const review = reviewResult.result;
|
|
380
|
+
console.log(`global review: ${review.outcome}`);
|
|
381
|
+
if (review.outcome === "approved") {
|
|
382
|
+
return committedCorrections;
|
|
383
|
+
}
|
|
384
|
+
if (attempt === input.maxAttempts) {
|
|
385
|
+
throw new Error(`Global review not approved after ${input.maxAttempts} attempts: ${review.summary}${formatRequiredChanges(review.required_changes)}`);
|
|
386
|
+
}
|
|
387
|
+
const revision = await input.runPhase({
|
|
388
|
+
phaseId: "global_revision",
|
|
389
|
+
phaseDir: path.join(input.runDir, "final", `global-revision-${attempt}`),
|
|
390
|
+
workdir: input.git.worktree,
|
|
391
|
+
harness: input.harness,
|
|
392
|
+
model: input.config.model,
|
|
393
|
+
reasoning: input.config.reasoning_effort,
|
|
394
|
+
capability: "write",
|
|
395
|
+
prompt: buildPhasePrompt({
|
|
396
|
+
guidance: GLOBAL_REVISION_PROMPT,
|
|
397
|
+
context: buildContextBlock([
|
|
398
|
+
["Required changes", review.required_changes ?? []]
|
|
399
|
+
])
|
|
400
|
+
})
|
|
401
|
+
});
|
|
402
|
+
if (!revision.ok) {
|
|
403
|
+
throw new Error(revision.error);
|
|
404
|
+
}
|
|
405
|
+
const { committed } = await commitAll({
|
|
406
|
+
cwd: input.git.worktree,
|
|
407
|
+
message: "Apply global review corrections"
|
|
408
|
+
});
|
|
409
|
+
if (committed) {
|
|
410
|
+
committedCorrections = true;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
return committedCorrections;
|
|
414
|
+
}
|
|
415
|
+
async function loadExecutionGuidance(nyxDir) {
|
|
416
|
+
const override = path.join(nyxDir, "prompts", "execution.md");
|
|
417
|
+
if (await pathExists(override)) {
|
|
418
|
+
const text = (await readText(override)).trim();
|
|
419
|
+
if (text) {
|
|
420
|
+
return text;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
return EXECUTION_PROMPT;
|
|
424
|
+
}
|
|
425
|
+
function workItemSummary(item) {
|
|
426
|
+
return {
|
|
427
|
+
key: item.key,
|
|
428
|
+
number: item.number,
|
|
429
|
+
title: item.title,
|
|
430
|
+
locator: item.source.locator,
|
|
431
|
+
url: item.url,
|
|
432
|
+
labels: item.labels
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
function buildCommitMessage(item) {
|
|
436
|
+
return `${item.title}\n\nWork item: ${item.source.locator}`;
|
|
437
|
+
}
|
|
438
|
+
function buildPrTitle(items) {
|
|
439
|
+
if (items.length === 1) {
|
|
440
|
+
return items[0].title;
|
|
441
|
+
}
|
|
442
|
+
return `NyxAgent: ${items.length} work items`;
|
|
443
|
+
}
|
|
444
|
+
function buildPrBody(items) {
|
|
445
|
+
const list = items.map((item) => `- ${item.title} (#${item.number})`).join("\n");
|
|
446
|
+
const closes = items.map((item) => `Closes #${item.number}`).join("\n");
|
|
447
|
+
return [
|
|
448
|
+
"Automated changes by NyxAgent.",
|
|
449
|
+
"",
|
|
450
|
+
"## Work items",
|
|
451
|
+
"",
|
|
452
|
+
list,
|
|
453
|
+
"",
|
|
454
|
+
closes
|
|
455
|
+
].join("\n");
|
|
456
|
+
}
|
|
457
|
+
function buildDraftPrTitle(items) {
|
|
458
|
+
return `[Draft] ${buildPrTitle(items)}`;
|
|
459
|
+
}
|
|
460
|
+
function buildDraftPrBody(items, reason) {
|
|
461
|
+
return [
|
|
462
|
+
"> [!WARNING]",
|
|
463
|
+
"> This pull request was opened automatically by NyxAgent after the run",
|
|
464
|
+
"> **failed review**. The work is preserved here for a human to finish.",
|
|
465
|
+
"",
|
|
466
|
+
`**Why the run failed:** ${reason}`,
|
|
467
|
+
"",
|
|
468
|
+
buildPrBody(items)
|
|
469
|
+
].join("\n");
|
|
470
|
+
}
|
|
471
|
+
/** Render review `required_changes` as a bullet list to append to a failure message. */
|
|
472
|
+
function formatRequiredChanges(changes) {
|
|
473
|
+
if (!changes || changes.length === 0) {
|
|
474
|
+
return "";
|
|
475
|
+
}
|
|
476
|
+
return `\n\nUnresolved review feedback:\n${changes
|
|
477
|
+
.map((change) => `- ${change}`)
|
|
478
|
+
.join("\n")}`;
|
|
479
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSON Schemas for the structured results NyxAgent requires from review-style
|
|
3
|
+
* phases. They are embedded (not files on disk) so they cannot drift from the
|
|
4
|
+
* engine and are shown verbatim to the agent in the phase prompt.
|
|
5
|
+
*/
|
|
6
|
+
export const SELECTION_SCHEMA = {
|
|
7
|
+
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
8
|
+
type: "object",
|
|
9
|
+
required: ["outcome"],
|
|
10
|
+
properties: {
|
|
11
|
+
outcome: { type: "string", enum: ["selected", "no_work"] },
|
|
12
|
+
work_item_keys: {
|
|
13
|
+
type: "array",
|
|
14
|
+
items: { type: "string" },
|
|
15
|
+
description: "Ordered keys of the chosen candidates (prerequisites first)."
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
allOf: [
|
|
19
|
+
{
|
|
20
|
+
if: { properties: { outcome: { const: "selected" } } },
|
|
21
|
+
then: { required: ["work_item_keys"] }
|
|
22
|
+
}
|
|
23
|
+
],
|
|
24
|
+
additionalProperties: true
|
|
25
|
+
};
|
|
26
|
+
const reviewSchema = {
|
|
27
|
+
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
28
|
+
type: "object",
|
|
29
|
+
required: ["outcome", "summary"],
|
|
30
|
+
properties: {
|
|
31
|
+
outcome: { type: "string", enum: ["approved", "changes_requested"] },
|
|
32
|
+
summary: {
|
|
33
|
+
type: "string",
|
|
34
|
+
minLength: 1,
|
|
35
|
+
description: "A brief assessment of the work."
|
|
36
|
+
},
|
|
37
|
+
required_changes: {
|
|
38
|
+
type: "array",
|
|
39
|
+
items: { type: "string" },
|
|
40
|
+
description: 'Specific, actionable changes (required when outcome is "changes_requested").'
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
allOf: [
|
|
44
|
+
{
|
|
45
|
+
if: { properties: { outcome: { const: "changes_requested" } } },
|
|
46
|
+
then: { required: ["required_changes"] }
|
|
47
|
+
}
|
|
48
|
+
],
|
|
49
|
+
additionalProperties: true
|
|
50
|
+
};
|
|
51
|
+
export const REVIEW_SCHEMA = reviewSchema;
|
|
52
|
+
export const GLOBAL_REVIEW_SCHEMA = reviewSchema;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { execa } from "execa";
|
|
2
|
+
/**
|
|
3
|
+
* Deterministic source-control side effects. NyxAgent — not the agent — performs
|
|
4
|
+
* every commit, push, and pull request, so closing the loop never depends on an
|
|
5
|
+
* LLM remembering to. All commands run with the process's own permissions (no
|
|
6
|
+
* sandbox), from the run worktree.
|
|
7
|
+
*/
|
|
8
|
+
async function git(cwd, args, label) {
|
|
9
|
+
const result = await execa("git", args, { cwd, reject: false });
|
|
10
|
+
if (result.exitCode !== 0) {
|
|
11
|
+
const detail = (result.stderr || result.stdout || "unknown error").trim();
|
|
12
|
+
throw new Error(`git ${label} failed: ${detail}`);
|
|
13
|
+
}
|
|
14
|
+
return { stdout: result.stdout };
|
|
15
|
+
}
|
|
16
|
+
/** Stage everything and return the staged diff (captures new files too). */
|
|
17
|
+
export async function stageAllAndDiff(cwd) {
|
|
18
|
+
await git(cwd, ["add", "-A"], "add");
|
|
19
|
+
const diff = await git(cwd, ["diff", "--cached"], "diff --cached");
|
|
20
|
+
return diff.stdout;
|
|
21
|
+
}
|
|
22
|
+
/** The committed diff for the run: everything on HEAD that is not on `base`. */
|
|
23
|
+
export async function rangeDiff(cwd, base) {
|
|
24
|
+
const diff = await git(cwd, ["diff", `${base}..HEAD`], "diff range");
|
|
25
|
+
return diff.stdout;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Stage all changes and commit them. Returns whether a commit was actually made
|
|
29
|
+
* (false when there was nothing to commit).
|
|
30
|
+
*/
|
|
31
|
+
export async function commitAll(input) {
|
|
32
|
+
await git(input.cwd, ["add", "-A"], "add");
|
|
33
|
+
const staged = await execa("git", ["diff", "--cached", "--quiet"], { cwd: input.cwd, reject: false });
|
|
34
|
+
// exit 0 = no staged changes, exit 1 = staged changes present.
|
|
35
|
+
if (staged.exitCode === 0) {
|
|
36
|
+
return { committed: false };
|
|
37
|
+
}
|
|
38
|
+
// Split a "subject\n\nbody" message into conventional subject + body args.
|
|
39
|
+
const [subject, ...rest] = input.message.split("\n\n");
|
|
40
|
+
const commitArgs = ["commit", "-m", subject];
|
|
41
|
+
const body = rest.join("\n\n").trim();
|
|
42
|
+
if (body) {
|
|
43
|
+
commitArgs.push("-m", body);
|
|
44
|
+
}
|
|
45
|
+
await git(input.cwd, commitArgs, "commit");
|
|
46
|
+
return { committed: true };
|
|
47
|
+
}
|
|
48
|
+
/** Number of commits on HEAD ahead of `base`. */
|
|
49
|
+
export async function commitsAhead(cwd, base) {
|
|
50
|
+
const result = await git(cwd, ["rev-list", "--count", `${base}..HEAD`], "rev-list");
|
|
51
|
+
return Number.parseInt(result.stdout.trim(), 10) || 0;
|
|
52
|
+
}
|
|
53
|
+
export async function pushBranch(input) {
|
|
54
|
+
await git(input.cwd, ["push", "-u", "origin", input.branch], "push");
|
|
55
|
+
}
|
|
56
|
+
export async function createPullRequest(input) {
|
|
57
|
+
const args = [
|
|
58
|
+
"pr",
|
|
59
|
+
"create",
|
|
60
|
+
"--repo",
|
|
61
|
+
input.repo,
|
|
62
|
+
"--base",
|
|
63
|
+
input.base,
|
|
64
|
+
"--head",
|
|
65
|
+
input.head,
|
|
66
|
+
"--title",
|
|
67
|
+
input.title,
|
|
68
|
+
"--body",
|
|
69
|
+
input.body
|
|
70
|
+
];
|
|
71
|
+
if (input.draft) {
|
|
72
|
+
args.push("--draft");
|
|
73
|
+
}
|
|
74
|
+
const result = await execa("gh", args, { cwd: input.cwd, reject: false });
|
|
75
|
+
if (result.exitCode !== 0) {
|
|
76
|
+
const detail = (result.stderr || result.stdout || "unknown error").trim();
|
|
77
|
+
throw new Error(`gh pr create failed: ${detail}`);
|
|
78
|
+
}
|
|
79
|
+
return result.stdout.trim();
|
|
80
|
+
}
|
package/dist/runtime/time.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
|
|
1
|
+
/** Validates a parsed phase result against its JSON Schema using Ajv. */
|
|
2
2
|
import { Ajv2020 } from "ajv/dist/2020.js";
|
|
3
|
-
export
|
|
4
|
-
const schema = JSON.parse(await readFile(schemaPath, "utf8"));
|
|
3
|
+
export function validateAgainstSchema(schema, value) {
|
|
5
4
|
const ajv = new Ajv2020({ allErrors: true });
|
|
6
5
|
const validate = ajv.compile(schema);
|
|
7
6
|
const valid = validate(value);
|