@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,8 +1,22 @@
1
+ /** Reads, JSON-parses, and schema-validates a .nyxagent/config.json file. */
1
2
  import { readFile } from "node:fs/promises";
2
- import { parse } from "smol-toml";
3
3
  import { nyxConfigSchema } from "./schema.js";
4
4
  export async function loadConfig(configPath) {
5
5
  const raw = await readFile(configPath, "utf8");
6
- const parsed = parse(raw);
7
- return nyxConfigSchema.parse(parsed);
6
+ let parsed;
7
+ try {
8
+ parsed = JSON.parse(raw);
9
+ }
10
+ catch (error) {
11
+ const message = error instanceof Error ? error.message : String(error);
12
+ throw new Error(`Invalid JSON in ${configPath}: ${message}`);
13
+ }
14
+ const result = nyxConfigSchema.safeParse(parsed);
15
+ if (!result.success) {
16
+ const detail = result.error.issues
17
+ .map((issue) => `${issue.path.join(".") || "<root>"}: ${issue.message}`)
18
+ .join("; ");
19
+ throw new Error(`Invalid NyxAgent config (${configPath}): ${detail}`);
20
+ }
21
+ return result.data;
8
22
  }
@@ -1,152 +1,35 @@
1
1
  import { z } from "zod";
2
- const modelSchema = z
3
- .object({
4
- name: z.string().min(1),
5
- reasoning_level: z.string().min(1).default("medium")
6
- })
7
- .passthrough();
8
- const modelOverrideSchema = modelSchema.partial().passthrough();
9
- const harnessSchema = z
10
- .object({
11
- preset: z.string().min(1).optional(),
12
- command: z.string().min(1),
13
- args: z.array(z.string()).default([]),
14
- prompt_input: z.literal("stdin").default("stdin")
15
- })
16
- .passthrough();
17
- const harnessOverrideSchema = harnessSchema.partial().passthrough();
18
- const phaseSchema = z
19
- .object({
20
- id: z.string().min(1),
21
- prompt: z.string().min(1),
22
- output_schema: z.string().min(1).optional(),
23
- required_output: z.boolean().default(false),
24
- max_visits_per_iteration: z.number().int().positive().default(1),
25
- next: z.string().min(1).optional(),
26
- transitions: z.record(z.string(), z.string()).optional(),
27
- model: modelOverrideSchema.optional(),
28
- harness: harnessOverrideSchema.optional()
29
- })
30
- .passthrough();
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();
2
+ /**
3
+ * The closed-pipeline configuration. NyxAgent runs one fixed workflow
4
+ * (select -> implement -> [review] -> commit -> [global review] -> pull request);
5
+ * this config only tunes the knobs the pipeline exposes. The workflow shape
6
+ * itself is not configurable.
7
+ */
8
+ export const harnessNames = ["codex", "claude"];
9
+ export const reviewModes = ["each", "all", "both", "none"];
41
10
  const githubRepositoryPattern = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
42
- const workItemsSchema = z
43
- .object({
44
- source: workItemsSourceSchema,
45
- path: z.string().min(1).optional(),
46
- repository: z.string().min(1).optional(),
47
- max_candidates: z.number().int().positive().default(50),
48
- excerpt_chars: z.number().int().nonnegative().default(800)
49
- })
50
- .passthrough()
51
- .superRefine((workItems, ctx) => {
52
- if (workItems.source === "local" && !workItems.path) {
53
- ctx.addIssue({
54
- code: "custom",
55
- path: ["path"],
56
- message: 'Local work items require "path"'
57
- });
58
- }
59
- if (workItems.source === "github") {
60
- if (!workItems.repository) {
61
- ctx.addIssue({
62
- code: "custom",
63
- path: ["repository"],
64
- message: 'GitHub work items require "repository"'
65
- });
66
- }
67
- else if (!githubRepositoryPattern.test(workItems.repository)) {
68
- ctx.addIssue({
69
- code: "custom",
70
- path: ["repository"],
71
- message: 'GitHub repository must use "owner/repo"'
72
- });
73
- }
74
- }
75
- });
76
11
  export const nyxConfigSchema = z
