A headless job runner for the Ushabti development framework, built on the Claude Agent SDK.
Pharaoh watches a dispatch directory for markdown files, feeds them through the Ushabti Scribe-Builder-Overseer loop via the Agent SDK, manages git branching and pull requests, and reports status through a JSON file on disk. It runs unattended -- no terminal, no human in the loop.
- Node.js 20 or later
- An Anthropic API key set in the environment (
ANTHROPIC_API_KEY) - A project with Ushabti configured (
.ushabti/directory withlaws.md,style.md, etc.) - Git (optional, for automated branching and PRs)
- GitHub CLI (
gh) (optional, for automated pull request creation)
From your project directory (the one with .ushabti/):
npx @adamrdrew/pharaoh servePharaoh will:
- Create
.pharaoh/and.pharaoh/dispatch/if they don't exist - Write
.pharaoh/pharaoh.jsonwith statusidle - Begin watching
.pharaoh/dispatch/for.mdfiles - Log all activity to
.pharaoh/pharaoh.log
To run a phase, drop a dispatch file into the dispatch directory:
cat > .pharaoh/dispatch/my-feature.md << 'EOF'
---
phase: add-login-page
model: opus
---
Build a login page with email and password fields.
Validate that both fields are non-empty before submission.
EOFPharaoh picks up the file, deletes it, executes the Ushabti loop, and writes the result to pharaoh.json. When it finishes, it returns to idle and is ready for the next job.
npx @adamrdrew/pharaoh serve [--model <model>]| Flag | Default | Description |
|---|---|---|
--model |
claude-opus-4-20250514 |
Claude model to use for phase execution |
The server runs in the foreground and responds to SIGTERM and SIGINT (Ctrl+C) for graceful shutdown.
Dispatch files are markdown with optional YAML frontmatter:
---
phase: my-phase-name
model: opus
---
Your phase prompt goes here. This is the content that gets
passed to the Ushabti ir-kat skill as the PHASE_PROMPT.Frontmatter fields:
| Field | Required | Default | Description |
|---|---|---|---|
phase |
No | unnamed-phase |
Human-readable name. Appears in logs, status, git branch names. |
model |
No | opus |
Model identifier passed to the Agent SDK. |
Rules:
- The body (everything after the
---closing delimiter) must be non-empty. - Frontmatter must be valid YAML.
- Malformed files are logged as errors and deleted without blocking the server.
Read .pharaoh/pharaoh.json to check what Pharaoh is doing:
cat .pharaoh/pharaoh.jsonThe file is one of four shapes, determined by the status field:
idle -- ready for work:
{
"status": "idle",
"pid": 48210,
"started": "2026-02-09T15:00:00.000Z"
}busy -- executing a phase:
{
"status": "busy",
"pid": 48210,
"started": "2026-02-09T15:00:00.000Z",
"phase": "add-login-page",
"phaseStarted": "2026-02-09T15:01:00.000Z"
}done -- phase completed successfully:
{
"status": "done",
"pid": 48210,
"started": "2026-02-09T15:00:00.000Z",
"phase": "add-login-page",
"phaseStarted": "2026-02-09T15:01:00.000Z",
"phaseCompleted": "2026-02-09T15:05:00.000Z",
"costUsd": 0.45,
"turns": 12
}blocked -- phase failed:
{
"status": "blocked",
"pid": 48210,
"started": "2026-02-09T15:00:00.000Z",
"phase": "add-login-page",
"phaseStarted": "2026-02-09T15:01:00.000Z",
"phaseCompleted": "2026-02-09T15:05:00.000Z",
"error": "Max turns reached",
"costUsd": 1.20,
"turns": 200
}Status transitions follow this state machine:
idle --> busy --> done --> idle
\--> blocked --> idle
The file is written atomically (write to .tmp, then rename), so partial reads are not possible.
tail -f .pharaoh/pharaoh.logLog entries are timestamped and structured:
[2026-02-09 15:00:00] [INFO] Pharaoh server starting {"pid":48210,"cwd":"/home/user/my-project"}
[2026-02-09 15:00:00] [INFO] Watcher started {"path":"/home/user/my-project/.pharaoh/dispatch"}
[2026-02-09 15:01:00] [INFO] Processing dispatch file {"path":"..."}
[2026-02-09 15:01:00] [INFO] Dispatch file parsed {"phase":"add-login-page","model":"opus"}
[2026-02-09 15:01:00] [INFO] Created feature branch {"branch":"pharaoh/add-login-page"}
[2026-02-09 15:01:00] [INFO] Starting phase execution {"phase":"add-login-page"}
[2026-02-09 15:05:00] [INFO] Phase completed successfully {"phase":"add-login-page","turns":12,"costUsd":0.45}
[2026-02-09 15:05:00] [INFO] Pushed branch {"branch":"pharaoh/add-login-page"}
[2026-02-09 15:05:00] [INFO] Opened pull request {"phase":"add-login-page"}
Log levels: DEBUG, INFO, WARN, ERROR.
Send SIGTERM or SIGINT:
kill -TERM $(jq -r .pid .pharaoh/pharaoh.json)
# or just Ctrl+C if running in the foregroundPharaoh shuts down gracefully: stops the watcher, removes pharaoh.json, and logs the shutdown.
When Pharaoh runs inside a git repository, it automates the branching and PR workflow around each phase.
- Checks that the current branch is
mainormaster - Checks that the working tree is clean
- Pulls latest changes from the remote
- Creates a feature branch named
pharaoh/<phase-slug>(e.g.,pharaoh/add-login-page)
If any check fails, it logs a warning and continues with phase execution anyway.
- Stages all changes (
git add -A) - Commits with the message:
Phase <name> complete - Pushes the branch to origin
- Opens a pull request via
gh pr create(if the GitHub CLI is installed)
If gh is not available, steps 1-3 still run and the log tells you to create the PR manually.
When not inside a git repository, all git operations are silently skipped. Pharaoh works anywhere.
Pharaoh is a long-running process that converts filesystem events into Ushabti phase executions.
- A
.mdfile appears in.pharaoh/dispatch/ - The watcher detects the file via chokidar
- Pharaoh reads and parses the frontmatter and body, then deletes the dispatch file
- Status is set to
busy - Git pre-phase: branch creation from
main - The phase prompt is sent to the Claude Agent SDK, which invokes the Ushabti
/ir-katskill (Scribe plans, Builder implements, Overseer reviews) - After the SDK query completes, a lightweight verification query checks that the Ushabti loop actually reached a terminal state
- Git post-phase: commit, push, and PR (on success only)
- Status is set to
doneorblocked, then back toidle
Pharaoh processes one job at a time. If a dispatch file arrives while a phase is running, it is queued and processed when the current phase finishes. Jobs are processed in the order they arrive.
Pharaoh blocks the AskUserQuestion tool via a PreToolUse hook. When the agent tries to ask a question, it receives "Proceed with your best judgement" and continues autonomously. This is what makes Pharaoh headless -- no human interaction is required or possible during execution.
After the main SDK query finishes, Pharaoh runs a second, lightweight query using /phase-status latest (with a 10-turn limit on Sonnet) to confirm the Ushabti loop reached a terminal state. If the phase is still in an incomplete state like building or planned, the result is reported as blocked even if the SDK query itself returned successfully. This prevents false positives from early agent exits.
.pharaoh/
dispatch/ # Drop .md files here to trigger phases
pharaoh.json # Current server status (atomic writes)
pharaoh.log # Structured log file
.ushabti/ # Ushabti framework configuration
laws.md # Project invariants
style.md # Code conventions
docs/ # Project documentation
phases/ # Numbered phase directories
To work on Pharaoh itself:
git clone git@github.com:adamrdrew/pharaoh.git
cd pharaoh
npm install
npm run build # TypeScript compilation
npm run typecheck # Type-check without emitting
npm test # Run all tests
npm run test:watch # Run tests in watch mode
npm run serve # Run from source via tsxPharaoh depends on Ushabti via a GitHub Git reference (github:adamrdrew/ushabti). npm resolves this to the latest commit on Ushabti's default branch. The lockfile pins a specific commit hash, so Pharaoh won't pick up new Ushabti changes until you explicitly update.
1. Push your changes in Ushabti first
cd /path/to/ushabti
# Bump version in package.json if appropriate
git add -A && git commit -m "Your changes"
git push origin master2. Update the lockfile in Pharaoh
cd /path/to/pharaoh
npm update ushabtiThis fetches the latest commit from GitHub and updates package-lock.json with the new commit hash and version. Verify with:
grep -A3 '"node_modules/ushabti"' package-lock.jsonYou should see the new version and commit hash.
3. Bump Pharaoh's version
# In package.json, bump the "version" field (e.g. 0.1.5 -> 0.1.6)4. Build and test
npm run build
npm test5. Commit and push Pharaoh
git add package.json package-lock.json
git commit -m "Bump ushabti to <version>"
git push origin master6. Publish to npm
npm publish --access publicThis runs prepublishOnly (which calls npm run build) automatically. The new version is then available via npx @adamrdrew/pharaoh serve.
7. Clear stale npx caches
npx caches resolved packages in ~/.npm/_npx/. If you (or a consumer) previously ran npx @adamrdrew/pharaoh serve, the old version persists even after publishing. To pick up the new version:
# Find which cache entry has Pharaoh
ls ~/.npm/_npx/*/node_modules/@adamrdrew/pharaoh/package.json 2>/dev/null
# Delete the stale cache directory (the hash will vary)
rm -rf ~/.npm/_npx/<hash>The next npx @adamrdrew/pharaoh serve invocation will fetch the latest published version.
Quick reference (the whole thing in one go):
# In ushabti/
git push origin master
# In pharaoh/
npm update ushabti
# Edit package.json version
npm run build && npm test
git add package.json package-lock.json
git commit -m "Bump ushabti to <version>"
git push origin master
npm publish --access publicMIT