@nyxa/nyx-agent 0.3.4 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13,6 +13,10 @@ async function buildRuntimeContract(input) {
13
13
  const schemaText = input.phase.output_schema
14
14
  ? await readFile(resolveNyxPath(input.projectRoot, input.phase.output_schema, `output_schema for phase "${input.phase.id}"`), "utf8")
15
15
  : undefined;
16
+ const workdir = typeof input.context.workdir === "string"
17
+ ? input.context.workdir
18
+ : input.projectRoot;
19
+ const git = input.context.git;
16
20
  const lines = [
17
21
  "# NyxAgent Runtime Contract",
18
22
  "",
@@ -20,57 +24,15 @@ async function buildRuntimeContract(input) {
20
24
  "Follow the phase prompt, but obey this runtime contract first.",
21
25
  "",
22
26
  `Project root: ${input.projectRoot}`,
23
- `Run dir: ${input.runDir}`,
24
- `Iteration dir: ${input.iterationDir}`,
25
- `Phase dir: ${input.phaseDir}`,
26
- `State file: ${input.stateFile}`,
27
- `Phase id: ${input.phase.id}`,
28
- "",
29
- "Current state:",
30
- "```json",
31
- JSON.stringify(input.context.state ?? {}, null, 2),
32
- "```",
33
- "",
34
- "Work item configuration:",
35
- "```json",
36
- JSON.stringify(input.config.work_items ?? {}, null, 2),
37
- "```",
38
- "",
39
- "Available work items:",
40
- "```json",
41
- JSON.stringify(input.context.available_work_items ?? [], null, 2),
42
- "```",
43
- "",
44
- "Recommended work item queue:",
45
- "```json",
46
- JSON.stringify(input.context.recommended_work_item_queue ?? [], null, 2),
47
- "```",
48
- "",
49
- "Selected work item queue:",
50
- "```json",
51
- JSON.stringify(input.context.selected_work_item_queue ?? [], null, 2),
52
- "```",
53
- "",
54
- "Work item annotations:",
55
- "```json",
56
- JSON.stringify(input.context.work_item_annotations ?? [], null, 2),
57
- "```",
58
- "",
59
- "Seen work item keys:",
60
- "```json",
61
- JSON.stringify(input.context.seen_work_item_keys ?? [], null, 2),
62
- "```",
63
- "",
64
- "Completed work item keys:",
65
- "```json",
66
- JSON.stringify(input.context.completed_work_item_keys ?? [], null, 2),
67
- "```",
68
- "",
69
- "Last completed work item:",
70
- "```json",
71
- JSON.stringify(input.context.last_completed_work_item ?? null, null, 2),
72
- "```"
27
+ `Working directory: ${workdir}`
73
28
  ];
29
+ if (workdir !== input.projectRoot) {
30
+ lines.push("Run all commands and edit files from the working directory above (an", "isolated git worktree). The project root only holds NyxAgent run artifacts.");
31
+ }
32
+ if (git?.branch) {
33
+ lines.push(`Git branch: ${git.branch} (base ${git.base ?? "unknown"})`);
34
+ }
35
+ lines.push(`Run dir: ${input.runDir}`, `Iteration dir: ${input.iterationDir}`, `Phase dir: ${input.phaseDir}`, `State file: ${input.stateFile}`, `Phase id: ${input.phase.id}`, "", "Current state:", "```json", JSON.stringify(input.context.state ?? {}, null, 2), "```", "", "Work item configuration:", "```json", JSON.stringify(input.config.work_items ?? {}, null, 2), "```", "", "Available work items:", "```json", JSON.stringify(input.context.available_work_items ?? [], null, 2), "```", "", "Recommended work item queue:", "```json", JSON.stringify(input.context.recommended_work_item_queue ?? [], null, 2), "```", "", "Selected work item queue:", "```json", JSON.stringify(input.context.selected_work_item_queue ?? [], null, 2), "```", "", "Work item annotations:", "```json", JSON.stringify(input.context.work_item_annotations ?? [], null, 2), "```", "", "Seen work item keys:", "```json", JSON.stringify(input.context.seen_work_item_keys ?? [], null, 2), "```", "", "Completed work item keys:", "```json", JSON.stringify(input.context.completed_work_item_keys ?? [], null, 2), "```", "", "Last completed work item:", "```json", JSON.stringify(input.context.last_completed_work_item ?? null, null, 2), "```");
74
36
  if (input.phase.transitions) {
75
37
  lines.push("", "Configured outcome transitions:", "```json", JSON.stringify(input.phase.transitions, null, 2), "```");
76
38
  }
@@ -0,0 +1,109 @@
1
+ import path from "node:path";
2
+ import { rm } from "node:fs/promises";
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
+ }
20
+ await assertGitRepository(input.projectRoot);
21
+ const branch = sanitizeBranch(renderTemplate(input.git.branch_template, { run_id: input.runId }));
22
+ 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 };
33
+ }
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 };
41
+ }
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 });
57
+ 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 });
61
+ await execa("git", ["worktree", "prune"], {
62
+ cwd: input.projectRoot,
63
+ reject: false
64
+ });
65
+ }
66
+ }
67
+ export function sanitizeBranch(name) {
68
+ return name
69
+ .trim()
70
+ .replace(/\s+/g, "-")
71
+ .replace(/[~^:?*[\]\\]/g, "-")
72
+ .replace(/\.{2,}/g, ".")
73
+ .replace(/\/{2,}/g, "/")
74
+ .replace(/-{2,}/g, "-")
75
+ .replace(/^[-/.]+/, "")
76
+ .replace(/[-/.]+$/, "");
77
+ }
78
+ async function assertGitRepository(cwd) {
79
+ const result = await execa("git", ["rev-parse", "--is-inside-work-tree"], {
80
+ cwd,
81
+ reject: false
82
+ });
83
+ 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".`);
85
+ }
86
+ }
87
+ async function currentRef(cwd) {
88
+ const named = await execa("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
89
+ cwd,
90
+ reject: false
91
+ });
92
+ const name = named.stdout.trim();
93
+ if (named.exitCode === 0 && name && name !== "HEAD") {
94
+ return name;
95
+ }
96
+ const sha = await execa("git", ["rev-parse", "HEAD"], { cwd, reject: false });
97
+ return sha.stdout.trim();
98
+ }
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
+ async function runGit(cwd, args, label) {
104
+ const result = await execa("git", args, { cwd, reject: false });
105
+ if (result.exitCode !== 0) {
106
+ const detail = (result.stderr || result.stdout || "unknown error").trim();
107
+ throw new Error(`Failed to ${label} with git: ${detail}`);
108
+ }
109
+ }
@@ -29,7 +29,7 @@ export async function runPhase(input) {
29
29
  context,
30
30
  config: input.config,
31
31
  phase: input.phase,
32
- projectRoot: input.projectRoot
32
+ workdir: input.workdir ?? input.projectRoot
33
33
  });
