orcastrator 0.2.13 → 0.2.15

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
@@ -22,7 +22,17 @@ Start with a plain-language goal:
22
22
  orca "add auth to the app"
23
23
  ```
24
24
 
25
- Orca will create a run, plan tasks, execute them, and persist run state.
25
+ Orca will create a run, plan tasks, run a pre-execution review/improvement pass on the task graph, execute the reviewed graph, and persist run state.
26
+
27
+ ### Pre-execution review-improvement stage
28
+
29
+ After planning, Orca runs a structured review pass that can edit the task graph before execution starts. The review output is schema-validated and supports concrete graph operations:
30
+
31
+ - update task fields (`name`, `description`, `acceptance_criteria`)
32
+ - add/remove task
33
+ - add/remove dependency
34
+
35
+ The edited graph is re-validated as a DAG. If review output is invalid, Orca fails with an actionable error by default. You can configure `review.onInvalid: "warn_skip"` to log a warning and continue with the original planner graph.
26
36
 
27
37
  ## Spec And Plan Files
28
38
 
@@ -92,6 +102,10 @@ export default {
92
102
  codex: {
93
103
  model: "gpt-5.3-codex", // override the codex model
94
104
  multiAgent: true, // enable codex multi-agent (see below)
105
+ },
106
+ review: {
107
+ enabled: true, // default true
108
+ onInvalid: "fail" // or "warn_skip"
95
109
  }
96
110
  };
97
111
  ```
@@ -135,6 +149,7 @@ Global:
135
149
  - `--on-milestone <cmd>`
136
150
  - `--on-task-complete <cmd>`
137
151
  - `--on-task-fail <cmd>`
152
+ - `--on-invalid-plan <cmd>`
138
153
  - `--on-complete <cmd>`
139
154
  - `--on-error <cmd>`
140
155
 
@@ -188,7 +203,7 @@ Global:
188
203
 
189
204
  - `--anthropic-key <key>`
190
205
  - `--openai-key <key>`
191
- - `--check` (API key lookup order: CLI flag → process env → `~/.openclaw/openclaw.json` `env.vars` → `~/.claude/.env` → `~/.config/claude/.env` → `./.env`)
206
+ - `--check` (API key lookup order: CLI flag → process env → `~/.openclaw/openclaw.json` `env.vars` → `~/.claude/.env` → `~/.config/claude/.env`)
192
207
  - `--global`
193
208
  - `--project`
194
209
 
@@ -204,6 +219,7 @@ Hook names:
204
219
  - `onMilestone`
205
220
  - `onTaskComplete`
206
221
  - `onTaskFail`
222
+ - `onInvalidPlan`
207
223
  - `onComplete`
208
224
  - `onError`
209
225
 
@@ -222,6 +238,15 @@ Run IDs are generated as:
222
238
  - Project: `./orca.config.js` or `./orca.config.ts`
223
239
  - Explicit: `--config <path>`
224
240
 
241
+ ### Project Instruction Files
242
+
243
+ During planning, Orca automatically injects project instruction files when present:
244
+
245
+ 1. `AGENTS.md`
246
+ 2. `CLAUDE.md`
247
+
248
+ Files are discovered from the project root (nearest `.git` from the spec/task context) and injected in that order.
249
+
225
250
  ### Run State Locations
226
251
 
227
252
  - Run status: `<runsDir>/<run-id>/status.json`
@@ -230,8 +255,54 @@ Run IDs are generated as:
230
255
 
231
256
  ## Development
232
257
 
258
+ Install dependencies with npm (primary lockfile):
259
+
260
+ ```bash
261
+ npm install
262
+ ```
263
+
264
+ Run local development and tests with Bun (faster runtime for this project):
265
+
233
266
  ```bash
234
- bun install
235
- bun test
236
267
  bun run src/cli/index.ts "your goal here"
268
+ bun test src
269
+ ```
270
+
271
+ ## Validation pipeline
272
+
273
+ Use the full validation gate before opening/publishing changes:
274
+
275
+ ```bash
276
+ npm run validate
237
277
  ```
278
+
279
+ This runs, in order:
280
+
281
+ 1. `npm run lint` (Oxlint syntax/style/static rules)
282
+ 2. `npm run lint:type-aware` (Oxlint + tsgolint alpha type-aware + type-check diagnostics)
283
+ 3. `npm run typecheck` (TypeScript Native Preview via `tsgo --noEmit`, with environment fallback to `tsc --noEmit`)
284
+ 4. `npm run test`
285
+ 5. `npm run build`
286
+
287
+ `npm run build` remains `tsc` because the native preview compiler is used here as a fast typecheck gate; production JS emission stays on stable `typescript` for predictable package output.
288
+
289
+ ## Package manager + lockfile policy
290
+
291
+ Orca uses a mixed runtime/tooling model on purpose:
292
+
293
+ - **npm is canonical for dependency resolution, release builds, and deterministic installs**.
294
+ - **Bun is used as a runtime/test runner in local workflows** (`dev`, `start`, `test`).
295
+
296
+ Commit both lockfiles:
297
+
298
+ - `package-lock.json` — canonical dependency graph for npm/CI/publish
299
+ - `bun.lock` — Bun runtime resolution parity for local Bun commands
300
+
301
+ When dependencies change, update both lockfiles in the same PR:
302
+
303
+ ```bash
304
+ npm install
305
+ bun install
306
+ ```
307
+
308
+ This keeps npm and Bun behavior aligned without forcing a disruptive full migration.
@@ -1,5 +1,6 @@
1
1
  import { query } from "@anthropic-ai/claude-agent-sdk";
