summaryrefslogtreecommitdiff
path: root/scripts
diff options
context:
space:
mode:
Diffstat (limited to 'scripts')
-rw-r--r--scripts/check-token78
-rw-r--r--scripts/ct-submit62
-rw-r--r--scripts/ct-task169
-rwxr-xr-xscripts/debug-execution34
-rwxr-xr-xscripts/deploy31
-rw-r--r--scripts/drain-failed-tasks22
-rw-r--r--scripts/fix-permissions43
-rw-r--r--scripts/hooks/pre-commit7
-rw-r--r--scripts/hooks/pre-push6
-rw-r--r--scripts/install-hooks23
-rwxr-xr-xscripts/next-task5
-rwxr-xr-xscripts/reset-failed-tasks48
-rw-r--r--scripts/sync-credentials49
-rw-r--r--scripts/verify17
14 files changed, 565 insertions, 29 deletions
diff --git a/scripts/check-token b/scripts/check-token
new file mode 100644
index 0000000..40a3116
--- /dev/null
+++ b/scripts/check-token
@@ -0,0 +1,78 @@
+#!/usr/bin/env bash
+# check-token: Verify Claude OAuth token is valid against the Anthropic API.
+# Usage: check-token [--refresh] [--retry-task <id-prefix>]
+# --refresh re-authenticate via claude CLI if token is bad
+# --retry-task <id> after a successful token check/refresh, retry that task
+#
+# Exit codes: 0=valid, 1=expired/invalid, 2=credentials file missing
+
+set -euo pipefail
+
+CREDS="/root/.claude/.credentials.json"
+REFRESH=0
+RETRY_TASK=""
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --refresh) REFRESH=1; shift ;;
+ --retry-task) RETRY_TASK="$2"; shift 2 ;;
+ *) echo "Unknown arg: $1" >&2; exit 2 ;;
+ esac
+done
+
+if [[ ! -f "$CREDS" ]]; then
+ echo "ERROR: credentials file not found: $CREDS" >&2
+ exit 2
+fi
+
+ACCESS_TOKEN=$(python3 -c "
+import json, sys
+d = json.load(open('$CREDS'))
+tok = d.get('claudeAiOauth', {}).get('accessToken', '')
+if not tok:
+ print('MISSING', file=sys.stderr)
+ sys.exit(1)
+print(tok)
+")
+
+# Test token against the API with a minimal request
+HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
+ -X POST https://api.anthropic.com/v1/messages \
+ -H "anthropic-version: 2023-06-01" \
+ -H "anthropic-beta: oauth-2025-04-20" \
+ -H "Authorization: Bearer $ACCESS_TOKEN" \
+ -H "Content-Type: application/json" \
+ -d '{"model":"claude-haiku-4-5-20251001","max_tokens":1,"messages":[{"role":"user","content":"hi"}]}')
+
+if [[ "$HTTP_STATUS" == "200" ]]; then
+ echo "OK: token is valid (HTTP $HTTP_STATUS)"
+ if [[ -n "$RETRY_TASK" ]]; then
+ /workspace/claudomator/scripts/ct-task "$RETRY_TASK" retry
+ fi
+ exit 0
+elif [[ "$HTTP_STATUS" == "401" ]]; then
+ echo "EXPIRED: token rejected by API (HTTP 401)"
+ if [[ "$REFRESH" == "1" ]]; then
+ echo "Re-authenticating via claude CLI..."
+ claude --dangerously-skip-permissions /dev/null 2>&1 || true
+ # Check if creds were updated
+ NEW_TOKEN=$(python3 -c "import json; print(json.load(open('$CREDS')).get('claudeAiOauth',{}).get('accessToken',''))")
+ if [[ "$NEW_TOKEN" != "$ACCESS_TOKEN" ]]; then
+ echo "New token obtained. Syncing credentials..."
+ /workspace/claudomator/scripts/sync-credentials
+ if [[ -n "$RETRY_TASK" ]]; then
+ /workspace/claudomator/scripts/ct-task "$RETRY_TASK" retry
+ fi
+ exit 0
+ else
+ echo "Token unchanged — manual re-auth required: run 'claude' in a terminal" >&2
+ exit 1
+ fi
+ else
+ echo "Run: check-token --refresh or re-authenticate via 'claude'" >&2
+ exit 1
+ fi
+else
+ echo "WARN: unexpected HTTP $HTTP_STATUS from API (token may still be valid)"
+ exit 1
+fi
diff --git a/scripts/ct-submit b/scripts/ct-submit
new file mode 100644
index 0000000..26213c5
--- /dev/null
+++ b/scripts/ct-submit
@@ -0,0 +1,62 @@
+#!/usr/bin/env bash
+# ct-submit — Create and immediately run a Claudomator task
+#
+# Usage:
+# ct-submit --name "task name" --repo "/site/git.terst.org/repos/doot.git" --instructions "..."
+# ct-submit --name "task name" --instructions-file /tmp/instructions.txt
+#
+# Reads instructions from --instructions or --instructions-file.
+# Prints the task ID on success.
+
+set -euo pipefail
+
+API="http://localhost:8484"
+NAME=""
+REPO="/site/git.terst.org/repos/claudomator.git"
+INSTRUCTIONS=""
+INSTRUCTIONS_FILE=""
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --name) NAME="$2"; shift 2 ;;
+ --repo) REPO="$2"; shift 2 ;;
+ --instructions) INSTRUCTIONS="$2"; shift 2 ;;
+ --instructions-file) INSTRUCTIONS_FILE="$2"; shift 2 ;;
+ *) echo "Unknown arg: $1" >&2; exit 1 ;;
+ esac
+done
+
+if [[ -z "$NAME" ]]; then
+ echo "Error: --name is required" >&2
+ exit 1
+fi
+
+if [[ -n "$INSTRUCTIONS_FILE" ]]; then
+ INSTRUCTIONS="$(cat "$INSTRUCTIONS_FILE")"
+fi
+
+if [[ -z "$INSTRUCTIONS" ]]; then
+ echo "Error: --instructions or --instructions-file is required" >&2
+ exit 1
+fi
+
+PAYLOAD="$(jq -n \
+ --arg name "$NAME" \
+ --arg repo "$REPO" \
+ --arg inst "$INSTRUCTIONS" \
+ '{name: $name, repository_url: $repo, agent: {type: "claude", instructions: $inst}}')"
+
+RESPONSE="$(curl -s -X POST "$API/api/tasks" \
+ -H "Content-Type: application/json" \
+ -d "$PAYLOAD")"
+
+TASK_ID="$(echo "$RESPONSE" | jq -r '.id // empty')"
+if [[ -z "$TASK_ID" ]]; then
+ echo "Error creating task:" >&2
+ echo "$RESPONSE" >&2
+ exit 1
+fi
+
+curl -s -X POST "$API/api/tasks/$TASK_ID/run" > /dev/null
+
+echo "$TASK_ID"
diff --git a/scripts/ct-task b/scripts/ct-task
new file mode 100644
index 0000000..cd3388a
--- /dev/null
+++ b/scripts/ct-task
@@ -0,0 +1,169 @@
+#!/usr/bin/env bash
+# ct-task: Query, retry, cancel, or reset a Claudomator task by ID prefix.
+# Usage:
+# ct-task <id-prefix> — show task state + latest execution error
+# ct-task <id-prefix> retry — reset to PENDING and queue
+# ct-task <id-prefix> run — queue (must already be PENDING)
+# ct-task <id-prefix> reset — reset state to PENDING only
+# ct-task <id-prefix> cancel — cancel task (works from any state)
+# ct-task list [state] — list tasks (optional: filter by state)
+# ct-task cancel-all-failed — cancel every FAILED task
+#
+# Examples:
+# ct-task f8829d6f
+# ct-task f8829d6f retry
+# ct-task f8829d6f cancel
+# ct-task list FAILED
+# ct-task cancel-all-failed
+
+set -euo pipefail
+
+API="http://localhost:8484"
+DB="/site/doot.terst.org/data/claudomator.db"
+
+_jq() { python3 -c "import sys,json; d=json.load(sys.stdin); $1"; }
+
+task_id_from_prefix() {
+ local prefix="$1"
+ local id
+ id=$(sqlite3 "$DB" "SELECT id FROM tasks WHERE id LIKE '${prefix}%' ORDER BY created_at DESC LIMIT 1;")
+ if [[ -z "$id" ]]; then
+ echo "ERROR: no task found matching '${prefix}'" >&2
+ exit 1
+ fi
+ echo "$id"
+}
+
+cmd_show() {
+ local id
+ id=$(task_id_from_prefix "$1")
+ local task
+ task=$(curl -sf "$API/api/tasks/$id")
+ echo "$task" | _jq "
+t=d
+print('ID: ', t['id'])
+print('Name: ', t['name'])
+print('State: ', t['state'])
+print('Project:', t.get('project',''))
+summary = t.get('summary','')
+if summary: print('Summary:', summary)
+rejection = t.get('rejection_comment','')
+if rejection: print('Rejection:', rejection)
+"
+
+ # Latest execution error
+ local execs
+ execs=$(curl -sf "$API/api/tasks/$id/executions" 2>/dev/null || echo "[]")
+ local count
+ count=$(echo "$execs" | _jq "print(len(d))")
+ if [[ "$count" -gt 0 ]]; then
+ echo ""
+ echo "$execs" | _jq "
+e=d[0]
+print('Execution:', e['ID'])
+print('Status: ', e['Status'])
+print('Exit code:', e['ExitCode'])
+err = e.get('ErrorMsg','')
+if err: print('Error: ', err)
+cost = e.get('CostUSD',0)
+if cost: print('Cost: ', f'\${cost:.4f}')
+"
+ fi
+}
+
+cmd_reset() {
+ local id
+ id=$(task_id_from_prefix "$1")
+ sqlite3 "$DB" "UPDATE tasks SET state='PENDING' WHERE id='$id';"
+ echo "Reset $id to PENDING"
+}
+
+cmd_run() {
+ local id
+ id=$(task_id_from_prefix "$1")
+ local resp
+ resp=$(curl -sf -X POST "$API/api/tasks/$id/run" || true)
+ local state
+ state=$(curl -sf "$API/api/tasks/$id" | _jq "print(d['state'])")
+ echo "$id → $state"
+}
+
+cmd_retry() {
+ local id
+ id=$(task_id_from_prefix "$1")
+ sqlite3 "$DB" "UPDATE tasks SET state='PENDING' WHERE id='$id';"
+ curl -sf -X POST "$API/api/tasks/$id/run" >/dev/null || true
+ local state
+ state=$(curl -sf "$API/api/tasks/$id" | _jq "print(d['state'])")
+ echo "$id → $state"
+}
+
+cmd_cancel() {
+ local id
+ id=$(task_id_from_prefix "$1")
+ # Try API cancel first (works for PENDING/QUEUED/RUNNING/BLOCKED)
+ local http_status
+ http_status=$(curl -sf -o /dev/null -w "%{http_code}" -X POST "$API/api/tasks/$id/cancel" 2>/dev/null || echo "000")
+ if [[ "$http_status" == "200" ]]; then
+ echo "$id → CANCELLED"
+ return
+ fi
+ # Terminal states (FAILED, TIMED_OUT, etc.) can't transition via API — force via DB.
+ sqlite3 "$DB" "UPDATE tasks SET state='CANCELLED' WHERE id='$id';"
+ echo "$id → CANCELLED (forced)"
+}
+
+cmd_cancel_all_failed() {
+ local ids
+ ids=$(sqlite3 "$DB" "SELECT id FROM tasks WHERE state='FAILED';")
+ if [[ -z "$ids" ]]; then
+ echo "No FAILED tasks."
+ return
+ fi
+ while IFS= read -r id; do
+ sqlite3 "$DB" "UPDATE tasks SET state='CANCELLED' WHERE id='$id';"
+ echo "${id:0:8}… → CANCELLED"
+ done <<< "$ids"
+}
+
+cmd_list() {
+ local filter="${1:-}"
+ local tasks
+ tasks=$(curl -sf "$API/api/tasks")
+ if [[ -n "$filter" ]]; then
+ echo "$tasks" | _jq "
+for t in d:
+ if t['state'] == '${filter}'.upper() or '${filter}'.upper() in t['state']:
+ print(t['id'][:8], t['state'].ljust(12), t['name'][:60])
+"
+ else
+ echo "$tasks" | _jq "
+for t in d:
+ print(t['id'][:8], t['state'].ljust(12), t['name'][:60])
+"
+ fi
+}
+
+# Dispatch
+PREFIX="${1:-}"
+SUBCMD="${2:-show}"
+
+if [[ -z "$PREFIX" ]]; then
+ echo "Usage: ct-task <id-prefix> [show|retry|run|reset|cancel] OR ct-task list [state] OR ct-task cancel-all-failed" >&2
+ exit 1
+fi
+
+case "$PREFIX" in
+ list) cmd_list "${2:-}" ;;
+ cancel-all-failed) cmd_cancel_all_failed ;;
+ *)
+ case "$SUBCMD" in
+ show|"") cmd_show "$PREFIX" ;;
+ retry) cmd_retry "$PREFIX" ;;
+ run) cmd_run "$PREFIX" ;;
+ reset) cmd_reset "$PREFIX" ;;
+ cancel) cmd_cancel "$PREFIX" ;;
+ *) echo "Unknown subcommand: $SUBCMD" >&2; exit 1 ;;
+ esac
+ ;;
+esac
diff --git a/scripts/debug-execution b/scripts/debug-execution
index 87540b7..b4873b9 100755
--- a/scripts/debug-execution
+++ b/scripts/debug-execution
@@ -1,13 +1,14 @@
#!/usr/bin/env bash
# debug-execution: Show details for a failed task execution from the production DB.
-# Usage: ./scripts/debug-execution <execution-id-or-prefix>
+# Usage: ./scripts/debug-execution [execution-id-or-prefix]
# Example: ./scripts/debug-execution c74c877f
+# If no ID is given, defaults to the most recent execution.
set -euo pipefail
DB="/site/doot.terst.org/data/claudomator.db"
DATA_DIR="/site/doot.terst.org/data"
-PREFIX="${1:?Usage: $0 <execution-id-or-prefix>}"
+PREFIX="${1:-}"
if [[ ! -f "$DB" ]]; then
echo "ERROR: DB not found at $DB" >&2
@@ -15,16 +16,29 @@ if [[ ! -f "$DB" ]]; then
fi
# Look up execution
-ROW=$(sqlite3 "$DB" "
- SELECT id, task_id, exit_code, status, stdout_path, stderr_path, error_msg
- FROM executions
- WHERE id LIKE '${PREFIX}%'
- ORDER BY start_time DESC
- LIMIT 1;
-")
+if [[ -z "$PREFIX" ]]; then
+ ROW=$(sqlite3 "$DB" "
+ SELECT id, task_id, exit_code, status, stdout_path, stderr_path, error_msg
+ FROM executions
+ ORDER BY start_time DESC
+ LIMIT 1;
+ ")
+else
+ ROW=$(sqlite3 "$DB" "
+ SELECT id, task_id, exit_code, status, stdout_path, stderr_path, error_msg
+ FROM executions
+ WHERE id LIKE '${PREFIX}%'
+ ORDER BY start_time DESC
+ LIMIT 1;
+ ")
+fi
if [[ -z "$ROW" ]]; then
- echo "ERROR: No execution found matching '${PREFIX}'" >&2
+ if [[ -z "$PREFIX" ]]; then
+ echo "ERROR: No executions found in DB" >&2
+ else
+ echo "ERROR: No execution found matching '${PREFIX}'" >&2
+ fi
exit 1
fi
diff --git a/scripts/deploy b/scripts/deploy
index c7ff734..2161535 100755
--- a/scripts/deploy
+++ b/scripts/deploy
@@ -21,7 +21,7 @@ REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)"
cd "${REPO_DIR}"
echo "==> Pulling latest from bare repo..."
-git pull --ff-only local master
+git pull --ff-only local main
STASHED=false
if [ "$DIRTY" = false ] && [ -n "$(git status --porcelain)" ]; then
@@ -31,6 +31,9 @@ if [ "$DIRTY" = false ] && [ -n "$(git status --porcelain)" ]; then
trap 'if [ "$STASHED" = true ]; then echo "==> Popping stash..."; git stash pop; fi' EXIT
fi
+echo "==> Verifying (build + tests)..."
+"${REPO_DIR}/scripts/verify"
+
echo "==> Building claudomator..."
export GOCACHE="${SITE_DIR}/cache/go-build"
export GOPATH="${SITE_DIR}/cache/gopath"
@@ -39,23 +42,23 @@ go build -o "${BIN_DIR}/claudomator" ./cmd/claudomator/
echo "==> Copying scripts..."
mkdir -p "${SITE_DIR}/scripts"
-cp "${REPO_DIR}/scripts/"* "${SITE_DIR}/scripts/"
-chown -R www-data:www-data "${SITE_DIR}/scripts"
-chmod +x "${SITE_DIR}/scripts/"*
+find "${REPO_DIR}/scripts" -maxdepth 1 -type f -exec cp -p {} "${SITE_DIR}/scripts/" \;
+
+echo "==> Installing to /usr/local/bin..."
+install -m 755 "${BIN_DIR}/claudomator" /usr/local/bin/claudomator
+
+echo "==> Verifying system CLI version..."
+/usr/local/bin/claudomator version
echo "==> Fixing permissions..."
-chown www-data:www-data "${BIN_DIR}/claudomator"
-chmod +x "${BIN_DIR}/claudomator"
+"${REPO_DIR}/scripts/fix-permissions"
-if [ -f "${BIN_DIR}/claude" ]; then
- echo "==> Fixing Claude permissions..."
- chown www-data:www-data "${BIN_DIR}/claude"
- chmod +x "${BIN_DIR}/claude"
-fi
+echo "==> Syncing credentials..."
+"${REPO_DIR}/scripts/sync-credentials"
-echo "==> Installing to /usr/local/bin..."
-cp "${BIN_DIR}/claudomator" /usr/local/bin/claudomator
-chmod +x /usr/local/bin/claudomator
+echo "==> Ensuring binary and scripts are executable..."
+chmod +x "${BIN_DIR}/claudomator" /usr/local/bin/claudomator
+find "${SITE_DIR}/scripts" -maxdepth 1 -type f -exec chmod +x {} +
echo "==> Restarting service..."
sudo systemctl restart "${SERVICE}"
diff --git a/scripts/drain-failed-tasks b/scripts/drain-failed-tasks
new file mode 100644
index 0000000..4bb6992
--- /dev/null
+++ b/scripts/drain-failed-tasks
@@ -0,0 +1,22 @@
+#!/bin/bash
+# drain-failed-tasks — retry failed tasks by running start-next-task every 5 minutes
+# Usage: ./scripts/drain-failed-tasks [iterations]
+# Default: 29 iterations
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+ITERATIONS="${1:-29}"
+INTERVAL=300 # 5 minutes
+
+echo "Running start-next-task every ${INTERVAL}s for ${ITERATIONS} iterations"
+
+for ((i=1; i<=ITERATIONS; i++)); do
+ echo "[$(date '+%H:%M:%S')] Iteration ${i}/${ITERATIONS}"
+ "$SCRIPT_DIR/start-next-task" || true
+ if [[ $i -lt $ITERATIONS ]]; then
+ sleep "$INTERVAL"
+ fi
+done
+
+echo "[$(date '+%H:%M:%S')] Done."
diff --git a/scripts/fix-permissions b/scripts/fix-permissions
new file mode 100644
index 0000000..408a23e
--- /dev/null
+++ b/scripts/fix-permissions
@@ -0,0 +1,43 @@
+#!/bin/bash
+# claudomator-fix-perms — Fix ownership and permissions for Claudomator components
+set -euo pipefail
+
+SITE_DIR="/site/doot.terst.org"
+GIT_REPOS_DIR="/site/git.terst.org/repos"
+WORKSPACE_DIR="/workspace"
+
+echo "==> Fixing site ownership (www-data:www-data)..."
+chown -R www-data:www-data "${SITE_DIR}"
+
+echo "==> Ensuring binaries are executable..."
+if [ -d "${SITE_DIR}/bin" ]; then
+ find "${SITE_DIR}/bin" -type f -exec chmod +x {} +
+fi
+if [ -f "/usr/local/bin/claudomator" ]; then
+ chmod +x /usr/local/bin/claudomator
+fi
+
+echo "==> Ensuring scripts are executable..."
+if [ -d "${SITE_DIR}/scripts" ]; then
+ find "${SITE_DIR}/scripts" -type f -exec chmod +x {} +
+fi
+if [ -d "${WORKSPACE_DIR}/claudomator/scripts" ]; then
+ find "${WORKSPACE_DIR}/claudomator/scripts" -type f -exec chmod +x {} +
+fi
+
+echo "==> Fixing git bare repo permissions..."
+# Specifically fix object permissions that might be corrupted by root runs
+if [ -d "${GIT_REPOS_DIR}" ]; then
+ chown -R www-data:www-data "${GIT_REPOS_DIR}"
+ find "${GIT_REPOS_DIR}" -type d -exec chmod 775 {} +
+ find "${GIT_REPOS_DIR}" -type f -exec chmod 664 {} +
+fi
+
+echo "==> Fixing database permissions..."
+if [ -f "${SITE_DIR}/data/claudomator.db" ]; then
+ chmod 664 "${SITE_DIR}/data/claudomator.db"
+ # Ensure the data directory is writable for WAL mode
+ chmod 775 "${SITE_DIR}/data"
+fi
+
+echo "==> Done!"
diff --git a/scripts/hooks/pre-commit b/scripts/hooks/pre-commit
new file mode 100644
index 0000000..faf91fc
--- /dev/null
+++ b/scripts/hooks/pre-commit
@@ -0,0 +1,7 @@
+#!/bin/bash
+# pre-commit — Reject commits that don't compile.
+set -euo pipefail
+REPO_DIR="$(git rev-parse --show-toplevel)"
+echo "pre-commit: go build ./..."
+cd "${REPO_DIR}"
+go build ./...
diff --git a/scripts/hooks/pre-push b/scripts/hooks/pre-push
new file mode 100644
index 0000000..d851332
--- /dev/null
+++ b/scripts/hooks/pre-push
@@ -0,0 +1,6 @@
+#!/bin/bash
+# pre-push — Reject pushes where tests fail.
+set -euo pipefail
+REPO_DIR="$(git rev-parse --show-toplevel)"
+echo "pre-push: running scripts/verify..."
+exec "${REPO_DIR}/scripts/verify"
diff --git a/scripts/install-hooks b/scripts/install-hooks
new file mode 100644
index 0000000..454f3cd
--- /dev/null
+++ b/scripts/install-hooks
@@ -0,0 +1,23 @@
+#!/bin/bash
+# install-hooks — Symlink version-controlled hooks into .git/hooks/
+# Usage: ./scripts/install-hooks
+# Example: ./scripts/install-hooks
+
+set -euo pipefail
+
+REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)"
+HOOKS_SRC="${REPO_DIR}/scripts/hooks"
+HOOKS_DST="${REPO_DIR}/.git/hooks"
+
+for hook in "${HOOKS_SRC}"/*; do
+ name="$(basename "${hook}")"
+ target="${HOOKS_DST}/${name}"
+ if [ -e "${target}" ] && [ ! -L "${target}" ]; then
+ echo " skipping ${name}: non-symlink already exists at ${target}"
+ continue
+ fi
+ ln -sf "${hook}" "${target}"
+ echo " installed ${name}"
+done
+
+echo "==> Hooks installed."
diff --git a/scripts/next-task b/scripts/next-task
index c36fc23..697de11 100755
--- a/scripts/next-task
+++ b/scripts/next-task
@@ -32,9 +32,8 @@ fi
if [ -z "$next_task" ]; then
# 4. No child/sibling found: fall back to highest-priority oldest PENDING task
- # Exclude tasks that have a rejection comment or have already been executed
- # to avoid auto-approving rejected tasks.
- FALLBACK_SQL="SELECT id FROM tasks WHERE (state = 'PENDING' AND (rejection_comment IS NULL OR rejection_comment = '') AND id NOT IN (SELECT task_id FROM executions)) OR state = 'QUEUED'
+ # Exclude tasks with a rejection comment to avoid auto-approving rejected tasks.
+ FALLBACK_SQL="SELECT id FROM tasks WHERE (state = 'PENDING' AND (rejection_comment IS NULL OR rejection_comment = '')) OR state = 'QUEUED'
ORDER BY
CASE priority
WHEN 'critical' THEN 4
diff --git a/scripts/reset-failed-tasks b/scripts/reset-failed-tasks
index eddfff0..1f3b6d5 100755
--- a/scripts/reset-failed-tasks
+++ b/scripts/reset-failed-tasks
@@ -1,5 +1,49 @@
#!/bin/bash
+# Reset FAILED and CANCELLED tasks to PENDING and delete their preserved workspaces.
+# Usage: reset-failed-tasks [--dry-run]
-DB_PATH="/site/doot.terst.org/data/claudomator.db"
+set -euo pipefail
-sqlite3 "$DB_PATH" "UPDATE tasks SET state = 'PENDING' WHERE state = 'FAILED';"
+DB_PATH="${CLAUDOMATOR_DB:-/site/doot.terst.org/data/claudomator.db}"
+DRY_RUN=false
+[[ "${1:-}" == "--dry-run" ]] && DRY_RUN=true
+
+# Collect preserved sandbox dirs before resetting so we can clean them up.
+SANDBOX_DIRS=$(sqlite3 "$DB_PATH" "
+ SELECT DISTINCT e.sandbox_dir
+ FROM executions e
+ JOIN tasks t ON t.id = e.task_id
+ WHERE t.state IN ('FAILED','CANCELLED')
+ AND e.sandbox_dir IS NOT NULL
+ AND e.sandbox_dir != '';
+")
+
+TASK_COUNT=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM tasks WHERE state IN ('FAILED','CANCELLED');")
+
+echo "Tasks to reset: $TASK_COUNT"
+
+if [[ "$DRY_RUN" == "true" ]]; then
+ echo "[dry-run] Would reset $TASK_COUNT task(s) to PENDING."
+ if [[ -n "$SANDBOX_DIRS" ]]; then
+ echo "[dry-run] Workspaces to delete:"
+ echo "$SANDBOX_DIRS"
+ else
+ echo "[dry-run] No preserved workspaces to delete."
+ fi
+ exit 0
+fi
+
+sqlite3 "$DB_PATH" "UPDATE tasks SET state = 'PENDING' WHERE state IN ('FAILED','CANCELLED');"
+echo "Reset $TASK_COUNT task(s) to PENDING."
+
+DELETED=0
+while IFS= read -r dir; do
+ [[ -z "$dir" ]] && continue
+ if [[ -d "$dir" ]]; then
+ rm -rf "$dir"
+ echo "Deleted workspace: $dir"
+ DELETED=$((DELETED + 1))
+ fi
+done <<< "$SANDBOX_DIRS"
+
+echo "Deleted $DELETED workspace(s)."
diff --git a/scripts/sync-credentials b/scripts/sync-credentials
new file mode 100644
index 0000000..22e3f75
--- /dev/null
+++ b/scripts/sync-credentials
@@ -0,0 +1,49 @@
+#!/bin/bash
+# sync-credentials — copies Claude and Gemini credentials to workspace
+
+set -euo pipefail
+
+# This script is intended to be run by cron every 10 minutes.
+# It copies Claude and Gemini credentials from root home to workspace for claudomator.
+
+# Source paths
+SOURCE_CLAUDE="/root/.claude/.credentials.json"
+SOURCE_CLAUDE_SETTINGS="/root/.claude.json"
+SOURCE_GEMINI_OAUTH="/root/.gemini/oauth_creds.json"
+SOURCE_GEMINI_ACCOUNTS="/root/.gemini/google_accounts.json"
+
+# Destination paths
+DEST_CLAUDE="/workspace/claudomator/credentials/claude/.credentials.json"
+DEST_CLAUDE_SETTINGS="/workspace/claudomator/credentials/claude/.claude.json"
+DEST_GEMINI_OAUTH="/workspace/claudomator/credentials/gemini/oauth_creds.json"
+DEST_GEMINI_ACCOUNTS="/workspace/claudomator/credentials/gemini/google_accounts.json"
+
+# Sync Claude
+if [[ -f "$SOURCE_CLAUDE" ]]; then
+ mkdir -p "$(dirname "$DEST_CLAUDE")"
+ cp "$SOURCE_CLAUDE" "$DEST_CLAUDE"
+ chown root:www-data "$DEST_CLAUDE" 2>/dev/null || true
+ chmod 640 "$DEST_CLAUDE"
+ echo "Synced Claude credentials."
+fi
+
+if [[ -f "$SOURCE_CLAUDE_SETTINGS" ]]; then
+ cp "$SOURCE_CLAUDE_SETTINGS" "$DEST_CLAUDE_SETTINGS"
+ chmod 644 "$DEST_CLAUDE_SETTINGS"
+ echo "Synced Claude settings."
+fi
+
+# Sync Gemini
+if [[ -f "$SOURCE_GEMINI_OAUTH" ]]; then
+ mkdir -p "$(dirname "$DEST_GEMINI_OAUTH")"
+ cp "$SOURCE_GEMINI_OAUTH" "$DEST_GEMINI_OAUTH"
+ chmod 600 "$DEST_GEMINI_OAUTH"
+ echo "Synced Gemini OAuth credentials."
+fi
+
+if [[ -f "$SOURCE_GEMINI_ACCOUNTS" ]]; then
+ mkdir -p "$(dirname "$DEST_GEMINI_ACCOUNTS")"
+ cp "$SOURCE_GEMINI_ACCOUNTS" "$DEST_GEMINI_ACCOUNTS"
+ chmod 600 "$DEST_GEMINI_ACCOUNTS"
+ echo "Synced Gemini Google accounts."
+fi
diff --git a/scripts/verify b/scripts/verify
new file mode 100644
index 0000000..4f9c52f
--- /dev/null
+++ b/scripts/verify
@@ -0,0 +1,17 @@
+#!/bin/bash
+# verify — Build and test the claudomator codebase
+# Usage: ./scripts/verify
+# Example: ./scripts/verify
+
+set -euo pipefail
+
+REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)"
+cd "${REPO_DIR}"
+
+echo "==> Building..."
+go build ./...
+
+echo "==> Testing (race detector on)..."
+go test -race ./...
+
+echo "==> All checks passed."