orcastrator 0.2.4 → 0.2.6

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/README.md CHANGED
@@ -82,10 +82,30 @@ export default {
82
82
  onTaskComplete: "echo task done: $ORCA_TASK_NAME",
83
83
  onComplete: "echo run complete",
84
84
  onError: "echo run failed"
85
+ },
86
+ codex: {
87
+ model: "gpt-5.3-codex", // override the codex model
88
+ multiAgent: true, // enable codex multi-agent (see below)
85
89
  }
86
90
  };
87
91
  ```
88
92
 
93
+ ### Multi-agent mode
94
+
95
+ Codex supports experimental [multi-agent workflows](https://developers.openai.com/codex/multi-agent) where it can spawn parallel sub-agents for complex tasks.
96
+
97
+ To enable it in orca, set `codex.multiAgent: true` in your config:
98
+
99
+ ```js
100
+ export default {
101
+ codex: { multiAgent: true }
102
+ };
103
+ ```
104
+
105
+ When enabled, orca adds `multi_agent = true` to your global `~/.codex/config.toml`. If you already have multi-agent enabled in your Codex config, it will work automatically without setting anything in orca.
106
+
107
+ > **Note:** Multi-agent is off by default because enabling it modifies your global Codex configuration. It is currently an experimental Codex feature.
108
+
89
109
  ## Reference
90
110
 
91
111
  ### Flags
@@ -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}`);
@@ -4,6 +4,7 @@ import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { randomUUID } from "node:crypto";
6
6
  import { createCodexSession } from "../../agents/codex/session.js";
7
+ import { ensureCodexMultiAgent } from "../../core/codex-config.js";
7
8
  import { resolveConfig } from "../../core/config-loader.js";
8
9
  import { runPlanner } from "../../core/planner.js";
9
10
  import { runTaskRunner } from "../../core/task-runner.js";