2
2
  import { z } from "zod";
3
+ import { TaskGraphReviewPayloadSchema } from "../../core/task-graph-review.js";
3
4
  import { parseAgentJson } from "../../utils/agent-json.js";
4
5
  const PlannedTaskSchema = z.object({
5
6
  id: z.string().min(1),
@@ -101,6 +102,23 @@ const EXECUTION_OUTPUT_FORMAT = {
101
102
  type: "json_schema",
102
103
  schema: EXECUTION_OUTPUT_SCHEMA,
103
104
  };
105
+ const REVIEW_OUTPUT_SCHEMA = {
106
+ type: "object",
107
+ additionalProperties: false,
108
+ required: ["changes"],
109
+ properties: {
110
+ changes: {
111
+ type: "array",
112
+ items: {
113
+ type: "object"
114
+ }
115
+ }
116
+ }
117
+ };
118
+ const REVIEW_OUTPUT_FORMAT = {
119
+ type: "json_schema",
120
+ schema: REVIEW_OUTPUT_SCHEMA,
121
+ };
104
122
  function buildPlanningPrompt(spec, systemContext) {
105
123
  return [
106
124
  systemContext,
@@ -127,6 +145,27 @@ function buildTaskExecutionPrompt(task, runId, cwd, systemContext) {
127
145
  "If you cannot complete the task, set outcome=failed and provide a concise error.",
128
146
  ].join("\n\n");
129
147
  }
148
+ function buildTaskGraphReviewPrompt(tasks, systemContext) {
149
+ return [
150
+ systemContext,
151
+ "You are Orca's pre-execution task-graph reviewer.",
152
+ "Return only structured review operations in the configured schema.",
153
+ "Allowed operations: update_task (name/description/acceptance_criteria), add_task, remove_task, add_dependency, remove_dependency.",
154
+ "Return an empty changes array if no edits are needed.",
155
+ "Current task graph JSON:",
156
+ JSON.stringify(tasks, null, 2)
157
+ ].join("\n\n");
158
+ }
159
+ function parseStructuredTaskGraphReviewPayload(payload, rawResponse = "") {
160
+ const result = TaskGraphReviewPayloadSchema.safeParse(payload);
161
+ if (!result.success) {
162
+ throw formatSchemaError("Claude structured review payload failed schema validation", result.error);
163
+ }
164
+ return {
165
+ changes: result.data.changes,
166
+ rawResponse
167
+ };
168
+ }
130
169
  function extractAssistantText(message) {
131
170
  if (!message || typeof message !== "object") {
132
171
  return null;
@@ -277,6 +316,22 @@ export async function planSpec(spec, systemContext, config) {
277
316
  claudeQuery.close();
278
317
  }
279
318
  }
319
+ export async function reviewTaskGraph(tasks, systemContext, config) {
320
+ const claudeQuery = query({
321
+ prompt: buildTaskGraphReviewPrompt(tasks, systemContext),
322
+ options: buildClaudeQueryOptions(config, REVIEW_OUTPUT_FORMAT),
323
+ });
324
+ try {
325
+ const { rawResponse, structuredOutput } = await collectSessionResult(claudeQuery);
326
+ if (structuredOutput === undefined) {
327
+ throwMissingStructuredOutput("review");
328
+ }
329
+ return parseStructuredTaskGraphReviewPayload(structuredOutput, rawResponse);
330
+ }
331
+ finally {
332
+ claudeQuery.close();
333
+ }
334
+ }
280
335
  export async function executeTask(task, runId, config, systemContext) {
281
336
  const claudeQuery = query({
282
337
  prompt: buildTaskExecutionPrompt(task, runId, process.cwd(), systemContext),
@@ -1,4 +1,5 @@
1
1
  import { CodexClient } from "@ratley/codex-client";
2
+ import { TaskGraphReviewPayloadSchema } from "../../core/task-graph-review.js";
2
3
  function buildPlanningPrompt(spec, systemContext) {
3
4
  return [
4
5
  systemContext,
@@ -33,6 +34,36 @@ function buildTaskExecutionPrompt(task, runId, cwd, systemContext) {
33
34
  "Do not wrap it in markdown fences. Do not add any text after the JSON line. The JSON line is required.",
34
35
  ].join("\n\n");
35
36
  }
37
+ function buildTaskGraphReviewPrompt(tasks, systemContext) {
38
+ return [
39
+ systemContext,
40
+ "You are Orca's pre-execution task-graph reviewer.",
41
+ "Return JSON matching this shape exactly: {\"changes\":[...operations...]}",
42
+ "Allowed operation shapes:",
43
+ "- {\"op\":\"update_task\",\"taskId\":\"...\",\"fields\":{\"name\"?:string,\"description\"?:string,\"acceptance_criteria\"?:string[]}}",
44
+ "- {\"op\":\"add_task\",\"task\":<full task object>}",
45
+ "- {\"op\":\"remove_task\",\"taskId\":\"...\"}",
46
+ "- {\"op\":\"add_dependency\",\"taskId\":\"...\",\"dependsOn\":\"...\"}",
47
+ "- {\"op\":\"remove_dependency\",\"taskId\":\"...\",\"dependsOn\":\"...\"}",
48
+ "Return ONLY JSON. No markdown.",
49
+ "Current task graph:",
50
+ JSON.stringify(tasks, null, 2),
51
+ ].join("\n\n");
52
+ }
53
+ function parseTaskGraphReview(raw) {
54
+ const parsed = JSON.parse(extractJson(raw));
55
+ const result = TaskGraphReviewPayloadSchema.safeParse(parsed);
56
+ if (!result.success) {
57
+ const details = result.error.issues
58
+ .map((issue) => `${issue.path.length > 0 ? issue.path.join(".") : "<root>"}: ${issue.message}`)
59
+ .join("; ");
60
+ throw new Error(`Codex review response failed schema validation. ${details}`);
61
+ }
62
+ return {
63
+ changes: result.data.changes,
64
+ rawResponse: raw,
65
+ };
66
+ }
36
67
  function extractAgentText(result) {
37
68
  if (result.agentMessage.length > 0) {
38
69
  return result.agentMessage;
@@ -192,6 +223,21 @@ export async function createCodexSession(cwd, config) {
192
223
  rawResponse,
193
224
  };
194
225
  },
226
+ async reviewTaskGraph(tasks, systemContext) {
227
+ const effort = getEffort(config);
228
+ const result = effort
229
+ ? await client.runTurn({
230
+ threadId,
231
+ effort,
232
+ input: [{ type: "text", text: buildTaskGraphReviewPrompt(tasks, systemContext) }],
233
+ })
234
+ : await client.runTurn({
235
+ threadId,
236
+ input: [{ type: "text", text: buildTaskGraphReviewPrompt(tasks, systemContext) }],
237
+ });
238
+ const rawResponse = extractAgentText(result);
239
+ return parseTaskGraphReview(rawResponse);
240
+ },
195
241
  async executeTask(task, runId, systemContext) {
196
242
  const effort = getEffort(config);
197
243
  const result = effort
@@ -299,6 +345,15 @@ export async function planSpec(spec, systemContext, config) {
299
345
  await session.disconnect();
300
346
  }
301
347
  }
348
+ export async function reviewTaskGraph(tasks, systemContext, config) {
349
+ const session = await createCodexSession(process.cwd(), config);
350
+ try {
351
+ return await session.reviewTaskGraph(tasks, systemContext);
352
+ }
353
+ finally {
354
+ await session.disconnect();
355
+ }
356
+ }
302
357
  export async function executeTask(task, runId, config, systemContext) {
303
358
  const session = await createCodexSession(process.cwd(), config);
304
359
  try {
@@ -37,7 +37,7 @@ export async function cancelCommandHandler(options) {
37
37
  return;
38
38
  }
39
39
  const cancelledAt = new Date().toISOString();
40
- let cancelledTaskId = null;
40
+ let cancelledTaskId;
41
41
  const tasks = run.tasks.map((task) => {
42
42
  if (task.status === "in_progress") {
43
43
  cancelledTaskId = task.id;
@@ -7,7 +7,7 @@ import { InvalidArgumentError } from "commander";
7
7
  import { createCodexSession } from "../../agents/codex/session.js";
8
8
  import { ensureCodexMultiAgent } from "../../core/codex-config.js";
9
9
  import { resolveConfig } from "../../core/config-loader.js";
10
- import { runPlanner } from "../../core/planner.js";
10
+ import { InvalidPlanError, runPlanner } from "../../core/planner.js";
11
11
  import { runTaskRunner } from "../../core/task-runner.js";
12
12
  import { createOpenclawHookHandler, detectOpenclawAvailability } from "../../hooks/adapters/openclaw.js";
13
13
  import { createStdoutHookHandler } from "../../hooks/adapters/stdout.js";
@@ -19,6 +19,7 @@ const ALL_HOOKS = [
19
19
  "onMilestone",
20
20
  "onTaskComplete",
21
21
  "onTaskFail",
22
+ "onInvalidPlan",
22
23
  "onComplete",
23
24
  "onError"
24
25
  ];
@@ -26,6 +27,7 @@ const VALID_HOOK_NAMES = new Set([
26
27
  "onMilestone",
27
28
  "onTaskComplete",
28
29
  "onTaskFail",
30
+ "onInvalidPlan",
29
31
  "onComplete",
30
32
  "onError"
31
33
  ]);
@@ -63,6 +65,7 @@ function buildCliCommandHooks(options) {
63
65
  ...(options.onMilestone ? { onMilestone: options.onMilestone } : {}),
64
66
  ...(options.onTaskComplete ? { onTaskComplete: options.onTaskComplete } : {}),
65
67
  ...(options.onTaskFail ? { onTaskFail: options.onTaskFail } : {}),
68
+ ...(options.onInvalidPlan ? { onInvalidPlan: options.onInvalidPlan } : {}),
66
69
  ...(options.onComplete ? { onComplete: options.onComplete } : {}),
67
70
  ...(options.onError ? { onError: options.onError } : {})
68
71
  };
@@ -118,11 +121,6 @@ export async function runCommandHandler(options) {
118
121
  console.log(`Run ID: ${runId}`);
119
122
  const store = createStore();
120
123
  await store.createRun(runId, specPath);
121
- await runPlanner(specPath, store, runId, effectiveConfig);
122
- await store.updateRun(runId, {
123
- mode: "run",
124
- overallStatus: "running"
125
- });
126
124
  const cliCommandHooks = buildCliCommandHooks(options);
127
125
  const dispatcher = new HookDispatcher({
128
126
  commandHooks: {
@@ -162,6 +160,28 @@ export async function runCommandHandler(options) {
162
160
  const emitHook = async (event) => {
163
161
  await dispatcher.dispatch(event);
164
162
  };
163
+ try {
164
+ await runPlanner(specPath, store, runId, effectiveConfig);
165
+ }
166
+ catch (error) {
167
+ if (error instanceof InvalidPlanError) {
168
+ await emitHook({
169
+ runId: runId,
170
+ hook: "onInvalidPlan",
171
+ message: `invalid-plan:${error.stage}`,
172
+ timestamp: new Date().toISOString(),
173
+ error: error.message,
174
+ metadata: {
175
+ stage: error.stage
176
+ }
177
+ });
178
+ }
179
+ throw error;
180
+ }
181
+ await store.updateRun(runId, {
182
+ mode: "run",
183
+ overallStatus: "running"
184
+ });
165
185
  const executor = effectiveConfig?.executor ?? "codex";
166
186
  if (executor === "codex") {
167
187
  const cwd = process.cwd();
@@ -253,6 +273,7 @@ export function registerRunCommand(program) {
253
273
  .option("--on-milestone <cmd>", "Shell hook command for onMilestone")
254
274
  .option("--on-task-complete <cmd>", "Shell hook command for onTaskComplete")
255
275
  .option("--on-task-fail <cmd>", "Shell hook command for onTaskFail")
276
+ .option("--on-invalid-plan <cmd>", "Shell hook command for onInvalidPlan")
256
277
  .option("--on-complete <cmd>", "Shell hook command for onComplete")
257
278
  .option("--on-error <cmd>", "Shell hook command for onError")
258
279
  .action(async (goal, commandOptions) => {
@@ -57,7 +57,7 @@ export function resolveApiKey(flagValue, envVarName, openclawConfigPathOrOptions
57
57
  return envValue.trim();
58
58
  }
59
59
  const options = typeof openclawConfigPathOrOptions === "string"
60
- ? { ...(maybeOptions ?? {}), openclawConfigPath: openclawConfigPathOrOptions }
60
+ ? { ...maybeOptions, openclawConfigPath: openclawConfigPathOrOptions }
61
61
  : (openclawConfigPathOrOptions ?? {});
62
62
  const homedir = options.homedir ?? os.homedir();
63
63
  const openclawValue = readOpenclawEnvVar(envVarName, options.openclawConfigPath, homedir);
@@ -65,7 +65,6 @@ export function resolveApiKey(flagValue, envVarName, openclawConfigPathOrOptions
65
65
  return openclawValue;
66
66
  }
67
67
  const dotenvValue = readDotEnvFallback(envVarName, {
68
- cwd: options.cwd ?? process.cwd(),
69
68
  homedir
70
69
  });
71
70
  if (dotenvValue) {
@@ -104,8 +103,7 @@ function readOpenclawEnvVar(envVarName, openclawConfigPath, homedir = os.homedir
104
103
  function readDotEnvFallback(envVarName, options) {
105
104
  const candidatePaths = [
106
105
  path.join(options.homedir, ".claude", ".env"),
107
- path.join(options.homedir, ".config", "claude", ".env"),
108
- path.join(options.cwd, ".env")
106
+ path.join(options.homedir, ".config", "claude", ".env")
109
107
  ];
110
108
  for (const candidatePath of candidatePaths) {
111
109
  const value = readEnvVarFromDotEnvFile(candidatePath, envVarName);
@@ -52,7 +52,10 @@ function coerceConfig(candidate) {
52
52
  }
53
53
  if ("executor" in candidate && candidate.executor !== undefined) {
54
54
  if (candidate.executor !== "claude" && candidate.executor !== "codex") {
55
- throw new Error(`Config.executor must be 'claude' or 'codex', got ${String(candidate.executor)}`);
55
+ const executorDisplay = typeof candidate.executor === "string"
56
+ ? candidate.executor
57
+ : (JSON.stringify(candidate.executor) ?? describeType(candidate.executor));
58
+ throw new Error(`Config.executor must be 'claude' or 'codex', got ${executorDisplay}`);
56
59
  }
57
60
  }
58
61
  if ("claude" in candidate && candidate.claude !== undefined) {
@@ -77,6 +80,22 @@ function coerceConfig(candidate) {
77
80
  parseCodexEffort(candidate.codex.effort);
78
81
  }
79
82
  }
83
+ if ("review" in candidate && candidate.review !== undefined) {
84
+ if (!isObject(candidate.review)) {
85
+ throw new Error(`Config.review must be an object, got ${describeType(candidate.review)}`);
86
+ }
87
+ if ("enabled" in candidate.review && candidate.review.enabled !== undefined && typeof candidate.review.enabled !== "boolean") {
88
+ throw new Error(`Config.review.enabled must be a boolean, got ${describeType(candidate.review.enabled)}`);
89
+ }
90
+ if ("onInvalid" in candidate.review && candidate.review.onInvalid !== undefined) {
91
+ if (candidate.review.onInvalid !== "fail" && candidate.review.onInvalid !== "warn_skip") {
92
+ const onInvalidDisplay = typeof candidate.review.onInvalid === "string"
93
+ ? candidate.review.onInvalid
94
+ : (JSON.stringify(candidate.review.onInvalid) ?? describeType(candidate.review.onInvalid));
95
+ throw new Error(`Config.review.onInvalid must be 'fail' or 'warn_skip', got ${onInvalidDisplay}`);
96
+ }
97
+ }
98
+ }
80
99
  return candidate;
81
100
  }
82
101
  export async function loadConfig(configPath) {
@@ -112,6 +131,9 @@ export function mergeConfigs(...configs) {
112
131
  if (merged.pr !== undefined || config.pr !== undefined) {
113
132
  merged.pr = { ...merged.pr, ...config.pr };
114
133
  }
134
+ if (merged.review !== undefined || config.review !== undefined) {
135
+ merged.review = { ...merged.review, ...config.review };
136
+ }
115
137
  if (merged.hooks !== undefined || config.hooks !== undefined) {
116
138
  merged.hooks = { ...merged.hooks, ...config.hooks };
117
139
  }
@@ -1,14 +1,30 @@
1
1
  import { promises as fs } from "node:fs";
2
- import { planSpec as planSpecWithClaude } from "../agents/claude/session.js";
3
- import { planSpec as planSpecWithCodex } from "../agents/codex/session.js";
2
+ import path from "node:path";
3
+ import { planSpec as planSpecWithClaude, reviewTaskGraph as reviewTaskGraphWithClaude } from "../agents/claude/session.js";
4
+ import { planSpec as planSpecWithCodex, reviewTaskGraph as reviewTaskGraphWithCodex } from "../agents/codex/session.js";
4
5
  import { logger } from "../utils/logger.js";
5
6
  import { loadSkills } from "../utils/skill-loader.js";
6
7
  import { validateDAG } from "./dependency-graph.js";
8
+ import { applyTaskGraphReviewChanges, summarizeReviewChanges } from "./task-graph-review.js";
7
9
  const DEFAULT_SYSTEM_CONTEXT = "You are Orca planner.";
10
+ const PROJECT_INSTRUCTION_FILES = ["AGENTS.md", "CLAUDE.md"];
11
+ const PROJECT_INSTRUCTION_CHAR_CAP = 4_000;
12
+ export class InvalidPlanError extends Error {
13
+ stage;
14
+ constructor(stage, message) {
15
+ super(message);
16
+ this.name = "InvalidPlanError";
17
+ this.stage = stage;
18
+ }
19
+ }
8
20
  let testPlanSpecOverride = null;
21
+ let testReviewTaskGraphOverride = null;
9
22
  export function setPlanSpecForTests(fn) {
10
23
  testPlanSpecOverride = fn;
11
24
  }
25
+ export function setReviewTaskGraphForTests(fn) {
26
+ testReviewTaskGraphOverride = fn;
27
+ }
12
28
  function resolvePlanSpecImpl(config) {
13
29
  if (testPlanSpecOverride) {
14
30
  return testPlanSpecOverride;
@@ -16,6 +32,13 @@ function resolvePlanSpecImpl(config) {
16
32
  const executor = config?.executor ?? "codex";
17
33
  return executor === "claude" ? planSpecWithClaude : planSpecWithCodex;
18
34
  }
35
+ function resolveReviewTaskGraphImpl(config) {
36
+ if (testReviewTaskGraphOverride) {
37
+ return testReviewTaskGraphOverride;
38
+ }
39
+ const executor = config?.executor ?? "codex";
40
+ return executor === "claude" ? reviewTaskGraphWithClaude : reviewTaskGraphWithCodex;
41
+ }
19
42
  function formatSkillsSection(skills) {
20
43
  const formattedSkills = skills.map((skill) => [
21
44
  `### ${skill.name}`,
@@ -26,20 +49,139 @@ function formatSkillsSection(skills) {
26
49
  ].join("\n"));
27
50
  return ["## Available Skills", "", ...formattedSkills].join("\n");
28
51
  }
52
+ async function pathExists(targetPath) {
53
+ try {
54
+ await fs.access(targetPath);
55
+ return true;
56
+ }
57
+ catch {
58
+ return false;
59
+ }
60
+ }
61
+ async function resolveProjectContextDir(specPath) {
62
+ let currentDir = path.dirname(path.resolve(specPath));
63
+ while (true) {
64
+ const gitMarker = path.join(currentDir, ".git");
65
+ if (await pathExists(gitMarker)) {
66
+ return currentDir;
67
+ }
68
+ const parent = path.dirname(currentDir);
69
+ if (parent === currentDir) {
70
+ return path.dirname(path.resolve(specPath));
71
+ }
72
+ currentDir = parent;
73
+ }
74
+ }
75
+ async function loadProjectInstructions(specPath) {
76
+ const projectDir = await resolveProjectContextDir(specPath);
77
+ const instructions = [];
78
+ for (const fileName of PROJECT_INSTRUCTION_FILES) {
79
+ const filePath = path.join(projectDir, fileName);
80
+ if (!(await pathExists(filePath))) {
81
+ continue;
82
+ }
83
+ const rawContent = await fs.readFile(filePath, "utf8");
84
+ const content = rawContent.slice(0, PROJECT_INSTRUCTION_CHAR_CAP);
85
+ instructions.push({
86
+ fileName,
87
+ filePath,
88
+ content,
89
+ truncated: rawContent.length > PROJECT_INSTRUCTION_CHAR_CAP
90
+ });
91
+ }
92
+ return instructions;
93
+ }
94
+ function formatProjectInstructionsSection(instructions) {
95
+ const parts = ["## Project Instructions"];
96
+ for (const instruction of instructions) {
97
+ parts.push("");
98
+ parts.push(`### ${instruction.fileName} (${instruction.filePath})`);
99
+ parts.push("");
100
+ parts.push("```md");
101
+ parts.push(instruction.content);
102
+ parts.push("```");
103
+ if (instruction.truncated) {
104
+ parts.push(`(truncated to ${PROJECT_INSTRUCTION_CHAR_CAP} characters)`);
105
+ }
106
+ }
107
+ return parts.join("\n");
108
+ }
109
+ function buildSystemContext(skills, instructions) {
110
+ const sections = [DEFAULT_SYSTEM_CONTEXT];
111
+ if (instructions.length > 0) {
112
+ sections.push(formatProjectInstructionsSection(instructions));
113
+ }
114
+ if (skills.length > 0) {
115
+ sections.push(formatSkillsSection(skills));
116
+ }
117
+ return sections.join("\n\n");
118
+ }
119
+ async function runTaskGraphReview(tasks, systemContext, config) {
120
+ if (config?.review?.enabled === false) {
121
+ return { finalTasks: tasks, review: null };
122
+ }
123
+ logger.info("Review started: pre-execution task graph improvement pass");
124
+ const reviewFn = resolveReviewTaskGraphImpl(config);
125
+ let review;
126
+ try {
127
+ review = await reviewFn(tasks, systemContext, config);
128
+ }
129
+ catch (error) {
130
+ if (config?.review?.onInvalid === "warn_skip") {
131
+ logger.warn(`Review output invalid; skipping review changes (${error instanceof Error ? error.message : String(error)})`);
132
+ return { finalTasks: tasks, review: null };
133
+ }
134
+ throw new InvalidPlanError("review", `Review output invalid. ${error instanceof Error ? error.message : String(error)}`);
135
+ }
136
+ if (review.changes.length === 0) {
137
+ logger.info("Review made no changes");
138
+ return { finalTasks: tasks, review };
139
+ }
140
+ const updated = applyTaskGraphReviewChanges(tasks, review.changes);
141
+ try {
142
+ validateDAG(updated);
143
+ }
144
+ catch (error) {
145
+ throw new InvalidPlanError("review", error instanceof Error ? error.message : String(error));
146
+ }
147
+ const summary = summarizeReviewChanges(review.changes).join("; ");
148
+ logger.success(`Review made ${review.changes.length} changes: ${summary}`);
149
+ return { finalTasks: updated, review };
150
+ }
29
151
  export async function runPlanner(specPath, store, runId, config) {
30
152
  const spec = await fs.readFile(specPath, "utf8");
31
- const skills = await loadSkills(config);
32
- const systemContext = skills.length === 0
33
- ? DEFAULT_SYSTEM_CONTEXT
34
- : `${DEFAULT_SYSTEM_CONTEXT}\n\n${formatSkillsSection(skills)}`;
153
+ const [skills, instructions] = await Promise.all([loadSkills(config), loadProjectInstructions(specPath)]);
154
+ const systemContext = buildSystemContext(skills, instructions);
35
155
  const planSpecImpl = resolvePlanSpecImpl(config);
36
156
  const result = await planSpecImpl(spec, systemContext, config);
37
- validateDAG(result.tasks);
38
- await store.writeTasks(runId, result.tasks);
157
+ try {
158
+ validateDAG(result.tasks);
159
+ }
160
+ catch (error) {
161
+ throw new InvalidPlanError("planner", error instanceof Error ? error.message : String(error));
162
+ }
163
+ let finalTasks = result.tasks;
164
+ try {
165
+ const reviewed = await runTaskGraphReview(result.tasks, systemContext, config);
166
+ finalTasks = reviewed.finalTasks;
167
+ }
168
+ catch (error) {
169
+ if (config?.review?.onInvalid === "warn_skip") {
170
+ logger.warn(`Review changes rejected; proceeding with planner graph (${error instanceof Error ? error.message : String(error)})`);
171
+ finalTasks = result.tasks;
172
+ }
173
+ else if (error instanceof InvalidPlanError) {
174
+ throw error;
175
+ }
176
+ else {
177
+ throw new InvalidPlanError("review", `Review stage failed. ${error instanceof Error ? error.message : String(error)}`);
178
+ }
179
+ }
180
+ await store.writeTasks(runId, finalTasks);
39
181
  await store.updateRun(runId, {
40
182
  overallStatus: "planning",
41
- tasks: result.tasks,
183
+ tasks: finalTasks,
42
184
  milestones: ["plan-complete"]
43
185
  });
44
- logger.success(`Plan complete: ${result.tasks.length} tasks`);
186
+ logger.success(`Plan complete: ${finalTasks.length} tasks`);
45
187
  }
@@ -0,0 +1,132 @@
1
+ import { z } from "zod";
2
+ const TaskSchema = z.object({
3
+ id: z.string().min(1),
4
+ name: z.string().min(1),
5
+ description: z.string(),
6
+ dependencies: z.array(z.string()),
7
+ acceptance_criteria: z.array(z.string()),
8
+ status: z.enum(["pending", "in_progress", "done", "failed", "cancelled"]),
9
+ retries: z.number(),
10
+ maxRetries: z.number(),
11
+ startedAt: z.string().optional(),
12
+ finishedAt: z.string().optional(),
13
+ lastError: z.string().optional()
14
+ }).strict();
15
+ const ReviewOperationSchema = z.discriminatedUnion("op", [
16
+ z.object({
17
+ op: z.literal("update_task"),
18
+ taskId: z.string().min(1),
19
+ fields: z.object({
20
+ name: z.string().min(1).optional(),
21
+ description: z.string().optional(),
22
+ acceptance_criteria: z.array(z.string()).optional()
23
+ }).strict()
24
+ }).strict(),
25
+ z.object({
26
+ op: z.literal("add_task"),
27
+ task: TaskSchema
28
+ }).strict(),
29
+ z.object({
30
+ op: z.literal("remove_task"),
31
+ taskId: z.string().min(1)
32
+ }).strict(),
33
+ z.object({
34
+ op: z.literal("add_dependency"),
35
+ taskId: z.string().min(1),
36
+ dependsOn: z.string().min(1)
37
+ }).strict(),
38
+ z.object({
39
+ op: z.literal("remove_dependency"),
40
+ taskId: z.string().min(1),
41
+ dependsOn: z.string().min(1)
42
+ }).strict()
43
+ ]);
44
+ export const TaskGraphReviewPayloadSchema = z.object({
45
+ changes: z.array(ReviewOperationSchema)
46
+ }).strict();
47
+ function findTaskIndex(tasks, taskId) {
48
+ return tasks.findIndex((task) => task.id === taskId);
49
+ }
50
+ export function summarizeReviewChanges(changes) {
51
+ return changes.map((change) => {
52
+ switch (change.op) {
53
+ case "update_task": {
54
+ const keys = Object.keys(change.fields);
55
+ return `update_task(${change.taskId}: ${keys.join(",") || "no fields"})`;
56
+ }
57
+ case "add_task":
58
+ return `add_task(${change.task.id})`;
59
+ case "remove_task":
60
+ return `remove_task(${change.taskId})`;
61
+ case "add_dependency":
62
+ return `add_dependency(${change.taskId}<-${change.dependsOn})`;
63
+ case "remove_dependency":
64
+ return `remove_dependency(${change.taskId}<-${change.dependsOn})`;
65
+ default:
66
+ return "unknown";
67
+ }
68
+ });
69
+ }
70
+ export function applyTaskGraphReviewChanges(tasks, changes) {
71
+ const nextTasks = tasks.map((task) => ({ ...task, dependencies: [...task.dependencies], acceptance_criteria: [...task.acceptance_criteria] }));
72
+ for (const change of changes) {
73
+ switch (change.op) {
74
+ case "update_task": {
75
+ const index = findTaskIndex(nextTasks, change.taskId);
76
+ if (index === -1) {
77
+ throw new Error(`Review update_task failed: task not found (${change.taskId})`);
78
+ }
79
+ const current = nextTasks[index];
80
+ nextTasks[index] = {
81
+ ...current,
82
+ ...("name" in change.fields ? { name: change.fields.name ?? current.name } : {}),
83
+ ...("description" in change.fields ? { description: change.fields.description ?? current.description } : {}),
84
+ ...("acceptance_criteria" in change.fields
85
+ ? { acceptance_criteria: [...(change.fields.acceptance_criteria ?? current.acceptance_criteria)] }
86
+ : {})
87
+ };
88
+ break;
89
+ }
90
+ case "add_task": {
91
+ if (findTaskIndex(nextTasks, change.task.id) !== -1) {
92
+ throw new Error(`Review add_task failed: task already exists (${change.task.id})`);
93
+ }
94
+ nextTasks.push({
95
+ ...change.task,
96
+ dependencies: [...change.task.dependencies],
97
+ acceptance_criteria: [...change.task.acceptance_criteria]
98
+ });
99
+ break;
100
+ }
101
+ case "remove_task": {
102
+ const index = findTaskIndex(nextTasks, change.taskId);
103
+ if (index === -1) {
104
+ throw new Error(`Review remove_task failed: task not found (${change.taskId})`);
105
+ }
106
+ nextTasks.splice(index, 1);
107
+ break;
108
+ }
109
+ case "add_dependency": {
110
+ const index = findTaskIndex(nextTasks, change.taskId);
111
+ if (index === -1) {
112
+ throw new Error(`Review add_dependency failed: task not found (${change.taskId})`);
113
+ }
114
+ const current = nextTasks[index];
115
+ if (!current.dependencies.includes(change.dependsOn)) {
116
+ current.dependencies = [...current.dependencies, change.dependsOn];
117
+ }
118
+ break;
119
+ }
120
+ case "remove_dependency": {
121
+ const index = findTaskIndex(nextTasks, change.taskId);
122
+ if (index === -1) {
123
+ throw new Error(`Review remove_dependency failed: task not found (${change.taskId})`);
124
+ }
125
+ const current = nextTasks[index];
126
+ current.dependencies = current.dependencies.filter((dependency) => dependency !== change.dependsOn);
127
+ break;
128
+ }
129
+ }
130
+ }
131
+ return nextTasks;
132
+ }
@@ -1,4 +1,4 @@
1
- export function createStdoutHookHandler(prefix = "[hook]") {
1
+ export function createStdoutHookHandler(prefix = "[hook]", write = console.log) {
2
2
  return async (event) => {
3
3
  const line = {
4
4
  prefix,
@@ -10,6 +10,6 @@ export function createStdoutHookHandler(prefix = "[hook]") {
10
10
  error: event.error,
11
11
  metadata: event.metadata
12
12
  };
13
- console.log(JSON.stringify(line));
13
+ write(JSON.stringify(line));
14
14
  };
15
15
  }
@@ -64,7 +64,10 @@ export class HookDispatcher {
64
64
  ...process.env,
65
65
  ORCA_MSG: event.message,
66
66
  ORCA_RUN_ID: event.runId,
67
- ORCA_TASK_ID: event.taskId ?? ""
67
+ ORCA_TASK_ID: event.taskId ?? "",
68
+ ORCA_HOOK: event.hook,
69
+ ORCA_ERROR: event.error ?? "",
70
+ ORCA_STAGE: typeof event.metadata?.stage === "string" ? event.metadata.stage : ""
68
71
  }
69
72
  });
70
73
  }
@@ -3,7 +3,7 @@ function tryParseJson(input) {
3
3
  return JSON.parse(input);
4
4
  }
5
5
  catch {
6
- return null;
6
+ return undefined;
7
7
  }
8
8
  }
9
9
  function extractFencedCandidates(text) {
@@ -54,7 +54,7 @@ function extractFirstJsonObjectOrArray(text) {
54
54
  depth -= 1;
55
55
  if (depth === 0) {
56
56
  const candidate = text.slice(start, end + 1).trim();
57
- if (tryParseJson(candidate) !== null) {
57
+ if (tryParseJson(candidate) !== undefined) {
58
58
  return candidate;
59
59
  }
60
60
  break;
@@ -76,7 +76,7 @@ export function parseAgentJson(raw) {
76
76
  }
77
77
  for (const candidate of candidates) {
78
78
  const parsed = tryParseJson(candidate);
79
- if (parsed !== null) {
79
+ if (parsed !== undefined) {
80
80
  return parsed;
81
81
  }
82
82
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orcastrator",
3
- "version": "0.2.13",
3
+ "version": "0.2.15",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "orca": "dist/cli/index.js"
@@ -9,11 +9,15 @@
9
9
  "build": "tsc",
10
10
  "dev": "bun run src/cli/index.ts",
11
11
  "lint": "oxlint src/",
12
- "typecheck": "tsc --noEmit",
13
- "test": "bun test",
12
+ "typecheck": "node ./scripts/typecheck.mjs",
13
+ "test": "bun test src",
14
14
  "start": "bun run src/cli/index.ts",
15
15
  "prepare": "husky",
16
- "postbuild": "chmod +x dist/cli/index.js"
16
+ "postbuild": "chmod +x dist/cli/index.js",
17
+ "lint:type-aware": "oxlint --type-aware --type-check --deny-warnings src/ --ignore-pattern \"**/*.test.ts\"",
18
+ "typecheck:native": "tsgo --noEmit",
19
+ "typecheck:tsc": "tsc --noEmit",
20
+ "validate": "npm run lint && npm run lint:type-aware && npm run typecheck && npm run test && npm run build"
17
21
  },
18
22
  "dependencies": {
19
23
  "@anthropic-ai/claude-agent-sdk": "^0.2.47",
@@ -21,14 +25,16 @@
21
25
  "@ratley/codex-client": "^0.1.3",
22
26
  "chalk": "^5.3.0",
23
27
  "commander": "^13.1.0",
24
- "zod": "^3.24.1"
28
+ "zod": "^4.3.6"
25
29
  },
26
30
  "devDependencies": {
27
31
  "@types/bun": "^1.2.21",
28
32
  "husky": "^9.1.7",
29
33
  "lint-staged": "^16.2.0",
30
- "oxlint": "^0.15.14",
31
- "typescript": "^5.8.2"
34
+ "oxlint": "^1.49.0",
35
+ "typescript": "^5.8.2",
36
+ "@typescript/native-preview": "^7.0.0-dev.20260219.1",
37
+ "oxlint-tsgolint": "^0.14.1"
32
38
  },
33
39
  "lint-staged": {
34
40
  "*.ts": [