Whilly — Task Orchestrator

Python-based task orchestrator that runs Claude CLI agents to execute tasks from a JSON plan file.

Quick Start

# Install — prod (default, released version, isolated CLI on macOS/Linux/Windows)
pipx install whilly-orchestrator
# Contributor install instead? See README "For contributors (dev)" or: make install-dev

# First run: let whilly tell you where your user config lives
whilly --config path

# Either migrate a legacy .env, or copy the template:
whilly --config migrate                           # if you have an old .env
cp whilly.example.toml whilly.toml                # or start fresh from template

# Run with a specific plan
whilly .planning/my_tasks.json

# Auto-discover plans in the current directory
whilly

# Run all discovered plans sequentially
whilly --all

# Pull open GitHub issues as tasks and start immediately
whilly --from-github whilly:ready --go

# One specific issue or Jira ticket
whilly --from-issue owner/repo/42 --go           # slash form — shell-safe
whilly --from-issue 'owner/repo#42' --go         # '#' form, quote in zsh/bash
whilly jira import ABC-123 --run                 # single Jira ticket
whilly jira intake ABC-123                       # interactive Jira + repo choice

Task sources

Flag Source Notes
--from-github <label> GitHub issues by label all/*/- = no filter
--from-issue <ref> one GitHub issue owner/repo/N, owner/repo#N, or URL
jira import <key> one Jira ticket ABC-123 or browse URL; auth via [jira]; legacy --from-jira <key> --go still works
jira intake <key> one Jira ticket + repo choice interactive same/new/other repo selection, then PRD/context, plan preflight, autonomous run, or save-only
--from-project <url> GitHub Projects v2 board full board items
--from-issues-project <url> --repo o/r Projects board filtered by issue repo  

Every source writes to an idempotent plan file (tasks-…json); re-running refreshes description/priority/labels without losing status.

For Jira, whilly jira import validates auth before fetching. If JIRA_SERVER_URL, JIRA_USERNAME, or JIRA_API_TOKEN is missing, an interactive terminal prompts for it. Missing PAT opens the Jira Cloud API token page; non-interactive runs print the exact variables and the same URL. Use --no-interactive-config to force instructions-only behavior.

whilly jira intake is the safer daily driver when Jira does not imply a repo. It writes a top-level repo_targets block into the generated plan and sets each Jira task’s repo_target_id. --action plan runs strict apply plus TRIZ preflight; --action run runs strict apply before starting a worker. For GitLab, pass or paste the clone URL:

whilly jira intake ABC-123 --repo-url git@gitlab.example:group/project.git --action prd

Every intake also writes a jira_work block into the plan JSON. It records the classified work kind (feature, bug, task, devops), normal/hotfix urgency, context hashes for later Jira refresh checks, and supported Jira comment commands such as /whilly plan and /whilly run.

Before an autonomous run, point Whilly at a local checkout when you want a read-only code/test readiness gate:

whilly jira intake ABC-123 --repo-kind same --readiness-repo-path . --action run

The gate detects common test commands (pytest, npm test, go test, mvn test, gradle test, cargo test) and unit-test files. If the verdict is needs_test_plan, needs_repo_choice, needs_human_context, or blocked, action=run stops before importing/running unless you pass --allow-unready-run.

To refresh an already-known Jira issue without starting work, run one poll cycle:

whilly jira poll ABC-123
whilly jira poll ABC-123 --persist --plan-id jira-abc-123

poll rereads the issue, comments, changelog, linked issues, remote links, and GitLab/GitHub repo hints. With --persist it writes the snapshot into the Postgres jira_work_sessions / jira_work_events history tables. A long-running watcher can wrap this command in a scheduler or loop.

Lifecycle sync

Two integrations drive cards/tickets automatically as whilly task statuses change. Enable one or both in whilly.toml.

GitHub Projects v2

[project_board]
url = "https://github.com/users/you/projects/4"
enabled = true
default_repo = "you/your-repo"

[project_board.status_mapping]    # optional
in_progress = "Doing"

Requires gh auth refresh -s project once.

Jira

[jira]
server_url = "https://company.atlassian.net"
username   = "you@example.com"
token      = "keyring:whilly/jira"
enabled    = true
enable_board_sync = true

[jira.status_mapping]             # optional
in_progress = "Doing"
done        = "Review"

Drives Jira transitions via REST v3. Uses urllib stdlib — no extra deps.

Status mapping (defaults)

whilly GitHub column Jira transition
pending Todo To Do
in_progress In Progress In Progress
done (PR open) In Review In Review
merged Done Done
failed Failed Failed
skipped Refused Cancelled
blocked On Hold Blocked
human_loop Human Loop Waiting for Customer

Companion commands

export PLAN_FILE=tasks.json                    # placeholder — your plan json path
whilly --config show                           # merged config, secrets redacted
whilly --config path                           # OS-native user config location
whilly --config migrate                        # legacy .env → whilly.toml + keyring
whilly --ensure-board-statuses                 # create missing Projects v2 columns
whilly --post-merge "$PLAN_FILE"               # after an out-of-band merge: flush cards/tickets to Done

Inspecting task logs

Every plan run writes per-task artifacts under whilly_logs/:

File Content
whilly_logs/{task_id}.log Full stdout of the Claude CLI subprocess (or tmux pipe).
whilly_logs/{task_id}_prompt.txt Final prompt sent to the agent.
whilly_logs/tasks/{task_id}.events.jsonl Per-task structured timeline (start, retries, complete, skip).
whilly_logs/whilly_events.jsonl Global timeline (all tasks + plan-level events).
whilly_logs/tasks/http_trace.jsonl HTTP body capture (only with --trace).
whilly.log (rotated 10 MB × 5) The orchestrator’s own logger.

Three viewer subcommands sit in front of these files — no Rich, no extra deps:

export TASK_ID=TASK-001               # placeholder — your task id
whilly logs --list                    # table: task_id, status, duration, cost, last event
whilly logs "$TASK_ID"                # prompt + events timeline + stdout for one task
whilly logs --tail "$TASK_ID"         # live follow (also -f); Ctrl-C to exit

whilly logs is a read-only viewer — it does not run the startup banner, does not load a plan, and is safe to run while another Whilly is mid-flight in the same directory.

Verbose modes

By default Whilly only captures the Claude CLI’s stdout (the final JSON block). To see the HTTP traffic between Claude CLI and the Anthropic API, escalate:

whilly --verbose --tasks tasks.json   # ANTHROPIC_LOG=info — request lines, no bodies
whilly --trace   --tasks tasks.json   # ANTHROPIC_LOG=debug + http_trace.jsonl (full bodies)

--trace is loud: bodies grow logs by ~10–50× and may contain API keys and full prompts. Use it for one-off debugging, not for routine runs. Whilly prints a red warning banner whenever --trace is on and tags the event in whilly_events.jsonl so you can audit the file’s lineage later.

Cleanup

run_plan runs an age-based cleanup at startup. Files older than WHILLY_LOG_TTL_DAYS (default 14) are deleted from whilly_logs/ and whilly_logs/tasks/. The global whilly_events.jsonl and the rotating whilly.log* are spared — they have their own retention policies.

WHILLY_LOG_TTL_DAYS=0 whilly --tasks tasks.json   # disable cleanup entirely
WHILLY_LOG_TTL_DAYS=3 whilly --tasks tasks.json   # aggressive: keep last 3 days

Human-in-the-loop backend

claude_handoff pauses each task and waits for an external operator (or an interactive Claude session) to do the work:

WHILLY_AGENT_BACKEND=claude_handoff whilly --from-issue alice/repo/42 --go
# whilly writes .whilly/handoff/GH-42/prompt.md and blocks

whilly --handoff-list
whilly --handoff-show GH-42
whilly --handoff-complete GH-42 --status complete --message "done"
#                              ^^^^^^^^ complete / failed / blocked / human_loop / partial

blocked and human_loop signal “task can’t finish without help” — they land in the corresponding board column without being misreported as failed.

Configuration

Whilly reads its config through five layers, last wins:

defaults (dataclass)
  ↓
user TOML    — OS-native per-user config
  ↓
repo TOML    — ./whilly.toml  (project-local overrides)
  ↓
.env         — legacy, loads with a deprecation warning
  ↓
shell env    — WHILLY_* variables
  ↓
CLI flags    — highest precedence

User config location

whilly --config path prints the OS-native location:

OS Path
macOS ~/Library/Application Support/whilly/config.toml
Linux $XDG_CONFIG_HOME/whilly/config.toml (default ~/.config/whilly/config.toml)
Windows %APPDATA%\whilly\config.toml

Inspect the merged config

whilly --config show          # merged result, secrets redacted
whilly --config edit          # open user config in $EDITOR

Example whilly.toml

# Core loop
MAX_PARALLEL = 1             # concurrent agents (1 = sequential)
MAX_ITERATIONS = 0           # 0 = unlimited
BUDGET_USD = 0               # 0 = unlimited; warns at 80 %, kills at 100 %

# Agent backend
AGENT_BACKEND = "claude"
MODEL = "claude-opus-4-7[1m]"

# Workspace + tmux
USE_WORKSPACE = false           # v3.3.0: off by default; set to true or pass --workspace to enable
USE_TMUX = false

# Logging
LOG_DIR = "whilly_logs"
VOICE = false

# External integrations
CLOSE_EXTERNAL_TASKS = true
GITHUB_AUTO_CLOSE = true

# GitHub auth — cross-platform secrets
# Schemes: env:NAME, keyring:service/user, file:/path, literal
[github]
token = "keyring:whilly/github"

# Jira (optional)
[jira]
# server_url = "https://jira.example.com"
# username   = "you@example.com"
# token      = "keyring:whilly/jira"

Store secrets once, per OS:

python3 -c "import keyring; keyring.set_password('whilly', 'github', 'ghp_xxx')"

GitHub auth resolution order

Used by whilly/gh_utils.py::gh_subprocess_env() when invoking gh:

  1. WHILLY_GH_TOKEN → overrides everything, just for whilly’s subprocesses
  2. WHILLY_GH_PREFER_KEYRING=1 → strips env tokens, forces gh to use its own keyring
  3. [github].token in whilly.toml → resolved via env: / keyring: / file: schemes
  4. Ambient GITHUB_TOKEN / GH_TOKEN — passed through unchanged (cross-platform default)

Full env var reference (back-compat)

Variable Default Description
WHILLY_MAX_ITERATIONS 0 (unlimited) Max work iterations per plan
WHILLY_MAX_PARALLEL 3 Concurrent agents (1 = sequential)
WHILLY_MAX_TASK_RETRIES 5 Retries before a task is skipped/failed
WHILLY_BUDGET_USD 0 0 = unlimited; warns at 80 %, kills at 100 %
WHILLY_TIMEOUT 0 Wall-clock seconds per plan (0 = unlimited)
WHILLY_AGENT_BACKEND claude claude or opencode
WHILLY_MODEL claude-opus-4-6[1m] LLM model
WHILLY_USE_TMUX 0 Run each agent in its own tmux session
WHILLY_USE_WORKSPACE 0 Plan-level git worktree workspace (off by default since v3.3.0; set 1 or use --workspace to enable)
WHILLY_LOG_DIR whilly_logs Directory for per-task logs
WHILLY_LOG_TTL_DAYS 14 Delete agent logs older than N days at run start (0 = disabled)
WHILLY_VERBOSE 0 Same as --verbose/-v: sets ANTHROPIC_LOG=info (HTTP request lines)
WHILLY_TRACE_HTTP 0 Same as --trace: ANTHROPIC_LOG=debug + tasks/http_trace.jsonl (full bodies)
WHILLY_ORCHESTRATOR file file (key-files collisions) or llm (LLM batching)
WHILLY_VOICE 1 macOS voice notifications
SLACK_ACCESS_TOKEN (unset) Slack bot/user token; whilly run posts a summary to WHILLY_SLACK_CHANNEL when set (also accepts WHILLY_SLACK_ACCESS_TOKEN)
WHILLY_SLACK_CHANNEL (unset) / demo default C0B1WT58EBE Target channel id, e.g. C0B1WT58EBE — must be a channel the token’s app/user is a member of
WHILLY_SLACK_ENABLED 1 Kill switch; setting to 0 skips Slack even when token + channel are set
WHILLY_SLACK_API_BASE_URL https://slack.com/api Override the API root (test stubs / on-prem proxies)
WHILLY_SLACK_TIMEOUT_S 5.0 HTTP timeout for chat.postMessage
WHILLY_SLACK_MESSAGE_TEMPLATE (see whilly/config.py) str.format-style template; placeholders match RunCompletedEvent fields plus completed_at_iso
WHILLY_SLACK_WEBHOOK_URL (unset) Optional Incoming Webhook for per-task demo messages from workers; otherwise demo workers can use SLACK_ACCESS_TOKEN
WHILLY_SLACK_NOTIFY_EVENTS terminal Demo webhook events: terminal, started, all, or none
WHILLY_PUBLIC_BASE_URL http://127.0.0.1:8000 Base URL used in Slack links to /llm-ops
WHILLY_HEADLESS 0 JSON stdout, no TUI (auto when stdout is not a TTY)
WHILLY_DECOMPOSE_EVERY 5 Re-plan oversized pending tasks every N iterations
WHILLY_AUTO_MERGE ask ask / yes / claude / no on plan completion
WHILLY_GH_TOKEN (unset) Whilly-only GitHub token (overrides ambient)
WHILLY_GH_PREFER_KEYRING 0 Force gh keyring auth even when GITHUB_TOKEN is set
WHILLY_SUPPRESS_DOTENV_WARNING 0 Silence the legacy .env deprecation warning

Every WHILLY_* variable corresponds to an equivalent whilly.toml field (same name, any case). See whilly.example.toml for the complete template.

Slack run-completed notifications

whilly run posts one Slack message per invocation, summarising the plan id, worker id, hostname, and WorkerStats counters. The hook is in :mod:whilly.cli.run; the adapter is :mod:whilly.adapters.notifications.slack.

Operator setup (one-time):

  1. Create a Slack app, add the chat:write scope, install it into the target workspace.
  2. Either invite the bot to channel C0B1WT58EBE (or your own) or use a user token whose owner is a member of that channel.
  3. Export the env vars:

    export SLACK_ACCESS_TOKEN=xoxb-...        # or xoxe.xoxp-... (rotated user)
    export WHILLY_SLACK_CHANNEL=C0B1WT58EBE
    

The feature stays off when SLACK_ACCESS_TOKEN is empty (:func:whilly.adapters.notifications.factory.make_notifier returns a no-op NullNotifier). Slack outages and chat.postMessage {"ok": false} responses are logged at WARNING but never change the CLI exit code.

Slack per-task demo notifications

Distributed demo workers can also post task-level messages. This path is intended for workshop-demo.sh and supports either an Incoming Webhook or a bot token:

export WHILLY_SLACK_WEBHOOK_URL='https://hooks.slack.com/services/...'
export WHILLY_SLACK_NOTIFY_EVENTS=all
export WHILLY_PUBLIC_BASE_URL=http://127.0.0.1:8000
bash workshop-demo.sh --cli opencode --workers 2 --keep-running

Each message includes task id, plan id, worker id, model, terminal status, and a /llm-ops?task_id=... link. Webhook failures are logged and ignored. Bot-token mode defaults to channel C0B1WT58EBE; override with WHILLY_SLACK_CHANNEL when needed.

The summary text is a str.format template; override WHILLY_SLACK_MESSAGE_TEMPLATE to customise the message without touching code. Available placeholders match the :class:whilly.core.notifications.RunCompletedEvent fields plus {completed_at_iso}.

Operator Dashboard Parity

The active browser WUI and browserless TUI expose the same canonical operator surfaces: Overview, Compliance, Plans/Tasks, Workers, and Events. Shared surface copy is 1-5=switch.

Active worker controls use /api/v1/admin/workers/*; active review decisions use /api/v1/tasks/*/human-review. _logs.html remains a routeable noncanonical fragment behind ?fragment=logs, but it is not visible navigation until TUI parity expands. _admin.html and _prd.html are quarantined inactive fragments because their old /admin/* and /prd/* controls are not supported active WUI routes.

Keyboard Shortcuts

Key Action
q Quit the TUI / stop WUI live updates
r Refresh
R Resume workers
1-5 Switch Overview, Compliance, Plans/Tasks, Workers, Events
/ Focus filter
p Pause workers
j / k Select next/previous review gap on Compliance
a / x / c Approve, reject, or request changes for the selected review gap

(Windows: key listener is disabled; the dashboard itself still renders.)

Task Plan JSON Format

{
  "project": "My Project",
  "tasks": [
    {
      "id": "TASK-001",
      "phase": "Phase 1",
      "category": "functional",
      "priority": "critical",
      "description": "What to do",
      "status": "pending",
      "dependencies": [],
      "key_files": ["path/to/file.py"],
      "acceptance_criteria": ["AC1"],
      "test_steps": ["step1"]
    }
  ]
}

Architecture

whilly.py                    Entry point + main loop
whilly/
  config.py                 Layered config (defaults → TOML → .env → env)
  secrets.py                env:/keyring:/file:/literal resolver
  gh_utils.py               Central gh CLI subprocess env resolution
  task_manager.py           JSON plan CRUD, dependency resolution
  agent_runner.py           Claude/OpenCode subprocess + JSON parsing
  tmux_runner.py            Tmux session isolation
  orchestrator.py           File-based + LLM task batching
  dashboard.py              Rich Live TUI + keyboard handler
  reporter.py               JSON + Markdown cost reports
  decomposer.py             Task decomposition via LLM
  notifications.py          macOS voice alerts
  core/notifications.py     RunCompletedEvent + NotificationPort (pure)
  adapters/notifications/   Slack + Null impls; `make_notifier(cfg)` factory
  sources/                  Input adapters (GitHub Issues, Project v2, unified)
  sinks/                    Output adapters (PR creation, etc.)
  agents/                   Pluggable backends (claude, opencode)

Tmux Setup (optional)

When USE_TMUX = true, each agent runs in its own tmux session:

# View running agent sessions
tmux ls | grep whilly-

# Attach to a specific agent
tmux attach -t whilly-TASK-001

# Kill a session
tmux kill-session -t whilly-TASK-001

Troubleshooting

Problem Solution
Dashboard doesn’t render Check Rich works: python3 -c "from rich import print; print('[bold]test[/]')"
Agent auth errors (403) Check Claude CLI: claude --version
gh returns 401 / not found unset GITHUB_TOKEN && gh auth status; set WHILLY_GH_PREFER_KEYRING=1 on macOS
Tmux not found brew install tmux or set USE_TMUX = false
Tasks stuck in_progress Whilly resets stale tasks on startup
Too many API errors Whilly pauses 60 s after 5+ consecutive failures
Legacy .env warning on every run Run whilly --config migrate, or silence with WHILLY_SUPPRESS_DOTENV_WARNING=1