jira-sync
v0.4.0An idempotent, drift-aware, fail-closed reconcile engine that mirrors spec-kit specs into Jira (Epic per repo, Story per spec, Subtask per phase).
spec-kit-jira-sync
A real sync engine that mirrors your spec-kit specs into Jira — idempotent, drift-aware, and fail-closed.
Point it at a spec-kit repository, run one command, and every feature under
specs/NNN-feature/ shows up in Jira — grouped under a single per-repo Epic,
one Story per spec, a Subtask per task phase, with the phase's tasks
rendered as a checklist. Re-run it as often as you like: an unchanged corpus
produces zero writes.
Not a prompt that POSTs to your board
The honest competitor in this space is a markdown command file that tells an agent "read the checkboxes and POST them to Jira." That works exactly once, on a clean board, with a patient operator watching. It has no idempotency (re-running duplicates issues), no drift detection (it clobbers whatever a teammate advanced), and no failure contract (a 401 mid-run leaves a half-mirrored board).
spec-kit-jira-sync is the other thing — a reconcile engine, built the way
Kubernetes builds controllers: it reads the full desired state off disk, reads
the actual state from Jira, computes the difference, and writes only that. The
three properties that fall out of that design are the whole pitch:
| Property | What it means | Why a prompt can't promise it |
|---|---|---|
| Idempotent | Re-running against an unchanged corpus performs zero writes — no duplicate Epic, no rewritten fields, no re-posted comments. | A prompt re-creates whatever it's told to create; it has no model of "already there." |
| Drift-aware | When Jira is ahead of disk (someone advanced the Story), the engine surfaces a named WARNING and asks before overwriting — it never silently regresses your board. | A prompt has no notion of "Jira is further along than my file." |
| Fail-closed | If Jira can't be read reliably (auth, network, deleted issue, sustained 429), the engine writes nothing for that spec, reports it, and exits non-zero. | A prompt that can't read just guesses, or writes partial garbage. |
The filesystem is the single source of truth. Jira is a unidirectional, read-only mirror. Edit a Story in Jira and the next reconcile restores the disk-derived state — after telling you it found drift.
The automatic mirror
You don't run a sync command. After you install the bridge, every spec-kit
lifecycle command auto-mirrors to Jira — /speckit-specify, .clarify,
.plan, .tasks, .implement, .analyze. The board stays current as a side
effect of normal spec-kit work, with nothing to remember (Principle VII —
Memory-Just-Works). A bridge whose primary UI is "remember to run sync" drifts
and gets abandoned; auto-firing on every transition is what earns the bridge its
toolchain slot.
How it works: install registers the six after_* hooks into your
.specify/extensions.yml, each firing speckit.jira.push with
optional: false. An after_* hook fires after the lifecycle command has
already completed, so the mirror can never retroactively fail your command:
- Non-blocking. If the sync can't run (no
.envcreds, nojira-config.yml, Jira unreachable) the lifecycle command still succeeds and the bridge surfaces a single clean WARNING with the fix — never a hard error or a blocker (Principle VIII). - Idempotent. Each fire is an independent full reconcile; an unchanged corpus produces zero writes, so firing on every command is safe.
- Operator-controlled. Set a hook
enabled: falsein your.specify/extensions.ymland the bridge honours it — reinstall never silently re-enables a hook you turned off, never duplicates one, and a re-run is byte-identical.
The on-demand commands (push / status / install / seed) remain as the
recovery / escape-hatch path — for forcing a
sync, previewing drift, binding the repo, or validating the workflow by hand.
How it works
The pipeline is deliberately split into a vendor-neutral engine and a Jira-specific sink, joined by one interface:
flowchart TB
subgraph DISK["Your repo"]
direction LR
SPECS["specs/NNN-feature/<br/>spec.md - plan.md - tasks.md"]
end
subgraph NEUTRAL["Vendor-neutral engine"]
direction LR
PARSER["parser.sh<br/>infer lifecycle phase"]
WS["workstate JSON<br/>neutral, schema-valid"]
ENGINE["reconcile.sh<br/>drift - recency - idempotency"]
PARSER --> WS --> ENGINE
end
subgraph SINK["Jira-specific sink"]
direction LR
JSINK["jira_sink.sh<br/>workstate to REST"]
REST["Jira Cloud REST v3<br/>Epic - Story - Subtask"]
JSINK --> REST
end
SPECS --> PARSER
ENGINE -->|mutate_ / query_ / sync_| JSINK
- Parse.
parser.shreads eachspecs/NNN-feature/directory and infers the lifecycle phase from which artifacts exist and what they contain — no operator annotation required. - Neutralize. The parser emits
workstate— a neutral, schema-valid JSON document. The engine and sink never model Jira directly; they passworkstateitems around. Every emitted document is validated against the publishedworkstate.schema.json(Draft 2020-12) before any write. - Reconcile.
reconcile.sh(the engine, copied vendor-neutral from the shippedspec-kit-linear) computes per-spec drift and recency, then drives writes through a fixed set ofmutate_*/query_*/sync_*functions. - Sink.
jira_sink.shimplements that interface against Jira Cloud REST — idempotency lookups by label, ADF body rendering, transition POSTs, and the bounded 429-backoff fail-closed read path.
The workstate contract — and why this repo exists
workstate is a tracker-agnostic interchange format owned by a separate
schema repo. The deal is: a producer emits workstate; any sink consumes
it. The Jira sink reads only documented workstate fields and ignores unknown
extensions keys (so the format can grow without breaking sinks).
This repo is the independent second consumer that proves workstate is
genuinely neutral. A from-scratch Jira sink that eats the same workstate a
Linear-targeted tool produces is the validation that the format wasn't secretly
shaped to one vendor. The clean engine/sink seam is also what lets the two tools
later collapse into one shared engine plus per-tracker sinks.
The engine vs sink seam
flowchart TB
ENGINE["reconcile.sh - the engine<br/>(zero Jira knowledge)"]
IFACE{{"the interface<br/>mutate_issue_create - mutate_issue_update<br/>query_by_label - sync_subtasks - sync_comments<br/>config::get_status_transition"}}
SINK["jira_sink.sh - the sink<br/>(all Jira knowledge)"]
ENGINE --> IFACE --> SINK
config::get_status_transition (lifecycle phase → Jira status + transition id)
is the single vendor lever. Swap the sink and the same engine drives a different
tracker — that is the whole point of keeping Jira specifics out of the engine.
What lands in Jira
spec-kit artifacts map to Jira primitives:
flowchart LR
REPO["consumer repo"] --> EPIC["Epic<br/>(1 per repo)"]
SPEC["spec/ NNN-feature"] --> STORY["Story<br/>(under the Epic)"]
PHASE["Phase N<br/>in tasks.md"] --> SUB["Subtask<br/>(of the Story)"]
TASKS["tasks<br/>in that phase"] --> ADF["ADF checklist<br/>(in the Subtask body)"]
EPIC -.-> STORY -.-> SUB -.-> ADF
| On disk | Jira primitive | Idempotency key |
|---|---|---|
| Consumer repository | Epic (1 per repo, reused across runs) | label speckit-repo:<slug> |
Spec (specs/NNN-feature/) | Story under the repo Epic | label speckit-spec:NNN |
| Lifecycle phase | Story status + phase:* label; a terminal spec (ready_to_merge/merged) also cascades every phase Subtask to the done status | config phase→status map |
Task phase (## Phase <idx>) | Subtask of the Story (header index may be numeric or a single letter, separated by :/-/en-/em-dash) | parent + task-phase:<idx> label |
| Tasks within a phase | ADF checklist in the Subtask body | content diff |
| Clarification / decision sessions | comments | marker prefix → at-most-once |
ADRs / decision records (research.md) | comments (one per decision) | marker speckit-adr:<spec>-<id> → at-most-once, updated in place on a content change |
| Cross-spec dependencies | issue links | (rel, target) → at-most-once |
The ADF checklist is today's default. Configurable artifact mapping and a 2-level mode (collapse tasks into a Story-body checklist instead of separate Subtask issues) are on the roadmap.
Where the Story title comes from. The spec issue's title is derived from
spec.mdby a deterministic source ladder (no AI, no network — the bridge never summarizes at reconcile time): an explicitTitle:line → a concise# Feature Specification:H1 → the first sentence of the## Summarysection → the directory slug, first match wins. Every rung is capped at 120 characters on a word boundary (no ellipsis). A clean, within-cap H1 is used verbatim — byte-identical to before — so well-formed specs never see a title change; only specs whose H1 is the unfilled[FEATURE NAME]placeholder, is a verbose pasted wall, or is missing get a readable title instead of a raw slug.
Upgrading a multi-spec board? A bug in earlier builds matched a phase Subtask by its
task-phase:Nlabel alone — a phase number that is unique only within a spec — so across a repo with several specs every spec's "Phase N" collided on the same Subtask. The phase find is now scoped to the parent Story, so each spec mirrors to its own Subtask. A plainreconcile.sh --allself-heals a mis-scoped board: the collided Subtasks belong to the first-processed spec, which re-matches and corrects them, and every other spec creates its own — no orphans. For a clean slate, prune the oldtask-phase:*Subtasks (or run--remode) before re-pushing. See the CHANGELOG for details.
Merging a spec marks its phases done. When a spec reaches a terminal lifecycle (
ready_to_mergeormerged), the next reconcile transitions every phase Subtask under it to the done status — in the default config, no status-rollup needed. Previously a merged Story sat over a column of "To Do" Subtasks; now the whole spec reads complete. The cascade is idempotent (it fires only on a real status change), fail-closed on an unreadable read, and forward-only (it does not un-set children if the lifecycle later regresses). Phase headers are also more forgiving:## Phase A — Foundations,## Phase 1 — Setup, and## Phase 10: Polishall parse — the index may be a number or a single letter, separated by:,-, or an en-/em-dash.
Author attribution (opt-in)
By default the bridge creates issues with no assignee, so they fall to the
consumer project's default-assignee policy — on a PROJECT_LEAD project every
spec shows the project lead, never the actual author. Opt into
author-based attribution to make the board reflect who authored each spec,
as two tracks:
- An
author:<handle>label, always — account-independent, so it works even for authors who have no Jira account. The<handle>is an operator-chosen, non-PII token from the gitignored authors map (never an email). - A Jira assignee, on create only — set when the author maps to a real
accountId. It is never re-sent on update, so a manual reassignment in Jira survives (the Linear bridge's FR-034 semantics). Anull/absentaccountIdis label-only (the non-member case).
Author resolution (per spec): an explicit Owner:/Author: line in
spec.md wins; otherwise the first git author to add the spec dir;
otherwise unknown (no label, no assignee — not an error). Email → accountId
cannot be resolved at runtime (Jira's email user-search is GDPR-restricted), so
a static, operator-maintained map is required.
Enable it in the gitignored jira-config.yml:
attribution:
enabled: true # default OFF — absent/false = byte-identical to today
assignee: true # set the create-time assignee when the author maps
label: true # stamp the always-on author:<handle> label
authors_file: ".specify/extensions/jira/jira-authors.local.yml"
Then copy jira-authors.local.yml.sample to the gitignored
jira-authors.local.yml and fill in real ids (it holds real emails + account
ids = PII, so it is never committed):
schema_version: 1
authors:
"dev@example.com": { accountId: "<jira-account-id>", handle: "dev" }
"nonuser@example.com": { accountId: null, handle: "nonuser" } # label-only
default_assignee: null # null = leave unassigned (NOT the project lead)
PROJECT_LEADcaveat (FR-007): the bridge does not change your project's assignee policy. Ifassignee: trueand the project default isPROJECT_LEAD, an unmapped author's spec still lands on the lead. For a neutral mirror, set the project's default assignee to Unassigned.
A stale/deactivated accountId is fail-soft: the assignee write is surfaced
(warned) and the spec still completes with its label — never a full-run abort.
Changing the mapping: guarded re-mode (--remode)
The ordinary reconcile is add/update-only — it never deletes. So if you change the mapping shape (e.g. 3-level Subtasks → a 2-level in-body checklist, or a different issue type for specs), a plain reconcile adds the new-shape artifacts but leaves the old ones behind as orphans. A plain reconcile warns when it detects them, and points you at re-mode.
reconcile.sh --remode cleans this up in one guarded pass: it prunes the
bridge-owned artifacts the current mapping no longer projects, then regenerates the
new shape. It is the only destructive operation in the bridge, and it is
fenced:
- Opt-in only — reachable solely via
--remode; never fired by a hook. The ordinary reconcile (and everyafter_*hook) stays strictly non-destructive. - Bridge-owned only — it prunes only issues carrying the
speckit-*identity labels. An issue you created (no identity label) is structurally excluded and is never deleted, relabeled, or edited — even under the same Epic or with a lookalike summary. - Preview first —
reconcile.sh --remode --dry-runprints the exact prune + regenerate set and writes nothing; the real run acts on exactly that set. - Fail-closed — an unreadable Jira read aborts the whole re-mode before any delete. A partial prune failure is surfaced and finished by a re-run.
- Destruction model —
remode.destruction: hard-delete(default; bridge content is regenerable from the specs) orarchive(transition off-board + detach identity, preserving comments/links).
reconcile.sh --remode --dry-run --all # preview what would be pruned + regenerated
reconcile.sh --remode --all # prune orphans + regenerate the new shape
This rests on the regenerable-projection insight: Jira is a one-way mirror of the source-of-truth specs, so bridge-owned content needs no backup. See the constitution Principle I controlled-destruction carve-out (v1.1.0).
The reconcile decision flow
For every spec, the engine decides — per spec, independently — whether and how to write. It never silently clobbers, and it fails closed when it can't read:
flowchart TD
START([reconcile spec NNN]) --> READ{read Jira<br/>state for NNN}
READ -->|unreadable: 401/403/404/net/429-exhausted| FC["fail closed:<br/>NO write - record error"]
FC --> EXIT3([escalate exit 3])
READ -->|readable| DRIFT{compute drift:<br/>phase ahead? OR<br/>Jira newer than commit?}
DRIFT -->|no drift| WRITE["reconcile:<br/>create/update only the diff"]
DRIFT -->|backward-drift| WARN[surface named WARNING]
WARN --> DISP{disposition}
DISP -->|proceed: default / TTY y| WRITE
DISP -->|abort: --on-drift=abort / TTY n| SKIP["skip: Jira unchanged"]
WRITE --> SUM([structured summary])
SKIP --> SUM
EXIT3 --> SUM
A single reconcile run, end to end:
sequenceDiagram
participant Op as Operator
participant Eng as reconcile.sh
participant Sink as jira_sink.sh
participant Jira as Jira REST
Op->>Eng: reconcile.sh --all
Eng->>Sink: query_by_label(speckit-repo:slug)
Sink->>Jira: GET /search/jql
Jira-->>Sink: Epic (or absent)
Sink-->>Eng: epic key (reuse) / create
loop each spec NNN
Eng->>Sink: query_by_label(speckit-spec:NNN)
Sink->>Jira: GET /search/jql + GET /issue
Jira-->>Sink: Story status + updated (or absent)
Sink-->>Eng: actual state
Note over Eng: compute drift vs disk
alt drift and abort
Eng-->>Op: WARNING - skipped (Jira ahead)
else proceed
Eng->>Sink: mutate_issue_* / transition / sync_subtasks
Sink->>Jira: POST/PUT (only the diff)
end
end
Eng-->>Op: summary: created/updated/skipped + warnings/errors
The run summary drives the process exit code by monotonic escalation: the highest code that fires wins.
Install
Install the extension into the spec-kit project you want mirrored — run this from that consumer repo, not from inside the bridge's own checkout (the installer detects source-equals-target and halts rather than copy the bridge into itself).
Until this extension lands in the spec-kit community catalog, install from the
GitHub archive URL (the CLI's --from flag expects a direct .zip, not a repo
URL):
specify extension add jira-sync --from https://github.com/ashbrener/spec-kit-jira-sync/archive/refs/heads/main.zip
Or from a local checkout (--dev symlinks rather than copies, so your local
edits flow straight through):
specify extension add /path/to/spec-kit-jira-sync --dev
Once the extension is listed in the catalog, the short form works:
specify extension add jira-sync
Naming — the catalog id is
jira-sync(the barejiraslot is taken by an unrelated extension), but the extension installs under.specify/extensions/jira/and its commands are namespacedspeckit.jira.*.
Install copies the bridge into your repo's .specify/extensions/jira/,
registers its reconcile commands (speckit.jira.push, speckit.jira.status,
speckit.jira.install, speckit.jira.seed), and — if your project was
initialised with --ai-skills — generates a SKILL.md per command. Like the
Linear sibling, it also
auto-registers the six after_* lifecycle hooks into your
.specify/extensions.yml, so every /speckit-* command mirrors the spec state
to Jira automatically — the automatic mirror is the
primary path (Principle VII). The on-demand /speckit-jira-push is the
escape-hatch for when you want to force a sync by hand.
Install does not scaffold your credentials. After installing, add your
gitignored .env (below), then run /speckit-jira-install to resolve the
binding — it reads the project over the Jira REST API and writes
.specify/extensions/jira/jira-config.yml (project key, issue-type ids, the
lifecycle phase→status map, and the story-points field id) automatically, so you
never read an id off Jira by hand. Then /speckit-jira-seed confirms the
lifecycle mapping is reachable on the project's workflow. Both are idempotent
(a re-run is a byte-identical no-op) and fail-closed (exit 2 = missing input,
exit 3 = Jira unreadable).
Quick start
Prerequisites
- Runtime:
bash4.4+,curl,jq,git,gh - Dev/CI:
bats,shellcheck,yamllint,markdownlint-cli2, anduv(preferred — PEP 668-safe) forworkstateschema validation
Configure the two gitignored files
Credentials and the per-project binding live only in gitignored files — never in the tracked tree (Principles VI + IX).
.env — the Jira Cloud Basic-auth credential:
JIRA_BASE_URL=https://<your-site>.atlassian.net
JIRA_EMAIL=<you@example.com>
JIRA_API_TOKEN=<atlassian-api-token> # never commit; .env is gitignored
.specify/extensions/jira/jira-config.yml — the resolved per-project binding
(project key, issue-type / status / transition ids). You do not write this
by hand: run /speckit-jira-install to resolve every id over the Jira REST
API and write this gitignored file, then /speckit-jira-seed to confirm the
lifecycle mapping is reachable. The committed placeholder
config-template.yml shows the shape the install step
fills. (The manual route — copy the template and read the ids off the project via
the Atlassian MCP — still works; see
dogfood.md.)
Dry-run first — preview every write, touch nothing
src/reconcile.sh --all --dry-run
This reports every intended create / update / transition / comment without
calling Jira. The preview's reported actions match what a subsequent live run
performs (SC-007). Inspect the summary, then drop --dry-run:
src/reconcile.sh --all # reconcile every spec
src/reconcile.sh --spec 001 # reconcile only feature 001
src/reconcile.sh --all --on-drift=abort # never overwrite a drifted issue
Command surface
reconcile.sh [--spec NNN | --all] [--dry-run] [--on-drift=proceed|abort]
[--quiet] [--config PATH]
| Flag | Meaning |
|---|---|
--all | Reconcile every spec under specs/ (default if no --spec). |
--spec NNN | Reconcile only feature NNN. |
--dry-run | Compute and report every intended write; perform none. |
--on-drift=proceed|abort | Non-interactive disposition on backward-drift; default proceed-and-warn. |
--quiet | Suppress per-mutation logging; the summary still prints. |
--config PATH | Override the gitignored jira-config.yml location. |
Recovery / on-demand commands
The automatic mirror is the primary path — these commands are the escape-hatch: force a sync, preview drift, (re)bind the repo, or validate the workflow by hand. If you're driving from inside the agent harness, the slash commands run the engine without dropping to a terminal:
| Command | Maps to | What it does |
|---|---|---|
/speckit-jira-push | reconcile.sh [--all|--spec NNN] [--dry-run] [--on-drift=abort|proceed] | Write path — force a reconcile (the same path the after_* hooks fire). |
/speckit-jira-status | reconcile.sh --dry-run | Read-only preview — plans every write, issues none. |
/speckit-jira-install | the install ceremony | Resolve + write the gitignored binding; also (re)registers the after_* hooks. |
/speckit-jira-seed | the seed gate | Validate the labels + confirm every lifecycle status is reachable. |
/speckit-jira-push is the write path the hooks also fire;
/speckit-jira-status is the read-only drift/sync preview (it computes the same
diff a live push would perform, then stops). There is intentionally no pull:
the filesystem is the single source of truth and Jira is a unidirectional,
read-only mirror, so there is nothing to pull back.
Exit codes (monotonic escalation)
| Code | Meaning |
|---|---|
| 0 | Success; all processable specs reconciled (or nothing to do). |
| 1 | Completed with per-spec warnings (drift surfaced, missing tasks.md). |
| 2 | Project-level configuration error (no/invalid binding) — run halted. |
| 3 | One or more specs failed closed (unreadable Jira, exhausted 429 retries). |
| 4 | Consumer-tree privacy leak — fail-closed, zero Jira writes (see below). Terminal: never demoted by 1/3. |
The highest code that fires wins. See
quickstart.md for setup and running the
tests, and dogfood.md for self-provisioning
this repo against a real Jira project.
Configuration
The per-project binding is jira-config.yml, resolved once and stored gitignored
(real ids never enter the tree). Its shape is documented in the committed
placeholder config-template.yml:
jira:
project_key: "PROJ" # placeholder — the prefix in every issue key
issue_types: # numeric ids, NOT names
epic: "10001"
story: "10002"
subtask: "10003"
phase_status: # lifecycle phase -> target status id (via a transition)
specifying: "20001"
planning: "20002"
tasking: "20003"
implementing: "20004"
ready_to_merge: "20005"
merged: "20006"
transitions: {} # optional explicit transition ids; else resolved by target status
labels: # operator-chosen prefixes carrying idempotency identity
spec_prefix: "speckit-spec:"
repo_prefix: "speckit-repo:"
phase_prefix: "task-phase:"
lifecycle_prefix: "phase:"
Every identifier is a stable id, never an operator-editable display name (Principle V) — renaming a status in Jira's UI never breaks the bridge. A missing or unreadable binding is a project-level configuration error that halts the run (exit 2), distinct from a per-spec failure.
Status & roadmap
In active development — not yet released. This section is honest about what
is built versus planned. The suite is 182 bats tests, cross-model reviewed
at phase boundaries, run against a mocked Jira REST harness (a curl-shim that
returns fixture JSON keyed by method + URL) — and validated end-to-end against a
real Jira instance (see Live-dogfood-proven below).
Done — mock-validated
- US1 · Fresh mirror — a first run creates the per-repo Epic, one Story per spec under it, and a Subtask per task phase with each phase's tasks rendered as a done/not-done ADF checklist.
- US2 · Idempotent zero-churn re-run — a second run against an unchanged corpus performs zero writes, reuses the existing Epic, and reconciles a disk-authoritative Story status back to the derived value.
- US3 · Drift read — a Story ahead of disk (by lifecycle order or commit recency) surfaces a named backward-drift warning; it never silently clobbers.
- US4 · Lifecycle & content propagation — status transitions, new Subtasks, clarification comments, and cross-spec issue links, each created at most once.
- US5 · Fail-closed & observable — unreadable Jira, missing artifacts, and sustained 429 each fail closed for the affected spec and surface in the structured summary.
- Vendor-neutral reconcile engine — drift detection, commit-recency gating, layered idempotency, kept free of any Jira specifics.
workstateschema gate — every emitted record validated against the published schema (underuv, PEP 668-safe) before any write.- Install + seed ceremony —
/speckit-jira-installresolves the binding (project key, issue-type ids, lifecycle phase→status map, story-points field) over the Jira REST API and writes the gitignoredjira-config.yml; no more hand-editing ids./speckit-jira-seedvalidates the labels + confirms every lifecycle status is reachable on the project's workflow. Idempotent (byte-identical re-run), fail-closed (exit 2/3), Privacy IX.
Live-dogfood-proven
Mirrored this repo into a real Jira project and confirmed the guarantees against a live instance — which surfaced three real bugs the mock structurally could not catch, each fixed and regression-guarded:
/search/jqlreturned key-less / field-less issue stubs without an explicitfieldsrequest → every run duplicated the board.- Empty ADF descriptions churned every run (Jira drops empty paragraphs on store).
- Fresh creates churned once (Jira returns a
nulldescription until a later write settles it).
True zero-churn idempotency is now verified on real Jira (fresh mirror →
immediate re-run = 0 created / 0 updated). In-session slash commands
/speckit-jira-push (write) and /speckit-jira-status (read-only --dry-run
preview) drive it.
Roadmap
- Configurable artifact/relationship mapping + a 2-level mode (collapse
tasks into a Story-body checklist instead of separate Subtask issues) — now in
active spec-kit planning (
specs/002-configurable-mapping/: spec + plan done). - Exposing
workstateas a direct input (run the sink from aworkstatefile/stdin, skipping the parser) so any producer can feed it. - Carving the parser out into a standalone
workstateproducer, leaving this repo a pureworkstate → Jiraconsumer. - Engine extraction — collapsing the engine shared with
spec-kit-linearinto one package with per-tracker sinks. - Layer E (the real-time GitHub Action status flips).
Privacy
This is a public repository. No real Jira coordinate ever enters a tracked
file — no workspace, site, project key, account id, cloudId/UUID, email, or
API token. Documentation and fixtures use placeholders only (e.g.
https://<your-site>.atlassian.net, PROJ).
Real values live exclusively in gitignored files — .env, jira-config.yml,
and tests/.private-deny — and the privacy guard
(tests/unit/no-real-identifiers.bats) scans the tracked tree and gates CI on
any shape-based or operator-literal leak (Principle IX).
Consumer-side privacy guard (auto, every reconcile)
The CI guard above protects this repo. A separate consumer-side guard
protects the repos you install the bridge into. It runs automatically as a
fail-closed pre-write gate on every reconcile (and at install) — after the
config is loaded, before any Jira write, in --dry-run too. It scans the
consumer repo's whole tracked tree (git ls-files, binaries skipped) and
asserts the resolved jira-config.yml, .env, and jira-authors.local.yml are
gitignored-and-untracked.
It reports findings in two tiers (precision-blocks, recall-warns — the industry norm):
- BLOCK (fail closed, exit 4, zero Jira writes):
- your exact known coordinates — the
JIRA_EMAIL, theJIRA_BASE_URLhost, theJIRA_API_TOKEN, and any accountId in your authors map — matched as fixed strings (zero false positives; only the bridge knows them); - the Atlassian API-token prefix (
ATATT…); - an Atlassian site host
<name>.atlassian.net(the reservedexample.atlassian.netdocumentation host is intentionally exempt).
- your exact known coordinates — the
- WARN (surfaced, run proceeds): a generic email, a cloudId/UUID,
or a 24-hex /
NNNNNN:UUIDaccountId — broad shapes that also match ordinary content (a contributor email, a lockfile UUID), so they never halt you (a blocking control with false positives just gets disabled).
A non-git target also fails closed (the tree can't be proven clean). The failure
names the file and the shape class with copy-paste remediation, and
never echoes the matched secret (it reports the shape, not the bytes). On a
BLOCK: move the real value into the gitignored .env / jira-config.yml,
replace the tracked occurrence with a placeholder, scrub history if already
committed, and rotate the token if it was a credential.
Off-the-shelf scanners are recommended, never bundled. gitleaks /
trufflehog are a useful complementary net for generic secrets the bridge
doesn't know about; run them in your own CI. They are never a dependency of
the guard (its core is dep-free git + grep), are only ever invoked
best-effort if already on PATH, and trufflehog live-verification stays
off (it would call Jira's API). The bridge's known-value pass is the precise
guarantee no generic scanner can offer, since only the bridge knows its own
exact resolved coordinates.
Development
Run the exact CI gate locally before pushing:
scripts/check.sh # mirrors all CI jobs
# or individually:
shellcheck --shell=bash --severity=style src/*.sh
yamllint -d relaxed .github/workflows/ci.yml
npx --yes markdownlint-cli2 "specs/**/*.md" "*.md"
bats --recursive tests/unit # offline, curl-shim mocked
RUN_INTEGRATION_TESTS=1 bats tests/integration # needs a real binding + .env
See CONTRIBUTING.md for the full contribution workflow.
Project governance — the non-negotiable principles every spec and PR is checked
against — lives in
.specify/memory/constitution.md (v1.0.0).
Related work
- Spec Kit core — https://github.com/github/spec-kit
- Sibling bridge —
spec-kit-linear, the shipped twin targeting Linear; the reconcile engine here is copied from it and kept vendor-neutral. - Sibling extension —
spec-kit-red-team, adversarial spec review before/speckit.planlocks in architecture. workstate— the neutral interchange schema this repo proves as an independent second consumer.
License
MIT — see LICENSE.
Stats
Version
Install
Using the Specify CLI
specify extension add jira-sync --from https://github.com/ashbrener/spec-kit-jira-sync/archive/refs/tags/v0.4.0.zip