@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 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")
@@ -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))
@@ -1,3 +1,4 @@
1
+ /** `nyxagent run`: normalizes CLI options and hands off to the pipeline runner. */
1
2
  import path from "node:path";
2
3
  import { harnessNames } from "../config/schema.js";
3
4
  import { runPipeline } from "../runtime/runPipeline.js";
@@ -1,3 +1,4 @@
1
+ /** `nyxagent update`: self-update command that resolves the target version from npm and installs it globally. */
1
2
  import { createRequire } from "node:module";
2
3
  import { fileURLToPath } from "node:url";
3
4
  import { confirm } from "@inquirer/prompts";
@@ -1,3 +1,4 @@
1
+ /** Reads, JSON-parses, and schema-validates a .nyxagent/config.json file. */
1
2
  import { readFile } from "node:fs/promises";
2
3
  import { nyxConfigSchema } from "./schema.js";
3
4
  export async function loadConfig(configPath) {
@@ -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"),
@@ -1,3 +1,4 @@
1
+ /** Small filesystem helpers: ensure a directory, read/write text and JSON, check existence. */
1
2
  import { access, mkdir, readFile, writeFile } from "node:fs/promises";
2
3
  import path from "node:path";
3
4
  export async function ensureDir(dir) {
@@ -1,3 +1,4 @@
1
+ /** Read-only git snapshot (branch, HEAD, short status) recorded around each phase for the audit trail. */
1
2
  import { execa } from "execa";
2
3
  export async function getGitSnapshot(cwd) {
3
4
  const isRepo = await execa("git", ["rev-parse", "--is-inside-work-tree"], {
@@ -1,3 +1,4 @@
1
+ /** Tracks which work items each run completed (.nyxagent/state.json) so they are skipped on later runs. */
1
2
  import { readFile } from "node:fs/promises";
2
3
  import path from "node:path";
3
4
  import { writeJson } from "./files.js";
@@ -1,3 +1,4 @@
1
+ /** Project path helpers: locate the .nyxagent directory and render project-relative paths. */
1
2
  import path from "node:path";
2
3
  export function getNyxDir(projectRoot) {
3
4
  return path.join(projectRoot, ".nyxagent");
@@ -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
- else {
166
- console.log(pc.red(`\nRun failed. Branch ${git.branch} and worktree kept for debugging: ${relativeToProject(projectRoot, git.worktree)}`));
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 <= REVIEW_MAX_ATTEMPTS; attempt += 1) {
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 === REVIEW_MAX_ATTEMPTS) {
267
- throw new Error(`Review for #${input.item.number} not approved after ${REVIEW_MAX_ATTEMPTS} attempts: ${review.summary}`);
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 <= REVIEW_MAX_ATTEMPTS; attempt += 1) {
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 === REVIEW_MAX_ATTEMPTS) {
324
- throw new Error(`Global review not approved after ${REVIEW_MAX_ATTEMPTS} attempts: ${review.summary}`);
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
+ }
@@ -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 result = await execa("gh", [
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
- ], { cwd: input.cwd, reject: false });
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}`);
@@ -1,3 +1,4 @@
1
+ /** Builds a filesystem-safe, lexicographically sortable run id from a timestamp. */
1
2
  export function createRunId(date = new Date()) {
2
3
  return date.toISOString().replace(/\.\d{3}Z$/, "Z").replace(/[:]/g, "-");
3
4
  }
@@ -1,3 +1,4 @@
1
+ /** Validates a parsed phase result against its JSON Schema using Ajv. */
1
2
  import { Ajv2020 } from "ajv/dist/2020.js";
2
3
  export function validateAgainstSchema(schema, value) {
3
4
  const ajv = new Ajv2020({ allErrors: true });
@@ -1,3 +1,4 @@
1
+ /** Work items: lists GitHub issues via `gh`, normalizes them to candidates, and resolves the selected queue. */
1
2
  import { execa } from "execa";
2
3
  export async function listGitHubIssues(input) {
3
4
  const result = await execa("gh", [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nyxa/nyx-agent",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "A lightweight phase orchestrator for repeatedly launching coding agents with fresh context.",
5
5
  "type": "module",
6
6
  "repository": {