@nyxa/nyx-agent 0.3.4 → 0.4.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
@@ -9,6 +9,7 @@ Current commands:
9
9
  nyxagent init
10
10
  nyxagent init --missing
11
11
  nyxagent run
12
+ nyxagent update
12
13
  ```
13
14
 
14
15
  See [docs/nyxagent-v0-spec.md](docs/nyxagent-v0-spec.md) for the v0 design.
package/dist/cli.js CHANGED
@@ -4,6 +4,7 @@ import { createRequire } from "node:module";
4
4
  import pc from "picocolors";
5
5
  import { initCommand } from "./commands/init.js";
6
6
  import { runCommand } from "./commands/run.js";
7
+ import { updateCommand } from "./commands/update.js";
7
8
  const require = createRequire(import.meta.url);
8
9
  const packageJson = require("../package.json");
9
10
  const program = new Command();
@@ -17,11 +18,17 @@ program
17
18
  .option("--missing", "only add missing template files")
18
19
  .option("--harness <preset>", "harness preset: codex, claude, or custom")
19
20
  .option("--model <name>", "model name")
20
- .option("--reasoning-level <level>", "harness-neutral reasoning level")
21
+ .option("--reasoning-level <level>", "default harness-neutral reasoning level")
21
22
  .option("--max-iterations <count>", "maximum work items per run")
22
23
  .option("--work-items-source <source>", "work item source template: local or github")
23
24
  .option("--work-items-path <path>", "local markdown work item directory")
24
25
  .option("--work-items-repository <owner/repo>", "GitHub work item repository")
26
+ .option("--implementation-skill <name>", "implementation skill to reference in the execution prompt")
27
+ .option("--review", "include a review phase (alias for --review-mode each)")
28
+ .option("--no-review", "skip the review phase (alias for --review-mode none)")
29
+ .option("--review-mode <mode>", "review strategy: each, all, both, or none")
30
+ .option("--pull-request", "close the PRD with a pull request (GitHub work items)")
31
+ .option("--no-pull-request", "skip the pull request finalization phase")
25
32
  .action(async (options) => {
26
33
  await initCommand(options);
27
34
  });
@@ -32,6 +39,16 @@ program
32
39
  .action(async (options) => {
33
40
  await runCommand(options);
34
41
  });
42
+ program
43
+ .command("update")
44
+ .description("Update nyxagent to the latest published version")
45
+ .argument("[version]", "version or dist-tag to install (default: latest)")
46
+ .option("--check", "only check for a newer version, do not install")
47
+ .option("-y, --yes", "skip the confirmation prompt")
48
+ .option("--package-manager <pm>", "force a package manager: npm, pnpm, yarn, or bun")
49
+ .action(async (version, options) => {
50
+ await updateCommand(version, options);
51
+ });
35
52
  await program.parseAsync().catch((error) => {
36
53
  const message = error instanceof Error ? error.message : String(error);
37
54
  console.error(pc.red(`Error: ${message}`));
@@ -1,13 +1,17 @@
1
1
  import { readdir, stat } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
- import { input, number as numberPrompt, select } from "@inquirer/prompts";
4
+ import { confirm, input, number as numberPrompt, select } from "@inquirer/prompts";
5
5
  import pc from "picocolors";
6
6
  import { ensureDir, pathExists, readText, writeText } from "../runtime/files.js";
7
7
  import { getNyxDir, relativeToProject } from "../runtime/paths.js";
8
8
  const DEFAULT_OPENAI_MODEL = "gpt-5.5";
9
9
  const GITIGNORE_MARKER = "# NyxAgent runtime artifacts";
10
- const GITIGNORE_ENTRIES = [".nyxagent/runs/", ".nyxagent/state.json"];
10
+ const GITIGNORE_ENTRIES = [
11
+ ".nyxagent/runs/",
12
+ ".nyxagent/state.json",
13
+ ".nyxagent/worktrees/"
14
+ ];
11
15
  export async function initCommand(options, projectRoot = process.cwd()) {
12
16
  const root = path.resolve(projectRoot);
13
17
  const nyxDir = getNyxDir(root);
@@ -27,6 +31,10 @@ export async function initCommand(options, projectRoot = process.cwd()) {
27
31
  if (resolved) {
28
32
  await writeText(configPath, buildConfigToml(resolved));
29
33
  }
34
+ if (resolved?.implementationSkill) {
35
+ const executionPromptPath = path.join(nyxDir, "prompts", "execution.md");
36
+ await writeText(executionPromptPath, buildSkillExecutionPrompt(resolved.implementationSkill));
37
+ }
30
38
  if (resolved?.workItemsSource === "local" && resolved.workItemsPath) {
31
39
  await ensureWorkItemsDirectory(root, resolved.workItemsPath);
32
40
  }
@@ -50,9 +58,11 @@ async function resolveInitOptions(options, root) {
50
58
  }));
51
59
  const reasoningLevel = options.reasoningLevel ??
52
60
  (await input({
53
- message: "Reasoning level",
61
+ message: "Default reasoning level",
54
62
  default: "medium"
55
63
  }));
64
+ const implementationSkill = await resolveImplementationSkill(options);
65
+ const reviewMode = await resolveReviewMode(options);
56
66
  const parsedMaxIterations = options.maxIterations
57
67
  ? Number.parseInt(options.maxIterations, 10)
58
68
  : undefined;
@@ -92,6 +102,11 @@ async function resolveInitOptions(options, root) {
92
102
  if (workItemsSource === "github") {
93
103
  validateGitHubRepository(workItemsRepository);
94
104
  }
105
+ // GitHub PRDs are closed with a pull request by default (a dedicated branch +
106
+ // worktree). Opt out with --no-pull-request or by removing [git]/final_phase
107
+ // from the generated config. Pull requests are GitHub-specific, so local work
108
+ // item sources never get the finalization phase.
109
+ const pullRequest = workItemsSource === "github" ? options.pullRequest ?? true : false;
95
110
  if (!Number.isInteger(maxIterations) || maxIterations <= 0) {
96
111
  throw new Error("max iterations must be a positive integer");
97
112
  }
@@ -102,9 +117,63 @@ async function resolveInitOptions(options, root) {
102
117
  maxIterations,
103
118
  workItemsSource,
104
119
  workItemsPath,
105
- workItemsRepository
120
+ workItemsRepository,
121
+ implementationSkill,
122
+ reviewMode,
123
+ pullRequest
106
124
  };
107
125
  }
126
+ async function resolveReviewMode(options) {
127
+ if (options.reviewMode !== undefined) {
128
+ return normalizeReviewMode(options.reviewMode);
129
+ }
130
+ // Backward compatibility with the boolean --review / --no-review flags.
131
+ if (options.review !== undefined) {
132
+ return options.review ? "each" : "none";
133
+ }
134
+ return select({
135
+ message: "Review strategy",
136
+ choices: [
137
+ {
138
+ name: "After each task (review + correction inside the loop)",
139
+ value: "each"
140
+ },
141
+ {
142
+ name: "After all tasks (global review + correction)",
143
+ value: "all"
144
+ },
145
+ {
146
+ name: "Both (per-task and a final global review)",
147
+ value: "both"
148
+ },
149
+ { name: "No review", value: "none" }
150
+ ],
151
+ default: "each"
152
+ });
153
+ }
154
+ function normalizeReviewMode(mode) {
155
+ if (mode === "each" || mode === "all" || mode === "both" || mode === "none") {
156
+ return mode;
157
+ }
158
+ throw new Error('review mode must be "each", "all", "both", or "none"');
159
+ }
160
+ async function resolveImplementationSkill(options) {
161
+ if (options.implementationSkill !== undefined) {
162
+ const provided = options.implementationSkill.trim();
163
+ return provided.length > 0 ? provided : undefined;
164
+ }
165
+ const useSkill = await confirm({
166
+ message: "Use an implementation skill?",
167
+ default: false
168
+ });
169
+ if (!useSkill) {
170
+ return undefined;
171
+ }
172
+ const skill = (await input({
173
+ message: "Skill name (as you invoke it, e.g. /tdd)"
174
+ })).trim();
175
+ return skill.length > 0 ? skill : undefined;
176
+ }
108
177
  function normalizeWorkItemsSource(source) {
109
178
  if (source === "local" || source === "local-markdown") {
110
179
  return "local";
@@ -188,11 +257,71 @@ function getGitignoreAppendPrefix(current) {
188
257
  function normalizeGitignoreContent(value) {
189
258
  return `${value.replace(/\n*$/, "")}\n`;
190
259
  }
260
+ function buildSkillExecutionPrompt(skill) {
261
+ return `Implement the selected work item using the ${skill} skill.
262
+
263
+ Do not commit. Do not close or mark the work item done. Leave clear validation
264
+ evidence in your final response.
265
+ `;
266
+ }
191
267
  function buildConfigToml(options) {
192
268
  const harness = buildHarnessToml(options.harness);
193
269
  const workItems = buildWorkItemsToml(options);
270
+ const perTaskReview = options.reviewMode === "each" || options.reviewMode === "both";
271
+ const globalReview = options.reviewMode === "all" || options.reviewMode === "both";
272
+ const executionNext = perTaskReview ? "review" : "closure";
273
+ const reviewPhases = perTaskReview
274
+ ? `
275
+ [[phases]]
276
+ id = "revision"
277
+ prompt = "prompts/revision.md"
278
+ next = "review"
279
+ max_visits_per_iteration = 3
280
+
281
+ [[phases]]
282
+ id = "review"
283
+ prompt = "prompts/review.md"
284
+ output_schema = "schemas/review.schema.json"
285
+ required_output = true
286
+ max_visits_per_iteration = 3
287
+
288
+ [phases.harness]
289
+ args = ${formatTomlArray(buildHarnessArgs(options.harness, "readonly"))}
290
+
291
+ [phases.transitions]
292
+ approved = "closure"
293
+ changes_requested = "revision"
294
+ `
295
+ : "";
296
+ // The finalization terminal leaf the global review approves into: a pushed
297
+ // pull request for GitHub, otherwise a read-only finalize phase.
298
+ const finalizationTerminal = options.pullRequest
299
+ ? "pull_request"
300
+ : globalReview
301
+ ? "finalize"
302
+ : undefined;
303
+ // The entry of the finalization flow: the global review when enabled, else
304
+ // the pull request (when configured), else nothing.
305
+ const finalPhaseEntry = globalReview
306
+ ? "global_review"
307
+ : options.pullRequest
308
+ ? "pull_request"
309
+ : undefined;
310
+ const finalPhaseLine = finalPhaseEntry
311
+ ? `\nfinal_phase = "${finalPhaseEntry}"`
312
+ : "";
313
+ const gitSection = options.pullRequest ? `${buildGitToml()}\n\n` : "";
314
+ const pullRequestPhase = options.pullRequest
315
+ ? `\n${buildPullRequestPhaseToml(options.harness)}\n`
316
+ : "";
317
+ const globalReviewPhases = globalReview
318
+ ? `\n${buildGlobalReviewPhasesToml(options.harness, finalizationTerminal ?? "finalize")}\n`
319
+ : "";
320
+ const finalizePhase = globalReview && !options.pullRequest
321
+ ? `\n${buildFinalizePhaseToml(options.harness)}\n`
322
+ : "";
194
323
  return `[workflow]
