@nyxa/nyx-agent 0.2.0 → 0.3.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/dist/cli.js CHANGED
@@ -1,13 +1,16 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from "commander";
3
+ import { createRequire } from "node:module";
3
4
  import pc from "picocolors";
4
5
  import { initCommand } from "./commands/init.js";
5
6
  import { runCommand } from "./commands/run.js";
7
+ const require = createRequire(import.meta.url);
8
+ const packageJson = require("../package.json");
6
9
  const program = new Command();
7
10
  program
8
11
  .name("nyxagent")
9
12
  .description("Run coding-agent workflows with fresh context per phase.")
10
- .version("0.1.0");
13
+ .version(packageJson.version);
11
14
  program
12
15
  .command("init")
13
16
  .description("Create a .nyxagent project configuration")
@@ -16,8 +19,9 @@ program
16
19
  .option("--model <name>", "model name")
17
20
  .option("--reasoning-level <level>", "harness-neutral reasoning level")
18
21
  .option("--max-iterations <count>", "maximum work items per run")
19
- .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")
20
23
  .option("--work-items-path <path>", "local markdown work item directory")
24
+ .option("--work-items-repository <owner/repo>", "GitHub work item repository")
21
25
  .action(async (options) => {
22
26
  await initCommand(options);
23
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,26 @@ 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
+ "Seen work item keys:",
45
+ "```json",
46
+ JSON.stringify(input.context.seen_work_item_keys ?? [], null, 2),
47
+ "```",
48
+ "",
49
+ "Completed work item keys:",
50
+ "```json",
51
+ JSON.stringify(input.context.completed_work_item_keys ?? [], null, 2),
52
+ "```",
53
+ "",
54
+ "Last completed work item:",
55
+ "```json",
56
+ JSON.stringify(input.context.last_completed_work_item ?? null, null, 2),
37
57
  "```"
38
58
  ];
39
59
  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 } 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,11 @@ 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 ?? [],
84
86
  work_item: input.state.work_item ?? {},
85
87
  seen_work_item_keys: input.state.seen_work_item_keys ?? [],
88
+ completed_work_item_keys: input.state.completed_work_item_keys ?? [],
89
+ last_completed_work_item: input.state.last_completed_work_item ?? null,
86
90
  phase_results: input.state.phase_results ?? {},
87
91
  state: input.state,
88
92
  model,
@@ -171,6 +175,21 @@ async function parseAndValidatePhaseResult(input) {
171
175
  error: `Phase "${input.input.phase.id}" has transitions but result has no string outcome`
172
176
  };
173
177
  }
178
+ const workItem = readObjectProperty(parsed.value, "work_item");
179
+ if (workItem !== undefined) {
180
+ const currentWorkItemKey = readObjectProperty(readObjectProperty(input.input.state, "work_item"), "key");
181
+ const workItemValidation = validateWorkItemIdentity({
182
+ config: input.input.config,
183
+ workItem,
184
+ availableWorkItems: readWorkItemCandidates(input.input.state.available_work_items),
185
+ seenWorkItemKeys: readStringArray(input.input.state.seen_work_item_keys),
186
+ completedWorkItemKeys: readStringArray(input.input.state.completed_work_item_keys),
187
+ allowKnownKey: typeof currentWorkItemKey === "string" ? currentWorkItemKey : undefined
188
+ });
189
+ if (!workItemValidation.ok) {
190
+ return workItemValidation;
191
+ }
192
+ }
174
193
  await writeJson(path.join(input.phaseDir, "result.json"), parsed.value);
175
194
  return {
176
195
  ok: true,
@@ -242,3 +261,34 @@ async function repairStructuredResult(input) {
242
261
  error: lastError
243
262
  };
244
263
  }
