@nyxa/nyx-agent 0.9.1 → 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,7 +11,8 @@ 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
17
  import { filterAvailable, listGitHubWorkItemInventory, resolveSelectedQueue, } from "./workItems.js";
17
18
  const MAX_CANDIDATES = 50;
@@ -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,7 +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 planned = selected.slice(0, config.max_iterations);
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) {
125
+ reporter.detail(`Dependency cycle detected among selected work items; preserving confirmed order inside cycle(s): ${formatDependencyCycles({
126
+ cycles: finalSelection.cycleKeys,
127
+ items: selected,
128
+ })}`);
129
+ }
130
+ const planned = finalSelection.queue;
100
131
  reporter.info(`Selected ${planned.length} work item(s):`);
101
132
  for (const item of planned) {
102
133
  reporter.info(` - ${item.title} (#${item.number})`);
@@ -280,14 +311,30 @@ async function runSelection(input) {
280
311
  config: input.config,
281
312
  cliHarness: input.cliHarness,
282
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);
283
321
  const context = buildContextBlock([
284
322
  ["Repository", input.config.tracker.repo],
285
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
+ ],
286
332
  [
287
333
  "Available candidates",
288
- buildSelectionSections(input.candidates).map((section) => ({
334
+ selectionPlan.sections.map((section) => ({
289
335
  section: section.label ?? "Ungrouped issues",
290
- candidates: section.candidates.map(selectionCandidateSummary),
336
+ recommended_order: section.recommended.map(workItemReference),
337
+ candidates: section.candidates.map((candidate) => selectionCandidateSummary(candidate, candidateKeys, completedKeys)),
291
338
  })),
292
339
  ],
293
340
  ]);
@@ -321,7 +368,23 @@ async function runSelection(input) {
321
368
  if (!resolved.ok) {
322
369
  throw new Error(`Selection produced an invalid queue: ${resolved.error}`);
323
370
  }
324
- 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;
325
388
  }
326
389
  async function runExecution(input) {
327
390
  const agent = resolveAgentProfile({
@@ -874,18 +937,76 @@ function workItemSummary(item) {
874
937
  url: item.url,
875
938
  labels: item.labels,
876
939
  parent: item.parent,
940
+ blocked_by: item.blocked_by,
877
941
  };
878
942
  }
879
- 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) {
880
959
  return {
881
960
  key: candidate.key,
882
961
  number: candidate.number,
883
962
  title: candidate.title,
884
963
  labels: candidate.labels,
885
964
  parent: candidate.parent,
965
+ blocked_by: candidate.blocked_by,
966
+ dependency_status: dependencyStatusSummary({
967
+ dependencies: candidate.blocked_by ?? [],
968
+ candidateKeys,
969
+ completedKeys,
970
+ }),
886
971
  excerpt: candidate.excerpt,
887
972
  };
888
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
+ }
889
1010
  function buildCommitMessage(item) {
890
1011
  return `${item.title}\n\nWork item: ${item.source.locator}`;
891
1012
  }
@@ -973,6 +1094,17 @@ function completedParentClosures(input) {
973
1094
  function uniqueNumbers(numbers) {
974
1095
  return [...new Set(numbers)];
975
1096
  }
1097
+ function formatDependencyCycles(input) {
1098
+ const itemByKey = new Map(input.items.map((item) => [item.key, item]));
1099
+ return input.cycles
1100
+ .map((cycle) => cycle
1101
+ .map((key) => {
1102
+ const item = itemByKey.get(key);
1103
+ return item ? `#${item.number}` : key;
1104
+ })
1105
+ .join(" -> "))
1106
+ .join("; ");
1107
+ }
976
1108
  /** Render unresolved blockers as a bullet list to append to a failure message. */
977
1109
  function formatBlockers(blockers) {
978
1110
  if (blockers.length === 0) {
@@ -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,70 +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 proposedKeys = new Set(input.proposed.map((item) => item.key));
53
+ const plan = buildWorkItemSelectionPlan({
54
+ candidates: input.candidates,
55
+ proposed: input.proposed,
56
+ maxItems: input.maxItems ?? input.candidates.length,
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]));
35
60
  const items = [];
36
- for (const section of buildSelectionSections(input.candidates)) {
61
+ appendSelectionChoices({
62
+ items,
63
+ sections: plan.sections,
64
+ proposedKeys,
65
+ blockedByKey,
66
+ });
67
+ return items;
68
+ }
69
+ function appendSelectionChoices(input) {
70
+ for (const section of input.sections) {
37
71
  if (section.label) {
38
- items.push({ type: "separator", label: section.label });
72
+ input.items.push({ type: "separator", label: section.label });
39
73
  }
40
74
  for (const candidate of section.candidates) {
41
- const proposed = proposedKeys.has(candidate.key);
42
- items.push({
75
+ const proposed = input.proposedKeys.has(candidate.key);
76
+ const blocked = input.blockedByKey.get(candidate.key);
77
+ const item = {
43
78
  type: "choice",
44
79
  value: candidate.key,
45
80
  name: `#${candidate.number} ${candidate.title}${proposed ? " (agent)" : ""}`,
46
81
  checked: proposed,
47
- });
48
- }
49
- }
50
- return items;
51
- }
52
- export function buildSelectionSections(candidates) {
53
- const sections = [];
54
- const groupedSections = new Map();
55
- let ungroupedSection;
56
- for (const candidate of candidates) {
57
- const group = detectSelectionGroup(candidate);
58
- if (!group) {
59
- if (!ungroupedSection) {
60
- ungroupedSection = { candidates: [] };
61
- sections.push(ungroupedSection);
82
+ };
83
+ if (blocked) {
84
+ item.disabled = `blocked by ${blocked.blockers
85
+ .map((blocker) => `#${blocker.number}`)
86
+ .join(", ")}`;
62
87
  }
63
- ungroupedSection.candidates.push(candidate);
64
- continue;
65
- }
66
- let section = groupedSections.get(group.key);
67
- if (!section) {
68
- section = { label: group.label, candidates: [] };
69
- groupedSections.set(group.key, section);
70
- sections.push(section);
88
+ input.items.push(item);
71
89
  }
72
- section.candidates.push(candidate);
73
90
  }
74
- return sections;
75
91
  }
76
92
  function toInquirerChoice(item) {
77
93
  if (item.type === "separator") {
@@ -81,72 +97,21 @@ function toInquirerChoice(item) {
81
97
  value: item.value,
82
98
  name: item.name,
83
99
  checked: item.checked,
100
+ disabled: item.disabled,
84
101
  };
85
102
  }
86
- function detectSelectionGroup(candidate) {
87
- if (candidate.parent) {
88
- return {
89
- key: `parent:${candidate.parent.key}`,
90
- label: formatParentGroup(candidate.parent),
91
- };
92
- }
93
- for (const label of candidate.labels ?? []) {
94
- const group = parseGroupLabel(label);
95
- if (group) {
96
- return { key: `display:${group.toLowerCase()}`, label: group };
97
- }
98
- }
99
- const titleGroup = parseBracketedTitleGroup(candidate.title);
100
- return titleGroup
101
- ? { key: `display:${titleGroup.toLowerCase()}`, label: titleGroup }
102
- : undefined;
103
- }
104
- function formatParentGroup(parent) {
105
- if (!parent) {
106
- return "Parent";
107
- }
108
- if (!parent.title) {
109
- return `Parent #${parent.number}`;
110
- }
111
- const titledGroup = parseLeadingTitleGroup(parent.title);
112
- if (titledGroup) {
113
- return `${titledGroup.kind} #${parent.number}: ${titledGroup.name}`;
114
- }
115
- return `Parent #${parent.number}: ${parent.title}`;
116
- }
117
- function parseGroupLabel(label) {
118
- const match = /^(plan|prd)\s*[:/=-]\s*(.+)$/i.exec(label.trim());
119
- if (!match) {
120
- return undefined;
121
- }
122
- return formatGroupLabel(match[1], match[2]);
123
- }
124
- function parseBracketedTitleGroup(title) {
125
- const match = /^\[(plan|prd)\s*[:/=-]\s*([^\]]+)\]/i.exec(title.trim());
126
- if (!match) {
127
- return undefined;
128
- }
129
- return formatGroupLabel(match[1], match[2]);
130
- }
131
- function parseLeadingTitleGroup(title) {
132
- const match = /^(plan|prd)\s*[:/=-]\s*(.+)$/i.exec(title.trim());
133
- if (!match) {
134
- return undefined;
135
- }
136
- const name = match[2].trim();
137
- if (!name) {
138
- return undefined;
139
- }
140
- return {
141
- kind: match[1].toLowerCase() === "prd" ? "PRD" : "Plan",
142
- name,
143
- };
144
- }
145
- function formatGroupLabel(kind, rawName) {
146
- const name = rawName.trim();
147
- if (!name) {
148
- return undefined;
149
- }
150
- const prefix = kind.toLowerCase() === "prd" ? "PRD" : "Plan";
151
- 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
+ });
152
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
+ }
@@ -118,11 +118,13 @@ async function listOpenGitHubIssueRecords(input) {
118
118
  if (!Array.isArray(issues)) {
119
119
  throw new Error("gh issue list returned JSON that is not an array");
120
120
  }
121
- return issues.slice(0, input.maxCandidates).map((issue) => normalizeIssueRecord({
121
+ const records = issues.slice(0, input.maxCandidates).map((issue) => normalizeIssueRecord({
122
122
  issue,
123
123
  repo: input.repo,
124
124
  excerptChars: input.excerptChars,
125
125
  }));
126
+ await attachBlockedByDependencies({ records, repo: input.repo });
127
+ return records;
126
128
  }
127
129
  export function filterAvailable(input) {
128
130
  const completed = new Set(input.completedKeys);
@@ -155,6 +157,79 @@ export function resolveSelectedQueue(input) {
155
157
  }
156
158
  return { ok: true, queue };
157
159
  }
160
+ export function orderWorkItemsByDependencies(queue) {
161
+ const selectedKeys = new Set(queue.map((item) => item.key));
162
+ const itemByKey = new Map(queue.map((item) => [item.key, item]));
163
+ const originalIndex = new Map(queue.map((item, index) => [item.key, index]));
164
+ const dependenciesByKey = new Map();
165
+ for (const item of queue) {
166
+ const seen = new Set();
167
+ const dependencies = (item.blocked_by ?? [])
168
+ .map((dependency) => dependency.key)
169
+ .filter((key) => {
170
+ if (!selectedKeys.has(key) || seen.has(key)) {
171
+ return false;
172
+ }
173
+ seen.add(key);
174
+ return true;
175
+ });
176
+ dependenciesByKey.set(item.key, dependencies);
177
+ }
178
+ const components = findDependencyComponents({
179
+ keys: queue.map((item) => item.key),
180
+ dependenciesByKey,
181
+ });
182
+ const componentByKey = new Map();
183
+ for (const [index, component] of components.entries()) {
184
+ for (const key of component) {
185
+ componentByKey.set(key, index);
186
+ }
187
+ }
188
+ const componentDependsOn = new Map();
189
+ const dependentsByComponent = new Map();
190
+ for (const [key, dependencies] of dependenciesByKey) {
191
+ const component = componentByKey.get(key);
192
+ if (component === undefined) {
193
+ continue;
194
+ }
195
+ for (const dependencyKey of dependencies) {
196
+ const dependencyComponent = componentByKey.get(dependencyKey);
197
+ if (dependencyComponent === undefined ||
198
+ dependencyComponent === component) {
199
+ continue;
200
+ }
201
+ let dependsOn = componentDependsOn.get(component);
202
+ if (!dependsOn) {
203
+ dependsOn = new Set();
204
+ componentDependsOn.set(component, dependsOn);
205
+ }
206
+ dependsOn.add(dependencyComponent);
207
+ let dependents = dependentsByComponent.get(dependencyComponent);
208
+ if (!dependents) {
209
+ dependents = new Set();
210
+ dependentsByComponent.set(dependencyComponent, dependents);
211
+ }
212
+ dependents.add(component);
213
+ }
214
+ }
215
+ const orderedComponentIndexes = orderDependencyComponents({
216
+ components,
217
+ componentDependsOn,
218
+ dependentsByComponent,
219
+ itemByKey,
220
+ originalIndex,
221
+ });
222
+ const ordered = orderedComponentIndexes.flatMap((componentIndex) => sortKeysByOriginalOrder(components[componentIndex], originalIndex).flatMap((key) => {
223
+ const item = itemByKey.get(key);
224
+ return item ? [item] : [];
225
+ }));
226
+ return {
227
+ queue: ordered,
228
+ cycleKeys: components
229
+ .filter((component) => isCyclicDependencyComponent(component, dependenciesByKey))
230
+ .map((component) => sortKeysByOriginalOrder(component, originalIndex)),
231
+ };
232
+ }
158
233
  function normalizeIssueRecord(input) {
159
234
  if (!isRecord(input.issue)) {
160
235
  throw new Error("gh issue list returned a non-object issue");
@@ -216,6 +291,60 @@ function normalizeIssueRecord(input) {
216
291
  details,
217
292
  };
218
293
  }
294
+ async function attachBlockedByDependencies(input) {
295
+ const detailsByKey = new Map(input.records.map((record) => [record.candidate.key, record.details]));
296
+ const dependenciesByRecord = new Map();
297
+ const externalDependencies = new Map();
298
+ for (const record of input.records) {
299
+ const dependencies = parseBlockedByDependencies({
300
+ body: record.body,
301
+ repo: input.repo,
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);
317
+ if (!details) {
318
+ return dependency;
319
+ }
320
+ const enriched = { ...dependency };
321
+ if (details.title) {
322
+ enriched.title = details.title;
323
+ }
324
+ if (details.state) {
325
+ enriched.state = details.state;
326
+ }
327
+ return enriched;
328
+ });
329
+ if (dependencies.length > 0) {
330
+ record.candidate.blocked_by = dependencies;
331
+ }
332
+ }
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
+ }
219
348
  function buildExcerpt(content, maxChars) {
220
349
  if (maxChars <= 0) {
221
350
  return undefined;
@@ -383,6 +512,209 @@ function parseExplicitParentReferenceLine(line) {
383
512
  const number = Number.parseInt(match[1], 10);
384
513
  return Number.isInteger(number) && number > 0 ? number : undefined;
385
514
  }
515
+ function parseBlockedByDependencies(input) {
516
+ if (!input.body) {
517
+ return [];
518
+ }
519
+ const dependencies = [];
520
+ let blockedBySectionLevel;
521
+ for (const line of input.body.split(/\r?\n/)) {
522
+ const heading = parseMarkdownHeading(line);
523
+ if (heading) {
524
+ if (blockedBySectionLevel !== undefined &&
525
+ heading.level <= blockedBySectionLevel) {
526
+ blockedBySectionLevel = undefined;
527
+ }
528
+ if (isBlockedByHeading(heading.title)) {
529
+ blockedBySectionLevel = heading.level;
530
+ }
531
+ continue;
532
+ }
533
+ dependencies.push(...parseDirectBlockedByLine({
534
+ line,
535
+ repo: input.repo,
536
+ }));
537
+ if (blockedBySectionLevel !== undefined) {
538
+ dependencies.push(...parseBlockedBySectionBullet({
539
+ line,
540
+ repo: input.repo,
541
+ }));
542
+ }
543
+ }
544
+ return dedupeDependencies(dependencies);
545
+ }
546
+ function parseDirectBlockedByLine(input) {
547
+ const line = normalizeMarkdownLinePrefix(input.line);
548
+ const match = /^blocked\s+by\s*:\s*(.+)$/i.exec(line);
549
+ return match
550
+ ? parseIssueReferences({ text: match[1], repo: input.repo })
551
+ : [];
552
+ }
553
+ function parseBlockedBySectionBullet(input) {
554
+ const line = normalizeMarkdownLinePrefix(input.line);
555
+ const match = /^[-*+]\s+(?:\[[ xX]\]\s+)?(.+)$/.exec(line);
556
+ return match
557
+ ? parseIssueReferences({ text: match[1], repo: input.repo })
558
+ : [];
559
+ }
560
+ function parseIssueReferences(input) {
561
+ const dependencies = [];
562
+ const issueRefPattern = /#(\d+)|([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)#(\d+)|https:\/\/github\.com\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)\/issues\/(\d+)/gi;
563
+ for (const match of input.text.matchAll(issueRefPattern)) {
564
+ const repo = match[2] ??
565
+ (match[4] && match[5] ? `${match[4]}/${match[5]}` : input.repo);
566
+ const rawNumber = match[1] ?? match[3] ?? match[6];
567
+ const number = Number.parseInt(rawNumber, 10);
568
+ if (!repo || !Number.isInteger(number) || number <= 0) {
569
+ continue;
570
+ }
571
+ const locator = `${repo}#${number}`;
572
+ dependencies.push({
573
+ key: `github:${locator}`,
574
+ number,
575
+ source: { type: "github", locator },
576
+ });
577
+ }
578
+ return dedupeDependencies(dependencies);
579
+ }
580
+ function parseMarkdownHeading(line) {
581
+ const match = /^\s{0,3}(#{1,6})\s+(.+?)\s*#*\s*$/.exec(line);
582
+ if (!match) {
583
+ return undefined;
584
+ }
585
+ return {
586
+ level: match[1].length,
587
+ title: match[2].trim().replace(/[::]\s*$/, ""),
588
+ };
589
+ }
590
+ function isBlockedByHeading(title) {
591
+ return /^blocked\s+by$/i.test(title.trim());
592
+ }
593
+ function normalizeMarkdownLinePrefix(line) {
594
+ return line.trim().replace(/^>\s+/, "").trim();
595
+ }
596
+ function dedupeDependencies(dependencies) {
597
+ const byKey = new Map();
598
+ for (const dependency of dependencies) {
599
+ byKey.set(dependency.key, dependency);
600
+ }
601
+ return [...byKey.values()];
602
+ }
603
+ function findDependencyComponents(input) {
604
+ const components = [];
605
+ const indexByKey = new Map();
606
+ const lowLinkByKey = new Map();
607
+ const stack = [];
608
+ const onStack = new Set();
609
+ let nextIndex = 0;
610
+ const visit = (key) => {
611
+ indexByKey.set(key, nextIndex);
612
+ lowLinkByKey.set(key, nextIndex);
613
+ nextIndex += 1;
614
+ stack.push(key);
615
+ onStack.add(key);
616
+ for (const dependencyKey of input.dependenciesByKey.get(key) ?? []) {
617
+ if (!indexByKey.has(dependencyKey)) {
618
+ visit(dependencyKey);
619
+ lowLinkByKey.set(key, Math.min(lowLinkByKey.get(key) ?? 0, lowLinkByKey.get(dependencyKey) ?? 0));
620
+ continue;
621
+ }
622
+ if (onStack.has(dependencyKey)) {
623
+ lowLinkByKey.set(key, Math.min(lowLinkByKey.get(key) ?? 0, indexByKey.get(dependencyKey) ?? 0));
624
+ }
625
+ }
626
+ if (lowLinkByKey.get(key) !== indexByKey.get(key)) {
627
+ return;
628
+ }
629
+ const component = [];
630
+ let member;
631
+ do {
632
+ member = stack.pop();
633
+ if (!member) {
634
+ break;
635
+ }
636
+ onStack.delete(member);
637
+ component.push(member);
638
+ } while (member !== key);
639
+ components.push(component);
640
+ };
641
+ for (const key of input.keys) {
642
+ if (!indexByKey.has(key)) {
643
+ visit(key);
644
+ }
645
+ }
646
+ return components;
647
+ }
648
+ function orderDependencyComponents(input) {
649
+ const indegree = input.components.map((_, index) => input.componentDependsOn.get(index)?.size ?? 0);
650
+ const available = input.components
651
+ .map((_, index) => index)
652
+ .filter((index) => indegree[index] === 0);
653
+ const ordered = [];
654
+ while (available.length > 0) {
655
+ available.sort((a, b) => compareDependencyComponents({
656
+ a,
657
+ b,
658
+ components: input.components,
659
+ itemByKey: input.itemByKey,
660
+ originalIndex: input.originalIndex,
661
+ }));
662
+ const component = available.shift();
663
+ if (component === undefined) {
664
+ break;
665
+ }
666
+ ordered.push(component);
667
+ for (const dependent of input.dependentsByComponent.get(component) ?? []) {
668
+ indegree[dependent] -= 1;
669
+ if (indegree[dependent] === 0) {
670
+ available.push(dependent);
671
+ }
672
+ }
673
+ }
674
+ if (ordered.length === input.components.length) {
675
+ return ordered;
676
+ }
677
+ const orderedSet = new Set(ordered);
678
+ return [
679
+ ...ordered,
680
+ ...input.components
681
+ .map((_, index) => index)
682
+ .filter((index) => !orderedSet.has(index))
683
+ .sort((a, b) => compareDependencyComponents({
684
+ a,
685
+ b,
686
+ components: input.components,
687
+ itemByKey: input.itemByKey,
688
+ originalIndex: input.originalIndex,
689
+ })),
690
+ ];
691
+ }
692
+ function compareDependencyComponents(input) {
693
+ const aIndex = minOriginalIndex(input.components[input.a], input.originalIndex);
694
+ const bIndex = minOriginalIndex(input.components[input.b], input.originalIndex);
695
+ if (aIndex !== bIndex) {
696
+ return aIndex - bIndex;
697
+ }
698
+ return (minIssueNumber(input.components[input.a], input.itemByKey) -
699
+ minIssueNumber(input.components[input.b], input.itemByKey));
700
+ }
701
+ function minOriginalIndex(keys, originalIndex) {
702
+ return Math.min(...keys.map((key) => originalIndex.get(key) ?? Number.MAX_SAFE_INTEGER));
703
+ }
704
+ function minIssueNumber(keys, itemByKey) {
705
+ return Math.min(...keys.map((key) => itemByKey.get(key)?.number ?? Number.MAX_SAFE_INTEGER));
706
+ }
707
+ function sortKeysByOriginalOrder(keys, originalIndex) {
708
+ return [...keys].sort((a, b) => (originalIndex.get(a) ?? Number.MAX_SAFE_INTEGER) -
709
+ (originalIndex.get(b) ?? Number.MAX_SAFE_INTEGER));
710
+ }
711
+ function isCyclicDependencyComponent(component, dependenciesByKey) {
712
+ if (component.length > 1) {
713
+ return true;
714
+ }
715
+ const key = component[0];
716
+ return (dependenciesByKey.get(key) ?? []).includes(key);
717
+ }
386
718
  function parseGitHubRepo(repo) {
387
719
  const [owner, repoName, ...rest] = repo.split("/");
388
720
  if (!owner || !repoName || rest.length > 0) {
@@ -390,6 +722,26 @@ function parseGitHubRepo(repo) {
390
722
  }
391
723
  return { owner, repoName };
392
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
+ }
393
745
  async function getGitHubParentIssue(input) {
394
746
  const result = await ghApi([
395
747
  `repos/${input.owner}/${input.repoName}/issues/${input.issueNumber}/parent`,
@@ -419,6 +771,15 @@ async function listGitHubSubIssues(input) {
419
771
  .filter((issue) => Boolean(issue)),
420
772
  };
421
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
+ }
422
783
  async function ghApi(args) {
423
784
  const result = await execa("gh", [
424
785
  "api",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nyxa/nyx-agent",
3
- "version": "0.9.1",
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": {