34
34
  if (attempt.exitCode !== 0) {
35
35
  await writeAttemptMeta(attempt, {
@@ -93,7 +93,9 @@ function buildContext(input) {
93
93
  phase_results: input.state.phase_results ?? {},
94
94
  state: input.state,
95
95
  model,
96
- harness
96
+ harness,
97
+ workdir: input.workdir ?? input.projectRoot,
98
+ git: input.git ?? {}
97
99
  };
98
100
  }
99
101
  async function runHarnessAttempt(input) {
@@ -107,13 +109,13 @@ async function runHarnessAttempt(input) {
107
109
  const args = harness.args.map((arg) => renderTemplate(arg, input.context));
108
110
  const startedAt = new Date().toISOString();
109
111
  const started = Date.now();
110
- const gitBefore = await getGitSnapshot(input.projectRoot);
112
+ const gitBefore = await getGitSnapshot(input.workdir);
111
113
  let stdout = "";
112
114
  let stderr = "";
113
115
  let exitCode = 0;
114
116
  try {
115
117
  const result = await execa(command, args, {
116
- cwd: input.projectRoot,
118
+ cwd: input.workdir,
117
119
  input: input.prompt,
118
120
  reject: false
119
121
  });
@@ -127,7 +129,7 @@ async function runHarnessAttempt(input) {
127
129
  }
128
130
  const endedAt = new Date().toISOString();
129
131
  const durationMs = Date.now() - started;
130
- const gitAfter = await getGitSnapshot(input.projectRoot);
132
+ const gitAfter = await getGitSnapshot(input.workdir);
131
133
  await writeText(path.join(input.attemptDir, "stdout.log"), stdout);
132
134
  await writeText(path.join(input.attemptDir, "stderr.log"), stderr);
133
135
  const attempt = {
@@ -250,7 +252,7 @@ async function repairStructuredResult(input) {
250
252
  context: repairContext,
251
253
  config: input.input.config,
252
254
  phase: input.input.phase,
253
- projectRoot: input.input.projectRoot
255
+ workdir: input.input.workdir ?? input.input.projectRoot
254
256
  });
255
257
  if (repairAttempt.exitCode !== 0) {
256
258
  lastError = `Repair harness exited with code ${repairAttempt.exitCode}`;
@@ -4,6 +4,7 @@ import pc from "picocolors";
4
4
  import { loadConfig } from "../config/loadConfig.js";
5
5
  import { ensureDir, writeJson } from "./files.js";
6
6
  import { getGitSnapshot } from "./git.js";
7
+ import { setUpGitContext, tearDownGitContext } from "./gitLifecycle.js";
7
8
  import { markWorkItemCompleted, readWorkItemLedger, writeWorkItemLedger } from "./ledger.js";
8
9
  import { getNyxDir, relativeToProject } from "./paths.js";
9
10
  import { runPhase } from "./runPhase.js";
@@ -109,6 +110,53 @@ export async function runWorkflow(options) {
109
110
  console.log(`Run dir: ${relativeToProject(projectRoot, runDir)}`);
110
111
  return;
111
112
  }
113
+ let gitContext;
114
+ if (config.git && config.git.mode !== "off") {
115
+ gitContext = await setUpGitContext({ projectRoot, git: config.git, runId });
116
+ }
117
+ if (gitContext) {
118
+ console.log(`Git: ${gitContext.mode} on "${gitContext.branch}" (base "${gitContext.base}")`);
119
+ if (gitContext.mode === "worktree") {
120
+ console.log(`Worktree: ${relativeToProject(projectRoot, gitContext.worktree)}`);
121
+ }
122
+ console.log("");
123
+ }
124
+ const cleanup = config.git?.cleanup ?? "on_success";
125
+ let runSucceeded = false;
126
+ try {
127
+ runSucceeded = await runSelectedQueue({
128
+ projectRoot,
129
+ nyxDir,
130
+ runDir,
131
+ config,
132
+ phasesById,
133
+ selection,
134
+ selectedWorkItems,
135
+ runState,
136
+ ledger,
137
+ workdir: gitContext?.worktree ?? projectRoot,
138
+ gitContext
139
+ });
140
+ }
141
+ finally {
142
+ if (gitContext) {
143
+ await tearDownGitContext({
144
+ projectRoot,
145
+ context: gitContext,
146
+ cleanup,
147
+ success: runSucceeded
148
+ });
149
+ }
150
+ }
151
+ }
152
+ /**
153
+ * Run the confirmed work item queue and, when configured, the run-scoped final
154
+ * phase (e.g. opening the PRD pull request). Returns true when the whole run
155
+ * completed normally, false when it stopped early (stop_run).
156
+ */
157
+ async function runSelectedQueue(input) {
158
+ const { projectRoot, nyxDir, runDir, config, phasesById, selection, selectedWorkItems, runState, workdir, gitContext } = input;
159
+ let ledger = input.ledger;
112
160
  for (let queueIndex = 0; queueIndex < selectedWorkItems.length; queueIndex += 1) {
113
161
  const iterationNumber = queueIndex + 1;
114
162
  const workItem = selectedWorkItems[queueIndex];
@@ -148,7 +196,9 @@ export async function runWorkflow(options) {
148
196
  iterationState,
149
197
  runState,
150
198
  iterationNumber,
151
- maxIterations: selectedWorkItems.length
199
+ maxIterations: selectedWorkItems.length,
200
+ workdir,
201
+ git: gitContext
152
202
  });
153
203
  if (!phaseResult.ok) {
154
204
  throw new Error(phaseResult.error);
@@ -164,7 +214,7 @@ export async function runWorkflow(options) {
164
214
  console.log("");
165
215
  console.log(`Done: ${runState.status}`);
166
216
  console.log(`Run dir: ${relativeToProject(projectRoot, runDir)}`);
167
- return;
217
+ return false;
168
218
  }
169
219
  if (nextTarget === "stop_iteration" || nextTarget === "next_iteration") {
170
220
  iterationState.status = "completed";
@@ -186,6 +236,23 @@ export async function runWorkflow(options) {
186
236
  currentPhaseId = nextTarget;
187
237
  }
188
238
  }
239
+ if (config.workflow.final_phase) {
240
+ const finalized = await runFinalPhase({
241
+ projectRoot,
242
+ runDir,
243
+ config,
244
+ phasesById,
245
+ runState,
246
+ selectedWorkItems,
247
+ ledger,
248
+ workdir,
249
+ gitContext,
250
+ workItemAnnotations: selection.workItemAnnotations
251
+ });
252
+ if (!finalized) {
253
+ return false;
254
+ }
255
+ }
189
256
  runState.status =
190
257
  selectedWorkItems.length >= config.workflow.max_iterations
191
258
  ? "completed_max_iterations"
@@ -194,6 +261,91 @@ export async function runWorkflow(options) {
194
261
  console.log("");
195
262
  console.log(`Done: ${runState.status}`);
196
263
  console.log(`Run dir: ${relativeToProject(projectRoot, runDir)}`);
264
+ return true;
265
+ }
266
+ /**
267
+ * Execute the run-scoped finalization flow after all iterations complete. This
268
+ * is a self-contained phase graph (symmetric to the iteration flow started by
269
+ * `entry_phase`): it begins at `workflow.final_phase` and follows each phase's
270
+ * `next`/`transitions` until it reaches a leaf phase (success) or `stop_run`
271
+ * (abort). This is where, for example, a review -> revision -> pull request
272
+ * chain runs. The engine only provides the branch/worktree context and
273
+ * run-level state; the GitHub/PR semantics live in the phase prompts, keeping
274
+ * the engine agnostic. A single leaf `final_phase` simply runs once.
275
+ *
276
+ * Returns true when finalization completed normally, false when a phase routed
277
+ * to `stop_run`.
278
+ */
279
+ async function runFinalPhase(input) {
280
+ const finalPhaseId = input.config.workflow.final_phase;
281
+ if (!finalPhaseId) {
282
+ return true;
283
+ }
284
+ const finalizationDir = path.join(input.runDir, "finalization");
285
+ await ensureDir(finalizationDir);
286
+ const finalizationState = {
287
+ iteration: 0,
288
+ status: "running",
289
+ available_work_items: input.selectedWorkItems,
290
+ recommended_work_item_queue: input.selectedWorkItems,
291
+ selected_work_item_queue: input.selectedWorkItems,
292
+ work_item_annotations: input.workItemAnnotations,
293
+ seen_work_item_keys: [...input.runState.seen_work_item_keys],
294
+ completed_work_item_keys: [...input.ledger.completed_work_item_keys],
295
+ last_completed_work_item: input.ledger.last_completed_work_item,
296
+ phase_results: {},
297
+ phase_visit_counts: {}
298
+ };
299
+ const finalizationStateFile = path.join(finalizationDir, "state.json");
300
+ await writeJson(finalizationStateFile, finalizationState);
301
+ let currentPhaseId = finalPhaseId;
302
+ let lastResult;
303
+ while (currentPhaseId) {
304
+ const phase = input.phasesById.get(currentPhaseId);
305
+ if (!phase) {
306
+ throw new Error(`Unknown final phase "${currentPhaseId}"`);
307
+ }
308
+ const phaseResult = await runWorkflowPhase({
309
+ projectRoot: input.projectRoot,
310
+ runDir: input.runDir,
311
+ iterationDir: finalizationDir,
312
+ iterationStateFile: finalizationStateFile,
313
+ config: input.config,
314
+ phase,
315
+ iterationState: finalizationState,
316
+ runState: input.runState,
317
+ iterationNumber: "final",
318
+ maxIterations: input.selectedWorkItems.length,
319
+ workdir: input.workdir,
320
+ git: input.gitContext
321
+ });
322
+ if (!phaseResult.ok) {
323
+ throw new Error(phaseResult.error);
324
+ }
325
+ if (phaseResult.result !== undefined) {
326
+ lastResult = phaseResult.result;
327
+ }
328
+ const nextTarget = resolveNextTarget(phase, phaseResult.outcome);
329
+ if (nextTarget === "stop_run") {
330
+ finalizationState.status = "stopped";
331
+ input.runState.status = "stopped";
332
+ input.runState.final_result = phaseResult.result ?? { status: "stopped" };
333
+ await writeJson(finalizationStateFile, finalizationState);
334
+ await writeJson(path.join(input.runDir, "state.json"), input.runState);
335
+ console.log("");
336
+ console.log(`Done: ${input.runState.status}`);
337
+ console.log(`Run dir: ${relativeToProject(input.projectRoot, input.runDir)}`);
338
+ return false;
339
+ }
340
+ if (nextTarget === "next_iteration" || nextTarget === "stop_iteration") {
341
+ throw new Error(`Final phase "${phase.id}" routed to iteration-only target "${nextTarget}"; finalization phases must end at a leaf phase (no next/transitions) or "stop_run"`);
342
+ }
343
+ currentPhaseId = nextTarget;
344
+ }
345
+ finalizationState.status = "completed";
346
+ input.runState.final_result = lastResult ?? { status: "ok" };
347
+ await writeJson(finalizationStateFile, finalizationState);
348
+ return true;
197
349
  }
198
350
  async function runSelectionPhase(input) {
199
351
  const selectionPhase = input.phasesById.get(input.config.workflow.entry_phase);
@@ -303,7 +455,9 @@ async function runWorkflowPhase(input) {
303
455
  stateFile: input.iterationStateFile,
304
456
  config: input.config,
305
457
  phase: input.phase,
306
- state: input.iterationState
458
+ state: input.iterationState,
459
+ workdir: input.workdir,
460
+ git: input.git
307
461
  });
308
462
  if (!phaseResult.ok) {
309
463
  console.log(pc.red("failed"));