195
- entry_phase = "selection"
324
+ entry_phase = "selection"${finalPhaseLine}
196
325
  max_iterations = ${options.maxIterations}
197
326
 
198
327
  [model]
@@ -201,7 +330,7 @@ reasoning_level = "${escapeTomlString(options.reasoningLevel)}"
201
330
 
202
331
  ${harness}
203
332
 
204
- [repair]
333
+ ${gitSection}[repair]
205
334
  max_attempts = 1
206
335
  prompt = "prompts/repair-result.md"
207
336
 
@@ -215,7 +344,7 @@ required_output = true
215
344
  max_visits_per_iteration = 1
216
345
 
217
346
  [phases.harness]
218
- args = ${formatTomlArray(buildReadOnlyArgs(options.harness))}
347
+ args = ${formatTomlArray(buildHarnessArgs(options.harness, "readonly"))}
219
348
 
220
349
  [phases.transitions]
221
350
  selected = "execution"
@@ -224,49 +353,37 @@ no_work = "stop_run"
224
353
  [[phases]]
225
354
  id = "execution"
226
355
  prompt = "prompts/execution.md"
227
- next = "review"
228
- max_visits_per_iteration = 3
229
-
230
- [phases.model]
231
- reasoning_level = "high"
232
-
356
+ next = "${executionNext}"
357
+ max_visits_per_iteration = 1
358
+ ${reviewPhases}
233
359
  [[phases]]
