@nyxa/nyx-agent 0.4.1 → 0.5.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.
Files changed (39) hide show
  1. package/README.md +52 -9
  2. package/dist/cli.js +11 -16
  3. package/dist/commands/init.js +87 -466
  4. package/dist/commands/run.js +16 -3
  5. package/dist/config/loadConfig.js +16 -3
  6. package/dist/config/schema.js +27 -146
  7. package/dist/runtime/gitLifecycle.js +19 -57
  8. package/dist/runtime/harness.js +26 -0
  9. package/dist/runtime/paths.js +0 -12
  10. package/dist/runtime/prompts.js +103 -0
  11. package/dist/runtime/runPhase.js +85 -254
  12. package/dist/runtime/runPipeline.js +395 -0
  13. package/dist/runtime/schemas.js +52 -0
  14. package/dist/runtime/scm.js +76 -0
  15. package/dist/runtime/validateResult.js +1 -3
  16. package/dist/runtime/workItems.js +42 -118
  17. package/package.json +2 -5
  18. package/dist/runtime/buildPrompt.js +0 -54
  19. package/dist/runtime/effectiveConfig.js +0 -14
  20. package/dist/runtime/renderTemplate.js +0 -28
  21. package/dist/runtime/runWorkflow.js +0 -680
  22. package/dist/runtime/validateWorkItem.js +0 -212
  23. package/dist/runtime/workItemAnnotations.js +0 -39
  24. package/docs/nyxagent-v0-spec.md +0 -742
  25. package/templates/default/prompts/closure.md +0 -30
  26. package/templates/default/prompts/execution.md +0 -11
  27. package/templates/default/prompts/finalize.md +0 -7
  28. package/templates/default/prompts/global-review.md +0 -24
  29. package/templates/default/prompts/global-revision.md +0 -9
  30. package/templates/default/prompts/pull-request.md +0 -23
  31. package/templates/default/prompts/repair-result.md +0 -29
  32. package/templates/default/prompts/review.md +0 -18
  33. package/templates/default/prompts/revision.md +0 -7
  34. package/templates/default/prompts/selection.md +0 -46
  35. package/templates/default/schemas/closure.schema.json +0 -35
  36. package/templates/default/schemas/global-review.schema.json +0 -60
  37. package/templates/default/schemas/pull-request.schema.json +0 -44
  38. package/templates/default/schemas/review.schema.json +0 -60
  39. package/templates/default/schemas/selection.schema.json +0 -135
@@ -1,521 +1,142 @@
1
- import { readdir, stat } from "node:fs/promises";
2
1
  import path from "node:path";
3
- import { fileURLToPath } from "node:url";
4
- import { confirm, input, number as numberPrompt, select } from "@inquirer/prompts";
2
+ import { input, number as numberPrompt, select } from "@inquirer/prompts";
5
3
  import pc from "picocolors";
4
+ import { harnessNames, reviewModes } from "../config/schema.js";
6
5
  import { ensureDir, pathExists, readText, writeText } from "../runtime/files.js";
7
6
  import { getNyxDir, relativeToProject } from "../runtime/paths.js";
8
- const DEFAULT_OPENAI_MODEL = "gpt-5.5";
7
+ import { EXECUTION_PROMPT_FILE } from "../runtime/prompts.js";
8
+ const DEFAULT_CODEX_MODEL = "gpt-5.5";
9
9
  const GITIGNORE_MARKER = "# NyxAgent runtime artifacts";
10
10
  const GITIGNORE_ENTRIES = [
11
11
  ".nyxagent/runs/",
12
- ".nyxagent/state.json",
13
- ".nyxagent/worktrees/"
12
+ ".nyxagent/worktrees/",
13
+ ".nyxagent/state.json"
14
14
  ];
