diff --git a/apps/bot/.env.example b/apps/bot/.env.example
new file mode 100644
index 0000000..7950c0e
--- /dev/null
+++ b/apps/bot/.env.example
@@ -0,0 +1,17 @@
+# Wright Telegram Bot — environment variables
+# Copy to .env and fill in values: cp .env.example .env
+
+# Telegram bot token from @BotFather
+BOT_TOKEN=
+
+# Supabase project URL
+SUPABASE_URL=https://xndulzwwzioghxhfgzig.supabase.co
+
+# Supabase service_role key (NOT the anon key)
+SUPABASE_KEY=
+
+# GitHub personal access token (needs repo scope for PR creation)
+GITHUB_TOKEN=
+
+# Comma-separated Telegram user IDs allowed to use the bot (empty = no restriction)
+ALLOWED_TELEGRAM_USERS=
diff --git a/apps/bot/Dockerfile b/apps/bot/Dockerfile
new file mode 100644
index 0000000..8c1d1e6
--- /dev/null
+++ b/apps/bot/Dockerfile
@@ -0,0 +1,80 @@
+# =============================================================================
+# Wright Bot -- Production Multi-Stage Dockerfile
+#
+# Stages:
+# 1. deps -- install pnpm + all workspace dependencies
+# 2. build -- compile TypeScript (shared + bot)
+# 3. runtime -- minimal image with built artifacts
+#
+# Build context: repo root (run with `docker build -f apps/bot/Dockerfile .`)
+# =============================================================================
+
+# ---------------------------------------------------------------------------
+# Stage 1: Install dependencies
+# ---------------------------------------------------------------------------
+FROM node:22-slim AS deps
+
+RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
+
+WORKDIR /app
+
+# Copy only what pnpm needs for dependency resolution (layer cache)
+COPY package.json pnpm-workspace.yaml pnpm-lock.yaml* turbo.json ./
+COPY packages/shared/package.json packages/shared/
+COPY apps/bot/package.json apps/bot/
+
+# Install all deps (including devDependencies needed for build)
+RUN pnpm install --frozen-lockfile || pnpm install
+
+# ---------------------------------------------------------------------------
+# Stage 2: Build TypeScript
+# ---------------------------------------------------------------------------
+FROM deps AS build
+
+# Copy source files
+COPY tsconfig.base.json ./
+COPY packages/shared/tsconfig.json packages/shared/
+COPY packages/shared/src/ packages/shared/src/
+COPY apps/bot/tsconfig.json apps/bot/
+COPY apps/bot/src/ apps/bot/src/
+
+# Build shared first, then bot
+RUN pnpm --filter @wright/shared build && pnpm --filter @wright/bot build
+
+# Prune devDependencies for a leaner copy into runtime.
+RUN pnpm prune --prod \
+ && mkdir -p packages/shared/node_modules apps/bot/node_modules
+
+# ---------------------------------------------------------------------------
+# Stage 3: Runtime
+# ---------------------------------------------------------------------------
+FROM node:22-slim AS runtime
+
+WORKDIR /app
+
+# Copy workspace manifests (needed for pnpm workspace resolution at runtime)
+COPY --from=build /app/package.json /app/pnpm-workspace.yaml ./
+
+# Copy shared package (built artifacts + package.json)
+COPY --from=build /app/packages/shared/package.json packages/shared/
+COPY --from=build /app/packages/shared/dist/ packages/shared/dist/
+
+# Copy bot (built artifacts + package.json)
+COPY --from=build /app/apps/bot/package.json apps/bot/
+COPY --from=build /app/apps/bot/dist/ apps/bot/dist/
+
+# Copy production node_modules from pruned build stage
+COPY --from=build /app/node_modules/ node_modules/
+COPY --from=build /app/packages/shared/node_modules/ packages/shared/node_modules/
+COPY --from=build /app/apps/bot/node_modules/ apps/bot/node_modules/
+
+# Non-root user
+RUN groupadd --gid 1001 wright \
+ && useradd --uid 1001 --gid wright --shell /bin/bash --create-home wright \
+ && chown -R wright:wright /app
+
+USER wright
+
+WORKDIR /app/apps/bot
+
+CMD ["node", "dist/index.js"]
diff --git a/apps/bot/fly.toml b/apps/bot/fly.toml
new file mode 100644
index 0000000..23b1967
--- /dev/null
+++ b/apps/bot/fly.toml
@@ -0,0 +1,16 @@
+# Fly.io configuration for the wright Telegram bot
+# Long-polling bot — no HTTP service needed, just a persistent process
+
+app = "wright-bot"
+primary_region = "ord"
+
+[build]
+ dockerfile = "Dockerfile"
+
+[env]
+ NODE_ENV = "production"
+
+[[vm]]
+ memory = "256mb"
+ cpu_kind = "shared"
+ cpus = 1
diff --git a/apps/worker/.env.example b/apps/worker/.env.example
new file mode 100644
index 0000000..806da9c
--- /dev/null
+++ b/apps/worker/.env.example
@@ -0,0 +1,24 @@
+# Wright Worker — environment variables (for local development)
+# Copy to .env and fill in values: cp .env.example .env
+# In production (Fly.io), these are set via `fly secrets set`.
+
+# Supabase project URL
+SUPABASE_URL=https://xndulzwwzioghxhfgzig.supabase.co
+
+# Supabase service_role key (NOT the anon key)
+SUPABASE_SERVICE_ROLE_KEY=
+
+# Anthropic API key for Claude sessions
+ANTHROPIC_API_KEY=
+
+# Optional: Claude model to use (default: claude-sonnet-4-20250514)
+# CLAUDE_MODEL=claude-sonnet-4-20250514
+
+# Optional: Max turns per Claude session loop (default: 30)
+# MAX_TURNS_PER_LOOP=30
+
+# Optional: Test timeout in seconds (default: 300)
+# TEST_TIMEOUT_SECONDS=300
+
+# Optional: Workspace directory for cloned repos (default: /tmp/wright-work)
+# WORKSPACE_DIR=/tmp/wright-work
diff --git a/apps/worker/src/dev-loop.ts b/apps/worker/src/dev-loop.ts
index 40aa19c..04fe7ae 100644
--- a/apps/worker/src/dev-loop.ts
+++ b/apps/worker/src/dev-loop.ts
@@ -16,8 +16,20 @@ import { detectTestRunner, detectPackageManager, installDependencies, runTests }
import { runClaudeSession } from './claude-session.js'
import { existsSync, rmSync, mkdirSync } from 'fs'
+/**
+ * Strip Telegram HTML-like formatting delimiters (e.g. ``, ``, ``) from
+ * raw message text so they don't leak into prompts, PR bodies, or commit messages.
+ */
+function sanitizeTaskText(text: string): string {
+ return text.replace(/<\/?[^>]+>/g, '').trim()
+}
+
export async function runDevLoop(config: DevLoopConfig): Promise {
const { job } = config
+ // Sanitize task text early so all downstream uses (prompts, PR body, commit messages)
+ // receive clean text without Telegram formatting delimiters.
+ job.task = sanitizeTaskText(job.task)
+
const maxLoops = job.max_loops
const maxBudget = job.max_budget_usd
const maxTurns = config.maxTurnsPerLoop || DEFAULT_MAX_TURNS_PER_LOOP
@@ -195,7 +207,7 @@ export async function runDevLoop(config: DevLoopConfig): Promise
})
const eventType =
- lastTestResults.failed === 0 && lastTestResults.total > 0
+ lastTestResults.failed === 0 && lastTestResults.errors === 0
? 'test_pass'
: 'test_fail'
await emit(supabase, job.id, eventType, loop, {
@@ -230,7 +242,7 @@ export async function runDevLoop(config: DevLoopConfig): Promise
// 5c. Check pass
allTestsPassed =
- lastTestResults.failed === 0 && lastTestResults.total > 0
+ lastTestResults.failed === 0 && lastTestResults.errors === 0
}
// 6. Commit and push
@@ -238,7 +250,7 @@ export async function runDevLoop(config: DevLoopConfig): Promise
? `feat: ${job.task.slice(0, 60)}`
: `wip: ${job.task.slice(0, 60)} (${lastTestResults.passed}/${lastTestResults.total} tests passing)`
- const commitSha = await commitAndPush(workDir, commitMessage)
+ const commitSha = await commitAndPush(workDir, commitMessage, job.github_token)
// 7. Create PR
let prUrl: string | undefined
@@ -248,6 +260,7 @@ export async function runDevLoop(config: DevLoopConfig): Promise
job.task.slice(0, 70),
buildPrBody(job.task, lastTestResults, loopsCompleted, totalCost),
job.branch,
+ job.github_token,
)
await emit(supabase, job.id, 'pr_created', undefined, { prUrl })
} catch (err) {
@@ -304,12 +317,19 @@ ${workDir}
- Dependencies are already installed
## Rules
-1. Make changes to implement the requested task
-2. After making changes, run tests to verify they pass
-3. If tests fail, fix the code (not the tests) unless the tests are clearly wrong
-4. Keep changes minimal and focused on the task
-5. Do not refactor unrelated code
-6. Do not add unnecessary dependencies`
+1. Make changes to implement the requested task.
+2. After making changes, run tests to verify they pass.
+3. If tests fail, fix the code (not the tests) unless the tests are clearly wrong.
+4. Keep changes minimal and focused ONLY on the files directly required by the task.
+5. Do not refactor unrelated code.
+6. Do not add unnecessary dependencies.
+
+## Hard constraints — NEVER violate these
+- NEVER modify test files unless the task explicitly asks for test changes.
+- NEVER modify package manifests (package.json, pyproject.toml, Cargo.toml, go.mod, setup.py, setup.cfg, etc.) unless the task explicitly requires adding or removing a dependency.
+- NEVER modify lock files (uv.lock, pnpm-lock.yaml, package-lock.json, yarn.lock, Cargo.lock, poetry.lock, etc.) under any circumstances.
+- If the task is about documentation (README, docs/, *.md), ONLY modify documentation files. Do NOT touch source code, tests, or manifests.
+- When in doubt about whether a file is in scope, leave it unchanged.`
}
function buildInitialPrompt(
diff --git a/apps/worker/src/github-ops.ts b/apps/worker/src/github-ops.ts
index a6f344a..53068ba 100644
--- a/apps/worker/src/github-ops.ts
+++ b/apps/worker/src/github-ops.ts
@@ -7,6 +7,10 @@ export async function cloneRepo(repoUrl: string, workDir: string, token: string,
try {
const cloneArgs = branch ? ['--branch', branch] : []
await git.clone(authedUrl, workDir, cloneArgs)
+ // Strip credentials from the stored remote URL so they aren't
+ // accessible via .git/config (e.g. by the Claude subprocess).
+ const repoGit = simpleGit(workDir)
+ await repoGit.remote(['set-url', 'origin', repoUrl])
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
throw new Error(sanitizeGitError(msg, cleanToken))
@@ -31,7 +35,7 @@ export async function createFeatureBranch(
return branchName
}
-export async function commitAndPush(workDir: string, message: string): Promise {
+export async function commitAndPush(workDir: string, message: string, githubToken?: string): Promise {
const git = simpleGit(workDir)
await git.addConfig('user.email', 'wright@openadaptai.noreply.github.com')
@@ -47,9 +51,36 @@ export async function commitAndPush(workDir: string, message: string): Promise {})
+ }
+ }
+ }
const log = await git.log({ maxCount: 1 })
return log.latest?.hash || ''
@@ -57,18 +88,28 @@ export async function commitAndPush(workDir: string, message: string): Promise {
const { execFileSync } = await import('child_process')
+ // Minimal env — only what `gh` needs. Avoids leaking SUPABASE_SERVICE_ROLE_KEY etc.
+ const env: Record = {
+ PATH: process.env.PATH || '/usr/local/bin:/usr/bin:/bin',
+ HOME: process.env.HOME || '/home/wright',
+ }
+ if (githubToken) {
+ env.GH_TOKEN = githubToken
+ }
const result = execFileSync(
'gh',
['pr', 'create', '--title', title, '--body', body, '--base', baseBranch],
- { cwd: workDir, encoding: 'utf-8' },
+ { cwd: workDir, encoding: 'utf-8', env },
)
return result.trim() // Returns the PR URL
}