@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.
@@ -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),
@@ -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
- for (let iterationNumber = 1; iterationNumber <= config.workflow.max_iterations; iterationNumber += 1) {
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
- available_work_items: availableWorkItems,
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 = config.workflow.entry_phase;
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 visits = (iterationState.phase_visit_counts[phase.id] ?? 0) + 1;
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
- stateFile: iterationStateFile,
135
+ iterationStateFile,
94
136
  config,
95
137
  phase,
96
- state: iterationState
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 = phaseResult.outcome === "no_work" ? "no_work" : "stopped";
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 = "completed_max_iterations";
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
+ }
@@ -16,7 +16,8 @@ configuration.
16
16
  ## Goals
17
17
 
18
18
  - Install a `.nyxagent/` folder into a project with sensible templates.
19
- - Run a configurable phase workflow for up to `max_iterations` work items.
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 items in a
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
- - The engine scans the configured provider, normalizes candidates into
186
- `available_work_items`, and injects that inventory into runtime context.
187
- - The engine validates the selected `work_item` identity against `[work_items]`
188
- and requires the selected key to exist in `available_work_items` before
189
- recording phase results.
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 loop:
222
+ The default template expresses the standard run:
218
223
 
219
224
  ```text
220
- selection -> execution -> review
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
- - available work items for the iteration
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 one item from `available_work_items`
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 item based on the candidate title and excerpt
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@nyxa/nyx-agent",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "A lightweight phase orchestrator for repeatedly launching coding agents with fresh context.",
5
5
  "type": "module",
6
6
  "repository": {
@@ -1,19 +1,23 @@
1
- Select exactly one work item for this iteration.
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 this selection. Some candidates may be plans, PRDs, or concrete tasks. Pick
5
- the most actionable next item from the provided context.
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 a candidate is a plan that references more concrete candidates also present
8
- in `available_work_items`, you may choose the concrete task. If the plan itself
9
- is the best next work, choose the plan. If no candidate is exploitable, return
10
- `no_work`.
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`. The selected work item must be copied from
14
- `available_work_items`; do not invent a key.
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 identity must match the inventory entry:
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 a stable `work_item`
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
- "properties": {
6
- "outcome": {
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
- "required": ["work_item"]
94
+ "anyOf": [
95
+ {
96
+ "required": ["work_items"]
97
+ },
98
+ {
99
+ "required": ["work_item"]
100
+ }
101
+ ]
58
102
  }
59
103
  },
60
104
  {