@nyxa/nyx-agent 0.8.0 → 0.9.0

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
@@ -7,8 +7,9 @@ GitHub issue at a time, each phase with fresh context.
7
7
 
8
8
  For every run NyxAgent:
9
9
 
10
- 1. **Selects** open GitHub issues to work on (read-only), then asks the user to
11
- confirm the proposed checklist.
10
+ 1. **Selects** executable open GitHub issues to work on (read-only), presenting
11
+ GitHub sub-issues in parent PRD/plan sections, then asks the user to confirm
12
+ the proposed checklist with the same section order.
12
13
  2. For each selected issue, in an isolated git **worktree**:
13
14
  - **implements** it (the agent — the only customizable prompt),
14
15
  - optionally **reviews** it in bounded discovery rounds, then revises only
@@ -20,7 +21,9 @@ For every run NyxAgent:
20
21
  The agent only implements, reviews, and revises. Every git/gh side effect —
21
22
  commit, push, pull request — is performed by the engine, so closing the loop
22
23
  never depends on the model. Issues are closed by GitHub when the PR merges
23
- (`Closes #n` in the PR body); the human merges the PR.
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
27
 
25
28
  The workflow shape is fixed (not configurable). Only `.nyxagent/prompts/execution.md`
26
29
  is editable.
@@ -11,15 +11,15 @@ 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 { confirmWorkItemSelection, } from "./selectionConfirmation.js";
14
+ import { buildSelectionSections, confirmWorkItemSelection, } from "./selectionConfirmation.js";
15
15
  import { createRunId } from "./time.js";
16
- import { filterAvailable, listGitHubIssues, resolveSelectedQueue, } from "./workItems.js";
16
+ import { filterAvailable, listGitHubWorkItemInventory, resolveSelectedQueue, } from "./workItems.js";
17
17
  const MAX_CANDIDATES = 50;
18
18
  const EXCERPT_CHARS = 800;
19
19
  const CORRECTION_VALIDATION_MAX_ATTEMPTS = 3;
20
20
  export function defaultPipelineDependencies() {
21
21
  return {
22
- listIssues: listGitHubIssues,
22
+ listIssues: listGitHubWorkItemInventory,
23
23
  runPhase: runAgentPhase,
24
24
  pushBranch,
25
25
  createPullRequest,
@@ -59,12 +59,13 @@ export async function runPipeline(input = {}, deps = defaultPipelineDependencies
59
59
  const ledger = await readWorkItemLedger(nyxDir);
60
60
  reporter.detail(`Completed work items already in ledger: ${ledger.completed_work_item_keys.length}`);
61
61
  // 1. Selection runs read-only in the main checkout, before any branch exists.
62
+ const inventory = normalizeWorkItemInventory(await deps.listIssues({
63
+ repo: config.tracker.repo,
64
+ maxCandidates: MAX_CANDIDATES,
65
+ excerptChars: EXCERPT_CHARS,
66
+ }));
62
67
  const candidates = filterAvailable({
63
- candidates: await deps.listIssues({
64
- repo: config.tracker.repo,
65
- maxCandidates: MAX_CANDIDATES,
66
- excerptChars: EXCERPT_CHARS,
67
- }),
68
+ candidates: inventory.candidates,
68
69
  completedKeys: ledger.completed_work_item_keys,
69
70
  });
70
71
  reporter.detail(`Available work item candidates: ${candidates.length}`);
@@ -187,7 +188,10 @@ export async function runPipeline(input = {}, deps = defaultPipelineDependencies
187
188
  base: git.base,
188
189
  head: git.branch,
189
190
  title: buildPrTitle(completed),
190
- body: buildPrBody(completed),
191
+ body: buildPrBody({
192
+ items: completed,
193
+ parents: inventory.parents,
194
+ }),
191
195
  });
192
196
  reporter.success(`\nPull request opened: ${prUrl}`);
193
197
  success = true;
@@ -203,6 +207,7 @@ export async function runPipeline(input = {}, deps = defaultPipelineDependencies
203
207
  producedCommits,
204
208
  completed,
205
209
  config,
210
+ parents: inventory.parents,
206
211
  deps,
207
212
  reporter,
208
213
  });
@@ -252,7 +257,11 @@ async function salvageFailedRun(input) {
252
257
  base: input.git.base,
253
258
  head: input.git.branch,
254
259
  title: buildDraftPrTitle(input.completed),
255
- body: buildDraftPrBody(input.completed, reason),
260
+ body: buildDraftPrBody({
261
+ items: input.completed,
262
+ parents: input.parents,
263
+ reason,
264
+ }),
256
265
  draft: true,
257
266
  });
