@nyxa/nyx-agent 0.5.0 → 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 +6 -0
- package/dist/cli.js +2 -0
- package/dist/commands/init.js +30 -1
- package/dist/commands/run.js +1 -0
- package/dist/commands/update.js +1 -0
- package/dist/config/loadConfig.js +1 -0
- package/dist/config/schema.js +2 -0
- package/dist/runtime/files.js +1 -0
- package/dist/runtime/git.js +1 -0
- package/dist/runtime/ledger.js +1 -0
- package/dist/runtime/paths.js +1 -0
- package/dist/runtime/runPipeline.js +93 -9
- package/dist/runtime/scm.js +6 -2
- package/dist/runtime/time.js +1 -0
- package/dist/runtime/validateResult.js +1 -0
- package/dist/runtime/workItems.js +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -42,6 +42,7 @@ nyxagent update # self-update to the latest published version
|
|
|
42
42
|
"model": "gpt-5.5",
|
|
43
43
|
"reasoning_effort": "medium",
|
|
44
44
|
"review": "each",
|
|
45
|
+
"review_max_attempts": 4,
|
|
45
46
|
"tracker": { "type": "github", "repo": "owner/repo" },
|
|
46
47
|
"base_branch": "main",
|
|
47
48
|
"max_iterations": 5
|
|
@@ -50,8 +51,13 @@ nyxagent update # self-update to the latest published version
|
|
|
50
51
|
|
|
51
52
|
- `harness`: `codex` or `claude` (override per run with `--harness`).
|
|
52
53
|
- `review`: `each` (per task), `all` (global only), `both`, or `none`.
|
|
54
|
+
- `review_max_attempts`: review+revise rounds per stage before the run fails (default 4).
|
|
53
55
|
- `base_branch`: optional; defaults to the current branch at run time.
|
|
54
56
|
|
|
57
|
+
If a run fails review after exhausting its attempts but has already produced
|
|
58
|
+
commits, NyxAgent pushes the branch and opens a **draft** pull request with the
|
|
59
|
+
unresolved feedback, so the work is never stranded on an orphaned branch.
|
|
60
|
+
|
|
55
61
|
## Requirements
|
|
56
62
|
|
|
57
63
|
- A git repository with a GitHub remote.
|
package/dist/cli.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
/** CLI entry point: declares the `init`, `run`, and `update` commands and dispatches them. */
|
|
2
3
|
import { Command } from "commander";
|
|
3
4
|
import { createRequire } from "node:module";
|
|
4
5
|
import pc from "picocolors";
|
|
@@ -19,6 +20,7 @@ program
|
|
|
19
20
|
.option("--model <name>", "model name")
|
|
20
21
|
.option("--reasoning-effort <level>", "reasoning effort (default: medium)")
|
|
21
22
|
.option("--review <mode>", "review strategy: each, all, both, or none")
|
|
23
|
+
.option("--review-attempts <count>", "max review attempts per stage (default: 4)")
|
|
22
24
|
.option("--repo <owner/repo>", "GitHub repository")
|
|
23
25
|
.option("--base-branch <branch>", "base branch (default: current branch)")
|
|
24
26
|
.option("--max-iterations <count>", "maximum work items per run")
|
package/dist/commands/init.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
/** `nyxagent init`: scaffolds .nyxagent/config.json, the editable execution prompt, and .gitignore entries. */
|
|
1
2
|
import path from "node:path";
|
|
2
3
|
import { input, number as numberPrompt, select } from "@inquirer/prompts";
|
|
3
4
|
import pc from "picocolors";
|
|
@@ -7,10 +8,16 @@ import { getNyxDir, relativeToProject } from "../runtime/paths.js";
|
|
|
7
8
|
import { EXECUTION_PROMPT_FILE } from "../runtime/prompts.js";
|
|
8
9
|
const DEFAULT_CODEX_MODEL = "gpt-5.5";
|
|
9
10
|
const GITIGNORE_MARKER = "# NyxAgent runtime artifacts";
|
|
11
|
+
// Everything under .nyxagent/ is NyxAgent's own plumbing. Ignoring it keeps it
|
|
12
|
+
// out of the agent's worktree checkout and out of the commits/diffs it reviews,
|
|
13
|
+
// so the agent can never accidentally edit (or review) NyxAgent's own files.
|
|
10
14
|
const GITIGNORE_ENTRIES = [
|
|
11
15
|
".nyxagent/runs/",
|
|
12
16
|
".nyxagent/worktrees/",
|
|
13
|
-
".nyxagent/state.json"
|
|
17
|
+
".nyxagent/state.json",
|
|
18
|
+
".nyxagent/config.json",
|
|
19
|
+
".nyxagent/config.toml",
|
|
20
|
+
".nyxagent/prompts/"
|
|
14
21
|
];
|
|
15
22
|
export async function initCommand(options, projectRoot = process.cwd()) {
|
|
16
23
|
const root = path.resolve(projectRoot);
|
|
@@ -62,6 +69,17 @@ async function resolveInitOptions(options) {
|
|
|
62
69
|
],
|
|
63
70
|
default: "each"
|
|
64
71
|
});
|
|
72
|
+
const review_max_attempts = review === "none"
|
|
73
|
+
? 4
|
|
74
|
+
: parseReviewAttempts(options.reviewAttempts) ??
|
|
75
|
+
(await numberPrompt({
|
|
76
|
+
message: "Max review attempts per stage",
|
|
77
|
+
default: 4,
|
|
78
|
+
required: true
|
|
79
|
+
}));
|
|
80
|
+
if (!Number.isInteger(review_max_attempts) || review_max_attempts <= 0) {
|
|
81
|
+
throw new Error("review attempts must be a positive integer");
|
|
82
|
+
}
|
|
65
83
|
const repo = options.repo ?? (await input({ message: "GitHub repository (owner/repo)" }));
|
|
66
84
|
validateRepository(repo);
|
|
67
85
|
const baseBranchInput = options.baseBranch ??
|
|
@@ -84,6 +102,7 @@ async function resolveInitOptions(options) {
|
|
|
84
102
|
model: model.trim(),
|
|
85
103
|
reasoning_effort: reasoning_effort.trim() || "medium",
|
|
86
104
|
review,
|
|
105
|
+
review_max_attempts,
|
|
87
106
|
repo,
|
|
88
107
|
base_branch,
|
|
89
108
|
max_iterations
|
|
@@ -98,6 +117,10 @@ function buildConfig(options) {
|
|
|
98
117
|
tracker: { type: "github", repo: options.repo },
|
|
99
118
|
max_iterations: options.max_iterations
|
|
100
119
|
};
|
|
120
|
+
// No point persisting an attempts cap when reviews are disabled.
|
|
121
|
+
if (options.review !== "none") {
|
|
122
|
+
config.review_max_attempts = options.review_max_attempts;
|
|
123
|
+
}
|
|
101
124
|
if (options.base_branch) {
|
|
102
125
|
config.base_branch = options.base_branch;
|
|
103
126
|
}
|
|
@@ -126,6 +149,12 @@ function parseMaxIterations(value) {
|
|
|
126
149
|
}
|
|
127
150
|
return Number.parseInt(value, 10);
|
|
128
151
|
}
|
|
152
|
+
function parseReviewAttempts(value) {
|
|
153
|
+
if (value === undefined) {
|
|
154
|
+
return undefined;
|
|
155
|
+
}
|
|
156
|
+
return Number.parseInt(value, 10);
|
|
157
|
+
}
|
|
129
158
|
async function ensureGitignoreEntries(root) {
|
|
130
159
|
const gitignorePath = path.join(root, ".gitignore");
|
|
131
160
|
const current = (await pathExists(gitignorePath))
|
package/dist/commands/run.js
CHANGED
package/dist/commands/update.js
CHANGED
package/dist/config/schema.js
CHANGED
|
@@ -18,6 +18,8 @@ export const nyxConfigSchema = z
|
|
|
18
18
|
reasoning_effort: z.string().min(1).default("medium"),
|
|
19
19
|
/** When the agent reviews its own work. */
|
|
20
20
|
review: z.enum(reviewModes).default("each"),
|
|
21
|
+
/** How many review+revise rounds a review stage gets before the run fails. */
|
|
22
|
+
review_max_attempts: z.number().int().positive().default(4),
|
|
21
23
|
/** Work item tracker. GitHub issues only in this version. */
|
|
22
24
|
tracker: z.object({
|
|
23
25
|
type: z.literal("github"),
|
package/dist/runtime/files.js
CHANGED
package/dist/runtime/git.js
CHANGED
package/dist/runtime/ledger.js
CHANGED
package/dist/runtime/paths.js
CHANGED
|
@@ -13,7 +13,6 @@ import { createRunId } from "./time.js";
|
|
|
13
13
|
import { filterAvailable, listGitHubIssues, resolveSelectedQueue } from "./workItems.js";
|
|
14
14
|
const MAX_CANDIDATES = 50;
|
|
15
15
|
const EXCERPT_CHARS = 800;
|
|
16
|
-
const REVIEW_MAX_ATTEMPTS = 3;
|
|
17
16
|
export function defaultPipelineDependencies() {
|
|
18
17
|
return {
|
|
19
18
|
listIssues: listGitHubIssues,
|
|
@@ -106,6 +105,7 @@ export async function runPipeline(input = {}, deps = defaultPipelineDependencies
|
|
|
106
105
|
git,
|
|
107
106
|
harness,
|
|
108
107
|
config,
|
|
108
|
+
maxAttempts: config.review_max_attempts,
|
|
109
109
|
runPhase: deps.runPhase
|
|
110
110
|
});
|
|
111
111
|
}
|
|
@@ -132,6 +132,7 @@ export async function runPipeline(input = {}, deps = defaultPipelineDependencies
|
|
|
132
132
|
git,
|
|
133
133
|
harness,
|
|
134
134
|
config,
|
|
135
|
+
maxAttempts: config.review_max_attempts,
|
|
135
136
|
runPhase: deps.runPhase
|
|
136
137
|
});
|
|
137
138
|
if (corrections) {
|
|
@@ -155,6 +156,21 @@ export async function runPipeline(input = {}, deps = defaultPipelineDependencies
|
|
|
155
156
|
console.log(pc.green(`\nPull request opened: ${prUrl}`));
|
|
156
157
|
success = true;
|
|
157
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
|
+
}
|
|
158
174
|
finally {
|
|
159
175
|
if (success) {
|
|
160
176
|
await removeRunWorktree({ projectRoot, worktree: git.worktree });
|
|
@@ -162,10 +178,55 @@ export async function runPipeline(input = {}, deps = defaultPipelineDependencies
|
|
|
162
178
|
await deleteBranch({ projectRoot, branch: git.branch });
|
|
163
179
|
}
|
|
164
180
|
}
|
|
165
|
-
|
|
166
|
-
|
|
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;
|
|
167
199
|
}
|
|
168
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
|
+
}
|
|
169
230
|
}
|
|
170
231
|
async function runSelection(input) {
|
|
171
232
|
const context = buildContextBlock([
|
|
@@ -235,7 +296,7 @@ async function runExecution(input) {
|
|
|
235
296
|
}
|
|
236
297
|
}
|
|
237
298
|
async function runReviewLoop(input) {
|
|
238
|
-
for (let attempt = 1; attempt <=
|
|
299
|
+
for (let attempt = 1; attempt <= input.maxAttempts; attempt += 1) {
|
|
239
300
|
const diff = await stageAllAndDiff(input.git.worktree);
|
|
240
301
|
const reviewResult = await input.runPhase({
|
|
241
302
|
phaseId: "review",
|
|
@@ -263,8 +324,8 @@ async function runReviewLoop(input) {
|
|
|
263
324
|
if (review.outcome === "approved") {
|
|
264
325
|
return;
|
|
265
326
|
}
|
|
266
|
-
if (attempt ===
|
|
267
|
-
throw new Error(`Review for #${input.item.number} not approved after ${
|
|
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)}`);
|
|
268
329
|
}
|
|
269
330
|
const revision = await input.runPhase({
|
|
270
331
|
phaseId: "revision",
|
|
@@ -289,7 +350,7 @@ async function runReviewLoop(input) {
|
|
|
289
350
|
}
|
|
290
351
|
async function runGlobalReviewLoop(input) {
|
|
291
352
|
let committedCorrections = false;
|
|
292
|
-
for (let attempt = 1; attempt <=
|
|
353
|
+
for (let attempt = 1; attempt <= input.maxAttempts; attempt += 1) {
|
|
293
354
|
const diff = await rangeDiff(input.git.worktree, input.git.base);
|
|
294
355
|
const reviewResult = await input.runPhase({
|
|
295
356
|
phaseId: "global_review",
|
|
@@ -320,8 +381,8 @@ async function runGlobalReviewLoop(input) {
|
|
|
320
381
|
if (review.outcome === "approved") {
|
|
321
382
|
return committedCorrections;
|
|
322
383
|
}
|
|
323
|
-
if (attempt ===
|
|
324
|
-
throw new Error(`Global review not approved after ${
|
|
384
|
+
if (attempt === input.maxAttempts) {
|
|
385
|
+
throw new Error(`Global review not approved after ${input.maxAttempts} attempts: ${review.summary}${formatRequiredChanges(review.required_changes)}`);
|
|
325
386
|
}
|
|
326
387
|
const revision = await input.runPhase({
|
|
327
388
|
phaseId: "global_revision",
|
|
@@ -393,3 +454,26 @@ function buildPrBody(items) {
|
|
|
393
454
|
closes
|
|
394
455
|
].join("\n");
|
|
395
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
|
+
}
|
package/dist/runtime/scm.js
CHANGED
|
@@ -54,7 +54,7 @@ export async function pushBranch(input) {
|
|
|
54
54
|
await git(input.cwd, ["push", "-u", "origin", input.branch], "push");
|
|
55
55
|
}
|
|
56
56
|
export async function createPullRequest(input) {
|
|
57
|
-
const
|
|
57
|
+
const args = [
|
|
58
58
|
"pr",
|
|
59
59
|
"create",
|
|
60
60
|
"--repo",
|
|
@@ -67,7 +67,11 @@ export async function createPullRequest(input) {
|
|
|
67
67
|
input.title,
|
|
68
68
|
"--body",
|
|
69
69
|
input.body
|
|
70
|
-
]
|
|
70
|
+
];
|
|
71
|
+
if (input.draft) {
|
|
72
|
+
args.push("--draft");
|
|
73
|
+
}
|
|
74
|
+
const result = await execa("gh", args, { cwd: input.cwd, reject: false });
|
|
71
75
|
if (result.exitCode !== 0) {
|
|
72
76
|
const detail = (result.stderr || result.stdout || "unknown error").trim();
|
|
73
77
|
throw new Error(`gh pr create failed: ${detail}`);
|
package/dist/runtime/time.js
CHANGED