234
- id = "review"
235
- prompt = "prompts/review.md"
236
- output_schema = "schemas/review.schema.json"
360
+ id = "closure"
361
+ prompt = "prompts/closure.md"
362
+ output_schema = "schemas/closure.schema.json"
237
363
  required_output = true
238
- max_visits_per_iteration = 3
239
-
240
- [phases.model]
241
- reasoning_level = "high"
364
+ max_visits_per_iteration = 1
242
365
 
243
366
  [phases.harness]
244
- args = ${formatTomlArray(buildReadOnlyArgs(options.harness))}
367
+ args = ${formatTomlArray(buildHarnessArgs(options.harness, "write_network"))}
245
368
 
246
369
  [phases.transitions]
247
- approved = "closure"
248
- changes_requested = "execution"
249
-
250
- [[phases]]
251
- id = "closure"
252
- prompt = "prompts/closure.md"
253
- next = "next_iteration"
254
- max_visits_per_iteration = 1
255
- `;
370
+ closed = "next_iteration"
371
+ failed = "stop_run"
372
+ ${pullRequestPhase}${globalReviewPhases}${finalizePhase}`;
256
373
  }
257
374
  function buildHarnessToml(harness) {
258
375
  if (harness === "codex") {
259
376
  return `[harness]
260
377
  preset = "codex"
261
378
  command = "codex"
262
- args = ${formatTomlArray(buildCodexArgs(false))}
379
+ args = ${formatTomlArray(buildHarnessArgs("codex", "write"))}
263
380
  prompt_input = "stdin"`;
264
381
  }
265
382
  if (harness === "claude") {
266
383
  return `[harness]
267
384
  preset = "claude"
268
385
  command = "claude"
269
- args = ["--model", "{{model.name}}"]
386
+ args = ${formatTomlArray(buildHarnessArgs("claude", "write"))}
270
387
  prompt_input = "stdin"`;
271
388
  }
272
389
  return `[harness]
@@ -275,28 +392,112 @@ command = "your-agent-command"
275
392
  args = []
276
393
  prompt_input = "stdin"`;
277
394
  }
