Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 73 additions & 25 deletions .claude/skills/dld-common/scripts/regenerate-index.sh
Original file line number Diff line number Diff line change
@@ -1,56 +1,104 @@
#!/usr/bin/env bash
# Regenerate decisions/INDEX.md from all decision files.
# Reads YAML frontmatter from each DL-*.md file and builds a markdown table.
#
# Optional --include-base <ref>: also include decision files from the given git
# ref (e.g. origin/main) that are not present in the working tree. Used by
# /dld-reindex so a pre-rebase INDEX.md contains both renamed-local rows and
# base-branch rows the local commit hasn't seen yet — the rebase then auto-merges.

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/common.sh"

INCLUDE_BASE=""
while [[ $# -gt 0 ]]; do
case "$1" in
--include-base) INCLUDE_BASE="$2"; shift 2 ;;
*) echo "Unknown arg: $1" >&2; exit 1 ;;
esac
done

DECISIONS_DIR="$(get_decisions_dir)"
RECORDS_DIR="$(get_records_dir)"
MODE="$(get_mode)"
INDEX_FILE="$DECISIONS_DIR/INDEX.md"
PROJECT_ROOT="$(get_project_root)"
RECORDS_DIR_REL="${RECORDS_DIR#"$PROJECT_ROOT"/}"

if [[ ! -d "$RECORDS_DIR" ]]; then
echo "Error: records directory not found at $RECORDS_DIR" >&2
exit 1
fi

# Extract a frontmatter field from a decision file
# Usage: extract_field <file> <field>
if [[ -n "$INCLUDE_BASE" ]]; then
if ! git -C "$PROJECT_ROOT" rev-parse --verify --quiet "$INCLUDE_BASE^{commit}" >/dev/null; then
echo "Error: --include-base ref '$INCLUDE_BASE' not found." >&2
exit 1
fi
fi

# Read the body of a "source spec" — either local:<path> or base:<path>.
read_source() {
local source="$1"
case "$source" in
base:*) git -C "$PROJECT_ROOT" show "$INCLUDE_BASE:${source#base:}" 2>/dev/null ;;
local:*) cat "${source#local:}" ;;
*) cat "$source" ;; # backward compat
esac
}

# Extract a frontmatter field from a source spec.
extract_field() {
local file="$1"
local source="$1"
local field="$2"
# Read between --- markers, find the field
sed -n '/^---$/,/^---$/p' "$file" \
read_source "$source" \
| sed -n '/^---$/,/^---$/p' \
| grep "^${field}:" \
| head -1 \
| sed "s/^${field}:[[:space:]]*//" \
| sed 's/^"\(.*\)"$/\1/' \
| sed "s/^'\(.*\)'$/\1/"
}

# Extract array field as comma-separated string
# Usage: extract_array_field <file> <field>
# Extract array field as comma-separated string.
extract_array_field() {
local file="$1"
local source="$1"
local field="$2"
local raw
raw=$(extract_field "$file" "$field")
# Handle YAML inline array: [tag1, tag2, tag3]
raw=$(extract_field "$source" "$field")
echo "$raw" | sed 's/^\[//;s/\]$//;s/,[[:space:]]*/,/g;s/,/, /g'
}

# Collect all decision files
# Sort by numeric ID descending: extract ID number, sort, reconstruct
DECISION_FILES=$(find "$RECORDS_DIR" -name 'DL-*.md' -type f \
| awk -F/ '{file=$0; basename=$NF; gsub(/^DL-/,"",basename); gsub(/\.md$/,"",basename); print basename "\t" file}' \
| sort -n -r \
| cut -f2)
# Build the source list as <numeric-id>\t<source-spec> lines.
# Local working-tree files first.
SOURCES=""
while IFS= read -r f; do
[[ -z "$f" ]] && continue
bn=$(basename "$f" .md)
num="${bn#DL-}"
SOURCES+="$num"$'\t'"local:$f"$'\n'
done < <(find "$RECORDS_DIR" -name 'DL-*.md' -type f 2>/dev/null)

