@nyxa/nyx-agent 0.2.1 → 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 +2 -1
- package/dist/commands/init.js +51 -39
- package/dist/config/schema.js +37 -1
- package/dist/runtime/buildPrompt.js +20 -0
- package/dist/runtime/ledger.js +118 -0
- package/dist/runtime/runPhase.js +50 -0
- package/dist/runtime/runWorkflow.js +49 -1
- package/dist/runtime/validateWorkItem.js +156 -0
- package/dist/runtime/workItems.js +212 -0
- package/docs/nyxagent-v0-spec.md +51 -12
- package/package.json +1 -1
- package/templates/default/prompts/selection.md +19 -9
- package/templates/default/schemas/selection.schema.json +1 -1
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
|
|
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
|
});
|
package/dist/commands/init.js
CHANGED
|
@@ -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
|
|
28
|
-
await
|
|
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
|
-
|
|
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
|
|
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
|
|
72
|
+
if (workItemsSource === "local" && !workItemsPath) {
|
|
73
73
|
const issuesPath = path.join(root, "issues");
|
|
74
74
|
workItemsPath = await input({
|
|
75
|
-
message: "Local
|
|
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
|
|
139
|
+
async function ensureWorkItemsDirectory(root, taskPath) {
|
|
112
140
|
const absoluteTaskPath = path.resolve(root, taskPath);
|
|
113
|
-
await
|
|
114
|
-
|
|
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
|
|
260
|
+
if (options.workItemsSource === "local") {
|
|
249
261
|
return `[work_items]
|
|
250
|
-
source = "local
|
|
251
|
-
path = "${escapeTomlString(options.workItemsPath ?? ".nyxagent/tasks")}"
|
|
252
|
-
|
|
253
|
-
|
|
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 = "
|
|
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(", ")}]`;
|
package/dist/config/schema.js
CHANGED
|
@@ -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:
|
|
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
|
+
}
|
package/dist/runtime/runPhase.js
CHANGED
|
@@ -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
|
+
}
|
package/docs/nyxagent-v0-spec.md
CHANGED
|
@@ -26,7 +26,7 @@ configuration.
|
|
|
26
26
|
|
|
27
27
|
## Non-Goals For v0
|
|
28
28
|
|
|
29
|
-
- No
|
|
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
|
|
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`
|
|
179
|
-
|
|
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
|
-
-
|
|
396
|
-
-
|
|
397
|
-
-
|
|
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
|
|
470
|
+
- work item source template: `local` or `github`
|
|
433
471
|
|
|
434
|
-
For `local
|
|
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
|
|
442
|
-
sample
|
|
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
|
|
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,19 +1,29 @@
|
|
|
1
1
|
Select exactly one work item for this iteration.
|
|
2
2
|
|
|
3
|
-
Use
|
|
4
|
-
|
|
5
|
-
|
|
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
|
|
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.
|