From 57b4c0a2bbb85b8703680a8df71e54b65fb9322b Mon Sep 17 00:00:00 2001 From: Paul O'Reilly Date: Tue, 17 Mar 2026 11:14:34 +1300 Subject: [PATCH] Add unreflected-logs: find session logs pending reflection Scans all projects for session logs not yet processed by /reflect-logs, using .reflection-state.json to track reflected status. Co-Authored-By: Claude Opus 4.6 (1M context) --- scripts/unreflected-logs | 152 +++++++++++++++++++++++++++++++++ specs/unreflected-logs.spec.md | 101 ++++++++++++++++++++++ tests/test-unreflected-logs.sh | 109 +++++++++++++++++++++++ 3 files changed, 362 insertions(+) create mode 100755 scripts/unreflected-logs create mode 100644 specs/unreflected-logs.spec.md create mode 100755 tests/test-unreflected-logs.sh diff --git a/scripts/unreflected-logs b/scripts/unreflected-logs new file mode 100755 index 0000000..08cf83a --- /dev/null +++ b/scripts/unreflected-logs @@ -0,0 +1,152 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Colours +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +BOLD='\033[1m' +RESET='\033[0m' + +usage() { + cat <<'USAGE' +Usage: unreflected-logs [OPTIONS] [DIRECTORY] + +Scan project directories for session logs not yet processed by /reflect-logs. + +Arguments: + DIRECTORY Root directory to scan (default: current directory) + +Options: + -n, --dryrun List discovered projects without checking log status + -h, --help Show this help message +USAGE +} + +DRYRUN=false +DIRECTORY="." + +while [[ $# -gt 0 ]]; do + case "$1" in + -n|--dryrun) DRYRUN=true; shift ;; + -h|--help) usage; exit 0 ;; + -*) echo "Unknown option: $1" >&2; usage >&2; exit 2 ;; + *) DIRECTORY="$1"; shift ;; + esac +done + +DIRECTORY="$(cd "$DIRECTORY" && pwd)" + +# Discovery: find directories containing memory/log/ +projects=() +while IFS= read -r logdir; do + # logdir is /path/to/project/memory/log + # project root is two levels up + project_root="$(dirname "$(dirname "$logdir")")" + projects+=("$project_root") +done < <(find "$DIRECTORY" -type d \( -name node_modules -o -path "*/memory/log/*/memory" \) -prune -o -type d -name log -path "*/memory/log" -print 2>/dev/null | sort) + +if [[ ${#projects[@]} -eq 0 ]]; then + echo "No projects with session logs found in $DIRECTORY" + exit 0 +fi + +echo "Scanned ${#projects[@]} projects in $DIRECTORY" +echo + +# Dryrun: just list projects +if [[ "$DRYRUN" == true ]]; then + echo "[dryrun] Would check ${#projects[@]} projects:" + for project in "${projects[@]}"; do + rel="${project#"$DIRECTORY"/}" + [[ "$rel" == "$project" ]] && rel="." + echo " $rel" + done + exit 0 +fi + +# Check phase +total_unreflected=0 +projects_with_unreflected=0 +projects_all_reflected=0 + +declare -A project_results # project_path -> output string + +for project in "${projects[@]}"; do + rel="${project#"$DIRECTORY"/}" + [[ "$rel" == "$project" ]] && rel="." + + # List log files + log_files=() + while IFS= read -r f; do + log_files+=("$(basename "$f")") + done < <(find "$project/memory/log" -maxdepth 1 -name "*.md" -type f 2>/dev/null | sort) + + # Skip if no logs + [[ ${#log_files[@]} -eq 0 ]] && continue + + # Read reflection state + state_file="$project/.reflection-state.json" + processed_keys=() + if [[ -f "$state_file" ]]; then + # Extract keys from the "processed" object using python for reliable JSON parsing + if processed_output=$(python3 -c " +import json, sys +try: + with open(sys.argv[1]) as f: + data = json.load(f) + for key in data.get('processed', {}): + print(key) +except (json.JSONDecodeError, KeyError): + sys.exit(1) +" "$state_file" 2>/dev/null); then + while IFS= read -r key; do + processed_keys+=("$key") + done <<< "$processed_output" + else + echo " Warning: malformed .reflection-state.json in $rel" >&2 + fi + fi + + # Compare: find unreflected logs + unreflected=() + for log_file in "${log_files[@]}"; do + log_key="log/$log_file" + found=false + for key in "${processed_keys[@]}"; do + if [[ "$key" == "$log_key" ]]; then + found=true + break + fi + done + if [[ "$found" == false ]]; then + unreflected+=("$log_file") + fi + done + + if [[ ${#unreflected[@]} -gt 0 ]]; then + echo -e "── ${BOLD}${rel}${RESET} ──" + echo -e " ${YELLOW}${#unreflected[@]} unreflected${RESET} / ${#log_files[@]} total logs" + for u in "${unreflected[@]}"; do + echo " - $u" + done + echo + total_unreflected=$((total_unreflected + ${#unreflected[@]})) + projects_with_unreflected=$((projects_with_unreflected + 1)) + else + projects_all_reflected=$((projects_all_reflected + 1)) + fi +done + +# Summary +if [[ $projects_all_reflected -gt 0 ]]; then + echo -e "${GREEN}✓${RESET} $projects_all_reflected projects have all logs reflected." +fi + +if [[ $total_unreflected -eq 0 ]]; then + echo -e "\n${GREEN}✓${RESET} All logs reflected across all projects." + exit 0 +else + echo -e "\nTotal: ${YELLOW}${total_unreflected} unreflected logs${RESET} across ${projects_with_unreflected} projects" + exit 1 +fi diff --git a/specs/unreflected-logs.spec.md b/specs/unreflected-logs.spec.md new file mode 100644 index 0000000..a94ca9b --- /dev/null +++ b/specs/unreflected-logs.spec.md @@ -0,0 +1,101 @@ +# unreflected-logs + +## Purpose + +Scan project directories for session logs that have not yet been processed by `/reflect-logs`, showing a per-project summary. + +## Usage + +``` +unreflected-logs [OPTIONS] [DIRECTORY] +``` + +### Arguments + +| Argument | Default | Description | +|----------|---------|-------------| +| `DIRECTORY` | `.` (current directory) | Root directory to scan | + +### Flags + +| Flag | Short | Description | +|------|-------|-------------| +| `--dryrun` | `-n` | List discovered projects without checking log status | +| `--help` | `-h` | Show usage information | + +## Behaviour + +1. **Discovery phase:** Walk `DIRECTORY` recursively, looking for directories that contain a `memory/log/` subdirectory. Each such directory is a "project". Stop descending into `memory/` and `node_modules/` directories. + +2. **Check phase:** For each discovered project: + a. List all `*.md` files in `memory/log/`. + b. Read `.reflection-state.json` from the project root (sibling to `memory/`). If it doesn't exist, all logs are unreflected. + c. A log file is "reflected" if its path (relative, as `log/`) appears as a key in the `processed` object of the reflection state file. + d. Count reflected and unreflected logs. + +3. **Report phase:** Print a summary: + - **Projects with unreflected logs** get a section showing: + - Project path (relative to `DIRECTORY`) + - Count: `N unreflected / M total logs` + - List of unreflected log filenames + - **Projects with all logs reflected** are listed in a summary line (count only). + - **Total** line at the end: `N unreflected logs across M projects` + +4. **Exit code:** + - `0` — no unreflected logs found anywhere + - `1` — at least one unreflected log exists + +## Dryrun Behaviour + +When `--dryrun` is passed: +- Perform the discovery phase only +- Print each discovered project path (relative to `DIRECTORY`), one per line +- Prefix output with `[dryrun] Would check N projects:` +- Exit code is always `0` + +## Edge Cases + +| Scenario | Handling | +|----------|----------| +| No projects with `memory/log/` found | Print "No projects with session logs found in " and exit 0 | +| `memory/log/` exists but is empty | Skip project (no logs to report) | +| `.reflection-state.json` missing | Treat all logs as unreflected | +| `.reflection-state.json` malformed | Warn to stderr, treat all logs as unreflected | +| `processed` key missing from state | Treat all logs as unreflected | +| Permission denied on subdirectory | Skip with warning to stderr, continue scanning | + +## Examples + +### All reflected +``` +Scanned 4 projects in ~/dev/claude + +✓ All logs reflected across 4 projects. +``` + +### Some unreflected +``` +Scanned 4 projects in ~/dev/claude + +── projects/claude-foundations ── + 1 unreflected / 4 total logs + - 2026-03-15.225345.md + +── small-scripts ── + 2 unreflected / 3 total logs + - 2026-03-14.091200.md + - 2026-03-15.142300.md + +✓ 2 projects have all logs reflected. + +Total: 3 unreflected logs across 2 projects +``` + +### Dryrun +``` +[dryrun] Would check 4 projects: + ~/dev/claude/projects/claude-foundations + ~/dev/claude/projects/cluster-bootstrap + ~/dev/claude/small-scripts + ~/dev/claude/octopus/customer-issue-sync +``` diff --git a/tests/test-unreflected-logs.sh b/tests/test-unreflected-logs.sh new file mode 100755 index 0000000..3d8e945 --- /dev/null +++ b/tests/test-unreflected-logs.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +SCRIPT="$SCRIPT_DIR/../scripts/unreflected-logs" + +RED='\033[0;31m' +GREEN='\033[0;32m' +RESET='\033[0m' + +PASS=0 +FAIL=0 + +assert_exit() { + local desc="$1" expected="$2" + shift 2 + local actual + set +e + "$@" >/dev/null 2>&1 + actual=$? + set -e + if [[ "$actual" -eq "$expected" ]]; then + echo -e "${GREEN}PASS${RESET}: $desc" + PASS=$((PASS + 1)) + else + echo -e "${RED}FAIL${RESET}: $desc (expected exit $expected, got $actual)" + FAIL=$((FAIL + 1)) + fi +} + +assert_contains() { + local desc="$1" pattern="$2" + shift 2 + local output + set +e + output=$("$@" 2>&1) + set -e + if echo "$output" | grep -qF "$pattern"; then + echo -e "${GREEN}PASS${RESET}: $desc" + PASS=$((PASS + 1)) + else + echo -e "${RED}FAIL${RESET}: $desc (output missing: '$pattern')" + echo " Got: $output" + FAIL=$((FAIL + 1)) + fi +} + +# Setup temp directory structure +TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TMPDIR"' EXIT + +# Project with all logs reflected +mkdir -p "$TMPDIR/proj-a/memory/log" +echo "# log 1" > "$TMPDIR/proj-a/memory/log/2026-03-12.100000.md" +cat > "$TMPDIR/proj-a/.reflection-state.json" <<'JSON' +{"version": 1, "processed": {"log/2026-03-12.100000.md": "abc123"}} +JSON + +# Project with unreflected logs +mkdir -p "$TMPDIR/proj-b/memory/log" +echo "# log 1" > "$TMPDIR/proj-b/memory/log/2026-03-12.100000.md" +echo "# log 2" > "$TMPDIR/proj-b/memory/log/2026-03-13.100000.md" +cat > "$TMPDIR/proj-b/.reflection-state.json" <<'JSON' +{"version": 1, "processed": {"log/2026-03-12.100000.md": "abc123"}} +JSON + +# Project with no reflection state file +mkdir -p "$TMPDIR/proj-c/memory/log" +echo "# log 1" > "$TMPDIR/proj-c/memory/log/2026-03-14.100000.md" + +# Project with empty log dir +mkdir -p "$TMPDIR/proj-d/memory/log" + +# --- Tests --- + +echo "=== unreflected-logs tests ===" +echo + +# Help flag +assert_exit "--help exits 0" 0 "$SCRIPT" --help +assert_contains "--help shows usage" "Usage:" "$SCRIPT" --help + +# Dryrun +assert_contains "--dryrun lists projects" "[dryrun]" "$SCRIPT" --dryrun "$TMPDIR" +assert_exit "--dryrun always exits 0" 0 "$SCRIPT" --dryrun "$TMPDIR" + +# Detects unreflected logs +assert_exit "exits 1 when unreflected logs exist" 1 "$SCRIPT" "$TMPDIR" +assert_contains "shows unreflected log filename" "2026-03-13.100000.md" "$SCRIPT" "$TMPDIR" + +# Shows project with no state file as unreflected +assert_contains "missing state file = unreflected" "2026-03-14.100000.md" "$SCRIPT" "$TMPDIR" + +# Shows reflected project count +assert_contains "shows reflected project count" "1 projects have all logs reflected" "$SCRIPT" "$TMPDIR" + +# No projects found +EMPTY="$(mktemp -d)" +assert_contains "no projects message" "No projects with session logs" "$SCRIPT" "$EMPTY" +assert_exit "no projects exits 0" 0 "$SCRIPT" "$EMPTY" +rmdir "$EMPTY" + +# All reflected (only proj-a) +assert_exit "all reflected exits 0" 0 "$SCRIPT" "$TMPDIR/proj-a" + +echo +echo "---" +echo -e "Results: ${GREEN}$PASS passed${RESET}, ${RED}$FAIL failed${RESET}" +[[ $FAIL -eq 0 ]]