orcastrator 0.2.5 → 0.2.7
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/agents/claude/session.js +13 -4
- package/dist/agents/codex/session.js +6 -5
- package/dist/cli/commands/plan.js +3 -1
- package/dist/cli/commands/run.js +2 -2
- package/dist/core/config-loader.js +14 -1
- package/dist/core/planner.js +17 -2
- package/dist/core/task-runner.js +54 -5
- package/dist/utils/skill-loader.js +115 -0
- package/package.json +1 -1
|
@@ -13,8 +13,9 @@ function buildPlanningPrompt(spec, systemContext) {
|
|
|
13
13
|
spec
|
|
14
14
|
].join("\n\n");
|
|
15
15
|
}
|
|
16
|
-
function buildTaskExecutionPrompt(task, runId, cwd) {
|
|
16
|
+
function buildTaskExecutionPrompt(task, runId, cwd, systemContext) {
|
|
17
17
|
return [
|
|
18
|
+
...(systemContext ? [systemContext] : []),
|
|
18
19
|
"You are Orca's task execution assistant.",
|
|
19
20
|
`Run ID: ${runId}`,
|
|
20
21
|
`Repository CWD: ${cwd}`,
|
|
@@ -54,7 +55,15 @@ function parseTaskArray(raw) {
|
|
|
54
55
|
if (!Array.isArray(parsed)) {
|
|
55
56
|
throw new Error("Claude plan response was not a JSON array");
|
|
56
57
|
}
|
|
57
|
-
|
|
58
|
+
// Coerce numeric task IDs and dependency refs to strings.
|
|
59
|
+
// LLMs sometimes emit numeric IDs (1, 2, 3) even when we ask for strings.
|
|
60
|
+
return parsed.map((task) => ({
|
|
61
|
+
...task,
|
|
62
|
+
id: String(task.id),
|
|
63
|
+
dependencies: Array.isArray(task.dependencies)
|
|
64
|
+
? task.dependencies.map(String)
|
|
65
|
+
: [],
|
|
66
|
+
}));
|
|
58
67
|
}
|
|
59
68
|
function parseTaskExecution(raw) {
|
|
60
69
|
const parsed = JSON.parse(raw);
|
|
@@ -120,14 +129,14 @@ export async function planSpec(spec, systemContext) {
|
|
|
120
129
|
session.close();
|
|
121
130
|
}
|
|
122
131
|
}
|
|
123
|
-
export async function executeTask(task, runId, config) {
|
|
132
|
+
export async function executeTask(task, runId, config, systemContext) {
|
|
124
133
|
const session = unstable_v2_createSession({
|
|
125
134
|
model: getModel(config),
|
|
126
135
|
permissionMode: "bypassPermissions",
|
|
127
136
|
});
|
|
128
137
|
try {
|
|
129
138
|
const streamPromise = collectSessionResult(session);
|
|
130
|
-
await session.send(buildTaskExecutionPrompt(task, runId, process.cwd()));
|
|
139
|
+
await session.send(buildTaskExecutionPrompt(task, runId, process.cwd(), systemContext));
|
|
131
140
|
const rawResponse = await streamPromise;
|
|
132
141
|
return parseTaskExecution(rawResponse);
|
|
133
142
|
}
|
|
@@ -13,8 +13,9 @@ function buildPlanningPrompt(spec, systemContext) {
|
|
|
13
13
|
spec,
|
|
14
14
|
].join("\n\n");
|
|
15
15
|
}
|
|
16
|
-
function buildTaskExecutionPrompt(task, runId, cwd) {
|
|
16
|
+
function buildTaskExecutionPrompt(task, runId, cwd, systemContext) {
|
|
17
17
|
return [
|
|
18
|
+
...(systemContext ? [systemContext] : []),
|
|
18
19
|
"You are Orca's task execution assistant.",
|
|
19
20
|
`Run ID: ${runId}`,
|
|
20
21
|
`Repository CWD: ${cwd}`,
|
|
@@ -164,13 +165,13 @@ export async function createCodexSession(cwd, config) {
|
|
|
164
165
|
rawResponse,
|
|
165
166
|
};
|
|
166
167
|
},
|
|
167
|
-
async executeTask(task, runId) {
|
|
168
|
+
async executeTask(task, runId, systemContext) {
|
|
168
169
|
const result = await client.runTurn({
|
|
169
170
|
threadId,
|
|
170
171
|
input: [
|
|
171
172
|
{
|
|
172
173
|
type: "text",
|
|
173
|
-
text: buildTaskExecutionPrompt(task, runId, cwd),
|
|
174
|
+
text: buildTaskExecutionPrompt(task, runId, cwd, systemContext),
|
|
174
175
|
},
|
|
175
176
|
],
|
|
176
177
|
});
|
|
@@ -236,10 +237,10 @@ export async function planSpec(spec, systemContext, config) {
|
|
|
236
237
|
await session.disconnect();
|
|
237
238
|
}
|
|
238
239
|
}
|
|
239
|
-
export async function executeTask(task, runId, config) {
|
|
240
|
+
export async function executeTask(task, runId, config, systemContext) {
|
|
240
241
|
const session = await createCodexSession(process.cwd(), config);
|
|
241
242
|
try {
|
|
242
|
-
return await session.executeTask(task, runId);
|
|
243
|
+
return await session.executeTask(task, runId, systemContext);
|
|
243
244
|
}
|
|
244
245
|
finally {
|
|
245
246
|
await session.disconnect();
|
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
import { access } from "node:fs/promises";
|
|
2
2
|
import { constants as fsConstants } from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { resolveConfig } from "../../core/config-loader.js";
|
|
4
5
|
import { runPlanner } from "../../core/planner.js";
|
|
5
6
|
import { RunStore } from "../../state/store.js";
|
|
6
7
|
import { generateRunId } from "../../utils/ids.js";
|
|
7
8
|
export async function planCommand(options) {
|
|
8
9
|
const specPath = path.resolve(options.spec);
|
|
9
10
|
await access(specPath, fsConstants.R_OK);
|
|
11
|
+
const orcaConfig = await resolveConfig(options.config);
|
|
10
12
|
const runId = generateRunId(specPath);
|
|
11
13
|
console.log(`Run ID: ${runId}`);
|
|
12
14
|
const store = new RunStore();
|
|
13
15
|
await store.createRun(runId, specPath);
|
|
14
|
-
await runPlanner(specPath, store, runId);
|
|
16
|
+
await runPlanner(specPath, store, runId, orcaConfig);
|
|
15
17
|
const run = await store.getRun(runId);
|
|
16
18
|
if (!run) {
|
|
17
19
|
throw new Error(`Run not found after planning: ${runId}`);
|
package/dist/cli/commands/run.js
CHANGED
|
@@ -80,7 +80,7 @@ export async function runCommandHandler(options) {
|
|
|
80
80
|
console.log(`Run ID: ${runId}`);
|
|
81
81
|
const store = createStore();
|
|
82
82
|
await store.createRun(runId, specPath);
|
|
83
|
-
await runPlanner(specPath, store, runId);
|
|
83
|
+
await runPlanner(specPath, store, runId, orcaConfig);
|
|
84
84
|
await store.updateRun(runId, {
|
|
85
85
|
mode: "run",
|
|
86
86
|
overallStatus: "running"
|
|
@@ -155,7 +155,7 @@ export async function runCommandHandler(options) {
|
|
|
155
155
|
store,
|
|
156
156
|
...(orcaConfig ? { config: orcaConfig } : {}),
|
|
157
157
|
emitHook,
|
|
158
|
-
executeTask: (task, runId, _config) => codexSession.executeTask(task, runId),
|
|
158
|
+
executeTask: (task, runId, _config, systemContext) => codexSession.executeTask(task, runId, systemContext),
|
|
159
159
|
});
|
|
160
160
|
const reviewText = await codexSession.reviewChanges();
|
|
161
161
|
console.log("Codex post-execution review:");
|
|
@@ -19,6 +19,16 @@ function coerceConfig(candidate) {
|
|
|
19
19
|
if (!isObject(candidate)) {
|
|
20
20
|
throw new Error("Config module must export an object");
|
|
21
21
|
}
|
|
22
|
+
if ("skills" in candidate && candidate.skills !== undefined) {
|
|
23
|
+
if (!Array.isArray(candidate.skills)) {
|
|
24
|
+
throw new Error(`Config.skills must be an array, got ${describeType(candidate.skills)}`);
|
|
25
|
+
}
|
|
26
|
+
for (const skillPath of candidate.skills) {
|
|
27
|
+
if (typeof skillPath !== "string") {
|
|
28
|
+
throw new Error(`Config.skills entries must be strings, got ${describeType(skillPath)}`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
22
32
|
if ("hooks" in candidate && candidate.hooks !== undefined) {
|
|
23
33
|
if (!isObject(candidate.hooks)) {
|
|
24
34
|
throw new Error(`Config.hooks must be an object, got ${describeType(candidate.hooks)}`);
|
|
@@ -52,7 +62,7 @@ export async function loadConfig(configPath) {
|
|
|
52
62
|
const configCandidate = "default" in importedModule ? importedModule.default : importedModule;
|
|
53
63
|
return coerceConfig(configCandidate);
|
|
54
64
|
}
|
|
55
|
-
const TOP_LEVEL_SCALARS = ["runsDir", "sessionLogs", "maxRetries", "anthropicApiKey", "openaiApiKey"];
|
|
65
|
+
const TOP_LEVEL_SCALARS = ["runsDir", "sessionLogs", "maxRetries", "anthropicApiKey", "openaiApiKey", "executor"];
|
|
56
66
|
export function mergeConfigs(...configs) {
|
|
57
67
|
const presentConfigs = configs.filter((config) => config !== undefined);
|
|
58
68
|
if (presentConfigs.length === 0) {
|
|
@@ -80,6 +90,9 @@ export function mergeConfigs(...configs) {
|
|
|
80
90
|
if (merged.hookCommands !== undefined || config.hookCommands !== undefined) {
|
|
81
91
|
merged.hookCommands = { ...merged.hookCommands, ...config.hookCommands };
|
|
82
92
|
}
|
|
93
|
+
if (config.skills !== undefined) {
|
|
94
|
+
merged.skills = [...new Set([...(merged.skills ?? []), ...config.skills])];
|
|
95
|
+
}
|
|
83
96
|
}
|
|
84
97
|
return merged;
|
|
85
98
|
}
|
package/dist/core/planner.js
CHANGED
|
@@ -1,15 +1,30 @@
|
|
|
1
1
|
import { promises as fs } from "node:fs";
|
|
2
2
|
import { planSpec } from "../agents/claude/session.js";
|
|
3
3
|
import { logger } from "../utils/logger.js";
|
|
4
|
+
import { loadSkills } from "../utils/skill-loader.js";
|
|
4
5
|
import { validateDAG } from "./dependency-graph.js";
|
|
5
6
|
const DEFAULT_SYSTEM_CONTEXT = "You are Orca planner.";
|
|
6
7
|
let planSpecImpl = planSpec;
|
|
7
8
|
export function setPlanSpecForTests(fn) {
|
|
8
9
|
planSpecImpl = fn ?? planSpec;
|
|
9
10
|
}
|
|
10
|
-
|
|
11
|
+
function formatSkillsSection(skills) {
|
|
12
|
+
const formattedSkills = skills.map((skill) => [
|
|
13
|
+
`### ${skill.name}`,
|
|
14
|
+
"",
|
|
15
|
+
`Description: ${skill.description}`,
|
|
16
|
+
"",
|
|
17
|
+
skill.body
|
|
18
|
+
].join("\n"));
|
|
19
|
+
return ["## Available Skills", "", ...formattedSkills].join("\n");
|
|
20
|
+
}
|
|
21
|
+
export async function runPlanner(specPath, store, runId, config) {
|
|
11
22
|
const spec = await fs.readFile(specPath, "utf8");
|
|
12
|
-
const
|
|
23
|
+
const skills = await loadSkills(config);
|
|
24
|
+
const systemContext = skills.length === 0
|
|
25
|
+
? DEFAULT_SYSTEM_CONTEXT
|
|
26
|
+
: `${DEFAULT_SYSTEM_CONTEXT}\n\n${formatSkillsSection(skills)}`;
|
|
27
|
+
const result = await planSpecImpl(spec, systemContext);
|
|
13
28
|
validateDAG(result.tasks);
|
|
14
29
|
await store.writeTasks(runId, result.tasks);
|
|
15
30
|
await store.updateRun(runId, {
|
package/dist/core/task-runner.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { promises as fs } from "node:fs";
|
|
3
|
-
import
|
|
3
|
+
import * as claudeAgent from "../agents/claude/session.js";
|
|
4
|
+
import { createCodexSession } from "../agents/codex/session.js";
|
|
5
|
+
import { loadSkills } from "../utils/skill-loader.js";
|
|
4
6
|
import { getRunnable, validateDAG } from "./dependency-graph.js";
|
|
5
7
|
import { shouldRetry } from "./retry-policy.js";
|
|
6
|
-
|
|
8
|
+
// Non-null only when set by tests — null means "use real executor logic"
|
|
9
|
+
let testExecuteTaskOverride = null;
|
|
7
10
|
export function setExecuteTaskForTests(fn) {
|
|
8
|
-
|
|
11
|
+
testExecuteTaskOverride = fn;
|
|
9
12
|
}
|
|
10
13
|
function toErrorMessage(error) {
|
|
11
14
|
if (error instanceof Error) {
|
|
@@ -40,6 +43,16 @@ function stripOptionalFields(task, fields) {
|
|
|
40
43
|
function hasPendingTasks(tasks) {
|
|
41
44
|
return tasks.some((task) => task.status === "pending" || task.status === "in_progress");
|
|
42
45
|
}
|
|
46
|
+
function formatSkillsSection(skills) {
|
|
47
|
+
const formattedSkills = skills.map((skill) => [
|
|
48
|
+
`### ${skill.name}`,
|
|
49
|
+
"",
|
|
50
|
+
`Description: ${skill.description}`,
|
|
51
|
+
"",
|
|
52
|
+
skill.body
|
|
53
|
+
].join("\n"));
|
|
54
|
+
return ["## Available Skills", "", ...formattedSkills].join("\n");
|
|
55
|
+
}
|
|
43
56
|
function buildSessionSummary(run) {
|
|
44
57
|
const taskRows = run.tasks.length === 0
|
|
45
58
|
? "| (none) | - | - | - | - |\n"
|
|
@@ -83,8 +96,34 @@ async function writeSessionSummary(store, runId, sessionLogsDir) {
|
|
|
83
96
|
}
|
|
84
97
|
export async function runTaskRunner(options) {
|
|
85
98
|
const emitHook = options.emitHook ?? defaultEmitHook;
|
|
86
|
-
const executeTaskFn = options.executeTask ?? executeTaskImpl;
|
|
87
99
|
const { runId, store, config } = options;
|
|
100
|
+
const skills = await loadSkills(config);
|
|
101
|
+
const taskSystemContext = skills.length === 0 ? undefined : formatSkillsSection(skills);
|
|
102
|
+
// Test mocks bypass all executor logic entirely — no real sessions created.
|
|
103
|
+
const mockFn = options.executeTask ?? testExecuteTaskOverride;
|
|
104
|
+
// Build real executor (Codex persistent session or Claude stateless fallback).
|
|
105
|
+
// Only runs in production — skipped completely when a mock is active.
|
|
106
|
+
let codexSession;
|
|
107
|
+
let executeTaskFn;
|
|
108
|
+
if (mockFn) {
|
|
109
|
+
executeTaskFn = mockFn;
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
const executor = config?.executor ?? "codex";
|
|
113
|
+
if (executor === "codex") {
|
|
114
|
+
try {
|
|
115
|
+
codexSession = await createCodexSession(process.cwd(), config);
|
|
116
|
+
executeTaskFn = (task, taskRunId, _cfg, systemContext) => codexSession.executeTask(task, taskRunId, systemContext);
|
|
117
|
+
}
|
|
118
|
+
catch (sessionError) {
|
|
119
|
+
console.warn(`[orca] Codex session init failed, falling back to Claude: ${toErrorMessage(sessionError)}`);
|
|
120
|
+
executeTaskFn = claudeAgent.executeTask;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
executeTaskFn = claudeAgent.executeTask;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
88
127
|
let run = await store.getRun(runId);
|
|
89
128
|
if (!run) {
|
|
90
129
|
throw new Error(`Run not found: ${runId}`);
|
|
@@ -199,7 +238,7 @@ export async function runTaskRunner(options) {
|
|
|
199
238
|
tasks: inProgressTasks
|
|
200
239
|
});
|
|
201
240
|
try {
|
|
202
|
-
const result = await executeTaskFn(task, runId, config);
|
|
241
|
+
const result = await executeTaskFn(task, runId, config, taskSystemContext);
|
|
203
242
|
if (result.outcome === "done") {
|
|
204
243
|
const doneTasks = inProgressTasks.map((candidate) => {
|
|
205
244
|
if (candidate.id !== task.id) {
|
|
@@ -307,4 +346,14 @@ export async function runTaskRunner(options) {
|
|
|
307
346
|
await writeSessionSummary(store, runId, config?.sessionLogs);
|
|
308
347
|
throw error;
|
|
309
348
|
}
|
|
349
|
+
finally {
|
|
350
|
+
if (codexSession) {
|
|
351
|
+
try {
|
|
352
|
+
await codexSession.disconnect();
|
|
353
|
+
}
|
|
354
|
+
catch {
|
|
355
|
+
// Best-effort cleanup — don't mask the real error.
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
310
359
|
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
function expandHome(inputPath) {
|
|
5
|
+
if (inputPath === "~") {
|
|
6
|
+
return os.homedir();
|
|
7
|
+
}
|
|
8
|
+
if (inputPath.startsWith("~/") || inputPath.startsWith("~\\")) {
|
|
9
|
+
return path.join(os.homedir(), inputPath.slice(2));
|
|
10
|
+
}
|
|
11
|
+
return inputPath;
|
|
12
|
+
}
|
|
13
|
+
function parseFrontmatter(frontmatter) {
|
|
14
|
+
const parsed = {};
|
|
15
|
+
for (const rawLine of frontmatter.split(/\r?\n/u)) {
|
|
16
|
+
const line = rawLine.trim();
|
|
17
|
+
if (line === "" || line.startsWith("#")) {
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
const separatorIndex = line.indexOf(":");
|
|
21
|
+
if (separatorIndex <= 0) {
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
const key = line.slice(0, separatorIndex).trim();
|
|
25
|
+
if (key === "") {
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
let value = line.slice(separatorIndex + 1).trim();
|
|
29
|
+
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
30
|
+
(value.startsWith("'") && value.endsWith("'"))) {
|
|
31
|
+
value = value.slice(1, -1);
|
|
32
|
+
}
|
|
33
|
+
parsed[key] = value;
|
|
34
|
+
}
|
|
35
|
+
return parsed;
|
|
36
|
+
}
|
|
37
|
+
export function parseSkillFile(fileContent) {
|
|
38
|
+
const frontmatterMatch = fileContent.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/u);
|
|
39
|
+
if (!frontmatterMatch) {
|
|
40
|
+
return {
|
|
41
|
+
name: "",
|
|
42
|
+
description: "",
|
|
43
|
+
body: fileContent
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
const frontmatter = parseFrontmatter(frontmatterMatch[1] ?? "");
|
|
47
|
+
const body = fileContent.slice(frontmatterMatch[0].length);
|
|
48
|
+
return {
|
|
49
|
+
name: frontmatter.name ?? "",
|
|
50
|
+
description: frontmatter.description ?? "",
|
|
51
|
+
body
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
export async function loadSkill(skillDirPath) {
|
|
55
|
+
const expandedDirPath = path.resolve(expandHome(skillDirPath));
|
|
56
|
+
const skillFilePath = path.join(expandedDirPath, "SKILL.md");
|
|
57
|
+
let skillFileContent;
|
|
58
|
+
try {
|
|
59
|
+
skillFileContent = await readFile(skillFilePath, "utf8");
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
if (error.code === "ENOENT") {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
67
|
+
const parsed = parseSkillFile(skillFileContent);
|
|
68
|
+
const inferredName = path.basename(expandedDirPath);
|
|
69
|
+
return {
|
|
70
|
+
name: parsed.name || inferredName,
|
|
71
|
+
description: parsed.description,
|
|
72
|
+
body: parsed.body,
|
|
73
|
+
dirPath: expandedDirPath,
|
|
74
|
+
filePath: skillFilePath
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
export async function loadSkillsFromDir(skillsDirPath) {
|
|
78
|
+
const expandedDirPath = path.resolve(expandHome(skillsDirPath));
|
|
79
|
+
let entries;
|
|
80
|
+
try {
|
|
81
|
+
entries = await readdir(expandedDirPath, { withFileTypes: true, encoding: "utf8" });
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
if (error.code === "ENOENT") {
|
|
85
|
+
return [];
|
|
86
|
+
}
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
const subdirectories = entries
|
|
90
|
+
.filter((entry) => entry.isDirectory())
|
|
91
|
+
.map((entry) => entry.name)
|
|
92
|
+
.sort((a, b) => a.localeCompare(b));
|
|
93
|
+
const loaded = await Promise.all(subdirectories.map((subdirName) => loadSkill(path.join(expandedDirPath, subdirName))));
|
|
94
|
+
return loaded.filter((skill) => skill !== null);
|
|
95
|
+
}
|
|
96
|
+
export async function loadSkills(config) {
|
|
97
|
+
const fromConfig = await Promise.all((config?.skills ?? []).map((skillPath) => loadSkill(skillPath)));
|
|
98
|
+
const projectSkills = await loadSkillsFromDir(path.join(process.cwd(), ".orca", "skills"));
|
|
99
|
+
const globalSkills = await loadSkillsFromDir(path.join(os.homedir(), ".orca", "skills"));
|
|
100
|
+
const allSkills = [
|
|
101
|
+
...fromConfig.filter((skill) => skill !== null),
|
|
102
|
+
...projectSkills,
|
|
103
|
+
...globalSkills
|
|
104
|
+
];
|
|
105
|
+
const seenNames = new Set();
|
|
106
|
+
const deduped = [];
|
|
107
|
+
for (const skill of allSkills) {
|
|
108
|
+
if (seenNames.has(skill.name)) {
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
seenNames.add(skill.name);
|
|
112
|
+
deduped.push(skill);
|
|
113
|
+
}
|
|
114
|
+
return deduped;
|
|
115
|
+
}
|