@nyxa/nyx-agent 0.9.0 → 0.9.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.
package/README.md CHANGED
@@ -21,9 +21,10 @@ For every run NyxAgent:
21
21
  The agent only implements, reviews, and revises. Every git/gh side effect —
22
22
  commit, push, pull request — is performed by the engine, so closing the loop
23
23
  never depends on the model. Issues are closed by GitHub when the PR merges
24
- (`Closes #n` in the PR body); parent PRD/plan issues are closed only when
25
- NyxAgent can prove the PR completes their remaining open executable children.
26
- The human merges the PR.
24
+ (`Closes #n` in the PR body); parent PRD/plan issues referenced explicitly from
25
+ child descriptions are grouped for selection, while parent close lines are added
26
+ only when NyxAgent can prove the PR completes their remaining open executable
27
+ children. The human merges the PR.
27
28
 
28
29
  The workflow shape is fixed (not configurable). Only `.nyxagent/prompts/execution.md`
29
30
  is editable.
@@ -13,7 +13,7 @@ import { REVIEW_CHALLENGE_SCHEMA, REVIEW_DISCOVERY_SCHEMA, GLOBAL_REVIEW_SCHEMA,
13
13
  import { commitAll, commitsAhead, createPullRequest, pushBranch, rangeDiff, stageAllAndDiff, } from "./scm.js";
14
14
  import { buildSelectionSections, confirmWorkItemSelection, } from "./selectionConfirmation.js";
15
15
  import { createRunId } from "./time.js";
16
- import { filterAvailable, listGitHubWorkItemInventory, resolveSelectedQueue, } from "./workItems.js";
16
+ import { filterAvailable, listGitHubWorkItemInventory, orderWorkItemsByDependencies, resolveSelectedQueue, } from "./workItems.js";
17
17
  const MAX_CANDIDATES = 50;
18
18
  const EXCERPT_CHARS = 800;
19
19
  const CORRECTION_VALIDATION_MAX_ATTEMPTS = 3;
@@ -96,7 +96,14 @@ export async function runPipeline(input = {}, deps = defaultPipelineDependencies
96
96
  reporter.info("No work items selected. Nothing to do.");
97
97
  return;
98
98
  }
99
- const planned = selected.slice(0, config.max_iterations);
99
+ const orderedSelection = orderWorkItemsByDependencies(selected);
100
+ if (orderedSelection.cycleKeys.length > 0) {
101
+ reporter.detail(`Dependency cycle detected among selected work items; preserving confirmed order inside cycle(s): ${formatDependencyCycles({
102
+ cycles: orderedSelection.cycleKeys,
103
+ items: selected,
104
+ })}`);
105
+ }
106
+ const planned = orderedSelection.queue.slice(0, config.max_iterations);
100
107
  reporter.info(`Selected ${planned.length} work item(s):`);
101
108
  for (const item of planned) {
102
109
  reporter.info(` - ${item.title} (#${item.number})`);
@@ -874,6 +881,7 @@ function workItemSummary(item) {
874
881
  url: item.url,
875
882
  labels: item.labels,
876
883
  parent: item.parent,
884
+ blocked_by: item.blocked_by,
877
885
  };
878
886
  }
879
887
  function selectionCandidateSummary(candidate) {
@@ -883,6 +891,7 @@ function selectionCandidateSummary(candidate) {
883
891
  title: candidate.title,
884
892
  labels: candidate.labels,
885
893
  parent: candidate.parent,
894
+ blocked_by: candidate.blocked_by,
886
895
  excerpt: candidate.excerpt,
887
896
  };
888
897
  }
@@ -973,6 +982,17 @@ function completedParentClosures(input) {
973
982
  function uniqueNumbers(numbers) {
974
983
  return [...new Set(numbers)];
975
984
  }
985
+ function formatDependencyCycles(input) {
986
+ const itemByKey = new Map(input.items.map((item) => [item.key, item]));
987
+ return input.cycles
988
+ .map((cycle) => cycle
989
+ .map((key) => {
990
+ const item = itemByKey.get(key);
991
+ return item ? `#${item.number}` : key;
992
+ })
993
+ .join(" -> "))
994
+ .join("; ");
995
+ }
976
996
  /** Render unresolved blockers as a bullet list to append to a failure message. */
977
997
  function formatBlockers(blockers) {
978
998
  if (blockers.length === 0) {
@@ -31,15 +31,49 @@ export async function confirmWorkItemSelection(input) {
31
31
  });
32
32
  }
33
33
  export function buildSelectionChoiceItems(input) {
34
- const proposedKeys = new Set(input.proposed.map((item) => item.key));
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));
35
46
  const items = [];
36
- for (const section of buildSelectionSections(input.candidates)) {
47
+ let activeSectionLabel;
48
+ activeSectionLabel = appendSelectionChoices({
49
+ items,
50
+ sections: buildSelectionSections(proposedCandidates),
51
+ proposedKeys,
52
+ activeSectionLabel,
53
+ });
54
+ appendSelectionChoices({
55
+ items,
56
+ sections: buildSelectionSections(remainingCandidates),
57
+ proposedKeys,
58
+ activeSectionLabel,
59
+ });
60
+ return items;
61
+ }
62
+ function appendSelectionChoices(input) {
63
+ let activeSectionLabel = input.activeSectionLabel;
64
+ for (const section of input.sections) {
37
65
  if (section.label) {
38
- items.push({ type: "separator", label: 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;
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
+ input.items.push({
43
77
  type: "choice",
44
78
  value: candidate.key,
45
79
  name: `#${candidate.number} ${candidate.title}${proposed ? " (agent)" : ""}`,
@@ -47,7 +81,7 @@ export function buildSelectionChoiceItems(input) {
47
81
  });
48
82
  }
49
83
  }
50
- return items;
84
+ return activeSectionLabel;
51
85
  }
52
86
  export function buildSelectionSections(candidates) {
53
87
  const sections = [];
@@ -105,9 +139,14 @@ function formatParentGroup(parent) {
105
139
  if (!parent) {
106
140
  return "Parent";
107
141
  }
108
- return parent.title
109
- ? `Parent #${parent.number}: ${parent.title}`
110
- : `Parent #${parent.number}`;
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}`;
111
150
  }
112
151
  function parseGroupLabel(label) {
113
152
  const match = /^(plan|prd)\s*[:/=-]\s*(.+)$/i.exec(label.trim());
@@ -123,6 +162,20 @@ function parseBracketedTitleGroup(title) {
123
162
  }
124
163
  return formatGroupLabel(match[1], match[2]);
125
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
+ }
126
179
  function formatGroupLabel(kind, rawName) {
127
180
  const name = rawName.trim();
128
181
  if (!name) {
@@ -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
+ 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,31 @@ function normalizeIssueRecord(input) {
216
291
  details,
217
292
  };
218
293
  }
294
+ function attachBlockedByDependencies(input) {
295
+ const detailsByKey = new Map(input.records.map((record) => [record.candidate.key, record.details]));
296
+ for (const record of input.records) {
297
+ const dependencies = parseBlockedByDependencies({
298
+ body: record.body,
299
+ repo: input.repo,
300
+ }).map((dependency) => {
301
+ const details = detailsByKey.get(dependency.key);
302
+ if (!details) {
303
+ return dependency;
304
+ }
305
+ const enriched = { ...dependency };
306
+ if (details.title) {
307
+ enriched.title = details.title;
308
+ }
309
+ if (details.state) {
310
+ enriched.state = details.state;
311
+ }
312
+ return enriched;
313
+ });
314
+ if (dependencies.length > 0) {
315
+ record.candidate.blocked_by = dependencies;
316
+ }
317
+ }
318
+ }
219
319
  function buildExcerpt(content, maxChars) {
220
320
  if (maxChars <= 0) {
221
321
  return undefined;
@@ -376,13 +476,216 @@ function parseExplicitParentReferenceLine(line) {
376
476
  .replace(/^[-*]\s+/, "")
377
477
  .replace(/^>\s+/, "");
378
478
  const issueRef = "(?:#|[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+#|https://github\\.com/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+/issues/)(\\d+)";
379
- const match = new RegExp(`^(?:parent(?:\\s+issue)?|prd|plan)\\s*(?::|=|-)?\\s*${issueRef}\\s*$`, "i").exec(trimmed);
479
+ const match = new RegExp(`^(?:parent(?:\\s+(?:issue|prd|plan))?|prd|plan)\\s*(?::|=|-)?\\s*${issueRef}\\s*$`, "i").exec(trimmed);
380
480
  if (!match) {
381
481
  return undefined;
382
482
  }
383
483
  const number = Number.parseInt(match[1], 10);
384
484
  return Number.isInteger(number) && number > 0 ? number : undefined;
385
485
  }
486
+ function parseBlockedByDependencies(input) {
487
+ if (!input.body) {
488
+ return [];
489
+ }
490
+ const dependencies = [];
491
+ let blockedBySectionLevel;
492
+ for (const line of input.body.split(/\r?\n/)) {
493
+ const heading = parseMarkdownHeading(line);
494
+ if (heading) {
495
+ if (blockedBySectionLevel !== undefined &&
496
+ heading.level <= blockedBySectionLevel) {
497
+ blockedBySectionLevel = undefined;
498
+ }
499
+ if (isBlockedByHeading(heading.title)) {
500
+ blockedBySectionLevel = heading.level;
501
+ }
502
+ continue;
503
+ }
504
+ dependencies.push(...parseDirectBlockedByLine({
505
+ line,
506
+ repo: input.repo,
507
+ }));
508
+ if (blockedBySectionLevel !== undefined) {
509
+ dependencies.push(...parseBlockedBySectionBullet({
510
+ line,
511
+ repo: input.repo,
512
+ }));
513
+ }
514
+ }
515
+ return dedupeDependencies(dependencies);
516
+ }
517
+ function parseDirectBlockedByLine(input) {
518
+ const line = normalizeMarkdownLinePrefix(input.line);
519
+ const match = /^blocked\s+by\s*:\s*(.+)$/i.exec(line);
520
+ return match
521
+ ? parseIssueReferences({ text: match[1], repo: input.repo })
522
+ : [];
523
+ }
524
+ function parseBlockedBySectionBullet(input) {
525
+ const line = normalizeMarkdownLinePrefix(input.line);
526
+ const match = /^[-*+]\s+(?:\[[ xX]\]\s+)?(.+)$/.exec(line);
527
+ return match
528
+ ? parseIssueReferences({ text: match[1], repo: input.repo })
529
+ : [];
530
+ }
531
+ function parseIssueReferences(input) {
532
+ const dependencies = [];
533
+ 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;
534
+ for (const match of input.text.matchAll(issueRefPattern)) {
535
+ const repo = match[2] ??
536
+ (match[4] && match[5] ? `${match[4]}/${match[5]}` : input.repo);
537
+ const rawNumber = match[1] ?? match[3] ?? match[6];
538
+ const number = Number.parseInt(rawNumber, 10);
539
+ if (!repo || !Number.isInteger(number) || number <= 0) {
540
+ continue;
541
+ }
542
+ const locator = `${repo}#${number}`;
543
+ dependencies.push({
544
+ key: `github:${locator}`,
545
+ number,
546
+ source: { type: "github", locator },
547
+ });
548
+ }
549
+ return dedupeDependencies(dependencies);
550
+ }
551
+ function parseMarkdownHeading(line) {
552
+ const match = /^\s{0,3}(#{1,6})\s+(.+?)\s*#*\s*$/.exec(line);
553
+ if (!match) {
554
+ return undefined;
555
+ }
556
+ return {
557
+ level: match[1].length,
558
+ title: match[2].trim().replace(/[::]\s*$/, ""),
559
+ };
560
+ }
561
+ function isBlockedByHeading(title) {
562
+ return /^blocked\s+by$/i.test(title.trim());
563
+ }
564
+ function normalizeMarkdownLinePrefix(line) {
565
+ return line.trim().replace(/^>\s+/, "").trim();
566
+ }
567
+ function dedupeDependencies(dependencies) {
568
+ const byKey = new Map();
569
+ for (const dependency of dependencies) {
570
+ byKey.set(dependency.key, dependency);
571
+ }
572
+ return [...byKey.values()];
573
+ }
574
+ function findDependencyComponents(input) {
575
+ const components = [];
576
+ const indexByKey = new Map();
577
+ const lowLinkByKey = new Map();
578
+ const stack = [];
579
+ const onStack = new Set();
580
+ let nextIndex = 0;
581
+ const visit = (key) => {
582
+ indexByKey.set(key, nextIndex);
583
+ lowLinkByKey.set(key, nextIndex);
584
+ nextIndex += 1;
585
+ stack.push(key);
586
+ onStack.add(key);
587
+ for (const dependencyKey of input.dependenciesByKey.get(key) ?? []) {
588
+ if (!indexByKey.has(dependencyKey)) {
589
+ visit(dependencyKey);
590
+ lowLinkByKey.set(key, Math.min(lowLinkByKey.get(key) ?? 0, lowLinkByKey.get(dependencyKey) ?? 0));
591
+ continue;
592
+ }
593
+ if (onStack.has(dependencyKey)) {
594
+ lowLinkByKey.set(key, Math.min(lowLinkByKey.get(key) ?? 0, indexByKey.get(dependencyKey) ?? 0));
595
+ }
596
+ }
597
+ if (lowLinkByKey.get(key) !== indexByKey.get(key)) {
598
+ return;
599
+ }
600
+ const component = [];
601
+ let member;
602
+ do {
603
+ member = stack.pop();
604
+ if (!member) {
605
+ break;
606
+ }
607
+ onStack.delete(member);
608
+ component.push(member);
609
+ } while (member !== key);
610
+ components.push(component);
611
+ };
612
+ for (const key of input.keys) {
613
+ if (!indexByKey.has(key)) {
614
+ visit(key);
615
+ }
616
+ }
617
+ return components;
618
+ }
619
+ function orderDependencyComponents(input) {
620
+ const indegree = input.components.map((_, index) => input.componentDependsOn.get(index)?.size ?? 0);
621
+ const available = input.components
622
+ .map((_, index) => index)
623
+ .filter((index) => indegree[index] === 0);
624
+ const ordered = [];
625
+ while (available.length > 0) {
626
+ available.sort((a, b) => compareDependencyComponents({
627
+ a,
628
+ b,
629
+ components: input.components,
630
+ itemByKey: input.itemByKey,
631
+ originalIndex: input.originalIndex,
632
+ }));
633
+ const component = available.shift();
634
+ if (component === undefined) {
635
+ break;
636
+ }
637
+ ordered.push(component);
638
+ for (const dependent of input.dependentsByComponent.get(component) ?? []) {
639
+ indegree[dependent] -= 1;
640
+ if (indegree[dependent] === 0) {
641
+ available.push(dependent);
642
+ }
643
+ }
644
+ }
645
+ if (ordered.length === input.components.length) {
646
+ return ordered;
647
+ }
648
+ const orderedSet = new Set(ordered);
649
+ return [
650
+ ...ordered,
651
+ ...input.components
652
+ .map((_, index) => index)
653
+ .filter((index) => !orderedSet.has(index))
654
+ .sort((a, b) => compareDependencyComponents({
655
+ a,
656
+ b,
657
+ components: input.components,
658
+ itemByKey: input.itemByKey,
659
+ originalIndex: input.originalIndex,
660
+ })),
661
+ ];
662
+ }
663
+ function compareDependencyComponents(input) {
664
+ const aIndex = minOriginalIndex(input.components[input.a], input.originalIndex);
665
+ const bIndex = minOriginalIndex(input.components[input.b], input.originalIndex);
666
+ if (aIndex !== bIndex) {
667
+ return aIndex - bIndex;
668
+ }
669
+ return (minIssueNumber(input.components[input.a], input.itemByKey) -
670
+ minIssueNumber(input.components[input.b], input.itemByKey));
671
+ }
672
+ function minOriginalIndex(keys, originalIndex) {
673
+ return Math.min(...keys.map((key) => originalIndex.get(key) ?? Number.MAX_SAFE_INTEGER));
674
+ }
675
+ function minIssueNumber(keys, itemByKey) {
676
+ return Math.min(...keys.map((key) => itemByKey.get(key)?.number ?? Number.MAX_SAFE_INTEGER));
677
+ }
678
+ function sortKeysByOriginalOrder(keys, originalIndex) {
679
+ return [...keys].sort((a, b) => (originalIndex.get(a) ?? Number.MAX_SAFE_INTEGER) -
680
+ (originalIndex.get(b) ?? Number.MAX_SAFE_INTEGER));
681
+ }
682
+ function isCyclicDependencyComponent(component, dependenciesByKey) {
683
+ if (component.length > 1) {
684
+ return true;
685
+ }
686
+ const key = component[0];
687
+ return (dependenciesByKey.get(key) ?? []).includes(key);
688
+ }
386
689
  function parseGitHubRepo(repo) {
387
690
  const [owner, repoName, ...rest] = repo.split("/");
388
691
  if (!owner || !repoName || rest.length > 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nyxa/nyx-agent",
3
- "version": "0.9.0",
3
+ "version": "0.9.2",
4
4
  "description": "A lightweight phase orchestrator for repeatedly launching coding agents with fresh context.",
5
5
  "type": "module",
6
6
  "repository": {