264
+ function readObjectProperty(value, key) {
265
+ if (value &&
266
+ typeof value === "object" &&
267
+ Object.prototype.hasOwnProperty.call(value, key)) {
268
+ return value[key];
269
+ }
270
+ return undefined;
271
+ }
272
+ function readStringArray(value) {
273
+ if (!Array.isArray(value)) {
274
+ return undefined;
275
+ }
276
+ return value.filter((item) => typeof item === "string");
277
+ }
278
+ function readWorkItemCandidates(value) {
279
+ if (!Array.isArray(value)) {
280
+ return undefined;
281
+ }
282
+ return value.filter(isWorkItemCandidate);
283
+ }
284
+ function isWorkItemCandidate(value) {
285
+ const key = readObjectProperty(value, "key");
286
+ const title = readObjectProperty(value, "title");
287
+ const source = readObjectProperty(value, "source");
288
+ const type = readObjectProperty(source, "type");
289
+ const locator = readObjectProperty(source, "locator");
290
+ return (typeof key === "string" &&
291
+ typeof title === "string" &&
292
+ (type === "local" || type === "github") &&
293
+ typeof locator === "string");
294
+ }
@@ -3,9 +3,11 @@ import pc from "picocolors";
3
3
  import { loadConfig } from "../config/loadConfig.js";
4
4
  import { ensureDir, writeJson } from "./files.js";
5
5
  import { getGitSnapshot } from "./git.js";
6
+ import { markWorkItemCompleted, readWorkItemLedger, writeWorkItemLedger } from "./ledger.js";
6
7
  import { getNyxDir, relativeToProject } from "./paths.js";
7
8
  import { runPhase } from "./runPhase.js";
8
9
  import { createRunId } from "./time.js";
