@nyxa/nyx-agent 0.9.2 → 0.9.3

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.
@@ -6,15 +6,17 @@
6
6
  * prompt is pure guidance — NyxAgent prepends an engine-owned context block and,
7
7
  * for review-style phases, appends the required-result contract + JSON Schema.
8
8
  */
9
- export const SELECTION_PROMPT = `Select and order the GitHub issues to work on in this run.
9
+ export const SELECTION_PROMPT = `Select the GitHub issue series to work on in this run.
10
10
 
11
- The available open issues (candidates) are listed in the context above. Choose the
12
- ones that form a coherent unit of work for this run and order them so prerequisites
13
- come first. You may select a subset; skip issues that are unclear, blocked, or out
14
- of scope. Do not invent keys only use keys present in the candidates.
11
+ The context lists recommended executable series, blockers, and available open
12
+ issues by PRD/Plan section. Choose the coherent series or issue set that should be
13
+ started now. Prefer the recommended executable order when it matches the intent.
14
+ Skip issues with open blockers. Closed blockers are satisfied. Do not invent keys
15
+ — only use keys present in the candidates.
15
16
 
16
- Return outcome "selected" with the ordered keys in a work_item_keys array. If
17
- nothing is worth working on, return outcome "no_work" instead.`;
17
+ Return outcome "selected" with the chosen intent keys in a work_item_keys array;
18
+ the engine will expand PRD/Plan series and prerequisites safely. If nothing is
19
+ worth working on, return outcome "no_work" instead.`;
18
20
  export const EXECUTION_PROMPT = `Implement the selected work item described in the context above.
19
21
 
20
22
  Work only on this item. Keep changes focused and coherent. Use a
@@ -11,9 +11,10 @@ import { createRunReporter } from "./reporter.js";
11
11
  import { runAgentPhase, } from "./runPhase.js";
12
12
  import { REVIEW_CHALLENGE_SCHEMA, REVIEW_DISCOVERY_SCHEMA, GLOBAL_REVIEW_SCHEMA, REVIEW_VALIDATION_SCHEMA, SELECTION_SCHEMA, } from "./schemas.js";
13
13
  import { commitAll, commitsAhead, createPullRequest, pushBranch, rangeDiff, stageAllAndDiff, } from "./scm.js";
14
- import { buildSelectionSections, confirmWorkItemSelection, } from "./selectionConfirmation.js";
14
+ import { confirmWorkItemSelection, } from "./selectionConfirmation.js";
15
+ import { buildWorkItemSelectionPlan, formatSelectionBlockers, isDependencySatisfied, normalizeWorkItemSelection, } from "./selectionPlanner.js";
15
16
  import { createRunId } from "./time.js";
16
- import { filterAvailable, listGitHubWorkItemInventory, orderWorkItemsByDependencies, resolveSelectedQueue, } from "./workItems.js";
17
+ import { filterAvailable, listGitHubWorkItemInventory, resolveSelectedQueue, } from "./workItems.js";
17
18
  const MAX_CANDIDATES = 50;
18
19
  const EXCERPT_CHARS = 800;
19
20
  const CORRECTION_VALIDATION_MAX_ATTEMPTS = 3;
@@ -73,12 +74,18 @@ export async function runPipeline(input = {}, deps = defaultPipelineDependencies
73
74
  reporter.info("No open work items available. Nothing to do.");
74
75
  return;
75
76
  }
77
+ const selectionPlan = buildWorkItemSelectionPlan({
78
+ candidates,
79
+ maxItems: config.max_iterations,
80
+ completedKeys: ledger.completed_work_item_keys,
81
+ });
76
82
  const proposed = await runSelection({
77
83
  projectRoot,
78
84
  runDir,
79
85
  cliHarness: input.harness,
80
86
  config,
81
87
  candidates,
88
+ completedKeys: ledger.completed_work_item_keys,
82
89
  runPhase: deps.runPhase,
83
90
  reporter,
84
91
  });
@@ -87,7 +94,7 @@ export async function runPipeline(input = {}, deps = defaultPipelineDependencies
87
94
  return;
88
95
  }
89
96
  const selected = await deps.confirmSelection({
90
- candidates,
97
+ candidates: selectionPlan.candidateOrder,
91
98
  proposed,
92
99
  maxItems: config.max_iterations,
93
100
  autoAccept: input.autoAcceptSelection ?? false,
@@ -96,14 +103,31 @@ export async function runPipeline(input = {}, deps = defaultPipelineDependencies
96
103
  reporter.info("No work items selected. Nothing to do.");
97
104
  return;
98
105
  }
99
- const orderedSelection = orderWorkItemsByDependencies(selected);
100
- if (orderedSelection.cycleKeys.length > 0) {
106
+ const finalSelection = normalizeWorkItemSelection({
107
+ candidates,
108
+ selected,
109
+ maxItems: config.max_iterations,
110
+ expandGroups: false,
111
+ strictMax: true,
112
+ completedKeys: ledger.completed_work_item_keys,
113
+ });
114
+ if (!finalSelection.ok) {
115
+ throw new Error(finalSelection.error);
116
+ }
117
+ if (finalSelection.blockedCandidates.length > 0) {
118
+ reporter.warn(`Skipping blocked work item(s): ${formatSelectionBlockers(finalSelection.blockedCandidates)}`);
119
+ }
120
+ if (finalSelection.queue.length === 0) {
121
+ reporter.info("No executable work items selected. Nothing to do.");
122
+ return;
123
+ }
124
+ if (finalSelection.cycleKeys.length > 0) {
101
125
  reporter.detail(`Dependency cycle detected among selected work items; preserving confirmed order inside cycle(s): ${formatDependencyCycles({
102
- cycles: orderedSelection.cycleKeys,
126
+ cycles: finalSelection.cycleKeys,
103
127
  items: selected,
104
128
  })}`);
105
129
  }
106
- const planned = orderedSelection.queue.slice(0, config.max_iterations);
130
+ const planned = finalSelection.queue;
107
131
  reporter.info(`Selected ${planned.length} work item(s):`);
108
132
  for (const item of planned) {
109
133
  reporter.info(` - ${item.title} (#${item.number})`);
@@ -287,14 +311,30 @@ async function runSelection(input) {
287
311
  config: input.config,
288
312
  cliHarness: input.cliHarness,
289
313
  });
