Claude
Skills
Sign in
Back

index-sync

Included with Lifetime
$97 forever

Atomically updates both the specs index and the tasks index for a given spec, detects and repairs pre-existing drift between them, and enforces write invariants before any mutation

General

What this skill does


# Index Sync

## Purpose

Ensure the specs index and the tasks index (resolved via `scripts/resolve-paths.sh`) remain consistent at all times.
Every status/progress write to either index MUST go through this skill — never write one index alone.

## Why Two Indexes Exist

- `specs/index.json` (resolved as `$SPECS_INDEX`) — source of truth for spec lifecycle status (`draft`, `approved`, `in-progress`, `completed`, `cancelled`, `reserved`, `merged`, `obsolete`)
- `tasks/index.json` (resolved as `$TASKS_INDEX`) — mirrors spec status and carries task-level progress (`progress`, task counts).  Its vocabulary is `pending`, `in-progress`, `completed`, `cancelled`, `draft`, `reserved`, `merged`, `obsolete` — the same as the specs index except `approved` (which maps to `pending`) and the extra `pending` value.

They drift when one is written without the other.  This skill closes that gap.

## Inputs

You will receive:

1. **specId** — e.g. `"SPEC-001"` (required)
2. **newStatus** — the target status for both indexes.  Must be one of: `pending`, `in-progress`, `completed`, `cancelled`, `draft`, `reserved`, `merged`, `obsolete` (tasks index) / `draft`, `approved`, `in-progress`, `completed`, `cancelled`, `reserved`, `merged`, `obsolete` (specs index).  Pass `null` to skip status update (progress-only write).
3. **newProgress** — string in `"N/M"` format, e.g. `"4/10"` (optional — omit or pass `null` to skip progress update)
4. **specsIndexPath** — absolute path to the specs index (resolved via `scripts/resolve-paths.sh` as `$SPECS_INDEX`; default: `<repo-root>/.claude/specs/index.json`)
5. **tasksIndexPath** — absolute path to the tasks index (resolved via `scripts/resolve-paths.sh` as `$TASKS_INDEX`; default: `<repo-root>/.claude/tasks/index.json`)

## Process

### Step 0: Pre-Write Validation (Invariant Checks)

Before ANY write, validate the requested mutation.  These checks use `jq` only.  If any check fails, **ABORT with a clear error message** — do NOT write bad data.

#### 0a. Status enum check

If `newStatus` is provided, verify it is one of the allowed values:

```bash
# Allowed values for tasks/index.json epics
TASK_STATUSES="pending in-progress completed cancelled draft reserved merged obsolete"
# Allowed values for specs/index.json
SPEC_STATUSES="draft approved in-progress completed cancelled reserved merged obsolete"

# newStatus must be valid in AT LEAST ONE vocabulary.  `pending` is task-only
# and `approved` is spec-only, so requiring membership in BOTH would reject those
# legitimate values.  Accept if it appears in either set.
if ! echo "$TASK_STATUSES $SPEC_STATUSES" | grep -qw "$newStatus"; then
  echo "ABORT: invalid status '$newStatus' — not in tasks ($TASK_STATUSES) or specs ($SPEC_STATUSES) vocabulary"; exit 1
fi
```

#### 0b. Progress format check

If `newProgress` is provided, verify format `N/M` with integer N ≤ M:

```bash
# jq invariant check
echo "$newProgress" | jq -Rr '
  split("/") |
  if length != 2 then error("progress must be N/M format") else . end |
  (.[0] | tonumber) as $n |
  (.[1] | tonumber) as $m |
  if $n < 0 or $m < 0 then error("progress values must be non-negative") else . end |
  if $n > $m then error("completed (\($n)) cannot exceed total (\($m))") else "ok" end
' 2>&1 | grep -q "^ok$" || { echo "ABORT: invalid progress format '$newProgress' (must be N/M with N<=M)"; exit 1; }
```

#### 0c. Spec directory existence check

When writing a new epic entry, confirm `$SPECS_DIR/{slug}/` exists on disk.  Skip this check for updates to existing entries.

```bash
eval "$(bash "${CLAUDE_PLUGIN_ROOT}/scripts/resolve-paths.sh")"
SPEC_DIR="$SPECS_DIR/${specSlug}"
[ -d "$SPEC_DIR" ] || { echo "ABORT: spec directory '$SPEC_DIR' does not exist — cannot create index entry for a non-existent spec"; exit 1; }
```

