@nyxa/nyx-agent 0.2.1 → 0.3.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/dist/cli.js CHANGED
@@ -19,8 +19,9 @@ program
19
19
  .option("--model <name>", "model name")
20
20
  .option("--reasoning-level <level>", "harness-neutral reasoning level")
21
21
  .option("--max-iterations <count>", "maximum work items per run")
22
- .option("--work-items-source <source>", "work item source template: local-markdown, github, or custom")
22
+ .option("--work-items-source <source>", "work item source template: local or github")
23
23
  .option("--work-items-path <path>", "local markdown work item directory")
24
+ .option("--work-items-repository <owner/repo>", "GitHub work item repository")
24
25
  .action(async (options) => {
25
26
  await initCommand(options);
26
27
  });
@@ -24,8 +24,8 @@ export async function initCommand(options, projectRoot = process.cwd()) {
24
24
  if (resolved) {
25
25
  await writeText(configPath, buildConfigToml(resolved));
26
26
  }
27
- if (resolved?.workItemsSource === "local-markdown" && resolved.workItemsPath) {
28
- await maybeCreateSampleTask(root, resolved.workItemsPath, Boolean(options.missing));
27
+ if (resolved?.workItemsSource === "local" && resolved.workItemsPath) {
28
+ await ensureWorkItemsDirectory(root, resolved.workItemsPath);
29
29
  }
30
30
  console.log(pc.green("NyxAgent initialized."));
31
31
  console.log(`Config: ${relativeToProject(root, configPath)}`);
@@ -59,23 +59,36 @@ async function resolveInitOptions(options, root) {
59
59
  default: 5,
60
60
  required: true
61
61
  }));
62
- const workItemsSource = options.workItemsSource ??
63
- (await select({
62
+ const workItemsSource = options.workItemsSource
63
+ ? normalizeWorkItemsSource(options.workItemsSource)
64
+ : await select({
64
65
  message: "Work item source template",
65
66
  choices: [
66
- { name: "local-markdown", value: "local-markdown" },
67
- { name: "github", value: "github" },
68
- { name: "custom", value: "custom" }
67
+ { name: "local", value: "local" },
68
+ { name: "github", value: "github" }
69
69
  ]
70
- }));
70
+ });
71
71
  let workItemsPath = options.workItemsPath;
72
- if (workItemsSource === "local-markdown" && !workItemsPath) {
72
+ if (workItemsSource === "local" && !workItemsPath) {
73
73
  const issuesPath = path.join(root, "issues");
74
74
  workItemsPath = await input({
75
- message: "Local task path",
75
+ message: "Local work item path",
76
76
  default: (await pathExists(issuesPath)) ? "issues" : ".nyxagent/tasks"
77
77
  });
78
78
  }
79
+ if (workItemsSource !== "local" && workItemsPath) {
80
+ throw new Error("--work-items-path can only be used with local work items");
81
+ }
82
+ let workItemsRepository = options.workItemsRepository;
83
+ if (workItemsSource === "github" && !workItemsRepository) {
84
+ workItemsRepository = await input({
85
+ message: "GitHub repository",
86
+ default: ""
87
+ });
88
+ }
89
+ if (workItemsSource === "github") {
90
+ validateGitHubRepository(workItemsRepository);
91
+ }
79
92
  if (!Number.isInteger(maxIterations) || maxIterations <= 0) {
80
93
  throw new Error("max iterations must be a positive integer");
81
94
  }
@@ -85,9 +98,24 @@ async function resolveInitOptions(options, root) {
85
98
  reasoningLevel,
86
99
  maxIterations,
87
100
  workItemsSource,
88
- workItemsPath
101
+ workItemsPath,
102
+ workItemsRepository
89
103
  };
90
104
  }
