# Tracker operations

Source: https://flow-next.dev/integrations/tracker-operations/

The lifecycle touchpoints, the who-wins ladder, readiness and dependency projection, the deterministic transport contract with its error classes, and Jira's specifics.

This is the operations half of the [tracker bridge](https://flow-next.dev/integrations/tracker-sync/). Set the bridge up there; run it from here.

## Lifecycle sync points (on by default: opt-out)

Sync is wired into eight lifecycle skills. **When you hook the bridge up via the `/flow-next:tracker-sync` discovery ceremony, the whole pipeline activates by default** - connecting a tracker means you want it kept in sync, so you don’t opt in event-by-event. You **opt out** instead: exclude events at ceremony time, or turn any off later with `flowctl config set tracker.perEvent.<event> off`. Leaf values: `off | pull | push | reconcile | comment`.

| Event              | Config key                                                       | Default op                    | Fires when                                                                                                                          |
| ------------------ | ---------------------------------------------------------------- | ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| capture            | `tracker.perEvent.capture`                                       | `reconcile`                   | a spec is captured                                                                                                                  |
| refine             | `tracker.perEvent.interview` (the key keeps its historical name) | `reconcile`                   | a refine pass updates a spec                                                                                                        |
| plan               | `tracker.perEvent.plan`                                          | `reconcile`                   | a spec is decomposed into tasks                                                                                                     |
| work (first claim) | `tracker.perEvent.work.firstClaim`                               | `push`                        | the first task of a spec is claimed                                                                                                 |
| work (done)        | `tracker.perEvent.work.done`                                     | `comment`                     | a task completes                                                                                                                    |
| make-pr            | `tracker.perEvent.makePr`                                        | `comment`                     | a PR is opened - also moves the issue to **In Review** when an open PR exists (2.1.2+)                                              |
| resolve-pr         | `tracker.perEvent.resolvePr`                                     | `comment`                     | PR threads are resolved                                                                                                             |
| completion review  | `tracker.perEvent.completionReview`                              | `comment`                     | a spec-completion review runs - verdict + R-ID coverage comment, **never Done** (2.1.2+)                                            |
| land (merged)      | `tracker.perEvent.land.merged`                                   | status apply (no facade call) | a PR merges - the **only** path to `Done`, gated on the GitHub `MERGED` probe; active by default when the bridge is active (2.1.2+) |

The lifecycle skills value-check `flowctl sync active` and the specific `perEvent` leaf, short-circuiting cleanly when the bridge is off or an event was opted out. A no-tracker repo or excluded event costs one value-check and no provider request. When active, the caller composes any semantic body or comment in a private temp file and invokes the `flowctl tracker sync` facade once.

**Observable and forcing.** Every facade call is event-tagged (`work.firstClaim`, `work.done`, `capture`, `makePr`, etc.), so `.flow/sync-runs/` records which touchpoint it served. At end-of-skill, work, capture, and make-pr run the read-only audit `flowctl sync check <spec-id> --events <triggered-csv> --since <run-anchor>`. A `MISSING` event is retro-fired exactly once through the same facade, then re-checked against a fresh `--since`. The final summary carries `Tracker sync: OK | MISSING:<event> → retro-fired → OK | MISSING:<event> (retro-fire failed: <reason>) | n/a (bridge inactive)`.

**Activation is ceremony-gated, not flag-gated.** The config *schema* default for every `perEvent` leaf stays `off`, so a bare `tracker.enabled=true` set by hand or a script - without running the discovery ceremony - fires **no lifecycle-event sync** (every `perEvent` event stays dormant). Only the ceremony’s explicit per-event writes (or your own `config set`) turn events on. This keeps the accidental-enable guard while making the *intended* path (run the ceremony) sync everything. **The one thing not gated this way is make-pr’s PR↔issue link** - it’s unconditional whenever the bridge is active (the exception documented just below), so a bare `enabled=true` plus a linked spec still adds a `Ref` line on the next make-pr. That linkage is cheap, conflict-free, and the whole point (Linear Diffs); it does not mutate the spec or fire the lifecycle touchpoints.

**One exception - PR linkage is unconditional when the bridge is active.** `/flow-next:make-pr` always links the new PR to its tracker issue when `sync active` reports true and the spec is linked - it does **not** require opting `makePr` in. Linking a PR to its issue is zero- or near-zero-cost hygiene and powers Linear Diffs (below), so there is no reason to gate it. The `perEvent.makePr` leaf still governs any *extra* make-pr sync, such as a status comment. make-pr additionally **verifies the ref landed** post-create: it fetches the LIVE PR body via `gh pr view --json body` and, when the `Ref <identifier>` line is absent (e.g. an agent hand-rolled `gh pr create` and bypassed the deterministic append), repairs it append-only via `gh pr edit` - mechanical, idempotent, fully non-fatal.

### MISSING after retro-fire: recovery

A `Tracker sync: MISSING:<event> (retro-fire failed: <reason>)` summary line means the touchpoint did not fire and the one bounded retro-fire could not recover it. The primary work is unaffected. Tracker sync is best-effort and never blocks. To recover:

1. **Read the structured result** from the event receipt. The `class` distinguishes `auth`, `unresolved`, `stale_id`, `rate_limited`, `transport`, `capability`, `conflict`, and `external_action_required`.
2. **Resolve the named condition**, then re-fire the missed touchpoint through `/flow-next:tracker-sync`. The skill supplies recovery judgment; its runtime action is the matching `flowctl tracker sync` facade call.
3. **Verify**: `flowctl sync check <spec-id> --events <event> --since <retro-fire-time>` now prints `OK:<event>`.

## Status lifecycle: `Done` means *merged* (2.1.2)

`Done` is a claim that the work **shipped**, so Flow-Next reserves it for merge-confirmed state and never infers it from local completion. The flow→tracker status map is a function of `(spec status, completion_review_status, PR-merge-evidence)`:

| Phase                                            | Evidence                       | Tracker state                                                                                   |
| ------------------------------------------------ | ------------------------------ | ----------------------------------------------------------------------------------------------- |
| Work underway                                    | task `in_progress`             | `In Progress`                                                                                   |
| make-pr found an open PR                         | open PR for the branch         | `In Review`                                                                                     |
| PR merged                                        | GitHub `MERGED`                | `Done` (terminal)                                                                               |
| All tasks done + completion `ship`, no merged PR | no / open / closed-unmerged PR | **never `Done`** - stays `In Review` (open) or `In Progress` (no PR); ambiguous → `NEEDS_HUMAN` |

The merge-evidence gate is a **per-write invariant** - no touchpoint (and no manual `/flow-next:tracker-sync` reconcile) writes terminal `Done` without a `MERGED` probe result, so a premature “shipped” can’t reach the board (the bug that prompted this: a spec marked locally complete with no PR was wrongly pushed to `Done`). A manual reconcile *can* still recover `Done` once a merge exists. `flow --auto` mirrors the rule. An all-done spec with no merged PR is never `NO_WORK`; it routes to make-pr, or reports `DEFERRED_TO_LAND` when an open PR exists and land owns the merge.

### Merged outside land

[Land](https://flow-next.dev/autonomy/land/#after-the-merge) moves the issue to `Done` right after its own merge. A pull request merged any other way (the GitHub web UI, `gh pr merge`, auto-merge, a merge queue) leaves the issue at `In Review`, because nothing ran the post-merge step. You have two ways to finish it.

**Once, by hand.** Run `/flow-next:land <pr>` after the merge. On a merged pull request land only [replays the tracker step](https://flow-next.dev/autonomy/land/#replay-on-a-merged-pull-request).

**Every time, from CI.** Two existing flowctl commands do the whole job without an agent. `flowctl spec closed-in-range` lists the specs a range of commits closed, and `flowctl tracker status <spec> --to done --event land.merged` runs the same merge-evidence check and status policy land uses. Trigger on pushes to the default branch, since every merge path above ends in one:

.github/workflows/tracker-done.yml

```yaml
name: Tracker Done after merge
on:
  push:
    branches: [main]
jobs:
  tracker-done:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }   # closed-in-range reads the pushed range
      # The tracker commands import the bundled flowctl_tracker package,
      # so fetch the scripts directory, not flowctl.py alone.
      - run: git clone --depth 1 https://github.com/gmickel/flow-next /tmp/flow-next
      - run: |
          F="python3 /tmp/flow-next/plugins/flow-next/scripts/flowctl.py"
          for id in $($F spec closed-in-range --base "${{ github.event.before }}" --json | jq -r '.spec_ids[]'); do
            $F tracker status "$id" --to done --event land.merged || echo "::warning::$id did not move"
          done
        env:
          LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }}   # or the Jira token; GitHub Issues use GH_TOKEN
          GH_TOKEN: ${{ github.token }}                   # merge evidence comes from gh
```

What to expect from it:

* A run moves each linked issue the push delivered to `Done`, and a second run reads the issue and changes nothing.
* A spec with no linked issue is refused (“tracker is unlinked”) and no issue is created.
* Merge evidence is looked up by the spec’s branch name (`gh pr list --head <branch_name>`). That matches the usual one-spec-per-branch pull request. A pull request from a branch with another name, for example one carrying several specs, finds no evidence, so its issues stay `In Review` and need the by-hand replay. The job never marks an issue `Done` without a merge.
* The job needs the tracker’s API key as a CI secret. A host’s MCP connector (the Linear connector in Claude, say) only serves an agent session, never a pipeline.
* `tracker status` writes sync metadata into the spec’s JSON. That is harmless in a CI checkout that is thrown away, and it’s the reason to keep this job in CI: land does the same step in memory so a developer checkout stays clean.

## Reconciliation: who wins

* **Body** - an agentic host-agent semantic **3-way merge** against the `lastSyncedAt` merge-base snapshot, translating between flow’s structured spec and the tracker’s free-form issue. Only **genuine contradictions** surface; confident merges proceed.
* **Status** - a per-field **who-wins** ladder. The collision / deadlock case is evaluated **before** single-field terminal-wins rules (a `tracker=done × flow=…` deadlock falls to the tiebreak rather than being silently overwritten). Tiebreak is `tracker.conflictTiebreak` (`flow-wins | tracker-wins | always-ask`, default `always-ask`). `flow-wins` uses the normal provider-neutral status write and still requires merged-PR evidence for `Done`. `tracker-wins` folds a terminal tracker into the local spec without a provider write and emits the existing `pulled` receipt. The mirror - a merged Flow spec while the tracker is active - cannot converge through raw spec status, so it stays an explicit conflict with no write and no sync-state advance.
* **Comments and evidence** - two-way **append** with dedup; neither side overwrites the other.

## Readiness projection

When `tracker.readyState` is configured (the optional ceremony question above), every operation that reads the issue (`pull` / `reconcile`) projects the configured tracker state onto the local spec **`ready` flag** - so readiness has a single local read path whether it is human-set or tracker-driven (Flow-Next 1.12.0+).

* **One-way pull, tracker authoritative.** Readiness is projected tracker → local only - the local flag is never pushed to the tracker (no status write, no label add/remove). A local `flowctl spec ready` on a tracker-connected repo is overwritten by the next sync; the team blesses work **on the board**. This is also why `/flow-next:capture` and `/flow-next:refine` stop offering their local mark-ready prompt once `readyState` is configured.
* **Match semantics.** Linear: case-insensitive trimmed match on the workflow-state **name** (names, not `state.type` - a custom “Ready” state is typically `type=unstarted`, so type alone cannot distinguish Todo from Ready). GitHub / GitLab: the `readyState` **label** - present on the issue means `ready=true`, absent means `ready=false` (absence is a normal state; un-labeling is exactly how a GitHub user un-readies a spec). GitLab has no rich workflow states, so readiness is a label identical to GitHub’s. Jira: a case-insensitive match on the workflow-**status name** (like Linear), used raw - never resolved through `statusMap`.
* **Change-only receipts.** The projection applies via the idempotent `spec ready`/`unready` toggles and emits an event-tagged receipt only when the local flag actually changes - silent on a no-op echo.
* **Stale-config degradation.** A configured state name / label that no longer resolves on the tracker (renamed or deleted) produces a warn + `noop` receipt, leaves the flag untouched, and the rest of the sync continues - one bad knob never aborts the run, and a stale `readyState` must not silently un-ready every linked spec.
* **Orthogonal to status.** The projection never feeds the who-wins ladder, never advances `lastSyncedAt` by itself, and never blocks. `readyState: null` (the default) skips it entirely - no calls, no receipts, no flag writes.
* **`flow --auto` interplay (corrected in 3.28.0).** The projection feeds [`/flow-next:flow --auto`](https://flow-next.dev/autonomy/pilot/) directly, because the ready flag is the consent boundary it selects from. Its two-strike guard runs a **local** `spec unready` after the second healthy no-advance hop, which is advisory until the board reflects it, since the next pull projects the issue’s state back and re-readies the spec. **A projection-set ready never clears a strike.** The board echo re-grants readiness with nobody acting, and clearing on it would re-dispatch the same failing spec on every run. The recovery is the verb `flowctl pilot strikes clear <spec-id>`, and the `strike 2/2` verdict names it in its reason string. Moving the issue out of the ready state is still worth doing (it stops re-selection noise and keeps the board honest), and it does not clear the strike. The board stays the control plane for **readiness**; strikes are driver state in the ledger under the git common dir. Full walkthrough on the [flow —auto page](https://flow-next.dev/autonomy/pilot/#readiness-as-the-control-plane).

## Dependency projection

When a spec declares cross-spec dependencies via `depends_on_epics`, every push or reconcile asks `flowctl tracker relate` to project those edges onto linked issues, so the board shows the real ordering instead of a flat list.

* **Direction and fidelity.** A `depends_on_epics` edge means “this spec is blocked by that one.” Linear and Jira use native directional blocked-by relations. GitHub has no issue-level blocked-by relation, so flowctl makes the blocked issue a `sub_issue` of the blocker and returns `degraded.kind: "hierarchy"` with `degraded.form: "sub_issues"`. GitLab uses a native directional link when `capabilities.blockedBy` is true; otherwise it records a directionless `relates_to` link for board visibility and preserves direction and provenance in the fenced `<!-- flow:deps -->` body block.
* **Read-before-write idempotency.** Re-syncing creates no duplicate relations. Re-running is safe.
* **Provenance.** Flow records projected edges in the per-spec `depRelations` ledger, plus the fenced marker where a fallback needs it. A relation flowctl cannot prove it created is never removed. A ledgered relation that a human removed is deferred, never silently recreated.
* **Missing link → warning, not a block.** A dependency whose spec isn’t linked to an issue surfaces a named warning; the rest of the sync proceeds.
* **Completed blockers stay visible.** A `done` dependency keeps its relation on the board (the audit trail) but never re-gates `ready=true` - readiness already treats done deps as satisfied.
* **Collision-safe.** A ledgered edge that a tracker user removed is deferred, never silently recreated. Provider failures return a structured class and never block the primary work.

## Sync-state schema

State lives in the existing `.flow/specs/<id>.json` sidecar (not the markdown frontmatter - merge-base body snapshots would bloat the spec). The per-spec `tracker` block:

| Field                                | Meaning                                                                                                     |
| ------------------------------------ | ----------------------------------------------------------------------------------------------------------- |
| `id`                                 | tracker UUID - the durable dedupe key                                                                       |
| `identifier`                         | display key, e.g. `WOR-17`                                                                                  |
| `url`                                | issue URL                                                                                                   |
| `lastSyncedAt`                       | ISO timestamp of the last real reconciliation (advances on a real reconcile, never on a no-op pull or echo) |
| `baseHashFlow` / `baseHashTracker`   | content hashes of each merge-base side (echo fence)                                                         |
| `mergeBaseFlow` / `mergeBaseTracker` | the body snapshots themselves - the common ancestor for the agentic 3-way merge                             |

The **merge base is a paired snapshot at one sync point**: `flowctl sync set-merge-base` requires **both** the `--flow`/`--flow-file` and the `--tracker`/`--tracker-file` halves together. A partial write that pins one half to a stale sync point is rejected.

## Deterministic tracker transport

At runtime, skills do not choose provider routes or execute provider APIs. They pass semantic inputs to `flowctl tracker` and branch on its structured result.

`flowctl tracker resolve` writes normalized destination metadata and a capability snapshot to `tracker.resolved`. All consuming verbs read that persisted block. Scoped timestamps make partial refreshes honest. Refreshing `destination.statusIds` cannot make `capabilities` look fresh.

The command surface has three layers:

* **Wire verbs:** `read`, `update`, `comment-add`, `comment-list`, `comment-update`, `comment-delete`, `label`, `assign`, `list-open`, `list-states`, `relation-list`, `question`, `attach`, and `attach-get`.
* **Lifecycle verbs:** `create`, `create-first`, `persist-external`, `status`, `relate`, and `sync-body`.
* **Lifecycle facade:** `flowctl tracker sync <spec-id> --op <push|pull|reconcile|comment> --event <key>`. This is what lifecycle callers use. It owns create-if-unlinked, title and body projection, comment markers and deduplication, provider mutations, and one aggregate receipt.

Every invocation returns one JSON envelope. Success is:

```json
{"success":true,"data":{},"degraded":null,"probe":null}
```

Failure includes `success:false`, an exhaustive `class`, `error`, `retryable`, and typed `details`. Classes are `invalid_input`, `inactive`, `unresolved`, `auth`, `rate_limited`, `transport`, `not_found`, `capability`, `conflict`, `stale_id`, and `external_action_required`; their fixed exits are 2 through 12 in that order. Exit 0 is success. Branch on `class`, never provider error text. A confirmed capability loss appears as `degraded`; a failed capability probe appears separately as `probe` and keeps the prior capability.

## Edits made on the tracker

A teammate who edits a linked issue’s body on the board keeps that edit (6.1.0+). Before a body-writing `push` on a linked spec, the facade compares the current tracker body with the merge base it recorded at the last sync. When they differ, the push writes nothing and returns `class: conflict` with subtype `tracker_diverged`.

* **Attended.** The skill asks once: reconcile (recommended; merges both sides), overwrite the tracker body by rerunning the same push with `--overwrite-diverged`, or leave it.
* **Unattended.** Under `flow --auto` or any autonomy marker, the run never overwrites. It records `flowctl sync defer <spec-id> --summary "tracker body diverged since last sync"` and continues, and the decision waits in the deferred-findings sink.

A spec’s first push, before any merge base is recorded, behaves as before. A `--status-only` push to a linked issue writes status only, so a work first-claim is unaffected.

```bash
flowctl tracker sync <spec-id> --op push --event KEY --overwrite-diverged   # only after a human confirms
```

## Jira: the enterprise provider

Jira is the fourth tracker behind the same deterministic command contract. Its workflow model is richer than the normalized vocabulary, so it carries provider-specific metadata:

* **REST API, both deployment families.** Jira Cloud and Data Center / Server both use API version 2, whose text fields hold wiki markup. Cloud uses HTTP-basic `email:API_TOKEN` auth; Data Center / Server uses a Bearer PAT. A legacy configured version 3 is migrated to 2. Self-hosted instances behind a private CA can opt out of TLS verification.
* **No MCP runtime dependency.** The official Atlassian Remote MCP does not expose the complete mutation surface the bridge needs. External MCP use can return through `persist-external`; deterministic runtime operations stay in flowctl.
* **Workflow-aware status via the transitions API.** Jira forbids a direct status write - a change goes through the transitions API: read the transitions legal from the issue’s *current* status, resolve the one that lands the configured target, and POST it. A configurable `tracker.perTracker.statusMap` maps each normalized status to a Jira workflow status (by durable id, preferred, or name). A status with no map entry, or a target the project’s workflow can’t reach from the current state, is **deferred with a receipt, never forced** into an arbitrary lane. Terminal detection keys off the stable `statusCategory.key == "done"` signal, not the renamable status name.
* **`Done` means merged.** A locally-`done` spec with no merged PR maps to `in-review`, and only a `MERGED` PR probe lets terminal `done` reach the board. (See [Status lifecycle](https://flow-next.dev/integrations/tracker-operations/#status-lifecycle-done-means-merged-212).)
* **Native blocks links.** `depends_on_epics` edges project as native Jira `Blocks` issue links. Jira does not no-op a duplicate link, so flowctl reads before writing. A human-removed ledgered link is deferred, never silently re-created. The blocking link type is configurable and resolved once.
* **Specs render formatted in Jira.** flowctl converts issue and comment bodies from Markdown to Jira wiki markup on every write and decodes them back to Markdown once on read, so headings, bold, lists, code blocks and tables display as written while merge and comment dedup keep comparing Markdown. Correct display needs the **Wiki Style Renderer** on the description and comment fields; the Default Text Renderer shows the markup literally. An issue pushed before 6.0.1 is converted by its next push or reconcile while its body is unchanged since the last sync; a pull refuses it with a `jira_body_unconverted` conflict until then.
* **Backlog enumeration via JQL.** `flowctl tracker wire list-open` enumerates open issues at the exact `tracker.readyState`, using bounded pagination and honest `truncated` metadata.
* **Backlog dependency reads.** `flowctl tracker wire relation-list` accepts the durable/display locator returned by `list-open`, returns provider-neutral directed `blocks` rows for `flow --auto --backlog` dependency ordering, and fails closed when bounded pagination cannot prove the graph complete.
* **Read-only status enumeration (4.1.0).** `flowctl tracker wire list-states` lists the project’s statuses (id, name, statusCategory key) with a `complete` flag, so “does every configured status id still name a live status?” is answerable without `tracker resolve` - which repairs and *writes* the mapping. Detection never writes.
* **Tracker-only question parking.** `flowctl tracker wire question` receives the issue locator, four stable identity fields, and a private body file. It computes the canonical marker id, reads existing comments, and posts only when that id is absent.
* **PR link as a remote link.** Jira has neither GitHub’s `Refs #N` auto-linkify nor Linear’s Diffs attachment, so `/flow-next:make-pr` projects the PR↔issue link as a Jira **remote link** (idempotent on a deterministic `globalId`), falling back to a URL comment if the remote-link write is unavailable.

## Unattended-safe: conflicts queue

Every run emits a receipt (`flowctl sync receipt --status …`); genuine conflicts **queue** (`flowctl sync defer …`) rather than block. In autonomous mode an `always-ask` tiebreak resolves to **queue**, not prompt - the same policy, with surface-dependent delivery. Deferred conflicts land in the review deferred-findings sink (`.flow/review-deferred/<branch>.md`), where a human already looks for deferred work - so tracker-sync never needs `flowctl block` and never stalls the loop.

## flowctl surface

The skill owns semantic judgment. `flowctl tracker` owns deterministic provider reads and mutations. `flowctl sync` owns local bridge state: `active`, `get-state`, `set-tracker-id`, `set-last-synced`, `set-merge-base`, `clear`, `list-unsynced`, `list-stale`, `check-collisions`, the dependency ledger, `receipt`, `check`, and `defer`. See [CLI Reference](https://flow-next.dev/flowctl/cli-reference/#tracker-commands) and [Configuration](https://flow-next.dev/flowctl/configuration/#tracker-bridge).

The sync-engine shape (discovery ceremony, per-item `lastSyncedAt`, surface-diffs-never-overwrite) is adapted from Ray Fernando’s [`running-bug-review-board`](https://github.com/RayFernando1337/rayfernando-skills) `issue-trackers.md` (Apache-2.0).

## See also

* [Spec & task ids](https://flow-next.dev/reference/spec-schema/#spec-and-task-ids) - the hybrid id model: when a spec is `fn-NN`, `wor-17`, `gh-123`, or `gl-456`.
* [Collaboration](https://flow-next.dev/guides/for-teams/) - projection-not-coordination positioning, the tracker-first PM flow, and the team recommendation for `tracker.specIds`.
* [Configuration](https://flow-next.dev/flowctl/configuration/#tracker-bridge) - `flowctl sync` subcommands and the `tracker.*` config keys.
