@nyxa/nyx-agent 0.3.0 → 0.3.2

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