- TypeScript 99.9%
- JavaScript 0.1%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| docs | ||
| src | ||
| .gitignore | ||
| .prettierignore | ||
| .prettierrc.json | ||
| AGENTS.md | ||
| CHANGELOG.md | ||
| CODING_STANDARDS.md | ||
| DESIGN.md | ||
| DOMAIN.md | ||
| eslint.config.js | ||
| IMPLEMENTATION_PLAN.md | ||
| NOTICE | ||
| package-lock.json | ||
| package.json | ||
| piloop-config.example.yaml | ||
| piloop-config.yaml | ||
| README.md | ||
| tsconfig.build.json | ||
| tsconfig.json | ||
| vitest.config.ts | ||
pi-loop
Turn an issue (Jira, GitHub, Forgejo, or GitLab) into a reviewed, remediated Draft MR/PR on whatever platform hosts your repo — automatically.
pi-loop is a Node.js/TypeScript app built on the pi SDK
(@earendil-works/pi-coding-agent) that runs a five-stage agent loop:
issue fetch → implement → review → remediate → docs → MR/PR creation → issue write-back
You run it from inside your target repo with a single issue ref; it produces a Draft MR/PR and writes a comment back to the source issue. The Draft MR/PR is the only human checkpoint — there is no human gate mid-run.
Issue source and MR platform are independent axes: fetch from any supported
provider, open the MR/PR via the right mechanism for the detected origin
(glab for GitLab, gh for GitHub, or the Forgejo REST API for Forgejo).
See docs/design-multi-provider.md.
See
DESIGN.mdfor architecture and decisions, andIMPLEMENTATION_PLAN.mdfor the build plan. Contributors should also readAGENTS.md.
How it works
Each stage runs as a fresh pi agent session with a least-privilege tool allowlist. Stages communicate through on-disk artifacts (not shared conversation memory), which makes every hand-off explicit, inspectable, and re-runnable.
| Stage | Does | Produces |
|---|---|---|
| 0. Issue fetch | Fetch + normalize the issue via REST (plain code, no LLM) | issue-context.json |
| 1. Implement | Implement the ticket in the working tree | implementation.diff |
| 2. Review | Run build/test/lint + static review vs project standards | review-findings.md |
| 3. Remediate | Single pass to fix findings | final.diff |
| 3.5 Docs | Sync README.md + docs/**/*.md to the implemented behavior |
docs.diff |
| 4. MR creation | Branch, commit, push, open a Draft MR/PR (origin platform) | mr-result.json |
| — Write-back | Comment the MR/PR link on the source issue | (issue updated) |
All artifacts are written under .pi-loop/runs/<run-id>/ and kept out of the captured
diff. The docs stage is non-fatal: if it fails, pi-loop warns and continues to MR
creation; "no doc updates needed" is a success outcome.
Prerequisites
- Node.js >= 22.19.0 and npm (a TypeScript toolchain is only needed for local development, not to run an installed build).
gitand the CLI for CLI-driven platforms:glab(GitLab) orgh(GitHub). Forgejo MR creation uses the Forgejo REST API and does not need thefjCLI. pi-loop detects the platform from the git origin and fails fast at preflight when a required CLI is missing.- Credentials for the providers used in the run (see Configuration): Jira creds for
Jira issues,
GITHUB_TOKENfor GitHub issues,FORGEJO_TOKENfor Forgejo issues,GITLAB_TOKENfor GitLab issues/MRs. - A model/auth configured for the pi SDK (
ModelRuntime). - The target repo in a clean git state.
- The target repo has a
.gitignorecontaining a.pi-loop/rule (enforced at startup — pi-loop aborts with instructions otherwise). - When the docs stage is enabled (default), none of its in-scope doc files
(default
README.md+docs/**/*.md) may be covered by a git ignore source (.gitignore,.git/info/exclude, global excludes) — enforced at startup; pass--no-docsto skip this check and disable the docs stage for the run.
Install
pi-loop installs as a global pi-loop command (Phase 1 is a maintainer-only
local install; publishing to a registry is deferred to Phase 2 — see
Roadmap).
npm prefix setup (required)
install:local uses npm link, which writes to npm's global prefix. If your
prefix is /usr/local (the default on many systems), you need a user-level
prefix to avoid EACCES: permission denied:
mkdir -p ~/.npm-global
npm config set prefix ~/.npm-global
echo 'export PATH="$HOME/.npm-global/bin:$PATH"' >> ~/.bashrc
source ~/.bashrc
Verify with npm config get prefix — it should show ~/.npm-global (or your
chosen path).
Install from source
git clone <this-repo>
cd pi-loop
npm install # installs deps and builds dist/ via the prepare hook
npm run install:local # build + npm link → global `pi-loop` command
Verify the install and run it from any target repo (no tsx/npx needed):
pi-loop --help
Updating
There is no auto-update. To pick up new changes, pull and reinstall:
git pull
npm run install:local # rebuilds dist/ and reinstalls the global binary
Configuration
Set these environment variables (e.g. in a .env or your shell):
| Variable | Required | Description |
|---|---|---|
JIRA_BASE_URL |
yes | Jira Cloud base URL, e.g. https://acme.atlassian.net |
JIRA_EMAIL |
yes | Jira account email (Basic auth) |
JIRA_API_TOKEN |
yes | Jira API token (read + comment) |
GITLAB_TOKEN |
yes | GitLab token used by glab |
GITHUB_TOKEN |
source-dependent | GitHub token (GitHub-as-source + write-back) |
FORGEJO_TOKEN |
source-dependent | Forgejo token (Forgejo-as-source + MR creation + write-back) |
FORGEJO_BASE_URL |
no | Forgejo base URL (inferred from origin host) |
GITLAB_BASE_URL |
no | GitLab base URL (inferred from origin host) |
PILOOP_MODEL |
yes | Model id, e.g. anthropic/claude-opus-4-5 — required for deterministic model pinning; unset/empty aborts the run (fail-fast) to keep pi-loop independent of the interactive pi session's default model. Overridable per run with --model <id> (the flag wins when both are set) |
PILOOP_THINKING_LEVEL |
no | Thinking level for the model |
PILOOP_VERTEX_ANTHROPIC |
no | Set to 1 to enable the Claude-on-Vertex provider (opt-in) |
PILOOP_AUTO_MERGE |
no | Set to 1 or true to auto-merge the PR/MR after creation |
PILOOP_AUTO_MERGE_TIMEOUT_MS |
no | Max wait for mergeability (default: 600000 = 10 minutes) |
PILOOP_AUTO_MERGE_POLL_MS |
no | Poll interval in ms (default: 15000 = 15 seconds) |
PILOOP_E2E |
no | Set to 1 to enable the gated end-to-end test |
PILOOP_BASH_DEFAULT_TIMEOUT_S |
no | Default bash timeout in seconds (default: 120) |
PILOOP_BASH_MAX_TIMEOUT_S |
no | Hard cap for bash timeouts in seconds (default: 600) |
Each stage's skill body is bundled in src/skills/ (edit the TypeScript modules
there), so there are no skill-path env vars. The retired IMPLEMENT_SKILL_PATH,
REVIEW_SKILL_PATH, REMEDIATE_SKILL_PATH, and MR_SKILL_PATH vars are ignored; the
CLI warns when they are still set.
piloop-config.yaml (per-repo config)
For stable per-repo settings, create a committed piloop-config.yaml in the
repo root (the directory you run pi-loop from). It is optional — when absent,
behavior is unchanged. It covers every non-secret setting; credentials and
Jira email always stay in the environment.
A fully annotated example is committed at
piloop-config.example.yaml. Start from it:
cp piloop-config.example.yaml piloop-config.yaml
Then edit the copy to match your repo. A condensed example:
# piloop-config.yaml — example
source: jira # issue source (jira|github|forgejo|gitlab)
git-host: forgejo # MR platform (github|forgejo|gitlab)
label: pi-loop # scan label filter (default: pi-loop)
max-issues: 50 # scan cap (default: 50)
model: anthropic/claude-opus-4-5
thinking-level: high
auto-merge:
enabled: true # equivalent to --auto-merge / PILOOP_AUTO_MERGE
timeout-ms: 600000 # max wait for mergeability (default: 600000)
poll-ms: 15000 # poll interval (default: 15000)
bash:
default-timeout: 120 # PILOOP_BASH_DEFAULT_TIMEOUT_S (default: 120)
max-timeout: 600 # PILOOP_BASH_MAX_TIMEOUT_S (default: 600)
jira:
acceptance-criteria-field-id: customfield_10001
gitlab:
base-url: https://gitlab.example.com # inferred from origin when unset
forgejo:
base-url: https://code.example.com # inferred from origin when unset
docs:
enabled: true # default: true — set false to disable the docs stage
paths: # default: [README.md, docs/**/*.md]
- README.md
- docs/**/*.md
exclude: # default: [docs/reference/**] — applied on top of any paths
- docs/reference/**
The docs stage can also be disabled per run with the --no-docs CLI flag; it
overrides docs.enabled for that invocation only (CLI flag > YAML).
Precedence: CLI flag > YAML > env var > built-in defaults. The file is the
repo's committed baseline; it overrides environment variables (e.g. model: in
the file beats PILOOP_MODEL), and a CLI flag overrides both (e.g. --model
beats model: in the file). Env vars only fill keys the file leaves unset.
Validation: strict — unknown keys, wrong types, or malformed YAML abort the
run with exit code 1 (kind: config). Fix the file and re-run. Per-invocation
switches (issue refs, --fresh, --scan, --yes, --list-models, --help)
are not configurable from the file.
Claude on Vertex (optional)
pi-loop can run its stage sessions on Claude models served via Google Cloud Vertex AI. This is opt-in and off by default — when disabled, no provider is registered and behaviour is unchanged.
Enable it by setting the flag and selecting a vertex-anthropic/... model:
| Variable | Description |
|---|---|
PILOOP_VERTEX_ANTHROPIC |
Set to 1 to register the provider |
PILOOP_MODEL |
e.g. vertex-anthropic/claude-opus-4-8 |
GOOGLE_CLOUD_PROJECT |
GCP project (or ANTHROPIC_VERTEX_PROJECT_ID) |
| region (one of below) | CLOUD_ML_REGION, VERTEX_LOCATION, VERTEXAI_LOCATION, GOOGLE_CLOUD_LOCATION |
GOOGLE_APPLICATION_CREDENTIALS |
Service-account key path (optional; ADC / gcloud are used otherwise) |
Auth resolves in order: service-account JSON → Application Default Credentials
(gcloud auth application-default login) → gcloud CLI. Tokens are never logged.
Working model/region caveat: the vendored default region is us-east5, but for
some projects only global works (e.g. global + claude-opus-4-8). Set one of the
region vars to global if you hit 429/400/404 errors. Example:
PILOOP_VERTEX_ANTHROPIC=1 \
PILOOP_MODEL="vertex-anthropic/claude-opus-4-8" \
GOOGLE_CLOUD_PROJECT=<your-project> \
VERTEX_LOCATION=global \
pi-loop <ISSUE-REF>
The provider code under src/model/vertexAnthropic/ is vendored (MIT) from
pi-provider-vertex-anthropic;
see NOTICE for attribution.
⚠️ Adding a new Vertex model (important)
The Vertex-Anthropic model catalog is hardcoded in
src/model/vertexAnthropic/models.ts
(VERTEX_MODELS) — it is a vendored snapshot, not a live lookup. When
Anthropic ships a new Claude model on Vertex AI, pi-loop does not pick it
up automatically; the catalog must be updated by hand or the model id will
fail to resolve (--model vertex-anthropic/<new-id> errors as unknown).
To add one:
- Add an entry to the
VERTEX_MODELSarray inmodels.tswith the model'sid(the exact Vertex publisher-model id, e.g.claude-opus-5-1), a human-readablename,reasoning,input,contextWindow,maxTokens, and per-1M-tokencostfields — copy the shape of an existing entry. - Update
NOTICE's "Model catalog deviation" note: this file is vendored from upstreampi-provider-vertex-anthropic, so any model added here ahead of (or diverging from) what upstream ships must be recorded there, and removed/reconciled once upstream picks it up. - Add/extend a test in
registerVertexAnthropic.test.tsasserting the new id is present with the expectedreasoning/limits (see the existingclaude-sonnet-5/claude-opus-5cases for the pattern). - Run
npm testandnpm run lint, then verify end-to-end withPILOOP_MODEL="vertex-anthropic/<new-id>"(see the working model/region caveat above — some ids only resolve in theglobalregion).
Model ids and limits are otherwise not validated against a live Vertex API
— an incorrect id, contextWindow, or maxTokens will not fail fast; it
surfaces as a runtime 429/400/404 from Vertex or truncated output.
Usage
Run the installed pi-loop command from inside the target repository (there
is no --repo argument):
cd /path/to/your/target/repo
pi-loop ABC-123 # Jira issue key
pi-loop 42 # issue #42 on the detected origin platform
pi-loop owner/repo#42 # issue #42 on that repo's platform
pi-loop issue-42 # alias for issue #42 on the origin platform
pi-loop 42 --source github # explicit provider override
pi-loop 42 --fresh # force clean start (ignore incomplete runs)
pi-loop 42 --model anthropic/claude-opus-4-5 # per-run model override (flag wins over PILOOP_MODEL)
During local development you can run the CLI without installing via
npm start -- <ISSUE-REF>(see Development).
The MR/PR platform is detected from git remote get-url origin; --source overrides
provider inference for the issue ref (ambiguous refs fail with a usage error).
pi-loop will:
- Fetch and validate the issue from the resolved provider.
- Implement it, review it, and remediate it (single pass).
- Open a Draft MR/PR on the origin platform (branch
feature/<ISSUE-KEY>/<slug>for Jira,feature/issue-<number>/<slug>for GitHub/Forgejo/GitLab; target = default branch) viaglab,gh, or the Forgejo REST API. - Write back to the source issue: a comment with the MR/PR link (uniform across providers).
Inspect run artifacts under .pi-loop/runs/<run-id>/.
Auto-merge
By default pi-loop creates a Draft MR/PR. With --auto-merge (or
PILOOP_AUTO_MERGE=1), it creates a non-draft MR/PR, polls until the CI
checks pass and the PR/MR is mergeable, then merges it automatically. After a
successful merge the source issue is closed (Forgejo, GitHub, GitLab; Jira is a
no-op — Jira issues are not auto-closed).
pi-loop 42 --auto-merge # enable via CLI flag
PILOOP_AUTO_MERGE=1 pi-loop 42 # enable via environment
Tuning:
| Variable | Default | Description |
|---|---|---|
PILOOP_AUTO_MERGE_TIMEOUT_MS |
600000 |
Total timeout waiting for mergeability (ms) |
PILOOP_AUTO_MERGE_POLL_MS |
15000 |
Interval between mergeability checks (ms) |
When auto-merge times out or the merge API fails, the run fails with
auto-merge-timeout or auto-merge-failed respectively. The write-back comment
reflects the actual outcome (draft, merged, or merge-failed).
Resume
When pi-loop exits unexpectedly (agent crash, operator kill, network outage), re-running the same command automatically resumes from the failed stage instead of starting over.
How it works:
- The CLI scans
.pi-loop/runs/for the most recent incomplete run matching the issue key. - A run is incomplete when
error.jsonexists and no successfulmr-result.jsonormr-skipped.jsonexists. - Artifact existence determines which stages are skipped — completed stages are reused, failed stages are re-run.
- The working tree is trusted as-is (no git manipulation).
pi-loop ABC-123 # auto-resumes if incomplete run exists
pi-loop ABC-123 --fresh # force clean start, ignore incomplete runs
Working tree warnings: During resume, if the working tree has uncommitted
changes, a warning is logged but the run continues (expected after implement/
remediate stages). Use --fresh to start over on a clean tree.
Escaping resume: --fresh creates a completely new run with a new timestamp
and run directory, ignoring any incomplete runs.
Resuming with a different model: pass --model <id> on the resume command to
re-run the interrupted stage with a different model (e.g. after a model-related
failure). Nothing pins the model to the original run; the per-stage model banner
lines in run.log show which model each stage actually used.
Batch mode
Batch mode processes multiple issues serially in a single invocation. It is
opt-in and requires --auto-merge (each issue is auto-merged before the next
starts).
# Manual batch: multiple refs
pi-loop ABC-1 DEF-2 --auto-merge
# Scan mode (interactive — select issues from a label)
pi-loop --scan --auto-merge
# Scan mode (non-interactive, accept all)
pi-loop --scan --auto-merge --yes
Scan mode (--scan) discovers open issues by label on the origin platform
(Forgejo and GitLab). Use --label <label> to change the filter (default: pi-loop)
and --max-issues <n> to cap results (default: 50). Issues are ordered by
priority (critical > high > medium > low > none), then by creation date (oldest
first).
Batch resume: re-running the same batch command skips completed issues and resumes from the failed one.
Console output
Agent output streams live by default — no flags needed, in single-issue and batch mode alike (see ADR-011):
- Full model thinking streams to the terminal as an indented block — one
thinking:marker line, continuation lines indented under it, no truncation. This is the narrative of why the agent acts at every step. - Visible assistant text streams live as it is produced — deltas append to the current line (no per-delta timestamps) and embedded newlines start continuation lines.
- Tool call lines and results keep their existing compact format (errors always in full). Structural events (message/turn/agent boundaries, compaction, auto-retry, queue) stay suppressed.
- Colors (dim thinking, normal assistant text, colored tool lines, red
errors, blue prefix brackets) are enabled only when stdout is a TTY and
NO_COLORis not set, so redirected output and CI logs stay plain and free of ANSI escape codes. Partial lines are flushed at message/turn/tool boundaries and session end, so> fileredirects stay line-readable.
In batch mode, every new line carries a per-issue [i/N issue-KEY] prefix
(prefixes are applied on new lines only — continuation lines are indented, so
a long thinking block never becomes thousands of prefixed lines):
[2/3 issue-ABC-2][12:00 implement][bash] npm test
[2/3 issue-ABC-2][12:00 implement] thinking: The issue requires adding a timeout to the fetch
call. Let me locate the fetch site and check the tests.
[2/3 issue-ABC-2][12:00 implement] INFO implementing
Every agent line carries a [HH:mm <stage>] prefix; tool-start lines add a
[tool] bracket. Logger lines use the same [HH:mm <stage>] prefix (console
and run.log), and the whole prefix bracket group is colored blue on a TTY
(see ADR-012). There are no stage
header separator lines anymore — the stage is always visible in the prefix.
-
Batch status lines (
Batch [i/N]: …) keep their existing format and are not prefixed — they are self-identifying:Batch [1/3]: starting forgejo:ABC-1 Batch [1/3]: ✓ forgejo:ABC-1
A batch-manifest.json is written under .pi-loop/runs/<batch-id>/ after the
batch completes, recording the plan, selections, and per-issue outcomes.
Exit codes & error handling
| Exit code | Meaning |
|---|---|
0 |
Success — a Draft MR was created, or a no-code-change run completed (the deterministic comment on the source issue is the deliverable of that path) |
1 |
Pipeline failure — a stage failed hard; see error.json |
2 |
Usage error — bad/missing Jira key or unknown flag |
The orchestrator enforces a per-stage error-handling policy:
- Fail-fast on invalid setup/config, missing creds, or Jira
401/403/404(before any agent session starts). This includes a docs-scope preflight: if the docs stage is enabled and any in-scope doc file is covered by a git ignore source, pi-loop aborts before any session starts (see Prerequisites). - Bounded retries (3 attempts, exponential backoff) on transient Jira/GitLab/model
errors (
429/5xx/network). - Bundled skill bodies (in
src/skills/) are always present, so stages never degrade for a missing skill;degradedStagesis retained for compatibility and stays empty. - Abort + persist on a hard agent-stage failure: the session is disposed, artifacts
produced so far are kept, and
error.json(kind: agent-failure) is written. - Non-fatal write-back: a write-back failure after the MR is created never fails the run (the MR is the deliverable); it is surfaced in the final summary.
- Fatal no-code-change comment: on the no-code-change short-circuit there is no
MR — the deterministic comment on the source issue is the sole deliverable, so
a comment-post failure aborts the run:
error.jsonattributed to thewrite-backstage, exit1, and resumable. Explicit exception to the non-fatal write-back bullet above (ADR-021 §5).
On any hard failure the orchestrator writes error.json to the run directory:
{
"stage": "setup|issue-fetch|implement|review|remediate|docs|mr|write-back",
"kind": "config|auth|not-found|rate-limit|transient|network|agent-failure|git|auto-merge-timeout|auto-merge-failed|unknown",
"message": "string",
"retriable": false,
"attempts": 1,
"occurredAt": "ISO-8601",
}
On success, a final summary (MR URL, remaining findings, degraded stages, the
friction aggregate, and the Jira write-back outcome) is printed and appended to
run.log.
MR / VCS conventions (AGENTS.md-first)
The MR stage checks your target repo's AGENTS.md for VCS/MR tooling and conventions and
follows them if present. Otherwise it defaults per origin platform:
glab (GitLab, via GITLAB_TOKEN), gh (GitHub, via GITHUB_TOKEN), or the
Forgejo REST API (Forgejo, via FORGEJO_TOKEN); branch feature/<ISSUE-KEY>/<slug>
(Jira) or feature/issue-<number>/<slug> (GitHub/Forgejo/GitLab); target = the repo
default branch; state = Draft.
Development
For local development you don't need to install the global binary — run the CLI
directly from source with tsx:
npm install # install deps (also builds dist/ via prepare)
npm start -- <ISSUE-REF> # run the CLI from source via tsx
npm start -- --help
Common scripts:
| Script | Does |
|---|---|
npm start -- <args> |
Run the CLI from source (tsx src/cli.ts) — dev workflow |
npm run build |
Clean + compile src/ → dist/ and mark dist/cli.js executable |
npm run typecheck |
Type-check only (tsc --noEmit), no emit |
npm run install:local |
Build then npm link → install the global pi-loop |
npm run lint / npm run format |
eslint / prettier |
npm test |
Deterministic unit tests |
npm run build compiles with tsconfig.build.json (emits dist/, excludes test
files); npm pack --dry-run ships only dist/ and README.md (never src/ or
.pi-loop/).
Testing
npm test # deterministic unit tests (normal CI)
PILOOP_E2E=1 npm run e2e # gated end-to-end test (live creds + LLM required)
- Unit tests cover every non-LLM seam (Jira client, prompt/skill composer, artifact I/O + schemas, config loader, diff capture, git-exclude, MR-description builder, transition resolver, retry helper, error classifier, final-summary builder, and the orchestrator itself with all stages injected as fakes).
- The gated E2E runs the full pipeline against a sandbox repo + throwaway ticket and asserts on artifacts/exit conditions — never on exact LLM text.
The full-pipeline E2E needs a provisioned sandbox in addition to the normal config;
when these are unset it is skipped (so npm run e2e still passes):
| Variable | Description |
|---|---|
PILOOP_E2E |
Set to 1 to enable gated E2E specs |
PILOOP_E2E_JIRA_KEY |
A throwaway Jira issue key to implement |
PILOOP_E2E_REPO |
Path to a clean sandbox git repo to run the loop in |
Scope (v1)
In: single Jira key → single Draft GitLab MR; five-stage loop; single review +
single remediation pass; Jira write-back; multi-issue batch mode (opt-in via
--auto-merge with multiple refs); scan mode (--scan).
Out (non-goals): re-review loop / iteration cap, multi-repo, CI trigger, rollback/undo.
Project status
v1 pipeline complete. The codebase was developed milestone by milestone (M0–M8) per
IMPLEMENTATION_PLAN.md; M8 wires all stages into one
autonomous run with the error-handling policy, error.json persistence, and a final
summary, validated by deterministic unit tests plus the gated E2E.
Roadmap
Packaging — Phase 1 (done): pi-loop builds to dist/ and installs as a global
pi-loop command via a maintainer-only local install (npm run install:local).
Packaging — Phase 2 (planned): publish pi-loop to a private registry
(Verdaccio) so it can be installed with a plain npm i -g — covering the package
scope/name, publish automation (manual vs CI), and registry .npmrc/auth. Until
then, install from source as described in Install.