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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/launchd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ jobs:
rc=0
for t in tests/portable.sh tests/scheduler-label.sh tests/autodream-now.sh \
tests/install-dry-run.sh tests/install-dir.sh tests/install-path.sh \
tests/install-review-agent.sh; do
tests/install-review-agent.sh tests/install-skill.sh; do
echo "== $t"
bash "$t" || rc=1
done
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ jobs:
tests/slim-transcript.sh tests/apply-pins.sh \
tests/session-window.sh tests/dream-triage.sh tests/chunk-transcript.sh tests/merge-chunks.sh \
tests/cookie-cadence.sh tests/review-skip.sh tests/review-cmux.sh tests/notes-path.sh \
tests/tmp-cleanup.sh tests/x-bookmarks.sh tests/prune-worktrees.sh tests/triage-candidates.sh; do
tests/tmp-cleanup.sh tests/x-bookmarks.sh tests/prune-worktrees.sh tests/triage-candidates.sh tests/pending-triage.sh; do
echo "== $t"
bash "$t" || rc=1
done
Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@

All notable changes to cc-autodream. Format loosely follows Keep a Changelog.

## 2026-10-18

### Added
- **A global `/autodream` skill.** `install.sh` links `skills/autodream` into `~/.claude/skills/`; `--no-skill` skips it. A custom install dir links it only when `AUTODREAM_SKILLS_DIR` is set, because the skill looks for the scripts in `~/.claude/autodream`. The skill logs decisions under `## Triage in progress` and writes `## Triage decisions` only after the last question, so an interrupted session does not hide the rest. The skill prints `now triaging N issues from dates A to B` or `no issues to address!`, starts a dream through `autodream-now.sh`, recommends a yolo session, and walks the open questions of every report that has no `## Triage decisions` heading, oldest first. The count comes from the new `bin/pending-triage.sh` (`--summary`, `--list`), which looks back `AUTODREAM_PENDING_DAYS` (14) days. `review.sh` and `pending-triage.sh` now read the open-questions marker and the triaged heading through the new `bin/report-format.sh`. `tests/pending-triage.sh` has 14 assertions.

### Changed
- **`AUTODREAM_MIN_USER_TURNS` and `AUTODREAM_MIN_MINUTES` are checked at startup.** A non-number used to make the noise gate's `jq` fail with its error discarded, so every session went to a model call and nothing said why. The run now refuses to start, like the timeout knobs.
- `tmo` and `repo_slug` moved into `bin/portable.sh`. They were byte-identical copies in `prune-worktrees.sh` and `triage-candidates.sh`, and `tests/portable.sh` now pins them.

## 2026-10-14

### Added
Expand Down
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,8 +137,23 @@ AUTODREAM_FORCE=1 ~/.claude/autodream/run.sh 2026-05-29 # rebuild a date
~/.claude/autodream/review.sh # solve the latest report's questions
~/.claude/autodream/review.sh 2026-05-29 # triage a specific report
~/.claude/autodream/review.sh --force 2026-05-29 # open it even if there's nothing to triage
~/.claude/autodream/pending-triage.sh # how many questions are still unanswered, and from which dates
```

### `/autodream` in any Claude Code session

`install.sh` links `skills/autodream` into `~/.claude/skills/`, so `/autodream` works in every project. It does three things:

1. Prints what is waiting: `now triaging 5 issues from dates 2026-10-12 to 2026-10-17`, or `no issues to address!`.
2. Starts yesterday's dream in the background through `autodream-now.sh`.
3. Walks the open questions of every report that has no `## Triage decisions` heading, oldest first. A session logs under `## Triage in progress` and renames the heading after the last question, so a session that stops half way leaves the rest in the queue.

Variants: `/autodream today` runs today so far, and `/autodream force` rebuilds yesterday.

Triage runs the fixes you approve, so permission prompts interrupt it. The skill recommends a `claude --dangerously-skip-permissions` session to avoid them.

Only the last `AUTODREAM_PENDING_DAYS` days (default 14) count, so a report nobody triaged stops nagging after two weeks. To skip the link, pass `install.sh --no-skill`. An install into a directory other than `~/.claude` does not link the skill, because the skill looks for the scripts in `~/.claude/autodream`; set `AUTODREAM_SKILLS_DIR` to link it anyway, and export `AUTODREAM_DIR` (and `DREAMS_DIR`, if the reports are not in its sibling `dreams/`) in the shell that runs Claude Code.

### Which sessions belong to a day