258
267
  input.reporter.warn(`\nRun failed, but the work was salvaged into a DRAFT pull request: ${url}`);
@@ -276,12 +285,9 @@ async function runSelection(input) {
276
285
  ["Max work items this run", input.config.max_iterations],
277
286
  [
278
287
  "Available candidates",
279
- input.candidates.map((candidate) => ({
280
- key: candidate.key,
281
- number: candidate.number,
282
- title: candidate.title,
283
- labels: candidate.labels,
284
- excerpt: candidate.excerpt,
288
+ buildSelectionSections(input.candidates).map((section) => ({
289
+ section: section.label ?? "Ungrouped issues",
290
+ candidates: section.candidates.map(selectionCandidateSummary),
285
291
  })),
286
292
  ],
287
293
  ]);
@@ -867,6 +873,17 @@ function workItemSummary(item) {
867
873
  locator: item.source.locator,
868
874
  url: item.url,
869
875
  labels: item.labels,
876
+ parent: item.parent,
877
+ };
878
+ }
879
+ function selectionCandidateSummary(candidate) {
880
+ return {
881
+ key: candidate.key,
882
+ number: candidate.number,
883
+ title: candidate.title,
884
+ labels: candidate.labels,
885
+ parent: candidate.parent,
886
+ excerpt: candidate.excerpt,
870
887
  };
871
888
  }
872
889
  function buildCommitMessage(item) {
@@ -878,35 +895,84 @@ function buildPrTitle(items) {
878
895
  }
879
896
  return `NyxAgent: ${items.length} work items`;
880
897
  }
881
- function buildPrBody(items) {
882
- const list = items
898
+ function normalizeWorkItemInventory(result) {
899
+ if (Array.isArray(result)) {
900
+ return { candidates: result, parents: [] };
901
+ }
902
+ return {
903
+ candidates: result.candidates,
904
+ parents: result.parents ?? [],
905
+ };
906
+ }
907
+ function buildPrBody(input) {
908
+ const list = input.items
883
909
  .map((item) => `- ${item.title} (#${item.number})`)
884
910
  .join("\n");
885
- const closes = items.map((item) => `Closes #${item.number}`).join("\n");
886
- return [
911
+ const completedParents = completedParentClosures(input);
912
+ const closeNumbers = [
913
+ ...uniqueNumbers(input.items.map((item) => item.number)),
914
+ ...uniqueNumbers(completedParents.map((parent) => parent.number)),
915
+ ];
916
+ const closes = closeNumbers.map((number) => `Closes #${number}`).join("\n");
917
+ const sections = [
887
918
  "Automated changes by NyxAgent.",
888
919
  "",
889
920
  "## Work items",
890
921
  "",
891
922
  list,
892
- "",
893
- closes,
894
- ].join("\n");
923
+ ];
924
+ if (completedParents.length > 0) {
925
+ sections.push("", "## Completed plans", "", completedParents
926
+ .map((parent) => `- ${parent.title ?? `Parent issue ${parent.number}`} (#${parent.number})`)
927
+ .join("\n"));
928
+ }
929
+ sections.push("", closes);
930
+ return sections.join("\n");
895
931
  }
896
932
  function buildDraftPrTitle(items) {
897
933
  return `[Draft] ${buildPrTitle(items)}`;
898
934
  }
899
- function buildDraftPrBody(items, reason) {
935
+ function buildDraftPrBody(input) {
900
936
  return [
901
937
  "> [!WARNING]",
902
938
  "> This pull request was opened automatically by NyxAgent after the run",
903
939
  "> **failed review**. The work is preserved here for a human to finish.",
904
940
  "",
905
- `**Why the run failed:** ${reason}`,
941
+ `**Why the run failed:** ${input.reason}`,
906
942
  "",
907
- buildPrBody(items),
943
+ buildPrBody({
944
+ items: input.items,
945
+ parents: input.parents,
946
+ }),
908
947
  ].join("\n");
909
948
  }
949
+ function completedParentClosures(input) {
950
+ const completedKeys = new Set(input.items.map((item) => item.key));
951
+ const completedParentKeys = new Set(input.items
952
+ .map((item) => item.parent?.key)
953
+ .filter((key) => Boolean(key)));
954
+ const completedParents = [];
955
+ for (const parent of input.parents ?? []) {
956
+ if (!parent.closable || !completedParentKeys.has(parent.key)) {
957
+ continue;
958
+ }
959
+ const openChildren = parent.children.filter((child) => child.state !== "closed");
960
+ const openContainerChild = openChildren.some((child) => !child.executable);
961
+ if (openContainerChild) {
962
+ continue;
963
+ }
964
+ const completedChildInThisPr = parent.children.some((child) => completedKeys.has(child.key));
965
+ if (!completedChildInThisPr ||
966
+ openChildren.some((child) => !completedKeys.has(child.key))) {
967
+ continue;
968
+ }
969
+ completedParents.push(parent);
970
+ }
971
+ return completedParents;
972
+ }
973
+ function uniqueNumbers(numbers) {
974
+ return [...new Set(numbers)];
975
+ }
910
976
  /** Render unresolved blockers as a bullet list to append to a failure message. */
911
977
  function formatBlockers(blockers) {
912
978
  if (blockers.length === 0) {
@@ -7,41 +7,72 @@ export async function confirmWorkItemSelection(input) {
7
7
  if (!isInteractive) {
8
8
  throw new Error('Interactive work item selection requires a TTY. Re-run with "nyxagent run --yes" to accept the agent selection.');
9
9
  }
10
+ const choiceItems = buildSelectionChoiceItems(input);
10
11
  const selectedKeys = await checkbox({
11
12
  message: `Select work items to run (max ${input.maxItems})`,
12
- choices: buildSelectionChoiceItems(input).map(toInquirerChoice),
13
- pageSize: Math.min(Math.max(input.candidates.length, 7), 20),
13
+ choices: choiceItems.map(toInquirerChoice),
14
+ pageSize: Math.min(Math.max(choiceItems.length, 7), 20),
14
15
  required: false,
15
16
  validate: (selected) => selected.length <= input.maxItems ||
16
17
  `Select at most ${input.maxItems} work item(s).`,
17
18
  shortcuts: {
18
19
  all: null,
19
- invert: null
20
- }
20
+ invert: null,
21
+ },
21
22
  });
22
23
  const selected = new Set(selectedKeys);
23
- return input.candidates.filter((candidate) => selected.has(candidate.key));
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] : [];
31
+ });
24
32
  }
25
33
  export function buildSelectionChoiceItems(input) {
26
34
  const proposedKeys = new Set(input.proposed.map((item) => item.key));
27
35
  const items = [];
28
- let currentGroup;
29
- for (const candidate of input.candidates) {
30
- const group = detectPlanGroup(candidate);
31
- if (group && group !== currentGroup) {
32
- items.push({ type: "separator", label: group });
36
+ for (const section of buildSelectionSections(input.candidates)) {
37
+ if (section.label) {
38
+ items.push({ type: "separator", label: section.label });
39
+ }
40
+ for (const candidate of section.candidates) {
41
+ const proposed = proposedKeys.has(candidate.key);
42
+ items.push({
43
+ type: "choice",
44
+ value: candidate.key,
45
+ name: `#${candidate.number} ${candidate.title}${proposed ? " (agent)" : ""}`,
46
+ checked: proposed,
47
+ });
33
48
  }
34
- currentGroup = group;
35
- const proposed = proposedKeys.has(candidate.key);
36
- items.push({
37
- type: "choice",
38
- value: candidate.key,
39
- name: `#${candidate.number} ${candidate.title}${proposed ? " (agent)" : ""}`,
40
- checked: proposed
41
- });
42
49
  }
43
50
  return items;
44
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);
62
+ }
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);
71
+ }
72
+ section.candidates.push(candidate);
73
+ }
74
+ return sections;
75
+ }
45
76
  function toInquirerChoice(item) {
46
77
  if (item.type === "separator") {
47
78
  return new Separator(item.label);
@@ -49,17 +80,34 @@ function toInquirerChoice(item) {
49
80
  return {
50
81
  value: item.value,
51
82
  name: item.name,
52
- checked: item.checked
83
+ checked: item.checked,
53
84
  };
54
85
  }
55
- function detectPlanGroup(candidate) {
86
+ function detectSelectionGroup(candidate) {
87
+ if (candidate.parent) {
88
+ return {
89
+ key: `parent:${candidate.parent.key}`,
90
+ label: formatParentGroup(candidate.parent),
91
+ };
92
+ }
56
93
  for (const label of candidate.labels ?? []) {
57
94
  const group = parseGroupLabel(label);
58
95
  if (group) {
59
- return group;
96
+ return { key: `display:${group.toLowerCase()}`, label: group };
60
97
  }
61
98
  }
62
- return parseBracketedTitleGroup(candidate.title);
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
+ return parent.title
109
+ ? `Parent #${parent.number}: ${parent.title}`
110
+ : `Parent #${parent.number}`;
63
111
  }
64
112
  function parseGroupLabel(label) {
65
113
  const match = /^(plan|prd)\s*[:/=-]\s*(.+)$/i.exec(label.trim());
@@ -1,6 +1,96 @@
1
1
  /** Work items: lists GitHub issues via `gh`, normalizes them to candidates, and resolves the selected queue. */
2
2
  import { execa } from "execa";
3
3
  export async function listGitHubIssues(input) {
4
+ const records = await listOpenGitHubIssueRecords(input);
5
+ return records.map((record) => record.candidate);
6
+ }
7
+ export async function listGitHubWorkItemInventory(input) {
8
+ const records = await listOpenGitHubIssueRecords(input);
9
+ const issueByNumber = new Map(records.map((record) => [record.number, record]));
10
+ const nativeParentNumberByChild = new Map();
11
+ const explicitParentNumberByChild = new Map();
12
+ const parentDetails = new Map();
13
+ const subIssueResults = new Map();
14
+ const repoParts = parseGitHubRepo(input.repo);
15
+ for (const record of records) {
16
+ const parent = await getGitHubParentIssue({
17
+ ...repoParts,
18
+ issueNumber: record.number,
19
+ });
20
+ if (parent) {
21
+ nativeParentNumberByChild.set(record.number, parent.number);
22
+ parentDetails.set(parent.number, parent);
23
+ }
24
+ }
25
+ const numbersToInspectForSubIssues = new Set([
26
+ ...records.map((record) => record.number),
27
+ ...nativeParentNumberByChild.values(),
28
+ ]);
29
+ for (const issueNumber of numbersToInspectForSubIssues) {
30
+ const result = await listGitHubSubIssues({
31
+ ...repoParts,
32
+ issueNumber,
33
+ });
34
+ subIssueResults.set(issueNumber, result);
35
+ if (result.ok && result.issues.length > 0) {
36
+ const existing = issueByNumber.get(issueNumber);
37
+ if (existing) {
38
+ parentDetails.set(issueNumber, existing.details);
39
+ }
40
+ }
41
+ }
42
+ for (const record of records) {
43
+ if (nativeParentNumberByChild.has(record.number)) {
44
+ continue;
45
+ }
46
+ const explicitParent = parseExplicitParentReference(record);
47
+ if (explicitParent && explicitParent !== record.number) {
48
+ explicitParentNumberByChild.set(record.number, explicitParent);
49
+ const parentRecord = issueByNumber.get(explicitParent);
50
+ if (parentRecord) {
51
+ parentDetails.set(explicitParent, parentRecord.details);
52
+ }
53
+ }
54
+ }
55
+ const parentNumbers = new Set([
56
+ ...nativeParentNumberByChild.values(),
57
+ ...explicitParentNumberByChild.values(),
58
+ ]);
59
+ for (const [issueNumber, result] of subIssueResults) {
60
+ if (result.ok && result.issues.length > 0) {
61
+ parentNumbers.add(issueNumber);
62
+ }
63
+ }
64
+ const parentInventory = buildParentInventory({
65
+ repo: input.repo,
66
+ records,
67
+ parentNumbers,
68
+ nativeParentNumberByChild,
69
+ explicitParentNumberByChild,
70
+ parentDetails,
71
+ subIssueResults,
72
+ });
73
+ const parentByNumber = new Map(parentInventory.map((parent) => [parent.number, toCandidateParent(parent)]));
74
+ const candidates = records
75
+ .filter((record) => !parentNumbers.has(record.number))
76
+ .map((record) => {
77
+ const candidate = { ...record.candidate };
78
+ const parentNumber = nativeParentNumberByChild.get(record.number) ??
79
+ explicitParentNumberByChild.get(record.number);
80
+ const parent = parentNumber
81
+ ? parentByNumber.get(parentNumber)
82
+ : undefined;
83
+ if (parent) {
84
+ candidate.parent = parent;
85
+ }
86
+ return candidate;
87
+ });
88
+ return {
89
+ candidates,
90
+ parents: parentInventory,
91
+ };
92
+ }
93
+ async function listOpenGitHubIssueRecords(input) {
4
94
  const result = await execa("gh", [
5
95
  "issue",
6
96
  "list",
@@ -11,7 +101,7 @@ export async function listGitHubIssues(input) {
11
101
  "--limit",
12
102
  String(input.maxCandidates),
13
103
  "--json",
14
- "number,title,url,labels,updatedAt,body"
104
+ "number,title,url,labels,updatedAt,body,state",
15
105
  ], { reject: false });
16
106
  if (result.exitCode !== 0) {
17
107
  const detail = (result.stderr || result.stdout || "unknown error").trim();
@@ -28,12 +118,10 @@ export async function listGitHubIssues(input) {
28
118
  if (!Array.isArray(issues)) {
29
119
  throw new Error("gh issue list returned JSON that is not an array");
30
120
  }
31
- return issues
32
- .slice(0, input.maxCandidates)
33
- .map((issue) => normalizeIssue({
121
+ return issues.slice(0, input.maxCandidates).map((issue) => normalizeIssueRecord({
34
122
  issue,
35
123
  repo: input.repo,
36
- excerptChars: input.excerptChars
124
+ excerptChars: input.excerptChars,
37
125
  }));
38
126
  }
39
127
  export function filterAvailable(input) {
@@ -59,7 +147,7 @@ export function resolveSelectedQueue(input) {
59
147
  if (!candidate) {
60
148
  return {
61
149
  ok: false,
62
- error: `selection key "${key}" is not an available candidate`
150
+ error: `selection key "${key}" is not an available candidate`,
63
151
  };
64
152
  }
65
153
  seen.add(key);
@@ -67,7 +155,7 @@ export function resolveSelectedQueue(input) {
67
155
  }
68
156
  return { ok: true, queue };
69
157
  }
70
- function normalizeIssue(input) {
158
+ function normalizeIssueRecord(input) {
71
159
  if (!isRecord(input.issue)) {
72
160
  throw new Error("gh issue list returned a non-object issue");
73
161
  }
@@ -80,24 +168,53 @@ function normalizeIssue(input) {
80
168
  throw new Error(`GitHub issue #${number} is missing a title`);
81
169
  }
82
170
  const locator = `${input.repo}#${number}`;
171
+ const labels = normalizeLabels(input.issue.labels);
83
172
  const candidate = {
84
173
  key: `github:${locator}`,
85
174
  title,
86
175
  number,
87
176
  source: { type: "github", locator },
88
- labels: normalizeLabels(input.issue.labels)
177
+ labels,
89
178
  };
90
- if (typeof input.issue.url === "string" && input.issue.url.length > 0) {
91
- candidate.url = input.issue.url;
179
+ const url = typeof input.issue.url === "string"
180
+ ? input.issue.url
181
+ : typeof input.issue.html_url === "string"
182
+ ? input.issue.html_url
183
+ : undefined;
184
+ if (url && url.length > 0) {
185
+ candidate.url = url;
92
186
  }
93
- if (typeof input.issue.updatedAt === "string" &&
94
- input.issue.updatedAt.length > 0) {
95
- candidate.updated_at = input.issue.updatedAt;
187
+ const updatedAt = typeof input.issue.updatedAt === "string"
188
+ ? input.issue.updatedAt
189
+ : typeof input.issue.updated_at === "string"
190
+ ? input.issue.updated_at
191
+ : undefined;
192
+ if (updatedAt && updatedAt.length > 0) {
193
+ candidate.updated_at = updatedAt;
96
194
  }
195
+ let body;
97
196
  if (typeof input.issue.body === "string") {
98
- candidate.excerpt = buildExcerpt(input.issue.body, input.excerptChars);
197
+ body = input.issue.body;
198
+ candidate.excerpt = buildExcerpt(body, input.excerptChars);
99
199
  }
100
- return candidate;
200
+ const state = typeof input.issue.state === "string" ? input.issue.state : undefined;
201
+ const details = {
202
+ number,
203
+ title,
204
+ state,
205
+ };
206
+ if (typeof input.issue.id === "number" && Number.isInteger(input.issue.id)) {
207
+ details.id = input.issue.id;
208
+ }
209
+ return {
210
+ number,
211
+ title,
212
+ body,
213
+ state,
214
+ labels,
215
+ candidate,
216
+ details,
217
+ };
101
218
  }
102
219
  function buildExcerpt(content, maxChars) {
103
220
  if (maxChars <= 0) {
@@ -132,6 +249,223 @@ function normalizeLabels(labels) {
132
249
  .filter((label) => Boolean(label));
133
250
  return normalized.length > 0 ? normalized : undefined;
134
251
  }
252
+ function buildParentInventory(input) {
253
+ const parentNumbers = new Set([
254
+ ...input.parentNumbers,
255
+ ...input.nativeParentNumberByChild.values(),
256
+ ...input.explicitParentNumberByChild.values(),
257
+ ]);
258
+ const recordsByNumber = new Map(input.records.map((record) => [record.number, record]));
259
+ const parents = [];
260
+ for (const parentNumber of [...parentNumbers].sort((a, b) => a - b)) {
261
+ const nativeChildren = input.records
262
+ .filter((record) => input.nativeParentNumberByChild.get(record.number) === parentNumber)
263
+ .map((record) => issueDetailsToParentChild({
264
+ repo: input.repo,
265
+ issue: record.details,
266
+ parentNumbers,
267
+ }));
268
+ const explicitChildren = input.records
269
+ .filter((record) => input.explicitParentNumberByChild.get(record.number) === parentNumber)
270
+ .map((record) => issueDetailsToParentChild({
271
+ repo: input.repo,
272
+ issue: record.details,
273
+ parentNumbers,
274
+ }));
275
+ const subIssueResult = input.subIssueResults.get(parentNumber);
276
+ const children = subIssueResult?.ok && subIssueResult.issues.length > 0
277
+ ? subIssueResult.issues.map((issue) => issueDetailsToParentChild({
278
+ repo: input.repo,
279
+ issue,
280
+ parentNumbers,
281
+ }))
282
+ : [...nativeChildren, ...explicitChildren];
283
+ const relationship = nativeChildren.length > 0 ||
284
+ (subIssueResult?.ok && subIssueResult.issues.length > 0)
285
+ ? "github-sub-issue"
286
+ : "explicit-ref";
287
+ const parentDetail = input.parentDetails.get(parentNumber) ??
288
+ recordsByNumber.get(parentNumber)?.details;
289
+ const parentState = parentDetail?.state;
290
+ const closable = relationship === "github-sub-issue" &&
291
+ subIssueResult?.ok === true &&
292
+ subIssueResult.issues.length > 0 &&
293
+ parentState === "open";
294
+ parents.push({
295
+ ...buildParentMetadata({
296
+ repo: input.repo,
297
+ number: parentNumber,
298
+ title: parentDetail?.title,
299
+ relationship,
300
+ closable,
301
+ }),
302
+ state: parentState,
303
+ children: dedupeParentChildren(children),
304
+ });
305
+ }
306
+ return parents;
307
+ }
308
+ function buildParentMetadata(input) {
309
+ const locator = `${input.repo}#${input.number}`;
310
+ const parent = {
311
+ key: `github:${locator}`,
312
+ number: input.number,
313
+ source: { type: "github", locator },
314
+ relationship: input.relationship,
315
+ closable: input.closable,
316
+ };
317
+ if (input.title) {
318
+ parent.title = input.title;
319
+ }
320
+ return parent;
321
+ }
322
+ function toCandidateParent(parent) {
323
+ const candidateParent = {
324
+ key: parent.key,
325
+ number: parent.number,
326
+ source: parent.source,
327
+ relationship: parent.relationship,
328
+ closable: parent.closable,
329
+ };
330
+ if (parent.title) {
331
+ candidateParent.title = parent.title;
332
+ }
333
+ return candidateParent;
334
+ }
335
+ function issueDetailsToParentChild(input) {
336
+ const locator = `${input.repo}#${input.issue.number}`;
337
+ const child = {
338
+ key: `github:${locator}`,
339
+ number: input.issue.number,
340
+ executable: !input.parentNumbers.has(input.issue.number),
341
+ };
342
+ if (input.issue.title) {
343
+ child.title = input.issue.title;
344
+ }
345
+ if (input.issue.state) {
346
+ child.state = input.issue.state;
347
+ }
348
+ return child;
349
+ }
350
+ function dedupeParentChildren(children) {
351
+ const byNumber = new Map();
352
+ for (const child of children) {
353
+ byNumber.set(child.number, child);
354
+ }
355
+ return [...byNumber.values()].sort((a, b) => a.number - b.number);
356
+ }
357
+ function parseExplicitParentReference(record) {
358
+ const refs = new Set();
359
+ for (const label of record.labels ?? []) {
360
+ const ref = parseExplicitParentReferenceLine(label);
361
+ if (ref) {
362
+ refs.add(ref);
363
+ }
364
+ }
365
+ for (const line of record.body?.split(/\r?\n/) ?? []) {
366
+ const ref = parseExplicitParentReferenceLine(line);
367
+ if (ref) {
368
+ refs.add(ref);
369
+ }
370
+ }
371
+ return refs.size === 1 ? [...refs][0] : undefined;
372
+ }
373
+ function parseExplicitParentReferenceLine(line) {
374
+ const trimmed = line
375
+ .trim()
376
+ .replace(/^[-*]\s+/, "")
377
+ .replace(/^>\s+/, "");
378
+ 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);
380
+ if (!match) {
381
+ return undefined;
382
+ }
383
+ const number = Number.parseInt(match[1], 10);
384
+ return Number.isInteger(number) && number > 0 ? number : undefined;
385
+ }
386
+ function parseGitHubRepo(repo) {
387
+ const [owner, repoName, ...rest] = repo.split("/");
388
+ if (!owner || !repoName || rest.length > 0) {
389
+ throw new Error(`GitHub repo must be in owner/repo form: ${repo}`);
390
+ }
391
+ return { owner, repoName };
392
+ }
393
+ async function getGitHubParentIssue(input) {
394
+ const result = await ghApi([
395
+ `repos/${input.owner}/${input.repoName}/issues/${input.issueNumber}/parent`,
396
+ ]);
397
+ if (!result.ok || result.value === undefined) {
398
+ return undefined;
399
+ }
400
+ return normalizeGitHubIssueDetails(result.value);
401
+ }
402
+ async function listGitHubSubIssues(input) {
403
+ const result = await ghApi([
404
+ `repos/${input.owner}/${input.repoName}/issues/${input.issueNumber}/sub_issues?per_page=100`,
405
+ ]);
406
+ if (!result.ok) {
407
+ return { ok: false, issues: [] };
408
+ }
409
+ if (result.value === undefined) {
410
+ return { ok: true, issues: [] };
411
+ }
412
+ if (!Array.isArray(result.value)) {
413
+ return { ok: false, issues: [] };
414
+ }
415
+ return {
416
+ ok: true,
417
+ issues: result.value
418
+ .map((issue) => normalizeGitHubIssueDetails(issue))
419
+ .filter((issue) => Boolean(issue)),
420
+ };
421
+ }
422
+ async function ghApi(args) {
423
+ const result = await execa("gh", [
424
+ "api",
425
+ "-H",
426
+ "Accept: application/vnd.github+json",
427
+ "-H",
428
+ "X-GitHub-Api-Version: 2026-03-10",
429
+ ...args,
430
+ ], { reject: false });
431
+ if (result.exitCode !== 0) {
432
+ return isMissingGitHubRelation(result.stderr || result.stdout)
433
+ ? { ok: true, value: undefined }
434
+ : { ok: false };
435
+ }
436
+ if (result.stdout.trim().length === 0) {
437
+ return { ok: true, value: undefined };
438
+ }
439
+ try {
440
+ return { ok: true, value: JSON.parse(result.stdout) };
441
+ }
442
+ catch {
443
+ return { ok: false };
444
+ }
445
+ }
446
+ function normalizeGitHubIssueDetails(issue) {
447
+ if (!isRecord(issue)) {
448
+ return undefined;
449
+ }
450
+ const number = issue.number;
451
+ if (typeof number !== "number" || !Number.isInteger(number) || number <= 0) {
452
+ return undefined;
453
+ }
454
+ const details = { number };
455
+ if (typeof issue.title === "string" && issue.title.length > 0) {
456
+ details.title = issue.title;
457
+ }
458
+ if (typeof issue.state === "string" && issue.state.length > 0) {
459
+ details.state = issue.state;
460
+ }
461
+ if (typeof issue.id === "number" && Number.isInteger(issue.id)) {
462
+ details.id = issue.id;
463
+ }
464
+ return details;
465
+ }
466
+ function isMissingGitHubRelation(output) {
467
+ return /HTTP\s+(404|410)\b|not found|gone/i.test(output);
468
+ }
135
469
  function isRecord(value) {
136
470
  return Boolean(value && typeof value === "object" && !Array.isArray(value));
137
471
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nyxa/nyx-agent",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "A lightweight phase orchestrator for repeatedly launching coding agents with fresh context.",
5
5
  "type": "module",
6
6
  "repository": {