From b8101c944c50d3307181cdacfb260de35bce6bb1 Mon Sep 17 00:00:00 2001 From: Samuel Reed Date: Sun, 18 Oct 2026 11:06:29 -0400 Subject: [PATCH 1/7] refactor: move tmo and repo_slug into portable.sh Both were byte-identical in prune-worktrees.sh and triage-candidates.sh. tests/portable.sh now pins them. --- bin/portable.sh | 22 ++++++++++++++++++++++ bin/prune-worktrees.sh | 20 -------------------- bin/triage-candidates.sh | 20 -------------------- tests/portable.sh | 16 ++++++++++++++++ 4 files changed, 38 insertions(+), 40 deletions(-) diff --git a/bin/portable.sh b/bin/portable.sh index cc31bd0..e314c40 100755 --- a/bin/portable.sh +++ b/bin/portable.sh @@ -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 +} diff --git a/bin/prune-worktrees.sh b/bin/prune-worktrees.sh index c2e618e..c0fa807 100755 --- a/bin/prune-worktrees.sh +++ b/bin/prune-worktrees.sh @@ -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. @@ -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="" diff --git a/bin/triage-candidates.sh b/bin/triage-candidates.sh index c6a2815..f300e14 100755 --- a/bin/triage-candidates.sh +++ b/bin/triage-candidates.sh @@ -78,18 +78,6 @@ DATE=$(basename "$OUT") _p_is_date "$DATE" || DATE=$(date +%Y-%m-%d) : > "$TSV" 2>/dev/null || { echo "triage: findings dir unusable"; exit 0; } -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 -} - # tsvrow STATUS SLUG REPO UNTRIAGED OPEN OLDEST DETAIL - a tab or newline in a field becomes a space. tsvrow() { local f out="" sep="" @@ -128,14 +116,6 @@ NOW=$(date +%s) DATE_EPOCH=$(pdate_epoch "$DATE") || DATE_EPOCH=$NOW SEEN_SLUGS="" -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 -} - # last_suggestion SLUG -> "DATECOUNT" of its most recent state row, empty when none last_suggestion() { [ -r "$STATE" ] || return 0 diff --git a/tests/portable.sh b/tests/portable.sh index a0b16f5..45ef6ba 100755 --- a/tests/portable.sh +++ b/tests/portable.sh @@ -60,5 +60,21 @@ chmod 640 "$t/f"; eq "$(pstat_mode "$t/f")" 640 "mode 640" refuses "a missing file has no mtime" pstat_mtime "$t/none" rm -rf "$t" +echo "# tmo and repo_slug" +tmo 5 true; eq "$?" 0 "tmo passes a zero status through" +tmo 5 sh -c 'exit 7'; eq "$?" 7 "tmo passes a nonzero status through" +start=$SECONDS; tmo 1 sleep 5 && slow_rc=0 || slow_rc=$? +[ "$slow_rc" -ne 0 ] && [ $((SECONDS - start)) -lt 4 ] && eq ok ok "tmo kills a command past its bound" || eq "rc=$slow_rc" ok "tmo kills a command past its bound" +g=$(mktemp -d "${TMPDIR:-/tmp}/portable.XXXXXX") +git init -q "$g" 2>/dev/null +eq "$(repo_slug "$g")" "" "no remote has no slug" +git -C "$g" config remote.origin.url https://github.com/STRML/cc-autodream.git +eq "$(repo_slug "$g")" STRML/cc-autodream "https origin" +git -C "$g" config remote.origin.url git@github.com:STRML/cc-autodream.git +eq "$(repo_slug "$g")" STRML/cc-autodream "ssh origin" +git -C "$g" config remote.origin.url https://gitlab.com/STRML/cc-autodream.git +eq "$(repo_slug "$g")" "" "a non-github origin has no slug" +rm -rf "$g" + printf '\npassed: %s failed: %s\n' "$pass" "$fail" [ "$fail" -eq 0 ] From fb611a181afcb153b3b096fe109f683ed105bb9c Mon Sep 17 00:00:00 2001 From: Samuel Reed Date: Sun, 18 Oct 2026 11:21:38 -0400 Subject: [PATCH 2/7] fix: refuse a non-numeric noise-gate floor and drop change-narration comments AUTODREAM_MIN_USER_TURNS and AUTODREAM_MIN_MINUTES went to jq --argjson with stderr discarded, so a non-number gated nothing and sent every session to a model. They are checked at startup now, with a test. Seven comments that narrated a past change now state the constraint. --- bin/run.sh | 42 ++++++++++++++++++++++-------------------- tests/run-all.sh | 32 ++++++++++++++++++++++---------- 2 files changed, 44 insertions(+), 30 deletions(-) diff --git a/bin/run.sh b/bin/run.sh index d978795..052b280 100755 --- a/bin/run.sh +++ b/bin/run.sh @@ -216,6 +216,13 @@ case "$AUTODREAM_L1_MAX_CHUNKS" in esac AUTODREAM_L1_MAX_CHUNKS=$((10#$AUTODREAM_L1_MAX_CHUNKS)) [ "$AUTODREAM_L1_MAX_CHUNKS" -ge 1 ] || { echo "FATAL: AUTODREAM_L1_MAX_CHUNKS must be at least 1" >&2; exit 1; } +# The noise gate hands these to jq --argjson with stderr discarded. A non-number makes jq +# fail, the gate comes back empty, and every session goes to a model call with no message. +for _knob in AUTODREAM_MIN_USER_TURNS AUTODREAM_MIN_MINUTES; do + case "${!_knob:-0}" in + ''|*[!0-9]*) echo "FATAL: $_knob must be a non-negative integer (got '${!_knob}')" >&2; exit 1 ;; + esac +done # Isolated cwd for every `claude --print` worker (see "AI-title stubs" below). The # workers all read/write by ABSOLUTE path, so their cwd is functionally irrelevant — @@ -932,10 +939,10 @@ enumerate_for() { # $1=adapter $2=root -> NUL-delimited paths # Gated on the adapter having been ACCEPTED, not merely on adapter.sh being # executable: an executable check alone would run a directory that failed # containment. - # ACCEPTED_ADAPTERS is assigned directly in scan_roots. This used to call - # adapters_list per root, and that walk does two realpaths plus a jq for every - # adapter directory — repeated work whose answer cannot change mid-run, since - # nothing writes to adapters/ while the scan is in flight. + # ACCEPTED_ADAPTERS is assigned directly in scan_roots, not rebuilt through + # adapters_list per root: that walk costs two realpaths and a jq for every adapter + # directory, and its answer cannot change mid-run because nothing writes to + # adapters/ while the scan is in flight. if declare -F adapter_run >/dev/null 2>&1 \ && printf '%s\n' ${ACCEPTED_ADAPTERS:-} | grep -qxF "$1" \ && [ -x "$(adapters_root 2>/dev/null)/$1/adapter.sh" ]; then @@ -2728,8 +2735,8 @@ dispatch_l1() { # one parallel pass; idempotent worker → only the still-missin rm -f "$slimfile" "$normfile" "$dayfile" # On the FINAL retry round, fall back to a metadata-only findings stub so # the session is visible to L1_ERRORED and the L2 aggregator instead of - # disappearing into a silent .err file (the old behavior, which the - # 2026-06-11 self-audit flagged: 12 .err with l1_findings_with_error=0). + # disappearing into a silent .err file (2026-06-11 self-audit: 12 .err + # files with l1_findings_with_error=0). # Earlier rounds leave $output absent so the next round can retry; only # the last round writes the stub. AUTODREAM_L1_ROUNDS comes through the # environment (exported below). @@ -3064,9 +3071,9 @@ run() { log "no sessions to triage; writing stub report and exiting" # A zero-session night is not always an empty night. Every session can be # rejected for an unrepresentable path or dropped by collision handling, and - # this path used to return before run-stats.txt was written — so the report - # said "no sessions were modified" while the counters that would have - # contradicted it were never recorded anywhere. Say what was refused. + # run-stats.txt is written on this path too. Without it the report said "no + # sessions were modified" while the counters that would have contradicted it + # were never recorded anywhere. Say what was refused. # HASH_COLLISIONS counts collision EVENTS and each drops at least two paths, # so adding it to a path total understates the loss. Report the two # separately rather than inventing a combined figure that is wrong. @@ -3083,9 +3090,9 @@ run() { printf 'runner_dirty: %s\n' "$RUNNER_DIRTY" printf 'sessions_found_raw: %s\n' "$RAW" printf 'sessions_triaged: 0\n' - # Already computed above and previously omitted here. Without them a night - # where every session was a worker transcript or an empty shell looks - # identical to a night with no files at all. + # Emitted on the zero-triage path too: without them a night where every + # session was a worker transcript or an empty shell looks identical to a + # night with no files at all. printf 'self_sessions_excluded: %s\n' "$EXCLUDED" printf 'sessions_skipped_empty: %s\n' "$SKIPPED_EMPTY" printf 'sessions_rejected_path: %s\n' "$REJECTED_PATHS" @@ -3998,15 +4005,10 @@ PY # so upgrading the account upgrades the nightly report and nothing here has to # be edited. # - # This used to carry a date cutoff that picked "claude-fable-5[1m]" before - # 2026-06-21 and "claude-opus-4-7" from that day on, because Fable 5 left the - # subscription then. The cutoff has been in the past for months, so the branch - # was unreachable and the pin was simply "opus-4-7 forever" — a model two - # generations behind by 2026-09, quietly chosen by a comment about billing. - # A pin is a decision that has to be re-made every time the roster moves, and - # nothing here was ever going to remind anyone. + # No model is pinned here: a pin is a decision to re-make every time the roster + # moves, and a date-gated branch hid that and left an unreachable default. # - # AUTODREAM_L2_MODEL still overrides, for pinning a specific model deliberately. + # AUTODREAM_L2_MODEL overrides, for pinning a specific model deliberately. # If you set it, pin an EXACT string: the CLI silently falls back on an # unrecognized --model rather than failing (verified 2026-06-09 on 2.1.170; # bare "claude-fable-5" was one of those, where "fable" and the [1m]-suffixed diff --git a/tests/run-all.sh b/tests/run-all.sh index e9bc79d..9cdc74b 100755 --- a/tests/run-all.sh +++ b/tests/run-all.sh @@ -1015,9 +1015,9 @@ test_self_audit_stats_failure_denominator(){ assert_file "$stats" "run-stats.txt written" # After the fix, even a stubbed final-round failure is counted: the stub # carries an "error" key so it lands in l1_findings_with_error, AND the - # vs.-raw denominator stays accurate. Old behavior reported zero across - # the board even though the session never produced real findings. - assert_grep "$stats" 'l1_findings_with_error: 1' "stats now surface the failed session via the error key" + # vs.-raw denominator stays accurate. Reporting zero would hide a session that + # never produced real findings. + assert_grep "$stats" 'l1_findings_with_error: 1' "stats surface the failed session via the error key" rm -rf "$root" } @@ -3808,13 +3808,11 @@ test_failing_enumerator_aborts_the_run(){ /bin/bash "$RUN" "$DATE" > "$root/run.out" 2>&1 local rc=$? cat "$root/autodream/logs/run-$DATE.log" >> "$root/run.out" 2>/dev/null || true - # This used to assert the run ABORTS. It no longer does, and the change was - # deliberate: on a single-root host — the default install — "enumerator exited - # nonzero and returned nothing" is also the shape of a quiet date plus a - # transient find error, so aborting cost a night whose honest answer was the - # empty-night stub. What replaced the abort is a refusal to LIE: the run - # completes, roots_failed counts it, and the stub says the store was not fully - # read rather than claiming no files were modified. + # The run completes rather than aborting: on a single-root host — the default + # install — "enumerator exited nonzero and returned nothing" is also the shape of + # a quiet date plus a transient find error, so aborting would cost a night whose + # honest answer is the empty-night stub. roots_failed counts it, and the stub + # says the store was not fully read rather than claiming no files were modified. assert_eq "$rc" "0" "the run completes rather than losing the night" assert_grep "$root/run.out" 'contributes NO sessions' "the log names the enumeration failure" assert_grep "$root/autodream/findings/$DATE/run-stats.txt" '^roots_failed: 1$' \ @@ -5971,6 +5969,19 @@ test_chunking_is_off_when_the_knob_is_unset(){ rm -rf "$root0" "$rootu" } +test_noise_gate_knobs_are_validated(){ + echo "# noise gate: a non-numeric floor refuses to start instead of gating nothing" + local root; root=$(setup_env); mk_session "$root" s1 + AUTODREAM_MIN_USER_TURNS=two run_dream "$root" + assert_eq "$(cat "$root/run.exit")" "1" "a non-numeric AUTODREAM_MIN_USER_TURNS refuses to start" + assert_grep "$root/run.out" 'AUTODREAM_MIN_USER_TURNS must be a non-negative integer' "and says why" + AUTODREAM_MIN_MINUTES=1m run_dream "$root" + assert_eq "$(cat "$root/run.exit")" "1" "and so does a non-numeric AUTODREAM_MIN_MINUTES" + AUTODREAM_MIN_USER_TURNS=0 AUTODREAM_MIN_MINUTES=0 run_dream "$root" + assert_eq "$(cat "$root/run.exit")" "0" "0 is valid for both" + rm -rf "$root" +} + test_chunk_knobs_are_validated(){ echo "# chunking: its knobs are validated at startup, in the house style" local root; root=$(setup_env); mk_session "$root" s1 @@ -6289,6 +6300,7 @@ test_chunk_cap_of_one_reads_one_chunk_never_the_whole_transcript test_chunking_off_is_the_old_behaviour test_chunking_is_off_when_the_knob_is_unset test_chunk_knobs_are_validated +test_noise_gate_knobs_are_validated test_chunking_degrades_when_the_helpers_are_absent test_a_failed_chunker_never_hands_a_worker_the_whole_slim test_a_failed_chunker_on_a_transcript_that_was_never_slimmed_is_still_bounded From 98494ba13315d93ee766e83a00cf844270abce32 Mon Sep 17 00:00:00 2001 From: Samuel Reed Date: Sun, 18 Oct 2026 11:38:41 -0400 Subject: [PATCH 3/7] feat: add a global /autodream skill that triages unanswered dreams install.sh links skills/autodream into the skills dir (--no-skill skips it, AUTODREAM_SKILLS_DIR moves it). pending-triage.sh prints 'now triaging N issues from dates A to B' or 'no issues to address!' from the reports that have open questions and no Triage decisions heading. review.sh and pending-triage.sh share the marker parsing through report-format.sh. --- .github/workflows/launchd.yml | 2 +- .github/workflows/tests.yml | 2 +- CHANGELOG.md | 9 ++++ README.md | 15 ++++++ bin/pending-triage.sh | 81 ++++++++++++++++++++++++++++++ bin/report-format.sh | 34 +++++++++++++ bin/review.sh | 36 ++------------ install.sh | 15 +++++- skills/autodream/SKILL.md | 61 +++++++++++++++++++++++ tests/install-skill.sh | 77 +++++++++++++++++++++++++++++ tests/pending-triage.sh | 92 +++++++++++++++++++++++++++++++++++ tests/run-all.sh | 4 +- 12 files changed, 392 insertions(+), 36 deletions(-) create mode 100755 bin/pending-triage.sh create mode 100755 bin/report-format.sh create mode 100644 skills/autodream/SKILL.md create mode 100755 tests/install-skill.sh create mode 100755 tests/pending-triage.sh diff --git a/.github/workflows/launchd.yml b/.github/workflows/launchd.yml index 9179b55..18c8186 100644 --- a/.github/workflows/launchd.yml +++ b/.github/workflows/launchd.yml @@ -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 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 2ef5cfa..95cc900 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b15def..704228c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 and `AUTODREAM_SKILLS_DIR` moves it. 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 diff --git a/README.md b/README.md index d620ef2..98445a5 100644 --- a/README.md +++ b/README.md @@ -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. + +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`. To link somewhere else, set `AUTODREAM_SKILLS_DIR` (default `~/.claude/skills`). + ### Which sessions belong to a day A session belongs to the day its records say, not the day its file was last written. diff --git a/bin/pending-triage.sh b/bin/pending-triage.sh new file mode 100755 index 0000000..d62977a --- /dev/null +++ b/bin/pending-triage.sh @@ -0,0 +1,81 @@ +#!/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: +# DATEOPEN_QUESTIONSREPORT_PATH +# +# A report is pending when its open-questions marker says more than zero and it has no +# "## Triage decisions" heading. A report whose count cannot be read ("unknown": no +# marker, a shape review.sh does not recognise) is not counted, the same way the band +# mod reads a missing marker as zero. Only the last AUTODREAM_PENDING_DAYS days are +# looked at (default 14), 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 + +__env_dreams="${DREAMS_DIR:-}" +CONFIG_FILE="${AUTODREAM_CONFIG:-$AUTODREAM_DIR/config}" +# shellcheck disable=SC1090 +[ -f "$CONFIG_FILE" ] && . "$CONFIG_FILE" +[ -n "$__env_dreams" ] && DREAMS_DIR="$__env_dreams" +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 + +CUTOFF=$(pdate_shift "$(date +%Y-%m-%d)" "-$DAYS" d) + +# DATECOUNTPATH 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" ]] && continue + report_is_triaged "$f" && continue + n=$(report_open_questions "$f") + case "$n" in ''|*[!0-9]*) continue ;; esac + [ "$n" -gt 0 ] || continue + 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' '{s += $2} END {print s}') +FIRST=$(printf '%s\n' "$ROWS" | head -1 | cut -f1) +LAST=$(printf '%s\n' "$ROWS" | tail -1 | cut -f1) +NOUN=issues; [ "$TOTAL" -eq 1 ] && NOUN=issue +if [ "$FIRST" = "$LAST" ]; then echo "now triaging $TOTAL $NOUN from $FIRST" +else echo "now triaging $TOTAL $NOUN from dates $FIRST to $LAST"; fi diff --git a/bin/report-format.sh b/bin/report-format.sh new file mode 100755 index 0000000..57ee31b --- /dev/null +++ b/bin/report-format.sh @@ -0,0 +1,34 @@ +#!/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 +# `` 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/.*.*/\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"; } diff --git a/bin/review.sh b/bin/review.sh index 0dca786..dac175f 100755 --- a/bin/review.sh +++ b/bin/review.sh @@ -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:-}" @@ -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 -# `` 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/.*.*/\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(){ diff --git a/install.sh b/install.sh index fb57b4f..e45ade6 100755 --- a/install.sh +++ b/install.sh @@ -10,6 +10,7 @@ # ./install.sh /path/to # symlink into /path/to/autodream/ instead # ./install.sh --no-schedule # symlink only; don't touch launchd # ./install.sh --no-review # schedule the nightly but not the review triage popup agent +# ./install.sh --no-skill # don't link the global /autodream skill into ~/.claude/skills # ./install.sh --adapters claude,omp # harnesses a run scans (names or `all`); written to config # ./install.sh --l2-engine omp # adapter whose engine runs L2; written to config # ./install.sh --dry-run # show every change, make none (no links, config, plist, launchctl) @@ -22,6 +23,7 @@ REPO_DIR="$(cd "$(dirname "$0")" && pwd)" # ----------------------------------------------------------------- arg parsing -- SCHEDULE=1 REVIEW=1 +SKILL=1 DRY=0 ADAPTERS_ARG="" L2_ENGINE_ARG="" @@ -31,12 +33,13 @@ while [ "$#" -gt 0 ]; do case "$a" in --no-schedule) SCHEDULE=0 ;; --no-review) REVIEW=0 ;; + --no-skill) SKILL=0 ;; --dry-run) DRY=1 ;; --adapters|--l2-engine) [ "$#" -gt 0 ] && [ -n "$1" ] || { echo "install: $a needs a value" >&2; exit 64; } case "$a" in --adapters) ADAPTERS_ARG="$1" ;; *) L2_ENGINE_ARG="$1" ;; esac shift ;; - -h|--help) sed -n '2,16p' "$0"; exit 0 ;; + -h|--help) sed -n '2,17p' "$0"; exit 0 ;; -*) echo "install: unknown flag '$a'" >&2; exit 64 ;; *) TARGET_PARENT="$a" ;; esac @@ -158,6 +161,8 @@ link "$REPO_DIR/bin/lib-project.sh" "$TARGET/lib-project.sh" link "$REPO_DIR/bin/lib-install-dir.sh" "$TARGET/lib-install-dir.sh" link "$REPO_DIR/bin/adapters.sh" "$TARGET/adapters.sh" link "$REPO_DIR/bin/preflight.sh" "$TARGET/preflight.sh" +link "$REPO_DIR/bin/report-format.sh" "$TARGET/report-format.sh" +link "$REPO_DIR/bin/pending-triage.sh" "$TARGET/pending-triage.sh" # The adapters TREE, not individual files: run.sh resolves adapters//adapter.sh # relative to the script dir, so the whole directory has to be reachable from the # install target. Without this the installed runner silently takes the legacy inline @@ -167,6 +172,14 @@ link "$REPO_DIR/prompts/PROMPT.md" "$TARGET/PROMPT.md" link "$REPO_DIR/prompts/SESSION_TRIAGE.md" "$TARGET/SESSION_TRIAGE.md" link "$REPO_DIR/prompts/TRIAGE_DREAM.md" "$TARGET/TRIAGE_DREAM.md" +# The global /autodream skill. A skill nobody asked for must not fail the install, so a +# directory already sitting at the link's place (the user's own /autodream) is reported and left. +if [ "$SKILL" = 1 ]; then + SKILLS_DIR="${AUTODREAM_SKILLS_DIR:-$TARGET_PARENT/skills}" + if [ "$DRY" = 1 ]; then dry "mkdir -p $SKILLS_DIR"; else mkdir -p "$SKILLS_DIR"; fi + link "$REPO_DIR/skills/autodream" "$SKILLS_DIR/autodream" || echo " WARNING: /autodream skill not linked (see above); re-run after moving $SKILLS_DIR/autodream" >&2 +fi + if [ "$DRY" = 1 ]; then dry "chmod +x bin/*.sh adapters/*/adapter.sh"; else chmod +x "$REPO_DIR/bin/"*.sh # The adapters too. _adapter_ok requires -x on adapter.sh, and the loader treats a diff --git a/skills/autodream/SKILL.md b/skills/autodream/SKILL.md new file mode 100644 index 0000000..b9f3042 --- /dev/null +++ b/skills/autodream/SKILL.md @@ -0,0 +1,61 @@ +--- +name: autodream +description: Run an autodream now and triage the open questions earlier dreams left unanswered. Use when the user types /autodream, asks to force a dream, or asks what autodream issues are still open. +--- + +# autodream + +Start a dream, then work through the open questions that earlier dreams raised and nobody answered. + +`AUTODREAM_DIR` is the install dir (default `~/.claude/autodream`). Every script below lives there. + +## Steps + +1. Print the state of the queue. The first line of your reply is the output of this command, verbatim: + + ```bash + "${AUTODREAM_DIR:-$HOME/.claude/autodream}/pending-triage.sh" --summary + ``` + + It prints either `now triaging N issues from dates A to B` or `no issues to address!`. + +2. Start a dream in the background, so the newest report is on its way while you triage. It returns at once because launchd owns the run. + + - `/autodream` runs yesterday's dream. A finished report makes this a no-op. + - `/autodream today` runs today so far and rebuilds it: `autodream-now.sh "$(date +%Y-%m-%d)" --force`. + - `/autodream force` rebuilds yesterday: `autodream-now.sh --force`. + + ```bash + "${AUTODREAM_DIR:-$HOME/.claude/autodream}/autodream-now.sh" + ``` + + Do not wait for it. Say in one line that a dream is running and where its report will land (`dreams/.md`). + +3. If step 1 said `no issues to address!`, stop here. + +4. Otherwise recommend a yolo session in one sentence, then carry on without waiting for an answer. Triage executes the fixes you approve, so permission prompts interrupt every question. A session started with `claude --dangerously-skip-permissions` walks the queue without them. Skip the recommendation when this session already runs in bypass mode. + +5. List the pending reports, oldest first: + + ```bash + "${AUTODREAM_DIR:-$HOME/.claude/autodream}/pending-triage.sh" --list + ``` + + Each line is `date`, tab, open-question count, tab, report path. Read one report at a time and keep it out of the reply. + +6. Walk its open questions, one per turn: + + 1. Restate ONE open question in full before asking for any decision. The user has not read the report, so a bare label ("now question 2") is never enough. Carry (a) the question in plain words, (b) every motivation behind it: findings, session counts, evidence quotes and report section numbers, and (c) every potential fix, each with its trade-off. + 2. Say what you checked since the report was written, or could not check. + 3. Recommend one concrete action from (c). Be opinionated. + 4. Wait for approve, modify, skip or discuss. + 5. If approved, execute it. If modified, incorporate the change, confirm, then execute. If skipped or discussed, log the decision as one line. + 6. Log every decision as one line under a `## Triage decisions` heading at the bottom of that report (create it if absent). That heading is what marks a report as addressed. + +7. When a report's questions are resolved, write a short summary under `## Triage decisions` and move to the next date. When the last one is done, say `all caught up`. + +## Rules + +- Edits to `~/.claude/CLAUDE.md`, `~/.claude/rules/*`, `~/.claude/docs/guardrails/*` and any other global file need the user's explicit per-edit approval. +- Be terse everywhere except the question restatement, which is always complete. +- The newest dream may finish while you triage. Run `--summary` again at the end and say so if it added questions. diff --git a/tests/install-skill.sh b/tests/install-skill.sh new file mode 100755 index 0000000..ebd95fd --- /dev/null +++ b/tests/install-skill.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# The global /autodream skill: where install.sh links it, and when it leaves it alone. +# +# The install target is a sandbox directory (its first positional argument) and HOME is a +# sandbox too, so nothing here touches ~/.claude. --no-schedule keeps launchd out of it. +set -uo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO="$(cd "$HERE/.." && pwd)" + +PASS=0; FAIL=0 +ok() { PASS=$((PASS + 1)); printf ' ok - %s\n' "$1"; } +no() { FAIL=$((FAIL + 1)); printf ' FAIL - %s\n' "$1"; } +assert_eq() { if [ "$1" = "$2" ]; then ok "$3"; else no "$3 (got [$1] want [$2])"; fi; } +absent() { if [ -e "$1" ]; then no "$2"; else ok "$2"; fi; } +linked() { if [ -L "$1" ]; then ok "$2"; else no "$2"; fi; } +assert_grep() { if grep -qF -- "$2" "$1" 2>/dev/null; then ok "$3"; else no "$3 (no [$2] in $1)"; fi; } + +SANDBOX="" +trap '[ -n "$SANDBOX" ] && rm -rf "$SANDBOX"' EXIT + +new_sandbox() { + [ -n "$SANDBOX" ] && rm -rf "$SANDBOX" + SANDBOX=$(mktemp -d "${TMPDIR:-/tmp}/ccad-sk.XXXXXX") + SANDBOX=$(cd "$SANDBOX" && pwd) + mkdir -p "$SANDBOX/home" "$SANDBOX/target" +} +# install [env KEY=VALUE ...] -- +install() { + local envs=() + while [ "$#" -gt 0 ] && [ "$1" != "--" ]; do envs+=("$1"); shift; done + [ "${1:-}" = "--" ] && shift + env HOME="$SANDBOX/home" ${envs[@]+"${envs[@]}"} bash "$REPO/install.sh" --no-schedule "$@" > "$SANDBOX/install.out" 2>&1 +} + +echo "# the default install links the skill and the scripts it calls" +new_sandbox +install -- "$SANDBOX/target"; rc=$? +assert_eq "$rc" "0" "the install succeeds" +assert_eq "$(readlink "$SANDBOX/target/skills/autodream")" "$REPO/skills/autodream" "the skill is linked under the target's skills dir" +assert_eq "$(sed -n '2p' "$SANDBOX/target/skills/autodream/SKILL.md")" "name: autodream" "and its SKILL.md is readable through the link" +assert_eq "$(readlink "$SANDBOX/target/autodream/pending-triage.sh")" "$REPO/bin/pending-triage.sh" "pending-triage.sh is linked" +assert_eq "$(readlink "$SANDBOX/target/autodream/report-format.sh")" "$REPO/bin/report-format.sh" "and so is the library it sources" + +echo "# --no-skill leaves the skills dir alone" +new_sandbox +install -- --no-skill "$SANDBOX/target"; rc=$? +assert_eq "$rc" "0" "the install succeeds" +absent "$SANDBOX/target/skills" "no skills dir is created" + +echo "# AUTODREAM_SKILLS_DIR moves the link" +new_sandbox +install AUTODREAM_SKILLS_DIR="$SANDBOX/elsewhere" -- "$SANDBOX/target"; rc=$? +assert_eq "$rc" "0" "the install succeeds" +assert_eq "$(readlink "$SANDBOX/elsewhere/autodream")" "$REPO/skills/autodream" "the skill is linked there" +absent "$SANDBOX/target/skills" "and not under the target" + +echo "# a skill the user already has is left alone, and the install still finishes" +new_sandbox +mkdir -p "$SANDBOX/target/skills/autodream" +printf 'mine\n' > "$SANDBOX/target/skills/autodream/SKILL.md" +install -- "$SANDBOX/target"; rc=$? +assert_eq "$rc" "0" "the install succeeds" +assert_eq "$(cat "$SANDBOX/target/skills/autodream/SKILL.md")" "mine" "the user's skill is untouched" +assert_grep "$SANDBOX/install.out" "refusing to install over it" "the install says why" +assert_grep "$SANDBOX/install.out" "/autodream skill not linked" "and that the skill is missing" +linked "$SANDBOX/target/autodream/run.sh" "the scripts are still linked" + +echo "# a dry run links nothing" +new_sandbox +install -- --dry-run "$SANDBOX/target"; rc=$? +assert_eq "$rc" "0" "the dry run succeeds" +assert_grep "$SANDBOX/install.out" "[dry-run] link $SANDBOX/target/skills/autodream -> $REPO/skills/autodream" "it says what it would link" +absent "$SANDBOX/target/skills" "and creates no skills dir" + +printf '\npassed: %s failed: %s\n' "$PASS" "$FAIL" +[ "$FAIL" -eq 0 ] diff --git a/tests/pending-triage.sh b/tests/pending-triage.sh new file mode 100755 index 0000000..ef5f297 --- /dev/null +++ b/tests/pending-triage.sh @@ -0,0 +1,92 @@ +#!/bin/bash +# Tests for bin/pending-triage.sh: which dreams still have open questions nobody answered. +# +# Fixture reports in a sandbox dreams/ dir, dated relative to today because the script +# only looks at a trailing window. No network, no model calls. +# +# Usage: tests/pending-triage.sh +# Exit: 0 if every assertion passes, 1 otherwise. + +set -u + +HERE=$(cd "$(dirname "$0")" && pwd) +REPO=$(cd "$HERE/.." && pwd) +# shellcheck source=/dev/null +. "$HERE/lib-tmp.sh"; suite_tmp ccpend +# shellcheck source=/dev/null +. "$REPO/bin/portable.sh" +PENDING="$REPO/bin/pending-triage.sh" + +pass=0; fail=0 +ok(){ printf ' ok - %s\n' "$1"; pass=$((pass + 1)); } +no(){ printf ' FAIL - %s\n' "$1"; fail=$((fail + 1)); } +assert_eq(){ if [ "$1" = "$2" ]; then ok "$3"; else no "$3 (got [$1] want [$2])"; fi; } + +TODAY=$(date +%Y-%m-%d) +ago(){ pdate_shift "$TODAY" "-$1" d; } + +# report DIR DATE QUESTIONS [triaged] +report(){ + local f="$1/$2.md" + { printf '# Autodream — %s\n\n## Open questions\n\n\n' "$2" "$3" + [ "$3" -gt 0 ] && printf '**A question**\n' + [ "${4:-}" = triaged ] && printf '\n## Triage decisions\n\n- skipped\n' + } > "$f" +} + +new_env(){ local r; r=$(mktemp -d "${TMPDIR:-/tmp}/ccpend.XXXXXX"); mkdir -p "$r/dreams" "$r/autodream"; printf '%s' "$r"; } +run(){ # $1=root, rest=args + local root="$1"; shift + AUTODREAM_DIR="$root/autodream" DREAMS_DIR="$root/dreams" AUTODREAM_CONFIG=/nonexistent "$PENDING" "$@" +} + +echo "# an empty queue" +r=$(new_env) +assert_eq "$(run "$r" --summary)" "no issues to address!" "no reports at all" +assert_eq "$(run "$r")" "no issues to address!" "--summary is the default" +report "$r/dreams" "$(ago 1)" 0 +assert_eq "$(run "$r" --summary)" "no issues to address!" "a report with zero open questions" +report "$r/dreams" "$(ago 2)" 3 triaged +assert_eq "$(run "$r" --summary)" "no issues to address!" "a triaged report is not pending" +assert_eq "$(run "$r" --list)" "" "and --list prints nothing" +rm -rf "$r" + +echo "# one pending report" +r=$(new_env) +report "$r/dreams" "$(ago 1)" 1 +assert_eq "$(run "$r" --summary)" "now triaging 1 issue from $(ago 1)" "singular, one date" +rm -rf "$r" + +echo "# several reports" +r=$(new_env) +report "$r/dreams" "$(ago 5)" 2 +report "$r/dreams" "$(ago 3)" 0 +report "$r/dreams" "$(ago 2)" 4 triaged +report "$r/dreams" "$(ago 1)" 3 +assert_eq "$(run "$r" --summary)" "now triaging 5 issues from dates $(ago 5) to $(ago 1)" "totals the questions, names the first and last date" +assert_eq "$(run "$r" --list | cut -f1,2 | tr '\t\n' ' ')" "$(ago 5) 2 $(ago 1) 3 " "--list is oldest first and skips the triaged and the empty" +assert_eq "$(run "$r" --list | head -1 | cut -f3)" "$r/dreams/$(ago 5).md" "and carries the report path" +rm -rf "$r" + +echo "# the window" +r=$(new_env) +report "$r/dreams" "$(ago 30)" 2 +report "$r/dreams" "$(ago 1)" 1 +assert_eq "$(run "$r" --summary)" "now triaging 1 issue from $(ago 1)" "a report older than the window is left out" +assert_eq "$(AUTODREAM_PENDING_DAYS=60 run "$r" --summary)" "now triaging 3 issues from dates $(ago 30) to $(ago 1)" "AUTODREAM_PENDING_DAYS widens it" +AUTODREAM_PENDING_DAYS=soon run "$r" --summary >/dev/null 2>&1; assert_eq "$?" "2" "a non-numeric window is refused" +rm -rf "$r" + +echo "# a report whose count cannot be read" +r=$(new_env) +printf '# Autodream\n\nno marker here\n' > "$r/dreams/$(ago 1).md" +assert_eq "$(run "$r" --summary)" "no issues to address!" "no marker and no recognisable section is not counted" +rm -rf "$r" + +echo "# arguments" +r=$(new_env) +run "$r" --bogus >/dev/null 2>&1; assert_eq "$?" "2" "an unknown option is refused" +rm -rf "$r" + +printf '\npassed: %s failed: %s\n' "$pass" "$fail" +[ "$fail" -eq 0 ] diff --git a/tests/run-all.sh b/tests/run-all.sh index 9cdc74b..5ef49c3 100755 --- a/tests/run-all.sh +++ b/tests/run-all.sh @@ -6324,8 +6324,8 @@ echo echo "===== unit suites =====" # The suites that drive launchd need plutil and launchctl, so they run on macOS only (CI runs # them in launchd.yml, when a launchd file changes). -_suites="portable lib-project preflight adapters adapter-claude adapter-omp adapter-contract slim-transcript session-window merge-chunks chunk-transcript apply-pins review-skip review-cmux notes-path tmp-cleanup dream-triage cookie-cadence x-bookmarks prune-worktrees triage-candidates" -[ "$(uname -s)" = Darwin ] && _suites="$_suites scheduler-label autodream-now install-review-agent install-dry-run install-dir" +_suites="portable lib-project preflight adapters adapter-claude adapter-omp adapter-contract slim-transcript session-window merge-chunks chunk-transcript apply-pins review-skip review-cmux notes-path tmp-cleanup dream-triage cookie-cadence x-bookmarks prune-worktrees triage-candidates pending-triage" +[ "$(uname -s)" = Darwin ] && _suites="$_suites scheduler-label autodream-now install-review-agent install-dry-run install-dir install-skill" for _suite in $_suites; do _out=$(bash "$HERE/$_suite.sh" 2>&1) _rc=$? From 6812f1c6b5a1c4e2743815de3b719ac5a31427e1 Mon Sep 17 00:00:00 2001 From: Samuel Reed Date: Sun, 18 Oct 2026 12:14:29 -0400 Subject: [PATCH 4/7] fix: keep a half-triaged report pending and link the skill only where it can run Review findings. The skill logs under Triage in progress and writes Triage decisions after the last question, so an interrupted session does not hide the rest. A custom install dir links the skill only when AUTODREAM_SKILLS_DIR is set. pending-triage.sh sources the config with nounset off, lets the environment win, and ignores future-dated reports. link() returns failure when ln -s fails, and the skill says to work in the repository a question is about, to leave a rebuilding date out of the walk, and to skip the dream off macOS. --- CHANGELOG.md | 2 +- README.md | 4 ++-- bin/pending-triage.sh | 22 ++++++++++++------ install.sh | 11 ++++++--- skills/autodream/SKILL.md | 7 ++++-- tests/install-skill.sh | 49 ++++++++++++++++++++++++++------------- tests/pending-triage.sh | 26 +++++++++++++++++++++ 7 files changed, 90 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 704228c..88d4d23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ 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 and `AUTODREAM_SKILLS_DIR` moves it. 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. +- **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. diff --git a/README.md b/README.md index 98445a5..22c3cab 100644 --- a/README.md +++ b/README.md @@ -146,13 +146,13 @@ AUTODREAM_FORCE=1 ~/.claude/autodream/run.sh 2026-05-29 # rebuild a date 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. +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`. To link somewhere else, set `AUTODREAM_SKILLS_DIR` (default `~/.claude/skills`). +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` in the shell that runs Claude Code. ### Which sessions belong to a day diff --git a/bin/pending-triage.sh b/bin/pending-triage.sh index d62977a..1dce1d8 100755 --- a/bin/pending-triage.sh +++ b/bin/pending-triage.sh @@ -7,10 +7,12 @@ # DATEOPEN_QUESTIONSREPORT_PATH # # A report is pending when its open-questions marker says more than zero and it has no -# "## Triage decisions" heading. A report whose count cannot be read ("unknown": no -# marker, a shape review.sh does not recognise) is not counted, the same way the band -# mod reads a missing marker as zero. Only the last AUTODREAM_PENDING_DAYS days are -# looked at (default 14), so a report nobody ever triaged does not nag forever. +# "## 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 not counted, the same way the band mod reads a +# missing marker as zero. 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 @@ -29,11 +31,16 @@ _lib() { # $1=file name -> path of the sibling, following the symlink if needed . "$(_lib report-format.sh)" resolve_install_dir "${BASH_SOURCE[0]}" pending-triage.sh -__env_dreams="${DREAMS_DIR:-}" +# 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}" @@ -47,7 +54,8 @@ case "${1:-}" in *) echo "pending-triage.sh: unknown option: $1" >&2; exit 2 ;; esac -CUTOFF=$(pdate_shift "$(date +%Y-%m-%d)" "-$DAYS" d) +TODAY=$(date +%Y-%m-%d) +CUTOFF=$(pdate_shift "$TODAY" "-$DAYS" d) # DATECOUNTPATH for every pending report in the window, oldest first. pending() { @@ -55,7 +63,7 @@ pending() { 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" ]] && continue + [[ "$d" < "$CUTOFF" || "$d" > "$TODAY" ]] && continue report_is_triaged "$f" && continue n=$(report_open_questions "$f") case "$n" in ''|*[!0-9]*) continue ;; esac diff --git a/install.sh b/install.sh index e45ade6..a5a6b7c 100755 --- a/install.sh +++ b/install.sh @@ -124,7 +124,7 @@ link() { return 1 } fi - ln -s "$src" "$dst" + ln -s "$src" "$dst" || return 1 echo " $dst -> $src" } @@ -174,8 +174,13 @@ link "$REPO_DIR/prompts/TRIAGE_DREAM.md" "$TARGET/TRIAGE_DREAM.md" # The global /autodream skill. A skill nobody asked for must not fail the install, so a # directory already sitting at the link's place (the user's own /autodream) is reported and left. -if [ "$SKILL" = 1 ]; then - SKILLS_DIR="${AUTODREAM_SKILLS_DIR:-$TARGET_PARENT/skills}" +# Claude Code reads ~/.claude/skills, and the skill calls the scripts at ~/.claude/autodream unless +# AUTODREAM_DIR says otherwise. A custom install dir would leave a skill that cannot find them, +# so it is linked only when AUTODREAM_SKILLS_DIR says where the user wants it. +if [ "$SKILL" = 1 ] && [ -z "${AUTODREAM_SKILLS_DIR:-}" ] && [ "$TARGET_PARENT" != "$HOME/.claude" ]; then + echo " /autodream skill not linked: $TARGET_PARENT is not ~/.claude. Set AUTODREAM_SKILLS_DIR to link it, and export AUTODREAM_DIR=$TARGET." +elif [ "$SKILL" = 1 ]; then + SKILLS_DIR="${AUTODREAM_SKILLS_DIR:-$HOME/.claude/skills}" if [ "$DRY" = 1 ]; then dry "mkdir -p $SKILLS_DIR"; else mkdir -p "$SKILLS_DIR"; fi link "$REPO_DIR/skills/autodream" "$SKILLS_DIR/autodream" || echo " WARNING: /autodream skill not linked (see above); re-run after moving $SKILLS_DIR/autodream" >&2 fi diff --git a/skills/autodream/SKILL.md b/skills/autodream/SKILL.md index b9f3042..e2a7ee0 100644 --- a/skills/autodream/SKILL.md +++ b/skills/autodream/SKILL.md @@ -31,6 +31,8 @@ Start a dream, then work through the open questions that earlier dreams raised a Do not wait for it. Say in one line that a dream is running and where its report will land (`dreams/.md`). + `autodream-now.sh` hands the run to launchd, so it only works on macOS. Elsewhere, skip this step and say so. When you passed `force` or `today`, the report for that date is being rebuilt: leave that date out of the walk below and say it will show up on the next `/autodream`. + 3. If step 1 said `no issues to address!`, stop here. 4. Otherwise recommend a yolo session in one sentence, then carry on without waiting for an answer. Triage executes the fixes you approve, so permission prompts interrupt every question. A session started with `claude --dangerously-skip-permissions` walks the queue without them. Skip the recommendation when this session already runs in bypass mode. @@ -50,12 +52,13 @@ Start a dream, then work through the open questions that earlier dreams raised a 3. Recommend one concrete action from (c). Be opinionated. 4. Wait for approve, modify, skip or discuss. 5. If approved, execute it. If modified, incorporate the change, confirm, then execute. If skipped or discussed, log the decision as one line. - 6. Log every decision as one line under a `## Triage decisions` heading at the bottom of that report (create it if absent). That heading is what marks a report as addressed. + 6. Log every decision as one line under a `## Triage in progress` heading at the bottom of that report (create it if absent). Questions already logged there are done: skip them when you resume a report. -7. When a report's questions are resolved, write a short summary under `## Triage decisions` and move to the next date. When the last one is done, say `all caught up`. +7. When the last question of a report is resolved, rename its heading to `## Triage decisions` and add a short summary under it. That heading is what marks a report as addressed, so never write it earlier: a session that stops half way would hide the questions still open. Then move to the next date. When the last one is done, say `all caught up`. ## Rules +- A fix belongs to the repository or file the question is about, which is often not the project this session is in. Work out the target from the question, run the fix there, and do not change the current project for another project's question. When you cannot reach the target, show the fix and say where to apply it. - Edits to `~/.claude/CLAUDE.md`, `~/.claude/rules/*`, `~/.claude/docs/guardrails/*` and any other global file need the user's explicit per-edit approval. - Be terse everywhere except the question restatement, which is always complete. - The newest dream may finish while you triage. Run `--summary` again at the end and say so if it added questions. diff --git a/tests/install-skill.sh b/tests/install-skill.sh index ebd95fd..b4c598e 100755 --- a/tests/install-skill.sh +++ b/tests/install-skill.sh @@ -35,43 +35,60 @@ install() { echo "# the default install links the skill and the scripts it calls" new_sandbox +install -- "$SANDBOX/home/.claude"; rc=$? +assert_eq "$rc" "0" "the install succeeds" +assert_eq "$(readlink "$SANDBOX/home/.claude/skills/autodream")" "$REPO/skills/autodream" "the skill is linked under HOME/.claude/skills" +assert_eq "$(sed -n '2p' "$SANDBOX/home/.claude/skills/autodream/SKILL.md")" "name: autodream" "and its SKILL.md is readable through the link" +assert_eq "$(readlink "$SANDBOX/home/.claude/autodream/pending-triage.sh")" "$REPO/bin/pending-triage.sh" "pending-triage.sh is linked" +assert_eq "$(readlink "$SANDBOX/home/.claude/autodream/report-format.sh")" "$REPO/bin/report-format.sh" "and so is the library it sources" + +echo "# a custom install dir is not linked, because the skill would not find the scripts" +new_sandbox install -- "$SANDBOX/target"; rc=$? assert_eq "$rc" "0" "the install succeeds" -assert_eq "$(readlink "$SANDBOX/target/skills/autodream")" "$REPO/skills/autodream" "the skill is linked under the target's skills dir" -assert_eq "$(sed -n '2p' "$SANDBOX/target/skills/autodream/SKILL.md")" "name: autodream" "and its SKILL.md is readable through the link" -assert_eq "$(readlink "$SANDBOX/target/autodream/pending-triage.sh")" "$REPO/bin/pending-triage.sh" "pending-triage.sh is linked" -assert_eq "$(readlink "$SANDBOX/target/autodream/report-format.sh")" "$REPO/bin/report-format.sh" "and so is the library it sources" +absent "$SANDBOX/home/.claude/skills" "no skill is linked under HOME" +absent "$SANDBOX/target/skills" "and none under the custom dir" +assert_grep "$SANDBOX/install.out" "AUTODREAM_SKILLS_DIR" "the install says how to link it" echo "# --no-skill leaves the skills dir alone" new_sandbox -install -- --no-skill "$SANDBOX/target"; rc=$? +install -- --no-skill "$SANDBOX/home/.claude"; rc=$? assert_eq "$rc" "0" "the install succeeds" -absent "$SANDBOX/target/skills" "no skills dir is created" +absent "$SANDBOX/home/.claude/skills" "no skills dir is created" -echo "# AUTODREAM_SKILLS_DIR moves the link" +echo "# AUTODREAM_SKILLS_DIR links there, for a custom install too" new_sandbox install AUTODREAM_SKILLS_DIR="$SANDBOX/elsewhere" -- "$SANDBOX/target"; rc=$? assert_eq "$rc" "0" "the install succeeds" assert_eq "$(readlink "$SANDBOX/elsewhere/autodream")" "$REPO/skills/autodream" "the skill is linked there" -absent "$SANDBOX/target/skills" "and not under the target" +absent "$SANDBOX/home/.claude/skills" "and not under HOME" echo "# a skill the user already has is left alone, and the install still finishes" new_sandbox -mkdir -p "$SANDBOX/target/skills/autodream" -printf 'mine\n' > "$SANDBOX/target/skills/autodream/SKILL.md" -install -- "$SANDBOX/target"; rc=$? +mkdir -p "$SANDBOX/home/.claude/skills/autodream" +printf 'mine\n' > "$SANDBOX/home/.claude/skills/autodream/SKILL.md" +install -- "$SANDBOX/home/.claude"; rc=$? assert_eq "$rc" "0" "the install succeeds" -assert_eq "$(cat "$SANDBOX/target/skills/autodream/SKILL.md")" "mine" "the user's skill is untouched" +assert_eq "$(cat "$SANDBOX/home/.claude/skills/autodream/SKILL.md")" "mine" "the user's skill is untouched" assert_grep "$SANDBOX/install.out" "refusing to install over it" "the install says why" assert_grep "$SANDBOX/install.out" "/autodream skill not linked" "and that the skill is missing" -linked "$SANDBOX/target/autodream/run.sh" "the scripts are still linked" +linked "$SANDBOX/home/.claude/autodream/run.sh" "the scripts are still linked" + +echo "# a link that cannot be made is reported, not announced as done" +new_sandbox +mkdir -p "$SANDBOX/ro"; chmod 555 "$SANDBOX/ro" +install AUTODREAM_SKILLS_DIR="$SANDBOX/ro" -- "$SANDBOX/target"; rc=$? +chmod 755 "$SANDBOX/ro" +assert_eq "$rc" "0" "the install still finishes" +assert_grep "$SANDBOX/install.out" "/autodream skill not linked" "and says the skill is missing" +if grep -qF -- "$SANDBOX/ro/autodream -> " "$SANDBOX/install.out"; then no "and does not print a link line"; else ok "and does not print a link line"; fi echo "# a dry run links nothing" new_sandbox -install -- --dry-run "$SANDBOX/target"; rc=$? +install -- --dry-run "$SANDBOX/home/.claude"; rc=$? assert_eq "$rc" "0" "the dry run succeeds" -assert_grep "$SANDBOX/install.out" "[dry-run] link $SANDBOX/target/skills/autodream -> $REPO/skills/autodream" "it says what it would link" -absent "$SANDBOX/target/skills" "and creates no skills dir" +assert_grep "$SANDBOX/install.out" "[dry-run] link $SANDBOX/home/.claude/skills/autodream -> $REPO/skills/autodream" "it says what it would link" +absent "$SANDBOX/home/.claude/skills" "and creates no skills dir" printf '\npassed: %s failed: %s\n' "$PASS" "$FAIL" [ "$FAIL" -eq 0 ] diff --git a/tests/pending-triage.sh b/tests/pending-triage.sh index ef5f297..cb9590a 100755 --- a/tests/pending-triage.sh +++ b/tests/pending-triage.sh @@ -83,6 +83,32 @@ printf '# Autodream\n\nno marker here\n' > "$r/dreams/$(ago 1).md" assert_eq "$(run "$r" --summary)" "no issues to address!" "no marker and no recognisable section is not counted" rm -rf "$r" +echo "# a report part way through triage stays pending" +r=$(new_env) +report "$r/dreams" "$(ago 1)" 3 +printf '\n## Triage in progress\n\n- question 1: approved\n' >> "$r/dreams/$(ago 1).md" +assert_eq "$(run "$r" --summary)" "now triaging 3 issues from $(ago 1)" "the in-progress heading does not retire it" +rm -rf "$r" + +echo "# a date in the future is outside the window" +r=$(new_env) +report "$r/dreams" "$(pdate_shift "$TODAY" 3 d)" 2 +report "$r/dreams" "$(ago 1)" 1 +assert_eq "$(run "$r" --summary)" "now triaging 1 issue from $(ago 1)" "a future-dated report is left out" +rm -rf "$r" + +echo "# the config" +r=$(new_env) +report "$r/dreams" "$(ago 20)" 2 +report "$r/dreams" "$(ago 1)" 1 +# shellcheck disable=SC2016 # the dollar sign is the point: the config must reach an unset variable +printf 'BROKEN=$NO_SUCH_VARIABLE_ANYWHERE\nAUTODREAM_PENDING_DAYS=30\n' > "$r/autodream/config" +out=$(AUTODREAM_DIR="$r/autodream" DREAMS_DIR="$r/dreams" AUTODREAM_CONFIG="$r/autodream/config" "$PENDING" --summary 2>&1) +assert_eq "$out" "now triaging 3 issues from dates $(ago 20) to $(ago 1)" "an unset variable in the config does not kill the script, and the config applies" +out=$(AUTODREAM_PENDING_DAYS=5 AUTODREAM_DIR="$r/autodream" DREAMS_DIR="$r/dreams" AUTODREAM_CONFIG="$r/autodream/config" "$PENDING" --summary 2>&1) +assert_eq "$out" "now triaging 1 issue from $(ago 1)" "the environment beats the config" +rm -rf "$r" + echo "# arguments" r=$(new_env) run "$r" --bogus >/dev/null 2>&1; assert_eq "$?" "2" "an unknown option is refused" From 9587a5c38c53011eb644fb09b84d3e6bce9f7ecd Mon Sep 17 00:00:00 2001 From: Samuel Reed Date: Sun, 18 Oct 2026 12:30:16 -0400 Subject: [PATCH 5/7] fix: count what is left of a half-triaged report and accept a trailing slash on the install dir pending-triage.sh subtracts the decisions logged under Triage in progress, and one question always stays until the heading is renamed. install.sh compares the install dir without a trailing slash. --- README.md | 2 +- bin/pending-triage.sh | 4 ++++ bin/report-format.sh | 6 ++++++ install.sh | 2 +- tests/install-skill.sh | 6 ++++++ tests/pending-triage.sh | 4 +++- 6 files changed, 21 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 22c3cab..a9e6e51 100644 --- a/README.md +++ b/README.md @@ -152,7 +152,7 @@ Variants: `/autodream today` runs today so far, and `/autodream force` rebuilds 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` in the shell that runs Claude Code. +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 diff --git a/bin/pending-triage.sh b/bin/pending-triage.sh index 1dce1d8..1df17a5 100755 --- a/bin/pending-triage.sh +++ b/bin/pending-triage.sh @@ -68,6 +68,10 @@ pending() { n=$(report_open_questions "$f") case "$n" in ''|*[!0-9]*) continue ;; esac [ "$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=$((n - $(report_progress_count "$f"))) + [ "$n" -ge 1 ] || n=1 printf '%s\t%s\t%s\n' "$d" "$n" "$f" done } diff --git a/bin/report-format.sh b/bin/report-format.sh index 57ee31b..096258b 100755 --- a/bin/report-format.sh +++ b/bin/report-format.sh @@ -32,3 +32,9 @@ report_open_questions(){ } 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 +} diff --git a/install.sh b/install.sh index a5a6b7c..99b81b5 100755 --- a/install.sh +++ b/install.sh @@ -177,7 +177,7 @@ link "$REPO_DIR/prompts/TRIAGE_DREAM.md" "$TARGET/TRIAGE_DREAM.md" # Claude Code reads ~/.claude/skills, and the skill calls the scripts at ~/.claude/autodream unless # AUTODREAM_DIR says otherwise. A custom install dir would leave a skill that cannot find them, # so it is linked only when AUTODREAM_SKILLS_DIR says where the user wants it. -if [ "$SKILL" = 1 ] && [ -z "${AUTODREAM_SKILLS_DIR:-}" ] && [ "$TARGET_PARENT" != "$HOME/.claude" ]; then +if [ "$SKILL" = 1 ] && [ -z "${AUTODREAM_SKILLS_DIR:-}" ] && [ "${TARGET_PARENT%/}" != "$HOME/.claude" ]; then echo " /autodream skill not linked: $TARGET_PARENT is not ~/.claude. Set AUTODREAM_SKILLS_DIR to link it, and export AUTODREAM_DIR=$TARGET." elif [ "$SKILL" = 1 ]; then SKILLS_DIR="${AUTODREAM_SKILLS_DIR:-$HOME/.claude/skills}" diff --git a/tests/install-skill.sh b/tests/install-skill.sh index b4c598e..a743ff3 100755 --- a/tests/install-skill.sh +++ b/tests/install-skill.sh @@ -42,6 +42,12 @@ assert_eq "$(sed -n '2p' "$SANDBOX/home/.claude/skills/autodream/SKILL.md")" "na assert_eq "$(readlink "$SANDBOX/home/.claude/autodream/pending-triage.sh")" "$REPO/bin/pending-triage.sh" "pending-triage.sh is linked" assert_eq "$(readlink "$SANDBOX/home/.claude/autodream/report-format.sh")" "$REPO/bin/report-format.sh" "and so is the library it sources" +echo "# a trailing slash on the default dir is still the default" +new_sandbox +install -- "$SANDBOX/home/.claude/"; rc=$? +assert_eq "$rc" "0" "the install succeeds" +assert_eq "$(readlink "$SANDBOX/home/.claude/skills/autodream")" "$REPO/skills/autodream" "the skill is linked" + echo "# a custom install dir is not linked, because the skill would not find the scripts" new_sandbox install -- "$SANDBOX/target"; rc=$? diff --git a/tests/pending-triage.sh b/tests/pending-triage.sh index cb9590a..e6349f8 100755 --- a/tests/pending-triage.sh +++ b/tests/pending-triage.sh @@ -87,7 +87,9 @@ echo "# a report part way through triage stays pending" r=$(new_env) report "$r/dreams" "$(ago 1)" 3 printf '\n## Triage in progress\n\n- question 1: approved\n' >> "$r/dreams/$(ago 1).md" -assert_eq "$(run "$r" --summary)" "now triaging 3 issues from $(ago 1)" "the in-progress heading does not retire it" +assert_eq "$(run "$r" --summary)" "now triaging 2 issues from $(ago 1)" "the in-progress heading keeps it pending and counts what is left" +printf -- '- question 2: skipped\n- question 3: approved\n' >> "$r/dreams/$(ago 1).md" +assert_eq "$(run "$r" --summary)" "now triaging 1 issue from $(ago 1)" "one question always stays until the heading is renamed" rm -rf "$r" echo "# a date in the future is outside the window" From 7392145d09e800a69bf62f36ca852f901653fc3c Mon Sep 17 00:00:00 2001 From: Samuel Reed Date: Mon, 19 Oct 2026 02:16:31 -0400 Subject: [PATCH 6/7] fix: read a zero-padded question count as decimal and accept any run of trailing slashes on the install dir --- bin/pending-triage.sh | 4 ++-- install.sh | 2 +- tests/install-skill.sh | 5 +++++ tests/pending-triage.sh | 7 +++++++ 4 files changed, 15 insertions(+), 3 deletions(-) diff --git a/bin/pending-triage.sh b/bin/pending-triage.sh index 1df17a5..fca383a 100755 --- a/bin/pending-triage.sh +++ b/bin/pending-triage.sh @@ -67,10 +67,10 @@ pending() { report_is_triaged "$f" && continue n=$(report_open_questions "$f") case "$n" in ''|*[!0-9]*) continue ;; esac - [ "$n" -gt 0 ] || continue + [ "$((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=$((n - $(report_progress_count "$f"))) + n=$((10#$n - $(report_progress_count "$f"))) [ "$n" -ge 1 ] || n=1 printf '%s\t%s\t%s\n' "$d" "$n" "$f" done diff --git a/install.sh b/install.sh index 99b81b5..4983e61 100755 --- a/install.sh +++ b/install.sh @@ -177,7 +177,7 @@ link "$REPO_DIR/prompts/TRIAGE_DREAM.md" "$TARGET/TRIAGE_DREAM.md" # Claude Code reads ~/.claude/skills, and the skill calls the scripts at ~/.claude/autodream unless # AUTODREAM_DIR says otherwise. A custom install dir would leave a skill that cannot find them, # so it is linked only when AUTODREAM_SKILLS_DIR says where the user wants it. -if [ "$SKILL" = 1 ] && [ -z "${AUTODREAM_SKILLS_DIR:-}" ] && [ "${TARGET_PARENT%/}" != "$HOME/.claude" ]; then +if [ "$SKILL" = 1 ] && [ -z "${AUTODREAM_SKILLS_DIR:-}" ] && [ "$(printf '%s' "$TARGET_PARENT" | sed 's:/*$::')" != "$HOME/.claude" ]; then echo " /autodream skill not linked: $TARGET_PARENT is not ~/.claude. Set AUTODREAM_SKILLS_DIR to link it, and export AUTODREAM_DIR=$TARGET." elif [ "$SKILL" = 1 ]; then SKILLS_DIR="${AUTODREAM_SKILLS_DIR:-$HOME/.claude/skills}" diff --git a/tests/install-skill.sh b/tests/install-skill.sh index a743ff3..241ed64 100755 --- a/tests/install-skill.sh +++ b/tests/install-skill.sh @@ -48,6 +48,11 @@ install -- "$SANDBOX/home/.claude/"; rc=$? assert_eq "$rc" "0" "the install succeeds" assert_eq "$(readlink "$SANDBOX/home/.claude/skills/autodream")" "$REPO/skills/autodream" "the skill is linked" +new_sandbox +install -- "$SANDBOX/home/.claude//"; rc=$? +assert_eq "$rc" "0" "a doubled trailing slash installs" +assert_eq "$(readlink "$SANDBOX/home/.claude/skills/autodream")" "$REPO/skills/autodream" "and still links the skill" + echo "# a custom install dir is not linked, because the skill would not find the scripts" new_sandbox install -- "$SANDBOX/target"; rc=$? diff --git a/tests/pending-triage.sh b/tests/pending-triage.sh index e6349f8..878fe43 100755 --- a/tests/pending-triage.sh +++ b/tests/pending-triage.sh @@ -92,6 +92,13 @@ printf -- '- question 2: skipped\n- question 3: approved\n' >> "$r/dreams/$(ago assert_eq "$(run "$r" --summary)" "now triaging 1 issue from $(ago 1)" "one question always stays until the heading is renamed" rm -rf "$r" +echo "# a zero-padded marker is a decimal" +r=$(new_env) +report "$r/dreams" "$(ago 1)" 3 +sed -i.bak 's/open-questions=3/open-questions=08/' "$r/dreams/$(ago 1).md" +assert_eq "$(run "$r" --summary)" "now triaging 8 issues from $(ago 1)" "08 reads as eight, not as an invalid octal" +rm -rf "$r" + echo "# a date in the future is outside the window" r=$(new_env) report "$r/dreams" "$(pdate_shift "$TODAY" 3 d)" 2 From 4c9e0e3510ad9ecd7c6e6582444f7281b3288ba0 Mon Sep 17 00:00:00 2001 From: Samuel Reed Date: Mon, 19 Oct 2026 13:24:31 -0400 Subject: [PATCH 7/7] fix: list reports with an unreadable count, keep a user-owned skill link, and log decisions as bullets codex-review round 1: P1 an unknown report was hidden as an empty queue, P1 a user-owned symlink was replaced, P2 plain decision lines were not counted. Swept pending-triage.sh, SKILL.md and install.sh for the same classes (unknown state treated as absence, an existing link treated as ours, writer/parser format mismatch). --- bin/pending-triage.sh | 20 ++++++++++++++------ install.sh | 9 ++++++++- skills/autodream/SKILL.md | 4 ++-- tests/install-skill.sh | 20 +++++++++++++++++++- tests/pending-triage.sh | 5 ++++- 5 files changed, 47 insertions(+), 11 deletions(-) diff --git a/bin/pending-triage.sh b/bin/pending-triage.sh index fca383a..b71242f 100755 --- a/bin/pending-triage.sh +++ b/bin/pending-triage.sh @@ -5,13 +5,15 @@ # or "no issues to address!" # pending-triage.sh --list one line per report, oldest first: # DATEOPEN_QUESTIONSREPORT_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 not counted, the same way the band mod reads a -# missing marker as zero. Only the last AUTODREAM_PENDING_DAYS days are looked at (default +# 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. @@ -66,7 +68,9 @@ pending() { [[ "$d" < "$CUTOFF" || "$d" > "$TODAY" ]] && continue report_is_triaged "$f" && continue n=$(report_open_questions "$f") - case "$n" in ''|*[!0-9]*) continue ;; esac + 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. @@ -85,9 +89,13 @@ fi if [ -z "$ROWS" ]; then echo "no issues to address!"; exit 0; fi -TOTAL=$(printf '%s\n' "$ROWS" | awk -F'\t' '{s += $2} END {print s}') +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 -if [ "$FIRST" = "$LAST" ]; then echo "now triaging $TOTAL $NOUN from $FIRST" -else echo "now triaging $TOTAL $NOUN from dates $FIRST to $LAST"; fi +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 diff --git a/install.sh b/install.sh index 4983e61..d4afc6d 100755 --- a/install.sh +++ b/install.sh @@ -182,7 +182,14 @@ if [ "$SKILL" = 1 ] && [ -z "${AUTODREAM_SKILLS_DIR:-}" ] && [ "$(printf '%s' "$ elif [ "$SKILL" = 1 ]; then SKILLS_DIR="${AUTODREAM_SKILLS_DIR:-$HOME/.claude/skills}" if [ "$DRY" = 1 ]; then dry "mkdir -p $SKILLS_DIR"; else mkdir -p "$SKILLS_DIR"; fi - link "$REPO_DIR/skills/autodream" "$SKILLS_DIR/autodream" || echo " WARNING: /autodream skill not linked (see above); re-run after moving $SKILLS_DIR/autodream" >&2 + # A skill the user already has at this name (their own link or directory) is theirs: only an + # earlier link to this repo's skill is replaced. + if { [ -e "$SKILLS_DIR/autodream" ] || [ -L "$SKILLS_DIR/autodream" ]; } \ + && [ "$(readlink "$SKILLS_DIR/autodream" 2>/dev/null)" != "$REPO_DIR/skills/autodream" ]; then + echo " /autodream skill not linked: $SKILLS_DIR/autodream already exists and is not this repo's. Move it, then re-run install.sh." >&2 + else + link "$REPO_DIR/skills/autodream" "$SKILLS_DIR/autodream" || echo " WARNING: /autodream skill not linked (see above); re-run after moving $SKILLS_DIR/autodream" >&2 + fi fi if [ "$DRY" = 1 ]; then dry "chmod +x bin/*.sh adapters/*/adapter.sh"; else diff --git a/skills/autodream/SKILL.md b/skills/autodream/SKILL.md index e2a7ee0..e7fc338 100644 --- a/skills/autodream/SKILL.md +++ b/skills/autodream/SKILL.md @@ -43,7 +43,7 @@ Start a dream, then work through the open questions that earlier dreams raised a "${AUTODREAM_DIR:-$HOME/.claude/autodream}/pending-triage.sh" --list ``` - Each line is `date`, tab, open-question count, tab, report path. Read one report at a time and keep it out of the reply. + Each line is `date`, tab, open-question count, tab, report path. A count of `?` means the report does not say how many questions it holds: read its Open questions section and count them yourself. Read one report at a time and keep it out of the reply. 6. Walk its open questions, one per turn: @@ -52,7 +52,7 @@ Start a dream, then work through the open questions that earlier dreams raised a 3. Recommend one concrete action from (c). Be opinionated. 4. Wait for approve, modify, skip or discuss. 5. If approved, execute it. If modified, incorporate the change, confirm, then execute. If skipped or discussed, log the decision as one line. - 6. Log every decision as one line under a `## Triage in progress` heading at the bottom of that report (create it if absent). Questions already logged there are done: skip them when you resume a report. + 6. Log every decision as one bullet line (starting with `- `) under a `## Triage in progress` heading at the bottom of that report (create it if absent). Questions already logged there are done: skip them when you resume a report. 7. When the last question of a report is resolved, rename its heading to `## Triage decisions` and add a short summary under it. That heading is what marks a report as addressed, so never write it earlier: a session that stops half way would hide the questions still open. Then move to the next date. When the last one is done, say `all caught up`. diff --git a/tests/install-skill.sh b/tests/install-skill.sh index 241ed64..ab036c8 100755 --- a/tests/install-skill.sh +++ b/tests/install-skill.sh @@ -81,10 +81,28 @@ printf 'mine\n' > "$SANDBOX/home/.claude/skills/autodream/SKILL.md" install -- "$SANDBOX/home/.claude"; rc=$? assert_eq "$rc" "0" "the install succeeds" assert_eq "$(cat "$SANDBOX/home/.claude/skills/autodream/SKILL.md")" "mine" "the user's skill is untouched" -assert_grep "$SANDBOX/install.out" "refusing to install over it" "the install says why" +assert_grep "$SANDBOX/install.out" "already exists and is not this repo's" "the install says why" assert_grep "$SANDBOX/install.out" "/autodream skill not linked" "and that the skill is missing" linked "$SANDBOX/home/.claude/autodream/run.sh" "the scripts are still linked" +echo "# a symlink the user already has at that name is not replaced" +new_sandbox +mkdir -p "$SANDBOX/home/.claude/skills" "$SANDBOX/mine" +printf 'mine\n' > "$SANDBOX/mine/SKILL.md" +ln -s "$SANDBOX/mine" "$SANDBOX/home/.claude/skills/autodream" +install -- "$SANDBOX/home/.claude"; rc=$? +assert_eq "$rc" "0" "the install succeeds" +assert_eq "$(readlink "$SANDBOX/home/.claude/skills/autodream")" "$SANDBOX/mine" "the user's link still points at their skill" +assert_grep "$SANDBOX/install.out" "already exists and is not this repo's" "and the install says so" + +echo "# a link to this repo's skill is refreshed" +new_sandbox +mkdir -p "$SANDBOX/home/.claude/skills" +ln -s "$REPO/skills/autodream" "$SANDBOX/home/.claude/skills/autodream" +install -- "$SANDBOX/home/.claude"; rc=$? +assert_eq "$rc" "0" "a re-install succeeds" +assert_eq "$(readlink "$SANDBOX/home/.claude/skills/autodream")" "$REPO/skills/autodream" "and the link is still ours" + echo "# a link that cannot be made is reported, not announced as done" new_sandbox mkdir -p "$SANDBOX/ro"; chmod 555 "$SANDBOX/ro" diff --git a/tests/pending-triage.sh b/tests/pending-triage.sh index 878fe43..81fafbc 100755 --- a/tests/pending-triage.sh +++ b/tests/pending-triage.sh @@ -80,7 +80,10 @@ rm -rf "$r" echo "# a report whose count cannot be read" r=$(new_env) printf '# Autodream\n\nno marker here\n' > "$r/dreams/$(ago 1).md" -assert_eq "$(run "$r" --summary)" "no issues to address!" "no marker and no recognisable section is not counted" +assert_eq "$(run "$r" --summary)" "now triaging 1 report with an unreadable question count from $(ago 1)" "an unreadable count is named, never called an empty queue" +assert_eq "$(run "$r" --list | cut -f2)" "?" "and --list shows a ? count" +report "$r/dreams" "$(ago 2)" 2 +assert_eq "$(run "$r" --summary)" "now triaging 2 issues from dates $(ago 2) to $(ago 1), plus 1 report with an unreadable question count" "readable and unreadable reports are reported together" rm -rf "$r" echo "# a report part way through triage stays pending"