A session belongs to the day its records say, not the day its file was last written.
Expand Down
101 changes: 101 additions & 0 deletions bin/pending-triage.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
#!/bin/bash
# The open questions earlier dreams raised that nobody has answered yet.
#
# Usage: pending-triage.sh --summary one line: "now triaging N issues from dates A to B",
# or "no issues to address!"
# pending-triage.sh --list one line per report, oldest first:
# DATE<TAB>OPEN_QUESTIONS<TAB>REPORT_PATH
# (OPEN_QUESTIONS is ? when the report does not say)
#
# A report is pending when its open-questions marker says more than zero and it has no
# "## Triage decisions" heading. That heading means the report is finished, so a session
# that is part way through one logs under "## Triage in progress" and renames the heading
# after the last question. A report whose count cannot be read ("unknown": no marker, a
# shape review.sh does not recognise) is listed with a "?" count and reported on its own,
# because review.sh opens a session for it too; calling it an empty queue would hide it.
# Only the last AUTODREAM_PENDING_DAYS days are looked at (default
# 14, and never a future date), so a report nobody ever triaged does not nag forever.
# Always exits 0; a missing reports directory is an empty queue.

set -u

_here="$(dirname "${BASH_SOURCE[0]}")"
# Installed copies are symlinks: a merge updates them before install.sh links a new helper.
_lib() { # $1=file name -> path of the sibling, following the symlink if needed
[ -r "$_here/$1" ] && { printf '%s' "$_here/$1"; return; }
printf '%s' "$(dirname "$(readlink "${BASH_SOURCE[0]}" 2>/dev/null || echo "${BASH_SOURCE[0]}")")/$1"
}
# shellcheck source=/dev/null
. "$(_lib portable.sh)"
# shellcheck source=/dev/null
. "$(_lib lib-install-dir.sh)"
# shellcheck source=/dev/null
. "$(_lib report-format.sh)"
resolve_install_dir "${BASH_SOURCE[0]}" pending-triage.sh

# The config is user-edited. A bare source under set -u kills this shell on one misspelled
# variable, before the summary prints, so source it with nounset off. The environment wins.
__env_dreams="${DREAMS_DIR:-}"; __env_days="${AUTODREAM_PENDING_DAYS:-}"
CONFIG_FILE="${AUTODREAM_CONFIG:-$AUTODREAM_DIR/config}"
set +u
# shellcheck disable=SC1090
[ -f "$CONFIG_FILE" ] && . "$CONFIG_FILE"
set -u
[ -n "$__env_dreams" ] && DREAMS_DIR="$__env_dreams"
[ -n "$__env_days" ] && AUTODREAM_PENDING_DAYS="$__env_days"
DREAMS_DIR="${DREAMS_DIR:-$(dirname "$AUTODREAM_DIR")/dreams}"

DAYS="${AUTODREAM_PENDING_DAYS:-14}"
case "$DAYS" in ''|*[!0-9]*) echo "pending-triage.sh: AUTODREAM_PENDING_DAYS must be a whole number (got '$DAYS')" >&2; exit 2 ;; esac

MODE=summary
case "${1:-}" in
''|--summary) MODE=summary ;;
--list) MODE=list ;;
-h|--help) awk 'NR==1{next} /^#/{sub(/^# ?/,""); print; next} {exit}' "$0"; exit 0 ;;
*) echo "pending-triage.sh: unknown option: $1" >&2; exit 2 ;;
esac

TODAY=$(date +%Y-%m-%d)
CUTOFF=$(pdate_shift "$TODAY" "-$DAYS" d)

