@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
@@ -0,0 +1,395 @@
1
+ import path from "node:path";
2
+ import pc from "picocolors";
3
+ import { loadConfig } from "../config/loadConfig.js";
4
+ import { ensureDir, pathExists, readText } from "./files.js";
5
+ import { deleteBranch, removeRunWorktree, setUpRunWorktree } from "./gitLifecycle.js";
6
+ import { markWorkItemCompleted, readWorkItemLedger, writeWorkItemLedger } from "./ledger.js";
7
+ import { getNyxDir, relativeToProject } from "./paths.js";
8
+ import { buildContextBlock, buildPhasePrompt, EXECUTION_PROMPT, GLOBAL_REVIEW_PROMPT, GLOBAL_REVISION_PROMPT, REVIEW_PROMPT, REVISION_PROMPT, SELECTION_PROMPT, truncateForPrompt } from "./prompts.js";
9
+ import { runAgentPhase } from "./runPhase.js";
10
+ import { GLOBAL_REVIEW_SCHEMA, REVIEW_SCHEMA, SELECTION_SCHEMA } from "./schemas.js";
11
+ import { commitAll, commitsAhead, createPullRequest, pushBranch, rangeDiff, stageAllAndDiff } from "./scm.js";
12
+ import { createRunId } from "./time.js";
13
+ import { filterAvailable, listGitHubIssues, resolveSelectedQueue } from "./workItems.js";
14
+ const MAX_CANDIDATES = 50;
15
+ const EXCERPT_CHARS = 800;
16
+ const REVIEW_MAX_ATTEMPTS = 3;
17
+ export function defaultPipelineDependencies() {
18
+ return {
19
+ listIssues: listGitHubIssues,
20
+ runPhase: runAgentPhase,
21
+ pushBranch,
22
+ createPullRequest
23
+ };
24
+ }
25
+ /**
26
+ * The fixed NyxAgent pipeline:
27
+ *
28
+ * select -> for each item: implement -> [review/revise] -> commit
29
+ * -> [global review/revise] -> open pull request
30
+ *
31
+ * Control flow lives here in code (not in config). The agent only implements,
32
+ * reviews, and revises; every git/gh side effect is performed by the engine.
33
+ */
34
+ export async function runPipeline(input = {}, deps = defaultPipelineDependencies()) {
35
+ const projectRoot = path.resolve(input.projectRoot ?? process.cwd());
36
+ const nyxDir = getNyxDir(projectRoot);
37
+ const configPath = input.configPath ?? path.join(nyxDir, "config.json");
38
+ const config = await loadConfig(configPath);
39
+ const harness = input.harness ?? config.harness;
40
+ const runId = createRunId();
41
+ const runDir = path.join(nyxDir, "runs", runId);
42
+ await ensureDir(runDir);
43
+ console.log(pc.bold(`NyxAgent run ${runId}`));
44
+ console.log(`Harness: ${harness} · model: ${config.model} · review: ${config.review}`);
45
+ const ledger = await readWorkItemLedger(nyxDir);
46
+ // 1. Selection runs read-only in the main checkout, before any branch exists.
47
+ const candidates = filterAvailable({
48
+ candidates: await deps.listIssues({
49
+ repo: config.tracker.repo,
50
+ maxCandidates: MAX_CANDIDATES,
51
+ excerptChars: EXCERPT_CHARS
52
+ }),
53
+ completedKeys: ledger.completed_work_item_keys
54
+ });
55
+ if (candidates.length === 0) {
56
+ console.log("No open work items available. Nothing to do.");
57
+ return;
58
+ }
59
+ const selected = await runSelection({
60
+ projectRoot,
61
+ runDir,
62
+ harness,
63
+ config,
64
+ candidates,
65
+ runPhase: deps.runPhase
66
+ });
67
+ if (selected.length === 0) {
68
+ console.log("Selection chose no work items. Nothing to do.");
69
+ return;
70
+ }
71
+ const planned = selected.slice(0, config.max_iterations);
72
+ console.log(`Selected ${planned.length} work item(s):`);
73
+ for (const item of planned) {
74
+ console.log(` - ${item.title} (#${item.number})`);
75
+ }
76
+ // 2. One branch + worktree per run (created only now that there is work).
77
+ const git = await setUpRunWorktree({
78
+ projectRoot,
79
+ runId,
80
+ base: config.base_branch
81
+ });
82
+ console.log(`Branch ${git.branch} (base ${git.base})`);
83
+ let success = false;
84
+ let producedCommits = false;
85
+ let currentLedger = ledger;
86
+ const completed = [];
87
+ try {
88
+ const executionGuidance = await loadExecutionGuidance(nyxDir);
89
+ for (const [index, item] of planned.entries()) {
90
+ const iterationNumber = index + 1;
91
+ const iterationDir = path.join(runDir, "iterations", String(iterationNumber).padStart(3, "0"));
92
+ console.log(pc.cyan(`\n[${iterationNumber}/${planned.length}] ${item.title} (#${item.number})`));
93
+ await runExecution({
94
+ iterationDir,
95
+ item,
96
+ guidance: executionGuidance,
97
+ git,
98
+ harness,
99
+ config,
100
+ runPhase: deps.runPhase
101
+ });
102
+ if (config.review === "each" || config.review === "both") {
103
+ await runReviewLoop({
104
+ iterationDir,
105
+ item,
106
+ git,
107
+ harness,
108
+ config,
109
+ runPhase: deps.runPhase
110
+ });
111
+ }
112
+ const { committed } = await commitAll({
113
+ cwd: git.worktree,
114
+ message: buildCommitMessage(item)
115
+ });
116
+ if (committed) {
117
+ producedCommits = true;
118
+ }
119
+ else {
120
+ console.log(pc.yellow(" No changes to commit for this item."));
121
+ }
122
+ currentLedger = markWorkItemCompleted({
123
+ ledger: currentLedger,
124
+ workItem: item
125
+ });
126
+ await writeWorkItemLedger(nyxDir, currentLedger);
127
+ completed.push(item);
128
+ }
129
+ if (config.review === "all" || config.review === "both") {
130
+ const corrections = await runGlobalReviewLoop({
131
+ runDir,
132
+ git,
133
+ harness,
134
+ config,
135
+ runPhase: deps.runPhase
136
+ });
137
+ if (corrections) {
138
+ producedCommits = true;
139
+ }
140
+ }
141
+ if (!producedCommits || (await commitsAhead(git.worktree, git.base)) === 0) {
142
+ console.log("\nRun produced no commits; skipping pull request.");
143
+ success = true;
144
+ return;
145
+ }
146
+ await deps.pushBranch({ cwd: git.worktree, branch: git.branch });
147
+ const prUrl = await deps.createPullRequest({
148
+ cwd: git.worktree,
149
+ repo: config.tracker.repo,
150
+ base: git.base,
151
+ head: git.branch,
152
+ title: buildPrTitle(completed),
153
+ body: buildPrBody(completed)
154
+ });
155
+ console.log(pc.green(`\nPull request opened: ${prUrl}`));
156
+ success = true;
157
+ }
158
+ finally {
159
+ if (success) {
160
+ await removeRunWorktree({ projectRoot, worktree: git.worktree });
161
+ if (!producedCommits) {
162
+ await deleteBranch({ projectRoot, branch: git.branch });
163
+ }
164
+ }
165
+ else {
166
+ console.log(pc.red(`\nRun failed. Branch ${git.branch} and worktree kept for debugging: ${relativeToProject(projectRoot, git.worktree)}`));
167
+ }
168
+ }
169
+ }
170
+ async function runSelection(input) {
171
+ const context = buildContextBlock([
172
+ ["Repository", input.config.tracker.repo],
173
+ ["Max work items this run", input.config.max_iterations],
174
+ [
175
+ "Available candidates",
176
+ input.candidates.map((candidate) => ({
177
+ key: candidate.key,
178
+ number: candidate.number,
179
+ title: candidate.title,
180
+ labels: candidate.labels,
181
+ excerpt: candidate.excerpt
182
+ }))
183
+ ]
184
+ ]);
185
+ const result = await input.runPhase({
186
+ phaseId: "selection",
187
+ phaseDir: path.join(input.runDir, "selection"),
188
+ workdir: input.projectRoot,
189
+ harness: input.harness,
190
+ model: input.config.model,
191
+ reasoning: input.config.reasoning_effort,
192
+ capability: "readonly",
193
+ prompt: buildPhasePrompt({
194
+ guidance: SELECTION_PROMPT,
195
+ context,
196
+ schema: SELECTION_SCHEMA
197
+ }),
198
+ schema: SELECTION_SCHEMA
199
+ });
200
+ if (!result.ok) {
201
+ throw new Error(result.error);
202
+ }
203
+ const value = result.result;
204
+ if (value.outcome === "no_work") {
205
+ return [];
206
+ }
207
+ const resolved = resolveSelectedQueue({
208
+ keys: value.work_item_keys,
209
+ candidates: input.candidates
210
+ });
211
+ if (!resolved.ok) {
212
+ throw new Error(`Selection produced an invalid queue: ${resolved.error}`);
213
+ }
214
+ return resolved.queue;
215
+ }
216
+ async function runExecution(input) {
217
+ const context = buildContextBlock([
218
+ ["Work item", workItemSummary(input.item)],
219
+ ["Issue description", input.item.excerpt ?? "(no description provided)"],
220
+ ["Working directory", input.git.worktree],
221
+ ["Branch", `${input.git.branch} (base ${input.git.base})`]
222
+ ]);
223
+ const result = await input.runPhase({
224
+ phaseId: "execution",
225
+ phaseDir: path.join(input.iterationDir, "execution"),
226
+ workdir: input.git.worktree,
227
+ harness: input.harness,
228
+ model: input.config.model,
229
+ reasoning: input.config.reasoning_effort,
230
+ capability: "write",
231
+ prompt: buildPhasePrompt({ guidance: input.guidance, context })
232
+ });
233
+ if (!result.ok) {
234
+ throw new Error(result.error);
235
+ }
236
+ }
237
+ async function runReviewLoop(input) {
238
+ for (let attempt = 1; attempt <= REVIEW_MAX_ATTEMPTS; attempt += 1) {
239
+ const diff = await stageAllAndDiff(input.git.worktree);
240
+ const reviewResult = await input.runPhase({
241
+ phaseId: "review",
242
+ phaseDir: path.join(input.iterationDir, `review-${attempt}`),
243
+ workdir: input.git.worktree,
244
+ harness: input.harness,
245
+ model: input.config.model,
246
+ reasoning: input.config.reasoning_effort,
247
+ capability: "readonly",
248
+ prompt: buildPhasePrompt({
249
+ guidance: REVIEW_PROMPT,
250
+ context: buildContextBlock([
251
+ ["Work item", workItemSummary(input.item)],
252
+ ["Uncommitted changes (diff)", truncateForPrompt(diff || "(no changes)")]
253
+ ]),
254
+ schema: REVIEW_SCHEMA
255
+ }),
256
+ schema: REVIEW_SCHEMA
257
+ });
258
+ if (!reviewResult.ok) {
259
+ throw new Error(reviewResult.error);
260
+ }
261
+ const review = reviewResult.result;
262
+ console.log(` review: ${review.outcome}`);
263
+ if (review.outcome === "approved") {
264
+ return;
265
+ }
266
+ if (attempt === REVIEW_MAX_ATTEMPTS) {
267
+ throw new Error(`Review for #${input.item.number} not approved after ${REVIEW_MAX_ATTEMPTS} attempts: ${review.summary}`);
268
+ }
269
+ const revision = await input.runPhase({
270
+ phaseId: "revision",
271
+ phaseDir: path.join(input.iterationDir, `revision-${attempt}`),
272
+ workdir: input.git.worktree,
273
+ harness: input.harness,
274
+ model: input.config.model,
275
+ reasoning: input.config.reasoning_effort,
276
+ capability: "write",
277
+ prompt: buildPhasePrompt({
278
+ guidance: REVISION_PROMPT,
279
+ context: buildContextBlock([
280
+ ["Work item", workItemSummary(input.item)],
281
+ ["Required changes", review.required_changes ?? []]
282
+ ])
283
+ })
284
+ });
285
+ if (!revision.ok) {
286
+ throw new Error(revision.error);
287
+ }
288
+ }
289
+ }
290
+ async function runGlobalReviewLoop(input) {
291
+ let committedCorrections = false;
292
+ for (let attempt = 1; attempt <= REVIEW_MAX_ATTEMPTS; attempt += 1) {
293
+ const diff = await rangeDiff(input.git.worktree, input.git.base);
294
+ const reviewResult = await input.runPhase({
295
+ phaseId: "global_review",
296
+ phaseDir: path.join(input.runDir, "final", `global-review-${attempt}`),
297
+ workdir: input.git.worktree,
298
+ harness: input.harness,
299
+ model: input.config.model,
300
+ reasoning: input.config.reasoning_effort,
301
+ capability: "readonly",
302
+ prompt: buildPhasePrompt({
303
+ guidance: GLOBAL_REVIEW_PROMPT,
304
+ context: buildContextBlock([
305
+ ["Run branch", `${input.git.branch} (base ${input.git.base})`],
306
+ [
307
+ "Combined run diff (base...HEAD)",
308
+ truncateForPrompt(diff || "(no changes)")
309
+ ]
310
+ ]),
311
+ schema: GLOBAL_REVIEW_SCHEMA
312
+ }),
313
+ schema: GLOBAL_REVIEW_SCHEMA
314
+ });
315
+ if (!reviewResult.ok) {
316
+ throw new Error(reviewResult.error);
317
+ }
318
+ const review = reviewResult.result;
319
+ console.log(`global review: ${review.outcome}`);
320
+ if (review.outcome === "approved") {
321
+ return committedCorrections;
322
+ }
323
+ if (attempt === REVIEW_MAX_ATTEMPTS) {
324
+ throw new Error(`Global review not approved after ${REVIEW_MAX_ATTEMPTS} attempts: ${review.summary}`);
325
+ }
326
+ const revision = await input.runPhase({
327
+ phaseId: "global_revision",
328
+ phaseDir: path.join(input.runDir, "final", `global-revision-${attempt}`),
329
+ workdir: input.git.worktree,
330
+ harness: input.harness,
331
+ model: input.config.model,
332
+ reasoning: input.config.reasoning_effort,
333
+ capability: "write",
334
+ prompt: buildPhasePrompt({
335
+ guidance: GLOBAL_REVISION_PROMPT,
336
+ context: buildContextBlock([
337
+ ["Required changes", review.required_changes ?? []]
338
+ ])
339
+ })
340
+ });
341
+ if (!revision.ok) {
342
+ throw new Error(revision.error);
343
+ }
344
+ const { committed } = await commitAll({
345
+ cwd: input.git.worktree,
346
+ message: "Apply global review corrections"
347
+ });
348
+ if (committed) {
349
+ committedCorrections = true;
350
+ }
351
+ }
352
+ return committedCorrections;
353
+ }
354
+ async function loadExecutionGuidance(nyxDir) {
355
+ const override = path.join(nyxDir, "prompts", "execution.md");
356
+ if (await pathExists(override)) {
357
+ const text = (await readText(override)).trim();
358
+ if (text) {
359
+ return text;
360
+ }
361
+ }
362
+ return EXECUTION_PROMPT;
363
+ }
364
+ function workItemSummary(item) {
365
+ return {
366
+ key: item.key,
367
+ number: item.number,
368
+ title: item.title,
369
+ locator: item.source.locator,
370
+ url: item.url,
371
+ labels: item.labels
372
+ };
373
+ }
374
+ function buildCommitMessage(item) {
375
+ return `${item.title}\n\nWork item: ${item.source.locator}`;
376
+ }
377
+ function buildPrTitle(items) {
378
+ if (items.length === 1) {
379
+ return items[0].title;
380
+ }
381
+ return `NyxAgent: ${items.length} work items`;
382
+ }
383
+ function buildPrBody(items) {
384
+ const list = items.map((item) => `- ${item.title} (#${item.number})`).join("\n");
385
+ const closes = items.map((item) => `Closes #${item.number}`).join("\n");
386
+ return [
387
+ "Automated changes by NyxAgent.",
388
+ "",
389
+ "## Work items",
390
+ "",
391
+ list,
392
+ "",
393
+ closes
394
+ ].join("\n");
395
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * JSON Schemas for the structured results NyxAgent requires from review-style
3
+ * phases. They are embedded (not files on disk) so they cannot drift from the
4
+ * engine and are shown verbatim to the agent in the phase prompt.
5
+ */
6
+ export const SELECTION_SCHEMA = {
7
+ $schema: "https://json-schema.org/draft/2020-12/schema",
8
+ type: "object",
9
+ required: ["outcome"],
10
+ properties: {
11
+ outcome: { type: "string", enum: ["selected", "no_work"] },
12
+ work_item_keys: {
13
+ type: "array",
14
+ items: { type: "string" },
15
+ description: "Ordered keys of the chosen candidates (prerequisites first)."
16
+ }
17
+ },
18
+ allOf: [
19
+ {
20
+ if: { properties: { outcome: { const: "selected" } } },
21
+ then: { required: ["work_item_keys"] }
22
+ }
23
+ ],
24
+ additionalProperties: true
25
+ };
26
+ const reviewSchema = {
27
+ $schema: "https://json-schema.org/draft/2020-12/schema",
28
+ type: "object",
29
+ required: ["outcome", "summary"],
30
+ properties: {
31
+ outcome: { type: "string", enum: ["approved", "changes_requested"] },
32
+ summary: {
33
+ type: "string",
34
+ minLength: 1,
35
+ description: "A brief assessment of the work."
36
+ },
37
+ required_changes: {
38
+ type: "array",
39
+ items: { type: "string" },
40
+ description: 'Specific, actionable changes (required when outcome is "changes_requested").'
41
+ }
42
+ },
43
+ allOf: [
44
+ {
45
+ if: { properties: { outcome: { const: "changes_requested" } } },
46
+ then: { required: ["required_changes"] }
47
+ }
48
+ ],
49
+ additionalProperties: true
50
+ };
51
+ export const REVIEW_SCHEMA = reviewSchema;
52
+ export const GLOBAL_REVIEW_SCHEMA = reviewSchema;
@@ -0,0 +1,76 @@
1
+ import { execa } from "execa";
2
+ /**
3
+ * Deterministic source-control side effects. NyxAgent — not the agent — performs
4
+ * every commit, push, and pull request, so closing the loop never depends on an
5
+ * LLM remembering to. All commands run with the process's own permissions (no
6
+ * sandbox), from the run worktree.
7
+ */
8
+ async function git(cwd, args, label) {
9
+ const result = await execa("git", args, { cwd, reject: false });
10
+ if (result.exitCode !== 0) {
11
+ const detail = (result.stderr || result.stdout || "unknown error").trim();
12
+ throw new Error(`git ${label} failed: ${detail}`);
13
+ }
14
+ return { stdout: result.stdout };
15
+ }
16
+ /** Stage everything and return the staged diff (captures new files too). */
17
+ export async function stageAllAndDiff(cwd) {
18
+ await git(cwd, ["add", "-A"], "add");
19
+ const diff = await git(cwd, ["diff", "--cached"], "diff --cached");
20
+ return diff.stdout;
21
+ }
22
+ /** The committed diff for the run: everything on HEAD that is not on `base`. */
23
+ export async function rangeDiff(cwd, base) {
24
+ const diff = await git(cwd, ["diff", `${base}..HEAD`], "diff range");
25
+ return diff.stdout;
26
+ }
27
+ /**
28
+ * Stage all changes and commit them. Returns whether a commit was actually made
29
+ * (false when there was nothing to commit).
30
+ */
31
+ export async function commitAll(input) {
32
+ await git(input.cwd, ["add", "-A"], "add");
33
+ const staged = await execa("git", ["diff", "--cached", "--quiet"], { cwd: input.cwd, reject: false });
34
+ // exit 0 = no staged changes, exit 1 = staged changes present.
35
+ if (staged.exitCode === 0) {
36
+ return { committed: false };
37
+ }
38
+ // Split a "subject\n\nbody" message into conventional subject + body args.
39
+ const [subject, ...rest] = input.message.split("\n\n");
40
+ const commitArgs = ["commit", "-m", subject];
41
+ const body = rest.join("\n\n").trim();
42
+ if (body) {
43
+ commitArgs.push("-m", body);
44
+ }
45
+ await git(input.cwd, commitArgs, "commit");
46
+ return { committed: true };
47
+ }
48
+ /** Number of commits on HEAD ahead of `base`. */
49
+ export async function commitsAhead(cwd, base) {
50
+ const result = await git(cwd, ["rev-list", "--count", `${base}..HEAD`], "rev-list");
51
+ return Number.parseInt(result.stdout.trim(), 10) || 0;
52
+ }
53
+ export async function pushBranch(input) {
54
+ await git(input.cwd, ["push", "-u", "origin", input.branch], "push");
55
+ }
56
+ export async function createPullRequest(input) {
57
+ const result = await execa("gh", [
58
+ "pr",
59
+ "create",
60
+ "--repo",
61
+ input.repo,
62
+ "--base",
63
+ input.base,
64
+ "--head",
65
+ input.head,
66
+ "--title",
67
+ input.title,
68
+ "--body",
69
+ input.body
70
+ ], { cwd: input.cwd, reject: false });
71
+ if (result.exitCode !== 0) {
72
+ const detail = (result.stderr || result.stdout || "unknown error").trim();
73
+ throw new Error(`gh pr create failed: ${detail}`);
74
+ }
75
+ return result.stdout.trim();
76
+ }
@@ -1,7 +1,5 @@
1
- import { readFile } from "node:fs/promises";
2
1
  import { Ajv2020 } from "ajv/dist/2020.js";
3
- export async function validateAgainstSchema(schemaPath, value) {
4
- const schema = JSON.parse(await readFile(schemaPath, "utf8"));
2
+ export function validateAgainstSchema(schema, value) {
5
3
  const ajv = new Ajv2020({ allErrors: true });
6
4
  const validate = ajv.compile(schema);
7
5
  const valid = validate(value);