@nyxa/nyx-agent 0.6.0 → 0.7.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.
package/README.md CHANGED
@@ -7,7 +7,8 @@ GitHub issue at a time, each phase with fresh context.
7
7
 
8
8
  For every run NyxAgent:
9
9
 
10
- 1. **Selects** open GitHub issues to work on (read-only).
10
+ 1. **Selects** open GitHub issues to work on (read-only), then asks the user to
11
+ confirm the proposed checklist.
11
12
  2. For each selected issue, in an isolated git **worktree**:
12
13
  - **implements** it (the agent — the only customizable prompt),
13
14
  - optionally **reviews** and **revises** it (bounded loop),
@@ -27,8 +28,10 @@ is editable.
27
28
 
28
29
  ```bash
29
30
  nyxagent init # create .nyxagent/config.json (interactive)
30
- nyxagent run # run the pipeline
31
+ nyxagent run # run the pipeline, confirming selected work items
32
+ nyxagent run --yes # accept the agent selection without prompting
31
33
  nyxagent run --harness claude # override the configured harness for one run
34
+ nyxagent run --verbose # stream agent output and runtime details
32
35
  nyxagent update # self-update to the latest published version
33
36
  ```
34
37
 
@@ -63,3 +66,4 @@ unresolved feedback, so the work is never stranded on an orphaned branch.
63
66
  - A git repository with a GitHub remote.
64
67
  - The `gh` CLI authenticated for the configured repository.
65
68
  - The selected harness CLI (`codex` or `claude`) on `PATH`.
69
+ - An interactive terminal for `nyxagent run`, unless `--yes` is used.
package/dist/cli.js CHANGED
@@ -33,6 +33,8 @@ program
33
33
  .description("Run the NyxAgent pipeline")
34
34
  .option("--config <path>", "config path (default: .nyxagent/config.json)")
35
35
  .option("--harness <name>", "override the configured harness: codex or claude")
36
+ .option("-y, --yes", "accept the agent-selected work items without prompting")
37
+ .option("--verbose", "stream agent output and NyxAgent runtime details")
36
38
  .action(async (options) => {
37
39
  await runCommand(options);
38
40
  });
@@ -8,7 +8,9 @@ export async function runCommand(options, projectRoot = process.cwd()) {
8
8
  configPath: options.config
9
9
  ? path.resolve(projectRoot, options.config)
10
10
  : undefined,
11
- harness: normalizeHarness(options.harness)
11
+ harness: normalizeHarness(options.harness),
12
+ autoAcceptSelection: options.yes ?? false,
13
+ verbose: options.verbose ?? false,
12
14
  });
13
15
  }
