Tracker operations
This is the operations half of the tracker bridge. Set the bridge up there; run it from here.
Lifecycle sync points (on by default: opt-out)
Section titled “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
Section titled “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:
- Read the structured result from the event receipt. The
classdistinguishesauth,unresolved,stale_id,rate_limited,transport,capability,conflict, andexternal_action_required. - 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 matchingflowctl tracker syncfacade call. - Verify:
flowctl sync check <spec-id> --events <event> --since <retro-fire-time>now printsOK:<event>.
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):
| 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
Section titled “Merged outside land”Land 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.
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:
name: Tracker Done after mergeon: 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 ghWhat 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 stayIn Reviewand need the by-hand replay. The job never marks an issueDonewithout 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 statuswrites 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
Section titled “Reconciliation: who wins”- Body - an agentic host-agent semantic 3-way merge against the
lastSyncedAtmerge-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 istracker.conflictTiebreak(flow-wins | tracker-wins | always-ask, defaultalways-ask).flow-winsuses the normal provider-neutral status write and still requires merged-PR evidence forDone.tracker-winsfolds a terminal tracker into the local spec without a provider write and emits the existingpulledreceipt. 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
Section titled “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 readyon a tracker-connected repo is overwritten by the next sync; the team blesses work on the board. This is also why/flow-next:captureand/flow-next:refinestop offering their local mark-ready prompt oncereadyStateis configured. - Match semantics. Linear: case-insensitive trimmed match on the workflow-state name (names, not
state.type- a custom “Ready” state is typicallytype=unstarted, so type alone cannot distinguish Todo from Ready). GitHub / GitLab: thereadyStatelabel - present on the issue meansready=true, absent meansready=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 throughstatusMap. - Change-only receipts. The projection applies via the idempotent
spec ready/unreadytoggles 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 +
noopreceipt, leaves the flag untouched, and the rest of the sync continues - one bad knob never aborts the run, and a stalereadyStatemust not silently un-ready every linked spec. - Orthogonal to status. The projection never feeds the who-wins ladder, never advances
lastSyncedAtby itself, and never blocks.readyState: null(the default) skips it entirely - no calls, no receipts, no flag writes. flow --autointerplay (corrected in 3.28.0). The projection feeds/flow-next:flow --autodirectly, because the ready flag is the consent boundary it selects from. Its two-strike guard runs a localspec unreadyafter 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 verbflowctl pilot strikes clear <spec-id>, and thestrike 2/2verdict 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.
Dependency projection
Section titled “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_epicsedge 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 asub_issueof the blocker and returnsdegraded.kind: "hierarchy"withdegraded.form: "sub_issues". GitLab uses a native directional link whencapabilities.blockedByis true; otherwise it records a directionlessrelates_tolink 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
depRelationsledger, 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
donedependency keeps its relation on the board (the audit trail) but never re-gatesready=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
Section titled “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
Section titled “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, andattach-get. - Lifecycle verbs:
create,create-first,persist-external,status,relate, andsync-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:
{"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
Section titled “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 --autoor any autonomy marker, the run never overwrites. It recordsflowctl 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.
flowctl tracker sync <spec-id> --op push --event KEY --overwrite-diverged # only after a human confirmsJira: the enterprise provider
Section titled “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_TOKENauth; 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.statusMapmaps 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 stablestatusCategory.key == "done"signal, not the renamable status name. Donemeans merged. A locally-donespec with no merged PR maps toin-review, and only aMERGEDPR probe lets terminaldonereach the board. (See Status lifecycle.)- Native blocks links.
depends_on_epicsedges project as native JiraBlocksissue 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_unconvertedconflict until then. - Backlog enumeration via JQL.
flowctl tracker wire list-openenumerates open issues at the exacttracker.readyState, using bounded pagination and honesttruncatedmetadata. - Backlog dependency reads.
flowctl tracker wire relation-listaccepts the durable/display locator returned bylist-open, returns provider-neutral directedblocksrows forflow --auto --backlogdependency ordering, and fails closed when bounded pagination cannot prove the graph complete. - Read-only status enumeration (4.1.0).
flowctl tracker wire list-stateslists the project’s statuses (id, name, statusCategory key) with acompleteflag, so “does every configured status id still name a live status?” is answerable withouttracker resolve- which repairs and writes the mapping. Detection never writes. - Tracker-only question parking.
flowctl tracker wire questionreceives 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 #Nauto-linkify nor Linear’s Diffs attachment, so/flow-next:make-prprojects the PR↔issue link as a Jira remote link (idempotent on a deterministicglobalId), falling back to a URL comment if the remote-link write is unavailable.
Unattended-safe: conflicts queue
Section titled “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
Section titled “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 and 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).
See also
Section titled “See also”- Spec & task ids - the hybrid id model: when a spec is
fn-NN,wor-17,gh-123, orgl-456. - Collaboration - projection-not-coordination positioning, the tracker-first PM flow, and the team recommendation for
tracker.specIds. - Configuration -
flowctl syncsubcommands and thetracker.*config keys.