@@ -79,7 +80,7 @@ export async function runCommandHandler(options) {
79
80
  console.log(`Run ID: ${runId}`);
80
81
  const store = createStore();
81
82
  await store.createRun(runId, specPath);
82
- await runPlanner(specPath, store, runId);
83
+ await runPlanner(specPath, store, runId, orcaConfig);
83
84
  await store.updateRun(runId, {
84
85
  mode: "run",
85
86
  overallStatus: "running"
@@ -124,6 +125,10 @@ export async function runCommandHandler(options) {
124
125
  await dispatcher.dispatch(event);
125
126
  };
126
127
  const cwd = process.cwd();
128
+ const multiAgentResult = await ensureCodexMultiAgent(orcaConfig ?? undefined);
129
+ if (multiAgentResult.action === "created" || multiAgentResult.action === "appended") {
130
+ console.log(`Multi-agent: enabled (updated ${multiAgentResult.path})`);
131
+ }
127
132
  const codexSession = await createCodexSession(cwd, orcaConfig ?? undefined);
128
133
  try {
129
134
  // Phase 4: Codex consults the task graph before execution begins.
@@ -150,7 +155,7 @@ export async function runCommandHandler(options) {
150
155
  store,
151
156
  ...(orcaConfig ? { config: orcaConfig } : {}),
152
157
  emitHook,
153
- executeTask: (task, runId, _config) => codexSession.executeTask(task, runId),
158
+ executeTask: (task, runId, _config, systemContext) => codexSession.executeTask(task, runId, systemContext),
154
159
  });
155
160
  const reviewText = await codexSession.reviewChanges();
156
161
  console.log("Codex post-execution review:");
@@ -0,0 +1,58 @@
1
+ import { constants as fsConstants } from "node:fs";
2
+ import { access, mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ const CODEX_HOME = path.join(os.homedir(), ".codex");
6
+ const GLOBAL_CONFIG_FILE = path.join(CODEX_HOME, "config.toml");
7
+ const ORCA_MULTI_AGENT_BLOCK = `# Added by orca — remove or set multi_agent = false to disable
8
+ [features]
9
+ multi_agent = true
10
+ `;
11
+ function isMultiAgentEnabled(config) {
12
+ // Default: off. Only enable if explicitly set to true.
13
+ return config?.codex?.multiAgent === true;
14
+ }
15
+ function containsMultiAgentSetting(content) {
16
+ return /multi_agent\s*=/.test(content);
17
+ }
18
+ /**
19
+ * Ensures `~/.codex/config.toml` has `multi_agent = true` set.
20
+ *
21
+ * Uses the global config (not project-scoped) to avoid the "trusted projects"
22
+ * restriction that prevents project-level config from being loaded headlessly.
23
+ *
24
+ * - If the file doesn't exist: creates it with the multi_agent block.
25
+ * - If the file exists and already has `multi_agent`: leaves it alone.
26
+ * - If the file exists but has no `multi_agent`: appends the feature block.
27
+ * - If `multiAgent` is false in orca config: skips entirely.
28
+ *
29
+ * @param config - Orca config (checks codex.multiAgent flag)
30
+ * @param _configFile - Override config file path (for testing only)
31
+ */
32
+ export async function ensureCodexMultiAgent(config, _configFile) {
33
+ const configFile = _configFile ?? GLOBAL_CONFIG_FILE;
34
+ if (!isMultiAgentEnabled(config)) {
35
+ return { action: "skipped", path: configFile };
36
+ }
37
+ let existingContent = null;
38
+ try {
39
+ await access(configFile, fsConstants.R_OK);
40
+ existingContent = await readFile(configFile, "utf8");
41
+ }
42
+ catch (err) {
43
+ if (err.code !== "ENOENT") {
44
+ throw err;
45
+ }
46
+ }
47
+ if (existingContent === null) {
48
+ await mkdir(path.dirname(configFile), { recursive: true });
49
+ await writeFile(configFile, ORCA_MULTI_AGENT_BLOCK, "utf8");
50
+ return { action: "created", path: configFile };
51
+ }
52
+ if (containsMultiAgentSetting(existingContent)) {
53
+ return { action: "already-set", path: configFile };
54
+ }
55
+ const separator = existingContent.endsWith("\n") ? "\n" : "\n\n";
56
+ await writeFile(configFile, `${existingContent}${separator}${ORCA_MULTI_AGENT_BLOCK}`, "utf8");
57
+ return { action: "appended", path: configFile };
58
+ }
@@ -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)}`);
@@ -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,6 +1,7 @@
1
1
  import path from "node:path";
2
2
  import { promises as fs } from "node:fs";
3
3
  import { executeTask } from "../agents/claude/session.js";
4
+ import { loadSkills } from "../utils/skill-loader.js";
4
5
  import { getRunnable, validateDAG } from "./dependency-graph.js";
5
6
  import { shouldRetry } from "./retry-policy.js";
6
7
  let executeTaskImpl = executeTask;
@@ -40,6 +41,16 @@ function stripOptionalFields(task, fields) {
40
41
  function hasPendingTasks(tasks) {
41
42
  return tasks.some((task) => task.status === "pending" || task.status === "in_progress");
42
43
  }
44
+ function formatSkillsSection(skills) {
45
+ const formattedSkills = skills.map((skill) => [
46
+ `### ${skill.name}`,
47
+ "",
48
+ `Description: ${skill.description}`,
49
+ "",
50
+ skill.body
51
+ ].join("\n"));
52
+ return ["## Available Skills", "", ...formattedSkills].join("\n");
53
+ }
43
54
  function buildSessionSummary(run) {
44
55
  const taskRows = run.tasks.length === 0
45
56
  ? "| (none) | - | - | - | - |\n"
@@ -85,6 +96,8 @@ export async function runTaskRunner(options) {
85
96
  const emitHook = options.emitHook ?? defaultEmitHook;
86
97
  const executeTaskFn = options.executeTask ?? executeTaskImpl;
87
98
  const { runId, store, config } = options;
99
+ const skills = await loadSkills(config);
100
+ const taskSystemContext = skills.length === 0 ? undefined : formatSkillsSection(skills);
88
101
  let run = await store.getRun(runId);
89
102
  if (!run) {
90
103
  throw new Error(`Run not found: ${runId}`);
@@ -199,7 +212,7 @@ export async function runTaskRunner(options) {
199
212
  tasks: inProgressTasks
200
213
  });
201
214
  try {
202
- const result = await executeTaskFn(task, runId, config);
215
+ const result = await executeTaskFn(task, runId, config, taskSystemContext);
203
216
  if (result.outcome === "done") {
204
217
  const doneTasks = inProgressTasks.map((candidate) => {
205
218
  if (candidate.id !== task.id) {
@@ -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.4",
3
+ "version": "0.2.6",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "orca": "dist/cli/index.js"