14
16
  function normalizeHarness(value) {
@@ -0,0 +1,65 @@
1
+ import path from "node:path";
2
+ import pc from "picocolors";
3
+ export function createRunReporter(options = {}) {
4
+ const verbose = options.verbose ?? false;
5
+ const writeStdout = options.writeStdout ?? ((line) => console.log(line));
6
+ const writeStderr = options.writeStderr ??
7
+ ((line) => {
8
+ process.stderr.write(`${line}\n`);
9
+ });
10
+ const stdout = (line) => writeStdout(line);
11
+ const stderr = (line) => writeStderr(line);
12
+ return {
13
+ verbose,
14
+ heading: (message) => stdout(pc.bold(message)),
15
+ info: (message) => stdout(message),
16
+ section: (message) => stdout(pc.cyan(message)),
17
+ success: (message) => stdout(pc.green(message)),
18
+ warn: (message) => stdout(pc.yellow(message)),
19
+ error: (message) => stdout(pc.red(message)),
20
+ detail: (message) => {
21
+ if (verbose) {
22
+ stderr(pc.dim(message));
23
+ }
24
+ },
25
+ phaseStarted: (event) => {
26
+ if (!verbose) {
27
+ return;
28
+ }
29
+ const attempt = attemptLabel(event.attemptDir);
30
+ const command = [event.invocation.command, ...event.invocation.args].join(" ");
31
+ stderr(pc.dim(`[${event.phaseId} ${attempt}] start ${command} (cwd ${event.workdir}, capability ${event.capability}, model ${event.model}, reasoning ${event.reasoning})`));
32
+ },
33
+ phaseFinished: (event) => {
34
+ if (!verbose) {
35
+ return;
36
+ }
37
+ stderr(pc.dim(`[${event.phaseId} ${attemptLabel(event.attemptDir)}] exit ${event.exitCode} in ${formatDuration(event.durationMs)}`));
38
+ },
39
+ phaseArtifact: (event) => {
40
+ if (!verbose) {
41
+ return;
42
+ }
43
+ const prefix = event.attemptDir
44
+ ? `${event.phaseId} ${attemptLabel(event.attemptDir)}`
45
+ : event.phaseId;
46
+ stderr(pc.dim(`[${prefix}] artifact ${event.filePath}`));
47
+ },
48
+ agentOutput: (event) => {
49
+ if (!verbose) {
50
+ return;
51
+ }
52
+ const outputType = event.stream ?? event.eventType;
53
+ stderr(`[${event.phaseId} ${attemptLabel(event.attemptDir)} ${outputType}] ${event.message}`);
54
+ },
55
+ };
56
+ }
57
+ function attemptLabel(attemptDirOrPath) {
58
+ return path.basename(attemptDirOrPath);
59
+ }
60
+ function formatDuration(durationMs) {
61
+ if (durationMs < 1000) {
62
+ return `${durationMs}ms`;
63
+ }
64
+ return `${(durationMs / 1000).toFixed(1)}s`;
65
+ }
@@ -15,12 +15,12 @@ export async function runAgentPhase(input) {
15
15
  const attempt = await invokeHarness({
16
16
  attemptDir: path.join(input.phaseDir, "attempt-001"),
17
17
  input,
18
- prompt: input.prompt
18
+ prompt: input.prompt,
19
19
  });
20
20
  if (attempt.exitCode !== 0) {
21
21
  return {
22
22
  ok: false,
23
- error: `Phase "${input.phaseId}" failed with exit code ${attempt.exitCode}`
23
+ error: `Phase "${input.phaseId}" failed with exit code ${attempt.exitCode}`,
24
24
  };
25
25
  }
26
26
  if (!input.schema) {
@@ -28,16 +28,26 @@ export async function runAgentPhase(input) {
28
28
  }
29
29
  const parsed = parseAndValidate(input.schema, attempt.stdout);
30
30
  if (parsed.ok) {
31
- await writeJson(path.join(input.phaseDir, "result.json"), parsed.result);
31
+ const resultPath = path.join(input.phaseDir, "result.json");
32
+ await writeJson(resultPath, parsed.result);
33
+ input.reporter?.phaseArtifact({
34
+ phaseId: input.phaseId,
35
+ filePath: resultPath,
36
+ });
32
37
  return parsed;
33
38
  }
34
39
  const repaired = await repairResult({
35
40
  input,
36
41
  originalStdout: attempt.stdout,
37
- validationError: parsed.error
42
+ validationError: parsed.error,
38
43
  });
39
44
  if (repaired.ok && repaired.result !== undefined) {
40
- await writeJson(path.join(input.phaseDir, "result.json"), repaired.result);
45
+ const resultPath = path.join(input.phaseDir, "result.json");
46
+ await writeJson(resultPath, repaired.result);
47
+ input.reporter?.phaseArtifact({
48
+ phaseId: input.phaseId,
49
+ filePath: resultPath,
50
+ });
41
51
  }
42
52
  return repaired;
43
53
  }
@@ -48,19 +58,53 @@ async function invokeHarness(args) {
48
58
  harness: args.input.harness,
49
59
  capability: args.forceReadonly ? "readonly" : args.input.capability,
50
60
  model: args.input.model,
51
- reasoning: args.input.reasoning
61
+ reasoning: args.input.reasoning,
52
62
  });
53
63
  const startedAt = new Date().toISOString();
54
64
  const started = Date.now();
55
65
  const gitBefore = await getGitSnapshot(args.input.workdir);
66
+ args.input.reporter?.phaseStarted({
67
+ phaseId: args.input.phaseId,
68
+ attemptDir: args.attemptDir,
69
+ workdir: args.input.workdir,
70
+ capability: args.forceReadonly ? "readonly" : args.input.capability,
71
+ model: args.input.model,
72
+ reasoning: args.input.reasoning,
73
+ invocation,
74
+ });
56
75
  let stdout = "";
57
76
  let stderr = "";
58
77
  let exitCode = 0;
59
78
  try {
79
+ const verbose = args.input.reporter?.verbose
80
+ ? {
81
+ stdout: (line, event) => {
82
+ args.input.reporter?.agentOutput({
83
+ phaseId: args.input.phaseId,
84
+ attemptDir: args.attemptDir,
85
+ eventType: event.type,
86
+ stream: event.type === "output" ? "stdout" : undefined,
87
+ message: line,
88
+ });
89
+ return "";
90
+ },
91
+ stderr: (line, event) => {
92
+ args.input.reporter?.agentOutput({
93
+ phaseId: args.input.phaseId,
94
+ attemptDir: args.attemptDir,
95
+ eventType: event.type,
96
+ stream: event.type === "output" ? "stderr" : undefined,
97
+ message: line,
98
+ });
99
+ return "";
100
+ },
101
+ }
102
+ : "none";
60
103
  const result = await execa(invocation.command, invocation.args, {
61
104
  cwd: args.input.workdir,
62
105
  input: args.prompt,
63
- reject: false
106
+ reject: false,
107
+ verbose,
64
108
  });
65
109
  stdout = result.stdout;
66
110
  stderr = result.stderr;
@@ -71,18 +115,35 @@ async function invokeHarness(args) {
71
115
  stderr = error instanceof Error ? error.message : String(error);
72
116
  }
73
117
  const gitAfter = await getGitSnapshot(args.input.workdir);
74
- await writeText(path.join(args.attemptDir, "stdout.log"), stdout);
75
- await writeText(path.join(args.attemptDir, "stderr.log"), stderr);
76
- await writeJson(path.join(args.attemptDir, "meta.json"), {
118
+ const durationMs = Date.now() - started;
119
+ const stdoutPath = path.join(args.attemptDir, "stdout.log");
120
+ const stderrPath = path.join(args.attemptDir, "stderr.log");
121
+ const metaPath = path.join(args.attemptDir, "meta.json");
122
+ await writeText(stdoutPath, stdout);
123
+ await writeText(stderrPath, stderr);
124
+ await writeJson(metaPath, {
77
125
  command: invocation.command,
78
126
  args: invocation.args,
79
127
  started_at: startedAt,
80
128
  ended_at: new Date().toISOString(),
81
- duration_ms: Date.now() - started,
129
+ duration_ms: durationMs,
82
130
  exit_code: exitCode,
83
131
  git_before: gitBefore,
84
- git_after: gitAfter
132
+ git_after: gitAfter,
133
+ });
134
+ args.input.reporter?.phaseFinished({
135
+ phaseId: args.input.phaseId,
136
+ attemptDir: args.attemptDir,
137
+ durationMs,
138
+ exitCode,
85
139
  });
140
+ for (const filePath of [stdoutPath, stderrPath, metaPath]) {
141
+ args.input.reporter?.phaseArtifact({
142
+ phaseId: args.input.phaseId,
143
+ attemptDir: args.attemptDir,
144
+ filePath,
145
+ });
146
+ }
86
147
  return { stdout, stderr, exitCode };
87
148
  }
88
149
  function parseAndValidate(schema, stdout) {
@@ -118,13 +179,13 @@ async function repairResult(args) {
118
179
  "",
119
180
  "Original prompt:",
120
181
  "",
121
- args.input.prompt
182
+ args.input.prompt,
122
183
  ].join("\n");
123
184
  const attempt = await invokeHarness({
124
185
  attemptDir: path.join(args.input.phaseDir, `repair-${String(attemptNumber).padStart(3, "0")}`),
125
186
  input: args.input,
126
187
  prompt: repairPrompt,
127
- forceReadonly: true
188
+ forceReadonly: true,
128
189
  });
129
190
  if (attempt.exitCode !== 0) {
130
191
  lastError = `Repair harness exited with code ${attempt.exitCode}`;
@@ -138,6 +199,6 @@ async function repairResult(args) {
138
199
  }
139
200
  return {
140
201
  ok: false,
141
- error: `Phase "${args.input.phaseId}" produced an invalid result: ${lastError}`
202
+ error: `Phase "${args.input.phaseId}" produced an invalid result: ${lastError}`,
142
203
  };
143
204
  }
@@ -1,16 +1,17 @@
1
1
  import path from "node:path";
2
- import pc from "picocolors";
3
2
  import { loadConfig } from "../config/loadConfig.js";
4
3
  import { ensureDir, pathExists, readText } from "./files.js";
5
- import { deleteBranch, removeRunWorktree, setUpRunWorktree } from "./gitLifecycle.js";
6
- import { markWorkItemCompleted, readWorkItemLedger, writeWorkItemLedger } from "./ledger.js";
4
+ import { deleteBranch, removeRunWorktree, setUpRunWorktree, } from "./gitLifecycle.js";
5
+ import { markWorkItemCompleted, readWorkItemLedger, writeWorkItemLedger, } from "./ledger.js";
7
6
  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";
7
+ import { buildContextBlock, buildPhasePrompt, EXECUTION_PROMPT, GLOBAL_REVIEW_PROMPT, GLOBAL_REVISION_PROMPT, REVIEW_PROMPT, REVISION_PROMPT, SELECTION_PROMPT, truncateForPrompt, } from "./prompts.js";
8
+ import { createRunReporter } from "./reporter.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 { confirmWorkItemSelection, } from "./selectionConfirmation.js";
12
13
  import { createRunId } from "./time.js";
13
- import { filterAvailable, listGitHubIssues, resolveSelectedQueue } from "./workItems.js";
14
+ import { filterAvailable, listGitHubIssues, resolveSelectedQueue, } from "./workItems.js";
14
15
  const MAX_CANDIDATES = 50;
15
16
  const EXCERPT_CHARS = 800;
16
17
  export function defaultPipelineDependencies() {
@@ -18,7 +19,8 @@ export function defaultPipelineDependencies() {
18
19
  listIssues: listGitHubIssues,
19
20
  runPhase: runAgentPhase,
20
21
  pushBranch,
21
- createPullRequest
22
+ createPullRequest,
23
+ confirmSelection: confirmWorkItemSelection,
22
24
  };
23
25
  }
24
26
  /**
@@ -39,46 +41,64 @@ export async function runPipeline(input = {}, deps = defaultPipelineDependencies
39
41
  const runId = createRunId();
40
42
  const runDir = path.join(nyxDir, "runs", runId);
41
43
  await ensureDir(runDir);
42
- console.log(pc.bold(`NyxAgent run ${runId}`));
43
- console.log(`Harness: ${harness} · model: ${config.model} · review: ${config.review}`);
44
+ const reporter = input.reporter ?? createRunReporter({ verbose: input.verbose ?? false });
45
+ reporter.heading(`NyxAgent run ${runId}`);
46
+ reporter.info(`Harness: ${harness} · model: ${config.model} · review: ${config.review}`);
47
+ reporter.detail(`Config: ${relativeToProject(projectRoot, configPath)}`);
48
+ reporter.detail(`Artifacts: ${relativeToProject(projectRoot, runDir)}`);
49
+ reporter.detail(`Tracker: ${config.tracker.repo}`);
44
50
  const ledger = await readWorkItemLedger(nyxDir);
51
+ reporter.detail(`Completed work items already in ledger: ${ledger.completed_work_item_keys.length}`);
45
52
  // 1. Selection runs read-only in the main checkout, before any branch exists.
46
53
  const candidates = filterAvailable({
47
54
  candidates: await deps.listIssues({
48
55
  repo: config.tracker.repo,
49
56
  maxCandidates: MAX_CANDIDATES,
50
- excerptChars: EXCERPT_CHARS
57
+ excerptChars: EXCERPT_CHARS,
51
58
  }),
52
- completedKeys: ledger.completed_work_item_keys
59
+ completedKeys: ledger.completed_work_item_keys,
53
60
  });
61
+ reporter.detail(`Available work item candidates: ${candidates.length}`);
54
62
  if (candidates.length === 0) {
55
- console.log("No open work items available. Nothing to do.");
63
+ reporter.info("No open work items available. Nothing to do.");
56
64
  return;
57
65
  }
58
- const selected = await runSelection({
66
+ const proposed = await runSelection({
59
67
  projectRoot,
60
68
  runDir,
61
69
  harness,
62
70
  config,
63
71
  candidates,
64
- runPhase: deps.runPhase
72
+ runPhase: deps.runPhase,
73
+ reporter,
74
+ });
75
+ if (proposed.length === 0) {
76
+ reporter.info("Selection chose no work items. Nothing to do.");
77
+ return;
78
+ }
79
+ const selected = await deps.confirmSelection({
80
+ candidates,
81
+ proposed,
82
+ maxItems: config.max_iterations,
83
+ autoAccept: input.autoAcceptSelection ?? false,
65
84
  });
66
85
  if (selected.length === 0) {
67
- console.log("Selection chose no work items. Nothing to do.");
86
+ reporter.info("No work items selected. Nothing to do.");
68
87
  return;
69
88
  }
70
89
  const planned = selected.slice(0, config.max_iterations);
71
- console.log(`Selected ${planned.length} work item(s):`);
90
+ reporter.info(`Selected ${planned.length} work item(s):`);
72
91
  for (const item of planned) {
73
- console.log(` - ${item.title} (#${item.number})`);
92
+ reporter.info(` - ${item.title} (#${item.number})`);
74
93
  }
75
94
  // 2. One branch + worktree per run (created only now that there is work).
76
95
  const git = await setUpRunWorktree({
77
96
  projectRoot,
78
97
  runId,
79
- base: config.base_branch
98
+ base: config.base_branch,
80
99
  });
81
- console.log(`Branch ${git.branch} (base ${git.base})`);
100
+ reporter.info(`Branch ${git.branch} (base ${git.base})`);
101
+ reporter.detail(`Worktree: ${relativeToProject(projectRoot, git.worktree)}`);
82
102
  let success = false;
83
103
  let producedCommits = false;
84
104
  let currentLedger = ledger;
@@ -88,7 +108,7 @@ export async function runPipeline(input = {}, deps = defaultPipelineDependencies
88
108
  for (const [index, item] of planned.entries()) {
89
109
  const iterationNumber = index + 1;
90
110
  const iterationDir = path.join(runDir, "iterations", String(iterationNumber).padStart(3, "0"));
91
- console.log(pc.cyan(`\n[${iterationNumber}/${planned.length}] ${item.title} (#${item.number})`));
111
+ reporter.section(`\n[${iterationNumber}/${planned.length}] ${item.title} (#${item.number})`);
92
112
  await runExecution({
93
113
  iterationDir,
94
114
  item,
@@ -96,7 +116,8 @@ export async function runPipeline(input = {}, deps = defaultPipelineDependencies
96
116
  git,
97
117
  harness,
98
118
  config,
99
- runPhase: deps.runPhase
119
+ runPhase: deps.runPhase,
120
+ reporter,
100
121
  });
101
122
  if (config.review === "each" || config.review === "both") {
102
123
  await runReviewLoop({
@@ -106,22 +127,24 @@ export async function runPipeline(input = {}, deps = defaultPipelineDependencies
106
127
  harness,
107
128
  config,
108
129
  maxAttempts: config.review_max_attempts,
109
- runPhase: deps.runPhase
130
+ runPhase: deps.runPhase,
131
+ reporter,
110
132
  });
111
133
  }
112
134
  const { committed } = await commitAll({
113
135
  cwd: git.worktree,
114
- message: buildCommitMessage(item)
136
+ message: buildCommitMessage(item),
115
137
  });
116
138
  if (committed) {
117
139
  producedCommits = true;
140
+ reporter.detail(`Committed work item #${item.number}.`);
118
141
  }
119
142
  else {
120
- console.log(pc.yellow(" No changes to commit for this item."));
143
+ reporter.warn(" No changes to commit for this item.");
121
144
  }
122
145
  currentLedger = markWorkItemCompleted({
123
146
  ledger: currentLedger,
124
- workItem: item
147
+ workItem: item,
125
148
  });
126
149
  await writeWorkItemLedger(nyxDir, currentLedger);
127
150
  completed.push(item);
@@ -133,17 +156,20 @@ export async function runPipeline(input = {}, deps = defaultPipelineDependencies
133
156
  harness,
134
157
  config,
135
158
  maxAttempts: config.review_max_attempts,
136
- runPhase: deps.runPhase
159
+ runPhase: deps.runPhase,
160
+ reporter,
137
161
  });
138
162
  if (corrections) {
139
163
  producedCommits = true;
140
164
  }
141
165
  }
142
- if (!producedCommits || (await commitsAhead(git.worktree, git.base)) === 0) {
143
- console.log("\nRun produced no commits; skipping pull request.");
166
+ if (!producedCommits ||
167
+ (await commitsAhead(git.worktree, git.base)) === 0) {
168
+ reporter.info("\nRun produced no commits; skipping pull request.");
144
169
  success = true;
145
170
  return;
146
171
  }
172
+ reporter.detail(`Pushing branch ${git.branch}.`);
147
173
  await deps.pushBranch({ cwd: git.worktree, branch: git.branch });
148
174
  const prUrl = await deps.createPullRequest({
149
175
  cwd: git.worktree,
@@ -151,9 +177,9 @@ export async function runPipeline(input = {}, deps = defaultPipelineDependencies
151
177
  base: git.base,
152
178
  head: git.branch,
153
179
  title: buildPrTitle(completed),
154
- body: buildPrBody(completed)
180
+ body: buildPrBody(completed),
155
181
  });
156
- console.log(pc.green(`\nPull request opened: ${prUrl}`));
182
+ reporter.success(`\nPull request opened: ${prUrl}`);
157
183
  success = true;
158
184
  }
159
185
  catch (error) {
@@ -167,7 +193,8 @@ export async function runPipeline(input = {}, deps = defaultPipelineDependencies
167
193
  producedCommits,
168
194
  completed,
169
195
  config,
170
- deps
196
+ deps,
197
+ reporter,
171
198
  });
172
199
  throw error;
173
200
  }
@@ -199,14 +226,15 @@ async function salvageFailedRun(input) {
199
226
  }
200
227
  }
201
228
  if (ahead === 0) {
202
- console.log(pc.red(`\nRun failed. Branch ${input.git.branch} and worktree kept for debugging: ${location}`));
229
+ input.reporter.error(`\nRun failed. Branch ${input.git.branch} and worktree kept for debugging: ${location}`);
203
230
  return;
204
231
  }
205
232
  const reason = input.error instanceof Error ? input.error.message : String(input.error);
206
233
  try {
234
+ input.reporter.detail(`Pushing failed run branch ${input.git.branch}.`);
207
235
  await input.deps.pushBranch({
208
236
  cwd: input.git.worktree,
209
- branch: input.git.branch
237
+ branch: input.git.branch,
210
238
  });
211
239
  const url = await input.deps.createPullRequest({
212
240
  cwd: input.git.worktree,
@@ -215,17 +243,17 @@ async function salvageFailedRun(input) {
215
243
  head: input.git.branch,
216
244
  title: buildDraftPrTitle(input.completed),
217
245
  body: buildDraftPrBody(input.completed, reason),
218
- draft: true
246
+ draft: true,
219
247
  });
220
- console.log(pc.yellow(`\nRun failed, but the work was salvaged into a DRAFT pull request: ${url}`));
221
- console.log(pc.yellow(`Branch ${input.git.branch} and worktree kept: ${location}`));
248
+ input.reporter.warn(`\nRun failed, but the work was salvaged into a DRAFT pull request: ${url}`);
249
+ input.reporter.warn(`Branch ${input.git.branch} and worktree kept: ${location}`);
222
250
  }
223
251
  catch (salvageError) {
224
252
  const detail = salvageError instanceof Error
225
253
  ? salvageError.message
226
254
  : String(salvageError);
227
- console.log(pc.red(`\nRun failed, and salvaging the work into a draft pull request also failed: ${detail}`));
228
- console.log(pc.red(`Branch ${input.git.branch} and worktree kept for debugging: ${location}`));
255
+ input.reporter.error(`\nRun failed, and salvaging the work into a draft pull request also failed: ${detail}`);
256
+ input.reporter.error(`Branch ${input.git.branch} and worktree kept for debugging: ${location}`);
229
257
  }
230
258
  }
231
259
  async function runSelection(input) {
@@ -239,9 +267,9 @@ async function runSelection(input) {
239
267
  number: candidate.number,
240
268
  title: candidate.title,
241
269
  labels: candidate.labels,
242
- excerpt: candidate.excerpt
243
- }))
244
- ]
270
+ excerpt: candidate.excerpt,
271
+ })),
272
+ ],
245
273
  ]);
246
274
  const result = await input.runPhase({
247
275
  phaseId: "selection",
@@ -254,9 +282,10 @@ async function runSelection(input) {
254
282
  prompt: buildPhasePrompt({
255
283
  guidance: SELECTION_PROMPT,
256
284
  context,
257
- schema: SELECTION_SCHEMA
285
+ schema: SELECTION_SCHEMA,
258
286
  }),
259
- schema: SELECTION_SCHEMA
287
+ schema: SELECTION_SCHEMA,
288
+ reporter: input.reporter,
260
289
  });
261
290
  if (!result.ok) {
262
291
  throw new Error(result.error);
@@ -267,7 +296,7 @@ async function runSelection(input) {
267
296
  }
268
297
  const resolved = resolveSelectedQueue({
269
298
  keys: value.work_item_keys,
270
- candidates: input.candidates
299
+ candidates: input.candidates,
271
300
  });
272
301
  if (!resolved.ok) {
273
302
  throw new Error(`Selection produced an invalid queue: ${resolved.error}`);
@@ -279,7 +308,7 @@ async function runExecution(input) {
279
308
  ["Work item", workItemSummary(input.item)],
280
309
  ["Issue description", input.item.excerpt ?? "(no description provided)"],
281
310
  ["Working directory", input.git.worktree],
282
- ["Branch", `${input.git.branch} (base ${input.git.base})`]
311
+ ["Branch", `${input.git.branch} (base ${input.git.base})`],
283
312
  ]);
284
313
  const result = await input.runPhase({
285
314
  phaseId: "execution",
@@ -289,7 +318,8 @@ async function runExecution(input) {
289
318
  model: input.config.model,
290
319
  reasoning: input.config.reasoning_effort,
291
320
  capability: "write",
292
- prompt: buildPhasePrompt({ guidance: input.guidance, context })
321
+ prompt: buildPhasePrompt({ guidance: input.guidance, context }),
322
+ reporter: input.reporter,
293
323
  });
294
324
  if (!result.ok) {
295
325
  throw new Error(result.error);
@@ -310,17 +340,21 @@ async function runReviewLoop(input) {
310
340
  guidance: REVIEW_PROMPT,
311
341
  context: buildContextBlock([
312
342
  ["Work item", workItemSummary(input.item)],
313
- ["Uncommitted changes (diff)", truncateForPrompt(diff || "(no changes)")]
343
+ [
344
+ "Uncommitted changes (diff)",
345
+ truncateForPrompt(diff || "(no changes)"),
346
+ ],
314
347
  ]),
315
- schema: REVIEW_SCHEMA
348
+ schema: REVIEW_SCHEMA,
316
349
  }),
317
- schema: REVIEW_SCHEMA
350
+ schema: REVIEW_SCHEMA,
351
+ reporter: input.reporter,
318
352
  });
319
353
  if (!reviewResult.ok) {
320
354
  throw new Error(reviewResult.error);
321
355
  }
322
356
  const review = reviewResult.result;
323
- console.log(` review: ${review.outcome}`);
357
+ input.reporter.info(` review: ${review.outcome}`);
324
358
  if (review.outcome === "approved") {
325
359
  return;
326
360
  }
@@ -339,9 +373,10 @@ async function runReviewLoop(input) {
339
373
  guidance: REVISION_PROMPT,
340
374
  context: buildContextBlock([
341
375
  ["Work item", workItemSummary(input.item)],
342
- ["Required changes", review.required_changes ?? []]
343
- ])
344
- })
376
+ ["Required changes", review.required_changes ?? []],
377
+ ]),
378
+ }),
379
+ reporter: input.reporter,
345
380
  });
346
381
  if (!revision.ok) {
347
382
  throw new Error(revision.error);
@@ -366,18 +401,19 @@ async function runGlobalReviewLoop(input) {
366
401
  ["Run branch", `${input.git.branch} (base ${input.git.base})`],
367
402
  [
368
403
  "Combined run diff (base...HEAD)",
369
- truncateForPrompt(diff || "(no changes)")
370
- ]
404
+ truncateForPrompt(diff || "(no changes)"),
405
+ ],
371
406
  ]),
372
- schema: GLOBAL_REVIEW_SCHEMA
407
+ schema: GLOBAL_REVIEW_SCHEMA,
373
408
  }),
374
- schema: GLOBAL_REVIEW_SCHEMA
409
+ schema: GLOBAL_REVIEW_SCHEMA,
410
+ reporter: input.reporter,
375
411
  });
376
412
  if (!reviewResult.ok) {
377
413
  throw new Error(reviewResult.error);
378
414
  }
379
415
  const review = reviewResult.result;
380
- console.log(`global review: ${review.outcome}`);
416
+ input.reporter.info(`global review: ${review.outcome}`);
381
417
  if (review.outcome === "approved") {
382
418
  return committedCorrections;
383
419
  }
@@ -395,19 +431,21 @@ async function runGlobalReviewLoop(input) {
395
431
  prompt: buildPhasePrompt({
396
432
  guidance: GLOBAL_REVISION_PROMPT,
397
433
  context: buildContextBlock([
398
- ["Required changes", review.required_changes ?? []]
399
- ])
400
- })
434
+ ["Required changes", review.required_changes ?? []],
435
+ ]),
436
+ }),
437
+ reporter: input.reporter,
401
438
  });
402
439
  if (!revision.ok) {
403
440
  throw new Error(revision.error);
404
441
  }
405
442
  const { committed } = await commitAll({
406
443
  cwd: input.git.worktree,
407
- message: "Apply global review corrections"
444
+ message: "Apply global review corrections",
408
445
  });
409
446
  if (committed) {
410
447
  committedCorrections = true;
448
+ input.reporter.detail("Committed global review corrections.");
411
449
  }
412
450
  }
413
451
  return committedCorrections;
@@ -429,7 +467,7 @@ function workItemSummary(item) {
429
467
  title: item.title,
430
468
  locator: item.source.locator,
431
469
  url: item.url,
432
- labels: item.labels
470
+ labels: item.labels,
433
471
  };
434
472
  }
435
473
  function buildCommitMessage(item) {
@@ -442,7 +480,9 @@ function buildPrTitle(items) {
442
480
  return `NyxAgent: ${items.length} work items`;
443
481
  }
444
482
  function buildPrBody(items) {
445
- const list = items.map((item) => `- ${item.title} (#${item.number})`).join("\n");
483
+ const list = items
484
+ .map((item) => `- ${item.title} (#${item.number})`)
485
+ .join("\n");
446
486
  const closes = items.map((item) => `Closes #${item.number}`).join("\n");
447
487
  return [
448
488
  "Automated changes by NyxAgent.",
@@ -451,7 +491,7 @@ function buildPrBody(items) {
451
491
  "",
452
492
  list,
453
493
  "",
454
- closes
494
+ closes,
455
495
  ].join("\n");
456
496
  }
457
497
  function buildDraftPrTitle(items) {
@@ -465,7 +505,7 @@ function buildDraftPrBody(items, reason) {
465
505
  "",
466
506
  `**Why the run failed:** ${reason}`,
467
507
  "",
468
- buildPrBody(items)
508
+ buildPrBody(items),
469
509
  ].join("\n");
470
510
  }
471
511
  /** Render review `required_changes` as a bullet list to append to a failure message. */
@@ -0,0 +1,85 @@
1
+ import { checkbox, Separator } from "@inquirer/prompts";
2
+ export async function confirmWorkItemSelection(input) {
3
+ if (input.autoAccept) {
4
+ return input.proposed;
5
+ }
6
+ const isInteractive = input.isInteractive ?? Boolean(process.stdin.isTTY && process.stdout.isTTY);
7
+ if (!isInteractive) {
8
+ throw new Error('Interactive work item selection requires a TTY. Re-run with "nyxagent run --yes" to accept the agent selection.');
9
+ }
10
+ const selectedKeys = await checkbox({
11
+ message: `Select work items to run (max ${input.maxItems})`,
12
+ choices: buildSelectionChoiceItems(input).map(toInquirerChoice),
13
+ pageSize: Math.min(Math.max(input.candidates.length, 7), 20),
14
+ required: false,
15
+ validate: (selected) => selected.length <= input.maxItems ||
16
+ `Select at most ${input.maxItems} work item(s).`,
17
+ shortcuts: {
18
+ all: null,
19
+ invert: null
20
+ }
21
+ });
22
+ const selected = new Set(selectedKeys);
23
+ return input.candidates.filter((candidate) => selected.has(candidate.key));
24
+ }
25
+ export function buildSelectionChoiceItems(input) {
26
+ const proposedKeys = new Set(input.proposed.map((item) => item.key));
27
+ const items = [];
28
+ let currentGroup;
29
+ for (const candidate of input.candidates) {
30
+ const group = detectPlanGroup(candidate);
31
+ if (group && group !== currentGroup) {
32
+ items.push({ type: "separator", label: group });
33
+ }
34
+ currentGroup = group;
35
+ const proposed = proposedKeys.has(candidate.key);
36
+ items.push({
37
+ type: "choice",
38
+ value: candidate.key,
39
+ name: `#${candidate.number} ${candidate.title}${proposed ? " (agent)" : ""}`,
40
+ checked: proposed
41
+ });
42
+ }
43
+ return items;
44
+ }
45
+ function toInquirerChoice(item) {
46
+ if (item.type === "separator") {
47
+ return new Separator(item.label);
48
+ }
49
+ return {
50
+ value: item.value,
51
+ name: item.name,
52
+ checked: item.checked
53
+ };
54
+ }
55
+ function detectPlanGroup(candidate) {
56
+ for (const label of candidate.labels ?? []) {
57
+ const group = parseGroupLabel(label);
58
+ if (group) {
59
+ return group;
60
+ }
61
+ }
62
+ return parseBracketedTitleGroup(candidate.title);
63
+ }
64
+ function parseGroupLabel(label) {
65
+ const match = /^(plan|prd)\s*[:/=-]\s*(.+)$/i.exec(label.trim());
66
+ if (!match) {
67
+ return undefined;
68
+ }
69
+ return formatGroupLabel(match[1], match[2]);
70
+ }
71
+ function parseBracketedTitleGroup(title) {
72
+ const match = /^\[(plan|prd)\s*[:/=-]\s*([^\]]+)\]/i.exec(title.trim());
73
+ if (!match) {
74
+ return undefined;
75
+ }
76
+ return formatGroupLabel(match[1], match[2]);
77
+ }
78
+ function formatGroupLabel(kind, rawName) {
79
+ const name = rawName.trim();
80
+ if (!name) {
81
+ return undefined;
82
+ }
83
+ const prefix = kind.toLowerCase() === "prd" ? "PRD" : "Plan";
84
+ return `${prefix}: ${name}`;
85
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nyxa/nyx-agent",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "A lightweight phase orchestrator for repeatedly launching coding agents with fresh context.",
5
5
  "type": "module",
6
6
  "repository": {