@nyxa/nyx-agent 0.8.0 → 0.8.1

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), grouping
11
+ GitHub sub-issues under non-executable parent PRD/plan issues, then asks the
12
+ user to confirm the proposed checklist.
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.
@@ -13,13 +13,13 @@ 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 { 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}`);
@@ -281,6 +290,7 @@ async function runSelection(input) {
281
290
  number: candidate.number,
282
291
  title: candidate.title,
283
292
  labels: candidate.labels,
293
+ parent: candidate.parent,
284
294
  excerpt: candidate.excerpt,
285
295
  })),
286
296
  ],
@@ -867,6 +877,7 @@ function workItemSummary(item) {
867
877
  locator: item.source.locator,
868
878
  url: item.url,
869
879
  labels: item.labels,
880
+ parent: item.parent,
870
881
  };
871
882
  }
872
883
  function buildCommitMessage(item) {
@@ -878,35 +889,84 @@ function buildPrTitle(items) {
878
889
  }
879
890
  return `NyxAgent: ${items.length} work items`;
880
891
  }
881
- function buildPrBody(items) {
882
- const list = items
892
+ function normalizeWorkItemInventory(result) {
893
+ if (Array.isArray(result)) {
894
+ return { candidates: result, parents: [] };
895
+ }
896
+ return {
897
+ candidates: result.candidates,
898
+ parents: result.parents ?? [],
899
+ };
900
+ }
901
+ function buildPrBody(input) {
902
+ const list = input.items
883
903
  .map((item) => `- ${item.title} (#${item.number})`)
884
904
  .join("\n");
885
- const closes = items.map((item) => `Closes #${item.number}`).join("\n");
886
- return [
905
+ const completedParents = completedParentClosures(input);
906
+ const closeNumbers = [
907
+ ...uniqueNumbers(input.items.map((item) => item.number)),
908
+ ...uniqueNumbers(completedParents.map((parent) => parent.number)),
909
+ ];
910
+ const closes = closeNumbers.map((number) => `Closes #${number}`).join("\n");
911
+ const sections = [
887
912
  "Automated changes by NyxAgent.",
888
913
  "",
889
914
  "## Work items",
890
915
  "",
891
916
  list,
892
- "",
893
- closes,
894
- ].join("\n");
917
+ ];
918
+ if (completedParents.length > 0) {
919
+ sections.push("", "## Completed plans", "", completedParents
920
+ .map((parent) => `- ${parent.title ?? `Parent issue ${parent.number}`} (#${parent.number})`)
921
+ .join("\n"));
922
+ }
923
+ sections.push("", closes);
924
+ return sections.join("\n");
895
925
  }
896
926
  function buildDraftPrTitle(items) {
897
927
  return `[Draft] ${buildPrTitle(items)}`;
898
928
  }
899
- function buildDraftPrBody(items, reason) {
929
+ function buildDraftPrBody(input) {
900
930
  return [
901
931
  "> [!WARNING]",
902
932
  "> This pull request was opened automatically by NyxAgent after the run",
903
933
  "> **failed review**. The work is preserved here for a human to finish.",
904
934
  "",
905
- `**Why the run failed:** ${reason}`,
935
+ `**Why the run failed:** ${input.reason}`,
906
936
  "",
907
- buildPrBody(items),
937
+ buildPrBody({
938
+ items: input.items,
939
+ parents: input.parents,
940
+ }),
908
941
  ].join("\n");
909
942
  }
943
+ function completedParentClosures(input) {
944
+ const completedKeys = new Set(input.items.map((item) => item.key));
945
+ const completedParentKeys = new Set(input.items
946
+ .map((item) => item.parent?.key)
947
+ .filter((key) => Boolean(key)));
948
+ const completedParents = [];
949
+ for (const parent of input.parents ?? []) {
950
+ if (!parent.closable || !completedParentKeys.has(parent.key)) {
951
+ continue;
952
+ }
953
+ const openChildren = parent.children.filter((child) => child.state !== "closed");
954
+ const openContainerChild = openChildren.some((child) => !child.executable);
955
+ if (openContainerChild) {
956
+ continue;
957
+ }
958
+ const completedChildInThisPr = parent.children.some((child) => completedKeys.has(child.key));
959
+ if (!completedChildInThisPr ||
960
+ openChildren.some((child) => !completedKeys.has(child.key))) {
961
+ continue;
962
+ }
963
+ completedParents.push(parent);
964
+ }
965
+ return completedParents;
966
+ }
967
+ function uniqueNumbers(numbers) {
968
+ return [...new Set(numbers)];
969
+ }
910
970
  /** Render unresolved blockers as a bullet list to append to a failure message. */
911
971
  function formatBlockers(blockers) {
912
972
  if (blockers.length === 0) {
@@ -16,8 +16,8 @@ export async function confirmWorkItemSelection(input) {
16
16
  `Select at most ${input.maxItems} work item(s).`,
17
17
  shortcuts: {
18
18
  all: null,
19
- invert: null
20
- }
19
+ invert: null,
20
+ },
21
21
  });
22
22
  const selected = new Set(selectedKeys);
23
23
  return input.candidates.filter((candidate) => selected.has(candidate.key));
@@ -37,7 +37,7 @@ export function buildSelectionChoiceItems(input) {
37
37
  type: "choice",
38
38
  value: candidate.key,
39
39
  name: `#${candidate.number} ${candidate.title}${proposed ? " (agent)" : ""}`,
40
- checked: proposed
40
+ checked: proposed,
41
41
  });
42
42
  }
43
43
  return items;
@@ -49,10 +49,13 @@ function toInquirerChoice(item) {
49
49
  return {
50
50
  value: item.value,
51
51
  name: item.name,
52
- checked: item.checked
52
+ checked: item.checked,
53
53
  };
54
54
  }
55
55
  function detectPlanGroup(candidate) {
56
+ if (candidate.parent) {
57
+ return formatParentGroup(candidate.parent);
58
+ }
56
59
  for (const label of candidate.labels ?? []) {
57
60
  const group = parseGroupLabel(label);
58
61
  if (group) {
@@ -61,6 +64,14 @@ function detectPlanGroup(candidate) {
61
64
  }
62
65
  return parseBracketedTitleGroup(candidate.title);
63
66
  }
67
+ function formatParentGroup(parent) {
68
+ if (!parent) {
69
+ return "Parent";
70
+ }
71
+ return parent.title
72
+ ? `Parent #${parent.number}: ${parent.title}`
73
+ : `Parent #${parent.number}`;
74
+ }
64
75
  function parseGroupLabel(label) {
65
76
  const match = /^(plan|prd)\s*[:/=-]\s*(.+)$/i.exec(label.trim());
66
77
  if (!match) {
@@ -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.8.1",
4
4
  "description": "A lightweight phase orchestrator for repeatedly launching coding agents with fresh context.",
5
5
  "type": "module",
6
6
  "repository": {