@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.
Files changed (44) hide show
  1. package/README.md +58 -9
  2. package/dist/cli.js +13 -16
  3. package/dist/commands/init.js +112 -462
  4. package/dist/commands/run.js +17 -3
  5. package/dist/commands/update.js +1 -0
  6. package/dist/config/loadConfig.js +17 -3
  7. package/dist/config/schema.js +29 -146
  8. package/dist/runtime/files.js +1 -0
  9. package/dist/runtime/git.js +1 -0
  10. package/dist/runtime/gitLifecycle.js +19 -57
  11. package/dist/runtime/harness.js +26 -0
  12. package/dist/runtime/ledger.js +1 -0
  13. package/dist/runtime/paths.js +1 -12
  14. package/dist/runtime/prompts.js +103 -0
  15. package/dist/runtime/runPhase.js +85 -254
  16. package/dist/runtime/runPipeline.js +479 -0
  17. package/dist/runtime/schemas.js +52 -0
  18. package/dist/runtime/scm.js +80 -0
  19. package/dist/runtime/time.js +1 -0
  20. package/dist/runtime/validateResult.js +2 -3
  21. package/dist/runtime/workItems.js +43 -118
  22. package/package.json +2 -5
  23. package/dist/runtime/buildPrompt.js +0 -54
  24. package/dist/runtime/effectiveConfig.js +0 -14
  25. package/dist/runtime/renderTemplate.js +0 -28
  26. package/dist/runtime/runWorkflow.js +0 -680
  27. package/dist/runtime/validateWorkItem.js +0 -212
  28. package/dist/runtime/workItemAnnotations.js +0 -39
  29. package/docs/nyxagent-v0-spec.md +0 -742
  30. package/templates/default/prompts/closure.md +0 -30
  31. package/templates/default/prompts/execution.md +0 -11
  32. package/templates/default/prompts/finalize.md +0 -7
  33. package/templates/default/prompts/global-review.md +0 -24
  34. package/templates/default/prompts/global-revision.md +0 -9
  35. package/templates/default/prompts/pull-request.md +0 -23
  36. package/templates/default/prompts/repair-result.md +0 -29
  37. package/templates/default/prompts/review.md +0 -18
  38. package/templates/default/prompts/revision.md +0 -7
  39. package/templates/default/prompts/selection.md +0 -46
  40. package/templates/default/schemas/closure.schema.json +0 -35
  41. package/templates/default/schemas/global-review.schema.json +0 -60
  42. package/templates/default/schemas/pull-request.schema.json +0 -44
  43. package/templates/default/schemas/review.schema.json +0 -60
  44. package/templates/default/schemas/selection.schema.json +0 -135
@@ -1,521 +1,171 @@
1
- import { readdir, stat } from "node:fs/promises";
1
+ /** `nyxagent init`: scaffolds .nyxagent/config.json, the editable execution prompt, and .gitignore entries. */
2
2
  import path from "node:path";
3
- import { fileURLToPath } from "node:url";
4
- import { confirm, input, number as numberPrompt, select } from "@inquirer/prompts";
3
+ import { input, number as numberPrompt, select } from "@inquirer/prompts";
5
4
  import pc from "picocolors";
5
+ import { harnessNames, reviewModes } from "../config/schema.js";
6
6
  import { ensureDir, pathExists, readText, writeText } from "../runtime/files.js";
7
7
  import { getNyxDir, relativeToProject } from "../runtime/paths.js";
8
- const DEFAULT_OPENAI_MODEL = "gpt-5.5";
8
+ import { EXECUTION_PROMPT_FILE } from "../runtime/prompts.js";
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/",
16
+ ".nyxagent/worktrees/",
12
17
  ".nyxagent/state.json",
