diff --git a/.claude/skills/dld-snapshot/SKILL.md b/.claude/skills/dld-snapshot/SKILL.md index c890abc..4ed5083 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,28 @@ 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 +Check for uncommitted changes to decision files. Run `git status --porcelain -- /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. + +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 +62,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 +130,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 +233,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 +243,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 +254,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 +276,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..a87613d --- /dev/null +++ b/.claude/skills/dld-snapshot/scripts/detect-snapshot-changes.sh @@ -0,0 +1,107 @@ +#!/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) +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 + echo "mode: full" + 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 + 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..0c1e5bc 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,28 @@ 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 +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. + +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 +62,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 +130,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 +233,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 +243,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 +254,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 +276,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..a87613d --- /dev/null +++ b/skills/dld-snapshot/scripts/detect-snapshot-changes.sh @@ -0,0 +1,107 @@ +#!/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) +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 + echo "mode: full" + 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 + 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..7e5c916 --- /dev/null +++ b/tests/test_detect_changes.bats @@ -0,0 +1,220 @@ +#!/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) for new decisions" { + 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" +} + +@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" +} 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"