@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 +6 -2
- package/dist/cli.js +2 -0
- package/dist/commands/run.js +3 -1
- package/dist/runtime/reporter.js +65 -0
- package/dist/runtime/runPhase.js +76 -15
- package/dist/runtime/runPipeline.js +108 -68
- package/dist/runtime/selectionConfirmation.js +85 -0
- package/package.json +1 -1
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
|
});
|
package/dist/commands/run.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/runtime/runPhase.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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:
|
|
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 {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
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
|
-
|
|
43
|
-
|
|
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
|
-
|
|
63
|
+
reporter.info("No open work items available. Nothing to do.");
|
|
56
64
|
return;
|
|
57
65
|
}
|
|
58
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
90
|
+
reporter.info(`Selected ${planned.length} work item(s):`);
|
|
72
91
|
for (const item of planned) {
|
|
73
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ||
|
|
143
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
221
|
-
|
|
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
|
-
|
|
228
|
-
|
|
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
|
-
[
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|