Skip to content

Tracker Sync

/flow-next:tracker-sync projects a Flow-Next spec onto a tracker issue (Linear, GitHub, GitLab, or Jira) and reconciles body, status, and comments two-way. The skill owns the judgment — API calls, reconciliation, asking — while flowctl sync … provides the deterministic plumbing.

The .flow/specs/<id>.md spec is the single source of truth and the quality layer. The tracker is a co-editable mirror for teams that must live in it. The bridge is projection, not coordination:

  • The tracker mirrors the spec. Body, status, and comments all sync two-way — a vague PM-authored issue can be pulled in, fleshed out in Flow-Next, and synced back.
  • The tracker never drives flow state or spawns agents. There is no board-status-flips-fire-an-agent control plane. The spec stays where work is authored, enriched, and executed.

“Not coordination” means the tracker is not a control plane — it does not mean one-way. The contrast is OpenAI Symphony, where Linear is the canonical finite-state machine that spawns agents off a thin per-issue WORKFLOW.md. Flow-Next’s pitch is “Symphony, but with real specs, re-anchoring, and receipts” — the spec carries the weight; the tracker is a downstream window. A Symphony-style board-triggered per-spec executor is a separable future addition, explicitly out of scope here.

flowchart LR
  Spec[".flow/specs/<id>.md\nsource of truth"] -->|projection| Issue["Tracker issue\nco-editable mirror"]
  Issue -->|reconcile body / status / comments| Spec
  Issue -. never drives .-> Agent["Agent loop"]
  Spec --> Agent

Configuring the bridge is its own one-time step, separate from /flow-next:setup. Setup installs flowctl and project docs and never touches tracker config — that keeps the zero-dep base install clean for the (many) users who run no project-management software. The bridge is set up by running /flow-next:tracker-sync, whose discovery ceremony writes the config. When /flow-next:setup finishes it proposes running /flow-next:tracker-sync as an optional next step, so the bridge is discoverable without being imposed.

The bridge is off until explicitly enabled (tracker.enabled defaults false, tracker.type defaults null). The discovery ceremony detects → surfaces → asks → never assumes, and writes config only on confirmation, with provenance. No signal means nothing is written.

Five probed signals:

SignalProbeMeans
Linear MCP registeredhost MCP/tool list contains a Linear serverinteractive Linear transport available (OAuth handled)
LINEAR_API_KEY[ -n "$LINEAR_API_KEY" ]headless Linear GraphQL transport available
GitHub authgh auth status exits 0headless GitHub transport available
GitLab auth / tokenglab auth status exits 0, or GITLAB_TOKEN / CI_JOB_TOKEN setGitLab transport available (glab CLI primary → raw-REST token fallback; self-managed hosts honored)
Jira host + credentiala Jira base URL is configured (Cloud *.atlassian.net or a self-hosted JIRA_BASE_URL) and a credential is present (JIRA_EMAIL + JIRA_API_TOKEN on Cloud, or JIRA_PAT on Data Center / Server)Jira REST transport available (single rung, no MCP)

Resolution is env > config > ASK (the same ladder as flowctl review-backend): if env or config already decides the transport, the ceremony does not re-ask. On confirmation the skill writes via flowctl config set tracker.… and verifies with flowctl sync active --json (must report active: true). The bridge is active when raw tracker.enabled == true or raw tracker.type ∈ {linear, github, gitlab, jira}.

For a GitLab tracker the ceremony also writes tracker.perTracker.project (the group/sub-group/project path, e.g. group/subgroup/project — the GitLab analog of GitHub’s repo) and, for self-managed hosts, tracker.perTracker.host. Zero special setup — GitLab works from an existing glab auth login session or a GITLAB_TOKEN / CI_JOB_TOKEN already present (gh-style), with no Flow-Next-specific provisioning; the spec-first floor applies when neither is present.

