# TicketPilot setup playbook (for Claude Code)

You are reading this because a user pointed you at
`https://ticketpilot.sublimitysoft.com/ticketpilot-setup.md` and asked you to
connect **this repository** to TicketPilot.

Your job: audit what already exists on this machine, report the gaps, and fix
them. Work through the phases below in order. Do not skip the audit — most
"it doesn't work" cases are a mismatch between two things that both look fine
in isolation.

---

## 0. Rules you must follow

**Never ask for a secret in chat.** Jira API tokens and enrolment tokens must
never appear in your messages, your tool calls, or the transcript. Your Bash
tool is non-interactive — you cannot accept typed input mid-command. So when a
secret is needed you must **stop, print the exact command, and ask the user to
run it themselves in their own terminal**, then continue once they confirm.
The `ticketpilot-runner init` wizard prompts for secrets on stdin, which is
exactly why the user runs it, not you.

**Never print or echo these**, even when debugging:
- `jira.apiToken` and `runnerToken` inside `~/.config/ticketpilot/runner.json`
- any `tpe_…` enrolment token or `tpr_…` runner token

When you need to inspect that file, redact first (script in §1).

**Never commit** `runner.json`, and never copy it into the repo.

**What TicketPilot is**, so you can explain it: label a Jira ticket → a runner
on the user's own machine runs Claude Code read-only over their repo → an
implementation plan is posted as a Jira comment. Credentials live only on the
user's machine; the control plane stores identifiers and run metadata only.
Everything is **plan-only** — the executor pins Claude Code to `Read, Glob,
Grep`, so no code is ever written by a run.

---

## 1. Audit — run this first

```bash
# A. Is the runner CLI available?
command -v ticketpilot-runner || echo "MISSING: ticketpilot-runner not on PATH"

# B. Is Claude Code installed and authenticated?
claude --version || echo "MISSING: claude CLI"

# C. Is this a git checkout, and where?
git rev-parse --show-toplevel 2>/dev/null || echo "WARNING: not a git repo"

# D. Runner config state, with every secret redacted
python3 - <<'PY'
import json, os
p = os.path.expanduser("~/.config/ticketpilot/runner.json")
if not os.path.exists(p):
    print("MISSING: no runner config at", p); raise SystemExit
d = json.load(open(p))
if d.get("jira"):
    d["jira"]["apiToken"] = "<set, %d chars>" % len(d["jira"]["apiToken"])
d["runnerToken"] = "<set>" if d.get("runnerToken") else "<NOT ENROLLED>"
print(json.dumps(d, indent=2))
PY
```

From `D` record four things — you will need them in §3:
`controlPlaneUrl`, whether `runnerToken` is set, `jira.baseUrl`, and every key
under `repos` (the repo *names*) with its local path.

```bash
# E. Do the runner's own checks pass?
ticketpilot-runner test
```

`test` verifies four things: Jira auth, that the Jira project is readable, that
the mapped repo path exists, and that the `claude` binary runs.

Now report to the user, as a checklist, which of these is present and which is
missing. Then continue.

---

## 1b. If this repo already has hand-rolled Jira automation

Common case: the user already wired Jira and git together themselves — a
script, a cron job, a CI workflow, a git hook — and now wants TicketPilot to
take over. Find it before you add a second system on top of it.

```bash
# Existing Jira wiring in the repo
grep -rIl --exclude-dir={.git,node_modules,dist,.next} -e 'atlassian\.net' \
  -e 'JIRA_' -e 'rest/api/[23]' . 2>/dev/null

# Env files that may already hold a Jira credential (do NOT print their contents)
ls -a | grep -E '^\.env' ; ls .env* 2>/dev/null

# Scheduled or CI triggers
ls .github/workflows 2>/dev/null; crontab -l 2>/dev/null; ls .git/hooks | grep -v sample
```

Then:

- **Reuse the Jira API token they already have.** A working token needs no
  replacing — the user can paste the same one into `ticketpilot-runner init`.
  Do not read it out of their env file yourself; ask them to paste it into the
  wizard. If you find a token **committed to the repo**, say so plainly and
  recommend rotating it — that is a real exposure, not a style note.
- **Turn the old automation off before starting the runner.** Two systems
  watching the same tickets means duplicate comments, and if the old one writes
  labels it can fight the runner's `agent-planned` / `agent-failed` state.
  Disable — do not delete — and tell the user exactly what you disabled and how
  to restore it.
- **Check for label collisions.** If their existing automation already uses
  `agent-dev`, `agent-planned` or `agent-failed`, pick different labels for the
  TicketPilot rule rather than sharing them.
- **Keep the same checkout.** Point `repo add` at the working tree they already
  use; there is no need to re-clone.
- Anything their script did that TicketPilot cannot do yet — writing code,
  transitioning issues, non-label triggers — **say so** rather than quietly
  dropping it. See §5.

