@nyxa/nyx-agent 0.3.0 → 0.3.1
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/dist/runtime/buildPrompt.js +5 -0
- package/dist/runtime/runPhase.js +15 -1
- package/dist/runtime/runWorkflow.js +333 -49
- package/dist/runtime/validateWorkItem.js +56 -0
- package/docs/nyxagent-v0-spec.md +39 -22
- package/package.json +1 -1
- package/templates/default/prompts/selection.md +19 -11
- package/templates/default/schemas/selection.schema.json +51 -7
|
@@ -41,6 +41,11 @@ async function buildRuntimeContract(input) {
|
|
|
41
41
|
JSON.stringify(input.context.available_work_items ?? [], null, 2),
|
|
42
42
|
"```",
|
|
43
43
|
"",
|
|
44
|
+
"Selected work item queue:",
|
|
45
|
+
"```json",
|
|
46
|
+
JSON.stringify(input.context.selected_work_item_queue ?? [], null, 2),
|
|
47
|
+
"```",
|
|
48
|
+
"",
|
|
44
49
|
"Seen work item keys:",
|
|
45
50
|
"```json",
|
|
46
51
|
JSON.stringify(input.context.seen_work_item_keys ?? [], null, 2),
|
package/dist/runtime/runPhase.js
CHANGED
|
@@ -8,7 +8,7 @@ import { parseNyxAgentResult, getOutcome } from "./parseResult.js";
|
|
|
8
8
|
import { resolveNyxPath } from "./paths.js";
|
|
9
9
|
import { renderTemplate } from "./renderTemplate.js";
|
|
10
10
|
import { validateAgainstSchema } from "./validateResult.js";
|
|
11
|
-
import { validateWorkItemIdentity } from "./validateWorkItem.js";
|
|
11
|
+
import { validateWorkItemIdentity, validateWorkItemQueue } from "./validateWorkItem.js";
|
|
12
12
|
export async function runPhase(input) {
|
|
13
13
|
const phaseDir = path.join(input.iterationDir, "phases", input.phase.id);
|
|
14
14
|
await ensureDir(phaseDir);
|
|
@@ -83,6 +83,7 @@ function buildContext(input) {
|
|
|
83
83
|
workflow: input.config.workflow,
|
|
84
84
|
work_items: input.config.work_items ?? {},
|
|
85
85
|
available_work_items: input.state.available_work_items ?? [],
|
|
86
|
+
selected_work_item_queue: input.state.selected_work_item_queue ?? [],
|
|
86
87
|
work_item: input.state.work_item ?? {},
|
|
87
88
|
seen_work_item_keys: input.state.seen_work_item_keys ?? [],
|
|
88
89
|
completed_work_item_keys: input.state.completed_work_item_keys ?? [],
|
|
@@ -190,6 +191,19 @@ async function parseAndValidatePhaseResult(input) {
|
|
|
190
191
|
return workItemValidation;
|
|
191
192
|
}
|
|
192
193
|
}
|
|
194
|
+
const workItems = readObjectProperty(parsed.value, "work_items");
|
|
195
|
+
if (workItems !== undefined) {
|
|
196
|
+
const workItemQueueValidation = validateWorkItemQueue({
|
|
197
|
+
config: input.input.config,
|
|
198
|
+
workItems,
|
|
199
|
+
availableWorkItems: readWorkItemCandidates(input.input.state.available_work_items),
|
|
200
|
+
seenWorkItemKeys: readStringArray(input.input.state.seen_work_item_keys),
|
|
201
|
+
completedWorkItemKeys: readStringArray(input.input.state.completed_work_item_keys)
|
|
202
|
+
});
|
|
203
|
+
if (!workItemQueueValidation.ok) {
|
|
204
|
+
return workItemQueueValidation;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
193
207
|
await writeJson(path.join(input.phaseDir, "result.json"), parsed.value);
|
|
194
208
|
return {
|
|
195
209
|
ok: true,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
|
+
import { checkbox, Separator } from "@inquirer/prompts";
|
|
2
3
|
import pc from "picocolors";
|
|
3
4
|
import { loadConfig } from "../config/loadConfig.js";
|
|
4
5
|
import { ensureDir, writeJson } from "./files.js";
|
|
@@ -7,6 +8,7 @@ import { markWorkItemCompleted, readWorkItemLedger, writeWorkItemLedger } from "
|
|
|
7
8
|
import { getNyxDir, relativeToProject } from "./paths.js";
|
|
8
9
|
import { runPhase } from "./runPhase.js";
|
|
9
10
|
import { createRunId } from "./time.js";
|
|
11
|
+
import { validateWorkItemQueue } from "./validateWorkItem.js";
|
|
10
12
|
import { filterAvailableWorkItems, listWorkItemCandidates } from "./workItems.js";
|
|
11
13
|
export async function runWorkflow(options) {
|
|
12
14
|
const projectRoot = path.resolve(options.projectRoot);
|
|
@@ -24,6 +26,10 @@ export async function runWorkflow(options) {
|
|
|
24
26
|
status: "running",
|
|
25
27
|
current_iteration: 0,
|
|
26
28
|
completed_iterations: 0,
|
|
29
|
+
available_work_items: [],
|
|
30
|
+
selected_work_item_queue: [],
|
|
31
|
+
skipped_work_item_keys: [],
|
|
32
|
+
selection_groups: [],
|
|
27
33
|
seen_work_item_keys: [],
|
|
28
34
|
completed_work_item_keys: [...ledger.completed_work_item_keys],
|
|
29
35
|
last_completed_work_item: ledger.last_completed_work_item
|
|
@@ -46,21 +52,68 @@ export async function runWorkflow(options) {
|
|
|
46
52
|
console.log(`Model: ${config.model.name}`);
|
|
47
53
|
console.log("");
|
|
48
54
|
const phasesById = new Map(config.phases.map((phase) => [phase.id, phase]));
|
|
49
|
-
|
|
55
|
+
const selection = await runSelectionPhase({
|
|
56
|
+
projectRoot,
|
|
57
|
+
runDir,
|
|
58
|
+
config,
|
|
59
|
+
phasesById,
|
|
60
|
+
runState,
|
|
61
|
+
ledger
|
|
62
|
+
});
|
|
63
|
+
if (selection.status === "stopped") {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
runState.available_work_items = selection.availableWorkItems;
|
|
67
|
+
runState.selection_groups = selection.selectionGroups;
|
|
68
|
+
await writeJson(path.join(runDir, "state.json"), runState);
|
|
69
|
+
let confirmedWorkItems;
|
|
70
|
+
try {
|
|
71
|
+
confirmedWorkItems = normalizeConfirmedWorkItems({
|
|
72
|
+
queue: selection.workItems,
|
|
73
|
+
confirmed: await (options.confirmWorkItems ?? confirmWorkItemsWithCheckbox)({
|
|
74
|
+
workItems: selection.workItems,
|
|
75
|
+
selectionGroups: selection.selectionGroups,
|
|
76
|
+
maxIterations: config.workflow.max_iterations
|
|
77
|
+
})
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
runState.status = "failed";
|
|
82
|
+
await writeJson(path.join(runDir, "state.json"), runState);
|
|
83
|
+
throw error;
|
|
84
|
+
}
|
|
85
|
+
const selectedWorkItems = confirmedWorkItems.slice(0, config.workflow.max_iterations);
|
|
86
|
+
const selectedKeys = new Set(selectedWorkItems.map((item) => item.key));
|
|
87
|
+
runState.selected_work_item_queue = selectedWorkItems;
|
|
88
|
+
runState.skipped_work_item_keys = selection.workItems
|
|
89
|
+
.filter((item) => !selectedKeys.has(item.key))
|
|
90
|
+
.map((item) => item.key);
|
|
91
|
+
runState.seen_work_item_keys = selectedWorkItems.map((item) => item.key);
|
|
92
|
+
await writeJson(path.join(runDir, "state.json"), runState);
|
|
93
|
+
if (confirmedWorkItems.length > config.workflow.max_iterations) {
|
|
94
|
+
console.log(pc.yellow(`Only the first ${config.workflow.max_iterations} selected work items will run.`));
|
|
95
|
+
}
|
|
96
|
+
if (selectedWorkItems.length === 0) {
|
|
97
|
+
runState.status = "completed_no_selected_work";
|
|
98
|
+
await writeJson(path.join(runDir, "state.json"), runState);
|
|
99
|
+
console.log("");
|
|
100
|
+
console.log(`Done: ${runState.status}`);
|
|
101
|
+
console.log(`Run dir: ${relativeToProject(projectRoot, runDir)}`);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
for (let queueIndex = 0; queueIndex < selectedWorkItems.length; queueIndex += 1) {
|
|
105
|
+
const iterationNumber = queueIndex + 1;
|
|
106
|
+
const workItem = selectedWorkItems[queueIndex];
|
|
50
107
|
runState.current_iteration = iterationNumber;
|
|
51
108
|
await writeJson(path.join(runDir, "state.json"), runState);
|
|
52
109
|
const iterationDir = path.join(runDir, "iterations", String(iterationNumber).padStart(3, "0"));
|
|
53
110
|
await ensureDir(iterationDir);
|
|
54
|
-
const availableWorkItems = await loadAvailableWorkItems({
|
|
55
|
-
projectRoot,
|
|
56
|
-
config,
|
|
57
|
-
runState,
|
|
58
|
-
ledger
|
|
59
|
-
});
|
|
60
111
|
const iterationState = {
|
|
61
112
|
iteration: iterationNumber,
|
|
62
113
|
status: "running",
|
|
63
|
-
|
|
114
|
+
work_item: workItem,
|
|
115
|
+
available_work_items: selectedWorkItems,
|
|
116
|
+
selected_work_item_queue: selectedWorkItems,
|
|
64
117
|
seen_work_item_keys: [...runState.seen_work_item_keys],
|
|
65
118
|
completed_work_item_keys: [...ledger.completed_work_item_keys],
|
|
66
119
|
last_completed_work_item: ledger.last_completed_work_item,
|
|
@@ -69,62 +122,31 @@ export async function runWorkflow(options) {
|
|
|
69
122
|
};
|
|
70
123
|
const iterationStateFile = path.join(iterationDir, "state.json");
|
|
71
124
|
await writeJson(iterationStateFile, iterationState);
|
|
72
|
-
let currentPhaseId =
|
|
125
|
+
let currentPhaseId = selection.nextPhaseId;
|
|
73
126
|
while (currentPhaseId) {
|
|
74
127
|
const phase = phasesById.get(currentPhaseId);
|
|
75
128
|
if (!phase) {
|
|
76
129
|
throw new Error(`Unknown phase "${currentPhaseId}"`);
|
|
77
130
|
}
|
|
78
|
-
const
|
|
79
|
-
iterationState.phase_visit_counts[phase.id] = visits;
|
|
80
|
-
if (visits > phase.max_visits_per_iteration) {
|
|
81
|
-
iterationState.status = "failed";
|
|
82
|
-
runState.status = "failed";
|
|
83
|
-
await writeJson(iterationStateFile, iterationState);
|
|
84
|
-
await writeJson(path.join(runDir, "state.json"), runState);
|
|
85
|
-
throw new Error(`Phase "${phase.id}" exceeded max_visits_per_iteration=${phase.max_visits_per_iteration}`);
|
|
86
|
-
}
|
|
87
|
-
process.stdout.write(`[${iterationNumber}/${config.workflow.max_iterations}] ${phase.id} ... `);
|
|
88
|
-
await writeJson(iterationStateFile, iterationState);
|
|
89
|
-
const phaseResult = await runPhase({
|
|
131
|
+
const phaseResult = await runWorkflowPhase({
|
|
90
132
|
projectRoot,
|
|
91
133
|
runDir,
|
|
92
134
|
iterationDir,
|
|
93
|
-
|
|
135
|
+
iterationStateFile,
|
|
94
136
|
config,
|
|
95
137
|
phase,
|
|
96
|
-
|
|
138
|
+
iterationState,
|
|
139
|
+
runState,
|
|
140
|
+
iterationNumber,
|
|
141
|
+
maxIterations: selectedWorkItems.length
|
|
97
142
|
});
|
|
98
143
|
if (!phaseResult.ok) {
|
|
99
|
-
console.log(pc.red("failed"));
|
|
100
|
-
iterationState.status = "failed";
|
|
101
|
-
iterationState.phase_results[phase.id] = {
|
|
102
|
-
status: "failed",
|
|
103
|
-
error: phaseResult.error
|
|
104
|
-
};
|
|
105
|
-
runState.status = "failed";
|
|
106
|
-
await writeJson(iterationStateFile, iterationState);
|
|
107
|
-
await writeJson(path.join(runDir, "state.json"), runState);
|
|
108
144
|
throw new Error(phaseResult.error);
|
|
109
145
|
}
|
|
110
|
-
iterationState.phase_results[phase.id] =
|
|
111
|
-
phaseResult.result ?? { status: "ok" };
|
|
112
|
-
const workItem = readObjectProperty(phaseResult.result, "work_item");
|
|
113
|
-
if (workItem !== undefined) {
|
|
114
|
-
iterationState.work_item = workItem;
|
|
115
|
-
const key = readObjectProperty(workItem, "key");
|
|
116
|
-
if (typeof key === "string" && !runState.seen_work_item_keys.includes(key)) {
|
|
117
|
-
runState.seen_work_item_keys.push(key);
|
|
118
|
-
iterationState.seen_work_item_keys = [...runState.seen_work_item_keys];
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
146
|
const nextTarget = resolveNextTarget(phase, phaseResult.outcome);
|
|
122
|
-
const label = phaseResult.outcome ?? "ok";
|
|
123
|
-
console.log(pc.green(label));
|
|
124
|
-
await writeJson(iterationStateFile, iterationState);
|
|
125
|
-
await writeJson(path.join(runDir, "state.json"), runState);
|
|
126
147
|
if (nextTarget === "stop_run") {
|
|
127
|
-
iterationState.status =
|
|
148
|
+
iterationState.status =
|
|
149
|
+
phaseResult.outcome === "no_work" ? "no_work" : "stopped";
|
|
128
150
|
runState.status =
|
|
129
151
|
phaseResult.outcome === "no_work" ? "completed_no_work" : "stopped";
|
|
130
152
|
await writeJson(iterationStateFile, iterationState);
|
|
@@ -154,12 +176,203 @@ export async function runWorkflow(options) {
|
|
|
154
176
|
currentPhaseId = nextTarget;
|
|
155
177
|
}
|
|
156
178
|
}
|
|
157
|
-
runState.status =
|
|
179
|
+
runState.status =
|
|
180
|
+
selectedWorkItems.length >= config.workflow.max_iterations
|
|
181
|
+
? "completed_max_iterations"
|
|
182
|
+
: "completed_queue";
|
|
158
183
|
await writeJson(path.join(runDir, "state.json"), runState);
|
|
159
184
|
console.log("");
|
|
160
185
|
console.log(`Done: ${runState.status}`);
|
|
161
186
|
console.log(`Run dir: ${relativeToProject(projectRoot, runDir)}`);
|
|
162
187
|
}
|
|
188
|
+
async function runSelectionPhase(input) {
|
|
189
|
+
const selectionPhase = input.phasesById.get(input.config.workflow.entry_phase);
|
|
190
|
+
if (!selectionPhase) {
|
|
191
|
+
throw new Error(`Unknown phase "${input.config.workflow.entry_phase}"`);
|
|
192
|
+
}
|
|
193
|
+
const availableWorkItems = await loadAvailableWorkItems({
|
|
194
|
+
projectRoot: input.projectRoot,
|
|
195
|
+
config: input.config,
|
|
196
|
+
runState: input.runState,
|
|
197
|
+
ledger: input.ledger
|
|
198
|
+
});
|
|
199
|
+
const selectionDir = path.join(input.runDir, "selection");
|
|
200
|
+
await ensureDir(selectionDir);
|
|
201
|
+
const selectionState = {
|
|
202
|
+
iteration: 0,
|
|
203
|
+
status: "running",
|
|
204
|
+
available_work_items: availableWorkItems,
|
|
205
|
+
selected_work_item_queue: [],
|
|
206
|
+
seen_work_item_keys: [...input.runState.seen_work_item_keys],
|
|
207
|
+
completed_work_item_keys: [...input.ledger.completed_work_item_keys],
|
|
208
|
+
last_completed_work_item: input.ledger.last_completed_work_item,
|
|
209
|
+
phase_results: {},
|
|
210
|
+
phase_visit_counts: {}
|
|
211
|
+
};
|
|
212
|
+
const selectionStateFile = path.join(selectionDir, "state.json");
|
|
213
|
+
await writeJson(selectionStateFile, selectionState);
|
|
214
|
+
const phaseResult = await runWorkflowPhase({
|
|
215
|
+
projectRoot: input.projectRoot,
|
|
216
|
+
runDir: input.runDir,
|
|
217
|
+
iterationDir: selectionDir,
|
|
218
|
+
iterationStateFile: selectionStateFile,
|
|
219
|
+
config: input.config,
|
|
220
|
+
phase: selectionPhase,
|
|
221
|
+
iterationState: selectionState,
|
|
222
|
+
runState: input.runState,
|
|
223
|
+
iterationNumber: "selection",
|
|
224
|
+
maxIterations: input.config.workflow.max_iterations
|
|
225
|
+
});
|
|
226
|
+
if (!phaseResult.ok) {
|
|
227
|
+
throw new Error(phaseResult.error);
|
|
228
|
+
}
|
|
229
|
+
const nextTarget = resolveNextTarget(selectionPhase, phaseResult.outcome);
|
|
230
|
+
if (nextTarget === "stop_run") {
|
|
231
|
+
selectionState.status =
|
|
232
|
+
phaseResult.outcome === "no_work" ? "no_work" : "stopped";
|
|
233
|
+
input.runState.status =
|
|
234
|
+
phaseResult.outcome === "no_work" ? "completed_no_work" : "stopped";
|
|
235
|
+
await writeJson(selectionStateFile, selectionState);
|
|
236
|
+
await writeJson(path.join(input.runDir, "state.json"), input.runState);
|
|
237
|
+
console.log("");
|
|
238
|
+
console.log(`Done: ${input.runState.status}`);
|
|
239
|
+
console.log(`Run dir: ${relativeToProject(input.projectRoot, input.runDir)}`);
|
|
240
|
+
return { status: "stopped" };
|
|
241
|
+
}
|
|
242
|
+
if (!nextTarget || isReservedTarget(nextTarget)) {
|
|
243
|
+
throw new Error(`Selection phase must transition to a runnable phase, got "${nextTarget ?? "none"}"`);
|
|
244
|
+
}
|
|
245
|
+
const workItems = readSelectedWorkItems({
|
|
246
|
+
config: input.config,
|
|
247
|
+
result: phaseResult.result,
|
|
248
|
+
availableWorkItems,
|
|
249
|
+
seenWorkItemKeys: selectionState.seen_work_item_keys,
|
|
250
|
+
completedWorkItemKeys: selectionState.completed_work_item_keys
|
|
251
|
+
});
|
|
252
|
+
const selectionGroups = readSelectionGroups(phaseResult.result);
|
|
253
|
+
selectionState.status = "completed";
|
|
254
|
+
selectionState.selected_work_item_queue = workItems;
|
|
255
|
+
await writeJson(selectionStateFile, selectionState);
|
|
256
|
+
await writeJson(path.join(input.runDir, "state.json"), input.runState);
|
|
257
|
+
return {
|
|
258
|
+
status: "selected",
|
|
259
|
+
workItems,
|
|
260
|
+
selectionGroups,
|
|
261
|
+
availableWorkItems,
|
|
262
|
+
nextPhaseId: nextTarget
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
async function runWorkflowPhase(input) {
|
|
266
|
+
const visits = (input.iterationState.phase_visit_counts[input.phase.id] ?? 0) + 1;
|
|
267
|
+
input.iterationState.phase_visit_counts[input.phase.id] = visits;
|
|
268
|
+
if (visits > input.phase.max_visits_per_iteration) {
|
|
269
|
+
input.iterationState.status = "failed";
|
|
270
|
+
input.runState.status = "failed";
|
|
271
|
+
await writeJson(input.iterationStateFile, input.iterationState);
|
|
272
|
+
await writeJson(path.join(input.runDir, "state.json"), input.runState);
|
|
273
|
+
return {
|
|
274
|
+
ok: false,
|
|
275
|
+
error: `Phase "${input.phase.id}" exceeded max_visits_per_iteration=${input.phase.max_visits_per_iteration}`
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
process.stdout.write(`[${input.iterationNumber}/${input.maxIterations}] ${input.phase.id} ... `);
|
|
279
|
+
await writeJson(input.iterationStateFile, input.iterationState);
|
|
280
|
+
const phaseResult = await runPhase({
|
|
281
|
+
projectRoot: input.projectRoot,
|
|
282
|
+
runDir: input.runDir,
|
|
283
|
+
iterationDir: input.iterationDir,
|
|
284
|
+
stateFile: input.iterationStateFile,
|
|
285
|
+
config: input.config,
|
|
286
|
+
phase: input.phase,
|
|
287
|
+
state: input.iterationState
|
|
288
|
+
});
|
|
289
|
+
if (!phaseResult.ok) {
|
|
290
|
+
console.log(pc.red("failed"));
|
|
291
|
+
input.iterationState.status = "failed";
|
|
292
|
+
input.iterationState.phase_results[input.phase.id] = {
|
|
293
|
+
status: "failed",
|
|
294
|
+
error: phaseResult.error
|
|
295
|
+
};
|
|
296
|
+
input.runState.status = "failed";
|
|
297
|
+
await writeJson(input.iterationStateFile, input.iterationState);
|
|
298
|
+
await writeJson(path.join(input.runDir, "state.json"), input.runState);
|
|
299
|
+
return phaseResult;
|
|
300
|
+
}
|
|
301
|
+
input.iterationState.phase_results[input.phase.id] =
|
|
302
|
+
phaseResult.result ?? { status: "ok" };
|
|
303
|
+
const workItem = readObjectProperty(phaseResult.result, "work_item");
|
|
304
|
+
if (workItem !== undefined && input.iterationNumber !== "selection") {
|
|
305
|
+
input.iterationState.work_item = workItem;
|
|
306
|
+
const key = readObjectProperty(workItem, "key");
|
|
307
|
+
if (typeof key === "string" &&
|
|
308
|
+
!input.runState.seen_work_item_keys.includes(key)) {
|
|
309
|
+
input.runState.seen_work_item_keys.push(key);
|
|
310
|
+
input.iterationState.seen_work_item_keys = [
|
|
311
|
+
...input.runState.seen_work_item_keys
|
|
312
|
+
];
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
const label = phaseResult.outcome ?? "ok";
|
|
316
|
+
console.log(pc.green(label));
|
|
317
|
+
await writeJson(input.iterationStateFile, input.iterationState);
|
|
318
|
+
await writeJson(path.join(input.runDir, "state.json"), input.runState);
|
|
319
|
+
return phaseResult;
|
|
320
|
+
}
|
|
321
|
+
export async function confirmWorkItemsWithCheckbox(input) {
|
|
322
|
+
if (input.workItems.length === 0) {
|
|
323
|
+
return [];
|
|
324
|
+
}
|
|
325
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
326
|
+
throw new Error("Work item selection confirmation requires an interactive TTY");
|
|
327
|
+
}
|
|
328
|
+
const selectedKeys = await checkbox({
|
|
329
|
+
message: `Confirm work items to run (max ${input.maxIterations})`,
|
|
330
|
+
choices: buildConfirmationChoices(input),
|
|
331
|
+
required: false,
|
|
332
|
+
pageSize: Math.min(Math.max(input.workItems.length + 2, 5), 20)
|
|
333
|
+
});
|
|
334
|
+
const selected = new Set(selectedKeys);
|
|
335
|
+
return input.workItems.filter((item) => selected.has(item.key));
|
|
336
|
+
}
|
|
337
|
+
function buildConfirmationChoices(input) {
|
|
338
|
+
const choices = [];
|
|
339
|
+
const byKey = new Map(input.workItems.map((item) => [item.key, item]));
|
|
340
|
+
const displayed = new Set();
|
|
341
|
+
for (const group of input.selectionGroups) {
|
|
342
|
+
const groupItems = group.work_item_keys
|
|
343
|
+
.map((key) => byKey.get(key))
|
|
344
|
+
.filter((item) => {
|
|
345
|
+
if (!item) {
|
|
346
|
+
return false;
|
|
347
|
+
}
|
|
348
|
+
return !displayed.has(item.key);
|
|
349
|
+
});
|
|
350
|
+
if (groupItems.length === 0) {
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
choices.push(new Separator(`-- ${group.title} --`));
|
|
354
|
+
for (const item of groupItems) {
|
|
355
|
+
choices.push(buildConfirmationChoice(item));
|
|
356
|
+
displayed.add(item.key);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
const ungroupedItems = input.workItems.filter((item) => !displayed.has(item.key));
|
|
360
|
+
if (input.selectionGroups.length > 0 && ungroupedItems.length > 0) {
|
|
361
|
+
choices.push(new Separator("-- Ungrouped --"));
|
|
362
|
+
}
|
|
363
|
+
for (const item of ungroupedItems) {
|
|
364
|
+
choices.push(buildConfirmationChoice(item));
|
|
365
|
+
}
|
|
366
|
+
return choices;
|
|
367
|
+
}
|
|
368
|
+
function buildConfirmationChoice(item) {
|
|
369
|
+
return {
|
|
370
|
+
value: item.key,
|
|
371
|
+
name: `${item.title} (${item.key})`,
|
|
372
|
+
description: item.excerpt,
|
|
373
|
+
checked: true
|
|
374
|
+
};
|
|
375
|
+
}
|
|
163
376
|
function resolveNextTarget(phase, outcome) {
|
|
164
377
|
if (phase.transitions) {
|
|
165
378
|
if (!outcome) {
|
|
@@ -196,6 +409,74 @@ async function completeIterationWorkItem(input) {
|
|
|
196
409
|
await writeWorkItemLedger(input.nyxDir, ledger);
|
|
197
410
|
return ledger;
|
|
198
411
|
}
|
|
412
|
+
function readSelectedWorkItems(input) {
|
|
413
|
+
const workItems = readObjectProperty(input.result, "work_items");
|
|
414
|
+
const legacyWorkItem = readObjectProperty(input.result, "work_item");
|
|
415
|
+
const selection = workItems ?? (legacyWorkItem === undefined ? undefined : [legacyWorkItem]);
|
|
416
|
+
if (selection === undefined) {
|
|
417
|
+
throw new Error('Selection phase returned "selected" without work_items');
|
|
418
|
+
}
|
|
419
|
+
const validation = validateWorkItemQueue({
|
|
420
|
+
config: input.config,
|
|
421
|
+
workItems: selection,
|
|
422
|
+
availableWorkItems: input.availableWorkItems,
|
|
423
|
+
seenWorkItemKeys: input.seenWorkItemKeys,
|
|
424
|
+
completedWorkItemKeys: input.completedWorkItemKeys
|
|
425
|
+
});
|
|
426
|
+
if (!validation.ok) {
|
|
427
|
+
throw new Error(validation.error);
|
|
428
|
+
}
|
|
429
|
+
return validation.workItems;
|
|
430
|
+
}
|
|
431
|
+
function normalizeConfirmedWorkItems(input) {
|
|
432
|
+
const queuedByKey = new Map(input.queue.map((item) => [item.key, item]));
|
|
433
|
+
const selected = new Set();
|
|
434
|
+
const normalized = [];
|
|
435
|
+
for (const item of input.confirmed) {
|
|
436
|
+
const queued = queuedByKey.get(item.key);
|
|
437
|
+
if (!queued) {
|
|
438
|
+
throw new Error(`Confirmed work item key "${item.key}" is not in selected queue`);
|
|
439
|
+
}
|
|
440
|
+
if (selected.has(item.key)) {
|
|
441
|
+
throw new Error(`Confirmed work item key "${item.key}" was selected twice`);
|
|
442
|
+
}
|
|
443
|
+
selected.add(item.key);
|
|
444
|
+
normalized.push(queued);
|
|
445
|
+
}
|
|
446
|
+
return normalized;
|
|
447
|
+
}
|
|
448
|
+
function readSelectionGroups(result) {
|
|
449
|
+
const groups = readObjectProperty(result, "selection_groups");
|
|
450
|
+
if (!Array.isArray(groups)) {
|
|
451
|
+
return [];
|
|
452
|
+
}
|
|
453
|
+
return groups
|
|
454
|
+
.map((group) => {
|
|
455
|
+
if (!isRecord(group) || typeof group.title !== "string") {
|
|
456
|
+
return undefined;
|
|
457
|
+
}
|
|
458
|
+
const keys = Array.isArray(group.work_item_keys)
|
|
459
|
+
? group.work_item_keys.filter((key) => typeof key === "string")
|
|
460
|
+
: [];
|
|
461
|
+
if (keys.length === 0) {
|
|
462
|
+
return undefined;
|
|
463
|
+
}
|
|
464
|
+
const normalized = {
|
|
465
|
+
title: group.title,
|
|
466
|
+
work_item_keys: keys
|
|
467
|
+
};
|
|
468
|
+
if (typeof group.kind === "string") {
|
|
469
|
+
normalized.kind = group.kind;
|
|
470
|
+
}
|
|
471
|
+
return normalized;
|
|
472
|
+
})
|
|
473
|
+
.filter((group) => Boolean(group));
|
|
474
|
+
}
|
|
475
|
+
function isReservedTarget(target) {
|
|
476
|
+
return (target === "stop_run" ||
|
|
477
|
+
target === "stop_iteration" ||
|
|
478
|
+
target === "next_iteration");
|
|
479
|
+
}
|
|
199
480
|
function readObjectProperty(value, key) {
|
|
200
481
|
if (value &&
|
|
201
482
|
typeof value === "object" &&
|
|
@@ -204,3 +485,6 @@ function readObjectProperty(value, key) {
|
|
|
204
485
|
}
|
|
205
486
|
return undefined;
|
|
206
487
|
}
|
|
488
|
+
function isRecord(value) {
|
|
489
|
+
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
490
|
+
}
|
|
@@ -73,6 +73,54 @@ export function validateWorkItemIdentity(input) {
|
|
|
73
73
|
}
|
|
74
74
|
return { ok: true };
|
|
75
75
|
}
|
|
76
|
+
export function validateWorkItemQueue(input) {
|
|
77
|
+
if (!Array.isArray(input.workItems)) {
|
|
78
|
+
return {
|
|
79
|
+
ok: false,
|
|
80
|
+
error: "Selected work_items must be an array"
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
if (input.workItems.length === 0) {
|
|
84
|
+
return {
|
|
85
|
+
ok: false,
|
|
86
|
+
error: "Selected work_items must contain at least one item"
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
const seen = new Set();
|
|
90
|
+
const normalized = [];
|
|
91
|
+
for (const [index, workItem] of input.workItems.entries()) {
|
|
92
|
+
const validation = validateWorkItemIdentity({
|
|
93
|
+
config: input.config,
|
|
94
|
+
workItem,
|
|
95
|
+
availableWorkItems: input.availableWorkItems,
|
|
96
|
+
seenWorkItemKeys: input.seenWorkItemKeys,
|
|
97
|
+
completedWorkItemKeys: input.completedWorkItemKeys
|
|
98
|
+
});
|
|
99
|
+
if (!validation.ok) {
|
|
100
|
+
return {
|
|
101
|
+
ok: false,
|
|
102
|
+
error: `Selected work_items[${index}] ${validation.error}`
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
const key = readObjectProperty(workItem, "key");
|
|
106
|
+
if (typeof key !== "string") {
|
|
107
|
+
return {
|
|
108
|
+
ok: false,
|
|
109
|
+
error: `Selected work_items[${index}] key must be a string`
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
if (seen.has(key)) {
|
|
113
|
+
return {
|
|
114
|
+
ok: false,
|
|
115
|
+
error: `Selected work_items contains duplicate key "${key}"`
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
seen.add(key);
|
|
119
|
+
const candidate = input.availableWorkItems?.find((item) => item.key === key);
|
|
120
|
+
normalized.push(candidate ?? workItem);
|
|
121
|
+
}
|
|
122
|
+
return { ok: true, workItems: normalized };
|
|
123
|
+
}
|
|
76
124
|
function validateCandidateMembership(input) {
|
|
77
125
|
const candidate = input.availableWorkItems.find((item) => item.key === input.key);
|
|
78
126
|
if (!candidate) {
|
|
@@ -154,3 +202,11 @@ function normalizeRelativePath(value) {
|
|
|
154
202
|
function isRecord(value) {
|
|
155
203
|
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
156
204
|
}
|
|
205
|
+
function readObjectProperty(value, key) {
|
|
206
|
+
if (value &&
|
|
207
|
+
typeof value === "object" &&
|
|
208
|
+
Object.prototype.hasOwnProperty.call(value, key)) {
|
|
209
|
+
return value[key];
|
|
210
|
+
}
|
|
211
|
+
return undefined;
|
|
212
|
+
}
|
package/docs/nyxagent-v0-spec.md
CHANGED
|
@@ -16,7 +16,8 @@ configuration.
|
|
|
16
16
|
## Goals
|
|
17
17
|
|
|
18
18
|
- Install a `.nyxagent/` folder into a project with sensible templates.
|
|
19
|
-
-
|
|
19
|
+
- Select an ordered work-item queue once, then run a configurable phase workflow
|
|
20
|
+
for up to `max_iterations` confirmed work items.
|
|
20
21
|
- Launch a fresh harness process for each phase.
|
|
21
22
|
- Keep workflow structure generic through phase transitions and outcomes.
|
|
22
23
|
- Keep prompts focused on agent behavior, not runtime mechanics.
|
|
@@ -171,8 +172,8 @@ max_visits_per_iteration = 1
|
|
|
171
172
|
|
|
172
173
|
### Config Semantics
|
|
173
174
|
|
|
174
|
-
- `workflow.max_iterations` is the maximum number of distinct work
|
|
175
|
-
run.
|
|
175
|
+
- `workflow.max_iterations` is the maximum number of distinct confirmed work
|
|
176
|
+
items in a run.
|
|
176
177
|
- `phases[*].max_visits_per_iteration` prevents infinite loops inside one work
|
|
177
178
|
item.
|
|
178
179
|
- `model.reasoning_level` is a harness-neutral string.
|
|
@@ -180,13 +181,16 @@ max_visits_per_iteration = 1
|
|
|
180
181
|
- Per-phase `model` and `harness` blocks override global values.
|
|
181
182
|
- `work_items` supports only `local` and `github` in v0.
|
|
182
183
|
- `work_items.max_candidates` defaults to `50` and caps the inventory sent to
|
|
183
|
-
the selection prompt.
|
|
184
|
+
the initial selection prompt.
|
|
184
185
|
- `work_items.excerpt_chars` defaults to `800` and bounds candidate excerpts.
|
|
185
|
-
-
|
|
186
|
-
`available_work_items`, and
|
|
187
|
-
- The
|
|
188
|
-
|
|
189
|
-
|
|
186
|
+
- At run start, the engine scans the configured provider, normalizes candidates
|
|
187
|
+
into `available_work_items`, and sends that inventory to the selection phase.
|
|
188
|
+
- The selection phase returns an ordered `work_items` queue. NyxAgent validates
|
|
189
|
+
each selected identity against `[work_items]`, rejects duplicates, and
|
|
190
|
+
requires every selected key to exist in `available_work_items`.
|
|
191
|
+
- Before executing, NyxAgent shows the selected queue in an interactive
|
|
192
|
+
checkbox prompt. The user can uncheck work items; only confirmed items are
|
|
193
|
+
executed.
|
|
190
194
|
- Local markdown work items do not have a required frontmatter schema. The
|
|
191
195
|
provider infers titles from the first heading or filename and includes a
|
|
192
196
|
bounded content excerpt.
|
|
@@ -194,7 +198,8 @@ max_visits_per_iteration = 1
|
|
|
194
198
|
- GitHub keys use `github:<owner>/<repo>#<issue-number>` and must match the
|
|
195
199
|
configured repository.
|
|
196
200
|
- NyxAgent does not decide whether an item is a plan, PRD, or task. The
|
|
197
|
-
selection agent makes that semantic choice from the deterministic inventory
|
|
201
|
+
selection agent makes that semantic choice from the deterministic inventory
|
|
202
|
+
and may include optional `selection_groups` for user review.
|
|
198
203
|
|
|
199
204
|
## Workflow Model
|
|
200
205
|
|
|
@@ -214,10 +219,12 @@ Reserved next targets:
|
|
|
214
219
|
The engine does not know about development, review, approval, or closure. It
|
|
215
220
|
only knows phases, outcomes, transitions, and visit limits.
|
|
216
221
|
|
|
217
|
-
The default template expresses the standard
|
|
222
|
+
The default template expresses the standard run:
|
|
218
223
|
|
|
219
224
|
```text
|
|
220
|
-
selection ->
|
|
225
|
+
selection -> user confirms queue
|
|
226
|
+
for each confirmed work item:
|
|
227
|
+
execution -> review
|
|
221
228
|
review.approved -> closure -> next_iteration
|
|
222
229
|
review.changes_requested -> execution
|
|
223
230
|
selection.no_work -> stop_run
|
|
@@ -292,6 +299,7 @@ The runtime contract includes:
|
|
|
292
299
|
- work item context, when selected
|
|
293
300
|
- work item config from `[work_items]`
|
|
294
301
|
- `available_work_items`
|
|
302
|
+
- `selected_work_item_queue`
|
|
295
303
|
- `seen_work_item_keys`
|
|
296
304
|
- `completed_work_item_keys`
|
|
297
305
|
- `last_completed_work_item`
|
|
@@ -306,6 +314,7 @@ Prompts may use simple interpolation:
|
|
|
306
314
|
{{iteration_dir}}
|
|
307
315
|
{{phase_dir}}
|
|
308
316
|
{{state_file}}
|
|
317
|
+
{{selected_work_item_queue}}
|
|
309
318
|
{{work_item.key}}
|
|
310
319
|
{{work_item.title}}
|
|
311
320
|
{{model.name}}
|
|
@@ -322,17 +331,20 @@ Each run creates a timestamped directory:
|
|
|
322
331
|
.nyxagent/runs/2026-05-23T12-30-00/
|
|
323
332
|
run.json
|
|
324
333
|
state.json
|
|
334
|
+
selection/
|
|
335
|
+
state.json
|
|
336
|
+
phases/
|
|
337
|
+
selection/
|
|
338
|
+
attempt-001/
|
|
339
|
+
prompt.md
|
|
340
|
+
stdout.log
|
|
341
|
+
stderr.log
|
|
342
|
+
meta.json
|
|
343
|
+
result.json
|
|
325
344
|
iterations/
|
|
326
345
|
001/
|
|
327
346
|
state.json
|
|
328
347
|
phases/
|
|
329
|
-
selection/
|
|
330
|
-
attempt-001/
|
|
331
|
-
prompt.md
|
|
332
|
-
stdout.log
|
|
333
|
-
stderr.log
|
|
334
|
-
meta.json
|
|
335
|
-
result.json
|
|
336
348
|
execution/
|
|
337
349
|
attempt-001/
|
|
338
350
|
prompt.md
|
|
@@ -368,6 +380,10 @@ Each run creates a timestamped directory:
|
|
|
368
380
|
- run status
|
|
369
381
|
- current iteration
|
|
370
382
|
- completed iterations
|
|
383
|
+
- available work items seen by the initial selection phase
|
|
384
|
+
- selected work item queue confirmed for the run
|
|
385
|
+
- skipped selected work item keys
|
|
386
|
+
- selection groups returned for user review
|
|
371
387
|
- seen work item keys
|
|
372
388
|
- completed work item keys
|
|
373
389
|
- last completed work item
|
|
@@ -382,7 +398,7 @@ Each run creates a timestamped directory:
|
|
|
382
398
|
|
|
383
399
|
- iteration number
|
|
384
400
|
- work item
|
|
385
|
-
-
|
|
401
|
+
- selected work item queue for the run
|
|
386
402
|
- seen work item keys
|
|
387
403
|
- completed work item keys
|
|
388
404
|
- last completed work item
|
|
@@ -426,13 +442,14 @@ Default prompts should be concise but operational.
|
|
|
426
442
|
|
|
427
443
|
Selection:
|
|
428
444
|
|
|
429
|
-
- choose
|
|
445
|
+
- choose an ordered queue from `available_work_items`
|
|
430
446
|
- treat candidates agnostically: they may be plans, PRDs, or tasks
|
|
431
|
-
- prefer the most actionable
|
|
447
|
+
- prefer the most actionable items based on candidate titles and excerpts
|
|
432
448
|
- if a plan references concrete candidate tasks, choose the concrete task when
|
|
433
449
|
that is the best next work
|
|
434
450
|
- avoid keys already present in `seen_work_item_keys` or
|
|
435
451
|
`completed_work_item_keys`
|
|
452
|
+
- optionally include `selection_groups` to present related work by plan or PRD
|
|
436
453
|
- return `selected` or `no_work`
|
|
437
454
|
|
|
438
455
|
Execution:
|
package/package.json
CHANGED
|
@@ -1,19 +1,23 @@
|
|
|
1
|
-
Select
|
|
1
|
+
Select the ordered work item queue for this run.
|
|
2
2
|
|
|
3
3
|
Use `available_work_items` from the runtime contract as the complete inventory
|
|
4
|
-
for
|
|
5
|
-
|
|
4
|
+
for selection. Some candidates may be plans, PRDs, or concrete tasks. Build the
|
|
5
|
+
most actionable queue for this run, up to `workflow.max_iterations` items.
|
|
6
6
|
|
|
7
|
-
If
|
|
8
|
-
|
|
9
|
-
is
|
|
10
|
-
`
|
|
7
|
+
If candidates appear to belong to the same plan or PRD, keep related concrete
|
|
8
|
+
tasks together and use `selection_groups` to describe the grouping. The grouping
|
|
9
|
+
is only for user review; every executable item must still be included in
|
|
10
|
+
`work_items`.
|
|
11
|
+
|
|
12
|
+
Prefer concrete tasks over their parent plan when both are present and the task
|
|
13
|
+
is ready to execute. Choose the plan itself only when it is the best next work.
|
|
14
|
+
If no candidate is exploitable, return `no_work`.
|
|
11
15
|
|
|
12
16
|
Do not choose any key listed in `seen_work_item_keys` or
|
|
13
|
-
`completed_work_item_keys`.
|
|
14
|
-
`available_work_items`; do not invent
|
|
17
|
+
`completed_work_item_keys`. Every selected work item must be copied from
|
|
18
|
+
`available_work_items`; do not invent keys.
|
|
15
19
|
|
|
16
|
-
The selected work item
|
|
20
|
+
The selected work item identities must match the inventory entries:
|
|
17
21
|
|
|
18
22
|
- local: `source.type` is `local`, `source.locator` is the project-relative
|
|
19
23
|
markdown path, and `key` is `local:<source.locator>`.
|
|
@@ -23,7 +27,11 @@ The selected work item identity must match the inventory entry:
|
|
|
23
27
|
|
|
24
28
|
Return one of these outcomes:
|
|
25
29
|
|
|
26
|
-
- `selected`: include
|
|
30
|
+
- `selected`: include ordered `work_items`; optionally include
|
|
31
|
+
`selection_groups` with `title`, optional `kind`, and `work_item_keys`
|
|
27
32
|
- `no_work`: include a short `reason`
|
|
28
33
|
|
|
34
|
+
For compatibility, `work_item` is still accepted for a single selected item, but
|
|
35
|
+
new results should use `work_items`.
|
|
36
|
+
|
|
29
37
|
Do not modify project files or task files during selection.
|
|
@@ -2,12 +2,8 @@
|
|
|
2
2
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
3
|
"type": "object",
|
|
4
4
|
"required": ["outcome"],
|
|
5
|
-
"
|
|
6
|
-
"
|
|
7
|
-
"type": "string",
|
|
8
|
-
"enum": ["selected", "no_work"]
|
|
9
|
-
},
|
|
10
|
-
"work_item": {
|
|
5
|
+
"$defs": {
|
|
6
|
+
"workItem": {
|
|
11
7
|
"type": "object",
|
|
12
8
|
"required": ["key", "title", "source"],
|
|
13
9
|
"properties": {
|
|
@@ -39,6 +35,47 @@
|
|
|
39
35
|
}
|
|
40
36
|
},
|
|
41
37
|
"additionalProperties": true
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
"properties": {
|
|
41
|
+
"outcome": {
|
|
42
|
+
"type": "string",
|
|
43
|
+
"enum": ["selected", "no_work"]
|
|
44
|
+
},
|
|
45
|
+
"work_items": {
|
|
46
|
+
"type": "array",
|
|
47
|
+
"minItems": 1,
|
|
48
|
+
"items": {
|
|
49
|
+
"$ref": "#/$defs/workItem"
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
"work_item": {
|
|
53
|
+
"$ref": "#/$defs/workItem"
|
|
54
|
+
},
|
|
55
|
+
"selection_groups": {
|
|
56
|
+
"type": "array",
|
|
57
|
+
"items": {
|
|
58
|
+
"type": "object",
|
|
59
|
+
"required": ["title", "work_item_keys"],
|
|
60
|
+
"properties": {
|
|
61
|
+
"title": {
|
|
62
|
+
"type": "string",
|
|
63
|
+
"minLength": 1
|
|
64
|
+
},
|
|
65
|
+
"kind": {
|
|
66
|
+
"type": "string"
|
|
67
|
+
},
|
|
68
|
+
"work_item_keys": {
|
|
69
|
+
"type": "array",
|
|
70
|
+
"minItems": 1,
|
|
71
|
+
"items": {
|
|
72
|
+
"type": "string",
|
|
73
|
+
"minLength": 1
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
"additionalProperties": true
|
|
78
|
+
}
|
|
42
79
|
},
|
|
43
80
|
"reason": {
|
|
44
81
|
"type": "string"
|
|
@@ -54,7 +91,14 @@
|
|
|
54
91
|
}
|
|
55
92
|
},
|
|
56
93
|
"then": {
|
|
57
|
-
"
|
|
94
|
+
"anyOf": [
|
|
95
|
+
{
|
|
96
|
+
"required": ["work_items"]
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
"required": ["work_item"]
|
|
100
|
+
}
|
|
101
|
+
]
|
|
58
102
|
}
|
|
59
103
|
},
|
|
60
104
|
{
|