For a Jira tracker the ceremony detects the deployment shape once and persists it: tracker.perTracker.baseUrl (the site, e.g. https://acme.atlassian.net or a self-hosted https://jira.acme.com), tracker.perTracker.projectKey (the project the bridge scopes to), tracker.perTracker.authScheme (cloud-basic for Jira Cloud’s email:API_TOKEN HTTP-basic, or bearer-pat for a Data Center / Server PAT), tracker.perTracker.apiVersion (3 for Cloud’s /rest/api/3 with ADF bodies, 2 for DC/Server’s /rest/api/2 wiki text), and tracker.perTracker.statusMap (the normalized-status → Jira-workflow-status map the transitions API drives). Zero special setup — Jira works from a standard Jira credential (a Cloud API token from id.atlassian.com, or a self-hosted PAT) read from the environment each run and never stored; it is never an OAuth / Connect / Forge app. Credentials live in env (JIRA_EMAIL + JIRA_API_TOKEN on Cloud, JIRA_PAT on DC/Server); only the non-secret deployment shape is persisted. A self-hosted instance behind a private CA can opt out of TLS verification with tracker.perTracker.sslVerify=false (or JIRA_SSL_VERIFY=false).

Since Flow-Next 1.12.0 the ceremony ends with one optional, skippable readiness question: which tracker workflow state means “ready for work”? — a Linear workflow-state name (discovered from the team’s states, a “Ready”-looking name recommended), a GitHub label (suggested ready, pre-created idempotently), a GitLab label (suggested ready, pre-created idempotently; GitLab has no rich workflow, so readiness is a label like GitHub), or a Jira workflow-status name (matched like Linear, used raw in the promoted-lane filter — never through statusMap). The answer is stored as tracker.readyState; skipping writes nothing and the readiness gate stays dormant. See Readiness projection below.

Three entry flows - no fixed starting point

Section titled “Three entry flows - no fixed starting point”

There is no required starting point. All three flows attach sync state on link:

  1. Author-in-flow-then-push (flow-first). A fn-NN spec already exists. Push creates the tracker issue, then flowctl sync set-tracker-id attaches the issue UUID plus --identifier WOR-17 and --url. The fn-NN id is kept; the tracker key becomes a resolvable alias.
  2. Link-existing-issue (tracker-first) - “grab issue X and spec it.” Fetch the issue, create the spec keyed by the tracker key (flowctl spec create --tracker-first --tracker-identifier <key-or-ref>), seed the merge base from the current issue body, and treat the first pass as pull-only. All four trackers support tracker-first:
    • Linear WOR-17 / Jira PROJ-123 are native KEY-N and mint directly (wor-17-slug / proj-123-slug).
    • GitHub #123 and GitLab <project>#456 are not literal KEY-N (no alpha key / path + #), so flowctl mints synthetic keys while tracker.type matches: #123gh-123-slug, <project>#456gl-456-slug (project-scoped iid, never the opaque global id). Bare gh-123 / gl-456 resolve as aliases.
    • Flow-first remains available on every tracker (create fn-NN, then set-tracker-id with the issue ref as a display alias).
    • One Jira edge: a Data Center / Server custom key that can’t slugify into a canonical id (an underscore key like MY_PROJECT-7, or a long alphanumeric one) links flow-first / display-only - the key is stored as a shown alias, not a resolvable spec handle.
  3. Create-first (fresh idea - issue before any local spec). When tracker.specIds=tracker and no issue exists yet, tracker-sync creates the issue from title + body (no local spec id), returns {id, identifier, url}, then the caller mints → attaches → seeds the merge base. A retry after partial failure links, never re-creates.

The two id schemes coexist; resolution is case-insensitive. Ids never change - there is no rename-on-push. Mixed stores (fn-N-slug next to KEY-N-slug / gh-N-slug) are permanent and expected.

Tracker-first (canonical)Flow-first (alias)
canonical spec idwor-17-slug / gh-123-slug / gl-456-slugfn-NN-slug (unchanged)
canonical task idswor-17-slug.M / gh-123-slug.Mfn-NN-slug.M
branchsame as canonical idfn-NN-slug
bare aliaseswor-17 / gh-123 / gl-456 (and .M task forms) resolve to the full slug idWOR-17 (stored in tracker.identifier) resolves to fn-NN-slug. A GitHub/GitLab ref like #123 is display-only - stored and shown, never a resolvable handle; only the synthetic gh-123 / gl-456 form resolves
create / linkflowctl spec create --tracker-first --tracker-identifier <key-or-ref>flowctl sync set-tracker-id fn-NN-slug <uuid> --identifier <key> --url <url>

Also covered under Spec & task ids.

GitHub and GitLab do not ship a native KEY-N display form. While tracker.type is github / gitlab, flowctl synthesizes a resolvable key from the issue number:

tracker.typeNative identifierMinted spec id
linearWOR-17wor-17-slug (native key, unchanged)
jiraPROJ-123proj-123-slug (native key, unchanged)
github#123gh-123-slug (synthetic gh)
gitlab<project>#456gl-456-slug (synthetic gl; uses project-scoped iid, never the opaque global id)

Guards (not type-gating alone): while tracker.type is github/gitlab, the matching prefix (gh/gl) is contextually reserved for synthesis - an explicit native GH-123 identifier is rejected at mint/link. Before minting, a preflight of the existing store refuses a colliding canonical id or resolvable alias. A Linear/Jira repo natively keyed GH is unaffected (type is not github/gitlab). Re-pointing tracker.type (or re-pointing GitLab at a different project) is a documented hazard: previously minted ids keep their meaning; preflight stops a new mint from colliding with them.

ValueBehavior
flow (default; also the fail-closed read for a malformed on-disk value)Spec-creating skills mint fn-N-slug (today’s behavior)
trackerWith an active bridge, skills mint tracker-keyed ids: named issue → --tracker-first; fresh idea → create-first then mint
Terminal window
flowctl config set tracker.specIds tracker # or flow

Write side: only flow or tracker are accepted (invalid CLI writes are rejected). The leaf is unset-detectable (not materialized at init) so /flow-next:setup can ask once when a tracker is configured and the key is still absent; once set either way, setup never re-asks. Bridge inactive / no transport degrades silently to flow-first. Explicit user override always wins.

Network cost is conditional: when the matching tracker.perEvent.* touchpoint is already active, tracker-first reorders an existing remote write; when those leaves are off (their default), tracker-first introduces an earlier remote write that flow-first would not make.

A duplicate native fn-N ordinal whose full ids are distinct (e.g. two fn-122-… specs) is untidy, not broken. flowctl validate --all reports it as a top-level warning, not a root error. Bare fn-N resolution disambiguates rather than guessing - lists candidates and requires the full id. Ids never change; do not renumber.

  • Resolution is case-insensitive. flowctl show wor-17, work gh-123, plan gl-456, tasks wor-17.M all resolve. tracker.identifier stores the display form (WOR-17 / #123 / group/project#456); the canonical id derives from the lowercase key.
  • fn is the only globally reserved prefix. Synthetic gh/gl are reserved only while tracker.type matches. Native fn-N allocation counts fn-* only - a wor-9999 never bumps the next fn.
  • Native fn-N allocation is a union scan over the working tree, every registered git worktree’s .flow/specs/, and every ref (monotonic max-ever-allocated). Fail-open on git problems. That shrinks the parallel-agent collision window; separate unfetched clones can still collide - that is what tracker.specIds=tracker is for.
  • One tracker team / workspace per repo. The bridge assumes a single team key so a bare wor-17 resolves unambiguously.
  • No rename-on-push. Existing spec/task ids, branches, and dep edges are never mutated on link; the tracker key is added as a resolvable handle, not a replacement. flowctl spec set-title on a tracker-linked spec updates the title only - it does not re-slug the id, branch, or files.
  • One flow spec maps to one tracker issue. The tracker UUID is the durable dedupe key (flowctl sync set-tracker-id); flowctl sync check-collisions flags any UUID shared by two specs.
  • Tasks stay flow-local by default — they are never auto-created as tracker sub-issues. An optional checklist-in-body render (tasks as a body checklist, not sub-issues) is a body-format concern that is off by default.

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:

FieldMeaning
idtracker UUID — the durable dedupe key
identifierdisplay key, e.g. WOR-17
urlissue URL
lastSyncedAtISO timestamp of the last real reconciliation (advances on a real reconcile, never on a no-op pull or echo)
baseHashFlow / baseHashTrackercontent hashes of each merge-base side (echo fence)
mergeBaseFlow / mergeBaseTrackerthe 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.

The skill is transport-blind — it calls a normalized interface (fetchIssue / writeIssue / listComments / postComment / readStatus / setStatus) and never sees a wire shape. Each adapter detects the best available transport and degrades gracefully:

AdapterLadderStatus fidelity
LinearMCP → GraphQL (LINEAR_API_KEY) → no-opfull workflow states
GitHubgh (single rung) → no-opreduced fidelity (open / closed)
GitLabglab CLI → raw-REST /api/v4 (GITLAB_TOKEN / CI_JOB_TOKEN) → no-opreduced fidelity (open / closed)
JiraJira REST API (/rest/api/3 Cloud or /rest/api/2 DC/Server, token from env) → no-opworkflow-aware (transitions API + statusMap)

When no transport is reachable, the run is a noop plus a receipt note — never a crash. The transport actually used (mcp / graphql / gh / glab / rest / none) is recorded on every receipt — Jira’s REST rung records as rest (the shared “raw HTTP, token from env” transport class).

Jira is the fourth tracker, behind the same transport-blind interface. It mirrors the GitHub adapter’s shape — one transport rung plus the no-op floor — rather than Linear’s multi-rung ladder, but its workflow model is richer than the normalized vocabulary, so it carries the most adapter-specific weight:

  • REST API, both deployment families. Jira Cloud uses /rest/api/3 (ADF bodies, HTTP-basic email:API_TOKEN auth); Data Center / Server uses /rest/api/2 (wiki / plain-text bodies, Bearer PAT auth). The ceremony detects and persists which family applies (apiVersion + authScheme); the adapter branches on the persisted value and never re-infers it. Self-hosted instances behind a private CA can opt out of TLS verification.
  • No MCP — by design. Unlike Linear, Jira has no MCP rung. The official Atlassian Remote MCP is read-mostly — it does not expose issue transitions, issue links, or remote links, which is exactly the write surface the bridge needs (status, dependencies, the PR link). Third-party Jira MCP servers are a thin wrapper over the same REST API and the same token, adding a moving part that drifts without adding capability. So Jira is REST-only: a standard Jira credential, headless-native, with the fewest moving parts.
  • 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 — the same fn-66 invariant. The terminal-status gate is transport-blind: a locally-done spec with no merged PR arrives as in-review (mapped to the project’s In-Review status), and only a MERGED PR probe lets a terminal done reach the board. (See Status lifecycle.)
  • Native blocks links — no degrade. depends_on_epics edges project as native Jira Blocks issue links (is blocked by from the blocked issue’s view), available on every Jira tier (Cloud + DC/Server, no licensing gate to degrade around — unlike GitLab). Jira does not no-op a duplicate link, so the adapter reads-before-write to dedup, and a human-removed link is deferred, never silently re-created. The block link type is configurable (admins can rename Blocks); the adapter discovers it.
  • Markdown ↔ ADF body translation. On Cloud (/rest/api/3) issue descriptions and comments are Atlassian Document Format. The adapter translates a documented round-trip-safe markdown subset to and from ADF at the boundary, so reconcile only ever sees markdown. A write preserves unknown ADF nodes verbatim (panels, tables, media a human added in the Jira editor) by fetching the current ADF first and splicing — it never blind-regenerates the doc and drops un-modelled content. DC/Server bodies are wiki / plain text, so the ADF translation is gated to Cloud.
  • Backlog enumeration via JQL. Jira implements the listOpenIssues method (so /flow-next:pilot backlog mode can surface async gap-questions onto a Jira issue): it enumerates the promoted lane — open issues at the exact tracker.readyState status — via a JQL query, paginated by the Cloud POST /search/jql cursor or the DC/Server /rest/api/2/search offset.
  • 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.

Lifecycle sync points (on by default — opt-out)

Section titled “Lifecycle sync points (on by default — opt-out)”

Sync is wired into seven 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.

EventConfig keyDefault opFires when
capturetracker.perEvent.capturereconcilea spec is captured
interviewtracker.perEvent.interviewreconcilea spec is refined
plantracker.perEvent.planreconcilea spec is decomposed into tasks
work (first claim)tracker.perEvent.work.firstClaimpushthe first task of a spec is claimed
work (done)tracker.perEvent.work.donecommenta task completes
make-prtracker.perEvent.makePrcommenta PR is opened — also moves the issue to In Review when an open PR exists (2.1.2+)
resolve-prtracker.perEvent.resolvePrcommentPR threads are resolved
completion reviewtracker.perEvent.completionReviewcommenta spec-completion review runs — verdict + R-ID coverage comment, never Done (2.1.2+)
land (merged)tracker.perEvent.land.mergedpusha 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 — so a no-tracker repo (or an excluded event) costs a single value-check, no transport. The no-tracker path is the documented default and is shown unchanged everywhere else in these docs.

Observable + forcing (1.11.0). Every lifecycle dispatch is event-tagged: the tracker-sync skill writes its receipt with --event <perEvent-key> (work.firstClaim, work.done, capture, makePr, …), so .flow/sync-runs/ records which touchpoint each run 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> — independently of the touchpoints themselves, so a wholesale-skipped dispatch block is still caught. An event is MISSING iff it triggered this run AND its perEvent leaf is enabled AND the bridge is active AND no receipt with a matching event tag and timestamp ≥ --since exists (any receipt status clears — the check asserts the touchpoint ran; the receipt’s own status carries success/failure detail). A MISSING event is retro-fired exactly once — the skill re-dispatches the missed touchpoint via tracker-sync, then re-checks against a fresh --since — and the skill’s final summary carries a mandatory four-state Tracker sync: slot: OK | MISSING:<event> → retro-fired → OK | MISSING:<event> (retro-fire failed: <reason>) | n/a (bridge inactive). An explicit n/a proves the check ran; an absent slot is visible as a skipped check. With no tracker configured sync check exits silently in constant time — non-tracker repos see no change anywhere.

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.

Background dispatch — comment touchpoints off the critical path (2.17.0)

Section titled “Background dispatch — comment touchpoints off the critical path (2.17.0)”

Comment-shaped lifecycle touchpoints (work.done, completionReview, resolvePr, qa) on a linked spec no longer run inline in the host skill: they dispatch to a background tracker-runner subagent whose own context absorbs the sync references and API echoes — the host gets back a single parseable terminal line (TRACKER_RUNNER=<status> spec=<id> note="..."). In the live proof, ~124k tokens of runner work stayed out of the host window with zero added wall-clock (the runner fully overlapped the test suite).

What never forks: state-shaped ops (push/pull/reconcile, merge-base writes), an unlinked spec’s first touch, the discovery ceremony, manual /flow-next:tracker-sync runs, dry-runs, and interactive conflict resolution. Two invariants govern the overlap: one state-writer per spec (comment appends never advance lastSyncedAt — that is scoped to two-way reconciles), and join-before-audit (no sync check runs with a dispatch outstanding). Host tiers: Claude Code runs Tier A (background + notification join, fire-and-forget); Codex and Cursor run Tier B (isolated-but-awaited — context isolation kept, overlap forgone); anything else degrades loudly to inline. The sole rule statement lives in the plugin’s references/tracker-dispatch.md.

A Tracker sync: MISSING:<event> (retro-fire failed: <reason>) summary line means the touchpoint didn’t fire AND the one bounded retro-fire couldn’t recover it — typically no reachable transport (MCP server down, no LINEAR_API_KEY, gh unauthenticated). The primary work is unaffected: tracker sync is best-effort and never blocks, so the task is done / the PR is open. To recover by hand:

  1. Read the failure reason from the run’s receipts: ls -t .flow/sync-runs/sync-<spec-id>-*.json | head -3 — the status (noop / errored) and note fields on the event-tagged receipt say why it failed.
  2. Once transport returns, re-fire the missed touchpoint manually via the skill: /flow-next:tracker-sync push <spec-id> for status events (work.firstClaim, work.completionReview), or the matching op for comment events (comment <spec-id> for work.done / makePr).
  3. Verify: flowctl sync check <spec-id> --events <event> --since <retro-fire-time> now prints OK:<event>.

Linear Diffs — review the PR inside the issue

Section titled “Linear Diffs — review the PR inside the issue”

Linear Diffs (GA May 2026) renders a GitHub PR’s diff, file changes, checks, and inline review threads directly on the Linear issue, and lets you approve, request changes, or merge from inside Linear. Flow-Next makes your PRs Diffs-ready automatically when tracker.type == linear:

A Flow-Next PR (the spec's make-pr output) rendered as a Linear Diff inside its issue — full code diff, checks, and review controls without leaving Linear. Click to zoom.
  • What Flow-Next does. make-pr puts a non-closing Ref WOR-N line in the PR body so Linear’s GitHub integration auto-links the PR to the issue on the identifier — which is exactly what makes the diff render inside the issue. On the GraphQL transport it also creates a rich PR attachment (attachmentLinkURL) for status sync. Non-closing (Ref, not Fixes) is deliberate: the PR links and renders as a diff but does not auto-complete the Linear issue on merge. Since 2.1.2, merge evidence owns the Done transition — only land.merged (gated on the GitHub MERGED probe) writes Done; spec-completion-review posts a verdict comment but never completes the issue. The linkage is unconditional once the bridge is active — there is no separate makePr opt-in, which is also why make-pr can move the issue to In Review on this same unconditional path.

Status lifecycle — Done means merged (2.1.2)

Section titled “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):

PhaseEvidenceTracker state
Work underwaytask in_progressIn Progress
make-pr found an open PRopen PR for the branchIn Review
PR mergedGitHub MERGEDDone (terminal)
All tasks done + completion ship, no merged PRno / open / closed-unmerged PRnever 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. Pilot 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.

  • What you must enable (one-time, Linear-side — Flow-Next cannot set these for you): the Linear GitHub integration with code access to the repo, your personal GitHub connection, and “Enable code reviews” in Linear settings. Without them the PR still links and status still syncs; only the rendered diff view needs them.
  • GitHub / GitLab tracker. No Linear Diffs. The PR is cross-linked natively (Refs #N on GitHub; Ref <project>#<iid> on GitLab) and review happens on the host. Like the Linear branch, the cross-reference is non-closing (Refs, not Fixes) so merge does not bypass spec-completion-review.
  • Jira tracker. No Linear Diffs and no native git cross-reference (Jira does not auto-linkify a PR, and Smart-Commit keys need a DVCS connector that may be absent). Instead make-pr writes the PR↔issue link as a Jira remote link — idempotent on a deterministic globalId, rendered in the issue’s web-links panel regardless of any connector — falling back to a URL comment if the remote-link write fails. The link is informational, never status-driving, so merge still routes through the merge-evidence gate.
  • 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).
  • Comments and evidence — two-way append with dedup; neither side overwrites the other.

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:interview 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.
  • Pilot interplay. /flow-next:pilot selects ready specs, and its two-strike don’t-thrash guard runs a local spec unready — advisory until the board reflects it, since the next pull projects the issue’s state back (re-readying the spec, which pilot reads as a human re-bless and clears strikes). When pilot strikes a spec out, move the issue out of the ready state on the board; re-blessing after a fix is the reverse move. Full walkthrough on the pilot page.

When a spec declares cross-spec dependencies via depends_on_epics, every push / reconcile projects those edges onto the tracker as blocked-by relations between the linked issues — so the board shows the real ordering instead of a flat list of independent issues (Flow-Next 2.1.0+). Works on all adapters through one transport-blind hook (projectDepRelations, modelled on readiness projection).

  • Direction + fidelity. A depends_on_epics edge means “this spec is blocked by that one.” Linear: a native issue relation (issueRelationCreate type: blocks, MCP save_issue blockedBy on the MCP rung). GitHub: native issue dependencies (GA Aug 2025) via the REST …/dependencies/blocked_by endpoints when available, else a provenance-fenced <!-- flow:deps --> body block of #N references. GitLab: native directional is_blocked_by issue links on a Premium/Ultimate-licensed namespace; on a Free or personal namespace (where the API returns 403 Blocked issues not available for current license) the adapter writes a directionless relates_to link for UI visibility instead. On both tiers GitLab also always writes the provenance-fenced <!-- flow:deps --> body block — the durable direction source on the native path and the sole direction record on the degrade — so a body update preserves it on every tier. Jira: native directional Blocks issue links (the dependency blocks the blocked issue, which reads as “is blocked by”) on every tier — Cloud and DC/Server, no licensing gate, so no degrade and no <!-- flow:deps --> body block (the native link carries the direction). The skill never branches on tracker — only fidelity differs.
  • Read-before-write idempotency. Re-syncing creates no duplicate relations (Linear edges are canonicalized across relations + inverseRelations; the GitHub / GitLab fenced block is rewritten in place; GitLab native links are read from the existing /links listing; Jira does not no-op a duplicate POST /issueLink, so the adapter reads the existing link listing first and skips a present edge). Re-running is always safe.
  • Provenance — never clobbers a human’s relation. Flow records the edges it created in a per-spec depRelations ledger (native) or the fenced marker (GitHub / GitLab fallback); a relation it can’t prove it created is never removed. The projected flag keys off the directed tracker edge, so a relinked issue correctly reads un-projected. On Jira, where a duplicate link is not no-op’d and the native link carries no flow authorship, the ledger is the provenance authority and a ledgered edge whose native link a human deleted is deferred, never silently re-created.
  • 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 (queued receipt), never silently recreated. Unreachable transport → noop, never blocks.

Every run emits a receipt (flowctl sync receipt --status …); genuine conflicts queue (flowctl sync defer …) rather than block. In autonomous / Ralph 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. See Ralph guardrails.

The skill owns judgment; flowctl sync owns deterministic plumbing: sync active / get-state / set-tracker-id / set-last-synced / set-merge-base / clear / list-unsynced / list-stale / check-collisions / list-dep-relations / set-dep-relation / clear-dep-relation (dependency-projection ledger) / receipt (event-tagged via --event <perEvent-key>) / check (read-only lifecycle audit, OK/MISSING per event) / defer, plus the tracker.* config keys. See Configuration.

The sync-engine shape (discovery ceremony, per-item lastSyncedAt, surface-diffs-never-overwrite) is adapted from Ray Fernando’s running-bug-review-board issue-trackers.md (Apache-2.0).

  • Spec & task ids - the hybrid id model: when a spec is fn-NN, wor-17, gh-123, or gl-456.
  • Collaboration - projection-not-coordination positioning, the tracker-first PM flow, and the team recommendation for tracker.specIds.
  • Configurationflowctl sync subcommands and the tracker.* config keys.