---

## 2. Fill the gaps

Only do the steps that the audit showed as missing.

### 2.1 Install the runner

The npm package is not published yet. Install from the monorepo checkout. Do
**not** hand-copy a path into the symlink target — resolve it from the actual
checkout so there is nothing to typo or leave as a placeholder:

```bash
cd /path/to/Jira-Claude-Connector && pnpm install && pnpm build
REPO_ROOT="$(git rev-parse --show-toplevel)"
sudo tee /usr/local/bin/ticketpilot-runner >/dev/null <<EOF
#!/usr/bin/env bash
exec node "$REPO_ROOT/apps/runner/dist/cli.js" "\$@"
EOF
sudo chmod +x /usr/local/bin/ticketpilot-runner
```

If the user does not have the monorepo, they need it — ask where to clone it
rather than guessing a path.

**Upgrading an existing install** (new commits landed, or implement-mode
rules stopped working after an update): rebuild in place, then bounce the
daemon — the running process is still the old build until you restart it.

```bash
cd "$REPO_ROOT" && git pull origin main && pnpm install && pnpm build
ticketpilot-runner stop
ticketpilot-runner start
ticketpilot-runner status   # confirm it came back up
ticketpilot-runner test     # confirm all checks still PASS after the rebuild
```

### 2.2 Claude Code auth

The runner spawns `claude` with its own environment, so whatever auth that OS
user already has is what runs. A Claude subscription login is enough — there
is **no API key to configure**. Confirm with `claude --version` and one
`claude -p "reply ok"`.

### 2.3 Enrol the runner  ← needs the user's hands

Ask the user to:

1. Open <https://ticketpilot.sublimitysoft.com/dashboard> → **Add runner** →
   copy the enrolment token. It is single-use and expires in 24 hours, so it
   must be used right away.
2. Run this **in their own terminal** (not through you), pasting the token:

```bash
ticketpilot-runner init --cp-url https://ticketpilot.sublimitysoft.com
```

It will prompt for the enrolment token, Jira base URL
(`https://yourorg.atlassian.net`), Jira account email, and a Jira API token
from <https://id.atlassian.com/manage-profile/security/api-tokens>. The Jira
credential is validated against Jira *before* anything is saved, then written
to `~/.config/ticketpilot/runner.json` at mode 600. Nothing but the runner
token ever leaves the machine.

The Jira account needs: browse issues, add comments, **and edit issues** — the
runner writes labels back.

Wait for the user to confirm, then re-run the §1 audit to verify
`runnerToken` is now set.

### 2.4 GitHub token — only if any rule will use implement-mode

Skip this if every rule on this runner is plan-only. Implement-mode rules
(the ones that edit code and open a PR, not just post a plan comment) need a
GitHub credential — the runner will refuse with `no GitHub token configured
on this runner` at run time otherwise, which shows up as a failed run with
that exact error posted as a Jira comment.

```bash
ticketpilot-runner github set-token
```

This prompts for the token on stdin — **never pass it with `--token` through
a command you run on the user's behalf, and never ask them to paste it in
chat.** Have the user create a fine-grained PAT themselves at
<https://github.com/settings/personal-access-tokens/new>, scoped to only the
one repo this runner will push to, with **Contents: Read and write** and
**Pull requests: Read and write** (nothing else). It's verified against
GitHub before saving and stored the same way as the Jira token.

### 2.5 Map this repo

Pick a repo name and use it **byte-for-byte identically** here and in the UI.
A mismatch is the single most common cause of "connected but never polls".

```bash
ticketpilot-runner repo add "<repo-name>" "$(git rev-parse --show-toplevel)"
ticketpilot-runner repo list
```

### 2.6 Create the Project and Rule in the UI  ← user's hands

Tell the user exactly what to type — do not make them guess:

- **New project**: name (free text), **Jira project key** (the prefix in ticket
  IDs, e.g. `ENG` in `ENG-123` — uppercase, exact), **Repo name** = the exact
  string from §2.5.
- **New rule**: pick that project, a trigger (label `agent-dev`/their choice,
  or a comment keyword like `/implement` — matching is case-insensitive
  substring, so the keyword field must actually appear in what the user
  types), keep or edit the prompt template, timeout 900s. Placeholders
  available: `{{issueKey}}`, `{{summary}}`, `{{description}}`, `{{comments}}`.
  Editing the template to describe this codebase's stack and conventions is
  the highest leverage change available — offer to draft it from what you
  know of the repo. For implement-mode rules, an under-specified ticket is
  the most common cause of "it ran but wrote no code" (see §3 item 10) — the
  more concrete the ticket description, the better.

Success and failure labels are fixed: `agent-planned` and `agent-failed`.

### 2.7 Start it

```bash
ticketpilot-runner start
```