13
- ".nyxagent/worktrees/"
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);
17
24
  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.`);
25
+ const configPath = path.join(nyxDir, "config.json");
26
+ if ((await pathExists(configPath)) && !options.force) {
27
+ throw new Error('.nyxagent/config.json already exists. Use "nyxagent init --force" to overwrite it.');
21
28
  }
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;
29
+ const resolved = await resolveInitOptions(options);
27
30
  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);
31
+ await writeText(configPath, `${JSON.stringify(buildConfig(resolved), null, 2)}\n`);
32
+ const executionPromptPath = path.join(nyxDir, "prompts", "execution.md");
33
+ if (!(await pathExists(executionPromptPath))) {
34
+ await ensureDir(path.dirname(executionPromptPath));
35
+ await writeText(executionPromptPath, EXECUTION_PROMPT_FILE);
40
36
  }
37
+ await ensureGitignoreEntries(root);
41
38
  console.log(pc.green("NyxAgent initialized."));
42
39
  console.log(`Config: ${relativeToProject(root, configPath)}`);
40
+ console.log(`Editable prompt: ${relativeToProject(root, executionPromptPath)}`);
43
41
  }
44
- async function resolveInitOptions(options, root) {
45
- const harness = options.harness ??
46
- (await select({
47
- message: "Harness preset",
42
+ async function resolveInitOptions(options) {
43
+ const harness = options.harness
44
+ ? normalizeHarness(options.harness)
45
+ : await select({
46
+ message: "Default harness",
48
47
  choices: [
49
48
  { name: "codex", value: "codex" },
50
- { name: "claude", value: "claude" },
51
- { name: "custom", value: "custom" }
49
+ { name: "claude", value: "claude" }
52
50
  ]
53
- }));
51
+ });
54
52
  const model = options.model ??
55
53
  (await input({
56
54
  message: "Model",
57
- default: harness === "codex" ? DEFAULT_OPENAI_MODEL : ""
55
+ default: harness === "codex" ? DEFAULT_CODEX_MODEL : "",
56
+ validate: (value) => value.trim().length > 0 || "Model is required"
58
57
  }));
59
- const reasoningLevel = options.reasoningLevel ??
58
+ const reasoning_effort = options.reasoningEffort ??
59
+ (await input({ message: "Reasoning effort", default: "medium" }));
60
+ const review = options.review
61
+ ? normalizeReview(options.review)
62
+ : await select({
63
+ message: "Review strategy",
64
+ choices: [
65
+ { name: "After each task", value: "each" },
66
+ { name: "After all tasks (global review)", value: "all" },
67
+ { name: "Both per-task and global", value: "both" },
68
+ { name: "No review", value: "none" }
69
+ ],
70
+ default: "each"
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
+ }
83
+ const repo = options.repo ?? (await input({ message: "GitHub repository (owner/repo)" }));
84
+ validateRepository(repo);
85
+ const baseBranchInput = options.baseBranch ??
60
86
  (await input({
61
- message: "Default reasoning level",
62
- default: "medium"
87
+ message: "Base branch (blank = current branch at run time)",
88
+ default: ""
63
89
  }));
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 ??
90
+ const base_branch = baseBranchInput.trim() ? baseBranchInput.trim() : undefined;
91
+ const max_iterations = parseMaxIterations(options.maxIterations) ??
70
92
  (await numberPrompt({
71
- message: "Max iterations",
93
+ message: "Max work items per run",
72
94
  default: 5,
73
95
  required: true
74
96
  }));
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) {
97
+ if (!Number.isInteger(max_iterations) || max_iterations <= 0) {
111
98
  throw new Error("max iterations must be a positive integer");
112
99
  }
113
100
  return {
114
101
  harness,
115
- model,
116
- reasoningLevel,
117
- maxIterations,
118
- workItemsSource,
119
- workItemsPath,
120
- workItemsRepository,
121
- implementationSkill,
122
- reviewMode,
123
- pullRequest
102
+ model: model.trim(),
103
+ reasoning_effort: reasoning_effort.trim() || "medium",
104
+ review,
105
+ review_max_attempts,
106
+ repo,
107
+ base_branch,
108
+ max_iterations
124
109
  };
125
110
  }
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";
111
+ function buildConfig(options) {
112
+ const config = {
113
+ harness: options.harness,
114
+ model: options.model,
115
+ reasoning_effort: options.reasoning_effort,
116
+ review: options.review,
117
+ tracker: { type: "github", repo: options.repo },
118
+ max_iterations: options.max_iterations
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;
133
123
  }
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;
124
+ if (options.base_branch) {
125
+ config.base_branch = options.base_branch;
157
126
  }
158
- throw new Error('review mode must be "each", "all", "both", or "none"');
127
+ return config;
159
128
  }
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;
129
+ function normalizeHarness(value) {
130
+ if (harnessNames.includes(value)) {
131
+ return value;
171
132
  }
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;
133
+ throw new Error(`harness must be one of: ${harnessNames.join(", ")}`);
176
134
  }
177
- function normalizeWorkItemsSource(source) {
178
- if (source === "local" || source === "local-markdown") {
179
- return "local";
135
+ function normalizeReview(value) {
136
+ if (reviewModes.includes(value)) {
137
+ return value;
180
138
  }
181
- if (source === "github") {
182
- return source;
183
- }
184
- throw new Error('work item source must be "local" or "github"');
139
+ throw new Error(`review must be one of: ${reviewModes.join(", ")}`);
185
140
  }
186
- function validateGitHubRepository(repository) {
187
- if (!repository || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)) {
141
+ function validateRepository(repo) {
142
+ if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo)) {
188
143
  throw new Error('GitHub repository must use "owner/repo"');
189
144
  }
190
145
  }
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));
146
+ function parseMaxIterations(value) {
147
+ if (value === undefined) {
148
+ return undefined;
209
149
  }
150
+ return Number.parseInt(value, 10);
210
151
  }
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}`);
152
+ function parseReviewAttempts(value) {
153
+ if (value === undefined) {
154
+ return undefined;
219
155
  }
156
+ return Number.parseInt(value, 10);
220
157
  }