#### 0d. Required fields check (for new entries only)

When adding a new entry, verify all required fields are provided: `specId`, `title`, `status`, `path`.

### Step 1: Detect Pre-Existing Drift

Read both index files and compare the entry for `specId`.  Report any disagreements BEFORE applying the new write.

```bash
# Resolve paths in THIS block — each bash block runs in its own shell, so the
# eval must be repeated wherever the resolved vars are used.
eval "$(bash "${CLAUDE_PLUGIN_ROOT}/scripts/resolve-paths.sh")"

# Extract current values from both indexes (empty string if entry not found)
SPEC_STATUS=$(jq -r --arg id "$specId" '
  .specs // [] | map(select(.specId == $id)) | first | .status // ""
' "$SPECS_INDEX" 2>/dev/null)

TASK_STATUS=$(jq -r --arg id "$specId" '
  .epics // [] | map(select(.specId == $id)) | first | .status // ""
' "$TASKS_INDEX" 2>/dev/null)

TASK_PROGRESS=$(jq -r --arg id "$specId" '
  .epics // [] | map(select(.specId == $id)) | first | .progress // ""
' "$TASKS_INDEX" 2>/dev/null)
```

If `SPEC_STATUS != TASK_STATUS` and both are non-empty, report:

```
⚠ Drift detected for SPEC-NNN before write:
  specs/index.json  status: "<old-spec-status>"
  tasks/index.json  status: "<old-task-status>"
  Reconciling: specs/index.json is authoritative — tasks/index.json will be updated.
```

Trust `specs/index.json` as authoritative on status disagreements.  The new write then supersedes both.

### Step 2: Compute the Status Mapping

The two indexes share almost the same vocabulary.  The mapping is **identity** —
the tasks index mirrors the spec status exactly — with a **single exception**:
`approved` (a specs-only value) maps to `pending` in the tasks index.

| Canonical (specs index)  | Tasks index equivalent |
|--------------------------|------------------------|
| `draft`                  | `draft`                |
| `approved`               | `pending`              |
| `reserved`               | `reserved`             |
| `in-progress`            | `in-progress`          |
| `completed`              | `completed`            |
| `merged`                 | `merged`               |
| `cancelled`              | `cancelled`            |
| `obsolete`               | `obsolete`             |

When `newStatus` is `approved`, write `pending` into `tasks/index.json`.
Every other value is written as-is to both indexes (identity mapping).

### Step 3: Update specs/index.json

Use a single atomic `jq` rewrite (read → transform → write back).

```bash
# Resolve paths in THIS block (own shell — repeat the eval per block).
eval "$(bash "${CLAUDE_PLUGIN_ROOT}/scripts/resolve-paths.sh")"

# Wrap in flock to prevent concurrent writes (see Concurrency section below)
(
  flock -w 10 200 || { echo "ABORT: index busy, another session holds the lock — retry in a moment"; exit 1; }

  # Build the patch object (only include fields that are being updated)
  jq_status_patch=""
  [ -n "$newStatus" ] && jq_status_patch="| if .specId == \"$specId\" then .status = \"$newStatus\" else . end"

  jq --arg id "$specId" \
     --arg status "$newStatus" \
     '
     .specs |= map(
       if .specId == $id then
         (if ($status != "" and $status != "null") then .status = $status else . end)
       else . end
     )
     ' "$SPECS_INDEX" > "${SPECS_INDEX}.tmp" && mv "${SPECS_INDEX}.tmp" "$SPECS_INDEX"

) 200>"$LOCK"
```

If the entry does not exist in `$SPECS_INDEX` yet (new spec creation), append it instead of updating.

### Step 4: Update tasks/index.json

Apply the mapped status and/or progress to `$TASKS_INDEX`:

```bash
# Resolve paths in THIS block (own shell — repeat the eval per block).
eval "$(bash "${CLAUDE_PLUGIN_ROOT}/scripts/resolve-paths.sh")"

(
  flock -w 10 200 || { echo "ABORT: index busy, another session holds the lock — retry in a moment"; exit 1; }

  TASK_STATUS_VAL="$mappedTaskStatus"   # computed from Step 2 mapping
  PROGRESS_VAL="$newProgress"           # may be empty/null

  jq --arg id "$specId" \
     --arg status "$TASK_STATUS_VAL" \
     --arg progress "$PROGRESS_VAL" \
     '
     .epics |= map(
       if .specId == $id then
         (if ($status != "" and $status != "null") then .status = $statu

Related in General