105
+ function normalizeWorkItemsSource(source) {
106
+ if (source === "local" || source === "local-markdown") {
107
+ return "local";
108
+ }
109
+ if (source === "github") {
110
+ return source;
111
+ }
112
+ throw new Error('work item source must be "local" or "github"');
113
+ }
114
+ function validateGitHubRepository(repository) {
115
+ if (!repository || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)) {
116
+ throw new Error('GitHub repository must use "owner/repo"');
117
+ }
118
+ }
91
119
  function getTemplatesDir() {
92
120
  const currentFile = fileURLToPath(import.meta.url);
93
121
  return path.resolve(path.dirname(currentFile), "../../templates/default");
@@ -108,31 +136,15 @@ async function copyTemplateTree(sourceDir, destinationDir, missingOnly) {
108
136
  await writeText(destination, await readText(source));
109
137
  }
110
138
  }
111
- async function maybeCreateSampleTask(root, taskPath, missingOnly) {
139
+ async function ensureWorkItemsDirectory(root, taskPath) {
112
140
  const absoluteTaskPath = path.resolve(root, taskPath);
113
- await ensureDir(absoluteTaskPath);
114
- const sampleTaskPath = path.join(absoluteTaskPath, "TASK-0001.md");
115
- if (missingOnly && (await pathExists(sampleTaskPath))) {
116
- return;
141
+ if (!(await pathExists(absoluteTaskPath))) {
142
+ await ensureDir(absoluteTaskPath);
117
143
  }
118
144
  const taskPathStat = await stat(absoluteTaskPath);
119
145
  if (!taskPathStat.isDirectory()) {
120
146
  throw new Error(`Work item path is not a directory: ${taskPath}`);
121
147
  }
122
- if (!(await pathExists(sampleTaskPath))) {
123
- await writeText(sampleTaskPath, [
124
- "---",
125
- "nyx_id: TASK-0001",
126
- "status: open",
127
- "title: Replace this sample task",
128
- "---",
129
- "",
130
- "# Replace this sample task",
131
- "",
132
- "Describe the task here.",
133
- ""
134
- ].join("\n"));
135
- }
136
148
  }
137
149
  function buildConfigToml(options) {
138
150
  const harness = buildHarnessToml(options.harness);
@@ -245,18 +257,18 @@ function buildCodexArgs(readOnly) {
245
257
  return args;
246
258
  }
247
259
  function buildWorkItemsToml(options) {
248
- if (options.workItemsSource === "local-markdown") {
260
+ if (options.workItemsSource === "local") {
249
261
  return `[work_items]
250
- source = "local-markdown"
251
- path = "${escapeTomlString(options.workItemsPath ?? ".nyxagent/tasks")}"`;
252
- }
253
- if (options.workItemsSource === "github") {
254
- return `[work_items]
255
- source = "github"
256
- repository = ""`;
262
+ source = "local"
263
+ path = "${escapeTomlString(options.workItemsPath ?? ".nyxagent/tasks")}"
264
+ max_candidates = 50
265
+ excerpt_chars = 800`;
257
266
  }
258
267
  return `[work_items]
259
- source = "custom"`;
268
+ source = "github"
269
+ repository = "${escapeTomlString(options.workItemsRepository ?? "")}"
270
+ max_candidates = 50
271
+ excerpt_chars = 800`;
260
272
  }
261
273
  function formatTomlArray(values) {
262
274
  return `[${values.map((value) => `"${escapeTomlString(value)}"`).join(", ")}]`;
@@ -28,6 +28,42 @@ const phaseSchema = z
28
28
  harness: harnessOverrideSchema.optional()
29
29
  })
30
30
  .passthrough();
31
+ const workItemsSourceSchema = z.preprocess((value) => (value === "local-markdown" ? "local" : value), z.enum(["local", "github"]));
32
+ const githubRepositoryPattern = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
33
+ const workItemsSchema = z
34
+ .object({
35
+ source: workItemsSourceSchema,
36
+ path: z.string().min(1).optional(),
37
+ repository: z.string().min(1).optional(),
38
+ max_candidates: z.number().int().positive().default(50),
39
+ excerpt_chars: z.number().int().nonnegative().default(800)
40
+ })
41
+ .passthrough()
42
+ .superRefine((workItems, ctx) => {
43
+ if (workItems.source === "local" && !workItems.path) {
44
+ ctx.addIssue({
45
+ code: "custom",
46
+ path: ["path"],
47
+ message: 'Local work items require "path"'
48
+ });
49
+ }
50
+ if (workItems.source === "github") {
51
+ if (!workItems.repository) {
52
+ ctx.addIssue({
53
+ code: "custom",
54
+ path: ["repository"],
55
+ message: 'GitHub work items require "repository"'
56
+ });
57
+ }
58
+ else if (!githubRepositoryPattern.test(workItems.repository)) {
59
+ ctx.addIssue({
60
+ code: "custom",
61
+ path: ["repository"],
62
+ message: 'GitHub repository must use "owner/repo"'
63
+ });
64
+ }
65
+ }
66
+ });
31
67
  export const nyxConfigSchema = z
32
68
  .object({
33
69
  workflow: z.object({
@@ -45,7 +81,7 @@ export const nyxConfigSchema = z
45
81
  max_attempts: 1,
46
82
  prompt: "prompts/repair-result.md"
47
83
  }),
48
- work_items: z.record(z.string(), z.unknown()).optional(),
84
+ work_items: workItemsSchema.optional(),
49
85
  phases: z.array(phaseSchema).min(1)
50
86
  })
51
87
  .superRefine((config, ctx) => {
@@ -34,6 +34,31 @@ async function buildRuntimeContract(input) {
34
34
  "Work item configuration:",
35
35
  "```json",
36
36
  JSON.stringify(input.config.work_items ?? {}, null, 2),
37
+ "```",
38
+ "",
39
+ "Available work items:",
40
+ "```json",
41
+ JSON.stringify(input.context.available_work_items ?? [], null, 2),
42
+ "```",
43
+ "",
44
+ "Selected work item queue:",
45
+ "```json",
46
+ JSON.stringify(input.context.selected_work_item_queue ?? [], null, 2),
47
+ "```",
48
+ "",
49
+ "Seen work item keys:",
50
+ "```json",
51
+ JSON.stringify(input.context.seen_work_item_keys ?? [], null, 2),
52
+ "```",
53
+ "",
54
+ "Completed work item keys:",
55
+ "```json",
56
+ JSON.stringify(input.context.completed_work_item_keys ?? [], null, 2),
57
+ "```",
58
+ "",
59
+ "Last completed work item:",
60
+ "```json",
61
+ JSON.stringify(input.context.last_completed_work_item ?? null, null, 2),
37
62
  "```"
38
63
  ];
39
64
  if (input.phase.transitions) {
@@ -0,0 +1,118 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { writeJson } from "./files.js";
4
+ export async function readWorkItemLedger(nyxDir) {
5
+ const statePath = getLedgerPath(nyxDir);
6
+ let raw;
7
+ try {
8
+ raw = await readFile(statePath, "utf8");
9
+ }
10
+ catch (error) {
11
+ if (isNodeError(error) && error.code === "ENOENT") {
12
+ return createEmptyLedger();
13
+ }
14
+ throw error;
15
+ }
16
+ let parsed;
17
+ try {
18
+ parsed = JSON.parse(raw);
19
+ }
20
+ catch (error) {
21
+ const message = error instanceof Error ? error.message : String(error);
22
+ throw new Error(`Failed to parse .nyxagent/state.json: ${message}`);
23
+ }
24
+ return normalizeLedger(parsed);
25
+ }
26
+ export async function writeWorkItemLedger(nyxDir, ledger) {
27
+ await writeJson(getLedgerPath(nyxDir), ledger);
28
+ }
29
+ export function markWorkItemCompleted(input) {
30
+ const record = normalizeCompletedRecord(input.workItem, input.completedAt ?? new Date().toISOString());
31
+ if (!record) {
32
+ return input.ledger;
33
+ }
34
+ const completedWorkItemKeys = uniqueStrings([
35
+ ...input.ledger.completed_work_item_keys,
36
+ record.key
37
+ ]);
38
+ const previousHistory = input.ledger.completed_work_items.filter((item) => item.key !== record.key);
39
+ return {
40
+ completed_work_item_keys: completedWorkItemKeys,
41
+ last_completed_work_item: record,
42
+ completed_work_items: [...previousHistory, record]
43
+ };
44
+ }
45
+ function getLedgerPath(nyxDir) {
46
+ return path.join(nyxDir, "state.json");
47
+ }
48
+ function createEmptyLedger() {
49
+ return {
50
+ completed_work_item_keys: [],
51
+ last_completed_work_item: null,
52
+ completed_work_items: []
53
+ };
54
+ }
55
+ function normalizeLedger(value) {
56
+ if (!isRecord(value)) {
57
+ return createEmptyLedger();
58
+ }
59
+ const completedWorkItems = Array.isArray(value.completed_work_items)
60
+ ? value.completed_work_items
61
+ .map((item) => normalizeCompletedRecord(item))
62
+ .filter((item) => Boolean(item))
63
+ : [];
64
+ const completedKeys = uniqueStrings([
65
+ ...readStringArray(value.completed_work_item_keys),
66
+ ...completedWorkItems.map((item) => item.key)
67
+ ]);
68
+ const lastCompleted = normalizeCompletedRecord(value.last_completed_work_item) ??
69
+ completedWorkItems.at(-1);
70
+ return {
71
+ completed_work_item_keys: completedKeys,
72
+ last_completed_work_item: lastCompleted ?? null,
73
+ completed_work_items: completedWorkItems
74
+ };
75
+ }
76
+ function normalizeCompletedRecord(value, completedAt) {
77
+ if (!isRecord(value) || typeof value.key !== "string" || value.key.length === 0) {
78
+ return undefined;
79
+ }
80
+ const record = {
81
+ key: value.key,
82
+ completed_at: completedAt ??
83
+ (typeof value.completed_at === "string"
84
+ ? value.completed_at
85
+ : new Date().toISOString())
86
+ };
87
+ if (typeof value.title === "string" && value.title.length > 0) {
88
+ record.title = value.title;
89
+ }
90
+ if (typeof value.url === "string" && value.url.length > 0) {
91
+ record.url = value.url;
92
+ }
93
+ if (isRecord(value.source)) {
94
+ record.source = {};
95
+ if (typeof value.source.type === "string") {
96
+ record.source.type = value.source.type;
97
+ }
98
+ if (typeof value.source.locator === "string") {
99
+ record.source.locator = value.source.locator;
100
+ }
101
+ }
102
+ return record;
103
+ }
104
+ function readStringArray(value) {
105
+ if (!Array.isArray(value)) {
106
+ return [];
107
+ }
108
+ return value.filter((item) => typeof item === "string");
109
+ }
110
+ function uniqueStrings(values) {
111
+ return [...new Set(values)];
112
+ }
113
+ function isRecord(value) {
114
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
115
+ }
116
+ function isNodeError(error) {
117
+ return error instanceof Error && "code" in error;
118
+ }
@@ -8,6 +8,7 @@ import { parseNyxAgentResult, getOutcome } from "./parseResult.js";
8
8
  import { resolveNyxPath } from "./paths.js";
9
9
  import { renderTemplate } from "./renderTemplate.js";
10
10
  import { validateAgainstSchema } from "./validateResult.js";
11
+ import { validateWorkItemIdentity, validateWorkItemQueue } from "./validateWorkItem.js";
11
12
  export async function runPhase(input) {
12
13
  const phaseDir = path.join(input.iterationDir, "phases", input.phase.id);
13
14
  await ensureDir(phaseDir);
@@ -81,8 +82,12 @@ function buildContext(input) {
81
82
  phase: input.phase,
82
83
  workflow: input.config.workflow,
83
84
  work_items: input.config.work_items ?? {},
85
+ available_work_items: input.state.available_work_items ?? [],
86
+ selected_work_item_queue: input.state.selected_work_item_queue ?? [],
84
87
  work_item: input.state.work_item ?? {},
85
88
  seen_work_item_keys: input.state.seen_work_item_keys ?? [],
89
+ completed_work_item_keys: input.state.completed_work_item_keys ?? [],
90
+ last_completed_work_item: input.state.last_completed_work_item ?? null,
86
91
  phase_results: input.state.phase_results ?? {},
87
92
  state: input.state,
88
93
  model,
@@ -171,6 +176,34 @@ async function parseAndValidatePhaseResult(input) {
171
176
  error: `Phase "${input.input.phase.id}" has transitions but result has no string outcome`
172
177
  };
173
178
  }
179
+ const workItem = readObjectProperty(parsed.value, "work_item");
180
+ if (workItem !== undefined) {
181
+ const currentWorkItemKey = readObjectProperty(readObjectProperty(input.input.state, "work_item"), "key");
182
+ const workItemValidation = validateWorkItemIdentity({
183
+ config: input.input.config,
184
+ workItem,
185
+ availableWorkItems: readWorkItemCandidates(input.input.state.available_work_items),
186
+ seenWorkItemKeys: readStringArray(input.input.state.seen_work_item_keys),
187
+ completedWorkItemKeys: readStringArray(input.input.state.completed_work_item_keys),
188
+ allowKnownKey: typeof currentWorkItemKey === "string" ? currentWorkItemKey : undefined
189
+ });
190
+ if (!workItemValidation.ok) {
191
+ return workItemValidation;
192
+ }
193
+ }
194
+ const workItems = readObjectProperty(parsed.value, "work_items");
195
+ if (workItems !== undefined) {
196
+ const workItemQueueValidation = validateWorkItemQueue({
197
+ config: input.input.config,
198
+ workItems,
199
+ availableWorkItems: readWorkItemCandidates(input.input.state.available_work_items),
200
+ seenWorkItemKeys: readStringArray(input.input.state.seen_work_item_keys),
201
+ completedWorkItemKeys: readStringArray(input.input.state.completed_work_item_keys)
202
+ });
203
+ if (!workItemQueueValidation.ok) {
204
+ return workItemQueueValidation;
205
+ }
206
+ }
174
207
  await writeJson(path.join(input.phaseDir, "result.json"), parsed.value);
175
208
  return {
176
209
  ok: true,
@@ -242,3 +275,34 @@ async function repairStructuredResult(input) {
242
275
  error: lastError
243
276
  };
244
277
  }
278
+ function readObjectProperty(value, key) {
279
+ if (value &&
280
+ typeof value === "object" &&
281
+ Object.prototype.hasOwnProperty.call(value, key)) {
282
+ return value[key];
283
+ }
284
+ return undefined;
285
+ }
286
+ function readStringArray(value) {
287
+ if (!Array.isArray(value)) {
288
+ return undefined;
289
+ }
290
+ return value.filter((item) => typeof item === "string");
291
+ }
292
+ function readWorkItemCandidates(value) {
293
+ if (!Array.isArray(value)) {
294
+ return undefined;
295
+ }
296
+ return value.filter(isWorkItemCandidate);
297
+ }
298
+ function isWorkItemCandidate(value) {
299
+ const key = readObjectProperty(value, "key");
300
+ const title = readObjectProperty(value, "title");
301
+ const source = readObjectProperty(value, "source");
302
+ const type = readObjectProperty(source, "type");
303
+ const locator = readObjectProperty(source, "locator");
304
+ return (typeof key === "string" &&
305
+ typeof title === "string" &&
306
+ (type === "local" || type === "github") &&
307
+ typeof locator === "string");
308
+ }