From e99821310c8db823081cbe8c484af4aa7044a143 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jimmy=20Utterstr=C3=B6m?= Date: Sun, 22 Mar 2026 13:27:36 +0100 Subject: [PATCH 1/3] Add incremental snapshot updates to avoid full rewrites The snapshot skill now detects what changed since the last run and applies targeted edits instead of rewriting SNAPSHOT.md and OVERVIEW.md from scratch every time. This scales much better for large decision logs (70+ decisions). - Add detect-snapshot-changes.sh: uses decisions_included + git diff to identify new and modified decisions since last snapshot - Add commit_hash tracking to update-snapshot-state.sh (matches audit) - Restructure SKILL.md with full/incremental modes for both documents - SNAPSHOT.md: append new sections, remove superseded, update amended - OVERVIEW.md: update only affected sections, trust agent judgment on when a larger rewrite is needed to maintain abstraction level - Support --full flag to force full regeneration - Backward compatible with old state files lacking commit_hash - Add 12 new tests (11 for detect-changes, 1 for commit_hash tracking) Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/skills/dld-snapshot/SKILL.md | 72 +++++-- .../scripts/detect-snapshot-changes.sh | 100 ++++++++++ .../scripts/update-snapshot-state.sh | 8 +- skills/dld-snapshot/SKILL.md | 72 +++++-- .../scripts/detect-snapshot-changes.sh | 100 ++++++++++ .../scripts/update-snapshot-state.sh | 8 +- tests/test_detect_changes.bats | 186 ++++++++++++++++++ tests/test_snapshot_state.bats | 10 + 8 files changed, 526 insertions(+), 30 deletions(-) create mode 100755 .claude/skills/dld-snapshot/scripts/detect-snapshot-changes.sh create mode 100755 skills/dld-snapshot/scripts/detect-snapshot-changes.sh create mode 100644 tests/test_detect_changes.bats diff --git a/.claude/skills/dld-snapshot/SKILL.md b/.claude/skills/dld-snapshot/SKILL.md index c890abc..f9d4754 100644 --- a/.claude/skills/dld-snapshot/SKILL.md +++ b/.claude/skills/dld-snapshot/SKILL.md @@ -11,7 +11,7 @@ You are generating documents that project the current state of the decision log 1. **`decisions/SNAPSHOT.md`** — Detailed per-decision reference. Every active decision with its rationale and code references. 2. **`decisions/OVERVIEW.md`** — High-level narrative synthesis with Mermaid diagrams. The document you'd hand to someone who needs to understand the system. -If the project's `dld.config.yaml` defines `snapshot_artifacts`, additional custom documents are generated as well (see Step 4). +If the project's `dld.config.yaml` defines `snapshot_artifacts`, additional custom documents are generated as well (see Step 5). ## Script Paths @@ -23,6 +23,7 @@ Shared scripts (used indirectly via skill scripts): Skill-specific scripts: ``` .claude/skills/dld-snapshot/scripts/collect-active-decisions.sh +.claude/skills/dld-snapshot/scripts/detect-snapshot-changes.sh .claude/skills/dld-snapshot/scripts/update-snapshot-state.sh ``` @@ -32,7 +33,26 @@ Check that `dld.config.yaml` exists at the repo root. If not, tell the user to r There must be at least one `accepted` decision. If all decisions are `proposed`, tell the user there's nothing to snapshot yet and suggest `/dld-implement`. -## Step 1: Collect active decisions +## Step 1: Determine update mode + +If the user's message includes `--full` (e.g., `/dld-snapshot --full`), skip change detection and use **full mode** for all documents. + +Otherwise, run: + +```bash +bash .claude/skills/dld-snapshot/scripts/detect-snapshot-changes.sh +``` + +This outputs: +- `mode: full` — no prior snapshot state exists, or SNAPSHOT.md/OVERVIEW.md are missing. Proceed with full generation. +- `mode: incremental` — prior state exists. The output also includes: + - `new_decisions:` — comma-separated list of new accepted decision IDs (e.g., `DL-072, DL-073`) + - `modified_decisions:` — comma-separated list of existing decisions changed since last snapshot (status changes, content edits) + - `commit_range:` — the git range since last snapshot (e.g., `abc1234..HEAD`) + +If mode is `incremental` but both `new_decisions` and `modified_decisions` are empty, tell the user nothing has changed since the last snapshot and stop. + +## Step 2: Collect active decisions ```bash bash .claude/skills/dld-snapshot/scripts/collect-active-decisions.sh @@ -40,9 +60,24 @@ bash .claude/skills/dld-snapshot/scripts/collect-active-decisions.sh This outputs the full content of all `accepted` decisions, separated by `===DLD_DECISION_BOUNDARY===` markers. Parse the output to extract each decision's frontmatter and body. Use the project mode from `dld.config.yaml` (already checked in prerequisites) to determine the organization strategy. -## Step 2: Generate SNAPSHOT.md +## Step 3: Generate or update SNAPSHOT.md + +### Full mode + +Write `decisions/SNAPSHOT.md` from scratch using the template and rules below. -Write `decisions/SNAPSHOT.md` — the detailed per-decision reference. +### Incremental mode + +Read the existing `decisions/SNAPSHOT.md` and apply targeted edits: + +1. **For each new decision:** Identify which group heading (`## Namespace` or `## Tag Group`) it belongs to. Insert the new `### DL-NNN: Title` section in the correct position (ascending ID order within the group). If the group heading doesn't exist yet, add a new `## Group` section. +2. **For each modified decision:** Read its current status from the decision file. + - If now `superseded` or `deprecated`: remove its `### DL-NNN` section from the file. If the group becomes empty after removal, remove the group heading too. + - If still `accepted` but content changed: replace its `### DL-NNN` section with updated content. + - If it has a new `amends` relationship: add an `*Amended by: DL-YYY*` line to the target decision's section. +3. **Update the header:** Adjust the "Active decisions" count, the "DL-001 through DL-XXX" range, and the date. + +Do NOT rewrite the entire file. Use targeted edits to insert, remove, or modify only the affected sections. ### Template @@ -93,12 +128,25 @@ Write `decisions/SNAPSHOT.md` — the detailed per-decision reference. - If a decision has no Rationale section, omit the Rationale line - If a decision has no references, omit the Code line -## Step 3: Generate OVERVIEW.md - -Write `decisions/OVERVIEW.md` — the high-level narrative synthesis. +## Step 4: Generate or update OVERVIEW.md This is fundamentally different from SNAPSHOT.md. You are **synthesizing** across decisions to create a readable narrative that explains the system's current design. Think of it as the document you'd give a new team member or use to onboard an AI agent on the project. +### Full mode + +Write `decisions/OVERVIEW.md` from scratch using the template and guidelines below. + +### Incremental mode + +Read the existing `decisions/OVERVIEW.md`. Identify which domain sections are affected by the new and modified decisions — look at their namespaces, tags, and the topics they cover. Then: + +1. **Update only the affected sections.** Rewrite each affected section to integrate new decisions or remove references to superseded/deprecated ones. Leave unaffected sections untouched. +2. **Add new sections** if new decisions introduce a domain area not covered by existing sections. +3. **Update diagrams** only if the changeset affects the relationships or components they depict. +4. **Update the header** (decision count, date) and **Key Technical Choices** if any new decisions are cross-cutting. + +**Maintaining appropriate abstraction:** The overview should remain a high-level narrative, not grow into a detailed specification. As you update sections, consider whether the overall document still reads as an overview. If adding new decisions makes a section too detailed or the document too long, condense older stable content to make room — summarize established decisions more aggressively to keep focus on what matters for understanding the system. If the document's section structure no longer properly organizes the content (e.g., a new cross-cutting concern spans multiple existing sections), restructure as needed — this may mean a larger rewrite of the document, which is appropriate when the system's shape has meaningfully changed. + ### Template ```markdown @@ -183,7 +231,7 @@ When creating Mermaid diagrams: - **Use `
` for line breaks** inside quoted labels — do not use `\n` or literal newlines - **Avoid special characters** (`(`, `)`, `[`, `]`, `{`, `}`, `|`, `#`, `&`) in unquoted labels — use quoted labels or HTML entities instead -## Step 4: Generate custom artifacts +## Step 5: Generate custom artifacts Read the `snapshot_artifacts` key from `dld.config.yaml`. If the key is absent or the list is empty, skip this step entirely. @@ -193,7 +241,7 @@ For each entry in `snapshot_artifacts`: - Must end in `.md`. If not, warn the user and skip this artifact. - Must not collide (case-insensitive) with reserved filenames: `SNAPSHOT.md`, `OVERVIEW.md`, `INDEX.md`. If it collides, warn the user and skip this artifact. -2. **Generate `decisions/`** using the collected decisions (from Step 1) as context and the `prompt` field as the generation instruction. The file must begin with this standard header: +2. **Generate `decisions/<title>`** using the collected decisions (from Step 2) as context and the `prompt` field as the generation instruction. The file must begin with this standard header: ```markdown # <Title without .md extension> @@ -204,9 +252,9 @@ For each entry in `snapshot_artifacts`: Followed by the content generated according to the prompt. -Keep track of which custom artifacts were successfully generated — you will need the list for Step 5 and Step 6. +Keep track of which custom artifacts were successfully generated — you will need the list for Step 6 and Step 7. -## Step 5: Update snapshot state +## Step 6: Update snapshot state Pass any successfully generated custom artifact filenames as arguments: @@ -226,7 +274,7 @@ If no custom artifacts were generated, run without arguments: bash .claude/skills/dld-snapshot/scripts/update-snapshot-state.sh ``` -## Step 6: Suggest next steps +## Step 7: Suggest next steps > Snapshot generated: > - `decisions/SNAPSHOT.md` — detailed reference (N active decisions) diff --git a/.claude/skills/dld-snapshot/scripts/detect-snapshot-changes.sh b/.claude/skills/dld-snapshot/scripts/detect-snapshot-changes.sh new file mode 100755 index 0000000..af2b95e --- /dev/null +++ b/.claude/skills/dld-snapshot/scripts/detect-snapshot-changes.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +# Detect what changed since the last snapshot run. +# Reads .dld-state.yaml for snapshot.decisions_included and snapshot.commit_hash, +# then compares against current state to produce a structured changeset. +# +# Output format (YAML-like): +# mode: full|incremental +# new_decisions: DL-072, DL-073 +# modified_decisions: DL-015, DL-030 +# commit_range: abc1234..def5678 +# +# mode=full when no prior state exists or SNAPSHOT.md/OVERVIEW.md are missing. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../../dld-common/scripts/common.sh" + +DECISIONS_DIR="$(get_decisions_dir)" +RECORDS_DIR="$(get_records_dir)" +STATE_FILE="$DECISIONS_DIR/.dld-state.yaml" + +# Check if we have prior snapshot state +if [[ ! -f "$STATE_FILE" ]] || ! grep -q "^snapshot:" "$STATE_FILE"; then + echo "mode: full" + exit 0 +fi + +# Check if SNAPSHOT.md and OVERVIEW.md exist +if [[ ! -f "$DECISIONS_DIR/SNAPSHOT.md" ]] || [[ ! -f "$DECISIONS_DIR/OVERVIEW.md" ]]; then + echo "mode: full" + exit 0 +fi + +# Read previous snapshot state +PREV_INCLUDED=$(sed -n '/^snapshot:/,/^[^[:space:]]/{ s/^ decisions_included:[[:space:]]*//p; }' "$STATE_FILE" | head -1) +PREV_COMMIT=$(sed -n '/^snapshot:/,/^[^[:space:]]/{ s/^ commit_hash:[[:space:]]*//p; }' "$STATE_FILE" | head -1) + +# If no decisions_included recorded, do full +if [[ -z "$PREV_INCLUDED" ]]; then + echo "mode: full" + exit 0 +fi + +# Find new accepted decisions (ID > decisions_included) +NEW_DECISIONS="" +for file in $(find "$RECORDS_DIR" -name 'DL-*.md' -type f 2>/dev/null); do + NUM=$(basename "$file" | sed 's/^DL-\([0-9]*\)\.md$/\1/') + if [[ $((10#$NUM)) -gt $((10#$PREV_INCLUDED)) ]]; then + STATUS=$(sed -n '/^---$/,/^---$/p' "$file" \ + | grep "^status:" \ + | head -1 \ + | sed 's/^status:[[:space:]]*//') + if [[ "$STATUS" == "accepted" ]]; then + ID="DL-$(printf '%03d' $((10#$NUM)))" + if [[ -n "$NEW_DECISIONS" ]]; then + NEW_DECISIONS="$NEW_DECISIONS, $ID" + else + NEW_DECISIONS="$ID" + fi + fi + fi +done + +# Find modified decisions via git diff since last commit +MODIFIED_DECISIONS="" +COMMIT_RANGE="" +if [[ -n "$PREV_COMMIT" && "$PREV_COMMIT" != "unknown" ]]; then + # Verify the commit is reachable + if git cat-file -t "$PREV_COMMIT" &>/dev/null; then + CURRENT_COMMIT=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown") + if [[ "$PREV_COMMIT" != "$CURRENT_COMMIT" ]]; then + COMMIT_RANGE="${PREV_COMMIT}..HEAD" + # Get decision files changed since last snapshot commit + CHANGED_FILES=$(git diff --name-only "$COMMIT_RANGE" -- "$RECORDS_DIR" 2>/dev/null || true) + for changed in $CHANGED_FILES; do + BASENAME=$(basename "$changed") + # Only consider DL-*.md files + if [[ "$BASENAME" =~ ^DL-[0-9]+\.md$ ]]; then + NUM=$(echo "$BASENAME" | sed 's/^DL-\([0-9]*\)\.md$/\1/') + # Only report modifications to decisions that were in the previous snapshot + if [[ $((10#$NUM)) -le $((10#$PREV_INCLUDED)) ]]; then + ID="DL-$(printf '%03d' $((10#$NUM)))" + if [[ -n "$MODIFIED_DECISIONS" ]]; then + MODIFIED_DECISIONS="$MODIFIED_DECISIONS, $ID" + else + MODIFIED_DECISIONS="$ID" + fi + fi + fi + done + fi + fi +fi + +# If nothing changed, still report incremental with empty changeset +echo "mode: incremental" +echo "new_decisions: ${NEW_DECISIONS:-}" +echo "modified_decisions: ${MODIFIED_DECISIONS:-}" +echo "commit_range: ${COMMIT_RANGE:-}" diff --git a/.claude/skills/dld-snapshot/scripts/update-snapshot-state.sh b/.claude/skills/dld-snapshot/scripts/update-snapshot-state.sh index ba075a9..88508fb 100755 --- a/.claude/skills/dld-snapshot/scripts/update-snapshot-state.sh +++ b/.claude/skills/dld-snapshot/scripts/update-snapshot-state.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # Update the snapshot tracking state in .dld-state.yaml. -# Records the current timestamp, highest accepted decision ID, -# and per-artifact timestamps. +# Records the current timestamp, HEAD commit hash, highest accepted +# decision ID, and per-artifact timestamps. # Usage: update-snapshot-state.sh [ARTIFACT_NAME...] # # Without arguments, records timestamps for the built-in artifacts @@ -19,6 +19,7 @@ DECISIONS_DIR="$(get_decisions_dir)" RECORDS_DIR="$(get_records_dir)" STATE_FILE="$DECISIONS_DIR/.dld-state.yaml" TIMESTAMP="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" +COMMIT_HASH="$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")" # Find the highest accepted decision ID by checking status in frontmatter HIGHEST_NUM=0 @@ -47,6 +48,7 @@ done SNAPSHOT_BLOCK="snapshot: last_run: $TIMESTAMP + commit_hash: $COMMIT_HASH decisions_included: $HIGHEST_NUM artifacts: $ARTIFACTS_BLOCK" @@ -83,4 +85,4 @@ else mv "$TMPFILE" "$STATE_FILE" fi -echo "Snapshot state updated: $TIMESTAMP (through DL-$(printf '%03d' $HIGHEST_NUM))" +echo "Snapshot state updated: $TIMESTAMP at $COMMIT_HASH (through DL-$(printf '%03d' $HIGHEST_NUM))" diff --git a/skills/dld-snapshot/SKILL.md b/skills/dld-snapshot/SKILL.md index 7a6f37e..e6290e0 100644 --- a/skills/dld-snapshot/SKILL.md +++ b/skills/dld-snapshot/SKILL.md @@ -11,7 +11,7 @@ You are generating documents that project the current state of the decision log 1. **`decisions/SNAPSHOT.md`** — Detailed per-decision reference. Every active decision with its rationale and code references. 2. **`decisions/OVERVIEW.md`** — High-level narrative synthesis with Mermaid diagrams. The document you'd hand to someone who needs to understand the system. -If the project's `dld.config.yaml` defines `snapshot_artifacts`, additional custom documents are generated as well (see Step 4). +If the project's `dld.config.yaml` defines `snapshot_artifacts`, additional custom documents are generated as well (see Step 5). ## Script Paths @@ -23,6 +23,7 @@ Shared scripts (used indirectly via skill scripts): Skill-specific scripts: ``` scripts/collect-active-decisions.sh +scripts/detect-snapshot-changes.sh scripts/update-snapshot-state.sh ``` @@ -32,7 +33,26 @@ Check that `dld.config.yaml` exists at the repo root. If not, tell the user to r There must be at least one `accepted` decision. If all decisions are `proposed`, tell the user there's nothing to snapshot yet and suggest `/dld-implement`. -## Step 1: Collect active decisions +## Step 1: Determine update mode + +If the user's message includes `--full` (e.g., `/dld-snapshot --full`), skip change detection and use **full mode** for all documents. + +Otherwise, run: + +```bash +bash scripts/detect-snapshot-changes.sh +``` + +This outputs: +- `mode: full` — no prior snapshot state exists, or SNAPSHOT.md/OVERVIEW.md are missing. Proceed with full generation. +- `mode: incremental` — prior state exists. The output also includes: + - `new_decisions:` — comma-separated list of new accepted decision IDs (e.g., `DL-072, DL-073`) + - `modified_decisions:` — comma-separated list of existing decisions changed since last snapshot (status changes, content edits) + - `commit_range:` — the git range since last snapshot (e.g., `abc1234..HEAD`) + +If mode is `incremental` but both `new_decisions` and `modified_decisions` are empty, tell the user nothing has changed since the last snapshot and stop. + +## Step 2: Collect active decisions ```bash bash scripts/collect-active-decisions.sh @@ -40,9 +60,24 @@ bash scripts/collect-active-decisions.sh This outputs the full content of all `accepted` decisions, separated by `===DLD_DECISION_BOUNDARY===` markers. Parse the output to extract each decision's frontmatter and body. Use the project mode from `dld.config.yaml` (already checked in prerequisites) to determine the organization strategy. -## Step 2: Generate SNAPSHOT.md +## Step 3: Generate or update SNAPSHOT.md + +### Full mode + +Write `decisions/SNAPSHOT.md` from scratch using the template and rules below. -Write `decisions/SNAPSHOT.md` — the detailed per-decision reference. +### Incremental mode + +Read the existing `decisions/SNAPSHOT.md` and apply targeted edits: + +1. **For each new decision:** Identify which group heading (`## Namespace` or `## Tag Group`) it belongs to. Insert the new `### DL-NNN: Title` section in the correct position (ascending ID order within the group). If the group heading doesn't exist yet, add a new `## Group` section. +2. **For each modified decision:** Read its current status from the decision file. + - If now `superseded` or `deprecated`: remove its `### DL-NNN` section from the file. If the group becomes empty after removal, remove the group heading too. + - If still `accepted` but content changed: replace its `### DL-NNN` section with updated content. + - If it has a new `amends` relationship: add an `*Amended by: DL-YYY*` line to the target decision's section. +3. **Update the header:** Adjust the "Active decisions" count, the "DL-001 through DL-XXX" range, and the date. + +Do NOT rewrite the entire file. Use targeted edits to insert, remove, or modify only the affected sections. ### Template @@ -93,12 +128,25 @@ Write `decisions/SNAPSHOT.md` — the detailed per-decision reference. - If a decision has no Rationale section, omit the Rationale line - If a decision has no references, omit the Code line -## Step 3: Generate OVERVIEW.md - -Write `decisions/OVERVIEW.md` — the high-level narrative synthesis. +## Step 4: Generate or update OVERVIEW.md This is fundamentally different from SNAPSHOT.md. You are **synthesizing** across decisions to create a readable narrative that explains the system's current design. Think of it as the document you'd give a new team member or use to onboard an AI agent on the project. +### Full mode + +Write `decisions/OVERVIEW.md` from scratch using the template and guidelines below. + +### Incremental mode + +Read the existing `decisions/OVERVIEW.md`. Identify which domain sections are affected by the new and modified decisions — look at their namespaces, tags, and the topics they cover. Then: + +1. **Update only the affected sections.** Rewrite each affected section to integrate new decisions or remove references to superseded/deprecated ones. Leave unaffected sections untouched. +2. **Add new sections** if new decisions introduce a domain area not covered by existing sections. +3. **Update diagrams** only if the changeset affects the relationships or components they depict. +4. **Update the header** (decision count, date) and **Key Technical Choices** if any new decisions are cross-cutting. + +**Maintaining appropriate abstraction:** The overview should remain a high-level narrative, not grow into a detailed specification. As you update sections, consider whether the overall document still reads as an overview. If adding new decisions makes a section too detailed or the document too long, condense older stable content to make room — summarize established decisions more aggressively to keep focus on what matters for understanding the system. If the document's section structure no longer properly organizes the content (e.g., a new cross-cutting concern spans multiple existing sections), restructure as needed — this may mean a larger rewrite of the document, which is appropriate when the system's shape has meaningfully changed. + ### Template ```markdown @@ -183,7 +231,7 @@ When creating Mermaid diagrams: - **Use `<br/>` for line breaks** inside quoted labels — do not use `\n` or literal newlines - **Avoid special characters** (`(`, `)`, `[`, `]`, `{`, `}`, `|`, `#`, `&`) in unquoted labels — use quoted labels or HTML entities instead -## Step 4: Generate custom artifacts +## Step 5: Generate custom artifacts Read the `snapshot_artifacts` key from `dld.config.yaml`. If the key is absent or the list is empty, skip this step entirely. @@ -193,7 +241,7 @@ For each entry in `snapshot_artifacts`: - Must end in `.md`. If not, warn the user and skip this artifact. - Must not collide (case-insensitive) with reserved filenames: `SNAPSHOT.md`, `OVERVIEW.md`, `INDEX.md`. If it collides, warn the user and skip this artifact. -2. **Generate `decisions/<title>`** using the collected decisions (from Step 1) as context and the `prompt` field as the generation instruction. The file must begin with this standard header: +2. **Generate `decisions/<title>`** using the collected decisions (from Step 2) as context and the `prompt` field as the generation instruction. The file must begin with this standard header: ```markdown # <Title without .md extension> @@ -204,9 +252,9 @@ For each entry in `snapshot_artifacts`: Followed by the content generated according to the prompt. -Keep track of which custom artifacts were successfully generated — you will need the list for Step 5 and Step 6. +Keep track of which custom artifacts were successfully generated — you will need the list for Step 6 and Step 7. -## Step 5: Update snapshot state +## Step 6: Update snapshot state Pass any successfully generated custom artifact filenames as arguments: @@ -226,7 +274,7 @@ If no custom artifacts were generated, run without arguments: bash scripts/update-snapshot-state.sh ``` -## Step 6: Suggest next steps +## Step 7: Suggest next steps > Snapshot generated: > - `decisions/SNAPSHOT.md` — detailed reference (N active decisions) diff --git a/skills/dld-snapshot/scripts/detect-snapshot-changes.sh b/skills/dld-snapshot/scripts/detect-snapshot-changes.sh new file mode 100755 index 0000000..af2b95e --- /dev/null +++ b/skills/dld-snapshot/scripts/detect-snapshot-changes.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +# Detect what changed since the last snapshot run. +# Reads .dld-state.yaml for snapshot.decisions_included and snapshot.commit_hash, +# then compares against current state to produce a structured changeset. +# +# Output format (YAML-like): +# mode: full|incremental +# new_decisions: DL-072, DL-073 +# modified_decisions: DL-015, DL-030 +# commit_range: abc1234..def5678 +# +# mode=full when no prior state exists or SNAPSHOT.md/OVERVIEW.md are missing. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../../dld-common/scripts/common.sh" + +DECISIONS_DIR="$(get_decisions_dir)" +RECORDS_DIR="$(get_records_dir)" +STATE_FILE="$DECISIONS_DIR/.dld-state.yaml" + +# Check if we have prior snapshot state +if [[ ! -f "$STATE_FILE" ]] || ! grep -q "^snapshot:" "$STATE_FILE"; then + echo "mode: full" + exit 0 +fi + +# Check if SNAPSHOT.md and OVERVIEW.md exist +if [[ ! -f "$DECISIONS_DIR/SNAPSHOT.md" ]] || [[ ! -f "$DECISIONS_DIR/OVERVIEW.md" ]]; then + echo "mode: full" + exit 0 +fi + +# Read previous snapshot state +PREV_INCLUDED=$(sed -n '/^snapshot:/,/^[^[:space:]]/{ s/^ decisions_included:[[:space:]]*//p; }' "$STATE_FILE" | head -1) +PREV_COMMIT=$(sed -n '/^snapshot:/,/^[^[:space:]]/{ s/^ commit_hash:[[:space:]]*//p; }' "$STATE_FILE" | head -1) + +# If no decisions_included recorded, do full +if [[ -z "$PREV_INCLUDED" ]]; then + echo "mode: full" + exit 0 +fi + +# Find new accepted decisions (ID > decisions_included) +NEW_DECISIONS="" +for file in $(find "$RECORDS_DIR" -name 'DL-*.md' -type f 2>/dev/null); do + NUM=$(basename "$file" | sed 's/^DL-\([0-9]*\)\.md$/\1/') + if [[ $((10#$NUM)) -gt $((10#$PREV_INCLUDED)) ]]; then + STATUS=$(sed -n '/^---$/,/^---$/p' "$file" \ + | grep "^status:" \ + | head -1 \ + | sed 's/^status:[[:space:]]*//') + if [[ "$STATUS" == "accepted" ]]; then + ID="DL-$(printf '%03d' $((10#$NUM)))" + if [[ -n "$NEW_DECISIONS" ]]; then + NEW_DECISIONS="$NEW_DECISIONS, $ID" + else + NEW_DECISIONS="$ID" + fi + fi + fi +done + +# Find modified decisions via git diff since last commit +MODIFIED_DECISIONS="" +COMMIT_RANGE="" +if [[ -n "$PREV_COMMIT" && "$PREV_COMMIT" != "unknown" ]]; then + # Verify the commit is reachable + if git cat-file -t "$PREV_COMMIT" &>/dev/null; then + CURRENT_COMMIT=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown") + if [[ "$PREV_COMMIT" != "$CURRENT_COMMIT" ]]; then + COMMIT_RANGE="${PREV_COMMIT}..HEAD" + # Get decision files changed since last snapshot commit + CHANGED_FILES=$(git diff --name-only "$COMMIT_RANGE" -- "$RECORDS_DIR" 2>/dev/null || true) + for changed in $CHANGED_FILES; do + BASENAME=$(basename "$changed") + # Only consider DL-*.md files + if [[ "$BASENAME" =~ ^DL-[0-9]+\.md$ ]]; then + NUM=$(echo "$BASENAME" | sed 's/^DL-\([0-9]*\)\.md$/\1/') + # Only report modifications to decisions that were in the previous snapshot + if [[ $((10#$NUM)) -le $((10#$PREV_INCLUDED)) ]]; then + ID="DL-$(printf '%03d' $((10#$NUM)))" + if [[ -n "$MODIFIED_DECISIONS" ]]; then + MODIFIED_DECISIONS="$MODIFIED_DECISIONS, $ID" + else + MODIFIED_DECISIONS="$ID" + fi + fi + fi + done + fi + fi +fi + +# If nothing changed, still report incremental with empty changeset +echo "mode: incremental" +echo "new_decisions: ${NEW_DECISIONS:-}" +echo "modified_decisions: ${MODIFIED_DECISIONS:-}" +echo "commit_range: ${COMMIT_RANGE:-}" diff --git a/skills/dld-snapshot/scripts/update-snapshot-state.sh b/skills/dld-snapshot/scripts/update-snapshot-state.sh index ba075a9..88508fb 100755 --- a/skills/dld-snapshot/scripts/update-snapshot-state.sh +++ b/skills/dld-snapshot/scripts/update-snapshot-state.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # Update the snapshot tracking state in .dld-state.yaml. -# Records the current timestamp, highest accepted decision ID, -# and per-artifact timestamps. +# Records the current timestamp, HEAD commit hash, highest accepted +# decision ID, and per-artifact timestamps. # Usage: update-snapshot-state.sh [ARTIFACT_NAME...] # # Without arguments, records timestamps for the built-in artifacts @@ -19,6 +19,7 @@ DECISIONS_DIR="$(get_decisions_dir)" RECORDS_DIR="$(get_records_dir)" STATE_FILE="$DECISIONS_DIR/.dld-state.yaml" TIMESTAMP="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" +COMMIT_HASH="$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")" # Find the highest accepted decision ID by checking status in frontmatter HIGHEST_NUM=0 @@ -47,6 +48,7 @@ done SNAPSHOT_BLOCK="snapshot: last_run: $TIMESTAMP + commit_hash: $COMMIT_HASH decisions_included: $HIGHEST_NUM artifacts: $ARTIFACTS_BLOCK" @@ -83,4 +85,4 @@ else mv "$TMPFILE" "$STATE_FILE" fi -echo "Snapshot state updated: $TIMESTAMP (through DL-$(printf '%03d' $HIGHEST_NUM))" +echo "Snapshot state updated: $TIMESTAMP at $COMMIT_HASH (through DL-$(printf '%03d' $HIGHEST_NUM))" diff --git a/tests/test_detect_changes.bats b/tests/test_detect_changes.bats new file mode 100644 index 0000000..47b0733 --- /dev/null +++ b/tests/test_detect_changes.bats @@ -0,0 +1,186 @@ +#!/usr/bin/env bats +# Tests for dld-snapshot/scripts/detect-snapshot-changes.sh + +load 'test_helper/common' + +SCRIPT="" +STATE_SCRIPT="" + +setup() { + setup_flat_project + SCRIPT="$SKILLS_DIR/dld-snapshot/scripts/detect-snapshot-changes.sh" + STATE_SCRIPT="$SKILLS_DIR/dld-snapshot/scripts/update-snapshot-state.sh" +} + +teardown() { + teardown_project +} + +@test "detect-changes returns full when no state file exists" { + run bash "$SCRIPT" + assert_success + assert_output --partial "mode: full" +} + +@test "detect-changes returns full when state file has no snapshot section" { + cat > decisions/.dld-state.yaml <<'YAML' +audit: + last_run: 2026-01-10T08:00:00Z + commit_hash: abc1234 +YAML + + run bash "$SCRIPT" + assert_success + assert_output --partial "mode: full" +} + +@test "detect-changes returns full when SNAPSHOT.md is missing" { + create_decision "DL-001" "accepted" + bash "$STATE_SCRIPT" + # SNAPSHOT.md does not exist + + run bash "$SCRIPT" + assert_success + assert_output --partial "mode: full" +} + +@test "detect-changes returns full when OVERVIEW.md is missing" { + create_decision "DL-001" "accepted" + bash "$STATE_SCRIPT" + touch decisions/SNAPSHOT.md + # OVERVIEW.md does not exist + + run bash "$SCRIPT" + assert_success + assert_output --partial "mode: full" +} + +@test "detect-changes returns incremental with no changes" { + create_decision "DL-001" "accepted" + bash "$STATE_SCRIPT" + touch decisions/SNAPSHOT.md + touch decisions/OVERVIEW.md + git add -A && git commit -m "snapshot" --quiet + + run bash "$SCRIPT" + assert_success + assert_output --partial "mode: incremental" + assert_output --partial "new_decisions: " + assert_output --partial "modified_decisions: " +} + +@test "detect-changes finds new accepted decisions" { + create_decision "DL-001" "accepted" + bash "$STATE_SCRIPT" + touch decisions/SNAPSHOT.md + touch decisions/OVERVIEW.md + git add -A && git commit -m "snapshot" --quiet + + # Add new decisions + create_decision "DL-002" "accepted" + create_decision "DL-003" "accepted" + git add -A && git commit -m "new decisions" --quiet + + run bash "$SCRIPT" + assert_success + assert_output --partial "mode: incremental" + assert_output --partial "DL-002" + assert_output --partial "DL-003" +} + +@test "detect-changes ignores new proposed decisions" { + create_decision "DL-001" "accepted" + bash "$STATE_SCRIPT" + touch decisions/SNAPSHOT.md + touch decisions/OVERVIEW.md + git add -A && git commit -m "snapshot" --quiet + + create_decision "DL-002" "proposed" + git add -A && git commit -m "proposed" --quiet + + run bash "$SCRIPT" + assert_success + assert_output --partial "mode: incremental" + assert_output --partial "new_decisions: " + # DL-002 should NOT appear in new_decisions + refute_output --partial "DL-002" +} + +@test "detect-changes detects modified decisions via git diff" { + create_decision "DL-001" "accepted" + create_decision "DL-002" "accepted" + bash "$STATE_SCRIPT" + touch decisions/SNAPSHOT.md + touch decisions/OVERVIEW.md + git add -A && git commit -m "snapshot" --quiet + + # Modify DL-001 (e.g., supersede it) + create_decision "DL-001" "superseded" + git add -A && git commit -m "supersede DL-001" --quiet + + run bash "$SCRIPT" + assert_success + assert_output --partial "mode: incremental" + assert_output --partial "modified_decisions:" + assert_output --partial "DL-001" +} + +@test "detect-changes does not report new decisions as modified" { + create_decision "DL-001" "accepted" + bash "$STATE_SCRIPT" + touch decisions/SNAPSHOT.md + touch decisions/OVERVIEW.md + git add -A && git commit -m "snapshot" --quiet + + # Add DL-002 (new, not modified) + create_decision "DL-002" "accepted" + git add -A && git commit -m "new" --quiet + + run bash "$SCRIPT" + assert_success + # DL-002 should be in new_decisions, not modified_decisions + assert_line --partial "new_decisions: DL-002" + refute_line --regexp "modified_decisions:.*DL-002" +} + +@test "detect-changes reports commit range" { + create_decision "DL-001" "accepted" + bash "$STATE_SCRIPT" + touch decisions/SNAPSHOT.md + touch decisions/OVERVIEW.md + git add -A && git commit -m "snapshot" --quiet + + create_decision "DL-002" "accepted" + git add -A && git commit -m "new" --quiet + + run bash "$SCRIPT" + assert_success + assert_output --partial "commit_range:" + # Should contain a .. range + assert_output --regexp "commit_range: [a-f0-9]+\.\." +} + +@test "detect-changes works with old state format (no commit_hash)" { + create_decision "DL-001" "accepted" + touch decisions/SNAPSHOT.md + touch decisions/OVERVIEW.md + + # Write old-format state (no commit_hash) + cat > decisions/.dld-state.yaml <<'YAML' +snapshot: + last_run: 2026-01-15T10:00:00Z + decisions_included: 1 + artifacts: + SNAPSHOT.md: 2026-01-15T10:00:00Z + OVERVIEW.md: 2026-01-15T10:00:00Z +YAML + + create_decision "DL-002" "accepted" + + run bash "$SCRIPT" + assert_success + assert_output --partial "mode: incremental" + assert_output --partial "DL-002" + # No commit range since old state has no commit_hash + assert_line --partial "commit_range: " +} diff --git a/tests/test_snapshot_state.bats b/tests/test_snapshot_state.bats index 37497af..873c068 100644 --- a/tests/test_snapshot_state.bats +++ b/tests/test_snapshot_state.bats @@ -101,6 +101,16 @@ YAML assert_output --partial "decisions_included: 1" } +@test "update-snapshot-state includes commit hash" { + create_decision "DL-001" "accepted" + bash "$SCRIPT" + + run cat decisions/.dld-state.yaml + assert_output --partial "commit_hash:" + # Should be a short git hash (not "unknown" since we're in a git repo) + refute_output --partial "commit_hash: unknown" +} + @test "update-snapshot-state handles zero accepted decisions" { create_decision "DL-001" "proposed" bash "$SCRIPT" From c55cca64a4b39f42ad9a989e2a047f37838043ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jimmy=20Utterstr=C3=B6m?= <jimutt@outlook.com> Date: Sun, 22 Mar 2026 13:35:18 +0100 Subject: [PATCH 2/3] Fall back to last_run timestamp when commit_hash is missing When upgrading from an older state file that lacks snapshot.commit_hash, use git log --until=<last_run> to derive a baseline commit for detecting modified decisions. This handles the bootstrap scenario where an existing project runs the incremental snapshot skill for the first time. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- .../scripts/detect-snapshot-changes.sh | 7 ++++ .../scripts/detect-snapshot-changes.sh | 7 ++++ tests/test_detect_changes.bats | 40 +++++++++++++++++-- 3 files changed, 51 insertions(+), 3 deletions(-) diff --git a/.claude/skills/dld-snapshot/scripts/detect-snapshot-changes.sh b/.claude/skills/dld-snapshot/scripts/detect-snapshot-changes.sh index af2b95e..a87613d 100755 --- a/.claude/skills/dld-snapshot/scripts/detect-snapshot-changes.sh +++ b/.claude/skills/dld-snapshot/scripts/detect-snapshot-changes.sh @@ -35,6 +35,7 @@ fi # Read previous snapshot state PREV_INCLUDED=$(sed -n '/^snapshot:/,/^[^[:space:]]/{ s/^ decisions_included:[[:space:]]*//p; }' "$STATE_FILE" | head -1) PREV_COMMIT=$(sed -n '/^snapshot:/,/^[^[:space:]]/{ s/^ commit_hash:[[:space:]]*//p; }' "$STATE_FILE" | head -1) +PREV_RUN=$(sed -n '/^snapshot:/,/^[^[:space:]]/{ s/^ last_run:[[:space:]]*//p; }' "$STATE_FILE" | head -1) # If no decisions_included recorded, do full if [[ -z "$PREV_INCLUDED" ]]; then @@ -42,6 +43,12 @@ if [[ -z "$PREV_INCLUDED" ]]; then exit 0 fi +# If no commit_hash but we have last_run, derive a baseline commit from timestamp +if [[ -z "$PREV_COMMIT" || "$PREV_COMMIT" == "unknown" ]] && [[ -n "$PREV_RUN" ]]; then + # Find the most recent commit at or before last_run timestamp + PREV_COMMIT=$(git log --until="$PREV_RUN" --format='%h' -1 2>/dev/null || true) +fi + # Find new accepted decisions (ID > decisions_included) NEW_DECISIONS="" for file in $(find "$RECORDS_DIR" -name 'DL-*.md' -type f 2>/dev/null); do diff --git a/skills/dld-snapshot/scripts/detect-snapshot-changes.sh b/skills/dld-snapshot/scripts/detect-snapshot-changes.sh index af2b95e..a87613d 100755 --- a/skills/dld-snapshot/scripts/detect-snapshot-changes.sh +++ b/skills/dld-snapshot/scripts/detect-snapshot-changes.sh @@ -35,6 +35,7 @@ fi # Read previous snapshot state PREV_INCLUDED=$(sed -n '/^snapshot:/,/^[^[:space:]]/{ s/^ decisions_included:[[:space:]]*//p; }' "$STATE_FILE" | head -1) PREV_COMMIT=$(sed -n '/^snapshot:/,/^[^[:space:]]/{ s/^ commit_hash:[[:space:]]*//p; }' "$STATE_FILE" | head -1) +PREV_RUN=$(sed -n '/^snapshot:/,/^[^[:space:]]/{ s/^ last_run:[[:space:]]*//p; }' "$STATE_FILE" | head -1) # If no decisions_included recorded, do full if [[ -z "$PREV_INCLUDED" ]]; then @@ -42,6 +43,12 @@ if [[ -z "$PREV_INCLUDED" ]]; then exit 0 fi +# If no commit_hash but we have last_run, derive a baseline commit from timestamp +if [[ -z "$PREV_COMMIT" || "$PREV_COMMIT" == "unknown" ]] && [[ -n "$PREV_RUN" ]]; then + # Find the most recent commit at or before last_run timestamp + PREV_COMMIT=$(git log --until="$PREV_RUN" --format='%h' -1 2>/dev/null || true) +fi + # Find new accepted decisions (ID > decisions_included) NEW_DECISIONS="" for file in $(find "$RECORDS_DIR" -name 'DL-*.md' -type f 2>/dev/null); do diff --git a/tests/test_detect_changes.bats b/tests/test_detect_changes.bats index 47b0733..7e5c916 100644 --- a/tests/test_detect_changes.bats +++ b/tests/test_detect_changes.bats @@ -160,7 +160,7 @@ YAML assert_output --regexp "commit_range: [a-f0-9]+\.\." } -@test "detect-changes works with old state format (no commit_hash)" { +@test "detect-changes works with old state format (no commit_hash) for new decisions" { create_decision "DL-001" "accepted" touch decisions/SNAPSHOT.md touch decisions/OVERVIEW.md @@ -181,6 +181,40 @@ YAML assert_success assert_output --partial "mode: incremental" assert_output --partial "DL-002" - # No commit range since old state has no commit_hash - assert_line --partial "commit_range: " +} + +@test "detect-changes uses last_run timestamp fallback when no commit_hash" { + # Create initial state with a backdated commit + create_decision "DL-001" "accepted" + create_decision "DL-002" "accepted" + touch decisions/SNAPSHOT.md + touch decisions/OVERVIEW.md + git add -A + GIT_AUTHOR_DATE="2026-01-10T10:00:00Z" GIT_COMMITTER_DATE="2026-01-10T10:00:00Z" \ + git commit -m "initial snapshot" --quiet + + # Write old-format state with last_run after the initial commit + cat > decisions/.dld-state.yaml <<'YAML' +snapshot: + last_run: 2026-01-10T12:00:00Z + decisions_included: 2 + artifacts: + SNAPSHOT.md: 2026-01-10T12:00:00Z + OVERVIEW.md: 2026-01-10T12:00:00Z +YAML + git add -A + GIT_AUTHOR_DATE="2026-01-10T12:00:00Z" GIT_COMMITTER_DATE="2026-01-10T12:00:00Z" \ + git commit -m "state" --quiet + + # Now modify DL-001 well after last_run + create_decision "DL-001" "superseded" + git add -A + GIT_AUTHOR_DATE="2026-01-11T10:00:00Z" GIT_COMMITTER_DATE="2026-01-11T10:00:00Z" \ + git commit -m "supersede DL-001" --quiet + + run bash "$SCRIPT" + assert_success + assert_output --partial "mode: incremental" + assert_output --partial "modified_decisions:" + assert_output --partial "DL-001" } From eccbc0f2435b84b17a3c29a96098cf9c348b981f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jimmy=20Utterstr=C3=B6m?= <jimutt@outlook.com> Date: Sun, 22 Mar 2026 13:47:21 +0100 Subject: [PATCH 3/3] Auto-commit uncommitted decision files before change detection Add a prerequisite check that commits any pending decision file changes before running detect-snapshot-changes.sh. This prevents missed modifications when /dld-implement was run without committing. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- .claude/skills/dld-snapshot/SKILL.md | 2 ++ skills/dld-snapshot/SKILL.md | 2 ++ 2 files changed, 4 insertions(+) diff --git a/.claude/skills/dld-snapshot/SKILL.md b/.claude/skills/dld-snapshot/SKILL.md index f9d4754..4ed5083 100644 --- a/.claude/skills/dld-snapshot/SKILL.md +++ b/.claude/skills/dld-snapshot/SKILL.md @@ -33,6 +33,8 @@ Check that `dld.config.yaml` exists at the repo root. If not, tell the user to r There must be at least one `accepted` decision. If all decisions are `proposed`, tell the user there's nothing to snapshot yet and suggest `/dld-implement`. +Check for uncommitted changes to decision files. Run `git status --porcelain -- <decisions_dir>/records/` and inspect the output. If there are unstaged or staged-but-uncommitted decision files, commit them first with a message like `"Update decision records"` before proceeding. This ensures the change detection script can rely on git history. Tell the user you are committing pending decision changes. + ## Step 1: Determine update mode If the user's message includes `--full` (e.g., `/dld-snapshot --full`), skip change detection and use **full mode** for all documents. diff --git a/skills/dld-snapshot/SKILL.md b/skills/dld-snapshot/SKILL.md index e6290e0..0c1e5bc 100644 --- a/skills/dld-snapshot/SKILL.md +++ b/skills/dld-snapshot/SKILL.md @@ -33,6 +33,8 @@ Check that `dld.config.yaml` exists at the repo root. If not, tell the user to r There must be at least one `accepted` decision. If all decisions are `proposed`, tell the user there's nothing to snapshot yet and suggest `/dld-implement`. +Check for uncommitted changes to decision files. Run `git status --porcelain -- <decisions_dir>/records/` and inspect the output. If there are unstaged or staged-but-uncommitted decision files, commit them first with a message like `"Update decision records"` before proceeding. This ensures the change detection script can rely on git history. Tell the user you are committing pending decision changes. + ## Step 1: Determine update mode If the user's message includes `--full` (e.g., `/dld-snapshot --full`), skip change detection and use **full mode** for all documents.