@nyxa/nyx-agent 0.4.0 → 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.
- package/README.md +52 -9
- package/dist/cli.js +11 -16
- package/dist/commands/init.js +87 -466
- package/dist/commands/run.js +16 -3
- package/dist/config/loadConfig.js +16 -3
- package/dist/config/schema.js +27 -146
- package/dist/runtime/gitLifecycle.js +19 -57
- package/dist/runtime/harness.js +26 -0
- package/dist/runtime/paths.js +0 -12
- package/dist/runtime/prompts.js +103 -0
- package/dist/runtime/runPhase.js +85 -254
- package/dist/runtime/runPipeline.js +395 -0
- package/dist/runtime/schemas.js +52 -0
- package/dist/runtime/scm.js +76 -0
- package/dist/runtime/validateResult.js +1 -3
- package/dist/runtime/workItems.js +42 -118
- package/package.json +2 -5
- package/dist/runtime/buildPrompt.js +0 -54
- package/dist/runtime/effectiveConfig.js +0 -14
- package/dist/runtime/renderTemplate.js +0 -28
- package/dist/runtime/runWorkflow.js +0 -680
- package/dist/runtime/validateWorkItem.js +0 -212
- package/dist/runtime/workItemAnnotations.js +0 -39
- package/docs/nyxagent-v0-spec.md +0 -742
- package/templates/default/prompts/closure.md +0 -25
- package/templates/default/prompts/execution.md +0 -11
- package/templates/default/prompts/finalize.md +0 -7
- package/templates/default/prompts/global-review.md +0 -24
- package/templates/default/prompts/global-revision.md +0 -9
- package/templates/default/prompts/pull-request.md +0 -23
- package/templates/default/prompts/repair-result.md +0 -29
- package/templates/default/prompts/review.md +0 -18
- package/templates/default/prompts/revision.md +0 -7
- package/templates/default/prompts/selection.md +0 -46
- package/templates/default/schemas/closure.schema.json +0 -35
- package/templates/default/schemas/global-review.schema.json +0 -60
- package/templates/default/schemas/pull-request.schema.json +0 -44
- package/templates/default/schemas/review.schema.json +0 -60
- 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
|
|
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);
|