
Run Agent
- 1 installs
- 19 repo stars
- Updated March 1, 2026
- haowjy/orchestrate
Launches subagent runs composed of model, agent, skills, and prompt, routing to the correct CLI and writing run artifacts.
About
A single execution engine that composes runs from a model, optional skills, and a prompt, auto-routing to claude, codex, or opencode and logging everything. A developer uses it to launch and inspect structured subagent runs.
- Auto-routes to the correct CLI based on the model
- Supports template variables, session grouping, and dry-run
Run Agent by the numbers
- 1 all-time installs (skills.sh)
- Ranked #14,102 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 2, 2026 (Skillselion catalog sync)
npx skills add https://github.com/haowjy/orchestrate --skill run-agentAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| repo stars | ★ 19 |
| Last updated | March 1, 2026 |
| Repository | haowjy/orchestrate ↗ |
What it does
Launches subagent runs composed of model, agent, skills, and prompt, routing to the correct CLI and writing run artifacts.
Files
Run-Agent — Execution Engine
Single entry point for agent execution. A run is model + agent (opt) + skills (opt) + prompt. Routes to the correct CLI (claude, codex, opencode) based on the model, logs everything, and writes structured index entries.
Skills source: sibling skills (../). Runtime artifacts: .orchestrate/.
Runner scripts (relative to this skill directory):
scripts/run-agent.sh— launch a subagent runscripts/run-index.sh— inspect and manage runs
Run Composition
Compose runs dynamically by specifying model, skills, and prompt:
# Model + skills + prompt
scripts/run-agent.sh --model MODEL --skills SKILL1,SKILL2 -p "PROMPT"
# Model + prompt (no skills)
scripts/run-agent.sh --model MODEL -p "PROMPT"
# With labels and session grouping
scripts/run-agent.sh --model MODEL --skills SKILLS \
--session SESSION_ID --label KEY=VALUE -p "PROMPT"
# With template variables
scripts/run-agent.sh --model MODEL \
-v KEY1=path/to/file1 -v KEY2=path/to/file2 \
-p "Task using {{KEY1}} and {{KEY2}}"
# Dry run — see composed prompt + CLI command without executing
scripts/run-agent.sh --model MODEL --skills SKILLS --dry-run -p "PROMPT"Key Flags
| Flag | Description |
|---|---|
--model MODEL / -m | Model to use (auto-routes to correct CLI) |
--agent NAME | Agent profile for defaults + permissions |
--skills a,b,c | Skills to compose into the prompt |
--strict-skills | Fail fast when any listed skill is unknown |
-p "prompt" | Task prompt |
-f path/to/file | Reference file appended to prompt |
-v KEY=VALUE | Template variable substitution (repeatable) |
--session ID | Session ID for grouping related runs |
--label KEY=VALUE | Run metadata label (repeatable) |
| `-D brief\ | standard\ |
--continue-run REF | Continue a previous run's harness session |
--fork | Fork the session on continuation (default where supported) |
--in-place | Resume without forking (always for Codex) |
--dry-run | Show composed prompt without executing |
-C DIR | Working directory for subprocess |
Runtime Config (.orchestrate/config.toml)
On first run in a workspace, run-agent.sh auto-creates .orchestrate/config.toml with commented examples.
Use this file to pin skills that should be auto-added on every run:
[skills]
pinned = ["orchestrate", "run-agent", "mermaid"]Notes:
- Pinned skills are merged with agent-profile skills and CLI
--skills(deduplicated by name). - Default template is fully commented; uncomment/edit to enable.
Output Artifacts
Each run writes to .orchestrate/runs/agent-runs/<run-id>/:
params.json— run parameters and metadatainput.md— composed promptprompt.raw.md— composed prompt before runtime-generated output/report sectionsoutput.jsonl— raw CLI output (stream-json or JSONL)stderr.log— CLI diagnostics (also streamed to terminal)report.md— written by the subagent (or extracted as fallback)files-touched.nul— NUL-delimited file paths (canonical machine format)files-touched.txt— newline-delimited file paths (human-readable)
Run Index
Two-row append-only index at .orchestrate/index/runs.jsonl:
- Start row (written before execution):
status: "running"— provides crash visibility. - Finalize row (written after execution):
status: "completed"|"failed"with exit code, duration, token usage, git metadata.
A start row with no matching finalize row means the run crashed or is still in progress.
Structured Exit Codes
| Code | Meaning |
|---|---|
| 0 | Success |
| 1 | Agent/model error (bad output, task failure) |
| 2 | Infrastructure error (CLI not found, harness crash) |
| 3 | Timeout |
| 130 | Interrupted (SIGINT / user cancel) |
| 143 | Terminated (SIGTERM) |
Model Routing
| Pattern | CLI |
|---|---|
claude-*, opus*, sonnet*, haiku* | Claude (claude -p) |
gpt-*, o1*, o3*, o4*, codex* | Codex (codex exec) |
opencode-*, provider/model | OpenCode (opencode run) |
Routing is automatic from the selected model.
Run Explorer CLI
scripts/run-index.sh provides index-based run inspection:
scripts/run-index.sh list # List recent runs
scripts/run-index.sh list --failed --json # Failed runs as JSON
scripts/run-index.sh show @latest # Show last run details
scripts/run-index.sh report @latest # Read last run's report
scripts/run-index.sh logs @latest --tools # Tool call summary
scripts/run-index.sh files @latest # Files touched
scripts/run-index.sh stats # Aggregate statistics
scripts/run-index.sh continue @latest -p "PROMPT" # Continue a run's session
scripts/run-index.sh retry @last-failed # Retry a failed run
scripts/run-index.sh maintain --compact # Archive old index entriesRun references: full ID, unique prefix (8+ chars), @latest, @last-failed, @last-completed.
Helper Scripts
| Script | Purpose |
|---|---|
run-index.sh | Run explorer CLI (list, show, report, logs, files, stats, continue, retry, maintain) |
log-inspect.sh | Inspect run logs (summary, tools, errors, files, search) |
extract-files-touched.sh | Extract file paths from run output |
extract-harness-session-id.sh | Extract harness session/thread ID from output |
extract-report-fallback.sh | Extract last assistant message as report fallback |
load-model-guidance.sh | Load model guidance with override precedence |
# Runtime artifacts — hidden directories are generated, never committed
.*
!.gitignore
Run-Agent — Execution Engine
Single entry point for agent execution. A run is model + skills + prompt, optionally fronted by an agent profile for defaults and permissions. Routes to the correct CLI tool, logs each run, and writes structured index entries.
Skills source: sibling skills (../). Runtime artifacts: .orchestrate/ from the repo root.
No environment variables control runtime behavior — all configuration is via explicit flags.
Runner
RUNNER=scripts/run-agent.sh
INDEX=scripts/run-index.shQuick Start
# Model + skills + prompt
"$RUNNER" --model gpt-5.3-codex --skills reviewing -p "Review these changes"
# Kill a hung harness run (default: 15 minutes)
"$RUNNER" --model claude-sonnet-4-6 --timeout 15 -p "Review these changes"
# With an agent profile
"$RUNNER" --agent reviewer -p "Review these changes"
# With labels and session grouping
"$RUNNER" --model gpt-5.3-codex --skills scratchpad \
--session my-session --label ticket=PAY-123 \
-p "Implement the feature"
# Dry run — see composed prompt without executing
"$RUNNER" --model gpt-5.3-codex --skills reviewing --dry-run -p "Review auth"
# Inspect runs
"$INDEX" list
"$INDEX" show @latest
"$INDEX" statsHow It Works
1. Parse model, skills, prompt, labels, session, and context flags 2. Route model to the correct CLI (claude, codex, opencode) 3. List selected skills by name (harnesses load skill content natively) 4. Compose the final prompt (task prompt + reference files + skill names) 5. Write start index row (crash visibility) 6. Execute the CLI command 7. Write finalize index row with exit code, duration, git metadata, token usage 8. Log artifacts to .orchestrate/runs/agent-runs/<run-id>/
Notes:
--timeoutis in minutes (supports fractional minutes) and applies to the harness subprocess; a timed-out run exits with code3.- Some harnesses can exit
0while producing unusable output; run-agent treats empty output (Claude/OpenCode) or OpenCode error events as failures.
Output Artifacts
Each run writes to .orchestrate/runs/agent-runs/<run-id>/:
params.json— run parameters and metadatainput.md— composed promptoutput.jsonl— raw CLI output (stream-json or JSONL)stderr.log— CLI diagnosticsreport.md— written by the subagent (or extracted as fallback)files-touched.nul— NUL-delimited file paths (canonical format)files-touched.txt— newline-delimited file paths (human-readable)
Index: .orchestrate/index/runs.jsonl (two rows per run: start + finalize)
Helper Scripts
| Script | Purpose |
|---|---|
run-index.sh | Run explorer CLI (list, show, report, logs, files, stats, continue, retry, maintain) |
log-inspect.sh | Inspect run logs without loading full output |
extract-files-touched.sh | Parse touched files from a run log |
extract-harness-session-id.sh | Extract harness session/thread ID from output |
extract-report-fallback.sh | Extract last assistant message as report fallback |
load-model-guidance.sh | Load model guidance with override precedence |
Tests
tests/run-agent-unit.shDefault Model Guidance
Use this default only when no custom files exist in references/model-guidance/*.md.
Baseline picks
codex as an alias for gpt-5.3-codex opus as an alias for claude-opus-4-6
- Implementation:
gpt-5.3-codex - Review (medium/high risk): fan out across model families, prefer
gpt-5.3-codexfor most reviews to be cheaper and more thorough. - Nuanced correctness/architecture:
claude-opus-4-6and/orgpt-5.2with high variant - UI/frontend loops:
claude-opus-4-6 - Lightweight commit/message tasks:
claude-haiku-4-5to help create commits for the changes
Practical rules
1. Prefer the smallest model choice that controls risk. 2. Use multiple reviewers only when risk justifies it. 3. Keep skill sets minimal and task-relevant.
Model Guidance Overrides
Add one or more *.md files in this directory to customize model guidance.
Override Behavior
Model guidance uses custom override precedence:
1. If any .md files exist here (besides this README), they are concatenated in bytewise-lexicographic filename order. 2. When custom files exist, ../default-model-guidance.md is ignored. 3. If no custom files exist, ../default-model-guidance.md is used.
Example
Create my-project.md:
## Project-Specific Model Notes
- For database migrations, prefer claude-opus-4-6 (needs careful reasoning)
- For frontend components, prefer claude-sonnet-4-6 (fast iteration)When present, this will be used instead of the default guidance.
#!/usr/bin/env bash
# Extract likely touched files from a single agent run log.
# Uses structured JSON extraction when possible, with text-pattern fallback.
#
# Usage:
# extract-files-touched.sh <output-log> [output-file] [--nul]
#
# When --nul is passed, output is NUL-delimited (canonical machine-readable format).
# Otherwise output is newline-delimited (human-readable).
set -euo pipefail
OUTPUT_LOG="${1:?Usage: extract-files-touched.sh <output-log> [output-file] [--nul]}"
OUTPUT_FILE="${2:-/dev/stdout}"
NUL_MODE=false
if [[ "${3:-}" == "--nul" ]] || [[ "${2:-}" == "--nul" ]]; then
NUL_MODE=true
# If --nul was the second arg, output goes to stdout
if [[ "${2:-}" == "--nul" ]]; then
OUTPUT_FILE="/dev/stdout"
fi
fi
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel 2>/dev/null || echo "$SCRIPT_DIR")"
TMP_TEXT="$(mktemp)"
TMP_RAW_PATHS="$(mktemp)"
TMP_SORTED="$(mktemp)"
trap 'rm -f "$TMP_TEXT" "$TMP_RAW_PATHS" "$TMP_SORTED"' EXIT
# Seed with raw log text (handles non-JSON and error output).
cat "$OUTPUT_LOG" > "$TMP_TEXT"
# If output is JSON or JSONL, flatten all string values for better extraction.
if command -v jq >/dev/null 2>&1; then
jq -r '.. | strings' "$OUTPUT_LOG" 2>/dev/null >> "$TMP_TEXT" || true
jq -Rr 'fromjson? | .. | strings' "$OUTPUT_LOG" 2>/dev/null >> "$TMP_TEXT" || true
fi
# Extract candidates from common tool/log formats.
perl -ne '
while (/\*\*\* (?:Add|Update|Delete) File:\s*([^\r\n]+)/g) { print "$1\n"; }
while (/\*\*\* Move to:\s*([^\r\n]+)/g) { print "$1\n"; }
while (/"(?:path|file_path|filepath|filename|target_file|source_file|new_path|old_path|file)"\s*:\s*"((?:\\.|[^"\\])+)"/g) {
print "$1\n";
}
while (/(?:^|[\s`\x22\x27])(\.gitignore|AGENTS\.md|CLAUDE\.md|README\.md)(?=$|[\s`\x22\x27,:;])/g) {
print "$1\n";
}
' "$TMP_TEXT" > "$TMP_RAW_PATHS"
# Normalize and filter to repo-relevant paths.
awk -v root="$REPO_ROOT/" '
{
path = $0
gsub(/\r/, "", path)
sub(/^[[:space:]]+/, "", path)
sub(/[[:space:]]+$/, "", path)
sub(/\\n.*/, "", path)
sub(/\\r.*/, "", path)
gsub(/\\\//, "/", path)
gsub(/\\\\/, "\\", path)
gsub(/^["`]+/, "", path)
gsub(/["`]+$/, "", path)
sub(/:[0-9]+(:[0-9]+)?$/, "", path)
sub(/^\.\//, "", path)
if (index(path, root) == 1) {
path = substr(path, length(root) + 1)
}
if (path == "") next
if (path ~ /^https?:\/\//) next
if (path ~ /^[A-Za-z]+:\/\//) next
if (path ~ /^\/(tmp|dev|proc|sys)\//) next
if (path ~ /[<>|*?]/) next
if (path ~ /( \(|\)$)/) next
if (path ~ /[[:space:]]/) next
if (path ~ /^(true|false|null)$/) next
if (path ~ /^\//) next
if (path ~ /^(node_modules|vendor|\.git|__pycache__|dist|build|target)\//) next
if (path ~ /\// || path ~ /\.[a-zA-Z0-9]+$/) { print path; next }
}
' "$TMP_RAW_PATHS" | sort -u > "$TMP_SORTED"
# Output in requested format
if [[ "$NUL_MODE" == true ]]; then
# NUL-delimited output
tr '\n' '\0' < "$TMP_SORTED" > "$OUTPUT_FILE"
else
cat "$TMP_SORTED" > "$OUTPUT_FILE"
fi
#!/usr/bin/env bash
# extract-harness-session-id.sh — Extract harness-native session/thread ID from run output.
#
# Usage: extract-harness-session-id.sh <harness> <output.jsonl>
# Prints the harness session ID to stdout, exits non-zero if not found.
#
# Harness-specific extraction:
# claude: session_id from type=result event (skip hook events)
# codex: thread_id from first line (thread.started event only)
# opencode: sessionID from first event
set -euo pipefail
HARNESS="${1:?Usage: extract-harness-session-id.sh <harness> <output.jsonl>}"
OUTPUT="${2:?Usage: extract-harness-session-id.sh <harness> <output.jsonl>}"
if [[ ! -f "$OUTPUT" ]] || [[ ! -s "$OUTPUT" ]]; then
exit 1
fi
# Require jq for JSON parsing
if ! command -v jq >/dev/null 2>&1; then
echo "ERROR: jq is required for session ID extraction" >&2
exit 1
fi
session_id=""
case "$HARNESS" in
claude)
# Extract from first "result" event — avoids hook events which carry different session IDs.
# Claude stream-json: each line is a JSON object with "type" field.
session_id="$(grep '"type"' "$OUTPUT" 2>/dev/null \
| jq -r 'select(.type == "result") | .session_id // empty' 2>/dev/null \
| head -1 || echo "")"
# Fallback: try system/init event
if [[ -z "$session_id" ]]; then
session_id="$(grep '"type"' "$OUTPUT" 2>/dev/null \
| jq -r 'select(.type == "system" and .subtype != "hook_started" and .subtype != "hook_response") | .session_id // empty' 2>/dev/null \
| head -1 || echo "")"
fi
;;
codex)
# thread_id only appears on the first event (thread.started).
session_id="$(head -1 "$OUTPUT" | jq -r '.thread_id // empty' 2>/dev/null || echo "")"
;;
opencode)
# sessionID is on every event; take from first line.
session_id="$(head -1 "$OUTPUT" | jq -r '.sessionID // empty' 2>/dev/null || echo "")"
;;
*)
echo "ERROR: Unknown harness: $HARNESS" >&2
exit 1
;;
esac
if [[ -z "$session_id" ]]; then
exit 1
fi
echo "$session_id"
#!/usr/bin/env bash
# extract-report-fallback.sh — Extract last assistant message from harness output as report fallback.
#
# Usage: extract-report-fallback.sh <harness> <output.jsonl> <stderr.log> <exit-code>
# Writes report content to stdout.
# Exit 0 = report extracted, Exit 1 = fallback diagnostic produced.
#
# When an agent doesn't produce report.md, this script extracts the last assistant
# message from the harness output as a best-effort report.
set -euo pipefail
HARNESS="${1:?Usage: extract-report-fallback.sh <harness> <output.jsonl> <stderr.log> <exit-code>}"
OUTPUT="${2:?}"
STDERR_LOG="${3:?}"
EXIT_CODE="${4:?}"
_emit_diagnostic() {
# Compact diagnostic when parsing fails — keep under 10 lines.
echo "# Run Report (auto-generated)"
echo ""
echo "**Status**: $([ "$EXIT_CODE" -eq 0 ] && echo "completed" || echo "failed (exit $EXIT_CODE)")"
if [[ -f "$OUTPUT" ]] && [[ -s "$OUTPUT" ]]; then
local line_count
line_count="$(wc -l < "$OUTPUT" 2>/dev/null || echo "0")"
echo "**Output lines**: $line_count"
fi
if [[ -f "$STDERR_LOG" ]] && [[ -s "$STDERR_LOG" ]]; then
echo ""
echo "**Last error**:"
echo '```'
tail -3 "$STDERR_LOG" 2>/dev/null || true
echo '```'
fi
}
# Require jq
if ! command -v jq >/dev/null 2>&1; then
_emit_diagnostic
exit 1
fi
if [[ ! -f "$OUTPUT" ]] || [[ ! -s "$OUTPUT" ]]; then
_emit_diagnostic
exit 1
fi
last_message=""
case "$HARNESS" in
claude)
# Claude stream-json: look for assistant content blocks.
# The last "result" event contains the final assistant message.
last_message="$(grep '"type"' "$OUTPUT" 2>/dev/null \
| jq -r '
select(.type == "result")
| .result.text // .result.content // empty
| if type == "array" then
[.[] | select(.type == "text") | .text] | join("\n")
elif type == "string" then .
else empty
end
' 2>/dev/null \
| tail -1 || echo "")"
# Fallback: try content_block_delta events for streaming text
if [[ -z "$last_message" ]]; then
last_message="$(grep '"type"' "$OUTPUT" 2>/dev/null \
| jq -r 'select(.type == "assistant") | .message.content // empty | if type == "array" then [.[] | select(.type == "text") | .text] | join("\n") elif type == "string" then . else empty end' 2>/dev/null \
| tail -1 || echo "")"
fi
;;
codex)
# Codex JSONL: look for item.completed events with assistant messages.
last_message="$(grep '"type"' "$OUTPUT" 2>/dev/null \
| jq -r '
select(.type == "item.completed")
| .item
| select(.role == "assistant" or .type == "message")
| .content // empty
| if type == "array" then
[.[] | select(.type == "text" or .type == "output_text") | (.text // .output_text // empty)] | join("\n")
elif type == "string" then .
else empty
end
' 2>/dev/null \
| tail -1 || echo "")"
;;
opencode)
# OpenCode JSON events: look for assistant responses.
last_message="$(grep '"type"' "$OUTPUT" 2>/dev/null \
| jq -r '
select(.type == "assistant" or .type == "response")
| .content // .text // .message // empty
| if type == "array" then
[.[] | select(.type == "text") | .text] | join("\n")
elif type == "string" then .
else empty
end
' 2>/dev/null \
| tail -1 || echo "")"
;;
*)
_emit_diagnostic
exit 1
;;
esac
if [[ -n "$last_message" ]]; then
echo "$last_message"
exit 0
else
_emit_diagnostic
exit 1
fi
#!/usr/bin/env bash
# lib/exec.sh — CLI command building (argv array), execution, structured exit codes.
# Sourced by run-agent.sh; expects globals from the entrypoint.
# ─── Structured Exit Codes ────────────────────────────────────────────────────
# 0 = success, 1 = agent error, 2 = infra error, 3 = timeout, 130 = SIGINT, 143 = SIGTERM
# ─── Build CLI Command (argv array) ──────────────────────────────────────────
# Deterministic heuristic to infer Codex sandbox tier from tools list.
# Codex sandbox controls both filesystem AND network access:
# read-only — no writes, no network
# workspace-write — writes to workspace, no network by default
# danger-full-access — unrestricted filesystem + network
infer_sandbox_from_tools() {
local tools_csv="$1"
if [[ -z "$tools_csv" ]]; then
# No tools field = unrestricted
echo ""
return
fi
local has_web=false has_write=false has_unrestricted_bash=false has_read_only=false
IFS=',' read -ra tool_list <<< "$tools_csv"
for t in "${tool_list[@]}"; do
t="$(echo "$t" | xargs)"
case "$t" in
WebSearch|WebFetch) has_web=true ;;
Edit|Write) has_write=true ;;
Bash) has_unrestricted_bash=true ;;
Bash\(*) has_write=true ;; # Bash with restrictions = write-level
Read|Glob|Grep) has_read_only=true ;;
esac
done
if [[ "$has_unrestricted_bash" == true ]] || [[ "$has_web" == true ]]; then
echo "danger-full-access"
elif [[ "$has_write" == true ]]; then
echo "workspace-write"
elif [[ "$has_read_only" == true ]]; then
echo "read-only"
else
echo ""
fi
}
build_continuation_fallback_prompt() {
local original_run_id="$1"
local original_model="$2"
local original_log_dir="$3"
local follow_up_prompt="$4"
local original_input_file="$original_log_dir/input.md"
local original_report_file="$original_log_dir/report.md"
if [[ ! -f "$original_input_file" ]]; then
echo "ERROR: Cannot build continuation fallback prompt: missing $original_input_file" >&2
return 1
fi
if [[ ! -f "$original_report_file" ]]; then
echo "ERROR: Cannot build continuation fallback prompt: missing $original_report_file" >&2
return 1
fi
local original_input original_report
original_input="$(cat "$original_input_file")"
original_report="$(cat "$original_report_file")"
cat <<EOF
# Continuation Context
Native harness continuation was unavailable. Continue from this prior run context.
- Original run ID: $original_run_id
- Original model: $original_model
## Original Prompt
\`\`\`markdown
$original_input
\`\`\`
## Original Report
\`\`\`markdown
$original_report
\`\`\`
## Follow-Up Request
$follow_up_prompt
EOF
}
resolve_continuation_run_ref() {
local ref="$1"
local derived="$2"
case "$ref" in
@latest)
echo "$derived" | jq -r '.[0].run_id // empty'
;;
@last-failed)
echo "$derived" | jq -r '[.[] | select(.effective_status == "failed")] | .[0].run_id // empty'
;;
@last-completed)
echo "$derived" | jq -r '[.[] | select(.effective_status == "completed")] | .[0].run_id // empty'
;;
*)
local exact
exact="$(echo "$derived" | jq -r --arg ref "$ref" '[.[] | select(.run_id == $ref)] | .[0].run_id // empty')"
if [[ -n "$exact" ]]; then
echo "$exact"
return 0
fi
if [[ ${#ref} -lt 8 ]]; then
echo "ERROR: Continuation run reference prefix must be at least 8 characters (got ${#ref})." >&2
return 1
fi
local matches count
matches="$(echo "$derived" | jq -r --arg prefix "$ref" '[.[] | select(.run_id | startswith($prefix))] | map(.run_id)')"
count="$(echo "$matches" | jq 'length')"
if [[ "$count" -eq 0 ]]; then
echo "ERROR: No run matching continuation ref '$ref'." >&2
return 1
fi
if [[ "$count" -gt 1 ]]; then
echo "ERROR: Ambiguous continuation ref '$ref'. Use a longer prefix." >&2
return 1
fi
echo "$matches" | jq -r '.[0]'
;;
esac
}
prepare_continuation() {
[[ -z "${CONTINUE_RUN_REF:-}" ]] && return 0
local index_file="$ORCHESTRATE_ROOT/index/runs.jsonl"
if [[ ! -f "$index_file" ]]; then
echo "ERROR: Cannot continue run '$CONTINUE_RUN_REF': index file not found at $index_file" >&2
return 1
fi
if ! command -v jq >/dev/null 2>&1; then
echo "ERROR: jq is required for --continue-run." >&2
return 2
fi
local derived
derived="$(
jq -s '
group_by(.run_id)
| map(
. as $rows
| ($rows | map(select(.status == "running")) | first) as $start
| ($rows | map(select(.status == "completed" or .status == "failed")) | first) as $fin
| ($start // $rows[0])
+ (if $fin then $fin else {} end)
+ { effective_status: (if $fin then $fin.status else "running" end) }
)
| sort_by(.created_at_utc // "") | reverse
' "$index_file" 2>/dev/null
)"
local continue_run_id
continue_run_id="$(resolve_continuation_run_ref "$CONTINUE_RUN_REF" "$derived")" || return 1
if [[ -z "$continue_run_id" ]]; then
echo "ERROR: Could not resolve continuation ref '$CONTINUE_RUN_REF'." >&2
return 1
fi
local run_row
run_row="$(echo "$derived" | jq --arg id "$continue_run_id" '.[] | select(.run_id == $id)')"
if [[ -z "$run_row" ]]; then
echo "ERROR: Could not find continuation run '$continue_run_id' in index." >&2
return 1
fi
local effective_status source_harness source_model harness_session_id source_log_dir
effective_status="$(echo "$run_row" | jq -r '.effective_status // "running"')"
if [[ "$effective_status" == "running" ]]; then
echo "ERROR: Cannot continue run '$continue_run_id': run has no finalize row (crashed or still in progress)." >&2
return 1
fi
source_harness="$(echo "$run_row" | jq -r '.harness // empty')"
source_model="$(echo "$run_row" | jq -r '.model // empty')"
harness_session_id="$(echo "$run_row" | jq -r '.harness_session_id // empty')"
source_log_dir="$(echo "$run_row" | jq -r '.log_dir // empty')"
if [[ -z "$source_harness" || -z "$source_model" || -z "$source_log_dir" ]]; then
echo "ERROR: Cannot continue run '$continue_run_id': missing required run metadata." >&2
return 1
fi
# Continuations default to original model unless user explicitly overrides.
if [[ "${MODEL_FROM_CLI:-false}" != true ]]; then
MODEL="$source_model"
fi
local target_harness
target_harness="$(route_model "$MODEL" 2>/dev/null || echo "")"
if [[ -z "$target_harness" ]]; then
echo "ERROR: Cannot continue run '$continue_run_id': model '$MODEL' does not map to a supported harness." >&2
return 1
fi
if [[ "$target_harness" != "$source_harness" ]]; then
echo "ERROR: Cannot continue run '$continue_run_id': model '$MODEL' maps to '$target_harness', expected '$source_harness'." >&2
return 1
fi
CONTINUES_RUN_ID="$continue_run_id"
CONTINUATION_FALLBACK_REASON=""
if [[ -z "$harness_session_id" ]]; then
CONTINUATION_MODE="fallback-prompt"
CONTINUATION_FALLBACK_REASON="missing_session_id"
PROMPT="$(build_continuation_fallback_prompt "$continue_run_id" "$source_model" "$source_log_dir" "$PROMPT")" || return 1
return 0
fi
CONTINUE_HARNESS_SESSION_ID="$harness_session_id"
case "$source_harness" in
codex)
if [[ "${CONTINUATION_FORK_EXPLICIT:-false}" == true && "${CONTINUATION_FORK:-true}" == true ]]; then
echo "ERROR: Codex continuation does not support forking. Use --in-place or omit --fork." >&2
return 1
fi
CONTINUATION_MODE="in-place"
;;
claude|opencode)
if [[ "${CONTINUATION_FORK:-true}" == true ]]; then
CONTINUATION_MODE="fork"
else
CONTINUATION_MODE="in-place"
fi
;;
*)
CONTINUATION_MODE="fallback-prompt"
CONTINUATION_FALLBACK_REASON="unsupported_harness"
PROMPT="$(build_continuation_fallback_prompt "$continue_run_id" "$source_model" "$source_log_dir" "$PROMPT")" || return 1
;;
esac
}
build_cli_command() {
local tool
local normalized_tools
local native_continuation=false
CLI_PROMPT_MODE="stdin"
tool="$(route_model "$MODEL" 2>/dev/null || echo "")"
if [[ -z "$tool" ]]; then
if [[ -n "${CONTINUE_RUN_REF:-}" ]]; then
echo "ERROR: Unknown model family '$MODEL' for continuation run." >&2
return 2
fi
echo "[run-agent] WARNING: Unknown model family '$MODEL'; falling back to $FALLBACK_MODEL ($FALLBACK_CLI)" >&2
tool="$FALLBACK_CLI"
MODEL="$FALLBACK_MODEL"
elif ! command -v "$tool" >/dev/null 2>&1; then
if [[ -n "${CONTINUE_RUN_REF:-}" ]]; then
echo "ERROR: '$tool' CLI not found for continuation model '$MODEL'." >&2
return 2
fi
echo "[run-agent] WARNING: '$tool' CLI not found for model '$MODEL'; falling back to $FALLBACK_MODEL ($FALLBACK_CLI)" >&2
tool="$FALLBACK_CLI"
MODEL="$FALLBACK_MODEL"
fi
if ! command -v "$tool" >/dev/null 2>&1; then
echo "ERROR: '$tool' CLI not found. Install it or try a different model with -m." >&2
return 2
fi
CLI_CMD_ARGV=()
CLI_HARNESS="$tool"
if [[ -n "${CONTINUE_RUN_REF:-}" ]] \
&& [[ -n "${CONTINUE_HARNESS_SESSION_ID:-}" ]] \
&& [[ "${CONTINUATION_MODE:-}" != "fallback-prompt" ]]; then
native_continuation=true
fi
case "$tool" in
claude)
local -a agent_flags=()
if [[ -n "$AGENT_NAME" ]]; then
# Claude Code reads .claude/agents/<name>.md and enforces tools/permissions natively
agent_flags+=(--agent "$AGENT_NAME")
else
agent_flags+=(--dangerously-skip-permissions)
fi
CLI_CMD_ARGV=(env CLAUDECODE= claude -p - --model "$MODEL" --effort "$VARIANT" --verbose --output-format stream-json "${agent_flags[@]}")
if [[ "$native_continuation" == true ]]; then
CLI_CMD_ARGV+=(--resume "$CONTINUE_HARNESS_SESSION_ID")
if [[ "${CONTINUATION_MODE:-}" == "fork" ]]; then
CLI_CMD_ARGV+=(--fork-session)
fi
fi
;;
codex)
# Codex has no --agent flag. Determine sandbox from agent profile.
# Priority: explicit sandbox: field > inferred from tools: field > unrestricted
local effective_sandbox="${AGENT_SANDBOX:-}"
if [[ -z "$effective_sandbox" ]] && [[ -n "$AGENT_TOOLS" ]]; then
effective_sandbox="$(infer_sandbox_from_tools "$AGENT_TOOLS")"
fi
local -a perm_flags=()
case "${effective_sandbox:-}" in
read-only) perm_flags+=(--sandbox read-only) ;;
workspace-write) perm_flags+=(--sandbox workspace-write) ;;
danger-full-access) perm_flags+=(--sandbox danger-full-access) ;;
*) perm_flags+=(--dangerously-bypass-approvals-and-sandbox) ;;
esac
if [[ "$native_continuation" == true ]]; then
CLI_CMD_ARGV=(codex exec resume "$CONTINUE_HARNESS_SESSION_ID" -m "$MODEL" -c "model_reasoning_effort=$VARIANT" "${perm_flags[@]}" --json -)
else
CLI_CMD_ARGV=(codex exec -m "$MODEL" -c "model_reasoning_effort=$VARIANT" "${perm_flags[@]}" --json -)
fi
;;
opencode)
local effective_model
effective_model="$(strip_model_prefix "$MODEL")"
local -a agent_flags=()
if [[ -n "$AGENT_NAME" ]]; then
# OpenCode reads .agents/agents/<name>.md and enforces permissions natively
agent_flags+=(--agent "$AGENT_NAME")
fi
CLI_CMD_ARGV=(opencode run --model "$effective_model" --format json --print-logs --variant "$VARIANT" "${agent_flags[@]}")
CLI_PROMPT_MODE="arg"
if [[ "$native_continuation" == true ]]; then
CLI_CMD_ARGV+=(--session "$CONTINUE_HARNESS_SESSION_ID")
if [[ "${CONTINUATION_MODE:-}" == "fork" ]]; then
CLI_CMD_ARGV+=(--fork)
fi
fi
;;
*)
echo "ERROR: Unsupported CLI harness: $tool" >&2
return 2
;;
esac
}
format_cli_cmd() {
local out=""
for arg in "${CLI_CMD_ARGV[@]}"; do
if [[ "$arg" == *" "* || "$arg" == *"="* ]]; then
out+="\"$arg\" "
else
out+="$arg "
fi
done
echo "${out% }"
}
# ─── Files-Touched Extraction ────────────────────────────────────────────────
write_files_touched_from_log() {
local output_log="$1"
local log_dir="$2"
local extractor="$SCRIPT_DIR/extract-files-touched.sh"
if [[ -x "$extractor" ]]; then
# Produce NUL-delimited canonical format
if ! "$extractor" "$output_log" "$log_dir/files-touched.nul" --nul 2>/dev/null; then
# Fallback: try without --nul for backward compat during transition
"$extractor" "$output_log" "$log_dir/files-touched.txt" 2>/dev/null || true
return
fi
# Derive newline-delimited from NUL-delimited
if [[ -f "$log_dir/files-touched.nul" ]]; then
tr '\0' '\n' < "$log_dir/files-touched.nul" > "$log_dir/files-touched.txt"
fi
else
: > "$log_dir/files-touched.txt"
: > "$log_dir/files-touched.nul"
fi
}
# ─── Dry Run ─────────────────────────────────────────────────────────────────
do_dry_run() {
local cli_display
cli_display="$(format_cli_cmd)"
echo "═══ DRY RUN ═══"
echo ""
if [[ -n "${AGENT_NAME:-}" ]]; then echo "── Agent: $AGENT_NAME"; fi
echo "── Model: $MODEL ($(route_model "$MODEL" 2>/dev/null || echo "fallback"))"
echo "── Variant: $VARIANT"
echo "── Report: $DETAIL"
if [[ ${#SKILLS[@]} -gt 0 ]]; then echo "── Skills: ${SKILLS[*]}"; else echo "── Skills: none"; fi
if [[ -n "${AGENT_TOOLS:-}" ]]; then echo "── Tools: $AGENT_TOOLS"; else echo "── Tools: unrestricted"; fi
if [[ -n "${AGENT_SANDBOX:-}" ]]; then echo "── Sandbox: $AGENT_SANDBOX"; else echo "── Sandbox: none (unrestricted)"; fi
if [[ -n "${SESSION_ID:-}" ]]; then echo "── Session: $SESSION_ID"; fi
if [[ "$HAS_LABELS" == true ]]; then
local k
echo "── Labels:"
for k in "${!LABELS[@]}"; do
echo " - $k=${LABELS[$k]}"
done
else
echo "── Labels: none"
fi
if [[ ${#REF_FILES[@]} -gt 0 ]]; then echo "── Ref files: ${REF_FILES[*]}"; else echo "── Ref files: none"; fi
echo "── Working dir: $WORK_DIR"
echo ""
echo "── CLI Command (argv):"
echo " $cli_display"
echo ""
echo "── Composed Prompt:"
echo "────────────────────────────────────────"
echo "$COMPOSED_PROMPT"
echo ""
echo "[report instruction would be appended with LOG_DIR path at $DETAIL detail]"
echo "────────────────────────────────────────"
}
# ─── Signal Handling ──────────────────────────────────────────────────────────
_run_interrupted=false
_run_start_epoch=0
_handle_signal() {
local sig_code="$1"
_run_interrupted=true
# Write finalize row for observability before exiting
if [[ -n "${RUN_ID:-}" ]] && [[ -n "${LOG_DIR:-}" ]]; then
local duration=0
if [[ "$_run_start_epoch" -gt 0 ]]; then
local now_epoch
now_epoch="$(date +%s)"
duration=$((now_epoch - _run_start_epoch))
fi
append_finalize_row "$sig_code" "$duration" 2>/dev/null || true
fi
exit "$sig_code"
}
# ─── Execute ─────────────────────────────────────────────────────────────────
write_failfast_report() {
local exit_code="$1"
local title="$2"
local details="${3:-}"
# Avoid clobbering a non-empty report from an upstream harness (rare but possible).
if [[ -f "$LOG_DIR/report.md" ]] && [[ -s "$LOG_DIR/report.md" ]]; then
return 0
fi
{
echo "# Run Report (auto-generated)"
echo ""
echo "**Status**: failed (exit $exit_code)"
echo ""
echo "**Failure**: $title"
if [[ -n "$details" ]]; then
echo ""
echo "$details"
fi
} > "$LOG_DIR/report.md"
}
extract_opencode_error_message() {
local output_log="$1"
if ! command -v jq >/dev/null 2>&1; then
return 1
fi
# Best-effort: find the last error event and extract a message-like field.
jq -r '
select(.type == "error")
| (.error.data.message // .error.message // .error.data // .error // .message // empty)
' "$output_log" 2>/dev/null | tail -1
}
detect_opencode_error_event() {
local output_log="$1"
grep -q '"type":"error"' "$output_log" 2>/dev/null
}
do_execute() {
local cli_display output_log run_index_base_cmd show_cmd report_cmd files_cmd logs_cmd
cli_display="$(format_cli_cmd)"
# Set up logging and write start index row for crash visibility
setup_logging
export ORCHESTRATE_RUN_ID="$RUN_ID"
output_log="$LOG_DIR/output.jsonl"
write_log_params "$cli_display"
run_index_base_cmd="$SCRIPT_DIR/run-index.sh --repo \"$REPO_ROOT\""
show_cmd="$run_index_base_cmd show \"$RUN_ID\""
report_cmd="$run_index_base_cmd report \"$RUN_ID\""
files_cmd="$run_index_base_cmd files \"$RUN_ID\""
logs_cmd="$run_index_base_cmd logs \"$RUN_ID\""
# Capture git HEAD before execution (best-effort)
HEAD_BEFORE=""
if command -v git >/dev/null 2>&1; then
HEAD_BEFORE="$(git -C "$WORK_DIR" rev-parse HEAD 2>/dev/null || echo "")"
fi
# Write start row immediately (crash visibility)
append_start_row
# Install signal traps
trap '_handle_signal 130' INT
trap '_handle_signal 143' TERM
# Record start time for duration tracking
_run_start_epoch="$(date +%s)"
# Save composed prompt before run-time instructions are appended.
# This is used by retry to avoid duplicating generated sections.
echo "$COMPOSED_PROMPT" > "$LOG_DIR/prompt.raw.md"
# Append output directory and report instruction now that LOG_DIR is known.
COMPOSED_PROMPT+="$(build_output_dir_instruction "$LOG_DIR")"
COMPOSED_PROMPT+="$(build_report_instruction "$LOG_DIR/report.md" "$DETAIL")"
# Save composed prompt
echo "$COMPOSED_PROMPT" > "$LOG_DIR/input.md"
echo "[run-agent] Run: $RUN_ID | Model: $MODEL | Variant: $VARIANT" >&2
echo "[run-agent] run-index commands:" >&2
echo "[run-agent] show: $show_cmd" >&2
echo "[run-agent] report: $report_cmd" >&2
echo "[run-agent] files: $files_cmd" >&2
echo "[run-agent] logs: $logs_cmd" >&2
# Execute via argv array — no eval needed.
cd "$WORK_DIR"
local timeout_used=false
local -a runner=()
if command -v timeout >/dev/null 2>&1 && awk -v m="${TIMEOUT_MINUTES:-0}" 'BEGIN{exit !(m>0)}'; then
local timeout_seconds
timeout_seconds="$(awk -v m="${TIMEOUT_MINUTES:-0}" 'BEGIN{printf "%.3f", m*60}')"
# Use a grace period so the harness can flush logs before SIGKILL.
runner=(timeout --signal=TERM --kill-after=10s "${timeout_seconds}s")
timeout_used=true
fi
local harness_exit=0
set +e
if [[ "${CLI_PROMPT_MODE:-stdin}" == "arg" ]]; then
"${runner[@]}" "${CLI_CMD_ARGV[@]}" "$COMPOSED_PROMPT" \
> "$output_log" \
2> >(tee "$LOG_DIR/stderr.log" >&2)
else
"${runner[@]}" "${CLI_CMD_ARGV[@]}" <<< "$COMPOSED_PROMPT" \
> "$output_log" \
2> >(tee "$LOG_DIR/stderr.log" >&2)
fi
harness_exit=$?
set -e
# Map harness exit to structured exit code
local exit_code="$harness_exit"
if [[ "$timeout_used" == true ]] && { [[ "$harness_exit" -eq 124 ]] || [[ "$harness_exit" -eq 137 ]]; }; then
exit_code=3
write_failfast_report "$exit_code" "Timed out" "Harness exceeded ${TIMEOUT_MINUTES} minutes."
fi
# Exit codes 0, 1, 2, 3 pass through as-is (already structured).
# 130/143 are handled by signal traps above.
# Other non-zero codes map to 1 (agent error).
if [[ "$exit_code" -gt 3 ]] && [[ "$exit_code" -ne 130 ]] && [[ "$exit_code" -ne 143 ]]; then
exit_code=1
fi
# Fail-fast: don't silently succeed when a harness produces no usable output.
if [[ "$exit_code" -eq 0 ]]; then
case "$CLI_HARNESS" in
claude)
if [[ ! -f "$output_log" ]] || [[ ! -s "$output_log" ]]; then
exit_code=2
write_failfast_report "$exit_code" "No harness output captured" "Claude returned success but produced no stream-json events."
fi
;;
opencode)
if [[ ! -f "$output_log" ]] || [[ ! -s "$output_log" ]]; then
exit_code=2
write_failfast_report "$exit_code" "No harness output captured" "OpenCode returned success but produced no JSON events."
elif detect_opencode_error_event "$output_log"; then
local msg=""
msg="$(extract_opencode_error_message "$output_log" 2>/dev/null || echo "")"
exit_code=1
if [[ -n "$msg" ]]; then
write_failfast_report "$exit_code" "OpenCode error event" "$msg"
else
write_failfast_report "$exit_code" "OpenCode error event" "See output log for details: $output_log"
fi
fi
;;
esac
fi
# Derive files touched
write_files_touched_from_log "$output_log" "$LOG_DIR"
# Report fallback: if no report.md, try to extract last assistant message
if [[ ! -f "$LOG_DIR/report.md" ]] || [[ ! -s "$LOG_DIR/report.md" ]]; then
local fallback_extractor="$SCRIPT_DIR/extract-report-fallback.sh"
if [[ -x "$fallback_extractor" ]]; then
"$fallback_extractor" "$CLI_HARNESS" "$output_log" "$LOG_DIR/stderr.log" "$exit_code" \
> "$LOG_DIR/report.md" 2>/dev/null || true
fi
fi
# Compute duration
local end_epoch duration_seconds
end_epoch="$(date +%s)"
duration_seconds=$((_run_start_epoch > 0 ? end_epoch - _run_start_epoch : 0))
# Write finalize row
EXIT_CODE="$exit_code"
append_finalize_row "$exit_code" "$duration_seconds"
# Print report to stdout for the orchestrator
if [[ -f "$LOG_DIR/report.md" ]] && [[ -s "$LOG_DIR/report.md" ]]; then
cat "$LOG_DIR/report.md"
else
echo "---" >&2
echo "[run-agent] WARNING: Agent did not produce a report at $LOG_DIR/report.md" >&2
echo "[run-agent] Exit code: $exit_code" >&2
echo "[run-agent] Output log: $output_log" >&2
if [[ -f "$output_log" ]] && [[ -s "$output_log" ]]; then
echo "[run-agent] Last 40 lines of output:" >&2
tail -n 40 "$output_log" >&2
else
echo "[run-agent] Output log is empty — the CLI may have failed to start." >&2
fi
echo "---" >&2
fi
echo "[run-agent] Done (exit=$exit_code, duration=${duration_seconds}s). Run: $RUN_ID" >&2
echo "[run-agent] Report: $report_cmd" >&2
exit "$exit_code"
}
#!/usr/bin/env bash
# lib/logging.sh — Flat run storage, two-row index (start + finalize), path helpers.
# Sourced by run-agent.sh; expects globals from the entrypoint.
# ─── Path Helpers ─────────────────────────────────────────────────────────────
resolve_repo_path() {
local path="$1"
if [[ "$path" == /* ]]; then
echo "$path"
else
echo "$REPO_ROOT/$path"
fi
}
build_run_id() {
local ts suffix
ts="$(date -u +"%Y%m%dT%H%M%SZ")"
# Keep run IDs compact; agent/model stay in params.json + index metadata.
suffix="$(printf '%s%04x' "$$" "$RANDOM")"
echo "${ts}__${suffix}"
}
# ─── Log Setup ────────────────────────────────────────────────────────────────
setup_logging() {
if [[ -z "${RUN_ID:-}" ]]; then
RUN_ID="$(build_run_id)"
fi
LOG_DIR="$ORCHESTRATE_ROOT/runs/agent-runs/$RUN_ID"
if [[ "$LOG_DIR" == "/" ]]; then
echo "ERROR: LOG_DIR resolved to '/' — refusing to write run artifacts at filesystem root." >&2
exit 2
fi
mkdir -p "$LOG_DIR"
mkdir -p "$ORCHESTRATE_ROOT/index"
}
write_log_params() {
local cli_cmd="$1"
local skills_json labels_json now_utc session_id timeout_minutes
skills_json="$(build_skills_json)"
labels_json="$(build_labels_json)"
now_utc="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
session_id="${SESSION_ID:-$RUN_ID}"
timeout_minutes="${TIMEOUT_MINUTES:-${DEFAULT_TIMEOUT_MINUTES:-30}}"
cat > "$LOG_DIR/params.json" <<EOF
{
"run_id": "$(json_escape "$RUN_ID")",
"session_id": "$(json_escape "$session_id")",
"model": "$(json_escape "$MODEL")",
"variant": "$(json_escape "$VARIANT")",
"timeout_minutes": $timeout_minutes,
"agent": "$(json_escape "${AGENT_NAME:-}")",
"tools": "$(json_escape "${AGENT_TOOLS:-}")",
"sandbox": "$(json_escape "${AGENT_SANDBOX:-}")",
"skills": $skills_json,
"labels": $labels_json,
"cli": "$(json_escape "$cli_cmd")",
"harness": "$(json_escape "$(route_model "$MODEL")")",
"invoked_via": "$(json_escape "$0")",
"script_dir": "$(json_escape "$SCRIPT_DIR")",
"detail": "$(json_escape "$DETAIL")",
"cwd": "$(json_escape "$WORK_DIR")",
"created_at_utc": "$(json_escape "$now_utc")",
"log_dir": "$(json_escape "$LOG_DIR")"
}
EOF
}
# ─── Index Locking ────────────────────────────────────────────────────────────
# Use flock for atomic appends; fall back to mkdir-based lock if unavailable.
INDEX_FILE=""
_LOCK_FD=""
_LOCK_DIR=""
_resolve_index_file() {
INDEX_FILE="$ORCHESTRATE_ROOT/index/runs.jsonl"
}
_acquire_lock() {
local lock_path="$ORCHESTRATE_ROOT/index/runs.lock"
# Try flock first
if command -v flock >/dev/null 2>&1; then
exec {_LOCK_FD}>"$lock_path"
if flock -w 5 "$_LOCK_FD" 2>/dev/null; then
return 0
fi
fi
# Fallback: mkdir-based lock with timeout
_LOCK_DIR="$ORCHESTRATE_ROOT/index/runs.lockdir"
local attempts=0
while ! mkdir "$_LOCK_DIR" 2>/dev/null; do
attempts=$((attempts + 1))
if [[ $attempts -ge 50 ]]; then
echo "[run-agent] WARNING: Could not acquire index lock after 5s, appending without lock" >&2
_LOCK_DIR=""
return 0
fi
sleep 0.1
done
}
_release_lock() {
if [[ -n "${_LOCK_FD:-}" ]]; then
eval "exec ${_LOCK_FD}>&-" 2>/dev/null || true
_LOCK_FD=""
fi
if [[ -n "${_LOCK_DIR:-}" ]]; then
rmdir "$_LOCK_DIR" 2>/dev/null || true
_LOCK_DIR=""
fi
}
# ─── Two-Row Index ────────────────────────────────────────────────────────────
append_start_row() {
_resolve_index_file
local labels_json skills_json now_utc session_id
labels_json="$(build_labels_json)"
skills_json="$(build_skills_json)"
now_utc="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
session_id="${SESSION_ID:-$RUN_ID}"
local agent_field=""
if [[ -n "${AGENT_NAME:-}" ]]; then
agent_field="\"agent\":\"$(json_escape "$AGENT_NAME")\","
fi
local row
row=$(cat <<EOF
{"run_id":"$(json_escape "$RUN_ID")","status":"running","created_at_utc":"$(json_escape "$now_utc")","cwd":"$(json_escape "$WORK_DIR")","session_id":"$(json_escape "$session_id")","model":"$(json_escape "$MODEL")","harness":"$(json_escape "$CLI_HARNESS")",${agent_field}"skills":$skills_json,"labels":$labels_json,"log_dir":"$(json_escape "$LOG_DIR")"}
EOF
)
_acquire_lock
echo "$row" >> "$INDEX_FILE"
_release_lock
}
append_finalize_row() {
local exit_code="$1"
local duration_seconds="${2:-0}"
_resolve_index_file
local now_utc session_id failure_reason
now_utc="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
session_id="${SESSION_ID:-$RUN_ID}"
# Derive failure_reason from exit code
failure_reason="null"
if [[ "$exit_code" -ne 0 ]]; then
case "$exit_code" in
1) failure_reason='"agent_error"' ;;
2) failure_reason='"infra_error"' ;;
3) failure_reason='"timeout"' ;;
130) failure_reason='"interrupted"' ;;
143) failure_reason='"interrupted"' ;;
*) failure_reason='"unknown"' ;;
esac
fi
local status="completed"
[[ "$exit_code" -ne 0 ]] && status="failed"
local output_log="$LOG_DIR/output.jsonl"
local report_path="$LOG_DIR/report.md"
# Git metadata (best-effort)
local git_available="false" in_git_repo="false"
local head_before="${HEAD_BEFORE:-}" head_after=""
local commit_count=0 commit_tracking="none" commit_tracking_source="none" commit_tracking_confidence="low"
if command -v git >/dev/null 2>&1; then
git_available="true"
if git -C "$WORK_DIR" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
in_git_repo="true"
head_after="$(git -C "$WORK_DIR" rev-parse HEAD 2>/dev/null || echo "")"
# Count commits between start and end HEAD
if [[ -n "$head_before" ]] && [[ -n "$head_after" ]] && [[ "$head_before" != "$head_after" ]]; then
commit_count="$(git -C "$WORK_DIR" rev-list --count "$head_before".."$head_after" 2>/dev/null || echo "0")"
commit_tracking="tracked"
commit_tracking_source="fallback_git"
commit_tracking_confidence="medium"
fi
fi
fi
# Harness session ID (best-effort)
local harness_session_id=""
local extractor="$SCRIPT_DIR/extract-harness-session-id.sh"
if [[ -x "$extractor" ]] && [[ -f "$output_log" ]] && [[ -s "$output_log" ]]; then
harness_session_id="$("$extractor" "$CLI_HARNESS" "$output_log" 2>/dev/null || echo "")"
fi
# Token usage (best-effort, parsed from output)
local input_tokens="null" output_tokens="null"
if [[ -f "$output_log" ]] && [[ -s "$output_log" ]]; then
case "$CLI_HARNESS" in
claude)
# Claude result event has usage info
local usage_line
usage_line="$(grep '"type":"result"' "$output_log" 2>/dev/null | tail -1 || echo "")"
if [[ -n "$usage_line" ]]; then
input_tokens="$(echo "$usage_line" | jq -r '.result.input_tokens // empty' 2>/dev/null || echo "")"
output_tokens="$(echo "$usage_line" | jq -r '.result.output_tokens // empty' 2>/dev/null || echo "")"
[[ -z "$input_tokens" ]] && input_tokens="null"
[[ -z "$output_tokens" ]] && output_tokens="null"
fi
;;
esac
fi
# Continuation/retry metadata (set by caller if applicable)
local continues_field=""
if [[ -n "${CONTINUES_RUN_ID:-}" ]]; then
continues_field="\"continues\":\"$(json_escape "$CONTINUES_RUN_ID")\","
continues_field+="\"continuation_mode\":\"$(json_escape "${CONTINUATION_MODE:-fork}")\","
if [[ -n "${CONTINUATION_FALLBACK_REASON:-}" ]]; then
continues_field+="\"continuation_fallback_reason\":\"$(json_escape "$CONTINUATION_FALLBACK_REASON")\","
else
continues_field+="\"continuation_fallback_reason\":null,"
fi
fi
local retries_field=""
if [[ -n "${RETRIES_RUN_ID:-}" ]]; then
retries_field="\"retries\":\"$(json_escape "$RETRIES_RUN_ID")\","
fi
local row
row=$(cat <<EOF
{"run_id":"$(json_escape "$RUN_ID")","status":"$status","finished_at_utc":"$(json_escape "$now_utc")","duration_seconds":$duration_seconds,"exit_code":$exit_code,"failure_reason":$failure_reason,"output_log":"$(json_escape "$output_log")","report_path":"$(json_escape "$report_path")",${continues_field}${retries_field}"harness_session_id":"$(json_escape "$harness_session_id")","git_available":$git_available,"in_git_repo":$in_git_repo,"head_before":"$(json_escape "$head_before")","head_after":"$(json_escape "$head_after")","commit_count":$commit_count,"commit_tracking":"$(json_escape "$commit_tracking")","commit_tracking_source":"$(json_escape "$commit_tracking_source")","commit_tracking_confidence":"$(json_escape "$commit_tracking_confidence")","input_tokens":$input_tokens,"output_tokens":$output_tokens}
EOF
)
_acquire_lock
echo "$row" >> "$INDEX_FILE"
_release_lock
}
#!/usr/bin/env bash
# lib/parse.sh — Usage display and argument parsing.
# Sourced by run-agent.sh; expects globals from the entrypoint.
# ─── Usage ───────────────────────────────────────────────────────────────────
usage() {
local exit_code="${1:-1}"
local timeout_default="${DEFAULT_TIMEOUT_MINUTES:-30}"
cat <<EOF
Usage: run-agent.sh [OPTIONS]
Options:
-m, --model MODEL Model to use (required unless fallback applies)
-V, --variant VARIANT Model variant passed to harness (default: high)
Presets: low, medium, high, xhigh, max
Not all variants apply to all models.
--timeout M Kill hung harness runs after M minutes (default: ${timeout_default}). Supports fractional minutes.
--agent NAME Agent profile (passed to harness natively where supported)
--strict-skills Error on unknown skills (default: warn only)
-s, --skills LIST Comma-separated skill names to load
-p, --prompt TEXT Prompt text (can also pipe via stdin)
--session ID Session ID for grouping related runs
--label K=V Run metadata label (repeatable)
-v, --var KEY=VALUE Template variable substitution (repeatable)
-f, --file PATH Reference file/dir to list in prompt (repeatable)
-D, --detail LEVEL Report detail level: brief | standard | detailed (default: standard)
--continue-run REF Continue a previous run's harness session
--fork Fork the session on continuation (default where supported)
--in-place Resume without forking (always for Codex)
--dry-run Print composed prompt + CLI command, don't execute
-C, --cd DIR Working directory for subprocess
-h, --help Show this help
EOF
exit "$exit_code"
}
require_option_value() {
local opt="$1"
local remaining_args="$2"
if [[ "$remaining_args" -lt 2 ]]; then
echo "ERROR: $opt requires a value." >&2
usage
fi
}
preparse_work_dir_override() {
local args=("$@")
local idx=0
while [[ $idx -lt ${#args[@]} ]]; do
case "${args[$idx]}" in
-C|--cd)
if [[ $((idx + 1)) -ge ${#args[@]} ]]; then
echo "ERROR: ${args[$idx]} requires a value." >&2
usage
fi
WORK_DIR="${args[$((idx + 1))]}"
idx=$((idx + 2))
;;
*)
idx=$((idx + 1))
;;
esac
done
if [[ "$WORK_DIR" != /* ]]; then
WORK_DIR="$(pwd -P)/$WORK_DIR"
fi
}
parse_label_kv() {
local raw="$1"
local key="${raw%%=*}"
local val="${raw#*=}"
if [[ "$raw" != *=* ]]; then
echo "ERROR: --label requires KEY=VALUE (got: $raw)" >&2
exit 1
fi
if [[ -z "$key" || -z "$val" ]]; then
echo "ERROR: --label requires non-empty KEY and VALUE (got: $raw)" >&2
exit 1
fi
if [[ ! "$key" =~ ^[A-Za-z0-9._-]+$ ]]; then
echo "ERROR: Invalid label key '$key'. Allowed: letters, numbers, dot, underscore, dash." >&2
exit 1
fi
LABELS["$key"]="$val"
HAS_LABELS=true
}
# ─── Argument Parsing ────────────────────────────────────────────────────────
parse_args() {
preparse_work_dir_override "$@"
refresh_orchestrate_paths_from_workdir
while [[ $# -gt 0 ]]; do
case "$1" in
-m|--model)
require_option_value "$1" "$#"
MODEL="$2"
MODEL_FROM_CLI=true
shift 2
;;
-V|--variant)
require_option_value "$1" "$#"
VARIANT="$2"
VARIANT_FROM_CLI=true
shift 2
;;
--timeout)
require_option_value "$1" "$#"
TIMEOUT_MINUTES="$2"
shift 2
;;
--agent)
require_option_value "$1" "$#"
AGENT_NAME="$2"
shift 2
;;
--strict-skills)
STRICT_SKILLS=true
shift
;;
-s|--skills)
require_option_value "$1" "$#"
IFS=',' read -ra _skills <<< "$2"
for s in "${_skills[@]}"; do SKILLS+=("$(echo "$s" | xargs)"); done
shift 2
;;
-p|--prompt)
require_option_value "$1" "$#"
if [[ "$2" == "-" ]]; then
CLI_PROMPT=""
else
CLI_PROMPT="$2"
fi
shift 2
;;
--session)
require_option_value "$1" "$#"
SESSION_ID="$2"
shift 2
;;
--label)
require_option_value "$1" "$#"
parse_label_kv "$2"
shift 2
;;
-f|--file)
require_option_value "$1" "$#"
REF_FILES+=("$2")
shift 2
;;
-v|--var)
require_option_value "$1" "$#"
key="${2%%=*}"
val="${2#*=}"
VARS["$key"]="$val"
HAS_VARS=true
shift 2
;;
-D|--detail)
require_option_value "$1" "$#"
case "$2" in
brief|standard|detailed) DETAIL="$2" ;;
*) echo "[run-agent] WARNING: Invalid detail level '$2', defaulting to 'standard'" >&2; DETAIL="standard" ;;
esac
shift 2
;;
--continue-run)
require_option_value "$1" "$#"
CONTINUE_RUN_REF="$2"
shift 2
;;
--fork)
CONTINUATION_FORK=true
CONTINUATION_FORK_EXPLICIT=true
shift
;;
--in-place)
CONTINUATION_FORK=false
CONTINUATION_FORK_EXPLICIT=true
shift
;;
--dry-run) DRY_RUN=true; shift ;;
-C|--cd)
# Already handled by preparse; skip here.
shift 2
;;
-h|--help) usage 0 ;;
*)
echo "ERROR: Unknown argument: $1" >&2
usage
;;
esac
done
# Read prompt from stdin if not provided via -p
if [[ -z "$CLI_PROMPT" ]] && [[ ! -t 0 ]]; then
CLI_PROMPT="$(cat)"
fi
PROMPT="$CLI_PROMPT"
}
validate_args() {
if [[ -n "${TIMEOUT_MINUTES:-}" ]]; then
if ! [[ "$TIMEOUT_MINUTES" =~ ^[0-9]+([.][0-9]+)?$ ]]; then
echo "ERROR: --timeout must be a non-negative number of minutes (got: $TIMEOUT_MINUTES)" >&2
exit 1
fi
fi
# Model fallback
if [[ -z "$MODEL" ]]; then
echo "[run-agent] WARNING: No model specified; falling back to $FALLBACK_MODEL" >&2
MODEL="$FALLBACK_MODEL"
fi
# Advisory: warn early if the routed CLI isn't installed.
local routed_cli
routed_cli="$(route_model "$MODEL" 2>/dev/null || echo "")"
if [[ -n "$routed_cli" ]] && ! command -v "$routed_cli" >/dev/null 2>&1; then
echo "[run-agent] WARNING: '$routed_cli' not installed for model '$MODEL'; will fall back to $FALLBACK_MODEL ($FALLBACK_CLI)" >&2
fi
# Skill validation — check all discovery directories, not just orchestrate source.
if [[ ${#SKILLS[@]} -gt 0 ]]; then
local missing_skills=()
local skill
local -a skill_dirs=()
while IFS= read -r d; do
skill_dirs+=("$d")
done < <(build_discovery_dirs "skills")
for skill in "${SKILLS[@]}"; do
local found=false
for d in "${skill_dirs[@]}"; do
if [[ -f "$d/$skill/SKILL.md" ]]; then
found=true
break
fi
done
if [[ "$found" == false ]]; then
missing_skills+=("$skill")
fi
done
if [[ ${#missing_skills[@]} -gt 0 ]]; then
local joined
joined="$(IFS=,; echo "${missing_skills[*]}")"
if [[ "${STRICT_SKILLS:-false}" == true ]]; then
echo "ERROR: Unknown skill(s): $joined" >&2
echo " Searched: ${skill_dirs[*]}" >&2
exit 1
fi
echo "[run-agent] WARNING: Unknown skill(s): $joined" >&2
echo "[run-agent] WARNING: Searched: ${skill_dirs[*]}" >&2
fi
fi
if [[ -z "$PROMPT" ]] && [[ ${#SKILLS[@]} -eq 0 ]] && [[ -z "${CONTINUE_RUN_REF:-}" ]]; then
echo "ERROR: No prompt or skills specified. Use -p, -s, or --continue-run." >&2
exit 1
fi
# Validate template variables are not empty.
if [[ "$HAS_VARS" == true ]]; then
local key val
for key in "${!VARS[@]}"; do
val="${VARS[$key]}"
if [[ -z "$val" ]]; then
echo "ERROR: Template variable '$key' is empty." >&2
echo " Hint: If you set the value via a shell variable, make sure it was exported" >&2
echo " before the command, or use an inline value:" >&2
echo " export MY_VAR=/some/path && ./run-agent.sh -v $key=\"\$MY_VAR\"" >&2
echo " ./run-agent.sh -v $key=/some/path" >&2
exit 1
fi
done
fi
}
#!/usr/bin/env bash
# lib/prompt.sh — Skill loading, template substitution, prompt composition.
# Sourced by run-agent.sh; expects globals from the entrypoint.
# ─── Skill Loading ───────────────────────────────────────────────────────────
# Reads SKILL.md, strips YAML frontmatter, returns body with source path annotation.
# Not used by compose_prompt (skills are listed by name for harness-native loading),
# but kept for other callers (e.g. orchestrate skill policy loader).
load_skill() {
local name="$1"
local skill_file=""
# Search discovery dirs for the skill
while IFS= read -r d; do
local candidate="$d/$name/SKILL.md"
if [[ -f "$candidate" ]]; then
skill_file="$candidate"
break
fi
done < <(build_discovery_dirs "skills")
if [[ -z "$skill_file" ]]; then
echo "ERROR: Skill not found: $name" >&2
echo " Searched: $(build_discovery_dirs "skills" | tr '\n' ' ')" >&2
return 1
fi
# Emit source path so the subagent can resolve relative references
echo "Loaded from: $skill_file"
echo ""
# Strip YAML frontmatter (--- ... ---)
awk '
BEGIN { in_frontmatter=0; past_frontmatter=0 }
/^---$/ {
if (!past_frontmatter) {
if (in_frontmatter) { past_frontmatter=1; next }
else { in_frontmatter=1; next }
}
}
past_frontmatter || !in_frontmatter { if (past_frontmatter || NR > 1 || !/^---$/) print }
' "$skill_file"
}
append_skill_if_missing() {
local candidate="$1"
[[ -z "$candidate" ]] && return 0
local existing
for existing in "${SKILLS[@]}"; do
if [[ "$existing" == "$candidate" ]]; then
return 0
fi
done
SKILLS+=("$candidate")
}
load_pinned_skills_from_config() {
local config_file="$ORCHESTRATE_ROOT/config.toml"
[[ -f "$config_file" ]] || return 0
local skill
while IFS= read -r skill; do
append_skill_if_missing "$skill"
done < <(
awk '
function ltrim(s) { sub(/^[[:space:]]+/, "", s); return s }
function rtrim(s) { sub(/[[:space:]]+$/, "", s); return s }
function trim(s) { return rtrim(ltrim(s)) }
function strip_comment(s, i, c, out, in_dq, esc) {
out = ""
in_dq = 0
esc = 0
for (i = 1; i <= length(s); i++) {
c = substr(s, i, 1)
if (esc) {
out = out c
esc = 0
continue
}
if (c == "\\" && in_dq) {
out = out c
esc = 1
continue
}
if (c == "\"") {
in_dq = !in_dq
out = out c
continue
}
if (c == "#" && !in_dq) {
break
}
out = out c
}
return out
}
function warn(msg) {
print "[run-agent] WARNING: " msg > "/dev/stderr"
}
function emit_token(token, t, first, last) {
t = trim(token)
if (t == "") return 0
first = substr(t, 1, 1)
last = substr(t, length(t), 1)
if ((first == "\"" && last == "\"") || (first == "\047" && last == "\047")) {
t = substr(t, 2, length(t) - 2)
}
if (t == "") return 0
print t
return 1
}
function parse_array(array_text, inner, i, c, token, in_quote, quote_char, esc, emitted) {
array_text = trim(array_text)
if (substr(array_text, 1, 1) != "[" || substr(array_text, length(array_text), 1) != "]") {
return -1
}
inner = substr(array_text, 2, length(array_text) - 2)
token = ""
in_quote = 0
quote_char = ""
esc = 0
emitted = 0
for (i = 1; i <= length(inner); i++) {
c = substr(inner, i, 1)
if (in_quote) {
token = token c
if (esc) {
esc = 0
continue
}
if (c == "\\" && quote_char == "\"") {
esc = 1
continue
}
if (c == quote_char) {
in_quote = 0
quote_char = ""
}
continue
}
if (c == "\"" || c == "\047") {
in_quote = 1
quote_char = c
token = token c
continue
}
if (c == ",") {
emitted += emit_token(token)
token = ""
continue
}
token = token c
}
if (in_quote) return -1
emitted += emit_token(token)
return emitted
}
BEGIN { section = "" }
{
line = strip_comment($0)
line = trim(line)
if (line == "") next
if (line ~ /^\[[^]]+\]$/) {
section = substr(line, 2, length(line) - 2)
next
}
if (section == "skills" && line ~ /^pinned[[:space:]]*=/ && !in_pinned_array) {
value = line
sub(/^pinned[[:space:]]*=[[:space:]]*/, "", value)
value = trim(value)
saw_pinned = 1
pinned_start_line = NR
if (value !~ /^\[/) {
warn("Invalid [skills].pinned in " FILENAME ":" NR " (expected array)")
next
}
pinned_value = value
if (index(value, "]") > 0) {
parsed_count = parse_array(pinned_value)
if (parsed_count < 0) {
warn("Invalid [skills].pinned array in " FILENAME ":" pinned_start_line)
}
pinned_value = ""
} else {
in_pinned_array = 1
}
next
}
if (in_pinned_array) {
pinned_value = pinned_value " " line
if (index(line, "]") > 0) {
parsed_count = parse_array(pinned_value)
if (parsed_count < 0) {
warn("Invalid multiline [skills].pinned array in " FILENAME ":" pinned_start_line)
}
pinned_value = ""
in_pinned_array = 0
}
}
}
END {
if (in_pinned_array) {
warn("Unclosed multiline [skills].pinned array in " FILENAME ":" pinned_start_line)
} else if (saw_pinned && parsed_count < 0) {
warn("Failed to parse [skills].pinned in " FILENAME)
}
}
' "$config_file"
)
}
# ─── Resource Discovery ─────────────────────────────────────────────────────
# Returns ordered list of directories to search for skills or agents,
# based on the current harness. Whatever the orchestrating harness can
# find natively, run-agent.sh should find too.
#
# Priority: harness-own dir (repo) → orchestrate source → harness-own dir
# (global/home) → other harness dirs (repo).
#
# Usage: build_discovery_dirs "skills" or build_discovery_dirs "agents"
# Expects DISCOVERY_HARNESS to be set (or empty for harness-agnostic order).
build_discovery_dirs() {
local resource_type="$1" # "skills" or "agents"
local harness="${DISCOVERY_HARNESS:-}"
local -a dirs=()
local -A seen=()
_add_dir() {
local d="$1"
[[ -z "$d" ]] && return
# Resolve to absolute and dedup
local abs
abs="$(cd "$d" 2>/dev/null && pwd -P)" 2>/dev/null || abs="$d"
[[ -n "${seen[$abs]:-}" ]] && return
seen[$abs]=1
dirs+=("$d")
}
# Harness-own repo directories
case "$harness" in
claude)
_add_dir "$REPO_ROOT/.claude/$resource_type"
;;
codex)
_add_dir "$REPO_ROOT/.agents/$resource_type"
;;
opencode)
_add_dir "$REPO_ROOT/.agents/$resource_type"
_add_dir "$REPO_ROOT/.opencode/$resource_type"
;;
*)
# Harness unknown — search all repo-level dirs first
_add_dir "$REPO_ROOT/.claude/$resource_type"
_add_dir "$REPO_ROOT/.agents/$resource_type"
_add_dir "$REPO_ROOT/.opencode/$resource_type"
_add_dir "$REPO_ROOT/.cursor/$resource_type"
;;
esac
# Orchestrate source (always second priority)
if [[ "$resource_type" == "skills" ]]; then
_add_dir "$SKILLS_DIR"
else
_add_dir "$AGENTS_DIR"
fi
# Harness-own global/home directories
case "$harness" in
claude)
_add_dir "$HOME/.claude/$resource_type"
;;
opencode)
_add_dir "$HOME/.config/opencode/$resource_type"
;;
esac
# Other harness dirs (repo-level, lower priority)
_add_dir "$REPO_ROOT/.claude/$resource_type"
_add_dir "$REPO_ROOT/.agents/$resource_type"
_add_dir "$REPO_ROOT/.opencode/$resource_type"
_add_dir "$REPO_ROOT/.cursor/$resource_type"
# Global fallbacks (if not already added)
_add_dir "$HOME/.claude/$resource_type"
printf '%s\n' "${dirs[@]}"
}
# ─── Agent Loading ──────────────────────────────────────────────────────────
# Searches discovery dirs in order, returns path to first <name>.md found.
resolve_agent_file() {
local name="$1"
local -a dirs=()
while IFS= read -r d; do
dirs+=("$d")
done < <(build_discovery_dirs "agents")
for dir in "${dirs[@]}"; do
local candidate="$dir/$name.md"
if [[ -f "$candidate" ]]; then
echo "$candidate"
return 0
fi
done
echo "ERROR: Agent profile not found: $name" >&2
echo " Searched: ${dirs[*]}" >&2
return 1
}
# Loads agent profile, sets globals from frontmatter (model, variant, skills, sandbox, tools).
# CLI flags always override profile defaults.
load_agent_profile() {
local agent_file
agent_file="$(resolve_agent_file "$AGENT_NAME")" || exit 1
# Extract YAML frontmatter
local frontmatter
frontmatter="$(awk '
BEGIN { in_fm=0; past_fm=0 }
/^---$/ {
if (!past_fm) {
if (in_fm) { past_fm=1; next }
else { in_fm=1; next }
}
}
in_fm && !past_fm { print }
' "$agent_file")"
# Extract body (frontmatter stripped)
AGENT_BODY="$(awk '
BEGIN { in_fm=0; past_fm=0 }
/^---$/ {
if (!past_fm) {
if (in_fm) { past_fm=1; next }
else { in_fm=1; next }
}
}
past_fm || !in_fm { if (past_fm || NR > 1 || !/^---$/) print }
' "$agent_file")"
# Parse model: (only if not set from CLI)
if [[ "$MODEL_FROM_CLI" != true ]]; then
local profile_model
profile_model="$(echo "$frontmatter" | sed -n 's/^model:[[:space:]]*//p' | xargs)"
if [[ -n "$profile_model" ]]; then
MODEL="$profile_model"
fi
fi
# Parse variant: (only if not set from CLI)
if [[ "$VARIANT_FROM_CLI" != true ]]; then
local profile_variant
profile_variant="$(echo "$frontmatter" | sed -n 's/^variant:[[:space:]]*//p' | xargs)"
if [[ -n "$profile_variant" ]]; then
VARIANT="$profile_variant"
fi
fi
# Parse skills: merge with CLI --skills
while IFS= read -r s; do
append_skill_if_missing "$s"
done < <(_parse_yaml_list "skills" "$frontmatter")
# Parse sandbox: (orchestrate extension, used for Codex only)
local profile_sandbox
profile_sandbox="$(echo "$frontmatter" | sed -n 's/^sandbox:[[:space:]]*//p' | xargs)"
if [[ -n "$profile_sandbox" ]]; then
AGENT_SANDBOX="$profile_sandbox"
fi
# Parse tools: stored as comma-separated string
local tools_csv=""
while IFS= read -r t; do
[[ -z "$t" ]] && continue
[[ -n "$tools_csv" ]] && tools_csv+=","
tools_csv+="$t"
done < <(_parse_yaml_list "tools" "$frontmatter")
if [[ -n "$tools_csv" ]]; then
AGENT_TOOLS="$tools_csv"
fi
}
# ─── YAML List Parser ────────────────────────────────────────────────────────
# Parses both inline [a, b] and multiline YAML lists from frontmatter.
# Outputs one item per line, trimmed.
_parse_yaml_list() {
local key="$1"
local frontmatter="$2"
# Try inline format first: key: [a, b, c]
local inline
inline="$(echo "$frontmatter" | sed -n "s/^${key}:[[:space:]]*\[//p")"
if [[ -n "$inline" ]]; then
inline="${inline%]}"
IFS=',' read -ra items <<< "$inline"
for item in "${items[@]}"; do
item="$(echo "$item" | xargs)"
[[ -n "$item" ]] && echo "$item"
done
return
fi
# Try multiline format:
# key:
# - item1
# - item2
local in_list=false
while IFS= read -r line; do
if [[ "$in_list" == true ]]; then
if [[ "$line" =~ ^[[:space:]]+-[[:space:]]+(.*) ]]; then
local item="${BASH_REMATCH[1]}"
item="$(echo "$item" | xargs)"
[[ -n "$item" ]] && echo "$item"
elif [[ "$line" =~ ^[[:space:]]*$ ]]; then
continue
else
break # next key or end of list
fi
elif [[ "$line" =~ ^${key}:[[:space:]]*$ ]]; then
in_list=true
fi
done <<< "$frontmatter"
}
# ─── Model Routing ───────────────────────────────────────────────────────────
# Returns the CLI tool family for a given model name.
route_model() {
local model="$1"
case "$model" in
opus*|sonnet*|haiku*|claude-*)
echo "claude"
;;
gpt-*|o1*|o3*|o4*|codex*)
echo "codex"
;;
opencode-*|*/*)
echo "opencode"
;;
*)
echo "ERROR: Unknown model family: $model" >&2
echo " Supported: claude-*, gpt-*/codex*, opencode-*, provider/model" >&2
return 1
;;
esac
}
# Strip opencode- prefix before passing to the CLI.
# provider/model format (e.g. opencode/kimi-k2.5-free) is passed through unchanged.
strip_model_prefix() {
echo "${1#opencode-}"
}
# ─── Template Substitution ───────────────────────────────────────────────────
apply_template_vars() {
local text="$1"
for key in "${!VARS[@]}"; do
text="${text//\{\{$key\}\}/${VARS[$key]}}"
done
echo "$text"
}
json_escape() {
local text="$1"
text="${text//\\/\\\\}"
text="${text//\"/\\\"}"
text="${text//$'\n'/\\n}"
text="${text//$'\r'/\\r}"
text="${text//$'\t'/\\t}"
echo "$text"
}
build_skills_json() {
local json=""
local skill
for skill in "${SKILLS[@]}"; do
[[ -n "$json" ]] && json+=", "
json+="\"$(json_escape "$skill")\""
done
echo "[$json]"
}
build_labels_json() {
local json=""
local key
for key in "${!LABELS[@]}"; do
[[ -n "$json" ]] && json+=", "
json+="\"$(json_escape "$key")\":\"$(json_escape "${LABELS[$key]}")\""
done
echo "{$json}"
}
# ─── Compose Prompt ──────────────────────────────────────────────────────────
compose_prompt() {
local composed=""
# Task prompt first — it's the primary instruction.
if [[ -n "$PROMPT" ]]; then
composed+="$PROMPT"$'\n'
fi
# Reference files
if [[ ${#REF_FILES[@]} -gt 0 ]]; then
composed+=$'\n'"# Reference Files"$'\n\n'
for ref in "${REF_FILES[@]}"; do
local resolved_ref="$ref"
if [[ "$HAS_VARS" == true ]]; then
resolved_ref="$(apply_template_vars "$ref")"
fi
composed+="- $resolved_ref"$'\n'
done
fi
# Inject agent body for Codex (which has no native --agent flag).
# Claude Code and OpenCode load the agent profile natively via --agent.
if [[ -n "$AGENT_NAME" ]] && [[ -n "$AGENT_BODY" ]]; then
local harness
harness="$(route_model "$MODEL" 2>/dev/null || echo "")"
if [[ "$harness" == "codex" ]]; then
composed+=$'\n'"# Agent: $AGENT_NAME"$'\n\n'
composed+="$AGENT_BODY"$'\n\n'
fi
fi
# List skills by name — harnesses load them natively from their skill directories.
# Claude Code: .claude/skills/ | OpenCode: .agents/skills/ | Codex: .agents/skills/
if [[ ${#SKILLS[@]} -gt 0 ]]; then
composed+=$'\n'"# Skills"$'\n\n'
composed+="Use these skills to complete your task:"$'\n\n'
for skill in "${SKILLS[@]}"; do
composed+="- $skill"$'\n'
done
fi
# Apply template variables
if [[ "$HAS_VARS" == true ]]; then
composed="$(apply_template_vars "$composed")"
fi
echo "$composed"
}
# ─── Report Instruction ─────────────────────────────────────────────────────
# Appended to prompt so the subagent writes a report file the orchestrator can read.
build_output_dir_instruction() {
local log_dir="$1"
cat <<EOF
# Output Directory
Write any output files to: \`$log_dir/\`
EOF
}
build_report_instruction() {
local report_path="$1"
local level="$2"
local detail_guide=""
case "$level" in
brief)
detail_guide="Keep the report concise. Focus on: what was done, pass/fail status, any blockers."
;;
standard)
detail_guide="Include: what was done, key decisions made, files created/modified, verification results, and any issues or blockers."
;;
detailed)
detail_guide="Be thorough: what was done, reasoning behind decisions, all files touched with descriptions, full verification results, issues found, and recommendations for next steps."
;;
esac
cat <<EOF
# Report
**IMPORTANT — As your FINAL action**, write a report of your work to: \`$report_path\`
$detail_guide
Use plain markdown. This file is read by the orchestrator to understand what you did without parsing verbose logs.
EOF
}
#!/usr/bin/env bash
# load-model-guidance.sh — resolve model-guidance resources with override precedence.
#
# Precedence (custom replaces default):
# 1) If references/model-guidance/*.md files exist (excluding README.md),
# concatenate those in bytewise-lexicographic order.
# 2) Otherwise, load references/default-model-guidance.md.
#
# Usage:
# scripts/load-model-guidance.sh [--mode concat|paths]
# scripts/load-model-guidance.sh # default: concat
set -euo pipefail
MODE="concat"
usage() {
cat <<'EOF'
Usage: scripts/load-model-guidance.sh [--mode concat|paths]
Modes:
concat Concatenate selected guidance files to stdout (default)
paths Print selected guidance file paths, one per line
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--mode)
[[ $# -lt 2 ]] && { echo "ERROR: --mode requires a value" >&2; usage; exit 1; }
MODE="$2"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo "ERROR: Unknown argument: $1" >&2
usage
exit 1
;;
esac
done
case "$MODE" in
concat|paths) ;;
*)
echo "ERROR: Unsupported mode '$MODE' (expected concat|paths)" >&2
exit 1
;;
esac
# Resolve through symlinks for portability.
_source="${BASH_SOURCE[0]}"
while [[ -L "$_source" ]]; do
_dir="$(cd "$(dirname "$_source")" && pwd -P)"
_source="$(readlink "$_source")"
[[ "$_source" != /* ]] && _source="$_dir/$_source"
done
SCRIPT_DIR="$(cd "$(dirname "$_source")" && pwd -P)"
REF_DIR="$(cd "$SCRIPT_DIR/.." && pwd -P)/references"
DEFAULT_FILE="$REF_DIR/default-model-guidance.md"
CUSTOM_DIR="$REF_DIR/model-guidance"
declare -a selected=()
custom_found=false
# Concatenate custom files (excluding README.md)
if [[ -d "$CUSTOM_DIR" ]]; then
while IFS= read -r f; do
[[ -n "$f" ]] || continue
local_name="$(basename "$f")"
[[ "$local_name" == "README.md" ]] && continue
selected+=("$f")
custom_found=true
done < <(find "$CUSTOM_DIR" -maxdepth 1 -type f -name '*.md' | sort)
fi
if [[ "$custom_found" != true ]]; then
if [[ -f "$DEFAULT_FILE" ]]; then
selected+=("$DEFAULT_FILE")
else
echo "ERROR: Default model guidance not found: $DEFAULT_FILE" >&2
exit 1
fi
fi
if [[ "$MODE" == "paths" ]]; then
printf '%s\n' "${selected[@]}"
exit 0
fi
for i in "${!selected[@]}"; do
cat "${selected[$i]}"
if [[ "$i" -lt $((${#selected[@]} - 1)) ]]; then
printf '\n\n'
fi
done
#!/usr/bin/env bash
# log-inspect.sh — jq-based conversation log inspector.
#
# Usage:
# scripts/log-inspect.sh <mode> <output.json|output.jsonl> [pattern] [context]
#
# Modes:
# summary (default) — cost, tokens, turns, duration, models
# tools — tool call names + counts
# errors — is_error flags, permission denials
# files — delegates to extract-files-touched.sh
set -euo pipefail
# Resolve through symlinks
_source="${BASH_SOURCE[0]}"
while [[ -L "$_source" ]]; do
_dir="$(cd "$(dirname "$_source")" && pwd -P)"
_source="$(readlink "$_source")"
[[ "$_source" != /* ]] && _source="$_dir/$_source"
done
SCRIPT_DIR="$(cd "$(dirname "$_source")" && pwd -P)"
# ─── Args ────────────────────────────────────────────────────────────────────
MODE="${1:-summary}"
LOG_FILE="${2:-}"
PATTERN="${3:-}"
CONTEXT="${4:-2}"
if [[ -z "$LOG_FILE" ]]; then
cat <<'EOF'
Usage: scripts/log-inspect.sh <mode> <output.json|output.jsonl> [pattern] [context]
Modes:
summary (default) Cost, tokens, turns, duration, models
tools Tool call names + counts from result text
errors is_error flags, permission denials
files Delegates to extract-files-touched.sh
search Find pattern and show surrounding context (args: pattern [context])
EOF
exit 1
fi
if [[ ! -f "$LOG_FILE" ]]; then
echo "ERROR: File not found: $LOG_FILE" >&2
exit 1
fi
if ! command -v jq >/dev/null 2>&1; then
echo "ERROR: jq is required but not installed." >&2
echo " Install: sudo apt-get install jq (or brew install jq on macOS)" >&2
exit 1
fi
# ─── Format Detection ───────────────────────────────────────────────────────
# Determine whether the file is a single JSON value or a JSONL stream.
detect_format() {
if ! jq -e . "$LOG_FILE" >/dev/null 2>&1; then
echo "text"
return
fi
local item_count
item_count="$(jq -s 'length' "$LOG_FILE" 2>/dev/null || echo 0)"
if [[ "$item_count" == "1" ]]; then
echo "json"
else
echo "jsonl"
fi
}
detect_harness() {
if [[ "$FORMAT" == "text" ]]; then
echo "unknown"
return
fi
# Codex JSONL commonly includes thread./turn. event types.
if jq -sre 'any(.[]; (.type? // "" | tostring | test("^thread\\.|^turn\\.")))' "$LOG_FILE" >/dev/null 2>&1; then
echo "codex"
return
fi
# Claude stream-json commonly emits message/content_block event families.
if jq -sre 'any(.[]; (.type? // "" | tostring | test("^message_|^content_block_")))' "$LOG_FILE" >/dev/null 2>&1; then
echo "claude-stream-json"
return
fi
# Claude single-json includes these top-level fields.
if jq -e 'has("modelUsage") or has("permission_denials") or has("session_id")' "$LOG_FILE" >/dev/null 2>&1; then
echo "claude"
return
fi
# OpenCode JSON streams typically emit typed events but not codex thread./turn. prefixes.
if jq -sre 'any(.[]; has("type"))' "$LOG_FILE" >/dev/null 2>&1; then
echo "opencode-or-other-jsonl"
return
fi
echo "unknown"
}
FORMAT="$(detect_format)"
HARNESS="$(detect_harness)"
# ─── Summary Mode ───────────────────────────────────────────────────────────
do_summary() {
echo "═══ Log Summary ═══"
echo "File: $LOG_FILE"
echo "Format: $FORMAT"
echo "Harness: $HARNESS"
echo ""
if [[ "$FORMAT" == "text" ]]; then
local line_count
line_count="$(wc -l < "$LOG_FILE")"
echo "Lines: $line_count"
echo "(Non-JSON log; structured summary unavailable)"
elif [[ "$FORMAT" == "json" ]]; then
# Claude single-JSON format
# Claude output: cost is total_cost_usd, models are in modelUsage (object with model keys)
jq -r '
"Models: " + (if .modelUsage then (.modelUsage | keys | join(", ")) elif .model then .model else "unknown" end),
"Session ID: " + (.session_id // "unknown"),
"Cost (USD): $" + ((.total_cost_usd // .cost_usd // 0) | tostring),
"Duration: " + (if .duration_ms then ((.duration_ms / 1000 * 100 | round / 100) | tostring) + "s" else "unknown" end),
"Turns: " + (if .num_turns then (.num_turns | tostring) else "unknown" end),
"Result: " + (.subtype // .stop_reason // "unknown"),
"",
"Tokens:",
" Input: " + (if .usage.input_tokens then (.usage.input_tokens | tostring) else "unknown" end),
" Output: " + (if .usage.output_tokens then (.usage.output_tokens | tostring) else "unknown" end),
" Cache read: " + (if .usage.cache_read_input_tokens then (.usage.cache_read_input_tokens | tostring) else "n/a" end),
" Cache write: " + (if .usage.cache_creation_input_tokens then (.usage.cache_creation_input_tokens | tostring) else "n/a" end)
' "$LOG_FILE" 2>/dev/null || echo "(Could not extract summary fields)"
else
# JSONL format — aggregate across events
jq -sr '
"Models: " + (
([.[] | .model? // .model_name? // .response?.model? | strings] | unique) as $models |
if ($models | length) > 0 then ($models | join(", ")) else "unknown" end
),
"Session ID: " + (
([.[] | .session_id? // .thread_id? | strings] | unique | .[0]) // "unknown"
),
"Events: " + (length | tostring),
"Errors: " + (
[.[] | select((.type? // "" | tostring | ascii_downcase | contains("error")) or (.is_error? == true) or (.error? != null))] | length | tostring
),
"",
"Event Types:",
(
[.[] | .type? | strings]
| group_by(.)
| map({k: .[0], v: length})
| sort_by(-.v)
| if length == 0 then [" (none)"] else map(" \(.v)\t\(.k)") end
| .[]
)
' "$LOG_FILE" 2>/dev/null || echo "(Could not extract JSONL summary fields)"
fi
}
# ─── Tools Mode ──────────────────────────────────────────────────────────────
do_tools() {
echo "═══ Tool Calls ═══"
if [[ "$FORMAT" == "text" ]]; then
grep -oE '\b(Read|Write|Edit|Bash|Glob|Grep|WebSearch|WebFetch|Task|NotebookEdit|web_search|web_fetch|mcp__[A-Za-z0-9_.:-]+)\b' "$LOG_FILE" |
sort | uniq -c | sort -rn | awk '{printf "%s\t%s\n", $1, $2}' ||
echo "(No tool calls found)"
else
local tool_output
tool_output="$(
jq -sr '
map(
[
(.. | objects
| select((.type? // "" | tostring | ascii_downcase | test("tool|web_search|web_fetch|mcp")))
| (.name? // .tool_name? // .tool? // .function?.name? // .toolName? // empty)
),
(.. | objects | .tool_name? // empty),
(.. | objects | .function?.name? // empty),
(.. | objects
| select((.name? != null) and ((.tool_call_id? != null) or (.call_id? != null) or (.type? // "" | tostring | ascii_downcase | contains("tool"))))
| .name
)
] | flatten | map(strings) | unique | .[]
)
| map(select(length > 0))
| map(select(. != "unknown"))
| group_by(.)
| map({name: .[0], count: length})
| sort_by(-.count)
| .[]
| "\(.count)\t\(.name)"
' "$LOG_FILE" 2>/dev/null || true
)"
if [[ -n "$tool_output" ]]; then
echo "$tool_output"
else
# Text fallback across JSON payloads for common built-in and MCP-style tool names.
jq -r '.. | strings' "$LOG_FILE" 2>/dev/null |
grep -oE '\b(Read|Write|Edit|Bash|Glob|Grep|WebSearch|WebFetch|Task|NotebookEdit|web_search|web_fetch|mcp__[A-Za-z0-9_.:-]+)\b' |
sort | uniq -c | sort -rn | awk '{printf "%s\t%s\n", $1, $2}' ||
echo "(No tool calls found)"
fi
fi
}
# ─── Errors Mode ─────────────────────────────────────────────────────────────
do_errors() {
echo "═══ Errors ═══"
if [[ "$FORMAT" == "text" ]]; then
local err_count
err_count="$(grep -ciE 'error|failed|exception|denied' "$LOG_FILE" || true)"
[[ -z "$err_count" ]] && err_count="0"
echo "Error-pattern matches: $err_count"
echo ""
grep -iE 'error|failed|exception|denied' "$LOG_FILE" | head -20 || true
elif [[ "$FORMAT" == "json" ]]; then
# Check top-level is_error
local is_error
is_error="$(jq -r '.is_error // false' "$LOG_FILE" 2>/dev/null)"
echo "Top-level is_error: $is_error"
echo ""
# Permission denials (structured field in Claude output)
echo "Permission denials:"
jq -r '
if .permission_denials then
(.permission_denials | tostring)
else
" 0"
end
' "$LOG_FILE" 2>/dev/null || echo " (could not extract)"
# Check result for error patterns
echo ""
echo "Error patterns in result:"
local err_count
err_count="$(jq -r '.result // ""' "$LOG_FILE" 2>/dev/null | grep -ciE 'error|failed|exception' || true)"
[[ -z "$err_count" ]] && err_count="0"
echo " Matches: $err_count"
else
local err_count
err_count="$(jq -sr '[.[] | select((.type? // "" | tostring | ascii_downcase | contains("error")) or (.is_error? == true) or (.error? != null))] | length' "$LOG_FILE" 2>/dev/null || echo "0")"
echo "Error events: $err_count"
echo ""
jq -sr '
[.[] | select((.type? // "" | tostring | ascii_downcase | contains("error")) or (.is_error? == true) or (.error? != null))
| (.message? // .error?.message? // .content? // (.error? | tostring) // "error event")]
| .[:20]
| if length == 0 then ["(none found)"] else . end
| .[]
' "$LOG_FILE" 2>/dev/null || echo "(Could not parse JSONL errors)"
fi
}
# ─── Files Mode ──────────────────────────────────────────────────────────────
do_files() {
local extractor="$SCRIPT_DIR/extract-files-touched.sh"
if [[ -x "$extractor" ]]; then
"$extractor" "$LOG_FILE"
else
echo "ERROR: extract-files-touched.sh not found at $extractor" >&2
exit 1
fi
}
# ─── Search Mode ─────────────────────────────────────────────────────────────
do_search() {
if [[ -z "$PATTERN" ]]; then
echo "ERROR: search mode requires a pattern argument." >&2
echo "Usage: scripts/log-inspect.sh search <output.json|output.jsonl> <pattern> [context]" >&2
exit 1
fi
if ! [[ "$CONTEXT" =~ ^[0-9]+$ ]]; then
echo "ERROR: context must be a non-negative integer (got: $CONTEXT)" >&2
exit 1
fi
local target_file="$LOG_FILE"
local tmp_file=""
local max_lines=200
# For single JSON, pretty-print first so context windows are readable.
if [[ "$FORMAT" == "json" ]]; then
tmp_file="$(mktemp)"
if jq . "$LOG_FILE" > "$tmp_file" 2>/dev/null; then
target_file="$tmp_file"
else
rm -f "$tmp_file"
tmp_file=""
fi
fi
echo "═══ Search Matches ═══"
echo "File: $LOG_FILE"
echo "Format: $FORMAT"
echo "Pattern: $PATTERN"
echo "Context: $CONTEXT"
echo ""
local matches
matches="$(grep -n -i -C "$CONTEXT" -- "$PATTERN" "$target_file" 2>/dev/null || true)"
if [[ -z "$matches" ]]; then
echo "(No matches found)"
[[ -n "$tmp_file" ]] && rm -f "$tmp_file"
return 0
fi
# Prevent dumping very large logs into context.
sed -n "1,${max_lines}p" <<< "$matches"
local total_lines
total_lines="$(echo "$matches" | wc -l | tr -d ' ')"
if [[ "$total_lines" -gt "$max_lines" ]]; then
echo ""
echo "(truncated: showing first $max_lines of $total_lines lines)"
fi
[[ -n "$tmp_file" ]] && rm -f "$tmp_file"
return 0
}
# ─── Dispatch ────────────────────────────────────────────────────────────────
case "$MODE" in
summary) do_summary ;;
tools) do_tools ;;
errors) do_errors ;;
files) do_files ;;
search) do_search ;;
*)
echo "ERROR: Unknown mode: $MODE" >&2
echo "Valid modes: summary, tools, errors, files, search" >&2
exit 1
;;
esac
#!/usr/bin/env bash
# run-agent.sh — Single entry point for running any agent.
# Routes models to the correct CLI tool, composes prompts, logs everything.
#
# Usage:
# run-agent.sh [OPTIONS]
# run-agent.sh --model claude-sonnet-4-6 --skills reviewing -p "Review the changes"
# run-agent.sh --model gpt-5.3-codex -p "Implement feature" --label ticket=PAY-123
# run-agent.sh --dry-run --model claude-sonnet-4-6 --skills reviewing -p "test"
#
# A run is model + skills + prompt. No "agent" abstraction.
set -euo pipefail
# Resolve through symlinks so SKILLS_DIR is correct even when invoked via a symlink.
_source="${BASH_SOURCE[0]}"
while [[ -L "$_source" ]]; do
_dir="$(cd "$(dirname "$_source")" && pwd -P)"
_source="$(readlink "$_source")"
[[ "$_source" != /* ]] && _source="$_dir/$_source"
done
SCRIPT_DIR="$(cd "$(dirname "$_source")" && pwd -P)"
CURRENT_DIR="$(pwd -P)"
REPO_ROOT="$(git -C "$CURRENT_DIR" rev-parse --show-toplevel 2>/dev/null || echo "$CURRENT_DIR")"
ORCHESTRATE_ROOT=""
SKILLS_DIR=""
AGENTS_DIR=""
refresh_orchestrate_paths() {
local repo_base="$1"
ORCHESTRATE_ROOT="$repo_base/.orchestrate"
# Skills live in the submodule/clone source, not the runtime dir.
# Derive from SCRIPT_DIR (inside */run-agent/scripts/).
local source_root
source_root="$(cd "$SCRIPT_DIR/../../.." && pwd -P)"
SKILLS_DIR="$source_root/skills"
AGENTS_DIR="$source_root/agents"
}
refresh_orchestrate_paths_from_workdir() {
local candidate_repo
candidate_repo="$(git -C "$WORK_DIR" rev-parse --show-toplevel 2>/dev/null || echo "$WORK_DIR")"
REPO_ROOT="$candidate_repo"
refresh_orchestrate_paths "$REPO_ROOT"
}
# ─── Defaults ────────────────────────────────────────────────────────────────
FALLBACK_CLI="codex"
FALLBACK_MODEL="gpt-5.3-codex"
DEFAULT_TIMEOUT_MINUTES=30
MODEL=""
VARIANT="high"
AGENT_NAME=""
AGENT_BODY="" # markdown body (frontmatter stripped), used for Codex prompt injection
AGENT_TOOLS="" # comma-separated tool allowlist from agent profile
AGENT_SANDBOX="" # Codex sandbox: read-only, workspace-write, danger-full-access (or empty)
# Kill hung harness invocations (default: 30 minutes).
TIMEOUT_MINUTES="$DEFAULT_TIMEOUT_MINUTES"
SKILLS=()
PROMPT=""
CLI_PROMPT=""
DRY_RUN=false
DETAIL="standard"
STRICT_SKILLS=false
WORK_DIR="$REPO_ROOT"
SESSION_ID="" # explicit session grouping (--session)
CONTINUE_RUN_REF="" # continuation target (--continue-run)
CONTINUATION_FORK=true # fork by default where supported
CONTINUATION_FORK_EXPLICIT=false
declare -A VARS=()
declare -A LABELS=()
REF_FILES=()
HAS_VARS=false
HAS_LABELS=false
MODEL_FROM_CLI=false
VARIANT_FROM_CLI=false
# Runtime state (populated during execution)
declare -a CLI_CMD_ARGV=()
CLI_HARNESS=""
DISCOVERY_HARNESS="" # early harness hint for resource discovery (set before agent loading)
RUN_ID=""
LOG_DIR=""
EXIT_CODE=0
HEAD_BEFORE=""
# Continuation/retry metadata (set when applicable)
CONTINUES_RUN_ID=""
CONTINUATION_MODE=""
CONTINUATION_FALLBACK_REASON=""
RETRIES_RUN_ID="${RETRIES_RUN_ID:-}"
refresh_orchestrate_paths "$REPO_ROOT"
# ─── Source Modules ──────────────────────────────────────────────────────────
source "$SCRIPT_DIR/lib/parse.sh"
source "$SCRIPT_DIR/lib/prompt.sh"
source "$SCRIPT_DIR/lib/logging.sh"
source "$SCRIPT_DIR/lib/exec.sh"
# ─── Init ────────────────────────────────────────────────────────────────────
init_work_dir() {
if ! WORK_DIR="$(cd "$WORK_DIR" 2>/dev/null && pwd -P)"; then
echo "ERROR: Working directory does not exist: $WORK_DIR" >&2
exit 2
fi
REPO_ROOT="$(git -C "$WORK_DIR" rev-parse --show-toplevel 2>/dev/null || echo "$WORK_DIR")"
refresh_orchestrate_paths "$REPO_ROOT"
}
init_dirs() {
mkdir -p "$ORCHESTRATE_ROOT"
mkdir -p "$ORCHESTRATE_ROOT/runs/agent-runs"
mkdir -p "$ORCHESTRATE_ROOT/index"
local config_file="$ORCHESTRATE_ROOT/config.toml"
if [[ ! -f "$config_file" ]]; then
cat > "$config_file" <<'EOF'
# Orchestrate runtime configuration.
#
# Skills to auto-load on each run-agent invocation.
# [skills]
# pinned = ["orchestrate", "run-agent", "mermaid"]
#
# Additional runtime sections can be added over time.
# [runtime]
# example_option = "value"
EOF
fi
}
# ─── Main ────────────────────────────────────────────────────────────────────
parse_args "$@"
init_work_dir
# Derive harness early from MODEL (if known) so resource discovery uses
# the correct search order. If --agent without --model, DISCOVERY_HARNESS
# stays empty and build_discovery_dirs searches all dirs (harness-agnostic).
if [[ -n "$MODEL" ]]; then
DISCOVERY_HARNESS="$(route_model "$MODEL" 2>/dev/null || echo "")"
fi
# Load agent profile (if --agent specified) before validation so profile
# defaults (model, variant, skills) are available to validate_args.
if [[ -n "$AGENT_NAME" ]]; then
load_agent_profile
# Agent profile may have set MODEL — update discovery harness if it was unknown.
if [[ -z "$DISCOVERY_HARNESS" ]] && [[ -n "$MODEL" ]]; then
DISCOVERY_HARNESS="$(route_model "$MODEL" 2>/dev/null || echo "")"
fi
fi
load_pinned_skills_from_config
validate_args
prepare_continuation
init_dirs
COMPOSED_PROMPT="$(compose_prompt)"
build_cli_command
if [[ "$DRY_RUN" == true ]]; then
do_dry_run
exit 0
fi
do_execute