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.
@@ -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
- return parsed;
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}`);
@@ -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
  }
@@ -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
- export async function runPlanner(specPath, store, runId) {
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 result = await planSpecImpl(spec, DEFAULT_SYSTEM_CONTEXT);
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, {
@@ -1,11 +1,14 @@
1
1
  import path from "node:path";
2
2
  import { promises as fs } from "node:fs";
3
- import { executeTask } from "../agents/claude/session.js";
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
- let executeTaskImpl = executeTask;
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
- executeTaskImpl = fn ?? executeTask;
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orcastrator",
3
- "version": "0.2.5",
3
+ "version": "0.2.7",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "orca": "dist/cli/index.js"