From ba742ebc69c9c20524940d0a033b405f6e053c8d Mon Sep 17 00:00:00 2001 From: Samuel Reed Date: Tue, 20 Oct 2026 07:05:38 +0800 Subject: [PATCH 1/3] refactor: keep the L2 system prompt in one file instead of three inline copies prompts/L2_SYSTEM.txt holds the text the claude adapter, the omp adapter and run.sh's built-in claude fallback each carried inline (byte-identical, 657 characters). bin/l2-prompt.sh reads it and returns 2 with a message when it cannot. Both adapters' l2-argv exit 2 and print no argv without it, and run.sh's fallback builds no argv and logs why, because an engine started without the prompt runs L2 with no word on where the report goes. install.sh links the file and the loader. tests/l2-prompt.sh (25 assertions): red first (8 failures), then green. Covers the text, the loader's missing and empty cases, a symlinked lib, both adapters with and without the file in a throwaway tree, and that no script keeps an inline copy. --- .github/workflows/tests.yml | 2 +- CHANGELOG.md | 5 ++ CLAUDE.md | 2 +- adapters/claude/adapter.sh | 6 ++- adapters/omp/adapter.sh | 5 +- bin/l2-prompt.sh | 26 ++++++++++ bin/run.sh | 17 +++++-- install.sh | 2 + prompts/L2_SYSTEM.txt | 1 + tests/l2-prompt.sh | 98 +++++++++++++++++++++++++++++++++++++ tests/run-all.sh | 2 +- 11 files changed, 157 insertions(+), 9 deletions(-) create mode 100755 bin/l2-prompt.sh create mode 100644 prompts/L2_SYSTEM.txt create mode 100755 tests/l2-prompt.sh diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 95cc900..5be26af 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 tests/pending-triage.sh; do + tests/tmp-cleanup.sh tests/x-bookmarks.sh tests/prune-worktrees.sh tests/triage-candidates.sh tests/pending-triage.sh tests/l2-prompt.sh; do echo "== $t" bash "$t" || rc=1 done diff --git a/CHANGELOG.md b/CHANGELOG.md index 88d4d23..13adfe8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ All notable changes to cc-autodream. Format loosely follows Keep a Changelog. +## 2026-10-19 + +### Changed +- **The L2 system prompt is one file, `prompts/L2_SYSTEM.txt`.** The same 657 characters sat inline in the claude adapter, the omp adapter and `run.sh`'s built-in claude fallback. They state the stdout grammar that `bin/report-format.sh` parses (the report, `AUTODREAM_REPORT_END`, the optional pin block, the summary), so a drift between a copy and the parser would show up only as an L2 run whose output the runner cannot split. `bin/l2-prompt.sh` reads the file and returns 2 with a message when it cannot, and both adapters' `l2-argv` then exit 2 and print no argv, where an engine without the prompt would otherwise run with no word on where the report goes. `run.sh`'s fallback builds no argv without it and logs why. `install.sh` links the prompt and the loader. `tests/l2-prompt.sh` (25 assertions) pins the text, the missing-file failures and that no script keeps an inline copy. + ## 2026-10-18 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 74b2cc6..514fcdb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -393,7 +393,7 @@ report: ### Which engine runs L2 -L2 is an adapter too. `AUTODREAM_L2_ENGINE=` picks it (default: the first enabled adapter, so `claude` on an untouched install); a name that is not an accepted adapter stops the run before L1, not after. The adapter prints the command (`adapter.sh l2-argv [model]`, NUL-delimited, prompt on stdin, tools Glob and Read only) and `l1-env` supplies its environment. The model resolves `AUTODREAM_L2_MODEL_`, then `AUTODREAM_L2_MODEL`, then the manifest's `l2_model`; none at all is valid and means the engine's own default (claude). `run-stats.txt` records `l2_engine`, `l2_model` (`default` when none was named) and one `l1_model_` per source that had sessions, so a report that reads differently can be traced to the engine that wrote it. An install whose `adapters/` tree predates `l2-argv` keeps the built-in claude invocation, for claude only. +L2 is an adapter too. `AUTODREAM_L2_ENGINE=` picks it (default: the first enabled adapter, so `claude` on an untouched install); a name that is not an accepted adapter stops the run before L1, not after. The adapter prints the command (`adapter.sh l2-argv [model]`, NUL-delimited, prompt on stdin, tools Glob and Read only) and `l1-env` supplies its environment. The model resolves `AUTODREAM_L2_MODEL_`, then `AUTODREAM_L2_MODEL`, then the manifest's `l2_model`; none at all is valid and means the engine's own default (claude). `run-stats.txt` records `l2_engine`, `l2_model` (`default` when none was named) and one `l1_model_` per source that had sessions, so a report that reads differently can be traced to the engine that wrote it. An install whose `adapters/` tree predates `l2-argv` keeps the built-in claude invocation, for claude only. Every engine gets the same system prompt, `prompts/L2_SYSTEM.txt`, through `bin/l2-prompt.sh`; it names the stdout grammar `bin/report-format.sh` parses, so change them together. A missing prompt file makes `l2-argv` exit 2 rather than start an engine without it. ### The review triage LaunchAgent diff --git a/adapters/claude/adapter.sh b/adapters/claude/adapter.sh index aff773b..884dce2 100755 --- a/adapters/claude/adapter.sh +++ b/adapters/claude/adapter.sh @@ -218,6 +218,10 @@ case "$cmd" in # writer. The model is optional, and absent means the CLI's own default, so an upgrade of the # account upgrades the nightly report with nothing edited here. set -- ${1:+--model "$1"} + # The system prompt is prompts/L2_SYSTEM.txt, read through the loader. No file means no argv: + # an engine started without it would run L2 with no word on where the report goes. + # shellcheck source=/dev/null + sysprompt=$(. "$BIN/l2-prompt.sh" && l2_system_prompt) || exit 2 printf '%s\0' "${CLAUDE_BIN:-$HOME/.local/bin/claude}" \ --print \ --permission-mode bypassPermissions \ @@ -227,7 +231,7 @@ case "$cmd" in --disable-slash-commands \ --strict-mcp-config \ --settings '{"disableAllHooks":true}' \ - --append-system-prompt 'Headless aggregator. Read the per-session findings JSONs from the findings directory given on line 1 of the prompt, then produce the COMPLETE report only on standard output, ending with a line containing exactly AUTODREAM_REPORT_END. After that line, if you propose memory pins, print them between a line AUTODREAM_PINS_BEGIN and a line AUTODREAM_PINS_END, one JSON object per line. Do not use Write or Edit anywhere. Those paths are literal strings, not shell variables — never $-expand them. After the pin block print one line: report: then a 3-line summary (sessions reviewed, findings, pins proposed), then exit.' + --append-system-prompt "$sysprompt" ;; skills-inventory) diff --git a/adapters/omp/adapter.sh b/adapters/omp/adapter.sh index e6e9221..4b8b26c 100755 --- a/adapters/omp/adapter.sh +++ b/adapters/omp/adapter.sh @@ -143,6 +143,9 @@ case "$cmd" in [ "$#" -ge 1 ] && [ -n "$1" ] || exit 2 # The L1 overlay applies here too (advisor off, no local provider probes, no first-turn # recall). Glob and Read only: the report and the pins come back on stdout. + # The system prompt is prompts/L2_SYSTEM.txt, read through the loader. No file means no argv. + # shellcheck source=/dev/null + sysprompt=$(. "$BIN/l2-prompt.sh" && l2_system_prompt) || exit 2 printf '%s\0' "$(omp_bin)" \ --allow-home \ -p \ @@ -151,7 +154,7 @@ case "$cmd" in --config "${NO_ADVISOR_CFG:-$ADIR/l1-no-advisor.yml}" \ --model "$1" \ --tools=Glob,Read \ - --append-system-prompt 'Headless aggregator. Read the per-session findings JSONs from the findings directory given on line 1 of the prompt, then produce the COMPLETE report only on standard output, ending with a line containing exactly AUTODREAM_REPORT_END. After that line, if you propose memory pins, print them between a line AUTODREAM_PINS_BEGIN and a line AUTODREAM_PINS_END, one JSON object per line. Do not use Write or Edit anywhere. Those paths are literal strings, not shell variables — never $-expand them. After the pin block print one line: report: then a 3-line summary (sessions reviewed, findings, pins proposed), then exit.' + --append-system-prompt "$sysprompt" ;; skills-inventory) diff --git a/bin/l2-prompt.sh b/bin/l2-prompt.sh new file mode 100755 index 0000000..f8e4a6a --- /dev/null +++ b/bin/l2-prompt.sh @@ -0,0 +1,26 @@ +#!/bin/bash +# Sourced, never executed. The system prompt every L2 engine runs under, kept in +# prompts/L2_SYSTEM.txt so the claude adapter, the omp adapter and run.sh's built-in claude +# fallback all say the same thing. It states the stdout grammar that bin/report-format.sh parses +# (the report, AUTODREAM_REPORT_END, the optional pin block, the summary), so a change to that +# grammar is one edit here and one in report-format.sh. +# +# l2_system_prompt prints the text with no trailing newline added. It returns 2 and says why on +# stderr when no copy can be read: an engine started without this prompt would run L2 with no +# instruction about where the report goes, and the output would look like a night's work. + +l2_system_prompt() { + local self dir f + self="${BASH_SOURCE[0]}" + # An installed copy is a symlink into the checkout, and the prompt sits beside bin/ there. + if [ -L "$self" ]; then + f=$(readlink "$self") + case "$f" in /*) self="$f" ;; *) self="$(dirname "$self")/$f" ;; esac + fi + dir=$(cd -P "$(dirname "$self")" 2>/dev/null && pwd) || dir="" + for f in "$dir/../prompts/L2_SYSTEM.txt" "${AUTODREAM_DIR:-}/L2_SYSTEM.txt"; do + if [ -r "$f" ] && [ -s "$f" ]; then cat "$f"; return 0; fi + done + echo "l2-prompt: prompts/L2_SYSTEM.txt not found or empty (looked beside ${dir:-the lib} and in AUTODREAM_DIR=${AUTODREAM_DIR:-unset})" >&2 + return 2 +} diff --git a/bin/run.sh b/bin/run.sh index 052b280..964841e 100755 --- a/bin/run.sh +++ b/bin/run.sh @@ -4042,10 +4042,19 @@ PY # supports, see enumerate_for) still has to deliver the night. The fallback is claude-only, # for the same reason enumerate_for's is: it hardcodes one engine's invocation. log " the claude adapter has no l2-argv here; using the built-in claude invocation" - L2_ARGV=("$CLAUDE_BIN" --print --permission-mode bypassPermissions ${L2_MODEL:+--model "$L2_MODEL"} \ - --no-session-persistence --tools Glob Read --disable-slash-commands --strict-mcp-config \ - --settings '{"disableAllHooks":true}' \ - --append-system-prompt "Headless aggregator. Read the per-session findings JSONs from the findings directory given on line 1 of the prompt, then produce the COMPLETE report only on standard output, ending with a line containing exactly AUTODREAM_REPORT_END. After that line, if you propose memory pins, print them between a line AUTODREAM_PINS_BEGIN and a line AUTODREAM_PINS_END, one JSON object per line. Do not use Write or Edit anywhere. Those paths are literal strings, not shell variables — never \$-expand them. After the pin block print one line: report: then a 3-line summary (sessions reviewed, findings, pins proposed), then exit.") + # The system prompt is prompts/L2_SYSTEM.txt, the same text the adapters read. Without it no + # argv is built at all (the warning below fires), because an L2 run with no word on where the + # report goes only looks like a night's work. + # shellcheck source=/dev/null + L2_SYS=$(_l=$(find_lib l2-prompt.sh) && . "$_l" && l2_system_prompt) || L2_SYS="" + if [ -n "$L2_SYS" ]; then + L2_ARGV=("$CLAUDE_BIN" --print --permission-mode bypassPermissions ${L2_MODEL:+--model "$L2_MODEL"} \ + --no-session-persistence --tools Glob Read --disable-slash-commands --strict-mcp-config \ + --settings '{"disableAllHooks":true}' \ + --append-system-prompt "$L2_SYS") + else + log "WARNING: the built-in claude invocation needs prompts/L2_SYSTEM.txt through bin/l2-prompt.sh, and neither could be read" + fi fi if [ "${#L2_ARGV[@]}" -eq 0 ]; then log "WARNING: the $L2_ENGINE adapter produced no L2 command; every L2 attempt will fail" diff --git a/install.sh b/install.sh index d4afc6d..b6fe2f9 100755 --- a/install.sh +++ b/install.sh @@ -162,6 +162,7 @@ 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/l2-prompt.sh" "$TARGET/l2-prompt.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 @@ -171,6 +172,7 @@ link "$REPO_DIR/adapters" "$TARGET/adapters" 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" +link "$REPO_DIR/prompts/L2_SYSTEM.txt" "$TARGET/L2_SYSTEM.txt" # 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. diff --git a/prompts/L2_SYSTEM.txt b/prompts/L2_SYSTEM.txt new file mode 100644 index 0000000..3cad735 --- /dev/null +++ b/prompts/L2_SYSTEM.txt @@ -0,0 +1 @@ +Headless aggregator. Read the per-session findings JSONs from the findings directory given on line 1 of the prompt, then produce the COMPLETE report only on standard output, ending with a line containing exactly AUTODREAM_REPORT_END. After that line, if you propose memory pins, print them between a line AUTODREAM_PINS_BEGIN and a line AUTODREAM_PINS_END, one JSON object per line. Do not use Write or Edit anywhere. Those paths are literal strings, not shell variables — never $-expand them. After the pin block print one line: report: then a 3-line summary (sessions reviewed, findings, pins proposed), then exit. \ No newline at end of file diff --git a/tests/l2-prompt.sh b/tests/l2-prompt.sh new file mode 100755 index 0000000..e6302e8 --- /dev/null +++ b/tests/l2-prompt.sh @@ -0,0 +1,98 @@ +#!/bin/bash +# shellcheck disable=SC2016 # the `bash -c` bodies below are single-quoted on purpose: $1 is theirs, not ours +# Tests for the L2 system prompt: one file (prompts/L2_SYSTEM.txt), one loader (bin/l2-prompt.sh), +# three consumers (the claude adapter, the omp adapter, run.sh's built-in claude fallback). +# +# The text states the stdout grammar that bin/report-format.sh parses. Three byte-identical copies +# used to carry it; the risk was one of them drifting from the parser and an L2 run printing a +# report the runner could not split. A missing prompt must stop an engine from starting rather +# than start it with no instructions, so that failure is pinned here too. +# +# Usage: tests/l2-prompt.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 ccl2p + +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; } + +PROMPT_FILE="$REPO/prompts/L2_SYSTEM.txt" +WANT=$(cat "$PROMPT_FILE" 2>/dev/null) + +# Inside the suite's own temp root, which suite_tmp removes on every way out. +T=$(mktemp -d "${TMPDIR:-/tmp}/ccl2p.XXXXXX") + +# a minimal install tree: bin/, adapters/{claude,omp}, and optionally prompts/ +mk_tree(){ # $1=name $2=with-prompt(1|0) + local r="$T/$1" + mkdir -p "$r/bin" "$r/adapters/claude" "$r/adapters/omp" "$r/prompts" + cp "$REPO/bin/l2-prompt.sh" "$r/bin/" 2>/dev/null + cp "$REPO/adapters/claude/adapter.sh" "$r/adapters/claude/" + cp "$REPO/adapters/omp/adapter.sh" "$r/adapters/omp/" + [ "$2" = 1 ] && cp "$PROMPT_FILE" "$r/prompts/" 2>/dev/null + printf '%s' "$r" +} +# the value after --append-system-prompt in an adapter's NUL-delimited argv +sys_prompt_of(){ tr '\0' '\n' | awk '/^--append-system-prompt$/ { getline; print; exit }'; } + +echo "# the file states the grammar the report parser reads" +for tok in AUTODREAM_REPORT_END AUTODREAM_PINS_BEGIN AUTODREAM_PINS_END "line 1 of the prompt" "line 2 of the prompt"; do + case "$WANT" in *"$tok"*) ok "L2_SYSTEM.txt names $tok" ;; *) no "L2_SYSTEM.txt does not name $tok" ;; esac +done + +echo "# the loader" +# shellcheck source=/dev/null +. "$REPO/bin/l2-prompt.sh" +assert_eq "$(l2_system_prompt)" "$WANT" "prints the file" +cmp -s <(l2_system_prompt) "$PROMPT_FILE"; assert_eq "$?" "0" "byte for byte, no newline added" +r=$(mk_tree bare 0) +out=$(env -u AUTODREAM_DIR bash -c '. "$1/bin/l2-prompt.sh"; l2_system_prompt' _ "$r" 2>"$T/err"); rc=$? +assert_eq "$rc" "2" "a missing prompt returns 2" +assert_eq "$out" "" "and prints nothing on stdout" +case "$(cat "$T/err")" in *"L2_SYSTEM.txt not found"*) ok "and says which file is missing" ;; *) no "no message for a missing prompt ($(cat "$T/err"))" ;; esac +r=$(mk_tree emptyfile 0); : > "$r/prompts/L2_SYSTEM.txt" +env -u AUTODREAM_DIR bash -c '. "$1/bin/l2-prompt.sh"; l2_system_prompt' _ "$r" >/dev/null 2>&1; assert_eq "$?" "2" "an empty prompt file is refused too" +r=$(mk_tree viadir 0); mkdir -p "$T/ad"; cp "$PROMPT_FILE" "$T/ad/L2_SYSTEM.txt" +assert_eq "$(AUTODREAM_DIR="$T/ad" bash -c '. "$1/bin/l2-prompt.sh"; l2_system_prompt' _ "$r" 2>/dev/null)" "$WANT" "AUTODREAM_DIR's copy is the second place it looks" +mkdir -p "$T/farm"; ln -s "$REPO/bin/l2-prompt.sh" "$T/farm/l2-prompt.sh" +assert_eq "$(env -u AUTODREAM_DIR bash -c '. "$1/farm/l2-prompt.sh"; l2_system_prompt' _ "$T" 2>/dev/null)" "$WANT" "a symlinked lib resolves into its checkout, like an installed copy" + +echo "# the claude adapter" +r=$(mk_tree withprompt 1) +got=$(CLAUDE_BIN=/opt/test/claude bash "$r/adapters/claude/adapter.sh" l2-argv | sys_prompt_of) +assert_eq "$got" "$WANT" "l2-argv carries the file's text" +got=$(CLAUDE_BIN=/opt/test/claude bash "$REPO/adapters/claude/adapter.sh" l2-argv | sys_prompt_of) +assert_eq "$got" "$WANT" "the real adapter does too" +r=$(mk_tree noprompt 0) +out=$(env -u AUTODREAM_DIR CLAUDE_BIN=/opt/test/claude bash "$r/adapters/claude/adapter.sh" l2-argv 2>/dev/null | wc -c | tr -d ' ') +env -u AUTODREAM_DIR CLAUDE_BIN=/opt/test/claude bash "$r/adapters/claude/adapter.sh" l2-argv >/dev/null 2>&1; rc=$? +assert_eq "$rc" "2" "with no prompt file, l2-argv fails instead of starting an engine without one" +assert_eq "$out" "0" "and prints no argv" + +echo "# the omp adapter" +r=$(mk_tree ompwith 1) +got=$(OMP_BIN=/opt/test/omp bash "$r/adapters/omp/adapter.sh" l2-argv some-model | sys_prompt_of) +assert_eq "$got" "$WANT" "l2-argv carries the file's text" +r=$(mk_tree ompnone 0) +out=$(env -u AUTODREAM_DIR OMP_BIN=/opt/test/omp bash "$r/adapters/omp/adapter.sh" l2-argv some-model 2>/dev/null | wc -c | tr -d ' ') +env -u AUTODREAM_DIR OMP_BIN=/opt/test/omp bash "$r/adapters/omp/adapter.sh" l2-argv some-model >/dev/null 2>&1; rc=$? +assert_eq "$rc" "2" "with no prompt file, l2-argv fails" +assert_eq "$out" "0" "and prints no argv" +assert_eq "$(OMP_BIN=/opt/test/omp bash "$REPO/adapters/omp/adapter.sh" l2-argv some-model | sys_prompt_of)" "$WANT" "the real adapter carries it" + +echo "# one source: no consumer keeps its own copy" +copies=$(grep -ln "Headless aggregator" "$REPO"/bin/*.sh "$REPO"/adapters/*/adapter.sh 2>/dev/null || true) +assert_eq "$copies" "" "no script holds the prompt text inline" +if grep -q "l2-prompt.sh" "$REPO/bin/run.sh"; then ok "run.sh's built-in fallback reads it through the loader"; else no "run.sh does not use l2-prompt.sh"; fi +if grep -q "L2_SYSTEM.txt" "$REPO/install.sh"; then ok "install.sh links the prompt into the install"; else no "install.sh does not link L2_SYSTEM.txt"; fi +if grep -q "l2-prompt.sh" "$REPO/install.sh"; then ok "install.sh links the loader"; else no "install.sh does not link l2-prompt.sh"; fi + +printf '\npassed: %s failed: %s\n' "$pass" "$fail" +[ "$fail" -eq 0 ] diff --git a/tests/run-all.sh b/tests/run-all.sh index 5ef49c3..8f74e83 100755 --- a/tests/run-all.sh +++ b/tests/run-all.sh @@ -6324,7 +6324,7 @@ 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 pending-triage" +_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 l2-prompt" [ "$(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) From 7e707a4e4624a3329cbd8df135466671db555e6a Mon Sep 17 00:00:00 2001 From: Samuel Reed Date: Tue, 20 Oct 2026 07:25:54 +0800 Subject: [PATCH 2/3] fix: refuse to start before L1 when the L2 system prompt is missing, claude engine included Review of ba742eb: with prompts/L2_SYSTEM.txt absent and the claude adapter current, l2-argv exits 2, the built-in fallback also finds no prompt, and every L2 attempt fails after L1 has been paid for (the claude engine is exempt from the argv probe). run.sh now checks the prompt for every engine next to the AUTODREAM_L2_ENGINE validation, so the night stops with a FATAL naming the file. tests/run-all.sh: setup_env, setup_env_altroot and the two install-like fixtures now put L2_SYSTEM.txt in the sandbox's autodream dir, as install.sh does, because a run from a copied bin/ finds the prompt only there. New test_a_missing_l2_prompt_stops_the_run_before_l1 runs a copied install with no prompt anywhere and checks the exit code, the FATAL, that no L1 round starts and that no report is written; it fails with the check mutated away. --- bin/run.sh | 12 +++++++++++- tests/run-all.sh | 28 ++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/bin/run.sh b/bin/run.sh index 964841e..3e64268 100755 --- a/bin/run.sh +++ b/bin/run.sh @@ -3023,7 +3023,17 @@ run() { fatal_exit; return 1 fi # And an engine that cannot print a command at all (omp with no model resolved, an adapter - # without l2-argv) is refused for the same reason. claude is exempt: it has a built-in fallback. + # without l2-argv) is refused for the same reason. The argv probe skips claude, whose built-in + # fallback builds the command itself. + # + # The system prompt is checked for every engine, claude included: its fallback reads the same file, + # and an engine started without it runs L2 with no word on where the report goes. Found here, the + # night stops before L1 is paid for; found later, every L2 attempt fails after it. + # shellcheck source=/dev/null + if ! (_l=$(find_lib l2-prompt.sh) && . "$_l" && l2_system_prompt >/dev/null 2>&1); then + log_fatal "prompts/L2_SYSTEM.txt (read through bin/l2-prompt.sh) was not found beside run.sh, its link target or in AUTODREAM_DIR. Refusing to start." + fatal_exit; return 1 + fi _l2e="${AUTODREAM_L2_ENGINE:-${ENABLED_ADAPTERS%% *}}" if [ "$_l2e" != "claude" ] && [ -n "$_l2e" ]; then _l2m=$(adapter_l2_model "$_l2e" 2>/dev/null) || _l2m="" diff --git a/tests/run-all.sh b/tests/run-all.sh index 8f74e83..752e2f0 100755 --- a/tests/run-all.sh +++ b/tests/run-all.sh @@ -171,6 +171,7 @@ setup_env(){ mkdir -p "$root/projects/proj-a" "$root/autodream" "$root/dreams" "$root/cap" cp "$REPO/prompts/SESSION_TRIAGE.md" "$root/autodream/SESSION_TRIAGE.md" cp "$REPO/prompts/PROMPT.md" "$root/autodream/PROMPT.md" + cp "$REPO/prompts/L2_SYSTEM.txt" "$root/autodream/L2_SYSTEM.txt" # install.sh links it too printf '%s' "$root" } mk_session(){ # $1=root $2=name @@ -3461,6 +3462,7 @@ setup_env_altroot(){ # like setup_env, but with HOME inside the sandbox (no $1/p "$root/autodream" "$root/dreams" "$root/cap" cp "$REPO/prompts/SESSION_TRIAGE.md" "$root/autodream/SESSION_TRIAGE.md" cp "$REPO/prompts/PROMPT.md" "$root/autodream/PROMPT.md" + cp "$REPO/prompts/L2_SYSTEM.txt" "$root/autodream/L2_SYSTEM.txt" # install.sh links it too printf '%s' "$root" } mk_session_in(){ # $1=dir $2=name @@ -3956,6 +3958,7 @@ test_fresh_host_with_no_store_is_not_a_failure(){ mkdir -p "$T/home" "$T/autodream" "$T/dreams" cp "$REPO/prompts/SESSION_TRIAGE.md" "$T/autodream/SESSION_TRIAGE.md" cp "$REPO/prompts/PROMPT.md" "$T/autodream/PROMPT.md" + cp "$REPO/prompts/L2_SYSTEM.txt" "$T/autodream/L2_SYSTEM.txt" # install.sh links it too # No SESSION_ROOTS, no PROJECTS_DIR, and a HOME with no .claude at all. HOME="$T/home" AUTODREAM_CHANGELOG=0 CLAUDE_BIN="$MOCK" \ AUTODREAM_CONFIG="$T/autodream/config" AUTODREAM_CONSUME_DATE="$DATE" \ @@ -4580,6 +4583,7 @@ test_upgrade_lag_install_still_produces_a_report(){ mkdir -p "$T/home/.claude/projects/proj-a" "$T/autodream" "$T/dreams" cp "$REPO/prompts/SESSION_TRIAGE.md" "$T/autodream/SESSION_TRIAGE.md" cp "$REPO/prompts/PROMPT.md" "$T/autodream/PROMPT.md" + cp "$REPO/prompts/L2_SYSTEM.txt" "$T/autodream/L2_SYSTEM.txt" # install.sh links it too # Exactly what a pre-adapter install left behind: the helper scripts, and # run.sh as a symlink into the repo. Deliberately NOT lib-project.sh, # adapters.sh, preflight.sh or adapters/. @@ -5556,6 +5560,29 @@ test_window_degrades_when_the_helper_is_absent(){ rm -rf "$root" } +test_a_missing_l2_prompt_stops_the_run_before_l1(){ + echo "# L2 prompt: an install with no prompts/L2_SYSTEM.txt anywhere refuses to start, claude engine included" + local root; root=$(setup_env) + local inst="$root/inst"; mkdir -p "$inst/bin" + cp -R "$REPO/adapters" "$inst/adapters" + cp "$REPO"/bin/*.sh "$inst/bin/" + rm -f "$root/autodream/L2_SYSTEM.txt" # no AUTODREAM_DIR copy, and the copied tree has no prompts/ beside bin/ + mk_session "$root" s1 + mkdir -p "$root/shim-default"; printf '#!/bin/bash\nprintf %s "200"\n' "'%s'" > "$root/shim-default/curl"; chmod +x "$root/shim-default/curl" + PATH="$root/shim-default:$PATH" AUTODREAM_CHANGELOG=0 CLAUDE_BIN="$MOCK" AUTODREAM_CONFIG="$root/autodream/config" \ + AUTODREAM_CONSUME_DATE="$DATE" AUTODREAM_NETCHECK=0 AUTODREAM_RETRY_WAIT=0 AUTODREAM_L1_ROUNDS=1 \ + PROJECTS_DIR="$root/projects" AUTODREAM_DIR="$root/autodream" DREAMS_DIR="$root/dreams" \ + /bin/bash "$inst/bin/run.sh" "$DATE" > "$root/run.out" 2>&1 + local rc=$? + cat "$root/autodream/logs/run-$DATE.log" >> "$root/run.out" 2>/dev/null || true + [ "$rc" -ne 0 ] && ok "the run exits non-zero" || no "the run exits non-zero (got $rc)" + assert_grep "$root/run.out" 'L2_SYSTEM.txt' "the log names the missing prompt" + assert_grep "$root/run.out" 'Refusing to start' "and says it refused to start" + assert_nogrep "$root/run.out" 'L1 triage round' "no L1 round started, so nothing was paid for" + assert_no_file "$root/dreams/$DATE.md" "and no report was written" + rm -rf "$root" +} + test_window_is_dst_correct_end_to_end(){ echo "# window: the report day is local midnight to local midnight on a 23-hour DST day (America/New_York 2020-03-08)" local DATE=2020-03-08 root; root=$(setup_env) @@ -5777,6 +5804,7 @@ test_window_places_a_session_by_its_records_not_its_mtime test_window_cuts_a_multi_day_session_to_the_report_day test_window_off_restores_mtime_enumeration test_window_degrades_when_the_helper_is_absent +test_a_missing_l2_prompt_stops_the_run_before_l1 test_window_is_dst_correct_end_to_end test_rebuild_keeps_a_session_touched_after_its_day_and_retries_its_stale_err test_rebuild_of_an_omp_parent_touched_after_its_day_keeps_it_beside_its_advisor From cb3e3a0e993bf7334aecd2c089be2205c0e87096 Mon Sep 17 00:00:00 2001 From: Samuel Reed Date: Tue, 20 Oct 2026 08:32:20 +0800 Subject: [PATCH 3/3] fix: refuse a whitespace-only L2 prompt, test the relative-symlink loader branch, and fix the fallback's log line Review nits on ba742eb. -s accepted a prompt file of blank lines, which would start an engine with an empty instruction; the loader now needs a non-space character. The relative-readlink branch had no test (install.sh writes absolute links, but a hand-made link may not); added. run.sh's fallback logged 'the claude adapter has no l2-argv here', which is only half the story when the adapter exited 2; it now says it printed no L2 command. Both new loader tests fail with their rule mutated away (27 assertions). --- bin/l2-prompt.sh | 3 ++- bin/run.sh | 2 +- tests/l2-prompt.sh | 8 ++++++++ 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/bin/l2-prompt.sh b/bin/l2-prompt.sh index f8e4a6a..01b826e 100755 --- a/bin/l2-prompt.sh +++ b/bin/l2-prompt.sh @@ -19,7 +19,8 @@ l2_system_prompt() { fi dir=$(cd -P "$(dirname "$self")" 2>/dev/null && pwd) || dir="" for f in "$dir/../prompts/L2_SYSTEM.txt" "${AUTODREAM_DIR:-}/L2_SYSTEM.txt"; do - if [ -r "$f" ] && [ -s "$f" ]; then cat "$f"; return 0; fi + # Not just non-empty: a file of blank lines would start an engine with an empty instruction. + if [ -r "$f" ] && grep -q '[^[:space:]]' "$f" 2>/dev/null; then cat "$f"; return 0; fi done echo "l2-prompt: prompts/L2_SYSTEM.txt not found or empty (looked beside ${dir:-the lib} and in AUTODREAM_DIR=${AUTODREAM_DIR:-unset})" >&2 return 2 diff --git a/bin/run.sh b/bin/run.sh index 3e64268..f3a4c22 100755 --- a/bin/run.sh +++ b/bin/run.sh @@ -4051,7 +4051,7 @@ PY # An install whose adapters/ tree predates l2-argv (the per-file symlink installs this script # supports, see enumerate_for) still has to deliver the night. The fallback is claude-only, # for the same reason enumerate_for's is: it hardcodes one engine's invocation. - log " the claude adapter has no l2-argv here; using the built-in claude invocation" + log " the claude adapter printed no L2 command here (an adapters/ tree that predates l2-argv); using the built-in claude invocation" # The system prompt is prompts/L2_SYSTEM.txt, the same text the adapters read. Without it no # argv is built at all (the warning below fires), because an L2 run with no word on where the # report goes only looks like a night's work. diff --git a/tests/l2-prompt.sh b/tests/l2-prompt.sh index e6302e8..f49bb04 100755 --- a/tests/l2-prompt.sh +++ b/tests/l2-prompt.sh @@ -64,6 +64,14 @@ assert_eq "$(AUTODREAM_DIR="$T/ad" bash -c '. "$1/bin/l2-prompt.sh"; l2_system_p mkdir -p "$T/farm"; ln -s "$REPO/bin/l2-prompt.sh" "$T/farm/l2-prompt.sh" assert_eq "$(env -u AUTODREAM_DIR bash -c '. "$1/farm/l2-prompt.sh"; l2_system_prompt' _ "$T" 2>/dev/null)" "$WANT" "a symlinked lib resolves into its checkout, like an installed copy" +r=$(mk_tree wsonly 0); printf ' \n\n\t\n' > "$r/prompts/L2_SYSTEM.txt" +env -u AUTODREAM_DIR bash -c '. "$1/bin/l2-prompt.sh"; l2_system_prompt' _ "$r" >/dev/null 2>&1; assert_eq "$?" "2" "a prompt file of only whitespace is refused too" +# a symlink whose target is RELATIVE, as a hand-made link might be (install.sh writes absolute ones) +mkdir -p "$T/rel/real/bin" "$T/rel/real/prompts" "$T/rel/farm" +cp "$REPO/bin/l2-prompt.sh" "$T/rel/real/bin/"; cp "$PROMPT_FILE" "$T/rel/real/prompts/" +ln -s ../real/bin/l2-prompt.sh "$T/rel/farm/l2-prompt.sh" +assert_eq "$(env -u AUTODREAM_DIR bash -c '. "$1/rel/farm/l2-prompt.sh"; l2_system_prompt' _ "$T" 2>/dev/null)" "$WANT" "a relative symlink target resolves against the link's own directory" + echo "# the claude adapter" r=$(mk_tree withprompt 1) got=$(CLAUDE_BIN=/opt/test/claude bash "$r/adapters/claude/adapter.sh" l2-argv | sys_prompt_of)