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) <noreply@anthropic.com>
This commit is contained in:
152
scripts/unreflected-logs
Executable file
152
scripts/unreflected-logs
Executable file
@@ -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
|
||||
101
specs/unreflected-logs.spec.md
Normal file
101
specs/unreflected-logs.spec.md
Normal file
@@ -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/<filename>`) 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 <dir>" 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
|
||||
```
|
||||
109
tests/test-unreflected-logs.sh
Executable file
109
tests/test-unreflected-logs.sh
Executable file
@@ -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 ]]
|
||||
Reference in New Issue
Block a user