diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml deleted file mode 100644 index 5cdb4ec..0000000 --- a/.github/workflows/claude-code-review.yml +++ /dev/null @@ -1,91 +0,0 @@ -name: Claude Code Review - -on: - workflow_dispatch: - inputs: - pr_number: - description: "PR number to review when manually triggered" - required: true - pull_request: - types: [opened, synchronize] - -jobs: - claude-review: - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: write - issues: write - id-token: write - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 1 - - - name: Setup Bun - uses: oven-sh/setup-bun@v2 - - - name: Install dependencies - run: bun install --frozen-lockfile - - - name: Run Claude Code Review (structured) - id: claude-review - uses: anthropics/claude-code-action@v1 - with: - claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - prompt: | - REPO: ${{ github.repository }} - PR NUMBER: ${{ github.event.pull_request.number || inputs.pr_number }} - - You are reviewing a PR for Orca, a TypeScript CLI package. - Focus only on correctness, regressions, and security/runtime breakage. - - RULES: - 1. "No issues found" is valid and preferred when true. - 2. Review the diff as source of truth; ignore stale PR descriptions. - 3. Do not report style-only suggestions. - 4. For each finding, include exact file:line + concrete failure scenario. - 5. Use only these severities: - - CRITICAL: production breakage, security, data loss - - BUG: incorrect behavior users will hit - - RISK: concrete reproducible failure scenario with current code paths - - Return valid JSON matching schema: - - result: "NO_ISSUES" or "HAS_FINDINGS" - - comment_body: full human PR comment markdown - - If no issues: "No issues found. [one sentence summary of what the PR does]." - If issues: list findings by severity with file:line and quoted code. - - claude_args: | - --model claude-opus-4-6 - --allowed-tools "Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*)" - --json-schema '{"type":"object","properties":{"result":{"type":"string","enum":["NO_ISSUES","HAS_FINDINGS"]},"comment_body":{"type":"string"}},"required":["result","comment_body"]}' - - - name: Post review comment (for humans) - if: ${{ steps.claude-review.outputs.structured_output != '' }} - env: - GH_TOKEN: ${{ github.token }} - PR_NUMBER: ${{ github.event.pull_request.number || inputs.pr_number }} - STRUCTURED: ${{ steps.claude-review.outputs.structured_output }} - run: | - echo "$STRUCTURED" | jq -r '.comment_body' > body.txt - gh pr comment "$PR_NUMBER" --body-file body.txt - - - name: Gate PR on Claude result (deterministic) - env: - STRUCTURED: ${{ steps.claude-review.outputs.structured_output }} - run: | - if [ -z "$STRUCTURED" ]; then - echo "Claude review gate skipped: structured output missing (expected when this workflow is first introduced and not yet on default branch)." - exit 0 - fi - - result="$(echo "$STRUCTURED" | jq -r '.result')" - if [ "$result" != "NO_ISSUES" ]; then - echo "Claude review gate failed: result=$result" - exit 1 - fi - echo "Claude review gate passed: NO_ISSUES" diff --git a/.github/workflows/codex-code-review.yml b/.github/workflows/codex-code-review.yml new file mode 100644 index 0000000..85f24f4 --- /dev/null +++ b/.github/workflows/codex-code-review.yml @@ -0,0 +1,276 @@ +name: Codex Code Review + +on: + pull_request: + types: [opened, reopened, synchronize, ready_for_review] + +concurrency: + group: codex-structured-review-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + codex-structured-review: + name: Run Codex structured review + if: ${{ !github.event.pull_request.draft }} + runs-on: blacksmith-2vcpu-ubuntu-2404 + permissions: + contents: read + pull-requests: write + issues: write + env: + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + GITHUB_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + REPOSITORY: ${{ github.repository }} + PR_TITLE: ${{ github.event.pull_request.title }} + PR_BODY: ${{ github.event.pull_request.body }} + CHECKOUT_DIR: repo-checkout + REVIEW_WORKSPACE: codex-review-workspace + + steps: + - name: Validate OpenAI secret + run: | + set -euo pipefail + if [ -z "${OPENAI_API_KEY:-}" ]; then + echo "::error::OPENAI_API_KEY is not configured for this repository or organization." + exit 1 + fi + + - name: Checkout pull request merge commit + uses: actions/checkout@v5 + with: + path: ${{ env.CHECKOUT_DIR }} + ref: refs/pull/${{ github.event.pull_request.number }}/merge + + - name: Fetch base and head refs + run: | + set -euxo pipefail + git -C "${CHECKOUT_DIR}" fetch --no-tags origin \ + "${{ github.event.pull_request.base.ref }}" \ + +refs/pull/${{ github.event.pull_request.number }}/head + + - name: Prepare isolated review workspace + run: | + set -euo pipefail + rm -rf "${REVIEW_WORKSPACE}" + mkdir -p "${REVIEW_WORKSPACE}/.github/tmp" + python3 <<'PY' + import os + import pathlib + import shutil + import subprocess + + checkout = pathlib.Path(os.environ["CHECKOUT_DIR"]).resolve() + workspace = pathlib.Path(os.environ["REVIEW_WORKSPACE"]).resolve() + + def copy_file(rel_path: str) -> None: + src = checkout / rel_path + if not src.exists(): + return + dest = workspace / rel_path + dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(src, dest) + + required_files = [ + "README.md", + "SKILL.md", + "docs/PLAN.md", + "docs/codex-app-server.md", + "docs/codex-cli-reference.md", + ".orca/skills/code-simplifier/SKILL.md", + ] + for rel_path in required_files: + copy_file(rel_path) + + changed_files = subprocess.run( + [ + "git", + "-C", + str(checkout), + "diff", + "--name-only", + "--diff-filter=ACMR", + os.environ["BASE_SHA"], + os.environ["HEAD_SHA"], + ], + check=True, + capture_output=True, + text=True, + ).stdout.splitlines() + + manifest = workspace / ".github" / "tmp" / "changed-files.txt" + manifest.write_text("\n".join(changed_files) + ("\n" if changed_files else "")) + + for rel_path in changed_files: + copy_file(rel_path) + PY + + - name: Generate structured output schema + run: | + set -euo pipefail + cat <<'JSON' > "${REVIEW_WORKSPACE}/codex-output-schema.json" + { + "type": "object", + "properties": { + "result": { + "type": "string", + "enum": ["NO_ISSUES", "HAS_FINDINGS"] + }, + "comment_body": { + "type": "string", + "minLength": 1 + } + }, + "required": ["result", "comment_body"], + "additionalProperties": false + } + JSON + + - name: Build Codex review prompt + run: | + set -euo pipefail + cat <<'PROMPT' > "${REVIEW_WORKSPACE}/codex-prompt.md" + You are reviewing a PR for Orca, a TypeScript CLI that plans and executes task graphs through Codex. + + REVIEW CONTEXT: + - Read `README.md` for the current public CLI/config surface. + - Read `SKILL.md` for project operating conventions and expected Orca behavior. + - Read `docs/PLAN.md` for architecture and run lifecycle expectations. + - If the diff touches Codex protocol, session, or config behavior, also read: + - `docs/codex-app-server.md` + - `docs/codex-cli-reference.md` + - `code-simplifier` is bundled for Orca, but this is still a correctness review, not a style review. + + WORKSPACE SCOPE: + - The review workspace is intentionally isolated. It contains only: + 1. required context files (`README.md`, `SKILL.md`, relevant docs, and bundled review skill context) + 2. `.github/tmp/changed-files.txt` + 3. the PR-changed files that still exist at HEAD + - Treat `.github/tmp/changed-files.txt` as the allowlist for code review targets. + - Do not raise findings against files that are not in the changed-files allowlist. + - If a deleted file matters, use the unified diff only. Do not go hunting through unrelated code. + + SELF-REVIEW EXCLUSION: + - Ignore `.github/workflows/codex-code-review.yml` and the removed Claude review workflow file when reviewing this PR. + + YOUR JOB: Find correctness bugs, regressions, and security/runtime breakage that will affect real Orca users. + NOT YOUR JOB: Style preferences, naming suggestions, speculative future improvements, or docs nitpicks. + + RULES: + 1. "No issues found" is a VALID and PREFERRED outcome. Most PRs by experienced engineers are correct. If you are hedging, delete the finding. + 2. The diff is the source of truth, not the PR description. Review what changed, not what the description says should have changed. + 3. Before including any finding, ask: "Would this break current Orca behavior, a documented CLI/config contract, or a real user workflow?" If no, do not include it. + 4. Orca is Codex-only. Do not invent findings based on removed or unsupported Claude behavior. + 5. For task-runner, state-store, question-flow, planning, review, and PR workflow changes, focus on observable run-state regressions, stuck states, lost updates, incorrect transitions, and broken CLI behavior. + 6. For Codex session/app-server changes, focus on protocol mismatches, request/response handling, prompt/turn regressions, cancellation behavior, and failures caused by outdated or missing compatibility handling. + 7. For config and docs changes, only flag issues when the docs/config surface is demonstrably inconsistent with the implemented behavior in the diff or with included source files. Do not nitpick wording. + 8. Do not suggest tests unless the missing test is directly tied to a real bug or regression scenario in the changed code. + 9. When you find a real issue, be specific: exact file:line references, the concrete failure scenario, and why the current code causes it. + 10. Do not produce style-only findings such as extracting constants, renaming symbols, reorganizing helpers, or future-proofing abstractions. + 11. FINAL SELF-CHECK: If a finding depends on "someone might add X later", "consider", "could be cleaner", or "the abstraction feels wrong", delete it. + + SEVERITY LEVELS (only use these): + - CRITICAL: Production breakage, security issue, data loss, or a run-state/protocol bug that makes Orca unusable. + - BUG: Incorrect current behavior that users will hit. + - RISK: A concrete reproducible failure scenario with current inputs and callers. + + You MUST return valid JSON matching the schema. + - result: "NO_ISSUES" or "HAS_FINDINGS" + - comment_body: a complete PR comment for humans. If issues are found, list findings by severity with file:line references. If no issues are found, write: "No issues found. [one sentence summary of what the PR does]." + PROMPT + + { + echo "REPO: ${REPOSITORY}" + echo "PR NUMBER: ${PR_NUMBER}" + echo "" + echo "PR TITLE:" + printf '%s\n' "${PR_TITLE}" + echo "" + echo "PR BODY:" + printf '%s\n' "${PR_BODY}" + echo "" + echo "Repository: ${REPOSITORY}" + echo "Pull Request #: ${PR_NUMBER}" + echo "Base SHA: ${BASE_SHA}" + echo "Head SHA: ${HEAD_SHA}" + echo "" + echo "Changed files allowlist:" + cat "${REVIEW_WORKSPACE}/.github/tmp/changed-files.txt" + echo "" + echo "Changed files:" + git -C "${CHECKOUT_DIR}" --no-pager diff --name-status "${BASE_SHA}" "${HEAD_SHA}" + echo "" + echo "Unified diff (context=5):" + git -C "${CHECKOUT_DIR}" --no-pager diff --unified=5 "${BASE_SHA}" "${HEAD_SHA}" + } >> "${REVIEW_WORKSPACE}/codex-prompt.md" + + - name: Remove full checkout before review + run: | + set -euo pipefail + rm -rf "${CHECKOUT_DIR}" + + - name: Run Codex structured review + id: run-codex + uses: openai/codex-action@main + with: + openai-api-key: ${{ secrets.OPENAI_API_KEY }} + prompt-file: ${{ env.REVIEW_WORKSPACE }}/codex-prompt.md + output-schema-file: ${{ env.REVIEW_WORKSPACE }}/codex-output-schema.json + output-file: ${{ env.REVIEW_WORKSPACE }}/codex-output.json + working-directory: ${{ env.REVIEW_WORKSPACE }} + sandbox: read-only + safety-strategy: unsafe + allow-bots: true + model: gpt-5.4-2026-03-05 + + - name: Inspect structured output + if: ${{ steps.run-codex.outcome == 'success' }} + run: | + if [ -s "${REVIEW_WORKSPACE}/codex-output.json" ]; then + jq '.' "${REVIEW_WORKSPACE}/codex-output.json" + else + echo "Codex output file missing" + exit 1 + fi + + - name: Publish review comment + if: ${{ steps.run-codex.outcome == 'success' }} + env: + REVIEW_JSON: ${{ env.REVIEW_WORKSPACE }}/codex-output.json + run: | + set -euo pipefail + comment_body=$(jq -r '.comment_body' "$REVIEW_JSON") + + curl -sS \ + -X POST \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer ${GITHUB_TOKEN}" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "https://api.github.com/repos/${REPOSITORY}/issues/${PR_NUMBER}/comments" \ + -d "$(jq -n --arg body "$comment_body" '{body: $body}')" >/dev/null + + - name: Gate PR on Codex result + if: ${{ steps.run-codex.outcome == 'success' }} + env: + REVIEW_JSON: ${{ env.REVIEW_WORKSPACE }}/codex-output.json + run: | + set -euo pipefail + result=$(jq -r '.result' "$REVIEW_JSON") + if [ "$result" != "NO_ISSUES" ]; then + echo "Codex review gate failed: result=$result" + exit 1 + fi + echo "Codex review gate passed: NO_ISSUES" + + - name: Upload Codex artifacts + if: ${{ always() }} + uses: actions/upload-artifact@v4 + with: + name: codex-review-artifacts + path: | + ${{ env.REVIEW_WORKSPACE }}/codex-prompt.md + ${{ env.REVIEW_WORKSPACE }}/codex-output-schema.json + ${{ env.REVIEW_WORKSPACE }}/codex-output.json + ${{ env.REVIEW_WORKSPACE }}/.github/tmp/changed-files.txt diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 6dc438b..03d0fd6 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -128,6 +128,51 @@ jobs: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} run: npm publish + - name: Generate docs reference + if: steps.npm_check.outputs.exists != 'true' + run: node scripts/generate-docs-reference.mjs + + - name: Check docs sync token + if: steps.npm_check.outputs.exists != 'true' + id: docs_token + env: + DOCS_TOKEN: ${{ secrets.ORCA_WEB_DOCS_TOKEN }} + run: | + set -euo pipefail + + if [[ -n "$DOCS_TOKEN" ]]; then + echo "available=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "::warning::Skipping docs sync because ORCA_WEB_DOCS_TOKEN is not configured." + echo "available=false" >> "$GITHUB_OUTPUT" + + - name: Check out docs repository + if: steps.npm_check.outputs.exists != 'true' && steps.docs_token.outputs.available == 'true' + uses: actions/checkout@v4 + with: + repository: ratley/orca-web + path: orca-web + token: ${{ secrets.ORCA_WEB_DOCS_TOKEN }} + + - name: Sync docs reference + if: steps.npm_check.outputs.exists != 'true' && steps.docs_token.outputs.available == 'true' + run: cp docs/orca-reference.md orca-web/ORCA_REFERENCE.md + + - name: Create docs update PR + if: steps.npm_check.outputs.exists != 'true' && steps.docs_token.outputs.available == 'true' + uses: peter-evans/create-pull-request@v8 + with: + token: ${{ secrets.ORCA_WEB_DOCS_TOKEN }} + path: orca-web + branch: "automation/orca-reference-${{ steps.release.outputs.tag }}" + delete-branch: true + commit-message: "docs: update Orca reference for ${{ steps.release.outputs.tag }}" + title: "docs: update Orca reference for ${{ steps.release.outputs.tag }}" + body: | + Updates the generated Orca reference from happycatlabs/orca@${{ steps.release.outputs.release_sha }}. + - name: Publish skipped if: steps.npm_check.outputs.exists == 'true' - run: 'echo "No-op: version already published to npm."' \ No newline at end of file + run: 'echo "No-op: version already published to npm."' diff --git a/.gitignore b/.gitignore index bf3b17a..0f98a7d 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ dist .npmrc tmp/ session-logs/ +TODO.md diff --git a/.orca/skills/code-simplifier/SKILL.md b/.orca/skills/code-simplifier/SKILL.md index b2ab144..8d2b854 100644 --- a/.orca/skills/code-simplifier/SKILL.md +++ b/.orca/skills/code-simplifier/SKILL.md @@ -12,7 +12,6 @@ You will analyze recently modified code and apply refinements that: 1. **Preserve Functionality**: Never change what the code does - only how it does it. All original features, outputs, and behaviors must remain intact. 2. **Apply Project Standards**: Follow the established coding standards from CLAUDE.md including: - - Use ES modules with proper import sorting and extensions - Prefer `function` keyword over arrow functions - Use explicit return type annotations for top-level functions @@ -21,7 +20,6 @@ You will analyze recently modified code and apply refinements that: - Maintain consistent naming conventions 3. **Enhance Clarity**: Simplify code structure by: - - Reducing unnecessary complexity and nesting - Eliminating redundant code and abstractions - Improving readability through clear variable and function names @@ -31,7 +29,6 @@ You will analyze recently modified code and apply refinements that: - Choose clarity over brevity - explicit code is often better than overly compact code 4. **Maintain Balance**: Avoid over-simplification that could: - - Reduce code clarity or maintainability - Create overly clever solutions that are hard to understand - Combine too many concerns into single functions or components diff --git a/.oxfmtrc.json b/.oxfmtrc.json new file mode 100644 index 0000000..5106a3f --- /dev/null +++ b/.oxfmtrc.json @@ -0,0 +1,5 @@ +{ + "$schema": "./node_modules/oxfmt/configuration_schema.json", + "printWidth": 120, + "proseWrap": "preserve" +} diff --git a/.oxlintrc.json b/.oxlintrc.json index e04908b..a52d028 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -1,4 +1,6 @@ { + "$schema": "./node_modules/oxlint/configuration_schema.json", + "ignorePatterns": ["dist/**", "node_modules/**"], "rules": { "no-unused-vars": "error", "eqeqeq": "error", diff --git a/README.md b/README.md index 0aa06b4..44dbc2f 100644 --- a/README.md +++ b/README.md @@ -52,11 +52,17 @@ Run states: `planning` → `running` → `completed` | `failed` | `cancelled` | ### Answering questions -If a run hits `waiting_for_answer`, it's blocked until you respond: +If a run hits `waiting_for_answer`, execution pauses until a response is submitted to the live run: ```bash orca status --last # read the question -orca answer "yes, use migration A" # answer and resume the live run +orca answer "yes, use migration A" # answer and resume the same run +``` + +For multi-question prompts, pass JSON mapping question IDs to answers: + +```bash +orca answer '{"answers":{"q1":{"answers":["yes"]},"q2":{"answers":["option-a"]}}}' ``` ### Spec / plan files @@ -76,6 +82,7 @@ orca cancel --last # abort ``` Common failures: + - `auth error` → re-auth Codex (`codex auth`) or set `OPENAI_API_KEY` / `ORCA_OPENAI_API_KEY` - `no git repo` → `cd` into a git repo - `plan invalid` → goal too vague; cancel and restate @@ -104,46 +111,59 @@ Orca loads config in this order (later overrides earlier): `.ts` is preferred over `.js` when both exist. -Stale executor values from older configs are ignored and coerced to `codex`. Orca no longer supports alternate executors. +Orca is Codex-only. Stale executor values from older configs are coerced to `codex`. +Planning can be routed separately: by default Orca asks a lightweight Codex router whether Claude or Codex should generate the task graph, then Codex executes the resulting tasks. ```ts // orca.config.ts import { defineOrcaConfig } from "orcastrator"; export default defineOrcaConfig({ - executor: "codex", // only supported value - runsDir: "./.orca/runs", // default: ~/.orca/runs + executor: "codex", // only supported value + runsDir: "./.orca/runs", // default: ~/.orca/runs sessionLogs: "./session-logs", skills: ["./.orca/skills"], maxRetries: 1, + planner: { + agent: "auto", // "auto" | "claude" | "codex" + router: { + model: "gpt-5.3-codex-spark", + }, + }, + + claude: { + command: "claude", // uses `claude -p` for planning + model: "claude-opus-4-7", + effort: "high", + timeoutMs: 300000, + }, + codex: { - model: "gpt-5.3-codex", - effort: "medium", // fallback for all Codex turns unless overridden below + model: "gpt-5.5", + effort: "high", // fallback for all Codex turns unless overridden below thinkingLevel: { - decision: "low", // planning gate / quick routing decisions - planning: "xhigh", // task graph generation - review: "high", // task graph consultation + post-execution review prompts - execution: "medium", // task execution turns + decision: "low", // planning gate / quick routing decisions + planning: "xhigh", // task graph generation + review: "high", // task graph consultation + post-execution review prompts + execution: "medium", // task execution turns }, timeoutMs: 300000, - multiAgent: false, // see Multi-agent section - perCwdExtraUserRoots: [ - { cwd: process.cwd(), extraUserRoots: ["/tmp/shared-skills"] } - ], + multiAgent: false, // see Multi-agent section + perCwdExtraUserRoots: [{ cwd: process.cwd(), extraUserRoots: ["/tmp/shared-skills"] }], }, review: { plan: { enabled: true, - onInvalid: "fail", // "fail" | "warn_skip" + onInvalid: "fail", // "fail" | "warn_skip" }, execution: { enabled: true, maxCycles: 2, - onFindings: "auto_fix", // "auto_fix" | "report_only" | "fail" + onFindings: "auto_fix", // "auto_fix" | "report_only" | "fail" validator: { - auto: true, // auto-detect validators from package.json + auto: true, // auto-detect validators from package.json // commands: ["npm run validate"] // explicit override }, // prompt: "Prefer minimal safe fixes" @@ -159,7 +179,9 @@ export default defineOrcaConfig({ onTaskComplete: async (event, context) => { console.log(`task done: ${event.taskId} from pid ${context.pid}`); }, - onError: async (event) => { console.error(event.error); }, + onError: async (event) => { + console.error(event.error); + }, }, hookCommands: { @@ -176,11 +198,59 @@ After planning, Orca runs a pre-execution review that can edit the task graph (a After execution, Orca runs validation commands and asks Codex to review findings. With `onFindings: "auto_fix"`, it applies fixes and retries up to `maxCycles` times, then reports. Set `ORCA_SKIP_VALIDATORS=1` to skip validator auto-detection at runtime. -Use `codex.thinkingLevel` when you want different reasoning levels for different stages instead of a single global `codex.effort`. +Use `codex.thinkingLevel` when you want per-step reasoning levels instead of a single global `codex.effort`. Supported steps: `decision`, `planning`, `review`, `execution`. + +### Planner routing + +Set `planner.agent: "claude"` to always use the local Claude Code CLI for task graph generation, or `planner.agent: "codex"` to keep planning in Codex. The default `planner.agent: "auto"` asks Codex using `planner.router.model` and routes broad, creative, ambiguous work to Claude while keeping narrow implementation-heavy planning in Codex. + +`planner.router` is only valid with `planner.agent: "auto"`: + +```ts +// Automatic routing +planner: { + agent: "auto", + router: { model: "gpt-5.3-codex-spark" }, +} + +// Forced planning agent; no router is used +planner: { + agent: "claude", +} +``` + +The same rule is easiest to read as JSON shapes: + +```json +[ + { "planner": { "agent": "auto", "router": { "model": "gpt-5.3-codex-spark" } } }, + { "planner": { "agent": "claude" } }, + { "planner": { "agent": "codex" } } +] +``` + +Do not include `router` with forced `claude` or forced `codex` planning; Orca rejects that config because the router is bypassed. + +Claude planning shells out to the configured command with `-p` and passes the prompt on stdin. It does not replace Codex execution, task-graph review, or post-execution review. + +Model IDs are typed for the documented OpenAI and Claude models Orca supports. Check the provider docs when updating those lists: [OpenAI models](https://platform.openai.com/docs/models), [Anthropic Claude models](https://docs.anthropic.com/en/docs/about-claude/models), and [Claude Code model config](https://docs.anthropic.com/en/docs/claude-code/model-config). If you need an unreleased, private, or provider-specific model, wrap it explicitly: + +```ts +import { customModel, defineOrcaConfig } from "orcastrator"; + +export default defineOrcaConfig({ + codex: { + model: customModel("private-openai-model"), + }, + claude: { + model: customModel("private-claude-model"), + }, +}); +``` ### Multi-agent mode -Set `codex.multiAgent: true` to spawn parallel Codex agents per task. Faster for large refactors with independent subtasks; higher token cost. **Note:** this writes `multi_agent = true` to your global `~/.codex/config.toml`. +Set `codex.multiAgent: true` to enable multi-agent-aware prompt guidance. Orca's task runner stays sequential, but Codex can use subagents inside a task turn when work is independent. **Note:** this writes `multi_agent = true` to your global `~/.codex/config.toml`. If `~/.codex/config.toml` already enables `[features].multi_agent = true`, Orca also treats the run as multi-agent-aware for planning, review, consultation, and execution prompts even when `codex.multiAgent` is not set in Orca config. @@ -228,14 +298,15 @@ orca setup Interactive setup wizard **Key flags for `orca` (run):** -- `--codex-only` — force Codex executor -- `--codex-effort ` — override effort for this run +- `--codex-only` — compatibility flag; executor is already Codex-only +- `--codex-effort ` — override effort for this run - `--config ` — explicit config file +- `--on-question ` — command hook when Codex requests user input - `--on-complete `, `--on-error `, `--on-task-complete `, `--on-findings `, etc. **Key flags for `orca resume`:** -- `--codex-only`, `--codex-effort `, `--config `, `--run `, `--last` +- `--codex-only`, `--codex-effort `, `--config `, `--run `, `--last` **`orca setup` flags:** @@ -251,6 +322,7 @@ Available hook names: `onMilestone`, `onQuestion`, `onTaskComplete`, `onTaskFail - Function hooks (`config.hooks`): receive `(event, context)` where `context = { cwd, pid, invokedAt }` - Command hooks (`config.hookCommands` / `--on-*` flags): receive full event JSON over stdin +- `onQuestion` includes request metadata (`requestId`, `threadId`, `turnId`, `itemId`) and `questions[]` - Unknown hook keys in config are rejected at load time ### Run ID format @@ -278,7 +350,7 @@ bun test src npm run test:postexec-json ``` -Full validation gate (runs lint → type-check → tests → build): +Full validation gate (runs Oxfmt check, Oxlint, type-check, tests, and build): ```bash npm run validate diff --git a/SKILL.md b/SKILL.md index 90a2761..b5d173d 100644 --- a/SKILL.md +++ b/SKILL.md @@ -3,9 +3,9 @@ name: orca description: "Orchestrate multi-step AI coding tasks via the Orca CLI. Use when: running multi-file code changes, spawning background agents, planning and executing complex coding tasks end-to-end. NOT for: simple single-file edits, reading code, or any work in ~/clawd workspace." --- -# Orca — Operator Guide +# Orca - Operator Guide -Orca (`orcastrator`) breaks a goal into a task graph and executes it end-to-end via Codex. Codex plans and executes; a reviewer catches regressions and can auto-fix. +Orca (`orcastrator`) breaks a goal into a task graph and executes it end-to-end via Codex. Planning can be skipped, routed to Codex, or routed to the local Claude Code CLI through `claude -p`; execution and review still run through Codex. --- @@ -13,14 +13,44 @@ Orca (`orcastrator`) breaks a goal into a task graph and executes it end-to-end - Must be run inside a git repo (or pass `--skip-git-repo-check`) - Codex executor (default): requires `~/.codex/auth.json` (Codex OAuth) +- Claude planning (optional): requires `claude` on PATH, or set `claude.command` - Install: `npm install -g orcastrator` --- +## Planner Routing + +| Planner config | When to use | +| --------------------------------- | ------------------------------------------------------------------------- | +| `planner.agent: "auto"` (default) | Let a Codex router choose Claude or Codex for task graph generation | +| `planner.agent: "claude"` | Force broad or ambiguous planning through local Claude Code (`claude -p`) | +| `planner.agent: "codex"` | Force task graph generation to stay in Codex | + +`planner.router` only applies to `planner.agent: "auto"`. Forced planner configs should be this simple: + +```json +[{ "planner": { "agent": "claude" } }, { "planner": { "agent": "codex" } }] +``` + +Automatic routing is the only shape that uses a router model: + +```json +{ + "planner": { + "agent": "auto", + "router": { "model": "gpt-5.3-codex-spark" } + } +} +``` + +Claude planning shells out to `claude -p` with the prompt on stdin. It does not replace Codex execution, task graph review, or post-execution review. + +--- + ## Executor Selection -| Executor | When to use | -|---|---| +| Executor | When to use | +| ----------------- | ----------------------------------------------------------------------------- | | `codex` (default) | Most tasks. Persistent Codex session, fast, integrates with app-server skills | Override in config: `executor: "codex"` (default and only supported executor). @@ -57,6 +87,7 @@ Be specific. Orca passes your goal to a planner that generates a task graph — **Good:** `"Fix the TypeError thrown when user logs out with an expired token in src/auth/session.ts. Ensure existing tests pass and add a regression test."` Include: + - What to change and where - Acceptance criteria or test expectations - What NOT to touch (if relevant) @@ -71,6 +102,7 @@ orca status --run # status of a specific run ``` **Run states:** + - `planning` — generating task graph - `running` — executing tasks - `waiting_for_answer` — agent raised a question, needs `orca answer` @@ -106,6 +138,7 @@ orca cancel --last # abort if unrecoverable ``` **Common failures:** + - `auth error` → re-auth Codex (`codex auth`) or check `ANTHROPIC_API_KEY` - `no git repo` → `cd` into a git repo or use `--skip-git-repo-check` - `plan invalid` → goal was too vague; cancel and restate with more specificity @@ -168,17 +201,37 @@ Config locations (later entries override earlier): `~/.orca/config.ts` (preferre ```ts export default { - executor: "codex", // "codex" - sessionLogs: "./session-logs", // where to write session logs + executor: "codex", // "codex" + sessionLogs: "./session-logs", // where to write session logs + + planner: { + agent: "auto", // "auto" | "claude" | "codex" + router: { + model: "gpt-5.3-codex-spark", // only valid with agent: "auto" + }, + }, + + claude: { + command: "claude", + model: "claude-opus-4-7", + effort: "high", + timeoutMs: 300000, + }, hooks: { - onFindings: async (event, context) => { /* fires after reviewer */ }, - onComplete: async (event, context) => { /* fires on success */ }, - onError: async (event, context) => { /* fires on failure */ }, + onFindings: async (event, context) => { + /* fires after reviewer */ + }, + onComplete: async (event, context) => { + /* fires on success */ + }, + onError: async (event, context) => { + /* fires on failure */ + }, }, hookCommands: { - onComplete: "your-notify-command", // reads event JSON from stdin + onComplete: "your-notify-command", // reads event JSON from stdin onError: "your-error-command", }, @@ -188,15 +241,15 @@ export default { enabled: true, maxCycles: 2, onFindings: "auto_fix", - validator: { auto: true } - } + validator: { auto: true }, + }, }, codex: { + model: "gpt-5.5", + effort: "high", multiAgent: false, - perCwdExtraUserRoots: [ - { cwd: process.cwd(), extraUserRoots: ["/tmp/shared-skills"] } - ] + perCwdExtraUserRoots: [{ cwd: process.cwd(), extraUserRoots: ["/tmp/shared-skills"] }], }, }; ``` @@ -208,6 +261,7 @@ export default { Set `codex.multiAgent: true` to spawn parallel Codex agents per task. Faster for independent tasks; higher token cost. Use for large refactors with clearly separable subtasks. When parallelizing, enforce lane ownership: + - one sub-agent lane per independent subsystem/file set - avoid overlapping write scopes - keep integration/merge steps sequential @@ -219,6 +273,7 @@ When parallelizing, enforce lane ownership: Orca ships a bundled `code-simplifier` skill that's applied in planner, reviewer, and executor prompts automatically. Extra skills can be injected via `codex.perCwdExtraUserRoots` (scoped per cwd). Execution guardrails (especially for Codex): + - bias to the simplest implementation that works - no compatibility fallbacks or dead code unless explicitly required - run a simplification pass before final handoff @@ -228,6 +283,7 @@ Execution guardrails (especially for Codex): ## Done Criteria A run is complete when: + 1. `orca status --last` shows `complete` 2. A branch exists with the committed changes 3. `orca pr status --last` shows CI passing (if applicable) diff --git a/TODO.md b/TODO.md deleted file mode 100644 index 90ca9cd..0000000 --- a/TODO.md +++ /dev/null @@ -1,40 +0,0 @@ -# Orca TODO - -## Skills System -- ✅ Shipped in v0.2.6 — skill loader, frontmatter parsing, injection into planner + task-runner -- ✅ Skill discovery precedence is now explicit and deterministic: config.skills[] > .orca/skills/ > ~/.orca/skills/ > bundled `/.orca/skills/` (first name wins) -- ✅ `orca skills list` command shipped (includes bundled source labeling) - -## Codex-as-Executor -- ✅ Shipped in v0.2.7 — Codex is now default executor; persistent session per run; Claude fallback on init failure -- config: executor?: "claude" | "codex" in OrcaConfig (default: "codex") - -## Multi-Agent -- ✅ Shipped: opt-in via `codex: { multiAgent: true }` in orca.config.js — writes to `~/.codex/config.toml` -- Smoke test once Bradley has a real project to run it against (watch for "spawning sub-agents" in codex output) - -## Validation Hardening -- ✅ Shipped in v0.2.8 — executor config validation, symlink guard, EACCES/EPERM resilience, parseTaskArray field defaults, Codex session leak fix, claude session unit tests (19 new), shared PlanResult/TaskExecutionResult types - -## Recent Ships -- ✅ Bundled `code-simplifier` skill added at `.orca/skills/code-simplifier/SKILL.md`; planner/reviewer/executor prompts now explicitly apply it for all code-writing and code-review steps (behavior-preserving by default) -- ✅ Codex turn input now includes explicit `skill` items (`{ type: "skill", name, path }`) for all loaded skills (same precedence as loader), plus text input for every turn -- ✅ Codex app-server `skills/list` integration shipped with `forceReload: true` + optional `codex.perCwdExtraUserRoots` support (app-server-discovered skills append deterministically) -- ✅ `orca skills list` command shipped -- ✅ Executor override flags shipped: `--codex-only` / `--claude-only` -- ✅ Claude planner/executor deterministic structured-output path shipped (text JSON fallback gated) -- ✅ Effort controls shipped: `--codex-effort `, `--claude-effort ` -- ✅ Planning gate shipped: low-thinking `needsPlan` decision pass before heavy planning; skips full planner/review when a single execution task is enough -- ✅ Fine-grained Codex thinking controls shipped: `codex.thinkingLevel.decision|planning|execution` (with clear defaults) -- ✅ `orca setup --check` key detection improved (OpenClaw env + cross-platform `.env` fallbacks) -- ✅ Dedicated post-exec reviewer JSON hardening integration target shipped (`npm run test:postexec-json`) - -## Remaining -- Zod v3→v4 upgrade (peer dep conflict with @anthropic-ai/claude-agent-sdk@0.2.47) -- Review → improvement step: pre-execution review that modifies the task graph -- AGENTS.md / CLAUDE.md injection into planning context -- Review cycle depth: `maxReviewCycles` config property + `--max-review-cycles ` CLI flag - - Controls how many back-and-forth exchanges between executor and reviewer are allowed - - Always ends with a review (reviewer has last word) - - Default: 1 (one exchange, one final review) - - Example: maxReviewCycles=2 means executor→reviewer→executor→reviewer diff --git a/bun.lock b/bun.lock index 5152e2a..d59325b 100644 --- a/bun.lock +++ b/bun.lock @@ -6,7 +6,7 @@ "name": "orca", "dependencies": { "@inquirer/prompts": "^8.2.1", - "@ratley/codex-client": "^0.1.4", + "@ratley/codex-client": "^0.1.5", "chalk": "^5.3.0", "commander": "^13.1.0", "zod": "^4.3.6", @@ -14,11 +14,13 @@ "devDependencies": { "@arethetypeswrong/cli": "^0.17.4", "@types/bun": "^1.2.21", + "@types/node": "^25.6.0", "@typescript/native-preview": "^7.0.0-dev.20260219.1", "husky": "^9.1.7", "lint-staged": "^16.2.0", - "oxlint": "^1.49.0", - "oxlint-tsgolint": "^0.14.1", + "oxfmt": "^0.47.0", + "oxlint": "^1.62.0", + "oxlint-tsgolint": "^0.22.1", "publint": "^0.3.15", "typescript": "^5.8.2", }, @@ -69,65 +71,103 @@ "@loaderkit/resolve": ["@loaderkit/resolve@1.0.4", "", { "dependencies": { "@braidai/lang": "^1.0.0" } }, "sha512-rJzYKVcV4dxJv+vW6jlvagF8zvGxHJ2+HTr1e2qOejfmGhAApgJHl8Aog4mMszxceTRiKTTbnpgmTO1bEZHV/A=="], - "@oxlint-tsgolint/darwin-arm64": ["@oxlint-tsgolint/darwin-arm64@0.14.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-PRV1nI1N7OQd4YBzdZGTv9JaBnu8aLWE30zoF4IHDiiQewqMK1U5gT5an20A7g32301Ddr2jIOGgbgTEHi7e8A=="], + "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.47.0", "", { "os": "android", "cpu": "arm" }, "sha512-KrMQRdMi/upr81qT4ijK6X6BNp6jqpMY7FwILQnwIy9QLc3qpnhUx5rsCLGzn4ewsCQ0CNAspN2ogmP1GXLyLw=="], - "@oxlint-tsgolint/darwin-x64": ["@oxlint-tsgolint/darwin-x64@0.14.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-5wiV9kqrEqYhgdHWwF7k9BbprLfcqOVfLOY1wCgtMRWco91WAq+JgGsr362237iTRDfMyDbSBqsCO2ff2kFm0A=="], + "@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.47.0", "", { "os": "android", "cpu": "arm64" }, "sha512-r4ixS/PeUpAFKgrpDoZ5pSkthjZzVzKd95525Aazj+aOv9H4ulK5zYHGb7wFY5n5kZxHK8TbOJUZgoEb1ohddQ=="], - "@oxlint-tsgolint/linux-arm64": ["@oxlint-tsgolint/linux-arm64@0.14.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-xBDRBNjkvekf/iXc00/DXZv5WOElBRBQeZnvQ106P+P1d5bqaN/QHX6kDhZU8g9cLmsp3b+TZm3oJzOf9q9lbQ=="], + "@oxfmt/binding-darwin-arm64": ["@oxfmt/binding-darwin-arm64@0.47.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-CLWxiKpMl+195cm09CuaWEhJK0CirRkoMa07aR9+9AFPat2LfIKtwx1JqxZM0MTvcMe6+adlJNdVL6jdInvq3g=="], - "@oxlint-tsgolint/linux-x64": ["@oxlint-tsgolint/linux-x64@0.14.1", "", { "os": "linux", "cpu": "x64" }, "sha512-pUPo7UMShtIUJvOwRxrcIqvTg1tzzJMYZDIIAGIC8pN71UIqWu+yvMJEkY1X9ua1RxxBxDneomBRr+OEt/1I9w=="], + "@oxfmt/binding-darwin-x64": ["@oxfmt/binding-darwin-x64@0.47.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Xq5fjTYDC50faUeLSm0rZdBqoTgleXEdD7NpJdARtQIczkCJn3xNjMUSQQkUmh4CtxkKTNL68lytcOK3e/osgg=="], - "@oxlint-tsgolint/win32-arm64": ["@oxlint-tsgolint/win32-arm64@0.14.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-N999HgAKg+YKwlywyBMHkYpvHAl6DgFax04KOJQR/wL8UHeA/MKtuFRXafLiUzyuALanxlFky3fMtC1RAr0ZEw=="], + "@oxfmt/binding-freebsd-x64": ["@oxfmt/binding-freebsd-x64@0.47.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QOU9ZIJ52p5askcEC0QJvvr8trHAWoonul8bgISo6gYUL3s50zkqafBYcNAr9LJZQbsZtPfIWHk9+5+nUp1qJQ=="], - "@oxlint-tsgolint/win32-x64": ["@oxlint-tsgolint/win32-x64@0.14.1", "", { "os": "win32", "cpu": "x64" }, "sha512-C4JD7oGC/wG+eygEeiqJRl1d3TRPmyA3aNqGf8KqJG6/MPjx7w1lZppMUcoyfED9HIlZTMLj7KHmtcbZJWR5rg=="], + "@oxfmt/binding-linux-arm-gnueabihf": ["@oxfmt/binding-linux-arm-gnueabihf@0.47.0", "", { "os": "linux", "cpu": "arm" }, "sha512-oJxDM1aBhPvz9gmElBv8UpxyiqhwfjcbrSxT5F0xtuUzY6dQI27/AQPIt3eu3Z5Yvn0kQl5R7MA3Z+MbnRvCBw=="], - "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.49.0", "", { "os": "android", "cpu": "arm" }, "sha512-2WPoh/2oK9r/i2R4o4J18AOrm3HVlWiHZ8TnuCaS4dX8m5ZzRmHW0I3eLxEurQLHWVruhQN7fHgZnah+ag5iQg=="], + "@oxfmt/binding-linux-arm-musleabihf": ["@oxfmt/binding-linux-arm-musleabihf@0.47.0", "", { "os": "linux", "cpu": "arm" }, "sha512-g8Lh50VS4ibGz2q6v7r9UZY4D0dM16SdrFYOMzhqIoCwGcai8VMIRUAcqn1/jlCsOOzUXJ741+kCeJt0cofakQ=="], - "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.49.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YqJAGvNB11EzoKm1euVhZntb79alhMvWW/j12bYqdvVxn6xzEQWrEDCJg9BPo3A3tBCSUBKH7bVkAiCBqK/L1w=="], + "@oxfmt/binding-linux-arm64-gnu": ["@oxfmt/binding-linux-arm64-gnu@0.47.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-YrNT1vQ0asaXoRbrvYENPqmBfOQ9Xr8enPNOULeYfg44VjCcrUowFy5QZr+WawE0zyP8cH9e9Gxxg0fDEFzhcg=="], - "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.49.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-WFocCRlvVkMhChCJ2qpJfp1Gj/IjvyjuifH9Pex8m8yHonxxQa3d8DZYreuDQU3T4jvSY8rqhoRqnpc61Nlbxw=="], + "@oxfmt/binding-linux-arm64-musl": ["@oxfmt/binding-linux-arm64-musl@0.47.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-IxtQC/sbBi4ubbY+MdwdanRWrG9InQJVZqyMsBa5IUaQcnSg86gQme574HxXMC1p4bo4YhV99zQ+wNnGCvEgzw=="], - "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.49.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-BN0KniwvehbUfYztOMwEDkYoojGm/narf5oJf+/ap+6PnzMeWLezMaVARNIS0j3OdMkjHTEP8s3+GdPJ7WDywQ=="], + "@oxfmt/binding-linux-ppc64-gnu": ["@oxfmt/binding-linux-ppc64-gnu@0.47.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-EWXEhOMbWO0q6eJSbu0QLkU8cKi0ljlYLngeDs2Ocu/pm1rrLwyQiYzlFbdnMRURI4w9ndr1sI9rSbhlJ5o23Q=="], - "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.49.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-SnkAc/DPIY6joMCiP/+53Q+N2UOGMU6ULvbztpmvPJNF/jYPGhNbKtN982uj2Gs6fpbxYkmyj08QnpkD4fbHJA=="], + "@oxfmt/binding-linux-riscv64-gnu": ["@oxfmt/binding-linux-riscv64-gnu@0.47.0", "", { "os": "linux", "cpu": "none" }, "sha512-tZrjS11TUiDuEpRaqdk8K9F9xETRyKXfuZKmdeW+Gj7coBnm7+8sBEfyt033EAFEQSlkniAXvBLh+Qja2ioGBQ=="], - "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.49.0", "", { "os": "linux", "cpu": "arm" }, "sha512-6Z3EzRvpQVIpO7uFhdiGhdE8Mh3S2VWKLL9xuxVqD6fzPhyI3ugthpYXlCChXzO8FzcYIZ3t1+Kau+h2NY1hqA=="], + "@oxfmt/binding-linux-riscv64-musl": ["@oxfmt/binding-linux-riscv64-musl@0.47.0", "", { "os": "linux", "cpu": "none" }, "sha512-KBFy+2CFKUCZzYwX2ZOPQKck1vjQbz+hextuc19G4r0WRJwadfAeuQMQRQvB+Ivc8brlbOVg7et8K7E467440g=="], - "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.49.0", "", { "os": "linux", "cpu": "arm" }, "sha512-wdjXaQYAL/L25732mLlngfst4Jdmi/HLPVHb3yfCoP5mE3lO/pFFrmOJpqWodgv29suWY74Ij+RmJ/YIG5VuzQ=="], + "@oxfmt/binding-linux-s390x-gnu": ["@oxfmt/binding-linux-s390x-gnu@0.47.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-REUPFKVGSiK99B+9eaPhluEVglzaoj/SMykNC5SUiV2RSsBfV5lWN7Y0iCIc251Wz3GaeAGZsJ/zj3gjarxdFg=="], - "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.49.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-oSHpm8zmSvAG1BWUumbDRSg7moJbnwoEXKAkwDf/xTQJOzvbUknq95NVQdw/AduZr5dePftalB8rzJNGBogUMg=="], + "@oxfmt/binding-linux-x64-gnu": ["@oxfmt/binding-linux-x64-gnu@0.47.0", "", { "os": "linux", "cpu": "x64" }, "sha512-KVftVSVEDeIfRW3TIeLe3aNI/iY4m1fu5mDwHcisKMZSCMKLkrhFsjowC7o9RoqNPxbbglm2+/6KAKBIts2t0Q=="], - "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.49.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-xeqkMOARgGBlEg9BQuPDf6ZW711X6BT5qjDyeM5XNowCJeTSdmMhpePJjTEiVbbr3t21sIlK8RE6X5bc04nWyQ=="], + "@oxfmt/binding-linux-x64-musl": ["@oxfmt/binding-linux-x64-musl@0.47.0", "", { "os": "linux", "cpu": "x64" }, "sha512-DTsmGEaA2860Aq5VUyDO8/MT9NFxwVL93RnRYmpMwK6DsSkThmvEpqoUDDljziEpAedMRG19SCogrNbINSbLUQ=="], - "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.49.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-uvcqRO6PnlJGbL7TeePhTK5+7/JXbxGbN+C6FVmfICDeeRomgQqrfVjf0lUrVpUU8ii8TSkIbNdft3M+oNlOsQ=="], + "@oxfmt/binding-openharmony-arm64": ["@oxfmt/binding-openharmony-arm64@0.47.0", "", { "os": "none", "cpu": "arm64" }, "sha512-8r5BDro7fLOBoq1JXHLVSs55OlrxQhEso4HVo0TcY7OXJUPYfjPoOaYL5us+yIwqyP9rQwN+rxuiNFSmaxSuOQ=="], - "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.49.0", "", { "os": "linux", "cpu": "none" }, "sha512-Dw1HkdXAwHNH+ZDserHP2RzXQmhHtpsYYI0hf8fuGAVCIVwvS6w1+InLxpPMY25P8ASRNiFN3hADtoh6lI+4lg=="], + "@oxfmt/binding-win32-arm64-msvc": ["@oxfmt/binding-win32-arm64-msvc@0.47.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-qtz/gzm8IjSPUlseZ0ofW8zyHLoZsuP5HTfcGGkWkUblB89JT8GNYH3ICqjbDsqsGqXum0/ZndXTFplSdXFIcg=="], - "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.49.0", "", { "os": "linux", "cpu": "none" }, "sha512-EPlMYaA05tJ9km/0dI9K57iuMq3Tw+nHst7TNIegAJZrBPtsOtYaMFZEaWj02HA8FI5QvSnRHMt+CI+RIhXJBQ=="], + "@oxfmt/binding-win32-ia32-msvc": ["@oxfmt/binding-win32-ia32-msvc@0.47.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-5vIcdcIDE7nCx+MXN6sm8kbC4zajDB31E86rez4i45iHNH/2NjdKlJ720xcHTr3eeiMcttCGPHPhE1TjtBDGZw=="], - "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.49.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-yZiQL9qEwse34aMbnMb5VqiAWfDY+fLFuoJbHOuzB1OaJZbN1MRF9Nk+W89PIpGr5DNPDipwjZb8+Q7wOywoUQ=="], + "@oxfmt/binding-win32-x64-msvc": ["@oxfmt/binding-win32-x64-msvc@0.47.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Sr59Y5ms54ONBjxFeWhVlGyQcHXxcl9DxC23f6yXlRkcos7LXBLoO+KDfxexjHIOZh7cWqrWduzvUjJ+pHp8cQ=="], - "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.49.0", "", { "os": "linux", "cpu": "x64" }, "sha512-CcCDwMMXSchNkhdgvhVn3DLZ4EnBXAD8o8+gRzahg+IdSt/72y19xBgShJgadIRF0TsRcV/MhDUMwL5N/W54aQ=="], + "@oxlint-tsgolint/darwin-arm64": ["@oxlint-tsgolint/darwin-arm64@0.22.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4150Lpgc1YM09GcjA6GSrra1JoPjC7aOpfywLjWEY4vW0Sd1qKzqHF1WRaiw0/qUZ40OATYdv3aRd7ipPkWQbw=="], - "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.49.0", "", { "os": "linux", "cpu": "x64" }, "sha512-u3HfKV8BV6t6UCCbN0RRiyqcymhrnpunVmLFI8sEa5S/EBu+p/0bJ3D7LZ2KT6PsBbrB71SWq4DeFrskOVgIZg=="], + "@oxlint-tsgolint/darwin-x64": ["@oxlint-tsgolint/darwin-x64@0.22.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-vFWcPWYOgZs4HWcgS1EjUZg33NLcNfEYU49KGImmCfZWkflENrmBYV4HN/C0YeAPum6ZZ/goPSvQrB/cOD+NfA=="], - "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.49.0", "", { "os": "none", "cpu": "arm64" }, "sha512-dRDpH9fw+oeUMpM4br0taYCFpW6jQtOuEIec89rOgDA1YhqwmeRcx0XYeCv7U48p57qJ1XZHeMGM9LdItIjfzA=="], + "@oxlint-tsgolint/linux-arm64": ["@oxlint-tsgolint/linux-arm64@0.22.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-6LiUpP0Zir3+29FvBm7Y28q/dBjSHqTZ5MhG1Ckw4fGhI4cAvbcwXaKvbjx1TP7rRmBNOoq/M5xdpHjTb+GAew=="], - "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.49.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-6rrKe/wL9tn0qnOy76i1/0f4Dc3dtQnibGlU4HqR/brVHlVjzLSoaH0gAFnLnznh9yQ6gcFTBFOPrcN/eKPDGA=="], + "@oxlint-tsgolint/linux-x64": ["@oxlint-tsgolint/linux-x64@0.22.1", "", { "os": "linux", "cpu": "x64" }, "sha512-fuX1hEQfpHauUbXADsfqVhRzrUrGabzGXbj5wsp2vKhV5uk/Rze8Mba9GdjFGECzvXudMGqHqxB4r6jGRdhxVA=="], - "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.49.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-CXHLWAtLs2xG/aVy1OZiYJzrULlq0QkYpI6cd7VKMrab+qur4fXVE/B1Bp1m0h1qKTj5/FTGg6oU4qaXMjS/ug=="], + "@oxlint-tsgolint/win32-arm64": ["@oxlint-tsgolint/win32-arm64@0.22.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SZidAj+jrbZf9ZjBEYW0tiNZ+KasqB2zgW26qdiPpQSF/DzURnPmXz651IeA9YsmbVdHGIooEHUmev6QJdquA=="], - "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.49.0", "", { "os": "win32", "cpu": "x64" }, "sha512-VteIelt78kwzSglOozaQcs6BCS4Lk0j+QA+hGV0W8UeyaqQ3XpbZRhDU55NW1PPvCy1tg4VXsTlEaPovqto7nQ=="], + "@oxlint-tsgolint/win32-x64": ["@oxlint-tsgolint/win32-x64@0.22.1", "", { "os": "win32", "cpu": "x64" }, "sha512-QweSk9H5lFh5Y+WUf2Kq/OAN88V6+62ZwGhP38gqdRotI90luXSMkruFTj7Q2rYrzH4ZVNaSqx7NY8JpSfIzqg=="], + + "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.62.0", "", { "os": "android", "cpu": "arm" }, "sha512-pKsthNECyvJh8lPTICz6VcwVy2jOqdhhsp1rlxCkhgZR47aKvXPmaRWQDv+zlXpRae4qm1MaaTnutkaOk5aofg=="], + + "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.62.0", "", { "os": "android", "cpu": "arm64" }, "sha512-b1AUNViByvgmR2xJDubvLIr+dSuu3uraG7bsAoKo+xrpspPvu6RIn6Fhr2JUhobfep3jwUTy18Huco6GkwdvGQ=="], + + "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.62.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-iG+Tvf70UJ6otfwFYIHk36Sjq9cpPP5YLxkoggANNRtzgi3Tj3g8q6Ybqi6AtkU3+yg9QwF7bDCkCS6bbL4PCg=="], + + "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.62.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-oOWI6YPPr5AJUx+yIDlxmuUbQjS5gZX3OH3QisawYvsZgLiQVvZtR0rPBcJTxLWqt2ClrWg0DlSrlUiG5SQNHg=="], + + "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.62.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dLP33T7VLCmLVv4cvjkVX+rmkcwNk2UfxmsZPNur/7BQHoQR60zJ7XLiRvNUawlzn0u8ngCa3itjEG73MAMa/w=="], + + "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.62.0", "", { "os": "linux", "cpu": "arm" }, "sha512-fl//LWNks6qo9chNY60UDYyIwtp7a5cEx4Y/rHPjaarhuwqx6jtbzEpD5V5AqmdL4a6Y5D8zeXg5HF2Cr0QmSQ=="], + + "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.62.0", "", { "os": "linux", "cpu": "arm" }, "sha512-i5vkAuxvueTODV3J2dL61/TXewDHhMFKvtD156cIsk7GsdfiAu7zW7kY0NJXhKeFHeiMZIh7eFNjkPYH6J47HQ=="], + + "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.62.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-QwN19LLuIGuOjEflSeJkZmOTfBdBMlTmW8xbMf8TZhjd//cxVNYQPq75q7oKZBJc6hRx3gY7sX0Egc8cEIFZYg=="], + + "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.62.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-8eCy3FCDuWUM5hWujAv6heMvfZPbcCOU3SdQUAkixZLu5bSzOkNfirJiLGoQFO943xceOKkiQRMQNzH++jM3WA=="], + + "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.62.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-NjQ7K7tpTPDe9J+yq8p/s/J0E7lRCkK2uDBDqvT4XIT6f4Z0tlnr59OBg/WcrmVHER1AbrcfyxhGTXgcG8ytWg=="], + + "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.62.0", "", { "os": "linux", "cpu": "none" }, "sha512-oKZed9gmSwze29dEt3/Wnsv6l/Ygw/FUst+8Kfpv2SGeS/glEoTGZAMQw37SVyzFV76UTHJN2snGgxK2t2+8ow=="], + + "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.62.0", "", { "os": "linux", "cpu": "none" }, "sha512-gBjBxQ+9lGpAYq+ELqw0w8QXsBnkZclFc7GRX2r0LnEVn3ZTEqeIKpKcGjucmp76Q53bvJD0i4qBWBhcfhSfGA=="], + + "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.62.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-Ew2Kxs9EQ9/mbAIJ2hvocMC0wsOu6YKzStI2eFBDt+Td5O8seVC/oxgRIHqCcl5sf5ratA1nozQBAuv7tphkHg=="], + + "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.62.0", "", { "os": "linux", "cpu": "x64" }, "sha512-5z25jcAA0gfKyVwz71A0VXgaPlocPoTAxhlv/hgoK6tlCrfoNuw7haWbDHvGMfjXhdic4EqVXGRv5XsTqFnbRQ=="], + + "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.62.0", "", { "os": "linux", "cpu": "x64" }, "sha512-IWpHmMB6ZDllPvqWDkG6AmXrN7JF5e/c4g/0PuURsmlK+vHoYZPB70rr4u1bn3I4LsKCSpqqfveyx6UCOC8wdg=="], + + "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.62.0", "", { "os": "none", "cpu": "arm64" }, "sha512-fjlSxxrD5pA594vkyikCS9MnPRjQawW6/BLgyTYkO+73wwPlYjkcZ7LSd974l0Q2zkHQmu4DPvJFLYA7o8xrxQ=="], + + "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.62.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-EiFXr8loNS0Ul3Gu80+9nr1T8jRmnKocqmHHg16tj5ZqTgUXyb97l2rrspVHdDluyFn9JfR4PoJFdNzw4paHww=="], + + "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.62.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-IgOFvL73li1bFgab+hThXYA0N2Xms2kV2MvZN95cebV+fmrZ9AVui1JSxfeeqRLo3CpPxKZlzhyq4G0cnaAvIw=="], + + "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.62.0", "", { "os": "win32", "cpu": "x64" }, "sha512-6hMpyDWQ2zGA1OXFKBrdYMUveUCO8UJhkO6JdwZPd78xIdHZNhjx+pib+4fC2Cljuhjyl0QwA2F3df/bs4Bp6A=="], "@publint/pack": ["@publint/pack@0.1.4", "", {}, "sha512-HDVTWq3H0uTXiU0eeSQntcVUTPP3GamzeXI41+x7uU9J65JgWQh3qWZHblR1i0npXfFtF+mxBiU2nJH8znxWnQ=="], - "@ratley/codex-client": ["@ratley/codex-client@0.1.4", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-lefQMXoR12cImkNnZiHN/MraDdxKzrwH5DZSft6cQJ1s+tIZllNyCBBD6dtGX8O+8uz19SL/NyyHbBIDUtlIlw=="], + "@ratley/codex-client": ["@ratley/codex-client@0.1.5", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-sYpKYpCXn//QZpKJyboWPCC1YuZrMBacKYmDBMyFRw3F59w4n1tec5n1yHRrfF19IBGsx5MtHevZjfu6tuEaXw=="], "@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="], "@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="], - "@types/node": ["@types/node@20.19.33", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw=="], + "@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="], "@typescript/native-preview": ["@typescript/native-preview@7.0.0-dev.20260219.1", "", { "optionalDependencies": { "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260219.1", "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260219.1", "@typescript/native-preview-linux-arm": "7.0.0-dev.20260219.1", "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260219.1", "@typescript/native-preview-linux-x64": "7.0.0-dev.20260219.1", "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260219.1", "@typescript/native-preview-win32-x64": "7.0.0-dev.20260219.1" }, "bin": { "tsgo": "bin/tsgo.js" } }, "sha512-Y/mfpmpZwfwyNzBgki/wUm/pWLQM2gaL5cum6lbOv8QNZUtzcIy0wTPaS08sb4yhUSGC1jGaJEPR2FNctfeC2Q=="], @@ -251,9 +291,11 @@ "onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], - "oxlint": ["oxlint@1.49.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.49.0", "@oxlint/binding-android-arm64": "1.49.0", "@oxlint/binding-darwin-arm64": "1.49.0", "@oxlint/binding-darwin-x64": "1.49.0", "@oxlint/binding-freebsd-x64": "1.49.0", "@oxlint/binding-linux-arm-gnueabihf": "1.49.0", "@oxlint/binding-linux-arm-musleabihf": "1.49.0", "@oxlint/binding-linux-arm64-gnu": "1.49.0", "@oxlint/binding-linux-arm64-musl": "1.49.0", "@oxlint/binding-linux-ppc64-gnu": "1.49.0", "@oxlint/binding-linux-riscv64-gnu": "1.49.0", "@oxlint/binding-linux-riscv64-musl": "1.49.0", "@oxlint/binding-linux-s390x-gnu": "1.49.0", "@oxlint/binding-linux-x64-gnu": "1.49.0", "@oxlint/binding-linux-x64-musl": "1.49.0", "@oxlint/binding-openharmony-arm64": "1.49.0", "@oxlint/binding-win32-arm64-msvc": "1.49.0", "@oxlint/binding-win32-ia32-msvc": "1.49.0", "@oxlint/binding-win32-x64-msvc": "1.49.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.14.1" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-YZffp0gM+63CJoRhHjtjRnwKtAgUnXM6j63YQ++aigji2NVvLGsUlrXo9gJUXZOdcbfShLYtA6RuTu8GZ4lzOQ=="], + "oxfmt": ["oxfmt@0.47.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.47.0", "@oxfmt/binding-android-arm64": "0.47.0", "@oxfmt/binding-darwin-arm64": "0.47.0", "@oxfmt/binding-darwin-x64": "0.47.0", "@oxfmt/binding-freebsd-x64": "0.47.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.47.0", "@oxfmt/binding-linux-arm-musleabihf": "0.47.0", "@oxfmt/binding-linux-arm64-gnu": "0.47.0", "@oxfmt/binding-linux-arm64-musl": "0.47.0", "@oxfmt/binding-linux-ppc64-gnu": "0.47.0", "@oxfmt/binding-linux-riscv64-gnu": "0.47.0", "@oxfmt/binding-linux-riscv64-musl": "0.47.0", "@oxfmt/binding-linux-s390x-gnu": "0.47.0", "@oxfmt/binding-linux-x64-gnu": "0.47.0", "@oxfmt/binding-linux-x64-musl": "0.47.0", "@oxfmt/binding-openharmony-arm64": "0.47.0", "@oxfmt/binding-win32-arm64-msvc": "0.47.0", "@oxfmt/binding-win32-ia32-msvc": "0.47.0", "@oxfmt/binding-win32-x64-msvc": "0.47.0" }, "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-OFbkbzxKCpooQEnRmpTDnuwTX8KHXzZTQ4Df/hz85fpS67Pl+lxPEFvUtin56HIIS0B1k4X8oIzTXRZPufA2CA=="], + + "oxlint": ["oxlint@1.62.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.62.0", "@oxlint/binding-android-arm64": "1.62.0", "@oxlint/binding-darwin-arm64": "1.62.0", "@oxlint/binding-darwin-x64": "1.62.0", "@oxlint/binding-freebsd-x64": "1.62.0", "@oxlint/binding-linux-arm-gnueabihf": "1.62.0", "@oxlint/binding-linux-arm-musleabihf": "1.62.0", "@oxlint/binding-linux-arm64-gnu": "1.62.0", "@oxlint/binding-linux-arm64-musl": "1.62.0", "@oxlint/binding-linux-ppc64-gnu": "1.62.0", "@oxlint/binding-linux-riscv64-gnu": "1.62.0", "@oxlint/binding-linux-riscv64-musl": "1.62.0", "@oxlint/binding-linux-s390x-gnu": "1.62.0", "@oxlint/binding-linux-x64-gnu": "1.62.0", "@oxlint/binding-linux-x64-musl": "1.62.0", "@oxlint/binding-openharmony-arm64": "1.62.0", "@oxlint/binding-win32-arm64-msvc": "1.62.0", "@oxlint/binding-win32-ia32-msvc": "1.62.0", "@oxlint/binding-win32-x64-msvc": "1.62.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.18.0" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-1uFkg6HakjsGIpW9wNdeW4/2LOHW9MEkoWjZUTUfQtIHyLIZPYt00w3Sg+H3lH+206FgBPHBbW5dVE5l2ExECQ=="], - "oxlint-tsgolint": ["oxlint-tsgolint@0.14.1", "", { "optionalDependencies": { "@oxlint-tsgolint/darwin-arm64": "0.14.1", "@oxlint-tsgolint/darwin-x64": "0.14.1", "@oxlint-tsgolint/linux-arm64": "0.14.1", "@oxlint-tsgolint/linux-x64": "0.14.1", "@oxlint-tsgolint/win32-arm64": "0.14.1", "@oxlint-tsgolint/win32-x64": "0.14.1" }, "bin": { "tsgolint": "bin/tsgolint.js" } }, "sha512-+zbTyYt+86+8TcF//1NUoHs7v8kvu5vQvjnFZMerrhp5REzYFvgLdfT7LLBQd1qmTWeFQ4/ko1YLXKtoxTFxVw=="], + "oxlint-tsgolint": ["oxlint-tsgolint@0.22.1", "", { "optionalDependencies": { "@oxlint-tsgolint/darwin-arm64": "0.22.1", "@oxlint-tsgolint/darwin-x64": "0.22.1", "@oxlint-tsgolint/linux-arm64": "0.22.1", "@oxlint-tsgolint/linux-x64": "0.22.1", "@oxlint-tsgolint/win32-arm64": "0.22.1", "@oxlint-tsgolint/win32-x64": "0.22.1" }, "bin": { "tsgolint": "bin/tsgolint.js" } }, "sha512-YUSGSLUnoolsu8gxISEDio3q1rtsCozwfOzASUn3DT2mR2EeQ93uEEnen7s+6LpF+lyTQFln1pQfqwBh/fsVEg=="], "package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="], @@ -301,11 +343,13 @@ "thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="], + "tinypool": ["tinypool@2.1.0", "", {}, "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw=="], + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], "unicode-emoji-modifier-base": ["unicode-emoji-modifier-base@1.0.0", "", {}, "sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g=="], @@ -329,6 +373,8 @@ "@arethetypeswrong/core/typescript": ["typescript@5.6.1-rc", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-E3b2+1zEFu84jB0YQi9BORDjz9+jGbwwy1Zi3G0LUNw7a7cePUrHMRNy8aPh53nXpkFGVHSxIZo5vKTfYaFiBQ=="], + "bun-types/@types/node": ["@types/node@20.19.33", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw=="], + "cli-highlight/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "cli-truncate/string-width": ["string-width@8.2.0", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw=="], @@ -353,6 +399,8 @@ "wrap-ansi/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], + "bun-types/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "cli-truncate/string-width/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], "wrap-ansi/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], diff --git a/docs/PLAN.md b/docs/PLAN.md index fad388b..9275581 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -3,6 +3,7 @@ ## 1. Purpose and Scope Orca is a TypeScript CLI harness for coordinated agent work: + - Input: a spec markdown file. - Output: an executable, tracked, hookable task run. - Positioning: infrastructure layer, not a TUI and not tied to one agent vendor. @@ -12,6 +13,7 @@ Core principle: standardize the agent loop (plan, decompose, execute, track, not ## 2. Goals and Non-Goals ### Goals + - Provide a stable CLI for planning and execution. - Use Claude as orchestrator for decomposition and coordination. - Use Codex as a consultation oracle (read-only guidance), not primary implementer. @@ -20,6 +22,7 @@ Core principle: standardize the agent loop (plan, decompose, execute, track, not - Keep runtime cross-platform via Node.js and strict TypeScript. ### Non-Goals + - Building a visual terminal UI. - Hard-coding OpenClaw behavior as a required dependency. - Fully autonomous PR creation/push without human/agent confirmation. @@ -88,36 +91,44 @@ Core principle: standardize the agent loop (plan, decompose, execute, track, not ## 4. Key Design Decisions and Rationale 1. Node.js runtime. + - Rationale: broad cross-platform compatibility and mature ecosystem. - Consequence: default assumptions remain Node-compatible unless explicitly overridden. 1b. Bun as runtime and test runner. Rationale: native TypeScript execution without transpile, built-in fast test runner (bun test), simpler toolchain. tsc --noEmit is used for type checking only. Cross-platform caveat accepted for speed of development. 2. Strict TypeScript everywhere. + - Rationale: orchestration systems become state-heavy quickly; strict types reduce runtime drift. - Consequence: explicit discriminated unions for state transitions and hook events. 3. `oxlint` for lint + `tsgolint` for type checking. + - Rationale: fast feedback loop with modern Oxc stack. - Consequence: CI and local commands should enforce both checks before release. 4. Claude as orchestrator (SDK v2 preview). + - Rationale: leverage `unstable_v2_createSession`, `send`, and `stream` for iterative planning/execution loops. - Consequence: isolate SDK usage behind an adapter so future API changes are contained. 5. Codex consultation as an explicit tool call pattern. + - Rationale: keeps primary orchestration centered in Claude while enabling targeted coding guidance. - Consequence: bounded interface for questions/answers; read-only and auditable prompts. 6. Hook-first extensibility. + - Rationale: notifications and side effects vary by environment/team. - Consequence: hook execution must be fault-tolerant, observable, and non-blocking by default. 7. File-backed status (`status.json`) as source of truth. + - Rationale: transparent, inspectable state with minimal operational burden. - Consequence: define atomic writes and versioned schema to prevent corruption. 8. Manual confirmation for PR finalize. + - Rationale: safety and trust; avoid accidental pushes/PRs. - Consequence: `orca pr finalize` is explicit and confirmation-gated. @@ -187,6 +198,7 @@ orca/ ``` Global run store (outside project tree, configurable): + ```text ~/.orca/runs/ # default, override via ORCA_RUNS_DIR or orca.config.ts --<4char-hex>/ @@ -209,12 +221,7 @@ export interface Spec { createdAt: string; // ISO timestamp } -export type TaskStatus = - | "pending" - | "in_progress" - | "done" - | "failed" - | "cancelled"; +export type TaskStatus = "pending" | "in_progress" | "done" | "failed" | "cancelled"; export interface Task { id: string; @@ -250,12 +257,7 @@ export interface RunStatus { }; } -export type HookName = - | "onMilestone" - | "onTaskComplete" - | "onTaskFail" - | "onComplete" - | "onError"; +export type HookName = "onMilestone" | "onTaskComplete" | "onTaskFail" | "onComplete" | "onError"; export interface HookEvent { runId: RunId; @@ -295,6 +297,7 @@ export interface OrcaConfig { ## 7. Hook System (Detailed) ### Trigger Points + - `onMilestone`: planning completed, execution started, retry threshold reached, PR draft ready. - `onTaskComplete`: each task transitions to `done`. - `onTaskFail`: each task transitions to `failed` (including retry exhaustion). @@ -302,23 +305,30 @@ export interface OrcaConfig { - `onError`: unrecoverable run-level errors (parse failure, Claude session error, state corruption). ### Configuration Inputs + - `orca.config.ts` programmatic handlers (`async (event) => { ... }`). - CLI command hooks: e.g. `--on-milestone 'node -e '\''let s="";process.stdin.on("data",d=>s+=d);process.stdin.on("end",()=>{const p=JSON.parse(s);console.log(p.message);})'\'''`. - Both can coexist; execution order should be deterministic (programmatic first, CLI second). ### Default Routing Behavior + 1. If OpenClaw env is detected, default hook action: + - `openclaw system event --text "$(node -e 'let s="";process.stdin.on("data",d=>s+=d);process.stdin.on("end",()=>{const p=JSON.parse(s); process.stdout.write(p.message);})')" --mode now` + 2. Otherwise: + - log structured event to stdout. OpenClaw detection contract: + - Check binary presence in PATH (`which openclaw` on Unix, `where openclaw` on Windows). - Check auth/config presence: `OPENCLAW_GATEWAY_TOKEN` is set OR `~/.openclaw/openclaw.json` exists. - Both checks must pass to enable OpenClaw adapter. - If only one check passes, emit a warning and fall back to stdout. ### Reliability Rules + - Hook failures never mutate task outcomes. - Each hook invocation is wrapped in timeout + error capture. - Hook errors emit `onError` (guarded to avoid recursion loops). @@ -327,15 +337,18 @@ OpenClaw detection contract: ## 8. Codex Consultation Pattern ### Role Definition + - Codex is a specialized read-only advisor used by Claude subagents for implementation questions. - It is never the run orchestrator and never executes repo writes as part of consultation. ### Invocation Contract + - Claude subagent decides a question needs external implementation guidance. - Orca executes configured command (default `codex exec -s read-only`) with a bounded prompt. - Returned answer is attached to task context as advisory material. ### Guardrails + - Prompt includes current task, acceptance criteria, and relevant files list. - Response size limits and timeout are enforced. - Consultation transcript is logged in run artifacts for audit. @@ -348,10 +361,12 @@ OpenClaw detection contract: 1. Load and parse spec markdown. 2. Claude creates initial structured task JSON. 3. Codex pre-planning review validates: + - missing dependencies - ambiguous acceptance criteria - over-broad tasks - ordering/risk issues + 4. Orca merges feedback into a revised plan prompt for Claude. 5. Claude emits final task graph. 6. Validate graph integrity (unique IDs, no missing deps, no cycles). @@ -362,16 +377,19 @@ Execution starts only after step 7 succeeds. ## 10. Task Runner and Status Tracking ### Queue Model + - Build DAG from `dependencies[]`. - Select runnable tasks where all deps are `done`. - Process tasks sequentially first (phase 1), with optional future controlled parallelism. ### Retry Semantics + - Per-task retry counter, default from config (`maxRetries`). - Retry on transient orchestrator/tool errors. - Fail fast on schema/validation errors. ### Status Persistence + - Update `status.json` at every transition: - `pending -> in_progress -> done|failed|cancelled` - include timestamps, retries, and last error. @@ -381,53 +399,67 @@ Use atomic write strategy (`write temp + rename`) to protect against partial wri ## 11. CLI Interface ### `orca run` + ```bash orca run --spec ./specs/myfeature.md [--config ./orca.config.ts] ``` + - Runs pre-planning then execution. - Creates run directory and live status file. - First output line is always `Run ID: ` for concurrent run management. - Run ID format is deterministic + collision-resistant: `--<4char-hex>` (e.g. `onboarding-1708300800000-a3f2`). ### `orca plan` + ```bash orca plan --spec ./specs/myfeature.md [--config ./orca.config.ts] ``` + - Runs pre-planning only. - Outputs validated task graph without execution. ### `orca status` + ```bash orca status [--run ] ``` + - With no args, lists all runs in run store (same summary view as `orca list`). - With `--run `, prints detailed status + task table for that specific run. ### `orca list` + ```bash orca list ``` + - Lists all runs in run store with run ID, spec, status, and started time. - Primary run discovery command before `status --run`, `resume`, `cancel`, and `pr finalize`. ### `orca resume` + ```bash orca resume --run ``` + - Resumes a stopped/incomplete run by re-reading `tasks.json`. - Skips completed tasks and continues from the first incomplete task. ### `orca cancel` + ```bash orca cancel --run ``` + - Terminates a currently running process cleanly. - Marks run/task state as `cancelled` and persists status. ### `orca pr finalize` + ```bash orca pr finalize --run ``` + - Reads drafted PR title/body from run artifacts. - Shows draft and asks for confirmation. - Refuses unless run `overallStatus` is `completed`. @@ -435,10 +467,12 @@ orca pr finalize --run - Never pushes changes automatically. Run-targeting requirement for concurrent safety: + - All commands that operate on a specific run require `--run ` (no implicit "latest" selection). - If omitted, command must fail with a clear error and list active runs. ### Hook Flags (examples) + ```bash orca run --spec ./specs/myfeature.md \ --on-milestone 'node -e '\''let s="";process.stdin.on("data",d=>s+=d);process.stdin.on("end",()=>{const p=JSON.parse(s);console.log(p.message);})'\''' @@ -447,13 +481,16 @@ orca run --spec ./specs/myfeature.md \ ## 12. PR Flow (Optional, Hook-Based) 1. At run completion, if PR flow enabled: + - Claude drafts title + description from run context. + 2. Orca emits milestone: `PR draft ready`. 3. User/agent reviews draft text. 4. `orca pr finalize --run ` confirmation gate (only when run `overallStatus` is `completed`). 5. On confirm, Orca calls `gh` and stores PR URL in `status.json`. Safety constraints: + - No auto push. - No auto merge. - Explicit confirmation per finalize action. @@ -488,11 +525,13 @@ Safety constraints: ## 16. Implementation Phases ### Phase 0: Project Bootstrap + - Initialize TS project with strict `tsconfig`. - Add `oxlint` + `tsgolint` commands and CI checks. - Add base CLI entrypoint and command scaffolding. ### Phase 1: Planning Backbone (MVP) + - Implement spec loader and planner pipeline. - Integrate Claude SDK adapter (v2 preview methods). - Generate and validate task JSON graph. @@ -501,6 +540,7 @@ Safety constraints: - Implement `orca plan` command. ### Phase 2: Execution Engine + - Build dependency-aware task runner. - Add retry policy and state transitions. - Implement `orca run`, `orca list`, and `orca status`. @@ -510,23 +550,27 @@ Safety constraints: - Add robust atomic status writes. ### Phase 3: Hook Framework + - Implement dispatcher + event types. - Add stdout + OpenClaw default adapters. - Add CLI hook command templating and config handlers. - Add timeout/isolation and error telemetry around hooks. ### Phase 4: Codex Consultation Integration + - Add consultation adapter (`codex exec -s read-only`). - Integrate into Claude subagent decision flow. - Add transcript artifact logging and fallback behavior. ### Phase 5: PR Draft and Finalize + - Add on-complete draft generation. - Implement confirmation UX and `gh` integration. - Wire `orca pr finalize --run ` and status persistence. - Refuse finalize unless run `overallStatus` is `completed`. ### Phase 6: Hardening and Extensibility + - Schema versioning + migrations. - More integration tests (failure injection, retry behavior, hook failures). - Add global run store resolution (`ORCA_RUNS_DIR` env override, `orca.config.ts` fallback, default `~/.orca/runs`). @@ -535,9 +579,11 @@ Safety constraints: ## 17. Build Order Recommendation Start with `plan` before `run`: + 1. Reliable decomposition + validation gives immediate product value. 2. It de-risks execution by enforcing high-quality task graphs first. 3. Hooks and PR flow can be layered without destabilizing core orchestration. Immediate first milestone: + - `orca plan --spec ...` producing validated `tasks.json` + `status.json` with hook emission. diff --git a/docs/codex-app-server.md b/docs/codex-app-server.md index 1b646d6..a38e315 100644 --- a/docs/codex-app-server.md +++ b/docs/codex-app-server.md @@ -3,7 +3,7 @@ Codex app-server is the interface Codex uses to power rich clients (for example, the Codex VS Code extension). Use it when you want a deep integration inside your own product: authentication, conversation history, approvals, and streamed agent events. The app-server implementation is open source in the Codex GitHub repository ([openai/codex/codex-rs/app-server](https://github.com/openai/codex/tree/main/codex-rs/app-server)). See the [Open Source](https://developers.openai.com/codex/open-source) page for the full list of open-source Codex components. If you are automating jobs or running Codex in CI, use the - Codex SDK instead. +Codex SDK instead. ## Protocol @@ -56,9 +56,6 @@ codex app-server generate-json-schema --out ./schemas Example (Node.js / TypeScript): ```ts - - - const proc = spawn("codex", ["app-server"], { stdio: ["pipe", "pipe", "inherit"], }); @@ -159,10 +156,7 @@ Example with notification opt-out: }, "capabilities": { "experimentalApi": true, - "optOutNotificationMethods": [ - "codex/event/session_configured", - "item/agentMessage/delta" - ] + "optOutNotificationMethods": ["codex/event/session_configured", "item/agentMessage/delta"] } } } @@ -1435,4 +1429,4 @@ Field notes: - `limitName` is an optional user-facing label for the bucket. - `usedPercent` is current usage within the quota window. - `windowDurationMins` is the quota window length. -- `resetsAt` is a Unix timestamp (seconds) for the next reset. \ No newline at end of file +- `resetsAt` is a Unix timestamp (seconds) for the next reset. diff --git a/docs/codex-cli-reference.md b/docs/codex-cli-reference.md index aae41c7..5d21563 100644 --- a/docs/codex-cli-reference.md +++ b/docs/codex-cli-reference.md @@ -1,685 +1,685 @@ # Command line options export const globalFlagOptions = [ - { - key: "PROMPT", - type: "string", - description: - "Optional text instruction to start the session. Omit to launch the TUI without a pre-filled message.", - }, - { - key: "--image, -i", - type: "path[,path...]", - description: - "Attach one or more image files to the initial prompt. Separate multiple paths with commas or repeat the flag.", - }, - { - key: "--model, -m", - type: "string", - description: - "Override the model set in configuration (for example `gpt-5-codex`).", - }, - { - key: "--oss", - type: "boolean", - defaultValue: "false", - description: - 'Use the local open source model provider (equivalent to `-c model_provider="oss"`). Validates that Ollama is running.', - }, - { - key: "--profile, -p", - type: "string", - description: - "Configuration profile name to load from `~/.codex/config.toml`.", - }, - { - key: "--sandbox, -s", - type: "read-only | workspace-write | danger-full-access", - description: - "Select the sandbox policy for model-generated shell commands.", - }, - { - key: "--ask-for-approval, -a", - type: "untrusted | on-request | never", - description: - "Control when Codex pauses for human approval before running a command. `on-failure` is deprecated; prefer `on-request` for interactive runs or `never` for non-interactive runs.", - }, - { - key: "--full-auto", - type: "boolean", - defaultValue: "false", - description: - "Shortcut for low-friction local work: sets `--ask-for-approval on-request` and `--sandbox workspace-write`.", - }, - { - key: "--dangerously-bypass-approvals-and-sandbox, --yolo", - type: "boolean", - defaultValue: "false", - description: - "Run every command without approvals or sandboxing. Only use inside an externally hardened environment.", - }, - { - key: "--cd, -C", - type: "path", - description: - "Set the working directory for the agent before it starts processing your request.", - }, - { - key: "--search", - type: "boolean", - defaultValue: "false", - description: - 'Enable live web search (sets `web_search = "live"` instead of the default `"cached"`).', - }, - { - key: "--add-dir", - type: "path", - description: - "Grant additional directories write access alongside the main workspace. Repeat for multiple paths.", - }, - { - key: "--no-alt-screen", - type: "boolean", - defaultValue: "false", - description: - "Disable alternate screen mode for the TUI (overrides `tui.alternate_screen` for this run).", - }, - { - key: "--enable", - type: "feature", - description: - "Force-enable a feature flag (translates to `-c features.=true`). Repeatable.", - }, - { - key: "--disable", - type: "feature", - description: - "Force-disable a feature flag (translates to `-c features.=false`). Repeatable.", - }, - { - key: "--config, -c", - type: "key=value", - description: - "Override configuration values. Values parse as JSON if possible; otherwise the literal string is used.", - }, +{ +key: "PROMPT", +type: "string", +description: +"Optional text instruction to start the session. Omit to launch the TUI without a pre-filled message.", +}, +{ +key: "--image, -i", +type: "path[,path...]", +description: +"Attach one or more image files to the initial prompt. Separate multiple paths with commas or repeat the flag.", +}, +{ +key: "--model, -m", +type: "string", +description: +"Override the model set in configuration (for example `gpt-5-codex`).", +}, +{ +key: "--oss", +type: "boolean", +defaultValue: "false", +description: +'Use the local open source model provider (equivalent to `-c model_provider="oss"`). Validates that Ollama is running.', +}, +{ +key: "--profile, -p", +type: "string", +description: +"Configuration profile name to load from `~/.codex/config.toml`.", +}, +{ +key: "--sandbox, -s", +type: "read-only | workspace-write | danger-full-access", +description: +"Select the sandbox policy for model-generated shell commands.", +}, +{ +key: "--ask-for-approval, -a", +type: "untrusted | on-request | never", +description: +"Control when Codex pauses for human approval before running a command. `on-failure` is deprecated; prefer `on-request` for interactive runs or `never` for non-interactive runs.", +}, +{ +key: "--full-auto", +type: "boolean", +defaultValue: "false", +description: +"Shortcut for low-friction local work: sets `--ask-for-approval on-request` and `--sandbox workspace-write`.", +}, +{ +key: "--dangerously-bypass-approvals-and-sandbox, --yolo", +type: "boolean", +defaultValue: "false", +description: +"Run every command without approvals or sandboxing. Only use inside an externally hardened environment.", +}, +{ +key: "--cd, -C", +type: "path", +description: +"Set the working directory for the agent before it starts processing your request.", +}, +{ +key: "--search", +type: "boolean", +defaultValue: "false", +description: +'Enable live web search (sets `web_search = "live"` instead of the default `"cached"`).', +}, +{ +key: "--add-dir", +type: "path", +description: +"Grant additional directories write access alongside the main workspace. Repeat for multiple paths.", +}, +{ +key: "--no-alt-screen", +type: "boolean", +defaultValue: "false", +description: +"Disable alternate screen mode for the TUI (overrides `tui.alternate_screen` for this run).", +}, +{ +key: "--enable", +type: "feature", +description: +"Force-enable a feature flag (translates to `-c features.=true`). Repeatable.", +}, +{ +key: "--disable", +type: "feature", +description: +"Force-disable a feature flag (translates to `-c features.=false`). Repeatable.", +}, +{ +key: "--config, -c", +type: "key=value", +description: +"Override configuration values. Values parse as JSON if possible; otherwise the literal string is used.", +}, ]; export const commandOverview = [ - { - key: "codex", - href: "/codex/cli/reference#codex-interactive", - type: "stable", - description: - "Launch the terminal UI. Accepts the global flags above plus an optional prompt or image attachments.", - }, - { - key: "codex app-server", - href: "/codex/cli/reference#codex-app-server", - type: "experimental", - description: - "Launch the Codex app server for local development or debugging.", - }, - { - key: "codex app", - href: "/codex/cli/reference#codex-app", - type: "stable", - description: - "Launch the Codex desktop app on macOS, optionally opening a specific workspace path.", - }, - { - key: "codex debug app-server send-message-v2", - href: "/codex/cli/reference#codex-debug-app-server-send-message-v2", - type: "experimental", - description: - "Debug app-server by sending a single V2 message through the built-in test client.", - }, - { - key: "codex apply", - href: "/codex/cli/reference#codex-apply", - type: "stable", - description: - "Apply the latest diff generated by a Codex Cloud task to your local working tree. Alias: `codex a`.", - }, - { - key: "codex cloud", - href: "/codex/cli/reference#codex-cloud", - type: "experimental", - description: - "Browse or execute Codex Cloud tasks from the terminal without opening the TUI. Alias: `codex cloud-tasks`.", - }, - { - key: "codex completion", - href: "/codex/cli/reference#codex-completion", - type: "stable", - description: - "Generate shell completion scripts for Bash, Zsh, Fish, or PowerShell.", - }, - { - key: "codex features", - href: "/codex/cli/reference#codex-features", - type: "stable", - description: - "List feature flags and persistently enable or disable them in `config.toml`.", - }, - { - key: "codex exec", - href: "/codex/cli/reference#codex-exec", - type: "stable", - description: - "Run Codex non-interactively. Alias: `codex e`. Stream results to stdout or JSONL and optionally resume previous sessions.", - }, - { - key: "codex execpolicy", - href: "/codex/cli/reference#codex-execpolicy", - type: "experimental", - description: - "Evaluate execpolicy rule files and see whether a command would be allowed, prompted, or blocked.", - }, - { - key: "codex login", - href: "/codex/cli/reference#codex-login", - type: "stable", - description: - "Authenticate Codex using ChatGPT OAuth, device auth, or an API key piped over stdin.", - }, - { - key: "codex logout", - href: "/codex/cli/reference#codex-logout", - type: "stable", - description: "Remove stored authentication credentials.", - }, - { - key: "codex mcp", - href: "/codex/cli/reference#codex-mcp", - type: "experimental", - description: - "Manage Model Context Protocol servers (list, add, remove, authenticate).", - }, - { - key: "codex mcp-server", - href: "/codex/cli/reference#codex-mcp-server", - type: "experimental", - description: - "Run Codex itself as an MCP server over stdio. Useful when another agent consumes Codex.", - }, - { - key: "codex resume", - href: "/codex/cli/reference#codex-resume", - type: "stable", - description: - "Continue a previous interactive session by ID or resume the most recent conversation.", - }, - { - key: "codex fork", - href: "/codex/cli/reference#codex-fork", - type: "stable", - description: - "Fork a previous interactive session into a new thread, preserving the original transcript.", - }, - { - key: "codex sandbox", - href: "/codex/cli/reference#codex-sandbox", - type: "experimental", - description: - "Run arbitrary commands inside Codex-provided macOS seatbelt or Linux sandboxes (Landlock by default, optional bubblewrap pipeline).", - }, +{ +key: "codex", +href: "/codex/cli/reference#codex-interactive", +type: "stable", +description: +"Launch the terminal UI. Accepts the global flags above plus an optional prompt or image attachments.", +}, +{ +key: "codex app-server", +href: "/codex/cli/reference#codex-app-server", +type: "experimental", +description: +"Launch the Codex app server for local development or debugging.", +}, +{ +key: "codex app", +href: "/codex/cli/reference#codex-app", +type: "stable", +description: +"Launch the Codex desktop app on macOS, optionally opening a specific workspace path.", +}, +{ +key: "codex debug app-server send-message-v2", +href: "/codex/cli/reference#codex-debug-app-server-send-message-v2", +type: "experimental", +description: +"Debug app-server by sending a single V2 message through the built-in test client.", +}, +{ +key: "codex apply", +href: "/codex/cli/reference#codex-apply", +type: "stable", +description: +"Apply the latest diff generated by a Codex Cloud task to your local working tree. Alias: `codex a`.", +}, +{ +key: "codex cloud", +href: "/codex/cli/reference#codex-cloud", +type: "experimental", +description: +"Browse or execute Codex Cloud tasks from the terminal without opening the TUI. Alias: `codex cloud-tasks`.", +}, +{ +key: "codex completion", +href: "/codex/cli/reference#codex-completion", +type: "stable", +description: +"Generate shell completion scripts for Bash, Zsh, Fish, or PowerShell.", +}, +{ +key: "codex features", +href: "/codex/cli/reference#codex-features", +type: "stable", +description: +"List feature flags and persistently enable or disable them in `config.toml`.", +}, +{ +key: "codex exec", +href: "/codex/cli/reference#codex-exec", +type: "stable", +description: +"Run Codex non-interactively. Alias: `codex e`. Stream results to stdout or JSONL and optionally resume previous sessions.", +}, +{ +key: "codex execpolicy", +href: "/codex/cli/reference#codex-execpolicy", +type: "experimental", +description: +"Evaluate execpolicy rule files and see whether a command would be allowed, prompted, or blocked.", +}, +{ +key: "codex login", +href: "/codex/cli/reference#codex-login", +type: "stable", +description: +"Authenticate Codex using ChatGPT OAuth, device auth, or an API key piped over stdin.", +}, +{ +key: "codex logout", +href: "/codex/cli/reference#codex-logout", +type: "stable", +description: "Remove stored authentication credentials.", +}, +{ +key: "codex mcp", +href: "/codex/cli/reference#codex-mcp", +type: "experimental", +description: +"Manage Model Context Protocol servers (list, add, remove, authenticate).", +}, +{ +key: "codex mcp-server", +href: "/codex/cli/reference#codex-mcp-server", +type: "experimental", +description: +"Run Codex itself as an MCP server over stdio. Useful when another agent consumes Codex.", +}, +{ +key: "codex resume", +href: "/codex/cli/reference#codex-resume", +type: "stable", +description: +"Continue a previous interactive session by ID or resume the most recent conversation.", +}, +{ +key: "codex fork", +href: "/codex/cli/reference#codex-fork", +type: "stable", +description: +"Fork a previous interactive session into a new thread, preserving the original transcript.", +}, +{ +key: "codex sandbox", +href: "/codex/cli/reference#codex-sandbox", +type: "experimental", +description: +"Run arbitrary commands inside Codex-provided macOS seatbelt or Linux sandboxes (Landlock by default, optional bubblewrap pipeline).", +}, ]; export const execOptions = [ - { - key: "PROMPT", - type: "string | - (read stdin)", - description: - "Initial instruction for the task. Use `-` to pipe the prompt from stdin.", - }, - { - key: "--image, -i", - type: "path[,path...]", - description: - "Attach images to the first message. Repeatable; supports comma-separated lists.", - }, - { - key: "--model, -m", - type: "string", - description: "Override the configured model for this run.", - }, - { - key: "--oss", - type: "boolean", - defaultValue: "false", - description: - "Use the local open source provider (requires a running Ollama instance).", - }, - { - key: "--sandbox, -s", - type: "read-only | workspace-write | danger-full-access", - description: - "Sandbox policy for model-generated commands. Defaults to configuration.", - }, - { - key: "--profile, -p", - type: "string", - description: "Select a configuration profile defined in config.toml.", - }, - { - key: "--full-auto", - type: "boolean", - defaultValue: "false", - description: - "Apply the low-friction automation preset (`workspace-write` sandbox and `on-request` approvals).", - }, - { - key: "--dangerously-bypass-approvals-and-sandbox, --yolo", - type: "boolean", - defaultValue: "false", - description: - "Bypass approval prompts and sandboxing. Dangerous—only use inside an isolated runner.", - }, - { - key: "--cd, -C", - type: "path", - description: "Set the workspace root before executing the task.", - }, - { - key: "--skip-git-repo-check", - type: "boolean", - defaultValue: "false", - description: - "Allow running outside a Git repository (useful for one-off directories).", - }, - { - key: "--ephemeral", - type: "boolean", - defaultValue: "false", - description: "Run without persisting session rollout files to disk.", - }, - { - key: "--output-schema", - type: "path", - description: - "JSON Schema file describing the expected final response shape. Codex validates tool output against it.", - }, - { - key: "--color", - type: "always | never | auto", - defaultValue: "auto", - description: "Control ANSI color in stdout.", - }, - { - key: "--json, --experimental-json", - type: "boolean", - defaultValue: "false", - description: - "Print newline-delimited JSON events instead of formatted text.", - }, - { - key: "--output-last-message, -o", - type: "path", - description: - "Write the assistant’s final message to a file. Useful for downstream scripting.", - }, - { - key: "Resume subcommand", - type: "codex exec resume [SESSION_ID]", - description: - "Resume an exec session by ID or add `--last` to continue the most recent session from the current working directory. Add `--all` to consider sessions from any directory. Accepts an optional follow-up prompt.", - }, - { - key: "-c, --config", - type: "key=value", - description: - "Inline configuration override for the non-interactive run (repeatable).", - }, +{ +key: "PROMPT", +type: "string | - (read stdin)", +description: +"Initial instruction for the task. Use `-` to pipe the prompt from stdin.", +}, +{ +key: "--image, -i", +type: "path[,path...]", +description: +"Attach images to the first message. Repeatable; supports comma-separated lists.", +}, +{ +key: "--model, -m", +type: "string", +description: "Override the configured model for this run.", +}, +{ +key: "--oss", +type: "boolean", +defaultValue: "false", +description: +"Use the local open source provider (requires a running Ollama instance).", +}, +{ +key: "--sandbox, -s", +type: "read-only | workspace-write | danger-full-access", +description: +"Sandbox policy for model-generated commands. Defaults to configuration.", +}, +{ +key: "--profile, -p", +type: "string", +description: "Select a configuration profile defined in config.toml.", +}, +{ +key: "--full-auto", +type: "boolean", +defaultValue: "false", +description: +"Apply the low-friction automation preset (`workspace-write` sandbox and `on-request` approvals).", +}, +{ +key: "--dangerously-bypass-approvals-and-sandbox, --yolo", +type: "boolean", +defaultValue: "false", +description: +"Bypass approval prompts and sandboxing. Dangerous—only use inside an isolated runner.", +}, +{ +key: "--cd, -C", +type: "path", +description: "Set the workspace root before executing the task.", +}, +{ +key: "--skip-git-repo-check", +type: "boolean", +defaultValue: "false", +description: +"Allow running outside a Git repository (useful for one-off directories).", +}, +{ +key: "--ephemeral", +type: "boolean", +defaultValue: "false", +description: "Run without persisting session rollout files to disk.", +}, +{ +key: "--output-schema", +type: "path", +description: +"JSON Schema file describing the expected final response shape. Codex validates tool output against it.", +}, +{ +key: "--color", +type: "always | never | auto", +defaultValue: "auto", +description: "Control ANSI color in stdout.", +}, +{ +key: "--json, --experimental-json", +type: "boolean", +defaultValue: "false", +description: +"Print newline-delimited JSON events instead of formatted text.", +}, +{ +key: "--output-last-message, -o", +type: "path", +description: +"Write the assistant’s final message to a file. Useful for downstream scripting.", +}, +{ +key: "Resume subcommand", +type: "codex exec resume [SESSION_ID]", +description: +"Resume an exec session by ID or add `--last` to continue the most recent session from the current working directory. Add `--all` to consider sessions from any directory. Accepts an optional follow-up prompt.", +}, +{ +key: "-c, --config", +type: "key=value", +description: +"Inline configuration override for the non-interactive run (repeatable).", +}, ]; export const appServerOptions = [ - { - key: "--listen", - type: "stdio:// | ws://IP:PORT", - defaultValue: "stdio://", - description: - "Transport listener URL. `ws://` is experimental and intended for development/testing.", - }, +{ +key: "--listen", +type: "stdio:// | ws://IP:PORT", +defaultValue: "stdio://", +description: +"Transport listener URL. `ws://` is experimental and intended for development/testing.", +}, ]; export const appOptions = [ - { - key: "PATH", - type: "path", - defaultValue: ".", - description: - "Workspace path to open in Codex Desktop (`codex app` is available on macOS only).", - }, - { - key: "--download-url", - type: "url", - description: - "Advanced override for the Codex desktop DMG download URL used during install.", - }, +{ +key: "PATH", +type: "path", +defaultValue: ".", +description: +"Workspace path to open in Codex Desktop (`codex app` is available on macOS only).", +}, +{ +key: "--download-url", +type: "url", +description: +"Advanced override for the Codex desktop DMG download URL used during install.", +}, ]; export const debugAppServerSendMessageV2Options = [ - { - key: "USER_MESSAGE", - type: "string", - description: - "Message text sent to app-server through the built-in V2 test-client flow.", - }, +{ +key: "USER_MESSAGE", +type: "string", +description: +"Message text sent to app-server through the built-in V2 test-client flow.", +}, ]; export const resumeOptions = [ - { - key: "SESSION_ID", - type: "uuid", - description: - "Resume the specified session. Omit and use `--last` to continue the most recent session.", - }, - { - key: "--last", - type: "boolean", - defaultValue: "false", - description: - "Skip the picker and resume the most recent conversation from the current working directory.", - }, - { - key: "--all", - type: "boolean", - defaultValue: "false", - description: - "Include sessions outside the current working directory when selecting the most recent session.", - }, +{ +key: "SESSION_ID", +type: "uuid", +description: +"Resume the specified session. Omit and use `--last` to continue the most recent session.", +}, +{ +key: "--last", +type: "boolean", +defaultValue: "false", +description: +"Skip the picker and resume the most recent conversation from the current working directory.", +}, +{ +key: "--all", +type: "boolean", +defaultValue: "false", +description: +"Include sessions outside the current working directory when selecting the most recent session.", +}, ]; export const featuresOptions = [ - { - key: "List subcommand", - type: "codex features list", - description: - "Show known feature flags, their maturity stage, and their effective state.", - }, - { - key: "Enable subcommand", - type: "codex features enable ", - description: - "Persistently enable a feature flag in `config.toml`. Respects the active `--profile` when provided.", - }, - { - key: "Disable subcommand", - type: "codex features disable ", - description: - "Persistently disable a feature flag in `config.toml`. Respects the active `--profile` when provided.", - }, +{ +key: "List subcommand", +type: "codex features list", +description: +"Show known feature flags, their maturity stage, and their effective state.", +}, +{ +key: "Enable subcommand", +type: "codex features enable ", +description: +"Persistently enable a feature flag in `config.toml`. Respects the active `--profile` when provided.", +}, +{ +key: "Disable subcommand", +type: "codex features disable ", +description: +"Persistently disable a feature flag in `config.toml`. Respects the active `--profile` when provided.", +}, ]; export const execResumeOptions = [ - { - key: "SESSION_ID", - type: "uuid", - description: - "Resume the specified session. Omit and use `--last` to continue the most recent session.", - }, - { - key: "--last", - type: "boolean", - defaultValue: "false", - description: - "Resume the most recent conversation from the current working directory.", - }, - { - key: "--all", - type: "boolean", - defaultValue: "false", - description: - "Include sessions outside the current working directory when selecting the most recent session.", - }, - { - key: "--image, -i", - type: "path[,path...]", - description: - "Attach one or more images to the follow-up prompt. Separate multiple paths with commas or repeat the flag.", - }, - { - key: "PROMPT", - type: "string | - (read stdin)", - description: - "Optional follow-up instruction sent immediately after resuming.", - }, +{ +key: "SESSION_ID", +type: "uuid", +description: +"Resume the specified session. Omit and use `--last` to continue the most recent session.", +}, +{ +key: "--last", +type: "boolean", +defaultValue: "false", +description: +"Resume the most recent conversation from the current working directory.", +}, +{ +key: "--all", +type: "boolean", +defaultValue: "false", +description: +"Include sessions outside the current working directory when selecting the most recent session.", +}, +{ +key: "--image, -i", +type: "path[,path...]", +description: +"Attach one or more images to the follow-up prompt. Separate multiple paths with commas or repeat the flag.", +}, +{ +key: "PROMPT", +type: "string | - (read stdin)", +description: +"Optional follow-up instruction sent immediately after resuming.", +}, ]; export const forkOptions = [ - { - key: "SESSION_ID", - type: "uuid", - description: - "Fork the specified session. Omit and use `--last` to fork the most recent session.", - }, - { - key: "--last", - type: "boolean", - defaultValue: "false", - description: - "Skip the picker and fork the most recent conversation automatically.", - }, - { - key: "--all", - type: "boolean", - defaultValue: "false", - description: - "Show sessions beyond the current working directory in the picker.", - }, +{ +key: "SESSION_ID", +type: "uuid", +description: +"Fork the specified session. Omit and use `--last` to fork the most recent session.", +}, +{ +key: "--last", +type: "boolean", +defaultValue: "false", +description: +"Skip the picker and fork the most recent conversation automatically.", +}, +{ +key: "--all", +type: "boolean", +defaultValue: "false", +description: +"Show sessions beyond the current working directory in the picker.", +}, ]; export const execpolicyOptions = [ - { - key: "--rules, -r", - type: "path (repeatable)", - description: - "Path to an execpolicy rule file to evaluate. Provide multiple flags to combine rules across files.", - }, - { - key: "--pretty", - type: "boolean", - defaultValue: "false", - description: "Pretty-print the JSON result.", - }, - { - key: "COMMAND...", - type: "var-args", - description: "Command to be checked against the specified policies.", - }, +{ +key: "--rules, -r", +type: "path (repeatable)", +description: +"Path to an execpolicy rule file to evaluate. Provide multiple flags to combine rules across files.", +}, +{ +key: "--pretty", +type: "boolean", +defaultValue: "false", +description: "Pretty-print the JSON result.", +}, +{ +key: "COMMAND...", +type: "var-args", +description: "Command to be checked against the specified policies.", +}, ]; export const loginOptions = [ - { - key: "--with-api-key", - type: "boolean", - description: - "Read an API key from stdin (for example `printenv OPENAI_API_KEY | codex login --with-api-key`).", - }, - { - key: "--device-auth", - type: "boolean", - description: - "Use OAuth device code flow instead of launching a browser window.", - }, - { - key: "status subcommand", - type: "codex login status", - description: - "Print the active authentication mode and exit with 0 when logged in.", - }, +{ +key: "--with-api-key", +type: "boolean", +description: +"Read an API key from stdin (for example `printenv OPENAI_API_KEY | codex login --with-api-key`).", +}, +{ +key: "--device-auth", +type: "boolean", +description: +"Use OAuth device code flow instead of launching a browser window.", +}, +{ +key: "status subcommand", +type: "codex login status", +description: +"Print the active authentication mode and exit with 0 when logged in.", +}, ]; export const applyOptions = [ - { - key: "TASK_ID", - type: "string", - description: - "Identifier of the Codex Cloud task whose diff should be applied.", - }, +{ +key: "TASK_ID", +type: "string", +description: +"Identifier of the Codex Cloud task whose diff should be applied.", +}, ]; export const sandboxMacOptions = [ - { - key: "--full-auto", - type: "boolean", - defaultValue: "false", - description: - "Grant write access to the current workspace and `/tmp` without approvals.", - }, - { - key: "--config, -c", - type: "key=value", - description: - "Pass configuration overrides into the sandboxed run (repeatable).", - }, - { - key: "COMMAND...", - type: "var-args", - description: - "Shell command to execute under macOS Seatbelt. Everything after `--` is forwarded.", - }, +{ +key: "--full-auto", +type: "boolean", +defaultValue: "false", +description: +"Grant write access to the current workspace and `/tmp` without approvals.", +}, +{ +key: "--config, -c", +type: "key=value", +description: +"Pass configuration overrides into the sandboxed run (repeatable).", +}, +{ +key: "COMMAND...", +type: "var-args", +description: +"Shell command to execute under macOS Seatbelt. Everything after `--` is forwarded.", +}, ]; export const sandboxLinuxOptions = [ - { - key: "--full-auto", - type: "boolean", - defaultValue: "false", - description: - "Grant write access to the current workspace and `/tmp` inside the Landlock sandbox.", - }, - { - key: "--config, -c", - type: "key=value", - description: - "Configuration overrides applied before launching the sandbox (repeatable).", - }, - { - key: "COMMAND...", - type: "var-args", - description: - "Command to execute under Landlock + seccomp. Provide the executable after `--`.", - }, +{ +key: "--full-auto", +type: "boolean", +defaultValue: "false", +description: +"Grant write access to the current workspace and `/tmp` inside the Landlock sandbox.", +}, +{ +key: "--config, -c", +type: "key=value", +description: +"Configuration overrides applied before launching the sandbox (repeatable).", +}, +{ +key: "COMMAND...", +type: "var-args", +description: +"Command to execute under Landlock + seccomp. Provide the executable after `--`.", +}, ]; export const completionOptions = [ - { - key: "SHELL", - type: "bash | zsh | fish | power-shell | elvish", - defaultValue: "bash", - description: "Shell to generate completions for. Output prints to stdout.", - }, +{ +key: "SHELL", +type: "bash | zsh | fish | power-shell | elvish", +defaultValue: "bash", +description: "Shell to generate completions for. Output prints to stdout.", +}, ]; export const cloudExecOptions = [ - { - key: "QUERY", - type: "string", - description: - "Task prompt. If omitted, Codex prompts interactively for details.", - }, - { - key: "--env", - type: "ENV_ID", - description: - "Target Codex Cloud environment identifier (required). Use `codex cloud` to list options.", - }, - { - key: "--attempts", - type: "1-4", - defaultValue: "1", - description: - "Number of assistant attempts (best-of-N) Codex Cloud should run.", - }, +{ +key: "QUERY", +type: "string", +description: +"Task prompt. If omitted, Codex prompts interactively for details.", +}, +{ +key: "--env", +type: "ENV_ID", +description: +"Target Codex Cloud environment identifier (required). Use `codex cloud` to list options.", +}, +{ +key: "--attempts", +type: "1-4", +defaultValue: "1", +description: +"Number of assistant attempts (best-of-N) Codex Cloud should run.", +}, ]; export const cloudListOptions = [ - { - key: "--env", - type: "ENV_ID", - description: "Filter tasks by environment identifier.", - }, - { - key: "--limit", - type: "1-20", - defaultValue: "20", - description: "Maximum number of tasks to return.", - }, - { - key: "--cursor", - type: "string", - description: "Pagination cursor returned by a previous request.", - }, - { - key: "--json", - type: "boolean", - defaultValue: "false", - description: "Emit machine-readable JSON instead of plain text.", - }, +{ +key: "--env", +type: "ENV_ID", +description: "Filter tasks by environment identifier.", +}, +{ +key: "--limit", +type: "1-20", +defaultValue: "20", +description: "Maximum number of tasks to return.", +}, +{ +key: "--cursor", +type: "string", +description: "Pagination cursor returned by a previous request.", +}, +{ +key: "--json", +type: "boolean", +defaultValue: "false", +description: "Emit machine-readable JSON instead of plain text.", +}, ]; export const mcpCommands = [ - { - key: "list", - type: "--json", - description: - "List configured MCP servers. Add `--json` for machine-readable output.", - }, - { - key: "get ", - type: "--json", - description: - "Show a specific server configuration. `--json` prints the raw config entry.", - }, - { - key: "add ", - type: "-- | --url ", - description: - "Register a server using a stdio launcher command or a streamable HTTP URL. Supports `--env KEY=VALUE` for stdio transports.", - }, - { - key: "remove ", - description: "Delete a stored MCP server definition.", - }, - { - key: "login ", - type: "--scopes scope1,scope2", - description: - "Start an OAuth login for a streamable HTTP server (servers that support OAuth only).", - }, - { - key: "logout ", - description: - "Remove stored OAuth credentials for a streamable HTTP server.", - }, +{ +key: "list", +type: "--json", +description: +"List configured MCP servers. Add `--json` for machine-readable output.", +}, +{ +key: "get ", +type: "--json", +description: +"Show a specific server configuration. `--json` prints the raw config entry.", +}, +{ +key: "add ", +type: "-- | --url ", +description: +"Register a server using a stdio launcher command or a streamable HTTP URL. Supports `--env KEY=VALUE` for stdio transports.", +}, +{ +key: "remove ", +description: "Delete a stored MCP server definition.", +}, +{ +key: "login ", +type: "--scopes scope1,scope2", +description: +"Start an OAuth login for a streamable HTTP server (servers that support OAuth only).", +}, +{ +key: "logout ", +description: +"Remove stored OAuth credentials for a streamable HTTP server.", +}, ]; export const mcpAddOptions = [ - { - key: "COMMAND...", - type: "stdio transport", - description: - "Executable plus arguments to launch the MCP server. Provide after `--`.", - }, - { - key: "--env KEY=VALUE", - type: "repeatable", - description: - "Environment variable assignments applied when launching a stdio server.", - }, - { - key: "--url", - type: "https://…", - description: - "Register a streamable HTTP server instead of stdio. Mutually exclusive with `COMMAND...`.", - }, - { - key: "--bearer-token-env-var", - type: "ENV_VAR", - description: - "Environment variable whose value is sent as a bearer token when connecting to a streamable HTTP server.", - }, +{ +key: "COMMAND...", +type: "stdio transport", +description: +"Executable plus arguments to launch the MCP server. Provide after `--`.", +}, +{ +key: "--env KEY=VALUE", +type: "repeatable", +description: +"Environment variable assignments applied when launching a stdio server.", +}, +{ +key: "--url", +type: "https://…", +description: +"Register a streamable HTTP server instead of stdio. Mutually exclusive with `COMMAND...`.", +}, +{ +key: "--bearer-token-env-var", +type: "ENV_VAR", +description: +"Environment variable whose value is sent as a bearer token when connecting to a streamable HTTP server.", +}, ]; ## How to read this reference @@ -687,9 +687,9 @@ export const mcpAddOptions = [ This page catalogs every documented Codex CLI command and flag. Use the interactive tables to search by key or description. Each section indicates whether the option is stable or experimental and calls out risky combinations. The CLI inherits most defaults from ~/.codex/config.toml. Any - -c key=value overrides you pass at the command line take - precedence for that invocation. See [Config - basics](https://developers.openai.com/codex/config-basic#configuration-precedence) for more information. +-c key=value overrides you pass at the command line take +precedence for that invocation. See [Config +basics](https://developers.openai.com/codex/config-basic#configuration-precedence) for more information. ## Global flags @@ -701,8 +701,8 @@ When you run a subcommand, place global flags after it (for example, `codex exec ## Command overview The Maturity column uses feature maturity labels such as Experimental, Beta, - and Stable. See [Feature Maturity](https://developers.openai.com/codex/feature-maturity) for how to - interpret these labels. +and Stable. See [Feature Maturity](https://developers.openai.com/codex/feature-maturity) for how to +interpret these labels. Generated by `node scripts/generate-docs-reference.mjs` from `orcastrator@0.2.25`. Do not edit this file by hand. + +## Install + +```shell +npm install -g orcastrator +``` + +## Command Reference + +This section is generated from the Commander command tree used by the built Orca CLI. + +### orca + +```text +Usage: orca [options] [command] + +Orca CLI: coordinated agent run harness + +Options: + -V, --version output the version number + -h, --help display help for command + +Commands: + run [options] [goal] Run pre-planning and execution + answer [options] [run-id] [answer] Submit an answer for a run waiting for + input + plan [options] Run pre-planning and output validated task + graph + status [options] Show run status or list all runs + list [options] List all runs in run store + skills [options] List available skills + resume [options] Resume an incomplete run + cancel [options] Cancel an active run + pr Pull request workflow commands + pr-finalize [options] Finalize a prepared pull request + setup [options] Run first-time setup and environment + checks + help [command] display help for command +``` + +### orca run + +```text +Usage: orca run [options] [goal] + +Run pre-planning and execution + +Options: + --spec Path to spec markdown file + --plan Alias for --spec — path to a plan/spec file + --task Inline task text (alternative to --spec) + -p, --prompt Inline task text (alias for --task) + --config Path to orca config file + --codex-only Force Codex executor for this run (overrides config) + --codex-effort Codex thinking level override for this run + --on-milestone Shell hook command for onMilestone + --on-question Shell hook command for onQuestion + --on-task-complete Shell hook command for onTaskComplete + --on-task-fail Shell hook command for onTaskFail + --on-invalid-plan Shell hook command for onInvalidPlan + --on-findings Shell hook command for onFindings + --on-complete Shell hook command for onComplete + --on-error Shell hook command for onError + -h, --help display help for command +``` + +### orca plan + +```text +Usage: orca plan [options] + +Run pre-planning and output validated task graph + +Options: + --spec Path to spec markdown file + --config Path to orca config file + --on-milestone Shell hook command for onMilestone + --on-error Shell hook command for onError + -h, --help display help for command +``` + +### orca status + +```text +Usage: orca status [options] + +Show run status or list all runs + +Options: + --run Run ID to inspect + --last Use the most recent run + --config Path to orca config file + -h, --help display help for command +``` + +### orca list + +```text +Usage: orca list [options] + +List all runs in run store + +Options: + --config Path to orca config file + -h, --help display help for command +``` + +### orca skills + +```text +Usage: orca skills [options] + +List available skills + +Options: + --config Path to orca config file + -h, --help display help for command +``` + +### orca resume + +```text +Usage: orca resume [options] + +Resume an incomplete run + +Options: + --run Run ID to resume + --last Use the most recent run + --config Path to orca config file + --codex-only Force Codex executor for this resumed run (overrides + config) + --codex-effort Codex thinking level override for this resumed run + -h, --help display help for command +``` + +### orca cancel + +```text +Usage: orca cancel [options] + +Cancel an active run + +Options: + --run Run ID to cancel + --last Use the most recent run + --config Path to orca config file + -h, --help display help for command +``` + +### orca answer + +```text +Usage: orca answer [options] [run-id] [answer] + +Submit an answer for a run waiting for input + +Options: + --run Run ID waiting for answer + -h, --help display help for command +``` + +### orca pr + +```text +Usage: orca pr [options] [command] + +Pull request workflow commands + +Options: + -h, --help display help for command + +Commands: + draft [options] Create a draft pull request for a run + create [options] Create a pull request for a run + publish [options] Publish a draft PR (mark ready for review) + status [options] Show pull request status for a run + finalize [options] Deprecated: use `orca pr publish` +``` + +### orca pr draft + +```text +Usage: orca pr draft [options] + +Create a draft pull request for a run + +Options: + --run Run ID to create draft PR for + --last Use the most recent run + --config Path to orca config file + -h, --help display help for command +``` + +### orca pr create + +```text +Usage: orca pr create [options] + +Create a pull request for a run + +Options: + --run Run ID to create PR for + --last Use the most recent run + --config Path to orca config file + -h, --help display help for command +``` + +### orca pr publish + +```text +Usage: orca pr publish [options] + +Publish a draft PR (mark ready for review) + +Options: + --run Run ID to publish PR for + --last Use the most recent run + --config Path to orca config file + -h, --help display help for command +``` + +### orca pr status + +```text +Usage: orca pr status [options] + +Show pull request status for a run + +Options: + --run Run ID to inspect PR for + --last Use the most recent run + --config Path to orca config file + -h, --help display help for command +``` + +### orca setup + +```text +Usage: orca setup [options] + +Run first-time setup and environment checks + +Options: + --openai-key Provide OpenAI API key directly + --global Save config to ~/.orca/config.js (or ~/.orca/config.ts + with --ts) + --project Save config to ./orca.config.js (or ./orca.config.ts with + --ts) + --ts Write TypeScript config (.ts) with OrcaConfig satisfies + syntax + --executor Set executor in written config (codex) + -h, --help display help for command +``` + +### orca help + +```text +Usage: orca help [options] [command] + +display help for command + +Options: + -h, --help display help for command +``` + +### orca pr-finalize + +```text +Usage: orca pr-finalize [options] + +Finalize a prepared pull request + +Options: + --run Run ID to finalize PR for + --config Path to orca config file + -h, --help display help for command +``` + +## Config Schema And Public Type Reference + +Codex effort values: `low|medium|high|xhigh`. + +This section is generated from the Zod-backed config schema and public types in `src/types/index.ts`, which are re-exported by the package entry point. + +```ts +export type PlannerAgent = "codex" | "claude"; +export type PlannerAgentSelection = PlannerAgent | "auto"; +export type ClaudeEffort = CodexEffort | "max"; + +declare const CUSTOM_MODEL_ID: unique symbol; +export type CustomModelId = string & { readonly [CUSTOM_MODEL_ID]: true }; + +export type OpenAIModelId = + | "gpt-5.5" + | "gpt-5.2" + | "gpt-5.2-pro" + | "gpt-5.2-codex" + | "gpt-5.1" + | "gpt-5.1-codex" + | "gpt-5.1-codex-max" + | "gpt-5.1-codex-mini" + | "gpt-5" + | "gpt-5-codex" + | "gpt-5.3-codex" + | "gpt-5.3-codex-spark" + | CustomModelId; + +export type ClaudeModelId = + | "claude-opus-4-7" + | "claude-sonnet-4-6" + | "claude-opus-4-1" + | "claude-opus-4-1-20250805" + | "claude-opus-4-0" + | "claude-opus-4-20250514" + | "claude-sonnet-4-0" + | "claude-sonnet-4-20250514" + | "claude-3-7-sonnet-latest" + | "claude-3-7-sonnet-20250219" + | "claude-3-5-sonnet-latest" + | "claude-3-5-sonnet-20241022" + | "claude-3-5-haiku-latest" + | "claude-3-5-haiku-20241022" + | "opus" + | "sonnet" + | "haiku" + | "opusplan" + | "default" + | CustomModelId; + +export interface PlannerRoutingDecision { + planner: PlannerAgent; + reason: string; +} + +export interface PlannerRouterConfig { + /** + * Codex model used only for planner.agent="auto" routing. + */ + model?: OpenAIModelId; +} + +export type PlannerConfig = + | { + /** + * Ask a Codex router to choose Claude or Codex for task graph generation. + * This is also the default when planner is omitted. + */ + agent?: "auto"; + router?: PlannerRouterConfig; + } + | { + /** + * Always use Claude for non-skipped task graph generation. + */ + agent: "claude"; + router?: never; + } + | { + /** + * Always use Codex for non-skipped task graph generation. + */ + agent: "codex"; + router?: never; + }; + +export interface OrcaConfig { + openaiApiKey?: string; + runsDir?: string; + sessionLogs?: string; + skills?: string[]; + maxRetries?: number; + executor?: "codex"; + planner?: PlannerConfig; + claude?: { + command?: string; + model?: ClaudeModelId; + effort?: ClaudeEffort; + timeoutMs?: number; + }; + codex?: { + enabled?: boolean; + model?: OpenAIModelId; + effort?: CodexEffort; + thinkingLevel?: { + decision?: CodexEffort; + planning?: CodexEffort; + review?: CodexEffort; + execution?: CodexEffort; + }; + command?: string; + timeoutMs?: number; + multiAgent?: boolean; + /** + * Optional extra skill roots to use for specific working directories when + * querying Codex app-server skills/list. + */ + perCwdExtraUserRoots?: Array<{ + cwd: string; + extraUserRoots: string[]; + }>; + }; + hooks?: { [K in HookName]?: HookHandler }; + hookCommands?: Partial>; + pr?: { + enabled?: boolean; + requireConfirmation?: boolean; + }; + review?: { + /** @deprecated Use review.plan.enabled */ + enabled?: boolean; + /** @deprecated Use review.plan.onInvalid */ + onInvalid?: "fail" | "warn_skip"; + plan?: { + enabled?: boolean; + onInvalid?: "fail" | "warn_skip"; + }; + execution?: { + enabled?: boolean; + maxCycles?: number; + onFindings?: "auto_fix" | "report_only" | "fail"; + validator?: { + auto?: boolean; + commands?: string[]; + }; + prompt?: string; + }; + }; +} + +const scalarStringSchema = z.custom((value) => typeof value === "string", { + message: "must be a string", +}); +const scalarBooleanSchema = z.custom((value) => typeof value === "boolean", { + message: "must be a boolean", +}); +const positiveIntegerSchema = z.custom( + (value) => typeof value === "number" && Number.isInteger(value) && value >= 1, + { message: "must be an integer >= 1" }, +); +const nonnegativeIntegerSchema = z.custom( + (value) => typeof value === "number" && Number.isInteger(value) && value >= 0, + { message: "must be a nonnegative integer" }, +); + +const openAIModelSchema = scalarStringSchema as z.ZodType; +const claudeModelSchema = scalarStringSchema as z.ZodType; + +const codexEffortSchema = z + .custom((value) => typeof value === "string", { + message: "must be a string", + }) + .transform((value, context): CodexEffort => { + try { + return parseCodexEffort(value); + } catch (error) { + context.addIssue({ + code: "custom", + message: error instanceof Error ? error.message : String(error), + }); + + return z.NEVER; + } + }); + +const claudeEffortSchema = z.custom( + (value) => typeof value === "string" && ([...CODEX_EFFORT_VALUES, "max"] as string[]).includes(value), + { message: "must be one of low, medium, high, xhigh, max" }, +); + +const hookNameValues = [ + "onMilestone", + "onQuestion", + "onTaskComplete", + "onTaskFail", + "onInvalidPlan", + "onFindings", + "onComplete", + "onError", +] as const satisfies readonly HookName[]; + +const hookNameSet = new Set(hookNameValues); + +function addUnknownHookIssue(context: z.RefinementCtx, configPath: "hooks" | "hookCommands", hookName: string) { + context.addIssue({ + code: "custom", + path: [hookName], + message: `Unknown hook key in Config.${configPath}: ${hookName}. Allowed hooks: ${hookNameValues.join(", ")}`, + }); +} + +const hookHandlerSchema = z.custom((value) => typeof value === "function", { + message: "must be a function", +}); + +const hooksSchema = z.record(z.string(), hookHandlerSchema).superRefine((hooks, context) => { + for (const hookName of Object.keys(hooks)) { + if (!hookNameSet.has(hookName)) { + addUnknownHookIssue(context, "hooks", hookName); + } + } +}) as z.ZodType; + +const hookCommandsSchema = z.record(z.string(), scalarStringSchema).superRefine((hookCommands, context) => { + for (const hookName of Object.keys(hookCommands)) { + if (!hookNameSet.has(hookName)) { + addUnknownHookIssue(context, "hookCommands", hookName); + } + } +}) as z.ZodType; + +export const PlannerRouterConfigSchema = z + .object({ + model: openAIModelSchema.optional(), + }) + .passthrough() as z.ZodType; + +export const PlannerConfigSchema = z + .object({ + agent: z + .enum(["auto", "codex", "claude"], { + message: "must be 'auto', 'codex', or 'claude'", + }) + .optional(), + router: PlannerRouterConfigSchema.optional(), + }) + .passthrough() + .superRefine((planner, context) => { + if ((planner.agent === "claude" || planner.agent === "codex") && planner.router !== undefined) { + context.addIssue({ + code: "custom", + path: ["router"], + message: `Config.planner.router is only used when Config.planner.agent is 'auto'. Remove router for forced '${planner.agent}' planning.`, + }); + } + }) as z.ZodType; + +const codexThinkingLevelSchema = z + .object({ + decision: codexEffortSchema.optional(), + planning: codexEffortSchema.optional(), + review: codexEffortSchema.optional(), + execution: codexEffortSchema.optional(), + }) + .passthrough(); + +export const CodexConfigSchema = z + .object({ + enabled: scalarBooleanSchema.optional(), + model: openAIModelSchema.optional(), + effort: codexEffortSchema.optional(), + thinkingLevel: codexThinkingLevelSchema.optional(), + command: scalarStringSchema.optional(), + timeoutMs: positiveIntegerSchema.optional(), + multiAgent: scalarBooleanSchema.optional(), + perCwdExtraUserRoots: z + .array( + z + .object({ + cwd: scalarStringSchema, + extraUserRoots: z.array(scalarStringSchema), + }) + .passthrough(), + ) + .optional(), + }) + .passthrough() + .superRefine((codex, context) => { + if ("thinking" in codex && codex.thinking !== undefined) { + context.addIssue({ + code: "custom", + path: ["thinking"], + message: "Config.codex.thinking is no longer supported. Use Config.codex.thinkingLevel instead.", + }); + } + }) as z.ZodType; + +export const ClaudeConfigSchema = z + .object({ + command: scalarStringSchema.optional(), + model: claudeModelSchema.optional(), + effort: claudeEffortSchema.optional(), + timeoutMs: positiveIntegerSchema.optional(), + }) + .passthrough() as z.ZodType; + +const reviewOnInvalidSchema = z.enum(["fail", "warn_skip"], { + message: "must be 'fail' or 'warn_skip'", +}); +const reviewOnFindingsSchema = z.enum(["auto_fix", "report_only", "fail"], { + message: "must be 'auto_fix', 'report_only', or 'fail'", +}); + +export const ReviewConfigSchema = z + .object({ + enabled: scalarBooleanSchema.optional(), + onInvalid: reviewOnInvalidSchema.optional(), + plan: z + .object({ + enabled: scalarBooleanSchema.optional(), + onInvalid: reviewOnInvalidSchema.optional(), + }) + .passthrough() + .optional(), + execution: z + .object({ + enabled: scalarBooleanSchema.optional(), + maxCycles: positiveIntegerSchema.optional(), + onFindings: reviewOnFindingsSchema.optional(), + validator: z + .object({ + auto: scalarBooleanSchema.optional(), + commands: z.array(scalarStringSchema).optional(), + }) + .passthrough() + .optional(), + prompt: scalarStringSchema.optional(), + }) + .passthrough() + .optional(), + }) + .passthrough() as z.ZodType; + +export const OrcaConfigSchema = z + .object({ + openaiApiKey: scalarStringSchema.optional(), + runsDir: scalarStringSchema.optional(), + sessionLogs: scalarStringSchema.optional(), + skills: z.array(scalarStringSchema).optional(), + maxRetries: nonnegativeIntegerSchema.optional(), + executor: z.literal("codex").optional(), + planner: PlannerConfigSchema.optional(), + claude: ClaudeConfigSchema.optional(), + codex: CodexConfigSchema.optional(), + hooks: hooksSchema.optional(), + hookCommands: hookCommandsSchema.optional(), + pr: z + .object({ + enabled: scalarBooleanSchema.optional(), + requireConfirmation: scalarBooleanSchema.optional(), + }) + .passthrough() + .optional(), + review: ReviewConfigSchema.optional(), + }) + .passthrough(); +``` diff --git a/package-lock.json b/package-lock.json index a0a0fe4..62c2868 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,16 @@ { "name": "orcastrator", - "version": "0.2.24", + "version": "0.2.25", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "orcastrator", - "version": "0.2.24", + "version": "0.2.25", "license": "MIT", "dependencies": { "@inquirer/prompts": "^8.2.1", - "@ratley/codex-client": "^0.1.4", + "@ratley/codex-client": "^0.1.5", "chalk": "^5.3.0", "commander": "^13.1.0", "zod": "^4.3.6" @@ -21,11 +21,13 @@ "devDependencies": { "@arethetypeswrong/cli": "^0.17.4", "@types/bun": "^1.2.21", + "@types/node": "^25.6.0", "@typescript/native-preview": "^7.0.0-dev.20260219.1", "husky": "^9.1.7", "lint-staged": "^16.2.0", - "oxlint": "^1.49.0", - "oxlint-tsgolint": "^0.14.1", + "oxfmt": "^0.47.0", + "oxlint": "^1.62.0", + "oxlint-tsgolint": "^0.22.1", "publint": "^0.3.15", "typescript": "^5.8.2" } @@ -491,99 +493,396 @@ "@braidai/lang": "^1.0.0" } }, + "node_modules/@oxfmt/binding-android-arm-eabi": { + "version": "0.47.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.47.0.tgz", + "integrity": "sha512-KrMQRdMi/upr81qT4ijK6X6BNp6jqpMY7FwILQnwIy9QLc3qpnhUx5rsCLGzn4ewsCQ0CNAspN2ogmP1GXLyLw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-android-arm64": { + "version": "0.47.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.47.0.tgz", + "integrity": "sha512-r4ixS/PeUpAFKgrpDoZ5pSkthjZzVzKd95525Aazj+aOv9H4ulK5zYHGb7wFY5n5kZxHK8TbOJUZgoEb1ohddQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-darwin-arm64": { + "version": "0.47.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.47.0.tgz", + "integrity": "sha512-CLWxiKpMl+195cm09CuaWEhJK0CirRkoMa07aR9+9AFPat2LfIKtwx1JqxZM0MTvcMe6+adlJNdVL6jdInvq3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-darwin-x64": { + "version": "0.47.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.47.0.tgz", + "integrity": "sha512-Xq5fjTYDC50faUeLSm0rZdBqoTgleXEdD7NpJdARtQIczkCJn3xNjMUSQQkUmh4CtxkKTNL68lytcOK3e/osgg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-freebsd-x64": { + "version": "0.47.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.47.0.tgz", + "integrity": "sha512-QOU9ZIJ52p5askcEC0QJvvr8trHAWoonul8bgISo6gYUL3s50zkqafBYcNAr9LJZQbsZtPfIWHk9+5+nUp1qJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm-gnueabihf": { + "version": "0.47.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.47.0.tgz", + "integrity": "sha512-oJxDM1aBhPvz9gmElBv8UpxyiqhwfjcbrSxT5F0xtuUzY6dQI27/AQPIt3eu3Z5Yvn0kQl5R7MA3Z+MbnRvCBw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm-musleabihf": { + "version": "0.47.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.47.0.tgz", + "integrity": "sha512-g8Lh50VS4ibGz2q6v7r9UZY4D0dM16SdrFYOMzhqIoCwGcai8VMIRUAcqn1/jlCsOOzUXJ741+kCeJt0cofakQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm64-gnu": { + "version": "0.47.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.47.0.tgz", + "integrity": "sha512-YrNT1vQ0asaXoRbrvYENPqmBfOQ9Xr8enPNOULeYfg44VjCcrUowFy5QZr+WawE0zyP8cH9e9Gxxg0fDEFzhcg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm64-musl": { + "version": "0.47.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.47.0.tgz", + "integrity": "sha512-IxtQC/sbBi4ubbY+MdwdanRWrG9InQJVZqyMsBa5IUaQcnSg86gQme574HxXMC1p4bo4YhV99zQ+wNnGCvEgzw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-ppc64-gnu": { + "version": "0.47.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.47.0.tgz", + "integrity": "sha512-EWXEhOMbWO0q6eJSbu0QLkU8cKi0ljlYLngeDs2Ocu/pm1rrLwyQiYzlFbdnMRURI4w9ndr1sI9rSbhlJ5o23Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-riscv64-gnu": { + "version": "0.47.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.47.0.tgz", + "integrity": "sha512-tZrjS11TUiDuEpRaqdk8K9F9xETRyKXfuZKmdeW+Gj7coBnm7+8sBEfyt033EAFEQSlkniAXvBLh+Qja2ioGBQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-riscv64-musl": { + "version": "0.47.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.47.0.tgz", + "integrity": "sha512-KBFy+2CFKUCZzYwX2ZOPQKck1vjQbz+hextuc19G4r0WRJwadfAeuQMQRQvB+Ivc8brlbOVg7et8K7E467440g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-s390x-gnu": { + "version": "0.47.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.47.0.tgz", + "integrity": "sha512-REUPFKVGSiK99B+9eaPhluEVglzaoj/SMykNC5SUiV2RSsBfV5lWN7Y0iCIc251Wz3GaeAGZsJ/zj3gjarxdFg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-x64-gnu": { + "version": "0.47.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.47.0.tgz", + "integrity": "sha512-KVftVSVEDeIfRW3TIeLe3aNI/iY4m1fu5mDwHcisKMZSCMKLkrhFsjowC7o9RoqNPxbbglm2+/6KAKBIts2t0Q==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-x64-musl": { + "version": "0.47.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.47.0.tgz", + "integrity": "sha512-DTsmGEaA2860Aq5VUyDO8/MT9NFxwVL93RnRYmpMwK6DsSkThmvEpqoUDDljziEpAedMRG19SCogrNbINSbLUQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-openharmony-arm64": { + "version": "0.47.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.47.0.tgz", + "integrity": "sha512-8r5BDro7fLOBoq1JXHLVSs55OlrxQhEso4HVo0TcY7OXJUPYfjPoOaYL5us+yIwqyP9rQwN+rxuiNFSmaxSuOQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-win32-arm64-msvc": { + "version": "0.47.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.47.0.tgz", + "integrity": "sha512-qtz/gzm8IjSPUlseZ0ofW8zyHLoZsuP5HTfcGGkWkUblB89JT8GNYH3ICqjbDsqsGqXum0/ZndXTFplSdXFIcg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-win32-ia32-msvc": { + "version": "0.47.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.47.0.tgz", + "integrity": "sha512-5vIcdcIDE7nCx+MXN6sm8kbC4zajDB31E86rez4i45iHNH/2NjdKlJ720xcHTr3eeiMcttCGPHPhE1TjtBDGZw==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-win32-x64-msvc": { + "version": "0.47.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.47.0.tgz", + "integrity": "sha512-Sr59Y5ms54ONBjxFeWhVlGyQcHXxcl9DxC23f6yXlRkcos7LXBLoO+KDfxexjHIOZh7cWqrWduzvUjJ+pHp8cQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, "node_modules/@oxlint-tsgolint/darwin-arm64": { - "version": "0.14.1", - "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/darwin-arm64/-/darwin-arm64-0.14.1.tgz", - "integrity": "sha512-PRV1nI1N7OQd4YBzdZGTv9JaBnu8aLWE30zoF4IHDiiQewqMK1U5gT5an20A7g32301Ddr2jIOGgbgTEHi7e8A==", + "version": "0.22.1", + "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/darwin-arm64/-/darwin-arm64-0.22.1.tgz", + "integrity": "sha512-4150Lpgc1YM09GcjA6GSrra1JoPjC7aOpfywLjWEY4vW0Sd1qKzqHF1WRaiw0/qUZ40OATYdv3aRd7ipPkWQbw==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "darwin" ] }, "node_modules/@oxlint-tsgolint/darwin-x64": { - "version": "0.14.1", - "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/darwin-x64/-/darwin-x64-0.14.1.tgz", - "integrity": "sha512-5wiV9kqrEqYhgdHWwF7k9BbprLfcqOVfLOY1wCgtMRWco91WAq+JgGsr362237iTRDfMyDbSBqsCO2ff2kFm0A==", + "version": "0.22.1", + "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/darwin-x64/-/darwin-x64-0.22.1.tgz", + "integrity": "sha512-vFWcPWYOgZs4HWcgS1EjUZg33NLcNfEYU49KGImmCfZWkflENrmBYV4HN/C0YeAPum6ZZ/goPSvQrB/cOD+NfA==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "darwin" ] }, "node_modules/@oxlint-tsgolint/linux-arm64": { - "version": "0.14.1", - "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/linux-arm64/-/linux-arm64-0.14.1.tgz", - "integrity": "sha512-xBDRBNjkvekf/iXc00/DXZv5WOElBRBQeZnvQ106P+P1d5bqaN/QHX6kDhZU8g9cLmsp3b+TZm3oJzOf9q9lbQ==", + "version": "0.22.1", + "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/linux-arm64/-/linux-arm64-0.22.1.tgz", + "integrity": "sha512-6LiUpP0Zir3+29FvBm7Y28q/dBjSHqTZ5MhG1Ckw4fGhI4cAvbcwXaKvbjx1TP7rRmBNOoq/M5xdpHjTb+GAew==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@oxlint-tsgolint/linux-x64": { - "version": "0.14.1", - "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/linux-x64/-/linux-x64-0.14.1.tgz", - "integrity": "sha512-pUPo7UMShtIUJvOwRxrcIqvTg1tzzJMYZDIIAGIC8pN71UIqWu+yvMJEkY1X9ua1RxxBxDneomBRr+OEt/1I9w==", + "version": "0.22.1", + "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/linux-x64/-/linux-x64-0.22.1.tgz", + "integrity": "sha512-fuX1hEQfpHauUbXADsfqVhRzrUrGabzGXbj5wsp2vKhV5uk/Rze8Mba9GdjFGECzvXudMGqHqxB4r6jGRdhxVA==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@oxlint-tsgolint/win32-arm64": { - "version": "0.14.1", - "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/win32-arm64/-/win32-arm64-0.14.1.tgz", - "integrity": "sha512-N999HgAKg+YKwlywyBMHkYpvHAl6DgFax04KOJQR/wL8UHeA/MKtuFRXafLiUzyuALanxlFky3fMtC1RAr0ZEw==", + "version": "0.22.1", + "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/win32-arm64/-/win32-arm64-0.22.1.tgz", + "integrity": "sha512-8SZidAj+jrbZf9ZjBEYW0tiNZ+KasqB2zgW26qdiPpQSF/DzURnPmXz651IeA9YsmbVdHGIooEHUmev6QJdquA==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "win32" ] }, "node_modules/@oxlint-tsgolint/win32-x64": { - "version": "0.14.1", - "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/win32-x64/-/win32-x64-0.14.1.tgz", - "integrity": "sha512-C4JD7oGC/wG+eygEeiqJRl1d3TRPmyA3aNqGf8KqJG6/MPjx7w1lZppMUcoyfED9HIlZTMLj7KHmtcbZJWR5rg==", + "version": "0.22.1", + "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/win32-x64/-/win32-x64-0.22.1.tgz", + "integrity": "sha512-QweSk9H5lFh5Y+WUf2Kq/OAN88V6+62ZwGhP38gqdRotI90luXSMkruFTj7Q2rYrzH4ZVNaSqx7NY8JpSfIzqg==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "win32" ] }, "node_modules/@oxlint/binding-android-arm-eabi": { - "version": "1.49.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.49.0.tgz", - "integrity": "sha512-2WPoh/2oK9r/i2R4o4J18AOrm3HVlWiHZ8TnuCaS4dX8m5ZzRmHW0I3eLxEurQLHWVruhQN7fHgZnah+ag5iQg==", + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.62.0.tgz", + "integrity": "sha512-pKsthNECyvJh8lPTICz6VcwVy2jOqdhhsp1rlxCkhgZR47aKvXPmaRWQDv+zlXpRae4qm1MaaTnutkaOk5aofg==", "cpu": [ "arm" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "android" @@ -593,14 +892,13 @@ } }, "node_modules/@oxlint/binding-android-arm64": { - "version": "1.49.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.49.0.tgz", - "integrity": "sha512-YqJAGvNB11EzoKm1euVhZntb79alhMvWW/j12bYqdvVxn6xzEQWrEDCJg9BPo3A3tBCSUBKH7bVkAiCBqK/L1w==", + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.62.0.tgz", + "integrity": "sha512-b1AUNViByvgmR2xJDubvLIr+dSuu3uraG7bsAoKo+xrpspPvu6RIn6Fhr2JUhobfep3jwUTy18Huco6GkwdvGQ==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "android" @@ -610,14 +908,13 @@ } }, "node_modules/@oxlint/binding-darwin-arm64": { - "version": "1.49.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.49.0.tgz", - "integrity": "sha512-WFocCRlvVkMhChCJ2qpJfp1Gj/IjvyjuifH9Pex8m8yHonxxQa3d8DZYreuDQU3T4jvSY8rqhoRqnpc61Nlbxw==", + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.62.0.tgz", + "integrity": "sha512-iG+Tvf70UJ6otfwFYIHk36Sjq9cpPP5YLxkoggANNRtzgi3Tj3g8q6Ybqi6AtkU3+yg9QwF7bDCkCS6bbL4PCg==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "darwin" @@ -627,14 +924,13 @@ } }, "node_modules/@oxlint/binding-darwin-x64": { - "version": "1.49.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.49.0.tgz", - "integrity": "sha512-BN0KniwvehbUfYztOMwEDkYoojGm/narf5oJf+/ap+6PnzMeWLezMaVARNIS0j3OdMkjHTEP8s3+GdPJ7WDywQ==", + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.62.0.tgz", + "integrity": "sha512-oOWI6YPPr5AJUx+yIDlxmuUbQjS5gZX3OH3QisawYvsZgLiQVvZtR0rPBcJTxLWqt2ClrWg0DlSrlUiG5SQNHg==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "darwin" @@ -644,14 +940,13 @@ } }, "node_modules/@oxlint/binding-freebsd-x64": { - "version": "1.49.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.49.0.tgz", - "integrity": "sha512-SnkAc/DPIY6joMCiP/+53Q+N2UOGMU6ULvbztpmvPJNF/jYPGhNbKtN982uj2Gs6fpbxYkmyj08QnpkD4fbHJA==", + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.62.0.tgz", + "integrity": "sha512-dLP33T7VLCmLVv4cvjkVX+rmkcwNk2UfxmsZPNur/7BQHoQR60zJ7XLiRvNUawlzn0u8ngCa3itjEG73MAMa/w==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "freebsd" @@ -661,14 +956,13 @@ } }, "node_modules/@oxlint/binding-linux-arm-gnueabihf": { - "version": "1.49.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.49.0.tgz", - "integrity": "sha512-6Z3EzRvpQVIpO7uFhdiGhdE8Mh3S2VWKLL9xuxVqD6fzPhyI3ugthpYXlCChXzO8FzcYIZ3t1+Kau+h2NY1hqA==", + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.62.0.tgz", + "integrity": "sha512-fl//LWNks6qo9chNY60UDYyIwtp7a5cEx4Y/rHPjaarhuwqx6jtbzEpD5V5AqmdL4a6Y5D8zeXg5HF2Cr0QmSQ==", "cpu": [ "arm" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -678,14 +972,13 @@ } }, "node_modules/@oxlint/binding-linux-arm-musleabihf": { - "version": "1.49.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.49.0.tgz", - "integrity": "sha512-wdjXaQYAL/L25732mLlngfst4Jdmi/HLPVHb3yfCoP5mE3lO/pFFrmOJpqWodgv29suWY74Ij+RmJ/YIG5VuzQ==", + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.62.0.tgz", + "integrity": "sha512-i5vkAuxvueTODV3J2dL61/TXewDHhMFKvtD156cIsk7GsdfiAu7zW7kY0NJXhKeFHeiMZIh7eFNjkPYH6J47HQ==", "cpu": [ "arm" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -695,14 +988,13 @@ } }, "node_modules/@oxlint/binding-linux-arm64-gnu": { - "version": "1.49.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.49.0.tgz", - "integrity": "sha512-oSHpm8zmSvAG1BWUumbDRSg7moJbnwoEXKAkwDf/xTQJOzvbUknq95NVQdw/AduZr5dePftalB8rzJNGBogUMg==", + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.62.0.tgz", + "integrity": "sha512-QwN19LLuIGuOjEflSeJkZmOTfBdBMlTmW8xbMf8TZhjd//cxVNYQPq75q7oKZBJc6hRx3gY7sX0Egc8cEIFZYg==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -712,14 +1004,13 @@ } }, "node_modules/@oxlint/binding-linux-arm64-musl": { - "version": "1.49.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.49.0.tgz", - "integrity": "sha512-xeqkMOARgGBlEg9BQuPDf6ZW711X6BT5qjDyeM5XNowCJeTSdmMhpePJjTEiVbbr3t21sIlK8RE6X5bc04nWyQ==", + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.62.0.tgz", + "integrity": "sha512-8eCy3FCDuWUM5hWujAv6heMvfZPbcCOU3SdQUAkixZLu5bSzOkNfirJiLGoQFO943xceOKkiQRMQNzH++jM3WA==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -729,14 +1020,13 @@ } }, "node_modules/@oxlint/binding-linux-ppc64-gnu": { - "version": "1.49.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.49.0.tgz", - "integrity": "sha512-uvcqRO6PnlJGbL7TeePhTK5+7/JXbxGbN+C6FVmfICDeeRomgQqrfVjf0lUrVpUU8ii8TSkIbNdft3M+oNlOsQ==", + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.62.0.tgz", + "integrity": "sha512-NjQ7K7tpTPDe9J+yq8p/s/J0E7lRCkK2uDBDqvT4XIT6f4Z0tlnr59OBg/WcrmVHER1AbrcfyxhGTXgcG8ytWg==", "cpu": [ "ppc64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -746,14 +1036,13 @@ } }, "node_modules/@oxlint/binding-linux-riscv64-gnu": { - "version": "1.49.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.49.0.tgz", - "integrity": "sha512-Dw1HkdXAwHNH+ZDserHP2RzXQmhHtpsYYI0hf8fuGAVCIVwvS6w1+InLxpPMY25P8ASRNiFN3hADtoh6lI+4lg==", + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.62.0.tgz", + "integrity": "sha512-oKZed9gmSwze29dEt3/Wnsv6l/Ygw/FUst+8Kfpv2SGeS/glEoTGZAMQw37SVyzFV76UTHJN2snGgxK2t2+8ow==", "cpu": [ "riscv64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -763,14 +1052,13 @@ } }, "node_modules/@oxlint/binding-linux-riscv64-musl": { - "version": "1.49.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.49.0.tgz", - "integrity": "sha512-EPlMYaA05tJ9km/0dI9K57iuMq3Tw+nHst7TNIegAJZrBPtsOtYaMFZEaWj02HA8FI5QvSnRHMt+CI+RIhXJBQ==", + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.62.0.tgz", + "integrity": "sha512-gBjBxQ+9lGpAYq+ELqw0w8QXsBnkZclFc7GRX2r0LnEVn3ZTEqeIKpKcGjucmp76Q53bvJD0i4qBWBhcfhSfGA==", "cpu": [ "riscv64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -780,14 +1068,13 @@ } }, "node_modules/@oxlint/binding-linux-s390x-gnu": { - "version": "1.49.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.49.0.tgz", - "integrity": "sha512-yZiQL9qEwse34aMbnMb5VqiAWfDY+fLFuoJbHOuzB1OaJZbN1MRF9Nk+W89PIpGr5DNPDipwjZb8+Q7wOywoUQ==", + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.62.0.tgz", + "integrity": "sha512-Ew2Kxs9EQ9/mbAIJ2hvocMC0wsOu6YKzStI2eFBDt+Td5O8seVC/oxgRIHqCcl5sf5ratA1nozQBAuv7tphkHg==", "cpu": [ "s390x" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -797,14 +1084,13 @@ } }, "node_modules/@oxlint/binding-linux-x64-gnu": { - "version": "1.49.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.49.0.tgz", - "integrity": "sha512-CcCDwMMXSchNkhdgvhVn3DLZ4EnBXAD8o8+gRzahg+IdSt/72y19xBgShJgadIRF0TsRcV/MhDUMwL5N/W54aQ==", + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.62.0.tgz", + "integrity": "sha512-5z25jcAA0gfKyVwz71A0VXgaPlocPoTAxhlv/hgoK6tlCrfoNuw7haWbDHvGMfjXhdic4EqVXGRv5XsTqFnbRQ==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -814,14 +1100,13 @@ } }, "node_modules/@oxlint/binding-linux-x64-musl": { - "version": "1.49.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.49.0.tgz", - "integrity": "sha512-u3HfKV8BV6t6UCCbN0RRiyqcymhrnpunVmLFI8sEa5S/EBu+p/0bJ3D7LZ2KT6PsBbrB71SWq4DeFrskOVgIZg==", + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.62.0.tgz", + "integrity": "sha512-IWpHmMB6ZDllPvqWDkG6AmXrN7JF5e/c4g/0PuURsmlK+vHoYZPB70rr4u1bn3I4LsKCSpqqfveyx6UCOC8wdg==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -831,14 +1116,13 @@ } }, "node_modules/@oxlint/binding-openharmony-arm64": { - "version": "1.49.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.49.0.tgz", - "integrity": "sha512-dRDpH9fw+oeUMpM4br0taYCFpW6jQtOuEIec89rOgDA1YhqwmeRcx0XYeCv7U48p57qJ1XZHeMGM9LdItIjfzA==", + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.62.0.tgz", + "integrity": "sha512-fjlSxxrD5pA594vkyikCS9MnPRjQawW6/BLgyTYkO+73wwPlYjkcZ7LSd974l0Q2zkHQmu4DPvJFLYA7o8xrxQ==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "openharmony" @@ -848,14 +1132,13 @@ } }, "node_modules/@oxlint/binding-win32-arm64-msvc": { - "version": "1.49.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.49.0.tgz", - "integrity": "sha512-6rrKe/wL9tn0qnOy76i1/0f4Dc3dtQnibGlU4HqR/brVHlVjzLSoaH0gAFnLnznh9yQ6gcFTBFOPrcN/eKPDGA==", + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.62.0.tgz", + "integrity": "sha512-EiFXr8loNS0Ul3Gu80+9nr1T8jRmnKocqmHHg16tj5ZqTgUXyb97l2rrspVHdDluyFn9JfR4PoJFdNzw4paHww==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "win32" @@ -865,14 +1148,13 @@ } }, "node_modules/@oxlint/binding-win32-ia32-msvc": { - "version": "1.49.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.49.0.tgz", - "integrity": "sha512-CXHLWAtLs2xG/aVy1OZiYJzrULlq0QkYpI6cd7VKMrab+qur4fXVE/B1Bp1m0h1qKTj5/FTGg6oU4qaXMjS/ug==", + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.62.0.tgz", + "integrity": "sha512-IgOFvL73li1bFgab+hThXYA0N2Xms2kV2MvZN95cebV+fmrZ9AVui1JSxfeeqRLo3CpPxKZlzhyq4G0cnaAvIw==", "cpu": [ "ia32" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "win32" @@ -882,14 +1164,13 @@ } }, "node_modules/@oxlint/binding-win32-x64-msvc": { - "version": "1.49.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.49.0.tgz", - "integrity": "sha512-VteIelt78kwzSglOozaQcs6BCS4Lk0j+QA+hGV0W8UeyaqQ3XpbZRhDU55NW1PPvCy1tg4VXsTlEaPovqto7nQ==", + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.62.0.tgz", + "integrity": "sha512-6hMpyDWQ2zGA1OXFKBrdYMUveUCO8UJhkO6JdwZPd78xIdHZNhjx+pib+4fC2Cljuhjyl0QwA2F3df/bs4Bp6A==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "win32" @@ -912,10 +1193,9 @@ } }, "node_modules/@ratley/codex-client": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@ratley/codex-client/-/codex-client-0.1.4.tgz", - "integrity": "sha512-lefQMXoR12cImkNnZiHN/MraDdxKzrwH5DZSft6cQJ1s+tIZllNyCBBD6dtGX8O+8uz19SL/NyyHbBIDUtlIlw==", - "license": "MIT", + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/@ratley/codex-client/-/codex-client-0.1.5.tgz", + "integrity": "sha512-sYpKYpCXn//QZpKJyboWPCC1YuZrMBacKYmDBMyFRw3F59w4n1tec5n1yHRrfF19IBGsx5MtHevZjfu6tuEaXw==", "peerDependencies": { "typescript": "^5" } @@ -944,13 +1224,12 @@ } }, "node_modules/@types/node": { - "version": "20.19.33", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.33.tgz", - "integrity": "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==", + "version": "25.6.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", + "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", "devOptional": true, - "license": "MIT", "dependencies": { - "undici-types": "~6.21.0" + "undici-types": "~7.19.0" } }, "node_modules/@typescript/native-preview": { @@ -1899,12 +2178,50 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/oxfmt": { + "version": "0.47.0", + "resolved": "https://registry.npmjs.org/oxfmt/-/oxfmt-0.47.0.tgz", + "integrity": "sha512-OFbkbzxKCpooQEnRmpTDnuwTX8KHXzZTQ4Df/hz85fpS67Pl+lxPEFvUtin56HIIS0B1k4X8oIzTXRZPufA2CA==", + "dev": true, + "dependencies": { + "tinypool": "2.1.0" + }, + "bin": { + "oxfmt": "bin/oxfmt" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxfmt/binding-android-arm-eabi": "0.47.0", + "@oxfmt/binding-android-arm64": "0.47.0", + "@oxfmt/binding-darwin-arm64": "0.47.0", + "@oxfmt/binding-darwin-x64": "0.47.0", + "@oxfmt/binding-freebsd-x64": "0.47.0", + "@oxfmt/binding-linux-arm-gnueabihf": "0.47.0", + "@oxfmt/binding-linux-arm-musleabihf": "0.47.0", + "@oxfmt/binding-linux-arm64-gnu": "0.47.0", + "@oxfmt/binding-linux-arm64-musl": "0.47.0", + "@oxfmt/binding-linux-ppc64-gnu": "0.47.0", + "@oxfmt/binding-linux-riscv64-gnu": "0.47.0", + "@oxfmt/binding-linux-riscv64-musl": "0.47.0", + "@oxfmt/binding-linux-s390x-gnu": "0.47.0", + "@oxfmt/binding-linux-x64-gnu": "0.47.0", + "@oxfmt/binding-linux-x64-musl": "0.47.0", + "@oxfmt/binding-openharmony-arm64": "0.47.0", + "@oxfmt/binding-win32-arm64-msvc": "0.47.0", + "@oxfmt/binding-win32-ia32-msvc": "0.47.0", + "@oxfmt/binding-win32-x64-msvc": "0.47.0" + } + }, "node_modules/oxlint": { - "version": "1.49.0", - "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.49.0.tgz", - "integrity": "sha512-YZffp0gM+63CJoRhHjtjRnwKtAgUnXM6j63YQ++aigji2NVvLGsUlrXo9gJUXZOdcbfShLYtA6RuTu8GZ4lzOQ==", + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.62.0.tgz", + "integrity": "sha512-1uFkg6HakjsGIpW9wNdeW4/2LOHW9MEkoWjZUTUfQtIHyLIZPYt00w3Sg+H3lH+206FgBPHBbW5dVE5l2ExECQ==", "dev": true, - "license": "MIT", "bin": { "oxlint": "bin/oxlint" }, @@ -1915,28 +2232,28 @@ "url": "https://github.com/sponsors/Boshen" }, "optionalDependencies": { - "@oxlint/binding-android-arm-eabi": "1.49.0", - "@oxlint/binding-android-arm64": "1.49.0", - "@oxlint/binding-darwin-arm64": "1.49.0", - "@oxlint/binding-darwin-x64": "1.49.0", - "@oxlint/binding-freebsd-x64": "1.49.0", - "@oxlint/binding-linux-arm-gnueabihf": "1.49.0", - "@oxlint/binding-linux-arm-musleabihf": "1.49.0", - "@oxlint/binding-linux-arm64-gnu": "1.49.0", - "@oxlint/binding-linux-arm64-musl": "1.49.0", - "@oxlint/binding-linux-ppc64-gnu": "1.49.0", - "@oxlint/binding-linux-riscv64-gnu": "1.49.0", - "@oxlint/binding-linux-riscv64-musl": "1.49.0", - "@oxlint/binding-linux-s390x-gnu": "1.49.0", - "@oxlint/binding-linux-x64-gnu": "1.49.0", - "@oxlint/binding-linux-x64-musl": "1.49.0", - "@oxlint/binding-openharmony-arm64": "1.49.0", - "@oxlint/binding-win32-arm64-msvc": "1.49.0", - "@oxlint/binding-win32-ia32-msvc": "1.49.0", - "@oxlint/binding-win32-x64-msvc": "1.49.0" + "@oxlint/binding-android-arm-eabi": "1.62.0", + "@oxlint/binding-android-arm64": "1.62.0", + "@oxlint/binding-darwin-arm64": "1.62.0", + "@oxlint/binding-darwin-x64": "1.62.0", + "@oxlint/binding-freebsd-x64": "1.62.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.62.0", + "@oxlint/binding-linux-arm-musleabihf": "1.62.0", + "@oxlint/binding-linux-arm64-gnu": "1.62.0", + "@oxlint/binding-linux-arm64-musl": "1.62.0", + "@oxlint/binding-linux-ppc64-gnu": "1.62.0", + "@oxlint/binding-linux-riscv64-gnu": "1.62.0", + "@oxlint/binding-linux-riscv64-musl": "1.62.0", + "@oxlint/binding-linux-s390x-gnu": "1.62.0", + "@oxlint/binding-linux-x64-gnu": "1.62.0", + "@oxlint/binding-linux-x64-musl": "1.62.0", + "@oxlint/binding-openharmony-arm64": "1.62.0", + "@oxlint/binding-win32-arm64-msvc": "1.62.0", + "@oxlint/binding-win32-ia32-msvc": "1.62.0", + "@oxlint/binding-win32-x64-msvc": "1.62.0" }, "peerDependencies": { - "oxlint-tsgolint": ">=0.14.1" + "oxlint-tsgolint": ">=0.18.0" }, "peerDependenciesMeta": { "oxlint-tsgolint": { @@ -1945,21 +2262,20 @@ } }, "node_modules/oxlint-tsgolint": { - "version": "0.14.1", - "resolved": "https://registry.npmjs.org/oxlint-tsgolint/-/oxlint-tsgolint-0.14.1.tgz", - "integrity": "sha512-+zbTyYt+86+8TcF//1NUoHs7v8kvu5vQvjnFZMerrhp5REzYFvgLdfT7LLBQd1qmTWeFQ4/ko1YLXKtoxTFxVw==", + "version": "0.22.1", + "resolved": "https://registry.npmjs.org/oxlint-tsgolint/-/oxlint-tsgolint-0.22.1.tgz", + "integrity": "sha512-YUSGSLUnoolsu8gxISEDio3q1rtsCozwfOzASUn3DT2mR2EeQ93uEEnen7s+6LpF+lyTQFln1pQfqwBh/fsVEg==", "dev": true, - "license": "MIT", "bin": { "tsgolint": "bin/tsgolint.js" }, "optionalDependencies": { - "@oxlint-tsgolint/darwin-arm64": "0.14.1", - "@oxlint-tsgolint/darwin-x64": "0.14.1", - "@oxlint-tsgolint/linux-arm64": "0.14.1", - "@oxlint-tsgolint/linux-x64": "0.14.1", - "@oxlint-tsgolint/win32-arm64": "0.14.1", - "@oxlint-tsgolint/win32-x64": "0.14.1" + "@oxlint-tsgolint/darwin-arm64": "0.22.1", + "@oxlint-tsgolint/darwin-x64": "0.22.1", + "@oxlint-tsgolint/linux-arm64": "0.22.1", + "@oxlint-tsgolint/linux-x64": "0.22.1", + "@oxlint-tsgolint/win32-arm64": "0.22.1", + "@oxlint-tsgolint/win32-x64": "0.22.1" } }, "node_modules/package-manager-detector": { @@ -2252,6 +2568,15 @@ "node": ">=0.8" } }, + "node_modules/tinypool": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-2.1.0.tgz", + "integrity": "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==", + "dev": true, + "engines": { + "node": "^20.0.0 || >=22.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -2279,11 +2604,10 @@ } }, "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "devOptional": true, - "license": "MIT" + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", + "devOptional": true }, "node_modules/unicode-emoji-modifier-base": { "version": "1.0.0", diff --git a/package.json b/package.json index ba261be..07a5969 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,35 @@ { "name": "orcastrator", - "version": "0.2.24", + "version": "0.2.25", + "description": "Coordinated agent run harness", + "keywords": [ + "agent", + "ai", + "cli", + "codex", + "orchestration" + ], + "homepage": "https://orcastrator.dev", + "bugs": { + "url": "https://github.com/ratley/orca/issues" + }, + "license": "MIT", + "author": "ratley", + "repository": { + "type": "git", + "url": "git+https://github.com/ratley/orca.git" + }, + "bin": { + "orca": "dist/cli/index.js" + }, + "files": [ + "dist/**/*.js", + "dist/**/*.d.ts", + "dist/**/*.d.ts.map", + "!dist/**/*.test.js", + "README.md", + ".orca/skills/**/SKILL.md" + ], "type": "module", "types": "dist/index.d.ts", "exports": { @@ -13,13 +42,12 @@ "default": "./dist/types/index.js" } }, - "bin": { - "orca": "dist/cli/index.js" - }, "scripts": { "build": "tsc", "dev": "bun run src/cli/index.ts", "lint": "oxlint src/", + "format": "oxfmt . '!bun.lock' '!package-lock.json'", + "format:check": "oxfmt --check . '!bun.lock' '!package-lock.json'", "typecheck": "node ./scripts/typecheck.mjs", "test": "bun test src", "test:setup": "bash scripts/test-setup.sh", @@ -27,10 +55,10 @@ "start": "bun run src/cli/index.ts", "prepare": "husky", "postbuild": "chmod +x dist/cli/index.js", - "lint:type-aware": "oxlint --type-aware --type-check --deny-warnings src/ --ignore-pattern \"**/*.test.ts\"", + "lint:type-aware": "oxlint --type-aware --type-check --deny-warnings src/ --ignore-pattern \"**/*.test.ts\" --ignore-pattern \"**/*.test-harness.ts\" --ignore-pattern \"**/*.typecheck.ts\"", "typecheck:native": "tsgo --noEmit", "typecheck:tsc": "tsc --noEmit", - "validate": "npm run lint && npm run lint:type-aware && npm run typecheck && npm run test && npm run build", + "validate": "npm run format:check && npm run lint && npm run lint:type-aware && npm run typecheck && npm run test && npm run build", "smoke:hooks": "npm run build && node ./specs/smoke/hooks/run-hooks-smoke.mjs", "type:check": "tsc -p tsconfig.json --noEmit", "pkg:lint": "publint", @@ -40,7 +68,7 @@ }, "dependencies": { "@inquirer/prompts": "^8.2.1", - "@ratley/codex-client": "^0.1.4", + "@ratley/codex-client": "^0.1.5", "chalk": "^5.3.0", "commander": "^13.1.0", "zod": "^4.3.6" @@ -48,43 +76,23 @@ "devDependencies": { "@arethetypeswrong/cli": "^0.17.4", "@types/bun": "^1.2.21", + "@types/node": "^25.6.0", "@typescript/native-preview": "^7.0.0-dev.20260219.1", "husky": "^9.1.7", "lint-staged": "^16.2.0", - "oxlint": "^1.49.0", - "oxlint-tsgolint": "^0.14.1", + "oxfmt": "^0.47.0", + "oxlint": "^1.62.0", + "oxlint-tsgolint": "^0.22.1", "publint": "^0.3.15", "typescript": "^5.8.2" }, "lint-staged": { - "*.ts": [ + "*.{js,jsx,ts,tsx}": [ + "oxfmt --no-error-on-unmatched-pattern", "oxlint --fix" + ], + "*.{json,jsonc,css,md,mdx}": [ + "oxfmt --no-error-on-unmatched-pattern" ] - }, - "files": [ - "dist/**/*.js", - "dist/**/*.d.ts", - "dist/**/*.d.ts.map", - "!dist/**/*.test.js", - "README.md", - ".orca/skills/**/SKILL.md" - ], - "description": "Coordinated agent run harness", - "author": "ratley", - "license": "MIT", - "repository": { - "type": "git", - "url": "git+https://github.com/ratley/orca.git" - }, - "homepage": "https://orcastrator.dev", - "bugs": { - "url": "https://github.com/ratley/orca/issues" - }, - "keywords": [ - "ai", - "agent", - "codex", - "orchestration", - "cli" - ] + } } diff --git a/scripts/generate-docs-reference.mjs b/scripts/generate-docs-reference.mjs new file mode 100644 index 0000000..24f1acf --- /dev/null +++ b/scripts/generate-docs-reference.mjs @@ -0,0 +1,129 @@ +#!/usr/bin/env node + +import { execFileSync } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const rootDir = resolve(scriptDir, ".."); +const cliPath = resolve(rootDir, "dist/cli/index.js"); +const defaultOutputPath = resolve(rootDir, "docs/orca-reference.md"); + +const outputArgIndex = process.argv.indexOf("--out"); +const outputPath = + outputArgIndex >= 0 && process.argv[outputArgIndex + 1] + ? resolve(rootDir, process.argv[outputArgIndex + 1]) + : defaultOutputPath; + +if (!existsSync(cliPath)) { + console.error("Missing dist/cli/index.js. Run `npm run build` before generating docs."); + process.exit(1); +} + +const packageJson = JSON.parse(readFileSync(resolve(rootDir, "package.json"), "utf8")); +const typeSource = readFileSync(resolve(rootDir, "src/types/index.ts"), "utf8"); +const effortSource = readFileSync(resolve(rootDir, "src/types/effort.ts"), "utf8"); + +function runHelp(args) { + return execFileSync(process.execPath, [cliPath, ...args, "--help"], { + cwd: rootDir, + encoding: "utf8", + env: { ...process.env, NO_COLOR: "1" }, + }).trimEnd(); +} + +function extractSourceSection(source, startMarker, endMarker) { + const startIndex = source.indexOf(startMarker); + if (startIndex < 0) { + throw new Error(`Could not find source marker: ${startMarker}`); + } + + const endIndex = source.indexOf(endMarker, startIndex); + if (endIndex < 0) { + throw new Error(`Could not find source marker: ${endMarker}`); + } + + return source.slice(startIndex, endIndex).trim(); +} + +function extractArrayValues(source, marker) { + const match = source.match(new RegExp(`${marker} = \\[([^\\]]+)\\] as const;`)); + if (!match?.[1]) { + throw new Error(`Could not find values for ${marker}`); + } + + return [...match[1].matchAll(/"([^"]+)"/g)].map((item) => item[1]); +} + +const commandHelps = [ + ["orca", []], + ["orca run", ["run"]], + ["orca plan", ["plan"]], + ["orca status", ["status"]], + ["orca list", ["list"]], + ["orca skills", ["skills"]], + ["orca resume", ["resume"]], + ["orca cancel", ["cancel"]], + ["orca answer", ["answer"]], + ["orca pr", ["pr"]], + ["orca pr draft", ["pr", "draft"]], + ["orca pr create", ["pr", "create"]], + ["orca pr publish", ["pr", "publish"]], + ["orca pr status", ["pr", "status"]], + ["orca setup", ["setup"]], + ["orca help", ["help"]], + ["orca pr-finalize", ["pr-finalize"]], +]; + +const configReference = extractSourceSection( + typeSource, + "export type PlannerAgent", + "export function defineOrcaConfig", +); +const effortValues = extractArrayValues(effortSource, "CODEX_THINKING_LEVEL_VALUES"); + +const commandSections = commandHelps + .map(([title, args]) => { + const help = runHelp(args); + + return `### ${title} + +\`\`\`text +${help} +\`\`\``; + }) + .join("\n\n"); + +const markdown = `# orca + +Coordinated agent run harness. + +> Generated by \`node scripts/generate-docs-reference.mjs\` from \`orcastrator@${packageJson.version}\`. Do not edit this file by hand. + +## Install + +\`\`\`shell +npm install -g orcastrator +\`\`\` + +## Command Reference + +This section is generated from the Commander command tree used by the built Orca CLI. + +${commandSections} + +## Config Schema And Public Type Reference + +Codex effort values: \`${effortValues.join("|")}\`. + +This section is generated from the Zod-backed config schema and public types in \`src/types/index.ts\`, which are re-exported by the package entry point. + +\`\`\`ts +${configReference} +\`\`\` +`; + +mkdirSync(dirname(outputPath), { recursive: true }); +writeFileSync(outputPath, markdown, "utf8"); +console.log(`Wrote ${outputPath}`); diff --git a/session-logs/LATEST.md b/session-logs/LATEST.md deleted file mode 100644 index c009db1..0000000 --- a/session-logs/LATEST.md +++ /dev/null @@ -1,13 +0,0 @@ -# Session Log - -- Timestamp: 2026-03-16T07:40:30Z -- Scope: Codex clarification question flow, pending-question persistence, onQuestion hooks, answer/resume handling, and live CLI smoke coverage. -- Verification: - - `bun test src` - - `npm run typecheck:tsc` - - `npm run build` - - `bun test src/__tests__/client.test.ts src/__tests__/integration.test.ts` -- Notes: - - Orca now surfaces Codex `requestUserInput` prompts in `status.json`, `orca status`, and `onQuestion` hooks. - - `orca answer` writes structured answers and the original live run resumes without `orca resume`. - - The CLI smoke passed against a fake Codex app-server exercising `waiting_for_answer` end to end. diff --git a/specs/sample.md b/specs/sample.md index 8a91a9f..5c5609e 100644 --- a/specs/sample.md +++ b/specs/sample.md @@ -3,6 +3,7 @@ Add a /health endpoint to the API that returns 200 OK with JSON { status: 'ok', version: string }. ## Requirements + - GET /health returns 200 - Response: { status: 'ok', version: '1.0.0' } - No auth required diff --git a/specs/smoke/README.md b/specs/smoke/README.md new file mode 100644 index 0000000..466b8ee --- /dev/null +++ b/specs/smoke/README.md @@ -0,0 +1,32 @@ +# Orca Smoke Specs + +Checked-in manual smoke scenarios for exercising Orca end-to-end against real local repos in `tmp/`. + +These specs are intentionally small. The goal is to verify Orca behavior, not to build impressive apps. + +## Scenarios + +- `html-game-planning.md` + - planning-heavy Bun HTML game + - exercises `orca plan`, task-graph review, and consultation +- `question-flow-greeter.md` + - execution run that should require a clarification question + - exercises `waiting_for_answer`, `onQuestion`, `orca answer`, and same-run resume +- `no-plan-library.md` + - small execution run that Orca should automatically keep as a single task + - exercises the planning-skip path plus standard completion hooks +- `review-cycle-validator.md` + - execution run with a validator that should fail on the first attempt and succeed after Orca fixes findings + - exercises post-exec review, validator findings, `onFindings`, and auto-fix looping + +## Usage + +Create each smoke project under `tmp/smoke//`, copy the spec body into `SMOKE_SPEC.md`, `cd` into that repo, and run Orca from that project directory. + +Prefer isolated run state when smoking locally: + +```bash +export ORCA_RUNS_DIR="$(pwd)/.orca-runs" +``` + +For hook validation, point the hook commands at simple local scripts that append stdin payloads to a JSONL file. diff --git a/specs/smoke/hooks/record-command-hook.mjs b/specs/smoke/hooks/record-command-hook.mjs index 6286873..54a0762 100644 --- a/specs/smoke/hooks/record-command-hook.mjs +++ b/specs/smoke/hooks/record-command-hook.mjs @@ -14,14 +14,9 @@ for await (const chunk of process.stdin) { } const payload = JSON.parse(stdin); -const payloadEnvKeys = [ - "ORCA_HOOK", - "ORCA_MSG", - "ORCA_RUN_ID", - "ORCA_TASK_ID", - "ORCA_TASK_NAME", - "ORCA_ERROR" -].filter((key) => process.env[key] !== undefined); +const payloadEnvKeys = ["ORCA_HOOK", "ORCA_MSG", "ORCA_RUN_ID", "ORCA_TASK_ID", "ORCA_TASK_NAME", "ORCA_ERROR"].filter( + (key) => process.env[key] !== undefined, +); const record = { source: "command", @@ -30,7 +25,7 @@ const record = { message: payload.message, hasPayloadEnv: payloadEnvKeys.length > 0, payloadEnvKeys, - timestamp: new Date().toISOString() + timestamp: new Date().toISOString(), }; await mkdir(path.dirname(logPath), { recursive: true }); diff --git a/specs/smoke/hooks/run-hooks-smoke.mjs b/specs/smoke/hooks/run-hooks-smoke.mjs index 9f3c8d1..ddb7cc2 100644 --- a/specs/smoke/hooks/run-hooks-smoke.mjs +++ b/specs/smoke/hooks/run-hooks-smoke.mjs @@ -14,7 +14,7 @@ const { HookDispatcher } = await import(path.join(repoRoot, "dist", "hooks", "di const repoFixtures = [ "/Users/evesenara/code/orca-smoke-projects/node-tooling", "/Users/evesenara/code/orca-smoke-projects/python-tooling", - "/Users/evesenara/code/orca-smoke-projects/docs-only" + "/Users/evesenara/code/orca-smoke-projects/docs-only", ]; const allHooks = [ @@ -24,7 +24,7 @@ const allHooks = [ "onInvalidPlan", "onFindings", "onComplete", - "onError" + "onError", ]; function delay(ms) { @@ -38,7 +38,9 @@ async function appendRecord(record) { async function buildDispatcher() { const dispatcher = new HookDispatcher({ - commandHooks: Object.fromEntries(allHooks.map((hook) => [hook, `node ${JSON.stringify(commandHookScript)} ${JSON.stringify(logPath)}`])) + commandHooks: Object.fromEntries( + allHooks.map((hook) => [hook, `node ${JSON.stringify(commandHookScript)} ${JSON.stringify(logPath)}`]), + ), }); for (const hook of allHooks) { @@ -49,7 +51,7 @@ async function buildDispatcher() { runId: event.runId, message: event.message, context, - timestamp: new Date().toISOString() + timestamp: new Date().toISOString(), }); }); } @@ -58,31 +60,107 @@ async function buildDispatcher() { } async function runExecutionFlow(dispatcher, runId, repoPath, failureMode) { - await dispatcher.dispatch({ hook: "onMilestone", runId, message: "execution-started", timestamp: new Date().toISOString(), metadata: { repoPath } }); + await dispatcher.dispatch({ + hook: "onMilestone", + runId, + message: "execution-started", + timestamp: new Date().toISOString(), + metadata: { repoPath }, + }); await delay(15); - await dispatcher.dispatch({ hook: "onMilestone", runId, message: "planning-complete", timestamp: new Date().toISOString(), metadata: { repoPath } }); + await dispatcher.dispatch({ + hook: "onMilestone", + runId, + message: "planning-complete", + timestamp: new Date().toISOString(), + metadata: { repoPath }, + }); await delay(10); if (failureMode === "invalid-plan") { - await dispatcher.dispatch({ hook: "onInvalidPlan", runId, message: "invalid-plan:review", timestamp: new Date().toISOString(), error: "Cycle in DAG", metadata: { stage: "review", repoPath } }); - await dispatcher.dispatch({ hook: "onError", runId, message: "run-failed:invalid-plan", timestamp: new Date().toISOString(), error: "Cycle in DAG", metadata: { repoPath } }); + await dispatcher.dispatch({ + hook: "onInvalidPlan", + runId, + message: "invalid-plan:review", + timestamp: new Date().toISOString(), + error: "Cycle in DAG", + metadata: { stage: "review", repoPath }, + }); + await dispatcher.dispatch({ + hook: "onError", + runId, + message: "run-failed:invalid-plan", + timestamp: new Date().toISOString(), + error: "Cycle in DAG", + metadata: { repoPath }, + }); return; } - await dispatcher.dispatch({ hook: "onTaskComplete", runId, taskId: `${runId}-t1`, taskName: "bootstrap", message: "task-complete:bootstrap", timestamp: new Date().toISOString(), metadata: { repoPath } }); + await dispatcher.dispatch({ + hook: "onTaskComplete", + runId, + taskId: `${runId}-t1`, + taskName: "bootstrap", + message: "task-complete:bootstrap", + timestamp: new Date().toISOString(), + metadata: { repoPath }, + }); await delay(10); if (failureMode === "task-fail") { - await dispatcher.dispatch({ hook: "onTaskFail", runId, taskId: `${runId}-t2`, taskName: "tests", message: "task-fail:tests", timestamp: new Date().toISOString(), error: "unit test failed", metadata: { repoPath } }); - await dispatcher.dispatch({ hook: "onError", runId, message: "run-failed:task", timestamp: new Date().toISOString(), error: "unit test failed", metadata: { repoPath } }); + await dispatcher.dispatch({ + hook: "onTaskFail", + runId, + taskId: `${runId}-t2`, + taskName: "tests", + message: "task-fail:tests", + timestamp: new Date().toISOString(), + error: "unit test failed", + metadata: { repoPath }, + }); + await dispatcher.dispatch({ + hook: "onError", + runId, + message: "run-failed:task", + timestamp: new Date().toISOString(), + error: "unit test failed", + metadata: { repoPath }, + }); return; } - await dispatcher.dispatch({ hook: "onFindings", runId, message: "post-review found 1 issue", timestamp: new Date().toISOString(), metadata: { findingsCount: 1, repoPath } }); + await dispatcher.dispatch({ + hook: "onFindings", + runId, + message: "post-review found 1 issue", + timestamp: new Date().toISOString(), + metadata: { findingsCount: 1, repoPath }, + }); await delay(10); - await dispatcher.dispatch({ hook: "onTaskComplete", runId, taskId: `${runId}-t2`, taskName: "fixes", message: "task-complete:fixes", timestamp: new Date().toISOString(), metadata: { repoPath } }); - await dispatcher.dispatch({ hook: "onMilestone", runId, message: "execution-completed", timestamp: new Date().toISOString(), metadata: { repoPath } }); - await dispatcher.dispatch({ hook: "onComplete", runId, message: "run-completed", timestamp: new Date().toISOString(), metadata: { repoPath } }); + await dispatcher.dispatch({ + hook: "onTaskComplete", + runId, + taskId: `${runId}-t2`, + taskName: "fixes", + message: "task-complete:fixes", + timestamp: new Date().toISOString(), + metadata: { repoPath }, + }); + await dispatcher.dispatch({ + hook: "onMilestone", + runId, + message: "execution-completed", + timestamp: new Date().toISOString(), + metadata: { repoPath }, + }); + await dispatcher.dispatch({ + hook: "onComplete", + runId, + message: "run-completed", + timestamp: new Date().toISOString(), + metadata: { repoPath }, + }); } function summarize(records) { @@ -110,7 +188,7 @@ async function main() { await Promise.all([ runExecutionFlow(dispatcher, "con-node", repoFixtures[0], null), runExecutionFlow(dispatcher, "con-python", repoFixtures[1], "task-fail"), - runExecutionFlow(dispatcher, "con-docs", repoFixtures[2], "invalid-plan") + runExecutionFlow(dispatcher, "con-docs", repoFixtures[2], "invalid-plan"), ]); const raw = (await readFile(logPath, "utf8")).trim(); @@ -136,7 +214,9 @@ async function main() { } } - console.log(`Smoke OK: ${records.length} hook records (${functionRecords.length} function, ${commandRecords.length} command)`); + console.log( + `Smoke OK: ${records.length} hook records (${functionRecords.length} function, ${commandRecords.length} command)`, + ); console.log(`Log: ${logPath}`); } diff --git a/specs/smoke/no-plan-library.md b/specs/smoke/no-plan-library.md new file mode 100644 index 0000000..a974c94 --- /dev/null +++ b/specs/smoke/no-plan-library.md @@ -0,0 +1,80 @@ +# No-Plan Library Smoke + +Manual smoke scenario for exercising: + +- automatic planning skip for a tiny task +- standard task execution +- task completion hooks +- final completion without review-loop complications + +The generated project stays local and gitignored under `tmp/smoke/no-plan-lib/`. + +## Workspace setup + +```bash +mkdir -p tmp/smoke/no-plan-lib +cd tmp/smoke/no-plan-lib +bun init -y +git init +git add . +git commit -m "baseline" +``` + +Create a small starting project: + +```bash +cat > math.ts <<'EOF' +export function sum(a: number, b: number): number { + return a + b; +} +EOF + +cat > math.test.ts <<'EOF' +import { expect, test } from "bun:test"; +import { sum } from "./math"; + +test("sum adds two numbers", () => { + expect(sum(2, 3)).toBe(5); +}); +EOF +``` + +## Local spec file + +Copy the spec below into `tmp/smoke/no-plan-lib/SMOKE_SPEC.md`. + +```md +# Add A Small Math Helper + +Update this Bun project. Orca should decide that no multi-step plan is needed and keep execution as a single task. + +## Requirements + +- Add `multiply(a, b)` to `math.ts`. +- Add a Bun test that covers the new helper. +- Keep the project tiny and dependency-free. +- Run local verification before finishing. + +## Verification + +- Run `bun test`. +``` + +## Suggested run flow + +From `tmp/smoke/no-plan-lib/`: + +```bash +export ORCA_RUNS_DIR="$(pwd)/.orca-runs" +orca run --spec ./SMOKE_SPEC.md \ + --on-task-complete 'node ./hook-log.mjs task-complete' \ + --on-complete 'node ./hook-log.mjs complete' \ + --on-error 'node ./hook-log.mjs error' +``` + +## Manual acceptance + +- Orca decides planning can be skipped and finishes in a single execution task. +- `bun test` passes. +- Completion hooks fire. +- The project remains simple and framework-free. diff --git a/specs/smoke/question-flow-greeter.md b/specs/smoke/question-flow-greeter.md new file mode 100644 index 0000000..b43961d --- /dev/null +++ b/specs/smoke/question-flow-greeter.md @@ -0,0 +1,117 @@ +# Question Flow Greeter Smoke + +Manual smoke scenario for exercising: + +- a small execution run where Orca should skip multi-step planning +- live clarification during execution +- `waiting_for_answer` +- `onQuestion` +- `orca answer` +- same-run resume and completion hooks + +The generated project stays local and gitignored under `tmp/smoke/question-greeter/`. + +## Workspace setup + +```bash +mkdir -p tmp/smoke/question-greeter +cd tmp/smoke/question-greeter +bun init -y +git init +git add . +git commit -m "baseline" +``` + +Create a minimal starting project: + +```bash +mkdir -p test +cat > index.ts <<'EOF' +export const releaseCodename = "TODO"; + +export function greet(): string { + return `Release: ${releaseCodename}`; +} + +if (import.meta.main) { + console.log(greet()); +} +EOF + +cat > test/greet.test.ts <<'EOF' +import { expect, test } from "bun:test"; +import { greet, releaseCodename } from "../index"; + +test("release codename is set", () => { + expect(releaseCodename).not.toBe("TODO"); +}); + +test("greet includes the release codename", () => { + expect(greet()).toContain(releaseCodename); +}); +EOF + +cat > validate-answer.ts <<'EOF' +import { readFileSync } from "node:fs"; + +const codename = readFileSync(new URL("./codename.txt", import.meta.url), "utf8").trim(); +if (!codename) { + throw new Error("codename.txt was empty"); +} +console.log(`Validated codename: ${codename}`); +EOF +``` + +## Local spec file + +Copy the spec below into `tmp/smoke/question-greeter/SMOKE_SPEC.md`. + +```md +# Fill In A Missing Release Codename + +Update this Bun project so it asks for the missing release codename if needed during execution instead of guessing. + +## Requirements + +- Do not invent a codename. +- If the codename is not specified in the repo or the prompt context, ask for it explicitly before making the change. +- Update `index.ts` so the exported value is no longer `TODO`. +- Write the chosen codename into `codename.txt`. +- Keep the project minimal and avoid adding dependencies. +- Run local verification before finishing. + +## Verification + +- Run `bun test`. +- Run `bun run validate-answer.ts`. +``` + +## Suggested run flow + +From `tmp/smoke/question-greeter/`: + +```bash +export ORCA_RUNS_DIR="$(pwd)/.orca-runs" +orca run --spec ./SMOKE_SPEC.md \ + --on-question 'node ./hook-log.mjs question' \ + --on-complete 'node ./hook-log.mjs complete' \ + --on-error 'node ./hook-log.mjs error' \ + --on-task-complete 'node ./hook-log.mjs task-complete' +``` + +When the run enters `waiting_for_answer`, answer it: + +```bash +orca status --run +orca answer "Nebula-7" +``` + +## Manual acceptance + +- Orca asks for the missing codename instead of guessing. +- `orca status` shows the pending question and question IDs if multiple prompts are present. +- `orca answer` resumes the same live run. +- The final run completes successfully. +- `bun test` passes. +- `bun run validate-answer.ts` passes. +- `codename.txt` contains the answer that was provided. diff --git a/specs/smoke/review-cycle-validator.md b/specs/smoke/review-cycle-validator.md new file mode 100644 index 0000000..8d634ae --- /dev/null +++ b/specs/smoke/review-cycle-validator.md @@ -0,0 +1,94 @@ +# Review Cycle Validator Smoke + +Manual smoke scenario for exercising: + +- planned or unplanned execution with validator commands +- post-exec findings collection +- `onFindings` +- auto-fix review loop +- final completion after review + +The generated project stays local and gitignored under `tmp/smoke/review-cycle/`. + +## Workspace setup + +```bash +mkdir -p tmp/smoke/review-cycle +cd tmp/smoke/review-cycle +bun init -y +git init +git add . +git commit -m "baseline" +``` + +Create a small starting project: + +```bash +cat > index.ts <<'EOF' +export function label(name: string): string { + return `Hello, ${name}`; +} +EOF + +cat > index.test.ts <<'EOF' +import { expect, test } from "bun:test"; +import { label } from "./index"; + +test("label greets by name", () => { + expect(label("Orca")).toBe("Hello, Orca"); +}); +EOF + +cat > verify-output.ts <<'EOF' +import { readFileSync } from "node:fs"; + +const source = readFileSync(new URL("./index.ts", import.meta.url), "utf8"); +if (!source.includes("Goodbye")) { + throw new Error("expected updated farewell text"); +} +console.log("Verified expected farewell text"); +EOF +``` + +## Local spec file + +Copy the spec below into `tmp/smoke/review-cycle/SMOKE_SPEC.md`. + +```md +# Change The Greeting Contract + +Update this Bun project so the exported function now returns a farewell string instead of a greeting. + +## Requirements + +- Change `label(name)` so it returns `Goodbye, `. +- Update tests to match the new behavior. +- Keep the project tiny and dependency-free. +- Run local verification before finishing. + +## Verification + +- Run `bun test`. +- Run `bun run verify-output.ts`. +``` + +## Suggested run flow + +Use an Orca config that enables execution review and validator commands, then run: + +```bash +export ORCA_RUNS_DIR="$(pwd)/.orca-runs" +orca run --spec ./SMOKE_SPEC.md \ + --on-findings 'node ./hook-log.mjs findings' \ + --on-complete 'node ./hook-log.mjs complete' \ + --on-error 'node ./hook-log.mjs error' +``` + +## Manual acceptance + +- The validator commands run after execution. +- If execution or review misses something, Orca records findings and loops. +- `onFindings` fires when the review cycle finds a real problem. +- The final run reaches `completed` only after review succeeds. +- `bun test` passes. +- `bun run verify-output.ts` passes. diff --git a/src/agents/claude/session.test.ts b/src/agents/claude/session.test.ts new file mode 100644 index 0000000..691c98b --- /dev/null +++ b/src/agents/claude/session.test.ts @@ -0,0 +1,61 @@ +import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; + +import { planSpec } from "./session.js"; + +describe("claude planner adapter", () => { + let tempDir = ""; + + beforeEach(async () => { + tempDir = await mkdtemp(path.join(os.tmpdir(), "orca-claude-planner-test-")); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + test("runs claude print mode with prompt over stdin", async () => { + const fakeClaudePath = path.join(tempDir, "claude"); + const argsPath = path.join(tempDir, "args.txt"); + const stdinPath = path.join(tempDir, "stdin.txt"); + const tasksJson = JSON.stringify([ + { + id: "task-1", + name: "Plan task", + description: "Do the work", + dependencies: [], + acceptance_criteria: ["done"], + status: "pending", + retries: 0, + maxRetries: 3, + }, + ]); + + await writeFile( + fakeClaudePath, + [ + "#!/bin/sh", + `printf '%s\\n' "$@" > ${JSON.stringify(argsPath)}`, + `cat > ${JSON.stringify(stdinPath)}`, + `printf '%s\\n' ${JSON.stringify(tasksJson)}`, + ].join("\n"), + "utf8", + ); + await chmod(fakeClaudePath, 0o755); + + const result = await planSpec("Build the thing", "System context", { + claude: { command: fakeClaudePath, model: "claude-opus-4-7", effort: "high", timeoutMs: 1_000 }, + }); + + expect(result.tasks).toHaveLength(1); + expect(result.tasks[0]?.id).toBe("task-1"); + const args = await readFile(argsPath, "utf8"); + expect(args).toContain("-p"); + expect(args).toContain("--model\nclaude-opus-4-7"); + expect(args).toContain("--effort\nhigh"); + await expect(readFile(stdinPath, "utf8")).resolves.toContain("Build the thing"); + }); +}); diff --git a/src/agents/claude/session.ts b/src/agents/claude/session.ts new file mode 100644 index 0000000..b787567 --- /dev/null +++ b/src/agents/claude/session.ts @@ -0,0 +1,143 @@ +import { spawn } from "node:child_process"; + +import type { OrcaConfig, PlanResult, Task } from "../../types/index.js"; + +const DEFAULT_CLAUDE_TIMEOUT_MS = 300_000; + +function buildPlanningPrompt(spec: string, systemContext: string): string { + return [ + systemContext, + "You are decomposing a spec into an ordered Orca task graph.", + "Use broad product and implementation judgment when choosing the task boundaries.", + "Prefer task decomposition that maximizes safe parallelism for independent workstreams.", + "Isolate task ownership (files/subsystems) to avoid cross-task collisions.", + "Return a JSON array of tasks.", + "Each task must include fields: id, name, description, dependencies, acceptance_criteria, status, retries, maxRetries.", + 'Set status to "pending", retries to 0, and maxRetries to 3 for every task.', + "dependencies must be an array of task IDs.", + "acceptance_criteria must be an array of strings.", + "Return ONLY valid JSON. No markdown fences. No explanation.", + "Spec:", + spec, + ].join("\n\n"); +} + +function resolveClaudeCommand(config?: OrcaConfig): string { + return config?.claude?.command ?? process.env.ORCA_CLAUDE_COMMAND ?? process.env.ORCA_CLAUDE_PATH ?? "claude"; +} + +function resolveClaudeArgs(config?: OrcaConfig): string[] { + return [ + "-p", + "--output-format", + "text", + "--tools", + "", + ...(config?.claude?.model ? ["--model", config.claude.model] : []), + ...(config?.claude?.effort ? ["--effort", config.claude.effort] : []), + ]; +} + +function resolveClaudeTimeoutMs(config?: OrcaConfig): number { + return config?.claude?.timeoutMs ?? DEFAULT_CLAUDE_TIMEOUT_MS; +} + +function runClaudePrint(prompt: string, config?: OrcaConfig): Promise { + const command = resolveClaudeCommand(config); + const args = resolveClaudeArgs(config); + const timeoutMs = resolveClaudeTimeoutMs(config); + + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + cwd: process.cwd(), + env: process.env, + stdio: ["pipe", "pipe", "pipe"], + }); + + let stdout = ""; + let stderr = ""; + let settled = false; + + const timeout = setTimeout(() => { + settled = true; + child.kill("SIGTERM"); + reject(new Error(`Claude planner timed out after ${timeoutMs}ms`)); + }, timeoutMs); + + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + stdout += chunk; + }); + + child.stderr.setEncoding("utf8"); + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + + child.on("error", (error) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timeout); + reject(new Error(`Failed to start Claude planner command '${command}': ${error.message}`)); + }); + + child.on("close", (code, signal) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timeout); + + if (code !== 0) { + const detail = stderr.trim() || stdout.trim() || `signal ${signal ?? "unknown"}`; + reject(new Error(`Claude planner exited with code ${code ?? "null"}: ${detail}`)); + return; + } + + resolve(stdout.trim()); + }); + + child.stdin.end(prompt); + }); +} + +function extractJson(text: string): string { + const fenceMatch = text.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/); + if (fenceMatch?.[1]) { + return fenceMatch[1].trim(); + } + + const lines = text.trim().split("\n"); + for (let i = lines.length - 1; i >= 0; i -= 1) { + const line = lines[i]?.trim() ?? ""; + if (line.startsWith("[") || line.startsWith("{")) { + try { + JSON.parse(line); + return line; + } catch { + // Continue searching for a valid JSON line. + } + } + } + + return text.trim(); +} + +function parseTaskArray(raw: string): Task[] { + const parsed = JSON.parse(extractJson(raw)) as unknown; + if (!Array.isArray(parsed)) { + throw new Error("Claude plan response was not a JSON array"); + } + + return parsed as Task[]; +} + +export async function planSpec(spec: string, systemContext: string, config?: OrcaConfig): Promise { + const rawResponse = await runClaudePrint(buildPlanningPrompt(spec, systemContext), config); + return { + tasks: parseTaskArray(rawResponse), + rawResponse, + }; +} diff --git a/src/agents/codex/codex-path.test.ts b/src/agents/codex/codex-path.test.ts index 9ff7a96..29c5e6b 100644 --- a/src/agents/codex/codex-path.test.ts +++ b/src/agents/codex/codex-path.test.ts @@ -1,8 +1,12 @@ +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { describe, expect, test } from "bun:test"; import { clearResolvedCodexPathCacheForTests, compareCodexCliVersions, + resolveCodexPathsOnPath, parseCodexCliVersion, selectPreferredCodexBinary, } from "./codex-path.js"; @@ -67,6 +71,27 @@ describe("codex-path", () => { ).toBe("/first/codex"); }); + test("resolveCodexPathsOnPath includes all executable codex binaries on PATH", () => { + const tempRoot = mkdtempSync(path.join(os.tmpdir(), "orca-codex-path-")); + const firstDir = path.join(tempRoot, "first"); + const secondDir = path.join(tempRoot, "second"); + const firstCodex = path.join(firstDir, "codex"); + const secondCodex = path.join(secondDir, "codex"); + + mkdirSync(firstDir, { recursive: true }); + mkdirSync(secondDir, { recursive: true }); + writeFileSync(firstCodex, "#!/bin/sh\necho codex-cli 0.77.0\n", { mode: 0o755 }); + writeFileSync(secondCodex, "#!/bin/sh\necho codex-cli 0.115.0\n", { mode: 0o755 }); + chmodSync(firstCodex, 0o755); + chmodSync(secondCodex, 0o755); + + try { + expect(resolveCodexPathsOnPath([firstDir, secondDir].join(path.delimiter))).toEqual([firstCodex, secondCodex]); + } finally { + rmSync(tempRoot, { recursive: true, force: true }); + } + }); + test("clearResolvedCodexPathCacheForTests is callable", () => { clearResolvedCodexPathCacheForTests(); expect(true).toBe(true); diff --git a/src/agents/codex/codex-path.ts b/src/agents/codex/codex-path.ts index 85d58eb..ead5a86 100644 --- a/src/agents/codex/codex-path.ts +++ b/src/agents/codex/codex-path.ts @@ -38,11 +38,7 @@ export function parseCodexCliVersion(output: string): ParsedCodexCliVersion | nu return null; } - const prerelease = match[4] - ? match[4] - .split(".") - .map((part) => (/^\d+$/.test(part) ? Number(part) : part)) - : []; + const prerelease = match[4] ? match[4].split(".").map((part) => (/^\d+$/.test(part) ? Number(part) : part)) : []; return { major: Number(match[1]), @@ -143,13 +139,14 @@ export function selectPreferredCodexBinary(probes: CodexBinaryProbe[]): string | return best.path; } -function resolveCodexPathOnPath(): string | null { - const pathValue = process.env.PATH?.trim(); - if (!pathValue) { - return null; +export function resolveCodexPathsOnPath(pathValue: string | undefined = process.env.PATH): string[] { + const normalizedPath = pathValue?.trim(); + if (!normalizedPath) { + return []; } - for (const entry of pathValue.split(path.delimiter)) { + const discovered: string[] = []; + for (const entry of normalizedPath.split(path.delimiter)) { const trimmed = entry.trim(); if (trimmed.length === 0) { continue; @@ -158,19 +155,19 @@ function resolveCodexPathOnPath(): string | null { const candidatePath = path.join(trimmed, "codex"); try { accessSync(candidatePath, fsConstants.X_OK); - return candidatePath; + discovered.push(candidatePath); } catch { continue; } } - return null; + return discovered; } function getCandidatePaths(): string[] { return Array.from( new Set( - [resolveCodexPathOnPath(), ...KNOWN_CODEX_BINARY_CANDIDATES].filter( + [...resolveCodexPathsOnPath(), ...KNOWN_CODEX_BINARY_CANDIDATES].filter( (value): value is string => typeof value === "string" && value.trim().length > 0, ), ), @@ -213,9 +210,7 @@ async function autoResolveCodexPath(): Promise { }), ); - const preferred = selectPreferredCodexBinary( - available.filter((probe): probe is CodexBinaryProbe => probe !== null), - ); + const preferred = selectPreferredCodexBinary(available.filter((probe): probe is CodexBinaryProbe => probe !== null)); return preferred ?? FALLBACK_CODEX_PATH; } diff --git a/src/agents/codex/index.ts b/src/agents/codex/index.ts index edbc670..baeaa18 100644 --- a/src/agents/codex/index.ts +++ b/src/agents/codex/index.ts @@ -1,10 +1,3 @@ -export { - createCodexSession, - planSpec, - executeTask, -} from "./session.js"; +export { createCodexSession, planSpec, executeTask } from "./session.js"; -export type { - PlanResult, - TaskExecutionResult, -} from "./session.js"; +export type { PlanResult, TaskExecutionResult } from "./session.js"; diff --git a/src/agents/codex/session.test.ts b/src/agents/codex/session.test.ts index 52b3a45..7c51783 100644 --- a/src/agents/codex/session.test.ts +++ b/src/agents/codex/session.test.ts @@ -1,10 +1,9 @@ import { existsSync } from "node:fs"; +import path from "node:path"; import { describe, expect, test } from "bun:test"; import type { Task } from "../../types/index.js"; -import { createCodexSession } from "./session.js"; -// Try common locations for the codex binary const CODEX_PATHS = [ "/Applications/Codex.app/Contents/Resources/codex", Bun.which("codex"), @@ -12,7 +11,8 @@ const CODEX_PATHS = [ "/usr/local/bin/codex", ].filter(Boolean) as string[]; -const codexPath = CODEX_PATHS.find((p) => existsSync(p)) ?? null; +const codexPath = CODEX_PATHS.find((candidate) => existsSync(candidate)) ?? null; +const sessionModulePath = path.resolve(import.meta.dir, "session.ts"); function makeTask(overrides: Partial = {}): Task { return { @@ -35,64 +35,101 @@ function makeTask(overrides: Partial = {}): Task { }; } -if (!codexPath) { - test("codex adapter integration skipped (codex binary not found)", () => { - // Guard: skip gracefully when codex is not installed - expect(codexPath).toBeNull(); +function runCodexIntegrationSnippet(snippet: string): { exitCode: number; stdout: string; stderr: string } { + const proc = Bun.spawnSync({ + cmd: ["bun", "--eval", snippet], + stdout: "pipe", + stderr: "pipe", + env: { + ...process.env, + ORCA_CODEX_PATH: codexPath ?? "", + }, }); -} else { - describe("Codex adapter integration (createCodexSession)", () => { - test("creates session, executes a simple task, and disconnects", async () => { - const session = await createCodexSession("/tmp"); - - try { - expect(typeof session.threadId).toBe("string"); - expect(session.threadId.length).toBeGreaterThan(0); - const result = await session.executeTask(makeTask(), "test-run-id"); - - expect(result.outcome === "done" || result.outcome === "failed").toBe(true); - expect(typeof result.rawResponse).toBe("string"); - expect(result.rawResponse.length).toBeGreaterThan(0); - - console.log(`executeTask outcome: ${result.outcome}`); - console.log(`rawResponse length: ${result.rawResponse.length}`); - } finally { - await session.disconnect(); - } - }, 300_000); - - test("consultTaskGraph returns valid ConsultationResult", async () => { - const session = await createCodexSession("/tmp"); + return { + exitCode: proc.exitCode, + stdout: proc.stdout.toString("utf8"), + stderr: proc.stderr.toString("utf8"), + }; +} - try { - const tasks: Task[] = [ - makeTask({ id: "task-1", name: "Create file", dependencies: [] }), - makeTask({ id: "task-2", name: "Read file", dependencies: ["task-1"] }), - ]; +function parseLastJsonLine(stdout: string): T { + const jsonLine = stdout + .trim() + .split("\n") + .reverse() + .find((line) => line.trim().startsWith("{")); - const result = await session.consultTaskGraph(tasks); + if (!jsonLine) { + throw new Error(`No JSON object found in stdout:\n${stdout}`); + } - expect(Array.isArray(result.issues)).toBe(true); - expect(typeof result.ok).toBe("boolean"); + return JSON.parse(jsonLine) as T; +} - console.log(`Consultation ok: ${result.ok}`); - console.log(`Issues: ${JSON.stringify(result.issues)}`); - } finally { - await session.disconnect(); - } +if (!codexPath) { + test("codex adapter integration skipped (codex binary not found)", () => { + expect(codexPath).toBeNull(); + }); +} else { + describe("Codex adapter integration (createCodexSession)", () => { + test("creates session, executes a simple task, and disconnects", () => { + const task = JSON.stringify(makeTask()); + const { exitCode, stdout, stderr } = runCodexIntegrationSnippet(` + import { createCodexSession } from ${JSON.stringify(sessionModulePath)}; + const task = ${task}; + const session = await createCodexSession("/tmp"); + try { + const result = await session.executeTask(task, "test-run-id"); + console.log(JSON.stringify({ + threadId: session.threadId, + outcome: result.outcome, + rawLength: result.rawResponse.length, + })); + } finally { + await session.disconnect(); + } + `); + + expect(exitCode).toBe(0); + expect(stderr).toBe(""); + + const parsed = parseLastJsonLine<{ + threadId: string; + outcome: string; + rawLength: number; + }>(stdout); + expect(parsed.threadId.length).toBeGreaterThan(0); + expect(parsed.outcome === "done" || parsed.outcome === "failed").toBe(true); + expect(parsed.rawLength).toBeGreaterThan(0); }, 300_000); - test("reviewChanges returns a string", async () => { - const session = await createCodexSession("/tmp"); - - try { - const review = await session.reviewChanges(); - expect(typeof review).toBe("string"); - console.log(`Review length: ${review.length}`); - } finally { - await session.disconnect(); - } + test("consultTaskGraph returns valid ConsultationResult", () => { + const tasks = JSON.stringify([ + makeTask({ id: "task-1", name: "Create file", dependencies: [] }), + makeTask({ id: "task-2", name: "Read file", dependencies: ["task-1"] }), + ]); + const { exitCode, stdout, stderr } = runCodexIntegrationSnippet(` + import { createCodexSession } from ${JSON.stringify(sessionModulePath)}; + const tasks = ${tasks}; + const session = await createCodexSession("/tmp"); + try { + const result = await session.consultTaskGraph(tasks); + console.log(JSON.stringify(result)); + } finally { + await session.disconnect(); + } + `); + + expect(exitCode).toBe(0); + expect(stderr).toBe(""); + + const parsed = parseLastJsonLine<{ + issues: unknown[]; + ok: boolean; + }>(stdout); + expect(Array.isArray(parsed.issues)).toBe(true); + expect(typeof parsed.ok).toBe("boolean"); }, 300_000); }); } diff --git a/src/agents/codex/session.ts b/src/agents/codex/session.ts index c907919..16350a9 100644 --- a/src/agents/codex/session.ts +++ b/src/agents/codex/session.ts @@ -1,34 +1,39 @@ -import { readFile, unlink } from "node:fs/promises"; +import { randomUUID } from "node:crypto"; +import { createServer } from "node:net"; +import { readdir, readFile, unlink } from "node:fs/promises"; +import os from "node:os"; import path from "node:path"; import { CodexClient } from "@ratley/codex-client"; import type { CompletedTurn, RequestId, + ThreadItem, ToolRequestUserInputParams, ToolRequestUserInputResponse, } from "@ratley/codex-client"; import type { HookEvent, + OpenAIModelId, OrcaConfig, + PendingAnswerChannel, PlanResult, + PlannerRoutingDecision, RunId, Task, TaskExecutionResult, TaskGraphReviewOperation, - TaskGraphReviewResult + TaskGraphReviewResult, } from "../../types/index.js"; import { isCodexMultiAgentActive } from "../../core/codex-config.js"; -import { - buildQuestionHookMessage, - createPendingQuestion, - parseQuestionAnswerInput, -} from "../../core/question-flow.js"; +import { buildQuestionHookMessage, createPendingQuestion, parseQuestionAnswerInput } from "../../core/question-flow.js"; +import { clearSecretAnswerChannel, writeSecretAnswerChannel } from "../../core/secret-answer-channel.js"; import { TaskGraphReviewPayloadSchema } from "../../core/task-graph-review.js"; import { RunStore } from "../../state/store.js"; import type { CodexEffort } from "../../types/effort.js"; -import { loadSkills, type LoadedSkill } from "../../utils/skill-loader.js"; +import * as skillLoader from "../../utils/skill-loader.js"; +import type { LoadedSkill } from "../../utils/skill-loader.js"; import { logger } from "../../utils/logger.js"; import { resolveCodexPath } from "./codex-path.js"; @@ -58,12 +63,45 @@ function getMultiAgentPlanningGuidance(multiAgentActive: boolean): string[] { ]; } -function buildPlanningPrompt(spec: string, systemContext: string, multiAgentActive: boolean): string { +function getClarificationRequestGuidance( + clarificationToolAvailable: boolean, + scope: "planning" | "execution" | "review", +): string[] { + if (!clarificationToolAvailable) { + return []; + } + + const firstLine = + scope === "execution" + ? "If you need any user-provided value, preference, approval, or clarification to complete this task correctly, use Codex's request_user_input tool instead of guessing, failing, or baking the question into a later task." + : "If a blocking ambiguity prevents correct work, use Codex's request_user_input tool to ask concise clarification questions instead of guessing."; + + return [ + firstLine, + "Ask at most 3 short questions with stable snake_case ids.", + "If you need a secret such as a token or password, mark that question as secret.", + ...(scope === "execution" + ? [ + "If this task is itself about obtaining clarification, it is not complete until the question has been asked, answered, and the answer has been applied.", + "After the user answers, continue the same turn and finish the requested file changes, commands, and verification before responding.", + "Do not stop after acknowledging the answer. Resume implementation immediately and only finish once the requested edits are on disk and validated.", + ] + : []), + ]; +} + +function buildPlanningPrompt( + spec: string, + systemContext: string, + multiAgentActive: boolean, + clarificationToolAvailable: boolean, +): string { return [ systemContext, "You are decomposing a spec into an ordered task graph.", "Prefer task decomposition that maximizes safe parallelism for independent workstreams.", "Isolate task ownership (files/subsystems) to avoid cross-task collisions.", + ...getClarificationRequestGuidance(clarificationToolAvailable, "planning"), ...getMultiAgentPlanningGuidance(multiAgentActive), ...getCodeSimplifierGuidance(), "Return a JSON array of tasks.", @@ -77,12 +115,53 @@ function buildPlanningPrompt(spec: string, systemContext: string, multiAgentActi ].join("\n\n"); } +function isUnsupportedCollaborationModeError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return ( + message.includes("-32601") || + message.includes("Method not found") || + message.includes("Invalid collaboration mode list response") + ); +} + +async function detectCollaborationModeSupport( + client: CodexClient, + codexPath: string, + interactiveRunEnabled: boolean, +): Promise { + if (!interactiveRunEnabled) { + return false; + } + + const maybeListCollaborationModes = (client as CodexClient & { listCollaborationModes?: () => Promise }) + .listCollaborationModes; + + if (typeof maybeListCollaborationModes !== "function") { + return true; + } + + try { + await maybeListCollaborationModes.call(client); + return true; + } catch (error) { + if (!isUnsupportedCollaborationModeError(error)) { + throw error; + } + + logger.warn( + `Codex binary at ${codexPath} does not support collaboration mode / question flow. Falling back to non-interactive prompts for this run.`, + ); + return false; + } +} + function buildTaskExecutionPrompt( task: Task, runId: string, cwd: string, systemContext?: string, multiAgentActive = false, + clarificationContext?: string, ): string { return [ ...(systemContext ? [systemContext] : []), @@ -102,10 +181,11 @@ function buildTaskExecutionPrompt( `Task Name: ${task.name}`, "Task Description:", task.description, + ...(clarificationContext && clarificationContext.trim().length > 0 + ? ["Resolved Clarification Context:", clarificationContext.trim()] + : []), "Acceptance Criteria:", - ...task.acceptance_criteria.map( - (criterion, index) => `${index + 1}. ${criterion}`, - ), + ...task.acceptance_criteria.map((criterion, index) => `${index + 1}. ${criterion}`), "Execute this task. You have full shell access — run commands, read/write files, and do whatever is needed.", "IMPORTANT: When done, you MUST output the following JSON on its own line as the very last line of your response (no trailing text after it):", '{"outcome":"done"}', @@ -115,24 +195,72 @@ function buildTaskExecutionPrompt( ].join("\n\n"); } -function buildPlanDecisionPrompt(spec: string, systemContext: string): string { +function buildTaskExecutionClarificationPrompt(task: Task, runId: string, cwd: string, systemContext?: string): string { + return [ + ...(systemContext ? [systemContext] : []), + "You are Orca's execution clarification gate.", + ...getClarificationRequestGuidance(true, "execution"), + `Run ID: ${runId}`, + `Repository CWD: ${cwd}`, + `Task ID: ${task.id}`, + `Task Name: ${task.name}`, + "Task Description:", + task.description, + "Acceptance Criteria:", + ...task.acceptance_criteria.map((criterion, index) => `${index + 1}. ${criterion}`), + "Inspect the repository and task to decide whether execution needs any user-provided value or preference.", + "Do not edit files, do not run mutating commands, and do not claim the task is complete in this turn.", + "If user input is needed, ask via request_user_input, wait for the answer, then continue and summarize the resolved constraint.", + 'Return JSON only with shape: {"needsInput":boolean,"context":string}', + "Set needsInput=true only if you actually asked the user in this clarification turn.", + "Set context to a concise execution-ready summary of the user-provided value(s) and any discovered constraints the execution turn must honor. Use an empty string when no extra context is needed.", + ].join("\n\n"); +} + +function buildPlanDecisionPrompt(spec: string, systemContext: string, clarificationToolAvailable: boolean): string { return [ systemContext, "You are Orca's planning gate.", + ...getClarificationRequestGuidance(clarificationToolAvailable, "planning"), "Decide whether this spec needs multi-step planning or can run as one direct execution task.", "Set needsPlan=true when coordination/dependencies/research/design across multiple steps are required.", - "Set needsPlan=false when a single focused execution task is sufficient.", - "Return JSON only with shape: {\"needsPlan\":boolean,\"reason\":string}", + "Set needsPlan=true when the request spans multiple files, modules, docs, tests, CLI behavior, storage behavior, workflows, or user-facing design choices.", + "Set needsPlan=true when task boundaries, sequencing, ownership lanes, or acceptance criteria would materially improve execution.", + "Set needsPlan=false only when the request is a single focused edit with obvious files and obvious verification.", + 'Return JSON only with shape: {"needsPlan":boolean,"reason":string}', "Spec:", spec, ].join("\n\n"); } -function buildTaskGraphReviewPrompt(tasks: Task[], systemContext: string, multiAgentActive: boolean): string { +function buildPlannerRoutingPrompt(spec: string, systemContext: string, clarificationToolAvailable: boolean): string { + return [ + systemContext, + "You are Orca's planner router.", + ...getClarificationRequestGuidance(clarificationToolAvailable, "planning"), + "Choose which planning agent should generate the task graph for this request.", + "Default to claude for requests that already passed Orca's multi-step planning gate.", + "Choose claude for broad, creative, ambiguous, product/design-heavy, multi-domain, strategy-heavy, or coordination-heavy planning.", + "Choose claude when the work needs task boundaries, sequencing, ownership lanes, or tradeoff judgment across multiple files or subsystems.", + "Choose codex only when the planning itself is clearly narrow, mechanical, implementation-heavy, and already constrained to obvious code/test edits.", + "Do not choose based on execution; Codex will still execute the tasks after planning.", + 'Return JSON only with shape: {"planner":"claude"|"codex","reason":"..."}', + "Spec:", + spec, + ].join("\n\n"); +} + +function buildTaskGraphReviewPrompt( + tasks: Task[], + systemContext: string, + multiAgentActive: boolean, + clarificationToolAvailable: boolean, +): string { return [ systemContext, "You are Orca's pre-execution task-graph reviewer.", ...getCodeSimplifierGuidance(), + ...getClarificationRequestGuidance(clarificationToolAvailable, "review"), ...(multiAgentActive ? [ "Codex multi-agent mode is enabled for this run. Review the graph for safe subagent parallelization.", @@ -142,25 +270,38 @@ function buildTaskGraphReviewPrompt(tasks: Task[], systemContext: string, multiA "Add coordination tasks when parallel work needs a final integration step.", ] : []), - "Return JSON matching this shape exactly: {\"changes\":[...operations...]}", + 'Return JSON matching this shape exactly: {"changes":[...operations...]}', "Allowed operation shapes:", - "- {\"op\":\"update_task\",\"taskId\":\"...\",\"fields\":{\"name\"?:string,\"description\"?:string,\"acceptance_criteria\"?:string[]}}", - "- {\"op\":\"add_task\",\"task\":}", - "- {\"op\":\"remove_task\",\"taskId\":\"...\"}", - "- {\"op\":\"add_dependency\",\"taskId\":\"...\",\"dependsOn\":\"...\"}", - "- {\"op\":\"remove_dependency\",\"taskId\":\"...\",\"dependsOn\":\"...\"}", + '- {"op":"update_task","taskId":"...","fields":{"name"?:string,"description"?:string,"acceptance_criteria"?:string[]}}', + '- {"op":"add_task","task":}', + '- {"op":"remove_task","taskId":"..."}', + '- {"op":"add_dependency","taskId":"...","dependsOn":"..."}', + '- {"op":"remove_dependency","taskId":"...","dependsOn":"..."}', "Return ONLY JSON. No markdown.", "Current task graph:", JSON.stringify(tasks, null, 2), ].join("\n\n"); } -function buildTaskGraphConsultationPrompt(tasks: Task[], multiAgentActive: boolean): string { +function buildTaskGraphConsultationPrompt( + tasks: Task[], + multiAgentActive: boolean, + clarificationToolAvailable: boolean, +): string { const taskGraphJson = JSON.stringify(tasks, null, 2); return [ "Review this Orca task graph before execution.", "Flag any: missing steps, wrong dependency order, tasks that are underdefined, or potential blockers.", + ...getClarificationRequestGuidance(clarificationToolAvailable, "review"), + ...(clarificationToolAvailable + ? [ + "Execution tasks are allowed to pause and ask request_user_input questions when they truly need a user-provided value.", + "Do not ask the user whether Orca may pause during execution for clarification. Assume that execution-time request_user_input is available.", + "If a task already says it should ask for a missing user value during execution, treat that as a valid execution mechanism, not a reason to ask a meta-question about whether clarification is allowed.", + "Only ask a review-time clarification question if the graph cannot be assessed or corrected without an answer right now.", + ] + : []), ...(multiAgentActive ? [ "", @@ -174,7 +315,7 @@ function buildTaskGraphConsultationPrompt(tasks: Task[], multiAgentActive: boole "For minor issues (ambiguous wording, style preferences, nice-to-haves): list them in issues but set ok: true.", "If the graph looks generally reasonable and executable, set ok: true even if you have minor suggestions.", "", - "Be brief. Output JSON on the last line: { \"issues\": [...], \"ok\": boolean }", + 'Be brief. Output JSON on the last line: { "issues": [...], "ok": boolean }', "", "Task graph:", taskGraphJson, @@ -210,6 +351,14 @@ function extractAgentText(result: CompletedTurn): string { } } + const completedTurnAgentItems = result.turn.items.filter((item) => item.type === "agentMessage"); + if (completedTurnAgentItems.length > 0) { + const last = completedTurnAgentItems[completedTurnAgentItems.length - 1]; + if (last !== undefined && "text" in last && typeof last.text === "string") { + return last.text; + } + } + throw new Error("Codex response was empty"); } @@ -253,6 +402,11 @@ export interface PlanNeedDecision { reason: string; } +interface ExecutionClarificationDecision { + needsInput: boolean; + context: string; +} + function parsePlanDecision(raw: string): PlanNeedDecision { const json = extractJson(raw); const parsed = JSON.parse(json) as unknown; @@ -275,6 +429,50 @@ function parsePlanDecision(raw: string): PlanNeedDecision { }; } +function parsePlannerRoutingDecision(raw: string): PlannerRoutingDecision { + const json = extractJson(raw); + const parsed = JSON.parse(json) as unknown; + if (!parsed || typeof parsed !== "object") { + throw new Error("Codex planner routing response was not a JSON object"); + } + + const candidate = parsed as { planner?: unknown; reason?: unknown }; + if (candidate.planner !== "claude" && candidate.planner !== "codex") { + throw new Error("Codex planner routing response missing planner 'claude' or 'codex'"); + } + + if (typeof candidate.reason !== "string" || candidate.reason.trim().length === 0) { + throw new Error("Codex planner routing response missing non-empty reason"); + } + + return { + planner: candidate.planner, + reason: candidate.reason, + }; +} + +function parseExecutionClarificationDecision(raw: string): ExecutionClarificationDecision { + const json = extractJson(raw); + const parsed = JSON.parse(json) as unknown; + if (!parsed || typeof parsed !== "object") { + throw new Error("Codex execution clarification response was not a JSON object"); + } + + const candidate = parsed as { needsInput?: unknown; context?: unknown }; + if (typeof candidate.needsInput !== "boolean") { + throw new Error("Codex execution clarification response missing boolean needsInput"); + } + + if (typeof candidate.context !== "string") { + throw new Error("Codex execution clarification response missing string context"); + } + + return { + needsInput: candidate.needsInput, + context: candidate.context, + }; +} + const POSITIVE_COMPLETION_PATTERNS = [ /\bdone\b/i, /\bcomplet/i, @@ -300,13 +498,7 @@ const POSITIVE_COMPLETION_PATTERNS = [ /\bno\s+(?:errors?|issues?|failures?)\b/i, ]; -const FAILURE_PATTERNS = [ - /\berror\b/i, - /\bfailed?\b/i, - /\bcannot\b/i, - /\bunable\b/i, - /\bpermission denied\b/i, -]; +const FAILURE_PATTERNS = [/\berror\b/i, /\bfailed?\b/i, /\bcannot\b/i, /\bunable\b/i, /\bpermission denied\b/i]; function inferOutcomeFromText(raw: string): TaskExecutionResult { const hasFailure = FAILURE_PATTERNS.some((p) => p.test(raw)); @@ -327,7 +519,7 @@ function inferOutcomeFromText(raw: string): TaskExecutionResult { return { outcome: "done", rawResponse: raw }; } -function parseTaskExecution(raw: string): TaskExecutionResult { +function parseTaskExecutionWithSource(raw: string): { result: TaskExecutionResult; usedCompletionMarker: boolean } { let json: string; let parsed: unknown; @@ -335,18 +527,26 @@ function parseTaskExecution(raw: string): TaskExecutionResult { json = extractJson(raw); parsed = JSON.parse(json); } catch { - // Codex did not emit a JSON completion marker — fall back to text inference. - return inferOutcomeFromText(raw); + return { + result: inferOutcomeFromText(raw), + usedCompletionMarker: false, + }; } if (!parsed || typeof parsed !== "object") { - return inferOutcomeFromText(raw); + return { + result: inferOutcomeFromText(raw), + usedCompletionMarker: false, + }; } const candidate = parsed as { outcome?: unknown; error?: unknown }; if (candidate.outcome !== "done" && candidate.outcome !== "failed") { - return inferOutcomeFromText(raw); + return { + result: inferOutcomeFromText(raw), + usedCompletionMarker: false, + }; } if (candidate.error !== undefined && typeof candidate.error !== "string") { @@ -354,16 +554,120 @@ function parseTaskExecution(raw: string): TaskExecutionResult { } return { - outcome: candidate.outcome, - rawResponse: raw, - ...(typeof candidate.error === "string" ? { error: candidate.error } : {}), + result: { + outcome: candidate.outcome, + rawResponse: raw, + ...(typeof candidate.error === "string" ? { error: candidate.error } : {}), + }, + usedCompletionMarker: true, }; } +function collectCompletedTurnItems(result: CompletedTurn): ThreadItem[] { + return [...result.items, ...(Array.isArray(result.turn.items) ? result.turn.items : [])]; +} + +function taskLikelyMutatesFiles(task: Task): boolean { + const normalized = [task.name, task.description, ...task.acceptance_criteria].join("\n").toLowerCase(); + + return ( + normalized.includes(".ts") || + normalized.includes(".js") || + normalized.includes(".tsx") || + normalized.includes(".jsx") || + normalized.includes(".json") || + normalized.includes(".md") || + normalized.includes(".txt") || + normalized.includes("create ") || + normalized.includes("update ") || + normalized.includes("write ") || + normalized.includes("edit ") || + normalized.includes("export ") + ); +} + +function hasRecordedFileChanges(items: ThreadItem[]): boolean { + return items.some((item) => { + if (item.type !== "fileChange") { + return false; + } + + const status = "status" in item ? item.status : undefined; + const changes = "changes" in item ? item.changes : undefined; + return status === "completed" && Array.isArray(changes) && changes.length > 0; + }); +} + +function hasSuccessfulVerificationCommand(items: ThreadItem[]): boolean { + return items.some((item) => { + if (item.type !== "commandExecution") { + return false; + } + + const command = typeof item.command === "string" ? item.command : ""; + return ( + item.exitCode === 0 && + /(bun test|npm run test|npm test|validate|lint|typecheck|tsc|build|pytest|cargo test)/i.test(command) + ); + }); +} + +function enforceFallbackExecutionEvidence( + task: Task, + result: CompletedTurn, + parsedResult: TaskExecutionResult, + usedCompletionMarker: boolean, +): TaskExecutionResult { + if (usedCompletionMarker || parsedResult.outcome !== "done") { + return parsedResult; + } + + const items = collectCompletedTurnItems(result); + const fileChangesRecorded = hasRecordedFileChanges(items); + const verificationRan = hasSuccessfulVerificationCommand(items); + + if (taskLikelyMutatesFiles(task) && !fileChangesRecorded && !verificationRan) { + return { + outcome: "failed", + rawResponse: parsedResult.rawResponse, + error: + "Codex did not emit a JSON completion marker, no file changes were recorded, and no successful verification command ran for a task that required file edits.", + }; + } + + if (!fileChangesRecorded && !verificationRan && parsedResult.rawResponse.trim().length === 0) { + return { + outcome: "failed", + rawResponse: parsedResult.rawResponse, + error: "Codex did not emit a JSON completion marker or any concrete execution artifacts.", + }; + } + + return parsedResult; +} + function getModel(config?: OrcaConfig): string { return config?.codex?.model ?? process.env.ORCA_CODEX_MODEL ?? "gpt-5.3-codex"; } +function getPlannerRouterModel(config?: OrcaConfig): OpenAIModelId { + return ( + config?.planner?.router?.model ?? + (process.env.ORCA_PLANNER_ROUTER_MODEL as OpenAIModelId | undefined) ?? + "gpt-5.3-codex-spark" + ); +} + +function withPlannerRouterModel(config?: OrcaConfig): OrcaConfig { + return { + ...config, + codex: { + ...config?.codex, + model: getPlannerRouterModel(config), + }, + }; +} + type ThinkingStep = "decision" | "planning" | "review" | "execution"; const DEFAULT_THINKING_BY_STEP: Record = { @@ -374,6 +678,8 @@ const DEFAULT_THINKING_BY_STEP: Record = { }; const ANSWER_FILE_POLL_MS = 500; +const MAX_INLINE_SKILL_CONTEXT_CHARS = 24_000; +const MAX_INLINE_SKILL_BODY_CHARS = 4_000; function getEffort(config: OrcaConfig | undefined, step: ThinkingStep): CodexEffort { const explicitThinkingLevel = config?.codex?.thinkingLevel?.[step]; @@ -394,24 +700,45 @@ function buildTurnInput(text: string, skills: LoadedSkill[]): Array<{ type: "tex return [{ type: "text", text }]; } - const skillContext = usableSkills.map((skill) => [ - `Skill: ${skill.name}`, - `Source: ${skill.filePath}`, - skill.body.trim(), - ].join("\n")).join("\n\n"); - - return [{ - type: "text", - text: [ - text, - "Referenced Orca skills:", - skillContext, - ].join("\n\n"), - }]; + const sections: string[] = []; + let remainingChars = MAX_INLINE_SKILL_CONTEXT_CHARS; + let omittedCount = 0; + + for (const skill of usableSkills) { + const trimmedBody = skill.body.trim(); + const body = + trimmedBody.length > MAX_INLINE_SKILL_BODY_CHARS + ? `${trimmedBody.slice(0, MAX_INLINE_SKILL_BODY_CHARS)}\n[truncated]` + : trimmedBody; + const section = [`Skill: ${skill.name}`, `Source: ${skill.filePath}`, body].join("\n"); + + if (section.length > remainingChars) { + omittedCount += 1; + continue; + } + + sections.push(section); + remainingChars -= section.length + 2; + } + + if (omittedCount > 0) { + sections.push(`[${omittedCount} additional skills omitted to keep this Codex turn within context budget]`); + } + + return [ + { + type: "text", + text: [text, "Referenced Orca skills:", sections.join("\n\n")].join("\n\n"), + }, + ]; } interface RawSkill { name?: unknown; + description?: unknown; + shortDescription?: unknown; + interface?: unknown; + dependencies?: unknown; path?: unknown; } @@ -420,6 +747,26 @@ interface RawSkillsListEntry { skills?: unknown; } +function renderSkillMetadataBody(skill: RawSkill): string { + const sections: string[] = []; + + if (typeof skill.description === "string" && skill.description.trim().length > 0) { + sections.push(skill.description.trim()); + } else if (typeof skill.shortDescription === "string" && skill.shortDescription.trim().length > 0) { + sections.push(skill.shortDescription.trim()); + } + + if (skill.interface && typeof skill.interface === "object") { + sections.push(`Interface:\n${JSON.stringify(skill.interface, null, 2)}`); + } + + if (skill.dependencies && typeof skill.dependencies === "object") { + sections.push(`Dependencies:\n${JSON.stringify(skill.dependencies, null, 2)}`); + } + + return sections.join("\n\n").trim(); +} + function normalizePerCwdExtraUserRoots(config?: OrcaConfig): Array<{ cwd: string; extraUserRoots: string[] }> { const configured = config?.codex?.perCwdExtraUserRoots; if (!configured || configured.length === 0) { @@ -427,27 +774,95 @@ function normalizePerCwdExtraUserRoots(config?: OrcaConfig): Array<{ cwd: string } return configured - .filter((entry): entry is { cwd: string; extraUserRoots: string[] } => - typeof entry.cwd === "string" && Array.isArray(entry.extraUserRoots) + .filter( + (entry): entry is { cwd: string; extraUserRoots: string[] } => + typeof entry.cwd === "string" && Array.isArray(entry.extraUserRoots), ) .map((entry) => { const trimmedCwd = entry.cwd.trim(); return { cwd: trimmedCwd.length > 0 ? path.resolve(trimmedCwd) : "", extraUserRoots: entry.extraUserRoots - .filter((root): root is string => typeof root === "string") - .map((root) => root.trim()) - .filter((root) => root.length > 0), + .filter((root): root is string => typeof root === "string") + .map((root) => root.trim()) + .filter((root) => root.length > 0), }; }) .filter((entry) => entry.cwd.length > 0 && entry.extraUserRoots.length > 0); } -function getPerCwdExtraUserRootsForCwd(config: OrcaConfig | undefined, cwd: string): Array<{ cwd: string; extraUserRoots: string[] }> { +function getPerCwdExtraUserRootsForCwd( + config: OrcaConfig | undefined, + cwd: string, +): Array<{ cwd: string; extraUserRoots: string[] }> { const normalizedCwd = path.resolve(cwd); return normalizePerCwdExtraUserRoots(config).filter((entry) => entry.cwd === normalizedCwd); } +async function loadConfiguredPerCwdExtraRootSkills( + config: OrcaConfig | undefined, + cwd: string, +): Promise { + const configuredRoots = getPerCwdExtraUserRootsForCwd(config, cwd); + if (configuredRoots.length === 0) { + return []; + } + + const candidateDirs = new Set(); + for (const entry of configuredRoots) { + for (const root of entry.extraUserRoots) { + const resolvedRoot = path.resolve(root); + candidateDirs.add(resolvedRoot); + candidateDirs.add(path.join(resolvedRoot, "skills")); + candidateDirs.add(path.join(resolvedRoot, ".agents", "skills")); + candidateDirs.add(path.join(resolvedRoot, ".codex", "skills")); + } + } + + const discovered: LoadedSkill[] = []; + for (const candidateDir of candidateDirs) { + let entries; + try { + entries = await readdir(candidateDir, { withFileTypes: true, encoding: "utf8" }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + continue; + } + + throw error; + } + + for (const entry of entries) { + if (!entry.isDirectory()) { + continue; + } + + const skillDir = path.join(candidateDir, entry.name); + const skillFile = path.join(skillDir, "SKILL.md"); + let skillFileContent: string; + try { + skillFileContent = await readFile(skillFile, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + continue; + } + + throw error; + } + + const frontmatterMatch = skillFileContent.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/u); + discovered.push({ + name: entry.name, + description: "", + body: frontmatterMatch ? skillFileContent.slice(frontmatterMatch[0].length) : skillFileContent, + dirPath: skillDir, + filePath: skillFile, + }); + } + } + return discovered; +} + async function loadCodexListedSkills(client: CodexClient, cwd: string, config?: OrcaConfig): Promise { const perCwdExtraUserRoots = getPerCwdExtraUserRootsForCwd(config, cwd); @@ -470,7 +885,7 @@ async function loadCodexListedSkills(client: CodexClient, cwd: string, config?: this: unknown, method: string, params?: unknown, - timeoutMs?: number + timeoutMs?: number, ) => Promise; response = await request.call(client, "skills/list", { @@ -495,28 +910,36 @@ async function loadCodexListedSkills(client: CodexClient, cwd: string, config?: } for (const skill of entry.skills as RawSkill[]) { - if (!skill || typeof skill !== "object" || typeof skill.name !== "string" || typeof skill.path !== "string") { + if (!skill || typeof skill !== "object" || typeof skill.name !== "string") { continue; } - const normalizedSkillPath = skill.path.trim(); - if (normalizedSkillPath.length === 0) { - continue; + let skillBody = ""; + let normalizedSkillPath: string | null = null; + if (typeof skill.path === "string" && skill.path.trim().length > 0) { + normalizedSkillPath = skill.path.trim(); + try { + skillBody = await readFile(normalizedSkillPath, "utf8"); + } catch { + skillBody = ""; + } } - let skillBody = ""; - try { - skillBody = await readFile(normalizedSkillPath, "utf8"); - } catch { - skillBody = ""; + if (skillBody.trim().length === 0) { + skillBody = renderSkillMetadataBody(skill); } discovered.push({ name: skill.name, - description: "", + description: + typeof skill.description === "string" + ? skill.description + : typeof skill.shortDescription === "string" + ? skill.shortDescription + : "", body: skillBody, - dirPath: path.dirname(normalizedSkillPath), - filePath: normalizedSkillPath, + dirPath: normalizedSkillPath ? path.dirname(normalizedSkillPath) : cwd, + filePath: normalizedSkillPath ?? `${cwd}#skills/list:${skill.name}`, }); } } @@ -540,12 +963,17 @@ async function loadCodexListedSkills(client: CodexClient, cwd: string, config?: return discovered; } -async function resolveTurnSkills(client: CodexClient, config: OrcaConfig | undefined, cwd: string): Promise { - const baseSkills = await loadSkills(config); +async function resolveTurnSkills( + client: CodexClient, + config: OrcaConfig | undefined, + cwd: string, +): Promise { + const baseSkills = await skillLoader.loadSkills(config); + const configuredExtraRootSkills = await loadConfiguredPerCwdExtraRootSkills(config, cwd); const listedSkills = await loadCodexListedSkills(client, cwd, config); - if (listedSkills.length === 0) { + if (configuredExtraRootSkills.length === 0 && listedSkills.length === 0) { return baseSkills; } @@ -553,6 +981,11 @@ async function resolveTurnSkills(client: CodexClient, config: OrcaConfig | undef for (const skill of baseSkills) { mergedByName.set(skill.name, skill); } + for (const skill of configuredExtraRootSkills) { + if (!mergedByName.has(skill.name)) { + mergedByName.set(skill.name, skill); + } + } for (const skill of listedSkills) { if (!mergedByName.has(skill.name)) { mergedByName.set(skill.name, skill); @@ -636,11 +1069,12 @@ async function warnAboutUnavailableMcpServers(client: CodexClient): Promise - !!entry && - typeof entry === "object" && - typeof (entry as { name?: unknown }).name === "string" && - typeof (entry as { authStatus?: unknown }).authStatus === "string", + .filter( + (entry): entry is { name: string; authStatus: string } => + !!entry && + typeof entry === "object" && + typeof (entry as { name?: unknown }).name === "string" && + typeof (entry as { authStatus?: unknown }).authStatus === "string", ) .filter((entry) => entry.authStatus === "notLoggedIn") .map((entry) => entry.name); @@ -661,12 +1095,7 @@ function sleep(ms: number): Promise { }); } -async function appendRunError( - store: RunStore, - runId: RunId, - message: string, - taskId?: string, -): Promise { +async function appendRunError(store: RunStore, runId: RunId, message: string, taskId?: string): Promise { const run = await store.getRun(runId); if (!run) { return; @@ -682,9 +1111,130 @@ async function clearAnswerFile(store: RunStore, runId: RunId): Promise { await unlink(answerPath).catch(() => undefined); } +type ResumeOverallStatus = "planning" | "running"; + +type SecretAnswerChannelState = { + requestId: RequestId; + descriptor: PendingAnswerChannel; + nextSubmission: () => Promise; + close: () => Promise; +}; + +type SecretAnswerChannelFactory = (requestId: RequestId) => Promise; + +let testSecretAnswerChannelFactory: SecretAnswerChannelFactory | null = null; + +export function setSecretAnswerChannelFactoryForTests(factory: SecretAnswerChannelFactory | null): void { + testSecretAnswerChannelFactory = factory; +} + +function hasSecretQuestions( + params: ToolRequestUserInputParams | { questions: Array<{ isSecret?: boolean }> }, +): boolean { + return params.questions.some((question) => question.isSecret === true); +} + +async function createSecretAnswerChannel(requestId: RequestId): Promise { + if (testSecretAnswerChannelFactory) { + return await testSecretAnswerChannelFactory(requestId); + } + + const token = randomUUID(); + const socketPath = + process.platform === "win32" + ? `\\\\.\\pipe\\orca-answer-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}` + : path.join(os.tmpdir(), `orca-answer-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}.sock`); + const queuedAnswers: string[] = []; + const waitingResolvers: Array<(answer: string) => void> = []; + const server = createServer((socket) => { + socket.setEncoding("utf8"); + let buffer = ""; + let handled = false; + + socket.on("data", (chunk) => { + buffer += chunk; + if (handled || !buffer.includes("\n")) { + return; + } + + handled = true; + let response: { ok: boolean; error?: string } = { ok: true }; + + try { + const parsed = JSON.parse(buffer.trim()) as { token?: unknown; answer?: unknown }; + if (parsed.token !== token) { + throw new Error("invalid secret answer token"); + } + if (typeof parsed.answer !== "string" || parsed.answer.trim().length === 0) { + throw new Error("secret answer payload must include a non-empty answer string"); + } + + const resolver = waitingResolvers.shift(); + if (resolver) { + resolver(parsed.answer); + } else { + queuedAnswers.push(parsed.answer); + } + } catch (error) { + response = { + ok: false as const, + error: error instanceof Error ? error.message : String(error), + }; + } + + socket.end(`${JSON.stringify(response)}\n`); + }); + + socket.on("error", () => { + socket.destroy(); + }); + }); + + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(socketPath, () => { + server.off("error", reject); + resolve(); + }); + }); + + return { + requestId, + descriptor: { + transport: "ipc", + path: socketPath, + token, + }, + nextSubmission: async () => { + const next = queuedAnswers.shift(); + if (next !== undefined) { + return next; + } + + return await new Promise((resolve) => { + waitingResolvers.push(resolve); + }); + }, + close: async () => { + for (const resolve of waitingResolvers.splice(0)) { + resolve(""); + } + + await new Promise((resolve) => { + server.close(() => resolve()); + }); + + if (process.platform !== "win32") { + await unlink(socketPath).catch(() => undefined); + } + }, + }; +} + export interface SessionInteractionContext { runId: RunId; store: RunStore; + resumeOverallStatus?: ResumeOverallStatus; emitHook?: (event: HookEvent) => Promise; } @@ -703,6 +1253,7 @@ export async function createCodexSession( interactionContext?: SessionInteractionContext, ): Promise<{ decidePlanningNeed: (spec: string, systemContext: string) => Promise; + selectPlannerAgent: (spec: string, systemContext: string) => Promise; planSpec: (spec: string, systemContext: string) => Promise; reviewTaskGraph: (tasks: Task[], systemContext: string) => Promise; executeTask: (task: Task, runId: string, systemContext?: string) => Promise; @@ -726,8 +1277,60 @@ export async function createCodexSession( attachCodexStderrDiagnostics(client, codexPath); await client.connect(); await warnAboutUnavailableMcpServers(client); + const collaborationModeAvailable = await detectCollaborationModeSupport( + client, + codexPath, + interactionContext !== undefined, + ); let activeTaskContext: { taskId: string; taskName: string } | undefined; + let activeSecretAnswerChannel: SecretAnswerChannelState | undefined; + const resumedOverallStatus: ResumeOverallStatus = interactionContext?.resumeOverallStatus ?? "running"; + const resolvedServerRequests = new Set(); + const clarificationToolAvailable = interactionContext !== undefined && collaborationModeAvailable; + + const buildRunTurnParams = ( + step: ThinkingStep, + input: Array<{ type: "text"; text: string }>, + enableQuestionTool = false, + ) => { + const effort = getEffort(config, step); + const usePlanCollaborationMode = enableQuestionTool && step !== "execution"; + return { + threadId, + effort, + input, + ...(usePlanCollaborationMode + ? { + collaborationMode: { + mode: "plan" as const, + settings: { + model: getModel(config), + reasoning_effort: effort, + developer_instructions: null, + }, + }, + } + : {}), + }; + }; + + const buildExecutionClarificationTurnParams = (input: Array<{ type: "text"; text: string }>) => { + const effort = getEffort(config, "execution"); + return { + threadId, + effort, + input, + collaborationMode: { + mode: "plan" as const, + settings: { + model: getModel(config), + reasoning_effort: effort, + developer_instructions: null, + }, + }, + }; + }; const respondToUserInputRequest = (requestId: RequestId, response: ToolRequestUserInputResponse): void => { const specificResponder = Reflect.get(client as object, "respondToUserInputRequest"); @@ -755,7 +1358,21 @@ export async function createCodexSession( throw new Error("Codex client does not support rejecting server requests"); }; - const clearPendingQuestion = async (requestId: RequestId, overallStatus: "running" | "waiting_for_answer"): Promise => { + const clearPendingQuestion = async ( + requestId: RequestId, + overallStatus?: ResumeOverallStatus | "waiting_for_answer", + ): Promise => { + const secretAnswerChannel = activeSecretAnswerChannel; + if (secretAnswerChannel?.requestId === requestId) { + if (interactionContext) { + await clearSecretAnswerChannel(interactionContext.runId).catch(() => undefined); + } + await secretAnswerChannel.close().catch(() => undefined); + if (activeSecretAnswerChannel === secretAnswerChannel) { + activeSecretAnswerChannel = undefined; + } + } + if (!interactionContext) { return; } @@ -766,69 +1383,120 @@ export async function createCodexSession( } await interactionContext.store.updateRun(interactionContext.runId, { - overallStatus, + ...(overallStatus ? { overallStatus } : {}), pendingQuestion: undefined, }); }; const on = Reflect.get(client as object, "on"); if (typeof on === "function") { - on.call( - client, - "request:userInput", - (request: { requestId: RequestId } & ToolRequestUserInputParams) => { - void (async () => { - if (!interactionContext) { + on.call(client, "request:userInput", (request: { requestId: RequestId } & ToolRequestUserInputParams) => { + void (async () => { + if (!interactionContext) { + rejectUserInputRequest( + request.requestId, + "Orca cannot answer Codex requestUserInput prompts without an interactive run context.", + ); + return; + } + + const pendingQuestion = createPendingQuestion(request.requestId, request); + const currentRun = await interactionContext.store.getRun(interactionContext.runId); + if (!currentRun) { + rejectUserInputRequest( + request.requestId, + `Run not found while waiting for input: ${interactionContext.runId}`, + ); + return; + } + + if ( + currentRun.overallStatus === "completed" || + currentRun.overallStatus === "failed" || + currentRun.overallStatus === "cancelled" + ) { + rejectUserInputRequest( + request.requestId, + `Run ${interactionContext.runId} is already ${currentRun.overallStatus}; ignoring late requestUserInput prompt.`, + ); + return; + } + + await clearAnswerFile(interactionContext.store, interactionContext.runId); + let secretAnswerChannel: SecretAnswerChannelState | undefined; + if (hasSecretQuestions(request)) { + secretAnswerChannel = await createSecretAnswerChannel(request.requestId); + activeSecretAnswerChannel = secretAnswerChannel; + await writeSecretAnswerChannel(interactionContext.runId, secretAnswerChannel.descriptor); + } else if (activeSecretAnswerChannel) { + await clearSecretAnswerChannel(interactionContext.runId).catch(() => undefined); + await activeSecretAnswerChannel.close().catch(() => undefined); + activeSecretAnswerChannel = undefined; + } + + await interactionContext.store.updateRun(interactionContext.runId, { + overallStatus: "waiting_for_answer", + pendingQuestion, + }); + + if (interactionContext.emitHook) { + await interactionContext.emitHook({ + runId: interactionContext.runId, + hook: "onQuestion", + message: buildQuestionHookMessage(pendingQuestion), + timestamp: pendingQuestion.receivedAt, + requestId: pendingQuestion.requestId, + threadId: pendingQuestion.threadId, + turnId: pendingQuestion.turnId, + itemId: pendingQuestion.itemId, + questions: pendingQuestion.questions, + ...(activeTaskContext ? { taskId: activeTaskContext.taskId, taskName: activeTaskContext.taskName } : {}), + metadata: { + questionCount: pendingQuestion.questions.length, + }, + }); + } + + const answerPath = path.join(interactionContext.store.getRunDir(interactionContext.runId), "answer.txt"); + let nextSecretAnswer = secretAnswerChannel?.nextSubmission(); + + while (true) { + const currentRun = await interactionContext.store.getRun(interactionContext.runId); + if (!currentRun) { rejectUserInputRequest( request.requestId, - "Orca cannot answer Codex requestUserInput prompts without an interactive run context.", + `Run not found while waiting for answer: ${interactionContext.runId}`, ); return; } - const pendingQuestion = createPendingQuestion(request.requestId, request); - await clearAnswerFile(interactionContext.store, interactionContext.runId); - await interactionContext.store.updateRun(interactionContext.runId, { - overallStatus: "waiting_for_answer", - pendingQuestion, - }); - - if (interactionContext.emitHook) { - await interactionContext.emitHook({ - runId: interactionContext.runId, - hook: "onQuestion", - message: buildQuestionHookMessage(pendingQuestion), - timestamp: pendingQuestion.receivedAt, - requestId: pendingQuestion.requestId, - threadId: pendingQuestion.threadId, - turnId: pendingQuestion.turnId, - itemId: pendingQuestion.itemId, - questions: pendingQuestion.questions, - ...(activeTaskContext - ? { taskId: activeTaskContext.taskId, taskName: activeTaskContext.taskName } - : {}), - metadata: { - questionCount: pendingQuestion.questions.length, - }, - }); + if (currentRun.overallStatus === "cancelled") { + rejectUserInputRequest( + request.requestId, + `Run ${interactionContext.runId} was cancelled while waiting for input.`, + ); + await clearPendingQuestion(request.requestId); + return; } - const answerPath = path.join(interactionContext.store.getRunDir(interactionContext.runId), "answer.txt"); - - while (true) { - const currentRun = await interactionContext.store.getRun(interactionContext.runId); - if (!currentRun) { - rejectUserInputRequest(request.requestId, `Run not found while waiting for answer: ${interactionContext.runId}`); - return; - } + if (resolvedServerRequests.delete(request.requestId)) { + await clearPendingQuestion(request.requestId, resumedOverallStatus); + return; + } - if (currentRun.overallStatus === "cancelled") { - rejectUserInputRequest(request.requestId, `Run ${interactionContext.runId} was cancelled while waiting for input.`); - await clearPendingQuestion(request.requestId, "waiting_for_answer"); - return; + let rawAnswer: string; + if (secretAnswerChannel) { + const submittedSecretAnswer = await Promise.race([ + nextSecretAnswer ?? Promise.resolve(""), + sleep(ANSWER_FILE_POLL_MS).then(() => null), + ]); + if (submittedSecretAnswer === null) { + continue; } - let rawAnswer: string; + rawAnswer = submittedSecretAnswer; + nextSecretAnswer = secretAnswerChannel.nextSubmission(); + } else { try { rawAnswer = await readFile(answerPath, "utf8"); } catch (error) { @@ -839,73 +1507,104 @@ export async function createCodexSession( throw error; } - - try { - const parsedAnswer = parseQuestionAnswerInput(rawAnswer, pendingQuestion); - respondToUserInputRequest(request.requestId, parsedAnswer); - await clearAnswerFile(interactionContext.store, interactionContext.runId); - await clearPendingQuestion(request.requestId, "running"); - return; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - logger.warn(`Invalid answer for run ${interactionContext.runId}; waiting for another response (${message})`); - await appendRunError( - interactionContext.store, - interactionContext.runId, - `invalid-answer: ${message}`, - activeTaskContext?.taskId, - ); - await clearAnswerFile(interactionContext.store, interactionContext.runId); - } } - })().catch(async (error) => { - const message = error instanceof Error ? error.message : String(error); - logger.warn(`Failed while handling Codex requestUserInput: ${message}`); - if (interactionContext) { - await appendRunError(interactionContext.store, interactionContext.runId, `request-user-input-failed: ${message}`, activeTaskContext?.taskId); + + try { + const parsedAnswer = parseQuestionAnswerInput(rawAnswer, pendingQuestion); + respondToUserInputRequest(request.requestId, parsedAnswer); + await clearAnswerFile(interactionContext.store, interactionContext.runId); + await clearPendingQuestion(request.requestId, resumedOverallStatus); + return; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.warn( + `Invalid answer for run ${interactionContext.runId}; waiting for another response (${message})`, + ); + await appendRunError( + interactionContext.store, + interactionContext.runId, + `invalid-answer: ${message}`, + activeTaskContext?.taskId, + ); + await clearAnswerFile(interactionContext.store, interactionContext.runId); } - }); - }, - ); + } + })().catch(async (error) => { + const message = error instanceof Error ? error.message : String(error); + logger.warn(`Failed while handling Codex requestUserInput: ${message}`); + if (interactionContext) { + await appendRunError( + interactionContext.store, + interactionContext.runId, + `request-user-input-failed: ${message}`, + activeTaskContext?.taskId, + ); + } + }); + }); on.call(client, "serverRequest:resolved", (notification: { requestId: RequestId }) => { - void clearPendingQuestion(notification.requestId, "running"); + resolvedServerRequests.add(notification.requestId); + void clearPendingQuestion(notification.requestId, resumedOverallStatus); }); } let skills: LoadedSkill[]; let threadId: string; - try { - skills = await resolveTurnSkills(client, config, cwd); + const startNewThread = async (): Promise => { const thread = await client.startThread({}); threadId = thread.id; + return threadId; + }; + try { + skills = await resolveTurnSkills(client, config, cwd); + await startNewThread(); } catch (error) { await client.disconnect(); throw error; } return { - threadId, + get threadId(): string { + return threadId; + }, async decidePlanningNeed(spec: string, systemContext: string): Promise { - const result = await client.runTurn({ - threadId, - effort: getEffort(config, "decision"), - input: buildTurnInput(buildPlanDecisionPrompt(spec, systemContext), skills), - }); + const result = await client.runTurn( + buildRunTurnParams( + "decision", + buildTurnInput(buildPlanDecisionPrompt(spec, systemContext, clarificationToolAvailable), skills), + clarificationToolAvailable, + ), + ); const rawResponse = extractAgentText(result); return parsePlanDecision(rawResponse); }, - async planSpec( - spec: string, - systemContext: string, - ): Promise { + async selectPlannerAgent(spec: string, systemContext: string): Promise { + const result = await client.runTurn( + buildRunTurnParams( + "decision", + buildTurnInput(buildPlannerRoutingPrompt(spec, systemContext, clarificationToolAvailable), skills), + clarificationToolAvailable, + ), + ); + + const rawResponse = extractAgentText(result); + return parsePlannerRoutingDecision(rawResponse); + }, + + async planSpec(spec: string, systemContext: string): Promise { const result = await client.runTurn({ - threadId, - effort: getEffort(config, "planning"), - input: buildTurnInput(buildPlanningPrompt(spec, systemContext, multiAgentActive), skills), + ...buildRunTurnParams( + "planning", + buildTurnInput( + buildPlanningPrompt(spec, systemContext, multiAgentActive, clarificationToolAvailable), + skills, + ), + clarificationToolAvailable, + ), }); const rawResponse = extractAgentText(result); @@ -918,61 +1617,74 @@ export async function createCodexSession( async reviewTaskGraph(tasks: Task[], systemContext: string): Promise { const result = await client.runTurn({ - threadId, - effort: getEffort(config, "review"), - input: buildTurnInput(buildTaskGraphReviewPrompt(tasks, systemContext, multiAgentActive), skills), + ...buildRunTurnParams( + "review", + buildTurnInput( + buildTaskGraphReviewPrompt(tasks, systemContext, multiAgentActive, clarificationToolAvailable), + skills, + ), + clarificationToolAvailable, + ), }); const rawResponse = extractAgentText(result); return parseTaskGraphReview(rawResponse); }, - async executeTask( - task: Task, - runId: string, - systemContext?: string, - ): Promise { + async executeTask(task: Task, runId: string, systemContext?: string): Promise { activeTaskContext = { taskId: task.id, taskName: task.name }; + let clarificationContext = ""; let result: CompletedTurn; try { + if (clarificationToolAvailable) { + const clarificationResult = await client.runTurn( + buildExecutionClarificationTurnParams( + buildTurnInput(buildTaskExecutionClarificationPrompt(task, runId, cwd, systemContext), skills), + ), + ); + const clarificationRawResponse = extractAgentText(clarificationResult); + const clarificationDecision = parseExecutionClarificationDecision(clarificationRawResponse); + clarificationContext = clarificationDecision.context.trim(); + await startNewThread(); + } + result = await client.runTurn({ - threadId, - effort: getEffort(config, "execution"), - input: buildTurnInput(buildTaskExecutionPrompt(task, runId, cwd, systemContext, multiAgentActive), skills), + ...buildRunTurnParams( + "execution", + buildTurnInput( + buildTaskExecutionPrompt(task, runId, cwd, systemContext, multiAgentActive, clarificationContext), + skills, + ), + false, + ), }); } finally { activeTaskContext = undefined; } const rawResponse = extractAgentText(result); - - // Primary signal: use the SDK's structured turn status. + const { result: parsedTaskResult, usedCompletionMarker } = parseTaskExecutionWithSource(rawResponse); + const parsedResult = enforceFallbackExecutionEvidence(task, result, parsedTaskResult, usedCompletionMarker); const status = result.turn.status; - if (status === "completed") { - return { outcome: "done", rawResponse }; - } if (status === "failed") { return { outcome: "failed", - error: result.turn.error?.message ?? "Turn failed", + error: parsedResult.error ?? result.turn.error?.message ?? "Turn failed", rawResponse, }; } if (status === "interrupted") { - return { outcome: "failed", error: "Turn was interrupted", rawResponse }; + return { outcome: "failed", error: parsedResult.error ?? "Turn was interrupted", rawResponse }; } - // Fallback: status is unexpected/missing — parse text as before. - return parseTaskExecution(rawResponse); + return parsedResult; }, async consultTaskGraph(tasks: Task[]): Promise { - const prompt = buildTaskGraphConsultationPrompt(tasks, multiAgentActive); + const prompt = buildTaskGraphConsultationPrompt(tasks, multiAgentActive, clarificationToolAvailable); const result = await client.runTurn({ - threadId, - effort: getEffort(config, "review"), - input: buildTurnInput(prompt, skills), + ...buildRunTurnParams("review", buildTurnInput(prompt, skills), clarificationToolAvailable), }); const rawResponse = extractAgentText(result); @@ -1004,15 +1716,20 @@ export async function createCodexSession( async runPrompt(prompt: string, step: ThinkingStep = "execution"): Promise { const result = await client.runTurn({ - threadId, - effort: getEffort(config, step), - input: buildTurnInput(prompt, skills), + ...buildRunTurnParams(step, buildTurnInput(prompt, skills), false), }); return extractAgentText(result); }, async disconnect(): Promise { + if (activeSecretAnswerChannel) { + if (interactionContext) { + await clearSecretAnswerChannel(interactionContext.runId).catch(() => undefined); + } + await activeSecretAnswerChannel.close().catch(() => undefined); + activeSecretAnswerChannel = undefined; + } await client.disconnect(); }, }; @@ -1053,6 +1770,21 @@ export async function planSpec( } } +export async function selectPlannerAgent( + spec: string, + systemContext: string, + config?: OrcaConfig, + interactionContext?: SessionInteractionContext, +): Promise { + const session = await createCodexSession(process.cwd(), withPlannerRouterModel(config), interactionContext); + + try { + return await session.selectPlannerAgent(spec, systemContext); + } finally { + await session.disconnect(); + } +} + export async function reviewTaskGraph( tasks: Task[], systemContext: string, diff --git a/src/agents/codex/session.unit.test.ts b/src/agents/codex/session.unit.test.ts index b876be2..ec75e1b 100644 --- a/src/agents/codex/session.unit.test.ts +++ b/src/agents/codex/session.unit.test.ts @@ -117,41 +117,12 @@ describe("codex session effort wiring", () => { } }); - test("smoke: uses per-step thinkingLevel values for decision/planning/review/execution turns", async () => { - const efforts: string[] = []; - const runTurnMock = mock(async (params: { effort?: string; input?: Array<{ text?: string }> }) => { - efforts.push(params.effort ?? ""); - const prompt = params.input?.[0]?.text ?? ""; - if (prompt.includes("planning gate")) { - return { - agentMessage: '{"needsPlan":true,"reason":"multi-step"}', - turn: { status: "completed" }, - items: [], - }; - } - - if (prompt.includes("pre-execution task-graph reviewer")) { - return { - agentMessage: '{"changes":[]}', - turn: { status: "completed" }, - items: [], - }; - } - - if (prompt.includes("Review this Orca task graph before execution.")) { - return { - agentMessage: '{"issues":[],"ok":true}', - turn: { status: "completed" }, - items: [], - }; - } - - return { - agentMessage: "[]", - turn: { status: "completed" }, - items: [], - }; - }); + test("executeTask respects an assistant failure marker even when turn status is completed", async () => { + const runTurnMock = mock(async () => ({ + agentMessage: '{"outcome":"failed","error":"missing dependency"}', + turn: { status: "completed" }, + items: [], + })); mockMultiAgentDetection(false); mock.module("@ratley/codex-client", () => ({ @@ -173,22 +144,10 @@ describe("codex session effort wiring", () => { })); const { createCodexSession } = await import(`./session.ts?test=${Math.random()}`); - const session = await createCodexSession(process.cwd(), { - codex: { - thinkingLevel: { - decision: "low", - planning: "xhigh", - review: "high", - execution: "medium", - }, - }, - }); + const session = await createCodexSession(process.cwd()); try { - await session.decidePlanningNeed("spec", "context"); - await session.planSpec("spec", "context"); - await session.reviewTaskGraph([], "context"); - await session.executeTask( + const result = await session.executeTask( { id: "t1", name: "Task", @@ -202,38 +161,32 @@ describe("codex session effort wiring", () => { "run-1", "context", ); - await session.consultTaskGraph([]); - await session.runPrompt("review prompt", "review"); - expect(efforts).toEqual(["low", "xhigh", "high", "medium", "high", "high"]); + expect(result).toEqual({ + outcome: "failed", + error: "missing dependency", + rawResponse: '{"outcome":"failed","error":"missing dependency"}', + }); } finally { await session.disconnect(); } }); -}); - -describe("codex session code-simplifier guidance", () => { - test("includes explicit code-simplifier directives in planning, review, and execution prompts", async () => { - const prompts: string[] = []; - const runTurnMock = mock(async (params: { input?: Array<{ text?: string }> }) => { - const prompt = params.input?.[0]?.text ?? ""; - prompts.push(prompt); - - if (prompt.includes("pre-execution task-graph reviewer")) { - return { - agentMessage: '{"changes":[]}', - turn: { status: "completed" }, - items: [] - }; - } - - return { - agentMessage: "[]", - turn: { status: "completed" }, - items: [] - }; - }); + test("falls back to completed turn items when streamed agentMessage text is missing", async () => { + const runTurnMock = mock(async () => ({ + agentMessage: "", + turn: { + status: "completed", + items: [ + { + type: "agentMessage", + id: "msg-1", + text: '{"outcome":"done"}', + }, + ], + }, + items: [], + })); mockMultiAgentDetection(false); mock.module("@ratley/codex-client", () => ({ @@ -247,7 +200,7 @@ describe("codex session code-simplifier guidance", () => { async runReview(): Promise<{ reviewText: string }> { return { reviewText: "ok" }; } - } + }, })); mock.module("../../utils/skill-loader.js", () => ({ @@ -258,9 +211,7 @@ describe("codex session code-simplifier guidance", () => { const session = await createCodexSession(process.cwd()); try { - await session.planSpec("spec", "context"); - await session.reviewTaskGraph([], "context"); - await session.executeTask( + const result = await session.executeTask( { id: "t1", name: "Task", @@ -269,62 +220,45 @@ describe("codex session code-simplifier guidance", () => { acceptance_criteria: ["Done"], status: "pending", retries: 0, - maxRetries: 3 + maxRetries: 3, }, "run-1", - "context" + "context", ); - expect(prompts).toHaveLength(3); - for (const prompt of prompts) { - expect(prompt).toContain("For every code-writing step, explicitly apply code-simplifier guidance"); - expect(prompt).toContain("For every code-review step, explicitly apply code-simplifier guidance"); - expect(prompt).toContain("Keep changes behavior-preserving unless the task explicitly requires behavior changes."); - } + expect(result).toEqual({ + outcome: "done", + rawResponse: '{"outcome":"done"}', + }); } finally { await session.disconnect(); } }); -}); - -describe("codex session multi-agent prompt guidance", () => { - test("includes multi-agent guidance in planning, review, consultation, and execution prompts when active", async () => { - const prompts: string[] = []; - const runTurnMock = mock(async (params: { input?: Array<{ text?: string }> }) => { - const prompt = params.input?.[0]?.text ?? ""; - prompts.push(prompt); - - if (prompt.includes("pre-execution task-graph reviewer")) { - return { - agentMessage: '{"changes":[]}', - turn: { status: "completed" }, - items: [], - }; - } - if (prompt.includes("Review this Orca task graph before execution.")) { + test("reuses the persistent Codex thread across planning and execution", async () => { + const startThreadMock = mock(async () => ({ id: `thread-${startThreadMock.mock.calls.length + 1}` })); + const runTurnMock = mock(async () => { + if (runTurnMock.mock.calls.length === 1) { return { - agentMessage: '{"issues":[],"ok":true}', - turn: { status: "completed" }, + agentMessage: "[]", + turn: { status: "completed", items: [] }, items: [], }; } return { - agentMessage: "[]", - turn: { status: "completed" }, + agentMessage: '{"outcome":"done"}', + turn: { status: "completed", items: [] }, items: [], }; }); - mockMultiAgentDetection(true); + mockMultiAgentDetection(false); mock.module("@ratley/codex-client", () => ({ CodexClient: class { async connect(): Promise {} async disconnect(): Promise {} - async startThread(): Promise<{ id: string }> { - return { id: "thread-1" }; - } + startThread = startThreadMock; runTurn = runTurnMock; async runReview(): Promise<{ reviewText: string }> { return { reviewText: "ok" }; @@ -340,14 +274,13 @@ describe("codex session multi-agent prompt guidance", () => { const session = await createCodexSession(process.cwd()); try { + const initialThreadId = session.threadId; await session.planSpec("spec", "context"); - await session.reviewTaskGraph([], "context"); - await session.consultTaskGraph([]); await session.executeTask( { id: "t1", name: "Task", - description: "Do thing", + description: "Update index.ts and run tests", dependencies: [], acceptance_criteria: ["Done"], status: "pending", @@ -358,56 +291,30 @@ describe("codex session multi-agent prompt guidance", () => { "context", ); - const planningPrompt = prompts.find((prompt) => prompt.includes("You are decomposing a spec into an ordered task graph.")) ?? ""; - const reviewPrompt = prompts.find((prompt) => prompt.includes("You are Orca's pre-execution task-graph reviewer.")) ?? ""; - const consultationPrompt = prompts.find((prompt) => prompt.includes("Review this Orca task graph before execution.")) ?? ""; - const executionPrompt = prompts.find((prompt) => prompt.includes("You are Orca's task execution assistant.")) ?? ""; - - expect(planningPrompt).toContain("Codex multi-agent mode is enabled for this run. Shape the task graph so safe subagent parallelization is obvious."); - expect(planningPrompt).toContain("Do not bundle unrelated work into a single do-everything task when it can be safely split."); - - expect(reviewPrompt).toContain("Codex multi-agent mode is enabled for this run. Review the graph for safe subagent parallelization."); - expect(reviewPrompt).toContain("Flag ownership collisions where multiple tasks would touch the same files or subsystem without coordination."); - - expect(consultationPrompt).toContain("Codex multi-agent mode is enabled for this run."); - expect(consultationPrompt).toContain("Treat missed safe parallelism, fake dependencies, overlapping ownership, or missing integration tasks as review concerns."); - - expect(executionPrompt).toContain("Codex multi-agent mode is enabled for this run."); - expect(executionPrompt).toContain("If this task contains clearly independent subtasks with disjoint ownership, use subagents to parallelize them."); - expect(executionPrompt).toContain("Integrate subagent results yourself before final completion."); + expect(startThreadMock).toHaveBeenCalledTimes(1); + const executeCall = (runTurnMock.mock.calls as Array>)[1]?.[0]; + expect(executeCall?.threadId).toBe(session.threadId); + expect(session.threadId).toBe(initialThreadId); } finally { await session.disconnect(); } }); - test("omits multi-agent guidance from planning, review, consultation, and execution prompts when inactive", async () => { - const prompts: string[] = []; - const runTurnMock = mock(async (params: { input?: Array<{ text?: string }> }) => { - const prompt = params.input?.[0]?.text ?? ""; - prompts.push(prompt); - - if (prompt.includes("pre-execution task-graph reviewer")) { - return { - agentMessage: '{"changes":[]}', - turn: { status: "completed" }, - items: [], - }; - } - - if (prompt.includes("Review this Orca task graph before execution.")) { - return { - agentMessage: '{"issues":[],"ok":true}', - turn: { status: "completed" }, - items: [], - }; - } - - return { - agentMessage: "[]", - turn: { status: "completed" }, - items: [], - }; - }); + test("fails fallback success for file-edit tasks when no file changes were recorded", async () => { + const runTurnMock = mock(async () => ({ + agentMessage: "Implemented the requested update.", + turn: { + status: "completed", + items: [ + { + type: "agentMessage", + id: "msg-1", + text: "Implemented the requested update.", + }, + ], + }, + items: [], + })); mockMultiAgentDetection(false); mock.module("@ratley/codex-client", () => ({ @@ -432,16 +339,13 @@ describe("codex session multi-agent prompt guidance", () => { const session = await createCodexSession(process.cwd()); try { - await session.planSpec("spec", "context"); - await session.reviewTaskGraph([], "context"); - await session.consultTaskGraph([]); - await session.executeTask( + const result = await session.executeTask( { id: "t1", - name: "Task", - description: "Do thing", + name: "Write file", + description: "Create codename.txt with the exact answer.", dependencies: [], - acceptance_criteria: ["Done"], + acceptance_criteria: ["codename.txt exists"], status: "pending", retries: 0, maxRetries: 3, @@ -450,20 +354,48 @@ describe("codex session multi-agent prompt guidance", () => { "context", ); - for (const prompt of prompts) { - expect(prompt).not.toContain("Codex multi-agent mode is enabled for this run."); - expect(prompt).not.toContain("use subagents to parallelize them"); - expect(prompt).not.toContain("safe subagent parallelization"); - } + expect(result).toEqual({ + outcome: "failed", + rawResponse: "Implemented the requested update.", + error: + "Codex did not emit a JSON completion marker, no file changes were recorded, and no successful verification command ran for a task that required file edits.", + }); } finally { await session.disconnect(); } }); -}); -describe("codex session skill discovery", () => { - test("calls skills/list with forceReload and perCwdExtraUserRoots", async () => { - const requestMock = mock(async () => ({ data: [] })); + test("accepts verification-backed shell edits when no file changes were recorded", async () => { + const runTurnMock = mock(async () => ({ + agentMessage: "Implemented the requested update and verified it.", + turn: { + status: "completed", + items: [ + { + type: "agentMessage", + id: "msg-1", + text: "Implemented the requested update and verified it.", + }, + { + type: "commandExecution", + id: "cmd-1", + command: "python scripts/update_release.py", + status: "completed", + exitCode: 0, + commandActions: [], + }, + { + type: "commandExecution", + id: "cmd-2", + command: "bun test", + status: "completed", + exitCode: 0, + commandActions: [], + }, + ], + }, + items: [], + })); mockMultiAgentDetection(false); mock.module("@ratley/codex-client", () => ({ @@ -473,10 +405,7 @@ describe("codex session skill discovery", () => { async startThread(): Promise<{ id: string }> { return { id: "thread-1" }; } - async runTurn(): Promise<{ agentMessage: string; turn: { status: "completed" }; items: [] }> { - return { agentMessage: "[]", turn: { status: "completed" }, items: [] }; - } - request = requestMock; + runTurn = runTurnMock; async runReview(): Promise<{ reviewText: string }> { return { reviewText: "ok" }; } @@ -488,41 +417,78 @@ describe("codex session skill discovery", () => { })); const { createCodexSession } = await import(`./session.ts?test=${Math.random()}`); - const cwd = process.cwd(); - const session = await createCodexSession(cwd, { - codex: { - perCwdExtraUserRoots: [{ cwd, extraUserRoots: ["/tmp/extra-skills"] }], - }, - }); + const session = await createCodexSession(process.cwd()); try { - expect(requestMock).toHaveBeenCalledWith("skills/list", { - cwds: [cwd], - forceReload: true, - perCwdExtraUserRoots: [{ cwd, extraUserRoots: ["/tmp/extra-skills"] }], + const result = await session.executeTask( + { + id: "t1", + name: "Write file", + description: "Create codename.txt with the exact answer.", + dependencies: [], + acceptance_criteria: ["codename.txt exists"], + status: "pending", + retries: 0, + maxRetries: 3, + }, + "run-1", + "context", + ); + + expect(result).toEqual({ + outcome: "done", + rawResponse: "Implemented the requested update and verified it.", }); } finally { await session.disconnect(); } }); - test("merges app-server listed skills after Orca-loaded skills without overriding deterministic precedence", async () => { - type TurnInputItem = { type: "text"; text: string }; - - let capturedInput: TurnInputItem[] = []; - const listedSkillsRoot = await mkdtemp(path.join(os.tmpdir(), "orca-listed-skills-")); - const alphaSkillPath = path.join(listedSkillsRoot, "alpha-skill", "SKILL.md"); - const codeSimplifierPath = path.join(listedSkillsRoot, "code-simplifier", "SKILL.md"); - const zetaSkillPath = path.join(listedSkillsRoot, "zeta-skill", "SKILL.md"); + test("smoke: uses per-step thinkingLevel values for decision/planning/review/execution turns", async () => { + const efforts: string[] = []; + const runTurnMock = mock(async (params: { effort?: string; input?: Array<{ text?: string }> }) => { + efforts.push(params.effort ?? ""); + const prompt = params.input?.[0]?.text ?? ""; + if (prompt.includes("planning gate")) { + return { + agentMessage: '{"needsPlan":true,"reason":"multi-step"}', + turn: { status: "completed" }, + items: [], + }; + } - await mkdir(path.dirname(alphaSkillPath), { recursive: true }); - await mkdir(path.dirname(codeSimplifierPath), { recursive: true }); - await mkdir(path.dirname(zetaSkillPath), { recursive: true }); - await writeFile(alphaSkillPath, "alpha body", "utf8"); - await writeFile(codeSimplifierPath, "server code simplifier body", "utf8"); - await writeFile(zetaSkillPath, "zeta body", "utf8"); + if (prompt.includes("pre-execution task-graph reviewer")) { + return { + agentMessage: '{"changes":[]}', + turn: { status: "completed" }, + items: [], + }; + } - mockMultiAgentDetection(false); + if (prompt.includes("Review this Orca task graph before execution.")) { + return { + agentMessage: '{"issues":[],"ok":true}', + turn: { status: "completed" }, + items: [], + }; + } + + if (prompt.includes("execution clarification gate")) { + return { + agentMessage: '{"needsInput":false,"context":"No extra clarification needed."}', + turn: { status: "completed" }, + items: [], + }; + } + + return { + agentMessage: "[]", + turn: { status: "completed" }, + items: [], + }; + }); + + mockMultiAgentDetection(false); mock.module("@ratley/codex-client", () => ({ CodexClient: class { async connect(): Promise {} @@ -530,23 +496,7 @@ describe("codex session skill discovery", () => { async startThread(): Promise<{ id: string }> { return { id: "thread-1" }; } - async runTurn(params: { input?: TurnInputItem[] }): Promise<{ agentMessage: string; turn: { status: "completed" }; items: [] }> { - capturedInput = params.input ?? []; - return { agentMessage: "[]", turn: { status: "completed" }, items: [] }; - } - async request(): Promise<{ data: Array<{ skills: Array<{ name: string; path: string }> }> }> { - return { - data: [ - { - skills: [ - { name: "zeta-skill", path: zetaSkillPath }, - { name: "code-simplifier", path: codeSimplifierPath }, - { name: "alpha-skill", path: alphaSkillPath }, - ], - }, - ], - }; - } + runTurn = runTurnMock; async runReview(): Promise<{ reviewText: string }> { return { reviewText: "ok" }; } @@ -554,53 +504,80 @@ describe("codex session skill discovery", () => { })); mock.module("../../utils/skill-loader.js", () => ({ - loadSkills: async () => [ - { - name: "code-simplifier", - description: "desc", - body: "body", - dirPath: "/tmp/skills/code-simplifier", - filePath: "/tmp/skills/code-simplifier/SKILL.md", - }, - ], + loadSkills: async () => [], })); const { createCodexSession } = await import(`./session.ts?test=${Math.random()}`); - const session = await createCodexSession(process.cwd()); + const session = await createCodexSession(process.cwd(), { + codex: { + thinkingLevel: { + decision: "low", + planning: "xhigh", + review: "high", + execution: "medium", + }, + }, + }); try { + await session.decidePlanningNeed("spec", "context"); await session.planSpec("spec", "context"); + await session.reviewTaskGraph([], "context"); + await session.executeTask( + { + id: "t1", + name: "Task", + description: "Do thing", + dependencies: [], + acceptance_criteria: ["Done"], + status: "pending", + retries: 0, + maxRetries: 3, + }, + "run-1", + "context", + ); + await session.consultTaskGraph([]); + await session.runPrompt("review prompt", "review"); - const prompt = capturedInput[0]?.text ?? ""; - expect(prompt).toContain("Referenced Orca skills:"); - expect(prompt).toContain("Skill: code-simplifier"); - expect(prompt).toContain("Skill: alpha-skill"); - expect(prompt).toContain("Skill: zeta-skill"); - expect(prompt).toContain("body"); - expect(prompt).toContain("alpha body"); - expect(prompt).toContain("zeta body"); - expect(prompt).not.toContain("server code simplifier body"); + expect(efforts).toEqual(["low", "xhigh", "high", "medium", "high", "high"]); } finally { await session.disconnect(); } }); }); -describe("codex session inline skill context", () => { - test("disconnects Codex client if skill loading fails during session creation", async () => { - const disconnectMock = mock(async () => {}); +describe("codex session code-simplifier guidance", () => { + test("includes explicit code-simplifier directives in planning, review, and execution prompts", async () => { + const prompts: string[] = []; + const runTurnMock = mock(async (params: { input?: Array<{ text?: string }> }) => { + const prompt = params.input?.[0]?.text ?? ""; + prompts.push(prompt); + + if (prompt.includes("pre-execution task-graph reviewer")) { + return { + agentMessage: '{"changes":[]}', + turn: { status: "completed" }, + items: [], + }; + } + + return { + agentMessage: "[]", + turn: { status: "completed" }, + items: [], + }; + }); mockMultiAgentDetection(false); mock.module("@ratley/codex-client", () => ({ CodexClient: class { async connect(): Promise {} - disconnect = disconnectMock; + async disconnect(): Promise {} async startThread(): Promise<{ id: string }> { return { id: "thread-1" }; } - async runTurn(): Promise { - throw new Error("not used"); - } + runTurn = runTurnMock; async runReview(): Promise<{ reviewText: string }> { return { reviewText: "ok" }; } @@ -608,49 +585,87 @@ describe("codex session inline skill context", () => { })); mock.module("../../utils/skill-loader.js", () => ({ - loadSkills: async () => { - throw new Error("load failed"); - }, + loadSkills: async () => [], })); const { createCodexSession } = await import(`./session.ts?test=${Math.random()}`); + const session = await createCodexSession(process.cwd()); - await expect(createCodexSession(process.cwd())).rejects.toThrow("load failed"); - expect(disconnectMock).toHaveBeenCalledTimes(1); - }); + try { + await session.planSpec("spec", "context"); + await session.reviewTaskGraph([], "context"); + await session.executeTask( + { + id: "t1", + name: "Task", + description: "Do thing", + dependencies: [], + acceptance_criteria: ["Done"], + status: "pending", + retries: 0, + maxRetries: 3, + }, + "run-1", + "context", + ); - test("includes inline skill context inside the text input for every runTurn", async () => { - type TurnInputItem = { type: "text"; text: string }; + expect(prompts).toHaveLength(3); + for (const prompt of prompts) { + expect(prompt).toContain("For every code-writing step, explicitly apply code-simplifier guidance"); + expect(prompt).toContain("For every code-review step, explicitly apply code-simplifier guidance"); + expect(prompt).toContain( + "Keep changes behavior-preserving unless the task explicitly requires behavior changes.", + ); + } + } finally { + await session.disconnect(); + } + }); +}); - const runTurnCalls: Array<{ input?: TurnInputItem[] }> = []; - const runTurnMock = mock(async (params: { input?: TurnInputItem[] }) => { - runTurnCalls.push(params); - const prompt = params.input?.find((item) => item.type === "text")?.text ?? ""; +describe("codex session multi-agent prompt guidance", () => { + test("includes multi-agent guidance in planning, review, consultation, and execution prompts when active", async () => { + const prompts: string[] = []; + const runsDir = await mkdtemp(path.join(os.tmpdir(), "orca-session-multi-agent-active-")); + const store = new RunStore(runsDir); + const runId = "multi-agent-active-run-1000-abcd"; + await store.createRun(runId, "/tmp/spec.md"); + const runTurnMock = mock(async (params: { input?: Array<{ text?: string }> }) => { + const prompt = params.input?.[0]?.text ?? ""; + prompts.push(prompt); if (prompt.includes("pre-execution task-graph reviewer")) { return { agentMessage: '{"changes":[]}', turn: { status: "completed" }, - items: [] + items: [], }; } - if (prompt.includes('"issues": [...], "ok": boolean')) { + if (prompt.includes("Review this Orca task graph before execution.")) { return { agentMessage: '{"issues":[],"ok":true}', turn: { status: "completed" }, - items: [] + items: [], + }; + } + + if (prompt.includes("execution clarification gate")) { + return { + agentMessage: '{"needsInput":false,"context":"No extra clarification needed."}', + turn: { status: "completed" }, + items: [], }; } return { agentMessage: "[]", turn: { status: "completed" }, - items: [] + items: [], }; }); - mockMultiAgentDetection(false); + mockMultiAgentDetection(true); mock.module("@ratley/codex-client", () => ({ CodexClient: class { async connect(): Promise {} @@ -666,23 +681,20 @@ describe("codex session inline skill context", () => { })); mock.module("../../utils/skill-loader.js", () => ({ - loadSkills: async () => [ - { - name: "code-simplifier", - description: "desc", - body: "body", - dirPath: "/tmp/skills/code-simplifier", - filePath: "/tmp/skills/code-simplifier/SKILL.md", - }, - ], + loadSkills: async () => [], })); const { createCodexSession } = await import(`./session.ts?test=${Math.random()}`); - const session = await createCodexSession(process.cwd()); + const session = await createCodexSession(process.cwd(), undefined, { + runId, + store, + resumeOverallStatus: "running", + }); try { await session.planSpec("spec", "context"); await session.reviewTaskGraph([], "context"); + await session.consultTaskGraph([]); await session.executeTask( { id: "t1", @@ -697,55 +709,1294 @@ describe("codex session inline skill context", () => { "run-1", "context", ); - await session.consultTaskGraph([]); - await session.runPrompt("hello"); - expect(runTurnCalls.length).toBe(5); + const planningPrompt = + prompts.find((prompt) => prompt.includes("You are decomposing a spec into an ordered task graph.")) ?? ""; + const reviewPrompt = + prompts.find((prompt) => prompt.includes("You are Orca's pre-execution task-graph reviewer.")) ?? ""; + const consultationPrompt = + prompts.find((prompt) => prompt.includes("Review this Orca task graph before execution.")) ?? ""; + const executionPrompt = + prompts.find((prompt) => prompt.includes("You are Orca's task execution assistant.")) ?? ""; + + expect(planningPrompt).toContain( + "Codex multi-agent mode is enabled for this run. Shape the task graph so safe subagent parallelization is obvious.", + ); + expect(planningPrompt).toContain( + "Do not bundle unrelated work into a single do-everything task when it can be safely split.", + ); - for (const call of runTurnCalls) { - expect(call.input).toHaveLength(1); + expect(reviewPrompt).toContain( + "Codex multi-agent mode is enabled for this run. Review the graph for safe subagent parallelization.", + ); + expect(reviewPrompt).toContain( + "Flag ownership collisions where multiple tasks would touch the same files or subsystem without coordination.", + ); - const text = call.input?.[0]?.text ?? ""; - expect(text).toBeTruthy(); - expect(text).toContain("Referenced Orca skills:"); - expect(text).toContain("Skill: code-simplifier"); - expect(text).toContain("Source: /tmp/skills/code-simplifier/SKILL.md"); - expect(text).toContain("body"); - } + expect(consultationPrompt).toContain( + "Execution tasks are allowed to pause and ask request_user_input questions when they truly need a user-provided value.", + ); + expect(consultationPrompt).toContain( + "Do not ask the user whether Orca may pause during execution for clarification. Assume that execution-time request_user_input is available.", + ); + expect(consultationPrompt).toContain( + "If a task already says it should ask for a missing user value during execution, treat that as a valid execution mechanism, not a reason to ask a meta-question about whether clarification is allowed.", + ); + expect(consultationPrompt).toContain("Codex multi-agent mode is enabled for this run."); + expect(consultationPrompt).toContain( + "Treat missed safe parallelism, fake dependencies, overlapping ownership, or missing integration tasks as review concerns.", + ); + + expect(executionPrompt).toContain("Codex multi-agent mode is enabled for this run."); + expect(executionPrompt).toContain( + "If this task contains clearly independent subtasks with disjoint ownership, use subagents to parallelize them.", + ); + expect(executionPrompt).toContain("Integrate subagent results yourself before final completion."); } finally { await session.disconnect(); + await rm(runsDir, { recursive: true, force: true }); } }); -}); -describe("codex session question flow", () => { - test("persists pending questions, emits onQuestion, and resumes the same run after an answer", async () => { - const tempDir = await mkdtemp(path.join(os.tmpdir(), "orca-question-flow-")); - const store = new RunStore(path.join(tempDir, "runs")); - const runId = "run-1000-abcd"; + test("omits multi-agent guidance from planning, review, consultation, and execution prompts when inactive", async () => { + const prompts: string[] = []; + const runsDir = await mkdtemp(path.join(os.tmpdir(), "orca-session-multi-agent-inactive-")); + const store = new RunStore(runsDir); + const runId = "multi-agent-inactive-run-1000-abcd"; await store.createRun(runId, "/tmp/spec.md"); - await store.updateRun(runId, { mode: "run", overallStatus: "running" }); - - const hookEvents: Array<{ hook: string; message: string; taskId?: string; questions?: Array<{ id: string }> }> = []; - const responses: Array<{ requestId: string | number; response: unknown }> = []; - let resolveAnswerResponse: (() => void) | undefined; - const answerResponse = new Promise((resolve) => { - resolveAnswerResponse = resolve; - }); - let clientInstance: EventEmitter | null = null; + const runTurnMock = mock(async (params: { input?: Array<{ text?: string }> }) => { + const prompt = params.input?.[0]?.text ?? ""; + prompts.push(prompt); - try { - mockMultiAgentDetection(false); - mock.module("@ratley/codex-client", () => ({ - CodexClient: class extends EventEmitter { - constructor() { - super(); - clientInstance = this; - } + if (prompt.includes("pre-execution task-graph reviewer")) { + return { + agentMessage: '{"changes":[]}', + turn: { status: "completed" }, + items: [], + }; + } - async connect(): Promise {} - async disconnect(): Promise {} - async startThread(): Promise<{ id: string }> { + if (prompt.includes("Review this Orca task graph before execution.")) { + return { + agentMessage: '{"issues":[],"ok":true}', + turn: { status: "completed" }, + items: [], + }; + } + + if (prompt.includes("execution clarification gate")) { + return { + agentMessage: '{"needsInput":false,"context":"No extra clarification needed."}', + turn: { status: "completed" }, + items: [], + }; + } + + return { + agentMessage: "[]", + turn: { status: "completed" }, + items: [], + }; + }); + + mockMultiAgentDetection(false); + mock.module("@ratley/codex-client", () => ({ + CodexClient: class { + async connect(): Promise {} + async disconnect(): Promise {} + async startThread(): Promise<{ id: string }> { + return { id: "thread-1" }; + } + runTurn = runTurnMock; + async runReview(): Promise<{ reviewText: string }> { + return { reviewText: "ok" }; + } + }, + })); + + mock.module("../../utils/skill-loader.js", () => ({ + loadSkills: async () => [], + })); + + const { createCodexSession } = await import(`./session.ts?test=${Math.random()}`); + const session = await createCodexSession(process.cwd(), undefined, { + runId, + store, + resumeOverallStatus: "running", + }); + + try { + await session.planSpec("spec", "context"); + await session.reviewTaskGraph([], "context"); + await session.consultTaskGraph([]); + await session.executeTask( + { + id: "t1", + name: "Task", + description: "Do thing", + dependencies: [], + acceptance_criteria: ["Done"], + status: "pending", + retries: 0, + maxRetries: 3, + }, + "run-1", + "context", + ); + + for (const prompt of prompts) { + if (prompt.includes("Review this Orca task graph before execution.")) { + expect(prompt).toContain( + "Execution tasks are allowed to pause and ask request_user_input questions when they truly need a user-provided value.", + ); + expect(prompt).toContain( + "Do not ask the user whether Orca may pause during execution for clarification. Assume that execution-time request_user_input is available.", + ); + } + expect(prompt).not.toContain("Codex multi-agent mode is enabled for this run."); + expect(prompt).not.toContain("use subagents to parallelize them"); + expect(prompt).not.toContain("safe subagent parallelization"); + } + } finally { + await session.disconnect(); + await rm(runsDir, { recursive: true, force: true }); + } + }); + + test("uses a clarification turn before interactive execution", async () => { + type TurnInputItem = { type: "text"; text: string }; + + const runTurnCalls: Array<{ + collaborationMode?: { + mode?: string; + settings?: { + model?: string | null; + reasoning_effort?: string | null; + developer_instructions?: string | null; + }; + }; + input?: TurnInputItem[]; + }> = []; + const runsDir = await mkdtemp(path.join(os.tmpdir(), "orca-session-interactive-")); + const store = new RunStore(runsDir); + const runId = "interactive-run-1000-abcd"; + await store.createRun(runId, "/tmp/spec.md"); + + mockMultiAgentDetection(false); + mock.module("@ratley/codex-client", () => ({ + CodexClient: class { + async connect(): Promise {} + async disconnect(): Promise {} + async startThread(): Promise<{ id: string }> { + return { id: "thread-1" }; + } + async runTurn(params: { + collaborationMode?: { + mode?: string; + settings?: { + model?: string | null; + reasoning_effort?: string | null; + developer_instructions?: string | null; + }; + }; + input?: TurnInputItem[]; + }): Promise<{ agentMessage: string; turn: { status: "completed" }; items: [] }> { + runTurnCalls.push(params); + const prompt = params.input?.[0]?.text ?? ""; + if (prompt.includes("decomposing a spec")) { + return { agentMessage: "[]", turn: { status: "completed" }, items: [] }; + } + + if (prompt.includes("execution clarification gate")) { + return { + agentMessage: + '{"needsInput":true,"context":"Use the user-provided release codename when updating files."}', + turn: { status: "completed" }, + items: [], + }; + } + + return { agentMessage: '{"outcome":"done"}', turn: { status: "completed" }, items: [] }; + } + async runReview(): Promise<{ reviewText: string }> { + return { reviewText: "ok" }; + } + }, + })); + + mock.module("../../utils/skill-loader.js", () => ({ + loadSkills: async () => [], + })); + + const { createCodexSession } = await import(`./session.ts?test=${Math.random()}`); + const session = await createCodexSession(process.cwd(), undefined, { + runId: runId as `${string}-${number}-${string}`, + store, + resumeOverallStatus: "running", + }); + + try { + await session.planSpec("spec", "context"); + await session.executeTask( + { + id: "T1", + name: "Collect Release Codename", + description: "Ask the user which release codename to use.", + dependencies: [], + acceptance_criteria: ["Ask exactly one clarification question and use the answer."], + status: "pending", + retries: 0, + maxRetries: 3, + }, + runId, + "context", + ); + + const planningCall = runTurnCalls[0]; + const clarificationCall = runTurnCalls[1]; + const executionCall = runTurnCalls[2]; + expect(planningCall?.collaborationMode).toEqual({ + mode: "plan", + settings: { + model: "gpt-5.3-codex", + reasoning_effort: "high", + developer_instructions: null, + }, + }); + expect(clarificationCall?.collaborationMode).toEqual({ + mode: "plan", + settings: { + model: "gpt-5.3-codex", + reasoning_effort: "medium", + developer_instructions: null, + }, + }); + expect(executionCall?.collaborationMode).toBeUndefined(); + + const clarificationPrompt = clarificationCall?.input?.[0]?.text ?? ""; + expect(clarificationPrompt).toContain("You are Orca's execution clarification gate."); + expect(clarificationPrompt).toContain( + "use Codex's request_user_input tool instead of guessing, failing, or baking the question into a later task", + ); + + const executionPrompt = executionCall?.input?.[0]?.text ?? ""; + expect(executionPrompt).toContain("Resolved Clarification Context:"); + expect(executionPrompt).toContain("Use the user-provided release codename when updating files."); + expect(executionPrompt).not.toContain("request_user_input"); + } finally { + await session.disconnect(); + await rm(runsDir, { recursive: true, force: true }); + } + }); + + test("runs a non-mutating clarification pass before ordinary interactive execution", async () => { + type TurnInputItem = { type: "text"; text: string }; + + const runTurnCalls: Array<{ collaborationMode?: { mode?: string }; input?: TurnInputItem[] }> = []; + const runsDir = await mkdtemp(path.join(os.tmpdir(), "orca-session-default-exec-")); + const store = new RunStore(runsDir); + const runId = "default-exec-1000-abcd"; + await store.createRun(runId, "/tmp/spec.md"); + + mockMultiAgentDetection(false); + mock.module("@ratley/codex-client", () => ({ + CodexClient: class { + async connect(): Promise {} + async disconnect(): Promise {} + async startThread(): Promise<{ id: string }> { + return { id: "thread-1" }; + } + async runTurn(params: { + collaborationMode?: { mode?: string }; + input?: TurnInputItem[]; + }): Promise<{ agentMessage: string; turn: { status: "completed" }; items: [] }> { + runTurnCalls.push(params); + const prompt = params.input?.[0]?.text ?? ""; + if (prompt.includes("execution clarification gate")) { + return { + agentMessage: '{"needsInput":false,"context":""}', + turn: { status: "completed" }, + items: [], + }; + } + return { agentMessage: '{"outcome":"done"}', turn: { status: "completed" }, items: [] }; + } + async runReview(): Promise<{ reviewText: string }> { + return { reviewText: "ok" }; + } + }, + })); + + mock.module("../../utils/skill-loader.js", () => ({ + loadSkills: async () => [], + })); + + const { createCodexSession } = await import(`./session.ts?test=${Math.random()}`); + const session = await createCodexSession(process.cwd(), undefined, { + runId: runId as `${string}-${number}-${string}`, + store, + resumeOverallStatus: "running", + }); + + try { + await session.executeTask( + { + id: "T1", + name: "Add subtract export", + description: "Add subtract(a, b) and update tests.", + dependencies: [], + acceptance_criteria: ["bun test passes"], + status: "pending", + retries: 0, + maxRetries: 3, + }, + runId, + "context", + ); + + expect(runTurnCalls[0]?.collaborationMode).toMatchObject({ mode: "plan" }); + expect(runTurnCalls[1]?.collaborationMode).toBeUndefined(); + + const clarificationPrompt = runTurnCalls[0]?.input?.[0]?.text ?? ""; + expect(clarificationPrompt).toContain("You are Orca's execution clarification gate."); + + const executionPrompt = runTurnCalls[1]?.input?.[0]?.text ?? ""; + expect(executionPrompt).not.toContain("request_user_input"); + } finally { + await session.disconnect(); + await rm(runsDir, { recursive: true, force: true }); + } + }); + + test("falls back to non-interactive prompts when collaboration mode is unsupported", async () => { + type TurnInputItem = { type: "text"; text: string }; + + const runTurnCalls: Array<{ collaborationMode?: { mode?: string }; input?: TurnInputItem[] }> = []; + const runsDir = await mkdtemp(path.join(os.tmpdir(), "orca-session-unsupported-collab-")); + const store = new RunStore(runsDir); + const runId = "unsupported-collab-1000-abcd"; + await store.createRun(runId, "/tmp/spec.md"); + + mockMultiAgentDetection(false); + mock.module("@ratley/codex-client", () => ({ + CodexClient: class { + async connect(): Promise {} + async disconnect(): Promise {} + async startThread(): Promise<{ id: string }> { + return { id: "thread-1" }; + } + async listCollaborationModes(): Promise { + throw new Error("-32601: Method not found"); + } + async runTurn(params: { + collaborationMode?: { mode?: string }; + input?: TurnInputItem[]; + }): Promise<{ agentMessage: string; turn: { status: "completed" }; items: [] }> { + runTurnCalls.push(params); + const prompt = params.input?.[0]?.text ?? ""; + if (prompt.includes("decomposing a spec")) { + return { agentMessage: "[]", turn: { status: "completed" }, items: [] }; + } + return { agentMessage: '{"outcome":"done"}', turn: { status: "completed" }, items: [] }; + } + async runReview(): Promise<{ reviewText: string }> { + return { reviewText: "ok" }; + } + }, + })); + + mock.module("../../utils/skill-loader.js", () => ({ + loadSkills: async () => [], + })); + + const { createCodexSession } = await import(`./session.ts?test=${Math.random()}`); + const session = await createCodexSession(process.cwd(), undefined, { + runId: runId as `${string}-${number}-${string}`, + store, + resumeOverallStatus: "running", + }); + + try { + await session.planSpec("spec", "context"); + await session.executeTask( + { + id: "T1", + name: "Add subtract export", + description: "Add subtract(a, b) and update tests.", + dependencies: [], + acceptance_criteria: ["bun test passes"], + status: "pending", + retries: 0, + maxRetries: 3, + }, + runId, + "context", + ); + + expect(runTurnCalls).toHaveLength(2); + expect(runTurnCalls[0]?.collaborationMode).toBeUndefined(); + expect(runTurnCalls[1]?.collaborationMode).toBeUndefined(); + + const planningPrompt = runTurnCalls[0]?.input?.[0]?.text ?? ""; + expect(planningPrompt).not.toContain("request_user_input"); + + const executionPrompt = runTurnCalls[1]?.input?.[0]?.text ?? ""; + expect(executionPrompt).not.toContain("execution clarification gate"); + expect(executionPrompt).not.toContain("request_user_input"); + } finally { + await session.disconnect(); + await rm(runsDir, { recursive: true, force: true }); + } + }); + + test("does not force review prompts through plan collaboration mode", async () => { + type TurnInputItem = { type: "text"; text: string }; + + const runTurnCalls: Array<{ collaborationMode?: { mode?: string }; input?: TurnInputItem[] }> = []; + const runsDir = await mkdtemp(path.join(os.tmpdir(), "orca-session-review-prompt-")); + const store = new RunStore(runsDir); + const runId = "review-prompt-1000-abcd"; + await store.createRun(runId, "/tmp/spec.md"); + + mockMultiAgentDetection(false); + mock.module("@ratley/codex-client", () => ({ + CodexClient: class { + async connect(): Promise {} + async disconnect(): Promise {} + async startThread(): Promise<{ id: string }> { + return { id: "thread-1" }; + } + async runTurn(params: { + collaborationMode?: { mode?: string }; + input?: TurnInputItem[]; + }): Promise<{ agentMessage: string; turn: { status: "completed" }; items: [] }> { + runTurnCalls.push(params); + return { + agentMessage: '{"summary":"clean","findings":[],"fixed":false}', + turn: { status: "completed" }, + items: [], + }; + } + async runReview(): Promise<{ reviewText: string }> { + return { reviewText: "ok" }; + } + }, + })); + + mock.module("../../utils/skill-loader.js", () => ({ + loadSkills: async () => [], + })); + + const { createCodexSession } = await import(`./session.ts?test=${Math.random()}`); + const session = await createCodexSession(process.cwd(), undefined, { + runId: runId as `${string}-${number}-${string}`, + store, + resumeOverallStatus: "running", + }); + + try { + await session.runPrompt("review prompt", "review"); + expect(runTurnCalls).toHaveLength(1); + expect(runTurnCalls[0]?.collaborationMode).toBeUndefined(); + } finally { + await session.disconnect(); + await rm(runsDir, { recursive: true, force: true }); + } + }); +}); + +describe("codex session skill discovery", () => { + test("calls skills/list with forceReload and perCwdExtraUserRoots", async () => { + const requestMock = mock(async () => ({ data: [] })); + + mockMultiAgentDetection(false); + mock.module("@ratley/codex-client", () => ({ + CodexClient: class { + async connect(): Promise {} + async disconnect(): Promise {} + async startThread(): Promise<{ id: string }> { + return { id: "thread-1" }; + } + async runTurn(): Promise<{ agentMessage: string; turn: { status: "completed" }; items: [] }> { + return { agentMessage: "[]", turn: { status: "completed" }, items: [] }; + } + request = requestMock; + async runReview(): Promise<{ reviewText: string }> { + return { reviewText: "ok" }; + } + }, + })); + + mock.module("../../utils/skill-loader.js", () => ({ + loadSkills: async () => [], + })); + + const { createCodexSession } = await import(`./session.ts?test=${Math.random()}`); + const cwd = process.cwd(); + const session = await createCodexSession(cwd, { + codex: { + perCwdExtraUserRoots: [{ cwd, extraUserRoots: ["/tmp/extra-skills"] }], + }, + }); + + try { + expect(requestMock).toHaveBeenCalledWith("skills/list", { + cwds: [cwd], + forceReload: true, + perCwdExtraUserRoots: [{ cwd, extraUserRoots: ["/tmp/extra-skills"] }], + }); + } finally { + await session.disconnect(); + } + }); + + test("merges app-server listed skills after Orca-loaded skills without overriding deterministic precedence", async () => { + type TurnInputItem = { type: "text"; text: string }; + + let capturedInput: TurnInputItem[] = []; + const listedSkillsRoot = await mkdtemp(path.join(os.tmpdir(), "orca-listed-skills-")); + const alphaSkillPath = path.join(listedSkillsRoot, "alpha-skill", "SKILL.md"); + const codeSimplifierPath = path.join(listedSkillsRoot, "code-simplifier", "SKILL.md"); + const zetaSkillPath = path.join(listedSkillsRoot, "zeta-skill", "SKILL.md"); + + await mkdir(path.dirname(alphaSkillPath), { recursive: true }); + await mkdir(path.dirname(codeSimplifierPath), { recursive: true }); + await mkdir(path.dirname(zetaSkillPath), { recursive: true }); + await writeFile(alphaSkillPath, "alpha body", "utf8"); + await writeFile(codeSimplifierPath, "server code simplifier body", "utf8"); + await writeFile(zetaSkillPath, "zeta body", "utf8"); + + mockMultiAgentDetection(false); + mock.module("@ratley/codex-client", () => ({ + CodexClient: class { + async connect(): Promise {} + async disconnect(): Promise {} + async startThread(): Promise<{ id: string }> { + return { id: "thread-1" }; + } + async runTurn(params: { + input?: TurnInputItem[]; + }): Promise<{ agentMessage: string; turn: { status: "completed" }; items: [] }> { + capturedInput = params.input ?? []; + return { agentMessage: "[]", turn: { status: "completed" }, items: [] }; + } + async request(): Promise<{ data: Array<{ skills: Array<{ name: string; path: string }> }> }> { + return { + data: [ + { + skills: [ + { name: "zeta-skill", path: zetaSkillPath }, + { name: "code-simplifier", path: codeSimplifierPath }, + { name: "alpha-skill", path: alphaSkillPath }, + ], + }, + ], + }; + } + async runReview(): Promise<{ reviewText: string }> { + return { reviewText: "ok" }; + } + }, + })); + + mock.module("../../utils/skill-loader.js", () => ({ + loadSkills: async () => [ + { + name: "code-simplifier", + description: "desc", + body: "body", + dirPath: "/tmp/skills/code-simplifier", + filePath: "/tmp/skills/code-simplifier/SKILL.md", + }, + ], + })); + + const { createCodexSession } = await import(`./session.ts?test=${Math.random()}`); + const session = await createCodexSession(process.cwd()); + + try { + await session.planSpec("spec", "context"); + + const prompt = capturedInput[0]?.text ?? ""; + expect(prompt).toContain("Referenced Orca skills:"); + expect(prompt).toContain("Skill: code-simplifier"); + expect(prompt).toContain("Skill: alpha-skill"); + expect(prompt).toContain("Skill: zeta-skill"); + expect(prompt).toContain("body"); + expect(prompt).toContain("alpha body"); + expect(prompt).toContain("zeta body"); + expect(prompt).not.toContain("server code simplifier body"); + } finally { + await session.disconnect(); + } + }); + + test("loads real skill bodies from configured perCwdExtraUserRoots even when skills/list omits path", async () => { + type TurnInputItem = { type: "text"; text: string }; + + let capturedInput: TurnInputItem[] = []; + const sharedRoot = await mkdtemp(path.join(os.tmpdir(), "orca-extra-skill-root-")); + const skillName = "shared-root-skill"; + const sharedSkillPath = path.join(sharedRoot, ".agents", "skills", skillName, "SKILL.md"); + await mkdir(path.dirname(sharedSkillPath), { recursive: true }); + await writeFile(sharedSkillPath, "Real shared skill workflow body", "utf8"); + + try { + mockMultiAgentDetection(false); + mock.module("@ratley/codex-client", () => ({ + CodexClient: class { + async connect(): Promise {} + async disconnect(): Promise {} + async startThread(): Promise<{ id: string }> { + return { id: "thread-1" }; + } + async runTurn(params: { + input?: TurnInputItem[]; + }): Promise<{ agentMessage: string; turn: { status: "completed" }; items: [] }> { + capturedInput = params.input ?? []; + return { agentMessage: "[]", turn: { status: "completed" }, items: [] }; + } + async request(): Promise<{ + data: Array<{ + cwd: string; + skills: Array<{ + name: string; + description: string; + enabled: boolean; + interface: { displayName: string }; + dependencies: { tools: Array<{ type: string; value: string }> }; + }>; + errors: []; + }>; + }> { + return { + data: [ + { + cwd: process.cwd(), + skills: [ + { + name: skillName, + description: "Use metadata when path is absent.", + enabled: true, + interface: { displayName: "Metadata Skill" }, + dependencies: { tools: [{ type: "env_var", value: "OPENAI_API_KEY" }] }, + }, + ], + errors: [], + }, + ], + }; + } + async runReview(): Promise<{ reviewText: string }> { + return { reviewText: "ok" }; + } + }, + })); + + const { createCodexSession } = await import(`./session.ts?test=${Math.random()}`); + const session = await createCodexSession(process.cwd(), { + codex: { + perCwdExtraUserRoots: [{ cwd: process.cwd(), extraUserRoots: [sharedRoot] }], + }, + }); + + await session.planSpec("spec", "context"); + + const prompt = capturedInput[0]?.text ?? ""; + expect(prompt).toContain("Referenced Orca skills:"); + expect(prompt).toContain(`Skill: ${skillName}`); + expect(prompt).toContain("Real shared skill workflow body"); + expect(prompt).not.toContain("Use metadata when path is absent."); + await session.disconnect(); + } finally { + await rm(sharedRoot, { recursive: true, force: true }); + } + }); +}); + +describe("codex session inline skill context", () => { + test("disconnects Codex client if skill loading fails during session creation", async () => { + const disconnectMock = mock(async () => {}); + + mockMultiAgentDetection(false); + mock.module("@ratley/codex-client", () => ({ + CodexClient: class { + async connect(): Promise {} + disconnect = disconnectMock; + async startThread(): Promise<{ id: string }> { + return { id: "thread-1" }; + } + async runTurn(): Promise { + throw new Error("not used"); + } + async runReview(): Promise<{ reviewText: string }> { + return { reviewText: "ok" }; + } + }, + })); + + mock.module("../../utils/skill-loader.js", () => ({ + loadSkills: async () => { + throw new Error("load failed"); + }, + })); + + const { createCodexSession } = await import(`./session.ts?test=${Math.random()}`); + + await expect(createCodexSession(process.cwd())).rejects.toThrow("load failed"); + expect(disconnectMock).toHaveBeenCalledTimes(1); + }); + + test("includes inline skill context inside the text input for every runTurn", async () => { + type TurnInputItem = { type: "text"; text: string }; + + const runTurnCalls: Array<{ input?: TurnInputItem[] }> = []; + const runTurnMock = mock(async (params: { input?: TurnInputItem[] }) => { + runTurnCalls.push(params); + const prompt = params.input?.find((item) => item.type === "text")?.text ?? ""; + + if (prompt.includes("pre-execution task-graph reviewer")) { + return { + agentMessage: '{"changes":[]}', + turn: { status: "completed" }, + items: [], + }; + } + + if (prompt.includes('"issues": [...], "ok": boolean')) { + return { + agentMessage: '{"issues":[],"ok":true}', + turn: { status: "completed" }, + items: [], + }; + } + + return { + agentMessage: "[]", + turn: { status: "completed" }, + items: [], + }; + }); + + mockMultiAgentDetection(false); + mock.module("@ratley/codex-client", () => ({ + CodexClient: class { + async connect(): Promise {} + async disconnect(): Promise {} + async startThread(): Promise<{ id: string }> { + return { id: "thread-1" }; + } + runTurn = runTurnMock; + async runReview(): Promise<{ reviewText: string }> { + return { reviewText: "ok" }; + } + }, + })); + + mock.module("../../utils/skill-loader.js", () => ({ + loadSkills: async () => [ + { + name: "code-simplifier", + description: "desc", + body: "body", + dirPath: "/tmp/skills/code-simplifier", + filePath: "/tmp/skills/code-simplifier/SKILL.md", + }, + ], + })); + + const { createCodexSession } = await import(`./session.ts?test=${Math.random()}`); + const session = await createCodexSession(process.cwd()); + + try { + await session.planSpec("spec", "context"); + await session.reviewTaskGraph([], "context"); + await session.executeTask( + { + id: "t1", + name: "Task", + description: "Do thing", + dependencies: [], + acceptance_criteria: ["Done"], + status: "pending", + retries: 0, + maxRetries: 3, + }, + "run-1", + "context", + ); + await session.consultTaskGraph([]); + await session.runPrompt("hello"); + + expect(runTurnCalls.length).toBe(5); + + for (const call of runTurnCalls) { + expect(call.input).toHaveLength(1); + + const text = call.input?.[0]?.text ?? ""; + expect(text).toBeTruthy(); + expect(text).toContain("Referenced Orca skills:"); + expect(text).toContain("Skill: code-simplifier"); + expect(text).toContain("Source: /tmp/skills/code-simplifier/SKILL.md"); + expect(text).toContain("body"); + } + } finally { + await session.disconnect(); + } + }); +}); + +describe("codex session question flow", () => { + test("restores planning status after answering a planning-time clarification", async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), "orca-question-flow-planning-")); + const store = new RunStore(path.join(tempDir, "runs")); + const runId = "run-1000-abcd"; + await store.createRun(runId, "/tmp/spec.md"); + await store.updateRun(runId, { mode: "plan", overallStatus: "planning" }); + + const responses: Array<{ requestId: string | number; response: unknown }> = []; + let resolveAnswerResponse: (() => void) | undefined; + const answerResponse = new Promise((resolve) => { + resolveAnswerResponse = resolve; + }); + let clientInstance: EventEmitter | null = null; + + try { + mockMultiAgentDetection(false); + mock.module("@ratley/codex-client", () => ({ + CodexClient: class extends EventEmitter { + constructor() { + super(); + clientInstance = this; + } + + async connect(): Promise {} + async disconnect(): Promise {} + async startThread(): Promise<{ id: string }> { + return { id: "thread-1" }; + } + async runReview(): Promise<{ reviewText: string }> { + return { reviewText: "ok" }; + } + respondToUserInputRequest(requestId: string | number, response: unknown): void { + responses.push({ requestId, response }); + resolveAnswerResponse?.(); + } + rejectServerRequest(): void {} + async runTurn(): Promise<{ agentMessage: string; turn: { status: "completed" }; items: [] }> { + queueMicrotask(() => { + clientInstance?.emit("request:userInput", { + requestId: "req-1", + itemId: "item-1", + threadId: "thread-1", + turnId: "turn-1", + questions: [ + { + header: "Framework", + id: "framework", + question: "Which framework should I target?", + isOther: true, + isSecret: false, + options: null, + }, + ], + }); + }); + + await answerResponse; + clientInstance?.emit("serverRequest:resolved", { requestId: "req-1" }); + + return { + agentMessage: "[]", + turn: { status: "completed" }, + items: [], + }; + } + }, + })); + + mock.module("../../utils/skill-loader.js", () => ({ + loadSkills: async () => [], + })); + + const { createCodexSession } = await import(`./session.ts?test=${Math.random()}`); + const session = await createCodexSession(process.cwd(), undefined, { + runId: runId as `${string}-${number}-${string}`, + store, + resumeOverallStatus: "planning", + }); + + try { + const planningPromise = session.planSpec("spec", "context"); + + const waitingRun = await waitFor(async () => { + const run = await store.getRun(runId); + return run?.pendingQuestion ? run : null; + }); + + expect(waitingRun.overallStatus).toBe("waiting_for_answer"); + + const answerPath = path.join(store.getRunDir(runId), "answer.txt"); + await writeFile(answerPath, `${JSON.stringify({ answers: { framework: { answers: ["bun"] } } })}\n`, "utf8"); + + await planningPromise; + + const resumedRun = await waitFor(async () => { + const run = await store.getRun(runId); + return run && run.pendingQuestion === undefined ? run : null; + }); + + expect(resumedRun.overallStatus).toBe("planning"); + expect(responses).toEqual([ + { + requestId: "req-1", + response: { + answers: { + framework: { + answers: ["bun"], + }, + }, + }, + }, + ]); + } finally { + await session.disconnect(); + } + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); + + test("persists pending questions, emits onQuestion, and resumes the same run after an answer", async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), "orca-question-flow-")); + const store = new RunStore(path.join(tempDir, "runs")); + const runId = "run-1000-abcd"; + await store.createRun(runId, "/tmp/spec.md"); + await store.updateRun(runId, { mode: "run", overallStatus: "running" }); + + const hookEvents: Array<{ hook: string; message: string; taskId?: string; questions?: Array<{ id: string }> }> = []; + const responses: Array<{ requestId: string | number; response: unknown }> = []; + let resolveAnswerResponse: (() => void) | undefined; + const answerResponse = new Promise((resolve) => { + resolveAnswerResponse = resolve; + }); + let clientInstance: EventEmitter | null = null; + + try { + mockMultiAgentDetection(false); + mock.module("@ratley/codex-client", () => ({ + CodexClient: class extends EventEmitter { + constructor() { + super(); + clientInstance = this; + } + + async connect(): Promise {} + async disconnect(): Promise {} + async startThread(): Promise<{ id: string }> { + return { id: "thread-1" }; + } + async runReview(): Promise<{ reviewText: string }> { + return { reviewText: "ok" }; + } + respondToUserInputRequest(requestId: string | number, response: unknown): void { + responses.push({ requestId, response }); + resolveAnswerResponse?.(); + } + rejectServerRequest(): void {} + private runTurnCount = 0; + async runTurn(): Promise<{ agentMessage: string; turn: { status: "completed" }; items: [] }> { + this.runTurnCount += 1; + if (this.runTurnCount === 1) { + queueMicrotask(() => { + clientInstance?.emit("request:userInput", { + requestId: "req-1", + itemId: "item-1", + threadId: "thread-1", + turnId: "turn-1", + questions: [ + { + header: "Game Type", + id: "game_type", + question: "Which game type should I build?", + isOther: true, + isSecret: false, + options: [ + { label: "Arcade", description: "Arcade style" }, + { label: "Puzzle", description: "Puzzle style" }, + ], + }, + ], + }); + }); + + await answerResponse; + clientInstance?.emit("serverRequest:resolved", { requestId: "req-1" }); + + return { + agentMessage: '{"needsInput":true,"context":"Game type selected by user: Arcade."}', + turn: { status: "completed" }, + items: [], + }; + } + + return { + agentMessage: '{"outcome":"done"}', + turn: { status: "completed" }, + items: [], + }; + } + }, + })); + + mock.module("../../utils/skill-loader.js", () => ({ + loadSkills: async () => [], + })); + + const { createCodexSession } = await import(`./session.ts?test=${Math.random()}`); + const session = await createCodexSession(process.cwd(), undefined, { + runId: runId as `${string}-${number}-${string}`, + store, + emitHook: async (event) => { + hookEvents.push({ + hook: event.hook, + message: event.message, + ...(event.taskId ? { taskId: event.taskId } : {}), + ...("questions" in event ? { questions: event.questions.map((question) => ({ id: question.id })) } : {}), + }); + }, + }); + + try { + const executionPromise = session.executeTask( + { + id: "task-1", + name: "Build the game", + description: "Implement the requested game.", + dependencies: [], + acceptance_criteria: ["Game is implemented"], + status: "pending", + retries: 0, + maxRetries: 3, + }, + runId, + "context", + ); + + const waitingRun = await waitFor(async () => { + const run = await store.getRun(runId); + return run?.pendingQuestion ? run : null; + }); + + expect(waitingRun.overallStatus).toBe("waiting_for_answer"); + expect(waitingRun.pendingQuestion?.requestId).toBe("req-1"); + expect(waitingRun.pendingQuestion?.questions[0]?.id).toBe("game_type"); + expect(hookEvents).toContainEqual({ + hook: "onQuestion", + message: "Which game type should I build?", + taskId: "task-1", + questions: [{ id: "game_type" }], + }); + + const answerPath = path.join(store.getRunDir(runId), "answer.txt"); + await writeFile(answerPath, `${JSON.stringify({ answers: { game_type: { answers: ["Arcade"] } } })}\n`, "utf8"); + + const result = await executionPromise; + expect(result.outcome).toBe("done"); + expect(responses).toEqual([ + { + requestId: "req-1", + response: { + answers: { + game_type: { + answers: ["Arcade"], + }, + }, + }, + }, + ]); + + const resumedRun = await waitFor(async () => { + const run = await store.getRun(runId); + return run && run.pendingQuestion === undefined ? run : null; + }); + expect(resumedRun.overallStatus).toBe("running"); + await expect(readFile(answerPath, "utf8")).rejects.toThrow(); + } finally { + await session.disconnect(); + } + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); + + test("keeps a run cancelled when cancellation happens while waiting for input", async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), "orca-question-flow-cancelled-")); + const store = new RunStore(path.join(tempDir, "runs")); + const runId = "run-1000-abcd"; + await store.createRun(runId, "/tmp/spec.md"); + await store.updateRun(runId, { mode: "run", overallStatus: "running" }); + + const rejectedRequests: Array<{ requestId: string | number; error: { code: number; message: string } }> = []; + let settleRequest: (() => void) | undefined; + const requestSettled = new Promise((resolve) => { + settleRequest = resolve; + }); + let clientInstance: EventEmitter | null = null; + + try { + mockMultiAgentDetection(false); + mock.module("@ratley/codex-client", () => ({ + CodexClient: class extends EventEmitter { + constructor() { + super(); + clientInstance = this; + } + + async connect(): Promise {} + async disconnect(): Promise {} + async startThread(): Promise<{ id: string }> { + return { id: "thread-1" }; + } + async runReview(): Promise<{ reviewText: string }> { + return { reviewText: "ok" }; + } + respondToUserInputRequest(): void { + throw new Error("request should be rejected when run is cancelled"); + } + rejectServerRequest(requestId: string | number, error: { code: number; message: string }): void { + rejectedRequests.push({ requestId, error }); + settleRequest?.(); + } + private runTurnCount = 0; + async runTurn(): Promise<{ agentMessage: string; turn: { status: "completed" }; items: [] }> { + this.runTurnCount += 1; + if (this.runTurnCount === 1) { + queueMicrotask(() => { + clientInstance?.emit("request:userInput", { + requestId: "req-1", + itemId: "item-1", + threadId: "thread-1", + turnId: "turn-1", + questions: [ + { + header: "Game Type", + id: "game_type", + question: "Which game type should I build?", + isOther: true, + isSecret: false, + options: [ + { label: "Arcade", description: "Arcade style" }, + { label: "Puzzle", description: "Puzzle style" }, + ], + }, + ], + }); + }); + + await requestSettled; + + return { + agentMessage: '{"needsInput":true,"context":"Game type must come from the user response."}', + turn: { status: "completed" }, + items: [], + }; + } + + return { + agentMessage: '{"outcome":"done"}', + turn: { status: "completed" }, + items: [], + }; + } + }, + })); + + mock.module("../../utils/skill-loader.js", () => ({ + loadSkills: async () => [], + })); + + const { createCodexSession } = await import(`./session.ts?test=${Math.random()}`); + const session = await createCodexSession(process.cwd(), undefined, { + runId: runId as `${string}-${number}-${string}`, + store, + }); + + try { + const executionPromise = session.executeTask( + { + id: "task-1", + name: "Build the game", + description: "Implement the requested game.", + dependencies: [], + acceptance_criteria: ["Game is implemented"], + status: "pending", + retries: 0, + maxRetries: 3, + }, + runId, + "context", + ); + + await waitFor(async () => { + const run = await store.getRun(runId); + return run?.pendingQuestion ? run : null; + }); + + await store.updateRun(runId, { overallStatus: "cancelled" }); + + await executionPromise; + + const cancelledRun = await waitFor(async () => { + const run = await store.getRun(runId); + return run && run.pendingQuestion === undefined ? run : null; + }); + + expect(cancelledRun.overallStatus).toBe("cancelled"); + expect(rejectedRequests).toEqual([ + { + requestId: "req-1", + error: { + code: -32603, + message: `Run ${runId} was cancelled while waiting for input.`, + }, + }, + ]); + } finally { + await session.disconnect(); + } + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); + + test("uses a direct answer channel for secret questions and clears it after resume", async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), "orca-question-flow-secret-")); + const store = new RunStore(path.join(tempDir, "runs")); + const runId = "run-1000-abcd"; + await store.createRun(runId, "/tmp/spec.md"); + await store.updateRun(runId, { mode: "run", overallStatus: "running" }); + + const responses: Array<{ requestId: string | number; response: unknown }> = []; + let resolveAnswerResponse: (() => void) | undefined; + const answerResponse = new Promise((resolve) => { + resolveAnswerResponse = resolve; + }); + let clientInstance: EventEmitter | null = null; + let submitSecretAnswer: ((answer: string) => void) | undefined; + + try { + mockMultiAgentDetection(false); + mock.module("@ratley/codex-client", () => ({ + CodexClient: class extends EventEmitter { + constructor() { + super(); + clientInstance = this; + } + + async connect(): Promise {} + async disconnect(): Promise {} + async startThread(): Promise<{ id: string }> { return { id: "thread-1" }; } async runReview(): Promise<{ reviewText: string }> { @@ -756,31 +2007,38 @@ describe("codex session question flow", () => { resolveAnswerResponse?.(); } rejectServerRequest(): void {} + private runTurnCount = 0; async runTurn(): Promise<{ agentMessage: string; turn: { status: "completed" }; items: [] }> { - queueMicrotask(() => { - clientInstance?.emit("request:userInput", { - requestId: "req-1", - itemId: "item-1", - threadId: "thread-1", - turnId: "turn-1", - questions: [ - { - header: "Game Type", - id: "game_type", - question: "Which game type should I build?", - isOther: true, - isSecret: false, - options: [ - { label: "Arcade", description: "Arcade style" }, - { label: "Puzzle", description: "Puzzle style" }, - ], - }, - ], + this.runTurnCount += 1; + if (this.runTurnCount === 1) { + queueMicrotask(() => { + clientInstance?.emit("request:userInput", { + requestId: "req-1", + itemId: "item-1", + threadId: "thread-1", + turnId: "turn-1", + questions: [ + { + header: "API Key", + id: "api_key", + question: "Which API key should I use?", + isOther: true, + isSecret: true, + options: null, + }, + ], + }); }); - }); - await answerResponse; - clientInstance?.emit("serverRequest:resolved", { requestId: "req-1" }); + await answerResponse; + clientInstance?.emit("serverRequest:resolved", { requestId: "req-1" }); + + return { + agentMessage: '{"needsInput":true,"context":"Use the secret API key provided by the user."}', + turn: { status: "completed" }, + items: [], + }; + } return { agentMessage: '{"outcome":"done"}', @@ -795,28 +2053,63 @@ describe("codex session question flow", () => { loadSkills: async () => [], })); - const { createCodexSession } = await import(`./session.ts?test=${Math.random()}`); - const session = await createCodexSession(process.cwd(), undefined, { + const sessionModule = await import(`./session.ts?test=${Math.random()}`); + sessionModule.setSecretAnswerChannelFactoryForTests(async (requestId) => { + let queuedAnswer: string | undefined; + let waitingResolver: ((answer: string) => void) | undefined; + + submitSecretAnswer = (answer: string) => { + if (waitingResolver) { + const resolve = waitingResolver; + waitingResolver = undefined; + resolve(answer); + return; + } + + queuedAnswer = answer; + }; + + return { + requestId, + descriptor: { + transport: "ipc", + path: "/tmp/orca-test-secret-answer.sock", + token: "secret-token", + }, + nextSubmission: async () => { + if (queuedAnswer !== undefined) { + const answer = queuedAnswer; + queuedAnswer = undefined; + return answer; + } + + return await new Promise((resolve) => { + waitingResolver = resolve; + }); + }, + close: async () => { + if (waitingResolver) { + const resolve = waitingResolver; + waitingResolver = undefined; + resolve(""); + } + }, + }; + }); + const session = await sessionModule.createCodexSession(process.cwd(), undefined, { runId: runId as `${string}-${number}-${string}`, store, - emitHook: async (event) => { - hookEvents.push({ - hook: event.hook, - message: event.message, - ...(event.taskId ? { taskId: event.taskId } : {}), - ...("questions" in event ? { questions: event.questions.map((question) => ({ id: question.id })) } : {}), - }); - }, + resumeOverallStatus: "running", }); try { const executionPromise = session.executeTask( { id: "task-1", - name: "Build the game", - description: "Implement the requested game.", + name: "Configure auth", + description: "Use the provided secret.", dependencies: [], - acceptance_criteria: ["Game is implemented"], + acceptance_criteria: ["Auth is configured"], status: "pending", retries: 0, maxRetries: 3, @@ -825,27 +2118,21 @@ describe("codex session question flow", () => { "context", ); + const { readSecretAnswerChannel } = await import("../../core/secret-answer-channel.js"); + const waitingRun = await waitFor(async () => { const run = await store.getRun(runId); return run?.pendingQuestion ? run : null; }); expect(waitingRun.overallStatus).toBe("waiting_for_answer"); - expect(waitingRun.pendingQuestion?.requestId).toBe("req-1"); - expect(waitingRun.pendingQuestion?.questions[0]?.id).toBe("game_type"); - expect(hookEvents).toContainEqual({ - hook: "onQuestion", - message: "Which game type should I build?", - taskId: "task-1", - questions: [{ id: "game_type" }], + await expect(readSecretAnswerChannel(runId as `${string}-${number}-${string}`)).resolves.toEqual({ + transport: "ipc", + path: "/tmp/orca-test-secret-answer.sock", + token: "secret-token", }); - const answerPath = path.join(store.getRunDir(runId), "answer.txt"); - await writeFile( - answerPath, - `${JSON.stringify({ answers: { game_type: { answers: ["Arcade"] } } })}\n`, - "utf8", - ); + submitSecretAnswer?.("super-secret"); const result = await executionPromise; expect(result.outcome).toBe("done"); @@ -854,8 +2141,8 @@ describe("codex session question flow", () => { requestId: "req-1", response: { answers: { - game_type: { - answers: ["Arcade"], + api_key: { + answers: ["super-secret"], }, }, }, @@ -866,8 +2153,264 @@ describe("codex session question flow", () => { const run = await store.getRun(runId); return run && run.pendingQuestion === undefined ? run : null; }); + + await expect(readSecretAnswerChannel(runId as `${string}-${number}-${string}`)).resolves.toBeNull(); expect(resumedRun.overallStatus).toBe("running"); - await expect(readFile(answerPath, "utf8")).rejects.toThrow(); + await expect(readFile(path.join(store.getRunDir(runId), "answer.txt"), "utf8")).rejects.toThrow(); + } finally { + sessionModule.setSecretAnswerChannelFactoryForTests(null); + await session.disconnect(); + } + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); + + test("stops waiting when app-server resolves a user-input request before any answer is provided", async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), "orca-question-flow-resolved-")); + const store = new RunStore(path.join(tempDir, "runs")); + const runId = "run-1000-abcd"; + await store.createRun(runId, "/tmp/spec.md"); + await store.updateRun(runId, { mode: "run", overallStatus: "running" }); + + const responses: Array<{ requestId: string | number; response: unknown }> = []; + let clientInstance: EventEmitter | null = null; + + try { + mockMultiAgentDetection(false); + mock.module("@ratley/codex-client", () => ({ + CodexClient: class extends EventEmitter { + constructor() { + super(); + clientInstance = this; + } + + async connect(): Promise {} + async disconnect(): Promise {} + async startThread(): Promise<{ id: string }> { + return { id: "thread-1" }; + } + async runReview(): Promise<{ reviewText: string }> { + return { reviewText: "ok" }; + } + respondToUserInputRequest(requestId: string | number, response: unknown): void { + responses.push({ requestId, response }); + } + rejectServerRequest(): void {} + private runTurnCount = 0; + async runTurn(): Promise<{ agentMessage: string; turn: { status: "completed" }; items: [] }> { + this.runTurnCount += 1; + if (this.runTurnCount === 1) { + queueMicrotask(() => { + clientInstance?.emit("request:userInput", { + requestId: "req-1", + itemId: "item-1", + threadId: "thread-1", + turnId: "turn-1", + questions: [ + { + header: "Framework", + id: "framework", + question: "Which framework should I target?", + isOther: true, + isSecret: false, + options: null, + }, + ], + }); + }); + + queueMicrotask(() => { + setTimeout(() => { + clientInstance?.emit("serverRequest:resolved", { requestId: "req-1" }); + }, 20); + }); + + return { + agentMessage: '{"needsInput":false,"context":""}', + turn: { status: "completed" }, + items: [], + }; + } + + return { + agentMessage: '{"outcome":"done"}', + turn: { status: "completed" }, + items: [], + }; + } + }, + })); + + mock.module("../../utils/skill-loader.js", () => ({ + loadSkills: async () => [], + })); + + const { createCodexSession } = await import(`./session.ts?test=${Math.random()}`); + const session = await createCodexSession(process.cwd(), undefined, { + runId: runId as `${string}-${number}-${string}`, + store, + resumeOverallStatus: "running", + }); + + try { + const executionPromise = session.executeTask( + { + id: "task-1", + name: "Build the game", + description: "Implement the requested game.", + dependencies: [], + acceptance_criteria: ["Game is implemented"], + status: "pending", + retries: 0, + maxRetries: 3, + }, + runId, + "context", + ); + + const waitingRun = await waitFor(async () => { + const run = await store.getRun(runId); + return run?.pendingQuestion ? run : null; + }); + + expect(waitingRun.overallStatus).toBe("waiting_for_answer"); + + const result = await executionPromise; + expect(result.outcome).toBe("done"); + expect(responses).toEqual([]); + + const resumedRun = await waitFor(async () => { + const run = await store.getRun(runId); + return run && run.pendingQuestion === undefined ? run : null; + }); + + expect(resumedRun.overallStatus).toBe("running"); + await expect(readFile(path.join(store.getRunDir(runId), "answer.txt"), "utf8")).rejects.toThrow(); + } finally { + await session.disconnect(); + } + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); + + test("ignores late user-input requests after the run has already failed", async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), "orca-question-flow-late-")); + const store = new RunStore(path.join(tempDir, "runs")); + const runId = "run-1000-abcd"; + await store.createRun(runId, "/tmp/spec.md"); + await store.updateRun(runId, { mode: "run", overallStatus: "running" }); + + const rejectedRequests: Array<{ requestId: string | number; error: { code: number; message: string } }> = []; + let clientInstance: EventEmitter | null = null; + + try { + mockMultiAgentDetection(false); + mock.module("@ratley/codex-client", () => ({ + CodexClient: class extends EventEmitter { + constructor() { + super(); + clientInstance = this; + } + + async connect(): Promise {} + async disconnect(): Promise {} + async startThread(): Promise<{ id: string }> { + return { id: "thread-1" }; + } + async runReview(): Promise<{ reviewText: string }> { + return { reviewText: "ok" }; + } + rejectServerRequest(requestId: string | number, error: { code: number; message: string }): void { + rejectedRequests.push({ requestId, error }); + } + private runTurnCount = 0; + async runTurn(): Promise<{ agentMessage: string; turn: { status: "completed" }; items: [] }> { + this.runTurnCount += 1; + if (this.runTurnCount === 1) { + await store.updateRun(runId, { overallStatus: "failed" }); + setTimeout(() => { + clientInstance?.emit("request:userInput", { + requestId: "req-late", + itemId: "item-1", + threadId: "thread-1", + turnId: "turn-1", + questions: [ + { + header: "Codename", + id: "codename", + question: "Which codename should I use?", + isOther: true, + isSecret: false, + options: null, + }, + ], + }); + }, 10); + + return { + agentMessage: '{"needsInput":false,"context":""}', + turn: { status: "completed" }, + items: [], + }; + } + + return { + agentMessage: '{"outcome":"done"}', + turn: { status: "completed" }, + items: [], + }; + } + }, + })); + + mock.module("../../utils/skill-loader.js", () => ({ + loadSkills: async () => [], + })); + + const { createCodexSession } = await import(`./session.ts?test=${Math.random()}`); + const session = await createCodexSession(process.cwd(), undefined, { + runId: runId as `${string}-${number}-${string}`, + store, + resumeOverallStatus: "running", + }); + + try { + const result = await session.executeTask( + { + id: "task-1", + name: "Configure release", + description: "Use the provided codename.", + dependencies: [], + acceptance_criteria: ["Release is configured"], + status: "pending", + retries: 0, + maxRetries: 3, + }, + runId, + "context", + ); + + expect(result.outcome).toBe("done"); + + await waitFor(async () => { + const run = await store.getRun(runId); + return rejectedRequests.length > 0 && run ? run : null; + }); + + const run = await store.getRun(runId); + expect(run?.overallStatus).toBe("failed"); + expect(run?.pendingQuestion).toBeUndefined(); + expect(rejectedRequests).toEqual([ + { + requestId: "req-late", + error: { + code: -32603, + message: `Run ${runId} is already failed; ignoring late requestUserInput prompt.`, + }, + }, + ]); } finally { await session.disconnect(); } diff --git a/src/cli/commands/answer.test.ts b/src/cli/commands/answer.test.ts index d361ac0..15878cf 100644 --- a/src/cli/commands/answer.test.ts +++ b/src/cli/commands/answer.test.ts @@ -20,24 +20,32 @@ const originalStdoutIsTTY = Object.getOwnPropertyDescriptor(process.stdout, "isT function setStdoutTty(value: boolean): void { Object.defineProperty(process.stdout, "isTTY", { configurable: true, - value + value, }); } async function loadAnswerModule(options?: { input?: () => Promise; select?: () => Promise; -}): Promise<{ answerModule: AnswerModule; inputMock: ReturnType; selectMock: ReturnType }> { + password?: () => Promise; +}): Promise<{ + answerModule: AnswerModule; + inputMock: ReturnType; + selectMock: ReturnType; + passwordMock: ReturnType; +}> { const inputMock = mock(options?.input ?? (async () => "prompt answer")); const selectMock = mock(options?.select ?? (async () => "selected-run-1000-abcd")); + const passwordMock = mock(options?.password ?? (async () => "prompt secret")); mock.module("@inquirer/prompts", () => ({ input: inputMock, - select: selectMock + password: passwordMock, + select: selectMock, })); const answerModule = await import(`./answer.js?test=${Math.random()}`); - return { answerModule, inputMock, selectMock }; + return { answerModule, inputMock, selectMock, passwordMock }; } beforeEach(async () => { @@ -163,9 +171,7 @@ describe("answer command", () => { const { answerModule } = await loadAnswerModule(); - await expect(answerModule.answerCommandHandler(runId, undefined, {})).rejects.toThrow( - "no answer provided" - ); + await expect(answerModule.answerCommandHandler(runId, undefined, {})).rejects.toThrow("no answer provided"); }); test("prompts for answer when tty and no positional answer was provided", async () => { @@ -195,7 +201,7 @@ describe("answer command", () => { setStdoutTty(true); const { answerModule, inputMock } = await loadAnswerModule({ - input: async () => "from prompt" + input: async () => "from prompt", }); await answerModule.answerCommandHandler(runId, undefined, {}); @@ -234,7 +240,7 @@ describe("answer command", () => { setStdoutTty(true); const { answerModule, selectMock } = await loadAnswerModule({ - select: async () => runId + select: async () => runId, }); await answerModule.answerCommandHandler(undefined, "selected answer", {}); @@ -284,11 +290,64 @@ describe("answer command", () => { ); }); + test("uses password prompt and direct answer channel for secret questions", async () => { + const runId = "answer-secret-1000-abcd"; + const submissions: string[] = []; + const { writeSecretAnswerChannel } = await import("../../core/secret-answer-channel.js"); + + const store = new RunStore(runsDir); + await store.createRun(runId, "/tmp/spec.md"); + await store.updateRun(runId, { + overallStatus: "waiting_for_answer", + pendingQuestion: { + requestId: "req-1", + threadId: "thread-1", + turnId: "turn-1", + itemId: "item-1", + receivedAt: new Date().toISOString(), + questions: [ + { + header: "API Key", + id: "api_key", + question: "What API key should I use?", + isOther: true, + isSecret: true, + options: null, + }, + ], + }, + }); + await writeSecretAnswerChannel(runId as `${string}-${number}-${string}`, { + transport: "ipc", + path: path.join(tempDir, "secret-answer.sock"), + token: "secret-token", + }); + setStdoutTty(true); + + const { answerModule, passwordMock, inputMock } = await loadAnswerModule({ + password: async () => "super-secret", + }); + answerModule.setAnswerChannelSubmitterForTests(async (channel, payload) => { + submissions.push(JSON.stringify({ channel, payload })); + }); + + await answerModule.answerCommandHandler(runId, undefined, {}); + + expect(passwordMock).toHaveBeenCalled(); + expect(inputMock).not.toHaveBeenCalled(); + expect(submissions).toHaveLength(1); + expect(submissions[0]).toContain('"token":"secret-token"'); + expect(submissions[0]).toContain('\\"super-secret\\"'); + + const answerPath = path.join(runsDir, runId, "answer.txt"); + await expect(readFile(answerPath, "utf8")).rejects.toThrow(); + }); + test("fails when positional run-id and --run are both provided", async () => { const { answerModule } = await loadAnswerModule(); await expect( - answerModule.answerCommandHandler("run-a-1000-abcd", "yes", { run: "run-b-1001-abcd" }) + answerModule.answerCommandHandler("run-a-1000-abcd", "yes", { run: "run-b-1001-abcd" }), ).rejects.toThrow("positional run-id and --run are mutually exclusive"); }); }); diff --git a/src/cli/commands/answer.ts b/src/cli/commands/answer.ts index 5c51718..7229053 100644 --- a/src/cli/commands/answer.ts +++ b/src/cli/commands/answer.ts @@ -1,18 +1,28 @@ import path from "node:path"; import { promises as fs } from "node:fs"; +import { connect } from "node:net"; -import { input } from "@inquirer/prompts"; +import { input, password } from "@inquirer/prompts"; import type { Command } from "commander"; import { parseQuestionAnswerInput, serializeQuestionAnswerResponse } from "../../core/question-flow.js"; +import { readSecretAnswerChannel } from "../../core/secret-answer-channel.js"; import { RunStore } from "../../state/store.js"; -import type { PendingQuestion } from "../../types/index.js"; +import type { PendingAnswerChannel, PendingQuestion } from "../../types/index.js"; import { selectRun } from "../../utils/select-run.js"; export interface AnswerCommandOptions { run?: string; } +type AnswerChannelSubmitter = (channel: PendingAnswerChannel, payload: string) => Promise; + +let testAnswerChannelSubmitter: AnswerChannelSubmitter | null = null; + +export function setAnswerChannelSubmitterForTests(submitter: AnswerChannelSubmitter | null): void { + testAnswerChannelSubmitter = submitter; +} + function createStore(): RunStore { const runsDir = process.env.ORCA_RUNS_DIR; return runsDir ? new RunStore(runsDir) : new RunStore(); @@ -27,12 +37,22 @@ function resolveRunId(positionalRunId: string | undefined, optionRunId: string | } function formatQuestionPrompt(question: PendingQuestion["questions"][number]): string { - const options = question.options && question.options.length > 0 - ? ` Options: ${question.options.map((option) => option.label).join(", ")}.` - : ""; + const options = + question.options && question.options.length > 0 + ? ` Options: ${question.options.map((option) => option.label).join(", ")}.` + : ""; return `${question.header}: ${question.question}${options}`; } +function hasSecretQuestions(pendingQuestion: PendingQuestion | undefined): boolean { + return pendingQuestion?.questions.some((question) => question.isSecret) ?? false; +} + +async function promptForQuestionAnswer(question: PendingQuestion["questions"][number]): Promise { + const prompt = { message: formatQuestionPrompt(question) }; + return question.isSecret ? await password(prompt) : await input(prompt); +} + async function resolveAnswerPayload( pendingQuestion: PendingQuestion | undefined, answerArg: string | undefined, @@ -56,7 +76,7 @@ async function resolveAnswerPayload( const answers: Record = {}; for (const question of pendingQuestion.questions) { - const value = await input({ message: formatQuestionPrompt(question) }); + const value = await promptForQuestionAnswer(question); if (!value) { throw new Error(`no answer provided for question '${question.id}'`); } @@ -67,10 +87,47 @@ async function resolveAnswerPayload( return JSON.stringify({ answers }); } +async function submitAnswerViaChannel(channel: PendingAnswerChannel, payload: string): Promise { + if (testAnswerChannelSubmitter) { + await testAnswerChannelSubmitter(channel, payload); + return; + } + + await new Promise((resolve, reject) => { + const socket = connect(channel.path); + socket.setEncoding("utf8"); + + let responseBuffer = ""; + + socket.on("connect", () => { + socket.write(`${JSON.stringify({ token: channel.token, answer: payload })}\n`); + }); + + socket.on("data", (chunk) => { + responseBuffer += chunk; + }); + + socket.on("error", reject); + socket.on("end", () => { + try { + const parsed = JSON.parse(responseBuffer.trim()) as { ok?: boolean; error?: unknown }; + if (parsed.ok !== true) { + throw new Error( + typeof parsed.error === "string" ? parsed.error : "secret answer channel rejected the payload", + ); + } + resolve(); + } catch (error) { + reject(error); + } + }); + }); +} + export async function answerCommandHandler( positionalRunId: string | undefined, answerArg: string | undefined, - options: AnswerCommandOptions + options: AnswerCommandOptions, ): Promise { const store = createStore(); let runId = resolveRunId(positionalRunId, options.run); @@ -80,7 +137,7 @@ export async function answerCommandHandler( throw new Error("no run id provided"); } - runId = await selectRun(store) ?? undefined; + runId = (await selectRun(store)) ?? undefined; if (!runId) { console.error("No runs found."); process.exitCode = 1; @@ -105,6 +162,18 @@ export async function answerCommandHandler( const serialized = run.pendingQuestion ? serializeQuestionAnswerResponse(parseQuestionAnswerInput(answerPayload, run.pendingQuestion)) : `${answerPayload}\n`; + + if (hasSecretQuestions(run.pendingQuestion)) { + const answerChannel = await readSecretAnswerChannel(runId as `${string}-${number}-${string}`); + if (!answerChannel) { + throw new Error("run is waiting for a secret answer but has no active answer channel"); + } + + await submitAnswerViaChannel(answerChannel, answerPayload); + console.log(`Answer submitted. Run ${runId} will resume shortly.`); + return; + } + const answerPath = path.join(store.getRunDir(runId), "answer.txt"); await fs.mkdir(path.dirname(answerPath), { recursive: true }); await fs.writeFile(answerPath, serialized, "utf8"); diff --git a/src/cli/commands/cancel.ts b/src/cli/commands/cancel.ts index ffb6623..dca80d1 100644 --- a/src/cli/commands/cancel.ts +++ b/src/cli/commands/cancel.ts @@ -66,7 +66,7 @@ export async function cancelCommandHandler(options: CancelCommandOptions): Promi ...task, status: "cancelled" as const, finishedAt: cancelledAt, - lastError: "Run cancelled" + lastError: "Run cancelled", }; } @@ -75,7 +75,7 @@ export async function cancelCommandHandler(options: CancelCommandOptions): Promi await store.updateRun(run.runId, { overallStatus: "cancelled", - tasks + tasks, }); if (cancelledTaskId) { diff --git a/src/cli/commands/help.ts b/src/cli/commands/help.ts index 50e490f..905ac3d 100644 --- a/src/cli/commands/help.ts +++ b/src/cli/commands/help.ts @@ -27,7 +27,7 @@ function printStyledHelpPage(): void { printSection("RUNNING", [ { command: 'orca "add auth to the app"', description: "run with inline goal" }, { command: "orca --plan ./specs/feature.md", description: "run from plan file" }, - { command: "orca plan --spec ./specs/feature.md", description: "plan only, no execution" } + { command: "orca plan --spec ./specs/feature.md", description: "plan only, no execution" }, ]); printSection("RUN MANAGEMENT", [ @@ -37,7 +37,7 @@ function printStyledHelpPage(): void { { command: "orca answer ", description: "answer a waiting question" }, { command: "orca resume --last", description: "resume most recent run" }, { command: "orca resume --run ", description: "resume incomplete run" }, - { command: "orca cancel --run ", description: "cancel active run" } + { command: "orca cancel --run ", description: "cancel active run" }, ]); printSection("PULL REQUESTS", [ @@ -45,7 +45,7 @@ function printStyledHelpPage(): void { { command: "orca pr draft --run ", description: "create draft PR" }, { command: "orca pr create --run ", description: "create ready-for-review PR" }, { command: "orca pr publish --run ", description: "publish draft → ready for review" }, - { command: "orca pr status --run ", description: "check PR state and CI" } + { command: "orca pr status --run ", description: "check PR state and CI" }, ]); printSection("SETUP", [{ command: "orca setup", description: "first-time setup and Codex environment checks" }]); @@ -63,12 +63,15 @@ function printStyledHelpPage(): void { { command: "--on-question ", description: "shell hook on question required" }, { command: "--on-error ", description: "shell hook on run error" }, { command: "-h, --help", description: "show help for any command" }, - { command: "-V, --version", description: "show version" } + { command: "-V, --version", description: "show version" }, ]); } function findCommand(program: Command, pathText: string): Command | undefined { - const segments = pathText.split(" ").map((segment) => segment.trim()).filter((segment) => segment.length > 0); + const segments = pathText + .split(" ") + .map((segment) => segment.trim()) + .filter((segment) => segment.length > 0); let current: Command | undefined = program; for (const segment of segments) { current = current.commands.find((command) => command.name() === segment); @@ -79,19 +82,22 @@ function findCommand(program: Command, pathText: string): Command | undefined { export function registerHelpCommand(program: Command): void { program.addHelpCommand(false); - program.command("help [command]").description("display help for command").action((commandPath?: string) => { - if (!commandPath) { - printStyledHelpPage(); - return; - } + program + .command("help [command]") + .description("display help for command") + .action((commandPath?: string) => { + if (!commandPath) { + printStyledHelpPage(); + return; + } - const target = findCommand(program, commandPath); - if (!target) { - console.error(`error: unknown command '${commandPath}'`); - process.exitCode = 1; - return; - } + const target = findCommand(program, commandPath); + if (!target) { + console.error(`error: unknown command '${commandPath}'`); + process.exitCode = 1; + return; + } - target.outputHelp(); - }); + target.outputHelp(); + }); } diff --git a/src/cli/commands/list.ts b/src/cli/commands/list.ts index 7fc07b2..97c53cc 100644 --- a/src/cli/commands/list.ts +++ b/src/cli/commands/list.ts @@ -30,12 +30,7 @@ function formatTable(headers: string[], rows: string[][]): string { } function toSummaryRow(run: RunStatus): string[] { - return [ - run.runId, - run.specPath, - run.overallStatus, - run.createdAt - ]; + return [run.runId, run.specPath, run.overallStatus, run.createdAt]; } export function formatRunSummaryTable(runs: RunStatus[]): string { diff --git a/src/cli/commands/plan.test.ts b/src/cli/commands/plan.test.ts new file mode 100644 index 0000000..f3641f6 --- /dev/null +++ b/src/cli/commands/plan.test.ts @@ -0,0 +1,62 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; + +type PlanModule = typeof import("./plan.js"); + +let tempDir = ""; +let runsDir = ""; +let specPath = ""; +let logs: string[] = []; +const originalRunsDir = process.env.ORCA_RUNS_DIR; +const originalConsoleLog = console.log; + +async function loadPlanModule(): Promise { + return import(`./plan.js?test=${Math.random()}`); +} + +beforeEach(async () => { + tempDir = await mkdtemp(path.join(os.tmpdir(), "orca-plan-test-")); + runsDir = path.join(tempDir, "runs"); + specPath = path.join(tempDir, "spec.md"); + await writeFile(specPath, "# Spec\n", "utf8"); + process.env.ORCA_RUNS_DIR = runsDir; + logs = []; + + console.log = (...args: unknown[]) => { + logs.push(args.map(String).join(" ")); + }; + + mock.module("../../core/config-loader.js", () => ({ + resolveConfig: async () => undefined, + })); + mock.module("../../core/planner.js", () => ({ + runPlanner: async () => {}, + })); +}); + +afterEach(async () => { + mock.restore(); + console.log = originalConsoleLog; + + if (originalRunsDir === undefined) { + delete process.env.ORCA_RUNS_DIR; + } else { + process.env.ORCA_RUNS_DIR = originalRunsDir; + } + + await rm(tempDir, { recursive: true, force: true }); +}); + +describe("plan command", () => { + test("honors ORCA_RUNS_DIR for plan runs", async () => { + const planModule = await loadPlanModule(); + + await planModule.planCommand({ spec: specPath }); + + const runDirLine = logs.find((line) => line.startsWith("Run dir: ")); + expect(runDirLine).toBeTruthy(); + expect(runDirLine?.slice("Run dir: ".length)).toStartWith(runsDir); + }); +}); diff --git a/src/cli/commands/plan.ts b/src/cli/commands/plan.ts index fd2f7bb..39b287c 100644 --- a/src/cli/commands/plan.ts +++ b/src/cli/commands/plan.ts @@ -15,6 +15,11 @@ export interface PlanCommandOptions { onError?: string; } +function createStore(): RunStore { + const runsDir = process.env.ORCA_RUNS_DIR; + return runsDir ? new RunStore(runsDir) : new RunStore(); +} + export async function planCommand(options: { spec: string; config?: string }): Promise { const specPath = path.resolve(options.spec); await access(specPath, fsConstants.R_OK); @@ -23,7 +28,7 @@ export async function planCommand(options: { spec: string; config?: string }): P const runId = generateRunId(specPath); console.log(`Run ID: ${runId}`); - const store = new RunStore(); + const store = createStore(); await store.createRun(runId, specPath); await runPlanner(specPath, store, runId, orcaConfig); @@ -40,9 +45,7 @@ export async function planCommand(options: { spec: string; config?: string }): P } export async function planCommandHandler(options: PlanCommandOptions): Promise { - const commandOptions = options.config - ? { spec: options.spec, config: options.config } - : { spec: options.spec }; + const commandOptions = options.config ? { spec: options.spec, config: options.config } : { spec: options.spec }; await planCommand(commandOptions); } diff --git a/src/cli/commands/pr/create.ts b/src/cli/commands/pr/create.ts index eac97ac..f0038be 100644 --- a/src/cli/commands/pr/create.ts +++ b/src/cli/commands/pr/create.ts @@ -8,7 +8,7 @@ import { createStore, loadRunOrExit, printGhMissingAndExit, - resolveRunIdOrExit + resolveRunIdOrExit, } from "./shared.js"; function parsePrUrl(stdout: string): string | null { @@ -59,8 +59,8 @@ export async function prCreateCommandHandler(options: PrCommandOptions): Promise url, draftTitle: title, draftBody: body, - readyForFinalize: true - } + readyForFinalize: true, + }, }); console.log(url); diff --git a/src/cli/commands/pr/draft.ts b/src/cli/commands/pr/draft.ts index 3ed0e65..507d72f 100644 --- a/src/cli/commands/pr/draft.ts +++ b/src/cli/commands/pr/draft.ts @@ -8,7 +8,7 @@ import { createStore, loadRunOrExit, printGhMissingAndExit, - resolveRunIdOrExit + resolveRunIdOrExit, } from "./shared.js"; function parsePrUrl(stdout: string): string | null { @@ -59,8 +59,8 @@ export async function prDraftCommandHandler(options: PrCommandOptions): Promise< url, draftTitle: title, draftBody: body, - readyForFinalize: false - } + readyForFinalize: false, + }, }); console.log(url); diff --git a/src/cli/commands/pr/index.ts b/src/cli/commands/pr/index.ts index 2733682..b431c5a 100644 --- a/src/cli/commands/pr/index.ts +++ b/src/cli/commands/pr/index.ts @@ -9,9 +9,7 @@ import { prStatusCommandHandler, registerPrStatusCommand } from "./status.js"; import { selectRun } from "../../../utils/select-run.js"; export function registerPrCommand(program: Command): void { - const prCommand = program - .command("pr") - .description("Pull request workflow commands"); + const prCommand = program.command("pr").description("Pull request workflow commands"); registerPrDraftCommand(prCommand); registerPrCreateCommand(prCommand); @@ -55,8 +53,8 @@ export function registerPrCommand(program: Command): void { { name: "Create draft PR", value: "draft" }, { name: "Create PR (ready for review)", value: "create" }, { name: "Publish draft → ready for review", value: "publish" }, - { name: "View PR status & CI checks", value: "status" } - ] + { name: "View PR status & CI checks", value: "status" }, + ], }); const options = { run: runId }; diff --git a/src/cli/commands/pr/pr.test.ts b/src/cli/commands/pr/pr.test.ts index 385ce83..6adb31e 100644 --- a/src/cli/commands/pr/pr.test.ts +++ b/src/cli/commands/pr/pr.test.ts @@ -68,20 +68,20 @@ async function loadPrModules(options: { (async () => ({ stdout: "", stderr: "", - exitCode: 0 - })) + exitCode: 0, + })), ); mock.module("../../../utils/gh.js", () => ({ checkGhCli, - runGh + runGh, })); const nonce = Math.random(); const [draftModule, publishModule, statusModule] = await Promise.all([ import(`./draft.js?test=${nonce}`), import(`./publish.js?test=${nonce}`), - import(`./status.js?test=${nonce}`) + import(`./status.js?test=${nonce}`), ]); return { draftModule, publishModule, statusModule, checkGhCli, runGh }; @@ -93,7 +93,7 @@ describe("PR command handlers", () => { await store.createRun(runId, "/tmp/spec.md"); const { draftModule, runGh } = await loadPrModules({ - checkGhCli: async () => false + checkGhCli: async () => false, }); await draftModule.prDraftCommandHandler({ run: runId }); @@ -121,13 +121,21 @@ describe("PR command handlers", () => { runGh: async () => ({ stdout: `creating PR\n${prUrl}\n`, stderr: "", - exitCode: 0 - }) + exitCode: 0, + }), }); await draftModule.prDraftCommandHandler({ run: runId }); - expect(runGh).toHaveBeenCalledWith(["pr", "create", "--draft", "--title", "Orca run: spec.md", "--body", expect.any(String)]); + expect(runGh).toHaveBeenCalledWith([ + "pr", + "create", + "--draft", + "--title", + "Orca run: spec.md", + "--body", + expect.any(String), + ]); const updated = await store.getRun(runId); expect(updated?.pr?.url).toBe(prUrl); @@ -165,8 +173,8 @@ describe("PR command handlers", () => { await store.updateRun(runId, { pr: { readyForFinalize: false, - url: prUrl - } + url: prUrl, + }, }); const { statusModule } = await loadPrModules({ @@ -177,12 +185,12 @@ describe("PR command handlers", () => { url: prUrl, statusCheckRollup: [ { name: "ci/test", status: "COMPLETED", conclusion: "SUCCESS" }, - { context: "lint", state: "PENDING" } - ] + { context: "lint", state: "PENDING" }, + ], }), stderr: "", - exitCode: 0 - }) + exitCode: 0, + }), }); await statusModule.prStatusCommandHandler({ run: runId }); diff --git a/src/cli/commands/pr/publish.ts b/src/cli/commands/pr/publish.ts index b5caf68..0e9f5e4 100644 --- a/src/cli/commands/pr/publish.ts +++ b/src/cli/commands/pr/publish.ts @@ -6,7 +6,7 @@ import { createStore, loadRunOrExit, printGhMissingAndExit, - resolveRunIdOrExit + resolveRunIdOrExit, } from "./shared.js"; export async function prPublishCommandHandler(options: PrCommandOptions): Promise { @@ -43,8 +43,8 @@ export async function prPublishCommandHandler(options: PrCommandOptions): Promis pr: { ...run.pr, readyForFinalize: true, - finalizedAt: new Date().toISOString() - } + finalizedAt: new Date().toISOString(), + }, }); console.log(`PR marked ready for review: ${run.pr.url}`); diff --git a/src/cli/commands/pr/shared.ts b/src/cli/commands/pr/shared.ts index 68bedf6..e43a4e7 100644 --- a/src/cli/commands/pr/shared.ts +++ b/src/cli/commands/pr/shared.ts @@ -24,13 +24,7 @@ export function buildPrTitle(run: RunStatus): string { export function buildPrBody(run: RunStatus): string { const completedTasks = run.tasks.filter((task) => task.status === "done"); - const lines = [ - `Automated PR for run \`${run.runId}\`.`, - "", - `Spec: \`${run.specPath}\``, - "", - "Completed tasks:" - ]; + const lines = [`Automated PR for run \`${run.runId}\`.`, "", `Spec: \`${run.specPath}\``, "", "Completed tasks:"]; if (completedTasks.length === 0) { lines.push("- (none)"); @@ -69,7 +63,7 @@ export function printGhMissingAndExit(): void { export async function resolveRunIdOrExit( options: PrCommandOptions, - commandName: "draft" | "create" | "publish" | "status" + commandName: "draft" | "create" | "publish" | "status", ): Promise { if (options.last) { const store = createStore(); diff --git a/src/cli/commands/resume.ts b/src/cli/commands/resume.ts index 69a6763..75d54e0 100644 --- a/src/cli/commands/resume.ts +++ b/src/cli/commands/resume.ts @@ -42,7 +42,7 @@ function parseCodexEffortOption(value: string): CodexEffort { function applyExecutorOverrideForResume( config: OrcaConfig | undefined, - options: Pick + options: Pick, ): OrcaConfig | undefined { const nextConfig: OrcaConfig = { ...config }; @@ -103,7 +103,7 @@ export async function resumeCommandHandler(options: ResumeCommandOptions): Promi return { ...task, status: "pending" as const, - lastError: "Recovered from interrupted in_progress task" + lastError: "Recovered from interrupted in_progress task", }; } @@ -113,13 +113,13 @@ export async function resumeCommandHandler(options: ResumeCommandOptions): Promi await store.updateRun(run.runId, { mode: "run", overallStatus: "running", - tasks: resumedTasks + tasks: resumedTasks, }); await runTaskRunner({ runId: run.runId, store, - ...(effectiveConfig ? { config: effectiveConfig } : {}) + ...(effectiveConfig ? { config: effectiveConfig } : {}), }); const refreshed = await store.getRun(run.runId); diff --git a/src/cli/commands/run-command.test-harness.ts b/src/cli/commands/run-command.test-harness.ts index 6587d6a..5f796fb 100644 --- a/src/cli/commands/run-command.test-harness.ts +++ b/src/cli/commands/run-command.test-harness.ts @@ -76,24 +76,26 @@ export function createRunCommandTestHarness(tempPrefix: string): RunCommandTestH InvalidPlanErrorCtor: new (stage: "planner" | "review", message: string) => Error; }> { const runPlannerMock = mock(async () => {}); - const runTaskRunnerMock = mock(async (options: { runId: string; store: { updateRun: (runId: string, patch: unknown) => Promise } }) => { - await options.store.updateRun(options.runId, { - tasks: [ - { - id: "t1", - name: "task", - description: "task", - dependencies: [], - acceptance_criteria: ["done"], - status: "done", - retries: 0, - maxRetries: 3, - startedAt: new Date().toISOString(), - finishedAt: new Date().toISOString() - } - ] - }); - }); + const runTaskRunnerMock = mock( + async (options: { runId: string; store: { updateRun: (runId: string, patch: unknown) => Promise } }) => { + await options.store.updateRun(options.runId, { + tasks: [ + { + id: "t1", + name: "task", + description: "task", + dependencies: [], + acceptance_criteria: ["done"], + status: "done", + retries: 0, + maxRetries: 3, + startedAt: new Date().toISOString(), + finishedAt: new Date().toISOString(), + }, + ], + }); + }, + ); const hookDispatchMock = mock(async () => {}); class TestInvalidPlanError extends Error { @@ -116,35 +118,36 @@ export function createRunCommandTestHarness(tempPrefix: string): RunCommandTestH }); const ensureCodexMultiAgentMock = mock(async () => ({ action: "skipped" as const, - path: path.join(tempDir, "mock-codex-config.toml") + path: path.join(tempDir, "mock-codex-config.toml"), })); const createCodexSessionMock = mock(async () => ({ consultTaskGraph: async () => ({ issues: [], ok: true }), executeTask: async () => ({ outcome: "done" as const, rawResponse: '{"outcome":"done"}' }), runPrompt: async () => '{"summary":"clean","findings":[],"fixed":false}', reviewChanges: async () => "review", - disconnect: async () => {} + disconnect: async () => {}, })); void mock.module("../../core/planner.js", () => ({ runPlanner: runPlannerMock, - InvalidPlanError: TestInvalidPlanError + InvalidPlanError: TestInvalidPlanError, })); void mock.module("../../core/task-runner.js", () => ({ - runTaskRunner: runTaskRunnerMock + runTaskRunner: runTaskRunnerMock, + writeSessionSummary: async () => {}, })); void mock.module("../../core/config-loader.js", () => ({ - resolveConfig: resolveConfigMock + resolveConfig: resolveConfigMock, })); void mock.module("../../core/codex-config.js", () => ({ - ensureCodexMultiAgent: ensureCodexMultiAgentMock + ensureCodexMultiAgent: ensureCodexMultiAgentMock, })); void mock.module("../../agents/codex/session.js", () => ({ - createCodexSession: createCodexSessionMock + createCodexSession: createCodexSessionMock, })); void mock.module("../../hooks/adapters/openclaw.js", () => ({ detectOpenclawAvailability: () => ({ available: false }), - createOpenclawHookHandler: () => async () => {} + createOpenclawHookHandler: () => async () => {}, })); void mock.module("../../hooks/dispatcher.js", () => ({ HookDispatcher: class { @@ -152,10 +155,10 @@ export function createRunCommandTestHarness(tempPrefix: string): RunCommandTestH async dispatch(event: unknown): Promise { await (hookDispatchMock as (value: unknown) => Promise)(event); } - } + }, })); void mock.module("../../utils/ids.js", () => ({ - generateRunId: () => "run-test-1000-abcd" + generateRunId: () => "run-test-1000-abcd", })); const runModule = await import(`./run.js?test=${Math.random()}`); return { @@ -165,7 +168,7 @@ export function createRunCommandTestHarness(tempPrefix: string): RunCommandTestH createCodexSessionMock, ensureCodexMultiAgentMock, hookDispatchMock, - InvalidPlanErrorCtor: TestInvalidPlanError + InvalidPlanErrorCtor: TestInvalidPlanError, }; } @@ -179,6 +182,6 @@ export function createRunCommandTestHarness(tempPrefix: string): RunCommandTestH return { getTempDir: () => tempDir, loadRunModule, - parseRun + parseRun, }; } diff --git a/src/cli/commands/run.postexec-json.integration.test.ts b/src/cli/commands/run.postexec-json.integration.test.ts index 1aaabac..c8edd00 100644 --- a/src/cli/commands/run.postexec-json.integration.test.ts +++ b/src/cli/commands/run.postexec-json.integration.test.ts @@ -19,11 +19,15 @@ describe("post-exec reviewer JSON hardening integration", () => { executeTask: async () => ({ outcome: "done" as const, rawResponse: '{"outcome":"done"}' }), runPrompt: runPromptMock, reviewChanges: async () => "review", - disconnect: async () => {} + disconnect: async () => {}, })); const configPath = path.join(getTempDir(), "orca.config.js"); - await writeFile(configPath, "export default { review: { execution: { onFindings: 'report_only', validator: { auto: false } } } };\n", "utf8"); + await writeFile( + configPath, + "export default { review: { execution: { onFindings: 'report_only', validator: { auto: false } } } };\n", + "utf8", + ); await parseRun(runModule, ["run", "--task", "x", "--config", configPath]); @@ -31,7 +35,7 @@ describe("post-exec reviewer JSON hardening integration", () => { expect(runPromptMock).toHaveBeenNthCalledWith( 2, expect.stringContaining("previous post-execution review response was invalid"), - "review" + "review", ); }); @@ -46,20 +50,20 @@ describe("post-exec reviewer JSON hardening integration", () => { executeTask: async () => ({ outcome: "done" as const, rawResponse: '{"outcome":"done"}' }), runPrompt: runPromptMock, reviewChanges: async () => "review", - disconnect: async () => {} + disconnect: async () => {}, })); const configPath = path.join(getTempDir(), "orca.config.js"); - await writeFile(configPath, "export default { review: { execution: { onFindings: 'report_only', validator: { auto: false } } } };\n", "utf8"); + await writeFile( + configPath, + "export default { review: { execution: { onFindings: 'report_only', validator: { auto: false } } } };\n", + "utf8", + ); await parseRun(runModule, ["run", "--task", "x", "--config", configPath]); expect(runPromptMock).toHaveBeenCalledTimes(2); - expect(runPromptMock).toHaveBeenNthCalledWith( - 2, - expect.stringContaining("Schema validation failed"), - "review" - ); + expect(runPromptMock).toHaveBeenNthCalledWith(2, expect.stringContaining("Schema validation failed"), "review"); }); test("invalid reviewer JSON after bounded retries is treated as findings and dispatches onFindings", async () => { @@ -70,16 +74,20 @@ describe("post-exec reviewer JSON hardening integration", () => { executeTask: async () => ({ outcome: "done" as const, rawResponse: '{"outcome":"done"}' }), runPrompt: runPromptMock, reviewChanges: async () => "review", - disconnect: async () => {} + disconnect: async () => {}, })); const configPath = path.join(getTempDir(), "orca.config.js"); - await writeFile(configPath, "export default { review: { execution: { onFindings: 'report_only', validator: { auto: false } } } };\n", "utf8"); + await writeFile( + configPath, + "export default { review: { execution: { onFindings: 'report_only', validator: { auto: false } } } };\n", + "utf8", + ); await parseRun(runModule, ["run", "--task", "x", "--config", configPath]); const findingsEvent = hookDispatchMock.mock.calls.find( - (call) => (call[0] as { hook?: string })?.hook === "onFindings" + (call) => (call[0] as { hook?: string })?.hook === "onFindings", )?.[0] as { message?: string; metadata?: { findingsCount?: number } } | undefined; expect(runPromptMock).toHaveBeenCalledTimes(2); diff --git a/src/cli/commands/run.test.ts b/src/cli/commands/run.test.ts index 4b23cd4..d9c6708 100644 --- a/src/cli/commands/run.test.ts +++ b/src/cli/commands/run.test.ts @@ -110,7 +110,8 @@ describe("run command executor flags", () => { }); test("dispatches onInvalidPlan hook when planner rejects invalid graph", async () => { - const { runModule, runPlannerMock, hookDispatchMock, runTaskRunnerMock, InvalidPlanErrorCtor } = await loadRunModule(); + const { runModule, runPlannerMock, hookDispatchMock, runTaskRunnerMock, InvalidPlanErrorCtor } = + await loadRunModule(); runPlannerMock.mockImplementationOnce(async () => { throw new InvalidPlanErrorCtor("review", "Review output invalid. cycle"); @@ -119,7 +120,7 @@ describe("run command executor flags", () => { await parseRun(runModule, ["run", "--task", "x"]); expect(process.exitCode).toBe(1); const invalidPlanEvent = hookDispatchMock.mock.calls.find( - (call) => (call[0] as { hook?: string })?.hook === "onInvalidPlan" + (call) => (call[0] as { hook?: string })?.hook === "onInvalidPlan", )?.[0] as { hook: string; message: string; error?: string; metadata?: { stage?: string } } | undefined; expect(invalidPlanEvent?.message).toBe("invalid-plan:review"); @@ -134,9 +135,7 @@ describe("run command executor flags", () => { await parseRun(runModule, ["run", "--task", "x", "--codex-effort", "medium"]); const plannerConfig = runPlannerMock.mock.calls[0]?.[3] as { codex?: { effort?: string } } | undefined; - const runnerArg = runTaskRunnerMock.mock.calls[0]?.[0] as - | { config?: { codex?: { effort?: string } } } - | undefined; + const runnerArg = runTaskRunnerMock.mock.calls[0]?.[0] as { config?: { codex?: { effort?: string } } } | undefined; expect(plannerConfig?.codex?.effort).toBe("medium"); expect(runnerArg?.config?.codex?.effort).toBe("medium"); }); @@ -157,9 +156,7 @@ describe("run command executor flags", () => { test("rejects invalid boolean value for --codex-only", async () => { const { runModule } = await loadRunModule(); - await expect( - parseRun(runModule, ["run", "--task", "x", "--codex-only=false"]) - ).rejects.toThrow(); + await expect(parseRun(runModule, ["run", "--task", "x", "--codex-only=false"])).rejects.toThrow(); }); test("legacy review.enabled=false disables post-execution review", async () => { @@ -171,7 +168,7 @@ describe("run command executor flags", () => { executeTask: async () => ({ outcome: "done" as const, rawResponse: '{"outcome":"done"}' }), runPrompt: runPromptMock, reviewChanges: reviewChangesMock, - disconnect: async () => {} + disconnect: async () => {}, })); const configPath = path.join(getTempDir(), "orca.config.js"); @@ -182,6 +179,79 @@ describe("run command executor flags", () => { expect(reviewChangesMock).not.toHaveBeenCalled(); }); + test("marks run failed when task graph consultation throws", async () => { + const { runModule, createCodexSessionMock, runTaskRunnerMock } = await loadRunModule(); + createCodexSessionMock.mockImplementationOnce(async () => ({ + consultTaskGraph: async () => { + throw new Error("Quota exceeded. Check your plan and billing details."); + }, + executeTask: async () => ({ outcome: "done" as const, rawResponse: '{"outcome":"done"}' }), + runPrompt: async () => '{"summary":"clean","findings":[],"fixed":false}', + reviewChanges: async () => "review", + disconnect: async () => {}, + })); + + await parseRun(runModule, ["run", "--task", "x"]); + + expect(process.exitCode).toBe(1); + expect(runTaskRunnerMock).not.toHaveBeenCalled(); + + const statusPath = path.join(getTempDir(), "runs", "run-test-1000-abcd", "status.json"); + const status = JSON.parse(await readFile(statusPath, "utf8")) as { + overallStatus?: string; + errors?: Array<{ message?: string }>; + }; + expect(status.overallStatus).toBe("failed"); + expect(status.errors?.[0]?.message).toContain("Quota exceeded"); + }); + + test("does not dispatch onComplete twice when execution review is disabled", async () => { + const { runModule, runTaskRunnerMock, hookDispatchMock } = await loadRunModule(); + runTaskRunnerMock.mockImplementationOnce( + async (options: { + runId: string; + store: { updateRun: (runId: string, patch: unknown) => Promise }; + emitHook?: (event: unknown) => Promise; + }) => { + await options.store.updateRun(options.runId, { + overallStatus: "completed", + tasks: [ + { + id: "t1", + name: "task", + description: "task", + dependencies: [], + acceptance_criteria: ["done"], + status: "done", + retries: 0, + maxRetries: 3, + startedAt: new Date().toISOString(), + finishedAt: new Date().toISOString(), + }, + ], + }); + + await options.emitHook?.({ + runId: options.runId, + hook: "onComplete", + message: "run-completed", + timestamp: new Date().toISOString(), + metadata: { overallStatus: "completed" }, + }); + }, + ); + + const configPath = path.join(getTempDir(), "orca.config.js"); + await writeFile(configPath, "export default { review: { enabled: false } };\n", "utf8"); + + await parseRun(runModule, ["run", "--task", "x", "--config", configPath]); + + const onCompleteCalls = hookDispatchMock.mock.calls.filter( + (call) => (call[0] as { hook?: string })?.hook === "onComplete", + ); + expect(onCompleteCalls).toHaveLength(1); + }); + test("dispatches onFindings hook when post-execution review reports findings", async () => { const { runModule, createCodexSessionMock, hookDispatchMock } = await loadRunModule(); createCodexSessionMock.mockImplementationOnce(async () => ({ @@ -189,7 +259,7 @@ describe("run command executor flags", () => { executeTask: async () => ({ outcome: "done" as const, rawResponse: '{"outcome":"done"}' }), runPrompt: async () => '{"summary":"needs fixes","findings":["lint"],"fixed":false}', reviewChanges: async () => "review", - disconnect: async () => {} + disconnect: async () => {}, })); const configPath = path.join(getTempDir(), "orca.config.js"); @@ -198,13 +268,59 @@ describe("run command executor flags", () => { await parseRun(runModule, ["run", "--task", "x", "--config", configPath]); const findingsEvent = hookDispatchMock.mock.calls.find( - (call) => (call[0] as { hook?: string })?.hook === "onFindings" + (call) => (call[0] as { hook?: string })?.hook === "onFindings", )?.[0] as { metadata?: { findingsCount?: number; cycleIndex?: number } } | undefined; expect(findingsEvent?.metadata?.findingsCount).toBe(1); expect(findingsEvent?.metadata?.cycleIndex).toBe(1); }); + test("skips fallback review when structured execution review already ran", async () => { + const { runModule, createCodexSessionMock } = await loadRunModule(); + const reviewChangesMock = mock(async () => "review"); + createCodexSessionMock.mockImplementationOnce(async () => ({ + consultTaskGraph: async () => ({ issues: [], ok: true }), + executeTask: async () => ({ outcome: "done" as const, rawResponse: '{"outcome":"done"}' }), + runPrompt: async () => '{"summary":"clean","findings":[],"fixed":false}', + reviewChanges: reviewChangesMock, + disconnect: async () => {}, + })); + + const configPath = path.join(getTempDir(), "orca.config.js"); + await writeFile(configPath, "export default { review: { execution: { validator: { auto: false } } } };\n", "utf8"); + + await parseRun(runModule, ["run", "--task", "x", "--config", configPath]); + + expect(reviewChangesMock).not.toHaveBeenCalled(); + }); + + test("treats failed validator commands as findings even if reviewer reports none", async () => { + const { runModule, createCodexSessionMock, hookDispatchMock } = await loadRunModule(); + createCodexSessionMock.mockImplementationOnce(async () => ({ + consultTaskGraph: async () => ({ issues: [], ok: true }), + executeTask: async () => ({ outcome: "done" as const, rawResponse: '{"outcome":"done"}' }), + runPrompt: async () => '{"summary":"clean","findings":[],"fixed":false}', + reviewChanges: async () => "review", + disconnect: async () => {}, + })); + + const configPath = path.join(getTempDir(), "orca.config.js"); + await writeFile( + configPath, + "export default { review: { execution: { onFindings: 'report_only', validator: { auto: false, commands: ['node -e \"process.exit(1)\"'] } } } };\n", + "utf8", + ); + + await parseRun(runModule, ["run", "--task", "x", "--config", configPath]); + + const findingsEvent = hookDispatchMock.mock.calls.find( + (call) => (call[0] as { hook?: string })?.hook === "onFindings", + )?.[0] as { message?: string; metadata?: { findingsCount?: number } } | undefined; + + expect(findingsEvent?.metadata?.findingsCount).toBe(1); + expect(findingsEvent?.message).toContain("Validator failures still need attention"); + }); + test("auto_fix loop stops when clean", async () => { const { runModule, createCodexSessionMock } = await loadRunModule(); const runPromptMock = mock(async () => '{"summary":"clean","findings":[],"fixed":false}'); @@ -214,11 +330,15 @@ describe("run command executor flags", () => { executeTask: async () => ({ outcome: "done" as const, rawResponse: '{"outcome":"done"}' }), runPrompt: runPromptMock, reviewChanges: async () => "review", - disconnect: async () => {} + disconnect: async () => {}, })); const configPath = path.join(getTempDir(), "orca.config.js"); - await writeFile(configPath, "export default { review: { execution: { onFindings: 'auto_fix', maxCycles: 4, validator: { auto: false } } } };\n", "utf8"); + await writeFile( + configPath, + "export default { review: { execution: { onFindings: 'auto_fix', maxCycles: 4, validator: { auto: false } } } };\n", + "utf8", + ); await parseRun(runModule, ["run", "--task", "x", "--config", configPath]); expect(runPromptMock).toHaveBeenCalledTimes(2); @@ -232,11 +352,15 @@ describe("run command executor flags", () => { executeTask: async () => ({ outcome: "done" as const, rawResponse: '{"outcome":"done"}' }), runPrompt: runPromptMock, reviewChanges: async () => "review", - disconnect: async () => {} + disconnect: async () => {}, })); const configPath = path.join(getTempDir(), "orca.config.js"); - await writeFile(configPath, "export default { review: { execution: { onFindings: 'auto_fix', maxCycles: 2, validator: { auto: false } } } };\n", "utf8"); + await writeFile( + configPath, + "export default { review: { execution: { onFindings: 'auto_fix', maxCycles: 2, validator: { auto: false } } } };\n", + "utf8", + ); await parseRun(runModule, ["run", "--task", "x", "--config", configPath]); expect(runPromptMock).toHaveBeenCalledTimes(2); @@ -250,11 +374,15 @@ describe("run command executor flags", () => { executeTask: async () => ({ outcome: "done" as const, rawResponse: '{"outcome":"done"}' }), runPrompt: runPromptMock, reviewChanges: async () => "review", - disconnect: async () => {} + disconnect: async () => {}, })); const configPath = path.join(getTempDir(), "orca.config.js"); - await writeFile(configPath, "export default { review: { execution: { onFindings: 'fail', maxCycles: 3, validator: { auto: false } } } };\n", "utf8"); + await writeFile( + configPath, + "export default { review: { execution: { onFindings: 'fail', maxCycles: 3, validator: { auto: false } } } };\n", + "utf8", + ); await parseRun(runModule, ["run", "--task", "x", "--config", configPath]); expect(runPromptMock).toHaveBeenCalledTimes(1); @@ -272,16 +400,19 @@ describe("run command executor flags", () => { executeTask: async () => ({ outcome: "done" as const, rawResponse: '{"outcome":"done"}' }), runPrompt: runPromptMock, reviewChanges: async () => "review", - disconnect: async () => {} + disconnect: async () => {}, })); const configPath = path.join(getTempDir(), "orca.config.js"); - await writeFile(configPath, "export default { review: { execution: { onFindings: 'report_only', maxCycles: 3, validator: { auto: false } } } };\n", "utf8"); + await writeFile( + configPath, + "export default { review: { execution: { onFindings: 'report_only', maxCycles: 3, validator: { auto: false } } } };\n", + "utf8", + ); await parseRun(runModule, ["run", "--task", "x", "--config", configPath]); expect(runPromptMock).toHaveBeenCalledTimes(1); }); }); -describe("resume command executor flags", () => { -}); +describe("resume command executor flags", () => {}); diff --git a/src/cli/commands/run.ts b/src/cli/commands/run.ts index 71b3d52..26ce8da 100644 --- a/src/cli/commands/run.ts +++ b/src/cli/commands/run.ts @@ -13,7 +13,7 @@ import { createCodexSession } from "../../agents/codex/session.js"; import { ensureCodexMultiAgent } from "../../core/codex-config.js"; import { resolveConfig } from "../../core/config-loader.js"; import { InvalidPlanError, runPlanner } from "../../core/planner.js"; -import { runTaskRunner } from "../../core/task-runner.js"; +import { runTaskRunner, writeSessionSummary } from "../../core/task-runner.js"; import { createOpenclawHookHandler, detectOpenclawAvailability } from "../../hooks/adapters/openclaw.js"; import { createStdoutHookHandler } from "../../hooks/adapters/stdout.js"; import { HookDispatcher } from "../../hooks/dispatcher.js"; @@ -40,11 +40,13 @@ interface ExecutionReviewResult { rawResponse: string; } -const ExecutionReviewPayloadSchema = z.object({ - summary: z.string().min(1), - findings: z.array(z.string()), - fixed: z.boolean() -}).strict(); +const ExecutionReviewPayloadSchema = z + .object({ + summary: z.string().min(1), + findings: z.array(z.string()), + fixed: z.boolean(), + }) + .strict(); type StructuredReviewResult = z.infer; @@ -75,7 +77,7 @@ const ALL_HOOKS: HookName[] = [ "onInvalidPlan", "onFindings", "onComplete", - "onError" + "onError", ]; const VALID_HOOK_NAMES = new Set([ "onMilestone", @@ -85,7 +87,7 @@ const VALID_HOOK_NAMES = new Set([ "onInvalidPlan", "onFindings", "onComplete", - "onError" + "onError", ]); function isHookName(value: string): value is HookName { @@ -120,17 +122,18 @@ export async function maybeCreateFirstRunGlobalConfig(homedir: string = os.homed const projectJsConfigPath = path.join(process.cwd(), "orca.config.js"); const projectTsConfigPath = path.join(process.cwd(), "orca.config.ts"); - const hasAnyConfig = (await pathExists(globalJsConfigPath)) - || (await pathExists(globalTsConfigPath)) - || (await pathExists(projectJsConfigPath)) - || (await pathExists(projectTsConfigPath)); + const hasAnyConfig = + (await pathExists(globalJsConfigPath)) || + (await pathExists(globalTsConfigPath)) || + (await pathExists(projectJsConfigPath)) || + (await pathExists(projectTsConfigPath)); if (hasAnyConfig) { return; } await mkdir(path.dirname(globalJsConfigPath), { recursive: true }); - await writeFile(globalJsConfigPath, "export default {\n executor: \"codex\"\n};\n", "utf8"); + await writeFile(globalJsConfigPath, 'export default {\n executor: "codex"\n};\n', "utf8"); console.log("✓ Created ~/.orca/config.js (first run defaults)"); } @@ -155,7 +158,7 @@ function buildCliCommandHooks(options: RunCommandOptions): Partial + options: Pick, ): OrcaConfig | undefined { const nextConfig: OrcaConfig = { ...config }; @@ -206,14 +209,18 @@ function getExecutionReviewConfig(config?: OrcaConfig): { maxCycles: executionConfig?.maxCycles ?? 2, onFindings: executionConfig?.onFindings ?? "auto_fix", validatorAuto: skipValidators ? false : (executionConfig?.validator?.auto ?? true), - ...(executionConfig?.validator?.commands !== undefined ? { validatorCommands: executionConfig.validator.commands } : {}), - ...(executionConfig?.prompt !== undefined ? { prompt: executionConfig.prompt } : {}) + ...(executionConfig?.validator?.commands !== undefined + ? { validatorCommands: executionConfig.validator.commands } + : {}), + ...(executionConfig?.prompt !== undefined ? { prompt: executionConfig.prompt } : {}), }; } async function detectValidatorCommands(): Promise { try { - const packageJson = JSON.parse(await readFile(path.join(process.cwd(), "package.json"), "utf8")) as { scripts?: Record }; + const packageJson = JSON.parse(await readFile(path.join(process.cwd(), "package.json"), "utf8")) as { + scripts?: Record; + }; const scripts = packageJson.scripts ?? {}; if (typeof scripts.validate === "string") { return ["npm run validate"]; @@ -237,7 +244,7 @@ async function runValidatorCommands(commands: string[]): Promise result.exitCode !== 0); return [ "You are Orca's post-execution reviewer.", "Inspect uncommitted repository changes and validation command output.", "If there are fixable findings, apply fixes directly in the workspace before responding.", + "Any validator command with a non-zero exit code is a real finding until it is fixed.", + ...(failedValidations.length > 0 + ? [ + "One or more validators failed in this cycle.", + "Do not return findings=[] unless you have actually fixed the failures and rerun validation successfully.", + ] + : []), "Respond with JSON only using this exact shape:", '{"summary":"...","findings":["..."],"fixed":true|false}', `Cycle: ${cycleIndex}`, "Validation output:", JSON.stringify(validationResults, null, 2), - ...(extraPrompt ? ["Additional reviewer instructions:", extraPrompt] : []) + ...(extraPrompt ? ["Additional reviewer instructions:", extraPrompt] : []), ].join("\n\n"); } @@ -264,9 +283,9 @@ function extractJsonCandidate(raw: string): string { return (match?.[1] ?? raw).trim(); } -function parseStructuredExecutionReview(raw: string): - | { ok: true; value: StructuredReviewResult } - | { ok: false; error: string } { +function parseStructuredExecutionReview( + raw: string, +): { ok: true; value: StructuredReviewResult } | { ok: false; error: string } { let parsed: unknown; try { parsed = JSON.parse(extractJsonCandidate(raw)); @@ -290,7 +309,7 @@ function buildExecutionReviewRepairPrompt( cycleIndex: number, previousResponse: string, parseError: string, - extraPrompt?: string + extraPrompt?: string, ): string { return [ "Your previous post-execution review response was invalid.", @@ -302,7 +321,7 @@ function buildExecutionReviewRepairPrompt( "Do not include any additional keys.", "Previous invalid response:", previousResponse, - ...(extraPrompt ? ["Additional reviewer instructions:", extraPrompt] : []) + ...(extraPrompt ? ["Additional reviewer instructions:", extraPrompt] : []), ].join("\n\n"); } @@ -310,7 +329,7 @@ async function requestStructuredExecutionReview( runPrompt: (prompt: string) => Promise, cycleIndex: number, basePrompt: string, - extraPrompt?: string + extraPrompt?: string, ): Promise { const maxAttempts = 2; let prompt = basePrompt; @@ -326,12 +345,14 @@ async function requestStructuredExecutionReview( findings: parsed.value.findings, summary: parsed.value.summary, fixed: parsed.value.fixed, - rawResponse: raw + rawResponse: raw, }; } lastError = parsed.error; - console.error(`[orca] Post-execution reviewer response failed validation (attempt ${attempt}/${maxAttempts}): ${parsed.error}`); + console.error( + `[orca] Post-execution reviewer response failed validation (attempt ${attempt}/${maxAttempts}): ${parsed.error}`, + ); if (attempt < maxAttempts) { prompt = buildExecutionReviewRepairPrompt(cycleIndex, raw, parsed.error, extraPrompt); } @@ -341,7 +362,7 @@ async function requestStructuredExecutionReview( findings: [`review-response-parse-error: ${lastError}`], summary: `Post-execution reviewer returned invalid JSON after ${maxAttempts} attempts (${lastError})`, fixed: false, - rawResponse: lastRaw + rawResponse: lastRaw, }; } @@ -384,7 +405,9 @@ export async function runCommandHandler(options: RunCommandOptions): Promise codexSession.executeTask(task, taskRunId, systemContext), }); - const reviewConfig = getExecutionReviewConfig(effectiveConfig); const finalSummaries: string[] = []; const runAfterExecution = await store.getRun(runId); if (reviewConfig.enabled && (runAfterExecution?.tasks.length ?? 0) > 0) { const configured = reviewConfig.validatorCommands?.filter((item) => item.trim().length > 0) ?? []; - const validatorCommands = configured.length > 0 - ? configured - : (reviewConfig.validatorAuto ? await detectValidatorCommands() : []); + const validatorCommands = + configured.length > 0 ? configured : reviewConfig.validatorAuto ? await detectValidatorCommands() : []; for (let cycleIndex = 1; cycleIndex <= reviewConfig.maxCycles; cycleIndex += 1) { const validationResults = await runValidatorCommands(validatorCommands); const prompt = buildPostExecutionReviewPrompt(cycleIndex, validationResults, reviewConfig.prompt); - const reviewResult = await requestStructuredExecutionReview( + const initialReviewResult = await requestStructuredExecutionReview( (prompt) => codexSession.runPrompt(prompt, "review"), cycleIndex, prompt, - reviewConfig.prompt + reviewConfig.prompt, ); + const failedValidations = validationResults.filter((result) => result.exitCode !== 0); + const reviewResult = + failedValidations.length > 0 && initialReviewResult.findings.length === 0 + ? { + ...initialReviewResult, + summary: + initialReviewResult.summary.trim().length > 0 + ? `${initialReviewResult.summary} Validator failures still need attention.` + : "Validator failures still need attention.", + findings: failedValidations.map( + (result) => `Validator failed: ${result.command} (exit ${result.exitCode})`, + ), + fixed: false, + } + : initialReviewResult; finalSummaries.push(`cycle ${cycleIndex}: ${reviewResult.summary}`); if (reviewResult.findings.length === 0) { @@ -547,8 +587,8 @@ export async function runCommandHandler(options: RunCommandOptions): Promise 0) { console.log(fallbackReview); } + + const finalRun = await store.getRun(runId); + if ( + reviewConfig.enabled && + finalRun && + finalRun.overallStatus !== "failed" && + finalRun.overallStatus !== "cancelled" + ) { + await store.updateRun(runId, { overallStatus: "completed" }); + const completedAt = new Date().toISOString(); + await emitHook({ + runId: runId as HookEvent["runId"], + hook: "onComplete", + message: "run-completed", + timestamp: completedAt, + metadata: { overallStatus: "completed" }, + }); + await writeSessionSummary(store, runId, effectiveConfig?.sessionLogs); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const failedAt = new Date().toISOString(); + const currentRun = await store.getRun(runId); + await store.updateRun(runId, { + overallStatus: "failed", + ...(currentRun + ? { + errors: [...currentRun.errors, { at: failedAt, message }], + } + : {}), + }); + await writeSessionSummary(store, runId, effectiveConfig?.sessionLogs); + throw error; } finally { await codexSession.disconnect(); } @@ -595,11 +669,11 @@ export async function runCommandHandler(options: RunCommandOptions): Promise 0 && run.tasks.every((task) => task.status === "done") + run.tasks.length > 0 && run.tasks.every((task) => task.status === "done"), ); await store.updateRun(runId, { - overallStatus: finalStatus + overallStatus: finalStatus, }); const refreshed = await store.getRun(runId); @@ -609,6 +683,9 @@ export async function runCommandHandler(options: RunCommandOptions): Promise { @@ -641,7 +718,7 @@ export function registerRunCommand(program: Command): void { try { const normalizedOptions: RunCommandOptions = { ...commandOptions, - ...(goal !== undefined ? { goal } : {}) + ...(goal !== undefined ? { goal } : {}), }; const inlineTask = normalizedOptions.task ?? normalizedOptions.prompt ?? normalizedOptions.goal; diff --git a/src/cli/commands/setup.ts b/src/cli/commands/setup.ts index df39abb..9d52b3d 100644 --- a/src/cli/commands/setup.ts +++ b/src/cli/commands/setup.ts @@ -32,7 +32,7 @@ export function resolveApiKey( flagValue: string | undefined, envVarName: string, openclawConfigPathOrOptions?: string | ResolveApiKeyOptions, - maybeOptions?: ResolveApiKeyOptions + maybeOptions?: ResolveApiKeyOptions, ): string | undefined { if (flagValue && flagValue.trim().length > 0) return flagValue.trim(); if (envVarName === "OPENAI_API_KEY") { @@ -42,9 +42,10 @@ export function resolveApiKey( const envValue = process.env[envVarName]; if (envValue?.trim()) return envValue.trim(); - const options = typeof openclawConfigPathOrOptions === "string" - ? { ...maybeOptions, openclawConfigPath: openclawConfigPathOrOptions } - : (openclawConfigPathOrOptions ?? {}); + const options = + typeof openclawConfigPathOrOptions === "string" + ? { ...maybeOptions, openclawConfigPath: openclawConfigPathOrOptions } + : (openclawConfigPathOrOptions ?? {}); const homedir = options.homedir ?? os.homedir(); const configPath = options.openclawConfigPath ?? path.join(homedir, ".openclaw", "openclaw.json"); try { @@ -60,8 +61,12 @@ export function resolveApiKey( export function readCodexAuthJson(homedir: string = os.homedir()): string | undefined { const authPath = path.join(homedir, ".codex", "auth.json"); try { - const parsed = JSON.parse(readFileSync(authPath, "utf8")) as { OPENAI_API_KEY?: unknown; tokens?: { access_token?: unknown } }; - if (typeof parsed.tokens?.access_token === "string" && parsed.tokens.access_token.trim()) return parsed.tokens.access_token.trim(); + const parsed = JSON.parse(readFileSync(authPath, "utf8")) as { + OPENAI_API_KEY?: unknown; + tokens?: { access_token?: unknown }; + }; + if (typeof parsed.tokens?.access_token === "string" && parsed.tokens.access_token.trim()) + return parsed.tokens.access_token.trim(); if (typeof parsed.OPENAI_API_KEY === "string" && parsed.OPENAI_API_KEY.trim()) return parsed.OPENAI_API_KEY.trim(); } catch { return undefined; @@ -107,18 +112,52 @@ function serializeForModule(value: unknown, depth = 0): string { function buildMergedConfigModule(config: OrcaConfig, useTs: boolean): string { if (useTs) { - return ["// generated by orca setup", 'import type { OrcaConfig } from "orcastrator";', "", `export default ${serializeForModule(config)} satisfies OrcaConfig;`, ""].join("\n"); + return [ + "// generated by orca setup", + 'import type { OrcaConfig } from "orcastrator";', + "", + `export default ${serializeForModule(config)} satisfies OrcaConfig;`, + "", + ].join("\n"); } return `// generated by orca setup\nexport default ${serializeForModule(config)};\n`; } -function supportsPrompting(): boolean { return Boolean(process.stdin.isTTY); } -function commandExists(command: string): boolean { return spawnSync("which", [command], { stdio: "ignore" }).status === 0; } -function detectPackageManager(): PackageManager | null { if (commandExists("brew")) return "brew"; if (commandExists("apt")) return "apt"; if (commandExists("winget")) return "winget"; return null; } -function installGhVia(packageManager: PackageManager): void { if (packageManager === "brew") spawnSync("brew", ["install", "gh"], { stdio: "inherit" }); else if (packageManager === "apt") spawnSync("sudo", ["apt", "install", "gh"], { stdio: "inherit" }); else spawnSync("winget", ["install", "GitHub.cli"], { stdio: "inherit" }); } -function runGhAuthStatus(): { authenticated: boolean; detail: string } { const result = spawnSync("gh", ["auth", "status"], { encoding: "utf8" }); if (result.status === 0) { const version = execSync("gh --version", { encoding: "utf8" }).split("\n")[0]?.trim(); return { authenticated: true, detail: version ? `${version} (authenticated)` : "installed (authenticated)" }; } return { authenticated: false, detail: "installed (not authenticated)" }; } -function icon(status: CheckStatus): string { if (status === "pass") return chalk.green("✓"); if (status === "fail") return chalk.red("✗"); return chalk.yellow("!"); } -function printSummary(results: CheckResult[]): void { const width = Math.max(...results.map((result) => result.name.length), 1); for (const result of results) console.log(` ${icon(result.status)} ${result.name.padEnd(width, " ")} ${result.detail}`); } +function supportsPrompting(): boolean { + return Boolean(process.stdin.isTTY); +} +function commandExists(command: string): boolean { + return spawnSync("which", [command], { stdio: "ignore" }).status === 0; +} +function detectPackageManager(): PackageManager | null { + if (commandExists("brew")) return "brew"; + if (commandExists("apt")) return "apt"; + if (commandExists("winget")) return "winget"; + return null; +} +function installGhVia(packageManager: PackageManager): void { + if (packageManager === "brew") spawnSync("brew", ["install", "gh"], { stdio: "inherit" }); + else if (packageManager === "apt") spawnSync("sudo", ["apt", "install", "gh"], { stdio: "inherit" }); + else spawnSync("winget", ["install", "GitHub.cli"], { stdio: "inherit" }); +} +function runGhAuthStatus(): { authenticated: boolean; detail: string } { + const result = spawnSync("gh", ["auth", "status"], { encoding: "utf8" }); + if (result.status === 0) { + const version = execSync("gh --version", { encoding: "utf8" }).split("\n")[0]?.trim(); + return { authenticated: true, detail: version ? `${version} (authenticated)` : "installed (authenticated)" }; + } + return { authenticated: false, detail: "installed (not authenticated)" }; +} +function icon(status: CheckStatus): string { + if (status === "pass") return chalk.green("✓"); + if (status === "fail") return chalk.red("✗"); + return chalk.yellow("!"); +} +function printSummary(results: CheckResult[]): void { + const width = Math.max(...results.map((result) => result.name.length), 1); + for (const result of results) + console.log(` ${icon(result.status)} ${result.name.padEnd(width, " ")} ${result.detail}`); +} function parseSaveTarget(options: SetupCommandOptions): "global" | "project" | null { if (options.global && options.project) throw new Error("--global and --project cannot be used together"); @@ -128,21 +167,45 @@ function parseSaveTarget(options: SetupCommandOptions): "global" | "project" | n } function getConfigPath(target: "global" | "project", useTs: boolean): string { - return target === "project" ? path.resolve(useTs ? "orca.config.ts" : "orca.config.js") : path.join(os.homedir(), ".orca", useTs ? "config.ts" : "config.js"); + return target === "project" + ? path.resolve(useTs ? "orca.config.ts" : "orca.config.js") + : path.join(os.homedir(), ".orca", useTs ? "config.ts" : "config.js"); } async function loadExistingConfig(configPath: string): Promise { - try { await access(configPath, fsConstants.R_OK); } catch { return undefined; } + try { + await access(configPath, fsConstants.R_OK); + } catch { + return undefined; + } const imported = await import(`${pathToFileURL(configPath).href}?t=${Date.now()}`); const candidate = "default" in imported ? imported.default : imported; - return candidate && typeof candidate === "object" ? candidate as OrcaConfig : undefined; + return candidate && typeof candidate === "object" ? (candidate as OrcaConfig) : undefined; } -async function saveConfig(configPath: string, keys: ApiKeyConfig, executor: "codex" | undefined, useTs: boolean): Promise { +async function saveConfig( + configPath: string, + keys: ApiKeyConfig, + executor: "codex" | undefined, + useTs: boolean, +): Promise { const existing = await loadExistingConfig(configPath); - const merged: OrcaConfig = { ...existing, ...(keys.openaiApiKey ? { openaiApiKey: keys.openaiApiKey } : {}), ...(executor ? { executor } : {}) }; + const merged: OrcaConfig = { + ...existing, + ...(keys.openaiApiKey ? { openaiApiKey: keys.openaiApiKey } : {}), + ...(executor ? { executor } : {}), + }; await mkdir(path.dirname(configPath), { recursive: true }); - await writeFile(configPath, existing ? buildMergedConfigModule(merged, useTs) : buildConfigModule({ ...(merged.openaiApiKey ? { openaiApiKey: merged.openaiApiKey } : {}), ...(merged.executor ? { executor: "codex" } : {}) }), "utf8"); + await writeFile( + configPath, + existing + ? buildMergedConfigModule(merged, useTs) + : buildConfigModule({ + ...(merged.openaiApiKey ? { openaiApiKey: merged.openaiApiKey } : {}), + ...(merged.executor ? { executor: "codex" } : {}), + }), + "utf8", + ); } export async function setupCommandHandler(options: SetupCommandOptions): Promise { @@ -162,7 +225,11 @@ export async function setupCommandHandler(options: SetupCommandOptions): Promise codexAvailable = Boolean(openaiApiKey) || Boolean(readCodexAuthJson()); } - results.push({ name: "Codex", status: codexAvailable ? "pass" : "warn", detail: codexAvailable ? "available" : "not available" }); + results.push({ + name: "Codex", + status: codexAvailable ? "pass" : "warn", + detail: codexAvailable ? "available" : "not available", + }); const ghAvailable = commandExists("gh"); if (!ghAvailable) { @@ -171,7 +238,8 @@ export async function setupCommandHandler(options: SetupCommandOptions): Promise const installAnswer = (await rl.question("Install gh CLI? (y/N): ")).trim().toLowerCase(); if (installAnswer === "y" || installAnswer === "yes") { const packageManager = detectPackageManager(); - if (packageManager) installGhVia(packageManager); else console.log("Install manually: https://cli.github.com"); + if (packageManager) installGhVia(packageManager); + else console.log("Install manually: https://cli.github.com"); } } } else { @@ -181,9 +249,21 @@ export async function setupCommandHandler(options: SetupCommandOptions): Promise const configPath = getConfigPath(target, Boolean(options.ts)); const resolvedExecutor = options.executor ?? (codexAvailable ? "codex" : undefined); - await saveConfig(configPath, persistOpenAi && openaiApiKey ? { openaiApiKey } : {}, resolvedExecutor, Boolean(options.ts)); + await saveConfig( + configPath, + persistOpenAi && openaiApiKey ? { openaiApiKey } : {}, + resolvedExecutor, + Boolean(options.ts), + ); - const printedPath = target === "global" ? (options.ts ? "~/.orca/config.ts" : "~/.orca/config.js") : (options.ts ? "./orca.config.ts" : "./orca.config.js"); + const printedPath = + target === "global" + ? options.ts + ? "~/.orca/config.ts" + : "~/.orca/config.js" + : options.ts + ? "./orca.config.ts" + : "./orca.config.js"; if (resolvedExecutor) { console.log(chalk.green(`✓ Config saved to ${printedPath} (executor: ${resolvedExecutor})`)); process.exitCode = 0; diff --git a/src/cli/commands/skills.test.ts b/src/cli/commands/skills.test.ts index 4afab20..432b28a 100644 --- a/src/cli/commands/skills.test.ts +++ b/src/cli/commands/skills.test.ts @@ -12,17 +12,13 @@ let tempDir = ""; let logs: string[] = []; const originalConsoleLog = console.log; -function makeSkill(input: { - name: string; - description: string; - dirPath: string; -}): LoadedSkill { +function makeSkill(input: { name: string; description: string; dirPath: string }): LoadedSkill { return { name: input.name, description: input.description, body: `${input.name} body`, dirPath: input.dirPath, - filePath: path.join(input.dirPath, "SKILL.md") + filePath: path.join(input.dirPath, "SKILL.md"), }; } @@ -39,12 +35,12 @@ async function loadSkillsModule(options?: { const loadSkillsMock = mock(options?.loadSkills ?? (async () => [])); mock.module("../../core/config-loader.js", () => ({ - resolveConfig: resolveConfigMock + resolveConfig: resolveConfigMock, })); mock.module("../../utils/skill-loader.js", () => ({ loadSkills: loadSkillsMock, - getBundledSkillsDir: () => options?.bundledSkillsDir ?? path.join(tempDir, "bundled", ".orca", "skills") + getBundledSkillsDir: () => options?.bundledSkillsDir ?? path.join(tempDir, "bundled", ".orca", "skills"), })); const skillsModule = await import(`./skills.js?test=${Math.random()}`); @@ -73,7 +69,7 @@ describe("skills command", () => { test("prints 'No skills found.' when loader returns no skills", async () => { const { skillsModule } = await loadSkillsModule({ resolveConfig: async () => undefined, - loadSkills: async () => [] + loadSkills: async () => [], }); await skillsModule.skillsCommandHandler({}); @@ -89,9 +85,9 @@ describe("skills command", () => { makeSkill({ name: "Writer", description: "Generates copy", - dirPath: configSkillDir - }) - ] + dirPath: configSkillDir, + }), + ], }); await skillsModule.skillsCommandHandler({}); @@ -120,8 +116,8 @@ describe("skills command", () => { makeSkill({ name: "ConfigSkill", description: "From config", dirPath: configSkillDir }), makeSkill({ name: "ProjectSkill", description: "From project", dirPath: projectSkillDir }), makeSkill({ name: "GlobalSkill", description: "From global", dirPath: globalSkillDir }), - makeSkill({ name: "BundledSkill", description: "From bundled", dirPath: bundledSkillDir }) - ] + makeSkill({ name: "BundledSkill", description: "From bundled", dirPath: bundledSkillDir }), + ], }); await skillsModule.skillsCommandHandler({}); @@ -147,10 +143,10 @@ describe("skills command", () => { makeSkill({ name: "code-simplifier", description: "Bundled default", - dirPath: overlappingSkillDir - }) + dirPath: overlappingSkillDir, + }), ], - bundledSkillsDir: path.join(process.cwd(), ".orca", "skills") + bundledSkillsDir: path.join(process.cwd(), ".orca", "skills"), }); await skillsModule.skillsCommandHandler({}); @@ -161,12 +157,12 @@ describe("skills command", () => { test("passes --config option through resolveConfig and loadSkills", async () => { const resolvedConfig: OrcaConfig = { - skills: [path.join(tempDir, "config", "one")] + skills: [path.join(tempDir, "config", "one")], }; const { skillsModule, resolveConfigMock, loadSkillsMock } = await loadSkillsModule({ resolveConfig: async () => resolvedConfig, - loadSkills: async () => [] + loadSkills: async () => [], }); await skillsModule.skillsCommandHandler({ config: "./custom-orca.config.js" }); diff --git a/src/cli/commands/skills.ts b/src/cli/commands/skills.ts index 0b381b9..ddb4927 100644 --- a/src/cli/commands/skills.ts +++ b/src/cli/commands/skills.ts @@ -80,7 +80,7 @@ function formatSkillsTable(skills: LoadedSkill[], configSkillDirs: Set): skill.name, skill.description, detectSkillSource(skill, configSkillDirs), - skill.dirPath + skill.dirPath, ]); return formatTable(headers, rows); diff --git a/src/cli/commands/status.test.ts b/src/cli/commands/status.test.ts index af5a3f8..5da09ee 100644 --- a/src/cli/commands/status.test.ts +++ b/src/cli/commands/status.test.ts @@ -77,7 +77,49 @@ describe("status command", () => { const output = logs.join("\n"); expect(output).toContain("Pending Question:"); - expect(output).toContain("Game Type: Which game type should I build?"); + expect(output).toContain("Game Type (game_type): Which game type should I build?"); expect(output).toContain("Options: Arcade, Puzzle."); }); + + test("prints question ids for multi-question answer payloads", async () => { + const runId = "run-2000-abcd"; + const store = new RunStore(runsDir); + await store.createRun(runId, "/tmp/spec.md"); + await store.updateRun(runId, { + mode: "run", + overallStatus: "waiting_for_answer", + pendingQuestion: { + requestId: "req-2", + threadId: "thread-2", + turnId: "turn-2", + itemId: "item-2", + receivedAt: new Date().toISOString(), + questions: [ + { + header: "Backend", + id: "backend", + question: "Which backend should I use?", + isOther: true, + isSecret: false, + options: null, + }, + { + header: "Frontend", + id: "frontend", + question: "Which frontend should I use?", + isOther: true, + isSecret: false, + options: null, + }, + ], + }, + }); + + const statusModule = await loadStatusModule(); + await statusModule.statusCommandHandler({ run: runId }); + + const output = logs.join("\n"); + expect(output).toContain("Backend (backend): Which backend should I use?"); + expect(output).toContain("Frontend (frontend): Which frontend should I use?"); + }); }); diff --git a/src/cli/commands/status.ts b/src/cli/commands/status.ts index d738aba..aec07b2 100644 --- a/src/cli/commands/status.ts +++ b/src/cli/commands/status.ts @@ -42,7 +42,7 @@ function formatTaskTable(tasks: Task[]): string { task.status, `${task.retries}/${task.maxRetries}`, task.startedAt ?? "-", - task.finishedAt ?? "-" + task.finishedAt ?? "-", ]); return formatTable(headers, rows); diff --git a/src/core/codex-config.test.ts b/src/core/codex-config.test.ts index b0dfcc9..75ed7b4 100644 --- a/src/core/codex-config.test.ts +++ b/src/core/codex-config.test.ts @@ -67,7 +67,7 @@ describe("ensureCodexMultiAgent", () => { it("appends feature block when file exists but has no multi_agent", async () => { const fs = await import("node:fs/promises"); - await fs.writeFile(tmpConfigFile, "[mcp_servers.context7]\ncommand = \"npx\"\n", "utf8"); + await fs.writeFile(tmpConfigFile, '[mcp_servers.context7]\ncommand = "npx"\n', "utf8"); const result = await ensureCodexMultiAgent({ codex: { multiAgent: true } }, tmpConfigFile); expect(result.action).toBe("appended"); @@ -79,7 +79,7 @@ describe("ensureCodexMultiAgent", () => { it("appended block is separated from existing content with no trailing newline", async () => { const fs = await import("node:fs/promises"); - await fs.writeFile(tmpConfigFile, "[section]\nkey = \"value\"", "utf8"); + await fs.writeFile(tmpConfigFile, '[section]\nkey = "value"', "utf8"); await ensureCodexMultiAgent({ codex: { multiAgent: true } }, tmpConfigFile); const content = await readFile(tmpConfigFile, "utf8"); @@ -111,6 +111,13 @@ describe("isCodexMultiAgentActive", () => { await expect(isCodexMultiAgentActive({ codex: { multiAgent: false } }, tmpConfigFile)).resolves.toBe(false); }); + it("returns false when Orca config explicitly disables multi-agent even if root config enables it", async () => { + const fs = await import("node:fs/promises"); + await fs.writeFile(tmpConfigFile, "[features]\nmulti_agent = true\n", "utf8"); + + await expect(isCodexMultiAgentActive({ codex: { multiAgent: false } }, tmpConfigFile)).resolves.toBe(false); + }); + it("returns false when root config contains multi_agent = false", async () => { const fs = await import("node:fs/promises"); await fs.writeFile(tmpConfigFile, "[features]\nmulti_agent = false\n", "utf8"); diff --git a/src/core/codex-config.ts b/src/core/codex-config.ts index 2d18fe7..e63d8f9 100644 --- a/src/core/codex-config.ts +++ b/src/core/codex-config.ts @@ -13,9 +13,13 @@ const ORCA_MULTI_AGENT_BLOCK = `# Added by orca — remove or set multi_agent = multi_agent = true `; +function getExplicitMultiAgentSetting(config?: OrcaConfig): boolean | undefined { + return typeof config?.codex?.multiAgent === "boolean" ? config.codex.multiAgent : undefined; +} + function isMultiAgentEnabled(config?: OrcaConfig): boolean { // Default: off. Only enable if explicitly set to true. - return config?.codex?.multiAgent === true; + return getExplicitMultiAgentSetting(config) === true; } function containsMultiAgentSetting(content: string): boolean { @@ -68,12 +72,10 @@ function hasEnabledRootMultiAgentSetting(content: string): boolean { return false; } -export async function isCodexMultiAgentActive( - config?: OrcaConfig, - _configFile?: string, -): Promise { - if (isMultiAgentEnabled(config)) { - return true; +export async function isCodexMultiAgentActive(config?: OrcaConfig, _configFile?: string): Promise { + const explicitMultiAgentSetting = getExplicitMultiAgentSetting(config); + if (explicitMultiAgentSetting !== undefined) { + return explicitMultiAgentSetting; } const configFile = _configFile ?? GLOBAL_CONFIG_FILE; diff --git a/src/core/config-loader.test.ts b/src/core/config-loader.test.ts index 55f2ff2..3b70e66 100644 --- a/src/core/config-loader.test.ts +++ b/src/core/config-loader.test.ts @@ -35,7 +35,7 @@ describe("config-loader", () => { const resolved = await resolveConfigFromPaths( path.join(tempDir, "missing-global.js"), projectJsPath, - projectTsPath + projectTsPath, ); expect(resolved?.runsDir).toBe("from-ts"); @@ -55,7 +55,7 @@ describe("config-loader", () => { test("resolveConfigFromPaths returns undefined when no configs exist", async () => { const resolved = await resolveConfigFromPaths( path.join(tempDir, "missing-global.js"), - path.join(tempDir, "missing-project.js") + path.join(tempDir, "missing-project.js"), ); expect(resolved).toBeUndefined(); @@ -66,13 +66,13 @@ describe("config-loader", () => { await fs.writeFile( cliPath, "export default { runsDir: 'from-cli', sessionLogs: '/tmp/orca-session-logs' };\n", - "utf8" + "utf8", ); const resolved = await resolveConfigFromPaths( path.join(tempDir, "missing-global.js"), path.join(tempDir, "missing-project.js"), - cliPath + cliPath, ); expect(resolved?.runsDir).toBe("from-cli"); @@ -86,7 +86,7 @@ describe("config-loader", () => { const resolved = await resolveConfigFromPaths( path.join(tempDir, "missing-global.js"), path.join(tempDir, "missing-project.js"), - cliPath + cliPath, ); expect(resolved?.executor).toBe("codex"); @@ -100,8 +100,8 @@ describe("config-loader", () => { resolveConfigFromPaths( path.join(tempDir, "missing-global.js"), path.join(tempDir, "missing-project.js"), - cliPath - ) + cliPath, + ), ).rejects.toThrow("Unknown hook key in Config.hookCommands: onMystery"); }); @@ -113,8 +113,8 @@ describe("config-loader", () => { resolveConfigFromPaths( path.join(tempDir, "missing-global.js"), path.join(tempDir, "missing-project.js"), - cliPath - ) + cliPath, + ), ).rejects.toThrow("Unknown hook key in Config.hooks: onMystery"); }); @@ -126,8 +126,8 @@ describe("config-loader", () => { resolveConfigFromPaths( path.join(tempDir, "missing-global.js"), path.join(tempDir, "missing-project.js"), - cliPath - ) + cliPath, + ), ).rejects.toThrow("Config.review.enabled must be a boolean"); }); @@ -139,8 +139,8 @@ describe("config-loader", () => { resolveConfigFromPaths( path.join(tempDir, "missing-global.js"), path.join(tempDir, "missing-project.js"), - cliPath - ) + cliPath, + ), ).rejects.toThrow("Config.review.onInvalid must be 'fail' or 'warn_skip'"); }); @@ -151,12 +151,104 @@ describe("config-loader", () => { const resolved = await resolveConfigFromPaths( path.join(tempDir, "missing-global.js"), path.join(tempDir, "missing-project.js"), - cliPath + cliPath, ); expect(resolved?.executor).toBe("codex"); }); + test("resolveConfigFromPaths accepts auto planner router and claude config", async () => { + const cliPath = path.join(tempDir, "cli.config.js"); + await fs.writeFile( + cliPath, + "export default { planner: { agent: 'auto', router: { model: 'gpt-5.3-codex-spark' } }, claude: { command: '/bin/claude', model: 'claude-opus-4-7', effort: 'high', timeoutMs: 1000 } };\n", + "utf8", + ); + + const resolved = await resolveConfigFromPaths( + path.join(tempDir, "missing-global.js"), + path.join(tempDir, "missing-project.js"), + cliPath, + ); + + expect(resolved?.planner).toEqual({ agent: "auto", router: { model: "gpt-5.3-codex-spark" } }); + expect(resolved?.claude).toEqual({ + command: "/bin/claude", + model: "claude-opus-4-7", + effort: "high", + timeoutMs: 1000, + }); + }); + + test("resolveConfigFromPaths accepts forced claude planner without router", async () => { + const cliPath = path.join(tempDir, "cli.config.js"); + await fs.writeFile(cliPath, "export default { planner: { agent: 'claude' } };\n", "utf8"); + + const resolved = await resolveConfigFromPaths( + path.join(tempDir, "missing-global.js"), + path.join(tempDir, "missing-project.js"), + cliPath, + ); + + expect(resolved?.planner).toEqual({ agent: "claude" }); + }); + + test("resolveConfigFromPaths rejects invalid planner agent", async () => { + const cliPath = path.join(tempDir, "cli.config.js"); + await fs.writeFile(cliPath, "export default { planner: { agent: 'opus' } };\n", "utf8"); + + await expect( + resolveConfigFromPaths( + path.join(tempDir, "missing-global.js"), + path.join(tempDir, "missing-project.js"), + cliPath, + ), + ).rejects.toThrow("Config.planner.agent must be 'auto', 'codex', or 'claude'"); + }); + + test("resolveConfigFromPaths validates planner.router.model shape", async () => { + const cliPath = path.join(tempDir, "cli.config.js"); + await fs.writeFile(cliPath, "export default { planner: { router: { model: 123 } } };\n", "utf8"); + + await expect( + resolveConfigFromPaths( + path.join(tempDir, "missing-global.js"), + path.join(tempDir, "missing-project.js"), + cliPath, + ), + ).rejects.toThrow("Config.planner.router.model must be a string"); + }); + + test("resolveConfigFromPaths rejects router for forced planner agents", async () => { + const cliPath = path.join(tempDir, "cli.config.js"); + await fs.writeFile( + cliPath, + "export default { planner: { agent: 'claude', router: { model: 'gpt-5.3-codex-spark' } } };\n", + "utf8", + ); + + await expect( + resolveConfigFromPaths( + path.join(tempDir, "missing-global.js"), + path.join(tempDir, "missing-project.js"), + cliPath, + ), + ).rejects.toThrow("Config.planner.router is only used when Config.planner.agent is 'auto'"); + }); + + test("resolveConfigFromPaths rejects invalid claude effort", async () => { + const cliPath = path.join(tempDir, "cli.config.js"); + await fs.writeFile(cliPath, "export default { claude: { effort: 'extreme' } };\n", "utf8"); + + await expect( + resolveConfigFromPaths( + path.join(tempDir, "missing-global.js"), + path.join(tempDir, "missing-project.js"), + cliPath, + ), + ).rejects.toThrow("Config.claude.effort must be one of"); + }); + test("resolveConfigFromPaths throws on invalid codex effort value", async () => { const cliPath = path.join(tempDir, "cli.config.js"); await fs.writeFile(cliPath, "export default { codex: { effort: 'extreme' } };\n", "utf8"); @@ -165,8 +257,8 @@ describe("config-loader", () => { resolveConfigFromPaths( path.join(tempDir, "missing-global.js"), path.join(tempDir, "missing-project.js"), - cliPath - ) + cliPath, + ), ).rejects.toThrow("Codex thinking level must be one of"); }); @@ -178,8 +270,8 @@ describe("config-loader", () => { resolveConfigFromPaths( path.join(tempDir, "missing-global.js"), path.join(tempDir, "missing-project.js"), - cliPath - ) + cliPath, + ), ).rejects.toThrow("Codex thinking level must be one of"); }); @@ -188,13 +280,13 @@ describe("config-loader", () => { await fs.writeFile( cliPath, "export default { codex: { thinkingLevel: { decision: 'low', planning: 'xhigh', review: 'high', execution: 'medium' } } };\n", - "utf8" + "utf8", ); const resolved = await resolveConfigFromPaths( path.join(tempDir, "missing-global.js"), path.join(tempDir, "missing-project.js"), - cliPath + cliPath, ); expect(resolved?.codex?.thinkingLevel).toEqual({ @@ -207,18 +299,14 @@ describe("config-loader", () => { test("resolveConfigFromPaths rejects removed codex.thinking", async () => { const cliPath = path.join(tempDir, "cli.config.js"); - await fs.writeFile( - cliPath, - "export default { codex: { thinking: { execution: 'high' } } };\n", - "utf8" - ); + await fs.writeFile(cliPath, "export default { codex: { thinking: { execution: 'high' } } };\n", "utf8"); await expect( resolveConfigFromPaths( path.join(tempDir, "missing-global.js"), path.join(tempDir, "missing-project.js"), - cliPath - ) + cliPath, + ), ).rejects.toThrow("Config.codex.thinking is no longer supported"); }); @@ -227,15 +315,15 @@ describe("config-loader", () => { await fs.writeFile( cliPath, "export default { codex: { perCwdExtraUserRoots: [{ cwd: '/repo', extraUserRoots: [123] }] } };\n", - "utf8" + "utf8", ); await expect( resolveConfigFromPaths( path.join(tempDir, "missing-global.js"), path.join(tempDir, "missing-project.js"), - cliPath - ) + cliPath, + ), ).rejects.toThrow("Config.codex.perCwdExtraUserRoots[].extraUserRoots entries must be strings"); }); @@ -244,18 +332,16 @@ describe("config-loader", () => { await fs.writeFile( cliPath, "export default { codex: { perCwdExtraUserRoots: [{ cwd: '/repo', extraUserRoots: ['/tmp/skills'] }] } };\n", - "utf8" + "utf8", ); const resolved = await resolveConfigFromPaths( path.join(tempDir, "missing-global.js"), path.join(tempDir, "missing-project.js"), - cliPath + cliPath, ); - expect(resolved?.codex?.perCwdExtraUserRoots).toEqual([ - { cwd: "/repo", extraUserRoots: ["/tmp/skills"] }, - ]); + expect(resolved?.codex?.perCwdExtraUserRoots).toEqual([{ cwd: "/repo", extraUserRoots: ["/tmp/skills"] }]); }); test("resolveConfigFromPaths merges global and project", async () => { @@ -265,12 +351,12 @@ describe("config-loader", () => { await fs.writeFile( globalPath, "export default { runsDir: 'global-runs', maxRetries: 2, skills: ['/skills/global', '/skills/shared'], codex: { enabled: false, model: 'gpt-global' }, pr: { enabled: true, requireConfirmation: true }, hookCommands: { onError: 'echo global-error' } };\n", - "utf8" + "utf8", ); await fs.writeFile( projectPath, "export default { runsDir: 'project-runs', sessionLogs: '/tmp/project-session-logs', skills: ['/skills/project', '/skills/shared'], codex: { model: 'gpt-project' }, pr: { requireConfirmation: false }, hookCommands: { onMilestone: 'echo project-milestone' } };\n", - "utf8" + "utf8", ); const resolved = await resolveConfigFromPaths(globalPath, projectPath); @@ -280,18 +366,18 @@ describe("config-loader", () => { sessionLogs: "/tmp/project-session-logs", maxRetries: 2, skills: ["/skills/global", "/skills/shared", "/skills/project"], - codex: { + codex: { enabled: false, - model: "gpt-project" + model: "gpt-project", }, pr: { enabled: true, - requireConfirmation: false + requireConfirmation: false, }, hookCommands: { onError: "echo global-error", - onMilestone: "echo project-milestone" - } + onMilestone: "echo project-milestone", + }, }); }); @@ -299,10 +385,11 @@ describe("config-loader", () => { const globalConfig = { runsDir: "global", maxRetries: 1, - sessionLogs: "global-logs", skills: ["global-skill", "shared-skill"], - codex: { enabled: false }, + sessionLogs: "global-logs", + skills: ["global-skill", "shared-skill"], + codex: { enabled: false }, pr: { enabled: true }, - hookCommands: { onError: "echo global-error" } + hookCommands: { onError: "echo global-error" }, }; const projectConfig = { @@ -310,9 +397,9 @@ describe("config-loader", () => { maxRetries: 2, sessionLogs: "project-logs", skills: ["project-skill", "shared-skill"], - codex: { model: "gpt-project" }, + codex: { model: "gpt-project" }, pr: { requireConfirmation: true }, - hookCommands: { onError: "echo project-error", onComplete: "echo project-complete" } + hookCommands: { onError: "echo project-error", onComplete: "echo project-complete" }, }; const cliConfig = { @@ -321,9 +408,9 @@ describe("config-loader", () => { sessionLogs: "cli-logs", openaiApiKey: "cli-openai", skills: ["cli-skill", "project-skill"], - codex: { enabled: true }, + codex: { enabled: true }, pr: { requireConfirmation: false }, - hookCommands: { onComplete: "echo cli-complete" } + hookCommands: { onComplete: "echo cli-complete" }, }; const merged = mergeConfigs(globalConfig, projectConfig, cliConfig); @@ -331,20 +418,21 @@ describe("config-loader", () => { expect(merged).toEqual({ runsDir: "cli", sessionLogs: "cli-logs", - maxRetries: 3, openaiApiKey: "cli-openai", + maxRetries: 3, + openaiApiKey: "cli-openai", skills: ["global-skill", "shared-skill", "project-skill", "cli-skill"], - codex: { + codex: { enabled: true, - model: "gpt-project" + model: "gpt-project", }, pr: { enabled: true, - requireConfirmation: false + requireConfirmation: false, }, hookCommands: { onError: "echo project-error", - onComplete: "echo cli-complete" - } + onComplete: "echo cli-complete", + }, }); }); @@ -387,17 +475,48 @@ describe("config-loader", () => { }); }); + test("mergeConfigs merges planner and claude settings", () => { + const merged = mergeConfigs( + { + planner: { agent: "auto" as const, router: { model: "gpt-5.3-codex-spark" as const } }, + claude: { command: "claude-a", effort: "high" as const, timeoutMs: 1000 }, + }, + { + planner: { agent: "claude" as const }, + claude: { model: "sonnet" }, + }, + ); + + expect(merged?.planner).toEqual({ agent: "claude" }); + expect(merged?.claude).toEqual({ command: "claude-a", model: "sonnet", effort: "high", timeoutMs: 1000 }); + }); + + test("mergeConfigs does not materialize empty planner router", () => { + const merged = mergeConfigs({ planner: { agent: "claude" as const } }); + + expect(merged?.planner).toEqual({ agent: "claude" }); + }); + + test("mergeConfigs drops router when later config forces planner agent", () => { + const merged = mergeConfigs( + { planner: { agent: "auto" as const, router: { model: "gpt-5.3-codex-spark" as const } } }, + { planner: { agent: "codex" as const } }, + ); + + expect(merged?.planner).toEqual({ agent: "codex" }); + }); + test("mergeConfigs deeply merges review plan/execution settings", () => { const globalConfig = { review: { plan: { enabled: true, onInvalid: "fail" as const }, - execution: { enabled: true, maxCycles: 2, validator: { auto: true } } - } + execution: { enabled: true, maxCycles: 2, validator: { auto: true } }, + }, }; const projectConfig = { review: { - execution: { onFindings: "auto_fix" as const, validator: { commands: ["npm run test"] } } - } + execution: { onFindings: "auto_fix" as const, validator: { commands: ["npm run test"] } }, + }, }; const merged = mergeConfigs(globalConfig, projectConfig); @@ -410,9 +529,9 @@ describe("config-loader", () => { onFindings: "auto_fix", validator: { auto: true, - commands: ["npm run test"] - } - } + commands: ["npm run test"], + }, + }, }); }); }); diff --git a/src/core/config-loader.ts b/src/core/config-loader.ts index ebcb83d..ba3bece 100644 --- a/src/core/config-loader.ts +++ b/src/core/config-loader.ts @@ -4,271 +4,51 @@ import os from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; -import { parseCodexEffort } from "../types/effort.js"; -import type { HookName, OrcaConfig } from "../types/index.js"; - -const KNOWN_HOOK_NAMES: HookName[] = [ - "onMilestone", - "onQuestion", - "onTaskComplete", - "onTaskFail", - "onInvalidPlan", - "onFindings", - "onComplete", - "onError" -]; - -const knownHookNameSet = new Set(KNOWN_HOOK_NAMES); +import { OrcaConfigSchema, type OrcaConfig } from "../types/index.js"; function isObject(value: unknown): value is Record { return typeof value === "object" && value !== null; } -function describeType(value: unknown): string { - if (value === null) { - return "null"; +function formatConfigPath(pathParts: PropertyKey[]): string { + if (pathParts.length === 0) { + return "Config"; } - if (Array.isArray(value)) { - return "array"; - } - - return typeof value; + return `Config.${pathParts.map(String).join(".")}`; } -function coerceConfig(candidate: unknown): OrcaConfig { - if (!isObject(candidate)) { - throw new Error("Config module must export an object"); +function formatConfigIssue(issue: { path: PropertyKey[]; message: string }): string { + if (issue.message.startsWith("Config.") || issue.message.startsWith("Unknown hook key")) { + return issue.message; } - if ("skills" in candidate && candidate.skills !== undefined) { - if (!Array.isArray(candidate.skills)) { - throw new Error(`Config.skills must be an array, got ${describeType(candidate.skills)}`); - } - - for (const skillPath of candidate.skills) { - if (typeof skillPath !== "string") { - throw new Error( - `Config.skills entries must be strings, got ${describeType(skillPath)}` - ); - } - } + if ( + issue.message === "must be a string" && + issue.path.join(".").match(/^codex\.perCwdExtraUserRoots\.\d+\.extraUserRoots\.\d+$/) + ) { + return "Config.codex.perCwdExtraUserRoots[].extraUserRoots entries must be strings"; } - if ("hooks" in candidate && candidate.hooks !== undefined) { - if (!isObject(candidate.hooks)) { - throw new Error(`Config.hooks must be an object, got ${describeType(candidate.hooks)}`); - } - - for (const [hookName, handler] of Object.entries(candidate.hooks)) { - if (!knownHookNameSet.has(hookName)) { - throw new Error( - `Unknown hook key in Config.hooks: ${hookName}. Allowed hooks: ${KNOWN_HOOK_NAMES.join(", ")}` - ); - } - - if (typeof handler !== "function") { - throw new Error( - `Config.hooks.${hookName} must be a function, got ${describeType(handler)}` - ); - } - } - } - - if ("hookCommands" in candidate && candidate.hookCommands !== undefined) { - if (!isObject(candidate.hookCommands)) { - throw new Error( - `Config.hookCommands must be an object, got ${describeType(candidate.hookCommands)}` - ); - } - - for (const [hookName, command] of Object.entries(candidate.hookCommands)) { - if (!knownHookNameSet.has(hookName)) { - throw new Error( - `Unknown hook key in Config.hookCommands: ${hookName}. Allowed hooks: ${KNOWN_HOOK_NAMES.join(", ")}` - ); - } + return `${formatConfigPath(issue.path)} ${issue.message}`; +} - if (typeof command !== "string") { - throw new Error( - `Config.hookCommands.${hookName} must be a string, got ${describeType(command)}` - ); - } - } +function coerceConfig(candidate: unknown): OrcaConfig { + if (!isObject(candidate)) { + throw new Error("Config module must export an object"); } if ("executor" in candidate && candidate.executor !== undefined) { candidate.executor = "codex"; } - if ("codex" in candidate && candidate.codex !== undefined) { - if (!isObject(candidate.codex)) { - throw new Error(`Config.codex must be an object, got ${describeType(candidate.codex)}`); - } - - if ("effort" in candidate.codex && candidate.codex.effort !== undefined) { - if (typeof candidate.codex.effort !== "string") { - throw new Error( - `Config.codex.effort must be a string, got ${describeType(candidate.codex.effort)}` - ); - } - - candidate.codex.effort = parseCodexEffort(candidate.codex.effort); - } - - if ("thinkingLevel" in candidate.codex && candidate.codex.thinkingLevel !== undefined) { - if (!isObject(candidate.codex.thinkingLevel)) { - throw new Error(`Config.codex.thinkingLevel must be an object, got ${describeType(candidate.codex.thinkingLevel)}`); - } - - for (const key of ["decision", "planning", "review", "execution"] as const) { - const value = candidate.codex.thinkingLevel[key]; - if (value !== undefined) { - if (typeof value !== "string") { - throw new Error(`Config.codex.thinkingLevel.${key} must be a string, got ${describeType(value)}`); - } - candidate.codex.thinkingLevel[key] = parseCodexEffort(value); - } - } - } - - if ("thinking" in candidate.codex && candidate.codex.thinking !== undefined) { - throw new Error("Config.codex.thinking is no longer supported. Use Config.codex.thinkingLevel instead."); - } - - if ("perCwdExtraUserRoots" in candidate.codex && candidate.codex.perCwdExtraUserRoots !== undefined) { - if (!Array.isArray(candidate.codex.perCwdExtraUserRoots)) { - throw new Error( - `Config.codex.perCwdExtraUserRoots must be an array, got ${describeType(candidate.codex.perCwdExtraUserRoots)}` - ); - } - - for (const entry of candidate.codex.perCwdExtraUserRoots) { - if (!isObject(entry)) { - throw new Error( - `Config.codex.perCwdExtraUserRoots entries must be objects, got ${describeType(entry)}` - ); - } - - if (typeof entry.cwd !== "string") { - throw new Error( - `Config.codex.perCwdExtraUserRoots[].cwd must be a string, got ${describeType(entry.cwd)}` - ); - } - - if (!Array.isArray(entry.extraUserRoots)) { - throw new Error( - `Config.codex.perCwdExtraUserRoots[].extraUserRoots must be an array, got ${describeType(entry.extraUserRoots)}` - ); - } - - for (const root of entry.extraUserRoots) { - if (typeof root !== "string") { - throw new Error( - `Config.codex.perCwdExtraUserRoots[].extraUserRoots entries must be strings, got ${describeType(root)}` - ); - } - } - } - } + const parsed = OrcaConfigSchema.safeParse(candidate); + if (!parsed.success) { + const [firstIssue] = parsed.error.issues; + throw new Error(firstIssue ? formatConfigIssue(firstIssue) : "Config module is invalid"); } - if ("review" in candidate && candidate.review !== undefined) { - if (!isObject(candidate.review)) { - throw new Error(`Config.review must be an object, got ${describeType(candidate.review)}`); - } - - // legacy compatibility: allow top-level review.enabled / review.onInvalid - if ("enabled" in candidate.review && candidate.review.enabled !== undefined && typeof candidate.review.enabled !== "boolean") { - throw new Error(`Config.review.enabled must be a boolean, got ${describeType(candidate.review.enabled)}`); - } - - if ("onInvalid" in candidate.review && candidate.review.onInvalid !== undefined) { - if (candidate.review.onInvalid !== "fail" && candidate.review.onInvalid !== "warn_skip") { - const onInvalidDisplay = - typeof candidate.review.onInvalid === "string" - ? candidate.review.onInvalid - : (JSON.stringify(candidate.review.onInvalid) ?? describeType(candidate.review.onInvalid)); - throw new Error(`Config.review.onInvalid must be 'fail' or 'warn_skip', got ${onInvalidDisplay}`); - } - } - - if ("plan" in candidate.review && candidate.review.plan !== undefined) { - if (!isObject(candidate.review.plan)) { - throw new Error(`Config.review.plan must be an object, got ${describeType(candidate.review.plan)}`); - } - - if ("enabled" in candidate.review.plan && candidate.review.plan.enabled !== undefined && typeof candidate.review.plan.enabled !== "boolean") { - throw new Error(`Config.review.plan.enabled must be a boolean, got ${describeType(candidate.review.plan.enabled)}`); - } - - if ("onInvalid" in candidate.review.plan && candidate.review.plan.onInvalid !== undefined) { - if (candidate.review.plan.onInvalid !== "fail" && candidate.review.plan.onInvalid !== "warn_skip") { - const onInvalidDisplay = - typeof candidate.review.plan.onInvalid === "string" - ? candidate.review.plan.onInvalid - : (JSON.stringify(candidate.review.plan.onInvalid) ?? describeType(candidate.review.plan.onInvalid)); - throw new Error(`Config.review.plan.onInvalid must be 'fail' or 'warn_skip', got ${onInvalidDisplay}`); - } - } - } - - if ("execution" in candidate.review && candidate.review.execution !== undefined) { - if (!isObject(candidate.review.execution)) { - throw new Error(`Config.review.execution must be an object, got ${describeType(candidate.review.execution)}`); - } - - if ("enabled" in candidate.review.execution && candidate.review.execution.enabled !== undefined && typeof candidate.review.execution.enabled !== "boolean") { - throw new Error(`Config.review.execution.enabled must be a boolean, got ${describeType(candidate.review.execution.enabled)}`); - } - - if ("maxCycles" in candidate.review.execution && candidate.review.execution.maxCycles !== undefined) { - if (typeof candidate.review.execution.maxCycles !== "number" || !Number.isInteger(candidate.review.execution.maxCycles) || candidate.review.execution.maxCycles < 1) { - const maxCyclesDisplay = typeof candidate.review.execution.maxCycles === "number" - ? candidate.review.execution.maxCycles - : (JSON.stringify(candidate.review.execution.maxCycles) ?? describeType(candidate.review.execution.maxCycles)); - throw new Error(`Config.review.execution.maxCycles must be an integer >= 1, got ${maxCyclesDisplay}`); - } - } - - if ("onFindings" in candidate.review.execution && candidate.review.execution.onFindings !== undefined) { - if (candidate.review.execution.onFindings !== "auto_fix" && candidate.review.execution.onFindings !== "report_only" && candidate.review.execution.onFindings !== "fail") { - const display = typeof candidate.review.execution.onFindings === "string" - ? candidate.review.execution.onFindings - : (JSON.stringify(candidate.review.execution.onFindings) ?? describeType(candidate.review.execution.onFindings)); - throw new Error(`Config.review.execution.onFindings must be 'auto_fix', 'report_only', or 'fail', got ${display}`); - } - } - - if ("prompt" in candidate.review.execution && candidate.review.execution.prompt !== undefined && typeof candidate.review.execution.prompt !== "string") { - throw new Error(`Config.review.execution.prompt must be a string, got ${describeType(candidate.review.execution.prompt)}`); - } - - if ("validator" in candidate.review.execution && candidate.review.execution.validator !== undefined) { - if (!isObject(candidate.review.execution.validator)) { - throw new Error(`Config.review.execution.validator must be an object, got ${describeType(candidate.review.execution.validator)}`); - } - - if ("auto" in candidate.review.execution.validator && candidate.review.execution.validator.auto !== undefined && typeof candidate.review.execution.validator.auto !== "boolean") { - throw new Error(`Config.review.execution.validator.auto must be a boolean, got ${describeType(candidate.review.execution.validator.auto)}`); - } - - if ("commands" in candidate.review.execution.validator && candidate.review.execution.validator.commands !== undefined) { - if (!Array.isArray(candidate.review.execution.validator.commands)) { - throw new Error(`Config.review.execution.validator.commands must be an array, got ${describeType(candidate.review.execution.validator.commands)}`); - } - - for (const command of candidate.review.execution.validator.commands) { - if (typeof command !== "string") { - throw new Error(`Config.review.execution.validator.commands entries must be strings, got ${describeType(command)}`); - } - } - } - } - } - } - - return candidate as OrcaConfig; + return parsed.data as OrcaConfig; } export async function loadConfig(configPath?: string): Promise { @@ -286,10 +66,9 @@ export async function loadConfig(configPath?: string): Promise> = ["runsDir", "sessionLogs", "maxRetries", "openaiApiKey", "executor"]; +const TOP_LEVEL_SCALARS: Array< + keyof Pick +> = ["runsDir", "sessionLogs", "maxRetries", "openaiApiKey", "executor"]; export function mergeConfigs(...configs: Array): OrcaConfig | undefined { const presentConfigs = configs.filter((config): config is OrcaConfig => config !== undefined); @@ -307,20 +86,47 @@ export function mergeConfigs(...configs: Array): OrcaCon } if (merged.codex !== undefined || config.codex !== undefined) { - const mergedThinkingLevel = (merged.codex?.thinkingLevel !== undefined || config.codex?.thinkingLevel !== undefined) - ? { - ...merged.codex?.thinkingLevel, - ...config.codex?.thinkingLevel - } - : undefined; + const mergedThinkingLevel = + merged.codex?.thinkingLevel !== undefined || config.codex?.thinkingLevel !== undefined + ? { + ...merged.codex?.thinkingLevel, + ...config.codex?.thinkingLevel, + } + : undefined; merged.codex = { ...merged.codex, ...config.codex, - ...(mergedThinkingLevel !== undefined ? { thinkingLevel: mergedThinkingLevel } : {}) + ...(mergedThinkingLevel !== undefined ? { thinkingLevel: mergedThinkingLevel } : {}), + }; + } + + if (merged.planner !== undefined || config.planner !== undefined) { + const mergedRouter = + merged.planner?.router !== undefined || config.planner?.router !== undefined + ? { + ...merged.planner?.router, + ...config.planner?.router, + } + : undefined; + const mergedPlannerWithoutRouter = { ...merged.planner }; + const configPlannerWithoutRouter = { ...config.planner }; + delete (mergedPlannerWithoutRouter as { router?: unknown }).router; + delete (configPlannerWithoutRouter as { router?: unknown }).router; + + merged.planner = { + ...mergedPlannerWithoutRouter, + ...configPlannerWithoutRouter, + ...(mergedRouter !== undefined && config.planner?.agent !== "claude" && config.planner?.agent !== "codex" + ? { router: mergedRouter } + : {}), }; } + if (merged.claude !== undefined || config.claude !== undefined) { + merged.claude = { ...merged.claude, ...config.claude }; + } + if (merged.pr !== undefined || config.pr !== undefined) { merged.pr = { ...merged.pr, ...config.pr }; } @@ -331,16 +137,16 @@ export function mergeConfigs(...configs: Array): OrcaCon ...config.review, plan: { ...merged.review?.plan, - ...config.review?.plan + ...config.review?.plan, }, execution: { ...merged.review?.execution, ...config.review?.execution, validator: { ...merged.review?.execution?.validator, - ...config.review?.execution?.validator - } - } + ...config.review?.execution?.validator, + }, + }, }; } @@ -378,7 +184,7 @@ async function loadOptionalConfig(configPath: string): Promise { const globalConfig = await loadOptionalConfig(globalConfigPath); const projectConfig = await loadOptionalConfig(projectConfigPath); diff --git a/src/core/dependency-graph.test.ts b/src/core/dependency-graph.test.ts index f6e48fd..ea2c337 100644 --- a/src/core/dependency-graph.test.ts +++ b/src/core/dependency-graph.test.ts @@ -12,7 +12,7 @@ function makeTask(id: string, dependencies: string[] = []): Task { acceptance_criteria: ["ok"], status: "pending", retries: 0, - maxRetries: 3 + maxRetries: 3, }; } @@ -22,7 +22,7 @@ describe("dependency-graph", () => { { ...makeTask("t1"), status: "done" }, { ...makeTask("t2", ["t1"]), status: "pending" }, { ...makeTask("t3", ["t2"]), status: "pending" }, - { ...makeTask("t4"), status: "in_progress" } + { ...makeTask("t4"), status: "in_progress" }, ]; const runnable = getRunnable(tasks); diff --git a/src/core/planner.test.ts b/src/core/planner.test.ts index e31a899..8539257 100644 --- a/src/core/planner.test.ts +++ b/src/core/planner.test.ts @@ -4,7 +4,13 @@ import path from "node:path"; import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { runPlanner, setDecidePlanningNeedForTests, setPlanSpecForTests, setReviewTaskGraphForTests } from "./planner.js"; +import { + runPlanner, + setDecidePlanningNeedForTests, + setPlanSpecForTests, + setReviewTaskGraphForTests, + setSelectPlannerAgentForTests, +} from "./planner.js"; import { RunStore } from "../state/store.js"; describe("runPlanner task graph validation", () => { @@ -14,7 +20,18 @@ describe("runPlanner task graph validation", () => { let store: RunStore; const originalCwd = process.cwd(); - const baseTasks = [{ id: "t1", name: "Task 1", description: "desc", dependencies: [], acceptance_criteria: ["done"], status: "pending", retries: 0, maxRetries: 3 }] as const; + const baseTasks = [ + { + id: "t1", + name: "Task 1", + description: "desc", + dependencies: [], + acceptance_criteria: ["done"], + status: "pending", + retries: 0, + maxRetries: 3, + }, + ] as const; beforeEach(async () => { tempDir = await mkdtemp(path.join(os.tmpdir(), "orca-planner-test-")); @@ -26,6 +43,7 @@ describe("runPlanner task graph validation", () => { setPlanSpecForTests(async () => ({ tasks: [...baseTasks], rawResponse: "[]" })); setDecidePlanningNeedForTests(async () => ({ needsPlan: true, reason: "default" })); setReviewTaskGraphForTests(async () => ({ changes: [], rawResponse: "{}" })); + setSelectPlannerAgentForTests(async () => ({ planner: "codex", reason: "test default" })); }); afterEach(async () => { @@ -33,6 +51,7 @@ describe("runPlanner task graph validation", () => { setPlanSpecForTests(null); setDecidePlanningNeedForTests(null); setReviewTaskGraphForTests(null); + setSelectPlannerAgentForTests(null); await rm(tempDir, { recursive: true, force: true }); }); @@ -87,4 +106,38 @@ describe("runPlanner task graph validation", () => { await runPlanner(specPath, store, runId, undefined, { allowPlanSkip: true }); expect(calledPlanSpec).toBe(true); }); + + test("does not route to a planner when planning is skipped", async () => { + setDecidePlanningNeedForTests(async () => ({ needsPlan: false, reason: "single step" })); + let calledRouter = false; + setSelectPlannerAgentForTests(async () => { + calledRouter = true; + return { planner: "claude", reason: "should not run" }; + }); + + await runPlanner(specPath, store, runId, undefined, { allowPlanSkip: true }); + expect(calledRouter).toBe(false); + }); + + test("uses router when planner agent is auto", async () => { + let routerSpec = ""; + setSelectPlannerAgentForTests(async (spec) => { + routerSpec = spec; + return { planner: "codex", reason: "narrow coding task" }; + }); + + await runPlanner(specPath, store, runId, { planner: { agent: "auto" } }); + expect(routerSpec).toBe("# spec"); + }); + + test("forced planner agent bypasses router", async () => { + let calledRouter = false; + setSelectPlannerAgentForTests(async () => { + calledRouter = true; + return { planner: "codex", reason: "should not run" }; + }); + + await runPlanner(specPath, store, runId, { planner: { agent: "claude" } }); + expect(calledRouter).toBe(false); + }); }); diff --git a/src/core/planner.ts b/src/core/planner.ts index 7342f36..35b01ca 100644 --- a/src/core/planner.ts +++ b/src/core/planner.ts @@ -5,9 +5,18 @@ import { decidePlanningNeed as decidePlanningNeedWithCodex, planSpec as planSpecWithCodex, reviewTaskGraph as reviewTaskGraphWithCodex, + selectPlannerAgent as selectPlannerAgentWithCodex, type SessionInteractionContext, } from "../agents/codex/session.js"; -import type { HookEvent, OrcaConfig, Task, TaskGraphReviewResult } from "../types/index.js"; +import { planSpec as planSpecWithClaude } from "../agents/claude/session.js"; +import type { + HookEvent, + OrcaConfig, + PlannerAgent, + PlannerRoutingDecision, + Task, + TaskGraphReviewResult, +} from "../types/index.js"; import { logger } from "../utils/logger.js"; import { loadSkills, type LoadedSkill } from "../utils/skill-loader.js"; import { RunStore } from "../state/store.js"; @@ -21,6 +30,7 @@ const PROJECT_INSTRUCTION_CHAR_CAP = 4_000; type PlanSpecFn = typeof planSpecWithCodex; type DecidePlanningNeedFn = typeof decidePlanningNeedWithCodex; type ReviewTaskGraphFn = typeof reviewTaskGraphWithCodex; +type SelectPlannerAgentFn = typeof selectPlannerAgentWithCodex; export class InvalidPlanError extends Error { readonly stage: "planner" | "review"; @@ -42,6 +52,7 @@ type ProjectInstruction = { let testPlanSpecOverride: PlanSpecFn | null = null; let testDecidePlanningNeedOverride: DecidePlanningNeedFn | null = null; let testReviewTaskGraphOverride: ReviewTaskGraphFn | null = null; +let testSelectPlannerAgentOverride: SelectPlannerAgentFn | null = null; export function setPlanSpecForTests(fn: PlanSpecFn | null): void { testPlanSpecOverride = fn; @@ -55,8 +66,16 @@ export function setReviewTaskGraphForTests(fn: ReviewTaskGraphFn | null): void { testReviewTaskGraphOverride = fn; } -function resolvePlanSpecImpl(_config?: OrcaConfig): PlanSpecFn { - return testPlanSpecOverride ?? planSpecWithCodex; +export function setSelectPlannerAgentForTests(fn: SelectPlannerAgentFn | null): void { + testSelectPlannerAgentOverride = fn; +} + +function resolvePlanSpecImpl(_config: OrcaConfig | undefined, planner: PlannerAgent): PlanSpecFn { + if (testPlanSpecOverride) { + return testPlanSpecOverride; + } + + return planner === "claude" ? planSpecWithClaude : planSpecWithCodex; } function resolveDecidePlanningNeedImpl(_config?: OrcaConfig): DecidePlanningNeedFn { @@ -67,15 +86,31 @@ function resolveReviewTaskGraphImpl(_config?: OrcaConfig): ReviewTaskGraphFn { return testReviewTaskGraphOverride ?? reviewTaskGraphWithCodex; } +function resolveConfiguredPlannerAgent(config?: OrcaConfig): PlannerAgent | "auto" { + return config?.planner?.agent ?? "auto"; +} + +async function selectPlanningAgent( + spec: string, + systemContext: string, + config?: OrcaConfig, + interactionContext?: SessionInteractionContext, +): Promise { + const configured = resolveConfiguredPlannerAgent(config); + if (configured !== "auto") { + return { + planner: configured, + reason: `configured planner.agent=${configured}`, + }; + } + + const router = testSelectPlannerAgentOverride ?? selectPlannerAgentWithCodex; + return await router(spec, systemContext, config, interactionContext); +} + function formatSkillsSection(skills: LoadedSkill[]): string { const formattedSkills = skills.map((skill) => - [ - `### ${skill.name}`, - "", - `Description: ${skill.description}`, - "", - skill.body - ].join("\n") + [`### ${skill.name}`, "", `Description: ${skill.description}`, "", skill.body].join("\n"), ); return ["## Available Skills", "", ...formattedSkills].join("\n"); @@ -131,7 +166,7 @@ async function loadProjectInstructions(specPath: string): Promise PROJECT_INSTRUCTION_CHAR_CAP + truncated: rawContent.length > PROJECT_INSTRUCTION_CHAR_CAP, }); } @@ -171,10 +206,13 @@ function buildSystemContext(skills: LoadedSkill[], instructions: ProjectInstruct } function getPlanReviewConfig(config: OrcaConfig | undefined): { enabled: boolean; onInvalid: "fail" | "warn_skip" } { - const review = (config?.review ?? {}) as OrcaConfig["review"] & { enabled?: boolean; onInvalid?: "fail" | "warn_skip" }; + const review = (config?.review ?? {}) as OrcaConfig["review"] & { + enabled?: boolean; + onInvalid?: "fail" | "warn_skip"; + }; return { enabled: review.plan?.enabled ?? review.enabled ?? true, - onInvalid: review.plan?.onInvalid ?? review.onInvalid ?? "fail" + onInvalid: review.plan?.onInvalid ?? review.onInvalid ?? "fail", }; } @@ -197,11 +235,16 @@ async function runTaskGraphReview( review = await reviewFn(tasks, systemContext, config, interactionContext); } catch (error) { if (planReviewConfig.onInvalid === "warn_skip") { - logger.warn(`Review output invalid; skipping review changes (${error instanceof Error ? error.message : String(error)})`); + logger.warn( + `Review output invalid; skipping review changes (${error instanceof Error ? error.message : String(error)})`, + ); return { finalTasks: tasks, review: null }; } - throw new InvalidPlanError("review", `Review output invalid. ${error instanceof Error ? error.message : String(error)}`); + throw new InvalidPlanError( + "review", + `Review output invalid. ${error instanceof Error ? error.message : String(error)}`, + ); } if (review.changes.length === 0) { @@ -249,7 +292,10 @@ async function runFullPlanning( config?: OrcaConfig, interactionContext?: SessionInteractionContext, ): Promise { - const planSpecImpl = resolvePlanSpecImpl(config); + const selectedPlanner = await selectPlanningAgent(spec, systemContext, config, interactionContext); + logger.info(`Planning agent: ${selectedPlanner.planner} (${selectedPlanner.reason})`); + + const planSpecImpl = resolvePlanSpecImpl(config, selectedPlanner.planner); const result = await planSpecImpl(spec, systemContext, config, interactionContext); try { @@ -265,12 +311,17 @@ async function runFullPlanning( finalTasks = reviewed.finalTasks; } catch (error) { if (planReviewConfig.onInvalid === "warn_skip") { - logger.warn(`Review changes rejected; proceeding with planner graph (${error instanceof Error ? error.message : String(error)})`); + logger.warn( + `Review changes rejected; proceeding with planner graph (${error instanceof Error ? error.message : String(error)})`, + ); finalTasks = result.tasks; } else if (error instanceof InvalidPlanError) { throw error; } else { - throw new InvalidPlanError("review", `Review stage failed. ${error instanceof Error ? error.message : String(error)}`); + throw new InvalidPlanError( + "review", + `Review stage failed. ${error instanceof Error ? error.message : String(error)}`, + ); } } @@ -290,6 +341,7 @@ export async function runPlanner( const interactiveContext = { runId: runId as HookEvent["runId"], store, + resumeOverallStatus: "planning" as const, ...(options?.emitHook ? { emitHook: options.emitHook } : {}), }; @@ -313,7 +365,7 @@ export async function runPlanner( await store.updateRun(runId, { overallStatus: "planning", tasks: finalTasks, - milestones: ["plan-complete"] + milestones: ["plan-complete"], }); logger.success(`Plan complete: ${finalTasks.length} tasks`); diff --git a/src/core/question-flow.test.ts b/src/core/question-flow.test.ts new file mode 100644 index 0000000..3d16402 --- /dev/null +++ b/src/core/question-flow.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, test } from "bun:test"; + +import type { PendingQuestion } from "../types/index.js"; +import { parseQuestionAnswerInput } from "./question-flow.js"; + +function makePendingQuestion(questions: PendingQuestion["questions"]): PendingQuestion { + return { + requestId: "req-1", + threadId: "thread-1", + turnId: "turn-1", + itemId: "item-1", + receivedAt: new Date().toISOString(), + questions, + }; +} + +describe("parseQuestionAnswerInput", () => { + test("treats JSON snippets as plain text for a single pending question", () => { + const pendingQuestion = makePendingQuestion([ + { + header: "Config", + id: "config", + question: "What config should I use?", + isOther: true, + isSecret: false, + }, + ]); + + expect(parseQuestionAnswerInput('{"useMigration":true}', pendingQuestion)).toEqual({ + answers: { + config: { + answers: ['{"useMigration":true}'], + }, + }, + }); + }); + + test("rejects documented answers payloads that include unknown question ids", () => { + const pendingQuestion = makePendingQuestion([ + { + header: "Backend", + id: "backend", + question: "Which backend should I use?", + isOther: true, + isSecret: false, + }, + ]); + + expect(() => parseQuestionAnswerInput('{"answers":{"backedn":{"answers":["bun"]}}}', pendingQuestion)).toThrow( + "answer payload includes unknown question id 'backedn'", + ); + }); + + test("requires every pending question id in explicit answers payloads", () => { + const pendingQuestion = makePendingQuestion([ + { + header: "Runtime", + id: "runtime", + question: "Which runtime should I use?", + isOther: true, + isSecret: false, + }, + { + header: "Package Manager", + id: "package_manager", + question: "Which package manager should I use?", + isOther: true, + isSecret: false, + }, + ]); + + expect(() => parseQuestionAnswerInput('{"answers":{"runtime":{"answers":["bun"]}}}', pendingQuestion)).toThrow( + "answer payload is missing question id 'package_manager'", + ); + }); +}); diff --git a/src/core/question-flow.ts b/src/core/question-flow.ts index 062d61d..d9557f9 100644 --- a/src/core/question-flow.ts +++ b/src/core/question-flow.ts @@ -1,7 +1,4 @@ -import type { - ToolRequestUserInputParams, - ToolRequestUserInputResponse, -} from "@ratley/codex-client"; +import type { ToolRequestUserInputParams, ToolRequestUserInputResponse } from "@ratley/codex-client"; import type { PendingQuestion, PendingQuestionPrompt } from "../types/index.js"; @@ -43,11 +40,12 @@ function normalizeAnswerList(value: unknown): string[] | null { } function formatQuestionBlock(question: PendingQuestionPrompt): string { - const optionText = question.options && question.options.length > 0 - ? ` Options: ${question.options.map((option) => option.label).join(", ")}.` - : ""; + const optionText = + question.options && question.options.length > 0 + ? ` Options: ${question.options.map((option) => option.label).join(", ")}.` + : ""; - return `${question.header}: ${question.question}${optionText}`; + return `${question.header} (${question.id}): ${question.question}${optionText}`; } export function createPendingQuestion( @@ -74,16 +72,55 @@ export function buildQuestionHookMessage(pendingQuestion: PendingQuestion): stri } export function formatPendingQuestionForStatus(pendingQuestion: PendingQuestion): string[] { - return [ - "Pending Question:", - ...pendingQuestion.questions.map((question) => `- ${formatQuestionBlock(question)}`), - ]; + return ["Pending Question:", ...pendingQuestion.questions.map((question) => `- ${formatQuestionBlock(question)}`)]; } export function serializeQuestionAnswerResponse(response: ToolRequestUserInputResponse): string { return `${JSON.stringify(response, null, 2)}\n`; } +function buildSingleQuestionTextResponse( + question: PendingQuestionPrompt, + answer: string, +): ToolRequestUserInputResponse { + return { + answers: { + [question.id]: { + answers: [answer], + }, + }, + }; +} + +function normalizeStructuredAnswers( + answerRecord: Record, + pendingQuestion: PendingQuestion, +): Record { + const expectedQuestionIds = new Set(pendingQuestion.questions.map((question) => question.id)); + + for (const questionId of Object.keys(answerRecord)) { + if (!expectedQuestionIds.has(questionId)) { + throw new Error(`answer payload includes unknown question id '${questionId}'`); + } + } + + const normalizedAnswers: Record = {}; + for (const question of pendingQuestion.questions) { + if (!(question.id in answerRecord)) { + throw new Error(`answer payload is missing question id '${question.id}'`); + } + + const answers = normalizeAnswerList(answerRecord[question.id]); + if (answers === null) { + throw new Error(`answer payload for '${question.id}' must be a string, string array, or { answers: string[] }`); + } + + normalizedAnswers[question.id] = { answers }; + } + + return normalizedAnswers; +} + export function parseQuestionAnswerInput( rawInput: string, pendingQuestion: PendingQuestion, @@ -93,65 +130,42 @@ export function parseQuestionAnswerInput( throw new Error("answer payload is empty"); } - if (pendingQuestion.questions.length === 1 && !trimmed.startsWith("{")) { - const onlyQuestion = pendingQuestion.questions[0]; - if (!onlyQuestion) { - throw new Error("pending question is missing its question definition"); - } + const onlyQuestion = pendingQuestion.questions.length === 1 ? pendingQuestion.questions[0] : undefined; + if (pendingQuestion.questions.length === 1 && !onlyQuestion) { + throw new Error("pending question is missing its question definition"); + } - return { - answers: { - [onlyQuestion.id]: { - answers: [trimmed], - }, - }, - }; + if (pendingQuestion.questions.length === 1 && !trimmed.startsWith("{")) { + return buildSingleQuestionTextResponse(onlyQuestion!, trimmed); } let parsed: unknown; try { parsed = JSON.parse(trimmed); - } catch (error) { - throw new Error( - pendingQuestion.questions.length === 1 - ? `answer payload is not valid JSON: ${error instanceof Error ? error.message : String(error)}` - : "multiple pending questions require a JSON object mapping question ids to answers", - ); + } catch { + if (pendingQuestion.questions.length === 1) { + return buildSingleQuestionTextResponse(onlyQuestion!, trimmed); + } + + throw new Error("multiple pending questions require a JSON object mapping question ids to answers"); } if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + if (pendingQuestion.questions.length === 1) { + return buildSingleQuestionTextResponse(onlyQuestion!, trimmed); + } + throw new Error("answer payload must be a JSON object"); } const record = parsed as Record; if ("answers" in record && record.answers && typeof record.answers === "object" && !Array.isArray(record.answers)) { - const normalizedAnswers: Record = {}; - - for (const [questionId, answerValue] of Object.entries(record.answers as Record)) { - const answers = normalizeAnswerList(answerValue); - if (answers === null) { - throw new Error(`answer payload for '${questionId}' must be a string, string array, or { answers: string[] }`); - } - - normalizedAnswers[questionId] = { answers }; - } - - return { answers: normalizedAnswers }; + return { answers: normalizeStructuredAnswers(record.answers as Record, pendingQuestion) }; } - const normalizedAnswers: Record = {}; - for (const question of pendingQuestion.questions) { - if (!(question.id in record)) { - throw new Error(`answer payload is missing question id '${question.id}'`); - } - - const answers = normalizeAnswerList(record[question.id]); - if (answers === null) { - throw new Error(`answer payload for '${question.id}' must be a string, string array, or { answers: string[] }`); - } - - normalizedAnswers[question.id] = { answers }; + if (pendingQuestion.questions.length === 1 && !(onlyQuestion!.id in record)) { + return buildSingleQuestionTextResponse(onlyQuestion!, trimmed); } - return { answers: normalizedAnswers }; + return { answers: normalizeStructuredAnswers(record, pendingQuestion) }; } diff --git a/src/core/retry-policy.test.ts b/src/core/retry-policy.test.ts index 1fa9087..35dc541 100644 --- a/src/core/retry-policy.test.ts +++ b/src/core/retry-policy.test.ts @@ -11,7 +11,7 @@ const baseTask: Task = { acceptance_criteria: ["ok"], status: "pending", retries: 0, - maxRetries: 3 + maxRetries: 3, }; describe("shouldRetry", () => { @@ -30,7 +30,7 @@ describe("shouldRetry", () => { const exhaustedTask: Task = { ...baseTask, retries: 3, - maxRetries: 3 + maxRetries: 3, }; expect(shouldRetry(exhaustedTask, new Error("network timeout"))).toBe(false); diff --git a/src/core/retry-policy.ts b/src/core/retry-policy.ts index f0c4dd9..ff2ddde 100644 --- a/src/core/retry-policy.ts +++ b/src/core/retry-policy.ts @@ -19,15 +19,7 @@ function isPermanentError(error: unknown): boolean { return true; } - const permanentPatterns = [ - "schema", - "validation", - "invalid json", - "parse", - "cancelled", - "canceled", - "abort" - ]; + const permanentPatterns = ["schema", "validation", "invalid json", "parse", "cancelled", "canceled", "abort"]; return permanentPatterns.some((pattern) => message.includes(pattern)); } @@ -48,7 +40,7 @@ function isTransientError(error: unknown): boolean { "429", "503", "temporarily unavailable", - "socket hang up" + "socket hang up", ]; return transientPatterns.some((pattern) => message.includes(pattern)); diff --git a/src/core/secret-answer-channel.ts b/src/core/secret-answer-channel.ts new file mode 100644 index 0000000..fa2b4e4 --- /dev/null +++ b/src/core/secret-answer-channel.ts @@ -0,0 +1,60 @@ +import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import type { PendingAnswerChannel, RunId } from "../types/index.js"; + +function getSecretAnswerChannelsDir(): string { + return path.join(os.homedir(), ".orca", "runtime", "answer-channels"); +} + +function getSecretAnswerChannelFilePath(runId: RunId): string { + return path.join(getSecretAnswerChannelsDir(), `${runId}.json`); +} + +export async function writeSecretAnswerChannel(runId: RunId, channel: PendingAnswerChannel): Promise { + const channelsDir = getSecretAnswerChannelsDir(); + const channelFile = getSecretAnswerChannelFilePath(runId); + const tempFile = `${channelFile}.${process.pid}.${Date.now()}.tmp`; + const payload = JSON.stringify(channel); + + await mkdir(channelsDir, { recursive: true, mode: 0o700 }); + await writeFile(tempFile, payload, { encoding: "utf8", mode: 0o600 }); + await rename(tempFile, channelFile); +} + +export async function readSecretAnswerChannel(runId: RunId): Promise { + const channelFile = getSecretAnswerChannelFilePath(runId); + + let raw: string; + try { + raw = await readFile(channelFile, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return null; + } + + throw error; + } + + const parsed = JSON.parse(raw) as Partial; + if ( + parsed.transport !== "ipc" || + typeof parsed.path !== "string" || + parsed.path.length === 0 || + typeof parsed.token !== "string" || + parsed.token.length === 0 + ) { + throw new Error(`invalid secret answer channel metadata for run ${runId}`); + } + + return { + transport: "ipc", + path: parsed.path, + token: parsed.token, + }; +} + +export async function clearSecretAnswerChannel(runId: RunId): Promise { + await rm(getSecretAnswerChannelFilePath(runId), { force: true }); +} diff --git a/src/core/task-graph-review.ts b/src/core/task-graph-review.ts index 59e1114..782c4f6 100644 --- a/src/core/task-graph-review.ts +++ b/src/core/task-graph-review.ts @@ -2,53 +2,69 @@ import { z } from "zod"; import type { Task, TaskGraphReviewOperation } from "../types/index.js"; -const TaskSchema = z.object({ - id: z.string().min(1), - name: z.string().min(1), - description: z.string(), - dependencies: z.array(z.string()), - acceptance_criteria: z.array(z.string()), - status: z.enum(["pending", "in_progress", "done", "failed", "cancelled"]), - retries: z.number(), - maxRetries: z.number(), - startedAt: z.string().optional(), - finishedAt: z.string().optional(), - lastError: z.string().optional() -}).strict(); +const TaskSchema = z + .object({ + id: z.string().min(1), + name: z.string().min(1), + description: z.string(), + dependencies: z.array(z.string()), + acceptance_criteria: z.array(z.string()), + status: z.enum(["pending", "in_progress", "done", "failed", "cancelled"]), + retries: z.number(), + maxRetries: z.number(), + startedAt: z.string().optional(), + finishedAt: z.string().optional(), + lastError: z.string().optional(), + }) + .strict(); const ReviewOperationSchema = z.discriminatedUnion("op", [ - z.object({ - op: z.literal("update_task"), - taskId: z.string().min(1), - fields: z.object({ - name: z.string().min(1).optional(), - description: z.string().optional(), - acceptance_criteria: z.array(z.string()).optional() - }).strict() - }).strict(), - z.object({ - op: z.literal("add_task"), - task: TaskSchema - }).strict(), - z.object({ - op: z.literal("remove_task"), - taskId: z.string().min(1) - }).strict(), - z.object({ - op: z.literal("add_dependency"), - taskId: z.string().min(1), - dependsOn: z.string().min(1) - }).strict(), - z.object({ - op: z.literal("remove_dependency"), - taskId: z.string().min(1), - dependsOn: z.string().min(1) - }).strict() + z + .object({ + op: z.literal("update_task"), + taskId: z.string().min(1), + fields: z + .object({ + name: z.string().min(1).optional(), + description: z.string().optional(), + acceptance_criteria: z.array(z.string()).optional(), + }) + .strict(), + }) + .strict(), + z + .object({ + op: z.literal("add_task"), + task: TaskSchema, + }) + .strict(), + z + .object({ + op: z.literal("remove_task"), + taskId: z.string().min(1), + }) + .strict(), + z + .object({ + op: z.literal("add_dependency"), + taskId: z.string().min(1), + dependsOn: z.string().min(1), + }) + .strict(), + z + .object({ + op: z.literal("remove_dependency"), + taskId: z.string().min(1), + dependsOn: z.string().min(1), + }) + .strict(), ]); -export const TaskGraphReviewPayloadSchema = z.object({ - changes: z.array(ReviewOperationSchema) -}).strict(); +export const TaskGraphReviewPayloadSchema = z + .object({ + changes: z.array(ReviewOperationSchema), + }) + .strict(); function findTaskIndex(tasks: Task[], taskId: string): number { return tasks.findIndex((task) => task.id === taskId); @@ -76,7 +92,11 @@ export function summarizeReviewChanges(changes: TaskGraphReviewOperation[]): str } export function applyTaskGraphReviewChanges(tasks: Task[], changes: TaskGraphReviewOperation[]): Task[] { - const nextTasks = tasks.map((task) => ({ ...task, dependencies: [...task.dependencies], acceptance_criteria: [...task.acceptance_criteria] })); + const nextTasks = tasks.map((task) => ({ + ...task, + dependencies: [...task.dependencies], + acceptance_criteria: [...task.acceptance_criteria], + })); for (const change of changes) { switch (change.op) { @@ -93,7 +113,7 @@ export function applyTaskGraphReviewChanges(tasks: Task[], changes: TaskGraphRev ...("description" in change.fields ? { description: change.fields.description ?? current.description } : {}), ...("acceptance_criteria" in change.fields ? { acceptance_criteria: [...(change.fields.acceptance_criteria ?? current.acceptance_criteria)] } - : {}) + : {}), }; break; } @@ -104,7 +124,7 @@ export function applyTaskGraphReviewChanges(tasks: Task[], changes: TaskGraphRev nextTasks.push({ ...change.task, dependencies: [...change.task.dependencies], - acceptance_criteria: [...change.task.acceptance_criteria] + acceptance_criteria: [...change.task.acceptance_criteria], }); break; } diff --git a/src/core/task-runner.test.ts b/src/core/task-runner.test.ts index 6ba1976..25b01a0 100644 --- a/src/core/task-runner.test.ts +++ b/src/core/task-runner.test.ts @@ -16,7 +16,7 @@ function makeTask(id: string, dependencies: string[] = []): Task { acceptance_criteria: ["ok"], status: "pending", retries: 0, - maxRetries: 3 + maxRetries: 3, }; } @@ -42,7 +42,7 @@ describe("task-runner", () => { await store.updateRun(runId, { mode: "run", overallStatus: "running", - tasks + tasks, }); const calls: string[] = []; @@ -58,7 +58,7 @@ describe("task-runner", () => { store, emitHook: async (event) => { hookEvents.push(event); - } + }, }); const run = await store.getRun(runId); @@ -71,17 +71,57 @@ describe("task-runner", () => { expect(run.tasks.map((task) => task.status)).toEqual(["done", "done"]); expect(hookEvents.some((event) => event.hook === "onTaskComplete" && event.taskId === "t1")).toBe(true); expect(hookEvents.some((event) => event.hook === "onTaskComplete" && event.taskId === "t2")).toBe(true); - expect(hookEvents.some((event) => event.hook === "onMilestone" && event.message === "execution-started")).toBe(true); - expect(hookEvents.some((event) => event.hook === "onMilestone" && event.message === "execution-completed")).toBe(true); + expect(hookEvents.some((event) => event.hook === "onMilestone" && event.message === "execution-started")).toBe( + true, + ); + expect(hookEvents.some((event) => event.hook === "onMilestone" && event.message === "execution-completed")).toBe( + true, + ); expect(hookEvents.some((event) => event.hook === "onComplete" && event.message === "run-completed")).toBe(true); }); + test("defers final completion when configured for post-execution review", async () => { + const tasks = [makeTask("t1")]; + await store.updateRun(runId, { + mode: "run", + overallStatus: "running", + tasks, + }); + + const hookEvents: HookEvent[] = []; + + setExecuteTaskForTests(async () => ({ + outcome: "done", + rawResponse: '{"outcome":"done"}', + })); + + await runTaskRunner({ + runId, + store, + deferCompletion: true, + emitHook: async (event) => { + hookEvents.push(event); + }, + }); + + const run = await store.getRun(runId); + if (!run) { + throw new Error("Run missing after deferred completion"); + } + + expect(run.overallStatus).toBe("reviewing"); + expect(hookEvents.some((event) => event.hook === "onMilestone" && event.message === "execution-completed")).toBe( + true, + ); + expect(hookEvents.some((event) => event.hook === "onComplete")).toBe(false); + }); + test("retries transient task failure and then succeeds", async () => { const tasks = [makeTask("t1")]; await store.updateRun(runId, { mode: "run", overallStatus: "running", - tasks + tasks, }); let attempts = 0; @@ -113,7 +153,7 @@ describe("task-runner", () => { await store.updateRun(runId, { mode: "run", overallStatus: "running", - tasks + tasks, }); const hookEvents: HookEvent[] = []; @@ -127,7 +167,7 @@ describe("task-runner", () => { store, emitHook: async (event) => { hookEvents.push(event); - } + }, }); const run = await store.getRun(runId); @@ -147,14 +187,14 @@ describe("task-runner", () => { await store.updateRun(runId, { mode: "run", overallStatus: "running", - tasks: [cyclicTask] + tasks: [cyclicTask], }); const hookEvents: HookEvent[] = []; setExecuteTaskForTests(async () => ({ outcome: "done", - rawResponse: '{"outcome":"done"}' + rawResponse: '{"outcome":"done"}', })); await expect( @@ -163,8 +203,8 @@ describe("task-runner", () => { store, emitHook: async (event) => { hookEvents.push(event); - } - }) + }, + }), ).rejects.toThrow("Task graph has cycle"); const run = await store.getRun(runId); @@ -182,19 +222,19 @@ describe("task-runner", () => { await store.updateRun(runId, { mode: "run", overallStatus: "running", - tasks + tasks, }); setExecuteTaskForTests(async () => ({ outcome: "done", - rawResponse: '{"outcome":"done"}' + rawResponse: '{"outcome":"done"}', })); await runTaskRunner({ runId, store, config: { sessionLogs: sessionLogsDir }, - emitHook: async () => {} + emitHook: async () => {}, }); const summaryPath = path.join(sessionLogsDir, `${runId}.md`); @@ -213,19 +253,19 @@ describe("task-runner", () => { await store.updateRun(runId, { mode: "run", overallStatus: "running", - tasks + tasks, }); setExecuteTaskForTests(async () => ({ outcome: "done", - rawResponse: '{"outcome":"done"}' + rawResponse: '{"outcome":"done"}', })); await runTaskRunner({ runId, store, config: { sessionLogs: sessionLogsPath }, - emitHook: async () => {} + emitHook: async () => {}, }); const run = await store.getRun(runId); diff --git a/src/core/task-runner.ts b/src/core/task-runner.ts index 3f2f255..598f409 100644 --- a/src/core/task-runner.ts +++ b/src/core/task-runner.ts @@ -14,7 +14,7 @@ export type ExecuteTaskFn = ( task: Task, runId: string, config?: OrcaConfig, - systemContext?: string + systemContext?: string, ) => Promise<{ outcome: "done" | "failed"; rawResponse: string; error?: string }>; // Non-null only when set by tests — null means "use real executor logic" @@ -48,7 +48,7 @@ function applyTaskUpdate(tasks: Task[], taskId: string, patch: Partial): T return { ...task, - ...patch + ...patch, }; }); } @@ -72,19 +72,14 @@ export interface TaskRunnerOptions { store: RunStore; config?: OrcaConfig; emitHook?: EmitHook; + deferCompletion?: boolean; /** Override executor — used by tests only. In production, use config.executor. */ executeTask?: ExecuteTaskFn; } function formatSkillsSection(skills: LoadedSkill[]): string { const formattedSkills = skills.map((skill) => - [ - `### ${skill.name}`, - "", - `Description: ${skill.description}`, - "", - skill.body - ].join("\n") + [`### ${skill.name}`, "", `Description: ${skill.description}`, "", skill.body].join("\n"), ); return ["## Available Skills", "", ...formattedSkills].join("\n"); @@ -114,11 +109,11 @@ function buildSessionSummary(run: RunStatus): string { "| ID | Name | Status | Started At | Finished At |", "| --- | --- | --- | --- | --- |", taskRows, - "" + "", ].join("\n"); } -async function writeSessionSummary(store: RunStore, runId: string, sessionLogsDir?: string): Promise { +export async function writeSessionSummary(store: RunStore, runId: string, sessionLogsDir?: string): Promise { if (!sessionLogsDir) { return; } @@ -138,7 +133,7 @@ async function writeSessionSummary(store: RunStore, runId: string, sessionLogsDi export async function runTaskRunner(options: TaskRunnerOptions): Promise { const emitHook = options.emitHook ?? defaultEmitHook; - const { runId, store, config } = options; + const { runId, store, config, deferCompletion = false } = options; const skills = await loadSkills(config); const taskSystemContext = skills.length === 0 ? undefined : formatSkillsSection(skills); @@ -156,10 +151,10 @@ export async function runTaskRunner(options: TaskRunnerOptions): Promise { codexSession = await createCodexSession(process.cwd(), config, { runId, store, + resumeOverallStatus: "running", emitHook, }); - executeTaskFn = (task, taskRunId, _cfg, systemContext) => - codexSession!.executeTask(task, taskRunId, systemContext); + executeTaskFn = (task, taskRunId, _cfg, systemContext) => codexSession!.executeTask(task, taskRunId, systemContext); } try { @@ -175,7 +170,7 @@ export async function runTaskRunner(options: TaskRunnerOptions): Promise { hook: "onMilestone", message: "execution-started", timestamp: new Date().toISOString(), - metadata: { overallStatus: "running" } + metadata: { overallStatus: "running" }, }); while (true) { @@ -190,7 +185,7 @@ export async function runTaskRunner(options: TaskRunnerOptions): Promise { hook: "onMilestone", message: "execution-cancelled", timestamp: new Date().toISOString(), - metadata: { overallStatus: "cancelled" } + metadata: { overallStatus: "cancelled" }, }); await writeSessionSummary(store, runId, config?.sessionLogs); return; @@ -204,22 +199,25 @@ export async function runTaskRunner(options: TaskRunnerOptions): Promise { const allDone = run.tasks.every((task) => task.status === "done"); if (allDone) { - await store.updateRun(runId, { overallStatus: "completed" }); + const nextOverallStatus = deferCompletion ? "reviewing" : "completed"; + await store.updateRun(runId, { overallStatus: nextOverallStatus }); const completedAt = new Date().toISOString(); await emitHook({ runId: run.runId, hook: "onMilestone", message: "execution-completed", timestamp: completedAt, - metadata: { overallStatus: "completed" } - }); - await emitHook({ - runId: run.runId, - hook: "onComplete", - message: "run-completed", - timestamp: completedAt, - metadata: { overallStatus: "completed" } + metadata: { overallStatus: nextOverallStatus }, }); + if (!deferCompletion) { + await emitHook({ + runId: run.runId, + hook: "onComplete", + message: "run-completed", + timestamp: completedAt, + metadata: { overallStatus: "completed" }, + }); + } await writeSessionSummary(store, runId, config?.sessionLogs); return; } @@ -233,7 +231,7 @@ export async function runTaskRunner(options: TaskRunnerOptions): Promise { hook: "onMilestone", message: "execution-failed", timestamp: failedAt, - metadata: { overallStatus: "failed" } + metadata: { overallStatus: "failed" }, }); await emitHook({ runId: run.runId, @@ -241,7 +239,7 @@ export async function runTaskRunner(options: TaskRunnerOptions): Promise { message: `run-failed: ${failureMessage}`, timestamp: failedAt, error: failureMessage, - metadata: { overallStatus: "failed" } + metadata: { overallStatus: "failed" }, }); await writeSessionSummary(store, runId, config?.sessionLogs); return; @@ -254,7 +252,7 @@ export async function runTaskRunner(options: TaskRunnerOptions): Promise { hook: "onMilestone", message: "execution-cancelled", timestamp: new Date().toISOString(), - metadata: { overallStatus: "cancelled" } + metadata: { overallStatus: "cancelled" }, }); await writeSessionSummary(store, runId, config?.sessionLogs); return; @@ -281,14 +279,14 @@ export async function runTaskRunner(options: TaskRunnerOptions): Promise { return { ...stripOptionalFields(candidate, ["finishedAt", "lastError"]), status: "in_progress" as const, - startedAt: candidate.startedAt ?? now + startedAt: candidate.startedAt ?? now, }; }); await store.updateRun(runId, { mode: "run", overallStatus: "running", - tasks: inProgressTasks + tasks: inProgressTasks, }); try { @@ -303,7 +301,7 @@ export async function runTaskRunner(options: TaskRunnerOptions): Promise { return { ...stripOptionalFields(candidate, ["lastError"]), status: "done" as const, - finishedAt: new Date().toISOString() + finishedAt: new Date().toISOString(), }; }); @@ -315,7 +313,7 @@ export async function runTaskRunner(options: TaskRunnerOptions): Promise { message: `Task completed: ${task.name}`, timestamp: new Date().toISOString(), taskId: task.id, - taskName: task.name + taskName: task.name, }); continue; @@ -340,13 +338,13 @@ export async function runTaskRunner(options: TaskRunnerOptions): Promise { ...stripOptionalFields(candidate, ["finishedAt"]), status: "pending" as const, retries: currentTask.retries + 1, - lastError: errorMessage + lastError: errorMessage, }; }); await store.updateRun(runId, { tasks: retryTasks, - milestones: [...run.milestones, `retry:${task.id}:${currentTask.retries + 1}`] + milestones: [...run.milestones, `retry:${task.id}:${currentTask.retries + 1}`], }); await emitHook({ @@ -356,7 +354,7 @@ export async function runTaskRunner(options: TaskRunnerOptions): Promise { timestamp: new Date().toISOString(), taskId: task.id, taskName: task.name, - metadata: { retries: currentTask.retries + 1 } + metadata: { retries: currentTask.retries + 1 }, }); continue; @@ -366,7 +364,7 @@ export async function runTaskRunner(options: TaskRunnerOptions): Promise { const failedTasks = applyTaskUpdate(inProgressTasks, task.id, { status: "failed", finishedAt: failedAt, - lastError: errorMessage + lastError: errorMessage, }); await store.updateRun(runId, { @@ -377,9 +375,9 @@ export async function runTaskRunner(options: TaskRunnerOptions): Promise { { at: failedAt, message: errorMessage, - taskId: task.id - } - ] + taskId: task.id, + }, + ], }); await emitHook({ @@ -389,7 +387,7 @@ export async function runTaskRunner(options: TaskRunnerOptions): Promise { timestamp: failedAt, taskId: task.id, taskName: task.name, - error: errorMessage + error: errorMessage, }); } } @@ -407,7 +405,7 @@ export async function runTaskRunner(options: TaskRunnerOptions): Promise { message: `run-failed: ${errorMessage}`, timestamp: now, error: errorMessage, - metadata: { overallStatus: "failed" } + metadata: { overallStatus: "failed" }, }); await writeSessionSummary(store, runId, config?.sessionLogs); throw error; diff --git a/src/hooks/adapters/openclaw.test.ts b/src/hooks/adapters/openclaw.test.ts index c724c47..0a44345 100644 --- a/src/hooks/adapters/openclaw.test.ts +++ b/src/hooks/adapters/openclaw.test.ts @@ -15,7 +15,7 @@ function makeEvent(overrides: Partial = {}): HookEvent { hook: "onMilestone", message: "hook-message", timestamp: new Date().toISOString(), - ...overrides + ...overrides, } as HookEvent; } @@ -25,18 +25,18 @@ async function loadModuleWithMocks(options: { spawnImpl?: (...args: unknown[]) => MockChildProcess; }): Promise { mock.module("node:child_process", () => ({ - spawnSync: () => ({ status: options.binaryPresent ?? false ? 0 : 1 }), + spawnSync: () => ({ status: (options.binaryPresent ?? false) ? 0 : 1 }), spawn: (...args: unknown[]) => { if (!options.spawnImpl) { throw new Error("spawn implementation is required for this test"); } return options.spawnImpl(...args); - } + }, })); mock.module("node:fs", () => ({ - existsSync: () => options.authPresent ?? false + existsSync: () => options.authPresent ?? false, })); return await import(`./openclaw.js?test=${Math.random()}`); @@ -98,12 +98,18 @@ describe("createOpenclawHookHandler", () => { child.emit("exit", 0); }); return child; - } + }, }); const handler = module.createOpenclawHookHandler(50); - await expect(handler(makeEvent({ message: "hello" }), { cwd: process.cwd(), pid: process.pid, invokedAt: new Date().toISOString() })).resolves.toBeUndefined(); + await expect( + handler(makeEvent({ message: "hello" }), { + cwd: process.cwd(), + pid: process.pid, + invokedAt: new Date().toISOString(), + }), + ).resolves.toBeUndefined(); expect(spawnArgs[0]?.[0]).toBe("openclaw"); }); @@ -115,12 +121,14 @@ describe("createOpenclawHookHandler", () => { child.emit("exit", 1); }); return child; - } + }, }); const handler = module.createOpenclawHookHandler(50); - await expect(handler(makeEvent(), { cwd: process.cwd(), pid: process.pid, invokedAt: new Date().toISOString() })).rejects.toThrow("openclaw exited with code 1"); + await expect( + handler(makeEvent(), { cwd: process.cwd(), pid: process.pid, invokedAt: new Date().toISOString() }), + ).rejects.toThrow("openclaw exited with code 1"); }); test("rejects when spawn emits error", async () => { @@ -131,35 +139,41 @@ describe("createOpenclawHookHandler", () => { child.emit("error", new Error("binary not found")); }); return child; - } + }, }); const handler = module.createOpenclawHookHandler(50); - await expect(handler(makeEvent(), { cwd: process.cwd(), pid: process.pid, invokedAt: new Date().toISOString() })).rejects.toThrow("binary not found"); + await expect( + handler(makeEvent(), { cwd: process.cwd(), pid: process.pid, invokedAt: new Date().toISOString() }), + ).rejects.toThrow("binary not found"); }); test("rejects when spawn throws", async () => { const module = await loadModuleWithMocks({ spawnImpl: () => { throw new Error("spawn exploded"); - } + }, }); const handler = module.createOpenclawHookHandler(50); - await expect(handler(makeEvent(), { cwd: process.cwd(), pid: process.pid, invokedAt: new Date().toISOString() })).rejects.toThrow("spawn exploded"); + await expect( + handler(makeEvent(), { cwd: process.cwd(), pid: process.pid, invokedAt: new Date().toISOString() }), + ).rejects.toThrow("spawn exploded"); }); test("kills process and rejects on timeout", async () => { const child = new MockChildProcess(); const module = await loadModuleWithMocks({ - spawnImpl: () => child + spawnImpl: () => child, }); const handler = module.createOpenclawHookHandler(5); - await expect(handler(makeEvent(), { cwd: process.cwd(), pid: process.pid, invokedAt: new Date().toISOString() })).rejects.toThrow("openclaw timed out after 5ms"); + await expect( + handler(makeEvent(), { cwd: process.cwd(), pid: process.pid, invokedAt: new Date().toISOString() }), + ).rejects.toThrow("openclaw timed out after 5ms"); expect(child.kill).toHaveBeenCalledWith("SIGKILL"); }); }); diff --git a/src/hooks/adapters/openclaw.ts b/src/hooks/adapters/openclaw.ts index 5fae264..62b6aba 100644 --- a/src/hooks/adapters/openclaw.ts +++ b/src/hooks/adapters/openclaw.ts @@ -34,8 +34,7 @@ export function detectOpenclawAvailability(): { if (binary || auth) { return { available: false, - warning: - "OpenClaw detection is partial (binary/auth mismatch). Falling back to stdout hooks." + warning: "OpenClaw detection is partial (binary/auth mismatch). Falling back to stdout hooks.", }; } @@ -67,13 +66,9 @@ export function createOpenclawHookHandler(timeoutMs = 10_000): HookHandler { }; try { - child = spawn( - "openclaw", - ["system", "event", "--text", event.message, "--mode", "now"], - { - stdio: "ignore" - } - ); + child = spawn("openclaw", ["system", "event", "--text", event.message, "--mode", "now"], { + stdio: "ignore", + }); } catch (error) { settleReject(error); return; @@ -81,9 +76,7 @@ export function createOpenclawHookHandler(timeoutMs = 10_000): HookHandler { const timeout = setTimeout(() => { child.kill("SIGKILL"); - settleReject( - new Error(`openclaw timed out after ${timeoutMs}ms while handling hook ${event.hook}`) - ); + settleReject(new Error(`openclaw timed out after ${timeoutMs}ms while handling hook ${event.hook}`)); }, timeoutMs); child.on("error", (error) => { diff --git a/src/hooks/adapters/stdout.test.ts b/src/hooks/adapters/stdout.test.ts index 0fea089..c6be627 100644 --- a/src/hooks/adapters/stdout.test.ts +++ b/src/hooks/adapters/stdout.test.ts @@ -14,7 +14,7 @@ describe("createStdoutHookHandler", () => { timestamp: "2026-02-19T00:00:00.000Z", taskId: "t1", taskName: "task one", - metadata: { retries: 1 } + metadata: { retries: 1 }, }; const handler = createStdoutHookHandler("[test-hook]", (line) => { @@ -33,7 +33,7 @@ describe("createStdoutHookHandler", () => { timestamp: "2026-02-19T00:00:00.000Z", message: "task finished", error: undefined, - metadata: { retries: 1 } + metadata: { retries: 1 }, }); }); }); diff --git a/src/hooks/adapters/stdout.ts b/src/hooks/adapters/stdout.ts index afc8b07..01d0bc6 100644 --- a/src/hooks/adapters/stdout.ts +++ b/src/hooks/adapters/stdout.ts @@ -12,7 +12,7 @@ export function createStdoutHookHandler(prefix = "[hook]", write: StdoutWrite = timestamp: event.timestamp, message: event.message, error: event.error, - metadata: event.metadata + metadata: event.metadata, }; write(JSON.stringify(line)); diff --git a/src/hooks/dispatcher.test.ts b/src/hooks/dispatcher.test.ts index a535aaa..e1a11ae 100644 --- a/src/hooks/dispatcher.test.ts +++ b/src/hooks/dispatcher.test.ts @@ -12,7 +12,7 @@ function makeEvent(overrides: Partial = {}): HookEvent { hook: "onMilestone", message: "hook-message", timestamp: new Date().toISOString(), - ...overrides + ...overrides, } as HookEvent; } @@ -62,8 +62,8 @@ describe("HookDispatcher", () => { const outputPath = path.join(tempDir, "hook-output.txt"); const dispatcher = new HookDispatcher({ commandHooks: { - onFindings: `node -e 'const fs=require("node:fs"); let input=""; process.stdin.on("data",(d)=>input+=d); process.stdin.on("end",()=>{ const p=JSON.parse(input||"{}"); fs.writeFileSync("${outputPath}", [p.hook,p.message,p.runId,p.taskId,p.metadata?.stage,p.metadata?.findingsCount,p.metadata?.findingsSummary,p.metadata?.cycleIndex,String(process.env.ORCA_HOOK),String(process.env.ORCA_MSG),String(process.env.ORCA_RUN_ID),String(process.env.ORCA_TASK_ID),String(process.env.ORCA_TASK_NAME),String(process.env.ORCA_ERROR)].join("|")); });'` - } + onFindings: `node -e 'const fs=require("node:fs"); let input=""; process.stdin.on("data",(d)=>input+=d); process.stdin.on("end",()=>{ const p=JSON.parse(input||"{}"); fs.writeFileSync("${outputPath}", [p.hook,p.message,p.runId,p.taskId,p.metadata?.stage,p.metadata?.findingsCount,p.metadata?.findingsSummary,p.metadata?.cycleIndex,String(process.env.ORCA_HOOK),String(process.env.ORCA_MSG),String(process.env.ORCA_RUN_ID),String(process.env.ORCA_TASK_ID),String(process.env.ORCA_TASK_NAME),String(process.env.ORCA_ERROR)].join("|")); });'`, + }, }); await dispatcher.dispatch( @@ -71,12 +71,14 @@ describe("HookDispatcher", () => { hook: "onFindings", message: "hello $(whoami) && keep-literal", taskId: "task-9", - metadata: { stage: "review", findingsCount: 2, findingsSummary: "fix lint", cycleIndex: 1 } - }) + metadata: { stage: "review", findingsCount: 2, findingsSummary: "fix lint", cycleIndex: 1 }, + }), ); const output = await fs.readFile(outputPath, "utf8"); - expect(output).toBe("onFindings|hello $(whoami) && keep-literal|run-1000-abcd|task-9|review|2|fix lint|1|undefined|undefined|undefined|undefined|undefined|undefined"); + expect(output).toBe( + "onFindings|hello $(whoami) && keep-literal|run-1000-abcd|task-9|review|2|fix lint|1|undefined|undefined|undefined|undefined|undefined|undefined", + ); }); test("handler error triggers onError", async () => { @@ -138,14 +140,14 @@ describe("HookDispatcher", () => { const dispatcher = new HookDispatcher({ commandHooks: { onMilestone: "exit 1", - onError: `node -e 'const fs=require("node:fs"); let input=""; process.stdin.on("data",(d)=>input+=d); process.stdin.on("end",()=>{ const p=JSON.parse(input||"{}"); fs.writeFileSync("${outputPath}", [p.message,p.runId,p.taskId].join("|")); });'` - } + onError: `node -e 'const fs=require("node:fs"); let input=""; process.stdin.on("data",(d)=>input+=d); process.stdin.on("end",()=>{ const p=JSON.parse(input||"{}"); fs.writeFileSync("${outputPath}", [p.message,p.runId,p.taskId].join("|")); });'`, + }, }); await dispatcher.dispatch( makeEvent({ - taskId: "task-7" - }) + taskId: "task-7", + }), ); const output = await fs.readFile(outputPath, "utf8"); @@ -155,16 +157,16 @@ describe("HookDispatcher", () => { test("early command exit with large payload does not crash dispatch", async () => { const dispatcher = new HookDispatcher({ commandHooks: { - onMilestone: "node -e 'process.exit(0)'" - } + onMilestone: "node -e 'process.exit(0)'", + }, }); await expect( dispatcher.dispatch( makeEvent({ - message: "x".repeat(2 * 1024 * 1024) - }) - ) + message: "x".repeat(2 * 1024 * 1024), + }), + ), ).resolves.toBeUndefined(); }); }); diff --git a/src/hooks/dispatcher.ts b/src/hooks/dispatcher.ts index a29cef5..9314cfc 100644 --- a/src/hooks/dispatcher.ts +++ b/src/hooks/dispatcher.ts @@ -1,13 +1,7 @@ import { spawn } from "node:child_process"; import type { HookDispatchOptions } from "./types.js"; -import type { - HookEvent, - HookEventMap, - HookHandler, - HookHandlerContext, - HookName -} from "../types/index.js"; +import type { HookEvent, HookEventMap, HookHandler, HookHandlerContext, HookName } from "../types/index.js"; const DEFAULT_TIMEOUT_MS = 10_000; @@ -18,7 +12,7 @@ async function withTimeout(promise: Promise, timeoutMs: number): Promise((_, reject) => { timeout = setTimeout(() => reject(new Error(`Hook timeout after ${timeoutMs}ms`)), timeoutMs); - }) + }), ]); } finally { if (timeout) { @@ -48,7 +42,7 @@ export class HookDispatcher { const context: HookHandlerContext = { cwd: process.cwd(), pid: process.pid, - invokedAt: new Date().toISOString() + invokedAt: new Date().toISOString(), }; for (const handler of handlers) { @@ -74,7 +68,7 @@ export class HookDispatcher { const child = spawn(command, { shell: true, env: process.env, - stdio: ["pipe", "ignore", "pipe"] + stdio: ["pipe", "ignore", "pipe"], }); let settled = false; @@ -129,8 +123,8 @@ export class HookDispatcher { const details = stderr.trim(); reject( new Error( - `Hook command failed (${signal ? `signal=${signal}` : `exit=${code ?? "unknown"}`})${details ? `: ${details}` : ""}` - ) + `Hook command failed (${signal ? `signal=${signal}` : `exit=${code ?? "unknown"}`})${details ? `: ${details}` : ""}`, + ), ); }); @@ -160,12 +154,12 @@ export class HookDispatcher { error: error instanceof Error ? error.message : String(error), ...(sourceEvent.taskId ? { taskId: sourceEvent.taskId } : {}), ...(sourceEvent.taskName ? { taskName: sourceEvent.taskName } : {}), - ...(sourceEvent.metadata ? { metadata: sourceEvent.metadata } : {}) + ...(sourceEvent.metadata ? { metadata: sourceEvent.metadata } : {}), }; const context: HookHandlerContext = { cwd: process.cwd(), pid: process.pid, - invokedAt: new Date().toISOString() + invokedAt: new Date().toISOString(), }; for (const handler of onErrorHandlers) { diff --git a/src/index.ts b/src/index.ts index 7a87f1f..3476a79 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,14 +1,32 @@ -export { defineOrcaConfig } from "./types/index.js"; +export { + ClaudeConfigSchema, + CodexConfigSchema, + OrcaConfigSchema, + PlannerConfigSchema, + PlannerRouterConfigSchema, + ReviewConfigSchema, + customModel, + defineOrcaConfig, +} from "./types/index.js"; export type { BaseHookEvent, + ClaudeEffort, + ClaudeModelId, + CustomModelId, HookEvent, HookEventMap, HookHandler, HookHandlerContext, HookName, + OpenAIModelId, OrcaConfig, PlanResult, + PlannerAgent, + PlannerAgentSelection, + PlannerConfig, + PlannerRouterConfig, + PlannerRoutingDecision, RunId, RunStatus, Spec, @@ -16,5 +34,5 @@ export type { TaskExecutionResult, TaskGraphReviewOperation, TaskGraphReviewResult, - TaskStatus + TaskStatus, } from "./types/index.js"; diff --git a/src/state/schema.ts b/src/state/schema.ts index bc4ac03..d17ce96 100644 --- a/src/state/schema.ts +++ b/src/state/schema.ts @@ -1,12 +1,6 @@ import { z } from "zod"; -const TaskStatusSchema = z.enum([ - "pending", - "in_progress", - "done", - "failed", - "cancelled" -]); +const TaskStatusSchema = z.enum(["pending", "in_progress", "done", "failed", "cancelled"]); export const TaskSchema = z.object({ id: z.string(), @@ -19,18 +13,18 @@ export const TaskSchema = z.object({ maxRetries: z.number().int().nonnegative(), startedAt: z.string().optional(), finishedAt: z.string().optional(), - lastError: z.string().optional() + lastError: z.string().optional(), }); const ErrorEntrySchema = z.object({ at: z.string(), message: z.string(), - taskId: z.string().optional() + taskId: z.string().optional(), }); const PendingQuestionOptionSchema = z.object({ label: z.string(), - description: z.string() + description: z.string(), }); const PendingQuestionPromptSchema = z.object({ @@ -39,7 +33,7 @@ const PendingQuestionPromptSchema = z.object({ question: z.string(), isOther: z.boolean(), isSecret: z.boolean(), - options: z.array(PendingQuestionOptionSchema).nullable().optional() + options: z.array(PendingQuestionOptionSchema).nullable().optional(), }); const PendingQuestionSchema = z.object({ @@ -48,7 +42,7 @@ const PendingQuestionSchema = z.object({ turnId: z.string(), itemId: z.string(), receivedAt: z.string(), - questions: z.array(PendingQuestionPromptSchema) + questions: z.array(PendingQuestionPromptSchema), }); const PrStatusSchema = z.object({ @@ -56,7 +50,7 @@ const PrStatusSchema = z.object({ draftBody: z.string().optional(), readyForFinalize: z.boolean(), finalizedAt: z.string().optional(), - url: z.string().optional() + url: z.string().optional(), }); export const RunStatusSchema = z.object({ @@ -66,19 +60,12 @@ export const RunStatusSchema = z.object({ specPath: z.string(), createdAt: z.string(), updatedAt: z.string(), - overallStatus: z.enum([ - "planning", - "running", - "waiting_for_answer", - "completed", - "failed", - "cancelled" - ]), + overallStatus: z.enum(["planning", "running", "reviewing", "waiting_for_answer", "completed", "failed", "cancelled"]), tasks: z.array(TaskSchema), milestones: z.array(z.string()), errors: z.array(ErrorEntrySchema), pendingQuestion: PendingQuestionSchema.optional(), - pr: PrStatusSchema.optional() + pr: PrStatusSchema.optional(), }); export type TaskSchemaType = z.infer; diff --git a/src/state/store.test.ts b/src/state/store.test.ts index d0f44d4..1051acf 100644 --- a/src/state/store.test.ts +++ b/src/state/store.test.ts @@ -46,13 +46,16 @@ describe("RunStore", () => { await store.createRun(runId, "/tmp/spec.md"); const updated = await store.updateRun(runId, { overallStatus: "running", - milestones: ["started"] + milestones: ["started"], }); const statusPath = path.join(store.getRunDir(runId), "status.json"); const tmpPath = `${statusPath}.tmp`; - const tmpExists = await fs.access(tmpPath).then(() => true).catch(() => false); + const tmpExists = await fs + .access(tmpPath) + .then(() => true) + .catch(() => false); expect(updated.overallStatus).toBe("running"); expect(updated.milestones).toEqual(["started"]); diff --git a/src/state/store.ts b/src/state/store.ts index f243fdf..d2fc590 100644 --- a/src/state/store.ts +++ b/src/state/store.ts @@ -25,7 +25,7 @@ export class RunStore { overallStatus: "planning", tasks: [], milestones: [], - errors: [] + errors: [], }; await fs.mkdir(runDir, { recursive: true }); @@ -58,7 +58,7 @@ export class RunStore { const next = RunStatusSchema.parse({ ...existing, ...patch, - updatedAt: new Date().toISOString() + updatedAt: new Date().toISOString(), }) as RunStatus; await this.writeJsonAtomic(this.getStatusPath(runId), next); diff --git a/src/types/config-typing.typecheck.ts b/src/types/config-typing.typecheck.ts index 6450177..e8d4e7d 100644 --- a/src/types/config-typing.typecheck.ts +++ b/src/types/config-typing.typecheck.ts @@ -1,6 +1,18 @@ -import { defineOrcaConfig } from "./index.js"; +import { OrcaConfigSchema, customModel, defineOrcaConfig } from "./index.js"; defineOrcaConfig({ + planner: { + agent: "auto", + router: { + model: "gpt-5.3-codex-spark", + }, + }, + claude: { + command: "claude", + model: "claude-opus-4-7", + effort: "high", + timeoutMs: 300000, + }, hooks: { onQuestion: async (event) => { const questionId: string = event.questions[0]?.id ?? ""; @@ -17,15 +29,62 @@ defineOrcaConfig({ onError: async (event) => { const errorMessage: string = event.error; void errorMessage; - } - } + }, + }, +}); + +const parsedConfig = OrcaConfigSchema.parse({ + planner: { + agent: "auto", + router: { model: "gpt-5.3-codex-spark" }, + }, +}); + +defineOrcaConfig(parsedConfig); + +defineOrcaConfig({ + planner: { + agent: "claude", + }, +}); + +defineOrcaConfig({ + planner: { + agent: "codex", + }, +}); + +defineOrcaConfig({ + planner: { + agent: "claude", + // @ts-expect-error router is only valid when planner.agent is auto + router: { + model: "gpt-5.3-codex-spark", + }, + }, +}); + +defineOrcaConfig({ + codex: { + model: customModel("private-openai-model"), + }, + claude: { + model: customModel("private-claude-model"), + }, +}); + +defineOrcaConfig({ + codex: { + // @ts-expect-error unknown OpenAI models must use customModel() + model: "private-openai-model", + }, }); defineOrcaConfig({ hooks: { // @ts-expect-error unknown hook key should be rejected by types - onMystery: async () => {} - } + onMystery: async () => {}, + }, }); defineOrcaConfig({ @@ -34,6 +93,6 @@ defineOrcaConfig({ // @ts-expect-error onMilestone does not guarantee taskId const mustExist: string = event.taskId; void mustExist; - } - } + }, + }, }); diff --git a/src/types/effort.ts b/src/types/effort.ts index d853eb4..a20e53c 100644 --- a/src/types/effort.ts +++ b/src/types/effort.ts @@ -6,11 +6,7 @@ function formatAllowed(values: readonly string[]): string { return values.map((value) => `'${value}'`).join(", "); } -function parseEffort( - raw: string, - allowed: T, - label: string, -): T[number] { +function parseEffort(raw: string, allowed: T, label: string): T[number] { if ((allowed as readonly string[]).includes(raw)) { return raw as T[number]; } diff --git a/src/types/index.ts b/src/types/index.ts index 4a55b02..9c54b81 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,4 +1,6 @@ -import type { CodexEffort } from "./effort.js"; +import { z } from "zod"; + +import { CODEX_EFFORT_VALUES, parseCodexEffort, type CodexEffort } from "./effort.js"; export type RunId = `${string}-${number}-${string}`; @@ -10,12 +12,7 @@ export interface Spec { createdAt: string; } -export type TaskStatus = - | "pending" - | "in_progress" - | "done" - | "failed" - | "cancelled"; +export type TaskStatus = "pending" | "in_progress" | "done" | "failed" | "cancelled"; export interface Task { id: string; @@ -38,13 +35,7 @@ export interface RunStatus { specPath: string; createdAt: string; updatedAt: string; - overallStatus: - | "planning" - | "running" - | "waiting_for_answer" - | "completed" - | "failed" - | "cancelled"; + overallStatus: "planning" | "running" | "reviewing" | "waiting_for_answer" | "completed" | "failed" | "cancelled"; tasks: Task[]; milestones: string[]; errors: Array<{ at: string; message: string; taskId?: string }>; @@ -81,6 +72,12 @@ export interface PendingQuestion { questions: PendingQuestionPrompt[]; } +export interface PendingAnswerChannel { + transport: "ipc"; + path: string; + token: string; +} + export interface BaseHookEvent { runId: RunId; message: string; @@ -121,7 +118,7 @@ export interface HookHandlerContext { export type HookHandler = ( event: HookEventMap[K], - context: HookHandlerContext + context: HookHandlerContext, ) => Promise | void; // Shared agent result types (used by codex session adapters) @@ -138,38 +135,118 @@ export interface TaskExecutionResult { export type TaskGraphReviewOperation = | { - op: "update_task"; - taskId: string; - fields: { - name?: string; - description?: string; - acceptance_criteria?: string[]; - }; - } + op: "update_task"; + taskId: string; + fields: { + name?: string; + description?: string; + acceptance_criteria?: string[]; + }; + } | { - op: "add_task"; - task: Task; - } + op: "add_task"; + task: Task; + } | { - op: "remove_task"; - taskId: string; - } + op: "remove_task"; + taskId: string; + } | { - op: "add_dependency"; - taskId: string; - dependsOn: string; - } + op: "add_dependency"; + taskId: string; + dependsOn: string; + } | { - op: "remove_dependency"; - taskId: string; - dependsOn: string; - }; + op: "remove_dependency"; + taskId: string; + dependsOn: string; + }; export interface TaskGraphReviewResult { changes: TaskGraphReviewOperation[]; rawResponse: string; } +export type PlannerAgent = "codex" | "claude"; +export type PlannerAgentSelection = PlannerAgent | "auto"; +export type ClaudeEffort = CodexEffort | "max"; + +declare const CUSTOM_MODEL_ID: unique symbol; +export type CustomModelId = string & { readonly [CUSTOM_MODEL_ID]: true }; + +export type OpenAIModelId = + | "gpt-5.5" + | "gpt-5.2" + | "gpt-5.2-pro" + | "gpt-5.2-codex" + | "gpt-5.1" + | "gpt-5.1-codex" + | "gpt-5.1-codex-max" + | "gpt-5.1-codex-mini" + | "gpt-5" + | "gpt-5-codex" + | "gpt-5.3-codex" + | "gpt-5.3-codex-spark" + | CustomModelId; + +export type ClaudeModelId = + | "claude-opus-4-7" + | "claude-sonnet-4-6" + | "claude-opus-4-1" + | "claude-opus-4-1-20250805" + | "claude-opus-4-0" + | "claude-opus-4-20250514" + | "claude-sonnet-4-0" + | "claude-sonnet-4-20250514" + | "claude-3-7-sonnet-latest" + | "claude-3-7-sonnet-20250219" + | "claude-3-5-sonnet-latest" + | "claude-3-5-sonnet-20241022" + | "claude-3-5-haiku-latest" + | "claude-3-5-haiku-20241022" + | "opus" + | "sonnet" + | "haiku" + | "opusplan" + | "default" + | CustomModelId; + +export interface PlannerRoutingDecision { + planner: PlannerAgent; + reason: string; +} + +export interface PlannerRouterConfig { + /** + * Codex model used only for planner.agent="auto" routing. + */ + model?: OpenAIModelId; +} + +export type PlannerConfig = + | { + /** + * Ask a Codex router to choose Claude or Codex for task graph generation. + * This is also the default when planner is omitted. + */ + agent?: "auto"; + router?: PlannerRouterConfig; + } + | { + /** + * Always use Claude for non-skipped task graph generation. + */ + agent: "claude"; + router?: never; + } + | { + /** + * Always use Codex for non-skipped task graph generation. + */ + agent: "codex"; + router?: never; + }; + export interface OrcaConfig { openaiApiKey?: string; runsDir?: string; @@ -177,9 +254,16 @@ export interface OrcaConfig { skills?: string[]; maxRetries?: number; executor?: "codex"; + planner?: PlannerConfig; + claude?: { + command?: string; + model?: ClaudeModelId; + effort?: ClaudeEffort; + timeoutMs?: number; + }; codex?: { enabled?: boolean; - model?: string; + model?: OpenAIModelId; effort?: CodexEffort; thinkingLevel?: { decision?: CodexEffort; @@ -227,6 +311,227 @@ export interface OrcaConfig { }; } +const scalarStringSchema = z.custom((value) => typeof value === "string", { + message: "must be a string", +}); +const scalarBooleanSchema = z.custom((value) => typeof value === "boolean", { + message: "must be a boolean", +}); +const positiveIntegerSchema = z.custom( + (value) => typeof value === "number" && Number.isInteger(value) && value >= 1, + { message: "must be an integer >= 1" }, +); +const nonnegativeIntegerSchema = z.custom( + (value) => typeof value === "number" && Number.isInteger(value) && value >= 0, + { message: "must be a nonnegative integer" }, +); + +const openAIModelSchema = scalarStringSchema as z.ZodType; +const claudeModelSchema = scalarStringSchema as z.ZodType; + +const codexEffortSchema = z + .custom((value) => typeof value === "string", { + message: "must be a string", + }) + .transform((value, context): CodexEffort => { + try { + return parseCodexEffort(value); + } catch (error) { + context.addIssue({ + code: "custom", + message: error instanceof Error ? error.message : String(error), + }); + + return z.NEVER; + } + }); + +const claudeEffortSchema = z.custom( + (value) => typeof value === "string" && ([...CODEX_EFFORT_VALUES, "max"] as string[]).includes(value), + { message: "must be one of low, medium, high, xhigh, max" }, +); + +const hookNameValues = [ + "onMilestone", + "onQuestion", + "onTaskComplete", + "onTaskFail", + "onInvalidPlan", + "onFindings", + "onComplete", + "onError", +] as const satisfies readonly HookName[]; + +const hookNameSet = new Set(hookNameValues); + +function addUnknownHookIssue(context: z.RefinementCtx, configPath: "hooks" | "hookCommands", hookName: string) { + context.addIssue({ + code: "custom", + path: [hookName], + message: `Unknown hook key in Config.${configPath}: ${hookName}. Allowed hooks: ${hookNameValues.join(", ")}`, + }); +} + +const hookHandlerSchema = z.custom((value) => typeof value === "function", { + message: "must be a function", +}); + +const hooksSchema = z.record(z.string(), hookHandlerSchema).superRefine((hooks, context) => { + for (const hookName of Object.keys(hooks)) { + if (!hookNameSet.has(hookName)) { + addUnknownHookIssue(context, "hooks", hookName); + } + } +}) as z.ZodType; + +const hookCommandsSchema = z.record(z.string(), scalarStringSchema).superRefine((hookCommands, context) => { + for (const hookName of Object.keys(hookCommands)) { + if (!hookNameSet.has(hookName)) { + addUnknownHookIssue(context, "hookCommands", hookName); + } + } +}) as z.ZodType; + +export const PlannerRouterConfigSchema = z + .object({ + model: openAIModelSchema.optional(), + }) + .passthrough() as z.ZodType; + +export const PlannerConfigSchema = z + .object({ + agent: z + .enum(["auto", "codex", "claude"], { + message: "must be 'auto', 'codex', or 'claude'", + }) + .optional(), + router: PlannerRouterConfigSchema.optional(), + }) + .passthrough() + .superRefine((planner, context) => { + if ((planner.agent === "claude" || planner.agent === "codex") && planner.router !== undefined) { + context.addIssue({ + code: "custom", + path: ["router"], + message: `Config.planner.router is only used when Config.planner.agent is 'auto'. Remove router for forced '${planner.agent}' planning.`, + }); + } + }) as z.ZodType; + +const codexThinkingLevelSchema = z + .object({ + decision: codexEffortSchema.optional(), + planning: codexEffortSchema.optional(), + review: codexEffortSchema.optional(), + execution: codexEffortSchema.optional(), + }) + .passthrough(); + +export const CodexConfigSchema = z + .object({ + enabled: scalarBooleanSchema.optional(), + model: openAIModelSchema.optional(), + effort: codexEffortSchema.optional(), + thinkingLevel: codexThinkingLevelSchema.optional(), + command: scalarStringSchema.optional(), + timeoutMs: positiveIntegerSchema.optional(), + multiAgent: scalarBooleanSchema.optional(), + perCwdExtraUserRoots: z + .array( + z + .object({ + cwd: scalarStringSchema, + extraUserRoots: z.array(scalarStringSchema), + }) + .passthrough(), + ) + .optional(), + }) + .passthrough() + .superRefine((codex, context) => { + if ("thinking" in codex && codex.thinking !== undefined) { + context.addIssue({ + code: "custom", + path: ["thinking"], + message: "Config.codex.thinking is no longer supported. Use Config.codex.thinkingLevel instead.", + }); + } + }) as z.ZodType; + +export const ClaudeConfigSchema = z + .object({ + command: scalarStringSchema.optional(), + model: claudeModelSchema.optional(), + effort: claudeEffortSchema.optional(), + timeoutMs: positiveIntegerSchema.optional(), + }) + .passthrough() as z.ZodType; + +const reviewOnInvalidSchema = z.enum(["fail", "warn_skip"], { + message: "must be 'fail' or 'warn_skip'", +}); +const reviewOnFindingsSchema = z.enum(["auto_fix", "report_only", "fail"], { + message: "must be 'auto_fix', 'report_only', or 'fail'", +}); + +export const ReviewConfigSchema = z + .object({ + enabled: scalarBooleanSchema.optional(), + onInvalid: reviewOnInvalidSchema.optional(), + plan: z + .object({ + enabled: scalarBooleanSchema.optional(), + onInvalid: reviewOnInvalidSchema.optional(), + }) + .passthrough() + .optional(), + execution: z + .object({ + enabled: scalarBooleanSchema.optional(), + maxCycles: positiveIntegerSchema.optional(), + onFindings: reviewOnFindingsSchema.optional(), + validator: z + .object({ + auto: scalarBooleanSchema.optional(), + commands: z.array(scalarStringSchema).optional(), + }) + .passthrough() + .optional(), + prompt: scalarStringSchema.optional(), + }) + .passthrough() + .optional(), + }) + .passthrough() as z.ZodType; + +export const OrcaConfigSchema = z + .object({ + openaiApiKey: scalarStringSchema.optional(), + runsDir: scalarStringSchema.optional(), + sessionLogs: scalarStringSchema.optional(), + skills: z.array(scalarStringSchema).optional(), + maxRetries: nonnegativeIntegerSchema.optional(), + executor: z.literal("codex").optional(), + planner: PlannerConfigSchema.optional(), + claude: ClaudeConfigSchema.optional(), + codex: CodexConfigSchema.optional(), + hooks: hooksSchema.optional(), + hookCommands: hookCommandsSchema.optional(), + pr: z + .object({ + enabled: scalarBooleanSchema.optional(), + requireConfirmation: scalarBooleanSchema.optional(), + }) + .passthrough() + .optional(), + review: ReviewConfigSchema.optional(), + }) + .passthrough(); + export function defineOrcaConfig(config: OrcaConfig): OrcaConfig { return config; } + +export function customModel(id: T): CustomModelId { + return id as unknown as CustomModelId; +} diff --git a/src/utils/agent-json.test.ts b/src/utils/agent-json.test.ts index 399fbab..4e4bfb5 100644 --- a/src/utils/agent-json.test.ts +++ b/src/utils/agent-json.test.ts @@ -8,17 +8,17 @@ describe("parseAgentJson", () => { }); test("parses json fenced block", () => { - const input = "```json\n[{\"id\":\"1\"}]\n```"; + const input = '```json\n[{"id":"1"}]\n```'; expect(parseAgentJson(input)).toEqual([{ id: "1" }]); }); test("parses generic fenced block", () => { - const input = "```\n{\"outcome\":\"done\"}\n```"; + const input = '```\n{"outcome":"done"}\n```'; expect(parseAgentJson(input)).toEqual({ outcome: "done" }); }); test("parses first json payload from surrounding prose", () => { - const input = "I finished the task. Result below:\n\n{\"outcome\":\"done\"}\n\nThanks!"; + const input = 'I finished the task. Result below:\n\n{"outcome":"done"}\n\nThanks!'; expect(parseAgentJson(input)).toEqual({ outcome: "done" }); }); diff --git a/src/utils/agent-json.ts b/src/utils/agent-json.ts index 6000b2f..1903a21 100644 --- a/src/utils/agent-json.ts +++ b/src/utils/agent-json.ts @@ -81,10 +81,7 @@ function extractFirstJsonObjectOrArray(text: string): string | null { export function parseAgentJson(raw: string): unknown { const text = raw.trim(); - const candidates = [ - text, - ...extractFencedCandidates(text), - ]; + const candidates = [text, ...extractFencedCandidates(text)]; const extracted = extractFirstJsonObjectOrArray(text); if (extracted) { diff --git a/src/utils/gh.ts b/src/utils/gh.ts index 61e865b..ad6d375 100644 --- a/src/utils/gh.ts +++ b/src/utils/gh.ts @@ -6,21 +6,18 @@ export interface GhResult { exitCode: number; } -function spawnProcess( - cmd: string, - args: string[] -): Promise<{ stdout: string; stderr: string; exitCode: number }> { +function spawnProcess(cmd: string, args: string[]): Promise<{ stdout: string; stderr: string; exitCode: number }> { // Use Bun.spawn when available, fall back to Node.js child_process if (typeof globalThis.Bun !== "undefined") { return (async () => { const proc = globalThis.Bun.spawn([cmd, ...args], { stdout: "pipe", - stderr: "pipe" + stderr: "pipe", }); const [stdout, stderr, exitCode] = await Promise.all([ new Response(proc.stdout).text(), new Response(proc.stderr).text(), - proc.exited + proc.exited, ]); return { stdout, stderr, exitCode }; })(); @@ -30,8 +27,12 @@ function spawnProcess( let stdout = ""; let stderr = ""; const child = nodeSpawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] }); - child.stdout?.on("data", (d: Buffer) => { stdout += d.toString(); }); - child.stderr?.on("data", (d: Buffer) => { stderr += d.toString(); }); + child.stdout?.on("data", (d: Buffer) => { + stdout += d.toString(); + }); + child.stderr?.on("data", (d: Buffer) => { + stderr += d.toString(); + }); child.on("close", (code) => resolve({ stdout, stderr, exitCode: code ?? 1 })); child.on("error", () => resolve({ stdout, stderr, exitCode: 1 })); }); diff --git a/src/utils/ids.test.ts b/src/utils/ids.test.ts index 5bfd98a..1ad9e2b 100644 --- a/src/utils/ids.test.ts +++ b/src/utils/ids.test.ts @@ -1,21 +1,21 @@ -import { test, expect, describe } from 'bun:test'; -import { generateRunId } from './ids'; +import { test, expect, describe } from "bun:test"; +import { generateRunId } from "./ids"; -describe('generateRunId', () => { - test('returns a string', () => { - expect(typeof generateRunId('specs/onboarding.md')).toBe('string'); +describe("generateRunId", () => { + test("returns a string", () => { + expect(typeof generateRunId("specs/onboarding.md")).toBe("string"); }); - test('derives slug from filename', () => { - const id = generateRunId('specs/my-feature.md'); - expect(id.startsWith('my-feature-')).toBe(true); + test("derives slug from filename", () => { + const id = generateRunId("specs/my-feature.md"); + expect(id.startsWith("my-feature-")).toBe(true); }); - test('two calls produce different IDs', () => { - const a = generateRunId('specs/test.md'); - const b = generateRunId('specs/test.md'); + test("two calls produce different IDs", () => { + const a = generateRunId("specs/test.md"); + const b = generateRunId("specs/test.md"); expect(a).not.toBe(b); }); - test('handles special chars in filename', () => { - const id = generateRunId('specs/My Feature!! v2.md'); - expect(id.startsWith('my-feature-v2-')).toBe(true); + test("handles special chars in filename", () => { + const id = generateRunId("specs/My Feature!! v2.md"); + expect(id.startsWith("my-feature-v2-")).toBe(true); }); }); diff --git a/src/utils/ids.ts b/src/utils/ids.ts index 06b01c1..f98e087 100644 --- a/src/utils/ids.ts +++ b/src/utils/ids.ts @@ -1,12 +1,12 @@ -import { randomBytes } from 'crypto'; -import { basename, extname } from 'path'; +import { randomBytes } from "crypto"; +import { basename, extname } from "path"; export function generateRunId(specPath: string): string { const slug = basename(specPath, extname(specPath)) .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, ''); + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); const ts = Date.now(); - const hex = randomBytes(2).toString('hex'); + const hex = randomBytes(2).toString("hex"); return `${slug}-${ts}-${hex}`; } diff --git a/src/utils/last-run.test.ts b/src/utils/last-run.test.ts index d3cdcba..4f7a946 100644 --- a/src/utils/last-run.test.ts +++ b/src/utils/last-run.test.ts @@ -30,10 +30,10 @@ describe("getLastRun", () => { await store.createRun("second-1001-abcd", "/tmp/spec-b.md"); await store.updateRun("first-1000-abcd", { - createdAt: "2026-01-01T00:00:00.000Z" + createdAt: "2026-01-01T00:00:00.000Z", }); await store.updateRun("second-1001-abcd", { - createdAt: "2026-01-02T00:00:00.000Z" + createdAt: "2026-01-02T00:00:00.000Z", }); const run = await getLastRun(store); @@ -47,11 +47,11 @@ describe("getLastRun", () => { await store.updateRun("older-1000-abcd", { createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-12-31T00:00:00.000Z" + updatedAt: "2026-12-31T00:00:00.000Z", }); await store.updateRun("newer-1001-abcd", { createdAt: "2026-01-02T00:00:00.000Z", - updatedAt: "2026-01-02T00:00:00.000Z" + updatedAt: "2026-01-02T00:00:00.000Z", }); const run = await getLastRun(store); diff --git a/src/utils/logger.ts b/src/utils/logger.ts index e4a1f21..6d53d5f 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -4,5 +4,5 @@ export const logger = { info: (msg: string): void => console.log(chalk.blue("orca"), msg), success: (msg: string): void => console.log(chalk.green("✓"), msg), warn: (msg: string): void => console.log(chalk.yellow("⚠"), msg), - error: (msg: string): void => console.error(chalk.red("✗"), msg) + error: (msg: string): void => console.error(chalk.red("✗"), msg), }; diff --git a/src/utils/select-run.ts b/src/utils/select-run.ts index 8d9dbfa..a2968d9 100644 --- a/src/utils/select-run.ts +++ b/src/utils/select-run.ts @@ -13,9 +13,7 @@ export async function selectRun(store: RunStore): Promise { return null; } - const sorted = [...runs].sort( - (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() - ); + const sorted = [...runs].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); const choices = sorted.map((run) => { const prStatus = run.pr?.url ? (run.pr.finalizedAt ? "published" : "draft PR") : "no PR"; diff --git a/src/utils/skill-loader.test.ts b/src/utils/skill-loader.test.ts index 70a9cd0..5e2d9f2 100644 --- a/src/utils/skill-loader.test.ts +++ b/src/utils/skill-loader.test.ts @@ -34,14 +34,14 @@ describe("skill-loader", () => { "description: Helps with builds", "---", "# Skill Body", - "Run the build command." - ].join("\n") + "Run the build command.", + ].join("\n"), ); expect(parsed).toEqual({ name: "Build Skill", description: "Helps with builds", - body: "# Skill Body\nRun the build command." + body: "# Skill Body\nRun the build command.", }); }); @@ -52,7 +52,7 @@ describe("skill-loader", () => { expect(parsed).toEqual({ name: "", description: "", - body: content + body: content, }); }); @@ -69,10 +69,8 @@ describe("skill-loader", () => { await fs.mkdir(skillDir, { recursive: true }); await fs.writeFile( path.join(skillDir, "SKILL.md"), - ["---", "name: Deploy", "description: Deploy helper", "---", "Use this for deploy steps."].join( - "\n" - ), - "utf8" + ["---", "name: Deploy", "description: Deploy helper", "---", "Use this for deploy steps."].join("\n"), + "utf8", ); const loaded = await loadSkill(skillDir); @@ -81,7 +79,7 @@ describe("skill-loader", () => { description: "Deploy helper", body: "Use this for deploy steps.", dirPath: skillDir, - filePath: path.join(skillDir, "SKILL.md") + filePath: path.join(skillDir, "SKILL.md"), }); }); @@ -122,7 +120,7 @@ describe("skill-loader", () => { await fs.writeFile( path.join(externalSkillDir, "SKILL.md"), "---\nname: Linked\ndescription: L\n---\nlinked body", - "utf8" + "utf8", ); await fs.symlink(externalSkillDir, symlinkPath, "dir"); @@ -156,7 +154,7 @@ describe("skill-loader", () => { expect(loaded).toBeNull(); expect(warnMock).toHaveBeenCalledTimes(1); expect(String((warnMock.mock.calls as unknown[][])[0]?.[0] ?? "")).toContain( - `Skipping skill at ${skillDir}: unable to read SKILL.md` + `Skipping skill at ${skillDir}: unable to read SKILL.md`, ); } finally { console.warn = originalWarn; @@ -181,22 +179,22 @@ describe("skill-loader", () => { await fs.writeFile( path.join(configSkillDir, "SKILL.md"), "---\nname: Duplicate\ndescription: from config\n---\nconfig body", - "utf8" + "utf8", ); await fs.writeFile( path.join(projectDir, ".orca", "skills", "project-dup", "SKILL.md"), "---\nname: Duplicate\ndescription: from project\n---\nproject body", - "utf8" + "utf8", ); await fs.writeFile( path.join(homeDir, ".orca", "skills", "global-dup", "SKILL.md"), "---\nname: Duplicate\ndescription: from global\n---\nglobal body", - "utf8" + "utf8", ); await fs.writeFile( path.join(projectDir, ".orca", "skills", "project-unique", "SKILL.md"), "---\nname: Unique\ndescription: unique\n---\nunique body", - "utf8" + "utf8", ); const loaded = await loadSkills({ skills: [configSkillDir] }); @@ -218,7 +216,7 @@ describe("skill-loader", () => { await fs.writeFile( path.join(overrideDir, "SKILL.md"), "---\nname: code-simplifier\ndescription: project override\n---\nproject override body", - "utf8" + "utf8", ); const loaded = await loadSkills(); diff --git a/src/utils/skill-loader.ts b/src/utils/skill-loader.ts index 24a0b41..5b0ae52 100644 --- a/src/utils/skill-loader.ts +++ b/src/utils/skill-loader.ts @@ -48,10 +48,7 @@ function parseFrontmatter(frontmatter: string): Record { } let value = line.slice(separatorIndex + 1).trim(); - if ( - (value.startsWith('"') && value.endsWith('"')) || - (value.startsWith("'") && value.endsWith("'")) - ) { + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { value = value.slice(1, -1); } @@ -68,7 +65,7 @@ export function parseSkillFile(fileContent: string): ParsedSkillFile { return { name: "", description: "", - body: fileContent + body: fileContent, }; } @@ -78,7 +75,7 @@ export function parseSkillFile(fileContent: string): ParsedSkillFile { return { name: frontmatter.name ?? "", description: frontmatter.description ?? "", - body + body, }; } @@ -109,7 +106,7 @@ export async function loadSkill(skillDirPath: string): Promise a.localeCompare(b)); const loaded = await Promise.all( - subdirectories.map((subdirName) => loadSkill(path.join(expandedDirPath, subdirName))) + subdirectories.map((subdirName) => loadSkill(path.join(expandedDirPath, subdirName))), ); return loaded.filter((skill): skill is LoadedSkill => skill !== null); @@ -175,7 +172,7 @@ export async function loadSkills(config?: OrcaConfig): Promise { const [projectSkillsRealPath, bundledSkillsRealPath] = await Promise.all([ resolveExistingRealPath(projectSkillsDir), - resolveExistingRealPath(bundledSkillsDir) + resolveExistingRealPath(bundledSkillsDir), ]); const bundledSkills = projectSkillsRealPath !== null && projectSkillsRealPath === bundledSkillsRealPath @@ -186,7 +183,7 @@ export async function loadSkills(config?: OrcaConfig): Promise { ...fromConfig.filter((skill): skill is LoadedSkill => skill !== null), ...projectSkills, ...globalSkills, - ...bundledSkills + ...bundledSkills, ]; const seenNames = new Set(); diff --git a/tsconfig.json b/tsconfig.json index 00e9ae9..d785b31 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,7 +12,8 @@ "exactOptionalPropertyTypes": true, "esModuleInterop": true, "forceConsistentCasingInFileNames": true, - "skipLibCheck": true + "skipLibCheck": true, + "types": ["node", "bun"] }, "include": ["src"], "exclude": ["src/**/*.test.ts", "src/**/*.typecheck.ts"]