10
+ import { filterAvailableWorkItems, listWorkItemCandidates } from "./workItems.js";
9
11
  export async function runWorkflow(options) {
10
12
  const projectRoot = path.resolve(options.projectRoot);
11
13
  const nyxDir = getNyxDir(projectRoot);
@@ -14,13 +16,17 @@ export async function runWorkflow(options) {
14
16
  const runId = createRunId();
15
17
  const runDir = path.join(nyxDir, "runs", runId);
16
18
  await ensureDir(runDir);
19
+ let ledger = await readWorkItemLedger(nyxDir);
20
+ await writeWorkItemLedger(nyxDir, ledger);
17
21
  const git = await getGitSnapshot(projectRoot);
18
22
  const runState = {
19
23
  run_id: runId,
20
24
  status: "running",
21
25
  current_iteration: 0,
22
26
  completed_iterations: 0,
23
- seen_work_item_keys: []
27
+ seen_work_item_keys: [],
28
+ completed_work_item_keys: [...ledger.completed_work_item_keys],
29
+ last_completed_work_item: ledger.last_completed_work_item
24
30
  };
25
31
  await writeJson(path.join(runDir, "run.json"), {
26
32
  run_id: runId,
@@ -45,10 +51,19 @@ export async function runWorkflow(options) {
45
51
  await writeJson(path.join(runDir, "state.json"), runState);
46
52
  const iterationDir = path.join(runDir, "iterations", String(iterationNumber).padStart(3, "0"));
47
53
  await ensureDir(iterationDir);
54
+ const availableWorkItems = await loadAvailableWorkItems({
55
+ projectRoot,
56
+ config,
57
+ runState,
58
+ ledger
59
+ });
48
60
  const iterationState = {
49
61
  iteration: iterationNumber,
50
62
  status: "running",
63
+ available_work_items: availableWorkItems,
51
64
  seen_work_item_keys: [...runState.seen_work_item_keys],
65
+ completed_work_item_keys: [...ledger.completed_work_item_keys],
66
+ last_completed_work_item: ledger.last_completed_work_item,
52
67
  phase_results: {},
53
68
  phase_visit_counts: {}
54
69
  };
@@ -122,6 +137,16 @@ export async function runWorkflow(options) {
122
137
  if (nextTarget === "stop_iteration" || nextTarget === "next_iteration") {
123
138
  iterationState.status = "completed";
124
139
  runState.completed_iterations += 1;
140
+ ledger = await completeIterationWorkItem({
141
+ nyxDir,
142
+ ledger,
143
+ runState,
144
+ iterationState
145
+ });
146
+ iterationState.completed_work_item_keys = [
147
+ ...ledger.completed_work_item_keys
148
+ ];
149
+ iterationState.last_completed_work_item = ledger.last_completed_work_item;
125
150
  await writeJson(iterationStateFile, iterationState);
126
151
  await writeJson(path.join(runDir, "state.json"), runState);
127
152
  break;
@@ -148,6 +173,29 @@ function resolveNextTarget(phase, outcome) {
148
173
  }
149
174
  return phase.next;
150
175
  }
176
+ async function loadAvailableWorkItems(input) {
177
+ const candidates = await listWorkItemCandidates({
178
+ projectRoot: input.projectRoot,
179
+ config: input.config
180
+ });
181
+ return filterAvailableWorkItems({
182
+ candidates,
183
+ seenWorkItemKeys: input.runState.seen_work_item_keys,
184
+ completedWorkItemKeys: input.ledger.completed_work_item_keys
185
+ });
186
+ }
187
+ async function completeIterationWorkItem(input) {
188
+ const ledger = markWorkItemCompleted({
189
+ ledger: input.ledger,
190
+ workItem: input.iterationState.work_item
191
+ });
192
+ input.runState.completed_work_item_keys = [
193
+ ...ledger.completed_work_item_keys
194
+ ];
195
+ input.runState.last_completed_work_item = ledger.last_completed_work_item;
196
+ await writeWorkItemLedger(input.nyxDir, ledger);
197
+ return ledger;
198
+ }
151
199
  function readObjectProperty(value, key) {
152
200
  if (value &&
153
201
  typeof value === "object" &&
@@ -0,0 +1,156 @@
1
+ import path from "node:path";
2
+ export function validateWorkItemIdentity(input) {
3
+ const workItems = input.config.work_items;
4
+ if (!workItems) {
5
+ return {
6
+ ok: false,
7
+ error: "Selected work item requires configured [work_items]"
8
+ };
9
+ }
10
+ if (!isRecord(input.workItem)) {
11
+ return { ok: false, error: "Selected work item must be an object" };
12
+ }
13
+ const source = input.workItem.source;
14
+ if (!isRecord(source)) {
15
+ return { ok: false, error: "Selected work item source must be an object" };
16
+ }
17
+ const key = input.workItem.key;
18
+ const sourceType = source.type;
19
+ const locator = source.locator;
20
+ if (typeof key !== "string" || key.length === 0) {
21
+ return { ok: false, error: "Selected work item key must be a non-empty string" };
22
+ }
23
+ if (input.seenWorkItemKeys?.includes(key) && key !== input.allowKnownKey) {
24
+ return {
25
+ ok: false,
26
+ error: `Selected work item key "${key}" was already seen in this run`
27
+ };
28
+ }
29
+ if (input.completedWorkItemKeys?.includes(key) &&
30
+ key !== input.allowKnownKey) {
31
+ return {
32
+ ok: false,
33
+ error: `Selected work item key "${key}" is already completed`
34
+ };
35
+ }
36
+ if (sourceType !== workItems.source) {
37
+ return {
38
+ ok: false,
39
+ error: `Selected work item source.type must be "${workItems.source}"`
40
+ };
41
+ }
42
+ if (typeof locator !== "string" || locator.length === 0) {
43
+ return {
44
+ ok: false,
45
+ error: "Selected work item source.locator must be a non-empty string"
46
+ };
47
+ }
48
+ if (key !== `${workItems.source}:${locator}`) {
49
+ return {
50
+ ok: false,
51
+ error: `Selected work item key must be "${workItems.source}:${locator}"`
52
+ };
53
+ }
54
+ if (workItems.source === "local") {
55
+ const localValidation = validateLocalLocator(locator, workItems.path);
56
+ if (!localValidation.ok) {
57
+ return localValidation;
58
+ }
59
+ }
60
+ else {
61
+ const githubValidation = validateGitHubLocator(locator, workItems.repository);
62
+ if (!githubValidation.ok) {
63
+ return githubValidation;
64
+ }
65
+ }
66
+ if (input.availableWorkItems) {
67
+ return validateCandidateMembership({
68
+ key,
69
+ sourceType,
70
+ locator,
71
+ availableWorkItems: input.availableWorkItems
72
+ });
73
+ }
74
+ return { ok: true };
75
+ }
76
+ function validateCandidateMembership(input) {
77
+ const candidate = input.availableWorkItems.find((item) => item.key === input.key);
78
+ if (!candidate) {
79
+ return {
80
+ ok: false,
81
+ error: `Selected work item key "${input.key}" is not in available_work_items`
82
+ };
83
+ }
84
+ if (candidate.source.type !== input.sourceType ||
85
+ candidate.source.locator !== input.locator) {
86
+ return {
87
+ ok: false,
88
+ error: `Selected work item source does not match available_work_items entry "${input.key}"`
89
+ };
90
+ }
91
+ return { ok: true };
92
+ }
93
+ function validateLocalLocator(locator, configuredPath) {
94
+ const normalizedLocator = normalizeRelativePath(locator);
95
+ const normalizedPath = configuredPath
96
+ ? normalizeRelativePath(configuredPath)
97
+ : undefined;
98
+ if (!normalizedLocator || normalizedLocator !== locator) {
99
+ return {
100
+ ok: false,
101
+ error: "Local work item locator must be a canonical relative path"
102
+ };
103
+ }
104
+ if (!normalizedPath) {
105
+ return {
106
+ ok: false,
107
+ error: "Local work item validation requires [work_items].path"
108
+ };
109
+ }
110
+ if (normalizedLocator !== normalizedPath &&
111
+ !normalizedLocator.startsWith(`${normalizedPath}/`)) {
112
+ return {
113
+ ok: false,
114
+ error: `Local work item locator must stay under "${normalizedPath}"`
115
+ };
116
+ }
117
+ return { ok: true };
118
+ }
119
+ function validateGitHubLocator(locator, repository) {
120
+ if (!repository) {
121
+ return {
122
+ ok: false,
123
+ error: "GitHub work item validation requires [work_items].repository"
124
+ };
125
+ }
126
+ const match = /^([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)#([1-9][0-9]*)$/.exec(locator);
127
+ if (!match) {
128
+ return {
129
+ ok: false,
130
+ error: 'GitHub work item locator must use "owner/repo#number"'
131
+ };
132
+ }
133
+ if (match[1] !== repository) {
134
+ return {
135
+ ok: false,
136
+ error: `GitHub work item locator must use repository "${repository}"`
137
+ };
138
+ }
139
+ return { ok: true };
140
+ }
141
+ function normalizeRelativePath(value) {
142
+ if (value.length === 0 ||
143
+ value.includes("\\") ||
144
+ path.posix.isAbsolute(value) ||
145
+ /^[A-Za-z]:[\\/]/.test(value)) {
146
+ return undefined;
147
+ }
148
+ const normalized = path.posix.normalize(value);
149
+ if (normalized === "." || normalized === ".." || normalized.startsWith("../")) {
150
+ return undefined;
151
+ }
152
+ return normalized;
153
+ }
154
+ function isRecord(value) {
155
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
156
+ }
@@ -0,0 +1,212 @@
1
+ import { readdir, readFile, stat } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { execa } from "execa";
4
+ export async function listWorkItemCandidates(input) {
5
+ const workItems = input.config.work_items;
6
+ if (!workItems) {
7
+ return [];
8
+ }
9
+ if (workItems.source === "local") {
10
+ if (!workItems.path) {
11
+ throw new Error('Local work items require [work_items].path');
12
+ }
13
+ return listLocalWorkItemCandidates({
14
+ projectRoot: input.projectRoot,
15
+ workItemsPath: workItems.path,
16
+ maxCandidates: workItems.max_candidates,
17
+ excerptChars: workItems.excerpt_chars
18
+ });
19
+ }
20
+ if (!workItems.repository) {
21
+ throw new Error('GitHub work items require [work_items].repository');
22
+ }
23
+ return listGitHubWorkItemCandidates({
24
+ repository: workItems.repository,
25
+ maxCandidates: workItems.max_candidates,
26
+ excerptChars: workItems.excerpt_chars
27
+ });
28
+ }
29
+ export function filterAvailableWorkItems(input) {
30
+ const seen = new Set(input.seenWorkItemKeys);
31
+ const completed = new Set(input.completedWorkItemKeys);
32
+ return input.candidates.filter((candidate) => !seen.has(candidate.key) && !completed.has(candidate.key));
33
+ }
34
+ export async function listLocalWorkItemCandidates(input) {
35
+ const normalizedPath = normalizeProjectRelativePath(input.workItemsPath);
36
+ if (!normalizedPath) {
37
+ throw new Error("[work_items].path must be a canonical relative path");
38
+ }
39
+ const root = path.resolve(input.projectRoot);
40
+ const workItemsDir = path.resolve(root, normalizedPath);
41
+ const relative = path.relative(root, workItemsDir);
42
+ if (relative.startsWith("..") || path.isAbsolute(relative)) {
43
+ throw new Error("[work_items].path must stay inside the project root");
44
+ }
45
+ const dirStat = await stat(workItemsDir).catch((error) => {
46
+ const message = error instanceof Error ? error.message : String(error);
47
+ throw new Error(`Local work item path does not exist: ${normalizedPath} (${message})`);
48
+ });
49
+ if (!dirStat.isDirectory()) {
50
+ throw new Error(`Local work item path is not a directory: ${normalizedPath}`);
51
+ }
52
+ const files = (await collectMarkdownFiles(workItemsDir))
53
+ .map((filePath) => toPosixPath(path.relative(root, filePath)))
54
+ .sort((left, right) => left.localeCompare(right));
55
+ const candidates = [];
56
+ for (const locator of files.slice(0, input.maxCandidates)) {
57
+ const absolutePath = path.join(root, locator);
58
+ const content = await readFile(absolutePath, "utf8");
59
+ candidates.push({
60
+ key: `local:${locator}`,
61
+ title: inferMarkdownTitle(content, locator),
62
+ source: {
63
+ type: "local",
64
+ locator
65
+ },
66
+ excerpt: buildExcerpt(content, input.excerptChars)
67
+ });
68
+ }
69
+ return candidates;
70
+ }
71
+ export async function listGitHubWorkItemCandidates(input) {
72
+ const result = await execa("gh", [
73
+ "issue",
74
+ "list",
75
+ "--repo",
76
+ input.repository,
77
+ "--state",
78
+ "open",
79
+ "--limit",
80
+ String(input.maxCandidates),
81
+ "--json",
82
+ "number,title,url,labels,updatedAt,body"
83
+ ], { reject: false });
84
+ if (result.exitCode !== 0) {
85
+ const detail = (result.stderr || result.stdout || "unknown error").trim();
86
+ throw new Error(`Failed to list GitHub work items with gh: ${detail}`);
87
+ }
88
+ let issues;
89
+ try {
90
+ issues = JSON.parse(result.stdout);
91
+ }
92
+ catch (error) {
93
+ const message = error instanceof Error ? error.message : String(error);
94
+ throw new Error(`Failed to parse gh issue list JSON: ${message}`);
95
+ }
96
+ if (!Array.isArray(issues)) {
97
+ throw new Error("gh issue list returned JSON that is not an array");
98
+ }
99
+ return issues.slice(0, input.maxCandidates).map((issue) => normalizeGitHubIssue({
100
+ issue,
101
+ repository: input.repository,
102
+ excerptChars: input.excerptChars
103
+ }));
104
+ }
105
+ function normalizeGitHubIssue(input) {
106
+ if (!isRecord(input.issue)) {
107
+ throw new Error("gh issue list returned a non-object issue");
108
+ }
109
+ const number = input.issue.number;
110
+ const title = input.issue.title;
111
+ if (typeof number !== "number" || !Number.isInteger(number) || number <= 0) {
112
+ throw new Error("GitHub issue is missing a positive integer number");
113
+ }
114
+ if (typeof title !== "string" || title.length === 0) {
115
+ throw new Error(`GitHub issue #${number} is missing a title`);
116
+ }
117
+ const locator = `${input.repository}#${number}`;
118
+ const candidate = {
119
+ key: `github:${locator}`,
120
+ title,
121
+ source: {
122
+ type: "github",
123
+ locator
124
+ },
125
+ labels: normalizeLabels(input.issue.labels)
126
+ };
127
+ if (typeof input.issue.url === "string" && input.issue.url.length > 0) {
128
+ candidate.url = input.issue.url;
129
+ }
130
+ if (typeof input.issue.updatedAt === "string" &&
131
+ input.issue.updatedAt.length > 0) {
132
+ candidate.updated_at = input.issue.updatedAt;
133
+ }
134
+ if (typeof input.issue.body === "string") {
135
+ candidate.excerpt = buildExcerpt(input.issue.body, input.excerptChars);
136
+ }
137
+ return candidate;
138
+ }
139
+ async function collectMarkdownFiles(dir) {
140
+ const entries = await readdir(dir, { withFileTypes: true });
141
+ const files = [];
142
+ for (const entry of entries) {
143
+ const filePath = path.join(dir, entry.name);
144
+ if (entry.isDirectory()) {
145
+ files.push(...(await collectMarkdownFiles(filePath)));
146
+ continue;
147
+ }
148
+ if (entry.isFile() && entry.name.toLowerCase().endsWith(".md")) {
149
+ files.push(filePath);
150
+ }
151
+ }
152
+ return files;
153
+ }
154
+ function inferMarkdownTitle(content, locator) {
155
+ const heading = /^\s{0,3}#{1,6}\s+(.+?)\s*#*\s*$/m.exec(content);
156
+ if (heading?.[1]) {
157
+ return heading[1].trim();
158
+ }
159
+ return path.posix.basename(locator, ".md");
160
+ }
161
+ function buildExcerpt(content, maxChars) {
162
+ if (maxChars <= 0) {
163
+ return undefined;
164
+ }
165
+ const compact = content.replace(/\s+/g, " ").trim();
166
+ if (!compact) {
167
+ return undefined;
168
+ }
169
+ if (compact.length <= maxChars) {
170
+ return compact;
171
+ }
172
+ if (maxChars <= 3) {
173
+ return compact.slice(0, maxChars);
174
+ }
175
+ return `${compact.slice(0, maxChars - 3).trimEnd()}...`;
176
+ }
177
+ function normalizeLabels(labels) {
178
+ if (!Array.isArray(labels)) {
179
+ return undefined;
180
+ }
181
+ const normalized = labels
182
+ .map((label) => {
183
+ if (typeof label === "string") {
184
+ return label;
185
+ }
186
+ if (isRecord(label) && typeof label.name === "string") {
187
+ return label.name;
188
+ }
189
+ return undefined;
190
+ })
191
+ .filter((label) => Boolean(label));
192
+ return normalized.length > 0 ? normalized : undefined;
193
+ }
194
+ function normalizeProjectRelativePath(value) {
195
+ if (value.length === 0 ||
196
+ value.includes("\\") ||
197
+ path.posix.isAbsolute(value) ||
198
+ /^[A-Za-z]:[\\/]/.test(value)) {
199
+ return undefined;
200
+ }
201
+ const normalized = path.posix.normalize(value);
202
+ if (normalized === "." || normalized === ".." || normalized.startsWith("../")) {
203
+ return undefined;
204
+ }
205
+ return normalized;
206
+ }
207
+ function toPosixPath(value) {
208
+ return value.split(path.sep).join(path.posix.sep);
209
+ }
210
+ function isRecord(value) {
211
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
212
+ }
@@ -26,7 +26,7 @@ configuration.
26
26
 
27
27
  ## Non-Goals For v0
28
28
 
29
- - No built-in GitHub, Jira, Linear, or local task tracker adapter.
29
+ - No native classification of work items as plans, PRDs, or tasks.
30
30
  - No native Git commit or issue close logic in the engine.
31
31
  - No resume command.
32
32
  - No fully general DAG/workflow engine.
@@ -62,6 +62,7 @@ If `.nyxagent/` already exists:
62
62
  ```text
63
63
  .nyxagent/
64
64
  config.toml
65
+ state.json
65
66
  prompts/
66
67
  selection.md
67
68
  execution.md
@@ -113,8 +114,10 @@ max_attempts = 1
113
114
  prompt = "prompts/repair-result.md"
114
115
 
115
116
  [work_items]
116
- source = "local-markdown"
117
+ source = "local"
117
118
  path = "issues"
119
+ max_candidates = 50
120
+ excerpt_chars = 800
118
121
 
119
122
  [[phases]]
120
123
  id = "selection"
@@ -175,8 +178,23 @@ max_visits_per_iteration = 1
175
178
  - `model.reasoning_level` is a harness-neutral string.
176
179
  - Harness args are declarative and may interpolate config/runtime variables.
177
180
  - Per-phase `model` and `harness` blocks override global values.
178
- - `work_items` is informative in v0. It is injected into runtime context and
179
- used by prompts, not scanned by the engine.
181
+ - `work_items` supports only `local` and `github` in v0.
182
+ - `work_items.max_candidates` defaults to `50` and caps the inventory sent to
183
+ the selection prompt.
184
+ - `work_items.excerpt_chars` defaults to `800` and bounds candidate excerpts.
185
+ - The engine scans the configured provider, normalizes candidates into
186
+ `available_work_items`, and injects that inventory into runtime context.
187
+ - The engine validates the selected `work_item` identity against `[work_items]`
188
+ and requires the selected key to exist in `available_work_items` before
189
+ recording phase results.
190
+ - Local markdown work items do not have a required frontmatter schema. The
191
+ provider infers titles from the first heading or filename and includes a
192
+ bounded content excerpt.
193
+ - Local keys use `local:<relative-path-under-work-items-path>`.
194
+ - GitHub keys use `github:<owner>/<repo>#<issue-number>` and must match the
195
+ configured repository.
196
+ - NyxAgent does not decide whether an item is a plan, PRD, or task. The
197
+ selection agent makes that semantic choice from the deterministic inventory.
180
198
 
181
199
  ## Workflow Model
182
200
 
@@ -273,6 +291,10 @@ The runtime contract includes:
273
291
  - required structured output contract
274
292
  - work item context, when selected
275
293
  - work item config from `[work_items]`
294
+ - `available_work_items`
295
+ - `seen_work_item_keys`
296
+ - `completed_work_item_keys`
297
+ - `last_completed_work_item`
276
298
 
277
299
  The user prompt remains focused on domain behavior.
278
300
 
@@ -347,11 +369,23 @@ Each run creates a timestamped directory:
347
369
  - current iteration
348
370
  - completed iterations
349
371
  - seen work item keys
372
+ - completed work item keys
373
+ - last completed work item
374
+
375
+ `.nyxagent/state.json` records persistent work item completion state:
376
+
377
+ - completed work item keys
378
+ - last completed work item
379
+ - minimal completion history
350
380
 
351
381
  `iterations/NNN/state.json` records per-work-item state:
352
382
 
353
383
  - iteration number
354
384
  - work item
385
+ - available work items for the iteration
386
+ - seen work item keys
387
+ - completed work item keys
388
+ - last completed work item
355
389
  - phase results
356
390
  - phase visit counts
357
391
  - current phase status
@@ -392,9 +426,13 @@ Default prompts should be concise but operational.
392
426
 
393
427
  Selection:
394
428
 
395
- - inspect configured work item source
396
- - choose one work item
397
- - avoid keys already present in `seen_work_item_keys`
429
+ - choose one item from `available_work_items`
430
+ - treat candidates agnostically: they may be plans, PRDs, or tasks
431
+ - prefer the most actionable item based on the candidate title and excerpt
432
+ - if a plan references concrete candidate tasks, choose the concrete task when
433
+ that is the best next work
434
+ - avoid keys already present in `seen_work_item_keys` or
435
+ `completed_work_item_keys`
398
436
  - return `selected` or `no_work`
399
437
 
400
438
  Execution:
@@ -429,17 +467,18 @@ Closure:
429
467
  - model name
430
468
  - reasoning level
431
469
  - max iterations
432
- - work item source template: `local-markdown`, `github`, or `custom`
470
+ - work item source template: `local` or `github`
433
471
 
434
- For `local-markdown`, init asks for a task path.
472
+ For `local`, init asks for a work item path.
473
+ For `github`, init asks for a repository in `owner/repo` format.
435
474
 
436
475
  Default path selection:
437
476
 
438
477
  - use `issues/` if it exists
439
478
  - otherwise suggest `.nyxagent/tasks/`
440
479
 
441
- If the chosen local task path does not exist, init may create it and add a
442
- sample task.
480
+ If the chosen local work item path does not exist, init creates the directory but
481
+ does not create sample work items.
443
482
 
444
483
  ## Implementation Stack
445
484
 
@@ -479,7 +518,7 @@ src/
479
518
 
480
519
  - `nyxagent resume`
481
520
  - `nyxagent import-tasks`
482
- - tracker adapters for GitHub, Jira, Linear, or local frontmatter tasks
521
+ - additional tracker adapters
483
522
  - explicit Git commit adapter
484
523
  - stricter artifact redaction
485
524
  - richer phase retry policy
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nyxa/nyx-agent",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "A lightweight phase orchestrator for repeatedly launching coding agents with fresh context.",
5
5
  "type": "module",
6
6
  "repository": {
@@ -1,19 +1,29 @@
1
1
  Select exactly one work item for this iteration.
2
2
 
3
- Use the work item configuration from the runtime contract. Prefer open,
4
- well-scoped work that has not already been selected in this run. Avoid any item
5
- whose key appears in `seen_work_item_keys`.
3
+ Use `available_work_items` from the runtime contract as the complete inventory
4
+ for this selection. Some candidates may be plans, PRDs, or concrete tasks. Pick
5
+ the most actionable next item from the provided context.
6
6
 
7
- If no suitable work item exists, return `no_work`.
7
+ If a candidate is a plan that references more concrete candidates also present
8
+ in `available_work_items`, you may choose the concrete task. If the plan itself
9
+ is the best next work, choose the plan. If no candidate is exploitable, return
10
+ `no_work`.
11
+
12
+ Do not choose any key listed in `seen_work_item_keys` or
13
+ `completed_work_item_keys`. The selected work item must be copied from
14
+ `available_work_items`; do not invent a key.
15
+
16
+ The selected work item identity must match the inventory entry:
17
+
18
+ - local: `source.type` is `local`, `source.locator` is the project-relative
19
+ markdown path, and `key` is `local:<source.locator>`.
20
+ - github: `source.type` is `github`, `source.locator` is
21
+ `owner/repo#number` for the configured repository, and `key` is
22
+ `github:<source.locator>`.
8
23
 
9
24
  Return one of these outcomes:
10
25
 
11
26
  - `selected`: include a stable `work_item`
12
27
  - `no_work`: include a short `reason`
13
28
 
14
- The selected work item must have a stable key. Examples:
15
-
16
- - `github:owner/repo#42`
17
- - `local:issues/TASK-0007.md`
18
-
19
29
  Do not modify project files or task files during selection.
@@ -25,7 +25,7 @@
25
25
  "properties": {
26
26
  "type": {
27
27
  "type": "string",
28
- "minLength": 1
28
+ "enum": ["local", "github"]
29
29
  },
30
30
  "locator": {
31
31
  "type": "string",