/pm:tracker makes PM aware of an external issue tracker. Once configured, PM shapes the sync instructions in your CLAUDE.md rules block — telling your Claude Code agent which epics need issues created and when to transition them. The engine itself never calls the tracker; your agent does, using whatever tooling you have (an MCP server, the GitHub CLI, a REST client).
Detection
PM detects tracker signals before asking — but it is careful not to over-infer. Hosting a repo on GitHub, GitLab, or Bitbucket is NOT a signal. The mere presence of a remote does not mean your team manages work in that platform’s issue tracker. PM treats the following as real signals that a tracker is in active use:- A connected issue-tracker MCP that is in use —
mcp__jira__*,mcp__linear__*, a GitHub Issues/Projects tool, or similar. - Real issue-key conventions in commit or branch history — for example,
PROJ-123or#42. - An explicit statement in
CLAUDE.mdorREADMEthat “we track work in <X>”.
.conductor/state.json and PROJECT.md.
Configuring a tracker
Use theset-tracker subcommand to record or update tracker settings for the repo:
--intent is validated (0.50.0). Each <status>:<target> adds one entry to the map, and <status> must be one of pm’s statuses (untriaged|queued|active|paused|later|blocked|planned|archived); a value with no :, an empty half or an unknown status is refused by name and nothing is written. It used to be dropped without a word — the command exited 0 with “tracker set” and the intent recorded nowhere. --intent is for the PRIMARY tracker only — it maps statuses onto an outward mirror’s transitions, and a secondary tracker is inward-only, so --role secondary … --intent is refused where it used to be dropped silently.
Re-running set-tracker merges — only the flags you pass change. The CLAUDE.md rules block is refreshed automatically.
If the rules block cannot be located, set-tracker exits 11 after saving the tracker (0.44.0). The block is found by whole marker lines; an orphan BEGIN or END line, or two blocks, is refused with every marker’s line number, and the rules file is not touched. The refusal comes at the block write, so the tracker change is already in state.json while CLAUDE.md and PROJECT.md are not written, and verify-state reports PROJECT.md as stale until they are. Delete the stray marker lines from the shell, then run write-rules and render (or /pm:status) — the refusal says exactly that. Do not simply re-run set-tracker: set-tracker --role secondary --remove … run a second time finds no matching tracker and exits 1 before its block write, so the block would keep the removed tracker. The full refusal is shown in /pm:review-mode. An unreadable .conductor/state.json is refused before anything is written, exit 11, with the git remedies (see /pm:gate-guard).
A value is written into the block literally: a --repo containing $`` or &` lands verbatim. Before this, the block was spliced with a string replacement that treats those as substitution patterns, and `--repo 'o/n' copied the file’s own prefix into the block.
A misspelled flag is refused before anything is written, naming what set-tracker accepts — --drection outward used to exit 0 and store the default inward: conductor: unknown flag --drection for set-tracker — it accepts: --role, --system, --repo, --project, --instance, --mechanism, --direction, --intent, --remove, --force. Quote any value that contains a space.
statusIntent mapping
--intent maps PM’s lifecycle statuses to semantic goals, not literal workflow-transition names. Your agent resolves the actual transition using its own tooling at sync time. For example, active:in-progress means “when an epic is active, move its linked issue toward an in-progress state” — PM does not care whether your Jira board calls that transition “Start Progress” or “Begin”.
A fully configured tracker block in state.json looks like this:
Direction decides what PM emits — not the vendor
A secondary tracker is pinned to
inward — it is pull-only by definition, so --role secondary --direction outward is refused. An unrecognized value exits non-zero and writes nothing. A tracker with no recorded direction keeps the behavior its vendor produced before direction existed, whether or not the repo has been through /pm:upgrade.
Direction alone does not turn a section on: an inward section is emitted only when the tracker also names a scope to read — a repo for github-issues, a repo or --project for anything else. A tracker naming no scope gets no inward section, because PM may not emit a command line with an unfilled placeholder.
Outward mirroring
Where direction includesoutward, the CLAUDE.md rules block instructs your agent to:
- Create an issue for any epic that lacks an
externalId, then record the returned key: - Transition the issue toward the
statusIntenttarget each time the epic’s status changes — resolving the real workflow transition with your own tooling. - Link child epics to a parent tracker epic when a hierarchy is involved.
Inward pull, worked through on GitHub Issues
Any tracker whose direction includesinward pulls open items in as new untriaged epics during /pm:sync — the same pattern PM uses to auto-register OpenSpec changes found on disk. github-issues is not special here; it is simply the one system whose CLI PM can name concretely, so it makes the clearest example. Every other system receives the same steps phrased as “list open items in <system> (<scope>) with your own tooling”.
The repo shape is checked (0.45.0)
Agithub-issues tracker’s --repo is owner/name, or HOST/owner/name for GitHub Enterprise — letters, digits, -, and ./_ in the name. Anything else is refused for both roles, before anything is read or written, because that value is spliced into a literal gh line: --repo 'a/b; touch pwned' used to be accepted and written into CLAUDE.md.
--remove exemption applies to --role secondary only. A legacy secondary whose stored repo predates the check stays removable; a primary --remove carrying a refused --repo used to save it anyway. --system, --project and --repo also refuse a control character on both roles — a primary --remove carrying one exited 0 and wrote a forged ## FORGED heading into CLAUDE.md and state.json. --role secondary --remove is exempt there too, for the same reason.
--remove on the primary tracker is refused (0.50.0). The primary has no remove handler, so a primary --remove is refused outright and writes nothing — with a malformed --repo on that repo’s shape first, and otherwise because there is no primary removal. It used to fall through to the merge: bare, it exited 0 having removed nothing, and with a valid --repo it REPLACED the recorded one. Change the primary with set-tracker --system … instead; --role secondary … --remove is unchanged.
A repo whose recorded github-issues repo fails that shape stops receiving a literal gh line — it gets the vendor-neutral “list open items with your own tooling” step instead — until it re-runs set-tracker, and integrity’s new tracker-repo-not-a-github-repository check names it with the re-record command (and, for a secondary, its --remove printed shell-quoted).
A primary vendor switch drops the scope it no longer applies to (0.45.0).
set-tracker --system jira --project ABC used to keep the old repo, which then headed the jira section and seeded its ids. Any of repo/projectKey/instance not re-given on a vendor switch is now dropped, and each dropped value is printed. A legacy github-issues primary with no recorded direction also used to become an outward jira tracker on the switch; the switch now records the direction the old tracker resolved to, and says so./pm:sync, your agent:
- Lists open issues with
gh issue list --repo <repo> --state open --limit 1000 --json number,title,url,updatedAt,labels.--limitis not optional (0.45.0).ghreturns 30 items without it, so later items were never registered — and the closed-item step then read their absence as “closed” and proposed archiving their epics. The step carries a truncation stop: at 1000 items, say so rather than treat the page as the whole tracker.updatedAtis requested because the registration line below requires it. - Checks whether an epic already carries that item’s
externalUrl— if so, skips it.externalUrlis globally unique; a bareexternalIdis only unique within one tracker/repo. - Registers each new issue as an epic, using the recipe the rules block emits — which runs as written:
Fill every item value as ONE single-quoted word (write an embedded
'as'\'', never double quotes), and note that--title=and--external-url=stay attached to their values. That is not cosmetic: the engine classifies a token, not a position, so a detached--title '--help'is refused as an unknown flag even quoted, while--title='--limit=5 ignored'is one word and is read as data. Item titles used to be wrapped in double quotes with no instruction at all, which let a title like--helpor$(touch pwned)act rather than register. If the issue carries aP0/P1/P2/P3label, use that label’s priority instead of theP2default. add-epicitself rejects a duplicate (exits non-zero, writes nothing) as a second line of defense against a stale local view producing one — and so doupdate-epic(when it sets--external-urlor--external-id) andadd-many(0.50.0). The refusal names the epic already holding the item and its status (an archived epic still holds it). If that item was REOPENED, do not register a second epic: proposeupdate-epic <holder> --status untriagedto bring the archived holder back. Only when the holder is genuinely no longer mirrored, free the URL withupdate-epic <holder> --clear external-url.
--idis derived, not invented —<system>-<scope>-<number>, so the same item yields the same epic id in every repo and every session, and a re-run is refused as a duplicate rather than landing under a slug the agent made up from the title. Back when the emitted recipe omitted the required--id, two sessions hit exactly that failure the same afternoon. A non-numeric item key derives a slug instead (0.45.0): the jira recipe used to derivejira-abc-<issue-number>, which the id format refuses for the keyABC-123, so it now derives<issue-key-slug>.<lane>comes from lane routing (suggest-lane --ask=<issue-title>, the title filled as one single-quoted word), never a hardcodedclaude-code. The lane decides whether the work leaves any spec, plan, or gate record — hardcoding it decided that silently for every mirrored item. Override where routing is wrong and record why withupdate-epic <id> --notes "...".--external-updated-atcarries the item’s own updated timestamp, so a freshly mirrored epic starts with a freshness watermark instead of immediately polluting the “never re-read” count.
Primary + secondary trackers
A repo has exactly one primary tracker — everything above — plus, optionally, one or more secondary trackers. Secondary trackers cover a different case: your real dev tracker is Jira, but you also want to watch a GitHub repo for inbound issues, for example from outside contributors, or from another internal repo publishing cross-project notifications (a service filing a GitHub issue in a downstream repo to flag a breaking change). A secondary tracker gets exactly two behaviors:- Inward pull — open issues become untriaged epics, the same shape as the inward pull above, deduped by
externalUrl(globally unique) rather than bareexternalId(only unique within one tracker/repo — two secondary trackers can each have an issue numbered#42without colliding). - Completion status writeback — when an epic sourced from a secondary tracker reaches
archived, your agent closes the linked issue there too. - A watermark step of its own (0.45.0) — the secondary section now emits the same “re-read the ones whose tracker timestamp is newer, and record it with
record-tracker-refresh” step the primary inward section has. Its listing line requestsupdatedAttoo; before 0.45.0 it did not, while its own registration line required one, so the recipe could not run as written.
--system plus --repo or --project. Re-running set-tracker --role secondary with a matching system + repo/project merges into the existing entry instead of adding a duplicate. --remove against a key with no match exits non-zero and changes nothing.
Once configured, the CLAUDE.md rules block gains one “Secondary tracker sync” section per entry, in addition to — never instead of — the primary tracker’s own section.
Resyncing after completion
Where at least one configured tracker has an emittable inward procedure — its direction includesinward and it names a scope to read — the rules block also gains a “Sync after completing tracker-linked work” section: after your agent closes or transitions a tracker-linked issue as part of completing an epic, it re-syncs with your tracker(s) (/pm:sync) right away, since it’s already doing tracker I/O for that epic. The instruction is phrased tracker-count-agnostic — “re-sync with your tracker(s)” — so it reads correctly whether a repo has one tracker configured or several.
The session briefing mirrors this with a one-line, non-blocking nudge on the same condition. This is only ever a reminder; PM never runs a sync itself, and there’s no lastSyncedAt tracking behind it — just a prompt for your agent to decide whether it’s worth the round trip.
Changed in 0.27.0. Both used to fire more widely — the reminder appeared in every rules block that had a tracker at all, citing “the writeback steps above” that the same block never emitted, and the briefing’s nudge appeared whenever any tracker existed. An outward-only repo now gets neither.
Freshness and the refresh gate
A tracker-linked epic carriesexternalUpdatedAt — the tracker’s own updated timestamp as of the last time your agent actually read that item. Seeing it in a list response is not reading it: advancing the watermark from a listing erases the drift the watermark exists to find.
Before an epic becomes the active piece of work, its source of truth gets re-read and the obligation is cleared with:
externalId re-reads its local source (its plan, or its proposal plus tasks) and record-tracker-refresh refuses it by name.
TRACKER SYNC briefing line
In the session briefing, theTRACKER SYNC line lists only epics with an active status that still lack an externalId. PM never fabricates transition state — it cannot see the tracker’s current workflow position and will not guess at it.
Tracker awareness is additive. The conductor always tracks everything locally. A tracker only adds an external mirror — saying no to a tracker prompt changes nothing about local tracking, issue counts, or epic statuses.