278
- function buildReadOnlyArgs(harness) {
395
+ /**
396
+ * Build the harness CLI args for a phase capability. This is where the
397
+ * harness-specific network/permission posture lives, keeping the runtime
398
+ * engine itself agnostic (it only runs `command + args`).
399
+ *
400
+ * - readonly: no writes, no network (selection, review).
401
+ * - write: edit files locally (execution, revision).
402
+ * - write_network: edit files AND reach the network for `gh`/`git push`
403
+ * (closure, pull_request). This is the fix for issues never being closed:
404
+ * codex's default workspace-write sandbox blocks the network, so closing a
405
+ * GitHub issue silently failed.
406
+ */
407
+ function buildHarnessArgs(harness, capability) {
279
408
  if (harness === "codex") {
280
- return buildCodexArgs(true);
409
+ const args = [
410
+ "exec",
411
+ "--model",
412
+ "{{model.name}}",
413
+ "-c",
414
+ 'model_reasoning_effort="{{model.reasoning_level}}"'
415
+ ];
416
+ if (capability === "readonly") {
417
+ args.push("--sandbox", "read-only");
418
+ }
419
+ else if (capability === "write_network") {
420
+ args.push("-c", "sandbox_workspace_write.network_access=true");
421
+ }
422
+ args.push("-");
423
+ return args;
281
424
  }
282
425
  if (harness === "claude") {
283
- return ["--model", "{{model.name}}"];
426
+ const args = ["-p", "--model", "{{model.name}}", "--output-format", "text"];
427
+ if (capability === "readonly") {
428
+ args.push("--permission-mode", "plan");
429
+ }
430
+ else {
431
+ args.push("--dangerously-skip-permissions");
432
+ }
433
+ return args;
284
434
  }
285
435
  return [];
286
436
  }
287
- function buildCodexArgs(readOnly) {
288
- const args = [
289
- "exec",
290
- "--model",
291
- "{{model.name}}",
292
- "-c",
293
- 'model_reasoning_effort="{{model.reasoning_level}}"'
294
- ];
295
- if (readOnly) {
296
- args.push("--sandbox", "read-only");
297
- }
298
- args.push("-");
299
- return args;
437
+ function buildGitToml() {
438
+ return `[git]
439
+ mode = "worktree"
440
+ # base = "main" # defaults to the current branch
441
+ branch_template = "nyxagent/{{run_id}}"
442
+ worktree_dir = ".nyxagent/worktrees"
443
+ cleanup = "on_success"`;
444
+ }
445
+ function buildPullRequestPhaseToml(harness) {
446
+ return `[[phases]]
447
+ id = "pull_request"
448
+ prompt = "prompts/pull-request.md"
449
+ output_schema = "schemas/pull-request.schema.json"
450
+ required_output = true
451
+ max_visits_per_iteration = 1
452
+
453
+ [phases.harness]
454
+ args = ${formatTomlArray(buildHarnessArgs(harness, "write_network"))}`;
455
+ }
456
+ /**
457
+ * Build the run-level "global review + correction" finalization sub-graph:
458
+ *
459
+ * global_review --approved-----------> <terminal leaf>
460
+ * --changes_requested--> global_revision --> global_review
461
+ *
462
+ * `global_revision` relies on the default write harness (like the per-task
463
+ * `revision`/`execution` phases) and commits its own corrections, since no
464
+ * `closure` phase follows it before the terminal. `global_review` is read-only.
465
+ * The loop is bounded by max_visits_per_iteration = 3.
466
+ */
467
+ function buildGlobalReviewPhasesToml(harness, terminal) {
468
+ return `[[phases]]
469
+ id = "global_revision"
470
+ prompt = "prompts/global-revision.md"
471
+ next = "global_review"
472
+ max_visits_per_iteration = 3
473
+
474
+ [[phases]]
475
+ id = "global_review"
476
+ prompt = "prompts/global-review.md"
477
+ output_schema = "schemas/global-review.schema.json"
478
+ required_output = true
479
+ max_visits_per_iteration = 3
480
+
481
+ [phases.harness]
482
+ args = ${formatTomlArray(buildHarnessArgs(harness, "readonly"))}
483
+
484
+ [phases.transitions]
485
+ approved = "${terminal}"
486
+ changes_requested = "global_revision"`;
487
+ }
488
+ /**
489
+ * The terminal leaf of a global-review finalization flow when there is no pull
490
+ * request to land on (local sources, or GitHub with --no-pull-request). It is
491
+ * read-only and makes no changes; it just confirms/summarizes the run.
492
+ */
493
+ function buildFinalizePhaseToml(harness) {
494
+ return `[[phases]]
495
+ id = "finalize"
496
+ prompt = "prompts/finalize.md"
497
+ max_visits_per_iteration = 1
498
+
499
+ [phases.harness]
500
+ args = ${formatTomlArray(buildHarnessArgs(harness, "readonly"))}`;
300
501
  }
301
502
  function buildWorkItemsToml(options) {
302
503
  if (options.workItemsSource === "local") {
@@ -0,0 +1,148 @@
1
+ import { createRequire } from "node:module";
2
+ import { fileURLToPath } from "node:url";
3
+ import { confirm } from "@inquirer/prompts";
4
+ import { execa } from "execa";
5
+ import pc from "picocolors";
6
+ const PACKAGE_NAME = "@nyxa/nyx-agent";
7
+ export async function updateCommand(versionSpec, options, deps = defaultUpdateDependencies()) {
8
+ const spec = (versionSpec ?? "").trim() || "latest";
9
+ const current = deps.getCurrentVersion();
10
+ const target = await deps.resolveVersion(spec);
11
+ if (current === target) {
12
+ console.log(pc.green(`nyxagent is already up to date (${current}).`));
13
+ return;
14
+ }
15
+ // Only guard against downgrades for moving dist-tags like "latest"; an
16
+ // explicit version (e.g. "0.3.9") is treated as a deliberate pin.
17
+ const isDistTag = !/^\d/.test(spec);
18
+ if (isDistTag && compareVersions(current, target) > 0) {
19
+ console.log(pc.yellow(`Installed nyxagent ${current} is newer than ${spec} (${target}); nothing to do.`));
20
+ return;
21
+ }
22
+ const verb = compareVersions(target, current) >= 0 ? "Update" : "Downgrade";
23
+ console.log(`${verb} available: ${pc.dim(current)} -> ${pc.cyan(target)}`);
24
+ if (options.check) {
25
+ console.log(`Run ${pc.bold("nyxagent update")} to install it.`);
26
+ return;
27
+ }
28
+ const packageManager = options.packageManager
29
+ ? normalizePackageManager(options.packageManager)
30
+ : deps.detectPackageManager();
31
+ if (!options.yes) {
32
+ const confirmed = await deps.confirm(`${verb} nyxagent to ${target} with ${packageManager}?`);
33
+ if (!confirmed) {
34
+ console.log("Update cancelled.");
35
+ return;
36
+ }
37
+ }
38
+ console.log(`Installing ${PACKAGE_NAME}@${target} with ${packageManager}...`);
39
+ await deps.install({ packageManager, version: target });
40
+ console.log(pc.green(`nyxagent updated to ${target}. Run "nyxagent --version" to confirm.`));
41
+ }
42
+ function normalizePackageManager(value) {
43
+ if (value === "npm" ||
44
+ value === "pnpm" ||
45
+ value === "yarn" ||
46
+ value === "bun") {
47
+ return value;
48
+ }
49
+ throw new Error('package manager must be "npm", "pnpm", "yarn", or "bun"');
50
+ }
51
+ export function defaultUpdateDependencies() {
52
+ return {
53
+ getCurrentVersion: readPackageVersion,
54
+ resolveVersion: resolveVersionFromRegistry,
55
+ install: runGlobalInstall,
56
+ detectPackageManager: detectPackageManagerFromPath,
57
+ confirm: confirmInteractive
58
+ };
59
+ }
60
+ function readPackageVersion() {
61
+ const require = createRequire(import.meta.url);
62
+ const pkg = require("../../package.json");
63
+ return pkg.version;
64
+ }
65
+ async function resolveVersionFromRegistry(spec) {
66
+ const result = await execa("npm", ["view", `${PACKAGE_NAME}@${spec}`, "version"], { reject: false });
67
+ if (result.exitCode !== 0) {
68
+ const detail = (result.stderr || result.stdout || "unknown error").trim();
69
+ throw new Error(`Failed to query the npm registry: ${detail}`);
70
+ }
71
+ const lines = result.stdout
72
+ .trim()
73
+ .split(/\r?\n/)
74
+ .map((line) => line.trim())
75
+ .filter(Boolean);
76
+ if (lines.length === 0) {
77
+ throw new Error(`No published version matches ${PACKAGE_NAME}@${spec}`);
78
+ }
79
+ // For an exact version or dist-tag npm prints a bare version; for a range it
80
+ // prints `<name>@<version> '<version>'` lines, so take the last match.
81
+ const last = lines[lines.length - 1];
82
+ const match = /([0-9][^\s'"]*)\s*$/.exec(last);
83
+ return match ? match[1] : last;
84
+ }
85
+ async function runGlobalInstall(input) {
86
+ const spec = `${PACKAGE_NAME}@${input.version}`;
87
+ await execa(input.packageManager, installArgs(input.packageManager, spec), {
88
+ stdio: "inherit"
89
+ });
90
+ }
91
+ function installArgs(packageManager, spec) {
92
+ switch (packageManager) {
93
+ case "npm":
94
+ return ["install", "-g", spec];
95
+ case "pnpm":
96
+ return ["add", "-g", spec];
97
+ case "yarn":
98
+ return ["global", "add", spec];
99
+ case "bun":
100
+ return ["add", "-g", spec];
101
+ }
102
+ }
103
+ function detectPackageManagerFromPath() {
104
+ const here = fileURLToPath(import.meta.url).replace(/\\/g, "/").toLowerCase();
105
+ if (here.includes("/pnpm/") || here.includes("/.pnpm/")) {
106
+ return "pnpm";
107
+ }
108
+ if (here.includes("/.bun/") || here.includes("/bun/")) {
109
+ return "bun";
110
+ }
111
+ if (here.includes("/yarn/") || here.includes("/.config/yarn/")) {
112
+ return "yarn";
113
+ }
114
+ return "npm";
115
+ }
116
+ async function confirmInteractive(message) {
117
+ // In a non-interactive context the user already opted in by running the
118
+ // command, so proceed without a prompt that would otherwise fail.
119
+ if (!process.stdin.isTTY) {
120
+ return true;
121
+ }
122
+ return confirm({ message, default: true });
123
+ }
124
+ function compareVersions(a, b) {
125
+ const pa = parseVersion(a);
126
+ const pb = parseVersion(b);
127
+ for (let i = 0; i < 3; i += 1) {
128
+ if (pa.core[i] !== pb.core[i]) {
129
+ return pa.core[i] - pb.core[i];
130
+ }
131
+ }
132
+ // A final release outranks a prerelease sharing the same core (1.0.0 > 1.0.0-rc).
133
+ if (pa.pre === pb.pre) {
134
+ return 0;
135
+ }
136
+ if (pa.pre === "") {
137
+ return 1;
138
+ }
139
+ if (pb.pre === "") {
140
+ return -1;
141
+ }
142
+ return pa.pre < pb.pre ? -1 : 1;
143
+ }
144
+ function parseVersion(value) {
145
+ const [main, pre = ""] = value.replace(/^v/, "").split("-", 2);
146
+ const parts = main.split(".").map((part) => Number.parseInt(part, 10) || 0);
147
+ return { core: [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0], pre };
148
+ }
@@ -29,6 +29,15 @@ const phaseSchema = z
29
29
  })
30
30
  .passthrough();
31
31
  const workItemsSourceSchema = z.preprocess((value) => (value === "local-markdown" ? "local" : value), z.enum(["local", "github"]));
32
+ const gitSchema = z
33
+ .object({
34
+ mode: z.enum(["off", "branch", "worktree"]).default("off"),
35
+ base: z.string().min(1).optional(),
36
+ branch_template: z.string().min(1).default("nyxagent/{{run_id}}"),
37
+ worktree_dir: z.string().min(1).default(".nyxagent/worktrees"),
38
+ cleanup: z.enum(["always", "on_success", "never"]).default("on_success")
39
+ })
40
+ .passthrough();
32
41
  const githubRepositoryPattern = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
33
42
  const workItemsSchema = z
34
43
  .object({
@@ -68,10 +77,12 @@ export const nyxConfigSchema = z
68
77
  .object({
69
78
  workflow: z.object({
70
79
  entry_phase: z.string().min(1),
80
+ final_phase: z.string().min(1).optional(),
71
81
  max_iterations: z.number().int().positive()
72
82
  }),
73
83
  model: modelSchema,
74
84
  harness: harnessSchema,
85
+ git: gitSchema.optional(),
75
86
  repair: z
76
87
  .object({
77
88
  max_attempts: z.number().int().nonnegative().default(1),
@@ -110,6 +121,14 @@ export const nyxConfigSchema = z
110
121
  message: `Unknown entry phase "${config.workflow.entry_phase}"`
111
122
  });
112
123
  }
124
+ if (config.workflow.final_phase &&
125
+ !phaseIds.has(config.workflow.final_phase)) {
126
+ ctx.addIssue({
127
+ code: "custom",
128
+ path: ["workflow", "final_phase"],
129
+ message: `Unknown final phase "${config.workflow.final_phase}"`
130
+ });
131
+ }
113
132
  const reservedTargets = new Set([
114
133
  "stop_run",
115
134
  "stop_iteration",