- TypeScript 100%
| .githooks | ||
| docs | ||
| src | ||
| test | ||
| .gitignore | ||
| biome.json | ||
| config.example.yaml | ||
| CONTEXT.md | ||
| package-lock.json | ||
| package.json | ||
| PRD.md | ||
| README.md | ||
| slack-app-manifest.json | ||
| tsconfig.json | ||
| vitest.config.ts | ||
pier — Self-Hosted Ticket-to-PR Agentic Pipeline
pier drives software issues from idea to merged change through chat, with a human gate before any autonomous code is written. It is the orchestrator: it never embeds a model client, it drives a separate general-purpose coding agent (PI) as a subprocess and performs every tracker action itself.
Status: code-complete (v0.1.0). All automated behaviour is implemented and tested (217 tests). The live end-to-end path is operator-validated — see Validation.
New here? Start with the Quickstart — a linear zero-to-first-PR walkthrough (install PI → configure → validate → groom → autonomous PR).
Two decoupled capabilities
pier comprises two fully-decoupled capabilities whose only handoff is the issue and its state labels:
- Grooming (interactive) — a Slack-threaded conversation refines a rough brief into an agreed plan, written into a new issue marked
planned. Ends when a human runs/approvein the thread. - Autonomous implementation — once a human marks an issue
ready-to-dev, pier implements it in isolation, opens a PR, responds to review feedback, and merges on human approval.
Either can be used without the other; the handoff is just the issue and its label.
How it works
State lives on the issue/PR as a single label — applying one removes any other. ready-to-dev is the only human-applied state; all others are system-managed.
grooming (chat only)
│ human: /approve
▼
┌──────────► planned
│ │ human applies: ready-to-dev ◄── the gate
│ ▼
│ building ──────────────┐ failure / orphaned-on-restart
│ │ build success ▼
│ ▼ needs-attention (human re-applies ready-to-dev to retry)
│ PR #M opened, issue label cleared
│ │
│ in-review ◄──┐ pier pushes fixes
│ │ │ in place
│ human review comment┘
│ │ human approving review
│ ▼
└────────────done → PR merged (squash), `Closes #N` closes the issue, worktree cleaned up
The PR is the sole workflow item. On build success the originating issue's state label is cleared and a separate PR (#M) carries in-review → done; the issue is closed by Closes #N when the PR merges. Review feedback is addressed in place on the same branch and the PR stays in-review.
State labels (per repo): planned, ready-to-dev, building, in-review, addressing-feedback, done, needs-attention.
Control model
A level-triggered reconcile engine (Kubernetes-controller style) is the spine. Every signal — webhook, poll tick, or startup sweep — is just a trigger to reconcile an item; reconcile(item) re-reads the item's current tracker state and computes the single correct next action. It never trusts an event payload as truth.
- Idempotent — duplicate signals compute the same state → one action.
- Restart-safe — a startup sweep reconstructs all in-flight work from labels alone; pier holds no critical workflow state. An item found
buildingwith no live process on restart moves toneeds-attention. - The state label is the durable claim and the queue — waiting items sit at
ready-to-devand are re-enqueued by the sweep; bounded concurrency (default 1) with a per-item lock serialises triggers for the same item.
Architecture — ports and adapters
The orchestration core depends only on a few interfaces (ports); adapters implement them and the core never references a concrete service. This boundary is also the testing seam.
| Port | Responsibility | First adapter |
|---|---|---|
TrackerProvider |
labels, issues, PRs, merge, comments, event source | Forgejo (Gitea REST) · GitHub (per-project) |
ChatProvider |
thread messages, post, channel↔repo mapping | Slack (Socket Mode — outbound only) |
CodingAgent |
groom / build / addressFeedback |
PI (subprocess, --mode json) |
ProcessRunner, FileIO |
shell + filesystem | Node adapters |
Every adapter has a Fake (src/testing/) so the full pipeline is exercised in-memory without Forgejo, Slack, PI, or Docker.
Security boundary (C5)
- The coding agent holds only a repo-scoped push key (ephemeral
0600temp file, injected only for thegit pushstep, redacted from logs and comments). It can create a worktree, commit, and push its branch — nothing else. - pier performs every tracker action — labels, PR-open, merge, comments — using a single least-privilege Forgejo token the agent never sees.
- Builds run inside the repo's devcontainer: the worktree is the only writable mount, egress restricted to the model backend and the git remote.
- A redaction layer fronts the logger and the comment-poster; secrets are never logged.
Requirements
- Node ≥ 22
- PI with the superpowers skills installed (
grill-me,brainstorming,writing-plans,subagent-driven-development,using-git-worktrees) and amodels.json - A Slack app in Socket Mode (app + bot tokens)
- A self-hosted Forgejo instance or a github.com repository, with an API token and a repo-scoped deploy key
- Docker (for devcontainer build isolation)
- A Tailscale tailnet between pier and Forgejo (no inbound exposure)
Configuration
Config is YAML, validated by Zod, fail-fast. Copy config.example.yaml and edit it. Secrets are referenced by environment-variable name — the values never live in the file, and are resolved from .env at startup. Reuse a credential across projects by naming the same env var in each.
Each project carries its own Forgejo connection:
global:
defaultModelBackend: local/llama # resolved by PI's models.json; use the provider/model form (a bare name can fuzzy-match a keyless provider)
maxConcurrentBuilds: 1
inactivityTimeoutMs: 1800000 # 30m watchdog (primary)
absoluteCeilingMs: 21600000 # 6h backstop
groomingRoot: /Users/you/pier/grooming
sweepIntervalMs: 60000 # autonomous sweep cadence
worktreeRoot: /Users/you/pier/worktrees
logDir: /Users/you/pier/logs
projects:
- repoUrl: https://forgejo.your-tailnet.ts.net/you/your-repo # server + owner/name in one URL
tokenEnv: PIER_FORGEJO_TOKEN # NAME of the env var holding this repo's Forgejo token
pushKeyEnv: PIER_PUSH_KEY # NAME of the env var holding this repo's SSH push key
botLogin: pier-bot # the Forgejo account pier posts as on this server
slackChannelId: C0123456789
repoDir: /Users/you/pier/clones/your-repo
- repoUrl: https://git.example.com/team/other-repo # a different Forgejo server
tokenEnv: OTHER_FORGEJO_TOKEN
pushKeyEnv: PIER_PUSH_KEY # reuse the same key by naming the same env var
botLogin: other-bot
slackChannelId: C0987654321
repoDir: /Users/you/pier/clones/other-repo
slack:
appName: pier
botUserId: U0123456789
appTokenEnv: PIER_SLACK_APP_TOKEN
botTokenEnv: PIER_SLACK_BOT_TOKEN
alertMentionTarget: "<@U0123456789>" # optional: Slack user (<@U…>) or usergroup (<!subteam^S…>) @mentioned on loud alerts
notifications: true # optional (default true): set false to disable all pipeline Slack notifications
Each project selects its tracker with provider: github | forgejo (default forgejo). GitHub projects use github.com/owner/repo as repoUrl and a GitHub PAT for tokenEnv.
Per-project Forgejo fields:
repoUrl— full URL combining server andowner/name(e.g.https://forgejo.example.com/org/repo). Supports.gitsuffix and subpaths.tokenEnv— name of the env var holding this project's Forgejo API token (least-privilege).pushKeyEnv— name of the env var holding the SSH push key pier injects forgit push(repo-scoped deploy key).botLogin— the Forgejo account pier posts as on this server; its own activity is filtered out.
pier posts autonomous-pipeline status updates into each project's Slack channel — one thread per ticket, with reply_broadcast + an optional @mention (slack.alertMentionTarget) on action-required events (build failed, PR ready for review, needs-attention). Set slack.notifications: false to disable.
CLI
Run via npm run pier -- <command> (dev, through tsx) or node dist/src/cli.js <command> (built). All commands take --config <path>.
| Command | What it does |
|---|---|
validate |
Load and validate the config + secrets, then exit. |
setup |
Onboard each project: create the pier labels on the Forgejo repo and clone repoDir if absent. |
tick |
One full autonomous pass: reconcile every repo through the orchestrator, dispatching builds / feedback / merges to the bounded worker pool and draining them, then exit. This is what serve's scheduler runs. |
sweep |
A label-only reconcile pass: applies state-label transitions and failure reports via the actuator, but only logs build / feedback / merge intents without executing them (no worker pool). Useful for inspecting what tick would dispatch. |
serve |
The long-running service: Slack grooming listener + periodic sweep scheduler. Stays alive. |
install-service |
Render the launchd plist and write it to ~/Library/LaunchAgents/; prints the launchctl next-steps. |
agent-groom |
Drive PI for one grooming turn directly (--repo, --prompt, optional --thread). |
agent-build |
Drive PI for one build directly (--repo, --prompt, --workdir). Useful for the PI entry-gate check. |
Running as a service (macOS, NFR-6)
npm run build
node dist/src/cli.js install-service --config /path/to/config.yaml
# then, as printed:
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.pier.agent.plist
launchctl kickstart -k gui/$(id -u)/com.pier.agent
The launchd agent has RunAtLoad + KeepAlive, so it survives reboot and restarts on failure.
Development
npm install
npm test # vitest — full suite
npm run check # tsc --noEmit (typecheck)
npm run build # tsc → dist/
TypeScript / Node ESM throughout: .js import extensions, import type, strict, no any.
Project layout
src/
core/ reconcile engine, state-label model, actions, snapshot, sweep, logger
ports/ TrackerProvider, ChatProvider, CodingAgent, ProcessRunner, FileIO
adapters/ forgejo/ · slack/ · pi/ · process/ · fs/
build/ executor, feedback, merge, plan re-materialization, result, redaction
orchestrator/ worker pool, job dispatch, outcome logging
grooming/ Slack-thread ⇄ PI grooming, /approve → planned issue
workspace/ git worktrees, devcontainers, push-key injection, provisioning
service/ runService (listener + scheduler), launchd plist builder
config/ Zod schema + loader
testing/ Fakes for every port
cli.ts command entrypoint
docs/superpowers/
specs/ approved design spec
plans/ per-phase implementation plans
runbook-smoke.md operator acceptance runbook
Validation
Everything automatable is covered by 217 unit/integration tests against the Fakes. The live path (real Slack + Forgejo + PI + Docker) is operator-validated via docs/superpowers/runbook-smoke.md.
⛔ Start with §2 of the runbook — the PI entry gate. Run agent-build/agent-groom against real PI and confirm src/adapters/pi/events.ts extracts PI's final text and completion correctly. That parsing is the one load-bearing assumption no Fake can verify; validate it before the end-to-end smoke.
Design docs
- PRD:
PRD.md— the behaviour-only product requirements. - Design spec:
docs/superpowers/specs/2026-06-18-pier-design.md— technology and architecture decisions.