Keep it alive in `tmux`, or write a systemd user unit. Then have the user click
**Test connection** on the project in the dashboard — that round-trips through
the live WebSocket to this machine and shows four pass/fail checks.

### 2.8 Deployments — only if they want tickets shipped to environments

Skip this unless the user actually wants TicketPilot to deploy. Deploy steps
are **shell commands that run on this machine, as this user** — a real step up
from what the rest of the product does, so treat the decision as theirs and say
so plainly.

**How it is kept safe, in this order — repeat this to the user before enabling:**

1. Nothing generated at run time is ever executed. Claude can *propose* deploy
   steps by reading the repo, but the proposal is inert until a human saves it
   in the dashboard. What runs is exactly what they saved.
2. Deploys are off on this machine until someone with shell access here turns
   them on. No rule and no dashboard setting can flip that switch.
3. Who may ship where is decided by the control plane, not by this runner —
   see the permission list on each environment.

**A. Turn it on locally** (the user's call, on this machine):

```bash
ticketpilot-runner deploy enable
```

**B. Create the environments in the UI** ← user's hands. Under **Environments**,
one per target. For each, they set:

- **Name** — what people type in Jira: `/deploy staging`.
- **Branch** — the branch this environment runs, e.g. `develop` for dev, `main`
  for production. The runner keeps a dedicated checkout pinned to
  `origin/<branch>`, so a deploy never depends on what is checked out here.
  Leave it empty only if the steps handle the code themselves.
- **Promotion order** — `0`, `1`, `2`. Combined with **enforce promotion
  order**, this is what makes "dev first, then staging, then prod" a rule rather
  than a habit.
- **Deploy steps** — offer to press **Ask Claude for steps**: it reads this repo
  read-only and proposes commands based on what is actually here (package
  scripts, Dockerfile, CI, existing deploy scripts). Review them WITH the user
  before saving; you are the one who knows this repo.
- **Who can deploy** — roles, plus individual grants. For production the usual
  shape is: roles = owner only, **Allow deploys from Jira = OFF** (so it is
  dashboard-only), **Require approval = ON**.

**C. Store the secrets the steps need** — on this machine only. The dashboard
holds the variable NAMES so it can tell them what's missing; the values live
here, chmod 600, like the Jira token, and the runner strips them out of every
log line it ships:

```bash
ticketpilot-runner env set "<repo-name>" "<env-name>" "SSH_HOST=..." "SSH_USER=..."
ticketpilot-runner env list      # names only, never values
```

Never pass a real secret in a command you run on the user's behalf without
asking, and never ask them to paste one into chat.

**D. Link Jira accounts** ← user's hands. Under **Team**, each person's Jira
account id must be linked to their TicketPilot user. An unlinked Jira account
can never deploy — that is deliberate, not a bug. If someone is refused, the
ticket comment contains their account id, ready to paste in.

**E. Optionally add a deploy rule** so Jira can trigger it: trigger = comment
keyword `/deploy` (with **only if the ticket is in this column** set, if they
want "move the card AND comment"), or trigger = ticket moved into a column with
a fixed environment. Deploys can always be started from the dashboard's
**Deploy** button without any rule at all.

Verify with a real ticket to the *lowest* environment first, never production.

---

## 3. "It's connected but it never polls"

The runner reports online, the ticket is labelled, and nothing happens. Work
down this list — these are ordered by how often they are the cause.

1. **The runner is enrolled against a different control plane.** Check
   `controlPlaneUrl` from §1D. If it says `http://localhost:4000` or any other
   host, this runner is not talking to the panel the user is looking at. The
   dashboard will show no runner at all. Fix: re-enrol (§2.3) against
   `https://ticketpilot.sublimitysoft.com`.
2. **No project or rule exists on the panel for this org.** The control plane
   pushes the full desired state on connect; zero rules means the poller has
   nothing to do and stays silent. Check the dashboard shows both.
3. **Repo name mismatch.** The runner logs
   `rule "…": no local path for repo "X"` once per tick and skips the rule. The
   name in the UI must equal a key in `repos` exactly — trailing spaces and
   case both count.
4. **The ticket already carries `agent-planned` or `agent-failed`.** The poll
   JQL excludes both, by design — that state transition *is* the deduplication.
   Removing the label is how you retry.
5. **Rule is disabled.** Toggle it in the dashboard.
6. **Wrong Jira project key**, or the Jira account cannot see that project.
   `ticketpilot-runner test` reports this as `jira-project: FAIL`.
7. **The label is on a ticket in a different Jira project** than the one the
   rule is bound to.
8. **Not connected yet.** The poll tick returns immediately while the
   WebSocket is down, so a runner that cannot dial out looks idle rather than
   broken. Check the runner's stdout for reconnect messages, and that outbound
   443/WSS is allowed.
9. **More than 10 matching tickets.** Each tick claims at most 10, oldest
   `updated` first. Not a fault — just wait for the next tick.
10. **Comment-keyword rules only look at comments from the last 2 days**
    (`updated >= "-2d"` in the poll JQL) — an older `/implement` comment stops
    matching even if the label/keyword are otherwise fine. Also applies item 4
    the same way: a ticket already carrying `agent-planned`/`agent-failed`
    from an *earlier run* is excluded, so a fresh comment on it won't trigger
    until that label is removed.
11. **Runner process isn't actually running.** `ticketpilot-runner test`
    checks credentials, not the daemon — it passes even if nothing is
    listening. Confirm separately with `ticketpilot-runner status`.
12. **After rebuilding the runner from a new checkout, the old process is
    still what's running.** `pnpm build` alone doesn't restart anything —
    `ticketpilot-runner stop && ticketpilot-runner start` is required (see the
    upgrade steps in §2.1).
13. **Same applies to `github set-token`, `repo add`, or any command that
    rewrites `runner.json`** — an already-running background daemon loaded
    its config into memory once at `start` and never re-reads the file.
    `ticketpilot-runner test` can PASS right after you fix a token (it's a
    fresh, short-lived process reading the file live) while the actual
    running daemon still holds the old value and keeps failing runs with the
    same error. `ticketpilot-runner stop && ticketpilot-runner start` after
    *any* config change, not just after a rebuild.

Default poll interval is 30s (`pollIntervalSeconds` in `runner.json`, range
10–600; restart the runner to apply).

**Implement-mode ran but wrote no code, and the ticket just got a "run
failed" comment**: this is by design, not a bug. If Claude Code finishes
without editing any file, the runner treats that as a failed run (`Claude
wrote no changes — nothing to commit`) rather than a silent no-op, because a
PR-writing agent that does nothing is usually a sign the ticket was
ambiguous, not that the work is done. Check the run's live log panel (the
`agent`-level lines, which are Claude's own reasoning) to see why it chose
not to edit anything, then either sharpen the ticket description or the
rule's prompt template, remove the `agent-failed` label, and retry.

**A `/deploy` comment did nothing, or was refused**: unlike a failed run, a
refused deploy always says why on the ticket, and appears under **Runs →
Deploys** with the same reason. Read that first — it is almost always one of:

- *not linked* — the commenter's Jira account isn't mapped to a TicketPilot
  user. Their account id is in the refusal comment; paste it under **Team**.
- *no permission* — their role isn't on the environment's list. Grant them
  individually on the environment page rather than promoting them org-wide.
- *dashboard-only* — the environment has "allow deploys from Jira" off. This is
  the normal production setting; ship it from the dashboard.
- *promotion order* — the ticket hasn't shipped to a lower environment yet. The
  refusal names which one.
- *unknown environment* — a typo. The refusal lists the real names.

If there is no comment at all, the trigger never matched: check the rule's
keyword, the **only if the ticket is in this column** setting against the
ticket's actual status, and that the comment is less than 24 hours old (older
comments are ignored, so switching a rule on doesn't fire on ancient history).
Each comment fires at most once, by comment id — to deploy again, comment
again.

**A deploy failed with "deployments are turned off on this runner"**: expected
until someone runs `ticketpilot-runner deploy enable` on that machine, then
restarts the daemon. Missing secrets fail the same way, naming the exact
variables — set them with `ticketpilot-runner env set` and restart.

---

## 4. Verify end to end

1. Put the trigger label on one real ticket in the bound Jira project.
2. Within one poll interval the runner logs `claimed run …`.
3. The plan appears as a Jira comment with a run ID and cost footer, and the
   ticket gets `agent-planned`.
4. The run, its streaming logs and its cost show up under **Runs** in the
   dashboard.

On failure the ticket gets a comment naming the exact error plus the
`agent-failed` label, and is **not** retried automatically. Remove the label to
retry.

Report the result to the user plainly, including anything you could not verify
yourself.

---

## 5. Current limits — state these, don't work around them

- Jira **Cloud** only.
- Triggers: label added, comment keyword, ticket moved into a column. Actions:
  plan-only (read-only tools), implement (edits + PR, no shell), deploy (runs
  the saved steps, no model involved).
- Runs cannot be cancelled mid-flight; they finish or hit the timeout. That
  includes deploys — a deploy stops at its budget, it does not roll back.
- A deploy that fails partway leaves whatever earlier steps did in place.
  Write steps that are safe to re-run.
- Deploy logs are stored like every other run log. Known secret values are
  redacted, but a step that prints a secret the runner does not know about
  (e.g. one read from a file inside a command) would be stored.
- Cost shown in the dashboard comes from Claude Code's own accounting. Under a
  subscription it is a notional list-price figure, not money charged.