# Then base-only files (skip any whose basename already appears locally).
if [[ -n "$INCLUDE_BASE" ]]; then
LOCAL_BASENAMES=$(find "$RECORDS_DIR" -name 'DL-*.md' -type f -exec basename {} \; 2>/dev/null | sort -u)
while IFS= read -r p; do
[[ -z "$p" ]] && continue
bn=$(basename "$p")
if grep -qxF "$bn" <<<"$LOCAL_BASENAMES"; then
continue
fi
num=$(echo "$bn" | sed 's/^DL-//;s/\.md$//')
SOURCES+="$num"$'\t'"base:$p"$'\n'
done < <(git -C "$PROJECT_ROOT" ls-tree -r --name-only "$INCLUDE_BASE" -- "$RECORDS_DIR_REL" 2>/dev/null | grep -E 'DL-[0-9]+\.md$' || true)
fi

# Strip the trailing newline and sort by numeric ID descending.
SORTED_SOURCES=$(printf '%s' "$SOURCES" | sort -t$'\t' -k1,1 -n -r | cut -f2)

if [[ -z "$DECISION_FILES" ]]; then
# Write empty index
if [[ -z "$SORTED_SOURCES" ]]; then
{
echo "# Decision Log"
echo ""
Expand All @@ -66,7 +114,6 @@ if [[ -z "$DECISION_FILES" ]]; then
exit 0
fi

# Build the index
{
echo "# Decision Log"
echo ""
Expand All @@ -78,14 +125,15 @@ fi
echo "|----|-------|--------|------|"
fi

echo "$DECISION_FILES" | while IFS= read -r file; do
id=$(extract_field "$file" "id")
title=$(extract_field "$file" "title")
status=$(extract_field "$file" "status")
tags=$(extract_array_field "$file" "tags")
echo "$SORTED_SOURCES" | while IFS= read -r source; do
[[ -z "$source" ]] && continue
id=$(extract_field "$source" "id")
title=$(extract_field "$source" "title")
status=$(extract_field "$source" "status")
tags=$(extract_array_field "$source" "tags")

if [[ "$MODE" == "namespaced" ]]; then
namespace=$(extract_field "$file" "namespace")
namespace=$(extract_field "$source" "namespace")
echo "| $id | $title | $status | $namespace | $tags |"
else
echo "| $id | $title | $status | $tags |"
Expand Down
182 changes: 182 additions & 0 deletions .claude/skills/dld-reindex/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
---
name: dld-reindex
description: Resolve decision ID collisions between a local branch and the base branch (and open PRs) before rebasing. Renames colliding local decisions with git mv, rewrites cross-references and annotations, then squashes branch commits into a single rebase-clean reindex commit.
user_invocable: true
---

# /dld-reindex — Resolve Decision ID Collisions

You are helping the developer untangle decision ID collisions before they rebase. Two or more developers can draft `DL-NNN` decisions in parallel; once one of them lands on the base branch (or appears in an open PR), the others must rename their local copies to the next free ID. This skill handles that mechanically and produces a branch state that rebases cleanly.

**This skill rewrites branch history.** That's not optional: if a colliding path (e.g. `decisions/records/DL-205.md`) was added by any commit on the branch, `git rebase` will hit an add/add conflict on that commit *before* it ever sees a later rename. The only fix is to ensure the colliding path never appears in the branch's history. The skill does this by squashing all branch commits since the merge-base into one reindex commit containing the renamed files.

If the branch has already been pushed, finishing the reindex will require a `--force-with-lease` push. The skill asks for explicit consent before rewriting history.

## Interaction style

Use the `AskUserQuestion` tool when prompting for consent and at the finish step. Everything else is deterministic.

**Do not redirect any command output to `/tmp` files.** The scripts in this skill emit only what you need to act on; piping to `/tmp/*.txt`, `tee`-ing into scratch files, or stashing stderr separately is unnecessary and creates clutter outside the repo. If a command's output is too long to read in one go, narrow it (`| tail -N`, `| head -N`, or pass a more specific flag) rather than persisting it.

## Script Paths

Shared scripts:
```
.claude/skills/dld-common/scripts/common.sh
.claude/skills/dld-common/scripts/regenerate-index.sh
```

Skill-specific scripts:
```
.claude/skills/dld-reindex/scripts/resolve-base.sh
.claude/skills/dld-reindex/scripts/plan-renames.sh
.claude/skills/dld-reindex/scripts/find-collisions.sh
.claude/skills/dld-reindex/scripts/list-taken-ids.sh
.claude/skills/dld-reindex/scripts/rename-decision.sh
.claude/skills/dld-reindex/scripts/find-stale-mentions.sh
.claude/skills/dld-reindex/scripts/commit-reindex.sh
```

## Prerequisites

1. Check that `dld.config.yaml` exists at the repo root. If not, tell the user to run `/dld-init` first and stop.
2. Verify the working tree is clean (`git status --porcelain` empty). If not, ask the user to commit or stash first, then stop.
3. Fetch the latest base state:
```bash
git fetch origin
```

## Step 1: Resolve the base ref

```bash
BASE=$(bash .claude/skills/dld-reindex/scripts/resolve-base.sh)
```

`resolve-base.sh` prefers the branch's upstream when it tracks a *different* branch (the typical "feature → main" setup). It falls back to `origin/main` if the upstream is unset OR if the upstream tracks the same branch name as the current branch (i.e. it's just the remote copy of this same branch, not a useful collision base).

The user may pass an explicit base when invoking the skill (e.g. `/dld-reindex origin/develop`) — honor it if present.

## Step 2: Plan the renames

```bash
bash .claude/skills/dld-reindex/scripts/plan-renames.sh --base "$BASE"
```

Output is tab-separated, one rename per line:

```
<relative-path>\t<DL-OLD>\t<DL-NEW>
```

If the output is empty, exit with:

> No ID collisions detected. Safe to rebase onto `$BASE`.

`plan-renames.sh` may print a stderr note like `[dld-reindex] open PRs not scanned: gh CLI not installed`. **Always surface this to the user** so they know the renamed IDs were chosen against base-branch state only and may still collide with an open PR.

The underlying helpers (`find-collisions.sh`, `list-taken-ids.sh`) remain available for debugging, but the SKILL flow always goes through `plan-renames.sh`.

## Step 3: Get explicit consent for the history rewrite

Show the user the rename plan and the implication. Use `AskUserQuestion`:

> Resolving these collisions requires rewriting branch history. I will squash the N commits since `<merge-base>` into a single reindex commit. The original commit subjects will be preserved in the new commit body. If the branch has already been pushed, finishing will require `git push --force-with-lease`. How should I proceed?

Options:
- **Rewrite and force-push** — agent applies renames, squashes, commits, and runs `git push --force-with-lease`.
- **Rewrite only** — agent applies renames, squashes, and commits. User pushes when ready.
- **Cancel** — abort with no changes.

If the user cancels, exit without touching anything.

## Step 4: Apply renames

For each line in the plan, call:

```bash
bash .claude/skills/dld-reindex/scripts/rename-decision.sh --old DL-OLD --new DL-NEW --path <relative-path> --base "$BASE"
```

`rename-decision.sh` does all of:

- `git mv` the file from `DL-OLD.md` to `DL-NEW.md`.
- Patches the `id:` frontmatter field in the renamed file.
- Rewrites `DL-OLD` mentions inside the renamed file's body.
- Rewrites `DL-OLD` mentions inside OTHER locally-added/modified decision files (frontmatter `supersedes` / `amends` / `references` and body). Scoped to the local change set vs the base ref.
- Rewrites `` `@decision` ``(DL-OLD) annotations to `` `@decision` ``(DL-NEW) in non-decision files that are part of the local change set.

The substitution is digit-aware: renaming `DL-100` will not accidentally rewrite `DL-1000`.

**Note on plain-text DL-NNN mentions in code:** In non-decision files (source code, READMEs, etc.) `rename-decision.sh`'s rewrite is **scoped to `` `@decision` ``(DL-NNN) annotations only**. Bare `DL-NNN` references in comments, log strings, or test fixtures are left untouched here to avoid false-positive matches against unrelated identifiers. Step 5 handles them.

## Step 5: Review plain-text DL-OLD mentions

After all renames are applied (but before the squash), find any remaining bare `DL-OLD` references in non-decision changed files:

```bash
echo "$PLAN" | bash .claude/skills/dld-reindex/scripts/find-stale-mentions.sh --base "$BASE"
```

Output is tab-separated, one match per line: `<path>\t<line>\t<DL-OLD>\t<DL-NEW>\t<line-content>`. Empty output means nothing to review.

For each match:

1. Read the surrounding context in the file.
2. Decide whether the reference makes sense as `DL-OLD` or should become `DL-NEW`. A code comment like `// Span-driven batching (DL-207) groups resolutions sharing the same turn context (DL-202)` after a `DL-207 → DL-213` rename almost certainly wants `DL-213` (it's prose alongside an annotation). A test fixture string that's checking historical data may need to stay as `DL-OLD`.
3. If the line should change, use the `Edit` tool to update that specific occurrence — don't do a blanket find-and-replace.
4. If the line should stay, leave it.

Surface the list of matches to the user with your verdicts before you finish, so they can sanity-check the judgment calls.

## Step 6: Squash and commit

Pipe the rename plan into `commit-reindex.sh`:

```bash
echo "$PLAN" | bash .claude/skills/dld-reindex/scripts/commit-reindex.sh --base "$BASE"
```

This:

1. Computes the merge-base with `$BASE`.
2. Mixed-resets HEAD to the merge-base (working tree is preserved — it already holds the post-rename state from step 4).
3. Restores `INDEX.md` in the working tree to its merge-base state so the working tree is consistent with what we're about to commit. **INDEX.md is intentionally excluded from the reindex commit** — including it would cause a content conflict during rebase whenever the base branch also modified INDEX.md (git's 3-way merge fails to align both sides' top-of-file inserts even when row content overlaps). INDEX.md gets regenerated post-rebase instead.
4. Stages **only** an explicit path list derived from the original branch diff and the rename plan — the old paths (for deletions), the new paths (for additions), every other file the branch touched. Untracked unrelated paths (e.g. `.claude/worktrees`, scratch files, in-progress edits to unrelated files) are deliberately NOT swept in.
5. Commits with a templated message that lists the renames in the subject and preserves the original branch commits' subjects in the body.

**Do not use `git add -A` or `git commit -a` anywhere in this flow.** Use only `commit-reindex.sh` to commit. Targeting paths explicitly is the whole point of this step.

## Step 7: Push (if the user chose force-push)

If step 3's answer was "Rewrite and force-push":

```bash
if git rev-parse --verify --quiet "@{upstream}" >/dev/null; then
git push --force-with-lease
else
git push -u origin HEAD
fi
```

`--force-with-lease` is mandatory over `--force` here — it refuses to push if the remote moved since the last fetch, which protects against overwriting concurrent collaborator pushes.

## Step 8: Report

Print:

- The renames table.
- The stderr note from step 2 if `gh` was skipped.
- The number of commits squashed.
- The next steps:

> 1. `git rebase $BASE`
> 2. `bash .claude/skills/dld-common/scripts/regenerate-index.sh` (to repopulate INDEX.md with the renamed locals — the reindex commit intentionally leaves INDEX.md alone to keep the rebase conflict-free; INDEX.md is missing the renamed rows until you regenerate)
> 3. Commit the INDEX.md update

The skill never rebases or merges — that is always the user's call.

## Out of scope

- **Already-conflicted rebases.** If the user is mid-rebase with conflicts, tell them to `git rebase --abort` first and re-run this skill.
- **Preserving per-commit granularity.** The squash trades original commit boundaries for a deterministic rewrite. A future `--preserve-history` flag could perform a cherry-pick walk that rewrites each commit individually, but the edge cases (commits modifying an already-renamed file, merge commits, partial reruns) make it materially more complex than the squash.
- **Cross-namespace ID reconciliation** in namespaced projects. IDs are assumed globally unique across namespaces, matching `next-id.sh`.
Loading
Loading