Skip to content

sync-work-items

Invocation: User- and model-invocable
Argument hint: [--push-only|--pull-only] [--preview] [--all] [filter-flags…]
Allowed tools: Bash(${CLAUDE_PLUGIN_ROOT}/scripts/config-*), Bash(${CLAUDE_PLUGIN_ROOT}/skills/work/scripts/*)
Source: skills/work/sync-work-items/SKILL.md

Active integration: !${CLAUDE_PLUGIN_ROOT}/scripts/config-read-work.sh integration Default project code: !${CLAUDE_PLUGIN_ROOT}/scripts/config-read-work.sh default_project_code Work items directory: !${CLAUDE_PLUGIN_ROOT}/scripts/config-read-path.sh work

/sync-work-items reconciles the local work items under the work directory with the remote tracker named by work.integration. It is on-demand (never background), operates against exactly one integration per invocation, and writes can affect remote state — which a local VCS revert cannot recover — so a --preview mode is provided to inspect the plan before any side effect.

The safety-critical orchestration lives in tested scripts, not this prose: work-item-sync-decide.sh owns the (mode × state) decision table, work-item-sync-classify.sh owns change detection, and work-item-sync-apply.sh owns the per-item commit sequence (side-effect first, baseline last). This skill parses arguments, renders decisions, and runs the prompts/gates around them.

Config gate. The Active integration read above gates the whole skill. config-read-work.sh integration exits 0 with a blank line when nothing is configured, so branch on the string. If it is empty, print a clear, actionable error and stop — do not guess a tracker:

/sync-work-items needs an active remote tracker, but `work.integration` is not
configured.
What: the `work.integration` setting selects which remote your work items sync
with. It is currently unset.
Why: sync reads and writes that tracker's API; with no tracker there is
nothing to reconcile against.
Fix: set `work.integration` to one of `jira`, `linear`, `trello`, or
`github-issues` via /accelerator:configure, then re-run.

<sys> for every script below is this configured value — never re-derived. trello and github-issues are not built yet; the bridges report “not available” (exit 72) for them, which you surface as a clear message.

Prerequisites. Before any remote call, confirm jq (with -S support), sha256sum or shasum, and the repo’s VCS binary (git or jj) are present. If one is missing, name it and how to obtain it (same what/why/how shape as the config gate) rather than failing later with a raw command not found or a silently divergent hash.

Resolve the directional mode through the decision script so the mutually-exclusive rule is enforced in one tested place:

${CLAUDE_PLUGIN_ROOT}/skills/work/scripts/work-item-sync-decide.sh mode \
[--push-only] [--pull-only]

It prints bidirectional (the default when neither flag is given), push-only, or pull-only, and errors (exit 2) if both directional flags are supplied — surface that error and stop. Other arguments:

  • --preview — report the full set of intended changes (push, pull, conflict, push-unsynced, untracked-pull) without any local write or remote API write, and without touching last-sync.json. Combinable with any directional flag.
  • --all — for the untracked-remote pull (Step 4 / Phase 8), drop only the implicit work.default_project_code scope; user filters still apply.
  • remaining flags are tracker search-* filter flags, forwarded verbatim.

Omitting the directional flags means bidirectional. Example: /sync-work-items --push-only --preview previews only the local→remote pushes.

Capture the run-start epoch now (before reading any item) — it becomes the baseline’s global timestamp on clean completion, so a file edited during the run is re-hashed (not wrongly short-circuited) on the next run.

Collect the non-empty external_ids of the local work items (synced items) and fetch their remote state in one bulk call:

${CLAUDE_PLUGIN_ROOT}/skills/work/scripts/work-item-fetch-remote.sh \
--integration <sys> search --keys <comma-separated external_ids>

It returns { "found": {<key>:{updated}}, "absent": [...], "indeterminate": [...] } — the adapter picked the per-tracker strategy, so you never branch on tracker. If the bridge exits non-zero (remote unreachable / timed out), treat every key as indeterminate: nothing is written, and each affected item is reported as needs-retry. Resolve the baseline path and global timestamp once:

BASE=$(${CLAUDE_PLUGIN_ROOT}/skills/work/scripts/work-item-sync-baseline.sh path)
TS=$(jq -r '.timestamp // 0' "$BASE" 2>/dev/null || echo 0)

For each local item with a non-empty external_id, emitting processing item k of N progress as you go:

  1. Classify with the shared engine. Derive --remote-status from the pre-filter map (foundpresent with --remote-updated; absentabsent; indeterminate or degraded→indeterminate). For a found key whose updated differs from the baseline entry’s remote_updated_at, fetch the body (work-item-fetch-remote.sh … show --external-id <key>), project + canonicalise it with work-item-project-remote.sh --integration <sys> body, write it to a temp file, and pass --remote-body-file:

    STATE=$(${CLAUDE_PLUGIN_ROOT}/skills/work/scripts/work-item-sync-classify.sh \
    --file <path> --external-id <key> \
    --baseline "$(work-item-sync-baseline.sh get <id>)" --timestamp "$TS" \
    --remote-status <present|absent|indeterminate> \
    [--remote-updated <iso>] [--remote-body-file <tmp>])
  2. For a remotely-modified item, test the local file’s cleanliness before any overwrite (the recovery model is VCS revert, which cannot recover uncommitted working-copy changes):

    if work-item-file-dirty.sh <path>; then DIRTY=1; else DIRTY=0; fi
  3. Decide the action deterministically:

    ACTION=$(${CLAUDE_PLUGIN_ROOT}/skills/work/scripts/work-item-sync-decide.sh \
    decide --mode <mode> --state "$STATE" --dirty "$DIRTY")
    • pushwork-item-sync-apply.sh push (the update bridge, not create: the issue already exists). Extract the item’s title and body, write the body to a temp file, and pass --integration \<sys> --external-id \<key> --id \<id> --file \<path> --title \<t> --body-file \<tmp>.
    • pull → reconstruct the local file (keep the local frontmatter — id, external_id, and the other authored fields — replacing title/body from the projected remote), write it to a temp file, project the remote body, and run work-item-sync-apply.sh pull --id \<id> --file \<path> --new-content-file \<tmp> --remote-updated \<iso> --remote-body-file \<proj>. Emit <id>: local replaced from remote so the overwrite is visible and revertable.
    • skip-dirty → skip the pull, warn and list the id (a dirty local file is never silently overwritten). Reported under needs-retry.
    • skip-conflict → report the id under conflicts-skipped and write neither side. This is the directional-mode outcome (--push-only / --pull-only): resolving a conflict needs a write the mode forbids, so it is reported and skipped with no prompt.
    • prompt → bidirectional conflict resolution (see “Conflict resolution” below). The dirty-pull route (remotely-modified + dirty in bidirectional) also returns prompt and is resolved the same way.
    • noop → nothing to do (synced, or a forbidden-write cell, or indeterminate/remote-absent). Report indeterminate items under needs-retry and remote-absent items under remote-absent (never push to a non-existent issue).
  4. Aggregate pull-overwrite gate. If the number of local files a run will overwrite from remote exceeds the shared threshold (25, the same constant the untracked-pull gate uses), pin and evaluate before any pull write:

    Use the AskUserQuestion tool with two options (stating the count N):

    1. Yes, proceed — overwrite the N local files from remote
    2. No, abort — abort with zero writes and a non-zero exit

    It fails safe: if not running interactively, abort with zero writes.

  5. Terminal push handling. A 71/terminal code from the update bridge is never auto-retried (a resent request could apply twice on a response-uncertain failure): report the item as needs-manual-reconciliation and leave its baseline entry unset (the apply helper already does this), so the next run re-classifies authoritatively.

work-item-sync-apply.sh performs each item’s side-effect, then sets that id’s baseline entry last (per-item resumability). Re-running after a mid-run interruption is idempotent: reconciled items match their baseline and are skipped.

When decide returns prompt, resolve the conflict interactively. First render a section-grouped diff so a large item stays reviewable — local is the - baseline side, remote is the + side (the recommended/default-accept side):

${CLAUDE_PLUGIN_ROOT}/skills/work/scripts/work-item-section-diff.sh \
<local-file> <remote-reconstructed-file>

Then prompt with a typed token (not a y/n keystroke — a reflexive Enter must never discard local edits, and this avoids colliding with the [y/N] polarity used by the batch-push and untracked-pull gates). Pin the exact string:

Conflict on <id> (<external_id>). Recommended: keep remote.
Type 'remote' to OVERWRITE your local edits with the remote version,
'local' to push your local version to the remote, or
'skip' to leave both unchanged and resolve it later. [remote/local/skip]
No default — Enter (or an unrecognised entry) re-asks once, then skips.

Read the raw input and map it through the tested entry point (never re-derive the mapping in prose):

${CLAUDE_PLUGIN_ROOT}/skills/work/scripts/work-item-sync-decide.sh \
resolve-conflict-token "<raw input>"

It returns one action:

  • accept-remote → resolve as a pull: overwrite the local file from the remote via work-item-sync-apply.sh pull (Phase 6 ordering, incl. the post-write remote_hash).
  • push-local → resolve as a push: push the local version via work-item-sync-apply.sh push, and emit an override-log line to the summary naming the item, e.g. OVERRIDE <id> (<external_id>): pushed local→remote.
  • skip → report under conflicts-skipped, write nothing.

There is deliberately no Enter default: ‘Recommended: keep remote’ steers the choice but still requires typing the word, so a reflexive Enter (empty input) or any unrecognised token re-asks once, then resolves to skip — never to a destructive write.

Step 4: Unsynced push offer and untracked pull

Section titled “Step 4: Unsynced push offer and untracked pull”

For each local item with no external_id (never pushed), offer a push using one pinned grammar (per-item [y/N] with the fast-path keys surfaced in the string so they are discoverable):

Push <id> "<title>" to <tracker>? [y/N] (a = push all remaining, d = decline all remaining)
  • a / d touch only un-decided items and never resurrect declines.

  • Accepted → push via the create bridge:

    ${CLAUDE_PLUGIN_ROOT}/skills/work/scripts/work-item-create-remote.sh \
    --integration <sys> --title <t> --kind <kind> --body-file <tmp>

    Substitute the returned key into the item’s external_id line in memory, then write the whole item (frontmatter incl. external_id + body) in a single atomic_write, so the file never exists half-linked. work-item-push-decide.sh governs retry/terminal handling exactly as /create-work-item does (a 71/terminal is never auto-retried; the returned key, if any, is preserved with loud guidance).

  • Declined → untouched.

  • Under --preview: report the intended pushes via the create bridge’s --dry-run; make no write.

Fetch remote issues via the read bridge, forwarding the user’s filter flags verbatim:

${CLAUDE_PLUGIN_ROOT}/skills/work/scripts/work-item-fetch-remote.sh \
--integration <sys> search [filter-flags…]
  • Default scope is work.default_project_code — for jira this is the search flow’s own default project, so plain search is already scoped; for linear the team is catalogue-fixed (single-team), so there is no project scope.
  • --all forwards the tracker’s --all-projects primitive (jira only), dropping only the project clause; any user filters (e.g. --label) still apply.
  • Compute the untracked set: remote issues whose key is not already held by any local item’s external_id (a held key is already tracked — never create a duplicate).

Blast-radius gate. When the untracked set exceeds the shared threshold (25 — the same constant the pull-overwrite gate in Step 3 uses), pin, and evaluate before any creation write:

Use the AskUserQuestion tool with two options (stating the count N):

  1. Yes, proceed — create the N untracked issues locally
  2. No, abort — abort with zero creations and a non-zero exit

It fails safe: if not running interactively, abort with zero creations. This stops a mis-scoped --all or an automation-flooded project from flooding the work directory and exhausting IDs.

Allocate the whole batch up front — never per item in a loop (which would hand every pulled item the same number until each file lands):

${CLAUDE_PLUGIN_ROOT}/skills/work/scripts/work-item-next-number.sh --count N

For each issue, build the full frontmatter (incl. external_id = remote key and the allocated id) and body in memory, write it in a single atomic_write, then record its baseline entry (work-item-sync-baseline.sh set \<id> \<remote_updated_at> \<remote_hash> \<local_hash>, with remote_hash from work-item-project-remote.sh … body | work-item-normalise.sh --stdin over the issue’s show body, and local_hash from the just-written file). Re-validate each allocated id is still free immediately before its write and abort the batch on an unexpected collision rather than overwriting (single-writer assumption). The pull is idempotent across re-runs: a created item now carries an external_id, so it is no longer untracked. Under --preview: report the untracked set, allocate nothing, create nothing.

On clean completion (and never under --preview), advance the global baseline timestamp with the run-start epoch from Step 1:

${CLAUDE_PLUGIN_ROOT}/skills/work/scripts/work-item-sync-apply.sh finalise \
--timestamp <run-start-epoch>

Under --preview, run the same classification and decisions but route every push through the update bridge’s real dry-run (--dry-run, which forwards --print-payload) and report every pull instead of writing it. No baseline mutation occurs under --preview — neither per-item set nor finalise.

Print a summary grouped by action, listing the affected ids (not bare counts) so the user can see exactly which items changed without re-running:

pushed: <ids>
pulled: <ids>
pushed-unsynced: <ids> (new external_id written back)
pulled-untracked: <ids> (remote key → new local id)
conflicts-skipped: <ids>
overrides: OVERRIDE <id> (<external_id>): pushed local→remote
needs-retry: <ids>
remote-absent: <ids>
unsynced (not pushed): <ids> (declined)