314
+ const selectionPlan = buildWorkItemSelectionPlan({
315
+ candidates: input.candidates,
316
+ maxItems: input.config.max_iterations,
317
+ completedKeys: input.completedKeys,
318
+ });
319
+ const candidateKeys = new Set(input.candidates.map((candidate) => candidate.key));
320
+ const completedKeys = new Set(input.completedKeys);
290
321
  const context = buildContextBlock([
291
322
  ["Repository", input.config.tracker.repo],
292
323
  ["Max work items this run", input.config.max_iterations],
324
+ [
325
+ "Recommended executable series",
326
+ selectionPlan.recommendedSeries.map(selectionSeriesSummary),
327
+ ],
328
+ [
329
+ "Blocked candidates",
330
+ selectionPlan.blockedCandidates.map(selectionBlockerSummary),
331
+ ],
293
332
  [
294
333
  "Available candidates",
295
- buildSelectionSections(input.candidates).map((section) => ({
334
+ selectionPlan.sections.map((section) => ({
296
335
  section: section.label ?? "Ungrouped issues",
297
- candidates: section.candidates.map(selectionCandidateSummary),
336
+ recommended_order: section.recommended.map(workItemReference),
337
+ candidates: section.candidates.map((candidate) => selectionCandidateSummary(candidate, candidateKeys, completedKeys)),
298
338
  })),
299
339
  ],
300
340
  ]);
@@ -328,7 +368,23 @@ async function runSelection(input) {
328
368
  if (!resolved.ok) {
329
369
  throw new Error(`Selection produced an invalid queue: ${resolved.error}`);
330
370
  }
331
- return resolved.queue;
371
+ const normalized = normalizeWorkItemSelection({
372
+ candidates: input.candidates,
373
+ selected: resolved.queue,
374
+ maxItems: input.config.max_iterations,
375
+ expandGroups: true,
376
+ completedKeys: input.completedKeys,
377
+ });
378
+ if (!normalized.ok) {
379
+ throw new Error(normalized.error);
380
+ }
381
+ if (normalized.blockedCandidates.length > 0) {
382
+ input.reporter.detail(`Selection omitted blocked work item(s): ${formatSelectionBlockers(normalized.blockedCandidates)}`);
383
+ }
384
+ if (normalized.truncated) {
385
+ input.reporter.detail(`Selection truncated to ${input.config.max_iterations} work item(s) after dependency planning.`);
386
+ }
387
+ return normalized.queue;
332
388
  }
333
389
  async function runExecution(input) {
334
390
  const agent = resolveAgentProfile({
@@ -884,7 +940,22 @@ function workItemSummary(item) {
884
940
  blocked_by: item.blocked_by,
885
941
  };
886
942
  }
887
- function selectionCandidateSummary(candidate) {
943
+ function selectionSeriesSummary(series) {
944
+ return {
945
+ section: series.section ?? "Ungrouped issues",
946
+ work_item_keys: series.candidates.map((candidate) => candidate.key),
947
+ order: series.candidates.map(workItemReference),
948
+ blocked: series.blocked.map(selectionBlockerSummary),
949
+ truncated: series.truncated,
950
+ };
951
+ }
952
+ function selectionBlockerSummary(blocked) {
953
+ return {
954
+ candidate: workItemReference(blocked.candidate),
955
+ blockers: blocked.blockers.map(dependencyReference),
956
+ };
957
+ }
958
+ function selectionCandidateSummary(candidate, candidateKeys, completedKeys) {
888
959
  return {
889
960
  key: candidate.key,
890
961
  number: candidate.number,
@@ -892,9 +963,50 @@ function selectionCandidateSummary(candidate) {
892
963
  labels: candidate.labels,
893
964
  parent: candidate.parent,
894
965
  blocked_by: candidate.blocked_by,
966
+ dependency_status: dependencyStatusSummary({
967
+ dependencies: candidate.blocked_by ?? [],
968
+ candidateKeys,
969
+ completedKeys,
970
+ }),
895
971
  excerpt: candidate.excerpt,
896
972
  };
897
973
  }
974
+ function dependencyStatusSummary(input) {
975
+ const selectablePrerequisites = [];
976
+ const satisfiedBlockers = [];
977
+ const openBlockers = [];
978
+ for (const dependency of input.dependencies) {
979
+ if (input.candidateKeys.has(dependency.key)) {
980
+ selectablePrerequisites.push(dependency);
981
+ continue;
982
+ }
983
+ if (isDependencySatisfied(dependency, input.completedKeys)) {
984
+ satisfiedBlockers.push(dependency);
985
+ continue;
986
+ }
987
+ openBlockers.push(dependency);
988
+ }
989
+ return {
990
+ selectable_prerequisites: selectablePrerequisites.map(dependencyReference),
991
+ satisfied_blockers: satisfiedBlockers.map(dependencyReference),
992
+ open_blockers: openBlockers.map(dependencyReference),
993
+ };
994
+ }
995
+ function workItemReference(item) {
996
+ return {
997
+ key: item.key,
998
+ number: item.number,
999
+ title: item.title,
1000
+ };
1001
+ }
1002
+ function dependencyReference(dependency) {
1003
+ return {
1004
+ key: dependency.key,
1005
+ number: dependency.number,
1006
+ title: dependency.title,
1007
+ state: dependency.state,
1008
+ };
1009
+ }
898
1010
  function buildCommitMessage(item) {
899
1011
  return `${item.title}\n\nWork item: ${item.source.locator}`;
900
1012
  }
@@ -1,4 +1,6 @@
1
1
  import { checkbox, Separator } from "@inquirer/prompts";
2
+ import { buildWorkItemSelectionPlan, formatSelectionBlockers, normalizeWorkItemSelection, } from "./selectionPlanner.js";
3
+ export { buildSelectionSections } from "./selectionPlanner.js";
2
4
  export async function confirmWorkItemSelection(input) {
3
5
  if (input.autoAccept) {
4
6
  return input.proposed;
@@ -8,104 +10,84 @@ export async function confirmWorkItemSelection(input) {
8
10
  throw new Error('Interactive work item selection requires a TTY. Re-run with "nyxagent run --yes" to accept the agent selection.');
9
11
  }
10
12
  const choiceItems = buildSelectionChoiceItems(input);
13
+ const choiceOrder = choiceItems
14
+ .filter((item) => item.type === "choice")
15
+ .map((item) => item.value);
11
16
  const selectedKeys = await checkbox({
12
17
  message: `Select work items to run (max ${input.maxItems})`,
13
18
  choices: choiceItems.map(toInquirerChoice),
14
19
  pageSize: Math.min(Math.max(choiceItems.length, 7), 20),
15
20
  required: false,
16
- validate: (selected) => selected.length <= input.maxItems ||
17
- `Select at most ${input.maxItems} work item(s).`,
21
+ validate: (selected) => {
22
+ const normalized = normalizeSelectedKeys({
23
+ selectedKeys: selected.map((choice) => choice.value),
24
+ choiceOrder,
25
+ candidates: input.candidates,
26
+ maxItems: input.maxItems,
27
+ });
28
+ if (!normalized.ok) {
29
+ return normalized.error;
30
+ }
31
+ if (normalized.blockedCandidates.length > 0) {
32
+ return `Selection contains blocked work item(s): ${formatSelectionBlockers(normalized.blockedCandidates)}`;
33
+ }
34
+ return true;
35
+ },
18
36
  shortcuts: {
19
37
  all: null,
20
38
  invert: null,
21
39
  },
22
40
  });
23
- const selected = new Set(selectedKeys);
24
- const candidatesByKey = new Map(input.candidates.map((candidate) => [candidate.key, candidate]));
25
- return choiceItems.flatMap((item) => {
26
- if (item.type !== "choice" || !selected.has(item.value)) {
27
- return [];
28
- }
29
- const candidate = candidatesByKey.get(item.value);
30
- return candidate ? [candidate] : [];
41
+ const normalized = normalizeSelectedKeys({
42
+ selectedKeys,
43
+ choiceOrder,
44
+ candidates: input.candidates,
45
+ maxItems: input.maxItems,
31
46
  });
47
+ if (!normalized.ok) {
48
+ throw new Error(normalized.error);
49
+ }
50
+ return normalized.queue;
32
51
  }
33
52
  export function buildSelectionChoiceItems(input) {
34
- const candidateByKey = new Map(input.candidates.map((candidate) => [candidate.key, candidate]));
35
- const proposedCandidates = [];
36
- const proposedKeys = new Set();
37
- for (const proposed of input.proposed) {
38
- const candidate = candidateByKey.get(proposed.key);
39
- if (!candidate || proposedKeys.has(candidate.key)) {
40
- continue;
41
- }
42
- proposedCandidates.push(candidate);
43
- proposedKeys.add(candidate.key);
44
- }
45
- const remainingCandidates = input.candidates.filter((candidate) => !proposedKeys.has(candidate.key));
46
- const items = [];
47
- let activeSectionLabel;
48
- activeSectionLabel = appendSelectionChoices({
49
- items,
50
- sections: buildSelectionSections(proposedCandidates),
51
- proposedKeys,
52
- activeSectionLabel,
53
+ const plan = buildWorkItemSelectionPlan({
54
+ candidates: input.candidates,
55
+ proposed: input.proposed,
56
+ maxItems: input.maxItems ?? input.candidates.length,
53
57
  });
58
+ const proposedKeys = new Set(plan.recommendedQueue.map((item) => item.key));
59
+ const blockedByKey = new Map(plan.blockedCandidates.map((blocked) => [blocked.candidate.key, blocked]));
60
+ const items = [];
54
61
  appendSelectionChoices({
55
62
  items,
56
- sections: buildSelectionSections(remainingCandidates),
63
+ sections: plan.sections,
57
64
  proposedKeys,
58
- activeSectionLabel,
65
+ blockedByKey,
59
66
  });
60
67
  return items;
61
68
  }
62
69
  function appendSelectionChoices(input) {
63
- let activeSectionLabel = input.activeSectionLabel;
64
70
  for (const section of input.sections) {
65
71
  if (section.label) {
66
- if (activeSectionLabel !== section.label) {
67
- input.items.push({ type: "separator", label: section.label });
68
- }
69
- activeSectionLabel = section.label;
70
- }
71
- else {
72
- activeSectionLabel = undefined;
72
+ input.items.push({ type: "separator", label: section.label });
73
73
  }
74
74
  for (const candidate of section.candidates) {
75
75
  const proposed = input.proposedKeys.has(candidate.key);
76
- input.items.push({
76
+ const blocked = input.blockedByKey.get(candidate.key);
77
+ const item = {
77
78
  type: "choice",
78
79
  value: candidate.key,
79
80
  name: `#${candidate.number} ${candidate.title}${proposed ? " (agent)" : ""}`,
80
81
  checked: proposed,
81
- });
82
- }
83
- }
84
- return activeSectionLabel;
85
- }
86
- export function buildSelectionSections(candidates) {
87
- const sections = [];
88
- const groupedSections = new Map();
89
- let ungroupedSection;
90
- for (const candidate of candidates) {
91
- const group = detectSelectionGroup(candidate);
92
- if (!group) {
93
- if (!ungroupedSection) {
94
- ungroupedSection = { candidates: [] };
95
- sections.push(ungroupedSection);
82
+ };
83
+ if (blocked) {
84
+ item.disabled = `blocked by ${blocked.blockers
85
+ .map((blocker) => `#${blocker.number}`)
86
+ .join(", ")}`;
96
87
  }
97
- ungroupedSection.candidates.push(candidate);
98
- continue;
88
+ input.items.push(item);
99
89
  }
100
- let section = groupedSections.get(group.key);
101
- if (!section) {
102
- section = { label: group.label, candidates: [] };
103
- groupedSections.set(group.key, section);
104
- sections.push(section);
105
- }
106
- section.candidates.push(candidate);
107
90
  }
108
- return sections;
109
91
  }
110
92
  function toInquirerChoice(item) {
111
93
  if (item.type === "separator") {
@@ -115,72 +97,21 @@ function toInquirerChoice(item) {
115
97
  value: item.value,
116
98
  name: item.name,
117
99
  checked: item.checked,
100
+ disabled: item.disabled,
118
101
  };
119
102
  }
120
- function detectSelectionGroup(candidate) {
121
- if (candidate.parent) {
122
- return {
123
- key: `parent:${candidate.parent.key}`,
124
- label: formatParentGroup(candidate.parent),
125
- };
126
- }
127
- for (const label of candidate.labels ?? []) {
128
- const group = parseGroupLabel(label);
129
- if (group) {
130
- return { key: `display:${group.toLowerCase()}`, label: group };
131
- }
132
- }
133
- const titleGroup = parseBracketedTitleGroup(candidate.title);
134
- return titleGroup
135
- ? { key: `display:${titleGroup.toLowerCase()}`, label: titleGroup }
136
- : undefined;
137
- }
138
- function formatParentGroup(parent) {
139
- if (!parent) {
140
- return "Parent";
141
- }
142
- if (!parent.title) {
143
- return `Parent #${parent.number}`;
144
- }
145
- const titledGroup = parseLeadingTitleGroup(parent.title);
146
- if (titledGroup) {
147
- return `${titledGroup.kind} #${parent.number}: ${titledGroup.name}`;
148
- }
149
- return `Parent #${parent.number}: ${parent.title}`;
150
- }
151
- function parseGroupLabel(label) {
152
- const match = /^(plan|prd)\s*[:/=-]\s*(.+)$/i.exec(label.trim());
153
- if (!match) {
154
- return undefined;
155
- }
156
- return formatGroupLabel(match[1], match[2]);
157
- }
158
- function parseBracketedTitleGroup(title) {
159
- const match = /^\[(plan|prd)\s*[:/=-]\s*([^\]]+)\]/i.exec(title.trim());
160
- if (!match) {
161
- return undefined;
162
- }
163
- return formatGroupLabel(match[1], match[2]);
164
- }
165
- function parseLeadingTitleGroup(title) {
166
- const match = /^(plan|prd)\s*[:/=-]\s*(.+)$/i.exec(title.trim());
167
- if (!match) {
168
- return undefined;
169
- }
170
- const name = match[2].trim();
171
- if (!name) {
172
- return undefined;
173
- }
174
- return {
175
- kind: match[1].toLowerCase() === "prd" ? "PRD" : "Plan",
176
- name,
177
- };
178
- }
179
- function formatGroupLabel(kind, rawName) {
180
- const name = rawName.trim();
181
- if (!name) {
182
- return undefined;
183
- }
184
- const prefix = kind.toLowerCase() === "prd" ? "PRD" : "Plan";
185
- return `${prefix}: ${name}`;
103
+ function normalizeSelectedKeys(input) {
104
+ const selected = new Set(input.selectedKeys);
105
+ const candidatesByKey = new Map(input.candidates.map((candidate) => [candidate.key, candidate]));
106
+ const orderedSelected = input.choiceOrder.flatMap((key) => {
107
+ const candidate = candidatesByKey.get(key);
108
+ return candidate && selected.has(key) ? [candidate] : [];
109
+ });
110
+ return normalizeWorkItemSelection({
111
+ candidates: input.candidates,
112
+ selected: orderedSelected,
113
+ maxItems: input.maxItems,
114
+ expandGroups: false,
115
+ strictMax: true,
116
+ });
186
117
  }
@@ -0,0 +1,406 @@
1
+ import { orderWorkItemsByDependencies, } from "./workItems.js";
2
+ export function buildWorkItemSelectionPlan(input) {
3
+ const context = createPlanningContext(input);
4
+ const recommended = input.proposed
5
+ ? normalizeWithContext(context, {
6
+ selected: input.proposed,
7
+ maxItems: input.maxItems,
8
+ expandGroups: true,
9
+ })
10
+ : emptyNormalizedSelection();
11
+ const plannedSections = context.sections.map((section) => {
12
+ const sectionSeries = buildSectionSeries({
13
+ context,
14
+ section,
15
+ maxItems: input.maxItems,
16
+ });
17
+ return {
18
+ label: section.label,
19
+ candidates: section.candidates,
20
+ recommended: sectionSeries.queue,
21
+ blocked: section.candidates.flatMap((candidate) => blockerForCandidate(context, candidate)),
22
+ };
23
+ });
24
+ return {
25
+ sections: plannedSections,
26
+ recommendedSeries: plannedSections
27
+ .map((section) => ({
28
+ section: section.label,
29
+ candidates: section.recommended,
30
+ blocked: section.blocked,
31
+ truncated: section.recommended.length > 0 &&
32
+ section.recommended.length === input.maxItems &&
33
+ section.candidates.some((candidate) => !section.recommended.some((item) => item.key === candidate.key)),
34
+ }))
35
+ .filter((series) => series.candidates.length > 0 || series.blocked.length > 0),
36
+ recommendedQueue: recommended.ok ? recommended.queue : recommended.queue,
37
+ candidateOrder: context.sections.flatMap((section) => section.candidates),
38
+ blockedCandidates: [...context.blockedByKey.values()],
39
+ cycleKeys: recommended.cycleKeys,
40
+ truncated: recommended.truncated,
41
+ };
42
+ }
43
+ export function normalizeWorkItemSelection(input) {
44
+ return normalizeWithContext(createPlanningContext(input), input);
45
+ }
46
+ export function buildSelectionSections(candidates) {
47
+ const sections = [];
48
+ const groupedSections = new Map();
49
+ let ungroupedSection;
50
+ for (const candidate of candidates) {
51
+ const group = detectSelectionGroup(candidate);
52
+ if (!group) {
53
+ if (!ungroupedSection) {
54
+ ungroupedSection = { candidates: [] };
55
+ sections.push(ungroupedSection);
56
+ }
57
+ ungroupedSection.candidates.push(candidate);
58
+ continue;
59
+ }
60
+ let section = groupedSections.get(group.key);
61
+ if (!section) {
62
+ section = { label: group.label, candidates: [] };
63
+ groupedSections.set(group.key, section);
64
+ sections.push(section);
65
+ }
66
+ section.candidates.push(candidate);
67
+ }
68
+ return sections.map((section) => ({
69
+ label: section.label,
70
+ candidates: orderCandidatesForPlanning(section.candidates),
71
+ }));
72
+ }
73
+ export function isDependencySatisfied(dependency, completedKeys = new Set()) {
74
+ return (completedKeys.has(dependency.key) ||
75
+ dependency.state?.toLowerCase() === "closed");
76
+ }
77
+ export function formatSelectionBlockers(blocked) {
78
+ return blocked
79
+ .map((item) => `#${item.candidate.number} blocked by ${item.blockers
80
+ .map(formatDependencyReference)
81
+ .join(", ")}`)
82
+ .join("; ");
83
+ }
84
+ function buildSectionSeries(input) {
85
+ const first = input.section.candidates[0];
86
+ if (!first) {
87
+ return emptyNormalizedSelection();
88
+ }
89
+ return normalizeWithContext(input.context, {
90
+ selected: [first],
91
+ maxItems: input.maxItems,
92
+ expandGroups: Boolean(input.section.label),
93
+ });
94
+ }
95
+ function normalizeWithContext(context, input) {
96
+ if (input.selected.length === 0) {
97
+ return emptyNormalizedSelection();
98
+ }
99
+ const selected = dedupeCandidates(input.selected.flatMap((candidate) => {
100
+ const resolved = context.candidateByKey.get(candidate.key);
101
+ return resolved ? [resolved] : [];
102
+ }));
103
+ const selectedOrder = new Map(selected.map((candidate, index) => [candidate.key, index]));
104
+ const targetKeys = selectionTargetKeys({
105
+ context,
106
+ selected,
107
+ expandGroups: input.expandGroups,
108
+ });
109
+ const targetIndex = new Map(targetKeys.map((key, index) => [key, index]));
110
+ const closureKeys = new Set();
111
+ const blockedByKey = new Map();
112
+ const visiting = new Set();
113
+ const resolved = new Map();
114
+ const includeCandidate = (key) => {
115
+ const cached = resolved.get(key);
116
+ if (cached !== undefined) {
117
+ return cached;
118
+ }
119
+ const candidate = context.candidateByKey.get(key);
120
+ if (!candidate) {
121
+ return false;
122
+ }
123
+ if (visiting.has(key)) {
124
+ return true;
125
+ }
126
+ visiting.add(key);
127
+ const blockers = [];
128
+ for (const dependency of candidate.blocked_by ?? []) {
129
+ const dependencyCandidate = context.candidateByKey.get(dependency.key);
130
+ if (dependencyCandidate) {
131
+ if (!includeCandidate(dependencyCandidate.key)) {
132
+ blockers.push(enrichDependencyFromCandidate(dependency, dependencyCandidate));
133
+ }
134
+ continue;
135
+ }
136
+ if (!isDependencySatisfied(dependency, context.completedKeys)) {
137
+ blockers.push(dependency);
138
+ }
139
+ }
140
+ visiting.delete(key);
141
+ const ok = blockers.length === 0;
142
+ if (ok) {
143
+ closureKeys.add(key);
144
+ }
145
+ else {
146
+ blockedByKey.set(candidate.key, {
147
+ candidate,
148
+ blockers: dedupeDependencies(blockers),
149
+ });
150
+ }
151
+ resolved.set(key, ok);
152
+ return ok;
153
+ };
154
+ for (const key of targetKeys) {
155
+ includeCandidate(key);
156
+ }
157
+ const preOrdered = [...closureKeys]
158
+ .flatMap((key) => {
159
+ const candidate = context.candidateByKey.get(key);
160
+ return candidate ? [candidate] : [];
161
+ })
162
+ .sort((a, b) => compareSelectionCandidates({
163
+ a,
164
+ b,
165
+ targetIndex,
166
+ selectedOrder,
167
+ canonicalIndex: context.canonicalIndex,
168
+ }));
169
+ const ordered = orderWorkItemsByDependencies(preOrdered);
170
+ const requiredCount = ordered.queue.length;
171
+ if (input.strictMax && requiredCount > input.maxItems) {
172
+ return {
173
+ ok: false,
174
+ error: `Selection expands to ${requiredCount} work item(s) after prerequisites; max is ${input.maxItems}.`,
175
+ queue: ordered.queue.slice(0, input.maxItems),
176
+ cycleKeys: ordered.cycleKeys,
177
+ blockedCandidates: [...blockedByKey.values()],
178
+ requiredCount,
179
+ truncated: true,
180
+ };
181
+ }
182
+ return {
183
+ ok: true,
184
+ queue: ordered.queue.slice(0, input.maxItems),
185
+ cycleKeys: ordered.cycleKeys,
186
+ blockedCandidates: [...blockedByKey.values()],
187
+ requiredCount,
188
+ truncated: requiredCount > input.maxItems,
189
+ };
190
+ }
191
+ function createPlanningContext(input) {
192
+ const sections = buildSelectionSections(input.candidates);
193
+ const candidateOrder = sections.flatMap((section) => section.candidates);
194
+ const candidateByKey = new Map(input.candidates.map((candidate) => [candidate.key, candidate]));
195
+ const sectionByCandidateKey = new Map();
196
+ const canonicalIndex = new Map();
197
+ const completedKeys = new Set(input.completedKeys ?? []);
198
+ for (const [index, candidate] of candidateOrder.entries()) {
199
+ canonicalIndex.set(candidate.key, index);
200
+ }
201
+ for (const section of sections) {
202
+ for (const candidate of section.candidates) {
203
+ sectionByCandidateKey.set(candidate.key, section);
204
+ }
205
+ }
206
+ const context = {
207
+ candidates: input.candidates,
208
+ candidateByKey,
209
+ completedKeys,
210
+ sections,
211
+ sectionByCandidateKey,
212
+ canonicalIndex,
213
+ blockedByKey: new Map(),
214
+ };
215
+ context.blockedByKey = collectBlockedCandidates(context);
216
+ return context;
217
+ }
218
+ function collectBlockedCandidates(context) {
219
+ const blockedByKey = new Map();
220
+ const visiting = new Set();
221
+ const blockersFor = (candidate) => {
222
+ const cached = blockedByKey.get(candidate.key);
223
+ if (cached) {
224
+ return cached.blockers;
225
+ }
226
+ if (visiting.has(candidate.key)) {
227
+ return [];
228
+ }
229
+ visiting.add(candidate.key);
230
+ const blockers = [];
231
+ for (const dependency of candidate.blocked_by ?? []) {
232
+ const dependencyCandidate = context.candidateByKey.get(dependency.key);
233
+ if (dependencyCandidate) {
234
+ if (blockersFor(dependencyCandidate).length > 0) {
235
+ blockers.push(enrichDependencyFromCandidate(dependency, dependencyCandidate));
236
+ }
237
+ continue;
238
+ }
239
+ if (!isDependencySatisfied(dependency, context.completedKeys)) {
240
+ blockers.push(dependency);
241
+ }
242
+ }
243
+ visiting.delete(candidate.key);
244
+ if (blockers.length > 0) {
245
+ blockedByKey.set(candidate.key, {
246
+ candidate,
247
+ blockers: dedupeDependencies(blockers),
248
+ });
249
+ }
250
+ return blockers;
251
+ };
252
+ for (const candidate of context.candidates) {
253
+ blockersFor(candidate);
254
+ }
255
+ return blockedByKey;
256
+ }
257
+ function selectionTargetKeys(input) {
258
+ const keys = [];
259
+ const seen = new Set();
260
+ for (const candidate of input.selected) {
261
+ const section = input.context.sectionByCandidateKey.get(candidate.key);
262
+ const sectionCandidates = input.expandGroups && section?.label ? section.candidates : [candidate];
263
+ for (const item of sectionCandidates) {
264
+ if (!seen.has(item.key)) {
265
+ seen.add(item.key);
266
+ keys.push(item.key);
267
+ }
268
+ }
269
+ }
270
+ return keys;
271
+ }
272
+ function orderCandidatesForPlanning(candidates) {
273
+ const byNumber = [...candidates].sort(compareByIssueNumber);
274
+ return orderWorkItemsByDependencies(byNumber).queue;
275
+ }
276
+ function compareSelectionCandidates(input) {
277
+ const aRank = selectionCandidateRank(input.a, input);
278
+ const bRank = selectionCandidateRank(input.b, input);
279
+ if (aRank !== bRank) {
280
+ return aRank - bRank;
281
+ }
282
+ return compareByIssueNumber(input.a, input.b);
283
+ }
284
+ function selectionCandidateRank(candidate, input) {
285
+ return (input.targetIndex.get(candidate.key) ??
286
+ input.selectedOrder.get(candidate.key) ??
287
+ input.canonicalIndex.get(candidate.key) ??
288
+ Number.MAX_SAFE_INTEGER);
289
+ }
290
+ function compareByIssueNumber(a, b) {
291
+ if (a.number !== b.number) {
292
+ return a.number - b.number;
293
+ }
294
+ return a.title.localeCompare(b.title);
295
+ }
296
+ function blockerForCandidate(context, candidate) {
297
+ const blocker = context.blockedByKey.get(candidate.key);
298
+ return blocker ? [blocker] : [];
299
+ }
300
+ function enrichDependencyFromCandidate(dependency, candidate) {
301
+ return {
302
+ ...dependency,
303
+ title: dependency.title ?? candidate.title,
304
+ state: dependency.state ?? "open",
305
+ };
306
+ }
307
+ function dedupeCandidates(candidates) {
308
+ const seen = new Set();
309
+ const deduped = [];
310
+ for (const candidate of candidates) {
311
+ if (seen.has(candidate.key)) {
312
+ continue;
313
+ }
314
+ seen.add(candidate.key);
315
+ deduped.push(candidate);
316
+ }
317
+ return deduped;
318
+ }
319
+ function dedupeDependencies(dependencies) {
320
+ const byKey = new Map();
321
+ for (const dependency of dependencies) {
322
+ byKey.set(dependency.key, dependency);
323
+ }
324
+ return [...byKey.values()];
325
+ }
326
+ function formatDependencyReference(dependency) {
327
+ const state = dependency.state ? ` (${dependency.state})` : "";
328
+ return `#${dependency.number}${state}`;
329
+ }
330
+ function emptyNormalizedSelection() {
331
+ return {
332
+ ok: true,
333
+ queue: [],
334
+ cycleKeys: [],
335
+ blockedCandidates: [],
336
+ requiredCount: 0,
337
+ truncated: false,
338
+ };
339
+ }
340
+ function detectSelectionGroup(candidate) {
341
+ if (candidate.parent) {
342
+ return {
343
+ key: `parent:${candidate.parent.key}`,
344
+ label: formatParentGroup(candidate.parent),
345
+ };
346
+ }
347
+ for (const label of candidate.labels ?? []) {
348
+ const group = parseGroupLabel(label);
349
+ if (group) {
350
+ return { key: `display:${group.toLowerCase()}`, label: group };
351
+ }
352
+ }
353
+ const titleGroup = parseBracketedTitleGroup(candidate.title);
354
+ return titleGroup
355
+ ? { key: `display:${titleGroup.toLowerCase()}`, label: titleGroup }
356
+ : undefined;
357
+ }
358
+ function formatParentGroup(parent) {
359
+ if (!parent) {
360
+ return "Parent";
361
+ }
362
+ if (!parent.title) {
363
+ return `Parent #${parent.number}`;
364
+ }
365
+ const titledGroup = parseLeadingTitleGroup(parent.title);
366
+ if (titledGroup) {
367
+ return `${titledGroup.kind} #${parent.number}: ${titledGroup.name}`;
368
+ }
369
+ return `Parent #${parent.number}: ${parent.title}`;
370
+ }
371
+ function parseGroupLabel(label) {
372
+ const match = /^(plan|prd)\s*[:/=-]\s*(.+)$/i.exec(label.trim());
373
+ if (!match) {
374
+ return undefined;
375
+ }
376
+ return formatGroupLabel(match[1], match[2]);
377
+ }
378
+ function parseBracketedTitleGroup(title) {
379
+ const match = /^\[(plan|prd)\s*[:/=-]\s*([^\]]+)\]/i.exec(title.trim());
380
+ if (!match) {
381
+ return undefined;
382
+ }
383
+ return formatGroupLabel(match[1], match[2]);
384
+ }
385
+ function parseLeadingTitleGroup(title) {
386
+ const match = /^(plan|prd)\s*[:/=-]\s*(.+)$/i.exec(title.trim());
387
+ if (!match) {
388
+ return undefined;
389
+ }
390
+ const name = match[2].trim();
391
+ if (!name) {
392
+ return undefined;
393
+ }
394
+ return {
395
+ kind: match[1].toLowerCase() === "prd" ? "PRD" : "Plan",
396
+ name,
397
+ };
398
+ }
399
+ function formatGroupLabel(kind, rawName) {
400
+ const name = rawName.trim();
401
+ if (!name) {
402
+ return undefined;
403
+ }
404
+ const prefix = kind.toLowerCase() === "prd" ? "PRD" : "Plan";
405
+ return `${prefix}: ${name}`;
406
+ }
@@ -123,7 +123,7 @@ async function listOpenGitHubIssueRecords(input) {
123
123
  repo: input.repo,
124
124
  excerptChars: input.excerptChars,
125
125
  }));
126
- attachBlockedByDependencies({ records, repo: input.repo });
126
+ await attachBlockedByDependencies({ records, repo: input.repo });
127
127
  return records;
128
128
  }
129
129
  export function filterAvailable(input) {
@@ -291,14 +291,29 @@ function normalizeIssueRecord(input) {
291
291
  details,
292
292
  };
293
293
  }
294
- function attachBlockedByDependencies(input) {
294
+ async function attachBlockedByDependencies(input) {
295
295
  const detailsByKey = new Map(input.records.map((record) => [record.candidate.key, record.details]));
296
+ const dependenciesByRecord = new Map();
297
+ const externalDependencies = new Map();
296
298
  for (const record of input.records) {
297
299
  const dependencies = parseBlockedByDependencies({
298
300
  body: record.body,
299
301
  repo: input.repo,
300
- }).map((dependency) => {
301
- const details = detailsByKey.get(dependency.key);
302
+ });
303
+ dependenciesByRecord.set(record, dependencies);
304
+ for (const dependency of dependencies) {
305
+ if (!detailsByKey.has(dependency.key)) {
306
+ externalDependencies.set(dependency.key, dependency);
307
+ }
308
+ }
309
+ }
310
+ const externalDetailsByKey = await fetchExternalDependencyDetails([
311
+ ...externalDependencies.values(),
312
+ ]);
313
+ for (const record of input.records) {
314
+ const dependencies = (dependenciesByRecord.get(record) ?? []).map((dependency) => {
315
+ const details = detailsByKey.get(dependency.key) ??
316
+ externalDetailsByKey.get(dependency.key);
302
317
  if (!details) {
303
318
  return dependency;
304
319
  }
@@ -316,6 +331,20 @@ function attachBlockedByDependencies(input) {
316
331
  }
317
332
  }
318
333
  }
334
+ async function fetchExternalDependencyDetails(dependencies) {
335
+ const detailsByKey = new Map();
336
+ for (const dependency of dependencies) {
337
+ const locator = parseGitHubIssueLocator(dependency.source.locator);
338
+ if (!locator) {
339
+ continue;
340
+ }
341
+ const details = await getGitHubIssue(locator);
342
+ if (details) {
343
+ detailsByKey.set(dependency.key, details);
344
+ }
345
+ }
346
+ return detailsByKey;
347
+ }
319
348
  function buildExcerpt(content, maxChars) {
320
349
  if (maxChars <= 0) {
321
350
  return undefined;
@@ -693,6 +722,26 @@ function parseGitHubRepo(repo) {
693
722
  }
694
723
  return { owner, repoName };
695
724
  }
725
+ function parseGitHubIssueLocator(locator) {
726
+ const separator = locator.lastIndexOf("#");
727
+ if (separator <= 0 || separator === locator.length - 1) {
728
+ return undefined;
729
+ }
730
+ const repo = locator.slice(0, separator);
731
+ const issueNumber = Number.parseInt(locator.slice(separator + 1), 10);
732
+ if (!Number.isInteger(issueNumber) || issueNumber <= 0) {
733
+ return undefined;
734
+ }
735
+ try {
736
+ return {
737
+ ...parseGitHubRepo(repo),
738
+ issueNumber,
739
+ };
740
+ }
741
+ catch {
742
+ return undefined;
743
+ }
744
+ }
696
745
  async function getGitHubParentIssue(input) {
697
746
  const result = await ghApi([
698
747
  `repos/${input.owner}/${input.repoName}/issues/${input.issueNumber}/parent`,
@@ -722,6 +771,15 @@ async function listGitHubSubIssues(input) {
722
771
  .filter((issue) => Boolean(issue)),
723
772
  };
724
773
  }
774
+ async function getGitHubIssue(input) {
775
+ const result = await ghApi([
776
+ `repos/${input.owner}/${input.repoName}/issues/${input.issueNumber}`,
777
+ ]);
778
+ if (!result.ok || result.value === undefined) {
779
+ return undefined;
780
+ }
781
+ return normalizeGitHubIssueDetails(result.value);
782
+ }
725
783
  async function ghApi(args) {
726
784
  const result = await execa("gh", [
727
785
  "api",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nyxa/nyx-agent",
3
- "version": "0.9.2",
3
+ "version": "0.9.3",
4
4
  "description": "A lightweight phase orchestrator for repeatedly launching coding agents with fresh context.",
5
5
  "type": "module",
6
6
  "repository": {