77
12
  .object({
78
- workflow: z.object({
79
- entry_phase: z.string().min(1),
80
- final_phase: z.string().min(1).optional(),
81
- max_iterations: z.number().int().positive()
82
- }),
83
- model: modelSchema,
84
- harness: harnessSchema,
85
- git: gitSchema.optional(),
86
- repair: z
87
- .object({
88
- max_attempts: z.number().int().nonnegative().default(1),
89
- prompt: z.string().min(1).default("prompts/repair-result.md")
90
- })
91
- .default({
92
- max_attempts: 1,
93
- prompt: "prompts/repair-result.md"
13
+ /** Which agent CLI runs each phase. Overridable per run via `run --harness`. */
14
+ harness: z.enum(harnessNames),
15
+ /** Model name passed to the harness. */
16
+ model: z.string().min(1),
17
+ /** Reasoning effort passed to the harness (codex `model_reasoning_effort`). */
18
+ reasoning_effort: z.string().min(1).default("medium"),
19
+ /** When the agent reviews its own work. */
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),
23
+ /** Work item tracker. GitHub issues only in this version. */
24
+ tracker: z.object({
25
+ type: z.literal("github"),
26
+ repo: z
27
+ .string()
28
+ .regex(githubRepositoryPattern, 'tracker.repo must be "owner/repo"')
94
29
  }),
95
- work_items: workItemsSchema.optional(),
96
- phases: z.array(phaseSchema).min(1)
30
+ /** Base branch the run branch is cut from. Defaults to the current branch. */
31
+ base_branch: z.string().min(1).optional(),
32
+ /** Maximum work items processed in a single run. */
33
+ max_iterations: z.number().int().positive().default(5)
97
34
  })
98
- .superRefine((config, ctx) => {
99
- const phaseIds = new Set();
100
- for (const [index, phase] of config.phases.entries()) {
101
- if (phaseIds.has(phase.id)) {
102
- ctx.addIssue({
103
- code: "custom",
104
- path: ["phases", index, "id"],
105
- message: `Duplicate phase id "${phase.id}"`
106
- });
107
- }
108
- phaseIds.add(phase.id);
109
- if (phase.next && phase.transitions) {
110
- ctx.addIssue({
111
- code: "custom",
112
- path: ["phases", index],
113
- message: `Phase "${phase.id}" cannot define both next and transitions`
114
- });
115
- }
116
- }
117
- if (!phaseIds.has(config.workflow.entry_phase)) {
118
- ctx.addIssue({
119
- code: "custom",
120
- path: ["workflow", "entry_phase"],
121
- message: `Unknown entry phase "${config.workflow.entry_phase}"`
122
- });
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
- }
132
- const reservedTargets = new Set([
133
- "stop_run",
134
- "stop_iteration",
135
- "next_iteration"
136
- ]);
137
- for (const [index, phase] of config.phases.entries()) {
138
- const targets = [
139
- phase.next,
140
- ...Object.values(phase.transitions ?? {})
141
- ].filter((target) => Boolean(target));
142
- for (const target of targets) {
143
- if (!reservedTargets.has(target) && !phaseIds.has(target)) {
144
- ctx.addIssue({
145
- code: "custom",
146
- path: ["phases", index],
147
- message: `Phase "${phase.id}" points to unknown target "${target}"`
148
- });
149
- }
150
- }
151
- }
152
- });
35
+ .strict();
@@ -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,69 +1,35 @@
1
1
  import path from "node:path";
2
2
  import { rm } from "node:fs/promises";
3
3
  import { execa } from "execa";
4
- import { renderTemplate } from "./renderTemplate.js";
5
- /**
6
- * Set up the run-scoped git context (one branch per run = one PRD = one PR).
7
- *
8
- * The engine only performs generic git plumbing (branch + worktree). All
9
- * GitHub semantics (pushing, opening the PR, closing issues) stay in the
10
- * phase prompts, keeping the engine agnostic.
11
- *
12
- * Returns `undefined` when git management is disabled (`mode = "off"`), in
13
- * which case phases run in `projectRoot` exactly as before.
14
- */
15
- export async function setUpGitContext(input) {
16
- const mode = input.git.mode;
17
- if (mode === "off") {
18
- return undefined;
19
- }
4
+ const WORKTREE_DIR = ".nyxagent/worktrees";
5
+ export async function setUpRunWorktree(input) {
20
6
  await assertGitRepository(input.projectRoot);
21
- const branch = sanitizeBranch(renderTemplate(input.git.branch_template, { run_id: input.runId }));
7
+ const base = input.base ?? (await currentRef(input.projectRoot));
8
+ const branch = sanitizeBranch(`nyxagent/${input.runId}`);
22
9
  if (!branch) {
23
- throw new Error(`[git].branch_template "${input.git.branch_template}" produced an empty branch name`);
24
- }
25
- const base = input.git.base ?? (await currentRef(input.projectRoot));
26
- const branchExists = await refExists(input.projectRoot, branch);
27
- if (mode === "branch") {
28
- const args = branchExists
29
- ? ["checkout", branch]
30
- : ["checkout", "-b", branch, base];
31
- await runGit(input.projectRoot, args, "create branch");
32
- return { mode, branch, base, worktree: input.projectRoot };
10
+ throw new Error(`Could not derive a branch name from run id "${input.runId}"`);
33
11
  }
34
- // mode === "worktree"
35
- const worktree = path.resolve(input.projectRoot, input.git.worktree_dir, input.runId);
36
- const args = branchExists
37
- ? ["worktree", "add", worktree, branch]
38
- : ["worktree", "add", worktree, "-b", branch, base];
39
- await runGit(input.projectRoot, args, "create worktree");
40
- return { mode, branch, base, worktree };
12
+ const worktree = path.resolve(input.projectRoot, WORKTREE_DIR, input.runId);
13
+ await runGit(input.projectRoot, ["worktree", "add", worktree, "-b", branch, base], "create worktree");
14
+ return { branch, base, worktree };
41
15
  }
42
- /**
43
- * Tear down a run-scoped git context. The branch is always kept (it holds the
44
- * committed work and any pull request); only the worktree working directory is
45
- * removed, according to the cleanup policy.
46
- */
47
- export async function tearDownGitContext(input) {
48
- if (input.context.mode !== "worktree") {
49
- return;
50
- }
51
- const shouldRemove = input.cleanup === "always" ||
52
- (input.cleanup === "on_success" && input.success);
53
- if (!shouldRemove) {
54
- return;
55
- }
56
- const removal = await execa("git", ["worktree", "remove", input.context.worktree, "--force"], { cwd: input.projectRoot, reject: false });
16
+ export async function removeRunWorktree(input) {
17
+ const removal = await execa("git", ["worktree", "remove", input.worktree, "--force"], { cwd: input.projectRoot, reject: false });
57
18
  if (removal.exitCode !== 0) {
58
- // Fall back to pruning the directory and the worktree registry so a failed
59
- // run never leaves the next run unable to reuse the path.
60
- await rm(input.context.worktree, { recursive: true, force: true });
19
+ await rm(input.worktree, { recursive: true, force: true });
61
20
  await execa("git", ["worktree", "prune"], {
62
21
  cwd: input.projectRoot,
63
22
  reject: false
64
23
  });
65
24
  }
66
25
  }
26
+ /** Delete a local branch (used when a run produced no commits). */
27
+ export async function deleteBranch(input) {
28
+ await execa("git", ["branch", "-D", input.branch], {
29
+ cwd: input.projectRoot,
30
+ reject: false
31
+ });
32
+ }
67
33
  export function sanitizeBranch(name) {
68
34
  return name
69
35
  .trim()
@@ -81,7 +47,7 @@ async function assertGitRepository(cwd) {
81
47
  reject: false
82
48
  });
83
49
  if (result.exitCode !== 0 || result.stdout.trim() !== "true") {
84
- throw new Error(`[git].mode is enabled but ${cwd} is not a git repository. Run "git init" or set [git].mode = "off".`);
50
+ throw new Error(`${cwd} is not a git repository. Run "git init" first.`);
85
51
  }
86
52
  }
87
53
  async function currentRef(cwd) {
@@ -96,10 +62,6 @@ async function currentRef(cwd) {
96
62
  const sha = await execa("git", ["rev-parse", "HEAD"], { cwd, reject: false });
97
63
  return sha.stdout.trim();
98
64
  }
99
- async function refExists(cwd, ref) {
100
- const result = await execa("git", ["rev-parse", "--verify", "--quiet", `refs/heads/${ref}`], { cwd, reject: false });
101
- return result.exitCode === 0;
102
- }
103
65
  async function runGit(cwd, args, label) {
104
66
  const result = await execa("git", args, { cwd, reject: false });
105
67
  if (result.exitCode !== 0) {
@@ -0,0 +1,26 @@
1
+ export function buildHarnessInvocation(input) {
2
+ if (input.harness === "codex") {
3
+ const args = [
4
+ "exec",
5
+ "--model",
6
+ input.model,
7
+ "-c",
8
+ `model_reasoning_effort="${input.reasoning}"`
9
+ ];
10
+ if (input.capability === "readonly") {
11
+ args.push("--sandbox", "read-only");
12
+ }
13
+ // write: codex default workspace-write sandbox; no network needed.
14
+ args.push("-");
15
+ return { command: "codex", args };
16
+ }
17
+ // claude
18
+ const args = ["-p", "--model", input.model, "--output-format", "text"];
19
+ if (input.capability === "readonly") {
20
+ args.push("--permission-mode", "plan");
21
+ }
22
+ else {
23
+ args.push("--dangerously-skip-permissions");
24
+ }
25
+ return { command: "claude", args };
26
+ }
@@ -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,19 +1,8 @@
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");
4
5
  }
5
- export function resolveNyxPath(projectRoot, relativePath, label) {
6
- if (path.isAbsolute(relativePath)) {
7
- throw new Error(`${label} must be relative to .nyxagent`);
8
- }
9
- const nyxDir = getNyxDir(projectRoot);
10
- const resolved = path.resolve(nyxDir, relativePath);
11
- const relative = path.relative(nyxDir, resolved);
12
- if (relative.startsWith("..") || path.isAbsolute(relative)) {
13
- throw new Error(`${label} must stay inside .nyxagent`);
14
- }
15
- return resolved;
16
- }
17
6
  export function relativeToProject(projectRoot, absolutePath) {
18
7
  return path.relative(projectRoot, absolutePath) || ".";
19
8
  }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Embedded phase prompts and the prompt assembler.
3
+ *
4
+ * Only the execution prompt is user-overridable (via .nyxagent/prompts/execution.md);
5
+ * every other prompt is fixed here so the pipeline's contracts cannot drift. Each
6
+ * prompt is pure guidance — NyxAgent prepends an engine-owned context block and,
7
+ * for review-style phases, appends the required-result contract + JSON Schema.
8
+ */
9
+ export const SELECTION_PROMPT = `Select and order the GitHub issues to work on in this run.
10
+
11
+ The available open issues (candidates) are listed in the context above. Choose the
12
+ ones that form a coherent unit of work for this run and order them so prerequisites
13
+ come first. You may select a subset; skip issues that are unclear, blocked, or out
14
+ of scope. Do not invent keys — only use keys present in the candidates.
15
+
16
+ Return outcome "selected" with the ordered keys in a work_item_keys array. If
17
+ nothing is worth working on, return outcome "no_work" instead.`;
18
+ export const EXECUTION_PROMPT = `Implement the selected work item described in the context above.
19
+
20
+ Work only on this item. Keep changes focused and coherent. Use a
21
+ red-green-refactor loop when practical: cover the expected behavior with a focused
22
+ test, implement the smallest change that satisfies it, then tidy the result.
23
+
24
+ Do not commit and do not touch git — NyxAgent commits your changes for you. Leave
25
+ clear validation evidence (commands run and their results) in your final response.`;
26
+ export const REVIEW_PROMPT = `Review the implementation of the selected work item.
27
+
28
+ The uncommitted changes for this item are shown as a diff in the context above; you
29
+ may also read files in the working directory. Stay read-only and do not modify
30
+ anything.
31
+
32
+ Assess: alignment with the work item, correctness and regression risk, test or
33
+ validation evidence, design fit, and security or data-safety concerns.
34
+
35
+ Set outcome to "approved" when the work is ready, or "changes_requested" with a
36
+ concrete, actionable list in required_changes. Always include a short summary.`;
37
+ export const REVISION_PROMPT = `Apply the changes requested by the review for the selected work item.
38
+
39
+ The required changes are listed in the context above. Address exactly those, keeping
40
+ the work focused. Do not commit — NyxAgent commits your changes for you.`;
41
+ export const GLOBAL_REVIEW_PROMPT = `Review the entire run as a whole, now that every selected work item is implemented
42
+ and committed.
43
+
44
+ The combined diff for the run is shown in the context above; you may also read files
45
+ in the working directory. Stay read-only and do not modify anything.
46
+
47
+ Focus on cross-cutting concerns a per-item review cannot see: integration between
48
+ items, regressions one item introduced in another, overall design coherence,
49
+ duplication, and gaps versus the issues' intent.
50
+
51
+ Set outcome to "approved" when the run is coherent and ready, or
52
+ "changes_requested" with a concrete, actionable list in required_changes. Always
53
+ include a short summary.`;
54
+ export const GLOBAL_REVISION_PROMPT = `Apply the changes requested by the global review of the whole run.
55
+
56
+ The required changes are listed in the context above. Address exactly those, across
57
+ whichever work items are affected. Do not commit — NyxAgent commits your corrections
58
+ for you.`;
59
+ /** Rendered into .nyxagent/prompts/execution.md at init; the only editable prompt. */
60
+ export const EXECUTION_PROMPT_FILE = `${EXECUTION_PROMPT}
61
+ `;
62
+ /** Engine-owned context block prepended to every phase prompt. */
63
+ export function buildContextBlock(entries) {
64
+ const lines = ["## Context", ""];
65
+ for (const [label, value] of entries) {
66
+ if (value === undefined || value === null) {
67
+ continue;
68
+ }
69
+ lines.push(`### ${label}`, "");
70
+ if (typeof value === "string") {
71
+ lines.push(value === "" ? "(empty)" : value, "");
72
+ }
73
+ else {
74
+ lines.push("```json", JSON.stringify(value, null, 2), "```", "");
75
+ }
76
+ }
77
+ return lines.join("\n").trimEnd();
78
+ }
79
+ /** Assemble the full prompt sent to the harness for one phase. */
80
+ export function buildPhasePrompt(input) {
81
+ const parts = [
82
+ "# NyxAgent phase",
83
+ "",
84
+ "You run as one isolated phase of an automated workflow. Follow the context and",
85
+ "instructions below exactly.",
86
+ "",
87
+ input.context,
88
+ "",
89
+ "## Instructions",
90
+ "",
91
+ input.guidance.trim()
92
+ ];
93
+ if (input.schema) {
94
+ parts.push("", "## Required result", "", "End your response with a single <nyxagent_result> block containing JSON that", "matches this schema. NyxAgent parses the last such block, validates it, and", "ignores everything else for control flow.", "", "```json", JSON.stringify(input.schema, null, 2), "```", "", "<nyxagent_result>", "{ ... }", "</nyxagent_result>");
95
+ }
96
+ return parts.join("\n");
97
+ }
98
+ export function truncateForPrompt(text, maxChars = 40000) {
99
+ if (text.length <= maxChars) {
100
+ return text;
101
+ }
102
+ return `${text.slice(0, maxChars)}\n... [truncated ${text.length - maxChars} characters]`;
103
+ }