orcastrator 0.2.11 → 0.2.13

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
@@ -128,6 +128,10 @@ Global:
128
128
  - `--spec <path>`
129
129
  - `--plan <path>`
130
130
  - `--config <path>`
131
+ - `--codex-only` (force Codex executor for this run)
132
+ - `--claude-only` (force Claude executor for this run)
133
+ - `--codex-effort <low|medium|high>`
134
+ - `--claude-effort <low|medium|high|max>`
131
135
  - `--on-milestone <cmd>`
132
136
  - `--on-task-complete <cmd>`
133
137
  - `--on-task-fail <cmd>`
@@ -150,6 +154,10 @@ Global:
150
154
  - `--run <run-id>`
151
155
  - `--last`
152
156
  - `--config <path>`
157
+ - `--codex-only`
158
+ - `--claude-only`
159
+ - `--codex-effort <low|medium|high>`
160
+ - `--claude-effort <low|medium|high|max>`
153
161
 
154
162
  `orca cancel`:
155
163
 
@@ -180,7 +188,7 @@ Global:
180
188
 
181
189
  - `--anthropic-key <key>`
182
190
  - `--openai-key <key>`
183
- - `--check`
191
+ - `--check` (API key lookup order: CLI flag → process env → `~/.openclaw/openclaw.json` `env.vars` → `~/.claude/.env` → `~/.config/claude/.env` → `./.env`)
184
192
  - `--global`
185
193
  - `--project`
186
194
 
@@ -1,16 +1,114 @@
1
- import { unstable_v2_createSession } from "@anthropic-ai/claude-agent-sdk";
1
+ import { query } from "@anthropic-ai/claude-agent-sdk";
2
+ import { z } from "zod";
3
+ import { parseAgentJson } from "../../utils/agent-json.js";
4
+ const PlannedTaskSchema = z.object({
5
+ id: z.string().min(1),
6
+ name: z.string().min(1),
7
+ description: z.string(),
8
+ dependencies: z.array(z.string()),
9
+ acceptance_criteria: z.array(z.string()),
10
+ status: z.literal("pending"),
11
+ retries: z.literal(0),
12
+ maxRetries: z.literal(3),
13
+ }).strict();
14
+ const PlanPayloadSchema = z.object({
15
+ tasks: z.array(PlannedTaskSchema).min(1),
16
+ }).strict();
17
+ const TaskExecutionPayloadSchema = z
18
+ .object({
19
+ outcome: z.enum(["done", "failed"]),
20
+ error: z.string().optional(),
21
+ })
22
+ .strict()
23
+ .superRefine((value, ctx) => {
24
+ if (value.outcome === "failed" && !value.error) {
25
+ ctx.addIssue({
26
+ code: z.ZodIssueCode.custom,
27
+ message: "error is required when outcome=failed",
28
+ path: ["error"],
29
+ });
30
+ }
31
+ if (value.outcome === "done" && value.error !== undefined) {
32
+ ctx.addIssue({
33
+ code: z.ZodIssueCode.custom,
34
+ message: "error must be omitted when outcome=done",
35
+ path: ["error"],
36
+ });
37
+ }
38
+ });
39
+ const PLAN_OUTPUT_SCHEMA = {
40
+ type: "object",
41
+ additionalProperties: false,
42
+ required: ["tasks"],
43
+ properties: {
44
+ tasks: {
45
+ type: "array",
46
+ minItems: 1,
47
+ items: {
48
+ type: "object",
49
+ additionalProperties: false,
50
+ required: [
51
+ "id",
52
+ "name",
53
+ "description",
54
+ "dependencies",
55
+ "acceptance_criteria",
56
+ "status",
57
+ "retries",
58
+ "maxRetries",
59
+ ],
60
+ properties: {
61
+ id: { type: "string", minLength: 1 },
62
+ name: { type: "string", minLength: 1 },
63
+ description: { type: "string" },
64
+ dependencies: { type: "array", items: { type: "string" } },
65
+ acceptance_criteria: { type: "array", items: { type: "string" } },
66
+ status: { type: "string", enum: ["pending"] },
67
+ retries: { type: "number", enum: [0] },
68
+ maxRetries: { type: "number", enum: [3] },
69
+ },
70
+ },
71
+ },
72
+ },
73
+ };
74
+ const EXECUTION_OUTPUT_SCHEMA = {
75
+ type: "object",
76
+ additionalProperties: false,
77
+ required: ["outcome"],
78
+ properties: {
79
+ outcome: { type: "string", enum: ["done", "failed"] },
80
+ error: { type: "string" },
81
+ },
82
+ // eslint-disable-next-line unicorn/no-thenable
83
+ allOf: [
84
+ {
85
+ if: { properties: { outcome: { const: "failed" } } },
86
+ // eslint-disable-next-line unicorn/no-thenable
87
+ then: { required: ["error"] },
88
+ },
89
+ {
90
+ if: { properties: { outcome: { const: "done" } } },
91
+ // eslint-disable-next-line unicorn/no-thenable
92
+ then: { not: { required: ["error"] } },
93
+ },
94
+ ],
95
+ };
96
+ const PLAN_OUTPUT_FORMAT = {
97
+ type: "json_schema",
98
+ schema: PLAN_OUTPUT_SCHEMA,
99
+ };
100
+ const EXECUTION_OUTPUT_FORMAT = {
101
+ type: "json_schema",
102
+ schema: EXECUTION_OUTPUT_SCHEMA,
103
+ };
2
104
  function buildPlanningPrompt(spec, systemContext) {
3
105
  return [
4
106
  systemContext,
5
107
  "You are decomposing a spec into an ordered task graph.",
6
- "Return a JSON array of tasks.",
7
- "Each task must include fields: id, name, description, dependencies, acceptance_criteria, status, retries, maxRetries.",
8
- "Set status to \"pending\", retries to 0, and maxRetries to 3 for every task.",
9
- "dependencies must be an array of task IDs.",
10
- "acceptance_criteria must be an array of strings.",
11
- "Return ONLY valid JSON. No markdown fences. No explanation.",
108
+ "Use the configured structured output schema only.",
109
+ "Do not include prose or markdown.",
12
110
  "Spec:",
13
- spec
111
+ spec,
14
112
  ].join("\n\n");
15
113
  }
16
114
  function buildTaskExecutionPrompt(task, runId, cwd, systemContext) {
@@ -25,10 +123,8 @@ function buildTaskExecutionPrompt(task, runId, cwd, systemContext) {
25
123
  task.description,
26
124
  "Acceptance Criteria:",
27
125
  ...task.acceptance_criteria.map((criterion, index) => `${index + 1}. ${criterion}`),
28
- "Execute this task and return ONLY JSON.",
29
- "JSON schema:",
30
- "{\"outcome\":\"done\"|\"failed\",\"error\"?:\"string\"}",
31
- "If you cannot complete the task, return outcome=failed with a short error reason."
126
+ "Use the configured structured output schema only.",
127
+ "If you cannot complete the task, set outcome=failed and provide a concise error.",
32
128
  ].join("\n\n");
33
129
  }
