@nyxa/nyx-agent 0.7.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.
@@ -12,41 +12,127 @@ export const SELECTION_SCHEMA = {
12
12
  work_item_keys: {
13
13
  type: "array",
14
14
  items: { type: "string" },
15
- description: "Ordered keys of the chosen candidates (prerequisites first)."
16
- }
15
+ description: "Ordered keys of the chosen candidates (prerequisites first).",
16
+ },
17
17
  },
18
18
  allOf: [
19
19
  {
20
20
  if: { properties: { outcome: { const: "selected" } } },
21
- then: { required: ["work_item_keys"] }
22
- }
21
+ then: { required: ["work_item_keys"] },
22
+ },
23
23
  ],
24
- additionalProperties: true
24
+ additionalProperties: true,
25
+ };
26
+ const evidenceSchema = {
27
+ type: "object",
28
+ properties: {
29
+ file: { type: "string" },
30
+ line: { type: "integer", minimum: 1 },
31
+ command: { type: "string" },
32
+ observation: { type: "string" },
33
+ detail: { type: "string" },
34
+ },
35
+ additionalProperties: true,
36
+ };
37
+ const findingSchema = {
38
+ type: "object",
39
+ required: ["title", "required_change", "confidence", "evidence"],
40
+ properties: {
41
+ title: { type: "string", minLength: 1 },
42
+ required_change: { type: "string", minLength: 1 },
43
+ confidence: {
44
+ type: "string",
45
+ enum: ["low", "medium", "high"],
46
+ },
47
+ evidence: {
48
+ type: "array",
49
+ minItems: 1,
50
+ items: evidenceSchema,
51
+ description: "Concrete evidence: file+line, command+observation, or an equivalent current-code observation.",
52
+ },
53
+ },
54
+ additionalProperties: true,
25
55
  };
26
- const reviewSchema = {
56
+ const findingArraySchema = {
57
+ type: "array",
58
+ items: findingSchema,
59
+ default: [],
60
+ };
61
+ const reviewDiscoverySchema = {
27
62
  $schema: "https://json-schema.org/draft/2020-12/schema",
28
63
  type: "object",
29
- required: ["outcome", "summary"],
64
+ required: [
65
+ "summary",
66
+ "blockers",
67
+ "test_gaps",
68
+ "advisory_findings",
69
+ "uncertain_findings",
70
+ "rejected_findings",
71
+ ],
30
72
  properties: {
31
- outcome: { type: "string", enum: ["approved", "changes_requested"] },
32
73
  summary: {
33
74
  type: "string",
34
75
  minLength: 1,
35
- description: "A brief assessment of the work."
76
+ description: "A brief assessment of the work.",
36
77
  },
37
- required_changes: {
78
+ blockers: findingArraySchema,
79
+ test_gaps: findingArraySchema,
80
+ advisory_findings: findingArraySchema,
81
+ uncertain_findings: findingArraySchema,
82
+ rejected_findings: findingArraySchema,
83
+ },
84
+ additionalProperties: true,
85
+ };
86
+ const reviewChallengeSchema = {
87
+ $schema: "https://json-schema.org/draft/2020-12/schema",
88
+ type: "object",
89
+ required: ["summary", "blockers", "rejected_findings"],
90
+ properties: {
91
+ summary: { type: "string", minLength: 1 },
92
+ blockers: findingArraySchema,
93
+ rejected_findings: findingArraySchema,
94
+ },
95
+ additionalProperties: true,
96
+ };
97
+ const validationSchema = {
98
+ $schema: "https://json-schema.org/draft/2020-12/schema",
99
+ type: "object",
100
+ required: ["summary", "validations"],
101
+ properties: {
102
+ summary: { type: "string", minLength: 1 },
103
+ validations: {
38
104
  type: "array",
39
- items: { type: "string" },
40
- description: 'Specific, actionable changes (required when outcome is "changes_requested").'
41
- }
105
+ items: {
106
+ type: "object",
107
+ required: ["blocker_title", "status", "evidence"],
108
+ properties: {
109
+ blocker_title: { type: "string", minLength: 1 },
110
+ status: {
111
+ type: "string",
112
+ enum: [
113
+ "resolved",
114
+ "unresolved",
115
+ "false_positive",
116
+ "regression_from_correction",
117
+ ],
118
+ },
119
+ required_change: {
120
+ type: "string",
121
+ description: "Required when the status remains unresolved or is a correction-caused regression.",
122
+ },
123
+ evidence: {
124
+ type: "array",
125
+ minItems: 1,
126
+ items: evidenceSchema,
127
+ },
128
+ },
129
+ additionalProperties: true,
130
+ },
131
+ },
42
132
  },
43
- allOf: [
44
- {
45
- if: { properties: { outcome: { const: "changes_requested" } } },
46
- then: { required: ["required_changes"] }
47
- }
48
- ],
49
- additionalProperties: true
133
+ additionalProperties: true,
50
134
  };
51
- export const REVIEW_SCHEMA = reviewSchema;
52
- export const GLOBAL_REVIEW_SCHEMA = reviewSchema;
135
+ export const REVIEW_DISCOVERY_SCHEMA = reviewDiscoverySchema;
136
+ export const GLOBAL_REVIEW_SCHEMA = reviewDiscoverySchema;
137
+ export const REVIEW_CHALLENGE_SCHEMA = reviewChallengeSchema;
138
+ export const REVIEW_VALIDATION_SCHEMA = validationSchema;
@@ -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.7.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": {