# DATE<TAB>COUNT<TAB>PATH for every pending report in the window, oldest first.
pending() {
local f d n
for f in "$DREAMS_DIR"/[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9].md; do
[ -f "$f" ] || continue
d=$(basename "$f" .md)
[[ "$d" < "$CUTOFF" || "$d" > "$TODAY" ]] && continue
report_is_triaged "$f" && continue
n=$(report_open_questions "$f")
case "$n" in
''|*[!0-9]*) printf '%s\t?\t%s\n' "$d" "$f"; continue ;;
esac
[ "$((10#$n))" -gt 0 ] || continue
# A half-triaged report keeps its marker, so subtract what is already logged. One question
# always stays: the heading is renamed only when the last one is resolved.
n=$((10#$n - $(report_progress_count "$f")))
[ "$n" -ge 1 ] || n=1
printf '%s\t%s\t%s\n' "$d" "$n" "$f"
done
}

ROWS=$(pending)

if [ "$MODE" = list ]; then
[ -n "$ROWS" ] && printf '%s\n' "$ROWS"
exit 0
fi

if [ -z "$ROWS" ]; then echo "no issues to address!"; exit 0; fi

TOTAL=$(printf '%s\n' "$ROWS" | awk -F'\t' '$2 ~ /^[0-9]+$/ {s += $2} END {print s + 0}')
UNKNOWN=$(printf '%s\n' "$ROWS" | awk -F'\t' '$2 == "?"' | wc -l | tr -d ' ')
FIRST=$(printf '%s\n' "$ROWS" | head -1 | cut -f1)
LAST=$(printf '%s\n' "$ROWS" | tail -1 | cut -f1)
if [ "$FIRST" = "$LAST" ]; then SPAN="from $FIRST"; else SPAN="from dates $FIRST to $LAST"; fi
NOUN=issues; [ "$TOTAL" -eq 1 ] && NOUN=issue
RNOUN=reports; [ "$UNKNOWN" -eq 1 ] && RNOUN=report
if [ "$UNKNOWN" -eq 0 ]; then echo "now triaging $TOTAL $NOUN $SPAN"
elif [ "$TOTAL" -eq 0 ]; then echo "now triaging $UNKNOWN $RNOUN with an unreadable question count $SPAN"
else echo "now triaging $TOTAL $NOUN $SPAN, plus $UNKNOWN $RNOUN with an unreadable question count"; fi
22 changes: 22 additions & 0 deletions bin/portable.sh
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,25 @@ pstat_mtime() {
pstat_mode() {
if [ "$_P_GNU_STAT" = 1 ]; then stat -c %a "$1" 2>/dev/null; else stat -f %Lp "$1" 2>/dev/null; fi
}

# $1=seconds, rest=command -> runs it bounded: timeout, gtimeout, or a watchdog that kills the child
tmo() {
local s=$1 pid wd rc; shift
if command -v timeout >/dev/null 2>&1; then timeout "$s" "$@"; return $?
elif command -v gtimeout >/dev/null 2>&1; then gtimeout "$s" "$@"; return $?; fi
"$@" & pid=$!
( sleep "$s"; pkill -P "$pid" 2>/dev/null; kill "$pid" 2>/dev/null ) >/dev/null 2>&1 & wd=$!
wait "$pid"; rc=$?
kill "$wd" 2>/dev/null
wait "$wd" 2>/dev/null
return $rc
}

# $1=repo dir -> owner/repo from a github.com origin URL, empty otherwise
repo_slug() {
local url
url=$(git -C "$1" config --get remote.origin.url 2>/dev/null) || return 0
case "$url" in
*github.com[:/]*) url=${url#*github.com[:/]}; url=${url%.git}; printf '%s' "$url" ;;
esac
}
20 changes: 0 additions & 20 deletions bin/prune-worktrees.sh
Original file line number Diff line number Diff line change
Expand Up @@ -132,18 +132,6 @@ CUTOFF=$(TZ=UTC pdate_fmt_epoch $((NOW - CLOSED_DAYS * 86400)) +%Y-%m-%dT%H:%M:%
PROTECT_DEFAULT="main:master:develop:dev:development:trunk:staging:stage:production:prod:next:canary:gh-pages:release:release-*:release/*:v[0-9]*"
IFS=: read -r -a PROTECT <<< "$PROTECT_DEFAULT:${AUTODREAM_PRUNE_PROTECT:-}"

tmo() { # tmo SECONDS cmd... - always bounded: timeout, gtimeout, or a watchdog that kills the child
local s=$1 pid wd rc; shift
if command -v timeout >/dev/null 2>&1; then timeout "$s" "$@"; return $?
elif command -v gtimeout >/dev/null 2>&1; then gtimeout "$s" "$@"; return $?; fi
"$@" & pid=$!
( sleep "$s"; pkill -P "$pid" 2>/dev/null; kill "$pid" 2>/dev/null ) >/dev/null 2>&1 & wd=$!
wait "$pid"; rc=$?
kill "$wd" 2>/dev/null
wait "$wd" 2>/dev/null
return $rc
}

budget_ok() { [ "$SECONDS" -lt "$BUDGET" ]; }

# ledger EVENT REPO SLUG BRANCH TIP REMOTE_TIP WORKTREE DETAIL RECOVER - one append, a failure returns 1.
Expand Down Expand Up @@ -229,14 +217,6 @@ if [ "${AUTODREAM_PRUNE_TRASH:-1}" = 1 ] && command -v trash >/dev/null 2>&1; th

# ---- per-repo facts ------------------------------------------------------------------------

repo_slug() { # owner/repo from a github.com origin URL, empty otherwise
local url
url=$(git -C "$1" config --get remote.origin.url 2>/dev/null) || return 0
case "$url" in
*github.com[:/]*) url=${url#*github.com[:/]}; url=${url%.git}; printf '%s' "$url" ;;
esac
}

default_facts() { # sets DEF_NAME DEF_REF DEF_TIP for repo $1; DEF_NAME is empty when unknown
local repo=$1 b
DEF_NAME=$(git -C "$repo" symbolic-ref -q --short refs/remotes/origin/HEAD 2>/dev/null) || DEF_NAME=""
Expand Down
40 changes: 40 additions & 0 deletions bin/report-format.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
#!/bin/bash
# Sourced, never executed. How a finished autodream report says what is still open.
# review.sh and pending-triage.sh both read these two facts, so they read them here.

# How many open questions the report says need a human call. Prints an integer,
# or "unknown" when it cannot tell (the caller must then launch the session).
#
# The marker is the contract: PROMPT.md makes L2 emit
# `<!-- autodream:open-questions=N -->` in the Open questions section. Reports
# written before that contract existed have no marker, so we fall back to
# reading the prose L2 has used consistently ("None that clear the triviality
# gate this run."). The fallback only ever recognises a shape it is sure about;
# everything else is "unknown" rather than a guess.
report_open_questions(){
local report="$1" marker section first

marker=$(sed -n 's/.*<!-- *autodream:open-questions=\([0-9][0-9]*\) *-->.*/\1/p' "$report" | head -1)
if [ -n "$marker" ]; then printf '%s\n' "$marker"; return 0; fi

# Empty-day stub: run.sh writes this when no sessions were modified at all.
if grep -q '^No Claude Code sessions were modified' "$report"; then echo 0; return 0; fi

section=$(awk '/^## Open questions/{f=1;next} /^## /{f=0} f' "$report")
first=$(printf '%s\n' "$section" | grep -v '^[[:space:]]*$' | head -1)
# No section at all, or an empty one: not a shape we recognise. Launch.
[ -n "$first" ] || { echo unknown; return 0; }

case "$(printf '%s' "$first" | tr '[:upper:]' '[:lower:]')" in
none*) echo 0; return 0 ;;
esac
echo unknown
}

report_is_triaged(){ grep -q '^## Triage decisions' "$1"; }

# How many decisions a session has logged so far under "## Triage in progress". Bullets or
# numbered lines, as review.sh counts "## Triage decisions".
report_progress_count(){
awk '/^## Triage in progress/{f=1;next} /^## /{f=0} f' "$1" | grep -cE '^([-*]|[0-9]+\.) ' || true
}
36 changes: 5 additions & 31 deletions bin/review.sh
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@ _src="${BASH_SOURCE[0]}"; _lib="$(dirname "$_src")/lib-install-dir.sh"
# shellcheck source=/dev/null
. "$_lib"
resolve_install_dir "$_src" review.sh
# The open-questions marker and the triaged heading are read by pending-triage.sh too.
_rf="$(dirname "$_src")/report-format.sh"
[ -r "$_rf" ] || _rf="$(dirname "$(readlink "$_src" 2>/dev/null || echo "$_src")")/report-format.sh"
# shellcheck source=/dev/null
. "$_rf"

# Load the config file first, then let any env-provided values win over it.
__env_dreams="${DREAMS_DIR:-}"; __env_claude="${CLAUDE_BIN:-}"
Expand Down Expand Up @@ -107,37 +112,6 @@ fi

DATE=$(basename "$REPORT" .md)

# How many open questions the report says need a human call. Prints an integer,
# or "unknown" when it cannot tell (the caller must then launch the session).
#
# The marker is the contract: PROMPT.md makes L2 emit
# `<!-- autodream:open-questions=N -->` in the Open questions section. Reports
# written before that contract existed have no marker, so we fall back to
# reading the prose L2 has used consistently ("None that clear the triviality
# gate this run."). The fallback only ever recognises a shape it is sure about;
# everything else is "unknown" rather than a guess.
report_open_questions(){
local report="$1" marker section first

marker=$(sed -n 's/.*<!-- *autodream:open-questions=\([0-9][0-9]*\) *-->.*/\1/p' "$report" | head -1)
if [ -n "$marker" ]; then printf '%s\n' "$marker"; return 0; fi

# Empty-day stub: run.sh writes this when no sessions were modified at all.
if grep -q '^No Claude Code sessions were modified' "$report"; then echo 0; return 0; fi

section=$(awk '/^## Open questions/{f=1;next} /^## /{f=0} f' "$report")
first=$(printf '%s\n' "$section" | grep -v '^[[:space:]]*$' | head -1)
# No section at all, or an empty one: not a shape we recognise. Launch.
[ -n "$first" ] || { echo unknown; return 0; }

case "$(printf '%s' "$first" | tr '[:upper:]' '[:lower:]')" in
none*) echo 0; return 0 ;;
esac
echo unknown
}

report_is_triaged(){ grep -q '^## Triage decisions' "$1"; }

# Decisions have been written as both bullets and numbered lists across the
# report history, so count either. Used only to flavour the notice text.
report_triage_count(){
Expand Down
Loading
Loading