34
130
  function extractAssistantText(message) {
@@ -50,107 +146,154 @@ function extractAssistantText(message) {
50
146
  .trim();
51
147
  return text.length > 0 ? text : null;
52
148
  }
149
+ function formatSchemaError(prefix, error) {
150
+ const details = error.issues
151
+ .map((issue) => `${issue.path.length > 0 ? issue.path.join(".") : "<root>"}: ${issue.message}`)
152
+ .join("; ");
153
+ return new Error(`${prefix}. ${details}`);
154
+ }
53
155
  export function parseTaskArray(raw) {
54
- // Strip markdown fences if present (defensive fallback)
55
- const stripped = raw.trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```\s*$/i, "");
56
- const parsed = JSON.parse(stripped);
57
- if (!Array.isArray(parsed)) {
58
- throw new Error("Claude plan response was not a JSON array");
59
- }
60
- // Coerce numeric task IDs and dependency refs to strings.
61
- // LLMs sometimes emit numeric IDs (1, 2, 3) even when we ask for strings.
62
- return parsed.map((task) => ({
63
- ...task,
64
- id: String(task.id),
65
- dependencies: Array.isArray(task.dependencies)
66
- ? task.dependencies.map(String)
67
- : [],
68
- // Apply sensible defaults for required fields the LLM may have omitted.
69
- name: typeof task.name === "string" && task.name.length > 0 ? task.name : "Unnamed task",
70
- description: typeof task.description === "string" ? task.description : "",
71
- acceptance_criteria: Array.isArray(task.acceptance_criteria) ? task.acceptance_criteria : [],
72
- status: typeof task.status === "string" && task.status.length > 0 ? task.status : "pending",
73
- retries: typeof task.retries === "number" ? task.retries : 0,
74
- maxRetries: typeof task.maxRetries === "number" ? task.maxRetries : 3,
75
- }));
156
+ const parsed = parseAgentJson(raw);
157
+ const normalized = Array.isArray(parsed) ? { tasks: parsed } : parsed;
158
+ const result = PlanPayloadSchema.safeParse(normalized);
159
+ if (!result.success) {
160
+ throw formatSchemaError("Claude plan response failed schema validation", result.error);
161
+ }
162
+ return result.data.tasks;
76
163
  }
77
164
  export function parseTaskExecution(raw) {
78
- const parsed = JSON.parse(raw);
79
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
80
- throw new Error("Claude task response was not a JSON object");
165
+ const parsed = parseAgentJson(raw);
166
+ const result = TaskExecutionPayloadSchema.safeParse(parsed);
167
+ if (!result.success) {
168
+ throw formatSchemaError("Claude task response failed schema validation", result.error);
81
169
  }
82
- const candidate = parsed;
83
- if (candidate.outcome !== "done" && candidate.outcome !== "failed") {
84
- throw new Error("Claude task response missing valid outcome");
170
+ return {
171
+ outcome: result.data.outcome,
172
+ rawResponse: raw,
173
+ ...(result.data.error ? { error: result.data.error } : {}),
174
+ };
175
+ }
176
+ export function parseStructuredPlanPayload(payload) {
177
+ const result = PlanPayloadSchema.safeParse(payload);
178
+ if (!result.success) {
179
+ throw formatSchemaError("Claude structured plan payload failed schema validation", result.error);
85
180
  }
86
- if (candidate.error !== undefined && typeof candidate.error !== "string") {
87
- throw new Error("Claude task response error must be a string");
181
+ return result.data.tasks;
182
+ }
183
+ export function parseStructuredTaskExecutionPayload(payload, rawResponse = "") {
184
+ const result = TaskExecutionPayloadSchema.safeParse(payload);
185
+ if (!result.success) {
186
+ throw formatSchemaError("Claude structured task payload failed schema validation", result.error);
88
187
  }
89
188
  return {
90
- outcome: candidate.outcome,
91
- rawResponse: raw,
92
- ...(typeof candidate.error === "string" ? { error: candidate.error } : {})
189
+ outcome: result.data.outcome,
190
+ rawResponse,
191
+ ...(result.data.error ? { error: result.data.error } : {}),
93
192
  };
94
193
  }
95
- async function collectSessionResult(session) {
194
+ async function collectSessionResult(claudeQuery) {
96
195
  const assistantMessages = [];
97
196
  let resultText = null;
98
- for await (const message of session.stream()) {
197
+ let structuredOutput;
198
+ for await (const message of claudeQuery) {
99
199
  const assistantText = extractAssistantText(message);
100
200
  if (assistantText) {
101
201
  assistantMessages.push(assistantText);
102
202
  }
103
- if (message.type === "result" &&
104
- message.subtype === "success" &&
105
- typeof message.result === "string") {
106
- resultText = message.result;
203
+ if (message.type === "result" && message.subtype === "success") {
204
+ if (typeof message.result === "string") {
205
+ resultText = message.result;
206
+ }
207
+ if ("structured_output" in message) {
208
+ structuredOutput = message.structured_output;
209
+ }
107
210
  }
108
211
  if (message.type === "result" && message.subtype !== "success") {
109
212
  const details = "errors" in message ? message.errors.join("; ") : "unknown error";
110
213
  throw new Error(`Claude session failed (${message.subtype}): ${details}`);
111
214
  }
112
215
  }
113
- const rawResponse = assistantMessages.length > 0
114
- ? assistantMessages[assistantMessages.length - 1]
115
- : resultText;
116
- if (!rawResponse) {
216
+ const rawResponse = assistantMessages.length > 0 ? assistantMessages[assistantMessages.length - 1] : resultText;
217
+ if (!rawResponse && structuredOutput === undefined) {
117
218
  throw new Error("Claude response was empty");
118
219
  }
119
- return rawResponse;
220
+ return {
221
+ rawResponse: rawResponse ?? "",
222
+ structuredOutput,
223
+ };
120
224
  }
121
225
  function getModel(config) {
122
226
  return config?.claude?.model ?? process.env.ORCA_CLAUDE_MODEL ?? "claude-sonnet-4-5";
123
227
  }
124
- export async function planSpec(spec, systemContext, config) {
125
- const session = unstable_v2_createSession({
228
+ function getEffort(config) {
229
+ return config?.claude?.effort;
230
+ }
231
+ function buildClaudeQueryOptions(config, outputFormat) {
232
+ const options = {
126
233
  model: getModel(config),
127
234
  permissionMode: "bypassPermissions",
235
+ outputFormat,
236
+ };
237
+ const effort = getEffort(config);
238
+ if (effort) {
239
+ options.effortValue = effort;
240
+ }
241
+ return options;
242
+ }
243
+ function shouldAllowTextJsonFallback(config) {
244
+ if (config?.claude?.allowTextJsonFallback !== undefined) {
245
+ return config.claude.allowTextJsonFallback;
246
+ }
247
+ const env = process.env.ORCA_CLAUDE_ALLOW_TEXT_JSON_FALLBACK;
248
+ return env === "1" || env === "true";
249
+ }
250
+ function throwMissingStructuredOutput(kind) {
251
+ throw new Error(`Claude structured_output missing for ${kind}. Refusing freeform JSON parsing on critical path. ` +
252
+ "Set ORCA_CLAUDE_ALLOW_TEXT_JSON_FALLBACK=1 (or config.claude.allowTextJsonFallback=true) to temporarily enable fallback.");
253
+ }
254
+ export async function planSpec(spec, systemContext, config) {
255
+ const claudeQuery = query({
256
+ prompt: buildPlanningPrompt(spec, systemContext),
257
+ options: buildClaudeQueryOptions(config, PLAN_OUTPUT_FORMAT),
128
258
  });
129
259
  try {
130
- const streamPromise = collectSessionResult(session);
131
- await session.send(buildPlanningPrompt(spec, systemContext));
132
- const rawResponse = await streamPromise;
260
+ const { rawResponse, structuredOutput } = await collectSessionResult(claudeQuery);
261
+ if (structuredOutput !== undefined) {
262
+ return {
263
+ tasks: parseStructuredPlanPayload(structuredOutput),
264
+ rawResponse,
265
+ };
266
+ }
267
+ if (!shouldAllowTextJsonFallback(config)) {
268
+ throwMissingStructuredOutput("planner");
269
+ }
270
+ console.warn("[orca][claude] structured_output missing for planner; fallback text JSON parser enabled via explicit flag");
133
271
  return {
134
272
  tasks: parseTaskArray(rawResponse),
135
- rawResponse
273
+ rawResponse,
136
274
  };
137
275
  }
138
276
  finally {
139
- session.close();
277
+ claudeQuery.close();
140
278
  }
141
279
  }
142
280
  export async function executeTask(task, runId, config, systemContext) {
143
- const session = unstable_v2_createSession({
144
- model: getModel(config),
145
- permissionMode: "bypassPermissions",
281
+ const claudeQuery = query({
282
+ prompt: buildTaskExecutionPrompt(task, runId, process.cwd(), systemContext),
283
+ options: buildClaudeQueryOptions(config, EXECUTION_OUTPUT_FORMAT),
146
284
  });
147
285
  try {
148
- const streamPromise = collectSessionResult(session);
149
- await session.send(buildTaskExecutionPrompt(task, runId, process.cwd(), systemContext));
150
- const rawResponse = await streamPromise;
286
+ const { rawResponse, structuredOutput } = await collectSessionResult(claudeQuery);
287
+ if (structuredOutput !== undefined) {
288
+ return parseStructuredTaskExecutionPayload(structuredOutput, rawResponse);
289
+ }
290
+ if (!shouldAllowTextJsonFallback(config)) {
291
+ throwMissingStructuredOutput("task execution");
292
+ }
293
+ console.warn("[orca][claude] structured_output missing for task execution; fallback text JSON parser enabled via explicit flag");
151
294
  return parseTaskExecution(rawResponse);
152
295
  }
153
296
  finally {
154
- session.close();
297
+ claudeQuery.close();
155
298
  }
156
299
  }
@@ -158,6 +158,9 @@ function getCodexPath() {
158
158
  return (process.env.ORCA_CODEX_PATH ??
159
159
  `${process.env.HOME}/.nvm/versions/node/v22.22.0/bin/codex`);
160
160
  }
161
+ function getEffort(config) {
162
+ return config?.codex?.effort;
163
+ }
161
164
  export async function createCodexSession(cwd, config) {
162
165
  const client = new CodexClient({
163
166
  codexPath: getCodexPath(),
@@ -172,10 +175,17 @@ export async function createCodexSession(cwd, config) {
172
175
  return {
173
176
  threadId,
174
177
  async planSpec(spec, systemContext) {
175
- const result = await client.runTurn({
176
- threadId,
177
- input: [{ type: "text", text: buildPlanningPrompt(spec, systemContext) }],
178
- });
178
+ const effort = getEffort(config);
179
+ const result = effort
180
+ ? await client.runTurn({
181
+ threadId,
182
+ effort,
183
+ input: [{ type: "text", text: buildPlanningPrompt(spec, systemContext) }],
184
+ })
185
+ : await client.runTurn({
186
+ threadId,
187
+ input: [{ type: "text", text: buildPlanningPrompt(spec, systemContext) }],
188
+ });
179
189
  const rawResponse = extractAgentText(result);
180
190
  return {
181
191
  tasks: parseTaskArray(rawResponse),
@@ -183,15 +193,27 @@ export async function createCodexSession(cwd, config) {
183
193
  };
184
194
  },
185
195
  async executeTask(task, runId, systemContext) {
186
- const result = await client.runTurn({
187
- threadId,
188
- input: [
189
- {
190
- type: "text",
191
- text: buildTaskExecutionPrompt(task, runId, cwd, systemContext),
192
- },
193
- ],
194
- });
196
+ const effort = getEffort(config);
197
+ const result = effort
198
+ ? await client.runTurn({
199
+ threadId,
200
+ effort,
201
+ input: [
202
+ {
203
+ type: "text",
204
+ text: buildTaskExecutionPrompt(task, runId, cwd, systemContext),
205
+ },
206
+ ],
207
+ })
208
+ : await client.runTurn({
209
+ threadId,
210
+ input: [
211
+ {
212
+ type: "text",
213
+ text: buildTaskExecutionPrompt(task, runId, cwd, systemContext),
214
+ },
215
+ ],
216
+ });
195
217
  const rawResponse = extractAgentText(result);
196
218
  // Primary signal: use the SDK's structured turn status.
197
219
  const status = result.turn.status;
@@ -226,10 +248,17 @@ export async function createCodexSession(cwd, config) {
226
248
  "Task graph:",
227
249
  taskGraphJson,
228
250
  ].join("\n");
229
- const result = await client.runTurn({
230
- threadId,
231
- input: [{ type: "text", text: prompt }],
232
- });
251
+ const effort = getEffort(config);
252
+ const result = effort
253
+ ? await client.runTurn({
254
+ threadId,
255
+ effort,
256
+ input: [{ type: "text", text: prompt }],
257
+ })
258
+ : await client.runTurn({
259
+ threadId,
260
+ input: [{ type: "text", text: prompt }],
261
+ });
233
262
  const rawResponse = extractAgentText(result);
234
263
  const json = extractJson(rawResponse);
235
264
  const parsed = JSON.parse(json);
@@ -45,6 +45,22 @@ function printStyledHelpPage() {
45
45
  command: "--config <path>",
46
46
  description: "explicit config file (auto-discovered by default)"
47
47
  },
48
+ {
49
+ command: "--codex-only",
50
+ description: "override executor to Codex for the current run"
51
+ },
52
+ {
53
+ command: "--claude-only",
54
+ description: "override executor to Claude for the current run"
55
+ },
56
+ {
57
+ command: "--codex-effort <value>",
58
+ description: "override Codex effort for the current run"
59
+ },
60
+ {
61
+ command: "--claude-effort <value>",
62
+ description: "override Claude effort for the current run"
63
+ },
48
64
  { command: "--full-auto", description: "skip all questions, proceed autonomously" },
49
65
  { command: "--on-complete <cmd>", description: "shell hook on run complete" },
50
66
  { command: "--on-error <cmd>", description: "shell hook on run error" },
@@ -1,5 +1,8 @@
1
+ import { InvalidArgumentError } from "commander";
2
+ import { resolveConfig } from "../../core/config-loader.js";
1
3
  import { runTaskRunner } from "../../core/task-runner.js";
2
4
  import { RunStore } from "../../state/store.js";
5
+ import { parseClaudeEffort, parseCodexEffort } from "../../types/effort.js";
3
6
  import { getLastRun } from "../../utils/last-run.js";
4
7
  function createStore() {
5
8
  const runsDir = process.env.ORCA_RUNS_DIR;
@@ -14,8 +17,45 @@ function formatRunIds(runs) {
14
17
  function getActiveRuns(runs) {
15
18
  return runs.filter((run) => run.overallStatus === "planning" || run.overallStatus === "running");
16
19
  }
20
+ function parseCodexEffortOption(value) {
21
+ try {
22
+ return parseCodexEffort(value);
23
+ }
24
+ catch (error) {
25
+ throw new InvalidArgumentError(error instanceof Error ? error.message : String(error));
26
+ }
27
+ }
28
+ function parseClaudeEffortOption(value) {
29
+ try {
30
+ return parseClaudeEffort(value);
31
+ }
32
+ catch (error) {
33
+ throw new InvalidArgumentError(error instanceof Error ? error.message : String(error));
34
+ }
35
+ }
36
+ function applyExecutorOverrideForResume(config, options) {
37
+ const nextConfig = { ...config };
38
+ if (options.codexOnly || options.claudeOnly) {
39
+ nextConfig.executor = options.codexOnly ? "codex" : "claude";
40
+ }
41
+ if (options.codexEffort !== undefined) {
42
+ nextConfig.codex = { ...nextConfig.codex, effort: options.codexEffort };
43
+ }
44
+ if (options.claudeEffort !== undefined) {
45
+ nextConfig.claude = { ...nextConfig.claude, effort: options.claudeEffort };
46
+ }
47
+ if (config === undefined && Object.keys(nextConfig).length === 0) {
48
+ return undefined;
49
+ }
50
+ return nextConfig;
51
+ }
17
52
  export async function resumeCommandHandler(options) {
53
+ if (options.codexOnly && options.claudeOnly) {
54
+ throw new Error("--codex-only and --claude-only are mutually exclusive; choose only one executor override.");
55
+ }
18
56
  const store = createStore();
57
+ const resolvedConfig = await resolveConfig(options.config);
58
+ const effectiveConfig = applyExecutorOverrideForResume(resolvedConfig, options);
19
59
  if (options.last) {
20
60
  const lastRun = await getLastRun(store);
21
61
  if (!lastRun) {
@@ -59,7 +99,8 @@ export async function resumeCommandHandler(options) {
59
99
  });
60
100
  await runTaskRunner({
61
101
  runId: run.runId,
62
- store
102
+ store,
103
+ ...(effectiveConfig ? { config: effectiveConfig } : {})
63
104
  });
64
105
  const refreshed = await store.getRun(run.runId);
65
106
  if (!refreshed) {
@@ -74,5 +115,17 @@ export function registerResumeCommand(program) {
74
115
  .option("--run <run-id>", "Run ID to resume")
75
116
  .option("--last", "Use the most recent run")
76
117
  .option("--config <path>", "Path to orca config file")
77
- .action(async (options) => resumeCommandHandler(options));
118
+ .option("--codex-only", "Force Codex executor for this resumed run (overrides config)")
119
+ .option("--claude-only", "Force Claude executor for this resumed run (overrides config)")
120
+ .option("--codex-effort <value>", "Codex effort override for this resumed run", parseCodexEffortOption)
121
+ .option("--claude-effort <value>", "Claude effort override for this resumed run", parseClaudeEffortOption)
122
+ .action(async (options) => {
123
+ try {
124
+ await resumeCommandHandler(options);
125
+ }
126
+ catch (err) {
127
+ console.error(err instanceof Error ? err.message : String(err));
128
+ process.exitCode = 1;
129
+ }
130
+ });
78
131
  }
@@ -3,6 +3,7 @@ import { access, unlink, writeFile } from "node:fs/promises";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { randomUUID } from "node:crypto";
6
+ import { InvalidArgumentError } from "commander";
6
7
  import { createCodexSession } from "../../agents/codex/session.js";
7
8
  import { ensureCodexMultiAgent } from "../../core/codex-config.js";
8
9
  import { resolveConfig } from "../../core/config-loader.js";
@@ -12,6 +13,7 @@ import { createOpenclawHookHandler, detectOpenclawAvailability } from "../../hoo
12
13
  import { createStdoutHookHandler } from "../../hooks/adapters/stdout.js";
13
14
  import { HookDispatcher } from "../../hooks/dispatcher.js";
14
15
  import { RunStore } from "../../state/store.js";
16
+ import { parseClaudeEffort, parseCodexEffort } from "../../types/effort.js";
15
17
  import { generateRunId } from "../../utils/ids.js";
16
18
  const ALL_HOOKS = [
17
19
  "onMilestone",
@@ -30,6 +32,22 @@ const VALID_HOOK_NAMES = new Set([
30
32
  function isHookName(value) {
31
33
  return VALID_HOOK_NAMES.has(value);
32
34
  }
35
+ function parseCodexEffortOption(value) {
36
+ try {
37
+ return parseCodexEffort(value);
38
+ }
39
+ catch (error) {
40
+ throw new InvalidArgumentError(error instanceof Error ? error.message : String(error));
41
+ }
42
+ }
43
+ function parseClaudeEffortOption(value) {
44
+ try {
45
+ return parseClaudeEffort(value);
46
+ }
47
+ catch (error) {
48
+ throw new InvalidArgumentError(error instanceof Error ? error.message : String(error));
49
+ }
50
+ }
33
51
  function createStore() {
34
52
  const runsDir = process.env.ORCA_RUNS_DIR;
35
53
  return runsDir ? new RunStore(runsDir) : new RunStore();
@@ -49,7 +67,26 @@ function buildCliCommandHooks(options) {
49
67
  ...(options.onError ? { onError: options.onError } : {})
50
68
  };
51
69
  }
70
+ function applyExecutorOverrideForRun(config, options) {
71
+ const nextConfig = { ...config };
72
+ if (options.codexOnly || options.claudeOnly) {
73
+ nextConfig.executor = options.codexOnly ? "codex" : "claude";
74
+ }
75
+ if (options.codexEffort !== undefined) {
76
+ nextConfig.codex = { ...nextConfig.codex, effort: options.codexEffort };
77
+ }
78
+ if (options.claudeEffort !== undefined) {
79
+ nextConfig.claude = { ...nextConfig.claude, effort: options.claudeEffort };
80
+ }
81
+ if (config === undefined && Object.keys(nextConfig).length === 0) {
82
+ return undefined;
83
+ }
84
+ return nextConfig;
85
+ }
52
86
  export async function runCommandHandler(options) {
87
+ if (options.codexOnly && options.claudeOnly) {
88
+ throw new Error("--codex-only and --claude-only are mutually exclusive; choose only one executor override.");
89
+ }
53
90
  const inlineTask = options.task ?? options.prompt ?? options.goal;
54
91
  const inputSpecPath = options.spec ?? options.plan;
55
92
  if (options.goal !== undefined && (options.task || options.prompt)) {
@@ -76,11 +113,12 @@ export async function runCommandHandler(options) {
76
113
  throw new Error(`Spec file not found or not readable: ${specPath}`);
77
114
  });
78
115
  const orcaConfig = await resolveConfig(options.config);
116
+ const effectiveConfig = applyExecutorOverrideForRun(orcaConfig, options);
79
117
  const runId = generateRunId(specPath);
80
118
  console.log(`Run ID: ${runId}`);
81
119
  const store = createStore();
82
120
  await store.createRun(runId, specPath);
83
- await runPlanner(specPath, store, runId, orcaConfig);
121
+ await runPlanner(specPath, store, runId, effectiveConfig);
84
122
  await store.updateRun(runId, {
85
123
  mode: "run",
86
124
  overallStatus: "running"
@@ -124,45 +162,57 @@ export async function runCommandHandler(options) {
124
162
  const emitHook = async (event) => {
125
163
  await dispatcher.dispatch(event);
126
164
  };
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
- }
132
- const codexSession = await createCodexSession(cwd, orcaConfig ?? undefined);
133
- try {
134
- // Phase 4: Codex consults the task graph before execution begins.
135
- const plannedRun = await store.getRun(runId);
136
- if (!plannedRun) {
137
- throw new Error(`Run not found after planning: ${runId}`);
165
+ const executor = effectiveConfig?.executor ?? "codex";
166
+ if (executor === "codex") {
167
+ const cwd = process.cwd();
168
+ const multiAgentResult = await ensureCodexMultiAgent(effectiveConfig);
169
+ if (multiAgentResult.action === "created" || multiAgentResult.action === "appended") {
170
+ console.log(`Multi-agent: enabled (updated ${multiAgentResult.path})`);
138
171
  }
139
- console.log("Phase 4: Codex reviewing task graph...");
140
- const consultation = await codexSession.consultTaskGraph(plannedRun.tasks);
141
- if (consultation.issues.length > 0) {
142
- console.log("Codex consultation issues:");
143
- for (const issue of consultation.issues) {
144
- console.log(` - ${issue}`);
172
+ const codexSession = await createCodexSession(cwd, effectiveConfig);
173
+ try {
174
+ // Phase 4: Codex consults the task graph before execution begins.
175
+ const plannedRun = await store.getRun(runId);
176
+ if (!plannedRun) {
177
+ throw new Error(`Run not found after planning: ${runId}`);
145
178
  }
179
+ console.log("Phase 4: Codex reviewing task graph...");
180
+ const consultation = await codexSession.consultTaskGraph(plannedRun.tasks);
181
+ if (consultation.issues.length > 0) {
182
+ console.log("Codex consultation issues:");
183
+ for (const issue of consultation.issues) {
184
+ console.log(` - ${issue}`);
185
+ }
186
+ }
187
+ if (!consultation.ok) {
188
+ console.error("Codex flagged the task graph as not OK. Aborting.");
189
+ await store.updateRun(runId, { overallStatus: "failed" });
190
+ return;
191
+ }
192
+ console.log("Codex consultation passed. Starting execution...");
193
+ await runTaskRunner({
194
+ runId,
195
+ store,
196
+ ...(effectiveConfig ? { config: effectiveConfig } : {}),
197
+ emitHook,
198
+ executeTask: (task, taskRunId, _config, systemContext) => codexSession.executeTask(task, taskRunId, systemContext),
199
+ });
200
+ const reviewText = await codexSession.reviewChanges();
201
+ console.log("Codex post-execution review:");
202
+ console.log(reviewText);
146
203
  }
147
- if (!consultation.ok) {
148
- console.error("Codex flagged the task graph as not OK. Aborting.");
149
- await store.updateRun(runId, { overallStatus: "failed" });
150
- return;
204
+ finally {
205
+ await codexSession.disconnect();
151
206
  }
152
- console.log("Codex consultation passed. Starting execution...");
207
+ }
208
+ else {
209
+ console.log("Phase 4: Skipping Codex consultation because executor is set to Claude.");
153
210
  await runTaskRunner({
154
211
  runId,
155
212
  store,
156
- ...(orcaConfig ? { config: orcaConfig } : {}),
157
- emitHook,
158
- executeTask: (task, runId, _config, systemContext) => codexSession.executeTask(task, runId, systemContext),
213
+ ...(effectiveConfig ? { config: effectiveConfig } : {}),
214
+ emitHook
159
215
  });
160
- const reviewText = await codexSession.reviewChanges();
161
- console.log("Codex post-execution review:");
162
- console.log(reviewText);
163
- }
164
- finally {
165
- await codexSession.disconnect();
166
216
  }
167
217
  const run = await store.getRun(runId);
168
218
  if (!run) {
@@ -196,6 +246,10 @@ export function registerRunCommand(program) {
196
246
  .option("--task <text>", "Inline task text (alternative to --spec)")
197
247
  .option("-p, --prompt <text>", "Inline task text (alias for --task)")
198
248
  .option("--config <path>", "Path to orca config file")
249
+ .option("--codex-only", "Force Codex executor for this run (overrides config)")
250
+ .option("--claude-only", "Force Claude executor for this run (overrides config)")
251
+ .option("--codex-effort <value>", "Codex effort override for this run", parseCodexEffortOption)
252
+ .option("--claude-effort <value>", "Claude effort override for this run", parseClaudeEffortOption)
199
253
  .option("--on-milestone <cmd>", "Shell hook command for onMilestone")
200
254
  .option("--on-task-complete <cmd>", "Shell hook command for onTaskComplete")
201
255
  .option("--on-task-fail <cmd>", "Shell hook command for onTaskFail")
@@ -207,6 +261,11 @@ export function registerRunCommand(program) {
207
261
  ...commandOptions,
208
262
  ...(goal !== undefined ? { goal } : {})
209
263
  };
264
+ if (normalizedOptions.codexOnly && normalizedOptions.claudeOnly) {
265
+ console.error("Error: --codex-only and --claude-only are mutually exclusive; choose only one.");
266
+ process.exitCode = 1;
267
+ return;
268
+ }
210
269
  const inlineTask = normalizedOptions.task ?? normalizedOptions.prompt ?? normalizedOptions.goal;
211
270
  const inputSpecPath = normalizedOptions.spec ?? normalizedOptions.plan;
212
271
  if (normalizedOptions.goal !== undefined && (normalizedOptions.task || normalizedOptions.prompt)) {
@@ -1,5 +1,5 @@
1
1
  import { execSync, spawnSync } from "node:child_process";
2
- import { constants as fsConstants } from "node:fs";
2
+ import { constants as fsConstants, readFileSync } from "node:fs";
3
3
  import { access, mkdir, writeFile } from "node:fs/promises";
4
4
  import os from "node:os";
5
5
  import path from "node:path";
@@ -48,7 +48,7 @@ function commandExists(command) {
48
48
  const result = spawnSync("which", [command], { stdio: "ignore" });
49
49
  return result.status === 0;
50
50
  }
51
- export function resolveApiKey(flagValue, envVarName) {
51
+ export function resolveApiKey(flagValue, envVarName, openclawConfigPathOrOptions, maybeOptions) {
52
52
  if (flagValue && flagValue.trim().length > 0) {
53
53
  return flagValue.trim();
54
54
  }
@@ -56,8 +56,138 @@ export function resolveApiKey(flagValue, envVarName) {
56
56
  if (envValue && envValue.trim().length > 0) {
57
57
  return envValue.trim();
58
58
  }
59
+ const options = typeof openclawConfigPathOrOptions === "string"
60
+ ? { ...(maybeOptions ?? {}), openclawConfigPath: openclawConfigPathOrOptions }
61
+ : (openclawConfigPathOrOptions ?? {});
62
+ const homedir = options.homedir ?? os.homedir();
63
+ const openclawValue = readOpenclawEnvVar(envVarName, options.openclawConfigPath, homedir);
64
+ if (openclawValue) {
65
+ return openclawValue;
66
+ }
67
+ const dotenvValue = readDotEnvFallback(envVarName, {
68
+ cwd: options.cwd ?? process.cwd(),
69
+ homedir
70
+ });
71
+ if (dotenvValue) {
72
+ return dotenvValue;
73
+ }
59
74
  return undefined;
60
75
  }
76
+ function readOpenclawEnvVar(envVarName, openclawConfigPath, homedir = os.homedir()) {
77
+ const configPath = openclawConfigPath ?? path.join(homedir, ".openclaw", "openclaw.json");
78
+ try {
79
+ const fileText = readFileSync(configPath, "utf8");
80
+ const parsed = JSON.parse(fileText);
81
+ const value = parsed.env?.vars?.[envVarName];
82
+ if (typeof value === "string" && value.trim().length > 0) {
83
+ return value.trim();
84
+ }
85
+ if (value && typeof value === "object") {
86
+ const candidateObject = value;
87
+ for (const candidate of [
88
+ candidateObject.value,
89
+ candidateObject.ref,
90
+ candidateObject.opRef,
91
+ candidateObject.reference
92
+ ]) {
93
+ if (typeof candidate === "string" && candidate.trim().length > 0) {
94
+ return candidate.trim();
95
+ }
96
+ }
97
+ }
98
+ }
99
+ catch {
100
+ return undefined;
101
+ }
102
+ return undefined;
103
+ }
104
+ function readDotEnvFallback(envVarName, options) {
105
+ const candidatePaths = [
106
+ path.join(options.homedir, ".claude", ".env"),
107
+ path.join(options.homedir, ".config", "claude", ".env"),
108
+ path.join(options.cwd, ".env")
109
+ ];
110
+ for (const candidatePath of candidatePaths) {
111
+ const value = readEnvVarFromDotEnvFile(candidatePath, envVarName);
112
+ if (value !== undefined) {
113
+ return value;
114
+ }
115
+ }
116
+ return undefined;
117
+ }
118
+ function readEnvVarFromDotEnvFile(filePath, envVarName) {
119
+ try {
120
+ const parsed = parseDotEnv(readFileSync(filePath, "utf8"));
121
+ const value = parsed[envVarName];
122
+ if (value !== undefined && value.trim().length > 0) {
123
+ return value.trim();
124
+ }
125
+ }
126
+ catch {
127
+ return undefined;
128
+ }
129
+ return undefined;
130
+ }
131
+ function parseDotEnv(text) {
132
+ const values = {};
133
+ for (const rawLine of text.split(/\r?\n/)) {
134
+ const line = rawLine.trim();
135
+ if (!line || line.startsWith("#")) {
136
+ continue;
137
+ }
138
+ const normalized = line.startsWith("export ") ? line.slice(7).trimStart() : line;
139
+ const separatorIndex = normalized.indexOf("=");
140
+ if (separatorIndex <= 0) {
141
+ continue;
142
+ }
143
+ const key = normalized.slice(0, separatorIndex).trim();
144
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
145
+ continue;
146
+ }
147
+ let rawValue = normalized.slice(separatorIndex + 1).trim();
148
+ if (!rawValue) {
149
+ values[key] = "";
150
+ continue;
151
+ }
152
+ if (rawValue.startsWith('"') || rawValue.startsWith("'")) {
153
+ const quote = rawValue.charAt(0);
154
+ const closingQuoteIndex = findClosingQuote(rawValue, quote);
155
+ if (closingQuoteIndex > 0) {
156
+ rawValue = rawValue.slice(1, closingQuoteIndex);
157
+ }
158
+ else {
159
+ rawValue = rawValue.slice(1);
160
+ }
161
+ values[key] = quote === '"' ? unescapeDoubleQuoted(rawValue) : unescapeSingleQuoted(rawValue);
162
+ continue;
163
+ }
164
+ const commentStart = rawValue.search(/\s#/);
165
+ if (commentStart >= 0) {
166
+ rawValue = rawValue.slice(0, commentStart).trimEnd();
167
+ }
168
+ values[key] = rawValue;
169
+ }
170
+ return values;
171
+ }
172
+ function findClosingQuote(value, quote) {
173
+ for (let i = 1; i < value.length; i += 1) {
174
+ if (value[i] === quote && value[i - 1] !== "\\") {
175
+ return i;
176
+ }
177
+ }
178
+ return -1;
179
+ }
180
+ function unescapeDoubleQuoted(value) {
181
+ return value
182
+ .replace(/\\n/g, "\n")
183
+ .replace(/\\r/g, "\r")
184
+ .replace(/\\t/g, "\t")
185
+ .replace(/\\"/g, '"')
186
+ .replace(/\\\\/g, "\\");
187
+ }
188
+ function unescapeSingleQuoted(value) {
189
+ return value.replace(/\\'/g, "'").replace(/\\\\/g, "\\");
190
+ }
61
191
  export function detectPackageManager(exists = commandExists) {
62
192
  if (exists("brew")) {
63
193
  return "brew";
@@ -228,7 +358,7 @@ export async function setupCommandHandler(options) {
228
358
  console.log(chalk.green("✓ Anthropic API key found"));
229
359
  }
230
360
  else {
231
- results.push({ name: "ANTHROPIC_API_KEY", status: "warn", detail: checkMode ? "not configured" : "not set" });
361
+ results.push({ name: "ANTHROPIC_API_KEY", status: "warn", detail: "not set" });
232
362
  if (!checkMode)
233
363
  console.log(chalk.yellow("! ANTHROPIC_API_KEY not set"));
234
364
  }
@@ -241,7 +371,7 @@ export async function setupCommandHandler(options) {
241
371
  console.log(chalk.green("✓ OpenAI API key found"));
242
372
  }
243
373
  else {
244
- results.push({ name: "OPENAI_API_KEY", status: "warn", detail: checkMode ? "not configured" : "not set" });
374
+ results.push({ name: "OPENAI_API_KEY", status: "warn", detail: "not set" });
245
375
  if (!checkMode)
246
376
  console.log(chalk.yellow("! OPENAI_API_KEY not set"));
247
377
  }
@@ -0,0 +1,75 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+ import { resolveConfig } from "../../core/config-loader.js";
4
+ import { loadSkills } from "../../utils/skill-loader.js";
5
+ function pad(value, width) {
6
+ return value.padEnd(width, " ");
7
+ }
8
+ function formatTable(headers, rows) {
9
+ const widths = headers.map((header, index) => {
10
+ const rowWidths = rows.map((row) => row[index]?.length ?? 0);
11
+ return Math.max(header.length, ...rowWidths);
12
+ });
13
+ const headerLine = headers.map((header, index) => pad(header, widths[index] ?? header.length)).join(" ");
14
+ const separator = widths.map((width) => "-".repeat(width)).join(" ");
15
+ const body = rows.map((row) => row.map((cell, index) => pad(cell, widths[index] ?? cell.length)).join(" "));
16
+ return [headerLine, separator, ...body].join("\n");
17
+ }
18
+ function expandHome(inputPath) {
19
+ if (inputPath === "~") {
20
+ return os.homedir();
21
+ }
22
+ if (inputPath.startsWith("~/") || inputPath.startsWith("~\\")) {
23
+ return path.join(os.homedir(), inputPath.slice(2));
24
+ }
25
+ return inputPath;
26
+ }
27
+ function resolveConfigSkillDir(inputPath) {
28
+ return path.resolve(expandHome(inputPath));
29
+ }
30
+ function pathWithin(targetPath, parentPath) {
31
+ const relative = path.relative(parentPath, targetPath);
32
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
33
+ }
34
+ function detectSkillSource(skill, configSkillDirs) {
35
+ if (configSkillDirs.has(skill.dirPath)) {
36
+ return "config";
37
+ }
38
+ const projectSkillsRoot = path.join(process.cwd(), ".orca", "skills");
39
+ if (pathWithin(skill.dirPath, projectSkillsRoot)) {
40
+ return "project";
41
+ }
42
+ const globalSkillsRoot = path.join(os.homedir(), ".orca", "skills");
43
+ if (pathWithin(skill.dirPath, globalSkillsRoot)) {
44
+ return "global";
45
+ }
46
+ // loadSkills currently only returns config/project/global entries.
47
+ return "config";
48
+ }
49
+ function formatSkillsTable(skills, configSkillDirs) {
50
+ const headers = ["Name", "Description", "Source", "Path"];
51
+ const rows = skills.map((skill) => [
52
+ skill.name,
53
+ skill.description,
54
+ detectSkillSource(skill, configSkillDirs),
55
+ skill.dirPath
56
+ ]);
57
+ return formatTable(headers, rows);
58
+ }
59
+ export async function skillsCommandHandler(options) {
60
+ const config = await resolveConfig(options.config);
61
+ const configSkillDirs = new Set((config?.skills ?? []).map((skillPath) => resolveConfigSkillDir(skillPath)));
62
+ const skills = await loadSkills(config);
63
+ if (skills.length === 0) {
64
+ console.log("No skills found.");
65
+ return;
66
+ }
67
+ console.log(formatSkillsTable(skills, configSkillDirs));
68
+ }
69
+ export function registerSkillsCommand(program) {
70
+ program
71
+ .command("skills")
72
+ .description("List available skills")
73
+ .option("--config <path>", "Path to orca config file")
74
+ .action(async (options) => skillsCommandHandler(options));
75
+ }
package/dist/cli/index.js CHANGED
@@ -13,6 +13,7 @@ import { registerPrFinalizeCommand } from "./commands/pr-finalize.js";
13
13
  import { registerResumeCommand } from "./commands/resume.js";
14
14
  import { registerRunCommand } from "./commands/run.js";
15
15
  import { registerSetupCommand } from "./commands/setup.js";
16
+ import { registerSkillsCommand } from "./commands/skills.js";
16
17
  import { registerStatusCommand } from "./commands/status.js";
17
18
  const program = new Command();
18
19
  program.name("orca").description("Orca CLI: coordinated agent run harness").version(version);
@@ -21,6 +22,7 @@ registerAnswerCommand(program);
21
22
  registerPlanCommand(program);
22
23
  registerStatusCommand(program);
23
24
  registerListCommand(program);
25
+ registerSkillsCommand(program);
24
26
  registerResumeCommand(program);
25
27
  registerCancelCommand(program);
26
28
  registerPrCommand(program);
@@ -3,6 +3,7 @@ import { access } from "node:fs/promises";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { pathToFileURL } from "node:url";
6
+ import { parseClaudeEffort, parseCodexEffort } from "../types/effort.js";
6
7
  function isObject(value) {
7
8
  return typeof value === "object" && value !== null;
8
9
  }
@@ -54,6 +55,28 @@ function coerceConfig(candidate) {
54
55
  throw new Error(`Config.executor must be 'claude' or 'codex', got ${String(candidate.executor)}`);
55
56
  }
56
57
  }
58
+ if ("claude" in candidate && candidate.claude !== undefined) {
59
+ if (!isObject(candidate.claude)) {
60
+ throw new Error(`Config.claude must be an object, got ${describeType(candidate.claude)}`);
61
+ }
62
+ if ("effort" in candidate.claude && candidate.claude.effort !== undefined) {
63
+ if (typeof candidate.claude.effort !== "string") {
64
+ throw new Error(`Config.claude.effort must be a string, got ${describeType(candidate.claude.effort)}`);
65
+ }
66
+ parseClaudeEffort(candidate.claude.effort);
67
+ }
68
+ }
69
+ if ("codex" in candidate && candidate.codex !== undefined) {
70
+ if (!isObject(candidate.codex)) {
71
+ throw new Error(`Config.codex must be an object, got ${describeType(candidate.codex)}`);
72
+ }
73
+ if ("effort" in candidate.codex && candidate.codex.effort !== undefined) {
74
+ if (typeof candidate.codex.effort !== "string") {
75
+ throw new Error(`Config.codex.effort must be a string, got ${describeType(candidate.codex.effort)}`);
76
+ }
77
+ parseCodexEffort(candidate.codex.effort);
78
+ }
79
+ }
57
80
  return candidate;
58
81
  }
59
82
  export async function loadConfig(configPath) {
@@ -1,12 +1,20 @@
1
1
  import { promises as fs } from "node:fs";
2
- import { planSpec } from "../agents/claude/session.js";
2
+ import { planSpec as planSpecWithClaude } from "../agents/claude/session.js";
3
+ import { planSpec as planSpecWithCodex } from "../agents/codex/session.js";
3
4
  import { logger } from "../utils/logger.js";
4
5
  import { loadSkills } from "../utils/skill-loader.js";
5
6
  import { validateDAG } from "./dependency-graph.js";
6
7
  const DEFAULT_SYSTEM_CONTEXT = "You are Orca planner.";
7
- let planSpecImpl = planSpec;
8
+ let testPlanSpecOverride = null;
8
9
  export function setPlanSpecForTests(fn) {
9
- planSpecImpl = fn ?? planSpec;
10
+ testPlanSpecOverride = fn;
11
+ }
12
+ function resolvePlanSpecImpl(config) {
13
+ if (testPlanSpecOverride) {
14
+ return testPlanSpecOverride;
15
+ }
16
+ const executor = config?.executor ?? "codex";
17
+ return executor === "claude" ? planSpecWithClaude : planSpecWithCodex;
10
18
  }
11
19
  function formatSkillsSection(skills) {
12
20
  const formattedSkills = skills.map((skill) => [
@@ -24,6 +32,7 @@ export async function runPlanner(specPath, store, runId, config) {
24
32
  const systemContext = skills.length === 0
25
33
  ? DEFAULT_SYSTEM_CONTEXT
26
34
  : `${DEFAULT_SYSTEM_CONTEXT}\n\n${formatSkillsSection(skills)}`;
35
+ const planSpecImpl = resolvePlanSpecImpl(config);
27
36
  const result = await planSpecImpl(spec, systemContext, config);
28
37
  validateDAG(result.tasks);
29
38
  await store.writeTasks(runId, result.tasks);
@@ -0,0 +1,17 @@
1
+ export const CODEX_EFFORT_VALUES = ["low", "medium", "high"];
2
+ export const CLAUDE_EFFORT_VALUES = ["low", "medium", "high", "max"];
3
+ function formatAllowed(values) {
4
+ return values.map((value) => `'${value}'`).join(", ");
5
+ }
6
+ function parseEffort(raw, allowed, label) {
7
+ if (allowed.includes(raw)) {
8
+ return raw;
9
+ }
10
+ throw new Error(`${label} must be one of ${formatAllowed(allowed)}, got '${raw}'`);
11
+ }
12
+ export function parseCodexEffort(raw) {
13
+ return parseEffort(raw, CODEX_EFFORT_VALUES, "Codex effort");
14
+ }
15
+ export function parseClaudeEffort(raw) {
16
+ return parseEffort(raw, CLAUDE_EFFORT_VALUES, "Claude effort");
17
+ }
@@ -0,0 +1,84 @@
1
+ function tryParseJson(input) {
2
+ try {
3
+ return JSON.parse(input);
4
+ }
5
+ catch {
6
+ return null;
7
+ }
8
+ }
9
+ function extractFencedCandidates(text) {
10
+ const candidates = [];
11
+ const fenceRegex = /```(?:json)?\s*\n?([\s\S]*?)\n?```/gi;
12
+ for (const match of text.matchAll(fenceRegex)) {
13
+ const body = match[1]?.trim();
14
+ if (body) {
15
+ candidates.push(body);
16
+ }
17
+ }
18
+ return candidates;
19
+ }
20
+ function extractFirstJsonObjectOrArray(text) {
21
+ for (let start = 0; start < text.length; start += 1) {
22
+ const first = text[start];
23
+ if (first !== "{" && first !== "[") {
24
+ continue;
25
+ }
26
+ let depth = 0;
27
+ let inString = false;
28
+ let escaped = false;
29
+ for (let end = start; end < text.length; end += 1) {
30
+ const ch = text[end];
31
+ if (inString) {
32
+ if (escaped) {
33
+ escaped = false;
34
+ continue;
35
+ }
36
+ if (ch === "\\") {
37
+ escaped = true;
38
+ continue;
39
+ }
40
+ if (ch === '"') {
41
+ inString = false;
42
+ }
43
+ continue;
44
+ }
45
+ if (ch === '"') {
46
+ inString = true;
47
+ continue;
48
+ }
49
+ if (ch === "{" || ch === "[") {
50
+ depth += 1;
51
+ continue;
52
+ }
53
+ if (ch === "}" || ch === "]") {
54
+ depth -= 1;
55
+ if (depth === 0) {
56
+ const candidate = text.slice(start, end + 1).trim();
57
+ if (tryParseJson(candidate) !== null) {
58
+ return candidate;
59
+ }
60
+ break;
61
+ }
62
+ }
63
+ }
64
+ }
65
+ return null;
66
+ }
67
+ export function parseAgentJson(raw) {
68
+ const text = raw.trim();
69
+ const candidates = [
70
+ text,
71
+ ...extractFencedCandidates(text),
72
+ ];
73
+ const extracted = extractFirstJsonObjectOrArray(text);
74
+ if (extracted) {
75
+ candidates.push(extracted);
76
+ }
77
+ for (const candidate of candidates) {
78
+ const parsed = tryParseJson(candidate);
79
+ if (parsed !== null) {
80
+ return parsed;
81
+ }
82
+ }
83
+ throw new Error("Response did not contain valid JSON object/array");
84
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orcastrator",
3
- "version": "0.2.11",
3
+ "version": "0.2.13",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "orca": "dist/cli/index.js"