221
158
  async function ensureGitignoreEntries(root) {
222
159
  const gitignorePath = path.join(root, ".gitignore");
223
160
  const current = (await pathExists(gitignorePath))
224
161
  ? await readText(gitignorePath)
225
162
  : "";
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) {
163
+ const existing = new Set(current.split(/\r?\n/).map((line) => line.trim()));
164
+ const missing = GITIGNORE_ENTRIES.filter((entry) => !existing.has(entry));
165
+ if (missing.length === 0) {
230
166
  return;
231
167
  }
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, '\\"');
168
+ const prefix = current.length === 0 || current.endsWith("\n") ? "" : "\n";
169
+ const block = `${prefix}${current.length === 0 ? "" : "\n"}${GITIGNORE_MARKER}\n${missing.join("\n")}\n`;
170
+ await writeText(gitignorePath, `${current}${block}`);
521
171
  }
@@ -1,8 +1,22 @@
1
+ /** `nyxagent run`: normalizes CLI options and hands off to the pipeline runner. */
1
2
  import path from "node:path";
2
- import { runWorkflow } from "../runtime/runWorkflow.js";
3
+ import { harnessNames } from "../config/schema.js";
4
+ import { runPipeline } from "../runtime/runPipeline.js";
3
5
  export async function runCommand(options, projectRoot = process.cwd()) {
4
- await runWorkflow({
6
+ await runPipeline({
5
7
  projectRoot,
6
- configPath: options.config ? path.resolve(projectRoot, options.config) : undefined
8
+ configPath: options.config
9
+ ? path.resolve(projectRoot, options.config)
10
+ : undefined,
11
+ harness: normalizeHarness(options.harness)
7
12
  });
8
13
  }
14
+ function normalizeHarness(value) {
15
+ if (value === undefined) {
16
+ return undefined;
17
+ }
18
+ if (harnessNames.includes(value)) {
19
+ return value;
20
+ }
21
+ throw new Error(`--harness must be one of: ${harnessNames.join(", ")}`);
22
+ }
@@ -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";