#!/usr/bin/env bash # cto-worker.sh — CTO orchestrator helper. Wraps sandcastle invocation + PR opening + 5W reporting. # # Called by the cto-agent SKILL.md operating loop. Idempotent per WORK_ID. # # Usage: # cto-worker.sh sandcastle [provider=docker] # → invokes sandcastle.run(), returns JSON {commits, branch} on stdout # # cto-worker.sh open-pr <body> # → gh pr create on branch cto/<work-id>; returns PR URL on stdout # # cto-worker.sh emit-5w <work-id> <status> <summary> # → prints 5W block to stdout (Hermes captures into kanban completion) # # Env: # SANDCASTLE_REPO — default $HOME/workspaces/hermes/sandcastle # CTO_HOME — default $HOME/.hermes/cto-planb # GH_TOKEN — resolved via credbridge.sh; never set in argv set -euo pipefail SANDCASTLE_REPO="${SANDCASTLE_REPO:-$HOME/workspaces/hermes/sandcastle}" CTO_HOME="${CTO_HOME:-$HOME/.hermes/cto-planb}" CTO_REPO="$(cd "$(dirname "$0")/.." && pwd)" usage() { sed -n '2,17p' "$0"; exit 2; } cmd_sandcastle() { local work_id="${1:?work-id required}" local target="${2:?target-repo required}" local prompt_file="${3:?prompt-file required}" local provider="${4:-docker}" [ -d "$SANDCASTLE_REPO" ] || { echo "ERROR: $SANDCASTLE_REPO not found" >&2; return 1; } [ -d "$target" ] || { echo "ERROR: target repo $target not found" >&2; return 1; } [ -f "$prompt_file" ] || { echo "ERROR: prompt file $prompt_file not found" >&2; return 1; } case "$provider" in docker|podman) ;; noSandbox|nosandbox|head) echo "BLOCK: unsafe sandcastle provider/strategy requires JP approval: $provider" >&2 return 1 ;; *) echo "BLOCK: unsupported sandcastle provider: $provider" >&2 return 1 ;; esac # Hard rule: never run against read-only workspace siblings. case "$(basename "$target")" in hermes-agent|hermes-webui|marketingskills|sandcastle) echo "BLOCK: refusing to sandcastle against read-only sibling: $(basename "$target")" >&2 return 1 ;; esac local branch="cto/$work_id" cd "$SANDCASTLE_REPO" npx tsx -e " import { run, claudeCode } from '@ai-hero/sandcastle'; import { ${provider} } from '@ai-hero/sandcastle/sandboxes/${provider}'; const result = await run({ agent: claudeCode('claude-opus-4-7'), sandbox: ${provider}(), promptFile: '${prompt_file}', cwd: '${target}', branchStrategy: { type: 'branch', branch: '${branch}' }, maxIterations: 5, }); console.log(JSON.stringify({ work_id: '${work_id}', commits: result.commits, branch: result.branch }, null, 2)); " } cmd_open_pr() { local work_id="${1:?work-id required}" local target="${2:?target-repo required}" local title="${3:?title required}" local body="${4:?body required}" [ -d "$target" ] || { echo "ERROR: target $target not found" >&2; return 1; } local branch="cto/$work_id" # Resolve github-pat via credbridge (never via argv). source "$CTO_REPO/credbridge.sh" if ! cb_export GH_TOKEN github-pat 2>/dev/null; then echo "ERROR: github-pat not in credctl vault (add via 'credctl set github-pat')" >&2 return 1 fi cd "$target" # Ensure branch is pushed. if ! git -C "$target" rev-parse "refs/heads/$branch" >/dev/null 2>&1; then echo "ERROR: branch $branch not present locally — sandcastle did not commit?" >&2 return 1 fi git push -u origin "$branch" 2>&1 | tail -3 # Create PR — capture URL. gh pr create --head "$branch" --title "$title" --body "$body" 2>&1 | grep -oE 'https?://[^[:space:]]+' } cmd_emit_5w() { local work_id="${1:?work-id required}" local status="${2:?status required}" # shipped | blocked | needs-decision local summary="${3:?summary required}" cat <<EOF ## WHAT — Shipped ${summary} ## WHY — Approach sandcastle-orchestrated isolated execution per cto-agent SKILL.md operating loop ## HOW — Sandcastle invocations work_id=${work_id}; branch=cto/${work_id}; provider=docker (default); maxIterations=5 ## WHO — Next JP for merge approval (deploy gate) ## WHEN — Status ${status} — $(date -Iseconds) EOF } case "${1:-}" in sandcastle) shift; cmd_sandcastle "$@" ;; open-pr) shift; cmd_open_pr "$@" ;; emit-5w) shift; cmd_emit_5w "$@" ;; ""|-h|--help) usage ;; *) echo "unknown command: $1"; usage ;; esac