15
15
  export async function initCommand(options, projectRoot = process.cwd()) {
16
16
  const root = path.resolve(projectRoot);
17
17
  const nyxDir = getNyxDir(root);
18
- const exists = await pathExists(nyxDir);
19
- if (exists && !options.missing) {
20
- throw new Error(`.nyxagent already exists. Use "nyxagent init --missing" to add missing template files.`);
18
+ const configPath = path.join(nyxDir, "config.json");
19
+ if ((await pathExists(configPath)) && !options.force) {
20
+ throw new Error('.nyxagent/config.json already exists. Use "nyxagent init --force" to overwrite it.');
21
21
  }
22
- const configPath = path.join(nyxDir, "config.toml");
23
- const shouldCreateConfig = !options.missing || !(await pathExists(configPath));
24
- const resolved = shouldCreateConfig
25
- ? await resolveInitOptions(options, root)
26
- : undefined;
22
+ const resolved = await resolveInitOptions(options);
27
23
  await ensureDir(nyxDir);
28
- const templatesDir = getTemplatesDir();
29
- await copyTemplateTree(templatesDir, nyxDir, Boolean(options.missing));
30
- await ensureGitignoreEntries(root);
31
- if (resolved) {
32
- await writeText(configPath, buildConfigToml(resolved));
33
- }
34
- if (resolved?.implementationSkill) {
35
- const executionPromptPath = path.join(nyxDir, "prompts", "execution.md");
36
- await writeText(executionPromptPath, buildSkillExecutionPrompt(resolved.implementationSkill));
37
- }
38
- if (resolved?.workItemsSource === "local" && resolved.workItemsPath) {
39
- await ensureWorkItemsDirectory(root, resolved.workItemsPath);
24
+ await writeText(configPath, `${JSON.stringify(buildConfig(resolved), null, 2)}\n`);
25
+ const executionPromptPath = path.join(nyxDir, "prompts", "execution.md");
26
+ if (!(await pathExists(executionPromptPath))) {
27
+ await ensureDir(path.dirname(executionPromptPath));
28
+ await writeText(executionPromptPath, EXECUTION_PROMPT_FILE);
40
29
  }
30
+ await ensureGitignoreEntries(root);
41
31
  console.log(pc.green("NyxAgent initialized."));
42
32
  console.log(`Config: ${relativeToProject(root, configPath)}`);
33
+ console.log(`Editable prompt: ${relativeToProject(root, executionPromptPath)}`);
43
34
  }
44
- async function resolveInitOptions(options, root) {
45
- const harness = options.harness ??
46
- (await select({
47
- message: "Harness preset",
35
+ async function resolveInitOptions(options) {
36
+ const harness = options.harness
37
+ ? normalizeHarness(options.harness)
38
+ : await select({
39
+ message: "Default harness",
48
40
  choices: [
49
41
  { name: "codex", value: "codex" },
50
- { name: "claude", value: "claude" },
51
- { name: "custom", value: "custom" }
42
+ { name: "claude", value: "claude" }
52
43
  ]
53
- }));
44
+ });
54
45
  const model = options.model ??
55
46
  (await input({
56
47
  message: "Model",
57
- default: harness === "codex" ? DEFAULT_OPENAI_MODEL : ""
48
+ default: harness === "codex" ? DEFAULT_CODEX_MODEL : "",
49
+ validate: (value) => value.trim().length > 0 || "Model is required"
58
50
  }));
59
- const reasoningLevel = options.reasoningLevel ??
51
+ const reasoning_effort = options.reasoningEffort ??
52
+ (await input({ message: "Reasoning effort", default: "medium" }));
53
+ const review = options.review
54
+ ? normalizeReview(options.review)
55
+ : await select({
56
+ message: "Review strategy",
57
+ choices: [
58
+ { name: "After each task", value: "each" },
59
+ { name: "After all tasks (global review)", value: "all" },
60
+ { name: "Both per-task and global", value: "both" },
61
+ { name: "No review", value: "none" }
62
+ ],
63
+ default: "each"
64
+ });
65
+ const repo = options.repo ?? (await input({ message: "GitHub repository (owner/repo)" }));
66
+ validateRepository(repo);
67
+ const baseBranchInput = options.baseBranch ??
60
68
  (await input({
61
- message: "Default reasoning level",
62
- default: "medium"
69
+ message: "Base branch (blank = current branch at run time)",
70
+ default: ""
63
71
  }));
64
- const implementationSkill = await resolveImplementationSkill(options);
65
- const reviewMode = await resolveReviewMode(options);
66
- const parsedMaxIterations = options.maxIterations
67
- ? Number.parseInt(options.maxIterations, 10)
68
- : undefined;
69
- const maxIterations = parsedMaxIterations ??
72
+ const base_branch = baseBranchInput.trim() ? baseBranchInput.trim() : undefined;
73
+ const max_iterations = parseMaxIterations(options.maxIterations) ??
70
74
  (await numberPrompt({
71
- message: "Max iterations",
75
+ message: "Max work items per run",
72
76
  default: 5,
73
77
  required: true
74
78
  }));
75
- const workItemsSource = options.workItemsSource
76
- ? normalizeWorkItemsSource(options.workItemsSource)
77
- : await select({
78
- message: "Work item source template",
79
- choices: [
80
- { name: "local", value: "local" },
81
- { name: "github", value: "github" }
82
- ]
83
- });
84
- let workItemsPath = options.workItemsPath;
85
- if (workItemsSource === "local" && !workItemsPath) {
86
- const issuesPath = path.join(root, "issues");
87
- workItemsPath = await input({
88
- message: "Local work item path",
89
- default: (await pathExists(issuesPath)) ? "issues" : ".nyxagent/tasks"
90
- });
91
- }
92
- if (workItemsSource !== "local" && workItemsPath) {
93
- throw new Error("--work-items-path can only be used with local work items");
94
- }
95
- let workItemsRepository = options.workItemsRepository;
96
- if (workItemsSource === "github" && !workItemsRepository) {
97
- workItemsRepository = await input({
98
- message: "GitHub repository",
99
- default: ""
100
- });
101
- }
102
- if (workItemsSource === "github") {
103
- validateGitHubRepository(workItemsRepository);
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;
110
- if (!Number.isInteger(maxIterations) || maxIterations <= 0) {
79
+ if (!Number.isInteger(max_iterations) || max_iterations <= 0) {
111
80
  throw new Error("max iterations must be a positive integer");
112
81
  }
113
82
  return {
114
83
  harness,
115
- model,
116
- reasoningLevel,
117
- maxIterations,
118
- workItemsSource,
119
- workItemsPath,
120
- workItemsRepository,
121
- implementationSkill,
122
- reviewMode,
123
- pullRequest
84
+ model: model.trim(),
85
+ reasoning_effort: reasoning_effort.trim() || "medium",
86
+ review,
87
+ repo,
88
+ base_branch,
89
+ max_iterations
124
90
  };
125
91
  }
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;
92
+ function buildConfig(options) {
93
+ const config = {
94
+ harness: options.harness,
95
+ model: options.model,
96
+ reasoning_effort: options.reasoning_effort,
97
+ review: options.review,
98
+ tracker: { type: "github", repo: options.repo },
99
+ max_iterations: options.max_iterations
100
+ };
101
+ if (options.base_branch) {
102
+ config.base_branch = options.base_branch;
157
103
  }
158
- throw new Error('review mode must be "each", "all", "both", or "none"');
104
+ return config;
159
105
  }
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;
106
+ function normalizeHarness(value) {
107
+ if (harnessNames.includes(value)) {
108
+ return value;
171
109
  }
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;
110
+ throw new Error(`harness must be one of: ${harnessNames.join(", ")}`);
176
111
  }
177
- function normalizeWorkItemsSource(source) {
178
- if (source === "local" || source === "local-markdown") {
179
- return "local";
112
+ function normalizeReview(value) {
113
+ if (reviewModes.includes(value)) {
114
+ return value;
180
115
  }
181
- if (source === "github") {
182
- return source;
183
- }
184
- throw new Error('work item source must be "local" or "github"');
116
+ throw new Error(`review must be one of: ${reviewModes.join(", ")}`);
185
117
  }
186
- function validateGitHubRepository(repository) {
187
- if (!repository || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)) {
118
+ function validateRepository(repo) {
119
+ if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo)) {
188
120
  throw new Error('GitHub repository must use "owner/repo"');
189
121
  }
190
122
  }
191
- function getTemplatesDir() {
192
- const currentFile = fileURLToPath(import.meta.url);
193
- return path.resolve(path.dirname(currentFile), "../../templates/default");
194
- }
195
- async function copyTemplateTree(sourceDir, destinationDir, missingOnly) {
196
- await ensureDir(destinationDir);
197
- const entries = await readdir(sourceDir, { withFileTypes: true });
198
- for (const entry of entries) {
199
- const source = path.join(sourceDir, entry.name);
200
- const destination = path.join(destinationDir, entry.name);
201
- if (entry.isDirectory()) {
202
- await copyTemplateTree(source, destination, missingOnly);
203
- continue;
204
- }
205
- if (missingOnly && (await pathExists(destination))) {
206
- continue;
207
- }
208
- await writeText(destination, await readText(source));
209
- }
210
- }
211
- async function ensureWorkItemsDirectory(root, taskPath) {
212
- const absoluteTaskPath = path.resolve(root, taskPath);
213
- if (!(await pathExists(absoluteTaskPath))) {
214
- await ensureDir(absoluteTaskPath);
215
- }
216
- const taskPathStat = await stat(absoluteTaskPath);
217
- if (!taskPathStat.isDirectory()) {
218
- throw new Error(`Work item path is not a directory: ${taskPath}`);
123
+ function parseMaxIterations(value) {
124
+ if (value === undefined) {
125
+ return undefined;
219
126
  }
127
+ return Number.parseInt(value, 10);
220
128
  }
221
129
  async function ensureGitignoreEntries(root) {
222
130
  const gitignorePath = path.join(root, ".gitignore");
223
131
  const current = (await pathExists(gitignorePath))
224
132
  ? await readText(gitignorePath)
225
133
  : "";
226
- const lines = current.split(/\r?\n/);
227
- const existingEntries = new Set(lines.map((line) => line.trim()));
228
- const missingEntries = GITIGNORE_ENTRIES.filter((entry) => !existingEntries.has(entry));
229
- if (missingEntries.length === 0) {
134
+ const existing = new Set(current.split(/\r?\n/).map((line) => line.trim()));
135
+ const missing = GITIGNORE_ENTRIES.filter((entry) => !existing.has(entry));
136
+ if (missing.length === 0) {
230
137
  return;
231
138
  }
232
- const markerIndex = lines.findIndex((line) => line.trim() === GITIGNORE_MARKER);
233
- let updated;
234
- if (markerIndex >= 0) {
235
- const nextLines = [...lines];
236
- nextLines.splice(markerIndex + 1, 0, ...missingEntries);
237
- updated = normalizeGitignoreContent(nextLines.join("\n"));
238
- }
239
- else {
240
- const prefix = getGitignoreAppendPrefix(current);
241
- updated = `${current}${prefix}${GITIGNORE_MARKER}\n${missingEntries.join("\n")}\n`;
242
- }
243
- await writeText(gitignorePath, updated);
244
- }
245
- function getGitignoreAppendPrefix(current) {
246
- if (current.length === 0) {
247
- return "";
248
- }
249
- if (current.endsWith("\n\n")) {
250
- return "";
251
- }
252
- if (current.endsWith("\n")) {
253
- return "\n";
254
- }
255
- return "\n\n";
256
- }
257
- function normalizeGitignoreContent(value) {
258
- return `${value.replace(/\n*$/, "")}\n`;
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
- }
267
- function buildConfigToml(options) {
268
- const harness = buildHarnessToml(options.harness);
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
- : "";
323
- return `[workflow]
324
- entry_phase = "selection"${finalPhaseLine}
325
- max_iterations = ${options.maxIterations}
326
-
327
- [model]
328
- name = "${escapeTomlString(options.model)}"
329
- reasoning_level = "${escapeTomlString(options.reasoningLevel)}"
330
-
331
- ${harness}
332
-
333
- ${gitSection}[repair]
334
- max_attempts = 1
335
- prompt = "prompts/repair-result.md"
336
-
337
- ${workItems}
338
-
339
- [[phases]]
340
- id = "selection"
341
- prompt = "prompts/selection.md"
342
- output_schema = "schemas/selection.schema.json"
343
- required_output = true
344
- max_visits_per_iteration = 1
345
-
346
- [phases.harness]
347
- args = ${formatTomlArray(buildHarnessArgs(options.harness, "readonly"))}
348
-
349
- [phases.transitions]
350
- selected = "execution"
351
- no_work = "stop_run"
352
-
353
- [[phases]]
354
- id = "execution"
355
- prompt = "prompts/execution.md"
356
- next = "${executionNext}"
357
- max_visits_per_iteration = 1
358
- ${reviewPhases}
359
- [[phases]]
360
- id = "closure"
361
- prompt = "prompts/closure.md"
362
- output_schema = "schemas/closure.schema.json"
363
- required_output = true
364
- max_visits_per_iteration = 1
365
-
366
- [phases.harness]
367
- args = ${formatTomlArray(buildHarnessArgs(options.harness, "write_network"))}
368
-
369
- [phases.transitions]
370
- closed = "next_iteration"
371
- failed = "stop_run"
372
- ${pullRequestPhase}${globalReviewPhases}${finalizePhase}`;
373
- }
374
- function buildHarnessToml(harness) {
375
- if (harness === "codex") {
376
- return `[harness]
377
- preset = "codex"
378
- command = "codex"
379
- args = ${formatTomlArray(buildHarnessArgs("codex", "write"))}
380
- prompt_input = "stdin"`;
381
- }
382
- if (harness === "claude") {
383
- return `[harness]
384
- preset = "claude"
385
- command = "claude"
386
- args = ${formatTomlArray(buildHarnessArgs("claude", "write"))}
387
- prompt_input = "stdin"`;
388
- }
389
- return `[harness]
390
- preset = "custom"
391
- command = "your-agent-command"
392
- args = []
393
- prompt_input = "stdin"`;
394
- }
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) {
408
- if (harness === "codex") {
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;
424
- }
425
- if (harness === "claude") {
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;
434
- }
435
- return [];
436
- }
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"))}`;
501
- }
502
- function buildWorkItemsToml(options) {
503
- if (options.workItemsSource === "local") {
504
- return `[work_items]
505
- source = "local"
506
- path = "${escapeTomlString(options.workItemsPath ?? ".nyxagent/tasks")}"
507
- max_candidates = 50
508
- excerpt_chars = 800`;
509
- }
510
- return `[work_items]
511
- source = "github"
512
- repository = "${escapeTomlString(options.workItemsRepository ?? "")}"
513
- max_candidates = 50
514
- excerpt_chars = 800`;
515
- }
516
- function formatTomlArray(values) {
517
- return `[${values.map((value) => `"${escapeTomlString(value)}"`).join(", ")}]`;
518
- }
519
- function escapeTomlString(value) {
520
- return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
139
+ const prefix = current.length === 0 || current.endsWith("\n") ? "" : "\n";
140
+ const block = `${prefix}${current.length === 0 ? "" : "\n"}${GITIGNORE_MARKER}\n${missing.join("\n")}\n`;
141
+ await writeText(gitignorePath, `${current}${block}`);
521
142
  }
@@ -1,8 +1,21 @@
1
1
  import path from "node:path";
2
- import { runWorkflow } from "../runtime/runWorkflow.js";
2
+ import { harnessNames } from "../config/schema.js";
3
+ import { runPipeline } from "../runtime/runPipeline.js";
3
4
  export async function runCommand(options, projectRoot = process.cwd()) {
4
- await runWorkflow({
5
+ await runPipeline({
5
6
  projectRoot,
6
- configPath: options.config ? path.resolve(projectRoot, options.config) : undefined
7
+ configPath: options.config
8
+ ? path.resolve(projectRoot, options.config)
9
+ : undefined,
10
+ harness: normalizeHarness(options.harness)
7
11
  });
8
12
  }
13
+ function normalizeHarness(value) {
14
+ if (value === undefined) {
15
+ return undefined;
16
+ }
17
+ if (harnessNames.includes(value)) {
18
+ return value;
19
+ }
20
+ throw new Error(`--harness must be one of: ${harnessNames.join(", ")}`);
21
+ }
@@ -1,8 +1,21 @@
1
1
  import { readFile } from "node:fs/promises";
2
- import { parse } from "smol-toml";
3
2
  import { nyxConfigSchema } from "./schema.js";
4
3
  export async function loadConfig(configPath) {
5
4
  const raw = await readFile(configPath, "utf8");
6
- const parsed = parse(raw);
7
- return nyxConfigSchema.parse(parsed);
5
+ let parsed;
6
+ try {
7
+ parsed = JSON.parse(raw);
8
+ }
9
+ catch (error) {
10
+ const message = error instanceof Error ? error.message : String(error);
11
+ throw new Error(`Invalid JSON in ${configPath}: ${message}`);
12
+ }
13
+ const result = nyxConfigSchema.safeParse(parsed);
14
+ if (!result.success) {
15
+ const detail = result.error.issues
16
+ .map((issue) => `${issue.path.join(".") || "<root>"}: ${issue.message}`)
17
+ .join("; ");
18
+ throw new Error(`Invalid NyxAgent config (${configPath}): ${detail}`);
19
+ }
20
+ return result.data;
8
21
  }